@znight/cli 0.0.0-stage → 0.1.1

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,25 @@
1
+ /**
2
+ * Version ranges of the third-party dependencies of new projects. `@znight/*` packages use
3
+ * `^<version of @znight/cli>` (they are released in lockstep).
4
+ */
5
+ export const DEPENDENCY_VERSIONS: Readonly<Record<string, string>> = {
6
+ '@hono/node-server': '^2.1.5',
7
+ '@types/bun': '^1.3.0',
8
+ '@types/express': '^5.0.6',
9
+ '@types/node': '^24.0.0',
10
+ express: '^5.3.0',
11
+ fastify: '^5.12.7',
12
+ hono: '^4.13.14',
13
+ 'light-my-request': '^6.8.0',
14
+ typescript: '^7.0.0',
15
+ vite: '^8.0.0',
16
+ vitest: '^4.1.0',
17
+ zod: '^4.6.0',
18
+ }
19
+
20
+ /** Range of a third-party dependency. */
21
+ export function versionOf(name: string): string {
22
+ const version = DEPENDENCY_VERSIONS[name]
23
+ if (version === undefined) throw new Error(`[znight] no version declared for ${name}`)
24
+ return version
25
+ }
@@ -0,0 +1,422 @@
1
+ /**
2
+ * Code generators of `znight g <schematic> <name>`: one file per schematic, kebab-case file names
3
+ * (`user-profile.service.ts`), PascalCase classes (`UserProfileService`). Pure: {@link planSchematic}
4
+ * computes the file, the command writes it.
5
+ */
6
+ import { dirname, isAbsolute, join, normalize, relative } from 'node:path'
7
+
8
+ import { camelCase, constantCase, kebabCase, pascalCase } from '../names.ts'
9
+
10
+ /** Available schematics. */
11
+ export type SchematicName =
12
+ | 'service'
13
+ | 'controller'
14
+ | 'consumer'
15
+ | 'config'
16
+ | 'guard'
17
+ | 'interceptor'
18
+ | 'factory'
19
+ | 'job'
20
+
21
+ /** Names derived from the user input. */
22
+ export interface SchematicNames {
23
+ /** `user-profile` */
24
+ readonly kebab: string
25
+ /** `UserProfile` */
26
+ readonly pascal: string
27
+ /** `userProfile` */
28
+ readonly camel: string
29
+ /** `UserProfileService` */
30
+ readonly className: string
31
+ /**
32
+ * Dotted name of the element in the application: the directories of its file relative to `src/`
33
+ * (or to the project root, for a `--path` outside `src/`) and its kebab name, without the directory
34
+ * named after it (`reports.digest` for `src/reports/digest.jobs.ts`, `digest` for
35
+ * `src/digest/digest.jobs.ts`). Unlike `kebab`, it tells apart elements of the same name in different
36
+ * directories: the default name of a job (job names are unique in the application).
37
+ */
38
+ readonly qualified: string
39
+ }
40
+
41
+ /** Project facts that change the generated code. */
42
+ export interface SchematicContext {
43
+ /** The project depends on `zod`: consumers get a message schema. */
44
+ readonly zod: boolean
45
+ /** `interceptor` only: a method interceptor (`@Intercept`, AOP) instead of an HTTP interceptor. */
46
+ readonly aop: boolean
47
+ }
48
+
49
+ /** A schematic. */
50
+ export interface Schematic {
51
+ readonly name: SchematicName
52
+ readonly aliases: readonly string[]
53
+ readonly description: string
54
+ /** File and class suffix (`service` → `user.service.ts`, `UserService`). */
55
+ readonly suffix: string
56
+ render(names: SchematicNames, context: SchematicContext): string
57
+ /** What to do next, printed after the file is created. */
58
+ hint?(names: SchematicNames, context: SchematicContext): string | undefined
59
+ }
60
+
61
+ const service: Schematic = {
62
+ name: 'service',
63
+ aliases: ['s'],
64
+ description: 'An @Injectable() provider',
65
+ suffix: 'service',
66
+ render: names => `import { Injectable } from '@znight/core'
67
+
68
+ @Injectable()
69
+ export class ${names.className} {
70
+ // Declare dependencies as constructor parameters: the compiler resolves them from their types.
71
+ // constructor(private readonly repository: ${names.pascal}Repository) {}
72
+ }
73
+ `,
74
+ }
75
+
76
+ const controller: Schematic = {
77
+ name: 'controller',
78
+ aliases: ['co'],
79
+ description: 'An HTTP @Controller() with two routes',
80
+ suffix: 'controller',
81
+ render: names => `import { Controller, Get, Param } from '@znight/http'
82
+
83
+ @Controller('/${names.kebab}')
84
+ export class ${names.className} {
85
+ /** GET /${names.kebab} */
86
+ @Get()
87
+ list(): unknown[] {
88
+ return []
89
+ }
90
+
91
+ /** GET /${names.kebab}/:id */
92
+ @Get('/:id')
93
+ find(@Param('id') id: string): { id: string } {
94
+ return { id }
95
+ }
96
+ }
97
+ `,
98
+ }
99
+
100
+ const consumer: Schematic = {
101
+ name: 'consumer',
102
+ aliases: ['cs'],
103
+ description: 'A message @Consumer() (@znight/messaging)',
104
+ suffix: 'consumer',
105
+ render: (names, context) =>
106
+ context.zod
107
+ ? `import { Consumer, Message, OnMessage, Retry } from '@znight/messaging'
108
+ import { z } from 'zod'
109
+
110
+ export const ${names.pascal}Message = z.object({
111
+ id: z.string(),
112
+ })
113
+ export type ${names.pascal}Message = z.output<typeof ${names.pascal}Message>
114
+
115
+ /**
116
+ * Consumes the "${names.kebab}" queue.
117
+ * Delivery is at-least-once: make the handler idempotent. Throw to retry, throw a NonRetryableError
118
+ * to dead-letter the message right away.
119
+ */
120
+ @Consumer('${names.kebab}')
121
+ export class ${names.className} {
122
+ @OnMessage()
123
+ @Retry({ attempts: 3, backoff: 'exponential', delay: 1000 })
124
+ handle(@Message(${names.pascal}Message) message: ${names.pascal}Message): void {
125
+ console.log('received', message.id)
126
+ }
127
+ }
128
+ `
129
+ : `import { Consumer, Ctx, type InboundMessage, OnMessage, Retry } from '@znight/messaging'
130
+
131
+ /**
132
+ * Consumes the "${names.kebab}" queue.
133
+ * Delivery is at-least-once: make the handler idempotent. Throw to retry, throw a NonRetryableError
134
+ * to dead-letter the message right away.
135
+ */
136
+ @Consumer('${names.kebab}')
137
+ export class ${names.className} {
138
+ @OnMessage()
139
+ @Retry({ attempts: 3, backoff: 'exponential', delay: 1000 })
140
+ handle(@Ctx() message: InboundMessage): void {
141
+ console.log('received', message.id, message.payload)
142
+ }
143
+ }
144
+ `,
145
+ hint: () => 'consumers run with MessagingPlugin.configure({ driver }) in src/main.ts',
146
+ }
147
+
148
+ const config: Schematic = {
149
+ name: 'config',
150
+ aliases: ['cf'],
151
+ description: 'A @Config() class read from the environment (@znight/config)',
152
+ suffix: 'config',
153
+ render: names => `import { Config } from '@znight/config'
154
+
155
+ /**
156
+ * Settings read from the ${constantCase(names.camel)}_* environment variables (or .env files).
157
+ * Validated at boot by ConfigPlugin: the type of a property selects its coercion, an initializer
158
+ * makes it optional.
159
+ */
160
+ @Config('${names.camel}')
161
+ export class ${names.className} {
162
+ /** ${constantCase(names.camel)}_ENABLED */
163
+ enabled = true
164
+ }
165
+ `,
166
+ hint: names => `inject ${names.className} anywhere; values are loaded by ConfigPlugin.configure() in src/main.ts`,
167
+ }
168
+
169
+ const guard: Schematic = {
170
+ name: 'guard',
171
+ aliases: ['gu'],
172
+ description: 'An HTTP guard (CanActivate)',
173
+ suffix: 'guard',
174
+ render: names => `import type { CanActivate, HttpContext } from '@znight/http'
175
+
176
+ import { Injectable } from '@znight/core'
177
+
178
+ @Injectable()
179
+ export class ${names.className} implements CanActivate {
180
+ // The route decisions this guard enforces (createRouteDecorator groups, see "Route decisions" in
181
+ // the @znight/http README): the boot fails when a route of a required group has no guard
182
+ // enforcing its decision.
183
+ // static readonly enforces = [Public, Roles]
184
+
185
+ canActivate(ctx: HttpContext): boolean | Promise<boolean> {
186
+ // Return false (403 Forbidden) or throw an HttpException (UnauthorizedException...) to reject
187
+ // the request, e.g. return ctx.header('authorization') !== undefined
188
+ return true
189
+ }
190
+ }
191
+ `,
192
+ hint: names =>
193
+ `use it with @UseGuard(${names.className}) on a controller or a route, or HttpPlugin.configure({ guards: [${names.className}] })`,
194
+ }
195
+
196
+ const interceptor: Schematic = {
197
+ name: 'interceptor',
198
+ aliases: ['itc'],
199
+ description: 'An HTTP interceptor (HttpInterceptor), or a method interceptor with --aop',
200
+ suffix: 'interceptor',
201
+ render: (names, context) =>
202
+ context.aop
203
+ ? `import { Injectable, type MethodInterceptor, type MethodInvocation } from '@znight/core'
204
+
205
+ @Injectable()
206
+ export class ${names.className} implements MethodInterceptor {
207
+ intercept(invocation: MethodInvocation): unknown {
208
+ // Runs around every intercepted call. For a synchronous method, return synchronously.
209
+ return invocation.proceed()
210
+ }
211
+ }
212
+ `
213
+ : `import type { CallHandler, HttpContext, HttpInterceptor } from '@znight/http'
214
+
215
+ import { Injectable } from '@znight/core'
216
+
217
+ @Injectable()
218
+ export class ${names.className} implements HttpInterceptor {
219
+ async intercept(ctx: HttpContext, next: CallHandler): Promise<unknown> {
220
+ const started = performance.now()
221
+ try {
222
+ // the handler result (before serialisation): return it, transform it or replace it
223
+ return await next()
224
+ } finally {
225
+ ctx.setHeader('server-timing', \`app;dur=\${(performance.now() - started).toFixed(1)}\`)
226
+ }
227
+ }
228
+ }
229
+ `,
230
+ hint: (names, context) =>
231
+ context.aop
232
+ ? `use it with @Intercept(${names.className}) on any provider method`
233
+ : `use it with @UseInterceptor(${names.className}) on a controller or a route, or HttpPlugin.configure({ interceptors: [${names.className}] })`,
234
+ }
235
+
236
+ const factory: Schematic = {
237
+ name: 'factory',
238
+ aliases: ['f'],
239
+ description: 'A @Factory() whose @Provides() methods provide objects you do not own',
240
+ suffix: 'factory',
241
+ render: names => `import { Factory, Provides } from '@znight/core'
242
+
243
+ /** Replace with the type you provide: a database pool, an SDK client... */
244
+ export class ${names.pascal}Client {
245
+ close(): void {}
246
+ }
247
+
248
+ /**
249
+ * @Provides methods create providers for classes you cannot decorate. Their parameters are injected,
250
+ * their explicit return type is the provided type (Promise<T> for an async initialisation).
251
+ */
252
+ @Factory()
253
+ export class ${names.className} {
254
+ @Provides({ dispose: 'close' })
255
+ create${names.pascal}Client(): ${names.pascal}Client {
256
+ return new ${names.pascal}Client()
257
+ }
258
+ }
259
+ `,
260
+ }
261
+
262
+ const job: Schematic = {
263
+ name: 'job',
264
+ aliases: ['j'],
265
+ description: 'Scheduled jobs: @Cron, @Interval, @Timeout methods (@znight/schedule)',
266
+ suffix: 'jobs',
267
+ render: names => `import { Cron, type JobContext } from '@znight/schedule'
268
+
269
+ /**
270
+ * Scheduled jobs. A class with @Cron, @Interval or @Timeout methods becomes a singleton provider
271
+ * (no decorator needed): declare its dependencies as constructor parameters.
272
+ */
273
+ export class ${names.className} {
274
+ /** Every day at 03:00 UTC (set timeZone to follow a local clock). A run still running is skipped. */
275
+ @Cron('0 3 * * *', { name: '${names.qualified}.daily' })
276
+ daily(ctx: JobContext): void {
277
+ // checkpoint: throws when the run is aborted (shutdown, maxDuration or a lost lock)
278
+ ctx.signal.throwIfAborted()
279
+ }
280
+ }
281
+ `,
282
+ hint: () =>
283
+ 'jobs run with SchedulePlugin.configure() in src/main.ts; test them with createTestClock() from @znight/schedule/testing',
284
+ }
285
+
286
+ /** Every schematic, in display order. */
287
+ export const SCHEMATICS: readonly Schematic[] = [
288
+ service,
289
+ controller,
290
+ consumer,
291
+ config,
292
+ guard,
293
+ interceptor,
294
+ factory,
295
+ job,
296
+ ]
297
+
298
+ /** Finds a schematic by name or alias. */
299
+ export function findSchematic(name: string): Schematic | undefined {
300
+ const key = name.toLowerCase()
301
+ return SCHEMATICS.find(schematic => schematic.name === key || schematic.aliases.includes(key))
302
+ }
303
+
304
+ /** Parsed `<name>` argument: optional directories and the base name. */
305
+ export interface ParsedSchematicName {
306
+ /** Directory segments (`users/admin` → `['users']`), kebab-cased. */
307
+ readonly directories: readonly string[]
308
+ readonly names: SchematicNames
309
+ }
310
+
311
+ /**
312
+ * Parses `users/user-profile`, `UserProfile`, `user_profile.service.ts`... A trailing suffix
313
+ * matching the schematic (`user-service`, `UserService`, `user.service`) is dropped.
314
+ *
315
+ * @throws Error with a user-facing message when the name is invalid.
316
+ */
317
+ export function parseSchematicName(input: string, schematic: Schematic): ParsedSchematicName {
318
+ const segments = input
319
+ .trim()
320
+ .split(/[\\/]+/)
321
+ .filter(segment => segment !== '')
322
+ if (segments.length === 0) throw new Error('the name cannot be empty')
323
+ if (isAbsolute(input) || segments.some(segment => segment === '.' || segment === '..')) {
324
+ throw new Error(`invalid name "${input}": use a path relative to src/ without "." or ".." segments`)
325
+ }
326
+ const last = (segments.at(-1) ?? '').replace(/\.[cm]?tsx?$/, '')
327
+ let words = kebabCase(last)
328
+ for (const suffix of [`-${schematic.suffix}`, `.${schematic.suffix}`]) {
329
+ if (words.endsWith(suffix) && words !== suffix.slice(1)) words = words.slice(0, -suffix.length)
330
+ }
331
+ if (words === '') throw new Error(`invalid name "${input}"`)
332
+ if (!/^[a-z]/.test(words)) throw new Error(`invalid name "${input}": it must start with a letter`)
333
+ const directories = segments.slice(0, -1).map(segment => kebabCase(segment))
334
+ if (directories.some(directory => directory === '')) throw new Error(`invalid name "${input}"`)
335
+ const pascal = pascalCase(words)
336
+ return {
337
+ directories,
338
+ names: {
339
+ kebab: words,
340
+ pascal,
341
+ camel: camelCase(words),
342
+ className: `${pascal}${pascalCase(schematic.suffix)}`,
343
+ qualified: qualifiedName(directories, words),
344
+ },
345
+ }
346
+ }
347
+
348
+ /** `reports` + `digest` → `reports.digest`; the directory named after the element is left out. */
349
+ function qualifiedName(directories: readonly string[], kebab: string): string {
350
+ const segments = directories.at(-1) === kebab ? directories.slice(0, -1) : directories
351
+ return [...segments, kebab].join('.')
352
+ }
353
+
354
+ /**
355
+ * Directories of `directory` relative to `<root>/src`, or to `root` outside `src/`, kebab-cased
356
+ * (`undefined` outside the project).
357
+ */
358
+ function sourceDirectories(root: string, directory: string): string[] | undefined {
359
+ for (const base of [join(root, 'src'), root]) {
360
+ const path = relative(base, directory)
361
+ if (isAbsolute(path) || path === '..' || /^\.\.[\\/]/.test(path)) continue
362
+ return path
363
+ .split(/[\\/]+/)
364
+ .map(segment => kebabCase(segment))
365
+ .filter(segment => segment !== '')
366
+ }
367
+ return undefined
368
+ }
369
+
370
+ /** Where and what to generate. */
371
+ export interface SchematicPlan {
372
+ readonly schematic: Schematic
373
+ readonly names: SchematicNames
374
+ /** Absolute path of the file. */
375
+ readonly file: string
376
+ readonly content: string
377
+ readonly hint: string | undefined
378
+ }
379
+
380
+ /** Inputs of {@link planSchematic}. */
381
+ export interface PlanSchematicOptions {
382
+ readonly schematic: Schematic
383
+ readonly name: string
384
+ /** Project root (contains `src/`). */
385
+ readonly root: string
386
+ /** Directory of `--path` (absolute), replacing `<root>/src`. */
387
+ readonly path?: string | undefined
388
+ /** Do not create a directory named after the element. */
389
+ readonly flat?: boolean
390
+ readonly context: SchematicContext
391
+ }
392
+
393
+ /**
394
+ * Computes the file of a schematic:
395
+ *
396
+ * | Input | File |
397
+ * | ---------------------------- | ------------------------------------- |
398
+ * | `service user` | `src/user/user.service.ts` |
399
+ * | `service users/user` | `src/users/user.service.ts` |
400
+ * | `service user --flat` | `src/user.service.ts` |
401
+ * | `service user --path lib/x` | `lib/x/user.service.ts` |
402
+ */
403
+ export function planSchematic(options: PlanSchematicOptions): SchematicPlan {
404
+ const { schematic, context } = options
405
+ const parsed = parseSchematicName(options.name, schematic)
406
+ const base = options.path ?? join(options.root, 'src')
407
+ let directories = [...parsed.directories]
408
+ if (directories.length === 0 && options.path === undefined && options.flat !== true)
409
+ directories = [parsed.names.kebab]
410
+ const file = normalize(join(base, ...directories, `${parsed.names.kebab}.${schematic.suffix}.ts`))
411
+ // --path: the location of the file qualifies the name (src/emails/ → emails.digest)
412
+ const located = options.path === undefined ? undefined : sourceDirectories(options.root, dirname(file))
413
+ const names =
414
+ located === undefined ? parsed.names : { ...parsed.names, qualified: qualifiedName(located, parsed.names.kebab) }
415
+ return {
416
+ schematic,
417
+ names,
418
+ file,
419
+ content: schematic.render(names, context),
420
+ hint: schematic.hint?.(names, context),
421
+ }
422
+ }
package/src/version.ts ADDED
@@ -0,0 +1,20 @@
1
+ import { readFileSync } from 'node:fs'
2
+
3
+ let cached: string | undefined
4
+
5
+ /**
6
+ * Version of `@znight/cli`, read from its `package.json` (one level above both `src/` and `dist/`).
7
+ * Projects created by `znight new` depend on `@znight/*` packages at `^<version>`.
8
+ */
9
+ export function cliVersion(): string {
10
+ if (cached === undefined) {
11
+ try {
12
+ const manifest: unknown = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8'))
13
+ const version = (manifest as { version?: unknown }).version
14
+ cached = typeof version === 'string' ? version : '0.0.0'
15
+ } catch {
16
+ cached = '0.0.0'
17
+ }
18
+ }
19
+ return cached
20
+ }