@dunx/create-app 0.4.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (65) hide show
  1. package/dist/chunk-jgd5dmqh.js +698 -0
  2. package/dist/chunk-jgd5dmqh.js.map +12 -0
  3. package/dist/cli.js +68 -5
  4. package/dist/cli.js.map +3 -3
  5. package/dist/features.d.ts +74 -0
  6. package/dist/generate.d.ts +21 -0
  7. package/dist/index.js +1 -1
  8. package/dist/scaffold.d.ts +12 -1
  9. package/package.json +1 -1
  10. package/templates/base/_gitignore +5 -0
  11. package/templates/base/tsconfig.json +19 -0
  12. package/templates/features/auth/audit.service.ts +36 -0
  13. package/templates/features/auth/auth.demo.ts +173 -0
  14. package/templates/features/auth/auth.module.ts +54 -0
  15. package/templates/features/auth/auth.tables.ts +84 -0
  16. package/templates/features/auth/profile.controller.ts +37 -0
  17. package/templates/features/cache/cache.controller.ts +85 -0
  18. package/templates/features/cache/cache.module.ts +36 -0
  19. package/templates/features/cache/sessions.service.ts +90 -0
  20. package/templates/features/chat/chat.demo.ts +184 -0
  21. package/templates/features/chat/chat.gateway.ts +85 -0
  22. package/templates/features/chat/chat.module.ts +11 -0
  23. package/templates/features/chat/lobby.service.ts +20 -0
  24. package/templates/features/database/auth.schema.ts +75 -0
  25. package/templates/features/database/database.module.ts +39 -0
  26. package/templates/features/database/ledger.controller.ts +137 -0
  27. package/templates/features/database/ledger.service.ts +257 -0
  28. package/templates/features/database/schema.ts +27 -0
  29. package/templates/features/database/seeds/0001_ledger.seeder.ts +12 -0
  30. package/templates/features/database/seeds/0002_production_audit.seeder.ts +13 -0
  31. package/templates/features/docs/docs.demo.ts +130 -0
  32. package/templates/features/docs/docs.module.ts +9 -0
  33. package/templates/features/guards/auth.guard.ts +66 -0
  34. package/templates/features/guards/guards.demo.ts +75 -0
  35. package/templates/features/guards/guards.module.ts +16 -0
  36. package/templates/features/guards/reports.controller.ts +60 -0
  37. package/templates/features/guards/reports.service.ts +22 -0
  38. package/templates/features/health/health.controller.ts +65 -0
  39. package/templates/features/health/health.module.ts +5 -0
  40. package/templates/features/http/http.demo.ts +115 -0
  41. package/templates/features/http/http.module.ts +10 -0
  42. package/templates/features/http/request-log.ts +37 -0
  43. package/templates/features/jobs/jobs.controller.ts +102 -0
  44. package/templates/features/jobs/jobs.module.ts +35 -0
  45. package/templates/features/jobs/thumbnail.jobs.ts +53 -0
  46. package/templates/features/notes/notes.controller.ts +65 -0
  47. package/templates/features/notes/notes.module.ts +9 -0
  48. package/templates/features/notes/notes.service.ts +21 -0
  49. package/templates/features/pictures/images.controller.ts +74 -0
  50. package/templates/features/pictures/pictures.module.ts +22 -0
  51. package/templates/features/pictures/thumbnails.service.ts +108 -0
  52. package/templates/features/storage/files.controller.ts +130 -0
  53. package/templates/features/storage/storage.module.ts +21 -0
  54. package/templates/features/storage/uploads.service.ts +66 -0
  55. package/templates/features/storage/workspace.ts +33 -0
  56. package/templates/features/users/users.controller.ts +47 -0
  57. package/templates/features/users/users.demo.ts +62 -0
  58. package/templates/features/users/users.module.ts +11 -0
  59. package/templates/features/users/users.repository.ts +59 -0
  60. package/templates/features/users/users.schemas.ts +68 -0
  61. package/templates/features/users/users.service.ts +46 -0
  62. package/templates/minimal/_bunfig.toml +7 -0
  63. package/dist/chunk-rnjjb0bq.js +0 -69
  64. package/dist/chunk-rnjjb0bq.js.map +0 -10
  65. /package/templates/{minimal/bunfig.toml → base/_bunfig.toml} +0 -0
