@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.
- package/LICENSE +21 -0
- package/README.md +482 -2
- package/bin/znight.mjs +17 -0
- package/dist/cli.d.mts +1 -0
- package/dist/cli.mjs +13 -0
- package/dist/cli.mjs.map +1 -0
- package/dist/dev/orphan-guard.d.mts +1 -0
- package/dist/dev/orphan-guard.mjs +47 -0
- package/dist/dev/orphan-guard.mjs.map +1 -0
- package/dist/index.d.mts +679 -0
- package/dist/index.mjs +1 -0
- package/dist/main-BWsiUsxN.mjs +3865 -0
- package/dist/main-BWsiUsxN.mjs.map +1 -0
- package/dist/orphan-Dn-2xMBd.mjs +45 -0
- package/dist/orphan-Dn-2xMBd.mjs.map +1 -0
- package/package.json +71 -4
- package/src/cli.ts +11 -0
- package/src/command.ts +217 -0
- package/src/commands/build.ts +159 -0
- package/src/commands/dev.ts +249 -0
- package/src/commands/generate.ts +304 -0
- package/src/commands/new.ts +245 -0
- package/src/commands/openapi.ts +160 -0
- package/src/commands/schematic.ts +110 -0
- package/src/commands/shared.ts +106 -0
- package/src/commands/start.ts +79 -0
- package/src/context.ts +126 -0
- package/src/dev/orphan-guard.ts +49 -0
- package/src/dev/orphan.ts +74 -0
- package/src/dev/supervisor.ts +175 -0
- package/src/errors.ts +64 -0
- package/src/index.ts +73 -0
- package/src/integrations/compiler.ts +360 -0
- package/src/integrations/openapi.ts +97 -0
- package/src/integrations/process.ts +180 -0
- package/src/integrations/prompts.ts +108 -0
- package/src/integrations/resolve.ts +38 -0
- package/src/integrations/vite.ts +362 -0
- package/src/integrations/watch.ts +243 -0
- package/src/main.ts +195 -0
- package/src/names.ts +106 -0
- package/src/output.ts +110 -0
- package/src/runtime.ts +86 -0
- package/src/scaffold/create.ts +65 -0
- package/src/scaffold/options.ts +103 -0
- package/src/scaffold/templates.ts +771 -0
- package/src/scaffold/versions.ts +25 -0
- package/src/schematics/index.ts +422 -0
- 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
|
+
}
|