@getstrata/starter 0.1.4 → 0.1.9
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 +9 -20
- package/dist/cli.js +2400 -47
- package/dist/templates/.env.example +4 -1
- package/dist/templates/overlays/api/docs/API.md +9 -0
- package/dist/templates/overlays/server-htmx/public/assets/app.css +89 -0
- package/dist/templates/overlays/server-htmx/resources/views/errors/error.eta +14 -0
- package/dist/templates/overlays/server-htmx/resources/views/errors/forbidden.eta +4 -0
- package/dist/templates/overlays/server-htmx/resources/views/errors/not-found.eta +4 -0
- package/dist/templates/overlays/server-htmx/resources/views/layouts/app.eta +34 -0
- package/dist/templates/overlays/server-htmx/resources/views/organizations/_table.eta +23 -0
- package/dist/templates/overlays/server-htmx/resources/views/organizations/index.eta +37 -0
- package/dist/templates/overlays/server-htmx/resources/views/pages/home.eta +4 -0
- package/dist/templates/overlays/server-htmx/resources/views/partials/_flash.eta +3 -0
- package/dist/templates/overlays/spa-react/frontend/build.ts +17 -0
- package/dist/templates/overlays/spa-react/frontend/bun-env.d.ts +4 -0
- package/dist/templates/overlays/spa-react/frontend/bun.lock +51 -0
- package/dist/templates/overlays/spa-react/frontend/dev-server.ts +66 -0
- package/dist/templates/overlays/spa-react/frontend/index.html +12 -0
- package/dist/templates/overlays/spa-react/frontend/package.json +21 -0
- package/dist/templates/overlays/spa-react/frontend/src/App.tsx +80 -0
- package/dist/templates/overlays/spa-react/frontend/src/api/client.ts +86 -0
- package/dist/templates/overlays/spa-react/frontend/src/app.css +98 -0
- package/dist/templates/overlays/spa-react/frontend/src/auth/AuthContext.tsx +103 -0
- package/dist/templates/overlays/spa-react/frontend/src/auth/tokenStorage.ts +15 -0
- package/dist/templates/overlays/spa-react/frontend/src/main.tsx +22 -0
- package/dist/templates/overlays/spa-react/frontend/src/pages/LoginPage.tsx +67 -0
- package/dist/templates/overlays/spa-react/frontend/src/pages/NotFoundPage.tsx +12 -0
- package/dist/templates/overlays/spa-react/frontend/src/pages/OrganizationsPage.tsx +69 -0
- package/dist/templates/overlays/spa-react/frontend/src/pages/ProjectsPage.tsx +65 -0
- package/dist/templates/overlays/spa-react/frontend/src/pages/TasksPage.tsx +65 -0
- package/dist/templates/{templates → overlays/spa-react/frontend}/tsconfig.json +9 -6
- package/dist/templates/package.json +2 -2
- package/package.json +3 -3
- package/dist/templates/templates/.env.example +0 -19
- package/dist/templates/templates/docker-compose.yml +0 -14
- package/dist/templates/templates/package.json +0 -22
- package/dist/templates/templates/public/assets/site.css +0 -29
- package/dist/templates/templates/src/bootstrap/config.ts +0 -18
- package/dist/templates/templates/src/bootstrap/createApp.ts +0 -93
- package/dist/templates/templates/src/bootstrap/database.ts +0 -30
- package/dist/templates/templates/src/bootstrap/preload.ts +0 -5
- package/dist/templates/templates/src/bootstrap/providers/auth.ts +0 -41
- package/dist/templates/templates/src/bootstrap/providers/cache.ts +0 -28
- package/dist/templates/templates/src/bootstrap/providers/config.ts +0 -27
- package/dist/templates/templates/src/bootstrap/providers/index.ts +0 -14
- package/dist/templates/templates/src/bootstrap/providers/storage.ts +0 -11
- package/dist/templates/templates/src/bootstrap/server.ts +0 -25
- package/dist/templates/templates/src/db/fresh.ts +0 -19
- package/dist/templates/templates/src/db/migrate.ts +0 -35
- package/dist/templates/templates/src/lib/view.ts +0 -21
- package/dist/templates/templates/src/modules/site/index.ts +0 -29
- package/dist/templates/templates/src/routes.ts +0 -6
- package/dist/templates/templates/strata.config.ts +0 -7
- package/dist/templates/templates/views/home.eta +0 -5
- package/dist/templates/templates/views/layouts/app.eta +0 -18
package/dist/cli.js
CHANGED
|
@@ -1,77 +1,2430 @@
|
|
|
1
1
|
#!/usr/bin/env bun
|
|
2
2
|
// @bun
|
|
3
3
|
|
|
4
|
-
//
|
|
5
|
-
import { existsSync, mkdirSync
|
|
6
|
-
import { join, resolve } from "path";
|
|
4
|
+
// src/generate.ts
|
|
5
|
+
import { existsSync as existsSync2, mkdirSync as mkdirSync2, rmSync as rmSync2 } from "fs";
|
|
6
|
+
import { join as join2, resolve } from "path";
|
|
7
|
+
|
|
8
|
+
// src/copy.ts
|
|
9
|
+
import {
|
|
10
|
+
existsSync,
|
|
11
|
+
mkdirSync,
|
|
12
|
+
readdirSync,
|
|
13
|
+
readFileSync,
|
|
14
|
+
rmSync,
|
|
15
|
+
statSync,
|
|
16
|
+
writeFileSync
|
|
17
|
+
} from "fs";
|
|
18
|
+
import { join } from "path";
|
|
7
19
|
var PLACEHOLDER = /\{\{PROJECT_NAME\}\}/g;
|
|
20
|
+
function copyTree(source, target, projectName, skipNames = new Set) {
|
|
21
|
+
mkdirSync(target, { recursive: true });
|
|
22
|
+
for (const entry of readdirSync(source)) {
|
|
23
|
+
if (skipNames.has(entry) || entry === ".DS_Store") {
|
|
24
|
+
continue;
|
|
25
|
+
}
|
|
26
|
+
const from = join(source, entry);
|
|
27
|
+
const to = join(target, entry.replace(PLACEHOLDER, projectName));
|
|
28
|
+
const info = statSync(from);
|
|
29
|
+
if (info.isDirectory()) {
|
|
30
|
+
copyTree(from, to, projectName, skipNames);
|
|
31
|
+
continue;
|
|
32
|
+
}
|
|
33
|
+
let contents = readFileSync(from, "utf8");
|
|
34
|
+
if (contents.includes("{{PROJECT_NAME}}")) {
|
|
35
|
+
contents = contents.replace(PLACEHOLDER, projectName);
|
|
36
|
+
}
|
|
37
|
+
writeFileSync(to, contents, { mode: info.mode & 511 });
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
function copyOverlayTree(sourceRoot, targetRoot) {
|
|
41
|
+
if (!existsSync(sourceRoot)) {
|
|
42
|
+
return;
|
|
43
|
+
}
|
|
44
|
+
for (const entry of readdirSync(sourceRoot, { withFileTypes: true })) {
|
|
45
|
+
const sourcePath = join(sourceRoot, entry.name);
|
|
46
|
+
const targetPath = join(targetRoot, entry.name);
|
|
47
|
+
if (entry.isDirectory()) {
|
|
48
|
+
mkdirSync(targetPath, { recursive: true });
|
|
49
|
+
copyOverlayTree(sourcePath, targetPath);
|
|
50
|
+
continue;
|
|
51
|
+
}
|
|
52
|
+
if (existsSync(targetPath)) {
|
|
53
|
+
continue;
|
|
54
|
+
}
|
|
55
|
+
mkdirSync(join(targetPath, ".."), { recursive: true });
|
|
56
|
+
const contents = readFileSync(sourcePath);
|
|
57
|
+
writeFileSync(targetPath, contents);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
function writeText(target, contents) {
|
|
61
|
+
mkdirSync(join(target, ".."), { recursive: true });
|
|
62
|
+
writeFileSync(target, contents.endsWith(`
|
|
63
|
+
`) ? contents : `${contents}
|
|
64
|
+
`);
|
|
65
|
+
}
|
|
66
|
+
function removeIfExists(path) {
|
|
67
|
+
if (existsSync(path)) {
|
|
68
|
+
rmSync(path, { recursive: true, force: true });
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// src/types.ts
|
|
73
|
+
var FRONTENDS = ["api", "server-htmx", "spa-react", "hybrid"];
|
|
74
|
+
var DATABASES = ["sqlite", "postgres", "mysql"];
|
|
75
|
+
var AUTH_STACKS = [
|
|
76
|
+
"headers",
|
|
77
|
+
"cookie",
|
|
78
|
+
"token",
|
|
79
|
+
"jwt",
|
|
80
|
+
"cookie-token",
|
|
81
|
+
"cookie-token-jwt"
|
|
82
|
+
];
|
|
83
|
+
var TENANCY_DRIVERS = ["none", "rls"];
|
|
84
|
+
var CACHE_DRIVERS = ["array", "redis"];
|
|
85
|
+
var QUEUE_DRIVERS = ["sync", "redis"];
|
|
86
|
+
var MAIL_DRIVERS = ["log", "smtp"];
|
|
87
|
+
var DOCKER_SERVICE_NAMES = ["postgres", "mysql", "redis", "mailpit"];
|
|
88
|
+
var DOCKER_SERVICE_LABELS = {
|
|
89
|
+
postgres: "Postgres",
|
|
90
|
+
mysql: "MySQL",
|
|
91
|
+
redis: "Redis",
|
|
92
|
+
mailpit: "SMTP (Mailpit)"
|
|
93
|
+
};
|
|
94
|
+
function authUsesCookie(auth) {
|
|
95
|
+
return auth === "cookie" || auth.startsWith("cookie-");
|
|
96
|
+
}
|
|
97
|
+
function authUsesToken(auth) {
|
|
98
|
+
return auth === "token" || auth.includes("token");
|
|
99
|
+
}
|
|
100
|
+
function authUsesJwt(auth) {
|
|
101
|
+
return auth === "jwt" || auth.endsWith("jwt");
|
|
102
|
+
}
|
|
103
|
+
function authNeedsUsers(auth) {
|
|
104
|
+
return auth !== "headers";
|
|
105
|
+
}
|
|
106
|
+
function needsRedis(layers) {
|
|
107
|
+
return layers.cache === "redis" || layers.queue === "redis";
|
|
108
|
+
}
|
|
109
|
+
function emptyDockerServices() {
|
|
110
|
+
return { postgres: false, mysql: false, redis: false, mailpit: false };
|
|
111
|
+
}
|
|
112
|
+
function enableDockerServices(names) {
|
|
113
|
+
const services = emptyDockerServices();
|
|
114
|
+
for (const name of names) {
|
|
115
|
+
services[name] = true;
|
|
116
|
+
}
|
|
117
|
+
return services;
|
|
118
|
+
}
|
|
119
|
+
function neededDockerServices(layers) {
|
|
120
|
+
const needed = [];
|
|
121
|
+
if (layers.database === "postgres") {
|
|
122
|
+
needed.push("postgres");
|
|
123
|
+
}
|
|
124
|
+
if (layers.database === "mysql") {
|
|
125
|
+
needed.push("mysql");
|
|
126
|
+
}
|
|
127
|
+
if (needsRedis(layers)) {
|
|
128
|
+
needed.push("redis");
|
|
129
|
+
}
|
|
130
|
+
if (layers.mail === "smtp") {
|
|
131
|
+
needed.push("mailpit");
|
|
132
|
+
}
|
|
133
|
+
return needed;
|
|
134
|
+
}
|
|
135
|
+
function selectedDockerServices(layers) {
|
|
136
|
+
if (!layers.docker.enabled) {
|
|
137
|
+
return [];
|
|
138
|
+
}
|
|
139
|
+
return neededDockerServices(layers).filter((name) => layers.docker.services[name]);
|
|
140
|
+
}
|
|
141
|
+
function reconcileDocker(layers) {
|
|
142
|
+
const selected = selectedDockerServices(layers);
|
|
143
|
+
return {
|
|
144
|
+
...layers,
|
|
145
|
+
docker: {
|
|
146
|
+
enabled: selected.length > 0,
|
|
147
|
+
services: enableDockerServices(selected)
|
|
148
|
+
}
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
function dockerLayerForNeeded(layers, enabled) {
|
|
152
|
+
const needed = neededDockerServices(layers);
|
|
153
|
+
if (!enabled || needed.length === 0) {
|
|
154
|
+
return { enabled: false, services: emptyDockerServices() };
|
|
155
|
+
}
|
|
156
|
+
return { enabled: true, services: enableDockerServices(needed) };
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// src/presets.ts
|
|
160
|
+
var noneExtras = {
|
|
161
|
+
mfa: false,
|
|
162
|
+
emailVerification: false,
|
|
163
|
+
scim: false,
|
|
164
|
+
metrics: false
|
|
165
|
+
};
|
|
166
|
+
var enterpriseExtras = {
|
|
167
|
+
mfa: true,
|
|
168
|
+
emailVerification: true,
|
|
169
|
+
scim: true,
|
|
170
|
+
metrics: true
|
|
171
|
+
};
|
|
172
|
+
function withDocker(layers, composeForNeededTools) {
|
|
173
|
+
return {
|
|
174
|
+
...layers,
|
|
175
|
+
docker: dockerLayerForNeeded(layers, composeForNeededTools)
|
|
176
|
+
};
|
|
177
|
+
}
|
|
178
|
+
function defaultLayers() {
|
|
179
|
+
return withDocker({
|
|
180
|
+
frontend: "api",
|
|
181
|
+
database: "sqlite",
|
|
182
|
+
auth: "headers",
|
|
183
|
+
tenancy: "none",
|
|
184
|
+
cache: "array",
|
|
185
|
+
queue: "sync",
|
|
186
|
+
mail: "log",
|
|
187
|
+
spaPrefix: "/app",
|
|
188
|
+
extras: { ...noneExtras }
|
|
189
|
+
}, false);
|
|
190
|
+
}
|
|
191
|
+
var EXAMPLE_APPS = {
|
|
192
|
+
"hiroapp-hobby": defaultLayers(),
|
|
193
|
+
"hiroapp-team": withDocker({
|
|
194
|
+
frontend: "server-htmx",
|
|
195
|
+
database: "postgres",
|
|
196
|
+
auth: "cookie",
|
|
197
|
+
tenancy: "none",
|
|
198
|
+
cache: "redis",
|
|
199
|
+
queue: "redis",
|
|
200
|
+
mail: "log",
|
|
201
|
+
spaPrefix: "/app",
|
|
202
|
+
extras: { ...noneExtras, metrics: true }
|
|
203
|
+
}, true),
|
|
204
|
+
hiroapp: withDocker({
|
|
205
|
+
frontend: "server-htmx",
|
|
206
|
+
database: "postgres",
|
|
207
|
+
auth: "cookie-token-jwt",
|
|
208
|
+
tenancy: "rls",
|
|
209
|
+
cache: "redis",
|
|
210
|
+
queue: "redis",
|
|
211
|
+
mail: "smtp",
|
|
212
|
+
spaPrefix: "/app",
|
|
213
|
+
extras: { ...enterpriseExtras }
|
|
214
|
+
}, true)
|
|
215
|
+
};
|
|
216
|
+
|
|
217
|
+
// src/parseArgs.ts
|
|
8
218
|
function usage() {
|
|
9
|
-
|
|
219
|
+
return `Usage: create-strata [project-name] [options]
|
|
220
|
+
|
|
221
|
+
Scaffold a runnable Strata app. The wizard always asks each layer. For CI, pass --yes
|
|
222
|
+
and the layer flags you want (defaults are SQLite, JSON API, header auth).
|
|
10
223
|
|
|
11
|
-
|
|
224
|
+
Options:
|
|
225
|
+
--frontend api | server-htmx | spa-react | hybrid
|
|
226
|
+
--database sqlite | postgres | mysql (one database; not mixed)
|
|
227
|
+
--auth headers | cookie | token | jwt | cookie-token | cookie-token-jwt
|
|
228
|
+
--tenancy none | rls
|
|
229
|
+
--cache array | redis
|
|
230
|
+
--queue sync | redis
|
|
231
|
+
--mail log | smtp
|
|
232
|
+
--spa-prefix SPA URL prefix (default /app)
|
|
233
|
+
--mfa / --no-mfa
|
|
234
|
+
--email-verification / --no-email-verification
|
|
235
|
+
--scim / --no-scim
|
|
236
|
+
--metrics / --no-metrics
|
|
237
|
+
--extras Prompt (or enable) MFA, email verification, SCIM, metrics
|
|
238
|
+
--docker Write Docker Compose for every selected tool that needs a service
|
|
239
|
+
--no-docker Skip docker-compose.yml; use installs already on this machine
|
|
240
|
+
--docker-services Subset: postgres, mysql, redis, mailpit (comma-separated)
|
|
241
|
+
--force Replace an existing directory
|
|
242
|
+
--yes, --no-interactive
|
|
243
|
+
-h, --help
|
|
12
244
|
|
|
13
245
|
Examples:
|
|
14
|
-
bunx @getstrata/starter my-app
|
|
15
246
|
bunx create-strata my-app
|
|
247
|
+
bunx create-strata my-app --yes
|
|
248
|
+
bunx create-strata html --frontend server-htmx --database postgres --auth cookie --cache redis --queue redis --docker --yes
|
|
249
|
+
bunx create-strata html --frontend server-htmx --database postgres --no-docker --yes
|
|
250
|
+
`;
|
|
251
|
+
}
|
|
252
|
+
function takeValue(arg, prefix) {
|
|
253
|
+
if (arg.startsWith(`${prefix}=`)) {
|
|
254
|
+
return arg.slice(prefix.length + 1);
|
|
255
|
+
}
|
|
256
|
+
return;
|
|
257
|
+
}
|
|
258
|
+
function parseEnum(value, allowed, label) {
|
|
259
|
+
if (allowed.includes(value)) {
|
|
260
|
+
return value;
|
|
261
|
+
}
|
|
262
|
+
throw new Error(`Unknown ${label} "${value}". Expected ${allowed.join(", ")}.`);
|
|
263
|
+
}
|
|
264
|
+
function parseDockerServiceList(raw) {
|
|
265
|
+
const names = raw.split(",").map((part) => part.trim().toLowerCase()).filter((part) => part.length > 0);
|
|
266
|
+
const unknown = names.filter((name) => !DOCKER_SERVICE_NAMES.includes(name));
|
|
267
|
+
if (unknown.length > 0) {
|
|
268
|
+
throw new Error(`Unknown docker service "${unknown.join(", ")}". Expected ${DOCKER_SERVICE_NAMES.join(", ")}.`);
|
|
269
|
+
}
|
|
270
|
+
return names;
|
|
271
|
+
}
|
|
272
|
+
function dockerFlagsProvided(flags) {
|
|
273
|
+
return flags.docker !== undefined || flags.dockerServices !== undefined;
|
|
274
|
+
}
|
|
275
|
+
function parseCreateStrataArgs(argv) {
|
|
276
|
+
const flags = {
|
|
277
|
+
help: false,
|
|
278
|
+
yes: false,
|
|
279
|
+
noInteractive: false,
|
|
280
|
+
force: false,
|
|
281
|
+
extrasPrompt: false,
|
|
282
|
+
extras: {}
|
|
283
|
+
};
|
|
284
|
+
const positional = [];
|
|
285
|
+
for (let index = 0;index < argv.length; index += 1) {
|
|
286
|
+
const arg = argv[index];
|
|
287
|
+
if (!arg) {
|
|
288
|
+
continue;
|
|
289
|
+
}
|
|
290
|
+
if (arg === "-h" || arg === "--help") {
|
|
291
|
+
flags.help = true;
|
|
292
|
+
continue;
|
|
293
|
+
}
|
|
294
|
+
if (arg === "--yes" || arg === "-y") {
|
|
295
|
+
flags.yes = true;
|
|
296
|
+
continue;
|
|
297
|
+
}
|
|
298
|
+
if (arg === "--no-interactive") {
|
|
299
|
+
flags.noInteractive = true;
|
|
300
|
+
continue;
|
|
301
|
+
}
|
|
302
|
+
if (arg === "--force") {
|
|
303
|
+
flags.force = true;
|
|
304
|
+
continue;
|
|
305
|
+
}
|
|
306
|
+
if (arg === "--extras" || arg === "--corporate") {
|
|
307
|
+
flags.extrasPrompt = true;
|
|
308
|
+
continue;
|
|
309
|
+
}
|
|
310
|
+
if (arg === "--docker") {
|
|
311
|
+
flags.docker = true;
|
|
312
|
+
flags.dockerServices = undefined;
|
|
313
|
+
continue;
|
|
314
|
+
}
|
|
315
|
+
if (arg === "--no-docker") {
|
|
316
|
+
flags.docker = false;
|
|
317
|
+
flags.dockerServices = undefined;
|
|
318
|
+
continue;
|
|
319
|
+
}
|
|
320
|
+
const dockerServicesInline = takeValue(arg, "--docker-services");
|
|
321
|
+
if (dockerServicesInline !== undefined) {
|
|
322
|
+
flags.docker = true;
|
|
323
|
+
flags.dockerServices = parseDockerServiceList(dockerServicesInline);
|
|
324
|
+
continue;
|
|
325
|
+
}
|
|
326
|
+
if (arg === "--docker-services") {
|
|
327
|
+
flags.docker = true;
|
|
328
|
+
flags.dockerServices = parseDockerServiceList(argv[index + 1] ?? "");
|
|
329
|
+
index += 1;
|
|
330
|
+
continue;
|
|
331
|
+
}
|
|
332
|
+
const boolFlags = [
|
|
333
|
+
["--mfa", "mfa", true],
|
|
334
|
+
["--no-mfa", "mfa", false],
|
|
335
|
+
["--email-verification", "emailVerification", true],
|
|
336
|
+
["--no-email-verification", "emailVerification", false],
|
|
337
|
+
["--scim", "scim", true],
|
|
338
|
+
["--no-scim", "scim", false],
|
|
339
|
+
["--metrics", "metrics", true],
|
|
340
|
+
["--no-metrics", "metrics", false]
|
|
341
|
+
];
|
|
342
|
+
const boolMatch = boolFlags.find(([name]) => name === arg);
|
|
343
|
+
if (boolMatch) {
|
|
344
|
+
flags.extras[boolMatch[1]] = boolMatch[2];
|
|
345
|
+
continue;
|
|
346
|
+
}
|
|
347
|
+
const pairs = [
|
|
348
|
+
[
|
|
349
|
+
"--frontend",
|
|
350
|
+
(value) => {
|
|
351
|
+
flags.frontend = parseEnum(value, FRONTENDS, "frontend");
|
|
352
|
+
}
|
|
353
|
+
],
|
|
354
|
+
[
|
|
355
|
+
"--database",
|
|
356
|
+
(value) => {
|
|
357
|
+
flags.database = parseEnum(value, DATABASES, "database");
|
|
358
|
+
}
|
|
359
|
+
],
|
|
360
|
+
[
|
|
361
|
+
"--auth",
|
|
362
|
+
(value) => {
|
|
363
|
+
flags.auth = parseEnum(value, AUTH_STACKS, "auth");
|
|
364
|
+
}
|
|
365
|
+
],
|
|
366
|
+
[
|
|
367
|
+
"--tenancy",
|
|
368
|
+
(value) => {
|
|
369
|
+
flags.tenancy = parseEnum(value, TENANCY_DRIVERS, "tenancy");
|
|
370
|
+
}
|
|
371
|
+
],
|
|
372
|
+
[
|
|
373
|
+
"--cache",
|
|
374
|
+
(value) => {
|
|
375
|
+
flags.cache = parseEnum(value, CACHE_DRIVERS, "cache");
|
|
376
|
+
}
|
|
377
|
+
],
|
|
378
|
+
[
|
|
379
|
+
"--queue",
|
|
380
|
+
(value) => {
|
|
381
|
+
flags.queue = parseEnum(value, QUEUE_DRIVERS, "queue");
|
|
382
|
+
}
|
|
383
|
+
],
|
|
384
|
+
[
|
|
385
|
+
"--mail",
|
|
386
|
+
(value) => {
|
|
387
|
+
flags.mail = parseEnum(value, MAIL_DRIVERS, "mail");
|
|
388
|
+
}
|
|
389
|
+
],
|
|
390
|
+
[
|
|
391
|
+
"--spa-prefix",
|
|
392
|
+
(value) => {
|
|
393
|
+
flags.spaPrefix = value.startsWith("/") ? value : `/${value}`;
|
|
394
|
+
}
|
|
395
|
+
]
|
|
396
|
+
];
|
|
397
|
+
let matched = false;
|
|
398
|
+
for (const [name, apply] of pairs) {
|
|
399
|
+
const inline = takeValue(arg, name);
|
|
400
|
+
if (inline !== undefined) {
|
|
401
|
+
apply(inline);
|
|
402
|
+
matched = true;
|
|
403
|
+
break;
|
|
404
|
+
}
|
|
405
|
+
if (arg === name) {
|
|
406
|
+
apply(argv[index + 1] ?? "");
|
|
407
|
+
index += 1;
|
|
408
|
+
matched = true;
|
|
409
|
+
break;
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
if (matched) {
|
|
413
|
+
continue;
|
|
414
|
+
}
|
|
415
|
+
if (arg.startsWith("-")) {
|
|
416
|
+
throw new Error(`Unknown option ${arg}. Pass --help to list flags.`);
|
|
417
|
+
}
|
|
418
|
+
positional.push(arg);
|
|
419
|
+
}
|
|
420
|
+
if (positional[0]) {
|
|
421
|
+
flags.projectName = positional[0];
|
|
422
|
+
}
|
|
423
|
+
return flags;
|
|
424
|
+
}
|
|
425
|
+
function applyDockerFlags(layers, flags) {
|
|
426
|
+
const needed = neededDockerServices(layers);
|
|
427
|
+
if (flags.docker === false) {
|
|
428
|
+
return {
|
|
429
|
+
...layers,
|
|
430
|
+
docker: dockerLayerForNeeded(layers, false)
|
|
431
|
+
};
|
|
432
|
+
}
|
|
433
|
+
if (flags.dockerServices) {
|
|
434
|
+
const selected = flags.dockerServices.filter((name) => needed.includes(name));
|
|
435
|
+
return {
|
|
436
|
+
...layers,
|
|
437
|
+
docker: {
|
|
438
|
+
enabled: selected.length > 0,
|
|
439
|
+
services: enableDockerServices(selected)
|
|
440
|
+
}
|
|
441
|
+
};
|
|
442
|
+
}
|
|
443
|
+
if (flags.docker === true) {
|
|
444
|
+
return {
|
|
445
|
+
...layers,
|
|
446
|
+
docker: dockerLayerForNeeded(layers, true)
|
|
447
|
+
};
|
|
448
|
+
}
|
|
449
|
+
return layers;
|
|
450
|
+
}
|
|
451
|
+
function applyFlagOverrides(base, flags) {
|
|
452
|
+
const next = {
|
|
453
|
+
...base,
|
|
454
|
+
frontend: flags.frontend ?? base.frontend,
|
|
455
|
+
database: flags.database ?? base.database,
|
|
456
|
+
auth: flags.auth ?? base.auth,
|
|
457
|
+
tenancy: flags.tenancy ?? base.tenancy,
|
|
458
|
+
cache: flags.cache ?? base.cache,
|
|
459
|
+
queue: flags.queue ?? base.queue,
|
|
460
|
+
mail: flags.mail ?? base.mail,
|
|
461
|
+
spaPrefix: flags.spaPrefix ?? base.spaPrefix,
|
|
462
|
+
extras: { ...base.extras, ...flags.extras }
|
|
463
|
+
};
|
|
464
|
+
if (next.database !== "postgres") {
|
|
465
|
+
next.tenancy = "none";
|
|
466
|
+
}
|
|
467
|
+
return reconcileDocker(applyDockerFlags(next, flags));
|
|
468
|
+
}
|
|
469
|
+
function layersFromFlags(flags) {
|
|
470
|
+
return applyFlagOverrides(defaultLayers(), flags);
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
// src/prompt.ts
|
|
474
|
+
import { stdin as input, stdout as output } from "process";
|
|
475
|
+
import { createInterface } from "readline/promises";
|
|
476
|
+
function isInteractive(flags) {
|
|
477
|
+
if (flags.yes || flags.noInteractive) {
|
|
478
|
+
return false;
|
|
479
|
+
}
|
|
480
|
+
return Boolean(input.isTTY && output.isTTY);
|
|
481
|
+
}
|
|
482
|
+
function createReadlinePrompter() {
|
|
483
|
+
const rl = createInterface({ input, output });
|
|
484
|
+
return {
|
|
485
|
+
async question(message, defaultValue) {
|
|
486
|
+
const suffix = defaultValue ? ` [${defaultValue}]` : "";
|
|
487
|
+
const answer = (await rl.question(`${message}${suffix}: `)).trim();
|
|
488
|
+
return answer || defaultValue || "";
|
|
489
|
+
},
|
|
490
|
+
async confirm(message, defaultValue = false) {
|
|
491
|
+
const hint = defaultValue ? "Y/n" : "y/N";
|
|
492
|
+
const answer = (await rl.question(`${message} (${hint}): `)).trim().toLowerCase();
|
|
493
|
+
if (!answer) {
|
|
494
|
+
return defaultValue;
|
|
495
|
+
}
|
|
496
|
+
return answer === "y" || answer === "yes";
|
|
497
|
+
},
|
|
498
|
+
async select(message, choices, defaultValue) {
|
|
499
|
+
console.log(message);
|
|
500
|
+
for (const [index, choice] of choices.entries()) {
|
|
501
|
+
const marker = choice.value === defaultValue ? "*" : " ";
|
|
502
|
+
console.log(` ${index + 1}) ${marker} ${choice.label}`);
|
|
503
|
+
}
|
|
504
|
+
const defaultIndex = choices.findIndex((choice) => choice.value === defaultValue) + 1;
|
|
505
|
+
const answer = (await rl.question(`Choose [${defaultIndex}]: `)).trim();
|
|
506
|
+
if (!answer) {
|
|
507
|
+
return defaultValue;
|
|
508
|
+
}
|
|
509
|
+
const asNumber = Number.parseInt(answer, 10);
|
|
510
|
+
if (Number.isInteger(asNumber) && asNumber >= 1 && asNumber <= choices.length) {
|
|
511
|
+
const selected = choices[asNumber - 1];
|
|
512
|
+
return selected?.value ?? defaultValue;
|
|
513
|
+
}
|
|
514
|
+
const match = choices.find((choice) => choice.value === answer || choice.label === answer);
|
|
515
|
+
return match?.value ?? defaultValue;
|
|
516
|
+
},
|
|
517
|
+
close() {
|
|
518
|
+
rl.close();
|
|
519
|
+
}
|
|
520
|
+
};
|
|
521
|
+
}
|
|
522
|
+
async function promptLayers(flags, prompter) {
|
|
523
|
+
const layers = applyFlagOverrides(defaultLayers(), flags);
|
|
524
|
+
layers.frontend = await prompter.select("Frontend", [
|
|
525
|
+
{ value: "api", label: "api: JSON only" },
|
|
526
|
+
{ value: "server-htmx", label: "server-htmx: Eta HTML + HTMX" },
|
|
527
|
+
{ value: "spa-react", label: "spa-react: JSON + React under SPA_PREFIX" },
|
|
528
|
+
{ value: "hybrid", label: "hybrid: HTML at / plus SPA prefix" }
|
|
529
|
+
], layers.frontend);
|
|
530
|
+
layers.database = await prompter.select("Database (one engine)", [
|
|
531
|
+
{ value: "sqlite", label: "sqlite: file database" },
|
|
532
|
+
{ value: "postgres", label: "postgres" },
|
|
533
|
+
{ value: "mysql", label: "mysql" }
|
|
534
|
+
], layers.database);
|
|
535
|
+
layers.auth = await prompter.select("Auth", [
|
|
536
|
+
{ value: "headers", label: "headers: x-authenticated-user-id (local/tests)" },
|
|
537
|
+
{ value: "cookie", label: "cookie: sessions table + CSRF" },
|
|
538
|
+
{ value: "token", label: "token: opaque hashed Bearer" },
|
|
539
|
+
{ value: "jwt", label: "jwt: short-lived HS256" },
|
|
540
|
+
{ value: "cookie-token", label: "cookie-token: HTML cookies + API tokens" },
|
|
541
|
+
{ value: "cookie-token-jwt", label: "cookie-token-jwt: cookies, tokens, and JWT" }
|
|
542
|
+
], layers.auth);
|
|
543
|
+
if (layers.database === "postgres") {
|
|
544
|
+
layers.tenancy = await prompter.select("Tenancy", [
|
|
545
|
+
{ value: "none", label: "none: no tenant table" },
|
|
546
|
+
{ value: "rls", label: "rls: Postgres row-level security plus a tenant table" }
|
|
547
|
+
], layers.tenancy);
|
|
548
|
+
} else {
|
|
549
|
+
layers.tenancy = "none";
|
|
550
|
+
}
|
|
551
|
+
layers.cache = await prompter.select("Cache", [
|
|
552
|
+
{ value: "array", label: "array: in-process" },
|
|
553
|
+
{ value: "redis", label: "redis" }
|
|
554
|
+
], layers.cache);
|
|
555
|
+
layers.queue = await prompter.select("Queue", [
|
|
556
|
+
{ value: "sync", label: "sync: run jobs inline" },
|
|
557
|
+
{ value: "redis", label: "redis: background worker" }
|
|
558
|
+
], layers.queue);
|
|
559
|
+
layers.mail = await prompter.select("Mail", [
|
|
560
|
+
{ value: "log", label: "log: print messages" },
|
|
561
|
+
{ value: "smtp", label: "smtp" }
|
|
562
|
+
], layers.mail);
|
|
563
|
+
if (layers.frontend === "spa-react" || layers.frontend === "hybrid") {
|
|
564
|
+
layers.spaPrefix = await prompter.question("SPA prefix", layers.spaPrefix);
|
|
565
|
+
}
|
|
566
|
+
const askExtras = flags.extrasPrompt || await prompter.confirm("Configure extras (MFA, SCIM, metrics)?", false);
|
|
567
|
+
if (askExtras) {
|
|
568
|
+
layers.extras.mfa = await prompter.confirm("Staff MFA env flag?", layers.extras.mfa);
|
|
569
|
+
layers.extras.emailVerification = await prompter.confirm("Email verification env flag?", layers.extras.emailVerification);
|
|
570
|
+
layers.extras.scim = await prompter.confirm("SCIM env stubs?", layers.extras.scim);
|
|
571
|
+
layers.extras.metrics = await prompter.confirm("Metrics token?", layers.extras.metrics);
|
|
572
|
+
}
|
|
573
|
+
if (!dockerFlagsProvided(flags)) {
|
|
574
|
+
layers.docker = await promptDockerLayer(prompter, layers);
|
|
575
|
+
}
|
|
576
|
+
return applyFlagOverrides(layers, flags);
|
|
577
|
+
}
|
|
578
|
+
async function promptDockerLayer(prompter, layers) {
|
|
579
|
+
const needed = neededDockerServices(layers);
|
|
580
|
+
if (needed.length === 0) {
|
|
581
|
+
return dockerLayerForNeeded(layers, false);
|
|
582
|
+
}
|
|
583
|
+
const labels = needed.map((name) => DOCKER_SERVICE_LABELS[name]).join(", ");
|
|
584
|
+
const mode = await prompter.select(`How should supporting tools run (${labels})?`, [
|
|
585
|
+
{ value: "local", label: "local: installs already on this machine" },
|
|
586
|
+
{ value: "docker", label: "docker: Compose for all of them" },
|
|
587
|
+
{ value: "mix", label: "mix: pick Docker Compose vs local per tool" }
|
|
588
|
+
], "docker");
|
|
589
|
+
if (mode === "local") {
|
|
590
|
+
return dockerLayerForNeeded(layers, false);
|
|
591
|
+
}
|
|
592
|
+
if (mode === "docker") {
|
|
593
|
+
return dockerLayerForNeeded(layers, true);
|
|
594
|
+
}
|
|
595
|
+
const services = emptyDockerServices();
|
|
596
|
+
for (const name of needed) {
|
|
597
|
+
services[name] = await prompter.confirm(`Docker Compose for ${DOCKER_SERVICE_LABELS[name]}?`, true);
|
|
598
|
+
}
|
|
599
|
+
const selected = needed.filter((name) => services[name]);
|
|
600
|
+
return {
|
|
601
|
+
enabled: selected.length > 0,
|
|
602
|
+
services
|
|
603
|
+
};
|
|
604
|
+
}
|
|
605
|
+
async function resolveStarterPlan(flags, injected) {
|
|
606
|
+
if (!isInteractive(flags)) {
|
|
607
|
+
return {
|
|
608
|
+
projectName: flags.projectName ?? "strata-app",
|
|
609
|
+
layers: layersFromFlags(flags)
|
|
610
|
+
};
|
|
611
|
+
}
|
|
612
|
+
const prompter = injected ?? createReadlinePrompter();
|
|
613
|
+
try {
|
|
614
|
+
const projectName = flags.projectName || await prompter.question("Project name", "strata-app") || "strata-app";
|
|
615
|
+
const layers = await promptLayers({ ...flags, projectName }, prompter);
|
|
616
|
+
return { projectName, layers };
|
|
617
|
+
} finally {
|
|
618
|
+
if (!injected) {
|
|
619
|
+
prompter.close();
|
|
620
|
+
}
|
|
621
|
+
}
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
// src/renderAuth.ts
|
|
625
|
+
function renderAuthDirectory(layers) {
|
|
626
|
+
if (!authNeedsUsers(layers.auth)) {
|
|
627
|
+
return null;
|
|
628
|
+
}
|
|
629
|
+
const tokenLookup = authUsesToken(layers.auth) ? `
|
|
630
|
+
async resolveUserFromToken(token: string) {
|
|
631
|
+
if (!token || token.split(".").length === 3) {
|
|
632
|
+
return null;
|
|
633
|
+
}
|
|
634
|
+
const hashed = hashApiToken(token);
|
|
635
|
+
const rows = await getSql().unsafe<
|
|
636
|
+
Array<{
|
|
637
|
+
id: number;
|
|
638
|
+
user_id: number;
|
|
639
|
+
abilities: string;
|
|
640
|
+
expires_at: Date | string | null;
|
|
641
|
+
role?: string;
|
|
642
|
+
is_admin?: number | boolean;
|
|
643
|
+
email_verified_at?: Date | string | null;
|
|
644
|
+
}>
|
|
645
|
+
>(
|
|
646
|
+
\`SELECT t.id, t.user_id, t.abilities, t.expires_at, u.is_admin, u.email_verified_at
|
|
647
|
+
FROM api_tokens t INNER JOIN users u ON u.id = t.user_id
|
|
648
|
+
WHERE t.token_hash = ?\`,
|
|
649
|
+
[hashed],
|
|
650
|
+
);
|
|
651
|
+
const row = rows[0];
|
|
652
|
+
if (!row) {
|
|
653
|
+
return null;
|
|
654
|
+
}
|
|
655
|
+
if (row.expires_at && new Date(row.expires_at).getTime() <= Date.now()) {
|
|
656
|
+
return null;
|
|
657
|
+
}
|
|
658
|
+
let abilities: string[] = [];
|
|
659
|
+
try {
|
|
660
|
+
abilities = JSON.parse(String(row.abilities ?? "[]")) as string[];
|
|
661
|
+
} catch {
|
|
662
|
+
abilities = ["profile:read"];
|
|
663
|
+
}
|
|
664
|
+
return {
|
|
665
|
+
id: Number(row.user_id),
|
|
666
|
+
role: row.is_admin ? "admin" : "member",
|
|
667
|
+
abilities,
|
|
668
|
+
tokenId: Number(row.id),
|
|
669
|
+
emailVerifiedAt: row.email_verified_at ?? null,
|
|
670
|
+
};
|
|
671
|
+
},` : `
|
|
672
|
+
async resolveUserFromToken() {
|
|
673
|
+
return null;
|
|
674
|
+
},`;
|
|
675
|
+
const placeholder = layers.database === "postgres" ? "$1" : "?";
|
|
676
|
+
const hashImport = authUsesToken(layers.auth) ? `import { hashApiToken } from "@getstrata/core/auth/tokenHash";
|
|
677
|
+
` : "";
|
|
678
|
+
return `import type { AuthUser } from "@getstrata/core/auth/authContext";
|
|
679
|
+
import { verifyPassword } from "@getstrata/core/auth/password";
|
|
680
|
+
${hashImport}import type { AuthUserDirectory } from "@getstrata/core/contracts/authUserDirectory";
|
|
681
|
+
import { getSql } from "./database.ts";
|
|
682
|
+
|
|
683
|
+
function mapRole(isAdmin: unknown): string {
|
|
684
|
+
return isAdmin === true || isAdmin === 1 || isAdmin === "1" ? "admin" : "member";
|
|
685
|
+
}
|
|
686
|
+
|
|
687
|
+
export const starterAuthDirectory: AuthUserDirectory = {
|
|
688
|
+
${tokenLookup.replace("WHERE t.token_hash = ?", `WHERE t.token_hash = ${placeholder}`)}
|
|
689
|
+
|
|
690
|
+
async findByIdOrThrow(id: number) {
|
|
691
|
+
const rows = await getSql().unsafe<
|
|
692
|
+
Array<{
|
|
693
|
+
id: number;
|
|
694
|
+
email: string;
|
|
695
|
+
is_admin: number | boolean;
|
|
696
|
+
email_verified_at: Date | string | null;
|
|
697
|
+
password: string;
|
|
698
|
+
}>
|
|
699
|
+
>(\`SELECT id, email, is_admin, email_verified_at, password FROM users WHERE id = ${placeholder}\`, [id]);
|
|
700
|
+
const row = rows[0];
|
|
701
|
+
if (!row) {
|
|
702
|
+
throw new Error(\`User \${id} not found.\`);
|
|
703
|
+
}
|
|
704
|
+
return {
|
|
705
|
+
id: Number(row.id),
|
|
706
|
+
email: row.email,
|
|
707
|
+
role: mapRole(row.is_admin),
|
|
708
|
+
email_verified_at: row.email_verified_at ?? null,
|
|
709
|
+
password: row.password,
|
|
710
|
+
};
|
|
711
|
+
},
|
|
712
|
+
|
|
713
|
+
async findByEmail(email: string) {
|
|
714
|
+
const rows = await getSql().unsafe<
|
|
715
|
+
Array<{
|
|
716
|
+
id: number;
|
|
717
|
+
email: string;
|
|
718
|
+
is_admin: number | boolean;
|
|
719
|
+
email_verified_at: Date | string | null;
|
|
720
|
+
password: string;
|
|
721
|
+
}>
|
|
722
|
+
>(
|
|
723
|
+
\`SELECT id, email, is_admin, email_verified_at, password FROM users WHERE email = ${placeholder}\`,
|
|
724
|
+
[email.trim().toLowerCase()],
|
|
725
|
+
);
|
|
726
|
+
const row = rows[0];
|
|
727
|
+
if (!row) {
|
|
728
|
+
return null;
|
|
729
|
+
}
|
|
730
|
+
return {
|
|
731
|
+
id: Number(row.id),
|
|
732
|
+
email: row.email,
|
|
733
|
+
role: mapRole(row.is_admin),
|
|
734
|
+
email_verified_at: row.email_verified_at ?? null,
|
|
735
|
+
password: row.password,
|
|
736
|
+
};
|
|
737
|
+
},
|
|
738
|
+
|
|
739
|
+
async verifyCredentials(email: string, password: string): Promise<AuthUser | null> {
|
|
740
|
+
const user = await this.findByEmail(email);
|
|
741
|
+
if (!user?.password || !(await verifyPassword(password, user.password))) {
|
|
742
|
+
return null;
|
|
743
|
+
}
|
|
744
|
+
return {
|
|
745
|
+
id: user.id,
|
|
746
|
+
role: user.role,
|
|
747
|
+
emailVerifiedAt: user.email_verified_at ?? null,
|
|
748
|
+
};
|
|
749
|
+
},
|
|
750
|
+
};
|
|
751
|
+
`;
|
|
752
|
+
}
|
|
753
|
+
function renderAuthProvider(layers) {
|
|
754
|
+
if (layers.auth === "headers") {
|
|
755
|
+
return `import { CORE_AUTH_TOKEN } from "@getstrata/bootstrap/config";
|
|
756
|
+
import type { AuthUser } from "@getstrata/core/auth/authContext";
|
|
757
|
+
import { currentAuthUser } from "@getstrata/core/auth/authContext";
|
|
758
|
+
import type { ServiceProvider } from "@getstrata/core/contracts/di";
|
|
759
|
+
|
|
760
|
+
class StarterAuthManager {
|
|
761
|
+
async resolve(request?: Request): Promise<AuthUser | null> {
|
|
762
|
+
if (process.env.AUTH_DEV_HEADERS === "false") {
|
|
763
|
+
return request ? null : currentAuthUser();
|
|
764
|
+
}
|
|
765
|
+
if (request) {
|
|
766
|
+
const userId = request.headers.get("x-authenticated-user-id");
|
|
767
|
+
if (!userId) {
|
|
768
|
+
return null;
|
|
769
|
+
}
|
|
770
|
+
const role = request.headers.get("x-authenticated-user-role");
|
|
771
|
+
return {
|
|
772
|
+
id: userId,
|
|
773
|
+
...(role ? { role } : {}),
|
|
774
|
+
};
|
|
775
|
+
}
|
|
776
|
+
return currentAuthUser();
|
|
777
|
+
}
|
|
778
|
+
|
|
779
|
+
user(request?: Request) {
|
|
780
|
+
return this.resolve(request);
|
|
781
|
+
}
|
|
782
|
+
|
|
783
|
+
async check(request: Request) {
|
|
784
|
+
return (await this.user(request)) !== null;
|
|
785
|
+
}
|
|
786
|
+
}
|
|
787
|
+
|
|
788
|
+
const authProvider: ServiceProvider = {
|
|
789
|
+
name: "starter.auth",
|
|
790
|
+
register({ container }) {
|
|
791
|
+
container.set(CORE_AUTH_TOKEN, new StarterAuthManager());
|
|
792
|
+
},
|
|
793
|
+
};
|
|
794
|
+
|
|
795
|
+
export default authProvider;
|
|
796
|
+
`;
|
|
797
|
+
}
|
|
798
|
+
const cookieBlock = authUsesCookie(layers.auth) ? ` const auth = createCookieSessionAuthManager({
|
|
799
|
+
secret: process.env.SESSION_SECRET?.trim() || "dev-session-secret-change-me-please-32ch",
|
|
800
|
+
cookieName: "strata_session",
|
|
801
|
+
mapUser: (user) => ({
|
|
802
|
+
id: user.id,
|
|
803
|
+
role: user.is_admin ? "admin" : "member",
|
|
804
|
+
}),
|
|
805
|
+
});` : ` const fallback = ${authUsesToken(layers.auth) ? "new DatabaseTokenGuard(container)" : "new JwtGuard()"};
|
|
806
|
+
const auth = new AuthManager(fallback);`;
|
|
807
|
+
const tokenRegs = authUsesToken(layers.auth) ? ` const apiGuard = new DatabaseTokenGuard(container);
|
|
808
|
+
auth.registerGuard("api", apiGuard);
|
|
809
|
+
auth.registerGuard("access_token", apiGuard);
|
|
810
|
+
auth.registerGuard("token", apiGuard);` : "";
|
|
811
|
+
const jwtReg = authUsesJwt(layers.auth) ? ` auth.registerGuard("jwt", new JwtGuard());` : "";
|
|
812
|
+
const basicReg = authUsesToken(layers.auth) || authUsesJwt(layers.auth) ? ` auth.registerGuard("basic", new BasicAuthGuard(container));` : "";
|
|
813
|
+
const ability = authUsesToken(layers.auth) ? ` container.set(CORE_ABILITY_CHECKER_TOKEN, createTokenAbilityChecker());` : "";
|
|
814
|
+
const imports = [`import { CORE_AUTH_TOKEN } from "@getstrata/bootstrap/config";`];
|
|
815
|
+
if (authUsesCookie(layers.auth)) {
|
|
816
|
+
imports.push(`import { createCookieSessionAuthManager } from "@getstrata/bootstrap/web/session";`);
|
|
817
|
+
}
|
|
818
|
+
if (authUsesToken(layers.auth) || authUsesJwt(layers.auth)) {
|
|
819
|
+
imports.push(`import { BasicAuthGuard } from "@getstrata/core/auth/basicAuthGuard";`);
|
|
820
|
+
}
|
|
821
|
+
if (!authUsesCookie(layers.auth) && authUsesToken(layers.auth)) {
|
|
822
|
+
imports.push(`import { AuthManager, DatabaseTokenGuard } from "@getstrata/core/auth/guard";`);
|
|
823
|
+
} else if (!authUsesCookie(layers.auth) && authUsesJwt(layers.auth)) {
|
|
824
|
+
imports.push(`import { AuthManager } from "@getstrata/core/auth/guard";`);
|
|
825
|
+
} else if (authUsesCookie(layers.auth) && authUsesToken(layers.auth)) {
|
|
826
|
+
imports.push(`import { DatabaseTokenGuard } from "@getstrata/core/auth/guard";`);
|
|
827
|
+
}
|
|
828
|
+
if (authUsesJwt(layers.auth)) {
|
|
829
|
+
imports.push(`import { JwtGuard } from "@getstrata/core/auth/jwtGuard";`);
|
|
830
|
+
}
|
|
831
|
+
if (authUsesToken(layers.auth)) {
|
|
832
|
+
imports.push(`import { createTokenAbilityChecker } from "@getstrata/core/auth/tokenAbilityChecker";`);
|
|
833
|
+
}
|
|
834
|
+
imports.push(`import type { ServiceProvider } from "@getstrata/core/contracts/di";`);
|
|
835
|
+
const tokenImports = ["CORE_AUTH_USER_DIRECTORY_TOKEN"];
|
|
836
|
+
if (authUsesToken(layers.auth)) {
|
|
837
|
+
tokenImports.unshift("CORE_ABILITY_CHECKER_TOKEN");
|
|
838
|
+
}
|
|
839
|
+
imports.push(`import {
|
|
840
|
+
${tokenImports.join(`,
|
|
841
|
+
`)},
|
|
842
|
+
} from "@getstrata/core/contracts/serviceTokens";`);
|
|
843
|
+
imports.push(`import { starterAuthDirectory } from "../authDirectory.ts";`);
|
|
844
|
+
return `${imports.join(`
|
|
845
|
+
`)}
|
|
846
|
+
|
|
847
|
+
const authProvider: ServiceProvider = {
|
|
848
|
+
name: "starter.auth",
|
|
849
|
+
register({ container }) {
|
|
850
|
+
container.set(CORE_AUTH_USER_DIRECTORY_TOKEN, starterAuthDirectory);
|
|
851
|
+
${cookieBlock}
|
|
852
|
+
${tokenRegs}
|
|
853
|
+
${jwtReg}
|
|
854
|
+
${basicReg}
|
|
855
|
+
${ability}
|
|
856
|
+
container.set(CORE_AUTH_TOKEN, auth);
|
|
857
|
+
},
|
|
858
|
+
};
|
|
859
|
+
|
|
860
|
+
export default authProvider;
|
|
861
|
+
`;
|
|
862
|
+
}
|
|
863
|
+
function renderAuthModule(layers) {
|
|
864
|
+
if (!authNeedsUsers(layers.auth)) {
|
|
865
|
+
return null;
|
|
866
|
+
}
|
|
867
|
+
const cookieRoutes = authUsesCookie(layers.auth) ? `
|
|
868
|
+
webRoutes({ kernel, dependencies }) {
|
|
869
|
+
const auth = dependencies.container.resolve<CookieSessionAuthManager>(CORE_AUTH_TOKEN);
|
|
870
|
+
return {
|
|
871
|
+
"/login": {
|
|
872
|
+
GET: kernel.wrapWebGuest(async (request) =>
|
|
873
|
+
renderPage(
|
|
874
|
+
"auth/login.eta",
|
|
875
|
+
{ layout: { title: "Sign in" }, errors: {}, email: "" },
|
|
876
|
+
request,
|
|
877
|
+
),
|
|
878
|
+
),
|
|
879
|
+
POST: wrapWebLogin(
|
|
880
|
+
kernel,
|
|
881
|
+
async (request) => {
|
|
882
|
+
const { fields } = await parseFormBody(request);
|
|
883
|
+
const email = (fields.email ?? "").trim().toLowerCase();
|
|
884
|
+
const password = fields.password ?? "";
|
|
885
|
+
const user = await starterAuthDirectory.findByEmail?.(email);
|
|
886
|
+
if (!user?.password || !(await verifyPassword(password, user.password))) {
|
|
887
|
+
return renderPage(
|
|
888
|
+
"auth/login.eta",
|
|
889
|
+
{
|
|
890
|
+
layout: { title: "Sign in" },
|
|
891
|
+
errors: { email: "These credentials do not match our records." },
|
|
892
|
+
email,
|
|
893
|
+
},
|
|
894
|
+
request,
|
|
895
|
+
);
|
|
896
|
+
}
|
|
897
|
+
return auth.signInRedirect(
|
|
898
|
+
{
|
|
899
|
+
id: user.id,
|
|
900
|
+
name: user.email ?? "",
|
|
901
|
+
email: user.email ?? "",
|
|
902
|
+
is_admin: user.role === "admin",
|
|
903
|
+
},
|
|
904
|
+
"/",
|
|
905
|
+
);
|
|
906
|
+
},
|
|
907
|
+
async () => new Response("Too many login attempts", { status: 429 }),
|
|
908
|
+
),
|
|
909
|
+
},
|
|
910
|
+
"/logout": {
|
|
911
|
+
POST: kernel.wrapWebAuthenticatedAllowUnverified((request) =>
|
|
912
|
+
auth.signOutRedirect(request, "/login"),
|
|
913
|
+
),
|
|
914
|
+
},
|
|
915
|
+
};
|
|
916
|
+
},` : "";
|
|
917
|
+
const apiLogin = authUsesToken(layers.auth) ? `
|
|
918
|
+
"/api/v1/auth/login": {
|
|
919
|
+
POST: kernel.wrap("api", withErrorHandling(async (request) => {
|
|
920
|
+
const body = (await request.json()) as { email?: string; password?: string };
|
|
921
|
+
const email = (body.email ?? "").trim().toLowerCase();
|
|
922
|
+
const password = body.password ?? "";
|
|
923
|
+
const user = await starterAuthDirectory.verifyCredentials?.(email, password);
|
|
924
|
+
if (!user) {
|
|
925
|
+
return jsonResponse({ error: "Invalid credentials" }, { status: 422 });
|
|
926
|
+
}
|
|
927
|
+
const plain = \`strp_\${randomBytes(24).toString("hex")}\`;
|
|
928
|
+
await getSql().unsafe(
|
|
929
|
+
"INSERT INTO api_tokens (user_id, name, token_hash, abilities) VALUES (${layers.database === "postgres" ? "$1, $2, $3, $4" : "?, ?, ?, ?"})",
|
|
930
|
+
[user.id, "spa", hashApiToken(plain), JSON.stringify(["profile:read"])],
|
|
931
|
+
);
|
|
932
|
+
return jsonResponse({ token: plain });
|
|
933
|
+
})),
|
|
934
|
+
},
|
|
935
|
+
"/api/v1/auth/me": {
|
|
936
|
+
GET: kernel.wrapApi(async (request) => {
|
|
937
|
+
const user = await dependencies.container.resolve<AuthManager>(CORE_AUTH_TOKEN).requireUser(request);
|
|
938
|
+
const record = await starterAuthDirectory.findByIdOrThrow(Number(user.id));
|
|
939
|
+
return jsonResponse({
|
|
940
|
+
id: record.id,
|
|
941
|
+
name: record.email,
|
|
942
|
+
email: record.email,
|
|
943
|
+
role: record.role,
|
|
944
|
+
});
|
|
945
|
+
}),
|
|
946
|
+
},` : "";
|
|
947
|
+
const jwtLogin = authUsesJwt(layers.auth) ? `
|
|
948
|
+
"/api/auth/token": {
|
|
949
|
+
POST: kernel.wrap("api", withErrorHandling(async (request) => {
|
|
950
|
+
const body = (await request.json()) as { email?: string; password?: string };
|
|
951
|
+
const email = (body.email ?? "").trim().toLowerCase();
|
|
952
|
+
const password = body.password ?? "";
|
|
953
|
+
const user = await starterAuthDirectory.verifyCredentials?.(email, password);
|
|
954
|
+
if (!user) {
|
|
955
|
+
return jsonResponse({ error: "Invalid credentials" }, { status: 422 });
|
|
956
|
+
}
|
|
957
|
+
const token = signJwt({
|
|
958
|
+
sub: user.id,
|
|
959
|
+
role: user.role,
|
|
960
|
+
abilities: user.role === "admin" ? ["profile:read", "reports:export"] : ["profile:read"],
|
|
961
|
+
});
|
|
962
|
+
return jsonResponse({
|
|
963
|
+
token,
|
|
964
|
+
token_type: "bearer",
|
|
965
|
+
expires_in: jwtTtlSeconds(),
|
|
966
|
+
});
|
|
967
|
+
})),
|
|
968
|
+
},` : "";
|
|
969
|
+
const apiUser = authUsesToken(layers.auth) || authUsesJwt(layers.auth) ? `
|
|
970
|
+
"/api/user": {
|
|
971
|
+
GET: kernel.wrapApi(async (request) => {
|
|
972
|
+
const user = await dependencies.container.resolve<AuthManager>(CORE_AUTH_TOKEN).requireUser(request);
|
|
973
|
+
return jsonResponse({ id: user.id, role: user.role ?? "member" });
|
|
974
|
+
}),
|
|
975
|
+
},` : "";
|
|
976
|
+
const routesBlock = apiLogin || jwtLogin || apiUser ? `
|
|
977
|
+
routes({ kernel, dependencies }) {
|
|
978
|
+
return {${apiLogin}${jwtLogin}${apiUser}
|
|
979
|
+
};
|
|
980
|
+
},` : "";
|
|
981
|
+
const imports = [];
|
|
982
|
+
if (authUsesToken(layers.auth)) {
|
|
983
|
+
imports.push(`import { randomBytes } from "node:crypto";`);
|
|
984
|
+
}
|
|
985
|
+
imports.push(`import type { AppModule } from "@getstrata/bootstrap/contracts";`);
|
|
986
|
+
imports.push(`import { CORE_AUTH_TOKEN } from "@getstrata/bootstrap/config";`);
|
|
987
|
+
if (authUsesCookie(layers.auth)) {
|
|
988
|
+
imports.push(`import { parseFormBody } from "@getstrata/bootstrap/web/forms";`);
|
|
989
|
+
imports.push(`import { wrapWebLogin } from "@getstrata/bootstrap/web/routing";`);
|
|
990
|
+
imports.push(`import type { CookieSessionAuthManager } from "@getstrata/bootstrap/web/session";`);
|
|
991
|
+
}
|
|
992
|
+
if (authUsesToken(layers.auth) || authUsesJwt(layers.auth)) {
|
|
993
|
+
imports.push(`import { AuthManager } from "@getstrata/core/auth/guard";`);
|
|
994
|
+
}
|
|
995
|
+
if (authUsesJwt(layers.auth)) {
|
|
996
|
+
imports.push(`import { jwtTtlSeconds, signJwt } from "@getstrata/core/auth/jwt";`);
|
|
997
|
+
}
|
|
998
|
+
if (authUsesCookie(layers.auth)) {
|
|
999
|
+
imports.push(`import { verifyPassword } from "@getstrata/core/auth/password";`);
|
|
1000
|
+
}
|
|
1001
|
+
if (authUsesToken(layers.auth)) {
|
|
1002
|
+
imports.push(`import { hashApiToken } from "@getstrata/core/auth/tokenHash";`);
|
|
1003
|
+
}
|
|
1004
|
+
if (authUsesToken(layers.auth) || authUsesJwt(layers.auth)) {
|
|
1005
|
+
imports.push(`import { jsonResponse, withErrorHandling } from "@getstrata/core/http/response";`);
|
|
1006
|
+
}
|
|
1007
|
+
imports.push(`import { starterAuthDirectory } from "../../bootstrap/authDirectory.ts";`);
|
|
1008
|
+
if (authUsesToken(layers.auth)) {
|
|
1009
|
+
imports.push(`import { getSql } from "../../bootstrap/database.ts";`);
|
|
1010
|
+
}
|
|
1011
|
+
if (authUsesCookie(layers.auth)) {
|
|
1012
|
+
imports.push(`import { renderPage } from "../../lib/view.ts";`);
|
|
1013
|
+
}
|
|
1014
|
+
return `${imports.join(`
|
|
1015
|
+
`)}
|
|
1016
|
+
|
|
1017
|
+
const authModule: AppModule = {
|
|
1018
|
+
name: "auth",
|
|
1019
|
+
order: 2,${routesBlock}${cookieRoutes}
|
|
1020
|
+
};
|
|
1021
|
+
|
|
1022
|
+
export default authModule;
|
|
1023
|
+
`;
|
|
1024
|
+
}
|
|
1025
|
+
function renderSiteModule(layers) {
|
|
1026
|
+
const loginHint = authUsesCookie(layers.auth) && (layers.frontend === "server-htmx" || layers.frontend === "hybrid") ? " Sign in at /login." : "";
|
|
1027
|
+
return `import type { AppModule } from "@getstrata/bootstrap/contracts";
|
|
1028
|
+
import { withErrorHandling } from "@getstrata/core/http/response";
|
|
1029
|
+
import { pingDatabase } from "../../bootstrap/database.ts";
|
|
1030
|
+
import { plainText, renderPage } from "../../lib/view.ts";
|
|
1031
|
+
|
|
1032
|
+
const siteModule: AppModule = {
|
|
1033
|
+
name: "site",
|
|
1034
|
+
order: 1,
|
|
1035
|
+
routes({ kernel }) {
|
|
1036
|
+
return {
|
|
1037
|
+
"/health": kernel.wrap("api", withErrorHandling(async () => {
|
|
1038
|
+
const dbOk = await pingDatabase();
|
|
1039
|
+
return plainText(dbOk ? "ok" : "degraded");
|
|
1040
|
+
})),
|
|
1041
|
+
};
|
|
1042
|
+
},
|
|
1043
|
+
webRoutes({ kernel }) {
|
|
1044
|
+
return {
|
|
1045
|
+
"/": kernel.wrapWeb(async (request) =>
|
|
1046
|
+
renderPage(
|
|
1047
|
+
"home.eta",
|
|
1048
|
+
{
|
|
1049
|
+
layout: {
|
|
1050
|
+
title: "Home",
|
|
1051
|
+
description: "A new Strata application.${loginHint}",
|
|
1052
|
+
},
|
|
1053
|
+
},
|
|
1054
|
+
request,
|
|
1055
|
+
),
|
|
1056
|
+
),
|
|
1057
|
+
};
|
|
1058
|
+
},
|
|
1059
|
+
};
|
|
1060
|
+
|
|
1061
|
+
export default siteModule;
|
|
1062
|
+
`;
|
|
1063
|
+
}
|
|
1064
|
+
function renderLoginView() {
|
|
1065
|
+
return `<section class="section">
|
|
1066
|
+
<h1>Sign in</h1>
|
|
1067
|
+
<p>Seeded accounts use password <code>password</code>.</p>
|
|
1068
|
+
<% if (it.errors && it.errors.email) { %>
|
|
1069
|
+
<p class="error"><%= it.errors.email %></p>
|
|
1070
|
+
<% } %>
|
|
1071
|
+
<form method="post" action="/login">
|
|
1072
|
+
<input type="hidden" name="_token" value="<%= it.csrfToken %>" />
|
|
1073
|
+
<label>
|
|
1074
|
+
Email
|
|
1075
|
+
<input type="email" name="email" value="<%= it.email || "demo@example.com" %>" required />
|
|
1076
|
+
</label>
|
|
1077
|
+
<label>
|
|
1078
|
+
Password
|
|
1079
|
+
<input type="password" name="password" value="password" required />
|
|
1080
|
+
</label>
|
|
1081
|
+
<button type="submit">Sign in</button>
|
|
1082
|
+
</form>
|
|
1083
|
+
</section>
|
|
1084
|
+
`;
|
|
1085
|
+
}
|
|
1086
|
+
function renderLayout(layers, projectName) {
|
|
1087
|
+
const cookie = authUsesCookie(layers.auth);
|
|
1088
|
+
return `<!DOCTYPE html>
|
|
1089
|
+
<html lang="en">
|
|
1090
|
+
<head>
|
|
1091
|
+
<meta charset="utf-8" />
|
|
1092
|
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
1093
|
+
<title><%= it.layout.title %> \xB7 ${projectName}</title>
|
|
1094
|
+
<% if (it.layout.description) { %>
|
|
1095
|
+
<meta name="description" content="<%= it.layout.description %>" />
|
|
1096
|
+
<% } %>
|
|
1097
|
+
<link rel="stylesheet" href="/assets/site.css" />
|
|
1098
|
+
</head>
|
|
1099
|
+
<body>
|
|
1100
|
+
<header class="site-header">
|
|
1101
|
+
<a class="brand" href="/">${projectName}</a>
|
|
1102
|
+
<nav>
|
|
1103
|
+
${cookie ? '<a href="/login">Sign in</a>' : ""}
|
|
1104
|
+
</nav>
|
|
1105
|
+
</header>
|
|
1106
|
+
<main><%~ it.body %></main>
|
|
1107
|
+
</body>
|
|
1108
|
+
</html>
|
|
1109
|
+
`;
|
|
1110
|
+
}
|
|
1111
|
+
function renderHomeView(projectName, layers) {
|
|
1112
|
+
const loginLine = authUsesCookie(layers.auth) ? '<p>HTML sign-in: <a href="/login">/login</a> (demo@example.com / password).</p>' : "";
|
|
1113
|
+
return `<section class="section">
|
|
1114
|
+
<h1>Welcome to ${projectName}</h1>
|
|
1115
|
+
<p>Frontend <code>${layers.frontend}</code>, database <code>${layers.database}</code>, auth <code>${layers.auth}</code>.</p>
|
|
1116
|
+
<p>Health check: <a href="/health"><code>/health</code></a>.</p>
|
|
1117
|
+
${loginLine}
|
|
1118
|
+
</section>
|
|
1119
|
+
`;
|
|
1120
|
+
}
|
|
1121
|
+
|
|
1122
|
+
// src/renderEnv.ts
|
|
1123
|
+
function envFlag(value) {
|
|
1124
|
+
return value ? "true" : "false";
|
|
1125
|
+
}
|
|
1126
|
+
function appDatabaseName(projectName) {
|
|
1127
|
+
return `${projectName.replace(/[^A-Za-z0-9_]/g, "_")}_test`;
|
|
1128
|
+
}
|
|
1129
|
+
function defaultDatabaseUrl(layers, projectName) {
|
|
1130
|
+
if (layers.database === "sqlite") {
|
|
1131
|
+
return "sqlite:./storage/app.sqlite";
|
|
1132
|
+
}
|
|
1133
|
+
const database = appDatabaseName(projectName);
|
|
1134
|
+
if (layers.database === "mysql") {
|
|
1135
|
+
return `mysql://root:root@localhost:3306/${database}`;
|
|
1136
|
+
}
|
|
1137
|
+
return `postgresql://postgres:postgres@localhost:5432/${database}`;
|
|
1138
|
+
}
|
|
1139
|
+
function renderEnvExample(projectName, layers) {
|
|
1140
|
+
const lines = [
|
|
1141
|
+
`APP_NAME=${projectName}`,
|
|
1142
|
+
`APP_KEY_PREFIX=${projectName}`,
|
|
1143
|
+
"APP_ENV=local",
|
|
1144
|
+
"PORT=3000",
|
|
1145
|
+
"APP_URL=http://localhost:3000",
|
|
1146
|
+
`DATABASE_URL=${defaultDatabaseUrl(layers, projectName)}`,
|
|
1147
|
+
`DB_CONNECTION=${layers.database === "postgres" ? "pgsql" : layers.database}`,
|
|
1148
|
+
`FRONTEND_MODE=${layers.frontend}`,
|
|
1149
|
+
`SPA_PREFIX=${layers.spaPrefix}`,
|
|
1150
|
+
`TENANCY_DRIVER=${layers.tenancy}`,
|
|
1151
|
+
`CACHE_DRIVER=${layers.cache}`,
|
|
1152
|
+
`QUEUE_DRIVER=${layers.queue}`,
|
|
1153
|
+
`MAIL_DRIVER=${layers.mail}`,
|
|
1154
|
+
`AUTH_DEV_HEADERS=${envFlag(layers.auth === "headers")}`,
|
|
1155
|
+
`FEATURE_PUBLIC_READS=${envFlag(layers.frontend !== "api")}`
|
|
1156
|
+
];
|
|
1157
|
+
if (needsRedis(layers)) {
|
|
1158
|
+
lines.push("REDIS_URL=redis://127.0.0.1:6379");
|
|
1159
|
+
} else {
|
|
1160
|
+
lines.push("# REDIS_URL=redis://127.0.0.1:6379");
|
|
1161
|
+
}
|
|
1162
|
+
if (authUsesCookie(layers.auth) || layers.frontend === "server-htmx" || layers.frontend === "hybrid") {
|
|
1163
|
+
lines.push("SESSION_SECRET=dev-session-secret-change-me-please-32ch");
|
|
1164
|
+
} else {
|
|
1165
|
+
lines.push("# SESSION_SECRET=");
|
|
1166
|
+
}
|
|
1167
|
+
if (authUsesJwt(layers.auth)) {
|
|
1168
|
+
lines.push("JWT_SECRET=dev-jwt-secret-change-me-please-32chars");
|
|
1169
|
+
lines.push("JWT_TTL_SECONDS=3600");
|
|
1170
|
+
} else {
|
|
1171
|
+
lines.push("# JWT_SECRET=");
|
|
1172
|
+
}
|
|
1173
|
+
if (authUsesToken(layers.auth)) {
|
|
1174
|
+
lines.push("FEATURE_API_TOKENS=true");
|
|
1175
|
+
lines.push("TOKEN_HASH_PEPPER=dev-token-pepper-change-me");
|
|
1176
|
+
lines.push("API_TOKEN_DEFAULT_EXPIRY_DAYS=30");
|
|
1177
|
+
} else {
|
|
1178
|
+
lines.push("# FEATURE_API_TOKENS=false");
|
|
1179
|
+
}
|
|
1180
|
+
lines.push(`FEATURE_MFA=${envFlag(layers.extras.mfa)}`);
|
|
1181
|
+
lines.push(`FEATURE_EMAIL_VERIFICATION=${envFlag(layers.extras.emailVerification)}`);
|
|
1182
|
+
if (layers.extras.metrics) {
|
|
1183
|
+
lines.push("METRICS_TOKEN=dev-metrics-token-change-me");
|
|
1184
|
+
} else {
|
|
1185
|
+
lines.push("# METRICS_TOKEN=");
|
|
1186
|
+
}
|
|
1187
|
+
if (layers.extras.scim) {
|
|
1188
|
+
lines.push("FEATURE_SCIM=true");
|
|
1189
|
+
lines.push("SCIM_BEARER_TOKEN=dev-scim-token-change-me");
|
|
1190
|
+
} else {
|
|
1191
|
+
lines.push("# FEATURE_SCIM=false");
|
|
1192
|
+
lines.push("# SCIM_BEARER_TOKEN=");
|
|
1193
|
+
}
|
|
1194
|
+
if (layers.database === "mysql") {
|
|
1195
|
+
lines.push(`MYSQL_URL=${defaultDatabaseUrl(layers, projectName)}`);
|
|
1196
|
+
}
|
|
1197
|
+
if (layers.mail === "smtp") {
|
|
1198
|
+
lines.push("MAIL_HOST=localhost");
|
|
1199
|
+
lines.push("MAIL_PORT=1025");
|
|
1200
|
+
lines.push(`MAIL_FROM=noreply@${projectName}.local`);
|
|
1201
|
+
lines.push("MAIL_SECURE=false");
|
|
1202
|
+
} else {
|
|
1203
|
+
lines.push("# MAIL_HOST=");
|
|
1204
|
+
lines.push("# MAIL_FROM=");
|
|
1205
|
+
}
|
|
1206
|
+
lines.push("# TRUST_FORWARDED_FOR=true");
|
|
1207
|
+
return `${lines.join(`
|
|
1208
|
+
`)}
|
|
1209
|
+
`;
|
|
1210
|
+
}
|
|
1211
|
+
function renderDockerCompose(projectName, layers) {
|
|
1212
|
+
const selected = selectedDockerServices(layers);
|
|
1213
|
+
if (selected.length === 0) {
|
|
1214
|
+
return null;
|
|
1215
|
+
}
|
|
1216
|
+
const selectedSet = new Set(selected);
|
|
1217
|
+
const services = [];
|
|
1218
|
+
if (selectedSet.has("postgres")) {
|
|
1219
|
+
const database = appDatabaseName(projectName);
|
|
1220
|
+
services.push(` postgres:
|
|
1221
|
+
image: postgres:16-alpine
|
|
1222
|
+
environment:
|
|
1223
|
+
POSTGRES_USER: postgres
|
|
1224
|
+
POSTGRES_PASSWORD: postgres
|
|
1225
|
+
POSTGRES_DB: ${database}
|
|
1226
|
+
ports:
|
|
1227
|
+
- "5432:5432"
|
|
1228
|
+
volumes:
|
|
1229
|
+
- pgdata:/var/lib/postgresql/data`);
|
|
1230
|
+
}
|
|
1231
|
+
if (selectedSet.has("mysql")) {
|
|
1232
|
+
const database = appDatabaseName(projectName);
|
|
1233
|
+
services.push(` mysql:
|
|
1234
|
+
image: mysql:8.4
|
|
1235
|
+
environment:
|
|
1236
|
+
MYSQL_ROOT_PASSWORD: root
|
|
1237
|
+
MYSQL_DATABASE: ${database}
|
|
1238
|
+
ports:
|
|
1239
|
+
- "3306:3306"
|
|
1240
|
+
volumes:
|
|
1241
|
+
- mysqldata:/var/lib/mysql`);
|
|
1242
|
+
}
|
|
1243
|
+
if (selectedSet.has("redis")) {
|
|
1244
|
+
services.push(` redis:
|
|
1245
|
+
image: redis:7-alpine
|
|
1246
|
+
ports:
|
|
1247
|
+
- "6379:6379"`);
|
|
1248
|
+
}
|
|
1249
|
+
if (selectedSet.has("mailpit")) {
|
|
1250
|
+
services.push(` mailpit:
|
|
1251
|
+
image: axllent/mailpit:latest
|
|
1252
|
+
ports:
|
|
1253
|
+
- "1025:1025"
|
|
1254
|
+
- "8025:8025"`);
|
|
1255
|
+
}
|
|
1256
|
+
const volumes = [];
|
|
1257
|
+
if (selectedSet.has("postgres")) {
|
|
1258
|
+
volumes.push(" pgdata:");
|
|
1259
|
+
}
|
|
1260
|
+
if (selectedSet.has("mysql")) {
|
|
1261
|
+
volumes.push(" mysqldata:");
|
|
1262
|
+
}
|
|
1263
|
+
return `services:
|
|
1264
|
+
${services.join(`
|
|
1265
|
+
|
|
1266
|
+
`)}
|
|
1267
|
+
${volumes.length > 0 ? `
|
|
1268
|
+
volumes:
|
|
1269
|
+
${volumes.join(`
|
|
1270
|
+
`)}
|
|
1271
|
+
` : ""}`;
|
|
1272
|
+
}
|
|
1273
|
+
function renderGitignore() {
|
|
1274
|
+
return `node_modules
|
|
1275
|
+
.env
|
|
1276
|
+
.env.local
|
|
1277
|
+
dist
|
|
1278
|
+
frontend/dist
|
|
1279
|
+
storage/*.sqlite
|
|
1280
|
+
storage/*.sqlite-journal
|
|
1281
|
+
coverage
|
|
1282
|
+
*.tsbuildinfo
|
|
1283
|
+
`;
|
|
1284
|
+
}
|
|
1285
|
+
function renderPackageJson(projectName, options = {}) {
|
|
1286
|
+
const coreDeps = options.workspaceDependencies ? {
|
|
1287
|
+
"@getstrata/bootstrap": "workspace:*",
|
|
1288
|
+
"@getstrata/cli": "workspace:*",
|
|
1289
|
+
"@getstrata/core": "workspace:*"
|
|
1290
|
+
} : {
|
|
1291
|
+
"@getstrata/bootstrap": "^0.4.2",
|
|
1292
|
+
"@getstrata/cli": "^0.2.0",
|
|
1293
|
+
"@getstrata/core": "^0.7.4"
|
|
1294
|
+
};
|
|
1295
|
+
if (options.layers?.database === "mysql") {
|
|
1296
|
+
coreDeps.mysql2 = "^3.24.3";
|
|
1297
|
+
}
|
|
1298
|
+
return `${JSON.stringify({
|
|
1299
|
+
name: projectName,
|
|
1300
|
+
version: "0.1.0",
|
|
1301
|
+
private: true,
|
|
1302
|
+
type: "module",
|
|
1303
|
+
scripts: {
|
|
1304
|
+
dev: "strata dev",
|
|
1305
|
+
start: "strata start",
|
|
1306
|
+
"db:migrate": "strata migrate",
|
|
1307
|
+
"db:fresh": "strata migrate:fresh",
|
|
1308
|
+
check: "tsc --noEmit"
|
|
1309
|
+
},
|
|
1310
|
+
dependencies: coreDeps,
|
|
1311
|
+
devDependencies: {
|
|
1312
|
+
"@types/bun": "^1.4.0",
|
|
1313
|
+
typescript: "^5.9.2"
|
|
1314
|
+
}
|
|
1315
|
+
}, null, 2)}
|
|
1316
|
+
`;
|
|
1317
|
+
}
|
|
1318
|
+
function renderLayersManifest(projectName, layers) {
|
|
1319
|
+
return `${JSON.stringify({
|
|
1320
|
+
name: projectName,
|
|
1321
|
+
generatedBy: "create-strata",
|
|
1322
|
+
layers: {
|
|
1323
|
+
frontend: layers.frontend,
|
|
1324
|
+
database: layers.database,
|
|
1325
|
+
auth: layers.auth,
|
|
1326
|
+
tenancy: layers.tenancy,
|
|
1327
|
+
cache: layers.cache,
|
|
1328
|
+
queue: layers.queue,
|
|
1329
|
+
mail: layers.mail,
|
|
1330
|
+
spaPrefix: layers.spaPrefix,
|
|
1331
|
+
extras: layers.extras,
|
|
1332
|
+
docker: layers.docker
|
|
1333
|
+
},
|
|
1334
|
+
notes: "Generated by create-strata. Change layers later with flags or by editing env and bootstrap files."
|
|
1335
|
+
}, null, 2)}
|
|
1336
|
+
`;
|
|
1337
|
+
}
|
|
1338
|
+
function localEnvVars(services) {
|
|
1339
|
+
const vars = [];
|
|
1340
|
+
for (const name of services) {
|
|
1341
|
+
if (name === "postgres" || name === "mysql") {
|
|
1342
|
+
vars.push("DATABASE_URL");
|
|
1343
|
+
} else if (name === "redis") {
|
|
1344
|
+
vars.push("REDIS_URL");
|
|
1345
|
+
} else if (name === "mailpit") {
|
|
1346
|
+
vars.push("MAIL_HOST");
|
|
1347
|
+
}
|
|
1348
|
+
}
|
|
1349
|
+
return [...new Set(vars)];
|
|
1350
|
+
}
|
|
1351
|
+
function renderSupportingToolsReadme(layers) {
|
|
1352
|
+
const needed = neededDockerServices(layers);
|
|
1353
|
+
if (needed.length === 0) {
|
|
1354
|
+
return "";
|
|
1355
|
+
}
|
|
1356
|
+
const dockerOn = selectedDockerServices(layers);
|
|
1357
|
+
const dockerSet = new Set(dockerOn);
|
|
1358
|
+
const localOn = needed.filter((name) => !dockerSet.has(name));
|
|
1359
|
+
const lines = ["## Supporting tools", ""];
|
|
1360
|
+
if (dockerOn.length > 0) {
|
|
1361
|
+
const names = dockerOn.map((name) => DOCKER_SERVICE_LABELS[name]).join(", ");
|
|
1362
|
+
lines.push(`Docker Compose includes ${names}.`, "", "```bash", "docker compose up -d", "```", "");
|
|
1363
|
+
}
|
|
1364
|
+
if (localOn.length > 0) {
|
|
1365
|
+
const names = localOn.map((name) => DOCKER_SERVICE_LABELS[name]).join(", ");
|
|
1366
|
+
const envVars = localEnvVars(localOn).map((name) => `\`${name}\``).join(", ");
|
|
1367
|
+
lines.push(`Use local installs for ${names}. Point ${envVars} in \`.env\` at services on this machine.`, "");
|
|
1368
|
+
}
|
|
1369
|
+
return `${lines.join(`
|
|
1370
|
+
`)}
|
|
1371
|
+
`;
|
|
1372
|
+
}
|
|
1373
|
+
function renderReadme(projectName, layers) {
|
|
1374
|
+
const docker = renderDockerCompose(projectName, layers);
|
|
1375
|
+
const next = [`cd ${projectName}`, "cp .env.example .env"];
|
|
1376
|
+
if (docker) {
|
|
1377
|
+
next.push("docker compose up -d");
|
|
1378
|
+
}
|
|
1379
|
+
next.push("bun install", "strata migrate", "strata dev");
|
|
1380
|
+
const extras = Object.entries(layers.extras).filter(([, on]) => on).map(([key]) => key);
|
|
1381
|
+
const dockerServices = selectedDockerServices(layers);
|
|
1382
|
+
const neededTools = neededDockerServices(layers);
|
|
1383
|
+
const dockerLabel = neededTools.length === 0 ? "not needed" : dockerServices.length > 0 ? dockerServices.join(", ") : "off (local installs)";
|
|
1384
|
+
return `# ${projectName}
|
|
1385
|
+
|
|
1386
|
+
Strata app generated by \`create-strata\`.
|
|
1387
|
+
|
|
1388
|
+
## Layers
|
|
1389
|
+
|
|
1390
|
+
| Layer | Choice |
|
|
1391
|
+
|-------|--------|
|
|
1392
|
+
| Frontend | \`${layers.frontend}\` |
|
|
1393
|
+
| Database | \`${layers.database}\` |
|
|
1394
|
+
| Auth | \`${layers.auth}\` |
|
|
1395
|
+
| Tenancy | \`${layers.tenancy}\` |
|
|
1396
|
+
| Cache | \`${layers.cache}\` |
|
|
1397
|
+
| Queue | \`${layers.queue}\` |
|
|
1398
|
+
| Mail | \`${layers.mail}\` |
|
|
1399
|
+
| SPA prefix | \`${layers.spaPrefix}\` |
|
|
1400
|
+
| Docker Compose | ${dockerLabel} |
|
|
1401
|
+
${extras.length > 0 ? `| Extras | ${extras.join(", ")} |
|
|
1402
|
+
` : ""}
|
|
1403
|
+
This file is the map for this app. Framework guides: [Building apps](https://github.com/EyK-26/strata/blob/main/docs/BUILDING-APPS.md), [Auth](https://github.com/EyK-26/strata/blob/main/docs/AUTH.md), [Starter](https://github.com/EyK-26/strata/blob/main/docs/STARTER.md).
|
|
1404
|
+
|
|
1405
|
+
## Run it
|
|
1406
|
+
|
|
1407
|
+
\`\`\`bash
|
|
1408
|
+
${next.join(`
|
|
1409
|
+
`)}
|
|
1410
|
+
\`\`\`
|
|
1411
|
+
|
|
1412
|
+
Open http://localhost:3000. Health check: \`GET /health\`.
|
|
1413
|
+
|
|
1414
|
+
${renderSupportingToolsReadme(layers)}${layers.auth !== "headers" ? `
|
|
1415
|
+
Seeded login (password \`password\`):
|
|
1416
|
+
|
|
1417
|
+
- \`demo@example.com\` (member)
|
|
1418
|
+
- \`admin@example.test\` (admin)
|
|
1419
|
+
` : `
|
|
1420
|
+
Header auth is on for local use. Send \`x-authenticated-user-id\` (and optional \`x-authenticated-user-role\`). Production must set \`AUTH_DEV_HEADERS=false\`.
|
|
1421
|
+
`}${authUsesCookie(layers.auth) ? `
|
|
1422
|
+
HTML sign-in lives at \`/login\` (cookie session + CSRF when \`FRONTEND_MODE\` is \`server-htmx\` or \`hybrid\`).
|
|
1423
|
+
` : ""}${authUsesToken(layers.auth) ? `
|
|
1424
|
+
Opaque token login: \`POST /api/v1/auth/login\` with \`{ "email", "password" }\`. Send \`Authorization: Bearer\`.
|
|
1425
|
+
` : ""}${authUsesJwt(layers.auth) ? `
|
|
1426
|
+
JWT mint: \`POST /api/auth/token\` with email and password. Short-lived. Not a portal session.
|
|
1427
|
+
` : ""}
|
|
1428
|
+
## Production
|
|
1429
|
+
|
|
1430
|
+
\`createApp\` calls \`assertProductionSecrets()\` when \`APP_ENV=production\`. Set real secrets before you ship. Cookie HTML apps need \`SESSION_SECRET\` (32+ characters). Token apps need \`TOKEN_HASH_PEPPER\`. Set \`AUTH_DEV_HEADERS=false\`.
|
|
1431
|
+
`;
|
|
1432
|
+
}
|
|
1433
|
+
|
|
1434
|
+
// src/renderRuntime.ts
|
|
1435
|
+
function needsEnsure(layers) {
|
|
1436
|
+
return layers.database !== "sqlite";
|
|
1437
|
+
}
|
|
1438
|
+
function ensureImport(layers) {
|
|
1439
|
+
return needsEnsure(layers) ? `import { ensureAppDatabase } from "../bootstrap/ensureDatabase.ts";
|
|
1440
|
+
` : "";
|
|
1441
|
+
}
|
|
1442
|
+
function ensureCall(layers) {
|
|
1443
|
+
return needsEnsure(layers) ? ` await ensureAppDatabase();
|
|
1444
|
+
` : "";
|
|
1445
|
+
}
|
|
1446
|
+
function dialectFragments(database) {
|
|
1447
|
+
if (database === "sqlite") {
|
|
1448
|
+
return {
|
|
1449
|
+
id: "INTEGER PRIMARY KEY AUTOINCREMENT",
|
|
1450
|
+
text: "TEXT",
|
|
1451
|
+
timestamp: "TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP",
|
|
1452
|
+
timestampNull: "TEXT",
|
|
1453
|
+
bool: "INTEGER NOT NULL DEFAULT 0"
|
|
1454
|
+
};
|
|
1455
|
+
}
|
|
1456
|
+
if (database === "mysql") {
|
|
1457
|
+
return {
|
|
1458
|
+
id: "INT AUTO_INCREMENT PRIMARY KEY",
|
|
1459
|
+
text: "TEXT",
|
|
1460
|
+
timestamp: "DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP",
|
|
1461
|
+
timestampNull: "DATETIME NULL",
|
|
1462
|
+
bool: "TINYINT(1) NOT NULL DEFAULT 0"
|
|
1463
|
+
};
|
|
1464
|
+
}
|
|
1465
|
+
return {
|
|
1466
|
+
id: "SERIAL PRIMARY KEY",
|
|
1467
|
+
text: "TEXT",
|
|
1468
|
+
timestamp: "TIMESTAMPTZ NOT NULL DEFAULT NOW()",
|
|
1469
|
+
timestampNull: "TIMESTAMPTZ",
|
|
1470
|
+
bool: "BOOLEAN NOT NULL DEFAULT FALSE"
|
|
1471
|
+
};
|
|
1472
|
+
}
|
|
1473
|
+
function driverName(database) {
|
|
1474
|
+
return database === "postgres" ? "pgsql" : database;
|
|
1475
|
+
}
|
|
1476
|
+
function renderDatabaseTs(layers) {
|
|
1477
|
+
const driver = driverName(layers.database);
|
|
1478
|
+
if (layers.database === "sqlite") {
|
|
1479
|
+
return `import { mkdirSync } from "node:fs";
|
|
1480
|
+
import { dirname } from "node:path";
|
|
1481
|
+
import type { SqlDatabaseConnection } from "@getstrata/core/database/baseRepository";
|
|
1482
|
+
import { bindDatabaseConnection } from "@getstrata/core/database/boundConnection";
|
|
1483
|
+
import { registerDefaultDatabasePool } from "@getstrata/core/database/defaultConnection";
|
|
1484
|
+
import { createSqliteConnection } from "@getstrata/core/database/sqliteConnection";
|
|
1485
|
+
import { useSqlDialect } from "@getstrata/core/database/dialect";
|
|
1486
|
+
|
|
1487
|
+
export type SqlClient = {
|
|
1488
|
+
unsafe<T>(query: string, params?: readonly unknown[]): Promise<T[]>;
|
|
1489
|
+
close?: () => Promise<void> | void;
|
|
1490
|
+
};
|
|
1491
|
+
|
|
1492
|
+
let sql: SqlClient | null = null;
|
|
1493
|
+
|
|
1494
|
+
function sqliteFilename(url: string): string {
|
|
1495
|
+
const trimmed = url.trim();
|
|
1496
|
+
if (trimmed === ":memory:" || trimmed === "sqlite::memory:") {
|
|
1497
|
+
return ":memory:";
|
|
1498
|
+
}
|
|
1499
|
+
if (trimmed.startsWith("sqlite:")) {
|
|
1500
|
+
return trimmed.slice("sqlite:".length).replace(/^\\/\\//, "") || "./storage/app.sqlite";
|
|
1501
|
+
}
|
|
1502
|
+
return trimmed || "./storage/app.sqlite";
|
|
1503
|
+
}
|
|
1504
|
+
|
|
1505
|
+
function asSqlPool(client: SqlClient): SqlDatabaseConnection {
|
|
1506
|
+
const tagged = async () => {
|
|
1507
|
+
throw new Error(
|
|
1508
|
+
"SQLite starter connections do not run tagged SQL. Keep TENANCY_DRIVER=none or use Postgres.",
|
|
1509
|
+
);
|
|
1510
|
+
};
|
|
1511
|
+
return Object.assign(tagged, client) as SqlDatabaseConnection;
|
|
1512
|
+
}
|
|
1513
|
+
|
|
1514
|
+
export function getSql(): SqlClient {
|
|
1515
|
+
if (sql) {
|
|
1516
|
+
return sql;
|
|
1517
|
+
}
|
|
1518
|
+
|
|
1519
|
+
const url = process.env.DATABASE_URL;
|
|
1520
|
+
if (!url) {
|
|
1521
|
+
throw new Error("DATABASE_URL is required");
|
|
1522
|
+
}
|
|
1523
|
+
|
|
1524
|
+
useSqlDialect("${driver}");
|
|
1525
|
+
const filename = sqliteFilename(url);
|
|
1526
|
+
if (filename !== ":memory:") {
|
|
1527
|
+
mkdirSync(dirname(filename), { recursive: true });
|
|
1528
|
+
}
|
|
1529
|
+
sql = createSqliteConnection(filename);
|
|
1530
|
+
bindDatabaseConnection(sql);
|
|
1531
|
+
registerDefaultDatabasePool(asSqlPool(sql));
|
|
1532
|
+
return sql;
|
|
1533
|
+
}
|
|
1534
|
+
|
|
1535
|
+
export async function pingDatabase(): Promise<boolean> {
|
|
1536
|
+
try {
|
|
1537
|
+
await getSql().unsafe("SELECT 1");
|
|
1538
|
+
return true;
|
|
1539
|
+
} catch {
|
|
1540
|
+
return false;
|
|
1541
|
+
}
|
|
1542
|
+
}
|
|
1543
|
+
|
|
1544
|
+
export async function closeDatabase() {
|
|
1545
|
+
if (sql?.close) {
|
|
1546
|
+
await sql.close();
|
|
1547
|
+
}
|
|
1548
|
+
sql = null;
|
|
1549
|
+
}
|
|
1550
|
+
`;
|
|
1551
|
+
}
|
|
1552
|
+
if (layers.database === "mysql") {
|
|
1553
|
+
return `import { bindDatabaseConnection } from "@getstrata/core/database/boundConnection";
|
|
1554
|
+
import type { SqlDatabaseConnection } from "@getstrata/core/database/baseRepository";
|
|
1555
|
+
import { registerDefaultDatabasePool } from "@getstrata/core/database/defaultConnection";
|
|
1556
|
+
import { createMysqlConnection } from "@getstrata/core/database/mysqlConnection";
|
|
1557
|
+
import { useSqlDialect } from "@getstrata/core/database/dialect";
|
|
1558
|
+
|
|
1559
|
+
export type SqlClient = {
|
|
1560
|
+
unsafe<T>(query: string, params?: readonly unknown[]): Promise<T[]>;
|
|
1561
|
+
close?: () => Promise<void> | void;
|
|
1562
|
+
};
|
|
1563
|
+
|
|
1564
|
+
let sql: SqlClient | null = null;
|
|
1565
|
+
|
|
1566
|
+
export function getSql(): SqlClient {
|
|
1567
|
+
if (sql) {
|
|
1568
|
+
return sql;
|
|
1569
|
+
}
|
|
1570
|
+
|
|
1571
|
+
const url = process.env.DATABASE_URL;
|
|
1572
|
+
if (!url) {
|
|
1573
|
+
throw new Error("DATABASE_URL is required");
|
|
1574
|
+
}
|
|
1575
|
+
|
|
1576
|
+
useSqlDialect("${driver}");
|
|
1577
|
+
sql = createMysqlConnection(url);
|
|
1578
|
+
bindDatabaseConnection(sql);
|
|
1579
|
+
registerDefaultDatabasePool(sql as SqlDatabaseConnection);
|
|
1580
|
+
return sql;
|
|
1581
|
+
}
|
|
1582
|
+
|
|
1583
|
+
export async function pingDatabase(): Promise<boolean> {
|
|
1584
|
+
try {
|
|
1585
|
+
await getSql().unsafe("SELECT 1");
|
|
1586
|
+
return true;
|
|
1587
|
+
} catch {
|
|
1588
|
+
return false;
|
|
1589
|
+
}
|
|
1590
|
+
}
|
|
1591
|
+
|
|
1592
|
+
export async function closeDatabase() {
|
|
1593
|
+
if (sql?.close) {
|
|
1594
|
+
await sql.close();
|
|
1595
|
+
}
|
|
1596
|
+
sql = null;
|
|
1597
|
+
}
|
|
1598
|
+
`;
|
|
1599
|
+
}
|
|
1600
|
+
return `import { createBunSqlPool } from "@getstrata/core/database/bunSql";
|
|
1601
|
+
import type { SqlDatabaseConnection } from "@getstrata/core/database/baseRepository";
|
|
1602
|
+
import { bindDatabaseConnection } from "@getstrata/core/database/boundConnection";
|
|
1603
|
+
import {
|
|
1604
|
+
getDefaultDatabaseQuery,
|
|
1605
|
+
registerDefaultDatabasePool,
|
|
1606
|
+
} from "@getstrata/core/database/defaultConnection";
|
|
1607
|
+
import { useSqlDialect } from "@getstrata/core/database/dialect";
|
|
1608
|
+
|
|
1609
|
+
export type SqlClient = {
|
|
1610
|
+
unsafe<T>(query: string, params?: readonly unknown[]): Promise<T[]>;
|
|
1611
|
+
close?: () => Promise<void> | void;
|
|
1612
|
+
};
|
|
1613
|
+
|
|
1614
|
+
let sql: SqlClient | null = null;
|
|
1615
|
+
|
|
1616
|
+
export function getSql(): SqlClient {
|
|
1617
|
+
if (sql) {
|
|
1618
|
+
return sql;
|
|
1619
|
+
}
|
|
1620
|
+
|
|
1621
|
+
const url = process.env.DATABASE_URL;
|
|
1622
|
+
if (!url) {
|
|
1623
|
+
throw new Error("DATABASE_URL is required");
|
|
1624
|
+
}
|
|
1625
|
+
|
|
1626
|
+
useSqlDialect("${driver}");
|
|
1627
|
+
const pool = createBunSqlPool({ url, max: 5 }) as SqlDatabaseConnection;
|
|
1628
|
+
registerDefaultDatabasePool(pool);
|
|
1629
|
+
bindDatabaseConnection(getDefaultDatabaseQuery());
|
|
1630
|
+
sql = getDefaultDatabaseQuery() as SqlClient;
|
|
1631
|
+
return sql;
|
|
1632
|
+
}
|
|
1633
|
+
|
|
1634
|
+
export async function pingDatabase(): Promise<boolean> {
|
|
1635
|
+
try {
|
|
1636
|
+
await getSql().unsafe("SELECT 1");
|
|
1637
|
+
return true;
|
|
1638
|
+
} catch {
|
|
1639
|
+
return false;
|
|
1640
|
+
}
|
|
1641
|
+
}
|
|
1642
|
+
|
|
1643
|
+
export async function closeDatabase() {
|
|
1644
|
+
if (sql?.close) {
|
|
1645
|
+
await sql.close();
|
|
1646
|
+
}
|
|
1647
|
+
sql = null;
|
|
1648
|
+
}
|
|
1649
|
+
`;
|
|
1650
|
+
}
|
|
1651
|
+
function renderMigrateTs(layers) {
|
|
1652
|
+
const d = dialectFragments(layers.database);
|
|
1653
|
+
const statements = [];
|
|
1654
|
+
if (layers.tenancy === "rls") {
|
|
1655
|
+
statements.push(`CREATE TABLE IF NOT EXISTS tenant (
|
|
1656
|
+
id ${d.id},
|
|
1657
|
+
slug ${d.text} NOT NULL UNIQUE,
|
|
1658
|
+
plan ${d.text} NOT NULL DEFAULT 'enterprise',
|
|
1659
|
+
region ${d.text} NOT NULL DEFAULT 'eu'
|
|
1660
|
+
)`);
|
|
1661
|
+
}
|
|
1662
|
+
statements.push(`CREATE TABLE IF NOT EXISTS notes (
|
|
1663
|
+
id ${d.id},
|
|
1664
|
+
body ${d.text} NOT NULL,
|
|
1665
|
+
created_at ${d.timestamp}
|
|
1666
|
+
)`);
|
|
1667
|
+
if (authNeedsUsers(layers.auth)) {
|
|
1668
|
+
const tenantColumn = layers.tenancy === "rls" ? `
|
|
1669
|
+
tenant_id INTEGER NOT NULL DEFAULT 1,` : "";
|
|
1670
|
+
statements.push(`CREATE TABLE IF NOT EXISTS users (
|
|
1671
|
+
id ${d.id},
|
|
1672
|
+
name ${d.text} NOT NULL,
|
|
1673
|
+
email ${d.text} NOT NULL UNIQUE,
|
|
1674
|
+
password ${d.text} NOT NULL,
|
|
1675
|
+
is_admin ${d.bool},${tenantColumn}
|
|
1676
|
+
email_verified_at ${d.timestampNull},
|
|
1677
|
+
created_at ${d.timestamp}
|
|
1678
|
+
)`);
|
|
1679
|
+
}
|
|
1680
|
+
if (authUsesCookie(layers.auth)) {
|
|
1681
|
+
statements.push(`CREATE TABLE IF NOT EXISTS sessions (
|
|
1682
|
+
id ${d.text} PRIMARY KEY,
|
|
1683
|
+
user_id INTEGER NOT NULL,
|
|
1684
|
+
expires_at ${d.timestamp},
|
|
1685
|
+
user_agent ${d.text},
|
|
1686
|
+
ip_address ${d.text},
|
|
1687
|
+
last_active_at ${d.timestamp}
|
|
1688
|
+
)`);
|
|
1689
|
+
}
|
|
1690
|
+
if (authUsesToken(layers.auth)) {
|
|
1691
|
+
statements.push(`CREATE TABLE IF NOT EXISTS api_tokens (
|
|
1692
|
+
id ${d.id},
|
|
1693
|
+
user_id INTEGER NOT NULL,
|
|
1694
|
+
name ${d.text} NOT NULL,
|
|
1695
|
+
token_hash ${d.text} NOT NULL UNIQUE,
|
|
1696
|
+
abilities ${d.text} NOT NULL DEFAULT '[]',
|
|
1697
|
+
expires_at ${d.timestampNull},
|
|
1698
|
+
last_used_at ${d.timestampNull},
|
|
1699
|
+
created_at ${d.timestamp}
|
|
1700
|
+
)`);
|
|
1701
|
+
}
|
|
1702
|
+
const list = statements.map((sql) => ` \`${sql}\`,`).join(`
|
|
16
1703
|
`);
|
|
1704
|
+
const ph = layers.database === "postgres";
|
|
1705
|
+
const notePlaceholder = ph ? "$1" : "?";
|
|
1706
|
+
const userPlaceholders = ph ? "$1, $2, $3, $4), ($5, $6, $7, $8" : "?, ?, ?, ?), (?, ?, ?, ?";
|
|
1707
|
+
const adminFlag = ph ? "false, " : "0, ";
|
|
1708
|
+
const adminTrue = ph ? "true" : "1";
|
|
1709
|
+
const seedTenant = layers.tenancy === "rls" ? `
|
|
1710
|
+
const [{ count: tenantCount }] = await sql.unsafe<Array<{ count: string | number }>>(
|
|
1711
|
+
"SELECT COUNT(*) AS count FROM tenant",
|
|
1712
|
+
);
|
|
1713
|
+
if (Number(tenantCount) === 0) {
|
|
1714
|
+
await sql.unsafe(
|
|
1715
|
+
"INSERT INTO tenant (slug, plan, region) VALUES (${ph ? "$1, $2, $3" : "?, ?, ?"})",
|
|
1716
|
+
["default", "enterprise", "eu"],
|
|
1717
|
+
);
|
|
1718
|
+
}` : "";
|
|
1719
|
+
const seedUsers = authNeedsUsers(layers.auth) ? `
|
|
1720
|
+
const [{ count: userCount }] = await sql.unsafe<Array<{ count: string | number }>>(
|
|
1721
|
+
"SELECT COUNT(*) AS count FROM users",
|
|
1722
|
+
);
|
|
1723
|
+
if (Number(userCount) === 0) {
|
|
1724
|
+
const password = await hashPassword("password");
|
|
1725
|
+
await sql.unsafe(
|
|
1726
|
+
"INSERT INTO users (name, email, password, is_admin) VALUES (${userPlaceholders})",
|
|
1727
|
+
["Demo User", "demo@example.com", password, ${adminFlag}"Admin User", "admin@example.test", password, ${adminTrue}],
|
|
1728
|
+
);
|
|
1729
|
+
}` : "";
|
|
1730
|
+
const hashImport = authNeedsUsers(layers.auth) ? `import { hashPassword } from "@getstrata/core/auth/password";
|
|
1731
|
+
` : "";
|
|
1732
|
+
const seedBlock = `${seedTenant}${seedUsers}`;
|
|
1733
|
+
return `${hashImport}${ensureImport(layers)}import { getSql } from "../bootstrap/database.ts";
|
|
1734
|
+
|
|
1735
|
+
const migrations = [
|
|
1736
|
+
${list}
|
|
1737
|
+
];
|
|
1738
|
+
|
|
1739
|
+
export async function migrate() {
|
|
1740
|
+
${ensureCall(layers)} const sql = getSql();
|
|
1741
|
+
for (const statement of migrations) {
|
|
1742
|
+
await sql.unsafe(statement);
|
|
1743
|
+
}
|
|
1744
|
+
}
|
|
1745
|
+
|
|
1746
|
+
export async function seed() {
|
|
1747
|
+
${ensureCall(layers)} const sql = getSql();
|
|
1748
|
+
const [{ count }] = await sql.unsafe<Array<{ count: string | number }>>(
|
|
1749
|
+
"SELECT COUNT(*) AS count FROM notes",
|
|
1750
|
+
);
|
|
1751
|
+
if (Number(count) === 0) {
|
|
1752
|
+
await sql.unsafe("INSERT INTO notes (body) VALUES (${notePlaceholder})", [
|
|
1753
|
+
"Welcome to Strata!",
|
|
1754
|
+
]);
|
|
1755
|
+
}${seedBlock}
|
|
1756
|
+
}
|
|
1757
|
+
|
|
1758
|
+
if (import.meta.main) {
|
|
1759
|
+
await migrate();
|
|
1760
|
+
await seed();
|
|
1761
|
+
console.log("Database migrated and seeded.");
|
|
1762
|
+
process.exit(0);
|
|
1763
|
+
}
|
|
1764
|
+
`;
|
|
1765
|
+
}
|
|
1766
|
+
function dropTables(layers) {
|
|
1767
|
+
const ordered = [];
|
|
1768
|
+
if (authUsesToken(layers.auth)) {
|
|
1769
|
+
ordered.push("api_tokens");
|
|
1770
|
+
}
|
|
1771
|
+
if (authUsesCookie(layers.auth)) {
|
|
1772
|
+
ordered.push("sessions");
|
|
1773
|
+
}
|
|
1774
|
+
if (authNeedsUsers(layers.auth)) {
|
|
1775
|
+
ordered.push("users");
|
|
1776
|
+
}
|
|
1777
|
+
ordered.push("notes");
|
|
1778
|
+
if (layers.tenancy === "rls") {
|
|
1779
|
+
ordered.push("tenant");
|
|
1780
|
+
}
|
|
1781
|
+
return ordered;
|
|
17
1782
|
}
|
|
18
|
-
function
|
|
19
|
-
const
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
1783
|
+
function renderFreshTs(layers) {
|
|
1784
|
+
const tables = dropTables(layers);
|
|
1785
|
+
const cascade = layers.database === "sqlite" ? "" : " CASCADE";
|
|
1786
|
+
return `${ensureImport(layers)}import { getSql } from "../bootstrap/database.ts";
|
|
1787
|
+
import { migrate, seed } from "./migrate.ts";
|
|
1788
|
+
|
|
1789
|
+
const tables = ${JSON.stringify(tables)};
|
|
1790
|
+
|
|
1791
|
+
export async function fresh() {
|
|
1792
|
+
${ensureCall(layers)} const sql = getSql();
|
|
1793
|
+
for (const table of tables) {
|
|
1794
|
+
await sql.unsafe(\`DROP TABLE IF EXISTS \${table}${cascade}\`);
|
|
23
1795
|
}
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
1796
|
+
await migrate();
|
|
1797
|
+
await seed();
|
|
1798
|
+
}
|
|
1799
|
+
|
|
1800
|
+
if (import.meta.main) {
|
|
1801
|
+
await fresh();
|
|
1802
|
+
console.log("Database reset, migrated, and seeded.");
|
|
1803
|
+
process.exit(0);
|
|
1804
|
+
}
|
|
1805
|
+
`;
|
|
1806
|
+
}
|
|
1807
|
+
function renderSeedTs() {
|
|
1808
|
+
return `import { seed } from "./migrate.ts";
|
|
1809
|
+
|
|
1810
|
+
export { seed };
|
|
1811
|
+
|
|
1812
|
+
if (import.meta.main) {
|
|
1813
|
+
await seed();
|
|
1814
|
+
console.log("Database seeded.");
|
|
1815
|
+
process.exit(0);
|
|
1816
|
+
}
|
|
1817
|
+
`;
|
|
1818
|
+
}
|
|
1819
|
+
function renderStatusTs(layers) {
|
|
1820
|
+
const tables = dropTables(layers);
|
|
1821
|
+
return `${ensureImport(layers)}import { getSql } from "../bootstrap/database.ts";
|
|
1822
|
+
|
|
1823
|
+
const tables = ${JSON.stringify(tables)};
|
|
1824
|
+
|
|
1825
|
+
export async function status() {
|
|
1826
|
+
${ensureCall(layers)} const sql = getSql();
|
|
1827
|
+
console.log("Starter schema (inline SQL, not a migration runner):");
|
|
1828
|
+
for (const table of tables) {
|
|
1829
|
+
try {
|
|
1830
|
+
const rows = await sql.unsafe<Array<{ count: string | number }>>(
|
|
1831
|
+
\`SELECT COUNT(*) AS count FROM \${table}\`,
|
|
1832
|
+
);
|
|
1833
|
+
console.log(\`- [present] \${table} (rows: \${rows[0]?.count ?? 0})\`);
|
|
1834
|
+
} catch {
|
|
1835
|
+
console.log(\`- [missing] \${table}\`);
|
|
1836
|
+
}
|
|
28
1837
|
}
|
|
1838
|
+
}
|
|
1839
|
+
|
|
1840
|
+
if (import.meta.main) {
|
|
1841
|
+
await status();
|
|
1842
|
+
process.exit(0);
|
|
1843
|
+
}
|
|
1844
|
+
`;
|
|
1845
|
+
}
|
|
1846
|
+
function renderRollbackTs(layers) {
|
|
1847
|
+
const tables = dropTables(layers);
|
|
1848
|
+
const cascade = layers.database === "sqlite" ? "" : " CASCADE";
|
|
1849
|
+
return `${ensureImport(layers)}import { getSql } from "../bootstrap/database.ts";
|
|
1850
|
+
|
|
1851
|
+
const tables = ${JSON.stringify(tables)};
|
|
1852
|
+
|
|
1853
|
+
export async function rollback() {
|
|
1854
|
+
${ensureCall(layers)} const sql = getSql();
|
|
1855
|
+
for (const table of tables) {
|
|
1856
|
+
await sql.unsafe(\`DROP TABLE IF EXISTS \${table}${cascade}\`);
|
|
1857
|
+
console.log(\`dropped \${table}\`);
|
|
1858
|
+
}
|
|
1859
|
+
}
|
|
1860
|
+
|
|
1861
|
+
if (import.meta.main) {
|
|
1862
|
+
await rollback();
|
|
1863
|
+
console.log("Rolled back starter tables.");
|
|
1864
|
+
process.exit(0);
|
|
1865
|
+
}
|
|
1866
|
+
`;
|
|
1867
|
+
}
|
|
1868
|
+
function renderPreloadTs(layers, projectName) {
|
|
1869
|
+
const database = appDatabaseName(projectName);
|
|
1870
|
+
const fallback = layers.database === "sqlite" ? "sqlite:./storage/app.sqlite" : layers.database === "mysql" ? `mysql://root:root@localhost:3306/${database}` : `postgresql://postgres:postgres@localhost:5432/${database}`;
|
|
1871
|
+
return `import { join } from "node:path";
|
|
1872
|
+
import { configureModulesDirectory } from "@getstrata/bootstrap/discoverModules";
|
|
1873
|
+
|
|
1874
|
+
process.env.DATABASE_URL ??= ${JSON.stringify(fallback)};
|
|
1875
|
+
process.env.FRONTEND_MODE ??= ${JSON.stringify(layers.frontend)};
|
|
1876
|
+
process.env.SPA_PREFIX ??= ${JSON.stringify(layers.spaPrefix)};
|
|
1877
|
+
process.env.CACHE_DRIVER ??= ${JSON.stringify(layers.cache)};
|
|
1878
|
+
process.env.QUEUE_DRIVER ??= ${JSON.stringify(layers.queue)};
|
|
1879
|
+
process.env.MAIL_DRIVER ??= ${JSON.stringify(layers.mail)};
|
|
1880
|
+
process.env.TENANCY_DRIVER ??= ${JSON.stringify(layers.tenancy)};
|
|
1881
|
+
configureModulesDirectory(join(import.meta.dir, "../modules"));
|
|
1882
|
+
`;
|
|
1883
|
+
}
|
|
1884
|
+
function renderConfigTs() {
|
|
1885
|
+
return `export interface AppConfig {
|
|
1886
|
+
port: number;
|
|
1887
|
+
appUrl: string;
|
|
1888
|
+
databaseUrl: string;
|
|
1889
|
+
}
|
|
1890
|
+
|
|
1891
|
+
export function loadConfig(): AppConfig {
|
|
1892
|
+
const databaseUrl = process.env.DATABASE_URL;
|
|
1893
|
+
if (!databaseUrl) {
|
|
1894
|
+
throw new Error("DATABASE_URL is required");
|
|
1895
|
+
}
|
|
1896
|
+
|
|
29
1897
|
return {
|
|
30
|
-
|
|
31
|
-
|
|
1898
|
+
port: Number(process.env.PORT ?? 3000),
|
|
1899
|
+
appUrl: process.env.APP_URL ?? "http://localhost:3000",
|
|
1900
|
+
databaseUrl,
|
|
32
1901
|
};
|
|
33
1902
|
}
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
1903
|
+
`;
|
|
1904
|
+
}
|
|
1905
|
+
function renderConfigProvider(layers) {
|
|
1906
|
+
return `import {
|
|
1907
|
+
APP_PORT_CONFIG_KEY,
|
|
1908
|
+
CORE_CONFIG_TOKEN,
|
|
1909
|
+
DATABASE_URL_CONFIG_KEY,
|
|
1910
|
+
REDIS_URL_CONFIG_KEY,
|
|
1911
|
+
} from "@getstrata/bootstrap/config";
|
|
1912
|
+
import type { ServiceProvider } from "@getstrata/core/contracts/di";
|
|
1913
|
+
import { loadConfig } from "../config.ts";
|
|
1914
|
+
|
|
1915
|
+
const configProvider: ServiceProvider = {
|
|
1916
|
+
name: "starter.config",
|
|
1917
|
+
register({ container, config }) {
|
|
1918
|
+
const appConfig = loadConfig();
|
|
1919
|
+
|
|
1920
|
+
container.set(CORE_CONFIG_TOKEN, config);
|
|
1921
|
+
config.set(DATABASE_URL_CONFIG_KEY, appConfig.databaseUrl);
|
|
1922
|
+
config.set(APP_PORT_CONFIG_KEY, appConfig.port);
|
|
1923
|
+
config.set(REDIS_URL_CONFIG_KEY, process.env.REDIS_URL ?? "");
|
|
1924
|
+
config.set("app.url", appConfig.appUrl);
|
|
1925
|
+
config.set("cache.driver", process.env.CACHE_DRIVER ?? "${layers.cache}");
|
|
1926
|
+
config.set("cache.ttlMs", 3_600_000);
|
|
1927
|
+
config.set("cache.maxEntries", 100);
|
|
1928
|
+
config.set("queue.driver", process.env.QUEUE_DRIVER ?? "${layers.queue}");
|
|
1929
|
+
},
|
|
1930
|
+
};
|
|
1931
|
+
|
|
1932
|
+
export default configProvider;
|
|
1933
|
+
`;
|
|
1934
|
+
}
|
|
1935
|
+
function renderQueueProvider() {
|
|
1936
|
+
return `import type { ServiceProvider } from "@getstrata/core/contracts/di";
|
|
1937
|
+
import { CORE_QUEUE_TOKEN } from "@getstrata/core/contracts/serviceTokens";
|
|
1938
|
+
import {
|
|
1939
|
+
createAppQueue,
|
|
1940
|
+
createFailedJobService,
|
|
1941
|
+
FAILED_JOB_SERVICE_TOKEN,
|
|
1942
|
+
} from "@getstrata/core/queue/createAppQueue";
|
|
1943
|
+
|
|
1944
|
+
const queueProvider: ServiceProvider = {
|
|
1945
|
+
name: "starter.queue",
|
|
1946
|
+
register({ container }) {
|
|
1947
|
+
const driver = (process.env.QUEUE_DRIVER ?? "sync") as "sync" | "async" | "redis";
|
|
1948
|
+
const failedJobs = createFailedJobService();
|
|
1949
|
+
container.set(FAILED_JOB_SERVICE_TOKEN, failedJobs);
|
|
1950
|
+
container.set(CORE_QUEUE_TOKEN, createAppQueue(driver, process.env.REDIS_URL, failedJobs));
|
|
1951
|
+
},
|
|
1952
|
+
};
|
|
1953
|
+
|
|
1954
|
+
export default queueProvider;
|
|
1955
|
+
`;
|
|
1956
|
+
}
|
|
1957
|
+
function renderEnsureDatabaseTs(layers, projectName) {
|
|
1958
|
+
if (layers.database === "sqlite") {
|
|
1959
|
+
return null;
|
|
1960
|
+
}
|
|
1961
|
+
const database = appDatabaseName(projectName);
|
|
1962
|
+
const fallback = layers.database === "mysql" ? `mysql://root:root@localhost:3306/${database}` : `postgresql://postgres:postgres@localhost:5432/${database}`;
|
|
1963
|
+
if (layers.database === "mysql") {
|
|
1964
|
+
return `const APP_DATABASE = ${JSON.stringify(database)};
|
|
1965
|
+
|
|
1966
|
+
function resolveAppDatabaseUrl(): string {
|
|
1967
|
+
const explicit = process.env.APP_DATABASE_URL?.trim();
|
|
1968
|
+
if (explicit) {
|
|
1969
|
+
return explicit;
|
|
1970
|
+
}
|
|
1971
|
+
|
|
1972
|
+
const base = process.env.DATABASE_URL?.trim() || ${JSON.stringify(fallback)};
|
|
1973
|
+
try {
|
|
1974
|
+
const url = new URL(base);
|
|
1975
|
+
url.pathname = \`/\${APP_DATABASE}\`;
|
|
1976
|
+
return url.toString();
|
|
1977
|
+
} catch {
|
|
1978
|
+
return base;
|
|
1979
|
+
}
|
|
1980
|
+
}
|
|
1981
|
+
|
|
1982
|
+
export async function ensureAppDatabase(): Promise<string> {
|
|
1983
|
+
const url = resolveAppDatabaseUrl();
|
|
1984
|
+
process.env.DATABASE_URL = url;
|
|
1985
|
+
return url;
|
|
1986
|
+
}
|
|
1987
|
+
`;
|
|
1988
|
+
}
|
|
1989
|
+
return `const APP_DATABASE = ${JSON.stringify(database)};
|
|
1990
|
+
|
|
1991
|
+
function resolveAppDatabaseUrl(): string {
|
|
1992
|
+
const explicit = process.env.APP_DATABASE_URL?.trim();
|
|
1993
|
+
if (explicit) {
|
|
1994
|
+
return explicit;
|
|
1995
|
+
}
|
|
1996
|
+
|
|
1997
|
+
const base = process.env.DATABASE_URL?.trim() || ${JSON.stringify(fallback)};
|
|
1998
|
+
try {
|
|
1999
|
+
const url = new URL(base);
|
|
2000
|
+
url.pathname = \`/\${APP_DATABASE}\`;
|
|
2001
|
+
return url.toString();
|
|
2002
|
+
} catch {
|
|
2003
|
+
return base;
|
|
2004
|
+
}
|
|
2005
|
+
}
|
|
2006
|
+
|
|
2007
|
+
function adminCandidateUrls(url: string): string[] {
|
|
2008
|
+
const names = ["postgres", "template1"];
|
|
2009
|
+
try {
|
|
2010
|
+
const current = decodeURIComponent(new URL(url).pathname.replace(/^\\//, ""));
|
|
2011
|
+
if (current && !names.includes(current)) {
|
|
2012
|
+
names.push(current);
|
|
43
2013
|
}
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
2014
|
+
} catch {
|
|
2015
|
+
// keep the built-in admin databases
|
|
2016
|
+
}
|
|
2017
|
+
return names.map((name) => {
|
|
2018
|
+
const admin = new URL(url);
|
|
2019
|
+
admin.pathname = \`/\${name}\`;
|
|
2020
|
+
return admin.toString();
|
|
2021
|
+
});
|
|
2022
|
+
}
|
|
2023
|
+
|
|
2024
|
+
async function openAdminConnection(url: string): Promise<Bun.SQL> {
|
|
2025
|
+
let lastError: unknown;
|
|
2026
|
+
for (const candidate of adminCandidateUrls(url)) {
|
|
2027
|
+
const adminSql = new Bun.SQL(candidate);
|
|
2028
|
+
try {
|
|
2029
|
+
await adminSql\`SELECT 1\`;
|
|
2030
|
+
return adminSql;
|
|
2031
|
+
} catch (error) {
|
|
2032
|
+
lastError = error;
|
|
2033
|
+
await adminSql.close().catch(() => undefined);
|
|
47
2034
|
}
|
|
48
|
-
writeFileSync(to, contents, { mode: info.mode & 511 });
|
|
49
2035
|
}
|
|
2036
|
+
throw lastError instanceof Error
|
|
2037
|
+
? lastError
|
|
2038
|
+
: new Error("Could not open an admin connection to create the app database.");
|
|
50
2039
|
}
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
const
|
|
56
|
-
if (!
|
|
57
|
-
|
|
58
|
-
process.exit(1);
|
|
2040
|
+
|
|
2041
|
+
export async function ensureAppDatabase(): Promise<string> {
|
|
2042
|
+
const url = resolveAppDatabaseUrl();
|
|
2043
|
+
const parsed = new URL(url);
|
|
2044
|
+
const name = decodeURIComponent(parsed.pathname.replace(/^\\//, ""));
|
|
2045
|
+
if (!name) {
|
|
2046
|
+
throw new Error("DATABASE_URL is missing a database name.");
|
|
59
2047
|
}
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
2048
|
+
|
|
2049
|
+
const identifier = name.replace(/[^A-Za-z0-9_]/g, "");
|
|
2050
|
+
if (identifier !== name) {
|
|
2051
|
+
throw new Error(\`Refusing to create a database with an unsafe name: \${name}\`);
|
|
2052
|
+
}
|
|
2053
|
+
|
|
2054
|
+
const adminSql = await openAdminConnection(url);
|
|
2055
|
+
try {
|
|
2056
|
+
const rows = await adminSql\`
|
|
2057
|
+
SELECT 1 AS ok FROM pg_database WHERE datname = \${name}
|
|
2058
|
+
\`;
|
|
2059
|
+
if (rows.length === 0) {
|
|
2060
|
+
await adminSql.unsafe(\`CREATE DATABASE \${identifier}\`);
|
|
2061
|
+
}
|
|
2062
|
+
} finally {
|
|
2063
|
+
await adminSql.close();
|
|
63
2064
|
}
|
|
64
|
-
|
|
2065
|
+
|
|
2066
|
+
process.env.DATABASE_URL = url;
|
|
2067
|
+
process.env.APP_DATABASE_URL = url;
|
|
2068
|
+
return url;
|
|
2069
|
+
}
|
|
2070
|
+
`;
|
|
2071
|
+
}
|
|
2072
|
+
function renderSidecarsTs(_layers) {
|
|
2073
|
+
return null;
|
|
2074
|
+
}
|
|
2075
|
+
function renderProvidersIndex() {
|
|
2076
|
+
return `import type { ServiceProvider } from "@getstrata/core/contracts/di";
|
|
2077
|
+
import authProvider from "./auth.ts";
|
|
2078
|
+
import cacheProvider from "./cache.ts";
|
|
2079
|
+
import configProvider from "./config.ts";
|
|
2080
|
+
import queueProvider from "./queue.ts";
|
|
2081
|
+
import storageProvider from "./storage.ts";
|
|
2082
|
+
|
|
2083
|
+
const starterProviders: ServiceProvider[] = [
|
|
2084
|
+
configProvider,
|
|
2085
|
+
cacheProvider,
|
|
2086
|
+
storageProvider,
|
|
2087
|
+
queueProvider,
|
|
2088
|
+
authProvider,
|
|
2089
|
+
];
|
|
2090
|
+
|
|
2091
|
+
export { starterProviders };
|
|
2092
|
+
`;
|
|
2093
|
+
}
|
|
2094
|
+
function renderCreateAppTs(layers) {
|
|
2095
|
+
const ensureLine = needsEnsure(layers) ? `import { ensureAppDatabase } from "./ensureDatabase.ts";
|
|
2096
|
+
` : "";
|
|
2097
|
+
return `import { join } from "node:path";
|
|
2098
|
+
import "./preload.ts";
|
|
2099
|
+
import { runProviderPhase } from "@getstrata/bootstrap/context";
|
|
2100
|
+
import {
|
|
2101
|
+
type AppContext,
|
|
2102
|
+
type AppDependencies,
|
|
2103
|
+
type AppRouteMap,
|
|
2104
|
+
assertAppDependenciesComplete,
|
|
2105
|
+
type ConfigStore,
|
|
2106
|
+
type MutableAppDependencies,
|
|
2107
|
+
type ProviderContext,
|
|
2108
|
+
ServiceContainer,
|
|
2109
|
+
} from "@getstrata/bootstrap/contracts";
|
|
2110
|
+
import { mergeSpaRoutes } from "@getstrata/bootstrap/createSpaRoutes";
|
|
2111
|
+
import {
|
|
2112
|
+
configureModulesDirectory,
|
|
2113
|
+
ensureModulesLoaded,
|
|
2114
|
+
} from "@getstrata/bootstrap/discoverModules";
|
|
2115
|
+
import { createHealthRoutes } from "@getstrata/bootstrap/health";
|
|
2116
|
+
import { createMetricsRoutes } from "@getstrata/bootstrap/metricsRoutes";
|
|
2117
|
+
import { assertProductionSecrets } from "@getstrata/bootstrap/secretsGuard";
|
|
2118
|
+
import { createWebServer } from "@getstrata/bootstrap/web/server";
|
|
2119
|
+
import { setActiveApplicationContext } from "@getstrata/core/runtime/applicationRegistry";
|
|
2120
|
+
import { migrate } from "../db/migrate.ts";
|
|
2121
|
+
import { buildRoutes } from "../routes.ts";
|
|
2122
|
+
import { loadConfig } from "./config.ts";
|
|
2123
|
+
import { getSql } from "./database.ts";
|
|
2124
|
+
${ensureLine}import { starterProviders } from "./providers/index.ts";
|
|
2125
|
+
|
|
2126
|
+
export interface BootstrapOptions {
|
|
2127
|
+
migrate?: boolean;
|
|
2128
|
+
}
|
|
2129
|
+
|
|
2130
|
+
export interface BootstrappedApp {
|
|
2131
|
+
context: AppContext;
|
|
2132
|
+
routes: AppRouteMap;
|
|
2133
|
+
config: ReturnType<typeof loadConfig>;
|
|
2134
|
+
}
|
|
2135
|
+
|
|
2136
|
+
class AppConfigStore {
|
|
2137
|
+
private readonly values = new Map<string, unknown>();
|
|
2138
|
+
|
|
2139
|
+
set<T>(key: string, value: T): T {
|
|
2140
|
+
this.values.set(key, value);
|
|
2141
|
+
return value;
|
|
2142
|
+
}
|
|
2143
|
+
|
|
2144
|
+
get<T>(key: string): T | undefined {
|
|
2145
|
+
return this.values.get(key) as T | undefined;
|
|
2146
|
+
}
|
|
2147
|
+
|
|
2148
|
+
require<T>(key: string): T {
|
|
2149
|
+
const value = this.get<T>(key);
|
|
2150
|
+
if (value === undefined) {
|
|
2151
|
+
throw new Error(\`Missing required config value "\${key}".\`);
|
|
2152
|
+
}
|
|
2153
|
+
return value;
|
|
2154
|
+
}
|
|
2155
|
+
|
|
2156
|
+
has(key: string): boolean {
|
|
2157
|
+
return this.values.has(key);
|
|
2158
|
+
}
|
|
2159
|
+
}
|
|
2160
|
+
|
|
2161
|
+
function createAppContext(): AppContext {
|
|
2162
|
+
const container = new ServiceContainer();
|
|
2163
|
+
const config = new AppConfigStore() as unknown as ConfigStore;
|
|
2164
|
+
const dependencies: MutableAppDependencies = { container };
|
|
2165
|
+
const context: ProviderContext = { container, config, dependencies };
|
|
2166
|
+
|
|
2167
|
+
runProviderPhase(starterProviders, "register", context);
|
|
2168
|
+
runProviderPhase(starterProviders, "boot", context);
|
|
2169
|
+
|
|
2170
|
+
assertAppDependenciesComplete(dependencies);
|
|
2171
|
+
|
|
2172
|
+
const appContext = { container, config, dependencies: dependencies as AppDependencies };
|
|
2173
|
+
setActiveApplicationContext(appContext as never);
|
|
2174
|
+
return appContext;
|
|
2175
|
+
}
|
|
2176
|
+
|
|
2177
|
+
export async function bootstrapApp(options: BootstrapOptions = {}): Promise<BootstrappedApp> {
|
|
2178
|
+
const { migrate: runMigrate = true } = options;
|
|
2179
|
+
|
|
2180
|
+
if (process.env.APP_ENV === "production") {
|
|
2181
|
+
assertProductionSecrets();
|
|
2182
|
+
}
|
|
2183
|
+
|
|
2184
|
+
${needsEnsure(layers) ? ` await ensureAppDatabase();
|
|
2185
|
+
` : ""} const appConfig = loadConfig();
|
|
2186
|
+
getSql();
|
|
2187
|
+
configureModulesDirectory(join(import.meta.dir, "../modules"));
|
|
2188
|
+
await ensureModulesLoaded();
|
|
2189
|
+
const context = createAppContext();
|
|
2190
|
+
|
|
2191
|
+
if (runMigrate) {
|
|
2192
|
+
await migrate();
|
|
2193
|
+
}
|
|
2194
|
+
|
|
2195
|
+
const routes = mergeSpaRoutes(context.dependencies, {
|
|
2196
|
+
...createHealthRoutes(context.dependencies),
|
|
2197
|
+
...buildRoutes(context.dependencies),
|
|
2198
|
+
...createMetricsRoutes(),
|
|
2199
|
+
}, {
|
|
2200
|
+
distDirectory: join(import.meta.dir, "../../frontend/dist"),
|
|
2201
|
+
});
|
|
2202
|
+
|
|
2203
|
+
return { context, routes, config: appConfig };
|
|
2204
|
+
}
|
|
2205
|
+
|
|
2206
|
+
export async function createApp(options: BootstrapOptions = {}) {
|
|
2207
|
+
return bootstrapApp({ migrate: false, ...options });
|
|
2208
|
+
}
|
|
2209
|
+
|
|
2210
|
+
export function createAppServer(routes: AppRouteMap, port = 0) {
|
|
2211
|
+
return createWebServer({
|
|
2212
|
+
port,
|
|
2213
|
+
publicDir: "./public",
|
|
2214
|
+
routes,
|
|
2215
|
+
});
|
|
2216
|
+
}
|
|
2217
|
+
|
|
2218
|
+
export { createAppContext };
|
|
2219
|
+
`;
|
|
2220
|
+
}
|
|
2221
|
+
function renderRoutesTs() {
|
|
2222
|
+
return `import { buildModuleRoutes } from "@getstrata/bootstrap/buildModuleRoutes";
|
|
2223
|
+
import { buildWebModuleRoutes } from "@getstrata/bootstrap/buildWebModuleRoutes";
|
|
2224
|
+
import type { AppDependencies, AppRouteMap } from "@getstrata/bootstrap/contracts";
|
|
2225
|
+
|
|
2226
|
+
export function buildRoutes(dependencies: AppDependencies): AppRouteMap {
|
|
2227
|
+
const api = buildModuleRoutes(dependencies);
|
|
2228
|
+
return {
|
|
2229
|
+
...api,
|
|
2230
|
+
...buildWebModuleRoutes(dependencies, { clearRegistry: false }),
|
|
2231
|
+
};
|
|
2232
|
+
}
|
|
2233
|
+
`;
|
|
2234
|
+
}
|
|
2235
|
+
function renderViewTs() {
|
|
2236
|
+
return `import { join } from "node:path";
|
|
2237
|
+
import { resolveCsrfTokenForRequest } from "@getstrata/core/http/csrfToken";
|
|
2238
|
+
import { EtaViewEngine, htmlResponse } from "@getstrata/core/view";
|
|
2239
|
+
|
|
2240
|
+
const engine = new EtaViewEngine(join(import.meta.dir, "../../views"));
|
|
2241
|
+
|
|
2242
|
+
export interface LayoutData {
|
|
2243
|
+
title: string;
|
|
2244
|
+
description?: string;
|
|
2245
|
+
}
|
|
2246
|
+
|
|
2247
|
+
export async function renderPage(
|
|
2248
|
+
template: string,
|
|
2249
|
+
data: Record<string, unknown> & { layout: LayoutData },
|
|
2250
|
+
request?: Request,
|
|
2251
|
+
): Promise<Response> {
|
|
2252
|
+
const csrfToken = request ? resolveCsrfTokenForRequest(request) : "";
|
|
2253
|
+
const html = await engine.render(template, { ...data, csrfToken });
|
|
2254
|
+
return htmlResponse(html);
|
|
2255
|
+
}
|
|
2256
|
+
|
|
2257
|
+
export function plainText(body: string): Response {
|
|
2258
|
+
return new Response(body, { headers: { "content-type": "text/plain; charset=utf-8" } });
|
|
2259
|
+
}
|
|
2260
|
+
`;
|
|
2261
|
+
}
|
|
2262
|
+
|
|
2263
|
+
// src/generate.ts
|
|
2264
|
+
var PROJECT_NAME_PATTERN = /^[a-z0-9][a-z0-9-_]*$/i;
|
|
2265
|
+
function starterPackageRoot() {
|
|
2266
|
+
return join2(import.meta.dir, "..");
|
|
2267
|
+
}
|
|
2268
|
+
function resolveTemplateRoot(root = starterPackageRoot()) {
|
|
2269
|
+
const fromDist = join2(root, "templates");
|
|
2270
|
+
if (existsSync2(join2(fromDist, "src"))) {
|
|
2271
|
+
return fromDist;
|
|
2272
|
+
}
|
|
2273
|
+
const nested = join2(root, "dist/templates");
|
|
2274
|
+
if (existsSync2(join2(nested, "src"))) {
|
|
2275
|
+
return nested;
|
|
2276
|
+
}
|
|
2277
|
+
return fromDist;
|
|
2278
|
+
}
|
|
2279
|
+
function resolveOverlayRoot(root = starterPackageRoot()) {
|
|
2280
|
+
const candidates = [
|
|
2281
|
+
join2(root, "templates/overlays"),
|
|
2282
|
+
join2(root, "dist/templates/overlays"),
|
|
2283
|
+
join2(root, "../../templates/scaffold")
|
|
2284
|
+
];
|
|
2285
|
+
for (const candidate of candidates) {
|
|
2286
|
+
if (existsSync2(join2(candidate, "server-htmx")) || existsSync2(join2(candidate, "spa-react")) || existsSync2(join2(candidate, "api"))) {
|
|
2287
|
+
return candidate;
|
|
2288
|
+
}
|
|
2289
|
+
}
|
|
2290
|
+
return candidates[0] ?? join2(root, "templates/overlays");
|
|
2291
|
+
}
|
|
2292
|
+
function assertProjectName(projectName) {
|
|
2293
|
+
if (!PROJECT_NAME_PATTERN.test(projectName)) {
|
|
2294
|
+
throw new Error("Project name must contain only letters, numbers, hyphens, and underscores.");
|
|
2295
|
+
}
|
|
2296
|
+
}
|
|
2297
|
+
function applyFrontendOverlays(overlayRoot, targetDir, layers) {
|
|
2298
|
+
if (layers.frontend === "hybrid" || layers.frontend === "spa-react") {
|
|
2299
|
+
copyOverlayTree(join2(overlayRoot, "spa-react"), targetDir);
|
|
2300
|
+
}
|
|
2301
|
+
if (layers.frontend === "api") {
|
|
2302
|
+
copyOverlayTree(join2(overlayRoot, "api"), targetDir);
|
|
2303
|
+
}
|
|
2304
|
+
}
|
|
2305
|
+
function writeGeneratedFiles(options) {
|
|
2306
|
+
const { targetDir, projectName, layers } = options;
|
|
2307
|
+
const src = join2(targetDir, "src");
|
|
2308
|
+
writeText(join2(targetDir, ".env.example"), renderEnvExample(projectName, layers));
|
|
2309
|
+
writeText(join2(targetDir, ".gitignore"), renderGitignore());
|
|
2310
|
+
writeText(join2(targetDir, "package.json"), renderPackageJson(projectName, { ...options, layers }));
|
|
2311
|
+
writeText(join2(targetDir, "README.md"), renderReadme(projectName, layers));
|
|
2312
|
+
writeText(join2(targetDir, "strata.layers.json"), renderLayersManifest(projectName, layers));
|
|
2313
|
+
const compose = renderDockerCompose(projectName, layers);
|
|
2314
|
+
if (compose) {
|
|
2315
|
+
writeText(join2(targetDir, "docker-compose.yml"), compose);
|
|
2316
|
+
} else {
|
|
2317
|
+
removeIfExists(join2(targetDir, "docker-compose.yml"));
|
|
2318
|
+
}
|
|
2319
|
+
writeText(join2(src, "routes.ts"), renderRoutesTs());
|
|
2320
|
+
writeText(join2(src, "lib/view.ts"), renderViewTs());
|
|
2321
|
+
writeText(join2(src, "bootstrap/config.ts"), renderConfigTs());
|
|
2322
|
+
writeText(join2(src, "bootstrap/preload.ts"), renderPreloadTs(layers, projectName));
|
|
2323
|
+
writeText(join2(src, "bootstrap/database.ts"), renderDatabaseTs(layers));
|
|
2324
|
+
const ensureDatabase = renderEnsureDatabaseTs(layers, projectName);
|
|
2325
|
+
if (ensureDatabase) {
|
|
2326
|
+
writeText(join2(src, "bootstrap/ensureDatabase.ts"), ensureDatabase);
|
|
2327
|
+
} else {
|
|
2328
|
+
removeIfExists(join2(src, "bootstrap/ensureDatabase.ts"));
|
|
2329
|
+
}
|
|
2330
|
+
writeText(join2(src, "bootstrap/createApp.ts"), renderCreateAppTs(layers));
|
|
2331
|
+
writeText(join2(src, "bootstrap/providers/config.ts"), renderConfigProvider(layers));
|
|
2332
|
+
writeText(join2(src, "bootstrap/providers/queue.ts"), renderQueueProvider());
|
|
2333
|
+
writeText(join2(src, "bootstrap/providers/index.ts"), renderProvidersIndex());
|
|
2334
|
+
writeText(join2(src, "bootstrap/providers/auth.ts"), renderAuthProvider(layers));
|
|
2335
|
+
writeText(join2(src, "db/migrate.ts"), renderMigrateTs(layers));
|
|
2336
|
+
writeText(join2(src, "db/fresh.ts"), renderFreshTs(layers));
|
|
2337
|
+
writeText(join2(src, "db/seed.ts"), renderSeedTs());
|
|
2338
|
+
writeText(join2(src, "db/status.ts"), renderStatusTs(layers));
|
|
2339
|
+
writeText(join2(src, "db/rollback.ts"), renderRollbackTs(layers));
|
|
2340
|
+
writeText(join2(src, "modules/site/index.ts"), renderSiteModule(layers));
|
|
2341
|
+
const directory = renderAuthDirectory(layers);
|
|
2342
|
+
if (directory) {
|
|
2343
|
+
writeText(join2(src, "bootstrap/authDirectory.ts"), directory);
|
|
2344
|
+
}
|
|
2345
|
+
const sidecars = renderSidecarsTs(layers);
|
|
2346
|
+
if (sidecars) {
|
|
2347
|
+
writeText(join2(src, "bootstrap/sidecars.ts"), sidecars);
|
|
2348
|
+
}
|
|
2349
|
+
const authModule = renderAuthModule(layers);
|
|
2350
|
+
if (authModule) {
|
|
2351
|
+
writeText(join2(src, "modules/auth/index.ts"), authModule);
|
|
2352
|
+
}
|
|
2353
|
+
writeText(join2(targetDir, "views/home.eta"), renderHomeView(projectName, layers));
|
|
2354
|
+
writeText(join2(targetDir, "views/layouts/app.eta"), renderLayout(layers, projectName));
|
|
2355
|
+
if (authUsesCookie(layers.auth)) {
|
|
2356
|
+
writeText(join2(targetDir, "views/auth/login.eta"), renderLoginView());
|
|
2357
|
+
}
|
|
2358
|
+
mkdirSync2(join2(targetDir, "storage"), { recursive: true });
|
|
2359
|
+
writeText(join2(targetDir, "storage/.gitkeep"), "");
|
|
2360
|
+
}
|
|
2361
|
+
function printNextSteps(projectName, layers, compose) {
|
|
2362
|
+
const dockerOn = selectedDockerServices(layers);
|
|
2363
|
+
const neededTools = neededDockerServices(layers);
|
|
2364
|
+
const localOn = neededTools.filter((name) => !dockerOn.includes(name));
|
|
2365
|
+
const dockerSummary = neededTools.length === 0 ? "none" : dockerOn.length > 0 ? dockerOn.join("+") : "local";
|
|
65
2366
|
console.log(`
|
|
66
|
-
Created Strata app in ${
|
|
2367
|
+
Created Strata app in ${projectName}/
|
|
67
2368
|
`);
|
|
68
|
-
console.log(
|
|
69
|
-
console.log(`
|
|
2369
|
+
console.log(`frontend=${layers.frontend} db=${layers.database} auth=${layers.auth} docker=${dockerSummary}`);
|
|
2370
|
+
console.log(`
|
|
2371
|
+
Next steps:`);
|
|
2372
|
+
console.log(` cd ${projectName}`);
|
|
70
2373
|
console.log(" cp .env.example .env");
|
|
71
|
-
|
|
2374
|
+
if (compose) {
|
|
2375
|
+
console.log(" docker compose up -d");
|
|
2376
|
+
}
|
|
2377
|
+
if (localOn.length > 0) {
|
|
2378
|
+
console.log(` Point env at local ${localOn.join(", ")} (see README).`);
|
|
2379
|
+
}
|
|
72
2380
|
console.log(" bun install");
|
|
73
2381
|
console.log(" strata migrate");
|
|
74
2382
|
console.log(` strata dev
|
|
75
2383
|
`);
|
|
76
2384
|
}
|
|
77
|
-
|
|
2385
|
+
function generateProject(options) {
|
|
2386
|
+
assertProjectName(options.projectName);
|
|
2387
|
+
if (existsSync2(options.targetDir)) {
|
|
2388
|
+
if (!options.force) {
|
|
2389
|
+
throw new Error(`Directory already exists: ${options.targetDir}`);
|
|
2390
|
+
}
|
|
2391
|
+
rmSync2(options.targetDir, { recursive: true, force: true });
|
|
2392
|
+
}
|
|
2393
|
+
copyTree(options.templateRoot, options.targetDir, options.projectName, new Set(["overlays"]));
|
|
2394
|
+
applyFrontendOverlays(options.overlayRoot, options.targetDir, options.layers);
|
|
2395
|
+
writeGeneratedFiles(options);
|
|
2396
|
+
}
|
|
2397
|
+
async function runCreateStrata(argv, cwd = process.cwd()) {
|
|
2398
|
+
let flags;
|
|
2399
|
+
try {
|
|
2400
|
+
flags = parseCreateStrataArgs(argv);
|
|
2401
|
+
} catch (error) {
|
|
2402
|
+
console.error(error instanceof Error ? error.message : error);
|
|
2403
|
+
return 1;
|
|
2404
|
+
}
|
|
2405
|
+
if (flags.help) {
|
|
2406
|
+
console.log(usage());
|
|
2407
|
+
return 0;
|
|
2408
|
+
}
|
|
2409
|
+
try {
|
|
2410
|
+
const plan = await resolveStarterPlan(flags);
|
|
2411
|
+
const targetDir = resolve(cwd, plan.projectName);
|
|
2412
|
+
generateProject({
|
|
2413
|
+
projectName: plan.projectName,
|
|
2414
|
+
targetDir,
|
|
2415
|
+
layers: plan.layers,
|
|
2416
|
+
templateRoot: resolveTemplateRoot(),
|
|
2417
|
+
overlayRoot: resolveOverlayRoot(),
|
|
2418
|
+
force: flags.force
|
|
2419
|
+
});
|
|
2420
|
+
printNextSteps(plan.projectName, plan.layers, renderDockerCompose(plan.projectName, plan.layers) !== null);
|
|
2421
|
+
return 0;
|
|
2422
|
+
} catch (error) {
|
|
2423
|
+
console.error(error instanceof Error ? error.message : error);
|
|
2424
|
+
return 1;
|
|
2425
|
+
}
|
|
2426
|
+
}
|
|
2427
|
+
|
|
2428
|
+
// cli.ts
|
|
2429
|
+
var code = await runCreateStrata(process.argv.slice(2));
|
|
2430
|
+
process.exit(code);
|