@@ -0,0 +1,698 @@
1
+ // @bun
2
+ // src/features.ts
3
+ var CONFIG_GROUPS = Object.freeze({
4
+ port: {
5
+ schema: [
6
+ "PORT: z.coerce.number().int().min(0).max(65535).default(3000),"
7
+ ],
8
+ field: "readonly port: number;",
9
+ map: "port: value.PORT,",
10
+ env: [{ name: "PORT", value: "3000" }]
11
+ },
12
+ appName: {
13
+ schema: [],
14
+ field: "readonly appName: string;",
15
+ map: "appName: '__DUNX_APP_NAME__',",
16
+ env: []
17
+ },
18
+ log: {
19
+ schema: [
20
+ "LOG_LEVEL: z.enum(LogLevel).default(LogLevel.INFO),",
21
+ "/** Unset means console only. Set it to also append JSON to a rotating file. */",
22
+ "LOG_FILE: z.string().optional(),"
23
+ ],
24
+ field: "readonly log: { readonly level: LogLevel; readonly file: string | undefined };",
25
+ map: "log: { level: value.LOG_LEVEL, file: value.LOG_FILE },",
26
+ env: [{ name: "LOG_LEVEL", value: "info" }]
27
+ },
28
+ corsOrigin: {
29
+ schema: ["CORS_ORIGIN: z.string().default('https://example.com'),"],
30
+ field: "readonly corsOrigin: string;",
31
+ map: "corsOrigin: value.CORS_ORIGIN,",
32
+ env: [{ name: "CORS_ORIGIN", value: "https://example.com" }]
33
+ },
34
+ database: {
35
+ schema: [
36
+ "/** `:memory:` needs no server and leaves nothing behind, so restarts are clean. */",
37
+ "DATABASE_FILE: z.string().default(':memory:'),"
38
+ ],
39
+ field: "readonly database: { readonly file: string };",
40
+ map: "database: { file: value.DATABASE_FILE },",
41
+ env: [{ name: "DATABASE_FILE", value: ":memory:" }]
42
+ },
43
+ redis: {
44
+ schema: [
45
+ "/** Absent is fine: the cache routes report themselves degraded instead of failing. */",
46
+ "REDIS_URL: z.string().optional(),"
47
+ ],
48
+ field: "readonly redis: { readonly url: string | undefined };",
49
+ map: "redis: { url: value.REDIS_URL },",
50
+ env: [{ name: "REDIS_URL", value: "redis://localhost:6379" }]
51
+ },
52
+ images: {
53
+ schema: [
54
+ "IMAGE_QUALITY: z.coerce.number().int().min(1).max(100).default(82),"
55
+ ],
56
+ field: "readonly images: { readonly quality: number };",
57
+ map: "images: { quality: value.IMAGE_QUALITY },",
58
+ env: [{ name: "IMAGE_QUALITY", value: "82" }]
59
+ },
60
+ auth: {
61
+ schema: [
62
+ "/** better-auth signs session cookies with this. 32 characters is its own minimum. */",
63
+ "AUTH_SECRET: z.string().min(32).default('dunx-development-secret-not-for-production'),"
64
+ ],
65
+ field: "readonly auth: { readonly secret: string };",
66
+ map: "auth: { secret: value.AUTH_SECRET },",
67
+ env: [
68
+ {
69
+ name: "AUTH_SECRET",
70
+ value: "change-me-to-at-least-32-characters-long"
71
+ }
72
+ ]
73
+ },
74
+ seedUsers: {
75
+ schema: [],
76
+ field: "readonly seedUsers: readonly string[];",
77
+ map: "seedUsers: ['ada', 'grace'],",
78
+ env: []
79
+ },
80
+ authorization: {
81
+ schema: [],
82
+ field: "readonly authorization: { readonly enabled: boolean };",
83
+ map: "authorization: { enabled: true },",
84
+ env: []
85
+ }
86
+ });
87
+ var BASE_CONFIG = ["appName", "port", "log"];
88
+ var FEATURES = [
89
+ {
90
+ name: "notes",
91
+ source: "notes",
92
+ summary: "CRUD routes with zod validation. The smallest real feature.",
93
+ requires: [],
94
+ module: { klass: "NotesModule", from: "./notes/notes.module.js" },
95
+ dependencies: ["zod"],
96
+ config: []
97
+ },
98
+ {
99
+ name: "openapi",
100
+ source: "docs",
101
+ summary: "OpenAPI 3.1 from the routes own schemas, plus the explorer page.",
102
+ requires: [],
103
+ module: { klass: "DocsModule", from: "./docs/docs.module.js" },
104
+ dependencies: ["@dunx/openapi", "zod"],
105
+ config: []
106
+ },
107
+ {
108
+ name: "http",
109
+ source: "http",
110
+ summary: "CORS, a request-logging middleware and error mapping.",
111
+ requires: [],
112
+ module: { klass: "HttpModule", from: "./http/http.module.js" },
113
+ dependencies: [],
114
+ config: ["corsOrigin"]
115
+ },
116
+ {
117
+ name: "guards",
118
+ source: "guards",
119
+ summary: "Route guards with @Roles and @Public, and a protected controller.",
120
+ requires: [],
121
+ module: { klass: "GuardsModule", from: "./guards/guards.module.js" },
122
+ dependencies: ["zod"],
123
+ config: ["authorization"]
124
+ },
125
+ {
126
+ name: "database",
127
+ source: "database",
128
+ summary: "drizzle over bun:sqlite, with a schema, seeds and migrations.",
129
+ requires: [],
130
+ module: { klass: "DatabaseModule", from: "./database/database.module.js" },
131
+ dependencies: ["@dunx/infra", "drizzle-orm"],
132
+ config: ["database"]
133
+ },
134
+ {
135
+ name: "users",
136
+ source: "users",
137
+ summary: "A repository, a service and validated routes over the database.",
138
+ requires: ["database"],
139
+ module: { klass: "UsersModule", from: "./users/users.module.js" },
140
+ dependencies: ["@dunx/infra", "drizzle-orm", "zod"],
141
+ config: ["appName", "seedUsers"]
142
+ },
143
+ {
144
+ name: "auth",
145
+ source: "auth",
146
+ summary: "better-auth mounted, with SessionGuard and an audit trail.",
147
+ requires: ["database"],
148
+ module: { klass: "AccountsModule", from: "./auth/auth.module.js" },
149
+ dependencies: ["@dunx/auth", "better-auth", "drizzle-orm"],
150
+ config: ["auth", "port"]
151
+ },
152
+ {
153
+ name: "cache",
154
+ source: "cache",
155
+ summary: "Bun.RedisClient behind a session store, degrading when absent.",
156
+ requires: [],
157
+ module: { klass: "CacheModule", from: "./cache/cache.module.js" },
158
+ dependencies: ["@dunx/infra"],
159
+ config: ["redis"],
160
+ service: "Redis or Valkey"
161
+ },
162
+ {
163
+ name: "websockets",
164
+ source: "chat",
165
+ summary: "A @Gateway with @OnMessage events, PubSub and a Redis relay.",
166
+ requires: [],
167
+ module: { klass: "ChatModule", from: "./chat/chat.module.js" },
168
+ dependencies: ["@dunx/infra"],
169
+ config: [],
170
+ service: "Redis or Valkey, for multi-node fan-out only"
171
+ },
172
+ {
173
+ name: "images",
174
+ source: "pictures",
175
+ summary: "Bun.Image resizing and format conversion behind a route.",
176
+ requires: [],
177
+ module: { klass: "PicturesModule", from: "./pictures/pictures.module.js" },
178
+ dependencies: ["@dunx/infra"],
179
+ config: ["images"]
180
+ },
181
+ {
182
+ name: "files",
183
+ source: "storage",
184
+ summary: "Uploads and downloads on Bun.file, with a workspace root.",
185
+ requires: [],
186
+ module: { klass: "StorageModule", from: "./storage/storage.module.js" },
187
+ dependencies: ["@dunx/infra"],
188
+ config: []
189
+ },
190
+ {
191
+ name: "jobs",
192
+ source: "jobs",
193
+ summary: "bullmq queues and a worker, over Bun.RedisClient.",
194
+ requires: ["images"],
195
+ module: { klass: "JobsModule", from: "./jobs/jobs.module.js" },
196
+ dependencies: ["@dunx/infra", "bullmq", "ioredis"],
197
+ config: ["redis"],
198
+ service: "Redis or Valkey"
199
+ },
200
+ {
201
+ name: "health",
202
+ source: "health",
203
+ summary: "One endpoint reporting which parts are live and which degraded.",
204
+ requires: ["cache", "database"],
205
+ module: { klass: "HealthModule", from: "./health/health.module.js" },
206
+ dependencies: ["@dunx/infra"],
207
+ config: ["appName"]
208
+ }
209
+ ];
210
+ var featureNames = FEATURES.map((feature) => feature.name);
211
+ var byName = new Map(FEATURES.map((feature) => [feature.name, feature]));
212
+
213
+ class UnknownFeatureError extends Error {
214
+ name = "UnknownFeatureError";
215
+ }
216
+ var resolveFeatures = (requested) => {
217
+ const unknown = requested.filter((name) => !byName.has(name));
218
+ if (unknown.length > 0) {
219
+ throw new UnknownFeatureError(`Unknown feature${unknown.length === 1 ? "" : "s"}: ${unknown.join(", ")}. ` + `Available: ${featureNames.join(", ")}.`);
220
+ }
221
+ const ordered = [];
222
+ const seen = new Set;
223
+ const rank = new Map(FEATURES.map((feature, at) => [feature.name, at]));
224
+ const visit = (name) => {
225
+ if (seen.has(name))
226
+ return;
227
+ seen.add(name);
228
+ const feature = byName.get(name);
229
+ if (!feature)
230
+ return;
231
+ for (const required of [...feature.requires].sort((left, right) => (rank.get(left) ?? 0) - (rank.get(right) ?? 0))) {
232
+ visit(required);
233
+ }
234
+ ordered.push(feature);
235
+ };
236
+ for (const feature of FEATURES) {
237
+ if (requested.includes(feature.name))
238
+ visit(feature.name);
239
+ }
240
+ return ordered;
241
+ };
242
+ var impliedBy = (requested, resolved) => resolved.map((feature) => feature.name).filter((name) => !requested.includes(name));
243
+
244
+ // src/scaffold.ts
245
+ import { existsSync, readdirSync } from "fs";
246
+ import { basename, dirname, join, resolve } from "path";
247
+ import { fileURLToPath } from "url";
248
+ var {Glob } = globalThis.Bun;
249
+
250
+ // src/generate.ts
251
+ var HEADER = (name) => `// Generated by @dunx/create-app for ${name}. Yours to edit.
252
+ `;
253
+ var uniq = (values) => [...new Set(values)];
254
+ var configGroupsFor = (features) => {
255
+ const wanted = uniq([
256
+ ...BASE_CONFIG,
257
+ ...features.flatMap((feature) => feature.config)
258
+ ]);
259
+ return Object.keys(CONFIG_GROUPS).filter((group) => wanted.includes(group));
260
+ };
261
+ var dependenciesFor = (features) => uniq([
262
+ "@dunx/core",
263
+ "@dunx/http",
264
+ "@dunx/transform",
265
+ "@dunx/infra",
266
+ ...features.flatMap((feature) => feature.dependencies)
267
+ ]).sort();
268
+ var DUNX = /^@dunx\//;
269
+ var manifest = (features) => {
270
+ const deps = dependenciesFor(features);
271
+ const dependencies = {};
272
+ for (const dep of deps) {
273
+ dependencies[dep] = DUNX.test(dep) ? "__DUNX_VERSION__" : versionOf(dep);
274
+ }
275
+ const scripts = {
276
+ start: "bun src/main.ts",
277
+ test: "bun test",
278
+ typecheck: "tsc --noEmit"
279
+ };
280
+ if (features.some((feature) => feature.name === "jobs")) {
281
+ scripts["worker"] = "bun src/worker.ts";
282
+ }
283
+ return `${JSON.stringify({
284
+ name: "__DUNX_APP_NAME__",
285
+ version: "0.1.0",
286
+ private: true,
287
+ type: "module",
288
+ scripts,
289
+ dependencies,
290
+ devDependencies: {
291
+ "@dunx/testing": "__DUNX_VERSION__",
292
+ "@types/bun": ">=1.3.0",
293
+ typescript: "^5.7.0"
294
+ },
295
+ engines: { bun: ">=1.3.0" }
296
+ }, null, 2)}
297
+ `;
298
+ };
299
+ var THIRD_PARTY = Object.freeze({
300
+ zod: "^4.4.3",
301
+ "drizzle-orm": "^0.45.2",
302
+ "better-auth": "^1.6.25",
303
+ bullmq: "^6.0.5",
304
+ ioredis: "^6.0.0"
305
+ });
306
+ var versionOf = (dep) => THIRD_PARTY[dep] ?? "latest";
307
+ var appModule = (name, features) => {
308
+ const needsLogger = true;
309
+ const imports = [
310
+ "import { ConfigModule, Module } from '@dunx/core';",
311
+ ...needsLogger ? ["import { LoggerModule } from '@dunx/infra/logger';"] : [],
312
+ "import { AppConfigService, validate } from './config.js';",
313
+ ...features.map((feature) => `import { ${feature.module.klass} } from '${feature.module.from}';`)
314
+ ];
315
+ const moduleImports = [
316
+ "ConfigModule.forRoot({ validate, as: AppConfigService }),",
317
+ "// The level comes from the validated config, which is the one thing a",
318
+ "// zero-argument `forRoot` function cannot reach.",
319
+ "LoggerModule.forRootAsync(",
320
+ " {",
321
+ " useFactory: (config: AppConfigService) => ({",
322
+ " name: config.get('appName'),",
323
+ " level: config.get('log').level,",
324
+ " }),",
325
+ " inject: [AppConfigService] as const,",
326
+ " },",
327
+ " { captureGlobalErrors: true },",
328
+ "),",
329
+ ...features.map((feature) => `${feature.module.klass},`)
330
+ ];
331
+ return `${HEADER(name)}${imports.join(`
332
+ `)}
333
+
334
+ /**
335
+ * Import order is construction order, and shutdown runs in reverse - so config and
336
+ * the logger are built first and torn down last, and anything a feature depends on
337
+ * outlives it.
338
+ */
339
+ @Module({
340
+ imports: [
341
+ ${moduleImports.map((line) => ` ${line}`).join(`
342
+ `)}
343
+ ],
344
+ })
345
+ export class AppModule {}
346
+ `;
347
+ };
348
+ var config = (name, groups) => {
349
+ const chosen = groups.map((group) => [group, CONFIG_GROUPS[group]]).filter((entry) => entry[1] !== undefined);
350
+ const schema = chosen.flatMap(([, group]) => group.schema);
351
+ const needsLogLevel = groups.includes("log");
352
+ return `${HEADER(name)}import { ConfigService, type ConfigSource${needsLogLevel ? ", LogLevel" : ""} } from '@dunx/core';
353
+ import { z } from 'zod';
354
+
355
+ /**
356
+ * One validation function, which is the whole \`ConfigModule\` contract. dunx does
357
+ * not pick the library - this is zod because the routes already use it, and a
358
+ * hand-written function that throws would work identically.
359
+ *
360
+ * \`.default()\` is where a value comes from when the variable is unset, so a clean
361
+ * checkout boots with no \`.env\` at all. Bun loads \`.env\` and \`.env.local\` itself,
362
+ * so there is nothing here that reads a file.
363
+ */
364
+ const envSchema = z.object({
365
+ ${schema.map((line) => ` ${line}`).join(`
366
+ `)}
367
+ });
368
+
369
+ export interface AppConfig {
370
+ ${chosen.map(([, group]) => ` ${group.field}`).join(`
371
+ `)}
372
+ }
373
+
374
+ /**
375
+ * One name for the typed config everywhere. A subclass rather than
376
+ * \`ConfigService<AppConfig>\` at each site because a factory's \`inject: [...]\`
377
+ * carries no type argument - the class does, and it is a real runtime value, so it
378
+ * is both a precise token and a usable constructor annotation.
379
+ */
380
+ export class AppConfigService extends ConfigService<AppConfig> {}
381
+
382
+ /** The one broker channel the websocket relay carries every topic on. */
383
+ export const RELAY_CHANNEL = '__DUNX_APP_NAME__:ws';
384
+
385
+ /** Flat variables in, a shaped object out. Nothing downstream reads \`Bun.env\`. */
386
+ export const validate = (env: ConfigSource): AppConfig => {
387
+ const parsed = envSchema.safeParse(env);
388
+ if (!parsed.success) {
389
+ const issues = parsed.error.issues
390
+ .map((issue) => \`\${issue.path.join('.') || '(root)'}: \${issue.message}\`)
391
+ .join('\\n - ');
392
+ throw new Error(\`Configuration is invalid:\\n - \${issues}\`);
393
+ }
394
+ const value = parsed.data;
395
+
396
+ return {
397
+ ${chosen.map(([, group]) => ` ${group.map}`).join(`
398
+ `)}
399
+ };
400
+ };
401
+ `;
402
+ };
403
+ var has = (features, name) => features.some((feature) => feature.name === name);
404
+ var bootstrap = (name, features) => {
405
+ const openapi = has(features, "openapi");
406
+ const websockets = has(features, "websockets");
407
+ const http = has(features, "http");
408
+ const imports = [
409
+ `import { HttpFactory${websockets ? ", RedisRelay" : ""}, type HttpApp } from '@dunx/http';`,
410
+ ...openapi ? ["import { OpenApiModule } from '@dunx/openapi';"] : [],
411
+ "import { AppModule } from './app.module.js';",
412
+ `import { ${[
413
+ ...http ? ["AppConfigService"] : [],
414
+ ...websockets ? ["RELAY_CHANNEL"] : []
415
+ ].join(", ")} } from './config.js';`,
416
+ ...http ? ["import { RequestLoggerMiddleware } from './http/request-log.js';"] : []
417
+ ].filter((line) => !line.includes("{ }"));
418
+ const root = openapi ? `OpenApiModule.forRoot({
419
+ title: '__DUNX_APP_NAME__',
420
+ version: '0.1.0',
421
+ root: AppModule,
422
+ })` : "AppModule";
423
+ const options = websockets ? [
424
+ "// Multi-node websocket fan-out on `Bun.RedisClient`, so it costs no",
425
+ "// dependency. With no Redis running this degrades to single-process",
426
+ "// behaviour, logs one warning, and the app still boots.",
427
+ "websocket: { idleTimeout: 30 },",
428
+ "relay: new RedisRelay({ connectionTimeout: 500 }),",
429
+ "relayChannel: RELAY_CHANNEL,"
430
+ ] : [];
431
+ const shaping = [
432
+ "app.setGlobalPrefix('api');",
433
+ ...http ? [
434
+ "app.use(RequestLoggerMiddleware);",
435
+ "app.set('trust proxy', true);",
436
+ "app.enableCors({",
437
+ " origin: app.get(AppConfigService).get('corsOrigin'),",
438
+ " credentials: true,",
439
+ " maxAge: 600,",
440
+ "});"
441
+ ] : []
442
+ ];
443
+ return `${HEADER(name)}${imports.join(`
444
+ `)}
445
+
446
+ /**
447
+ * One app, built the same way for \`bun start\` and for the tests - so what the
448
+ * tests exercise is what actually serves.
449
+ *
450
+ * \`create()\` boots the container and discovers routes and gateways; \`listen()\` is
451
+ * what builds the \`Bun.serve\` route table. Everything between the two still gets to
452
+ * shape it, and after \`listen()\` every one of those throws.
453
+ */
454
+ export const createApp = async (): Promise<HttpApp> => {
455
+ const app = await HttpFactory.create(
456
+ ${root}${options.length === 0 ? `,
457
+ ` : `,
458
+ {
459
+ ${options.map((line) => ` ${line}`).join(`
460
+ `)}
461
+ },
462
+ `} );
463
+
464
+ ${shaping.map((line) => ` ${line}`).join(`
465
+ `)}
466
+
467
+ return app;
468
+ };
469
+ `;
470
+ };
471
+ var main = (name, features) => {
472
+ const health = has(features, "health");
473
+ const openapi = has(features, "openapi");
474
+ const lines = [
475
+ ...openapi ? [
476
+ "logger.info(`docs ${new URL('api/docs', url).href}`);",
477
+ "logger.info(`openapi ${new URL('api/openapi.json', url).href}`);"
478
+ ] : [],
479
+ ...health ? ["logger.info(`health ${new URL('api/health', url).href}`);"] : []
480
+ ];
481
+ return `${HEADER(name)}import { Logger } from '@dunx/core';
482
+ import { createApp } from './bootstrap.js';
483
+ import { AppConfigService } from './config.js';
484
+
485
+ async function bootstrap(): Promise<void> {
486
+ const app = await createApp();
487
+ app.enableShutdownHooks();
488
+
489
+ const config = app.get(AppConfigService);
490
+ const logger = app.get(Logger);
491
+ const url = await app.listen(config.get('port'));
492
+
493
+ logger.info(\`listening on \${url}\`);
494
+ ${lines.map((line) => ` ${line}`).join(`
495
+ `)}${lines.length > 0 ? `
496
+ ` : ""}
497
+ // Nothing else to do: the server holds the process open, and the shutdown hooks
498
+ // resolve this once a signal arrives.
499
+ await app.closed;
500
+ }
501
+
502
+ bootstrap().catch((error: unknown) => {
503
+ console.error('failed to start', error);
504
+ process.exit(1);
505
+ });
506
+ `;
507
+ };
508
+ var worker = (name) => `${HEADER(name)}import { AppFactory } from '@dunx/core';
509
+ import { AppModule } from './app.module.js';
510
+
511
+ /**
512
+ * A queue needs a process to drain it, and it is deliberately not the web one: a
513
+ * worker that shares the server's event loop competes with request handling.
514
+ */
515
+ const app = await AppFactory.create(AppModule);
516
+ app.enableShutdownHooks();
517
+ await app.closed;
518
+ `;
519
+ var envExample = (groups) => {
520
+ const lines = groups.flatMap((group) => CONFIG_GROUPS[group]?.env ?? []).map((entry) => `${entry.name}=${entry.value}`);
521
+ return lines.length === 0 ? `# Every variable has a default, so this file is optional.
522
+ ` : `# Every variable here has a default, so the app boots with no .env at all.
523
+ ${lines.join(`
524
+ `)}
525
+ `;
526
+ };
527
+ var readme = (name, features) => {
528
+ const services = features.filter((feature) => feature.service !== undefined);
529
+ return `# ${name}
530
+
531
+ Scaffolded with \`bunx @dunx/create-app\`.
532
+
533
+ \`\`\`bash
534
+ bun install
535
+ bun run start
536
+ \`\`\`
537
+
538
+ ## What is wired up
539
+
540
+ ${features.length === 0 ? "Nothing beyond the base app." : features.map((feature) => `- **${feature.name}** - ${feature.summary}`).join(`
541
+ `)}
542
+
543
+ ${services.length === 0 ? "" : `## Services
544
+
545
+ These features need something running. Each one degrades rather than failing the
546
+ boot, so the app still starts without them.
547
+
548
+ ${services.map((feature) => `- **${feature.name}** needs ${feature.service}`).join(`
549
+ `)}
550
+
551
+ `}## Layout
552
+
553
+ - \`src/main.ts\` - the entry point
554
+ - \`src/bootstrap.ts\` - builds the app; shared by \`start\` and the tests
555
+ - \`src/app.module.ts\` - the root module, importing every feature
556
+ - \`src/config.ts\` - one validation function, flat env in and a shaped object out
557
+ ${features.map((feature) => `- \`src/${feature.source}/\` - ${feature.name}`).join(`
558
+ `)}
559
+
560
+ \`main.ts\`, \`bootstrap.ts\`, \`app.module.ts\` and \`config.ts\` were generated for the
561
+ features you chose; everything else is copied from dunx's \`examples/full\`, which is
562
+ run and toured in CI on every push. The \`*.demo.ts\` files are that example's
563
+ scripted walkthroughs - delete one and its \`providers\` entry when you do not want it.
564
+
565
+ ## Constructor injection
566
+
567
+ \`bunfig.toml\` preloads \`@dunx/transform\`, which records each class's constructor
568
+ parameter types so the container can resolve them. Without that line providers are
569
+ built with no arguments and boot fails saying so.
570
+ `;
571
+ };
572
+
573
+ // src/scaffold.ts
574
+ var TEMPLATES = Object.freeze(["minimal"]);
575
+ var VERSION_PLACEHOLDER = "__DUNX_VERSION__";
576
+ var RENAMED = Object.freeze({
577
+ _gitignore: ".gitignore",
578
+ "_bunfig.toml": "bunfig.toml"
579
+ });
580
+ var IGNORED_WHEN_EMPTY = new Set([
581
+ ".DS_Store",
582
+ ".git",
583
+ ".gitkeep",
584
+ "LICENSE"
585
+ ]);
586
+
587
+ class ScaffoldError extends Error {
588
+ name = "ScaffoldError";
589
+ }
590
+ var templatesRoot = () => resolve(dirname(fileURLToPath(import.meta.url)), "..", "templates");
591
+ var isValidPackageName = (name) => /^(?:@[a-z0-9-*~][a-z0-9-*._~]*\/)?[a-z0-9-~][a-z0-9-._~]*$/.test(name);
592
+ var readPackageVersion = async () => {
593
+ const file = Bun.file(join(templatesRoot(), "..", "package.json"));
594
+ const json = await file.json();
595
+ return json.version ?? "0.0.0";
596
+ };
597
+ var fill = (contents, name, version) => contents.replaceAll(VERSION_PLACEHOLDER, version).replaceAll("__DUNX_APP_NAME__", name);
598
+ var generated = (name, features) => {
599
+ const groups = configGroupsFor(features);
600
+ const files = {
601
+ "package.json": manifest(features),
602
+ "README.md": readme(name, features),
603
+ ".env.example": envExample(groups),
604
+ "src/main.ts": main(name, features),
605
+ "src/bootstrap.ts": bootstrap(name, features),
606
+ "src/app.module.ts": appModule(name, features),
607
+ "src/config.ts": config(name, groups)
608
+ };
609
+ if (features.some((feature) => feature.name === "jobs")) {
610
+ files["src/worker.ts"] = worker(name);
611
+ }
612
+ return files;
613
+ };
614
+ var scaffold = async (options) => {
615
+ const template = options.template ?? "minimal";
616
+ if (!TEMPLATES.includes(template)) {
617
+ throw new ScaffoldError(`Unknown template "${template}". Available: ${TEMPLATES.join(", ")}.`);
618
+ }
619
+ const requested = options.features ?? [];
620
+ let features = [];
621
+ try {
622
+ features = resolveFeatures(requested);
623
+ } catch (error) {
624
+ throw new ScaffoldError(error instanceof Error ? error.message : String(error));
625
+ }
626
+ const composing = features.length > 0;
627
+ const directory = resolve(options.cwd ?? process.cwd(), options.target);
628
+ const name = options.name ?? basename(directory);
629
+ if (!isValidPackageName(name)) {
630
+ throw new ScaffoldError(`"${name}" is not a usable package name. Pass --name to choose one.`);
631
+ }
632
+ if (existsSync(directory) && options.force !== true) {
633
+ const blocking = readdirSync(directory).filter((entry) => !IGNORED_WHEN_EMPTY.has(entry));
634
+ if (blocking.length > 0) {
635
+ const shown = blocking.sort().slice(0, 3).join(", ");
636
+ const rest = blocking.length > 3 ? `, +${blocking.length - 3} more` : "";
637
+ throw new ScaffoldError(`${directory} is not empty (${shown}${rest}). ` + `Pass --force to write into it anyway.`);
638
+ }
639
+ }
640
+ const version = options.version ?? `^${await readPackageVersion()}`;
641
+ const written = [];
642
+ const copyTree = async (from, into) => {
643
+ for await (const relative of new Glob("**/*").scan({
644
+ cwd: from,
645
+ dot: true,
646
+ onlyFiles: true
647
+ })) {
648
+ const base2 = relative.split("/").at(-1) ?? relative;
649
+ const renamed = RENAMED[base2];
650
+ const target = join(into, renamed === undefined ? relative : join(dirname(relative), renamed));
651
+ const contents = await Bun.file(join(from, relative)).text();
652
+ await Bun.write(join(directory, target), fill(contents, name, version));
653
+ written.push(target);
654
+ }
655
+ };
656
+ if (!composing) {
657
+ const source = join(templatesRoot(), template);
658
+ if (!existsSync(source)) {
659
+ throw new ScaffoldError(`Template "${template}" is missing from ${source}.`);
660
+ }
661
+ await copyTree(source, ".");
662
+ return {
663
+ directory,
664
+ name,
665
+ template,
666
+ features: [],
667
+ files: written.sort()
668
+ };
669
+ }
670
+ const base = join(templatesRoot(), "base");
671
+ if (!existsSync(base)) {
672
+ throw new ScaffoldError(`The base template is missing from ${base}. Run \`bun run sync:templates\`.`);
673
+ }
674
+ await copyTree(base, ".");
675
+ for (const feature of features) {
676
+ const from = join(templatesRoot(), "features", feature.source);
677
+ if (!existsSync(from)) {
678
+ throw new ScaffoldError(`Feature "${feature.name}" is missing from ${from}. ` + "Run `bun run sync:templates`.");
679
+ }
680
+ await copyTree(from, join("src", feature.source));
681
+ }
682
+ for (const [target, contents] of Object.entries(generated(name, features))) {
683
+ await Bun.write(join(directory, target), fill(contents, name, version));
684
+ written.push(target);
685
+ }
686
+ return {
687
+ directory,
688
+ name,
689
+ template: "composed",
690
+ features: features.map((feature) => feature.name),
691
+ files: written.sort()
692
+ };
693
+ };
694
+
695
+ export { FEATURES, featureNames, impliedBy, TEMPLATES, VERSION_PLACEHOLDER, ScaffoldError, scaffold };
696
+
697
+ //# debugId=ABE556EA5FD94D1364756E2164756E21
698
+ //# sourceMappingURL=chunk-jgd5dmqh.js.map