@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,45 @@
|
|
|
1
|
+
import { existsSync } from "node:fs";
|
|
2
|
+
import { fileURLToPath } from "node:url";
|
|
3
|
+
// src/dev/orphan.ts
|
|
4
|
+
/**
|
|
5
|
+
* Environment variable configuring the guard in the application process:
|
|
6
|
+
* `<CLI pid>:<poll interval ms>:<kill timeout ms>`. The guard removes it from the environment right
|
|
7
|
+
* away, so that the processes the application starts itself are not guarded.
|
|
8
|
+
*/
|
|
9
|
+
const ORPHAN_GUARD_ENV = "ZNIGHT_DEV_PARENT";
|
|
10
|
+
/** Package export of the preloaded module (resolved from the CLI itself). */
|
|
11
|
+
const ORPHAN_GUARD_MODULE = "@znight/cli/orphan-guard";
|
|
12
|
+
/**
|
|
13
|
+
* The runtime arguments and environment installing the guard in an application run with `runtime`,
|
|
14
|
+
* `undefined` when the guard module cannot be located (the application then runs without it).
|
|
15
|
+
*/
|
|
16
|
+
function orphanGuard(runtime, settings, resolveModule = (specifier) => import.meta.resolve(specifier)) {
|
|
17
|
+
let url;
|
|
18
|
+
try {
|
|
19
|
+
url = resolveModule(ORPHAN_GUARD_MODULE);
|
|
20
|
+
} catch {
|
|
21
|
+
return;
|
|
22
|
+
}
|
|
23
|
+
if (!url.startsWith("file:") || !existsSync(fileURLToPath(url))) return void 0;
|
|
24
|
+
const args = runtime === "node" ? ["--import", url] : ["--preload", fileURLToPath(url)];
|
|
25
|
+
const value = `${settings.parentPid}:${settings.pollMs}:${settings.killTimeoutMs}`;
|
|
26
|
+
return {
|
|
27
|
+
args,
|
|
28
|
+
env: { [ORPHAN_GUARD_ENV]: value }
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
/** Reads {@link ORPHAN_GUARD_ENV}: `undefined` when it is missing or malformed. */
|
|
32
|
+
function parseOrphanGuard(value) {
|
|
33
|
+
const parts = value?.split(":") ?? [];
|
|
34
|
+
if (parts.length !== 3 || parts.some((part) => !/^\d+$/.test(part))) return void 0;
|
|
35
|
+
const [parentPid = 0, pollMs = 0, killTimeoutMs = 0] = parts.map(Number);
|
|
36
|
+
if (parentPid <= 0 || pollMs <= 0) return void 0;
|
|
37
|
+
return {
|
|
38
|
+
parentPid,
|
|
39
|
+
pollMs,
|
|
40
|
+
killTimeoutMs
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
export { orphanGuard as n, parseOrphanGuard as r, ORPHAN_GUARD_ENV as t };
|
|
44
|
+
|
|
45
|
+
//# sourceMappingURL=orphan-Dn-2xMBd.mjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"orphan-Dn-2xMBd.mjs","names":[],"sources":["../src/dev/orphan.ts"],"sourcesContent":["/**\n * The orphan guard of `znight dev`. The application runs detached (its own process group, so that a\n * terminal Ctrl+C reaches the CLI only), so when the CLI dies without stopping it (`kill -9`, the OOM\n * killer) it keeps running, holding its port. The CLI therefore preloads `orphan-guard.ts` into the\n * application (`--import` on Node.js, `--preload` on Bun): it watches the CLI process and stops the\n * application once the CLI is gone. No cooperation of the application is needed.\n */\nimport type { RuntimeName } from '../runtime.ts'\n\nimport { existsSync } from 'node:fs'\nimport { fileURLToPath } from 'node:url'\n\n/**\n * Environment variable configuring the guard in the application process:\n * `<CLI pid>:<poll interval ms>:<kill timeout ms>`. The guard removes it from the environment right\n * away, so that the processes the application starts itself are not guarded.\n */\nexport const ORPHAN_GUARD_ENV = 'ZNIGHT_DEV_PARENT'\n\n/** Package export of the preloaded module (resolved from the CLI itself). */\nconst ORPHAN_GUARD_MODULE = '@znight/cli/orphan-guard'\n\n/** Settings of the guard. */\nexport interface OrphanGuardSettings {\n /** The CLI process, parent of the application. */\n readonly parentPid: number\n /** How often the guard checks that the CLI is still there. */\n readonly pollMs: number\n /** Time the application gets to stop after SIGTERM before SIGKILL. */\n readonly killTimeoutMs: number\n}\n\n/** What a spawned application needs for the guard: runtime arguments and environment. */\nexport interface OrphanGuard {\n /** Runtime arguments preloading the guard (before the entry file). */\n readonly args: readonly string[]\n /** Environment variables configuring it. */\n readonly env: Readonly<Record<string, string>>\n}\n\n/** Default {@link OrphanGuardSettings.pollMs}. */\nexport const ORPHAN_GUARD_POLL_MS = 500\n\n/**\n * The runtime arguments and environment installing the guard in an application run with `runtime`,\n * `undefined` when the guard module cannot be located (the application then runs without it).\n */\nexport function orphanGuard(\n runtime: RuntimeName,\n settings: OrphanGuardSettings,\n resolveModule: (specifier: string) => string = specifier => import.meta.resolve(specifier),\n): OrphanGuard | undefined {\n let url: string\n try {\n url = resolveModule(ORPHAN_GUARD_MODULE)\n } catch {\n return undefined\n }\n // a missing preload would stop the application from starting\n if (!url.startsWith('file:') || !existsSync(fileURLToPath(url))) return undefined\n // Node.js imports URLs; Bun preloads paths (and ignores data: URLs)\n const args = runtime === 'node' ? ['--import', url] : ['--preload', fileURLToPath(url)]\n const value = `${settings.parentPid}:${settings.pollMs}:${settings.killTimeoutMs}`\n return { args, env: { [ORPHAN_GUARD_ENV]: value } }\n}\n\n/** Reads {@link ORPHAN_GUARD_ENV}: `undefined` when it is missing or malformed. */\nexport function parseOrphanGuard(value: string | undefined): OrphanGuardSettings | undefined {\n const parts = value?.split(':') ?? []\n if (parts.length !== 3 || parts.some(part => !/^\\d+$/.test(part))) return undefined\n const [parentPid = 0, pollMs = 0, killTimeoutMs = 0] = parts.map(Number)\n if (parentPid <= 0 || pollMs <= 0) return undefined\n return { parentPid, pollMs, killTimeoutMs }\n}\n"],"mappings":";;;;;;;;AAiBA,MAAa,mBAAmB;;AAGhC,MAAM,sBAAsB;;;;;AA2B5B,SAAgB,YACd,SACA,UACA,iBAA+C,cAAa,YAAY,QAAQ,SAAS,GAChE;CACzB,IAAI;CACJ,IAAI;EACF,MAAM,cAAc,mBAAmB;CACzC,QAAQ;EACN;CACF;CAEA,IAAI,CAAC,IAAI,WAAW,OAAO,KAAK,CAAC,WAAW,cAAc,GAAG,CAAC,GAAG,OAAO,KAAA;CAExE,MAAM,OAAO,YAAY,SAAS,CAAC,YAAY,GAAG,IAAI,CAAC,aAAa,cAAc,GAAG,CAAC;CACtF,MAAM,QAAQ,GAAG,SAAS,UAAU,GAAG,SAAS,OAAO,GAAG,SAAS;CACnE,OAAO;EAAE;EAAM,KAAK,GAAG,mBAAmB,MAAM;CAAE;AACpD;;AAGA,SAAgB,iBAAiB,OAA4D;CAC3F,MAAM,QAAQ,OAAO,MAAM,GAAG,KAAK,CAAC;CACpC,IAAI,MAAM,WAAW,KAAK,MAAM,MAAK,SAAQ,CAAC,QAAQ,KAAK,IAAI,CAAC,GAAG,OAAO,KAAA;CAC1E,MAAM,CAAC,YAAY,GAAG,SAAS,GAAG,gBAAgB,KAAK,MAAM,IAAI,MAAM;CACvE,IAAI,aAAa,KAAK,UAAU,GAAG,OAAO,KAAA;CAC1C,OAAO;EAAE;EAAW;EAAQ;CAAc;AAC5C"}
|
package/package.json
CHANGED
|
@@ -1,6 +1,73 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@znight/cli",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.1.1",
|
|
4
|
+
"description": "Znight CLI: scaffold projects, generate code, run dev server and builds",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"author": "Ugho STEPHAN",
|
|
7
|
+
"homepage": "https://github.com/znight-org/znight/tree/main/packages/cli#readme",
|
|
8
|
+
"repository": {
|
|
9
|
+
"type": "git",
|
|
10
|
+
"url": "git+https://github.com/znight-org/znight.git",
|
|
11
|
+
"directory": "packages/cli"
|
|
12
|
+
},
|
|
13
|
+
"bugs": "https://github.com/znight-org/znight/issues",
|
|
14
|
+
"keywords": ["znight", "cli", "scaffolding", "generator", "dependency-injection", "vite"],
|
|
15
|
+
"type": "module",
|
|
16
|
+
"sideEffects": false,
|
|
17
|
+
"engines": {
|
|
18
|
+
"node": ">=24"
|
|
19
|
+
},
|
|
20
|
+
"exports": {
|
|
21
|
+
".": {
|
|
22
|
+
"@znight/source": "./src/index.ts",
|
|
23
|
+
"types": "./dist/index.d.mts",
|
|
24
|
+
"default": "./dist/index.mjs"
|
|
25
|
+
},
|
|
26
|
+
"./orphan-guard": {
|
|
27
|
+
"@znight/source": "./src/dev/orphan-guard.ts",
|
|
28
|
+
"default": "./dist/dev/orphan-guard.mjs"
|
|
29
|
+
},
|
|
30
|
+
"./package.json": "./package.json"
|
|
31
|
+
},
|
|
32
|
+
"types": "./dist/index.d.mts",
|
|
33
|
+
"files": ["bin", "dist", "src"],
|
|
34
|
+
"scripts": {
|
|
35
|
+
"build": "tsdown",
|
|
36
|
+
"typecheck": "tsc --noEmit && tsc --noEmit -p test",
|
|
37
|
+
"test:e2e": "ZNIGHT_CLI_E2E=1 bun test --conditions=@znight/source test/e2e.test.ts"
|
|
38
|
+
},
|
|
39
|
+
"publishConfig": {
|
|
40
|
+
"access": "public"
|
|
41
|
+
},
|
|
42
|
+
"dependencies": {
|
|
43
|
+
"@clack/prompts": "^1.8.1",
|
|
44
|
+
"@znight/core": "0.1.1",
|
|
45
|
+
"citty": "^0.2.2",
|
|
46
|
+
"picocolors": "^1.1.1"
|
|
47
|
+
},
|
|
48
|
+
"bin": {
|
|
49
|
+
"znight": "./bin/znight.mjs"
|
|
50
|
+
},
|
|
51
|
+
"devDependencies": {
|
|
52
|
+
"vite": "8.3.5",
|
|
53
|
+
"@znight/compiler": "0.1.1",
|
|
54
|
+
"@znight/openapi": "0.1.1",
|
|
55
|
+
"@znight/schedule": "0.1.1"
|
|
56
|
+
},
|
|
57
|
+
"peerDependencies": {
|
|
58
|
+
"@znight/compiler": "^0.1.1",
|
|
59
|
+
"vite": "^8.0.0",
|
|
60
|
+
"@znight/openapi": "^0.1.1"
|
|
61
|
+
},
|
|
62
|
+
"peerDependenciesMeta": {
|
|
63
|
+
"@znight/compiler": {
|
|
64
|
+
"optional": true
|
|
65
|
+
},
|
|
66
|
+
"vite": {
|
|
67
|
+
"optional": true
|
|
68
|
+
},
|
|
69
|
+
"@znight/openapi": {
|
|
70
|
+
"optional": true
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
}
|
package/src/cli.ts
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* The `znight` binary. Everything lives in `main()`; this file only wires the process: arguments,
|
|
4
|
+
* exit code, and a safety net for handles left open by third-party code (prompts, watchers).
|
|
5
|
+
*/
|
|
6
|
+
import { main } from './main.ts'
|
|
7
|
+
|
|
8
|
+
const code = await main(process.argv.slice(2))
|
|
9
|
+
process.exitCode = code
|
|
10
|
+
// let pending output flush, then exit even if a library left a handle open
|
|
11
|
+
setTimeout(() => process.exit(code), 1000).unref()
|
package/src/command.ts
ADDED
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A small command layer over citty's argument parser: strict options (unknown options and extra
|
|
3
|
+
* arguments are usage errors, with suggestions), `--` pass-through arguments and help rendering
|
|
4
|
+
* that honours the color settings.
|
|
5
|
+
*/
|
|
6
|
+
import type { ArgDef, ArgsDef, ParsedArgs } from 'citty'
|
|
7
|
+
import type { CliContext } from './context.ts'
|
|
8
|
+
import type { Colors } from './output.ts'
|
|
9
|
+
|
|
10
|
+
import { parseArgs } from 'citty'
|
|
11
|
+
|
|
12
|
+
import { UsageError } from './errors.ts'
|
|
13
|
+
import { camelCase, kebabCase, suggest } from './names.ts'
|
|
14
|
+
import { stripAnsi } from './output.ts'
|
|
15
|
+
|
|
16
|
+
/** What a command receives. */
|
|
17
|
+
export interface CommandRunContext<A extends ArgsDef = ArgsDef> {
|
|
18
|
+
readonly args: ParsedArgs<A>
|
|
19
|
+
readonly cli: CliContext
|
|
20
|
+
/** Arguments of the command as typed (before `--`). */
|
|
21
|
+
readonly rawArgs: readonly string[]
|
|
22
|
+
/** Arguments after `--` (forwarded to the application by `dev` and `start`). */
|
|
23
|
+
readonly passthrough: readonly string[]
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** Whether an option was explicitly given (`--install`, `--no-install`, `--install=false`). */
|
|
27
|
+
export function hasFlag(rawArgs: readonly string[], name: string): boolean {
|
|
28
|
+
return rawArgs.some(
|
|
29
|
+
arg =>
|
|
30
|
+
arg === `--${name}` || arg === `--no-${name}` || arg.startsWith(`--${name}=`) || arg.startsWith(`--no-${name}=`),
|
|
31
|
+
)
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Description of a command. */
|
|
35
|
+
export interface CommandMeta {
|
|
36
|
+
readonly name: string
|
|
37
|
+
readonly description: string
|
|
38
|
+
readonly aliases?: readonly string[]
|
|
39
|
+
/** Usage line after `znight <name>` (default: computed from the arguments). */
|
|
40
|
+
readonly usage?: string
|
|
41
|
+
readonly examples?: readonly string[]
|
|
42
|
+
/** Extra help paragraph printed after the options. */
|
|
43
|
+
readonly details?: string | ((colors: Colors) => string)
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Definition of a command: typed arguments and the implementation. */
|
|
47
|
+
export interface CommandDefinition<A extends ArgsDef> {
|
|
48
|
+
readonly meta: CommandMeta
|
|
49
|
+
readonly args: A
|
|
50
|
+
/** Runs the command; the returned number is the exit code (default 0). */
|
|
51
|
+
run(context: CommandRunContext<A>): number | undefined | Promise<number | undefined>
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** A CLI command, ready to execute. */
|
|
55
|
+
export interface Command {
|
|
56
|
+
readonly meta: CommandMeta
|
|
57
|
+
readonly args: ArgsDef
|
|
58
|
+
/**
|
|
59
|
+
* Parses `argv` (the arguments after the command name, without `--` pass-through arguments) and
|
|
60
|
+
* runs the command. Resolves with the exit code.
|
|
61
|
+
*/
|
|
62
|
+
execute(argv: readonly string[], passthrough: readonly string[], cli: CliContext): Promise<number>
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Creates a {@link Command}; `args` keep their literal types in `run`. */
|
|
66
|
+
export function defineCommand<const A extends ArgsDef>(definition: CommandDefinition<A>): Command {
|
|
67
|
+
return {
|
|
68
|
+
meta: definition.meta,
|
|
69
|
+
args: definition.args,
|
|
70
|
+
execute: async (argv, passthrough, cli) => {
|
|
71
|
+
const args = parseCommandArgs(definition, argv)
|
|
72
|
+
return (await definition.run({ args, cli, rawArgs: argv, passthrough })) ?? 0
|
|
73
|
+
},
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** Parses `argv` (without the command name and without `--` pass-through) for `command`. */
|
|
78
|
+
export function parseCommandArgs<A extends ArgsDef>(
|
|
79
|
+
command: Pick<CommandDefinition<A>, 'args'>,
|
|
80
|
+
argv: readonly string[],
|
|
81
|
+
): ParsedArgs<A> {
|
|
82
|
+
checkOptions(command, argv)
|
|
83
|
+
let parsed: ParsedArgs<A>
|
|
84
|
+
try {
|
|
85
|
+
parsed = parseArgs<A>([...argv], command.args)
|
|
86
|
+
} catch (error) {
|
|
87
|
+
throw new UsageError(stripAnsi(error instanceof Error ? error.message : String(error)))
|
|
88
|
+
}
|
|
89
|
+
const positionals = Object.values(command.args).filter(arg => arg.type === 'positional').length
|
|
90
|
+
const extra = parsed._.slice(positionals)
|
|
91
|
+
if (extra.length > 0) {
|
|
92
|
+
throw new UsageError(`Unexpected argument${extra.length > 1 ? 's' : ''}: ${extra.join(' ')}`)
|
|
93
|
+
}
|
|
94
|
+
return parsed
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
interface OptionInfo {
|
|
98
|
+
readonly type: ArgDef['type']
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function optionTable(args: ArgsDef): Map<string, OptionInfo> {
|
|
102
|
+
const table = new Map<string, OptionInfo>([
|
|
103
|
+
['help', { type: 'boolean' }],
|
|
104
|
+
['h', { type: 'boolean' }],
|
|
105
|
+
])
|
|
106
|
+
for (const [name, def] of Object.entries(args)) {
|
|
107
|
+
if (def.type === 'positional') continue
|
|
108
|
+
const info: OptionInfo = { type: def.type }
|
|
109
|
+
for (const key of [name, kebabCase(name), camelCase(name), ...aliasesOf(def)]) table.set(key, info)
|
|
110
|
+
}
|
|
111
|
+
return table
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function aliasesOf(def: ArgDef): string[] {
|
|
115
|
+
if (!('alias' in def) || def.alias === undefined) return []
|
|
116
|
+
return Array.isArray(def.alias) ? [...def.alias] : [def.alias]
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function checkOptions(command: Pick<Command, 'args'>, argv: readonly string[]): void {
|
|
120
|
+
const table = optionTable(command.args)
|
|
121
|
+
const longNames = [...table.keys()].filter(name => name.length > 1)
|
|
122
|
+
for (let index = 0; index < argv.length; index++) {
|
|
123
|
+
const token = argv[index] ?? ''
|
|
124
|
+
if (!token.startsWith('-') || token === '-') continue
|
|
125
|
+
if (token.startsWith('--')) {
|
|
126
|
+
const [rawName = '', inlineValue] = token.slice(2).split(/=(.*)/s, 2)
|
|
127
|
+
let info = table.get(rawName)
|
|
128
|
+
if (info === undefined && rawName.startsWith('no-')) {
|
|
129
|
+
const negated = table.get(rawName.slice(3))
|
|
130
|
+
if (negated?.type === 'boolean') info = negated
|
|
131
|
+
}
|
|
132
|
+
if (info === undefined) {
|
|
133
|
+
const candidate = suggest(rawName, longNames)
|
|
134
|
+
throw new UsageError(
|
|
135
|
+
`Unknown option --${rawName}`,
|
|
136
|
+
candidate === undefined ? [] : [`did you mean --${candidate}?`],
|
|
137
|
+
)
|
|
138
|
+
}
|
|
139
|
+
if ((info.type === 'string' || info.type === 'enum') && inlineValue === undefined) index++
|
|
140
|
+
continue
|
|
141
|
+
}
|
|
142
|
+
// short options: -y, -f, or a group of boolean flags (-yf)
|
|
143
|
+
const letters = token.slice(1)
|
|
144
|
+
for (const [position, letter] of [...letters].entries()) {
|
|
145
|
+
const info = table.get(letter)
|
|
146
|
+
if (info === undefined) throw new UsageError(`Unknown option -${letter}`)
|
|
147
|
+
if ((info.type === 'string' || info.type === 'enum') && position === letters.length - 1) index++
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/** Formats the help of a command. */
|
|
153
|
+
export function renderCommandHelp(command: Pick<Command, 'meta' | 'args'>, colors: Colors): string {
|
|
154
|
+
const { meta } = command
|
|
155
|
+
const entries = Object.entries(command.args)
|
|
156
|
+
const positionals = entries.filter(([, def]) => def.type === 'positional')
|
|
157
|
+
const options = entries.filter(([, def]) => def.type !== 'positional')
|
|
158
|
+
const usage =
|
|
159
|
+
meta.usage ??
|
|
160
|
+
[
|
|
161
|
+
...positionals.map(([name, def]) =>
|
|
162
|
+
def.required === false || def.default !== undefined ? `[${name}]` : `<${name}>`,
|
|
163
|
+
),
|
|
164
|
+
options.length > 0 ? '[options]' : '',
|
|
165
|
+
]
|
|
166
|
+
.filter(Boolean)
|
|
167
|
+
.join(' ')
|
|
168
|
+
const lines = [`${colors.bold(`znight ${meta.name}`)} ${colors.dim('—')} ${meta.description}`, '']
|
|
169
|
+
lines.push(colors.bold('Usage'), ` ${colors.cyan(`znight ${meta.name}${usage === '' ? '' : ` ${usage}`}`)}`, '')
|
|
170
|
+
if (meta.aliases !== undefined && meta.aliases.length > 0) {
|
|
171
|
+
lines.push(colors.bold('Aliases'), ` ${meta.aliases.join(', ')}`, '')
|
|
172
|
+
}
|
|
173
|
+
if (positionals.length > 0) {
|
|
174
|
+
lines.push(colors.bold('Arguments'))
|
|
175
|
+
lines.push(
|
|
176
|
+
...table(
|
|
177
|
+
positionals.map(([name, def]) => [name, def.description ?? ''] as const),
|
|
178
|
+
colors,
|
|
179
|
+
),
|
|
180
|
+
)
|
|
181
|
+
lines.push('')
|
|
182
|
+
}
|
|
183
|
+
const optionRows = options.map(([name, def]) => [optionLabel(name, def), describeOption(def)] as const)
|
|
184
|
+
optionRows.push(['-h, --help', 'Show this help'])
|
|
185
|
+
lines.push(colors.bold('Options'), ...table(optionRows, colors), '')
|
|
186
|
+
if (meta.details !== undefined) {
|
|
187
|
+
lines.push(typeof meta.details === 'function' ? meta.details(colors) : meta.details, '')
|
|
188
|
+
}
|
|
189
|
+
if (meta.examples !== undefined && meta.examples.length > 0) {
|
|
190
|
+
lines.push(colors.bold('Examples'), ...meta.examples.map(example => ` ${colors.dim('$')} ${example}`), '')
|
|
191
|
+
}
|
|
192
|
+
return lines.join('\n')
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
function optionLabel(name: string, def: ArgDef): string {
|
|
196
|
+
const short = aliasesOf(def)
|
|
197
|
+
.filter(alias => alias.length === 1)
|
|
198
|
+
.map(alias => `-${alias}, `)
|
|
199
|
+
.join('')
|
|
200
|
+
const flag = def.type === 'boolean' && def.default === true ? `--no-${name}` : `--${name}`
|
|
201
|
+
let value = ''
|
|
202
|
+
if (def.type === 'enum') value = ` <${(def.options ?? []).join('|')}>`
|
|
203
|
+
else if (def.type === 'string') value = ` <${def.valueHint ?? name}>`
|
|
204
|
+
return `${short}${flag}${value}`
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
function describeOption(def: ArgDef): string {
|
|
208
|
+
const description = def.description ?? ''
|
|
209
|
+
if (def.type === 'boolean') return description
|
|
210
|
+
return def.default === undefined || def.default === '' ? description : `${description} (default: ${def.default})`
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/** Two aligned columns. */
|
|
214
|
+
export function table(rows: readonly (readonly [string, string])[], colors: Colors): string[] {
|
|
215
|
+
const width = Math.max(0, ...rows.map(([left]) => left.length))
|
|
216
|
+
return rows.map(([left, right]) => ` ${colors.cyan(left.padEnd(width))} ${right}`.trimEnd())
|
|
217
|
+
}
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `znight build`: production build with Vite (the `znight()` plugin compiles the container).
|
|
3
|
+
* `--openapi[=path]` adds the `openapi()` plugin of `@znight/openapi` when the Vite config has none:
|
|
4
|
+
* the OpenAPI document is emitted after the bundle (`dist/openapi.json` by default), with the
|
|
5
|
+
* `servers` of `--openapi-prefix` (the `prefix` of `HttpPlugin`) when `@OpenApiDocument` declares none.
|
|
6
|
+
*/
|
|
7
|
+
import type { Plugin } from 'vite'
|
|
8
|
+
import type { Command } from '../command.ts'
|
|
9
|
+
import type { CliContext } from '../context.ts'
|
|
10
|
+
import type { OpenApiVitePluginOptionsLike } from '../integrations/openapi.ts'
|
|
11
|
+
|
|
12
|
+
import { statSync } from 'node:fs'
|
|
13
|
+
|
|
14
|
+
import { defineCommand } from '../command.ts'
|
|
15
|
+
import { CliError, UsageError } from '../errors.ts'
|
|
16
|
+
import {
|
|
17
|
+
createOutputTracker,
|
|
18
|
+
formatBuildError,
|
|
19
|
+
runBuild,
|
|
20
|
+
type ViteConfigLoader,
|
|
21
|
+
type ViteLogLevel,
|
|
22
|
+
} from '../integrations/vite.ts'
|
|
23
|
+
import { formatBytes, formatDuration } from '../output.ts'
|
|
24
|
+
import { displayPath, parseHttpPrefix, resolveRoot } from './shared.ts'
|
|
25
|
+
|
|
26
|
+
/** `znight build` */
|
|
27
|
+
export const buildCommand: Command = defineCommand({
|
|
28
|
+
meta: {
|
|
29
|
+
name: 'build',
|
|
30
|
+
description: 'Build the application for production (vite build)',
|
|
31
|
+
usage: '[options]',
|
|
32
|
+
examples: [
|
|
33
|
+
'znight build',
|
|
34
|
+
'znight build --mode staging',
|
|
35
|
+
'znight build --openapi # also dist/openapi.json',
|
|
36
|
+
'znight build --openapi --openapi-prefix /api # HttpPlugin.configure({ prefix: "/api" })',
|
|
37
|
+
],
|
|
38
|
+
details: [
|
|
39
|
+
'Fails on any compiler error. With the defaults of the znight() plugin, src/main.ts is built to dist/main.js.',
|
|
40
|
+
'--openapi emits the OpenAPI document after the bundle (dist/openapi.json, or --openapi=<path>) with the',
|
|
41
|
+
'openapi() Vite plugin of @znight/openapi, unless the Vite config already registers it. When HttpPlugin',
|
|
42
|
+
'has a prefix, pass it with --openapi-prefix: it becomes the servers of the document (unless',
|
|
43
|
+
'@OpenApiDocument declares servers), like the document the application serves in "build" mode.',
|
|
44
|
+
].join('\n'),
|
|
45
|
+
},
|
|
46
|
+
args: {
|
|
47
|
+
root: { type: 'string', valueHint: 'dir', description: 'Project root (default: the current directory)' },
|
|
48
|
+
config: { type: 'string', valueHint: 'file', description: 'Vite config file (default: vite.config.*)' },
|
|
49
|
+
mode: { type: 'string', default: 'production', description: 'Vite mode' },
|
|
50
|
+
'config-loader': {
|
|
51
|
+
type: 'enum',
|
|
52
|
+
options: ['bundle', 'runner', 'native'],
|
|
53
|
+
description: 'How Vite loads the config file (default: bundle)',
|
|
54
|
+
},
|
|
55
|
+
'log-level': {
|
|
56
|
+
type: 'enum',
|
|
57
|
+
options: ['info', 'warn', 'error', 'silent'],
|
|
58
|
+
default: 'info',
|
|
59
|
+
description: 'Vite log level',
|
|
60
|
+
},
|
|
61
|
+
openapi: {
|
|
62
|
+
type: 'boolean',
|
|
63
|
+
description: 'Also emit the OpenAPI document (dist/openapi.json, or --openapi=<path>; needs @znight/openapi)',
|
|
64
|
+
},
|
|
65
|
+
'openapi-prefix': {
|
|
66
|
+
type: 'string',
|
|
67
|
+
valueHint: 'path',
|
|
68
|
+
description: 'With --openapi: the HTTP prefix of HttpPlugin, the servers default of the document (/api)',
|
|
69
|
+
},
|
|
70
|
+
},
|
|
71
|
+
async run({ args, cli, rawArgs }) {
|
|
72
|
+
const root = resolveRoot(cli, args.root)
|
|
73
|
+
const openapi = openapiOption(rawArgs, args.openapi)
|
|
74
|
+
const prefix = parseHttpPrefix(args['openapi-prefix'], 'openapi-prefix')
|
|
75
|
+
if (prefix !== undefined && openapi === undefined) {
|
|
76
|
+
throw new UsageError('--openapi-prefix needs --openapi', [`znight build --openapi --openapi-prefix ${prefix}`])
|
|
77
|
+
}
|
|
78
|
+
const vite = await cli.services.loadVite(root)
|
|
79
|
+
const tracker = createOutputTracker(root)
|
|
80
|
+
const extra: Plugin[] = []
|
|
81
|
+
if (openapi !== undefined) {
|
|
82
|
+
const api = await cli.services.loadOpenApiVite(root)
|
|
83
|
+
const options: OpenApiVitePluginOptionsLike = {
|
|
84
|
+
...(openapi === true ? {} : { output: openapi }),
|
|
85
|
+
...(prefix === undefined ? {} : { prefix }),
|
|
86
|
+
}
|
|
87
|
+
const plugin = api.openapi(options)
|
|
88
|
+
extra.push(plugin)
|
|
89
|
+
const given = [
|
|
90
|
+
...(options.output === undefined ? [] : [`--openapi=${options.output}`]),
|
|
91
|
+
...(options.prefix === undefined ? [] : [`--openapi-prefix ${options.prefix}`]),
|
|
92
|
+
]
|
|
93
|
+
if (given.length > 0) extra.push(ignoredOpenApiOptions(cli, plugin, given))
|
|
94
|
+
}
|
|
95
|
+
const started = performance.now()
|
|
96
|
+
try {
|
|
97
|
+
await runBuild(vite, {
|
|
98
|
+
root,
|
|
99
|
+
configFile: args.config,
|
|
100
|
+
configLoader: args['config-loader'] as ViteConfigLoader | undefined,
|
|
101
|
+
mode: args.mode,
|
|
102
|
+
logLevel: args['log-level'] as ViteLogLevel,
|
|
103
|
+
plugins: [tracker.plugin, ...extra],
|
|
104
|
+
})
|
|
105
|
+
} catch (error) {
|
|
106
|
+
// like the Vite CLI: the (enhanced) error message carries the location and the code frame
|
|
107
|
+
cli.reporter.logError(formatBuildError(error))
|
|
108
|
+
throw new CliError('Build failed', { cause: error })
|
|
109
|
+
}
|
|
110
|
+
const entry = tracker.entry()
|
|
111
|
+
const duration = formatDuration(performance.now() - started)
|
|
112
|
+
if (entry === undefined) {
|
|
113
|
+
cli.reporter.success(`Built in ${duration}`)
|
|
114
|
+
return 0
|
|
115
|
+
}
|
|
116
|
+
let size = ''
|
|
117
|
+
try {
|
|
118
|
+
size = ` ${cli.colors.dim(formatBytes(statSync(entry).size))}`
|
|
119
|
+
} catch {
|
|
120
|
+
size = ''
|
|
121
|
+
}
|
|
122
|
+
cli.reporter.success(`Built ${cli.colors.bold(displayPath(entry, cli.cwd))}${size} in ${duration}`)
|
|
123
|
+
cli.reporter.log(` ${cli.colors.dim('run it with')} ${cli.colors.cyan('znight start')}`)
|
|
124
|
+
return 0
|
|
125
|
+
},
|
|
126
|
+
})
|
|
127
|
+
|
|
128
|
+
/** `--openapi` (`true`), `--openapi=<path>` (the path), or `undefined`. */
|
|
129
|
+
function openapiOption(rawArgs: readonly string[], parsed: unknown): true | string | undefined {
|
|
130
|
+
for (const token of rawArgs) {
|
|
131
|
+
if (token.startsWith('--openapi=')) {
|
|
132
|
+
const value = token.slice('--openapi='.length)
|
|
133
|
+
if (value === 'false') return undefined
|
|
134
|
+
return value === '' || value === 'true' ? true : value
|
|
135
|
+
}
|
|
136
|
+
if (token === '--no-openapi') return undefined
|
|
137
|
+
}
|
|
138
|
+
return parsed === true || rawArgs.includes('--openapi') ? true : undefined
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* When the Vite config registers its own `openapi()`, that one emits the document and the copy of the
|
|
143
|
+
* CLI stays inactive: the options given on the command line (`given`) do not apply. Says so, instead
|
|
144
|
+
* of emitting a document without the requested output or prefix.
|
|
145
|
+
*/
|
|
146
|
+
function ignoredOpenApiOptions(cli: CliContext, plugin: Plugin, given: readonly string[]): Plugin {
|
|
147
|
+
return {
|
|
148
|
+
name: 'znight:cli-openapi-options',
|
|
149
|
+
apply: 'build',
|
|
150
|
+
configResolved(config) {
|
|
151
|
+
const active = config.plugins.find(candidate => candidate.name === plugin.name)
|
|
152
|
+
if (active === undefined || active === plugin) return
|
|
153
|
+
cli.reporter.warn(
|
|
154
|
+
`${given.join(' and ')} ignored: the openapi() plugin of the Vite config emits the document with its own options`,
|
|
155
|
+
['set them on openapi() in the Vite config (openapi({ output, prefix })), or remove it from there'],
|
|
156
|
+
)
|
|
157
|
+
},
|
|
158
|
+
}
|
|
159
|
+
}
|