@znight/cli 0.0.0-stage → 0.1.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 (49) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +482 -2
  3. package/bin/znight.mjs +17 -0
  4. package/dist/cli.d.mts +1 -0
  5. package/dist/cli.mjs +13 -0
  6. package/dist/cli.mjs.map +1 -0
  7. package/dist/dev/orphan-guard.d.mts +1 -0
  8. package/dist/dev/orphan-guard.mjs +47 -0
  9. package/dist/dev/orphan-guard.mjs.map +1 -0
  10. package/dist/index.d.mts +679 -0
  11. package/dist/index.mjs +1 -0
  12. package/dist/main-BWsiUsxN.mjs +3865 -0
  13. package/dist/main-BWsiUsxN.mjs.map +1 -0
  14. package/dist/orphan-Dn-2xMBd.mjs +45 -0
  15. package/dist/orphan-Dn-2xMBd.mjs.map +1 -0
  16. package/package.json +71 -4
  17. package/src/cli.ts +11 -0
  18. package/src/command.ts +217 -0
  19. package/src/commands/build.ts +159 -0
  20. package/src/commands/dev.ts +249 -0
  21. package/src/commands/generate.ts +304 -0
  22. package/src/commands/new.ts +245 -0
  23. package/src/commands/openapi.ts +160 -0
  24. package/src/commands/schematic.ts +110 -0
  25. package/src/commands/shared.ts +106 -0
  26. package/src/commands/start.ts +79 -0
  27. package/src/context.ts +126 -0
  28. package/src/dev/orphan-guard.ts +49 -0
  29. package/src/dev/orphan.ts +74 -0
  30. package/src/dev/supervisor.ts +175 -0
  31. package/src/errors.ts +64 -0
  32. package/src/index.ts +73 -0
  33. package/src/integrations/compiler.ts +360 -0
  34. package/src/integrations/openapi.ts +97 -0
  35. package/src/integrations/process.ts +180 -0
  36. package/src/integrations/prompts.ts +108 -0
  37. package/src/integrations/resolve.ts +38 -0
  38. package/src/integrations/vite.ts +362 -0
  39. package/src/integrations/watch.ts +243 -0
  40. package/src/main.ts +195 -0
  41. package/src/names.ts +106 -0
  42. package/src/output.ts +110 -0
  43. package/src/runtime.ts +86 -0
  44. package/src/scaffold/create.ts +65 -0
  45. package/src/scaffold/options.ts +103 -0
  46. package/src/scaffold/templates.ts +771 -0
  47. package/src/scaffold/versions.ts +25 -0
  48. package/src/schematics/index.ts +422 -0
  49. package/src/version.ts +20 -0
