@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,360 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The `@znight/compiler` programmatic API (spec §6.2), loaded lazily: commands that do not compile
|
|
3
|
+
* (`new`, `g`, `start`) never load it. Commands use {@link CompilerApi} so tests can inject a fake.
|
|
4
|
+
*
|
|
5
|
+
* The CLI only relies on the documented shapes below (structural types, no type import from the
|
|
6
|
+
* compiler), so any `@znight/compiler` of the same major version works.
|
|
7
|
+
*/
|
|
8
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
|
|
9
|
+
import { dirname, relative, resolve } from 'node:path'
|
|
10
|
+
|
|
11
|
+
import { CliError } from '../errors.ts'
|
|
12
|
+
import { createColors } from '../output.ts'
|
|
13
|
+
import { importFromProject } from './resolve.ts'
|
|
14
|
+
|
|
15
|
+
/** Location of a diagnostic (`Diagnostic.location`, §6.2). */
|
|
16
|
+
export interface DiagnosticLocation {
|
|
17
|
+
readonly file: string
|
|
18
|
+
readonly line: number
|
|
19
|
+
readonly column: number
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** A compiler diagnostic (§6.2). */
|
|
23
|
+
export interface Diagnostic {
|
|
24
|
+
readonly code: string
|
|
25
|
+
readonly severity: 'error' | 'warning'
|
|
26
|
+
readonly message: string
|
|
27
|
+
readonly location?: DiagnosticLocation | undefined
|
|
28
|
+
readonly hints?: readonly string[] | undefined
|
|
29
|
+
readonly related?: readonly { readonly message: string; readonly location: DiagnosticLocation }[] | undefined
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** A string alias of Vite's `resolve.alias` (`CompileOptions.aliases` of the compiler). */
|
|
33
|
+
export interface StringAliasLike {
|
|
34
|
+
readonly find: string
|
|
35
|
+
readonly replacement: string
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Compiler options (`ZnightOptions`, §6.2, plus the `CompileOptions` the Vite plugin derives from the
|
|
40
|
+
* Vite config). The CLI reads `root`, `include` and `outFile`; the others are passed through.
|
|
41
|
+
*/
|
|
42
|
+
export interface ZnightOptions {
|
|
43
|
+
readonly root?: string | undefined
|
|
44
|
+
readonly include?: readonly string[] | undefined
|
|
45
|
+
readonly outFile?: string | undefined
|
|
46
|
+
/** String aliases of the Vite config (`resolve.alias`). */
|
|
47
|
+
readonly aliases?: readonly StringAliasLike[] | undefined
|
|
48
|
+
readonly [option: string]: unknown
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** What the CLI reads from a compile result (`CompileResult`, §6.2). */
|
|
52
|
+
export interface CompileResultLike {
|
|
53
|
+
/** Content of the generated module (with errors: a module throwing them, or empty). */
|
|
54
|
+
readonly code: string
|
|
55
|
+
/** Absolute path of the generated module. */
|
|
56
|
+
readonly outFile: string
|
|
57
|
+
readonly diagnostics: readonly Diagnostic[]
|
|
58
|
+
readonly stats?: { readonly files?: number; readonly providers?: number; readonly durationMs?: number } | undefined
|
|
59
|
+
/** Files the result depends on (`createCompiler()` results). */
|
|
60
|
+
readonly watchFiles?: readonly string[] | undefined
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Result of {@link CompilerApi.generate}. */
|
|
64
|
+
export interface GenerateResultLike extends CompileResultLike {
|
|
65
|
+
/** Whether the generated module was written (its content changed). */
|
|
66
|
+
readonly written: boolean
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Result of {@link CompilerApi.check}. */
|
|
70
|
+
export interface CheckResultLike extends CompileResultLike {
|
|
71
|
+
/** The generated files on disk are missing or differ from what would be generated. */
|
|
72
|
+
readonly stale: boolean
|
|
73
|
+
/**
|
|
74
|
+
* The generated files that are missing or out of date (the module, and `znight.providers.json` in
|
|
75
|
+
* library mode). Empty when `stale` is `false`; empty or absent with `stale` when the compiler
|
|
76
|
+
* does not say which file is stale.
|
|
77
|
+
*/
|
|
78
|
+
readonly staleFiles?: readonly string[] | undefined
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** Files changed since the previous compile of an {@link IncrementalCompiler}. */
|
|
82
|
+
export interface CompilerChanges {
|
|
83
|
+
readonly changed?: readonly string[]
|
|
84
|
+
readonly added?: readonly string[]
|
|
85
|
+
readonly removed?: readonly string[]
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** Kind of a file system event (`ChangeKind` of the compiler). */
|
|
89
|
+
export type ChangeKind = 'changed' | 'added' | 'removed'
|
|
90
|
+
|
|
91
|
+
/** A compiler keeping its parse cache between compiles (`createCompiler()`), for watch mode. */
|
|
92
|
+
export interface IncrementalCompiler {
|
|
93
|
+
/** Compiles (only `changes` are re-read, everything without) and writes the module when it changed. */
|
|
94
|
+
generate(changes?: CompilerChanges): Promise<GenerateResultLike>
|
|
95
|
+
/** Whether `file` is the generated module (watchers ignore it). */
|
|
96
|
+
isOutFile(file: string): boolean
|
|
97
|
+
/**
|
|
98
|
+
* Directories where a created or deleted source changes the scan (`includeRoots()` of the
|
|
99
|
+
* compiler: the bases of the `include` globs in the project and in every source library, known once
|
|
100
|
+
* compiled), to watch recursively. The project root when the compiler does not tell.
|
|
101
|
+
*/
|
|
102
|
+
includeRoots(): readonly string[]
|
|
103
|
+
/**
|
|
104
|
+
* Whether a file event can change the next compile (`isRelevantChange()` of the compiler): `watched`
|
|
105
|
+
* are the `watchFiles` of the last compile, `roots` its {@link includeRoots}. When the compiler does
|
|
106
|
+
* not tell, every event but the generated module is.
|
|
107
|
+
*/
|
|
108
|
+
isRelevantChange(kind: ChangeKind, file: string, watched: ReadonlySet<string>, roots: readonly string[]): boolean
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** Options of {@link CompilerApi.formatDiagnostics}. */
|
|
112
|
+
export interface FormatDiagnosticsOptions {
|
|
113
|
+
readonly color?: boolean
|
|
114
|
+
readonly cwd?: string
|
|
115
|
+
/** Append the summary line (`Build failed with 2 errors`). */
|
|
116
|
+
readonly summary?: boolean
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** Options read from `znight.config.*` (`loadZnightConfig()`, §6.3). */
|
|
120
|
+
export interface ZnightConfigLike {
|
|
121
|
+
/** The config file, `undefined` when the project has none. */
|
|
122
|
+
readonly file: string | undefined
|
|
123
|
+
/** Its options, `root` resolved against the config file directory. */
|
|
124
|
+
readonly options: ZnightOptions
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** The part of `@znight/compiler` used by the CLI. */
|
|
128
|
+
export interface CompilerApi {
|
|
129
|
+
/** Compiles and writes the generated module when its content changed (`generate()`). */
|
|
130
|
+
generate(options: ZnightOptions): Promise<GenerateResultLike>
|
|
131
|
+
/** Compiles without writing and compares with the files on disk (`generate({ check: true })`). */
|
|
132
|
+
check(options: ZnightOptions): Promise<CheckResultLike>
|
|
133
|
+
/** An incremental compiler (`createCompiler()`). */
|
|
134
|
+
createIncremental(options: ZnightOptions): IncrementalCompiler
|
|
135
|
+
/** Code frames for diagnostics (`formatDiagnostics()`). */
|
|
136
|
+
formatDiagnostics(diagnostics: readonly Diagnostic[], options?: FormatDiagnosticsOptions): string
|
|
137
|
+
/**
|
|
138
|
+
* The options of `znight.config.*` (or of the file named by `ZNIGHT_CONFIG`) in `root`
|
|
139
|
+
* (`loadZnightConfig()`). Absent for compilers without it.
|
|
140
|
+
*/
|
|
141
|
+
loadConfig?(root: string): Promise<ZnightConfigLike>
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Not a literal in `import()`: the CLI type-checks and bundles without the compiler (an optional
|
|
146
|
+
* peer dependency), whose version is the project's.
|
|
147
|
+
*/
|
|
148
|
+
const COMPILER_PACKAGE: string = '@znight/compiler'
|
|
149
|
+
|
|
150
|
+
/** A compile result as returned by any `@znight/compiler` version (extra fields are optional). */
|
|
151
|
+
interface ModuleResult extends CompileResultLike {
|
|
152
|
+
/** The disk copy of the module (`code` before it existed; they differ in `build` mode only). */
|
|
153
|
+
readonly diskCode?: string
|
|
154
|
+
readonly written?: readonly string[]
|
|
155
|
+
readonly stale?: boolean
|
|
156
|
+
/** The stale generated files (check mode), when the compiler tells. */
|
|
157
|
+
readonly staleFiles?: readonly string[]
|
|
158
|
+
/** Library mode: `znight.providers.json` (`library` of a `CompileResult`). */
|
|
159
|
+
readonly library?: { readonly file?: unknown; readonly content?: unknown } | undefined
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/** The parts of a `ZnightCompiler` (`createCompiler()`) the CLI uses; all optional. */
|
|
163
|
+
export interface ModuleIncrementalCompiler {
|
|
164
|
+
generate?: (changes?: CompilerChanges, options?: { check?: boolean }) => Promise<ModuleResult>
|
|
165
|
+
isOutFile?: (file: string) => boolean
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/** The shape of the `@znight/compiler` module (every export optional: partial versions are adapted). */
|
|
169
|
+
export interface CompilerModule {
|
|
170
|
+
compile?: (options?: ZnightOptions) => Promise<ModuleResult>
|
|
171
|
+
generate?: (options?: ZnightOptions & { check?: boolean }) => Promise<ModuleResult>
|
|
172
|
+
createCompiler?: (options?: ZnightOptions) => ModuleIncrementalCompiler
|
|
173
|
+
formatDiagnostics?: (diagnostics: readonly Diagnostic[], options?: FormatDiagnosticsOptions) => string
|
|
174
|
+
loadZnightConfig?: (cwd?: string) => Promise<ZnightConfigLike>
|
|
175
|
+
/** The watch roots of a compiler (`includeRoots(compiler)`). */
|
|
176
|
+
includeRoots?: (compiler: ModuleIncrementalCompiler) => readonly string[]
|
|
177
|
+
/** Whether a file event can change the next compile of a compiler (`isRelevantChange(compiler, ...)`). */
|
|
178
|
+
isRelevantChange?: (
|
|
179
|
+
compiler: ModuleIncrementalCompiler,
|
|
180
|
+
kind: ChangeKind,
|
|
181
|
+
file: string,
|
|
182
|
+
watched: ReadonlySet<string>,
|
|
183
|
+
roots: readonly string[],
|
|
184
|
+
) => boolean
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* Loads `@znight/compiler` as installed in the project (falls back to the CLI's own peer) and adapts
|
|
189
|
+
* it to {@link CompilerApi}.
|
|
190
|
+
*/
|
|
191
|
+
export async function loadCompiler(root: string): Promise<CompilerApi> {
|
|
192
|
+
let module: CompilerModule
|
|
193
|
+
try {
|
|
194
|
+
module = (await importFromProject(COMPILER_PACKAGE, root, () => import(COMPILER_PACKAGE))) as CompilerModule
|
|
195
|
+
} catch (error) {
|
|
196
|
+
throw new CliError('Cannot load @znight/compiler', {
|
|
197
|
+
cause: error,
|
|
198
|
+
hints: ['install it in your project: npm install -D @znight/compiler vite'],
|
|
199
|
+
})
|
|
200
|
+
}
|
|
201
|
+
return adaptCompilerModule(module)
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
function readText(file: string): string | undefined {
|
|
205
|
+
try {
|
|
206
|
+
return readFileSync(file, 'utf8')
|
|
207
|
+
} catch {
|
|
208
|
+
return undefined
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* Adapts a loaded compiler module: `generate()` / `generate({ check })` / `createCompiler()` when
|
|
214
|
+
* available, otherwise the same behaviour built on `compile()`.
|
|
215
|
+
*/
|
|
216
|
+
export function adaptCompilerModule(module: CompilerModule): CompilerApi {
|
|
217
|
+
const { compile, generate, createCompiler, loadZnightConfig } = module
|
|
218
|
+
if (typeof compile !== 'function' && typeof generate !== 'function') {
|
|
219
|
+
throw new CliError('This version of @znight/compiler exposes neither compile() nor generate()', {
|
|
220
|
+
hints: ['upgrade @znight/compiler to the version of @znight/cli'],
|
|
221
|
+
})
|
|
222
|
+
}
|
|
223
|
+
const format = module.formatDiagnostics ?? fallbackFormatDiagnostics
|
|
224
|
+
|
|
225
|
+
/** Pure compile: `compile()`, else `generate({ check: true })` (never writes). */
|
|
226
|
+
const compileOnly = (options: ZnightOptions): Promise<ModuleResult> =>
|
|
227
|
+
typeof compile === 'function'
|
|
228
|
+
? compile(options)
|
|
229
|
+
: (generate as NonNullable<CompilerModule['generate']>)({ ...options, check: true })
|
|
230
|
+
|
|
231
|
+
/** Write-if-changed on top of a compile result, when the compiler did not write itself. */
|
|
232
|
+
const written = (result: ModuleResult, before: string | undefined): GenerateResultLike => ({
|
|
233
|
+
...result,
|
|
234
|
+
written: Array.isArray(result.written) ? result.written.length > 0 : before !== readText(result.outFile),
|
|
235
|
+
})
|
|
236
|
+
|
|
237
|
+
const generateOnce = async (options: ZnightOptions): Promise<GenerateResultLike> => {
|
|
238
|
+
if (typeof generate === 'function') {
|
|
239
|
+
const before = readText(resolveOutFile(options))
|
|
240
|
+
return written(await generate(options), before)
|
|
241
|
+
}
|
|
242
|
+
const result = await compileOnly(options)
|
|
243
|
+
const changed = !hasErrors(result.diagnostics) && writeIfChanged(result.outFile, result.diskCode ?? result.code)
|
|
244
|
+
return { ...result, written: changed }
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
return {
|
|
248
|
+
generate: generateOnce,
|
|
249
|
+
check: async options => {
|
|
250
|
+
const result =
|
|
251
|
+
typeof generate === 'function' ? await generate({ ...options, check: true }) : await compileOnly(options)
|
|
252
|
+
const staleFiles = Array.isArray(result.staleFiles) ? result.staleFiles : staleOutputs(result)
|
|
253
|
+
const stale = typeof result.stale === 'boolean' ? result.stale : staleFiles.length > 0
|
|
254
|
+
return { ...result, stale, staleFiles: stale ? staleFiles : [] }
|
|
255
|
+
},
|
|
256
|
+
createIncremental: options => {
|
|
257
|
+
const compiler = typeof createCompiler === 'function' ? createCompiler(options) : undefined
|
|
258
|
+
const incremental = compiler?.generate
|
|
259
|
+
const outFile = resolveOutFile(options)
|
|
260
|
+
const isOutFile = (file: string): boolean =>
|
|
261
|
+
typeof compiler?.isOutFile === 'function' ? compiler.isOutFile(file) : resolve(file) === outFile
|
|
262
|
+
const { includeRoots, isRelevantChange } = module
|
|
263
|
+
return {
|
|
264
|
+
generate: async changes => {
|
|
265
|
+
if (compiler === undefined || typeof incremental !== 'function') return generateOnce(options)
|
|
266
|
+
const before = readText(outFile)
|
|
267
|
+
return written(await incremental.call(compiler, changes), before)
|
|
268
|
+
},
|
|
269
|
+
isOutFile,
|
|
270
|
+
includeRoots: () =>
|
|
271
|
+
compiler !== undefined && typeof includeRoots === 'function'
|
|
272
|
+
? includeRoots(compiler)
|
|
273
|
+
: [resolve(options.root ?? '.')],
|
|
274
|
+
isRelevantChange: (kind, file, watched, roots) =>
|
|
275
|
+
compiler !== undefined && typeof isRelevantChange === 'function'
|
|
276
|
+
? isRelevantChange(compiler, kind, file, watched, roots)
|
|
277
|
+
: !isOutFile(file),
|
|
278
|
+
}
|
|
279
|
+
},
|
|
280
|
+
formatDiagnostics: (diagnostics, options) => format(diagnostics, options),
|
|
281
|
+
...(typeof loadZnightConfig === 'function'
|
|
282
|
+
? { loadConfig: (root: string): Promise<ZnightConfigLike> => loadZnightConfig(root) }
|
|
283
|
+
: {}),
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
/**
|
|
288
|
+
* The generated files of a compile result that differ from the disk: the module, and
|
|
289
|
+
* `znight.providers.json` in library mode. None with errors (nothing would be written).
|
|
290
|
+
*/
|
|
291
|
+
function staleOutputs(result: ModuleResult): string[] {
|
|
292
|
+
if (hasErrors(result.diagnostics)) return []
|
|
293
|
+
const outputs: { file: string; content: string }[] = [
|
|
294
|
+
{ file: result.outFile, content: result.diskCode ?? result.code },
|
|
295
|
+
]
|
|
296
|
+
const { file, content } = result.library ?? {}
|
|
297
|
+
if (typeof file === 'string' && typeof content === 'string') outputs.push({ file, content })
|
|
298
|
+
return outputs.filter(output => readText(output.file) !== output.content).map(output => output.file)
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
/** Default `outFile` of the compiler (§6.2). */
|
|
302
|
+
export const DEFAULT_OUT_FILE = 'src/.znight/container.generated.ts'
|
|
303
|
+
|
|
304
|
+
/** Absolute path of the generated module for `options`. */
|
|
305
|
+
export function resolveOutFile(options: ZnightOptions): string {
|
|
306
|
+
return resolve(options.root ?? '.', options.outFile ?? DEFAULT_OUT_FILE)
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
/** Whether a list of diagnostics contains an error. */
|
|
310
|
+
export function hasErrors(diagnostics: readonly Diagnostic[]): boolean {
|
|
311
|
+
return diagnostics.some(diagnostic => diagnostic.severity === 'error')
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
/** Writes `content` to `file` unless it already has that exact content. Returns whether it wrote. */
|
|
315
|
+
export function writeIfChanged(file: string, content: string): boolean {
|
|
316
|
+
if (existsSync(file) && readFileSync(file, 'utf8') === content) return false
|
|
317
|
+
mkdirSync(dirname(file), { recursive: true })
|
|
318
|
+
writeFileSync(file, content)
|
|
319
|
+
return true
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
/**
|
|
323
|
+
* Plain formatter used when the installed compiler has no `formatDiagnostics()`:
|
|
324
|
+
*
|
|
325
|
+
* ```
|
|
326
|
+
* ✗ ZNIGHT001: Missing provider for 'EmailService' required by NotificationService
|
|
327
|
+
* --> src/notification/notification.service.ts:12:5
|
|
328
|
+
* = hint: Add @Injectable() to EmailService
|
|
329
|
+
* ```
|
|
330
|
+
*/
|
|
331
|
+
export function fallbackFormatDiagnostics(
|
|
332
|
+
diagnostics: readonly Diagnostic[],
|
|
333
|
+
options: FormatDiagnosticsOptions = {},
|
|
334
|
+
): string {
|
|
335
|
+
const colors = createColors(options.color === true)
|
|
336
|
+
const blocks: string[] = []
|
|
337
|
+
for (const diagnostic of diagnostics) {
|
|
338
|
+
const isError = diagnostic.severity === 'error'
|
|
339
|
+
const head = isError
|
|
340
|
+
? colors.red(`✗ ${diagnostic.code}: ${diagnostic.message}`)
|
|
341
|
+
: colors.yellow(`! ${diagnostic.code}: ${diagnostic.message}`)
|
|
342
|
+
const lines = [colors.bold(head)]
|
|
343
|
+
if (diagnostic.location !== undefined) {
|
|
344
|
+
lines.push(` ${colors.cyan('-->')} ${formatLocation(diagnostic.location, options.cwd)}`)
|
|
345
|
+
}
|
|
346
|
+
for (const hint of diagnostic.hints ?? []) lines.push(` ${colors.dim('=')} ${colors.bold('hint:')} ${hint}`)
|
|
347
|
+
for (const related of diagnostic.related ?? []) {
|
|
348
|
+
lines.push(
|
|
349
|
+
` ${colors.dim('=')} ${colors.bold('note:')} ${related.message} (${formatLocation(related.location, options.cwd)})`,
|
|
350
|
+
)
|
|
351
|
+
}
|
|
352
|
+
blocks.push(lines.join('\n'))
|
|
353
|
+
}
|
|
354
|
+
return blocks.join('\n\n')
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
function formatLocation(location: { file: string; line: number; column: number }, cwd: string | undefined): string {
|
|
358
|
+
const file = cwd === undefined ? location.file : relative(cwd, location.file).replaceAll('\\', '/')
|
|
359
|
+
return `${file}:${location.line}:${location.column}`
|
|
360
|
+
}
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `@znight/openapi`, loaded lazily from the project (an optional peer dependency): `znight openapi`
|
|
3
|
+
* uses its emission API (`@znight/openapi/emit`), `znight build --openapi` its Vite plugin
|
|
4
|
+
* (`@znight/openapi/vite`). Commands use the structural types below, so tests can inject fakes.
|
|
5
|
+
*/
|
|
6
|
+
import type { Plugin } from 'vite'
|
|
7
|
+
import type { Diagnostic, ZnightOptions } from './compiler.ts'
|
|
8
|
+
|
|
9
|
+
import { CliError } from '../errors.ts'
|
|
10
|
+
import { importFromProject } from './resolve.ts'
|
|
11
|
+
|
|
12
|
+
/** Options of `emitOpenApi()` (`@znight/openapi/emit`). */
|
|
13
|
+
export interface OpenApiEmitOptionsLike {
|
|
14
|
+
readonly root: string
|
|
15
|
+
readonly output: readonly string[]
|
|
16
|
+
readonly check?: boolean
|
|
17
|
+
readonly prefix?: string
|
|
18
|
+
readonly runtime?: 'auto' | 'node' | 'bun'
|
|
19
|
+
readonly converters?: string
|
|
20
|
+
readonly config?: string
|
|
21
|
+
readonly configLoader?: string
|
|
22
|
+
readonly compilerOptions?: ZnightOptions
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** A warning of the assembly. */
|
|
26
|
+
export interface OpenApiWarningLike {
|
|
27
|
+
readonly code: string
|
|
28
|
+
readonly message: string
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** What the CLI reads from the result of `emitOpenApi()`. */
|
|
32
|
+
export interface OpenApiEmitResultLike {
|
|
33
|
+
readonly ok: boolean
|
|
34
|
+
readonly written: readonly string[]
|
|
35
|
+
readonly stale: boolean
|
|
36
|
+
readonly staleFiles: readonly string[]
|
|
37
|
+
readonly diagnostics: readonly Diagnostic[]
|
|
38
|
+
readonly warnings: readonly OpenApiWarningLike[]
|
|
39
|
+
readonly outputs: readonly string[]
|
|
40
|
+
readonly runtime: 'node' | 'bun'
|
|
41
|
+
readonly document?: { readonly paths?: Readonly<Record<string, object>> } | undefined
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** `@znight/openapi/emit`. */
|
|
45
|
+
export interface OpenApiEmitApi {
|
|
46
|
+
emitOpenApi(options: OpenApiEmitOptionsLike): Promise<OpenApiEmitResultLike>
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Options of the `openapi()` Vite plugin. */
|
|
50
|
+
export interface OpenApiVitePluginOptionsLike {
|
|
51
|
+
readonly output?: string
|
|
52
|
+
/** HTTP prefix (`servers` default when `@OpenApiDocument` declares none). */
|
|
53
|
+
readonly prefix?: string
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** `@znight/openapi/vite`. */
|
|
57
|
+
export interface OpenApiViteApi {
|
|
58
|
+
openapi(options?: OpenApiVitePluginOptionsLike): Plugin
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Not literals in `import()`: the CLI builds without `@znight/openapi` (an optional peer). */
|
|
62
|
+
const EMIT_MODULE: string = '@znight/openapi/emit'
|
|
63
|
+
const VITE_MODULE: string = '@znight/openapi/vite'
|
|
64
|
+
|
|
65
|
+
const MISSING_HINTS = ['install it in your project: npm install @znight/openapi']
|
|
66
|
+
|
|
67
|
+
/** Loads `@znight/openapi/emit` as installed in the project. */
|
|
68
|
+
export async function loadOpenApiEmit(root: string): Promise<OpenApiEmitApi> {
|
|
69
|
+
let module: Partial<OpenApiEmitApi>
|
|
70
|
+
try {
|
|
71
|
+
module = (await importFromProject(EMIT_MODULE, root, () => import(EMIT_MODULE))) as Partial<OpenApiEmitApi>
|
|
72
|
+
} catch (error) {
|
|
73
|
+
throw new CliError('Cannot load @znight/openapi', { cause: error, hints: MISSING_HINTS })
|
|
74
|
+
}
|
|
75
|
+
if (typeof module.emitOpenApi !== 'function') {
|
|
76
|
+
throw new CliError('This version of @znight/openapi has no emitOpenApi()', {
|
|
77
|
+
hints: ['upgrade @znight/openapi to the version of @znight/cli'],
|
|
78
|
+
})
|
|
79
|
+
}
|
|
80
|
+
return module as OpenApiEmitApi
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** Loads `@znight/openapi/vite` as installed in the project. */
|
|
84
|
+
export async function loadOpenApiVite(root: string): Promise<OpenApiViteApi> {
|
|
85
|
+
let module: Partial<OpenApiViteApi>
|
|
86
|
+
try {
|
|
87
|
+
module = (await importFromProject(VITE_MODULE, root, () => import(VITE_MODULE))) as Partial<OpenApiViteApi>
|
|
88
|
+
} catch (error) {
|
|
89
|
+
throw new CliError('Cannot load @znight/openapi', { cause: error, hints: MISSING_HINTS })
|
|
90
|
+
}
|
|
91
|
+
if (typeof module.openapi !== 'function') {
|
|
92
|
+
throw new CliError('This version of @znight/openapi has no openapi() Vite plugin', {
|
|
93
|
+
hints: ['upgrade @znight/openapi to the version of @znight/cli'],
|
|
94
|
+
})
|
|
95
|
+
}
|
|
96
|
+
return module as OpenApiViteApi
|
|
97
|
+
}
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Child processes: the application (`znight dev` / `znight start`) and tools (`git`, package
|
|
3
|
+
* managers). Behind {@link ProcessService} so that tests can use fake processes.
|
|
4
|
+
*/
|
|
5
|
+
import { type ChildProcess, spawn as nodeSpawn } from 'node:child_process'
|
|
6
|
+
import { constants } from 'node:os'
|
|
7
|
+
|
|
8
|
+
/** How a child process ended. */
|
|
9
|
+
export interface ExitStatus {
|
|
10
|
+
/** Exit code, `null` when killed by a signal or when the process could not start. */
|
|
11
|
+
readonly code: number | null
|
|
12
|
+
readonly signal: NodeJS.Signals | null
|
|
13
|
+
/** Spawn failure (`ENOENT`...). */
|
|
14
|
+
readonly error?: Error | undefined
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** A running child process. */
|
|
18
|
+
export interface ChildHandle {
|
|
19
|
+
readonly pid: number | undefined
|
|
20
|
+
/** Sends a signal (to its process group when it runs detached). Returns `false` when it already exited. */
|
|
21
|
+
kill(signal: NodeJS.Signals): boolean
|
|
22
|
+
/** Resolves once the process exited (never rejects). */
|
|
23
|
+
readonly exited: Promise<ExitStatus>
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** Options of {@link ProcessService.spawn}. */
|
|
27
|
+
export interface SpawnOptions {
|
|
28
|
+
readonly cwd: string
|
|
29
|
+
readonly env: Readonly<Record<string, string | undefined>>
|
|
30
|
+
/** Default `'inherit'`. */
|
|
31
|
+
readonly stdio?: 'inherit' | 'ignore'
|
|
32
|
+
/**
|
|
33
|
+
* POSIX only: run the process in its own process group, so that a terminal Ctrl+C reaches the CLI
|
|
34
|
+
* only (the CLI then forwards exactly one signal). The process is killed when the CLI exits.
|
|
35
|
+
*/
|
|
36
|
+
readonly detached?: boolean
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Result of {@link ProcessService.run}. */
|
|
40
|
+
export interface RunResult extends ExitStatus {
|
|
41
|
+
readonly stdout: string
|
|
42
|
+
readonly stderr: string
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Options of {@link ProcessService.run}. */
|
|
46
|
+
export interface RunOptions {
|
|
47
|
+
readonly cwd: string
|
|
48
|
+
readonly env: Readonly<Record<string, string | undefined>>
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Replaces the current process image (`execve(2)`) after changing the working directory to `cwd`.
|
|
53
|
+
* `args[0]` is the program name. Never returns on success.
|
|
54
|
+
*/
|
|
55
|
+
export type ExecveFunction = (
|
|
56
|
+
file: string,
|
|
57
|
+
args: readonly string[],
|
|
58
|
+
env: Readonly<Record<string, string>>,
|
|
59
|
+
cwd: string,
|
|
60
|
+
) => void
|
|
61
|
+
|
|
62
|
+
/** Process primitives used by the commands. */
|
|
63
|
+
export interface ProcessService {
|
|
64
|
+
spawn(command: string, args: readonly string[], options: SpawnOptions): ChildHandle
|
|
65
|
+
/** Runs a command to completion, capturing its output. */
|
|
66
|
+
run(command: string, args: readonly string[], options: RunOptions): Promise<RunResult>
|
|
67
|
+
/** `process.execve` when the runtime supports it (POSIX). */
|
|
68
|
+
readonly execve?: ExecveFunction | undefined
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const detachedChildren = new Set<ChildProcess>()
|
|
72
|
+
let exitHookInstalled = false
|
|
73
|
+
|
|
74
|
+
function killDetachedChildren(): void {
|
|
75
|
+
for (const child of detachedChildren) {
|
|
76
|
+
killTree(child, 'SIGKILL', true)
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function killTree(child: ChildProcess, signal: NodeJS.Signals, detached: boolean): boolean {
|
|
81
|
+
if (child.exitCode !== null || child.signalCode !== null) return false
|
|
82
|
+
if (detached && child.pid !== undefined && process.platform !== 'win32') {
|
|
83
|
+
try {
|
|
84
|
+
process.kill(-child.pid, signal)
|
|
85
|
+
return true
|
|
86
|
+
} catch {
|
|
87
|
+
// the group is gone: fall back to the process itself
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
return child.kill(signal)
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function cleanEnv(env: Readonly<Record<string, string | undefined>>): Record<string, string> {
|
|
94
|
+
const result: Record<string, string> = {}
|
|
95
|
+
for (const [key, value] of Object.entries(env)) {
|
|
96
|
+
if (value !== undefined) result[key] = value
|
|
97
|
+
}
|
|
98
|
+
return result
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** Starts a process with `node:child_process`. */
|
|
102
|
+
export function spawnProcess(command: string, args: readonly string[], options: SpawnOptions): ChildHandle {
|
|
103
|
+
const detached = options.detached === true && process.platform !== 'win32'
|
|
104
|
+
const child = nodeSpawn(command, [...args], {
|
|
105
|
+
cwd: options.cwd,
|
|
106
|
+
env: cleanEnv(options.env),
|
|
107
|
+
stdio: options.stdio ?? 'inherit',
|
|
108
|
+
detached,
|
|
109
|
+
windowsHide: true,
|
|
110
|
+
})
|
|
111
|
+
if (detached) {
|
|
112
|
+
detachedChildren.add(child)
|
|
113
|
+
if (!exitHookInstalled) {
|
|
114
|
+
exitHookInstalled = true
|
|
115
|
+
process.on('exit', killDetachedChildren)
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
const exited = new Promise<ExitStatus>(resolve => {
|
|
119
|
+
child.once('error', error => {
|
|
120
|
+
detachedChildren.delete(child)
|
|
121
|
+
resolve({ code: null, signal: null, error })
|
|
122
|
+
})
|
|
123
|
+
child.once('exit', (code, signal) => {
|
|
124
|
+
detachedChildren.delete(child)
|
|
125
|
+
resolve({ code, signal })
|
|
126
|
+
})
|
|
127
|
+
})
|
|
128
|
+
return {
|
|
129
|
+
pid: child.pid,
|
|
130
|
+
kill: signal => killTree(child, signal, detached),
|
|
131
|
+
exited,
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** Runs a command to completion with `node:child_process`, capturing stdout and stderr. */
|
|
136
|
+
export function runProcess(command: string, args: readonly string[], options: RunOptions): Promise<RunResult> {
|
|
137
|
+
return new Promise<RunResult>(resolve => {
|
|
138
|
+
const child = nodeSpawn(command, [...args], {
|
|
139
|
+
cwd: options.cwd,
|
|
140
|
+
env: cleanEnv(options.env),
|
|
141
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
142
|
+
windowsHide: true,
|
|
143
|
+
// package managers are `.cmd` shims on Windows
|
|
144
|
+
shell: process.platform === 'win32',
|
|
145
|
+
})
|
|
146
|
+
let stdout = ''
|
|
147
|
+
let stderr = ''
|
|
148
|
+
child.stdout?.setEncoding('utf8').on('data', (chunk: string) => {
|
|
149
|
+
stdout += chunk
|
|
150
|
+
})
|
|
151
|
+
child.stderr?.setEncoding('utf8').on('data', (chunk: string) => {
|
|
152
|
+
stderr += chunk
|
|
153
|
+
})
|
|
154
|
+
child.once('error', error => resolve({ code: null, signal: null, error, stdout, stderr }))
|
|
155
|
+
child.once('close', (code, signal) => resolve({ code, signal, stdout, stderr }))
|
|
156
|
+
})
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/** The default {@link ProcessService}. */
|
|
160
|
+
export function createProcessService(): ProcessService {
|
|
161
|
+
const execve = (process as { execve?: (file: string, args: string[], env: Record<string, string>) => void }).execve
|
|
162
|
+
return {
|
|
163
|
+
spawn: spawnProcess,
|
|
164
|
+
run: runProcess,
|
|
165
|
+
execve:
|
|
166
|
+
typeof execve === 'function' && process.platform !== 'win32'
|
|
167
|
+
? (file, args, env, cwd) => {
|
|
168
|
+
process.chdir(cwd)
|
|
169
|
+
execve.call(process, file, [...args], { ...env })
|
|
170
|
+
}
|
|
171
|
+
: undefined,
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/** Exit code of the CLI mirroring a child's exit status (`128 + n` for signals, like shells). */
|
|
176
|
+
export function exitCodeOf(status: ExitStatus): number {
|
|
177
|
+
if (status.code !== null) return status.code
|
|
178
|
+
if (status.signal !== null) return 128 + (constants.signals[status.signal] ?? 1)
|
|
179
|
+
return 1
|
|
180
|
+
}
|