@znight/cli 0.6.2 → 0.6.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.
@@ -3,9 +3,9 @@
3
3
  * of options, `create.ts` writes them. The sample application uses the public APIs of the
4
4
  * `@znight/*` packages exactly as documented in their READMEs.
5
5
  */
6
- import type { Feature, HttpAdapterName, PackageManager, ProjectOptions } from './options.ts'
6
+ import type { Feature, HttpAdapterName, OpenApiUiName, PackageManager, ProjectOptions } from './options.ts'
7
7
 
8
- import { execBinary, runScript } from './options.ts'
8
+ import { DEFAULT_OPENAPI_UI, execBinary, OPENAPI_UI_LABELS, runScript } from './options.ts'
9
9
  import { versionOf } from './versions.ts'
10
10
 
11
11
  /** A file of the new project. */
@@ -16,11 +16,17 @@ export interface ProjectFile {
16
16
  }
17
17
 
18
18
  interface Flags {
19
+ /** npm package name of the project (the logger name). */
20
+ readonly name: string
19
21
  readonly config: boolean
20
22
  readonly observability: boolean
21
23
  readonly messaging: boolean
22
24
  readonly schedule: boolean
23
25
  readonly openapi: boolean
26
+ readonly openapiUi: OpenApiUiName
27
+ readonly graphql: boolean
28
+ readonly websocket: boolean
29
+ readonly docker: boolean
24
30
  readonly adapter: HttpAdapterName
25
31
  readonly pm: PackageManager
26
32
  readonly bun: boolean
@@ -29,11 +35,16 @@ interface Flags {
29
35
  function flagsOf(options: ProjectOptions): Flags {
30
36
  const has = (feature: Feature): boolean => options.features.includes(feature)
31
37
  return {
38
+ name: options.name,
32
39
  config: has('config'),
33
40
  observability: has('observability'),
34
41
  messaging: has('messaging'),
35
42
  schedule: has('schedule'),
36
43
  openapi: has('openapi'),
44
+ openapiUi: options.openapiUi ?? DEFAULT_OPENAPI_UI,
45
+ graphql: has('graphql'),
46
+ websocket: has('websocket'),
47
+ docker: options.docker ?? true,
37
48
  adapter: options.adapter,
38
49
  pm: options.packageManager,
39
50
  bun: options.packageManager === 'bun',
@@ -65,7 +76,19 @@ export function renderProject(options: ProjectOptions): ProjectFile[] {
65
76
  files.push({ path: 'test/heartbeat.jobs.test.ts', content: heartbeatJobsTest(flags) })
66
77
  }
67
78
  if (flags.openapi) files.push({ path: 'src/api.ts', content: apiDocument(options) })
79
+ if (flags.graphql) {
80
+ files.push({ path: 'src/app/greeting.resolver.ts', content: greetingResolver() })
81
+ files.push({ path: 'test/greeting.resolver.test.ts', content: greetingResolverTest(flags) })
82
+ }
83
+ if (flags.websocket) {
84
+ files.push({ path: 'src/app/greeting.gateway.ts', content: greetingGateway() })
85
+ files.push({ path: 'test/greeting.gateway.test.ts', content: greetingGatewayTest(flags) })
86
+ }
68
87
  if (flags.bun) files.push({ path: 'bunfig.toml', content: bunfig(flags) })
88
+ if (flags.docker) {
89
+ files.push({ path: 'Dockerfile', content: dockerfile(flags) })
90
+ files.push({ path: '.dockerignore', content: dockerignore() })
91
+ }
69
92
  if (flags.pm === 'yarn') files.push({ path: '.yarnrc.yml', content: 'nodeLinker: node-modules\n' })
70
93
  return files.sort((a, b) => (a.path < b.path ? -1 : 1))
71
94
  }
@@ -89,6 +112,15 @@ function packageJson(options: ProjectOptions, flags: Flags): string {
89
112
  if (flags.messaging) dependencies['@znight/messaging'] = znight
90
113
  if (flags.schedule) dependencies['@znight/schedule'] = znight
91
114
  if (flags.openapi) dependencies['@znight/openapi'] = znight
115
+ if (flags.graphql) {
116
+ dependencies['@znight/graphql'] = znight
117
+ dependencies['graphql'] = versionOf('graphql')
118
+ }
119
+ if (flags.websocket) {
120
+ dependencies['@znight/websocket'] = znight
121
+ // the transport of the node:http based servers (Bun provides its own ws module)
122
+ dependencies['ws'] = versionOf('ws')
123
+ }
92
124
  if (flags.adapter === 'fastify') dependencies['fastify'] = versionOf('fastify')
93
125
  if (flags.adapter === 'express') dependencies['express'] = versionOf('express')
94
126
  if (flags.adapter === 'hono') {
@@ -219,6 +251,139 @@ coverage
219
251
  `
220
252
  }
221
253
 
254
+ // ---------------------------------------------------------------------------------------------------
255
+ // Docker
256
+
257
+ /** How the image installs and builds with a package manager. */
258
+ interface DockerSteps {
259
+ /** Files of the install layers (the lockfile is required: installs are frozen). */
260
+ readonly manifests: string
261
+ /** Package manager setup of the base stage. */
262
+ readonly setup: readonly string[]
263
+ /** Every dependency, for the build. */
264
+ readonly install: string
265
+ /** Production dependencies only, without the compiler, Vite, TypeScript... */
266
+ readonly production: string
267
+ readonly build: string
268
+ }
269
+
270
+ function dockerSteps(pm: PackageManager): DockerSteps {
271
+ // pnpm and yarn come from Corepack (bundled with Node.js 24); the packageManager field of
272
+ // package.json, when present, picks their version
273
+ const corepack = (tool: string): string[] => [
274
+ 'ENV COREPACK_ENABLE_DOWNLOAD_PROMPT=0',
275
+ `RUN corepack enable && corepack install --global ${tool}`,
276
+ ]
277
+ switch (pm) {
278
+ case 'bun':
279
+ return {
280
+ manifests: 'package.json bun.lock',
281
+ setup: [],
282
+ install: 'bun install --frozen-lockfile',
283
+ // --omit=peer: Bun would also install the optional peers the dev dependencies satisfy (the
284
+ // @znight/* packages declare the compiler as one); required peers are direct dependencies
285
+ production: 'bun install --frozen-lockfile --production --omit=peer',
286
+ build: 'bun run build',
287
+ }
288
+ case 'pnpm':
289
+ return {
290
+ manifests: 'package.json pnpm-lock.yaml',
291
+ setup: corepack('pnpm@10'),
292
+ install: 'pnpm install --frozen-lockfile',
293
+ production: 'pnpm install --frozen-lockfile --prod',
294
+ build: 'pnpm run build',
295
+ }
296
+ case 'yarn':
297
+ return {
298
+ manifests: 'package.json yarn.lock .yarnrc.yml',
299
+ setup: corepack('yarn@4'),
300
+ install: 'yarn install --immutable',
301
+ production: 'yarn workspaces focus --all --production',
302
+ build: 'yarn build',
303
+ }
304
+ default:
305
+ return {
306
+ manifests: 'package.json package-lock.json',
307
+ setup: [],
308
+ install: 'npm ci',
309
+ // without the devDependencies in package.json, npm also leaves out the optional peers they
310
+ // satisfy (the @znight/* packages declare the compiler as one): `npm ci --omit=dev` keeps them
311
+ production: 'npm pkg delete devDependencies && npm ci --omit=dev',
312
+ build: 'npm run build',
313
+ }
314
+ }
315
+ }
316
+
317
+ /** A multi-stage image: build with every dependency, run the bundle with the production ones. */
318
+ function dockerfile(flags: Flags): string {
319
+ const steps = dockerSteps(flags.pm)
320
+ const runtime = flags.bun ? 'bun' : 'node'
321
+ const [baseImage, runImage] = flags.bun ? ['oven/bun:1', 'oven/bun:1-slim'] : ['node:24-slim', 'node:24-slim']
322
+ // the images ship neither curl nor wget: the runtime itself probes GET /health
323
+ const probe =
324
+ "fetch('http://127.0.0.1:' + (process.env.PORT || 3000) + '/health').then(r => process.exit(r.ok ? 0 : 1), () => process.exit(1))"
325
+ const command = flags.bun
326
+ ? '["bun", "--no-env-file", "dist/main.js"]'
327
+ : '["node", "--enable-source-maps", "dist/main.js"]'
328
+ const envFiles = flags.bun
329
+ ? '\n# --no-env-file: Bun would load .env files into process.env by itself, with its own precedence'
330
+ : ''
331
+ return `# syntax=docker/dockerfile:1
332
+ # Production image: docker build -t ${dockerTag(flags.name)} .
333
+ # Installs are frozen: install the dependencies once (${flags.pm} install) so that the lockfile exists.
334
+
335
+ FROM ${baseImage} AS base
336
+ ${steps.setup.map(line => `${line}\n`).join('')}WORKDIR /app
337
+
338
+ # --- production dependencies only, at the versions of the lockfile ----------------------------------
339
+ FROM base AS deps
340
+ COPY ${steps.manifests} ./
341
+ RUN ${steps.production}
342
+
343
+ # --- build: every dependency, since the Znight compiler, Vite and TypeScript run here ---------------
344
+ FROM base AS build
345
+ COPY ${steps.manifests} ./
346
+ RUN ${steps.install}
347
+ COPY . .
348
+ # generates the DI container (src/.znight), fails on any wiring error, bundles dist/main.js
349
+ RUN ${steps.build}
350
+
351
+ # --- runtime: the bundle and the production dependencies, as a non-root user ----------------------
352
+ FROM ${runImage}
353
+ WORKDIR /app
354
+ ENV NODE_ENV=production
355
+ COPY --from=build /app/package.json ./
356
+ COPY --from=deps /app/node_modules ./node_modules
357
+ COPY --from=build /app/dist ./dist
358
+ USER ${runtime}
359
+ EXPOSE 3000
360
+ HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \\
361
+ CMD ["${runtime}", "-e", "${probe}"]
362
+ # exec form: the runtime is PID 1 and receives SIGTERM directly (graceful shutdown)${envFiles}
363
+ CMD ${command}
364
+ `
365
+ }
366
+
367
+ /** A Docker image name for an npm package name (`@acme/api` -> `acme-api`). */
368
+ function dockerTag(name: string): string {
369
+ return name.replace(/^@/, '').replace('/', '-')
370
+ }
371
+
372
+ function dockerignore(): string {
373
+ return `# not part of the image: the build installs and generates everything itself
374
+ node_modules
375
+ dist
376
+ coverage
377
+ **/.znight
378
+ *.log
379
+ .git
380
+ .env
381
+ .env.*
382
+ Dockerfile
383
+ .dockerignore
384
+ `
385
+ }
386
+
222
387
  // ---------------------------------------------------------------------------------------------------
223
388
  // application
224
389
 
@@ -243,6 +408,7 @@ function mainTs(flags: Flags): string {
243
408
  const imports = [
244
409
  flags.config ? "import { ConfigPlugin } from '@znight/config'" : undefined,
245
410
  "import { Application } from '@znight/core'",
411
+ flags.graphql ? "import { GraphQLPlugin } from '@znight/graphql'" : undefined,
246
412
  "import { HttpPlugin } from '@znight/http'",
247
413
  adapterImport(flags.adapter),
248
414
  flags.messaging ? "import { MessagingPlugin } from '@znight/messaging'" : undefined,
@@ -250,6 +416,7 @@ function mainTs(flags: Flags): string {
250
416
  flags.observability ? "import { ObservabilityPlugin } from '@znight/observability'" : undefined,
251
417
  flags.openapi ? "import { OpenApiPlugin } from '@znight/openapi'" : undefined,
252
418
  flags.schedule ? "import { SchedulePlugin } from '@znight/schedule'" : undefined,
419
+ flags.websocket ? "import { WebSocketPlugin } from '@znight/websocket'" : undefined,
253
420
  ].filter(line => line !== undefined)
254
421
  const localImports = [
255
422
  "import { __ZNIGHT_METADATA__ } from './.znight/container.generated.js'",
@@ -260,7 +427,17 @@ function mainTs(flags: Flags): string {
260
427
  if (flags.observability) {
261
428
  plugins.push(
262
429
  ' // first, so that its hooks wrap everything: request logs, metrics (with prom-client), traces',
263
- ' ObservabilityPlugin.configure(),',
430
+ ' ObservabilityPlugin.configure({',
431
+ ' logging: {',
432
+ ` name: ${quote(flags.name)},`,
433
+ ' },',
434
+ ' // metrics: {',
435
+ ' // // options of the metrics registry',
436
+ ' // },',
437
+ ' // tracing: {',
438
+ ' // // options of the tracer',
439
+ ' // },',
440
+ ' }),',
264
441
  )
265
442
  }
266
443
  if (flags.config) {
@@ -299,8 +476,20 @@ function mainTs(flags: Flags): string {
299
476
  }
300
477
  if (flags.openapi) {
301
478
  plugins.push(
302
- ' // GET /openapi.json and the API reference at /docs (document options: src/api.ts)',
303
- ' OpenApiPlugin.configure(),',
479
+ ` // GET /openapi.json and the API reference at /docs, with ${OPENAPI_UI_LABELS[flags.openapiUi]} (document options: src/api.ts)`,
480
+ ` OpenApiPlugin.configure({ ui: ${quote(flags.openapiUi)} }),`,
481
+ )
482
+ }
483
+ if (flags.graphql) {
484
+ plugins.push(
485
+ ' // POST /graphql, GET /graphql (GraphiQL in development): the schema is derived from the resolvers',
486
+ ' GraphQLPlugin.configure(),',
487
+ )
488
+ }
489
+ if (flags.websocket) {
490
+ plugins.push(
491
+ " // WebSocket gateways on the HTTP server's port (ws on Node.js, Bun's own WebSocket on Bun)",
492
+ ' WebSocketPlugin.configure(),',
304
493
  )
305
494
  }
306
495
 
@@ -320,6 +509,20 @@ await app.start()
320
509
  `
321
510
  }
322
511
 
512
+ /** A single-quoted TypeScript string literal of `value` (quotes, backslashes and line breaks escaped). */
513
+ function quote(value: string): string {
514
+ let out = "'"
515
+ for (const char of value) {
516
+ const code = char.codePointAt(0) ?? 0
517
+ if (char === "'" || char === '\\') out += `\\${char}`
518
+ else if (char === '\n') out += '\\n'
519
+ else if (char === '\r') out += '\\r'
520
+ else if (code < 0x20 || code === 0x2028 || code === 0x2029) out += `\\u${code.toString(16).padStart(4, '0')}`
521
+ else out += char
522
+ }
523
+ return `${out}'`
524
+ }
525
+
323
526
  function appConfig(): string {
324
527
  return `import { Config, Env } from '@znight/config'
325
528
 
@@ -519,6 +722,60 @@ export class Api {}
519
722
  `
520
723
  }
521
724
 
725
+ function greetingResolver(): string {
726
+ return `import { Args, Query, Resolver } from '@znight/graphql'
727
+
728
+ import { GreetingService } from './greeting.service.js'
729
+
730
+ /** A greeting. */
731
+ export interface Greeting {
732
+ readonly message: string
733
+ }
734
+
735
+ /**
736
+ * GraphQL resolver (code-first): the compiler derives the schema from the TypeScript types and the
737
+ * JSDoc, \`type Query { greeting(name: String!): Greeting! }\` here.
738
+ */
739
+ @Resolver()
740
+ export class GreetingResolver {
741
+ constructor(private readonly greetings: GreetingService) {}
742
+
743
+ /**
744
+ * Greets someone.
745
+ * @param name Who to greet.
746
+ */
747
+ @Query()
748
+ greeting(@Args('name') name: string): Greeting {
749
+ return { message: this.greetings.greet(name) }
750
+ }
751
+ }
752
+ `
753
+ }
754
+
755
+ function greetingGateway(): string {
756
+ return `import { Gateway, OnMessage, Payload } from '@znight/websocket'
757
+
758
+ import { CreateGreeting } from './greeting.schemas.js'
759
+ import { GreetingService } from './greeting.service.js'
760
+
761
+ /**
762
+ * WebSocket gateway on ws://localhost:3000/ws, served by the HTTP server. Frames are JSON: the
763
+ * request {"event": "greet", "data": {"name": "Ada"}, "id": 1} is answered with
764
+ * {"id": 1, "data": {"message": "Hello, Ada."}}.
765
+ */
766
+ @Gateway('/ws')
767
+ export class GreetingGateway {
768
+ constructor(private readonly greetings: GreetingService) {}
769
+
770
+ /** The payload is validated by the schema (a BAD_REQUEST error reply otherwise). */
771
+ @OnMessage('greet')
772
+ greet(@Payload(CreateGreeting) input: CreateGreeting): { message: string } {
773
+ return { message: this.greetings.greet(input.name, input.excited) }
774
+ }
775
+ }
776
+ `
777
+ }
778
+
522
779
  // ---------------------------------------------------------------------------------------------------
523
780
  // tests
524
781
 
@@ -596,29 +853,45 @@ describe('GreetingService', () => {
596
853
  `
597
854
  }
598
855
 
599
- function greetingControllerTest(flags: Flags): string {
600
- const imports = [
601
- testImports(flags, 'afterEach, beforeEach, describe, expect, test'),
602
- "import { HttpPlugin } from '@znight/http'",
603
- ]
856
+ /** Imports and `plugins` array of a `TestApp` test, with the plugins the providers need. */
857
+ function testAppPlugins(
858
+ flags: Flags,
859
+ names: string,
860
+ extra: { readonly graphql?: boolean; readonly websocket?: boolean } = {},
861
+ ): { imports: string; plugins: string } {
862
+ const imports = [testImports(flags, names)]
863
+ if (extra.graphql === true) {
864
+ imports.push("import { GraphQLPlugin } from '@znight/graphql'")
865
+ imports.push("import { executeOperation } from '@znight/graphql/testing'")
866
+ }
867
+ imports.push("import { HttpPlugin } from '@znight/http'")
604
868
  if (flags.messaging) {
605
869
  imports.push("import { MessagingPlugin } from '@znight/messaging'")
606
870
  imports.push("import { memory } from '@znight/messaging/memory'")
607
871
  }
608
872
  if (flags.observability) imports.push("import { ObservabilityPlugin } from '@znight/observability'")
609
873
  imports.push("import { TestApp } from '@znight/testing'")
874
+ if (extra.websocket === true) imports.push("import { WebSocketPlugin } from '@znight/websocket'")
610
875
  const plugins: string[] = []
611
- // no metrics server (port 9464 once prom-client is installed): test files and `dev` run side by side
876
+ // metrics off: no metrics server colliding on :9464 with `dev` or other test files, and no need for
877
+ // prom-client (any metrics object requires it)
612
878
  if (flags.observability) {
613
- plugins.push("ObservabilityPlugin.configure({ logging: { level: 'silent' }, metrics: { server: false } })")
879
+ plugins.push("ObservabilityPlugin.configure({ logging: { level: 'silent' }, metrics: false })")
614
880
  }
615
881
  if (flags.messaging) plugins.push('MessagingPlugin.configure({ driver: memory() })')
616
882
  // port 0: TestApp.inject() sends requests in memory, the server listens on a free port
617
883
  plugins.push('HttpPlugin.configure({ port: 0 })')
618
- const pluginList =
884
+ if (extra.graphql === true) plugins.push('GraphQLPlugin.configure()')
885
+ if (extra.websocket === true) plugins.push('WebSocketPlugin.configure()')
886
+ const list =
619
887
  plugins.length === 1 ? `[${plugins[0]}]` : `[\n${plugins.map(plugin => ` ${plugin},`).join('\n')}\n ]`
888
+ return { imports: imports.join('\n'), plugins: list }
889
+ }
890
+
891
+ function greetingControllerTest(flags: Flags): string {
892
+ const { imports, plugins: pluginList } = testAppPlugins(flags, 'afterEach, beforeEach, describe, expect, test')
620
893
  const greeting = 'Hello'
621
- return `${imports.join('\n')}
894
+ return `${imports}
622
895
 
623
896
  import { __ZNIGHT_METADATA__ as metadata } from '../src/.znight/container.generated.js'
624
897
 
@@ -656,6 +929,60 @@ describe('GreetingController', () => {
656
929
  `
657
930
  }
658
931
 
932
+ function greetingResolverTest(flags: Flags): string {
933
+ const { imports, plugins } = testAppPlugins(flags, 'describe, expect, test', { graphql: true })
934
+ return `${imports}
935
+
936
+ import { __ZNIGHT_METADATA__ as metadata } from '../src/.znight/container.generated.js'
937
+
938
+ // executeOperation() sends the operation through the whole HTTP pipeline, in memory
939
+ describe('GreetingResolver', () => {
940
+ test('query { greeting }', async () => {
941
+ const app = await TestApp.create({
942
+ metadata,
943
+ plugins: ${plugins},
944
+ })
945
+ try {
946
+ const response = await executeOperation(app, { query: '{ greeting(name: "Ada") { message } }' })
947
+
948
+ expect(response.status).toBe(200)
949
+ expect(response.errors).toBeUndefined()
950
+ expect(response.data).toEqual({ greeting: { message: 'Hello, Ada.' } })
951
+ } finally {
952
+ await app.close()
953
+ }
954
+ })
955
+ })
956
+ `
957
+ }
958
+
959
+ function greetingGatewayTest(flags: Flags): string {
960
+ const { imports, plugins } = testAppPlugins(flags, 'describe, expect, test', { websocket: true })
961
+ return `${imports}
962
+
963
+ import { __ZNIGHT_METADATA__ as metadata } from '../src/.znight/container.generated.js'
964
+
965
+ // app.connect() opens an in-memory connection, through the real handshake
966
+ describe('GreetingGateway', () => {
967
+ test('answers "greet" requests', async () => {
968
+ const app = await TestApp.create({
969
+ metadata,
970
+ plugins: ${plugins},
971
+ })
972
+ try {
973
+ const client = await app.connect('/ws')
974
+
975
+ expect(await client.request('greet', { name: 'Ada', excited: true })).toEqual({ message: 'Hello, Ada!' })
976
+ await expect(client.request('greet', { name: '' })).rejects.toMatchObject({ code: 'BAD_REQUEST' })
977
+ await client.close()
978
+ } finally {
979
+ await app.close()
980
+ }
981
+ })
982
+ })
983
+ `
984
+ }
985
+
659
986
  // ---------------------------------------------------------------------------------------------------
660
987
  // README
661
988
 
@@ -671,8 +998,25 @@ function readme(options: ProjectOptions, flags: Flags): string {
671
998
  flags.messaging ? '- **Messaging** with `@znight/messaging` (in-memory broker in `src/main.ts`)' : undefined,
672
999
  flags.schedule ? '- **Scheduled jobs** with `@znight/schedule` (`src/app/heartbeat.jobs.ts`)' : undefined,
673
1000
  flags.openapi
674
- ? '- **OpenAPI** with `@znight/openapi` (`/openapi.json`, API reference at `/docs`, options in `src/api.ts`)'
1001
+ ? `- **OpenAPI** with \`@znight/openapi\` (\`/openapi.json\`, ${OPENAPI_UI_LABELS[flags.openapiUi]} API reference at \`/docs\`, options in \`src/api.ts\`)`
675
1002
  : undefined,
1003
+ flags.graphql
1004
+ ? '- **GraphQL** with `@znight/graphql` (code-first: the schema is derived from `src/app/greeting.resolver.ts`)'
1005
+ : undefined,
1006
+ flags.websocket
1007
+ ? '- **WebSocket** with `@znight/websocket` (`src/app/greeting.gateway.ts`, on the HTTP port at `/ws`)'
1008
+ : undefined,
1009
+ ].filter(line => line !== undefined)
1010
+ const app = [
1011
+ flags.config ? 'app.config.ts # @Config: settings read from the environment' : undefined,
1012
+ flags.messaging ? 'greeting.consumer.ts # @Consumer: handles the "greetings" queue' : undefined,
1013
+ 'greeting.controller.ts # @Controller: /greetings routes',
1014
+ flags.websocket ? 'greeting.gateway.ts # @Gateway: the /ws WebSocket endpoint' : undefined,
1015
+ flags.graphql ? 'greeting.resolver.ts # @Resolver: the greeting GraphQL query' : undefined,
1016
+ 'greeting.schemas.ts # Zod schemas',
1017
+ 'greeting.service.ts # @Injectable',
1018
+ 'health.controller.ts # GET /health',
1019
+ flags.schedule ? 'heartbeat.jobs.ts # @Cron: a job every minute' : undefined,
676
1020
  ].filter(line => line !== undefined)
677
1021
  const tree = [
678
1022
  'src/',
@@ -682,16 +1026,11 @@ function readme(options: ProjectOptions, flags: Flags): string {
682
1026
  : undefined,
683
1027
  '├─ .znight/ # generated DI container (git-ignored)',
684
1028
  '└─ app/',
685
- flags.config ? ' ├─ app.config.ts # @Config: settings read from the environment' : undefined,
686
- flags.messaging ? ' ├─ greeting.consumer.ts # @Consumer: handles the "greetings" queue' : undefined,
687
- ' ├─ greeting.controller.ts # @Controller: /greetings routes',
688
- ' ├─ greeting.schemas.ts # Zod schemas',
689
- ' ├─ greeting.service.ts # @Injectable',
690
- flags.schedule ? ' ├─ health.controller.ts # GET /health' : ' └─ health.controller.ts # GET /health',
691
- flags.schedule ? ' └─ heartbeat.jobs.ts # @Cron: a job every minute' : undefined,
1029
+ ...app.map((line, index) => ` ${index === app.length - 1 ? '└─' : '├─'} ${line}`),
692
1030
  'test/ # TestBed (unit) and TestApp (HTTP) tests',
693
1031
  'vite.config.ts # znight() plugin: generates the container, server build',
694
1032
  'znight.config.ts # compiler options',
1033
+ flags.docker ? 'Dockerfile # production image (multi-stage, non-root)' : undefined,
695
1034
  ].filter(line => line !== undefined)
696
1035
  const configNote = flags.config
697
1036
  ? `
@@ -707,6 +1046,22 @@ problem listed.${
707
1046
  \`@znight/config\`, the same way on Node.js and Bun.`
708
1047
  : ''
709
1048
  }
1049
+ `
1050
+ : ''
1051
+ const image = dockerTag(options.name)
1052
+ const dockerNote = flags.docker
1053
+ ? `
1054
+ ## Docker
1055
+
1056
+ \`\`\`sh
1057
+ docker build -t ${image} .
1058
+ docker run --rm -p 3000:3000 ${image}
1059
+ \`\`\`
1060
+
1061
+ The \`Dockerfile\` builds with every dependency (the Znight compiler runs during the build), then
1062
+ ships \`dist/\` with the production dependencies only, on ${flags.bun ? 'Bun' : 'Node.js'}, as a non-root user, with
1063
+ \`NODE_ENV=production\` and a health check on \`GET /health\`. Installs are frozen: the lockfile of
1064
+ \`${flags.pm} install\` must exist.
710
1065
  `
711
1066
  : ''
712
1067
  const testNote = flags.bun
@@ -742,7 +1097,7 @@ ${
742
1097
  ${run('dev')}
743
1098
  curl localhost:3000/health
744
1099
  curl localhost:3000/greetings/Ada?excited=true
745
- curl -X POST localhost:3000/greetings -H 'content-type: application/json' -d '{"name":"Grace"}'${flags.openapi ? '\ncurl localhost:3000/openapi.json # the API reference is at http://localhost:3000/docs' : ''}
1100
+ curl -X POST localhost:3000/greetings -H 'content-type: application/json' -d '{"name":"Grace"}'${flags.openapi ? '\ncurl localhost:3000/openapi.json # the API reference is at http://localhost:3000/docs' : ''}${flags.graphql ? `\ncurl localhost:3000/graphql -H 'content-type: application/json' -d '{"query":"{ greeting(name: \\"Ada\\") { message } }"}'` : ''}
746
1101
  \`\`\`
747
1102
 
748
1103
  ## Project structure
@@ -759,7 +1114,7 @@ Add code with the generators:
759
1114
  ${znight} g service users/user # src/users/user.service.ts
760
1115
  ${znight} g controller users/user # src/users/user.controller.ts${flags.schedule ? `\n${znight} g job reports/digest # src/reports/digest.jobs.ts` : ''}
761
1116
  \`\`\`
762
- ${configNote}
1117
+ ${configNote}${dockerNote}
763
1118
  ## Tests
764
1119
 
765
1120
  ${testNote}
@@ -9,11 +9,13 @@ export const DEPENDENCY_VERSIONS: Readonly<Record<string, string>> = {
9
9
  '@types/node': '^24.0.0',
10
10
  express: '^5.3.0',
11
11
  fastify: '^5.12.7',
12
+ graphql: '^17.0.2',
12
13
  hono: '^4.13.14',
13
14
  'light-my-request': '^6.8.0',
14
15
  typescript: '^7.0.0',
15
16
  vite: '^8.0.0',
16
17
  vitest: '^4.1.0',
18
+ ws: '^8.22.0',
17
19
  zod: '^4.6.0',
18
20
  }
19
21