@@ -0,0 +1,771 @@
1
+ /**
2
+ * Files of a project created by `znight new`. Pure: {@link renderProject} returns the files for a set
3
+ * of options, `create.ts` writes them. The sample application uses the public APIs of the
4
+ * `@znight/*` packages exactly as documented in their READMEs.
5
+ */
6
+ import type { Feature, HttpAdapterName, PackageManager, ProjectOptions } from './options.ts'
7
+
8
+ import { execBinary, runScript } from './options.ts'
9
+ import { versionOf } from './versions.ts'
10
+
11
+ /** A file of the new project. */
12
+ export interface ProjectFile {
13
+ /** POSIX path relative to the project directory. */
14
+ readonly path: string
15
+ readonly content: string
16
+ }
17
+
18
+ interface Flags {
19
+ readonly config: boolean
20
+ readonly observability: boolean
21
+ readonly messaging: boolean
22
+ readonly schedule: boolean
23
+ readonly openapi: boolean
24
+ readonly adapter: HttpAdapterName
25
+ readonly pm: PackageManager
26
+ readonly bun: boolean
27
+ }
28
+
29
+ function flagsOf(options: ProjectOptions): Flags {
30
+ const has = (feature: Feature): boolean => options.features.includes(feature)
31
+ return {
32
+ config: has('config'),
33
+ observability: has('observability'),
34
+ messaging: has('messaging'),
35
+ schedule: has('schedule'),
36
+ openapi: has('openapi'),
37
+ adapter: options.adapter,
38
+ pm: options.packageManager,
39
+ bun: options.packageManager === 'bun',
40
+ }
41
+ }
42
+
43
+ /** Every file of a new project, sorted by path. */
44
+ export function renderProject(options: ProjectOptions): ProjectFile[] {
45
+ const flags = flagsOf(options)
46
+ const files: ProjectFile[] = [
47
+ { path: 'package.json', content: packageJson(options, flags) },
48
+ { path: 'tsconfig.json', content: tsconfigJson(flags) },
49
+ { path: 'vite.config.ts', content: viteConfig() },
50
+ { path: 'znight.config.ts', content: znightConfig(flags) },
51
+ { path: '.gitignore', content: gitignore() },
52
+ { path: 'README.md', content: readme(options, flags) },
53
+ { path: 'src/main.ts', content: mainTs(flags) },
54
+ { path: 'src/app/greeting.schemas.ts', content: greetingSchemas(flags) },
55
+ { path: 'src/app/greeting.service.ts', content: greetingService(flags) },
56
+ { path: 'src/app/greeting.controller.ts', content: greetingController(flags) },
57
+ { path: 'src/app/health.controller.ts', content: healthController() },
58
+ { path: 'test/greeting.service.test.ts', content: greetingServiceTest(flags) },
59
+ { path: 'test/greeting.controller.test.ts', content: greetingControllerTest(flags) },
60
+ ]
61
+ if (flags.config) files.push({ path: 'src/app/app.config.ts', content: appConfig() })
62
+ if (flags.messaging) files.push({ path: 'src/app/greeting.consumer.ts', content: greetingConsumer(flags) })
63
+ if (flags.schedule) {
64
+ files.push({ path: 'src/app/heartbeat.jobs.ts', content: heartbeatJobs() })
65
+ files.push({ path: 'test/heartbeat.jobs.test.ts', content: heartbeatJobsTest(flags) })
66
+ }
67
+ if (flags.openapi) files.push({ path: 'src/api.ts', content: apiDocument(options) })
68
+ if (flags.bun) files.push({ path: 'bunfig.toml', content: bunfig(flags) })
69
+ if (flags.pm === 'yarn') files.push({ path: '.yarnrc.yml', content: 'nodeLinker: node-modules\n' })
70
+ return files.sort((a, b) => (a.path < b.path ? -1 : 1))
71
+ }
72
+
73
+ // ---------------------------------------------------------------------------------------------------
74
+ // configuration files
75
+
76
+ function sortRecord(record: Record<string, string>): Record<string, string> {
77
+ return Object.fromEntries(Object.entries(record).sort(([a], [b]) => (a < b ? -1 : 1)))
78
+ }
79
+
80
+ function packageJson(options: ProjectOptions, flags: Flags): string {
81
+ const znight = `^${options.znightVersion}`
82
+ const dependencies: Record<string, string> = {
83
+ '@znight/core': znight,
84
+ '@znight/http': znight,
85
+ zod: versionOf('zod'),
86
+ }
87
+ if (flags.config) dependencies['@znight/config'] = znight
88
+ if (flags.observability) dependencies['@znight/observability'] = znight
89
+ if (flags.messaging) dependencies['@znight/messaging'] = znight
90
+ if (flags.schedule) dependencies['@znight/schedule'] = znight
91
+ if (flags.openapi) dependencies['@znight/openapi'] = znight
92
+ if (flags.adapter === 'fastify') dependencies['fastify'] = versionOf('fastify')
93
+ if (flags.adapter === 'express') dependencies['express'] = versionOf('express')
94
+ if (flags.adapter === 'hono') {
95
+ dependencies['hono'] = versionOf('hono')
96
+ dependencies['@hono/node-server'] = versionOf('@hono/node-server')
97
+ }
98
+ const devDependencies: Record<string, string> = {
99
+ '@types/node': versionOf('@types/node'),
100
+ '@znight/cli': znight,
101
+ '@znight/compiler': znight,
102
+ '@znight/testing': znight,
103
+ typescript: versionOf('typescript'),
104
+ vite: versionOf('vite'),
105
+ }
106
+ if (flags.bun) devDependencies['@types/bun'] = versionOf('@types/bun')
107
+ else devDependencies['vitest'] = versionOf('vitest')
108
+ if (flags.adapter === 'express') devDependencies['@types/express'] = versionOf('@types/express')
109
+ // in-memory requests (TestApp.inject) with the node and express adapters
110
+ if (flags.adapter === 'node' || flags.adapter === 'express') {
111
+ devDependencies['light-my-request'] = versionOf('light-my-request')
112
+ }
113
+ const manifest = {
114
+ name: options.name,
115
+ version: '0.1.0',
116
+ private: true,
117
+ type: 'module',
118
+ engines: { node: '>=24' },
119
+ scripts: {
120
+ // `bun run` honours the node shebang of the znight binary: the runtime has to be explicit
121
+ dev: flags.bun ? 'znight dev --runtime bun' : 'znight dev',
122
+ build: 'znight build',
123
+ start: flags.bun ? 'znight start --runtime bun' : 'znight start',
124
+ generate: 'znight generate',
125
+ typecheck: 'znight generate && tsc --noEmit',
126
+ test: flags.bun ? 'bun test' : 'vitest run',
127
+ ...(flags.openapi ? { openapi: 'znight openapi', 'openapi:check': 'znight openapi --check' } : {}),
128
+ },
129
+ dependencies: sortRecord(dependencies),
130
+ devDependencies: sortRecord(devDependencies),
131
+ }
132
+ return `${JSON.stringify(manifest, null, 2)}\n`
133
+ }
134
+
135
+ function tsconfigJson(flags: Flags): string {
136
+ return `{
137
+ "compilerOptions": {
138
+ "target": "ES2024",
139
+ "lib": ["ES2024", "ESNext.Disposable"],
140
+ "module": "preserve",
141
+ "moduleResolution": "bundler",
142
+ "moduleDetection": "force",
143
+ "types": ["${flags.bun ? 'bun' : 'node'}"],
144
+ "strict": true,
145
+ "noUncheckedIndexedAccess": true,
146
+ "noImplicitOverride": true,
147
+ // Znight decorators are TypeScript decorators: no emitDecoratorMetadata nor reflect-metadata
148
+ "experimentalDecorators": true,
149
+ "verbatimModuleSyntax": true,
150
+ "isolatedModules": true,
151
+ "resolveJsonModule": true,
152
+ "skipLibCheck": true,
153
+ "noEmit": true,
154
+ // vite.config.ts imports ./znight.config.ts (also loaded as is by Node.js and Bun)
155
+ "allowImportingTsExtensions": true
156
+ },
157
+ "include": ["src", "test", "vite.config.ts", "znight.config.ts"]
158
+ }
159
+ `
160
+ }
161
+
162
+ function viteConfig(): string {
163
+ return `import { znight } from '@znight/compiler'
164
+ import { defineConfig } from 'vite'
165
+
166
+ import options from './znight.config.ts'
167
+
168
+ // znight() generates the dependency injection container at build time and configures a server
169
+ // build: src/main.ts -> dist/main.js (Node.js 24 target, source maps).
170
+ export default defineConfig({
171
+ plugins: [znight(options)],
172
+ })
173
+ `
174
+ }
175
+
176
+ function znightConfig(flags: Flags): string {
177
+ const readers = flags.bun
178
+ ? `// Options of the Znight compiler. The znight() Vite plugin (vite.config.ts), the Bun preload of the
179
+ // tests (bunfig.toml) and \`znight generate\` read them, so they all generate the same container.`
180
+ : `// Options of the Znight compiler. The znight() Vite plugin (vite.config.ts, also used by Vitest)
181
+ // and \`znight generate\` read them, so they all generate the same container.`
182
+ return `import type { ZnightOptions } from '@znight/compiler'
183
+
184
+ ${readers}
185
+ // https://github.com/znight-org/znight/tree/main/packages/compiler#options
186
+ export default {
187
+ // reportUnused: true, // warn about providers nothing uses (ZNIGHT019)
188
+ } satisfies ZnightOptions
189
+ `
190
+ }
191
+
192
+ function bunfig(flags: Flags): string {
193
+ // Bun loads .env files into process.env with its own precedence, which then wins over dotenv()
194
+ const env = flags.config
195
+ ? `# @znight/config loads the .env files (dotenv()), the same way on Node.js and Bun: Bun's own
196
+ # loading would put them in process.env first, with its own precedence
197
+ env = false
198
+
199
+ `
200
+ : ''
201
+ return `${env}[test]
202
+ # Regenerates the DI container (src/.znight/container.generated.ts) before the tests run
203
+ preload = ["@znight/compiler/bun"]
204
+ `
205
+ }
206
+
207
+ function gitignore(): string {
208
+ return `node_modules
209
+ dist
210
+ coverage
211
+ *.log
212
+
213
+ # generated by the Znight compiler (znight generate, vite build, bun test)
214
+ .znight
215
+
216
+ # local environment
217
+ .env.local
218
+ .env.*.local
219
+ `
220
+ }
221
+
222
+ // ---------------------------------------------------------------------------------------------------
223
+ // application
224
+
225
+ function adapterImport(adapter: HttpAdapterName): string | undefined {
226
+ switch (adapter) {
227
+ case 'fastify':
228
+ return "import { fastifyAdapter } from '@znight/http/fastify'"
229
+ case 'express':
230
+ return "import { expressAdapter } from '@znight/http/express'"
231
+ case 'hono':
232
+ return "import { honoAdapter } from '@znight/http/hono'"
233
+ default:
234
+ return undefined
235
+ }
236
+ }
237
+
238
+ function adapterCall(adapter: HttpAdapterName): string | undefined {
239
+ return adapter === 'node' ? undefined : `${adapter}Adapter()`
240
+ }
241
+
242
+ function mainTs(flags: Flags): string {
243
+ const imports = [
244
+ flags.config ? "import { ConfigPlugin } from '@znight/config'" : undefined,
245
+ "import { Application } from '@znight/core'",
246
+ "import { HttpPlugin } from '@znight/http'",
247
+ adapterImport(flags.adapter),
248
+ flags.messaging ? "import { MessagingPlugin } from '@znight/messaging'" : undefined,
249
+ flags.messaging ? "import { memory } from '@znight/messaging/memory'" : undefined,
250
+ flags.observability ? "import { ObservabilityPlugin } from '@znight/observability'" : undefined,
251
+ flags.openapi ? "import { OpenApiPlugin } from '@znight/openapi'" : undefined,
252
+ flags.schedule ? "import { SchedulePlugin } from '@znight/schedule'" : undefined,
253
+ ].filter(line => line !== undefined)
254
+ const localImports = [
255
+ "import { __ZNIGHT_METADATA__ } from './.znight/container.generated.js'",
256
+ flags.config ? "import { AppConfig } from './app/app.config.js'" : undefined,
257
+ ].filter(line => line !== undefined)
258
+
259
+ const plugins: string[] = []
260
+ if (flags.observability) {
261
+ plugins.push(
262
+ ' // first, so that its hooks wrap everything: request logs, metrics (with prom-client), traces',
263
+ ' ObservabilityPlugin.configure(),',
264
+ )
265
+ }
266
+ if (flags.config) {
267
+ plugins.push(
268
+ ' // loads .env files and the environment, validates every @Config class before anything starts',
269
+ ' ConfigPlugin.configure(),',
270
+ )
271
+ }
272
+ if (flags.messaging) {
273
+ plugins.push(
274
+ ' // in-memory broker for development: use rabbitmq(), sqs() or kafka() in production',
275
+ ' MessagingPlugin.configure({ driver: memory() }),',
276
+ )
277
+ }
278
+ if (flags.schedule) {
279
+ plugins.push(
280
+ ' // before HttpPlugin: plugins stop in reverse order, so HTTP stops first, then running jobs drain',
281
+ ' SchedulePlugin.configure(),',
282
+ )
283
+ }
284
+ const adapter = adapterCall(flags.adapter)
285
+ if (flags.config) {
286
+ plugins.push(
287
+ ' HttpPlugin.configure(container => ({',
288
+ ...(adapter === undefined ? [] : [` adapter: ${adapter},`]),
289
+ ' port: container.get(AppConfig).port,',
290
+ ' })),',
291
+ )
292
+ } else {
293
+ const port = 'port: Number(process.env.PORT ?? 3000)'
294
+ plugins.push(
295
+ adapter === undefined
296
+ ? ` HttpPlugin.configure({ ${port} }),`
297
+ : ` HttpPlugin.configure({ adapter: ${adapter}, ${port} }),`,
298
+ )
299
+ }
300
+ if (flags.openapi) {
301
+ plugins.push(
302
+ ' // GET /openapi.json and the API reference at /docs (document options: src/api.ts)',
303
+ ' OpenApiPlugin.configure(),',
304
+ )
305
+ }
306
+
307
+ return `${imports.join('\n')}
308
+
309
+ ${localImports.join('\n')}
310
+
311
+ const app = await Application.create({
312
+ metadata: __ZNIGHT_METADATA__,
313
+ plugins: [
314
+ ${plugins.join('\n')}
315
+ ],
316
+ })
317
+
318
+ // SIGINT / SIGTERM stop the application gracefully (in-flight requests are drained)
319
+ await app.start()
320
+ `
321
+ }
322
+
323
+ function appConfig(): string {
324
+ return `import { Config, Env } from '@znight/config'
325
+
326
+ /**
327
+ * Settings of the application, validated at boot by ConfigPlugin. Each property is read from the
328
+ * environment (or a .env file): APP_<PROPERTY> by default, or the name given to @Env. Properties
329
+ * with an initializer are optional; their type selects the coercion ("3000" -> 3000).
330
+ */
331
+ @Config('app')
332
+ export class AppConfig {
333
+ /** HTTP port (PORT). */
334
+ @Env('PORT')
335
+ port = 3000
336
+
337
+ /** Greeting used by GreetingService (APP_GREETING). */
338
+ greeting = 'Hello'
339
+ }
340
+ `
341
+ }
342
+
343
+ function greetingSchemas(flags: Flags): string {
344
+ const usage = flags.messaging
345
+ ? 'Body of POST /greetings and POST /greetings/async, payload of the "greetings" queue.'
346
+ : 'Body of POST /greetings.'
347
+ return `import { z } from 'zod'
348
+
349
+ /** ${usage} */
350
+ export const CreateGreeting = z.object({
351
+ name: z.string().trim().min(1).max(100),
352
+ excited: z.boolean().default(false),
353
+ })
354
+ export type CreateGreeting = z.output<typeof CreateGreeting>
355
+ `
356
+ }
357
+
358
+ function greetingService(flags: Flags): string {
359
+ const imports = ["import { Injectable } from '@znight/core'"]
360
+ if (flags.observability) imports.push("import { Logger } from '@znight/observability'")
361
+ const local = flags.config ? "\nimport { AppConfig } from './app.config.js'\n" : ''
362
+ const params: string[] = []
363
+ if (flags.config) params.push(' private readonly config: AppConfig,')
364
+ if (flags.observability) params.push(' private readonly logger: Logger,')
365
+ let members = ''
366
+ if (params.length > 0) {
367
+ members = ` // dependencies are resolved at build time from the parameter types
368
+ constructor(
369
+ ${params.join('\n')}
370
+ ) {}
371
+
372
+ `
373
+ }
374
+ const greeting = flags.config ? 'this.config.greeting' : "'Hello'"
375
+ const log = flags.observability ? " this.logger.debug('Greeting', { name, excited })\n" : ''
376
+ return `${imports.join('\n')}
377
+ ${local}
378
+ @Injectable()
379
+ export class GreetingService {
380
+ ${members} greet(name: string, excited = false): string {
381
+ ${log} return \`\${${greeting}}, \${name}\${excited ? '!' : '.'}\`
382
+ }
383
+ }
384
+ `
385
+ }
386
+
387
+ function greetingController(flags: Flags): string {
388
+ const httpImports = flags.messaging
389
+ ? 'Body, Controller, Get, HttpCode, Param, Post, Query'
390
+ : 'Body, Controller, Get, Param, Post, Query'
391
+ const imports = [`import { ${httpImports} } from '@znight/http'`]
392
+ if (flags.messaging) imports.push("import { MessageBroker } from '@znight/messaging'")
393
+ const ctor = flags.messaging
394
+ ? ` constructor(
395
+ private readonly greetings: GreetingService,
396
+ private readonly broker: MessageBroker,
397
+ ) {}`
398
+ : ' constructor(private readonly greetings: GreetingService) {}'
399
+ const enqueue = flags.messaging
400
+ ? `
401
+
402
+ /** POST /greetings/async: published to the "greetings" queue, handled by GreetingConsumer. */
403
+ @Post('/async')
404
+ @HttpCode(202)
405
+ async enqueue(@Body(CreateGreeting) input: CreateGreeting): Promise<{ id: string }> {
406
+ const { id } = await this.broker.publish('greetings', input, { type: 'greeting.requested' })
407
+ return { id }
408
+ }`
409
+ : ''
410
+ return `${imports.join('\n')}
411
+
412
+ import { CreateGreeting } from './greeting.schemas.js'
413
+ import { GreetingService } from './greeting.service.js'
414
+
415
+ @Controller('/greetings')
416
+ export class GreetingController {
417
+ ${ctor}
418
+
419
+ /** GET /greetings/Ada?excited=true: route and query parameters are coerced to their types. */
420
+ @Get('/:name')
421
+ greet(@Param('name') name: string, @Query('excited') excited?: boolean): { message: string } {
422
+ return { message: this.greetings.greet(name, excited) }
423
+ }
424
+
425
+ /** POST /greetings {"name": "Ada"}: the body is validated by the schema (400 otherwise). */
426
+ @Post()
427
+ create(@Body(CreateGreeting) input: CreateGreeting): { message: string } {
428
+ return { message: this.greetings.greet(input.name, input.excited) }
429
+ }${enqueue}
430
+ }
431
+ `
432
+ }
433
+
434
+ function healthController(): string {
435
+ return `import { Controller, Get } from '@znight/http'
436
+
437
+ @Controller('/health')
438
+ export class HealthController {
439
+ /** GET /health */
440
+ @Get()
441
+ check(): { status: 'ok'; uptime: number } {
442
+ return { status: 'ok', uptime: Math.round(process.uptime()) }
443
+ }
444
+ }
445
+ `
446
+ }
447
+
448
+ function greetingConsumer(flags: Flags): string {
449
+ const imports = ["import { Consumer, Message, OnMessage, Retry } from '@znight/messaging'"]
450
+ if (flags.observability) imports.push("import { Logger } from '@znight/observability'")
451
+ const ctor = flags.observability
452
+ ? ` constructor(
453
+ private readonly greetings: GreetingService,
454
+ private readonly logger: Logger,
455
+ ) {}`
456
+ : ' constructor(private readonly greetings: GreetingService) {}'
457
+ const output = flags.observability
458
+ ? ' this.logger.info(this.greetings.greet(request.name, request.excited))'
459
+ : ' console.log(this.greetings.greet(request.name, request.excited))'
460
+ return `${imports.join('\n')}
461
+
462
+ import { CreateGreeting } from './greeting.schemas.js'
463
+ import { GreetingService } from './greeting.service.js'
464
+
465
+ /**
466
+ * Consumes the "greetings" queue. Delivery is at-least-once: handlers must be idempotent. A failing
467
+ * handler is retried, then the message goes to the dead-letter queue (greetings.dlq).
468
+ */
469
+ @Consumer('greetings')
470
+ export class GreetingConsumer {
471
+ ${ctor}
472
+
473
+ @OnMessage('greeting.requested')
474
+ @Retry({ attempts: 3, backoff: 'exponential', delay: 500 })
475
+ handle(@Message(CreateGreeting) request: CreateGreeting): void {
476
+ ${output}
477
+ }
478
+ }
479
+ `
480
+ }
481
+
482
+ function heartbeatJobs(): string {
483
+ return `import { Cron, type JobContext } from '@znight/schedule'
484
+
485
+ /**
486
+ * Scheduled jobs. A class with @Cron, @Interval or @Timeout methods becomes a singleton provider
487
+ * (no decorator needed): its dependencies are constructor parameters. Running several instances?
488
+ * Give SchedulePlugin a lock store so that each occurrence runs on one of them.
489
+ */
490
+ export class HeartbeatJobs {
491
+ /** Number of runs. */
492
+ beats = 0
493
+ /** The occurrence of the last run. */
494
+ last: Date | undefined
495
+
496
+ /** Every minute (cron expressions run in UTC unless they set a timeZone). */
497
+ @Cron('* * * * *', { name: 'heartbeat' })
498
+ beat(ctx: JobContext): void {
499
+ this.beats++
500
+ this.last = ctx.scheduledAt
501
+ }
502
+ }
503
+ `
504
+ }
505
+
506
+ function apiDocument(options: ProjectOptions): string {
507
+ return `import { OpenApiDocument } from '@znight/openapi'
508
+
509
+ /**
510
+ * ${options.name}
511
+ *
512
+ * The HTTP API of ${options.name}. Title and description of the OpenAPI document come from this
513
+ * comment, the version from package.json.
514
+ */
515
+ @OpenApiDocument({
516
+ tags: [{ name: 'Greeting', description: 'Greetings' }],
517
+ })
518
+ export class Api {}
519
+ `
520
+ }
521
+
522
+ // ---------------------------------------------------------------------------------------------------
523
+ // tests
524
+
525
+ function heartbeatJobsTest(flags: Flags): string {
526
+ return `${testImports(flags, 'describe, expect, test')}
527
+ import { SchedulePlugin, SchedulerRegistry } from '@znight/schedule'
528
+ import { createTestClock } from '@znight/schedule/testing'
529
+ import { TestApp } from '@znight/testing'
530
+
531
+ import { __ZNIGHT_METADATA__ as metadata } from '../src/.znight/container.generated.js'
532
+ import { HeartbeatJobs } from '../src/app/heartbeat.jobs.js'
533
+
534
+ // virtual time: the clock only moves when the test advances it, and waits for the runs it starts
535
+ describe('HeartbeatJobs', () => {
536
+ test('beats every minute', async () => {
537
+ const clock = createTestClock('2026-01-01T00:00:30Z')
538
+ const app = await TestApp.create({
539
+ metadata,
540
+ plugins: [SchedulePlugin.configure({ clock })],
541
+ only: [HeartbeatJobs],
542
+ })
543
+ try {
544
+ await clock.advance('3m')
545
+
546
+ expect(app.get(HeartbeatJobs).beats).toBe(3)
547
+ expect(app.get(HeartbeatJobs).last?.toISOString()).toBe('2026-01-01T00:03:00.000Z')
548
+ const heartbeat = app.get(SchedulerRegistry).get('heartbeat')
549
+ expect(heartbeat?.nextRun?.toISOString()).toBe('2026-01-01T00:04:00.000Z')
550
+ } finally {
551
+ await app.close()
552
+ }
553
+ })
554
+ })
555
+ `
556
+ }
557
+
558
+ function testImports(flags: Flags, names: string): string {
559
+ return flags.bun ? `import { ${names} } from 'bun:test'` : `import { ${names} } from 'vitest'`
560
+ }
561
+
562
+ function greetingServiceTest(flags: Flags): string {
563
+ const imports = [testImports(flags, 'describe, expect, test')]
564
+ if (flags.observability) imports.push("import { Logger } from '@znight/observability'")
565
+ imports.push("import { TestBed } from '@znight/testing'")
566
+ const configOption = flags.config ? "\n config: { app: { greeting: 'Hi' } }, // @Config('app') values" : ''
567
+ const greeting = flags.config ? 'Hi' : 'Hello'
568
+ const loggerTest = flags.observability
569
+ ? `
570
+
571
+ test('logs every greeting', async () => {
572
+ const { instance, mocks } = await TestBed.create(GreetingService, { metadata })
573
+
574
+ instance.greet('Grace')
575
+
576
+ // Logger is provided by ObservabilityPlugin at runtime: TestBed injects an auto-mock
577
+ expect(mocks.get(Logger).debug).toHaveBeenCalledWith('Greeting', { name: 'Grace', excited: false })
578
+ })`
579
+ : ''
580
+ return `${imports.join('\n')}
581
+
582
+ import { __ZNIGHT_METADATA__ as metadata } from '../src/.znight/container.generated.js'
583
+ import { GreetingService } from '../src/app/greeting.service.js'
584
+
585
+ // TestBed builds GreetingService with its generated factory and auto-mocks its dependencies
586
+ describe('GreetingService', () => {
587
+ test('greets', async () => {
588
+ const { instance } = await TestBed.create(GreetingService, {
589
+ metadata,${configOption}
590
+ })
591
+
592
+ expect(instance.greet('Ada')).toBe('${greeting}, Ada.')
593
+ expect(instance.greet('Ada', true)).toBe('${greeting}, Ada!')
594
+ })${loggerTest}
595
+ })
596
+ `
597
+ }
598
+
599
+ function greetingControllerTest(flags: Flags): string {
600
+ const imports = [
601
+ testImports(flags, 'afterEach, beforeEach, describe, expect, test'),
602
+ "import { HttpPlugin } from '@znight/http'",
603
+ ]
604
+ if (flags.messaging) {
605
+ imports.push("import { MessagingPlugin } from '@znight/messaging'")
606
+ imports.push("import { memory } from '@znight/messaging/memory'")
607
+ }
608
+ if (flags.observability) imports.push("import { ObservabilityPlugin } from '@znight/observability'")
609
+ imports.push("import { TestApp } from '@znight/testing'")
610
+ const plugins: string[] = []
611
+ // no metrics server (port 9464 once prom-client is installed): test files and `dev` run side by side
612
+ if (flags.observability) {
613
+ plugins.push("ObservabilityPlugin.configure({ logging: { level: 'silent' }, metrics: { server: false } })")
614
+ }
615
+ if (flags.messaging) plugins.push('MessagingPlugin.configure({ driver: memory() })')
616
+ // port 0: TestApp.inject() sends requests in memory, the server listens on a free port
617
+ plugins.push('HttpPlugin.configure({ port: 0 })')
618
+ const pluginList =
619
+ plugins.length === 1 ? `[${plugins[0]}]` : `[\n${plugins.map(plugin => ` ${plugin},`).join('\n')}\n ]`
620
+ const greeting = 'Hello'
621
+ return `${imports.join('\n')}
622
+
623
+ import { __ZNIGHT_METADATA__ as metadata } from '../src/.znight/container.generated.js'
624
+
625
+ // TestApp starts the application (without signal handlers) and sends requests in memory
626
+ describe('GreetingController', () => {
627
+ let app: TestApp
628
+
629
+ beforeEach(async () => {
630
+ app = await TestApp.create({
631
+ metadata,
632
+ plugins: ${pluginList},
633
+ })
634
+ })
635
+
636
+ afterEach(async () => {
637
+ await app.close()
638
+ })
639
+
640
+ test('GET /greetings/:name', async () => {
641
+ const response = await app.inject({ method: 'GET', url: '/greetings/Ada?excited=true' })
642
+
643
+ expect(response.status).toBe(200)
644
+ expect(response.json()).toEqual({ message: '${greeting}, Ada!' })
645
+ })
646
+
647
+ test('POST /greetings validates the body', async () => {
648
+ const created = await app.inject({ method: 'POST', url: '/greetings', body: { name: 'Grace' } })
649
+ expect(created.status).toBe(201)
650
+ expect(created.json()).toEqual({ message: '${greeting}, Grace.' })
651
+
652
+ const invalid = await app.inject({ method: 'POST', url: '/greetings', body: { name: '' } })
653
+ expect(invalid.status).toBe(400)
654
+ })
655
+ })
656
+ `
657
+ }
658
+
659
+ // ---------------------------------------------------------------------------------------------------
660
+ // README
661
+
662
+ function readme(options: ProjectOptions, flags: Flags): string {
663
+ const run = (script: string): string => runScript(flags.pm, script)
664
+ const znight = execBinary(flags.pm, 'znight')
665
+ const features = [
666
+ `- **HTTP** with \`@znight/http\` (${flags.adapter === 'node' ? 'built-in node:http server' : `${flags.adapter} adapter`})`,
667
+ flags.config ? '- **Configuration** with `@znight/config` (`src/app/app.config.ts`, `.env` files)' : undefined,
668
+ flags.observability
669
+ ? '- **Observability** with `@znight/observability` (structured logs, metrics, traces)'
670
+ : undefined,
671
+ flags.messaging ? '- **Messaging** with `@znight/messaging` (in-memory broker in `src/main.ts`)' : undefined,
672
+ flags.schedule ? '- **Scheduled jobs** with `@znight/schedule` (`src/app/heartbeat.jobs.ts`)' : undefined,
673
+ flags.openapi
674
+ ? '- **OpenAPI** with `@znight/openapi` (`/openapi.json`, API reference at `/docs`, options in `src/api.ts`)'
675
+ : undefined,
676
+ ].filter(line => line !== undefined)
677
+ const tree = [
678
+ 'src/',
679
+ '├─ main.ts # Application.create() + plugins + start()',
680
+ flags.openapi
681
+ ? '├─ api.ts # @OpenApiDocument: title, tags, security of openapi.json'
682
+ : undefined,
683
+ '├─ .znight/ # generated DI container (git-ignored)',
684
+ '└─ 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,
692
+ 'test/ # TestBed (unit) and TestApp (HTTP) tests',
693
+ 'vite.config.ts # znight() plugin: generates the container, server build',
694
+ 'znight.config.ts # compiler options',
695
+ ].filter(line => line !== undefined)
696
+ const configNote = flags.config
697
+ ? `
698
+ ## Configuration
699
+
700
+ \`AppConfig\` (\`src/app/app.config.ts\`) reads \`PORT\` and \`APP_GREETING\` from the environment or from
701
+ \`.env\` / \`.env.local\` files. Missing or invalid values stop the application at boot with every
702
+ problem listed.${
703
+ flags.bun
704
+ ? `
705
+
706
+ \`bunfig.toml\` turns Bun's own \`.env\` loading off (\`env = false\`): the files are read by
707
+ \`@znight/config\`, the same way on Node.js and Bun.`
708
+ : ''
709
+ }
710
+ `
711
+ : ''
712
+ const testNote = flags.bun
713
+ ? '`bun test` regenerates the container first (`bunfig.toml` preloads `@znight/compiler/bun`).'
714
+ : 'Vitest uses `vite.config.ts`, so the `znight()` plugin regenerates the container before the tests.'
715
+ const compilerNote = flags.bun
716
+ ? 'the `znight()` Vite plugin, the Bun preload of the tests and `znight generate`'
717
+ : 'the `znight()` Vite plugin (builds and Vitest) and `znight generate`'
718
+ return `# ${options.name}
719
+
720
+ A [Znight](https://github.com/znight-org/znight) application: dependency injection resolved at **build
721
+ time** — no \`reflect-metadata\`, errors reported by the compiler with their location.
722
+
723
+ ${features.join('\n')}
724
+
725
+ ## Scripts
726
+
727
+ | Command | Description |
728
+ | --- | --- |
729
+ | \`${run('dev')}\` | Build in watch mode and restart the application on every change |
730
+ | \`${run('build')}\` | Production build (\`dist/main.js\`) |
731
+ | \`${run('start')}\` | Run the production build |
732
+ | \`${run('test')}\` | Run the tests |
733
+ | \`${run('typecheck')}\` | Generate the container, then type-check with \`tsc\` |
734
+ | \`${run('generate')}\` | Generate \`src/.znight/container.generated.ts\` (\`znight generate --check\` in CI) |
735
+ ${
736
+ flags.openapi
737
+ ? `| \`${run('openapi')}\` | Write \`openapi.json\` (commit it; \`${run('openapi:check')}\` in CI) |
738
+ `
739
+ : ''
740
+ }
741
+ \`\`\`sh
742
+ ${run('dev')}
743
+ curl localhost:3000/health
744
+ 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' : ''}
746
+ \`\`\`
747
+
748
+ ## Project structure
749
+
750
+ \`\`\`txt
751
+ ${tree.join('\n')}
752
+ \`\`\`
753
+
754
+ Providers are discovered by the compiler from their decorators: there are no modules to declare.
755
+ Its options live in \`znight.config.ts\`, read by ${compilerNote}.
756
+ Add code with the generators:
757
+
758
+ \`\`\`sh
759
+ ${znight} g service users/user # src/users/user.service.ts
760
+ ${znight} g controller users/user # src/users/user.controller.ts${flags.schedule ? `\n${znight} g job reports/digest # src/reports/digest.jobs.ts` : ''}
761
+ \`\`\`
762
+ ${configNote}
763
+ ## Tests
764
+
765
+ ${testNote}
766
+
767
+ ## Learn more
768
+
769
+ - [Znight documentation](https://github.com/znight-org/znight#readme)
770
+ `
771
+ }