@znight/cli 0.0.0-stage → 0.1.1
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,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'
|