@dunx/create-app 3.0.3 → 3.0.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -13,25 +13,52 @@ bunx @dunx/create-app my-api
13
13
  ```
14
14
  cd my-api
15
15
  bun install
16
- bun run start # http://localhost:3000/greetings
16
+ bun run dev # http://localhost:3000/greetings, restarting on a change
17
17
  ```
18
18
 
19
+ ## The questions
20
+
21
+ There is no flag for choosing features. The command opens a list:
22
+
23
+ ```
24
+ ? Features 2 chosen, 1 pulled in
25
+ ○ notes CRUD routes with zod validation. The smallest real feature.
26
+ ◉ openapi OpenAPI 3.1 from the routes own schemas, plus the Swagger UI page.
27
+ ◈ database drizzle over bun:sqlite, with a schema, seeds and migrations.
28
+ ❯ ◉ users A repository, a service and validated routes over the database.
29
+ ○ auth better-auth mounted, with SessionGuard and an audit trail.
30
+ database comes along as a requirement.
31
+ Space toggles. ↑↓ moves. a all, n none. Enter continues.
32
+ ```
33
+
34
+ | Key | Does |
35
+ | --------------- | --------------------------------------------- |
36
+ | `↑` `↓`, `k` `j`, Tab | Move the cursor |
37
+ | Space | Toggle the feature under it |
38
+ | `a` / `n` | Everything / nothing |
39
+ | Enter | Take the selection |
40
+ | Ctrl+C, Esc | Stop, having written nothing |
41
+
42
+ `◉` is chosen, `◈` is pulled in by something else you chose, `○` is neither. The
43
+ two lines under the list update as you go: what your selection drags in, and which
44
+ of it needs Redis or Postgres running to do anything.
45
+
46
+ Three more questions appear only when there is something to ask: a directory, when
47
+ the command line named none; a package name, when the directory's is one npm would
48
+ reject; and whether to write into a directory that already has files in it.
49
+
19
50
  ## Options
20
51
 
