@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,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
+ }