@getstrata/starter 0.1.8 → 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.
Files changed (54) hide show
  1. package/README.md +9 -22
  2. package/dist/cli.js +2400 -47
  3. package/dist/templates/overlays/api/docs/API.md +9 -0
  4. package/dist/templates/overlays/server-htmx/public/assets/app.css +89 -0
  5. package/dist/templates/overlays/server-htmx/resources/views/errors/error.eta +14 -0
  6. package/dist/templates/overlays/server-htmx/resources/views/errors/forbidden.eta +4 -0
  7. package/dist/templates/overlays/server-htmx/resources/views/errors/not-found.eta +4 -0
  8. package/dist/templates/overlays/server-htmx/resources/views/layouts/app.eta +34 -0
  9. package/dist/templates/overlays/server-htmx/resources/views/organizations/_table.eta +23 -0
  10. package/dist/templates/overlays/server-htmx/resources/views/organizations/index.eta +37 -0
  11. package/dist/templates/overlays/server-htmx/resources/views/pages/home.eta +4 -0
  12. package/dist/templates/overlays/server-htmx/resources/views/partials/_flash.eta +3 -0
  13. package/dist/templates/overlays/spa-react/frontend/build.ts +17 -0
  14. package/dist/templates/overlays/spa-react/frontend/bun-env.d.ts +4 -0
  15. package/dist/templates/overlays/spa-react/frontend/bun.lock +51 -0
  16. package/dist/templates/overlays/spa-react/frontend/dev-server.ts +66 -0
  17. package/dist/templates/overlays/spa-react/frontend/index.html +12 -0
  18. package/dist/templates/overlays/spa-react/frontend/package.json +21 -0
  19. package/dist/templates/overlays/spa-react/frontend/src/App.tsx +80 -0
  20. package/dist/templates/overlays/spa-react/frontend/src/api/client.ts +86 -0
  21. package/dist/templates/overlays/spa-react/frontend/src/app.css +98 -0
  22. package/dist/templates/overlays/spa-react/frontend/src/auth/AuthContext.tsx +103 -0
  23. package/dist/templates/overlays/spa-react/frontend/src/auth/tokenStorage.ts +15 -0
  24. package/dist/templates/overlays/spa-react/frontend/src/main.tsx +22 -0
  25. package/dist/templates/overlays/spa-react/frontend/src/pages/LoginPage.tsx +67 -0
  26. package/dist/templates/overlays/spa-react/frontend/src/pages/NotFoundPage.tsx +12 -0
  27. package/dist/templates/overlays/spa-react/frontend/src/pages/OrganizationsPage.tsx +69 -0
  28. package/dist/templates/overlays/spa-react/frontend/src/pages/ProjectsPage.tsx +65 -0
  29. package/dist/templates/overlays/spa-react/frontend/src/pages/TasksPage.tsx +65 -0
  30. package/dist/templates/{templates → overlays/spa-react/frontend}/tsconfig.json +9 -6
  31. package/dist/templates/package.json +1 -1
  32. package/package.json +3 -3
  33. package/dist/templates/templates/.env.example +0 -22
  34. package/dist/templates/templates/docker-compose.yml +0 -14
  35. package/dist/templates/templates/package.json +0 -22
  36. package/dist/templates/templates/public/assets/site.css +0 -29
  37. package/dist/templates/templates/src/bootstrap/config.ts +0 -18
  38. package/dist/templates/templates/src/bootstrap/createApp.ts +0 -93
  39. package/dist/templates/templates/src/bootstrap/database.ts +0 -30
  40. package/dist/templates/templates/src/bootstrap/preload.ts +0 -5
  41. package/dist/templates/templates/src/bootstrap/providers/auth.ts +0 -41
  42. package/dist/templates/templates/src/bootstrap/providers/cache.ts +0 -28
  43. package/dist/templates/templates/src/bootstrap/providers/config.ts +0 -27
  44. package/dist/templates/templates/src/bootstrap/providers/index.ts +0 -14
  45. package/dist/templates/templates/src/bootstrap/providers/storage.ts +0 -11
  46. package/dist/templates/templates/src/bootstrap/server.ts +0 -25
  47. package/dist/templates/templates/src/db/fresh.ts +0 -19
  48. package/dist/templates/templates/src/db/migrate.ts +0 -35
  49. package/dist/templates/templates/src/lib/view.ts +0 -21
  50. package/dist/templates/templates/src/modules/site/index.ts +0 -29
  51. package/dist/templates/templates/src/routes.ts +0 -6
  52. package/dist/templates/templates/strata.config.ts +0 -7
  53. package/dist/templates/templates/views/home.eta +0 -5
  54. 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
- // cli.ts
5
- import { existsSync, mkdirSync, readdirSync, readFileSync, statSync, writeFileSync } from "fs";
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
- console.log(`Usage: create-strata [project-name]
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
- Scaffold a new Strata application with Bun, @getstrata/core, and @getstrata/bootstrap.
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 parseArgs(argv) {
19
- const positional = argv.filter((arg) => !arg.startsWith("-"));
20
- if (argv.includes("-h") || argv.includes("--help")) {
21
- usage();
22
- process.exit(0);
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
- const projectName = positional[0] ?? "strata-app";
25
- if (!/^[a-z0-9][a-z0-9-_]*$/i.test(projectName)) {
26
- console.error("Project name must contain only letters, numbers, hyphens, and underscores.");
27
- process.exit(1);
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
- projectName,
31
- targetDir: resolve(process.cwd(), projectName)
1898
+ port: Number(process.env.PORT ?? 3000),
1899
+ appUrl: process.env.APP_URL ?? "http://localhost:3000",
1900
+ databaseUrl,
32
1901
  };
33
1902
  }
34
- function copyTemplate(source, target, projectName) {
35
- mkdirSync(target, { recursive: true });
36
- for (const entry of readdirSync(source)) {
37
- const from = join(source, entry);
38
- const to = join(target, entry.replace(PLACEHOLDER, projectName));
39
- const info = statSync(from);
40
- if (info.isDirectory()) {
41
- copyTemplate(from, to, projectName);
42
- continue;
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
- let contents = readFileSync(from, "utf8");
45
- if (contents.includes("{{PROJECT_NAME}}")) {
46
- contents = contents.replace(PLACEHOLDER, projectName);
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
- function main() {
52
- const options = parseArgs(process.argv.slice(2));
53
- if (!options)
54
- process.exit(1);
55
- const templateDir = join(import.meta.dir, "templates");
56
- if (!existsSync(templateDir)) {
57
- console.error("Template directory missing. Reinstall create-strata.");
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
- if (existsSync(options.targetDir)) {
61
- console.error(`Directory already exists: ${options.targetDir}`);
62
- process.exit(1);
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
- copyTemplate(templateDir, options.targetDir, options.projectName);
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 ${options.projectName}/
2367
+ Created Strata app in ${projectName}/
67
2368
  `);
68
- console.log("Next steps:");
69
- console.log(` cd ${options.projectName}`);
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
- console.log(" docker compose up -d");
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
- main();
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);