21
- | Flag | Default | Meaning |
22
- | ------------------- | ------------------ | -------------------------------------------------- |
23
- | `--name <name>` | the directory name | Package name for the generated app |
24
- | `--with <a,b,c>` | | Features to compose the app from |
25
- | `--all` | off | Every feature |
26
- | `--list` | | Print the features and exit |
27
- | `--template <name>` | `minimal` | Which template to write |
28
- | `--force` | off | Write into a directory that already has files |
29
- | `--yes`, `-y` | off | Take the minimal template without prompting |
30
- | `--help` | | Print usage |
31
-
32
- With neither `--with` nor `--yes`, and a terminal attached, it lists the features
33
- and reads one line of stdin. Piped or in CI it never prompts, so **an agent or a
34
- script should pass `--yes` or `--with`**.
52
+ | Flag | Default | Meaning |
53
+ | --------------- | ------------------ | --------------------------------------------- |
54
+ | `--name <name>` | the directory name | Package name for the generated app |
55
+ | `--force` | off | Write into a directory that already has files |
56
+ | `--yes`, `-y` | off | Skip the questions, take the minimal template |
57
+ | `--help` | | Print usage |
58
+
59
+ **Piped, redirected or in CI it asks nothing** and writes the minimal template, so
60
+ a script never hangs on a question nothing can answer. To choose features without
61
+ a terminal, call [`scaffold`](#programmatic-use) rather than passing flags.
35
62
 
36
63
  The name is validated against npm's rules **before** anything is created, because
37
64
  an invalid one would otherwise surface as a confusing `bun install` failure inside
@@ -45,9 +72,31 @@ bunx @dunx/create-app .
45
72
  ```
46
73
 
47
74
  `.git`, `.gitkeep`, `.DS_Store` and `LICENSE` do not count as contents, so a fresh
48
- repo or a clone of an empty GitHub repository is a valid target without `--force`.
75
+ repo or a clone of an empty GitHub repository is a valid target without a question.
49
76
  Nothing else is ignored: `.gitignore` and `README.md` both come out of the template,
50
- and overwriting your copy of either is what `--force` is there to ask about.
77
+ and overwriting your copy of either is what the last question asks about.
78
+
79
+ ## What a composed app looks like
80
+
81
+ ```
82
+ my-api/
83
+ src/
84
+ main.ts exports createApp, and serves it when run directly
85
+ app.module.ts the root module, importing every feature
86
+ config.ts one validation function, flat env in and a shaped object out
87
+ users/ one directory per feature you chose
88
+ package.json dev, start, test, typecheck
89
+ bunfig.toml the transform preload
90
+ ```
91
+
92
+ Three files are generated for the selection and the feature directories are copied.
93
+ `main.ts` is one file rather than two: a test imports `createApp` from it, and the
94
+ `import.meta.main` block at the bottom is what stops that starting a server.
95
+
96
+ **There is no worker entry point, even with queues.** `QueueModule` is given
97
+ `consume: true`, so the container opens the bullmq workers at `onInit` and closes
98
+ them before the connections the handlers use. A handler marked `background: true` is
99
+ forked by bullmq itself into `src/jobs/jobs.processor.ts`.
51
100
 
52
101
  ## What it generates
53
102
 
@@ -92,9 +141,14 @@ import { scaffold } from '@dunx/create-app';
92
141
  const { directory, files } = await scaffold({
93
142
  target: 'my-api',
94
143
  name: '@acme/my-api',
144
+ features: ['users', 'openapi'],
95
145
  });
96
146
  ```
97
147
 
148
+ This is the scripted path the removed `--with` flag used to be. `features` takes
149
+ the same names the list shows, in any order, and pulls in what they require;
150
+ `FEATURES` exports the set. Omitting it writes the minimal template.
151
+
98
152
  `scaffold` throws `ScaffoldError` for anything the caller can fix - an unknown
99
153
  template, an unusable package name, a non-empty target without `force` - and lets
100
154
  everything else propagate.
@@ -0,0 +1,14 @@
1
+ import type { Style } from './style.js';
2
+ /**
3
+ * The header the questions run under.
4
+ *
5
+ * Printed once, before the first prompt, and only when there is a terminal to
6
+ * print it into: a logo piped into a log file is noise. A terminal too narrow for
7
+ * the logo gets the one-line form rather than a wrapped one, which is what
8
+ * `Bun.stringWidth` is measured against.
9
+ */
10
+ export declare class Banner {
11
+ #private;
12
+ constructor(style: Style);
13
+ lines(width: number, version: string): readonly string[];
14
+ }
@@ -221,7 +221,7 @@ var FEATURES = [
221
221
  {
222
222
  name: "jobs",
223
223
  source: "jobs",
224
- summary: "bullmq queues and a worker, over Bun.RedisClient.",
224
+ summary: "bullmq queues over Bun.RedisClient, background handlers forked.",
225
225
  requires: ["images"],
226
226
  module: { klass: "JobsModule", from: "./jobs/jobs.module.js" },
227
227
  dependencies: ["@dunx/infra", "bullmq", "ioredis", "zod"],
@@ -354,18 +354,20 @@ var layout = (features) => features.length === 0 ? `- \`src/main.ts\` - the entr
354
354
  - \`src/greetings.controller.ts\`, \`src/greetings.service.ts\` - one route and its provider
355
355
  - \`src/app.test.ts\` - the whole app behind a real server on port 0
356
356
  - \`bunfig.toml\` - the preload line constructor injection needs
357
- ` : `- \`src/main.ts\` - the entry point
358
- - \`src/bootstrap.ts\` - builds the app; shared by \`start\` and the tests
357
+ ` : `- \`src/main.ts\` - exports \`createApp\`, and serves it when run directly
359
358
  - \`src/app.module.ts\` - the root module, importing every feature
360
359
  - \`src/config.ts\` - one validation function, flat env in and a shaped object out
361
360
  ${features.map((feature) => `- \`src/${feature.source}/\` - ${feature.name}`).join(`
362
361
  `)}
363
362
  - \`bunfig.toml\` - the preload line constructor injection needs
364
363
 
365
- \`main.ts\`, \`bootstrap.ts\`, \`app.module.ts\` and \`config.ts\` were generated for the
366
- features chosen at scaffold time. Everything else was copied from dunx's
367
- \`examples/full\`. The \`*.demo.ts\` files are that example's scripted walkthroughs;
368
- delete one and its \`providers\` entry to drop it.
364
+ \`main.ts\`, \`app.module.ts\` and \`config.ts\` were generated for the features chosen
365
+ at scaffold time. Everything else was copied from dunx's \`examples/full\`. The
366
+ \`*.demo.ts\` files are that example's scripted walkthroughs; delete one and its
367
+ \`providers\` entry to drop it.
368
+
369
+ A test imports \`createApp\` from \`./main.js\`; the \`import.meta.main\` block at the
370
+ bottom is what stops that starting a server.
369
371
  `;
370
372
  var agents = (name, features) => {
371
373
  const services = features.filter((feature) => feature.service !== undefined);
@@ -380,11 +382,16 @@ Notes for an agent working in this application. It is a
380
382
 
381
383
  \`\`\`bash
382
384
  bun install
383
- bun run start # http://localhost:3000
385
+ bun run dev # http://localhost:3000, restarting on a change
386
+ bun run start
384
387
  bun test
385
388
  bun run typecheck
386
- ${hasJobs ? `bun run worker # drains the queues; the web process does not
387
- ` : ""}\`\`\`
389
+ \`\`\`${hasJobs ? `
390
+
391
+ **There is no worker command.** \`QueueModule\` is given \`consume: true\`, so the
392
+ container opens the workers at \`onInit\` and closes them before the connections they
393
+ use. A handler marked \`background: true\` is forked by bullmq into
394
+ \`src/jobs/jobs.processor.ts\`, which nobody runs by hand.` : ""}
388
395
 
389
396
  ## Layout
390
397
 
@@ -450,13 +457,11 @@ var manifest = (features) => {
450
457
  dependencies[dep] = DUNX.test(dep) ? "__DUNX_VERSION__" : versionOf(dep);
451
458
  }
452
459
  const scripts = {
460
+ dev: "bun --watch src/main.ts",
453
461
  start: "bun src/main.ts",
454
462
  test: "bun test",
455
463
  typecheck: "tsc --noEmit"
456
464
  };
457
- if (features.some((feature) => feature.name === "jobs")) {
458
- scripts["worker"] = "bun src/worker.ts";
459
- }
460
465
  return `${JSON.stringify({
461
466
  name: "__DUNX_APP_NAME__",
462
467
  version: "0.1.0",
@@ -583,15 +588,17 @@ ${chosen.map(([, group]) => ` ${group.map}`).join(`
583
588
  `;
584
589
  };
585
590
  var has = (features, name) => features.some((feature) => feature.name === name);
586
- var bootstrap = (name, features) => {
591
+ var main = (name, features) => {
587
592
  const openapi = has(features, "openapi");
588
593
  const websockets = has(features, "websockets");
589
594
  const http = has(features, "http");
595
+ const health = has(features, "health");
590
596
  const documentsAuth = openapi && has(features, "auth");
591
597
  const assets = has(features, "assets");
592
598
  const throttle = has(features, "throttle");
593
599
  const imports = [
594
600
  ...documentsAuth ? ["import { Auth, betterAuthDocument } from '@dunx/auth';"] : [],
601
+ "import { Logger } from '@dunx/core';",
595
602
  `import { ${[
596
603
  "HttpFactory",
597
604
  ...websockets ? ["RedisRelay"] : [],
@@ -601,12 +608,9 @@ var bootstrap = (name, features) => {
601
608
  ].join(", ")} } from '@dunx/http';`,
602
609
  ...openapi ? ["import { OpenApiModule } from '@dunx/openapi';"] : [],
603
610
  "import { AppModule } from './app.module.js';",
604
- `import { ${[
605
- ...http ? ["AppConfigService"] : [],
606
- ...websockets ? ["RELAY_CHANNEL"] : []
607
- ].join(", ")} } from './config.js';`,
611
+ `import { ${["AppConfigService", ...websockets ? ["RELAY_CHANNEL"] : []].join(", ")} } from './config.js';`,
608
612
  ...http ? ["import { RequestTrailMiddleware } from './http/request-trail.js';"] : []
609
- ].filter((line) => !line.includes("{ }"));
613
+ ];
610
614
  const root = documentsAuth ? `OpenApiModule.forRootAsync({
611
615
  root: AppModule,
612
616
  inject: [Auth] as const,
@@ -644,12 +648,20 @@ var bootstrap = (name, features) => {
644
648
  ] : [],
645
649
  ...throttle ? ["app.use(ThrottleGuard);"] : []
646
650
  ];
651
+ const urls = [
652
+ ...openapi ? [
653
+ "logger.info(`docs ${new URL('api/docs', url).href}`);",
654
+ "logger.info(`openapi ${new URL('api/openapi.json', url).href}`);"
655
+ ] : [],
656
+ ...health ? ["logger.info(`health ${new URL('api/health', url).href}`);"] : []
657
+ ];
647
658
  return `${HEADER(name)}${imports.join(`
648
659
  `)}
649
660
 
650
661
  /**
651
- * One app, built the same way for \`bun start\` and for the tests - so what the
652
- * tests exercise is what actually serves.
662
+ * One app for \`bun run start\`, \`bun run dev\` and the tests, and one file:
663
+ * \`createApp\` is exported for a caller that wants the shape without a server, and
664
+ * the block at the bottom serves it when this file is the entry point.
653
665
  *
654
666
  * \`create()\` boots the container and discovers routes and gateways; \`listen()\` is
655
667
  * what builds the \`Bun.serve\` route table. Everything between the two still gets to
@@ -670,23 +682,8 @@ ${shaping.map((line) => ` ${line}`).join(`
670
682
 
671
683
  return app;
672
684
  };
673
- `;
674
- };
675
- var main = (name, features) => {
676
- const health = has(features, "health");
677
- const openapi = has(features, "openapi");
678
- const lines = [
679
- ...openapi ? [
680
- "logger.info(`docs ${new URL('api/docs', url).href}`);",
681
- "logger.info(`openapi ${new URL('api/openapi.json', url).href}`);"
682
- ] : [],
683
- ...health ? ["logger.info(`health ${new URL('api/health', url).href}`);"] : []
684
- ];
685
- return `${HEADER(name)}import { Logger } from '@dunx/core';
686
- import { createApp } from './bootstrap.js';
687
- import { AppConfigService } from './config.js';
688
685
 
689
- async function bootstrap(): Promise<void> {
686
+ const start = async (): Promise<void> => {
690
687
  const app = await createApp();
691
688
  app.enableShutdownHooks();
692
689
 
@@ -695,31 +692,24 @@ async function bootstrap(): Promise<void> {
695
692
  const url = await app.listen(config.get('port'));
696
693
 
697
694
  logger.info(\`listening on \${url}\`);
698
- ${lines.map((line) => ` ${line}`).join(`
699
- `)}${lines.length > 0 ? `
695
+ ${urls.map((line) => ` ${line}`).join(`
696
+ `)}${urls.length > 0 ? `
700
697
  ` : ""}
701
698
  // Nothing else to do: the server holds the process open, and the shutdown hooks
702
699
  // resolve this once a signal arrives.
703
700
  await app.closed;
704
- }
705
-
706
- bootstrap().catch((error: unknown) => {
707
- console.error('failed to start', error);
708
- process.exit(1);
709
- });
710
- `;
711
701
  };
712
- var worker = (name) => `${HEADER(name)}import { AppFactory } from '@dunx/core';
713
- import { AppModule } from './app.module.js';
714
702
 
715
- /**
716
- * A queue needs a process to drain it, and it is deliberately not the web one: a
717
- * worker that shares the server's event loop competes with request handling.
718
- */
719
- const app = await AppFactory.create(AppModule);
720
- app.enableShutdownHooks();
721
- await app.closed;
703
+ // False when a test imports this file for \`createApp\` alone, which is what lets one
704
+ // module be both the entry point and the app's definition.
705
+ if (import.meta.main) {
706
+ start().catch((error: unknown) => {
707
+ console.error('failed to start', error);
708
+ process.exit(1);
709
+ });
710
+ }
722
711
  `;
712
+ };
723
713
  var envExample = (groups) => {
724
714
  const lines = groups.flatMap((group) => CONFIG_GROUPS[group]?.env ?? []).map((entry) => `${entry.name}=${entry.value}`);
725
715
  return lines.length === 0 ? `# Every variable has a default, so this file is optional.
@@ -736,6 +726,7 @@ Scaffolded with \`bunx @dunx/create-app\`.
736
726
 
737
727
  \`\`\`bash
738
728
  bun install
729
+ bun run dev # restarts on a change
739
730
  bun run start
740
731
  \`\`\`
741
732
 
@@ -754,17 +745,19 @@ ${services.map((feature) => `- **${feature.name}** needs ${feature.service}`).jo
754
745
 
755
746
  `}## Layout
756
747
 
757
- - \`src/main.ts\` - the entry point
758
- - \`src/bootstrap.ts\` - builds the app; shared by \`start\` and the tests
748
+ - \`src/main.ts\` - exports \`createApp\`, and serves it when run directly
759
749
  - \`src/app.module.ts\` - the root module, importing every feature
760
750
  - \`src/config.ts\` - one validation function, flat env in and a shaped object out
761
751
  ${features.map((feature) => `- \`src/${feature.source}/\` - ${feature.name}`).join(`
762
752
  `)}
763
753
 
764
- \`main.ts\`, \`bootstrap.ts\`, \`app.module.ts\` and \`config.ts\` were generated for the
765
- features you chose; everything else is copied from dunx's \`examples/full\`, which is
766
- run and toured in CI on every push. The \`*.demo.ts\` files are that example's
767
- scripted walkthroughs - delete one and its \`providers\` entry when you do not want it.
754
+ \`main.ts\`, \`app.module.ts\` and \`config.ts\` were generated for the features you
755
+ chose; everything else is copied from dunx's \`examples/full\`, which is run and
756
+ toured in CI on every push. The \`*.demo.ts\` files are that example's scripted
757
+ walkthroughs - delete one and its \`providers\` entry when you do not want it.
758
+
759
+ A test imports \`createApp\` from \`./main.js\` and never starts a server: the
760
+ \`import.meta.main\` block at the bottom of the file is false for an import.
768
761
 
769
762
  ## Constructor injection
770
763
 
@@ -787,13 +780,14 @@ var IGNORED_WHEN_EMPTY = new Set([
787
780
  ".gitkeep",
788
781
  "LICENSE"
789
782
  ]);
783
+ var blockingEntries = (directory) => existsSync(directory) ? readdirSync(directory).filter((entry) => !IGNORED_WHEN_EMPTY.has(entry)).sort() : [];
790
784
 
791
785
  class ScaffoldError extends Error {
792
786
  name = "ScaffoldError";
793
787
  }
794
788
  var templatesRoot = () => resolve(dirname(fileURLToPath(import.meta.url)), "..", "templates");
795
789
  var isValidPackageName = (name) => /^(?:@[a-z0-9-*~][a-z0-9-*._~]*\/)?[a-z0-9-~][a-z0-9-._~]*$/.test(name);
796
- var readPackageVersion = async () => {
790
+ var packageVersion = async () => {
797
791
  const file = Bun.file(join(templatesRoot(), "..", "package.json"));
798
792
  const json = await file.json();
799
793
  return json.version ?? "0.0.0";
@@ -801,19 +795,14 @@ var readPackageVersion = async () => {
801
795
  var fill = (contents, name, version) => contents.replaceAll(VERSION_PLACEHOLDER, version).replaceAll("__DUNX_APP_NAME__", name);
802
796
  var generated = (name, features) => {
803
797
  const groups = configGroupsFor(features);
804
- const files = {
798
+ return {
805
799
  "package.json": manifest(features),
806
800
  "README.md": readme(name, features),
807
801
  ".env.example": envExample(groups),
808
802
  "src/main.ts": main(name, features),
809
- "src/bootstrap.ts": bootstrap(name, features),
810
803
  "src/app.module.ts": appModule(name, features),
811
804
  "src/config.ts": config(name, groups)
812
805
  };
813
- if (features.some((feature) => feature.name === "jobs")) {
814
- files["src/worker.ts"] = worker(name);
815
- }
816
- return files;
817
806
  };
818
807
  var scaffold = async (options) => {
819
808
  const template = options.template ?? "minimal";
@@ -833,15 +822,15 @@ var scaffold = async (options) => {
833
822
  if (!isValidPackageName(name)) {
834
823
  throw new ScaffoldError(`"${name}" is not a usable package name. Pass --name to choose one.`);
835
824
  }
836
- if (existsSync(directory) && options.force !== true) {
837
- const blocking = readdirSync(directory).filter((entry) => !IGNORED_WHEN_EMPTY.has(entry));
825
+ if (options.force !== true) {
826
+ const blocking = blockingEntries(directory);
838
827
  if (blocking.length > 0) {
839
- const shown = blocking.sort().slice(0, 3).join(", ");
828
+ const shown = blocking.slice(0, 3).join(", ");
840
829
  const rest = blocking.length > 3 ? `, +${blocking.length - 3} more` : "";
841
830
  throw new ScaffoldError(`${directory} is not empty (${shown}${rest}). ` + `Pass --force to write into it anyway.`);
842
831
  }
843
832
  }
844
- const version = options.version ?? `^${await readPackageVersion()}`;
833
+ const version = options.version ?? `^${await packageVersion()}`;
845
834
  const written = [];
846
835
  const copyTree = async (from, into) => {
847
836
  for await (const relative of new Glob("**/*").scan({
@@ -903,4 +892,4 @@ var scaffold = async (options) => {
903
892
  };
904
893
  };
905
894
 
906
- export { FEATURES, featureNames, impliedBy, TEMPLATES, VERSION_PLACEHOLDER, ScaffoldError, scaffold };
895
+ export { FEATURES, featureNames, resolveFeatures, impliedBy, TEMPLATES, VERSION_PLACEHOLDER, blockingEntries, ScaffoldError, isValidPackageName, packageVersion, scaffold };