@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,249 @@
1
+ /**
2
+ * `znight dev`: Vite build in watch mode (no Vite dev server) and a full restart of the application
3
+ * process after every successful rebuild — no partial hot replacement of providers: a restart is
4
+ * fast and always consistent. A failed build keeps the last good process running, and so does a
5
+ * failed compilation: in watch mode the `znight()` plugin does not fail the build (the bundle
6
+ * contains a module throwing the diagnostics), so its result (`plugin.api.result`) is checked
7
+ * before restarting.
8
+ */
9
+ import type { Command } from '../command.ts'
10
+ import type { CliContext } from '../context.ts'
11
+ import type { Diagnostic } from '../integrations/compiler.ts'
12
+ import type { BuildWatcher, ViteConfigLoader, ViteLogLevel, WatcherEvent } from '../integrations/vite.ts'
13
+
14
+ import { resolve } from 'node:path'
15
+
16
+ import { defineCommand } from '../command.ts'
17
+ import { ORPHAN_GUARD_POLL_MS, orphanGuard } from '../dev/orphan.ts'
18
+ import { AppSupervisor } from '../dev/supervisor.ts'
19
+ import { CliError } from '../errors.ts'
20
+ import { fallbackFormatDiagnostics } from '../integrations/compiler.ts'
21
+ import { exitCodeOf } from '../integrations/process.ts'
22
+ import { createOutputTracker, formatBuildError, watchBuild } from '../integrations/vite.ts'
23
+ import { formatDuration, plural } from '../output.ts'
24
+ import { type RuntimeOption, resolveRuntime, runtimeArguments, runtimeExecutable } from '../runtime.ts'
25
+ import { displayPath, onTermination, parseNonNegativeInteger, resolveRoot } from './shared.ts'
26
+
27
+ /** Clears the terminal and its scrollback. */
28
+ const CLEAR_SCREEN = '\x1b[2J\x1b[3J\x1b[H'
29
+
30
+ /** `znight dev` */
31
+ export const devCommand: Command = defineCommand({
32
+ meta: {
33
+ name: 'dev',
34
+ description: 'Build in watch mode and restart the application after every successful rebuild',
35
+ usage: '[options] [-- <application arguments>]',
36
+ examples: ['znight dev', 'znight dev --runtime bun --clear', 'znight dev --inspect -- --verbose'],
37
+ details: [
38
+ 'Uses "vite build --watch" with the project Vite config (the znight() plugin regenerates the container).',
39
+ 'On every successful build the application receives SIGTERM, gets --kill-timeout ms to stop gracefully',
40
+ '(then SIGKILL), and a new process starts. A failed build or compilation (ZNIGHT errors) keeps the',
41
+ 'last good process running.',
42
+ ].join('\n'),
43
+ },
44
+ args: {
45
+ root: { type: 'string', valueHint: 'dir', description: 'Project root (default: the current directory)' },
46
+ config: { type: 'string', valueHint: 'file', description: 'Vite config file (default: vite.config.*)' },
47
+ mode: { type: 'string', default: 'development', description: 'Vite mode' },
48
+ 'config-loader': {
49
+ type: 'enum',
50
+ options: ['bundle', 'runner', 'native'],
51
+ description: 'How Vite loads the config file (default: bundle)',
52
+ },
53
+ runtime: {
54
+ type: 'enum',
55
+ options: ['auto', 'node', 'bun'],
56
+ default: 'auto',
57
+ description: 'Runtime of the application (auto: the runtime running the CLI)',
58
+ },
59
+ entry: { type: 'string', valueHint: 'file', description: 'File to run (default: the entry chunk of the build)' },
60
+ inspect: { type: 'boolean', description: 'Start the application with --inspect (debugger)' },
61
+ clear: { type: 'boolean', description: 'Clear the screen before each rebuild' },
62
+ debounce: { type: 'string', valueHint: 'ms', default: '100', description: 'Delay before restarting after a build' },
63
+ 'kill-timeout': {
64
+ type: 'string',
65
+ valueHint: 'ms',
66
+ default: '5000',
67
+ description: 'Time the application gets to stop after SIGTERM before SIGKILL',
68
+ },
69
+ 'log-level': {
70
+ type: 'enum',
71
+ options: ['info', 'warn', 'error', 'silent'],
72
+ default: 'warn',
73
+ description: 'Vite log level',
74
+ },
75
+ },
76
+ async run({ args, cli, passthrough }) {
77
+ const root = resolveRoot(cli, args.root)
78
+ const debounceMs = parseNonNegativeInteger(args.debounce, 'debounce', 100)
79
+ const killTimeoutMs = parseNonNegativeInteger(args['kill-timeout'], 'kill-timeout', 5000)
80
+ const runtime = resolveRuntime(args.runtime as RuntimeOption, cli.runtime)
81
+ const executable = runtimeExecutable(runtime, cli.runtime, cli.env)
82
+ if (executable === undefined) {
83
+ throw new CliError(`Cannot find "${runtime}" in PATH`, { hints: ['install it, or choose another --runtime'] })
84
+ }
85
+ const logLevel = args['log-level'] as ViteLogLevel
86
+ const { colors, reporter } = cli
87
+ // before Vite runs: its build sets process.env.NODE_ENV (to "production") when it is not set
88
+ const env = { ...cli.env }
89
+ // the application runs detached: it stops by itself when the CLI dies without stopping it (kill -9)
90
+ const guard = orphanGuard(runtime, { parentPid: process.pid, pollMs: ORPHAN_GUARD_POLL_MS, killTimeoutMs })
91
+ const runtimeArgs = runtimeArguments(runtime, [
92
+ ...(guard?.args ?? []),
93
+ ...(args.inspect === true ? ['--inspect'] : []),
94
+ ])
95
+ const vite = await cli.services.loadVite(root)
96
+ const tracker = createOutputTracker(root)
97
+ let entry: string | undefined
98
+
99
+ const supervisor = new AppSupervisor({
100
+ debounceMs,
101
+ killTimeoutMs,
102
+ spawn: () => {
103
+ const file = entry ?? resolve(root, 'dist/main.js')
104
+ return cli.services.processes.spawn(executable, [...runtimeArgs, file, ...passthrough], {
105
+ cwd: root,
106
+ env: { ...env, ...guard?.env, NODE_ENV: env['NODE_ENV'] ?? 'development' },
107
+ detached: true,
108
+ })
109
+ },
110
+ events: {
111
+ onStart: (child, restart) => {
112
+ const file = displayPath(entry ?? resolve(root, 'dist/main.js'), cli.cwd)
113
+ reporter.info(
114
+ `${restart ? 'Restarted' : 'Started'} ${colors.bold(file)} ${colors.dim(`(${runtime}, pid ${child.pid ?? '?'})`)}`,
115
+ )
116
+ },
117
+ onStop: (_status, forced) => {
118
+ if (forced) reporter.warn(`The application did not stop within ${killTimeoutMs}ms: killed`)
119
+ },
120
+ onExit: status => {
121
+ if (status.error !== undefined) {
122
+ reporter.error(`Cannot start ${runtime}: ${status.error.message}`)
123
+ return
124
+ }
125
+ const code = exitCodeOf(status)
126
+ const message = `The application exited with code ${code}${status.signal === null ? '' : ` (${status.signal})`}`
127
+ if (code === 0) reporter.info(`${message}. Waiting for changes...`)
128
+ else reporter.error(`${message}. Waiting for changes...`)
129
+ },
130
+ onError: error =>
131
+ reporter.error(`Cannot start the application: ${error instanceof Error ? error.message : String(error)}`),
132
+ },
133
+ })
134
+
135
+ const state = { failed: false, first: true, started: 0 }
136
+ const onEvent = (event: WatcherEvent): void => {
137
+ switch (event.code) {
138
+ case 'START':
139
+ state.failed = false
140
+ state.started = performance.now()
141
+ if (!state.first) {
142
+ if (args.clear === true && cli.stdout.isTTY === true) cli.stdout.write(CLEAR_SCREEN)
143
+ reporter.info(colors.dim('Change detected, rebuilding...'))
144
+ }
145
+ break
146
+ case 'ERROR':
147
+ state.failed = true
148
+ state.first = false
149
+ // Vite closes the build of BUNDLE_END itself, not the one of ERROR
150
+ void Promise.resolve()
151
+ .then(() => event.result?.close())
152
+ .catch(() => undefined)
153
+ // Vite logs build errors itself, unless it is silenced
154
+ if (logLevel === 'silent') reporter.logError(formatBuildError(event.error))
155
+ reporter.error(
156
+ `Build failed. ${supervisor.running ? 'The last good build keeps running. ' : ''}Waiting for changes...`,
157
+ )
158
+ break
159
+ case 'END': {
160
+ state.first = false
161
+ if (state.failed) break
162
+ // the bundle was written, but with a failed compile it throws the diagnostics at startup
163
+ const compile = tracker.compile()
164
+ if (compile !== undefined && !compile.ok) {
165
+ // a restart still pending for a previous build would run this broken output
166
+ supervisor.cancelRestart()
167
+ const errors = compile.diagnostics.filter(diagnostic => diagnostic.severity === 'error').length
168
+ // the znight() plugin prints the diagnostics through the Vite logger, unless it is silenced
169
+ const printed = logLevel === 'silent' ? printDiagnostics(cli, root, compile.diagnostics) : Promise.resolve()
170
+ void printed.then(() =>
171
+ reporter.error(
172
+ `Compilation failed with ${plural(errors, 'error')}. ${supervisor.running ? 'The last good build keeps running. ' : ''}Waiting for changes...`,
173
+ ),
174
+ )
175
+ break
176
+ }
177
+ entry = args.entry === undefined ? (tracker.entry() ?? entry) : resolve(root, args.entry)
178
+ reporter.success(`Built in ${formatDuration(performance.now() - (tracker.startedAt() ?? state.started))}`)
179
+ supervisor.scheduleRestart()
180
+ break
181
+ }
182
+ default:
183
+ break
184
+ }
185
+ }
186
+
187
+ reporter.info(
188
+ `${colors.bold('znight dev')} ${colors.dim(`v${cli.version}`)} — vite build --watch, ${runtime} runtime`,
189
+ )
190
+ let watcher: BuildWatcher
191
+ try {
192
+ watcher = await watchBuild(vite, {
193
+ root,
194
+ configFile: args.config,
195
+ configLoader: args['config-loader'] as ViteConfigLoader | undefined,
196
+ mode: args.mode,
197
+ logLevel,
198
+ plugins: [tracker.plugin],
199
+ })
200
+ } catch (error) {
201
+ if (error instanceof CliError) throw error
202
+ // configuration errors (the config file does not load, invalid options...)
203
+ reporter.logError(formatBuildError(error))
204
+ throw new CliError('Cannot start the Vite build', { cause: error })
205
+ }
206
+ watcher.on('event', onEvent)
207
+ return waitForTermination(cli, watcher, supervisor)
208
+ },
209
+ })
210
+
211
+ /** Prints compiler diagnostics with the project's `formatDiagnostics()` (a built-in formatter otherwise). */
212
+ async function printDiagnostics(cli: CliContext, root: string, diagnostics: readonly Diagnostic[]): Promise<void> {
213
+ if (diagnostics.length === 0) return
214
+ const options = { color: cli.colors.isColorSupported, cwd: root, summary: false }
215
+ let text: string
216
+ try {
217
+ text = (await cli.services.loadCompiler(root)).formatDiagnostics(diagnostics, options)
218
+ } catch {
219
+ text = fallbackFormatDiagnostics(diagnostics, options)
220
+ }
221
+ cli.reporter.logError(text.trimEnd())
222
+ }
223
+
224
+ function waitForTermination(cli: CliContext, watcher: BuildWatcher, supervisor: AppSupervisor): Promise<number> {
225
+ return new Promise<number>(resolveExit => {
226
+ const dispose = onTermination(
227
+ cli,
228
+ signal => {
229
+ // SIGHUP: the terminal is gone, nothing can be printed
230
+ if (signal !== 'SIGHUP') cli.reporter.info(cli.colors.dim('Stopping... (press Ctrl+C again to force)'))
231
+ void (async () => {
232
+ try {
233
+ await watcher.close()
234
+ } finally {
235
+ // the application stops gracefully on SIGINT or SIGTERM
236
+ await supervisor.stop(signal === 'SIGINT' ? 'SIGINT' : 'SIGTERM')
237
+ dispose()
238
+ resolveExit(0)
239
+ }
240
+ })()
241
+ },
242
+ () => {
243
+ supervisor.kill()
244
+ dispose()
245
+ resolveExit(130)
246
+ },
247
+ )
248
+ })
249
+ }
@@ -0,0 +1,304 @@
1
+ /**
2
+ * `znight generate`: writes the DI container module (`src/.znight/container.generated.ts`) with the
3
+ * compiler, checks that it is up to date (`--check`, for CI) or keeps it up to date (`--watch`, for
4
+ * `tsc --watch` and editors).
5
+ */
6
+ import type { Command } from '../command.ts'
7
+ import type { CliContext } from '../context.ts'
8
+ import type {
9
+ CheckResultLike,
10
+ CompileResultLike,
11
+ CompilerApi,
12
+ GenerateResultLike,
13
+ IncrementalCompiler,
14
+ ZnightConfigLike,
15
+ ZnightOptions,
16
+ } from '../integrations/compiler.ts'
17
+
18
+ import { existsSync } from 'node:fs'
19
+ import { resolve } from 'node:path'
20
+
21
+ import { defineCommand } from '../command.ts'
22
+ import { CliError, errorMessage } from '../errors.ts'
23
+ import { hasErrors } from '../integrations/compiler.ts'
24
+ import { findViteConfig, readZnightPluginOptions, type ViteConfigLoader } from '../integrations/vite.ts'
25
+ import {
26
+ changeKind,
27
+ classifyChanges,
28
+ realPath,
29
+ TargetWatchers,
30
+ toPosixPath,
31
+ watchTargets,
32
+ } from '../integrations/watch.ts'
33
+ import { formatDuration, plural } from '../output.ts'
34
+ import { displayPath, onTermination, resolveRoot } from './shared.ts'
35
+
36
+ /** `znight generate` */
37
+ export const generateCommand: Command = defineCommand({
38
+ meta: {
39
+ name: 'generate',
40
+ description: 'Generate the dependency injection container (src/.znight/container.generated.ts)',
41
+ usage: '[options]',
42
+ examples: ['znight generate', 'znight generate --check # in CI, before tsc', 'znight generate --watch'],
43
+ details: [
44
+ 'The compiler options are read from znight.config.* (or the file named by ZNIGHT_CONFIG), the file',
45
+ 'shared with the Bun preload; without one, from the znight() plugin of vite.config.ts, so that the',
46
+ 'generated file is the same as the one of the Vite build. --config reads the given Vite config.',
47
+ '"znight generate <schematic> <name>" is an alias of "znight g <schematic> <name>".',
48
+ ].join('\n'),
49
+ },
50
+ args: {
51
+ check: {
52
+ type: 'boolean',
53
+ description: 'Do not write: exit with code 1 when the generated file is missing or out of date',
54
+ },
55
+ watch: { type: 'boolean', alias: 'w', description: 'Regenerate on every change' },
56
+ root: { type: 'string', valueHint: 'dir', description: 'Project root (default: the current directory)' },
57
+ config: {
58
+ type: 'string',
59
+ valueHint: 'file',
60
+ description: 'Vite config file to read the znight() options from (instead of znight.config.*)',
61
+ },
62
+ 'config-loader': {
63
+ type: 'enum',
64
+ options: ['bundle', 'runner', 'native'],
65
+ description: 'How Vite loads the config file (default: bundle)',
66
+ },
67
+ },
68
+ async run({ args, cli }) {
69
+ const root = resolveRoot(cli, args.root)
70
+ const compiler = await cli.services.loadCompiler(root)
71
+ const configLoader = args['config-loader'] as ViteConfigLoader | undefined
72
+ const options = await compilerOptions(cli, compiler, root, args.config, configLoader)
73
+ if (args.check === true) return check(cli, compiler, options)
74
+ if (args.watch === true) return watch(cli, compiler, options)
75
+ const result = await generateOnce(cli, compiler, options)
76
+ return hasErrors(result.diagnostics) ? 1 : 0
77
+ },
78
+ })
79
+
80
+ /**
81
+ * The compiler options, with `root`: those of `znight.config.*` when the project has one (unless a
82
+ * Vite config is given with `--config`), else those of `znight()` in the Vite config (when exposed),
83
+ * else the defaults.
84
+ */
85
+ export async function compilerOptions(
86
+ cli: CliContext,
87
+ compiler: CompilerApi,
88
+ root: string,
89
+ configFile: string | undefined,
90
+ configLoader: ViteConfigLoader | undefined,
91
+ ): Promise<ZnightOptions> {
92
+ if (configFile === undefined) {
93
+ const shared = await znightConfigOptions(compiler, root)
94
+ if (shared !== undefined) return shared
95
+ }
96
+ let options: ZnightOptions | undefined
97
+ // no Vite config: nothing to read, do not even load Vite
98
+ if (findViteConfig(root, configFile) === undefined) return { root }
99
+ try {
100
+ const vite = await cli.services.loadVite(root)
101
+ const lookup = await readZnightPluginOptions(vite, root, configFile, configLoader)
102
+ if (lookup !== undefined && !lookup.found) {
103
+ cli.reporter.warn(`${displayPath(lookup.configFile, cli.cwd)} does not register the znight() plugin`)
104
+ }
105
+ options = lookup?.options
106
+ } catch (error) {
107
+ if (configFile !== undefined) {
108
+ throw new CliError(`Cannot load the Vite config ${configFile}`, { cause: error, hints: [errorMessage(error)] })
109
+ }
110
+ cli.reporter.warn(
111
+ `Cannot read the znight() options from the Vite config, using the defaults: ${errorMessage(error)}`,
112
+ )
113
+ }
114
+ return { ...options, root: resolve(root, options?.root ?? '.') }
115
+ }
116
+
117
+ /** Options of `znight.config.*` (`loadZnightConfig()` of the compiler), `undefined` without the file. */
118
+ async function znightConfigOptions(compiler: CompilerApi, root: string): Promise<ZnightOptions | undefined> {
119
+ if (compiler.loadConfig === undefined) return undefined
120
+ let config: ZnightConfigLike
121
+ try {
122
+ config = await compiler.loadConfig(root)
123
+ } catch (error) {
124
+ throw new CliError('Cannot load the Znight config (znight.config.*)', {
125
+ cause: error,
126
+ hints: [errorMessage(error)],
127
+ })
128
+ }
129
+ if (config.file === undefined) return undefined
130
+ // `logLevel` is an option of the Bun preload only
131
+ const { logLevel: _preloadLogLevel, ...options } = config.options
132
+ return { ...options, root: resolve(root, options.root ?? '.') }
133
+ }
134
+
135
+ function printDiagnostics(cli: CliContext, compiler: CompilerApi, result: CompileResultLike, root: string): void {
136
+ if (result.diagnostics.length === 0) return
137
+ const text = compiler.formatDiagnostics(result.diagnostics, {
138
+ color: cli.colors.isColorSupported,
139
+ cwd: root,
140
+ summary: false,
141
+ })
142
+ cli.reporter.logError(text.trimEnd())
143
+ cli.reporter.logError()
144
+ }
145
+
146
+ function statsOf(result: CompileResultLike, elapsedMs: number): string {
147
+ const parts: string[] = []
148
+ if (result.stats?.providers !== undefined) parts.push(plural(result.stats.providers, 'provider'))
149
+ if (result.stats?.files !== undefined) parts.push(plural(result.stats.files, 'file'))
150
+ parts.push(formatDuration(result.stats?.durationMs ?? elapsedMs))
151
+ return parts.join(', ')
152
+ }
153
+
154
+ function failure(cli: CliContext, result: CompileResultLike): void {
155
+ const errors = result.diagnostics.filter(diagnostic => diagnostic.severity === 'error').length
156
+ cli.reporter.error(`Generation failed with ${plural(errors, 'error')}`)
157
+ }
158
+
159
+ /** Prints the outcome of a generation. */
160
+ function report(
161
+ cli: CliContext,
162
+ compiler: CompilerApi,
163
+ result: GenerateResultLike,
164
+ root: string,
165
+ elapsed: number,
166
+ ): void {
167
+ printDiagnostics(cli, compiler, result, root)
168
+ if (hasErrors(result.diagnostics)) {
169
+ failure(cli, result)
170
+ return
171
+ }
172
+ const file = displayPath(result.outFile, cli.cwd)
173
+ cli.reporter.success(
174
+ `${result.written ? 'Generated' : 'Up to date:'} ${cli.colors.bold(file)} ${cli.colors.dim(`(${statsOf(result, elapsed)})`)}`,
175
+ )
176
+ }
177
+
178
+ async function generateOnce(
179
+ cli: CliContext,
180
+ compiler: CompilerApi,
181
+ options: ZnightOptions,
182
+ ): Promise<GenerateResultLike> {
183
+ const started = performance.now()
184
+ const result = await compiler.generate(options)
185
+ report(cli, compiler, result, options.root ?? cli.cwd, performance.now() - started)
186
+ return result
187
+ }
188
+
189
+ /**
190
+ * The generated files `--check` reports, the module first: those the compiler says are stale (the
191
+ * module when it does not say which), and the module when it is missing.
192
+ */
193
+ function staleFilesOf(result: CheckResultLike): string[] {
194
+ const stale = new Set<string>()
195
+ if (!existsSync(result.outFile)) stale.add(result.outFile)
196
+ if (result.stale) {
197
+ const files = result.staleFiles ?? []
198
+ for (const file of files.length > 0 ? files : [result.outFile]) stale.add(file)
199
+ }
200
+ return [...stale].sort((a, b) => Number(b === result.outFile) - Number(a === result.outFile))
201
+ }
202
+
203
+ async function check(cli: CliContext, compiler: CompilerApi, options: ZnightOptions): Promise<number> {
204
+ const root = options.root ?? cli.cwd
205
+ const started = performance.now()
206
+ const result = await compiler.check(options)
207
+ printDiagnostics(cli, compiler, result, root)
208
+ if (hasErrors(result.diagnostics)) {
209
+ failure(cli, result)
210
+ return 1
211
+ }
212
+ const file = displayPath(result.outFile, cli.cwd)
213
+ const stale = staleFilesOf(result)
214
+ for (const path of stale) {
215
+ const shown = displayPath(path, cli.cwd)
216
+ // the other generated file is the library manifest (`dist/znight.providers.json`)
217
+ const hint =
218
+ path === result.outFile
219
+ ? 'run "znight generate" (it is git-ignored: generate it before tsc and tests)'
220
+ : 'run "znight generate" after the build: a build cleaning dist/ (tsdown, tsc --build --clean...) deletes it'
221
+ if (existsSync(path)) cli.reporter.error(`${shown} is out of date`, ['run "znight generate"'])
222
+ else cli.reporter.error(`${shown} is missing`, [hint])
223
+ }
224
+ if (stale.length > 0) return 1
225
+ cli.reporter.success(
226
+ `${cli.colors.bold(file)} is up to date ${cli.colors.dim(`(${statsOf(result, performance.now() - started)})`)}`,
227
+ )
228
+ return 0
229
+ }
230
+
231
+ /** What the watcher knows about the last compile. */
232
+ interface WatchState {
233
+ /** Files the last compile depends on (`watchFiles`): absolute, real, POSIX-style. */
234
+ readonly known: ReadonlySet<string>
235
+ /** Directories where a created or deleted source changes the scan (`includeRoots()`). */
236
+ readonly roots: readonly string[]
237
+ }
238
+
239
+ async function watch(cli: CliContext, compiler: CompilerApi, options: ZnightOptions): Promise<number> {
240
+ const root = options.root ?? cli.cwd
241
+ const realRoot = realPath(root)
242
+ const incremental: IncrementalCompiler = compiler.createIncremental(options)
243
+ let state: WatchState = { known: new Set(), roots: [] }
244
+ let pending = new Set<string>()
245
+ let running = false
246
+ let timer: ReturnType<typeof setTimeout> | undefined
247
+
248
+ // which events matter is the compiler's call: the CLI only tells their kind, which fs.watch does not
249
+ const watchers = new TargetWatchers(cli.services.watch, file => {
250
+ const path = realPath(file)
251
+ if (!incremental.isRelevantChange(changeKind(path, state.known), path, state.known, state.roots)) return
252
+ pending.add(path)
253
+ if (timer !== undefined) clearTimeout(timer)
254
+ timer = setTimeout(() => {
255
+ timer = undefined
256
+ void run(false)
257
+ }, 50)
258
+ })
259
+
260
+ const run = async (initial: boolean): Promise<void> => {
261
+ if (running) return
262
+ running = true
263
+ let first = initial
264
+ try {
265
+ do {
266
+ const files = pending
267
+ pending = new Set()
268
+ const started = performance.now()
269
+ let known = state.known
270
+ try {
271
+ const result = await incremental.generate(first ? undefined : classifyChanges(files, state.known))
272
+ if (result.watchFiles !== undefined) {
273
+ known = new Set(result.watchFiles.map(file => toPosixPath(resolve(file))))
274
+ }
275
+ report(cli, compiler, result, root, performance.now() - started)
276
+ } catch (error) {
277
+ cli.reporter.error(`Generation failed: ${errorMessage(error)}`)
278
+ }
279
+ // the include directories of the source libraries are known once compiled
280
+ state = { known, roots: incremental.includeRoots().map(realPath) }
281
+ watchers.update(watchTargets(realRoot, state.roots, state.known), files)
282
+ first = false
283
+ } while (pending.size > 0)
284
+ } finally {
285
+ running = false
286
+ }
287
+ }
288
+
289
+ await run(true)
290
+ cli.reporter.info(cli.colors.dim('Watching for changes... (Ctrl+C to stop)'))
291
+
292
+ return new Promise<number>(resolveExit => {
293
+ const dispose = onTermination(
294
+ cli,
295
+ () => {
296
+ if (timer !== undefined) clearTimeout(timer)
297
+ watchers.close()
298
+ dispose()
299
+ resolveExit(0)
300
+ },
301
+ () => undefined,
302
+ )
303
+ })
304
+ }