@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,245 @@
|
|
|
1
|
+
/** `znight new <name>`: scaffolds a project, interactively or from flags. */
|
|
2
|
+
import type { Command } from '../command.ts'
|
|
3
|
+
import type { CliContext } from '../context.ts'
|
|
4
|
+
import type { Prompts } from '../integrations/prompts.ts'
|
|
5
|
+
import type { Feature, HttpAdapterName, PackageManager } from '../scaffold/options.ts'
|
|
6
|
+
|
|
7
|
+
import { basename, resolve } from 'node:path'
|
|
8
|
+
|
|
9
|
+
import { defineCommand, hasFlag } from '../command.ts'
|
|
10
|
+
import { CliError, UsageError } from '../errors.ts'
|
|
11
|
+
import { toPackageName, unscopedName, validatePackageName } from '../names.ts'
|
|
12
|
+
import { initGit, installDependencies, targetState, writeProject } from '../scaffold/create.ts'
|
|
13
|
+
import {
|
|
14
|
+
ADAPTER_LABELS,
|
|
15
|
+
DEFAULT_FEATURES,
|
|
16
|
+
detectPackageManager,
|
|
17
|
+
FEATURE_LABELS,
|
|
18
|
+
FEATURES,
|
|
19
|
+
HTTP_ADAPTERS,
|
|
20
|
+
installCommand,
|
|
21
|
+
PACKAGE_MANAGERS,
|
|
22
|
+
parseFeatures,
|
|
23
|
+
runScript,
|
|
24
|
+
} from '../scaffold/options.ts'
|
|
25
|
+
import { renderProject } from '../scaffold/templates.ts'
|
|
26
|
+
import { displayPath } from './shared.ts'
|
|
27
|
+
|
|
28
|
+
/** `znight new` */
|
|
29
|
+
export const newCommand: Command = defineCommand({
|
|
30
|
+
meta: {
|
|
31
|
+
name: 'new',
|
|
32
|
+
description: 'Create a new Znight project',
|
|
33
|
+
usage: '[name] [options]',
|
|
34
|
+
aliases: ['create', 'init'],
|
|
35
|
+
examples: [
|
|
36
|
+
'znight new my-service',
|
|
37
|
+
'znight new my-service --yes --pm pnpm --adapter fastify --features config,observability,messaging',
|
|
38
|
+
'znight new my-worker --yes --features config,schedule',
|
|
39
|
+
'znight new . --yes --no-install',
|
|
40
|
+
],
|
|
41
|
+
},
|
|
42
|
+
args: {
|
|
43
|
+
name: {
|
|
44
|
+
type: 'positional',
|
|
45
|
+
required: false,
|
|
46
|
+
description: 'Project name (npm package name), or "." for the current directory',
|
|
47
|
+
},
|
|
48
|
+
yes: { type: 'boolean', alias: 'y', description: 'Do not ask questions: use the flags and the defaults' },
|
|
49
|
+
pm: {
|
|
50
|
+
type: 'enum',
|
|
51
|
+
options: ['bun', 'npm', 'pnpm', 'yarn'],
|
|
52
|
+
description: 'Package manager (default: the one running the CLI, bun under Bun, else npm)',
|
|
53
|
+
},
|
|
54
|
+
adapter: {
|
|
55
|
+
type: 'enum',
|
|
56
|
+
options: ['node', 'fastify', 'express', 'hono'],
|
|
57
|
+
description: 'HTTP adapter (default: node, the built-in node:http server)',
|
|
58
|
+
},
|
|
59
|
+
features: {
|
|
60
|
+
type: 'string',
|
|
61
|
+
valueHint: 'list',
|
|
62
|
+
description:
|
|
63
|
+
'Comma-separated features: config, observability, messaging, schedule, openapi, or "all" / "none" (default: config,observability)',
|
|
64
|
+
},
|
|
65
|
+
install: { type: 'boolean', default: true, description: 'Do not install the dependencies' },
|
|
66
|
+
git: { type: 'boolean', default: true, description: 'Do not initialise a git repository' },
|
|
67
|
+
dir: { type: 'string', valueHint: 'path', description: 'Target directory (default: ./<name>)' },
|
|
68
|
+
force: { type: 'boolean', description: 'Write into a non-empty directory (existing files are overwritten)' },
|
|
69
|
+
},
|
|
70
|
+
async run({ args: options, cli, rawArgs }) {
|
|
71
|
+
const interactive = cli.interactive && options.yes !== true
|
|
72
|
+
const prompts = interactive ? await cli.services.loadPrompts() : undefined
|
|
73
|
+
prompts?.intro(cli.colors.bold(`Create a Znight project ${cli.colors.dim(`v${cli.version}`)}`))
|
|
74
|
+
|
|
75
|
+
// name and directory
|
|
76
|
+
let name = options.name
|
|
77
|
+
if (name === undefined || name === '') {
|
|
78
|
+
if (prompts === undefined) {
|
|
79
|
+
throw new UsageError('Missing project name', ['znight new <name> (or "." to use the current directory)'])
|
|
80
|
+
}
|
|
81
|
+
name = await prompts.text({
|
|
82
|
+
message: 'Project name?',
|
|
83
|
+
placeholder: 'my-service',
|
|
84
|
+
validate: value => (value === '.' ? undefined : validatePackageName(value.trim())),
|
|
85
|
+
})
|
|
86
|
+
}
|
|
87
|
+
name = name.trim()
|
|
88
|
+
const inPlace = name === '.'
|
|
89
|
+
if (inPlace) name = toPackageName(basename(cli.cwd))
|
|
90
|
+
const problem = validatePackageName(name)
|
|
91
|
+
if (problem !== undefined) {
|
|
92
|
+
const candidate = toPackageName(name)
|
|
93
|
+
throw new UsageError(
|
|
94
|
+
`Invalid project name "${name}": ${problem}`,
|
|
95
|
+
candidate !== '' && validatePackageName(candidate) === undefined ? [`try "${candidate}"`] : [],
|
|
96
|
+
)
|
|
97
|
+
}
|
|
98
|
+
const directory = resolve(cli.cwd, options.dir ?? (inPlace ? '.' : unscopedName(name)))
|
|
99
|
+
await checkTarget(directory, cli, prompts, options.force === true)
|
|
100
|
+
|
|
101
|
+
// choices
|
|
102
|
+
const packageManager: PackageManager =
|
|
103
|
+
options.pm ??
|
|
104
|
+
(await prompts?.select({
|
|
105
|
+
message: 'Package manager?',
|
|
106
|
+
choices: PACKAGE_MANAGERS.map(pm => ({ value: pm, label: pm })),
|
|
107
|
+
initialValue: detectPackageManager(cli.env, cli.runtime.name === 'bun'),
|
|
108
|
+
})) ??
|
|
109
|
+
detectPackageManager(cli.env, cli.runtime.name === 'bun')
|
|
110
|
+
const adapter: HttpAdapterName =
|
|
111
|
+
options.adapter ??
|
|
112
|
+
(await prompts?.select({
|
|
113
|
+
message: 'HTTP adapter?',
|
|
114
|
+
choices: HTTP_ADAPTERS.map(value => ({ value, label: ADAPTER_LABELS[value] })),
|
|
115
|
+
initialValue: 'node',
|
|
116
|
+
})) ??
|
|
117
|
+
'node'
|
|
118
|
+
const features = await chooseFeatures(options.features, prompts)
|
|
119
|
+
const install =
|
|
120
|
+
prompts === undefined || hasFlag(rawArgs, 'install')
|
|
121
|
+
? options.install !== false
|
|
122
|
+
: await prompts.confirm({ message: `Install the dependencies with ${packageManager}?`, initialValue: true })
|
|
123
|
+
const git =
|
|
124
|
+
prompts === undefined || hasFlag(rawArgs, 'git')
|
|
125
|
+
? options.git !== false
|
|
126
|
+
: await prompts.confirm({ message: 'Initialise a git repository?', initialValue: true })
|
|
127
|
+
|
|
128
|
+
// files
|
|
129
|
+
const files = renderProject({ name, packageManager, adapter, features, znightVersion: cli.version })
|
|
130
|
+
writeProject(directory, files)
|
|
131
|
+
const where = displayPath(directory, cli.cwd)
|
|
132
|
+
const summary = `${cli.colors.bold(name)} (${packageManager}, ${adapter}${features.length > 0 ? `, ${features.join(', ')}` : ''})`
|
|
133
|
+
if (prompts === undefined) cli.reporter.success(`Created ${summary} in ${where}`)
|
|
134
|
+
else prompts.note(files.map(file => file.path).join('\n'), `Created ${summary} in ${where}`)
|
|
135
|
+
|
|
136
|
+
if (git) await runGit(directory, cli, prompts)
|
|
137
|
+
let installed = false
|
|
138
|
+
if (install) installed = await runInstall(directory, packageManager, cli, prompts)
|
|
139
|
+
|
|
140
|
+
// next steps
|
|
141
|
+
const steps = [
|
|
142
|
+
...(inPlace && options.dir === undefined ? [] : [`cd ${where}`]),
|
|
143
|
+
...(installed ? [] : [installCommand(packageManager).join(' ')]),
|
|
144
|
+
runScript(packageManager, 'dev'),
|
|
145
|
+
]
|
|
146
|
+
if (prompts === undefined) {
|
|
147
|
+
cli.reporter.log()
|
|
148
|
+
cli.reporter.log(cli.colors.bold('Next steps:'))
|
|
149
|
+
for (const step of steps) cli.reporter.log(` ${cli.colors.cyan(step)}`)
|
|
150
|
+
cli.reporter.log()
|
|
151
|
+
} else {
|
|
152
|
+
prompts.outro(`Next steps:\n${steps.map(step => ` ${cli.colors.cyan(step)}`).join('\n')}`)
|
|
153
|
+
}
|
|
154
|
+
if (install && !installed) {
|
|
155
|
+
throw new CliError('The project was created but installing the dependencies failed', {
|
|
156
|
+
hints: [`run "${installCommand(packageManager).join(' ')}" in ${where}`],
|
|
157
|
+
})
|
|
158
|
+
}
|
|
159
|
+
return 0
|
|
160
|
+
},
|
|
161
|
+
})
|
|
162
|
+
|
|
163
|
+
async function checkTarget(
|
|
164
|
+
directory: string,
|
|
165
|
+
cli: CliContext,
|
|
166
|
+
prompts: Prompts | undefined,
|
|
167
|
+
force: boolean,
|
|
168
|
+
): Promise<void> {
|
|
169
|
+
const state = targetState(directory)
|
|
170
|
+
const where = displayPath(directory, cli.cwd)
|
|
171
|
+
if (state === 'file') throw new CliError(`${where} exists and is not a directory`)
|
|
172
|
+
if (state !== 'not-empty' || force) return
|
|
173
|
+
if (prompts !== undefined) {
|
|
174
|
+
const proceed = await prompts.confirm({
|
|
175
|
+
message: `${where} is not empty. Write the project into it anyway (existing files are overwritten)?`,
|
|
176
|
+
initialValue: false,
|
|
177
|
+
})
|
|
178
|
+
if (proceed) return
|
|
179
|
+
}
|
|
180
|
+
throw new CliError(`${where} is not empty`, {
|
|
181
|
+
hints: ['choose another name or --dir, or pass --force to write into it anyway'],
|
|
182
|
+
})
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
async function chooseFeatures(value: string | undefined, prompts: Prompts | undefined): Promise<Feature[]> {
|
|
186
|
+
if (value !== undefined) {
|
|
187
|
+
const parsed = parseFeatures(value)
|
|
188
|
+
if ('unknown' in parsed) {
|
|
189
|
+
throw new UsageError(`Unknown feature${parsed.unknown.length > 1 ? 's' : ''}: ${parsed.unknown.join(', ')}`, [
|
|
190
|
+
`available features: ${FEATURES.join(', ')} (or "all", "none")`,
|
|
191
|
+
])
|
|
192
|
+
}
|
|
193
|
+
return parsed.features
|
|
194
|
+
}
|
|
195
|
+
if (prompts === undefined) return [...DEFAULT_FEATURES]
|
|
196
|
+
const selected = await prompts.multiselect({
|
|
197
|
+
message: 'Features? (space to toggle)',
|
|
198
|
+
choices: FEATURES.map(feature => ({ value: feature, label: FEATURE_LABELS[feature] })),
|
|
199
|
+
initialValues: DEFAULT_FEATURES,
|
|
200
|
+
})
|
|
201
|
+
return FEATURES.filter(feature => selected.includes(feature))
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
async function runGit(directory: string, cli: CliContext, prompts: Prompts | undefined): Promise<void> {
|
|
205
|
+
const outcome = await initGit(cli.services.processes, directory, cli.env)
|
|
206
|
+
const message = {
|
|
207
|
+
initialized: 'Initialised a git repository',
|
|
208
|
+
'inside-repository': 'Already inside a git repository: skipped git init',
|
|
209
|
+
unavailable: 'git is not available: skipped git init',
|
|
210
|
+
failed: 'git init failed: skipped',
|
|
211
|
+
}[outcome]
|
|
212
|
+
if (outcome === 'initialized') {
|
|
213
|
+
if (prompts === undefined) cli.reporter.success(message)
|
|
214
|
+
} else {
|
|
215
|
+
cli.reporter.warn(message)
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
async function runInstall(
|
|
220
|
+
directory: string,
|
|
221
|
+
packageManager: PackageManager,
|
|
222
|
+
cli: CliContext,
|
|
223
|
+
prompts: Prompts | undefined,
|
|
224
|
+
): Promise<boolean> {
|
|
225
|
+
const command = installCommand(packageManager).join(' ')
|
|
226
|
+
const spinner = prompts?.spinner()
|
|
227
|
+
if (spinner === undefined) cli.reporter.info(`Installing the dependencies (${command})...`)
|
|
228
|
+
else spinner.start(`Installing the dependencies (${command})`)
|
|
229
|
+
const result = await installDependencies(cli.services.processes, directory, packageManager, cli.env)
|
|
230
|
+
const ok = result.code === 0 && result.error === undefined
|
|
231
|
+
if (ok) {
|
|
232
|
+
if (spinner === undefined) cli.reporter.success('Installed the dependencies')
|
|
233
|
+
else spinner.stop('Installed the dependencies')
|
|
234
|
+
return true
|
|
235
|
+
}
|
|
236
|
+
const reason =
|
|
237
|
+
result.error === undefined
|
|
238
|
+
? `${command} exited with code ${result.code ?? result.signal}`
|
|
239
|
+
: `${packageManager} could not be started (${result.error.message})`
|
|
240
|
+
if (spinner === undefined) cli.reporter.error(reason)
|
|
241
|
+
else spinner.stop(reason, true)
|
|
242
|
+
const output = `${result.stdout}${result.stderr}`.trim()
|
|
243
|
+
if (output !== '') cli.reporter.logError(output.split('\n').slice(-30).join('\n'))
|
|
244
|
+
return false
|
|
245
|
+
}
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `znight openapi`: writes the OpenAPI document of the application (`openapi.json`, or YAML by
|
|
3
|
+
* extension) with `@znight/openapi/emit`, or checks that the committed file is up to date
|
|
4
|
+
* (`--check`, for CI). The modules of the application run in a child process.
|
|
5
|
+
*/
|
|
6
|
+
import type { Command } from '../command.ts'
|
|
7
|
+
import type { CliContext } from '../context.ts'
|
|
8
|
+
import type { CompilerApi, Diagnostic } from '../integrations/compiler.ts'
|
|
9
|
+
import type { OpenApiEmitResultLike } from '../integrations/openapi.ts'
|
|
10
|
+
import type { ViteConfigLoader } from '../integrations/vite.ts'
|
|
11
|
+
|
|
12
|
+
import { existsSync } from 'node:fs'
|
|
13
|
+
|
|
14
|
+
import { defineCommand } from '../command.ts'
|
|
15
|
+
import { CliError, errorMessage } from '../errors.ts'
|
|
16
|
+
import { hasErrors } from '../integrations/compiler.ts'
|
|
17
|
+
import { formatDuration, plural } from '../output.ts'
|
|
18
|
+
import { compilerOptions } from './generate.ts'
|
|
19
|
+
import { displayPath, parseHttpPrefix, resolveRoot } from './shared.ts'
|
|
20
|
+
|
|
21
|
+
/** Values of a repeatable string option (`-o a -o b`, `--output=a`, `--output b,c`). */
|
|
22
|
+
export function repeatedOption(rawArgs: readonly string[], name: string, alias: string): string[] {
|
|
23
|
+
const values: string[] = []
|
|
24
|
+
for (let index = 0; index < rawArgs.length; index++) {
|
|
25
|
+
const token = rawArgs[index] ?? ''
|
|
26
|
+
let value: string | undefined
|
|
27
|
+
if (token === `--${name}` || token === `-${alias}`) value = rawArgs[++index]
|
|
28
|
+
else if (token.startsWith(`--${name}=`)) value = token.slice(name.length + 3)
|
|
29
|
+
else if (token.startsWith(`-${alias}=`)) value = token.slice(alias.length + 2)
|
|
30
|
+
if (value === undefined) continue
|
|
31
|
+
for (const part of value.split(',')) if (part.trim() !== '') values.push(part.trim())
|
|
32
|
+
}
|
|
33
|
+
return values
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** `znight openapi` */
|
|
37
|
+
export const openapiCommand: Command = defineCommand({
|
|
38
|
+
meta: {
|
|
39
|
+
name: 'openapi',
|
|
40
|
+
description: 'Emit the OpenAPI document of the application (openapi.json, --check in CI)',
|
|
41
|
+
usage: '[options]',
|
|
42
|
+
examples: [
|
|
43
|
+
'znight openapi # writes openapi.json at the project root',
|
|
44
|
+
'znight openapi -o docs/openapi.yaml # YAML, chosen by the extension (needs yaml)',
|
|
45
|
+
'znight openapi --check # CI: exit 1 when the file is missing or stale',
|
|
46
|
+
'znight openapi --prefix /api # servers default when @OpenApiDocument declares none',
|
|
47
|
+
'znight openapi --runtime bun # load the modules with Bun',
|
|
48
|
+
],
|
|
49
|
+
details: [
|
|
50
|
+
'The application is compiled like "znight generate" (compile errors stop everything), then its',
|
|
51
|
+
'generated module is loaded in a child process (Bun: the compiler preload; Node.js: a Vite server)',
|
|
52
|
+
'and @znight/openapi assembles the document. Nothing is instantiated. Outputs are only written',
|
|
53
|
+
'when they change. Requires @znight/openapi in the project.',
|
|
54
|
+
].join('\n'),
|
|
55
|
+
},
|
|
56
|
+
args: {
|
|
57
|
+
output: {
|
|
58
|
+
type: 'string',
|
|
59
|
+
alias: 'o',
|
|
60
|
+
valueHint: 'file',
|
|
61
|
+
description: 'Output file, repeatable (.yaml / .yml write YAML). Default: openapi.json',
|
|
62
|
+
},
|
|
63
|
+
check: { type: 'boolean', description: 'Do not write: exit with code 1 when an output is missing or stale' },
|
|
64
|
+
prefix: { type: 'string', valueHint: 'path', description: 'HTTP prefix: the servers default (/api)' },
|
|
65
|
+
runtime: {
|
|
66
|
+
type: 'enum',
|
|
67
|
+
options: ['auto', 'node', 'bun'],
|
|
68
|
+
description: 'Runtime loading the application modules (default: auto, the one running the CLI)',
|
|
69
|
+
},
|
|
70
|
+
converters: {
|
|
71
|
+
type: 'string',
|
|
72
|
+
valueHint: 'file',
|
|
73
|
+
description: 'Module whose default export is a SchemaConverter[] (custom schema libraries)',
|
|
74
|
+
},
|
|
75
|
+
root: { type: 'string', valueHint: 'dir', description: 'Project root (default: the current directory)' },
|
|
76
|
+
config: { type: 'string', valueHint: 'file', description: 'Vite config file (default: vite.config.*)' },
|
|
77
|
+
'config-loader': {
|
|
78
|
+
type: 'enum',
|
|
79
|
+
options: ['bundle', 'runner', 'native'],
|
|
80
|
+
description: 'How Vite loads the config file (default: bundle)',
|
|
81
|
+
},
|
|
82
|
+
},
|
|
83
|
+
async run({ args, cli, rawArgs }) {
|
|
84
|
+
const root = resolveRoot(cli, args.root)
|
|
85
|
+
const outputs = repeatedOption(rawArgs, 'output', 'o')
|
|
86
|
+
const prefix = parseHttpPrefix(args.prefix, 'prefix')
|
|
87
|
+
const compiler = await cli.services.loadCompiler(root)
|
|
88
|
+
const configLoader = args['config-loader'] as ViteConfigLoader | undefined
|
|
89
|
+
const options = await compilerOptions(cli, compiler, root, args.config, configLoader)
|
|
90
|
+
const openapi = await cli.services.loadOpenApi(root)
|
|
91
|
+
const started = performance.now()
|
|
92
|
+
let result: OpenApiEmitResultLike
|
|
93
|
+
try {
|
|
94
|
+
result = await openapi.emitOpenApi({
|
|
95
|
+
root,
|
|
96
|
+
output: outputs.length > 0 ? outputs : ['openapi.json'],
|
|
97
|
+
...(args.check === true ? { check: true } : {}),
|
|
98
|
+
...(prefix === undefined ? {} : { prefix }),
|
|
99
|
+
...(args.runtime === undefined ? {} : { runtime: args.runtime as 'auto' | 'node' | 'bun' }),
|
|
100
|
+
...(args.converters === undefined ? {} : { converters: args.converters }),
|
|
101
|
+
...(args.config === undefined ? {} : { config: args.config }),
|
|
102
|
+
...(configLoader === undefined ? {} : { configLoader }),
|
|
103
|
+
compilerOptions: options,
|
|
104
|
+
})
|
|
105
|
+
} catch (error) {
|
|
106
|
+
const hints = (error as { hints?: unknown } | null)?.hints
|
|
107
|
+
throw new CliError(`OpenAPI emission failed: ${errorMessage(error)}`, {
|
|
108
|
+
cause: error,
|
|
109
|
+
hints: Array.isArray(hints) ? (hints as string[]) : [],
|
|
110
|
+
})
|
|
111
|
+
}
|
|
112
|
+
printDiagnostics(cli, compiler, result.diagnostics, root)
|
|
113
|
+
if (!result.ok || hasErrors(result.diagnostics)) {
|
|
114
|
+
const errors = result.diagnostics.filter(diagnostic => diagnostic.severity === 'error').length
|
|
115
|
+
cli.reporter.error(`OpenAPI emission failed: the application does not compile (${plural(errors, 'error')})`)
|
|
116
|
+
return 1
|
|
117
|
+
}
|
|
118
|
+
for (const warning of result.warnings) cli.reporter.warn(`${warning.code}: ${warning.message}`)
|
|
119
|
+
const elapsed = formatDuration(performance.now() - started)
|
|
120
|
+
const operations = Object.values(result.document?.paths ?? {}).reduce(
|
|
121
|
+
(count, item) => count + Object.keys(item).length,
|
|
122
|
+
0,
|
|
123
|
+
)
|
|
124
|
+
const stats = cli.colors.dim(`(${plural(operations, 'operation')}, ${result.runtime}, ${elapsed})`)
|
|
125
|
+
if (args.check === true) {
|
|
126
|
+
for (const file of result.staleFiles) {
|
|
127
|
+
const shown = displayPath(file, cli.cwd)
|
|
128
|
+
if (existsSync(file))
|
|
129
|
+
cli.reporter.error(`${shown} is out of date`, ['run "znight openapi" and commit the result'])
|
|
130
|
+
else cli.reporter.error(`${shown} is missing`, ['run "znight openapi" and commit the result'])
|
|
131
|
+
}
|
|
132
|
+
if (result.stale) return 1
|
|
133
|
+
cli.reporter.success(
|
|
134
|
+
`${result.outputs.map(file => cli.colors.bold(displayPath(file, cli.cwd))).join(', ')} up to date ${stats}`,
|
|
135
|
+
)
|
|
136
|
+
return 0
|
|
137
|
+
}
|
|
138
|
+
for (const file of result.outputs) {
|
|
139
|
+
const shown = cli.colors.bold(displayPath(file, cli.cwd))
|
|
140
|
+
cli.reporter.success(`${result.written.includes(file) ? 'Wrote' : 'Up to date:'} ${shown} ${stats}`)
|
|
141
|
+
}
|
|
142
|
+
return 0
|
|
143
|
+
},
|
|
144
|
+
})
|
|
145
|
+
|
|
146
|
+
function printDiagnostics(
|
|
147
|
+
cli: CliContext,
|
|
148
|
+
compiler: CompilerApi,
|
|
149
|
+
diagnostics: readonly Diagnostic[],
|
|
150
|
+
root: string,
|
|
151
|
+
): void {
|
|
152
|
+
if (diagnostics.length === 0) return
|
|
153
|
+
const text = compiler.formatDiagnostics(diagnostics, {
|
|
154
|
+
color: cli.colors.isColorSupported,
|
|
155
|
+
cwd: root,
|
|
156
|
+
summary: false,
|
|
157
|
+
})
|
|
158
|
+
cli.reporter.logError(text.trimEnd())
|
|
159
|
+
cli.reporter.logError()
|
|
160
|
+
}
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
/** `znight g <schematic> <name>`: generates a service, controller, consumer, config, guard, interceptor, factory or job. */
|
|
2
|
+
import type { Command } from '../command.ts'
|
|
3
|
+
import type { Colors } from '../output.ts'
|
|
4
|
+
|
|
5
|
+
import { existsSync, mkdirSync, writeFileSync } from 'node:fs'
|
|
6
|
+
import { dirname, resolve } from 'node:path'
|
|
7
|
+
|
|
8
|
+
import { defineCommand, table } from '../command.ts'
|
|
9
|
+
import { CliError, UsageError } from '../errors.ts'
|
|
10
|
+
import { suggest } from '../names.ts'
|
|
11
|
+
import { findSchematic, planSchematic, SCHEMATICS } from '../schematics/index.ts'
|
|
12
|
+
import { displayPath, findProjectRoot, readManifest, resolveRoot } from './shared.ts'
|
|
13
|
+
|
|
14
|
+
function schematicList(colors: Colors): string {
|
|
15
|
+
return table(
|
|
16
|
+
SCHEMATICS.map(
|
|
17
|
+
schematic => [`${schematic.name} (${schematic.aliases.join(', ')})`, schematic.description] as const,
|
|
18
|
+
),
|
|
19
|
+
colors,
|
|
20
|
+
).join('\n')
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** `znight g` */
|
|
24
|
+
export const schematicCommand: Command = defineCommand({
|
|
25
|
+
meta: {
|
|
26
|
+
name: 'g',
|
|
27
|
+
description: 'Generate a service, controller, consumer, config, guard, interceptor, factory or job',
|
|
28
|
+
usage: '<schematic> <name> [options]',
|
|
29
|
+
aliases: ['generate <schematic>'],
|
|
30
|
+
details: colors =>
|
|
31
|
+
`${colors.bold('Schematics')}\n${schematicList(colors)}\n\nFiles are named <name>.<schematic>.ts (kebab-case), classes <Name><Schematic> (PascalCase).`,
|
|
32
|
+
examples: [
|
|
33
|
+
'znight g service users/user # src/users/user.service.ts (UserService)',
|
|
34
|
+
'znight g controller order # src/order/order.controller.ts (OrderController)',
|
|
35
|
+
'znight g guard auth --flat # src/auth.guard.ts (AuthGuard)',
|
|
36
|
+
'znight g interceptor timing --aop # a method interceptor (@Intercept)',
|
|
37
|
+
'znight g consumer orders --path src/messaging',
|
|
38
|
+
'znight g job digest # src/digest/digest.jobs.ts (DigestJobs)',
|
|
39
|
+
],
|
|
40
|
+
},
|
|
41
|
+
args: {
|
|
42
|
+
schematic: { type: 'positional', required: false, description: 'What to generate (see Schematics below)' },
|
|
43
|
+
name: {
|
|
44
|
+
type: 'positional',
|
|
45
|
+
required: false,
|
|
46
|
+
description: 'Name, optionally prefixed with directories relative to src/ (users/user-profile)',
|
|
47
|
+
},
|
|
48
|
+
path: { type: 'string', valueHint: 'dir', description: 'Directory of the file (default: src/<name>/)' },
|
|
49
|
+
flat: { type: 'boolean', description: 'Do not create a directory named after the element' },
|
|
50
|
+
force: { type: 'boolean', alias: 'f', description: 'Overwrite an existing file' },
|
|
51
|
+
'dry-run': { type: 'boolean', alias: 'd', description: 'Print the file instead of writing it' },
|
|
52
|
+
aop: { type: 'boolean', description: 'interceptor: a method interceptor (@Intercept) instead of an HTTP one' },
|
|
53
|
+
root: { type: 'string', valueHint: 'dir', description: 'Project root (default: the nearest package.json)' },
|
|
54
|
+
},
|
|
55
|
+
run({ args, cli }) {
|
|
56
|
+
if (args.schematic === undefined || args.schematic === '') {
|
|
57
|
+
throw new UsageError('Missing <schematic>', [`available schematics:\n${schematicList(cli.colors)}`])
|
|
58
|
+
}
|
|
59
|
+
const schematic = findSchematic(args.schematic)
|
|
60
|
+
if (schematic === undefined) {
|
|
61
|
+
const candidate = suggest(
|
|
62
|
+
args.schematic,
|
|
63
|
+
SCHEMATICS.map(item => item.name),
|
|
64
|
+
)
|
|
65
|
+
throw new UsageError(`Unknown schematic "${args.schematic}"`, [
|
|
66
|
+
...(candidate === undefined ? [] : [`did you mean "${candidate}"?`]),
|
|
67
|
+
`available schematics: ${SCHEMATICS.map(item => item.name).join(', ')}`,
|
|
68
|
+
])
|
|
69
|
+
}
|
|
70
|
+
if (args.name === undefined || args.name === '') {
|
|
71
|
+
throw new UsageError('Missing <name>', [
|
|
72
|
+
`znight g ${schematic.name} <name>, e.g. znight g ${schematic.name} users/user`,
|
|
73
|
+
])
|
|
74
|
+
}
|
|
75
|
+
const root = args.root === undefined ? findProjectRoot(cli.cwd) : resolveRoot(cli, args.root)
|
|
76
|
+
const manifest = readManifest(root)
|
|
77
|
+
const zod = manifest.dependencies?.['zod'] !== undefined || manifest.devDependencies?.['zod'] !== undefined
|
|
78
|
+
let plan: ReturnType<typeof planSchematic>
|
|
79
|
+
try {
|
|
80
|
+
plan = planSchematic({
|
|
81
|
+
schematic,
|
|
82
|
+
name: args.name,
|
|
83
|
+
root,
|
|
84
|
+
path: args.path === undefined ? undefined : resolve(cli.cwd, args.path),
|
|
85
|
+
flat: args.flat === true,
|
|
86
|
+
context: { zod, aop: args.aop === true },
|
|
87
|
+
})
|
|
88
|
+
} catch (error) {
|
|
89
|
+
throw new UsageError(error instanceof Error ? error.message : String(error))
|
|
90
|
+
}
|
|
91
|
+
const file = displayPath(plan.file, cli.cwd)
|
|
92
|
+
if (args['dry-run'] === true) {
|
|
93
|
+
cli.reporter.info(`Would create ${cli.colors.bold(file)} (${plan.names.className})`)
|
|
94
|
+
cli.reporter.log()
|
|
95
|
+
cli.reporter.log(plan.content)
|
|
96
|
+
return 0
|
|
97
|
+
}
|
|
98
|
+
const exists = existsSync(plan.file)
|
|
99
|
+
if (exists && args.force !== true) {
|
|
100
|
+
throw new CliError(`${file} already exists`, { hints: ['pass --force to overwrite it'] })
|
|
101
|
+
}
|
|
102
|
+
mkdirSync(dirname(plan.file), { recursive: true })
|
|
103
|
+
writeFileSync(plan.file, plan.content)
|
|
104
|
+
cli.reporter.success(
|
|
105
|
+
`${exists ? 'Overwrote' : 'Created'} ${cli.colors.bold(file)} ${cli.colors.dim(`(${plan.names.className})`)}`,
|
|
106
|
+
)
|
|
107
|
+
if (plan.hint !== undefined) cli.reporter.log(` ${cli.colors.dim(plan.hint)}`)
|
|
108
|
+
return 0
|
|
109
|
+
},
|
|
110
|
+
})
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
/** Helpers shared by the commands. */
|
|
2
|
+
import type { CliContext } from '../context.ts'
|
|
3
|
+
|
|
4
|
+
import { existsSync, readFileSync, statSync } from 'node:fs'
|
|
5
|
+
import { dirname, isAbsolute, join, relative, resolve, sep } from 'node:path'
|
|
6
|
+
|
|
7
|
+
import { CliError, UsageError } from '../errors.ts'
|
|
8
|
+
|
|
9
|
+
/** `--root`, resolved against the working directory. Must be an existing directory. */
|
|
10
|
+
export function resolveRoot(cli: CliContext, root: string | undefined): string {
|
|
11
|
+
const directory = resolve(cli.cwd, root ?? '.')
|
|
12
|
+
let isDirectory = false
|
|
13
|
+
try {
|
|
14
|
+
isDirectory = statSync(directory).isDirectory()
|
|
15
|
+
} catch {
|
|
16
|
+
isDirectory = false
|
|
17
|
+
}
|
|
18
|
+
if (!isDirectory) throw new CliError(`Project root not found: ${directory}`)
|
|
19
|
+
return directory
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** The nearest directory containing a `package.json`, from `start` upwards (`start` when none). */
|
|
23
|
+
export function findProjectRoot(start: string): string {
|
|
24
|
+
let directory = start
|
|
25
|
+
for (;;) {
|
|
26
|
+
if (existsSync(join(directory, 'package.json'))) return directory
|
|
27
|
+
const parent = dirname(directory)
|
|
28
|
+
if (parent === directory) return start
|
|
29
|
+
directory = parent
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Reads `package.json` of `root` (an empty object when missing or invalid). */
|
|
34
|
+
export function readManifest(root: string): {
|
|
35
|
+
dependencies?: Record<string, string>
|
|
36
|
+
devDependencies?: Record<string, string>
|
|
37
|
+
} {
|
|
38
|
+
try {
|
|
39
|
+
const manifest: unknown = JSON.parse(readFileSync(join(root, 'package.json'), 'utf8'))
|
|
40
|
+
return typeof manifest === 'object' && manifest !== null ? manifest : {}
|
|
41
|
+
} catch {
|
|
42
|
+
return {}
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Parses a non-negative integer option (milliseconds...). */
|
|
47
|
+
export function parseNonNegativeInteger(value: string | undefined, option: string, fallback: number): number {
|
|
48
|
+
if (value === undefined || value === '') return fallback
|
|
49
|
+
const parsed = Number(value)
|
|
50
|
+
if (!Number.isInteger(parsed) || parsed < 0) {
|
|
51
|
+
throw new UsageError(`Invalid value for --${option}: expected a non-negative integer, got "${value}"`)
|
|
52
|
+
}
|
|
53
|
+
return parsed
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** An HTTP prefix option (`/api`, the `prefix` of `HttpPlugin`): `undefined` when not given. */
|
|
57
|
+
export function parseHttpPrefix(value: string | undefined, option: string): string | undefined {
|
|
58
|
+
if (value === undefined) return undefined
|
|
59
|
+
if (!value.startsWith('/')) {
|
|
60
|
+
throw new UsageError(`Invalid --${option} "${value}": expected a path starting with "/"`)
|
|
61
|
+
}
|
|
62
|
+
return value
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** A path for messages: relative to `from`, or absolute when that is shorter. POSIX separators. */
|
|
66
|
+
export function displayPath(file: string, from: string): string {
|
|
67
|
+
const path = relative(from, file)
|
|
68
|
+
if (path === '') return '.'
|
|
69
|
+
const shortest = isAbsolute(path) || (path.startsWith('..') && file.length < path.length) ? file : path
|
|
70
|
+
return shortest.split(sep).join('/')
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Signals stopping a long-running command: Ctrl+C, `kill`, the terminal closed (`SIGHUP`) and, on
|
|
75
|
+
* POSIX, `Ctrl+\` (`SIGQUIT`). Without a listener the last two kill the CLI without running its exit
|
|
76
|
+
* hooks, leaving the application of `znight dev` (in its own session) running.
|
|
77
|
+
*/
|
|
78
|
+
export const TERMINATION_SIGNALS: readonly NodeJS.Signals[] =
|
|
79
|
+
process.platform === 'win32' ? ['SIGINT', 'SIGTERM', 'SIGHUP'] : ['SIGINT', 'SIGTERM', 'SIGHUP', 'SIGQUIT']
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Calls `onFirst` on the first {@link TERMINATION_SIGNALS termination signal}; `onRepeat` runs on
|
|
83
|
+
* any later one (force quit). Returns a function removing the listeners.
|
|
84
|
+
*/
|
|
85
|
+
export function onTermination(
|
|
86
|
+
cli: CliContext,
|
|
87
|
+
onFirst: (signal: NodeJS.Signals) => void,
|
|
88
|
+
onRepeat: (signal: NodeJS.Signals) => void,
|
|
89
|
+
): () => void {
|
|
90
|
+
let received = false
|
|
91
|
+
const listeners = TERMINATION_SIGNALS.map(signal => {
|
|
92
|
+
const listener = (): void => {
|
|
93
|
+
if (received) {
|
|
94
|
+
onRepeat(signal)
|
|
95
|
+
return
|
|
96
|
+
}
|
|
97
|
+
received = true
|
|
98
|
+
onFirst(signal)
|
|
99
|
+
}
|
|
100
|
+
cli.signals.on(signal, listener)
|
|
101
|
+
return [signal, listener] as const
|
|
102
|
+
})
|
|
103
|
+
return () => {
|
|
104
|
+
for (const [signal, listener] of listeners) cli.signals.off(signal, listener)
|
|
105
|
+
}
|
|
106
|
+
}
|