@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,79 @@
1
+ /**
2
+ * `znight start`: runs the production build with Node.js (or Bun). On POSIX the CLI process is
3
+ * replaced by the runtime (`execve`): same PID, signals and exit code are native (ideal as a
4
+ * container entry point). Elsewhere the application runs as a child process; signals are forwarded
5
+ * and its exit code is returned.
6
+ */
7
+ import type { Command } from '../command.ts'
8
+
9
+ import { existsSync } from 'node:fs'
10
+ import { resolve } from 'node:path'
11
+
12
+ import { defineCommand } from '../command.ts'
13
+ import { CliError } from '../errors.ts'
14
+ import { exitCodeOf } from '../integrations/process.ts'
15
+ import { type RuntimeOption, resolveRuntime, runtimeArguments, runtimeExecutable } from '../runtime.ts'
16
+ import { displayPath, resolveRoot } from './shared.ts'
17
+
18
+ /** Entries tried when `--entry` is not given. */
19
+ const DEFAULT_ENTRIES = ['dist/main.js', 'dist/main.mjs']
20
+
21
+ /** `znight start` */
22
+ export const startCommand: Command = defineCommand({
23
+ meta: {
24
+ name: 'start',
25
+ description: 'Run the production build',
26
+ usage: '[options] [-- <application arguments>]',
27
+ examples: ['znight start', 'znight start --runtime bun', 'znight start --entry dist/worker.js -- --verbose'],
28
+ details:
29
+ 'NODE_ENV defaults to "production". Signals (SIGINT, SIGTERM) reach the application, which stops gracefully.',
30
+ },
31
+ args: {
32
+ root: { type: 'string', valueHint: 'dir', description: 'Project root (default: the current directory)' },
33
+ entry: { type: 'string', valueHint: 'file', description: 'File to run (default: dist/main.js)' },
34
+ runtime: {
35
+ type: 'enum',
36
+ options: ['auto', 'node', 'bun'],
37
+ default: 'auto',
38
+ description: 'Runtime of the application (auto: the runtime running the CLI)',
39
+ },
40
+ },
41
+ async run({ args, cli, passthrough }) {
42
+ const root = resolveRoot(cli, args.root)
43
+ const candidates = args.entry === undefined ? DEFAULT_ENTRIES : [args.entry]
44
+ const entry = candidates.map(file => resolve(root, file)).find(file => existsSync(file))
45
+ if (entry === undefined) {
46
+ throw new CliError(`${displayPath(resolve(root, candidates[0] ?? ''), cli.cwd)} not found`, {
47
+ hints: ['build the application first: znight build'],
48
+ })
49
+ }
50
+ const runtime = resolveRuntime(args.runtime as RuntimeOption, cli.runtime)
51
+ const executable = runtimeExecutable(runtime, cli.runtime, cli.env)
52
+ if (executable === undefined) {
53
+ throw new CliError(`Cannot find "${runtime}" in PATH`, { hints: ['install it, or choose another --runtime'] })
54
+ }
55
+ const argv = [...runtimeArguments(runtime), entry, ...passthrough]
56
+ const env = { ...cli.env, NODE_ENV: cli.env['NODE_ENV'] ?? 'production' }
57
+ const { processes } = cli.services
58
+ if (processes.execve !== undefined) {
59
+ const clean: Record<string, string> = {}
60
+ for (const [key, value] of Object.entries(env)) if (value !== undefined) clean[key] = value
61
+ processes.execve(executable, [executable, ...argv], clean, root)
62
+ // only reached with a test double (a real execve never returns)
63
+ return 0
64
+ }
65
+ const child = processes.spawn(executable, argv, { cwd: root, env, detached: true })
66
+ const forward = (signal: NodeJS.Signals) => (): void => {
67
+ child.kill(signal)
68
+ }
69
+ const listeners = (['SIGINT', 'SIGTERM', 'SIGHUP'] as const).map(signal => [signal, forward(signal)] as const)
70
+ for (const [signal, listener] of listeners) cli.signals.on(signal, listener)
71
+ try {
72
+ const status = await child.exited
73
+ if (status.error !== undefined) throw new CliError(`Cannot start ${runtime}: ${status.error.message}`)
74
+ return exitCodeOf(status)
75
+ } finally {
76
+ for (const [signal, listener] of listeners) cli.signals.off(signal, listener)
77
+ }
78
+ },
79
+ })
package/src/context.ts ADDED
@@ -0,0 +1,126 @@
1
+ /**
2
+ * Everything a command needs from the outside world: streams, environment, runtime and the
3
+ * services (compiler, Vite, processes, prompts, file watching). Tests replace any of them.
4
+ */
5
+ import type { CompilerApi } from './integrations/compiler.ts'
6
+ import type { OpenApiEmitApi, OpenApiViteApi } from './integrations/openapi.ts'
7
+ import type { ProcessService } from './integrations/process.ts'
8
+ import type { Prompts } from './integrations/prompts.ts'
9
+ import type { ViteApi } from './integrations/vite.ts'
10
+ import type { WatchFactory } from './integrations/watch.ts'
11
+ import type { Colors, OutputStream, Reporter } from './output.ts'
12
+ import type { HostRuntime } from './runtime.ts'
13
+
14
+ import { loadCompiler } from './integrations/compiler.ts'
15
+ import { loadOpenApiEmit, loadOpenApiVite } from './integrations/openapi.ts'
16
+ import { createProcessService } from './integrations/process.ts'
17
+ import { loadPrompts } from './integrations/prompts.ts'
18
+ import { loadVite } from './integrations/vite.ts'
19
+ import { watchFiles } from './integrations/watch.ts'
20
+ import { createColors, createReporter, shouldUseColor } from './output.ts'
21
+ import { detectHostRuntime } from './runtime.ts'
22
+ import { cliVersion } from './version.ts'
23
+
24
+ /** A source of process signals (`process` by default). */
25
+ export interface SignalSource {
26
+ on(signal: NodeJS.Signals, listener: () => void): void
27
+ off(signal: NodeJS.Signals, listener: () => void): void
28
+ }
29
+
30
+ /** External services, lazily loaded. */
31
+ export interface CliServices {
32
+ /** Loads `@znight/compiler` for the project at `root`. */
33
+ loadCompiler(root: string): Promise<CompilerApi>
34
+ /** Loads `vite` for the project at `root`. */
35
+ loadVite(root: string): Promise<ViteApi>
36
+ /** Loads the interactive prompts. */
37
+ loadPrompts(): Promise<Prompts>
38
+ /** Loads `@znight/openapi/emit` for the project at `root` (`znight openapi`). */
39
+ loadOpenApi(root: string): Promise<OpenApiEmitApi>
40
+ /** Loads `@znight/openapi/vite` for the project at `root` (`znight build --openapi`). */
41
+ loadOpenApiVite(root: string): Promise<OpenApiViteApi>
42
+ readonly processes: ProcessService
43
+ readonly watch: WatchFactory
44
+ }
45
+
46
+ /** The execution context of a command. */
47
+ export interface CliContext {
48
+ readonly cwd: string
49
+ readonly env: Readonly<Record<string, string | undefined>>
50
+ readonly stdout: OutputStream
51
+ readonly stderr: OutputStream
52
+ /** Whether prompts may be shown (stdin and stdout are TTYs, not in CI). */
53
+ readonly interactive: boolean
54
+ readonly colors: Colors
55
+ readonly reporter: Reporter
56
+ readonly runtime: HostRuntime
57
+ /** Version of `@znight/cli`. */
58
+ readonly version: string
59
+ readonly signals: SignalSource
60
+ readonly services: CliServices
61
+ /** Print stack traces of unexpected errors. */
62
+ readonly debug: boolean
63
+ }
64
+
65
+ /** Overrides accepted by {@link createContext}. */
66
+ export interface CliContextOptions {
67
+ readonly cwd?: string
68
+ readonly env?: Readonly<Record<string, string | undefined>>
69
+ readonly stdout?: OutputStream
70
+ readonly stderr?: OutputStream
71
+ readonly interactive?: boolean
72
+ /** `--color` / `--no-color`. */
73
+ readonly color?: boolean | undefined
74
+ readonly runtime?: HostRuntime
75
+ readonly version?: string
76
+ readonly signals?: SignalSource
77
+ readonly services?: Partial<CliServices>
78
+ readonly debug?: boolean
79
+ }
80
+
81
+ const processSignals: SignalSource = {
82
+ on: (signal, listener) => {
83
+ process.on(signal, listener)
84
+ },
85
+ off: (signal, listener) => {
86
+ process.off(signal, listener)
87
+ },
88
+ }
89
+
90
+ /**
91
+ * Creates a context from the current process, with optional overrides. The environment is a copy
92
+ * of `process.env` taken now: tools running in the CLI process (Vite sets `NODE_ENV`) do not change
93
+ * what the commands see and pass to the application.
94
+ */
95
+ export function createContext(options: CliContextOptions = {}): CliContext {
96
+ const env = options.env ?? { ...process.env }
97
+ const stdout = options.stdout ?? process.stdout
98
+ const stderr = options.stderr ?? process.stderr
99
+ const colors = createColors(shouldUseColor({ env, stream: stdout, flag: options.color }))
100
+ const interactive =
101
+ options.interactive ??
102
+ (process.stdin.isTTY === true && stdout.isTTY === true && env['CI'] === undefined && env['TERM'] !== 'dumb')
103
+ return {
104
+ cwd: options.cwd ?? process.cwd(),
105
+ env,
106
+ stdout,
107
+ stderr,
108
+ interactive,
109
+ colors,
110
+ reporter: createReporter(stdout, stderr, colors),
111
+ runtime: options.runtime ?? detectHostRuntime(),
112
+ version: options.version ?? cliVersion(),
113
+ signals: options.signals ?? processSignals,
114
+ debug:
115
+ options.debug ?? (env['ZNIGHT_DEBUG'] !== undefined && env['ZNIGHT_DEBUG'] !== '' && env['ZNIGHT_DEBUG'] !== '0'),
116
+ services: {
117
+ loadCompiler: options.services?.loadCompiler ?? loadCompiler,
118
+ loadVite: options.services?.loadVite ?? loadVite,
119
+ loadPrompts: options.services?.loadPrompts ?? loadPrompts,
120
+ loadOpenApi: options.services?.loadOpenApi ?? loadOpenApiEmit,
121
+ loadOpenApiVite: options.services?.loadOpenApiVite ?? loadOpenApiVite,
122
+ processes: options.services?.processes ?? createProcessService(),
123
+ watch: options.services?.watch ?? watchFiles,
124
+ },
125
+ }
126
+ }
@@ -0,0 +1,49 @@
1
+ /**
2
+ * Preloaded into the application by `znight dev` (see `orphan.ts`): stops the application when the
3
+ * CLI that started it is gone without stopping it (`kill -9`, the OOM killer).
4
+ *
5
+ * Configured by `ZNIGHT_DEV_PARENT` (removed from the environment right away: the processes the
6
+ * application starts, `fork()` included, are left alone), and only in the process the CLI started
7
+ * (whose parent is the CLI). Every poll interval it checks that it was not re-parented and that the
8
+ * CLI still exists (an unref'd timer: it never keeps the application alive). Once the CLI is gone,
9
+ * the application gets SIGTERM (its process group when it leads one, as the CLI would send it), so
10
+ * it stops gracefully, then SIGKILL after the kill timeout.
11
+ */
12
+ import { ORPHAN_GUARD_ENV, parseOrphanGuard } from './orphan.ts'
13
+
14
+ const settings = parseOrphanGuard(process.env[ORPHAN_GUARD_ENV])
15
+ Reflect.deleteProperty(process.env, ORPHAN_GUARD_ENV)
16
+
17
+ if (settings !== undefined && process.ppid === settings.parentPid) {
18
+ const { parentPid, pollMs, killTimeoutMs } = settings
19
+ const poll = setInterval(() => {
20
+ if (process.ppid === parentPid && isAlive(parentPid)) return
21
+ clearInterval(poll)
22
+ setTimeout(() => signal('SIGKILL'), killTimeoutMs).unref()
23
+ signal('SIGTERM')
24
+ }, pollMs)
25
+ poll.unref()
26
+ }
27
+
28
+ /** Whether `pid` exists (EPERM: it does, but is not the CLI, which runs as the same user). */
29
+ function isAlive(pid: number): boolean {
30
+ try {
31
+ process.kill(pid, 0)
32
+ return true
33
+ } catch {
34
+ return false
35
+ }
36
+ }
37
+
38
+ /** Signals the process group of the application (it leads one when detached), else the process. */
39
+ function signal(name: NodeJS.Signals): void {
40
+ try {
41
+ if (process.platform !== 'win32') {
42
+ process.kill(-process.pid, name)
43
+ return
44
+ }
45
+ } catch {
46
+ // not a group leader
47
+ }
48
+ process.kill(process.pid, name)
49
+ }
@@ -0,0 +1,74 @@
1
+ /**
2
+ * The orphan guard of `znight dev`. The application runs detached (its own process group, so that a
3
+ * terminal Ctrl+C reaches the CLI only), so when the CLI dies without stopping it (`kill -9`, the OOM
4
+ * killer) it keeps running, holding its port. The CLI therefore preloads `orphan-guard.ts` into the
5
+ * application (`--import` on Node.js, `--preload` on Bun): it watches the CLI process and stops the
6
+ * application once the CLI is gone. No cooperation of the application is needed.
7
+ */
8
+ import type { RuntimeName } from '../runtime.ts'
9
+
10
+ import { existsSync } from 'node:fs'
11
+ import { fileURLToPath } from 'node:url'
12
+
13
+ /**
14
+ * Environment variable configuring the guard in the application process:
15
+ * `<CLI pid>:<poll interval ms>:<kill timeout ms>`. The guard removes it from the environment right
16
+ * away, so that the processes the application starts itself are not guarded.
17
+ */
18
+ export const ORPHAN_GUARD_ENV = 'ZNIGHT_DEV_PARENT'
19
+
20
+ /** Package export of the preloaded module (resolved from the CLI itself). */
21
+ const ORPHAN_GUARD_MODULE = '@znight/cli/orphan-guard'
22
+
23
+ /** Settings of the guard. */
24
+ export interface OrphanGuardSettings {
25
+ /** The CLI process, parent of the application. */
26
+ readonly parentPid: number
27
+ /** How often the guard checks that the CLI is still there. */
28
+ readonly pollMs: number
29
+ /** Time the application gets to stop after SIGTERM before SIGKILL. */
30
+ readonly killTimeoutMs: number
31
+ }
32
+
33
+ /** What a spawned application needs for the guard: runtime arguments and environment. */
34
+ export interface OrphanGuard {
35
+ /** Runtime arguments preloading the guard (before the entry file). */
36
+ readonly args: readonly string[]
37
+ /** Environment variables configuring it. */
38
+ readonly env: Readonly<Record<string, string>>
39
+ }
40
+
41
+ /** Default {@link OrphanGuardSettings.pollMs}. */
42
+ export const ORPHAN_GUARD_POLL_MS = 500
43
+
44
+ /**
45
+ * The runtime arguments and environment installing the guard in an application run with `runtime`,
46
+ * `undefined` when the guard module cannot be located (the application then runs without it).
47
+ */
48
+ export function orphanGuard(
49
+ runtime: RuntimeName,
50
+ settings: OrphanGuardSettings,
51
+ resolveModule: (specifier: string) => string = specifier => import.meta.resolve(specifier),
52
+ ): OrphanGuard | undefined {
53
+ let url: string
54
+ try {
55
+ url = resolveModule(ORPHAN_GUARD_MODULE)
56
+ } catch {
57
+ return undefined
58
+ }
59
+ // a missing preload would stop the application from starting
60
+ if (!url.startsWith('file:') || !existsSync(fileURLToPath(url))) return undefined
61
+ // Node.js imports URLs; Bun preloads paths (and ignores data: URLs)
62
+ const args = runtime === 'node' ? ['--import', url] : ['--preload', fileURLToPath(url)]
63
+ const value = `${settings.parentPid}:${settings.pollMs}:${settings.killTimeoutMs}`
64
+ return { args, env: { [ORPHAN_GUARD_ENV]: value } }
65
+ }
66
+
67
+ /** Reads {@link ORPHAN_GUARD_ENV}: `undefined` when it is missing or malformed. */
68
+ export function parseOrphanGuard(value: string | undefined): OrphanGuardSettings | undefined {
69
+ const parts = value?.split(':') ?? []
70
+ if (parts.length !== 3 || parts.some(part => !/^\d+$/.test(part))) return undefined
71
+ const [parentPid = 0, pollMs = 0, killTimeoutMs = 0] = parts.map(Number)
72
+ if (parentPid <= 0 || pollMs <= 0) return undefined
73
+ return { parentPid, pollMs, killTimeoutMs }
74
+ }
@@ -0,0 +1,175 @@
1
+ /**
2
+ * Restart orchestration of `znight dev`: one application process at a time, restarted (debounced)
3
+ * after each successful build. Stopping sends SIGTERM, waits for the exit and sends SIGKILL after a
4
+ * timeout. Restarts are serialised and coalesced: a burst of builds causes one restart with the
5
+ * latest output. A failed build simply does not schedule a restart (and cancels a pending one when
6
+ * its output is broken), so the last good process keeps running.
7
+ */
8
+ import type { ChildHandle, ExitStatus } from '../integrations/process.ts'
9
+
10
+ /** Notifications of an {@link AppSupervisor}. */
11
+ export interface SupervisorEvents {
12
+ /** A process was started (`restart` is false for the first one). */
13
+ onStart?(child: ChildHandle, restart: boolean): void
14
+ /** A process stopped by the supervisor exited (`forced`: it needed SIGKILL). */
15
+ onStop?(status: ExitStatus, forced: boolean): void
16
+ /** The current process exited by itself (crash, or the application ended). */
17
+ onExit?(status: ExitStatus): void
18
+ /** Starting a process failed synchronously. */
19
+ onError?(error: unknown): void
20
+ }
21
+
22
+ /** Options of {@link AppSupervisor}. */
23
+ export interface AppSupervisorOptions {
24
+ /** Starts a new application process. */
25
+ readonly spawn: () => ChildHandle
26
+ /** Delay applied by {@link AppSupervisor.scheduleRestart}. Default 100 ms. */
27
+ readonly debounceMs?: number
28
+ /** Time a process gets to exit after SIGTERM before SIGKILL. Default 5000 ms. */
29
+ readonly killTimeoutMs?: number
30
+ readonly events?: SupervisorEvents
31
+ }
32
+
33
+ /** Keeps one application process running and restarts it on demand. */
34
+ export class AppSupervisor {
35
+ readonly #spawn: () => ChildHandle
36
+ readonly #debounceMs: number
37
+ readonly #killTimeoutMs: number
38
+ readonly #events: SupervisorEvents
39
+ readonly #stopping = new Set<ChildHandle>()
40
+ #current: ChildHandle | undefined
41
+ #debounce: ReturnType<typeof setTimeout> | undefined
42
+ #queue: Promise<void> = Promise.resolve()
43
+ #queued = false
44
+ #closed = false
45
+ #started = 0
46
+
47
+ constructor(options: AppSupervisorOptions) {
48
+ this.#spawn = options.spawn
49
+ this.#debounceMs = options.debounceMs ?? 100
50
+ this.#killTimeoutMs = options.killTimeoutMs ?? 5000
51
+ this.#events = options.events ?? {}
52
+ }
53
+
54
+ /** Whether an application process is running. */
55
+ get running(): boolean {
56
+ return this.#current !== undefined
57
+ }
58
+
59
+ /** The running process. */
60
+ get current(): ChildHandle | undefined {
61
+ return this.#current
62
+ }
63
+
64
+ /** Number of processes started so far. */
65
+ get starts(): number {
66
+ return this.#started
67
+ }
68
+
69
+ /** Restarts after `debounceMs`; calls in between postpone it. */
70
+ scheduleRestart(): void {
71
+ if (this.#closed) return
72
+ if (this.#debounce !== undefined) clearTimeout(this.#debounce)
73
+ this.#debounce = setTimeout(() => {
74
+ this.#debounce = undefined
75
+ void this.restart()
76
+ }, this.#debounceMs)
77
+ }
78
+
79
+ /**
80
+ * Cancels a restart scheduled by {@link scheduleRestart} and not started yet (the output it would
81
+ * run turned out to be broken, e.g. a failed compilation). Returns whether one was pending.
82
+ */
83
+ cancelRestart(): boolean {
84
+ if (this.#debounce === undefined) return false
85
+ clearTimeout(this.#debounce)
86
+ this.#debounce = undefined
87
+ return true
88
+ }
89
+
90
+ /**
91
+ * Stops the current process (if any) and starts a new one. Restarts never overlap; a restart
92
+ * requested while another one is waiting to run is merged with it.
93
+ */
94
+ restart(): Promise<void> {
95
+ if (this.#closed) return this.#queue
96
+ if (this.#queued) return this.#queue
97
+ this.#queued = true
98
+ this.#queue = this.#queue
99
+ .then(async () => {
100
+ this.#queued = false
101
+ if (this.#closed) return
102
+ await this.#stopCurrent('SIGTERM')
103
+ if (this.#closed) return
104
+ this.#startNew()
105
+ })
106
+ .catch((error: unknown) => {
107
+ // keep the queue usable: a failing listener must not block later restarts
108
+ this.#queued = false
109
+ this.#events.onError?.(error)
110
+ })
111
+ return this.#queue
112
+ }
113
+
114
+ /** Stops the current process with `signal` (SIGKILL after the timeout) and refuses new starts. */
115
+ async stop(signal: NodeJS.Signals = 'SIGTERM'): Promise<void> {
116
+ this.#closed = true
117
+ if (this.#debounce !== undefined) {
118
+ clearTimeout(this.#debounce)
119
+ this.#debounce = undefined
120
+ }
121
+ await this.#queue
122
+ await this.#stopCurrent(signal)
123
+ }
124
+
125
+ /** Immediately SIGKILLs every process (a second Ctrl+C). */
126
+ kill(): void {
127
+ this.#closed = true
128
+ if (this.#debounce !== undefined) clearTimeout(this.#debounce)
129
+ this.#current?.kill('SIGKILL')
130
+ for (const child of this.#stopping) child.kill('SIGKILL')
131
+ }
132
+
133
+ #startNew(): void {
134
+ let child: ChildHandle
135
+ try {
136
+ child = this.#spawn()
137
+ } catch (error) {
138
+ this.#events.onError?.(error)
139
+ return
140
+ }
141
+ const restart = this.#started > 0
142
+ this.#started++
143
+ this.#current = child
144
+ void child.exited.then(status => {
145
+ if (this.#current !== child) return
146
+ this.#current = undefined
147
+ this.#events.onExit?.(status)
148
+ })
149
+ this.#events.onStart?.(child, restart)
150
+ }
151
+
152
+ async #stopCurrent(signal: NodeJS.Signals): Promise<void> {
153
+ const child = this.#current
154
+ if (child === undefined) return
155
+ this.#current = undefined
156
+ this.#stopping.add(child)
157
+ try {
158
+ child.kill(signal)
159
+ let timer: ReturnType<typeof setTimeout> | undefined
160
+ const timeout = new Promise<'timeout'>(resolve => {
161
+ timer = setTimeout(() => resolve('timeout'), this.#killTimeoutMs)
162
+ })
163
+ const first = await Promise.race([child.exited, timeout])
164
+ clearTimeout(timer)
165
+ if (first === 'timeout') {
166
+ child.kill('SIGKILL')
167
+ this.#events.onStop?.(await child.exited, true)
168
+ } else {
169
+ this.#events.onStop?.(first, false)
170
+ }
171
+ } finally {
172
+ this.#stopping.delete(child)
173
+ }
174
+ }
175
+ }
package/src/errors.ts ADDED
@@ -0,0 +1,64 @@
1
+ /**
2
+ * Errors thrown by commands. The dispatcher turns them into a message on stderr and an exit code;
3
+ * any other error is reported as an unexpected failure (exit code 1, stack trace with `--debug`).
4
+ */
5
+
6
+ /** Exit codes of the `znight` binary. */
7
+ export const ExitCode = {
8
+ /** The command succeeded. */
9
+ Ok: 0,
10
+ /** The command failed (compilation errors, failed build, stale generated file...). */
11
+ Failure: 1,
12
+ /** Invalid usage: unknown command or option, missing or invalid argument. */
13
+ Usage: 2,
14
+ /** The user cancelled an interactive prompt or interrupted the command. */
15
+ Cancelled: 130,
16
+ } as const
17
+
18
+ /** One of the {@link ExitCode} values (or any process exit code). */
19
+ export type ExitCodeValue = number
20
+
21
+ /** Options of a {@link CliError}. */
22
+ export interface CliErrorOptions {
23
+ /** Exit code of the process. Default {@link ExitCode.Failure}. */
24
+ readonly exitCode?: number
25
+ /** Lines printed after the message (`= hint: ...`). */
26
+ readonly hints?: readonly string[]
27
+ /** Original error. */
28
+ readonly cause?: unknown
29
+ }
30
+
31
+ /** An expected failure: printed without stack trace. */
32
+ export class CliError extends Error {
33
+ readonly exitCode: number
34
+ readonly hints: readonly string[]
35
+
36
+ constructor(message: string, options: CliErrorOptions = {}) {
37
+ super(message, options.cause === undefined ? undefined : { cause: options.cause })
38
+ this.name = 'CliError'
39
+ this.exitCode = options.exitCode ?? ExitCode.Failure
40
+ this.hints = options.hints ?? []
41
+ }
42
+ }
43
+
44
+ /** Invalid command line usage (exit code 2). The usage of the command is printed after the message. */
45
+ export class UsageError extends CliError {
46
+ constructor(message: string, hints: readonly string[] = []) {
47
+ super(message, { exitCode: ExitCode.Usage, hints })
48
+ this.name = 'UsageError'
49
+ }
50
+ }
51
+
52
+ /** The user cancelled a prompt (exit code 130). */
53
+ export class CancelledError extends CliError {
54
+ constructor(message = 'Cancelled') {
55
+ super(message, { exitCode: ExitCode.Cancelled })
56
+ this.name = 'CancelledError'
57
+ }
58
+ }
59
+
60
+ /** Message of an unknown thrown value. */
61
+ export function errorMessage(error: unknown): string {
62
+ if (error instanceof Error) return error.message
63
+ return String(error)
64
+ }
package/src/index.ts ADDED
@@ -0,0 +1,73 @@
1
+ /**
2
+ * `@znight/cli`: the `znight` command (`znight new`, `generate`, `g`, `dev`, `build`, `start`).
3
+ *
4
+ * The programmatic API runs the CLI in-process (`main(['build'])`) and exposes the pure building
5
+ * blocks: project templates, schematics and the restart supervisor of `znight dev`.
6
+ */
7
+ export type { Command, CommandDefinition, CommandMeta, CommandRunContext } from './command.ts'
8
+ export type { CliContext, CliContextOptions, CliServices, SignalSource } from './context.ts'
9
+ export type {
10
+ AppSupervisorOptions,
11
+ SupervisorEvents,
12
+ } from './dev/supervisor.ts'
13
+ export type {
14
+ CheckResultLike,
15
+ CompileResultLike,
16
+ CompilerApi,
17
+ CompilerChanges,
18
+ CompilerModule,
19
+ Diagnostic,
20
+ DiagnosticLocation,
21
+ FormatDiagnosticsOptions,
22
+ GenerateResultLike,
23
+ IncrementalCompiler,
24
+ ZnightConfigLike,
25
+ ZnightOptions,
26
+ } from './integrations/compiler.ts'
27
+ export type {
28
+ OpenApiEmitApi,
29
+ OpenApiEmitOptionsLike,
30
+ OpenApiEmitResultLike,
31
+ OpenApiViteApi,
32
+ OpenApiVitePluginOptionsLike,
33
+ OpenApiWarningLike,
34
+ } from './integrations/openapi.ts'
35
+ export type {
36
+ ChildHandle,
37
+ ExecveFunction,
38
+ ExitStatus,
39
+ ProcessService,
40
+ RunOptions,
41
+ RunResult,
42
+ SpawnOptions,
43
+ } from './integrations/process.ts'
44
+ export type { PromptChoice, Prompts } from './integrations/prompts.ts'
45
+ export type { FileWatcher, WatchFactory, WatchTarget } from './integrations/watch.ts'
46
+ export type { OutputStream, Reporter } from './output.ts'
47
+ export type { HostRuntime, RuntimeName, RuntimeOption } from './runtime.ts'
48
+ export type {
49
+ Feature,
50
+ HttpAdapterName,
51
+ PackageManager,
52
+ ProjectOptions,
53
+ } from './scaffold/options.ts'
54
+ export type { ProjectFile } from './scaffold/templates.ts'
55
+ export type {
56
+ PlanSchematicOptions,
57
+ Schematic,
58
+ SchematicContext,
59
+ SchematicName,
60
+ SchematicNames,
61
+ SchematicPlan,
62
+ } from './schematics/index.ts'
63
+
64
+ export { defineCommand } from './command.ts'
65
+ export { createContext } from './context.ts'
66
+ export { AppSupervisor } from './dev/supervisor.ts'
67
+ export { CancelledError, CliError, ExitCode, UsageError } from './errors.ts'
68
+ export { adaptCompilerModule, loadCompiler } from './integrations/compiler.ts'
69
+ export { COMMANDS, main } from './main.ts'
70
+ export { DEFAULT_FEATURES, FEATURES, HTTP_ADAPTERS, PACKAGE_MANAGERS } from './scaffold/options.ts'
71
+ export { renderProject } from './scaffold/templates.ts'
72
+ export { findSchematic, planSchematic, SCHEMATICS } from './schematics/index.ts'
73
+ export { cliVersion } from './version.ts'