@znight/cli 0.0.0-stage → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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,3865 @@
|
|
|
1
|
+
import { n as orphanGuard } from "./orphan-Dn-2xMBd.mjs";
|
|
2
|
+
import { createRequire } from "node:module";
|
|
3
|
+
import { parseArgs } from "citty";
|
|
4
|
+
import { stripVTControlCharacters } from "node:util";
|
|
5
|
+
import picocolors from "picocolors";
|
|
6
|
+
import { accessSync, constants, existsSync, mkdirSync, readFileSync, readdirSync, realpathSync, statSync, watch, writeFileSync } from "node:fs";
|
|
7
|
+
import { basename, delimiter, dirname, isAbsolute, join, normalize, posix, relative, resolve, sep } from "node:path";
|
|
8
|
+
import { pathToFileURL } from "node:url";
|
|
9
|
+
import { spawn } from "node:child_process";
|
|
10
|
+
import { constants as constants$1 } from "node:os";
|
|
11
|
+
// src/errors.ts
|
|
12
|
+
/**
|
|
13
|
+
* Errors thrown by commands. The dispatcher turns them into a message on stderr and an exit code;
|
|
14
|
+
* any other error is reported as an unexpected failure (exit code 1, stack trace with `--debug`).
|
|
15
|
+
*/
|
|
16
|
+
/** Exit codes of the `znight` binary. */
|
|
17
|
+
const ExitCode = {
|
|
18
|
+
/** The command succeeded. */
|
|
19
|
+
Ok: 0,
|
|
20
|
+
/** The command failed (compilation errors, failed build, stale generated file...). */
|
|
21
|
+
Failure: 1,
|
|
22
|
+
/** Invalid usage: unknown command or option, missing or invalid argument. */
|
|
23
|
+
Usage: 2,
|
|
24
|
+
/** The user cancelled an interactive prompt or interrupted the command. */
|
|
25
|
+
Cancelled: 130
|
|
26
|
+
};
|
|
27
|
+
/** An expected failure: printed without stack trace. */
|
|
28
|
+
var CliError = class extends Error {
|
|
29
|
+
exitCode;
|
|
30
|
+
hints;
|
|
31
|
+
constructor(message, options = {}) {
|
|
32
|
+
super(message, options.cause === void 0 ? void 0 : { cause: options.cause });
|
|
33
|
+
this.name = "CliError";
|
|
34
|
+
this.exitCode = options.exitCode ?? ExitCode.Failure;
|
|
35
|
+
this.hints = options.hints ?? [];
|
|
36
|
+
}
|
|
37
|
+
};
|
|
38
|
+
/** Invalid command line usage (exit code 2). The usage of the command is printed after the message. */
|
|
39
|
+
var UsageError = class extends CliError {
|
|
40
|
+
constructor(message, hints = []) {
|
|
41
|
+
super(message, {
|
|
42
|
+
exitCode: ExitCode.Usage,
|
|
43
|
+
hints
|
|
44
|
+
});
|
|
45
|
+
this.name = "UsageError";
|
|
46
|
+
}
|
|
47
|
+
};
|
|
48
|
+
/** The user cancelled a prompt (exit code 130). */
|
|
49
|
+
var CancelledError = class extends CliError {
|
|
50
|
+
constructor(message = "Cancelled") {
|
|
51
|
+
super(message, { exitCode: ExitCode.Cancelled });
|
|
52
|
+
this.name = "CancelledError";
|
|
53
|
+
}
|
|
54
|
+
};
|
|
55
|
+
/** Message of an unknown thrown value. */
|
|
56
|
+
function errorMessage(error) {
|
|
57
|
+
if (error instanceof Error) return error.message;
|
|
58
|
+
return String(error);
|
|
59
|
+
}
|
|
60
|
+
// src/names.ts
|
|
61
|
+
/**
|
|
62
|
+
* Naming helpers shared by `znight new` and `znight g`: case conversions, npm package names,
|
|
63
|
+
* "did you mean" suggestions.
|
|
64
|
+
*/
|
|
65
|
+
/** Splits an identifier into lower-case words: `UserProfile`, `user_profile`, `HTTPClient` → words. */
|
|
66
|
+
function words(name) {
|
|
67
|
+
return name.replace(/([a-z0-9])([A-Z])/g, "$1 $2").replace(/([A-Z]+)([A-Z][a-z])/g, "$1 $2").split(/[^A-Za-z0-9]+/).filter((word) => word !== "").map((word) => word.toLowerCase());
|
|
68
|
+
}
|
|
69
|
+
/** `user-profile` */
|
|
70
|
+
function kebabCase(name) {
|
|
71
|
+
return words(name).join("-");
|
|
72
|
+
}
|
|
73
|
+
/** `UserProfile` */
|
|
74
|
+
function pascalCase(name) {
|
|
75
|
+
return words(name).map((word) => word.charAt(0).toUpperCase() + word.slice(1)).join("");
|
|
76
|
+
}
|
|
77
|
+
/** `userProfile` */
|
|
78
|
+
function camelCase(name) {
|
|
79
|
+
const pascal = pascalCase(name);
|
|
80
|
+
return pascal.charAt(0).toLowerCase() + pascal.slice(1);
|
|
81
|
+
}
|
|
82
|
+
/** `USER_PROFILE` */
|
|
83
|
+
function constantCase(name) {
|
|
84
|
+
return words(name).map((word) => word.toUpperCase()).join("_");
|
|
85
|
+
}
|
|
86
|
+
const PACKAGE_NAME = /^(?:@[a-z0-9][a-z0-9._~-]*\/)?[a-z0-9][a-z0-9._~-]*$/;
|
|
87
|
+
/**
|
|
88
|
+
* Validates an npm package name (lower case, URL-safe, at most 214 characters, optional scope).
|
|
89
|
+
* Returns a problem description, or `undefined` when the name is valid.
|
|
90
|
+
*/
|
|
91
|
+
function validatePackageName(name) {
|
|
92
|
+
if (name === "") return "the name cannot be empty";
|
|
93
|
+
if (name.length > 214) return "the name cannot be longer than 214 characters";
|
|
94
|
+
if (name !== name.trim()) return "the name cannot start or end with spaces";
|
|
95
|
+
if (name !== name.toLowerCase()) return "the name must be lower case";
|
|
96
|
+
if (name.startsWith(".") || name.startsWith("_")) return "the name cannot start with \".\" or \"_\"";
|
|
97
|
+
if (!PACKAGE_NAME.test(name)) return "the name may only contain lower-case letters, digits, \"-\", \".\", \"_\" and \"~\" (and an optional @scope/)";
|
|
98
|
+
}
|
|
99
|
+
/** Turns any string into a valid package name candidate: `My App!` → `my-app`. */
|
|
100
|
+
function toPackageName(name) {
|
|
101
|
+
return name.trim().toLowerCase().replace(/\s+/g, "-").replace(/^[._]+/, "").replace(/[^a-z0-9@/._~-]+/g, "-").replace(/-+/g, "-").replace(/^-|-$/g, "");
|
|
102
|
+
}
|
|
103
|
+
/** The unscoped part of a package name: `@acme/api` → `api`. */
|
|
104
|
+
function unscopedName(name) {
|
|
105
|
+
const slash = name.indexOf("/");
|
|
106
|
+
return name.startsWith("@") && slash !== -1 ? name.slice(slash + 1) : name;
|
|
107
|
+
}
|
|
108
|
+
/** Levenshtein distance (small strings only). */
|
|
109
|
+
function editDistance(a, b) {
|
|
110
|
+
const previous = Array.from({ length: b.length + 1 }, (_, index) => index);
|
|
111
|
+
for (let i = 1; i <= a.length; i++) {
|
|
112
|
+
let diagonal = previous[0] ?? 0;
|
|
113
|
+
previous[0] = i;
|
|
114
|
+
for (let j = 1; j <= b.length; j++) {
|
|
115
|
+
const above = previous[j] ?? 0;
|
|
116
|
+
const cost = a[i - 1] === b[j - 1] ? 0 : 1;
|
|
117
|
+
previous[j] = Math.min(above + 1, (previous[j - 1] ?? 0) + 1, diagonal + cost);
|
|
118
|
+
diagonal = above;
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
return previous[b.length] ?? 0;
|
|
122
|
+
}
|
|
123
|
+
/** The closest candidate within a distance of `max(2, length / 3)`, if any. */
|
|
124
|
+
function suggest(input, candidates) {
|
|
125
|
+
let best;
|
|
126
|
+
let bestDistance = Number.POSITIVE_INFINITY;
|
|
127
|
+
const limit = Math.max(2, Math.floor(input.length / 3));
|
|
128
|
+
for (const candidate of candidates) {
|
|
129
|
+
const distance = editDistance(input.toLowerCase(), candidate.toLowerCase());
|
|
130
|
+
if (distance < bestDistance && distance <= limit) {
|
|
131
|
+
best = candidate;
|
|
132
|
+
bestDistance = distance;
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
return best;
|
|
136
|
+
}
|
|
137
|
+
// src/output.ts
|
|
138
|
+
/**
|
|
139
|
+
* Terminal output: color detection (NO_COLOR, FORCE_COLOR, `--color` / `--no-color`, TTY), a small
|
|
140
|
+
* reporter writing consistent status lines, and formatting helpers.
|
|
141
|
+
*/
|
|
142
|
+
/**
|
|
143
|
+
* Whether colors should be written: `--no-color` and a non-empty `NO_COLOR` disable them,
|
|
144
|
+
* `--color` and `FORCE_COLOR` (other than `0`/`false`) force them, otherwise a TTY that is not
|
|
145
|
+
* `TERM=dumb` gets colors.
|
|
146
|
+
*/
|
|
147
|
+
function shouldUseColor({ env, stream, flag }) {
|
|
148
|
+
if (flag !== void 0) return flag;
|
|
149
|
+
if (env["NO_COLOR"] !== void 0 && env["NO_COLOR"] !== "") return false;
|
|
150
|
+
const force = env["FORCE_COLOR"];
|
|
151
|
+
if (force !== void 0) return force !== "0" && force !== "false";
|
|
152
|
+
return stream.isTTY === true && env["TERM"] !== "dumb";
|
|
153
|
+
}
|
|
154
|
+
/** picocolors formatters, enabled or not. */
|
|
155
|
+
function createColors(enabled) {
|
|
156
|
+
return picocolors.createColors(enabled);
|
|
157
|
+
}
|
|
158
|
+
/** Removes ANSI escape sequences. */
|
|
159
|
+
function stripAnsi(text) {
|
|
160
|
+
return stripVTControlCharacters(text);
|
|
161
|
+
}
|
|
162
|
+
/** Creates a {@link Reporter} writing to the given streams. */
|
|
163
|
+
function createReporter(stdout, stderr, colors) {
|
|
164
|
+
const line = (stream, text) => {
|
|
165
|
+
stream.write(`${text}\n`);
|
|
166
|
+
};
|
|
167
|
+
const withHints = (stream, hints) => {
|
|
168
|
+
for (const hint of hints ?? []) line(stream, ` ${colors.dim("=")} ${colors.bold("hint:")} ${hint}`);
|
|
169
|
+
};
|
|
170
|
+
return {
|
|
171
|
+
colors,
|
|
172
|
+
log: (message = "") => line(stdout, message),
|
|
173
|
+
info: (message) => line(stdout, `${colors.cyan("›")} ${message}`),
|
|
174
|
+
success: (message) => line(stdout, `${colors.green("✓")} ${message}`),
|
|
175
|
+
warn: (message, hints) => {
|
|
176
|
+
line(stderr, `${colors.yellow("!")} ${message}`);
|
|
177
|
+
withHints(stderr, hints);
|
|
178
|
+
},
|
|
179
|
+
error: (message, hints) => {
|
|
180
|
+
line(stderr, `${colors.red("✗")} ${colors.red(message)}`);
|
|
181
|
+
withHints(stderr, hints);
|
|
182
|
+
},
|
|
183
|
+
logError: (message = "") => line(stderr, message)
|
|
184
|
+
};
|
|
185
|
+
}
|
|
186
|
+
/** `850ms`, `1.24s`, `2m 05s`. */
|
|
187
|
+
function formatDuration(ms) {
|
|
188
|
+
if (ms < 1e3) return `${Math.round(ms)}ms`;
|
|
189
|
+
if (ms < 6e4) return `${(ms / 1e3).toFixed(2)}s`;
|
|
190
|
+
const minutes = Math.floor(ms / 6e4);
|
|
191
|
+
const seconds = Math.round(ms % 6e4 / 1e3);
|
|
192
|
+
return `${minutes}m ${String(seconds).padStart(2, "0")}s`;
|
|
193
|
+
}
|
|
194
|
+
/** `512 B`, `1.5 kB`, `2.31 MB` (SI units, like Vite). */
|
|
195
|
+
function formatBytes(bytes) {
|
|
196
|
+
if (bytes < 1e3) return `${bytes} B`;
|
|
197
|
+
if (bytes < 1e6) return `${(bytes / 1e3).toFixed(2)} kB`;
|
|
198
|
+
return `${(bytes / 1e6).toFixed(2)} MB`;
|
|
199
|
+
}
|
|
200
|
+
/** `1 provider`, `3 providers`. */
|
|
201
|
+
function plural(count, singular, pluralForm = `${singular}s`) {
|
|
202
|
+
return `${count} ${count === 1 ? singular : pluralForm}`;
|
|
203
|
+
}
|
|
204
|
+
// src/command.ts
|
|
205
|
+
/** Whether an option was explicitly given (`--install`, `--no-install`, `--install=false`). */
|
|
206
|
+
function hasFlag(rawArgs, name) {
|
|
207
|
+
return rawArgs.some((arg) => arg === `--${name}` || arg === `--no-${name}` || arg.startsWith(`--${name}=`) || arg.startsWith(`--no-${name}=`));
|
|
208
|
+
}
|
|
209
|
+
/** Creates a {@link Command}; `args` keep their literal types in `run`. */
|
|
210
|
+
function defineCommand(definition) {
|
|
211
|
+
return {
|
|
212
|
+
meta: definition.meta,
|
|
213
|
+
args: definition.args,
|
|
214
|
+
execute: async (argv, passthrough, cli) => {
|
|
215
|
+
const args = parseCommandArgs(definition, argv);
|
|
216
|
+
return await definition.run({
|
|
217
|
+
args,
|
|
218
|
+
cli,
|
|
219
|
+
rawArgs: argv,
|
|
220
|
+
passthrough
|
|
221
|
+
}) ?? 0;
|
|
222
|
+
}
|
|
223
|
+
};
|
|
224
|
+
}
|
|
225
|
+
/** Parses `argv` (without the command name and without `--` pass-through) for `command`. */
|
|
226
|
+
function parseCommandArgs(command, argv) {
|
|
227
|
+
checkOptions(command, argv);
|
|
228
|
+
let parsed;
|
|
229
|
+
try {
|
|
230
|
+
parsed = parseArgs([...argv], command.args);
|
|
231
|
+
} catch (error) {
|
|
232
|
+
throw new UsageError(stripAnsi(error instanceof Error ? error.message : String(error)));
|
|
233
|
+
}
|
|
234
|
+
const positionals = Object.values(command.args).filter((arg) => arg.type === "positional").length;
|
|
235
|
+
const extra = parsed._.slice(positionals);
|
|
236
|
+
if (extra.length > 0) throw new UsageError(`Unexpected argument${extra.length > 1 ? "s" : ""}: ${extra.join(" ")}`);
|
|
237
|
+
return parsed;
|
|
238
|
+
}
|
|
239
|
+
function optionTable(args) {
|
|
240
|
+
const table = /* @__PURE__ */ new Map([["help", { type: "boolean" }], ["h", { type: "boolean" }]]);
|
|
241
|
+
for (const [name, def] of Object.entries(args)) {
|
|
242
|
+
if (def.type === "positional") continue;
|
|
243
|
+
const info = { type: def.type };
|
|
244
|
+
for (const key of [
|
|
245
|
+
name,
|
|
246
|
+
kebabCase(name),
|
|
247
|
+
camelCase(name),
|
|
248
|
+
...aliasesOf(def)
|
|
249
|
+
]) table.set(key, info);
|
|
250
|
+
}
|
|
251
|
+
return table;
|
|
252
|
+
}
|
|
253
|
+
function aliasesOf(def) {
|
|
254
|
+
if (!("alias" in def) || def.alias === void 0) return [];
|
|
255
|
+
return Array.isArray(def.alias) ? [...def.alias] : [def.alias];
|
|
256
|
+
}
|
|
257
|
+
function checkOptions(command, argv) {
|
|
258
|
+
const table = optionTable(command.args);
|
|
259
|
+
const longNames = [...table.keys()].filter((name) => name.length > 1);
|
|
260
|
+
for (let index = 0; index < argv.length; index++) {
|
|
261
|
+
const token = argv[index] ?? "";
|
|
262
|
+
if (!token.startsWith("-") || token === "-") continue;
|
|
263
|
+
if (token.startsWith("--")) {
|
|
264
|
+
const [rawName = "", inlineValue] = token.slice(2).split(/=(.*)/s, 2);
|
|
265
|
+
let info = table.get(rawName);
|
|
266
|
+
if (info === void 0 && rawName.startsWith("no-")) {
|
|
267
|
+
const negated = table.get(rawName.slice(3));
|
|
268
|
+
if (negated?.type === "boolean") info = negated;
|
|
269
|
+
}
|
|
270
|
+
if (info === void 0) {
|
|
271
|
+
const candidate = suggest(rawName, longNames);
|
|
272
|
+
throw new UsageError(`Unknown option --${rawName}`, candidate === void 0 ? [] : [`did you mean --${candidate}?`]);
|
|
273
|
+
}
|
|
274
|
+
if ((info.type === "string" || info.type === "enum") && inlineValue === void 0) index++;
|
|
275
|
+
continue;
|
|
276
|
+
}
|
|
277
|
+
const letters = token.slice(1);
|
|
278
|
+
for (const [position, letter] of [...letters].entries()) {
|
|
279
|
+
const info = table.get(letter);
|
|
280
|
+
if (info === void 0) throw new UsageError(`Unknown option -${letter}`);
|
|
281
|
+
if ((info.type === "string" || info.type === "enum") && position === letters.length - 1) index++;
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
/** Formats the help of a command. */
|
|
286
|
+
function renderCommandHelp(command, colors) {
|
|
287
|
+
const { meta } = command;
|
|
288
|
+
const entries = Object.entries(command.args);
|
|
289
|
+
const positionals = entries.filter(([, def]) => def.type === "positional");
|
|
290
|
+
const options = entries.filter(([, def]) => def.type !== "positional");
|
|
291
|
+
const usage = meta.usage ?? [...positionals.map(([name, def]) => def.required === false || def.default !== void 0 ? `[${name}]` : `<${name}>`), options.length > 0 ? "[options]" : ""].filter(Boolean).join(" ");
|
|
292
|
+
const lines = [`${colors.bold(`znight ${meta.name}`)} ${colors.dim("—")} ${meta.description}`, ""];
|
|
293
|
+
lines.push(colors.bold("Usage"), ` ${colors.cyan(`znight ${meta.name}${usage === "" ? "" : ` ${usage}`}`)}`, "");
|
|
294
|
+
if (meta.aliases !== void 0 && meta.aliases.length > 0) lines.push(colors.bold("Aliases"), ` ${meta.aliases.join(", ")}`, "");
|
|
295
|
+
if (positionals.length > 0) {
|
|
296
|
+
lines.push(colors.bold("Arguments"));
|
|
297
|
+
lines.push(...table(positionals.map(([name, def]) => [name, def.description ?? ""]), colors));
|
|
298
|
+
lines.push("");
|
|
299
|
+
}
|
|
300
|
+
const optionRows = options.map(([name, def]) => [optionLabel(name, def), describeOption(def)]);
|
|
301
|
+
optionRows.push(["-h, --help", "Show this help"]);
|
|
302
|
+
lines.push(colors.bold("Options"), ...table(optionRows, colors), "");
|
|
303
|
+
if (meta.details !== void 0) lines.push(typeof meta.details === "function" ? meta.details(colors) : meta.details, "");
|
|
304
|
+
if (meta.examples !== void 0 && meta.examples.length > 0) lines.push(colors.bold("Examples"), ...meta.examples.map((example) => ` ${colors.dim("$")} ${example}`), "");
|
|
305
|
+
return lines.join("\n");
|
|
306
|
+
}
|
|
307
|
+
function optionLabel(name, def) {
|
|
308
|
+
const short = aliasesOf(def).filter((alias) => alias.length === 1).map((alias) => `-${alias}, `).join("");
|
|
309
|
+
const flag = def.type === "boolean" && def.default === true ? `--no-${name}` : `--${name}`;
|
|
310
|
+
let value = "";
|
|
311
|
+
if (def.type === "enum") value = ` <${(def.options ?? []).join("|")}>`;
|
|
312
|
+
else if (def.type === "string") value = ` <${def.valueHint ?? name}>`;
|
|
313
|
+
return `${short}${flag}${value}`;
|
|
314
|
+
}
|
|
315
|
+
function describeOption(def) {
|
|
316
|
+
const description = def.description ?? "";
|
|
317
|
+
if (def.type === "boolean") return description;
|
|
318
|
+
return def.default === void 0 || def.default === "" ? description : `${description} (default: ${def.default})`;
|
|
319
|
+
}
|
|
320
|
+
/** Two aligned columns. */
|
|
321
|
+
function table(rows, colors) {
|
|
322
|
+
const width = Math.max(0, ...rows.map(([left]) => left.length));
|
|
323
|
+
return rows.map(([left, right]) => ` ${colors.cyan(left.padEnd(width))} ${right}`.trimEnd());
|
|
324
|
+
}
|
|
325
|
+
// src/integrations/resolve.ts
|
|
326
|
+
/**
|
|
327
|
+
* Imports `specifier` as resolved from the project `root` (the versions the project installed, like
|
|
328
|
+
* `vite` and `tsc` do), or with `fallback` (resolved from the CLI) when the project does not have it.
|
|
329
|
+
*/
|
|
330
|
+
async function importFromProject(specifier, root, fallback) {
|
|
331
|
+
const resolved = resolveFromProject(specifier, root);
|
|
332
|
+
if (resolved !== void 0) try {
|
|
333
|
+
return await import(pathToFileURL(resolved).href);
|
|
334
|
+
} catch (error) {
|
|
335
|
+
if (!isModuleNotFound(error)) throw error;
|
|
336
|
+
}
|
|
337
|
+
return fallback();
|
|
338
|
+
}
|
|
339
|
+
/** Absolute path of `specifier` resolved from `root`, or `undefined`. */
|
|
340
|
+
function resolveFromProject(specifier, root) {
|
|
341
|
+
try {
|
|
342
|
+
return createRequire(join(root, "package.json")).resolve(specifier);
|
|
343
|
+
} catch {
|
|
344
|
+
return;
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
function isModuleNotFound(error) {
|
|
348
|
+
const code = error?.code;
|
|
349
|
+
return code === "ERR_MODULE_NOT_FOUND" || code === "MODULE_NOT_FOUND";
|
|
350
|
+
}
|
|
351
|
+
// src/integrations/compiler.ts
|
|
352
|
+
/**
|
|
353
|
+
* The `@znight/compiler` programmatic API (spec §6.2), loaded lazily: commands that do not compile
|
|
354
|
+
* (`new`, `g`, `start`) never load it. Commands use {@link CompilerApi} so tests can inject a fake.
|
|
355
|
+
*
|
|
356
|
+
* The CLI only relies on the documented shapes below (structural types, no type import from the
|
|
357
|
+
* compiler), so any `@znight/compiler` of the same major version works.
|
|
358
|
+
*/
|
|
359
|
+
/**
|
|
360
|
+
* Not a literal in `import()`: the CLI type-checks and bundles without the compiler (an optional
|
|
361
|
+
* peer dependency), whose version is the project's.
|
|
362
|
+
*/
|
|
363
|
+
const COMPILER_PACKAGE = "@znight/compiler";
|
|
364
|
+
/**
|
|
365
|
+
* Loads `@znight/compiler` as installed in the project (falls back to the CLI's own peer) and adapts
|
|
366
|
+
* it to {@link CompilerApi}.
|
|
367
|
+
*/
|
|
368
|
+
async function loadCompiler(root) {
|
|
369
|
+
let module;
|
|
370
|
+
try {
|
|
371
|
+
module = await importFromProject(COMPILER_PACKAGE, root, () => import(COMPILER_PACKAGE));
|
|
372
|
+
} catch (error) {
|
|
373
|
+
throw new CliError("Cannot load @znight/compiler", {
|
|
374
|
+
cause: error,
|
|
375
|
+
hints: ["install it in your project: npm install -D @znight/compiler vite"]
|
|
376
|
+
});
|
|
377
|
+
}
|
|
378
|
+
return adaptCompilerModule(module);
|
|
379
|
+
}
|
|
380
|
+
function readText(file) {
|
|
381
|
+
try {
|
|
382
|
+
return readFileSync(file, "utf8");
|
|
383
|
+
} catch {
|
|
384
|
+
return;
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
/**
|
|
388
|
+
* Adapts a loaded compiler module: `generate()` / `generate({ check })` / `createCompiler()` when
|
|
389
|
+
* available, otherwise the same behaviour built on `compile()`.
|
|
390
|
+
*/
|
|
391
|
+
function adaptCompilerModule(module) {
|
|
392
|
+
const { compile, generate, createCompiler, loadZnightConfig } = module;
|
|
393
|
+
if (typeof compile !== "function" && typeof generate !== "function") throw new CliError("This version of @znight/compiler exposes neither compile() nor generate()", { hints: ["upgrade @znight/compiler to the version of @znight/cli"] });
|
|
394
|
+
const format = module.formatDiagnostics ?? fallbackFormatDiagnostics;
|
|
395
|
+
/** Pure compile: `compile()`, else `generate({ check: true })` (never writes). */
|
|
396
|
+
const compileOnly = (options) => typeof compile === "function" ? compile(options) : generate({
|
|
397
|
+
...options,
|
|
398
|
+
check: true
|
|
399
|
+
});
|
|
400
|
+
/** Write-if-changed on top of a compile result, when the compiler did not write itself. */
|
|
401
|
+
const written = (result, before) => ({
|
|
402
|
+
...result,
|
|
403
|
+
written: Array.isArray(result.written) ? result.written.length > 0 : before !== readText(result.outFile)
|
|
404
|
+
});
|
|
405
|
+
const generateOnce = async (options) => {
|
|
406
|
+
if (typeof generate === "function") {
|
|
407
|
+
const before = readText(resolveOutFile(options));
|
|
408
|
+
return written(await generate(options), before);
|
|
409
|
+
}
|
|
410
|
+
const result = await compileOnly(options);
|
|
411
|
+
const changed = !hasErrors(result.diagnostics) && writeIfChanged(result.outFile, result.diskCode ?? result.code);
|
|
412
|
+
return {
|
|
413
|
+
...result,
|
|
414
|
+
written: changed
|
|
415
|
+
};
|
|
416
|
+
};
|
|
417
|
+
return {
|
|
418
|
+
generate: generateOnce,
|
|
419
|
+
check: async (options) => {
|
|
420
|
+
const result = typeof generate === "function" ? await generate({
|
|
421
|
+
...options,
|
|
422
|
+
check: true
|
|
423
|
+
}) : await compileOnly(options);
|
|
424
|
+
const staleFiles = Array.isArray(result.staleFiles) ? result.staleFiles : staleOutputs(result);
|
|
425
|
+
const stale = typeof result.stale === "boolean" ? result.stale : staleFiles.length > 0;
|
|
426
|
+
return {
|
|
427
|
+
...result,
|
|
428
|
+
stale,
|
|
429
|
+
staleFiles: stale ? staleFiles : []
|
|
430
|
+
};
|
|
431
|
+
},
|
|
432
|
+
createIncremental: (options) => {
|
|
433
|
+
const compiler = typeof createCompiler === "function" ? createCompiler(options) : void 0;
|
|
434
|
+
const incremental = compiler?.generate;
|
|
435
|
+
const outFile = resolveOutFile(options);
|
|
436
|
+
const isOutFile = (file) => typeof compiler?.isOutFile === "function" ? compiler.isOutFile(file) : resolve(file) === outFile;
|
|
437
|
+
const { includeRoots, isRelevantChange } = module;
|
|
438
|
+
return {
|
|
439
|
+
generate: async (changes) => {
|
|
440
|
+
if (compiler === void 0 || typeof incremental !== "function") return generateOnce(options);
|
|
441
|
+
const before = readText(outFile);
|
|
442
|
+
return written(await incremental.call(compiler, changes), before);
|
|
443
|
+
},
|
|
444
|
+
isOutFile,
|
|
445
|
+
includeRoots: () => compiler !== void 0 && typeof includeRoots === "function" ? includeRoots(compiler) : [resolve(options.root ?? ".")],
|
|
446
|
+
isRelevantChange: (kind, file, watched, roots) => compiler !== void 0 && typeof isRelevantChange === "function" ? isRelevantChange(compiler, kind, file, watched, roots) : !isOutFile(file)
|
|
447
|
+
};
|
|
448
|
+
},
|
|
449
|
+
formatDiagnostics: (diagnostics, options) => format(diagnostics, options),
|
|
450
|
+
...typeof loadZnightConfig === "function" ? { loadConfig: (root) => loadZnightConfig(root) } : {}
|
|
451
|
+
};
|
|
452
|
+
}
|
|
453
|
+
/**
|
|
454
|
+
* The generated files of a compile result that differ from the disk: the module, and
|
|
455
|
+
* `znight.providers.json` in library mode. None with errors (nothing would be written).
|
|
456
|
+
*/
|
|
457
|
+
function staleOutputs(result) {
|
|
458
|
+
if (hasErrors(result.diagnostics)) return [];
|
|
459
|
+
const outputs = [{
|
|
460
|
+
file: result.outFile,
|
|
461
|
+
content: result.diskCode ?? result.code
|
|
462
|
+
}];
|
|
463
|
+
const { file, content } = result.library ?? {};
|
|
464
|
+
if (typeof file === "string" && typeof content === "string") outputs.push({
|
|
465
|
+
file,
|
|
466
|
+
content
|
|
467
|
+
});
|
|
468
|
+
return outputs.filter((output) => readText(output.file) !== output.content).map((output) => output.file);
|
|
469
|
+
}
|
|
470
|
+
/** Absolute path of the generated module for `options`. */
|
|
471
|
+
function resolveOutFile(options) {
|
|
472
|
+
return resolve(options.root ?? ".", options.outFile ?? "src/.znight/container.generated.ts");
|
|
473
|
+
}
|
|
474
|
+
/** Whether a list of diagnostics contains an error. */
|
|
475
|
+
function hasErrors(diagnostics) {
|
|
476
|
+
return diagnostics.some((diagnostic) => diagnostic.severity === "error");
|
|
477
|
+
}
|
|
478
|
+
/** Writes `content` to `file` unless it already has that exact content. Returns whether it wrote. */
|
|
479
|
+
function writeIfChanged(file, content) {
|
|
480
|
+
if (existsSync(file) && readFileSync(file, "utf8") === content) return false;
|
|
481
|
+
mkdirSync(dirname(file), { recursive: true });
|
|
482
|
+
writeFileSync(file, content);
|
|
483
|
+
return true;
|
|
484
|
+
}
|
|
485
|
+
/**
|
|
486
|
+
* Plain formatter used when the installed compiler has no `formatDiagnostics()`:
|
|
487
|
+
*
|
|
488
|
+
* ```
|
|
489
|
+
* ✗ ZNIGHT001: Missing provider for 'EmailService' required by NotificationService
|
|
490
|
+
* --> src/notification/notification.service.ts:12:5
|
|
491
|
+
* = hint: Add @Injectable() to EmailService
|
|
492
|
+
* ```
|
|
493
|
+
*/
|
|
494
|
+
function fallbackFormatDiagnostics(diagnostics, options = {}) {
|
|
495
|
+
const colors = createColors(options.color === true);
|
|
496
|
+
const blocks = [];
|
|
497
|
+
for (const diagnostic of diagnostics) {
|
|
498
|
+
const head = diagnostic.severity === "error" ? colors.red(`✗ ${diagnostic.code}: ${diagnostic.message}`) : colors.yellow(`! ${diagnostic.code}: ${diagnostic.message}`);
|
|
499
|
+
const lines = [colors.bold(head)];
|
|
500
|
+
if (diagnostic.location !== void 0) lines.push(` ${colors.cyan("-->")} ${formatLocation(diagnostic.location, options.cwd)}`);
|
|
501
|
+
for (const hint of diagnostic.hints ?? []) lines.push(` ${colors.dim("=")} ${colors.bold("hint:")} ${hint}`);
|
|
502
|
+
for (const related of diagnostic.related ?? []) lines.push(` ${colors.dim("=")} ${colors.bold("note:")} ${related.message} (${formatLocation(related.location, options.cwd)})`);
|
|
503
|
+
blocks.push(lines.join("\n"));
|
|
504
|
+
}
|
|
505
|
+
return blocks.join("\n\n");
|
|
506
|
+
}
|
|
507
|
+
function formatLocation(location, cwd) {
|
|
508
|
+
return `${cwd === void 0 ? location.file : relative(cwd, location.file).replaceAll("\\", "/")}:${location.line}:${location.column}`;
|
|
509
|
+
}
|
|
510
|
+
// src/integrations/openapi.ts
|
|
511
|
+
/** Not literals in `import()`: the CLI builds without `@znight/openapi` (an optional peer). */
|
|
512
|
+
const EMIT_MODULE = "@znight/openapi/emit";
|
|
513
|
+
const VITE_MODULE = "@znight/openapi/vite";
|
|
514
|
+
const MISSING_HINTS = ["install it in your project: npm install @znight/openapi"];
|
|
515
|
+
/** Loads `@znight/openapi/emit` as installed in the project. */
|
|
516
|
+
async function loadOpenApiEmit(root) {
|
|
517
|
+
let module;
|
|
518
|
+
try {
|
|
519
|
+
module = await importFromProject(EMIT_MODULE, root, () => import(EMIT_MODULE));
|
|
520
|
+
} catch (error) {
|
|
521
|
+
throw new CliError("Cannot load @znight/openapi", {
|
|
522
|
+
cause: error,
|
|
523
|
+
hints: MISSING_HINTS
|
|
524
|
+
});
|
|
525
|
+
}
|
|
526
|
+
if (typeof module.emitOpenApi !== "function") throw new CliError("This version of @znight/openapi has no emitOpenApi()", { hints: ["upgrade @znight/openapi to the version of @znight/cli"] });
|
|
527
|
+
return module;
|
|
528
|
+
}
|
|
529
|
+
/** Loads `@znight/openapi/vite` as installed in the project. */
|
|
530
|
+
async function loadOpenApiVite(root) {
|
|
531
|
+
let module;
|
|
532
|
+
try {
|
|
533
|
+
module = await importFromProject(VITE_MODULE, root, () => import(VITE_MODULE));
|
|
534
|
+
} catch (error) {
|
|
535
|
+
throw new CliError("Cannot load @znight/openapi", {
|
|
536
|
+
cause: error,
|
|
537
|
+
hints: MISSING_HINTS
|
|
538
|
+
});
|
|
539
|
+
}
|
|
540
|
+
if (typeof module.openapi !== "function") throw new CliError("This version of @znight/openapi has no openapi() Vite plugin", { hints: ["upgrade @znight/openapi to the version of @znight/cli"] });
|
|
541
|
+
return module;
|
|
542
|
+
}
|
|
543
|
+
// src/integrations/process.ts
|
|
544
|
+
/**
|
|
545
|
+
* Child processes: the application (`znight dev` / `znight start`) and tools (`git`, package
|
|
546
|
+
* managers). Behind {@link ProcessService} so that tests can use fake processes.
|
|
547
|
+
*/
|
|
548
|
+
const detachedChildren = /* @__PURE__ */ new Set();
|
|
549
|
+
let exitHookInstalled = false;
|
|
550
|
+
function killDetachedChildren() {
|
|
551
|
+
for (const child of detachedChildren) killTree(child, "SIGKILL", true);
|
|
552
|
+
}
|
|
553
|
+
function killTree(child, signal, detached) {
|
|
554
|
+
if (child.exitCode !== null || child.signalCode !== null) return false;
|
|
555
|
+
if (detached && child.pid !== void 0 && process.platform !== "win32") try {
|
|
556
|
+
process.kill(-child.pid, signal);
|
|
557
|
+
return true;
|
|
558
|
+
} catch {}
|
|
559
|
+
return child.kill(signal);
|
|
560
|
+
}
|
|
561
|
+
function cleanEnv(env) {
|
|
562
|
+
const result = {};
|
|
563
|
+
for (const [key, value] of Object.entries(env)) if (value !== void 0) result[key] = value;
|
|
564
|
+
return result;
|
|
565
|
+
}
|
|
566
|
+
/** Starts a process with `node:child_process`. */
|
|
567
|
+
function spawnProcess(command, args, options) {
|
|
568
|
+
const detached = options.detached === true && process.platform !== "win32";
|
|
569
|
+
const child = spawn(command, [...args], {
|
|
570
|
+
cwd: options.cwd,
|
|
571
|
+
env: cleanEnv(options.env),
|
|
572
|
+
stdio: options.stdio ?? "inherit",
|
|
573
|
+
detached,
|
|
574
|
+
windowsHide: true
|
|
575
|
+
});
|
|
576
|
+
if (detached) {
|
|
577
|
+
detachedChildren.add(child);
|
|
578
|
+
if (!exitHookInstalled) {
|
|
579
|
+
exitHookInstalled = true;
|
|
580
|
+
process.on("exit", killDetachedChildren);
|
|
581
|
+
}
|
|
582
|
+
}
|
|
583
|
+
const exited = new Promise((resolve) => {
|
|
584
|
+
child.once("error", (error) => {
|
|
585
|
+
detachedChildren.delete(child);
|
|
586
|
+
resolve({
|
|
587
|
+
code: null,
|
|
588
|
+
signal: null,
|
|
589
|
+
error
|
|
590
|
+
});
|
|
591
|
+
});
|
|
592
|
+
child.once("exit", (code, signal) => {
|
|
593
|
+
detachedChildren.delete(child);
|
|
594
|
+
resolve({
|
|
595
|
+
code,
|
|
596
|
+
signal
|
|
597
|
+
});
|
|
598
|
+
});
|
|
599
|
+
});
|
|
600
|
+
return {
|
|
601
|
+
pid: child.pid,
|
|
602
|
+
kill: (signal) => killTree(child, signal, detached),
|
|
603
|
+
exited
|
|
604
|
+
};
|
|
605
|
+
}
|
|
606
|
+
/** Runs a command to completion with `node:child_process`, capturing stdout and stderr. */
|
|
607
|
+
function runProcess(command, args, options) {
|
|
608
|
+
return new Promise((resolve) => {
|
|
609
|
+
const child = spawn(command, [...args], {
|
|
610
|
+
cwd: options.cwd,
|
|
611
|
+
env: cleanEnv(options.env),
|
|
612
|
+
stdio: [
|
|
613
|
+
"ignore",
|
|
614
|
+
"pipe",
|
|
615
|
+
"pipe"
|
|
616
|
+
],
|
|
617
|
+
windowsHide: true,
|
|
618
|
+
shell: process.platform === "win32"
|
|
619
|
+
});
|
|
620
|
+
let stdout = "";
|
|
621
|
+
let stderr = "";
|
|
622
|
+
child.stdout?.setEncoding("utf8").on("data", (chunk) => {
|
|
623
|
+
stdout += chunk;
|
|
624
|
+
});
|
|
625
|
+
child.stderr?.setEncoding("utf8").on("data", (chunk) => {
|
|
626
|
+
stderr += chunk;
|
|
627
|
+
});
|
|
628
|
+
child.once("error", (error) => resolve({
|
|
629
|
+
code: null,
|
|
630
|
+
signal: null,
|
|
631
|
+
error,
|
|
632
|
+
stdout,
|
|
633
|
+
stderr
|
|
634
|
+
}));
|
|
635
|
+
child.once("close", (code, signal) => resolve({
|
|
636
|
+
code,
|
|
637
|
+
signal,
|
|
638
|
+
stdout,
|
|
639
|
+
stderr
|
|
640
|
+
}));
|
|
641
|
+
});
|
|
642
|
+
}
|
|
643
|
+
/** The default {@link ProcessService}. */
|
|
644
|
+
function createProcessService() {
|
|
645
|
+
const execve = process.execve;
|
|
646
|
+
return {
|
|
647
|
+
spawn: spawnProcess,
|
|
648
|
+
run: runProcess,
|
|
649
|
+
execve: typeof execve === "function" && process.platform !== "win32" ? (file, args, env, cwd) => {
|
|
650
|
+
process.chdir(cwd);
|
|
651
|
+
execve.call(process, file, [...args], { ...env });
|
|
652
|
+
} : void 0
|
|
653
|
+
};
|
|
654
|
+
}
|
|
655
|
+
/** Exit code of the CLI mirroring a child's exit status (`128 + n` for signals, like shells). */
|
|
656
|
+
function exitCodeOf(status) {
|
|
657
|
+
if (status.code !== null) return status.code;
|
|
658
|
+
if (status.signal !== null) return 128 + (constants$1.signals[status.signal] ?? 1);
|
|
659
|
+
return 1;
|
|
660
|
+
}
|
|
661
|
+
// src/integrations/prompts.ts
|
|
662
|
+
/**
|
|
663
|
+
* Interactive prompts of `znight new`, backed by `@clack/prompts` (loaded lazily). A cancelled
|
|
664
|
+
* prompt (Ctrl+C, Escape) throws a {@link CancelledError}.
|
|
665
|
+
*/
|
|
666
|
+
/** Loads `@clack/prompts` and adapts it to {@link Prompts}. */
|
|
667
|
+
async function loadPrompts() {
|
|
668
|
+
const clack = await import("@clack/prompts");
|
|
669
|
+
const unwrap = (value) => {
|
|
670
|
+
if (clack.isCancel(value) || typeof value === "symbol") throw new CancelledError("Operation cancelled");
|
|
671
|
+
return value;
|
|
672
|
+
};
|
|
673
|
+
const toOptions = (choices) => choices.map((choice) => ({
|
|
674
|
+
value: choice.value,
|
|
675
|
+
label: choice.label,
|
|
676
|
+
...choice.hint === void 0 ? {} : { hint: choice.hint }
|
|
677
|
+
}));
|
|
678
|
+
return {
|
|
679
|
+
intro: (title) => clack.intro(title),
|
|
680
|
+
outro: (message) => clack.outro(message),
|
|
681
|
+
text: async (options) => unwrap(await clack.text({
|
|
682
|
+
message: options.message,
|
|
683
|
+
...options.placeholder === void 0 ? {} : { placeholder: options.placeholder },
|
|
684
|
+
...options.initialValue === void 0 ? {} : { initialValue: options.initialValue },
|
|
685
|
+
...options.validate === void 0 ? {} : { validate: (value) => options.validate?.(value ?? "") }
|
|
686
|
+
})),
|
|
687
|
+
select: async (options) => {
|
|
688
|
+
const value = await clack.select({
|
|
689
|
+
message: options.message,
|
|
690
|
+
options: toOptions(options.choices),
|
|
691
|
+
...options.initialValue === void 0 ? {} : { initialValue: options.initialValue }
|
|
692
|
+
});
|
|
693
|
+
return unwrap(value);
|
|
694
|
+
},
|
|
695
|
+
multiselect: async (options) => {
|
|
696
|
+
const value = await clack.multiselect({
|
|
697
|
+
message: options.message,
|
|
698
|
+
required: false,
|
|
699
|
+
options: toOptions(options.choices),
|
|
700
|
+
...options.initialValues === void 0 ? {} : { initialValues: [...options.initialValues] }
|
|
701
|
+
});
|
|
702
|
+
return unwrap(value);
|
|
703
|
+
},
|
|
704
|
+
confirm: async (options) => unwrap(await clack.confirm({
|
|
705
|
+
message: options.message,
|
|
706
|
+
...options.initialValue === void 0 ? {} : { initialValue: options.initialValue }
|
|
707
|
+
})),
|
|
708
|
+
spinner: () => {
|
|
709
|
+
const spinner = clack.spinner();
|
|
710
|
+
return {
|
|
711
|
+
start: (message) => spinner.start(message),
|
|
712
|
+
stop: (message, failed = false) => failed ? spinner.error(message) : spinner.stop(message)
|
|
713
|
+
};
|
|
714
|
+
},
|
|
715
|
+
note: (message, title) => clack.note(message, title)
|
|
716
|
+
};
|
|
717
|
+
}
|
|
718
|
+
// src/integrations/vite.ts
|
|
719
|
+
/** Loads `vite` as installed in the project. */
|
|
720
|
+
async function loadVite(root) {
|
|
721
|
+
try {
|
|
722
|
+
return await importFromProject("vite", root, () => import("vite"));
|
|
723
|
+
} catch (error) {
|
|
724
|
+
throw new CliError("Cannot load vite", {
|
|
725
|
+
cause: error,
|
|
726
|
+
hints: ["install it in your project: npm install -D vite @znight/compiler"]
|
|
727
|
+
});
|
|
728
|
+
}
|
|
729
|
+
}
|
|
730
|
+
/** Creates an {@link OutputTracker}; relative output paths are resolved against the Vite root. */
|
|
731
|
+
function createOutputTracker(root) {
|
|
732
|
+
let entry;
|
|
733
|
+
let started;
|
|
734
|
+
let api;
|
|
735
|
+
let compile;
|
|
736
|
+
let viteRoot = root;
|
|
737
|
+
return {
|
|
738
|
+
plugin: {
|
|
739
|
+
name: "znight:cli-output",
|
|
740
|
+
configResolved(config) {
|
|
741
|
+
api = findZnightApi(config.plugins);
|
|
742
|
+
if (typeof config.root === "string") viteRoot = config.root;
|
|
743
|
+
},
|
|
744
|
+
buildStart() {
|
|
745
|
+
started = performance.now();
|
|
746
|
+
compile = void 0;
|
|
747
|
+
},
|
|
748
|
+
writeBundle(outputOptions, bundle) {
|
|
749
|
+
compile = compileOutcome(api?.result);
|
|
750
|
+
const chunks = Object.values(bundle).filter((output) => output.type === "chunk" && output.isEntry);
|
|
751
|
+
const chunk = chunks.find((candidate) => candidate.name === "main") ?? chunks[0];
|
|
752
|
+
if (chunk === void 0) return;
|
|
753
|
+
entry = outputOptions.file === void 0 ? resolve(viteRoot, outputOptions.dir ?? "dist", chunk.fileName) : resolve(viteRoot, outputOptions.file);
|
|
754
|
+
}
|
|
755
|
+
},
|
|
756
|
+
entry: () => entry,
|
|
757
|
+
startedAt: () => started,
|
|
758
|
+
compile: () => compile
|
|
759
|
+
};
|
|
760
|
+
}
|
|
761
|
+
/** The `plugin.api` of the `znight()` plugin among the resolved plugins. */
|
|
762
|
+
function findZnightApi(plugins) {
|
|
763
|
+
for (const plugin of plugins) {
|
|
764
|
+
if (typeof plugin !== "object" || plugin === null || plugin.name !== "znight") continue;
|
|
765
|
+
const api = plugin.api;
|
|
766
|
+
if (typeof api === "object" && api !== null && "result" in api) return api;
|
|
767
|
+
}
|
|
768
|
+
}
|
|
769
|
+
function compileOutcome(result) {
|
|
770
|
+
if (typeof result !== "object" || result === null) return void 0;
|
|
771
|
+
const { ok, diagnostics } = result;
|
|
772
|
+
if (typeof ok !== "boolean") return void 0;
|
|
773
|
+
return {
|
|
774
|
+
ok,
|
|
775
|
+
diagnostics: Array.isArray(diagnostics) ? diagnostics : []
|
|
776
|
+
};
|
|
777
|
+
}
|
|
778
|
+
/** Name of {@link projectRootPlugin}. */
|
|
779
|
+
const ROOT_PLUGIN = "znight:cli-root";
|
|
780
|
+
/**
|
|
781
|
+
* Resolves the Vite root like the Vite CLI run in the project directory: the `root` of the config
|
|
782
|
+
* file against `projectRoot`, or `projectRoot` without one. An inline `root` would override the
|
|
783
|
+
* config file's, so the CLI sets it from the first `config` hook instead.
|
|
784
|
+
*/
|
|
785
|
+
function projectRootPlugin(projectRoot) {
|
|
786
|
+
return {
|
|
787
|
+
name: ROOT_PLUGIN,
|
|
788
|
+
config: {
|
|
789
|
+
order: "pre",
|
|
790
|
+
handler: (config) => ({ root: resolve(projectRoot, config.root ?? ".") })
|
|
791
|
+
}
|
|
792
|
+
};
|
|
793
|
+
}
|
|
794
|
+
function inlineConfig(options, watch) {
|
|
795
|
+
return {
|
|
796
|
+
configFile: findViteConfig(options.root, options.configFile) ?? false,
|
|
797
|
+
mode: options.mode,
|
|
798
|
+
logLevel: options.logLevel,
|
|
799
|
+
...options.configLoader === void 0 ? {} : { configLoader: options.configLoader },
|
|
800
|
+
clearScreen: false,
|
|
801
|
+
plugins: [projectRootPlugin(options.root), ...options.plugins ?? []],
|
|
802
|
+
build: watch ? { watch: {} } : {}
|
|
803
|
+
};
|
|
804
|
+
}
|
|
805
|
+
/** Runs `vite build` once. Throws the build error. */
|
|
806
|
+
async function runBuild(vite, options) {
|
|
807
|
+
const result = await vite.build(inlineConfig(options, false));
|
|
808
|
+
if (isWatcher(result)) await result.close();
|
|
809
|
+
}
|
|
810
|
+
/** Starts `vite build --watch`. */
|
|
811
|
+
async function watchBuild(vite, options) {
|
|
812
|
+
const result = await vite.build(inlineConfig(options, true));
|
|
813
|
+
if (!isWatcher(result)) throw new CliError("vite build did not start in watch mode", { hints: ["check that your Vite config does not override \"build.watch\""] });
|
|
814
|
+
return result;
|
|
815
|
+
}
|
|
816
|
+
function isWatcher(value) {
|
|
817
|
+
return typeof value === "object" && value !== null && typeof value.on === "function" && typeof value.close === "function";
|
|
818
|
+
}
|
|
819
|
+
/** Vite's `DEFAULT_CONFIG_FILES`, in its order. */
|
|
820
|
+
const CONFIG_FILES = [
|
|
821
|
+
"vite.config.js",
|
|
822
|
+
"vite.config.mjs",
|
|
823
|
+
"vite.config.ts",
|
|
824
|
+
"vite.config.cjs",
|
|
825
|
+
"vite.config.mts",
|
|
826
|
+
"vite.config.cts"
|
|
827
|
+
];
|
|
828
|
+
/** The Vite config file of the project (the one Vite would load from `root`), if any. */
|
|
829
|
+
function findViteConfig(root, configFile) {
|
|
830
|
+
if (configFile !== void 0) return resolve(root, configFile);
|
|
831
|
+
return CONFIG_FILES.map((file) => join(root, file)).find((file) => existsSync(file));
|
|
832
|
+
}
|
|
833
|
+
/**
|
|
834
|
+
* Resolves the project's Vite config as `znight build` does and reads the options the `znight()`
|
|
835
|
+
* plugin compiles with, so that `znight generate` compiles exactly like the Vite build. Returns
|
|
836
|
+
* `undefined` without a config file. `process.env.NODE_ENV`, which Vite sets, is left untouched.
|
|
837
|
+
*/
|
|
838
|
+
async function readZnightPluginOptions(vite, root, configFile, configLoader) {
|
|
839
|
+
const file = findViteConfig(root, configFile);
|
|
840
|
+
const resolveConfig = vite.resolveConfig;
|
|
841
|
+
if (file === void 0 || resolveConfig === void 0) return void 0;
|
|
842
|
+
const inline = {
|
|
843
|
+
configFile: file,
|
|
844
|
+
mode: "production",
|
|
845
|
+
logLevel: "silent",
|
|
846
|
+
...configLoader === void 0 ? {} : { configLoader },
|
|
847
|
+
plugins: [projectRootPlugin(root)]
|
|
848
|
+
};
|
|
849
|
+
const config = await preservingNodeEnv(() => resolveConfig.call(vite, inline, "build", "production", "production"));
|
|
850
|
+
const loaded = config.configFile ?? file;
|
|
851
|
+
for (const plugin of config.plugins) {
|
|
852
|
+
if (typeof plugin !== "object" || plugin === null) continue;
|
|
853
|
+
const name = plugin.name;
|
|
854
|
+
if (typeof name !== "string" || !isZnightPluginName(name)) continue;
|
|
855
|
+
return {
|
|
856
|
+
configFile: loaded,
|
|
857
|
+
found: true,
|
|
858
|
+
options: compileOptionsOf(plugin, config)
|
|
859
|
+
};
|
|
860
|
+
}
|
|
861
|
+
return {
|
|
862
|
+
configFile: loaded,
|
|
863
|
+
found: false
|
|
864
|
+
};
|
|
865
|
+
}
|
|
866
|
+
/** `znight` and its sub-plugins (`znight:*`), not the CLI's own plugins. */
|
|
867
|
+
function isZnightPluginName(name) {
|
|
868
|
+
return name === "znight" || name.startsWith("znight:") && !name.startsWith("znight:cli-");
|
|
869
|
+
}
|
|
870
|
+
async function preservingNodeEnv(run) {
|
|
871
|
+
const nodeEnv = process.env["NODE_ENV"];
|
|
872
|
+
try {
|
|
873
|
+
return await run();
|
|
874
|
+
} finally {
|
|
875
|
+
if (nodeEnv === void 0) delete process.env["NODE_ENV"];
|
|
876
|
+
else process.env["NODE_ENV"] = nodeEnv;
|
|
877
|
+
}
|
|
878
|
+
}
|
|
879
|
+
const isObject = (value) => typeof value === "object" && value !== null;
|
|
880
|
+
/** {@link ZnightPluginLookup.options}: the compiler's resolved options when exposed, else derived. */
|
|
881
|
+
function compileOptionsOf(plugin, config) {
|
|
882
|
+
const api = plugin.api;
|
|
883
|
+
let options = {};
|
|
884
|
+
let resolved;
|
|
885
|
+
if (isObject(api)) {
|
|
886
|
+
const value = typeof api["getOptions"] === "function" ? api["getOptions"].call(api) : api["options"];
|
|
887
|
+
if (isObject(value)) options = value;
|
|
888
|
+
const compiler = api["compiler"];
|
|
889
|
+
if (isObject(compiler) && isObject(compiler["options"])) resolved = compiler["options"];
|
|
890
|
+
}
|
|
891
|
+
const root = typeof resolved?.["root"] === "string" ? resolved["root"] : resolve(config.root, options.root ?? ".");
|
|
892
|
+
const aliases = Array.isArray(resolved?.["aliases"]) ? resolved["aliases"] : stringAliases(config.resolve?.alias);
|
|
893
|
+
const conditions = resolved?.["runtimeConditions"];
|
|
894
|
+
return {
|
|
895
|
+
...options,
|
|
896
|
+
root,
|
|
897
|
+
...aliases.length > 0 ? { aliases } : {},
|
|
898
|
+
...Array.isArray(conditions) ? { runtimeConditions: conditions } : {}
|
|
899
|
+
};
|
|
900
|
+
}
|
|
901
|
+
/** The string entries of Vite's (resolved) `resolve.alias`: the compiler ignores RegExp aliases. */
|
|
902
|
+
function stringAliases(alias) {
|
|
903
|
+
if (!Array.isArray(alias)) return [];
|
|
904
|
+
return alias.filter((entry) => isObject(entry) && typeof entry["find"] === "string" && typeof entry["replacement"] === "string");
|
|
905
|
+
}
|
|
906
|
+
/** Message of a Vite/Rolldown build error, with its location and code frame when available. */
|
|
907
|
+
function formatBuildError(error) {
|
|
908
|
+
if (!(error instanceof Error)) return String(error);
|
|
909
|
+
const details = error;
|
|
910
|
+
const lines = [details.plugin === void 0 ? error.message : `[${details.plugin}] ${error.message}`];
|
|
911
|
+
const file = details.loc?.file ?? details.id;
|
|
912
|
+
if (file !== void 0 && !error.message.includes(file)) lines.push(details.loc === void 0 ? ` --> ${file}` : ` --> ${file}:${details.loc.line}:${details.loc.column}`);
|
|
913
|
+
if (details.frame !== void 0 && details.frame !== "" && !error.message.includes(details.frame)) lines.push(details.frame);
|
|
914
|
+
return lines.join("\n");
|
|
915
|
+
}
|
|
916
|
+
// src/integrations/watch.ts
|
|
917
|
+
const IGNORED_SEGMENTS = /* @__PURE__ */ new Set([
|
|
918
|
+
"node_modules",
|
|
919
|
+
"dist",
|
|
920
|
+
"coverage"
|
|
921
|
+
]);
|
|
922
|
+
/** Native separators to forward slashes (the compiler's path style, also on Windows). */
|
|
923
|
+
function toPosixPath(path) {
|
|
924
|
+
return path.includes("\\") ? path.replaceAll("\\", "/") : path;
|
|
925
|
+
}
|
|
926
|
+
/**
|
|
927
|
+
* The absolute, POSIX-style real path of `path` (symbolic links resolved and the on-disk casing:
|
|
928
|
+
* `/var` → `/private/var` on macOS), also when its last segments do not exist (anymore): the real
|
|
929
|
+
* path of the deepest existing parent, then the rest. Makes watcher paths comparable with the
|
|
930
|
+
* compiler's.
|
|
931
|
+
*/
|
|
932
|
+
function realPath(path) {
|
|
933
|
+
const absolute = resolve(path);
|
|
934
|
+
try {
|
|
935
|
+
return toPosixPath(realpathSync.native(absolute));
|
|
936
|
+
} catch {
|
|
937
|
+
const parent = resolve(absolute, "..");
|
|
938
|
+
if (parent === absolute) return toPosixPath(absolute);
|
|
939
|
+
return `${realPath(parent).replace(/\/$/, "")}/${posix.basename(toPosixPath(absolute))}`;
|
|
940
|
+
}
|
|
941
|
+
}
|
|
942
|
+
/**
|
|
943
|
+
* Whether a path relative to a watched directory is never relevant: inside `node_modules`, `dist`,
|
|
944
|
+
* `coverage` or a dot directory (`.git`, `.znight`...).
|
|
945
|
+
*/
|
|
946
|
+
function isIgnoredPath(relativePath) {
|
|
947
|
+
return relativePath.split(/[\\/]/).some((segment) => IGNORED_SEGMENTS.has(segment) || segment.startsWith("."));
|
|
948
|
+
}
|
|
949
|
+
/** `true` when `file` is `directory` or inside it (absolute POSIX paths). */
|
|
950
|
+
function isWithin(directory, file) {
|
|
951
|
+
return file === directory || file.startsWith(directory.endsWith("/") ? directory : `${directory}/`);
|
|
952
|
+
}
|
|
953
|
+
/** Drops the directories nested in another one; sorted, de-duplicated. */
|
|
954
|
+
function outermost(directories) {
|
|
955
|
+
const sorted = [...new Set(directories)].sort();
|
|
956
|
+
return sorted.filter((directory) => !sorted.some((other) => other !== directory && isWithin(other, directory)));
|
|
957
|
+
}
|
|
958
|
+
/**
|
|
959
|
+
* Directories to watch: the include `roots` (`includeRoots()` of the compiler) recursively; the
|
|
960
|
+
* project `root` and the directories of `files` (the `watchFiles` of the last compile) that are
|
|
961
|
+
* outside them, non-recursively.
|
|
962
|
+
*/
|
|
963
|
+
function watchTargets(root, roots, files = []) {
|
|
964
|
+
const recursive = outermost(roots.map(toPosixPath));
|
|
965
|
+
const covered = (directory) => recursive.some((base) => isWithin(base, directory));
|
|
966
|
+
const flat = /* @__PURE__ */ new Set();
|
|
967
|
+
const directories = [toPosixPath(resolve(root))];
|
|
968
|
+
for (const file of files) directories.push(posix.dirname(toPosixPath(resolve(file))));
|
|
969
|
+
for (const directory of directories) if (!covered(directory)) flat.add(directory);
|
|
970
|
+
return [...recursive.map((path) => ({
|
|
971
|
+
path,
|
|
972
|
+
recursive: true
|
|
973
|
+
})), ...[...flat].sort().map((path) => ({
|
|
974
|
+
path,
|
|
975
|
+
recursive: false
|
|
976
|
+
}))];
|
|
977
|
+
}
|
|
978
|
+
function kindOf(path) {
|
|
979
|
+
try {
|
|
980
|
+
return statSync(path).isDirectory() ? "directory" : "file";
|
|
981
|
+
} catch {
|
|
982
|
+
return "missing";
|
|
983
|
+
}
|
|
984
|
+
}
|
|
985
|
+
/** The known files inside `directory`. */
|
|
986
|
+
function knownWithin(known, directory) {
|
|
987
|
+
const prefix = directory.endsWith("/") ? directory : `${directory}/`;
|
|
988
|
+
return [...known].filter((file) => file.startsWith(prefix));
|
|
989
|
+
}
|
|
990
|
+
/**
|
|
991
|
+
* The kind of an `fs.watch` event on `file` (absolute, real, POSIX-style), which `fs.watch` does not
|
|
992
|
+
* tell, from the disk and the files of the last compile (`known`): `removed` when it is gone,
|
|
993
|
+
* `changed` for a known file, `added` otherwise (a new file, a directory created or moved there).
|
|
994
|
+
*/
|
|
995
|
+
function changeKind(file, known) {
|
|
996
|
+
switch (kindOf(file)) {
|
|
997
|
+
case "missing": return "removed";
|
|
998
|
+
case "directory": return "added";
|
|
999
|
+
default: return known.has(file) ? "changed" : "added";
|
|
1000
|
+
}
|
|
1001
|
+
}
|
|
1002
|
+
/**
|
|
1003
|
+
* Classifies relevant paths (`isRelevantChange()` of the compiler) for the incremental compiler,
|
|
1004
|
+
* given the files of the last compile: a deleted path is removed with the known files it contained
|
|
1005
|
+
* (a directory moved away), an existing directory is added (the compiler re-runs the scan) and the
|
|
1006
|
+
* known files it contains are re-read (or removed when gone), an existing file is changed when
|
|
1007
|
+
* known, added otherwise.
|
|
1008
|
+
*/
|
|
1009
|
+
function classifyChanges(files, known) {
|
|
1010
|
+
const changed = /* @__PURE__ */ new Set();
|
|
1011
|
+
const added = /* @__PURE__ */ new Set();
|
|
1012
|
+
const removed = /* @__PURE__ */ new Set();
|
|
1013
|
+
for (const file of files) switch (kindOf(file)) {
|
|
1014
|
+
case "missing":
|
|
1015
|
+
removed.add(file);
|
|
1016
|
+
for (const inner of knownWithin(known, file)) removed.add(inner);
|
|
1017
|
+
break;
|
|
1018
|
+
case "directory":
|
|
1019
|
+
added.add(file);
|
|
1020
|
+
for (const inner of knownWithin(known, file)) (existsSync(inner) ? changed : removed).add(inner);
|
|
1021
|
+
break;
|
|
1022
|
+
default: if (known.has(file)) changed.add(file);
|
|
1023
|
+
else added.add(file);
|
|
1024
|
+
}
|
|
1025
|
+
return {
|
|
1026
|
+
changed: [...changed].filter((file) => !removed.has(file)),
|
|
1027
|
+
added: [...added].filter((file) => !removed.has(file)),
|
|
1028
|
+
removed: [...removed]
|
|
1029
|
+
};
|
|
1030
|
+
}
|
|
1031
|
+
/** The default {@link WatchFactory}, based on `fs.watch`. */
|
|
1032
|
+
const watchFiles = (targets, onChange) => {
|
|
1033
|
+
const watchers = [];
|
|
1034
|
+
for (const target of targets) {
|
|
1035
|
+
if (!existsSync(target.path)) continue;
|
|
1036
|
+
let watcher;
|
|
1037
|
+
try {
|
|
1038
|
+
watcher = watch(target.path, { recursive: target.recursive }, (_event, filename) => {
|
|
1039
|
+
if (filename === null) {
|
|
1040
|
+
onChange(target.path);
|
|
1041
|
+
return;
|
|
1042
|
+
}
|
|
1043
|
+
const name = filename.toString();
|
|
1044
|
+
if (!isIgnoredPath(name)) onChange(join(target.path, name));
|
|
1045
|
+
});
|
|
1046
|
+
} catch {
|
|
1047
|
+
continue;
|
|
1048
|
+
}
|
|
1049
|
+
watcher.on("error", () => {});
|
|
1050
|
+
watchers.push(watcher);
|
|
1051
|
+
}
|
|
1052
|
+
return { close: () => {
|
|
1053
|
+
for (const watcher of watchers) watcher.close();
|
|
1054
|
+
} };
|
|
1055
|
+
};
|
|
1056
|
+
const targetKey = (target) => `${target.recursive ? "r" : "f"}:${target.path}`;
|
|
1057
|
+
/**
|
|
1058
|
+
* The watchers of a set of targets that changes after every compile: one watcher per target (started
|
|
1059
|
+
* with a {@link WatchFactory}), so that unchanged targets keep their watcher.
|
|
1060
|
+
*/
|
|
1061
|
+
var TargetWatchers = class {
|
|
1062
|
+
#factory;
|
|
1063
|
+
#onChange;
|
|
1064
|
+
#watchers = /* @__PURE__ */ new Map();
|
|
1065
|
+
constructor(factory, onChange) {
|
|
1066
|
+
this.#factory = factory;
|
|
1067
|
+
this.#onChange = onChange;
|
|
1068
|
+
}
|
|
1069
|
+
/** The watched targets. */
|
|
1070
|
+
get targets() {
|
|
1071
|
+
return [...this.#watchers.keys()].map((key) => ({
|
|
1072
|
+
path: key.slice(2),
|
|
1073
|
+
recursive: key.startsWith("r")
|
|
1074
|
+
}));
|
|
1075
|
+
}
|
|
1076
|
+
/**
|
|
1077
|
+
* Watches exactly `targets`: stops the watchers of the targets that are gone, starts the new ones,
|
|
1078
|
+
* and restarts those whose directory was missing when they started, or was itself created, moved or
|
|
1079
|
+
* deleted since (`touched`: the paths of the last changes).
|
|
1080
|
+
*/
|
|
1081
|
+
update(targets, touched = /* @__PURE__ */ new Set()) {
|
|
1082
|
+
const wanted = new Map(targets.map((target) => [targetKey(target), target]));
|
|
1083
|
+
for (const [key, entry] of this.#watchers) {
|
|
1084
|
+
if (wanted.has(key)) continue;
|
|
1085
|
+
entry.watcher.close();
|
|
1086
|
+
this.#watchers.delete(key);
|
|
1087
|
+
}
|
|
1088
|
+
for (const [key, target] of wanted) {
|
|
1089
|
+
const entry = this.#watchers.get(key);
|
|
1090
|
+
const exists = existsSync(target.path);
|
|
1091
|
+
if (entry !== void 0) {
|
|
1092
|
+
if (!exists || entry.existed && !touched.has(target.path)) continue;
|
|
1093
|
+
entry.watcher.close();
|
|
1094
|
+
}
|
|
1095
|
+
this.#watchers.set(key, {
|
|
1096
|
+
watcher: this.#factory([target], this.#onChange),
|
|
1097
|
+
existed: exists
|
|
1098
|
+
});
|
|
1099
|
+
}
|
|
1100
|
+
}
|
|
1101
|
+
close() {
|
|
1102
|
+
for (const entry of this.#watchers.values()) entry.watcher.close();
|
|
1103
|
+
this.#watchers.clear();
|
|
1104
|
+
}
|
|
1105
|
+
};
|
|
1106
|
+
// src/runtime.ts
|
|
1107
|
+
/**
|
|
1108
|
+
* JavaScript runtimes the application can run on (`znight dev`, `znight start`) and executable
|
|
1109
|
+
* lookup in `PATH`.
|
|
1110
|
+
*/
|
|
1111
|
+
/** Detects the runtime executing this process. */
|
|
1112
|
+
function detectHostRuntime() {
|
|
1113
|
+
return {
|
|
1114
|
+
name: typeof process.versions["bun"] === "string" ? "bun" : "node",
|
|
1115
|
+
execPath: process.execPath,
|
|
1116
|
+
platform: process.platform
|
|
1117
|
+
};
|
|
1118
|
+
}
|
|
1119
|
+
/** Resolves `auto`. */
|
|
1120
|
+
function resolveRuntime(option, host) {
|
|
1121
|
+
return option === void 0 || option === "auto" ? host.name : option;
|
|
1122
|
+
}
|
|
1123
|
+
/**
|
|
1124
|
+
* Finds an executable in `PATH` (`PATHEXT` extensions on Windows). Absolute paths are checked as is.
|
|
1125
|
+
* Returns `undefined` when not found.
|
|
1126
|
+
*/
|
|
1127
|
+
function which(command, env, platform = process.platform) {
|
|
1128
|
+
const extensions = platform === "win32" ? ["", ...(env["PATHEXT"] ?? ".EXE;.CMD;.BAT;.COM").split(";").filter(Boolean)] : [""];
|
|
1129
|
+
const candidates = isAbsolute(command) ? [command] : (env["PATH"] ?? env["Path"] ?? "").split(platform === "win32" ? ";" : delimiter).filter((directory) => directory !== "").map((directory) => join(directory, command));
|
|
1130
|
+
for (const candidate of candidates) for (const extension of extensions) {
|
|
1131
|
+
const file = candidate + extension;
|
|
1132
|
+
if (isExecutable(file, platform)) return file;
|
|
1133
|
+
}
|
|
1134
|
+
}
|
|
1135
|
+
function isExecutable(file, platform) {
|
|
1136
|
+
try {
|
|
1137
|
+
if (!statSync(file).isFile()) return false;
|
|
1138
|
+
if (platform !== "win32") accessSync(file, constants.X_OK);
|
|
1139
|
+
return true;
|
|
1140
|
+
} catch {
|
|
1141
|
+
return false;
|
|
1142
|
+
}
|
|
1143
|
+
}
|
|
1144
|
+
/**
|
|
1145
|
+
* The executable of `runtime`: the running executable when it is that runtime, otherwise the one
|
|
1146
|
+
* found in `PATH`.
|
|
1147
|
+
*/
|
|
1148
|
+
function runtimeExecutable(runtime, host, env) {
|
|
1149
|
+
if (runtime === host.name) return host.execPath;
|
|
1150
|
+
return which(runtime, env, host.platform);
|
|
1151
|
+
}
|
|
1152
|
+
/** Runtime arguments placed before the entry file. */
|
|
1153
|
+
function runtimeArguments(runtime, extra = []) {
|
|
1154
|
+
return runtime === "node" ? ["--enable-source-maps", ...extra] : [...extra];
|
|
1155
|
+
}
|
|
1156
|
+
// src/version.ts
|
|
1157
|
+
let cached;
|
|
1158
|
+
/**
|
|
1159
|
+
* Version of `@znight/cli`, read from its `package.json` (one level above both `src/` and `dist/`).
|
|
1160
|
+
* Projects created by `znight new` depend on `@znight/*` packages at `^<version>`.
|
|
1161
|
+
*/
|
|
1162
|
+
function cliVersion() {
|
|
1163
|
+
if (cached === void 0) try {
|
|
1164
|
+
const version = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8")).version;
|
|
1165
|
+
cached = typeof version === "string" ? version : "0.0.0";
|
|
1166
|
+
} catch {
|
|
1167
|
+
cached = "0.0.0";
|
|
1168
|
+
}
|
|
1169
|
+
return cached;
|
|
1170
|
+
}
|
|
1171
|
+
// src/context.ts
|
|
1172
|
+
const processSignals = {
|
|
1173
|
+
on: (signal, listener) => {
|
|
1174
|
+
process.on(signal, listener);
|
|
1175
|
+
},
|
|
1176
|
+
off: (signal, listener) => {
|
|
1177
|
+
process.off(signal, listener);
|
|
1178
|
+
}
|
|
1179
|
+
};
|
|
1180
|
+
/**
|
|
1181
|
+
* Creates a context from the current process, with optional overrides. The environment is a copy
|
|
1182
|
+
* of `process.env` taken now: tools running in the CLI process (Vite sets `NODE_ENV`) do not change
|
|
1183
|
+
* what the commands see and pass to the application.
|
|
1184
|
+
*/
|
|
1185
|
+
function createContext(options = {}) {
|
|
1186
|
+
const env = options.env ?? { ...process.env };
|
|
1187
|
+
const stdout = options.stdout ?? process.stdout;
|
|
1188
|
+
const stderr = options.stderr ?? process.stderr;
|
|
1189
|
+
const colors = createColors(shouldUseColor({
|
|
1190
|
+
env,
|
|
1191
|
+
stream: stdout,
|
|
1192
|
+
flag: options.color
|
|
1193
|
+
}));
|
|
1194
|
+
const interactive = options.interactive ?? (process.stdin.isTTY === true && stdout.isTTY === true && env["CI"] === void 0 && env["TERM"] !== "dumb");
|
|
1195
|
+
return {
|
|
1196
|
+
cwd: options.cwd ?? process.cwd(),
|
|
1197
|
+
env,
|
|
1198
|
+
stdout,
|
|
1199
|
+
stderr,
|
|
1200
|
+
interactive,
|
|
1201
|
+
colors,
|
|
1202
|
+
reporter: createReporter(stdout, stderr, colors),
|
|
1203
|
+
runtime: options.runtime ?? detectHostRuntime(),
|
|
1204
|
+
version: options.version ?? cliVersion(),
|
|
1205
|
+
signals: options.signals ?? processSignals,
|
|
1206
|
+
debug: options.debug ?? (env["ZNIGHT_DEBUG"] !== void 0 && env["ZNIGHT_DEBUG"] !== "" && env["ZNIGHT_DEBUG"] !== "0"),
|
|
1207
|
+
services: {
|
|
1208
|
+
loadCompiler: options.services?.loadCompiler ?? loadCompiler,
|
|
1209
|
+
loadVite: options.services?.loadVite ?? loadVite,
|
|
1210
|
+
loadPrompts: options.services?.loadPrompts ?? loadPrompts,
|
|
1211
|
+
loadOpenApi: options.services?.loadOpenApi ?? loadOpenApiEmit,
|
|
1212
|
+
loadOpenApiVite: options.services?.loadOpenApiVite ?? loadOpenApiVite,
|
|
1213
|
+
processes: options.services?.processes ?? createProcessService(),
|
|
1214
|
+
watch: options.services?.watch ?? watchFiles
|
|
1215
|
+
}
|
|
1216
|
+
};
|
|
1217
|
+
}
|
|
1218
|
+
// src/dev/supervisor.ts
|
|
1219
|
+
/** Keeps one application process running and restarts it on demand. */
|
|
1220
|
+
var AppSupervisor = class {
|
|
1221
|
+
#spawn;
|
|
1222
|
+
#debounceMs;
|
|
1223
|
+
#killTimeoutMs;
|
|
1224
|
+
#events;
|
|
1225
|
+
#stopping = /* @__PURE__ */ new Set();
|
|
1226
|
+
#current;
|
|
1227
|
+
#debounce;
|
|
1228
|
+
#queue = Promise.resolve();
|
|
1229
|
+
#queued = false;
|
|
1230
|
+
#closed = false;
|
|
1231
|
+
#started = 0;
|
|
1232
|
+
constructor(options) {
|
|
1233
|
+
this.#spawn = options.spawn;
|
|
1234
|
+
this.#debounceMs = options.debounceMs ?? 100;
|
|
1235
|
+
this.#killTimeoutMs = options.killTimeoutMs ?? 5e3;
|
|
1236
|
+
this.#events = options.events ?? {};
|
|
1237
|
+
}
|
|
1238
|
+
/** Whether an application process is running. */
|
|
1239
|
+
get running() {
|
|
1240
|
+
return this.#current !== void 0;
|
|
1241
|
+
}
|
|
1242
|
+
/** The running process. */
|
|
1243
|
+
get current() {
|
|
1244
|
+
return this.#current;
|
|
1245
|
+
}
|
|
1246
|
+
/** Number of processes started so far. */
|
|
1247
|
+
get starts() {
|
|
1248
|
+
return this.#started;
|
|
1249
|
+
}
|
|
1250
|
+
/** Restarts after `debounceMs`; calls in between postpone it. */
|
|
1251
|
+
scheduleRestart() {
|
|
1252
|
+
if (this.#closed) return;
|
|
1253
|
+
if (this.#debounce !== void 0) clearTimeout(this.#debounce);
|
|
1254
|
+
this.#debounce = setTimeout(() => {
|
|
1255
|
+
this.#debounce = void 0;
|
|
1256
|
+
this.restart();
|
|
1257
|
+
}, this.#debounceMs);
|
|
1258
|
+
}
|
|
1259
|
+
/**
|
|
1260
|
+
* Cancels a restart scheduled by {@link scheduleRestart} and not started yet (the output it would
|
|
1261
|
+
* run turned out to be broken, e.g. a failed compilation). Returns whether one was pending.
|
|
1262
|
+
*/
|
|
1263
|
+
cancelRestart() {
|
|
1264
|
+
if (this.#debounce === void 0) return false;
|
|
1265
|
+
clearTimeout(this.#debounce);
|
|
1266
|
+
this.#debounce = void 0;
|
|
1267
|
+
return true;
|
|
1268
|
+
}
|
|
1269
|
+
/**
|
|
1270
|
+
* Stops the current process (if any) and starts a new one. Restarts never overlap; a restart
|
|
1271
|
+
* requested while another one is waiting to run is merged with it.
|
|
1272
|
+
*/
|
|
1273
|
+
restart() {
|
|
1274
|
+
if (this.#closed) return this.#queue;
|
|
1275
|
+
if (this.#queued) return this.#queue;
|
|
1276
|
+
this.#queued = true;
|
|
1277
|
+
this.#queue = this.#queue.then(async () => {
|
|
1278
|
+
this.#queued = false;
|
|
1279
|
+
if (this.#closed) return;
|
|
1280
|
+
await this.#stopCurrent("SIGTERM");
|
|
1281
|
+
if (this.#closed) return;
|
|
1282
|
+
this.#startNew();
|
|
1283
|
+
}).catch((error) => {
|
|
1284
|
+
this.#queued = false;
|
|
1285
|
+
this.#events.onError?.(error);
|
|
1286
|
+
});
|
|
1287
|
+
return this.#queue;
|
|
1288
|
+
}
|
|
1289
|
+
/** Stops the current process with `signal` (SIGKILL after the timeout) and refuses new starts. */
|
|
1290
|
+
async stop(signal = "SIGTERM") {
|
|
1291
|
+
this.#closed = true;
|
|
1292
|
+
if (this.#debounce !== void 0) {
|
|
1293
|
+
clearTimeout(this.#debounce);
|
|
1294
|
+
this.#debounce = void 0;
|
|
1295
|
+
}
|
|
1296
|
+
await this.#queue;
|
|
1297
|
+
await this.#stopCurrent(signal);
|
|
1298
|
+
}
|
|
1299
|
+
/** Immediately SIGKILLs every process (a second Ctrl+C). */
|
|
1300
|
+
kill() {
|
|
1301
|
+
this.#closed = true;
|
|
1302
|
+
if (this.#debounce !== void 0) clearTimeout(this.#debounce);
|
|
1303
|
+
this.#current?.kill("SIGKILL");
|
|
1304
|
+
for (const child of this.#stopping) child.kill("SIGKILL");
|
|
1305
|
+
}
|
|
1306
|
+
#startNew() {
|
|
1307
|
+
let child;
|
|
1308
|
+
try {
|
|
1309
|
+
child = this.#spawn();
|
|
1310
|
+
} catch (error) {
|
|
1311
|
+
this.#events.onError?.(error);
|
|
1312
|
+
return;
|
|
1313
|
+
}
|
|
1314
|
+
const restart = this.#started > 0;
|
|
1315
|
+
this.#started++;
|
|
1316
|
+
this.#current = child;
|
|
1317
|
+
child.exited.then((status) => {
|
|
1318
|
+
if (this.#current !== child) return;
|
|
1319
|
+
this.#current = void 0;
|
|
1320
|
+
this.#events.onExit?.(status);
|
|
1321
|
+
});
|
|
1322
|
+
this.#events.onStart?.(child, restart);
|
|
1323
|
+
}
|
|
1324
|
+
async #stopCurrent(signal) {
|
|
1325
|
+
const child = this.#current;
|
|
1326
|
+
if (child === void 0) return;
|
|
1327
|
+
this.#current = void 0;
|
|
1328
|
+
this.#stopping.add(child);
|
|
1329
|
+
try {
|
|
1330
|
+
child.kill(signal);
|
|
1331
|
+
let timer;
|
|
1332
|
+
const timeout = new Promise((resolve) => {
|
|
1333
|
+
timer = setTimeout(() => resolve("timeout"), this.#killTimeoutMs);
|
|
1334
|
+
});
|
|
1335
|
+
const first = await Promise.race([child.exited, timeout]);
|
|
1336
|
+
clearTimeout(timer);
|
|
1337
|
+
if (first === "timeout") {
|
|
1338
|
+
child.kill("SIGKILL");
|
|
1339
|
+
this.#events.onStop?.(await child.exited, true);
|
|
1340
|
+
} else this.#events.onStop?.(first, false);
|
|
1341
|
+
} finally {
|
|
1342
|
+
this.#stopping.delete(child);
|
|
1343
|
+
}
|
|
1344
|
+
}
|
|
1345
|
+
};
|
|
1346
|
+
// src/commands/shared.ts
|
|
1347
|
+
/** `--root`, resolved against the working directory. Must be an existing directory. */
|
|
1348
|
+
function resolveRoot(cli, root) {
|
|
1349
|
+
const directory = resolve(cli.cwd, root ?? ".");
|
|
1350
|
+
let isDirectory = false;
|
|
1351
|
+
try {
|
|
1352
|
+
isDirectory = statSync(directory).isDirectory();
|
|
1353
|
+
} catch {
|
|
1354
|
+
isDirectory = false;
|
|
1355
|
+
}
|
|
1356
|
+
if (!isDirectory) throw new CliError(`Project root not found: ${directory}`);
|
|
1357
|
+
return directory;
|
|
1358
|
+
}
|
|
1359
|
+
/** The nearest directory containing a `package.json`, from `start` upwards (`start` when none). */
|
|
1360
|
+
function findProjectRoot(start) {
|
|
1361
|
+
let directory = start;
|
|
1362
|
+
for (;;) {
|
|
1363
|
+
if (existsSync(join(directory, "package.json"))) return directory;
|
|
1364
|
+
const parent = dirname(directory);
|
|
1365
|
+
if (parent === directory) return start;
|
|
1366
|
+
directory = parent;
|
|
1367
|
+
}
|
|
1368
|
+
}
|
|
1369
|
+
/** Reads `package.json` of `root` (an empty object when missing or invalid). */
|
|
1370
|
+
function readManifest(root) {
|
|
1371
|
+
try {
|
|
1372
|
+
const manifest = JSON.parse(readFileSync(join(root, "package.json"), "utf8"));
|
|
1373
|
+
return typeof manifest === "object" && manifest !== null ? manifest : {};
|
|
1374
|
+
} catch {
|
|
1375
|
+
return {};
|
|
1376
|
+
}
|
|
1377
|
+
}
|
|
1378
|
+
/** Parses a non-negative integer option (milliseconds...). */
|
|
1379
|
+
function parseNonNegativeInteger(value, option, fallback) {
|
|
1380
|
+
if (value === void 0 || value === "") return fallback;
|
|
1381
|
+
const parsed = Number(value);
|
|
1382
|
+
if (!Number.isInteger(parsed) || parsed < 0) throw new UsageError(`Invalid value for --${option}: expected a non-negative integer, got "${value}"`);
|
|
1383
|
+
return parsed;
|
|
1384
|
+
}
|
|
1385
|
+
/** An HTTP prefix option (`/api`, the `prefix` of `HttpPlugin`): `undefined` when not given. */
|
|
1386
|
+
function parseHttpPrefix(value, option) {
|
|
1387
|
+
if (value === void 0) return void 0;
|
|
1388
|
+
if (!value.startsWith("/")) throw new UsageError(`Invalid --${option} "${value}": expected a path starting with "/"`);
|
|
1389
|
+
return value;
|
|
1390
|
+
}
|
|
1391
|
+
/** A path for messages: relative to `from`, or absolute when that is shorter. POSIX separators. */
|
|
1392
|
+
function displayPath(file, from) {
|
|
1393
|
+
const path = relative(from, file);
|
|
1394
|
+
if (path === "") return ".";
|
|
1395
|
+
return (isAbsolute(path) || path.startsWith("..") && file.length < path.length ? file : path).split(sep).join("/");
|
|
1396
|
+
}
|
|
1397
|
+
/**
|
|
1398
|
+
* Signals stopping a long-running command: Ctrl+C, `kill`, the terminal closed (`SIGHUP`) and, on
|
|
1399
|
+
* POSIX, `Ctrl+\` (`SIGQUIT`). Without a listener the last two kill the CLI without running its exit
|
|
1400
|
+
* hooks, leaving the application of `znight dev` (in its own session) running.
|
|
1401
|
+
*/
|
|
1402
|
+
const TERMINATION_SIGNALS = process.platform === "win32" ? [
|
|
1403
|
+
"SIGINT",
|
|
1404
|
+
"SIGTERM",
|
|
1405
|
+
"SIGHUP"
|
|
1406
|
+
] : [
|
|
1407
|
+
"SIGINT",
|
|
1408
|
+
"SIGTERM",
|
|
1409
|
+
"SIGHUP",
|
|
1410
|
+
"SIGQUIT"
|
|
1411
|
+
];
|
|
1412
|
+
/**
|
|
1413
|
+
* Calls `onFirst` on the first {@link TERMINATION_SIGNALS termination signal}; `onRepeat` runs on
|
|
1414
|
+
* any later one (force quit). Returns a function removing the listeners.
|
|
1415
|
+
*/
|
|
1416
|
+
function onTermination(cli, onFirst, onRepeat) {
|
|
1417
|
+
let received = false;
|
|
1418
|
+
const listeners = TERMINATION_SIGNALS.map((signal) => {
|
|
1419
|
+
const listener = () => {
|
|
1420
|
+
if (received) {
|
|
1421
|
+
onRepeat(signal);
|
|
1422
|
+
return;
|
|
1423
|
+
}
|
|
1424
|
+
received = true;
|
|
1425
|
+
onFirst(signal);
|
|
1426
|
+
};
|
|
1427
|
+
cli.signals.on(signal, listener);
|
|
1428
|
+
return [signal, listener];
|
|
1429
|
+
});
|
|
1430
|
+
return () => {
|
|
1431
|
+
for (const [signal, listener] of listeners) cli.signals.off(signal, listener);
|
|
1432
|
+
};
|
|
1433
|
+
}
|
|
1434
|
+
// src/commands/build.ts
|
|
1435
|
+
/** `znight build` */
|
|
1436
|
+
const buildCommand = defineCommand({
|
|
1437
|
+
meta: {
|
|
1438
|
+
name: "build",
|
|
1439
|
+
description: "Build the application for production (vite build)",
|
|
1440
|
+
usage: "[options]",
|
|
1441
|
+
examples: [
|
|
1442
|
+
"znight build",
|
|
1443
|
+
"znight build --mode staging",
|
|
1444
|
+
"znight build --openapi # also dist/openapi.json",
|
|
1445
|
+
"znight build --openapi --openapi-prefix /api # HttpPlugin.configure({ prefix: \"/api\" })"
|
|
1446
|
+
],
|
|
1447
|
+
details: [
|
|
1448
|
+
"Fails on any compiler error. With the defaults of the znight() plugin, src/main.ts is built to dist/main.js.",
|
|
1449
|
+
"--openapi emits the OpenAPI document after the bundle (dist/openapi.json, or --openapi=<path>) with the",
|
|
1450
|
+
"openapi() Vite plugin of @znight/openapi, unless the Vite config already registers it. When HttpPlugin",
|
|
1451
|
+
"has a prefix, pass it with --openapi-prefix: it becomes the servers of the document (unless",
|
|
1452
|
+
"@OpenApiDocument declares servers), like the document the application serves in \"build\" mode."
|
|
1453
|
+
].join("\n")
|
|
1454
|
+
},
|
|
1455
|
+
args: {
|
|
1456
|
+
root: {
|
|
1457
|
+
type: "string",
|
|
1458
|
+
valueHint: "dir",
|
|
1459
|
+
description: "Project root (default: the current directory)"
|
|
1460
|
+
},
|
|
1461
|
+
config: {
|
|
1462
|
+
type: "string",
|
|
1463
|
+
valueHint: "file",
|
|
1464
|
+
description: "Vite config file (default: vite.config.*)"
|
|
1465
|
+
},
|
|
1466
|
+
mode: {
|
|
1467
|
+
type: "string",
|
|
1468
|
+
default: "production",
|
|
1469
|
+
description: "Vite mode"
|
|
1470
|
+
},
|
|
1471
|
+
"config-loader": {
|
|
1472
|
+
type: "enum",
|
|
1473
|
+
options: [
|
|
1474
|
+
"bundle",
|
|
1475
|
+
"runner",
|
|
1476
|
+
"native"
|
|
1477
|
+
],
|
|
1478
|
+
description: "How Vite loads the config file (default: bundle)"
|
|
1479
|
+
},
|
|
1480
|
+
"log-level": {
|
|
1481
|
+
type: "enum",
|
|
1482
|
+
options: [
|
|
1483
|
+
"info",
|
|
1484
|
+
"warn",
|
|
1485
|
+
"error",
|
|
1486
|
+
"silent"
|
|
1487
|
+
],
|
|
1488
|
+
default: "info",
|
|
1489
|
+
description: "Vite log level"
|
|
1490
|
+
},
|
|
1491
|
+
openapi: {
|
|
1492
|
+
type: "boolean",
|
|
1493
|
+
description: "Also emit the OpenAPI document (dist/openapi.json, or --openapi=<path>; needs @znight/openapi)"
|
|
1494
|
+
},
|
|
1495
|
+
"openapi-prefix": {
|
|
1496
|
+
type: "string",
|
|
1497
|
+
valueHint: "path",
|
|
1498
|
+
description: "With --openapi: the HTTP prefix of HttpPlugin, the servers default of the document (/api)"
|
|
1499
|
+
}
|
|
1500
|
+
},
|
|
1501
|
+
async run({ args, cli, rawArgs }) {
|
|
1502
|
+
const root = resolveRoot(cli, args.root);
|
|
1503
|
+
const openapi = openapiOption(rawArgs, args.openapi);
|
|
1504
|
+
const prefix = parseHttpPrefix(args["openapi-prefix"], "openapi-prefix");
|
|
1505
|
+
if (prefix !== void 0 && openapi === void 0) throw new UsageError("--openapi-prefix needs --openapi", [`znight build --openapi --openapi-prefix ${prefix}`]);
|
|
1506
|
+
const vite = await cli.services.loadVite(root);
|
|
1507
|
+
const tracker = createOutputTracker(root);
|
|
1508
|
+
const extra = [];
|
|
1509
|
+
if (openapi !== void 0) {
|
|
1510
|
+
const api = await cli.services.loadOpenApiVite(root);
|
|
1511
|
+
const options = {
|
|
1512
|
+
...openapi === true ? {} : { output: openapi },
|
|
1513
|
+
...prefix === void 0 ? {} : { prefix }
|
|
1514
|
+
};
|
|
1515
|
+
const plugin = api.openapi(options);
|
|
1516
|
+
extra.push(plugin);
|
|
1517
|
+
const given = [...options.output === void 0 ? [] : [`--openapi=${options.output}`], ...options.prefix === void 0 ? [] : [`--openapi-prefix ${options.prefix}`]];
|
|
1518
|
+
if (given.length > 0) extra.push(ignoredOpenApiOptions(cli, plugin, given));
|
|
1519
|
+
}
|
|
1520
|
+
const started = performance.now();
|
|
1521
|
+
try {
|
|
1522
|
+
await runBuild(vite, {
|
|
1523
|
+
root,
|
|
1524
|
+
configFile: args.config,
|
|
1525
|
+
configLoader: args["config-loader"],
|
|
1526
|
+
mode: args.mode,
|
|
1527
|
+
logLevel: args["log-level"],
|
|
1528
|
+
plugins: [tracker.plugin, ...extra]
|
|
1529
|
+
});
|
|
1530
|
+
} catch (error) {
|
|
1531
|
+
cli.reporter.logError(formatBuildError(error));
|
|
1532
|
+
throw new CliError("Build failed", { cause: error });
|
|
1533
|
+
}
|
|
1534
|
+
const entry = tracker.entry();
|
|
1535
|
+
const duration = formatDuration(performance.now() - started);
|
|
1536
|
+
if (entry === void 0) {
|
|
1537
|
+
cli.reporter.success(`Built in ${duration}`);
|
|
1538
|
+
return 0;
|
|
1539
|
+
}
|
|
1540
|
+
let size = "";
|
|
1541
|
+
try {
|
|
1542
|
+
size = ` ${cli.colors.dim(formatBytes(statSync(entry).size))}`;
|
|
1543
|
+
} catch {
|
|
1544
|
+
size = "";
|
|
1545
|
+
}
|
|
1546
|
+
cli.reporter.success(`Built ${cli.colors.bold(displayPath(entry, cli.cwd))}${size} in ${duration}`);
|
|
1547
|
+
cli.reporter.log(` ${cli.colors.dim("run it with")} ${cli.colors.cyan("znight start")}`);
|
|
1548
|
+
return 0;
|
|
1549
|
+
}
|
|
1550
|
+
});
|
|
1551
|
+
/** `--openapi` (`true`), `--openapi=<path>` (the path), or `undefined`. */
|
|
1552
|
+
function openapiOption(rawArgs, parsed) {
|
|
1553
|
+
for (const token of rawArgs) {
|
|
1554
|
+
if (token.startsWith("--openapi=")) {
|
|
1555
|
+
const value = token.slice(10);
|
|
1556
|
+
if (value === "false") return void 0;
|
|
1557
|
+
return value === "" || value === "true" ? true : value;
|
|
1558
|
+
}
|
|
1559
|
+
if (token === "--no-openapi") return void 0;
|
|
1560
|
+
}
|
|
1561
|
+
return parsed === true || rawArgs.includes("--openapi") ? true : void 0;
|
|
1562
|
+
}
|
|
1563
|
+
/**
|
|
1564
|
+
* When the Vite config registers its own `openapi()`, that one emits the document and the copy of the
|
|
1565
|
+
* CLI stays inactive: the options given on the command line (`given`) do not apply. Says so, instead
|
|
1566
|
+
* of emitting a document without the requested output or prefix.
|
|
1567
|
+
*/
|
|
1568
|
+
function ignoredOpenApiOptions(cli, plugin, given) {
|
|
1569
|
+
return {
|
|
1570
|
+
name: "znight:cli-openapi-options",
|
|
1571
|
+
apply: "build",
|
|
1572
|
+
configResolved(config) {
|
|
1573
|
+
const active = config.plugins.find((candidate) => candidate.name === plugin.name);
|
|
1574
|
+
if (active === void 0 || active === plugin) return;
|
|
1575
|
+
cli.reporter.warn(`${given.join(" and ")} ignored: the openapi() plugin of the Vite config emits the document with its own options`, ["set them on openapi() in the Vite config (openapi({ output, prefix })), or remove it from there"]);
|
|
1576
|
+
}
|
|
1577
|
+
};
|
|
1578
|
+
}
|
|
1579
|
+
// src/commands/dev.ts
|
|
1580
|
+
/** Clears the terminal and its scrollback. */
|
|
1581
|
+
const CLEAR_SCREEN = "\x1B[2J\x1B[3J\x1B[H";
|
|
1582
|
+
/** `znight dev` */
|
|
1583
|
+
const devCommand = defineCommand({
|
|
1584
|
+
meta: {
|
|
1585
|
+
name: "dev",
|
|
1586
|
+
description: "Build in watch mode and restart the application after every successful rebuild",
|
|
1587
|
+
usage: "[options] [-- <application arguments>]",
|
|
1588
|
+
examples: [
|
|
1589
|
+
"znight dev",
|
|
1590
|
+
"znight dev --runtime bun --clear",
|
|
1591
|
+
"znight dev --inspect -- --verbose"
|
|
1592
|
+
],
|
|
1593
|
+
details: [
|
|
1594
|
+
"Uses \"vite build --watch\" with the project Vite config (the znight() plugin regenerates the container).",
|
|
1595
|
+
"On every successful build the application receives SIGTERM, gets --kill-timeout ms to stop gracefully",
|
|
1596
|
+
"(then SIGKILL), and a new process starts. A failed build or compilation (ZNIGHT errors) keeps the",
|
|
1597
|
+
"last good process running."
|
|
1598
|
+
].join("\n")
|
|
1599
|
+
},
|
|
1600
|
+
args: {
|
|
1601
|
+
root: {
|
|
1602
|
+
type: "string",
|
|
1603
|
+
valueHint: "dir",
|
|
1604
|
+
description: "Project root (default: the current directory)"
|
|
1605
|
+
},
|
|
1606
|
+
config: {
|
|
1607
|
+
type: "string",
|
|
1608
|
+
valueHint: "file",
|
|
1609
|
+
description: "Vite config file (default: vite.config.*)"
|
|
1610
|
+
},
|
|
1611
|
+
mode: {
|
|
1612
|
+
type: "string",
|
|
1613
|
+
default: "development",
|
|
1614
|
+
description: "Vite mode"
|
|
1615
|
+
},
|
|
1616
|
+
"config-loader": {
|
|
1617
|
+
type: "enum",
|
|
1618
|
+
options: [
|
|
1619
|
+
"bundle",
|
|
1620
|
+
"runner",
|
|
1621
|
+
"native"
|
|
1622
|
+
],
|
|
1623
|
+
description: "How Vite loads the config file (default: bundle)"
|
|
1624
|
+
},
|
|
1625
|
+
runtime: {
|
|
1626
|
+
type: "enum",
|
|
1627
|
+
options: [
|
|
1628
|
+
"auto",
|
|
1629
|
+
"node",
|
|
1630
|
+
"bun"
|
|
1631
|
+
],
|
|
1632
|
+
default: "auto",
|
|
1633
|
+
description: "Runtime of the application (auto: the runtime running the CLI)"
|
|
1634
|
+
},
|
|
1635
|
+
entry: {
|
|
1636
|
+
type: "string",
|
|
1637
|
+
valueHint: "file",
|
|
1638
|
+
description: "File to run (default: the entry chunk of the build)"
|
|
1639
|
+
},
|
|
1640
|
+
inspect: {
|
|
1641
|
+
type: "boolean",
|
|
1642
|
+
description: "Start the application with --inspect (debugger)"
|
|
1643
|
+
},
|
|
1644
|
+
clear: {
|
|
1645
|
+
type: "boolean",
|
|
1646
|
+
description: "Clear the screen before each rebuild"
|
|
1647
|
+
},
|
|
1648
|
+
debounce: {
|
|
1649
|
+
type: "string",
|
|
1650
|
+
valueHint: "ms",
|
|
1651
|
+
default: "100",
|
|
1652
|
+
description: "Delay before restarting after a build"
|
|
1653
|
+
},
|
|
1654
|
+
"kill-timeout": {
|
|
1655
|
+
type: "string",
|
|
1656
|
+
valueHint: "ms",
|
|
1657
|
+
default: "5000",
|
|
1658
|
+
description: "Time the application gets to stop after SIGTERM before SIGKILL"
|
|
1659
|
+
},
|
|
1660
|
+
"log-level": {
|
|
1661
|
+
type: "enum",
|
|
1662
|
+
options: [
|
|
1663
|
+
"info",
|
|
1664
|
+
"warn",
|
|
1665
|
+
"error",
|
|
1666
|
+
"silent"
|
|
1667
|
+
],
|
|
1668
|
+
default: "warn",
|
|
1669
|
+
description: "Vite log level"
|
|
1670
|
+
}
|
|
1671
|
+
},
|
|
1672
|
+
async run({ args, cli, passthrough }) {
|
|
1673
|
+
const root = resolveRoot(cli, args.root);
|
|
1674
|
+
const debounceMs = parseNonNegativeInteger(args.debounce, "debounce", 100);
|
|
1675
|
+
const killTimeoutMs = parseNonNegativeInteger(args["kill-timeout"], "kill-timeout", 5e3);
|
|
1676
|
+
const runtime = resolveRuntime(args.runtime, cli.runtime);
|
|
1677
|
+
const executable = runtimeExecutable(runtime, cli.runtime, cli.env);
|
|
1678
|
+
if (executable === void 0) throw new CliError(`Cannot find "${runtime}" in PATH`, { hints: ["install it, or choose another --runtime"] });
|
|
1679
|
+
const logLevel = args["log-level"];
|
|
1680
|
+
const { colors, reporter } = cli;
|
|
1681
|
+
const env = { ...cli.env };
|
|
1682
|
+
const guard = orphanGuard(runtime, {
|
|
1683
|
+
parentPid: process.pid,
|
|
1684
|
+
pollMs: 500,
|
|
1685
|
+
killTimeoutMs
|
|
1686
|
+
});
|
|
1687
|
+
const runtimeArgs = runtimeArguments(runtime, [...guard?.args ?? [], ...args.inspect === true ? ["--inspect"] : []]);
|
|
1688
|
+
const vite = await cli.services.loadVite(root);
|
|
1689
|
+
const tracker = createOutputTracker(root);
|
|
1690
|
+
let entry;
|
|
1691
|
+
const supervisor = new AppSupervisor({
|
|
1692
|
+
debounceMs,
|
|
1693
|
+
killTimeoutMs,
|
|
1694
|
+
spawn: () => {
|
|
1695
|
+
const file = entry ?? resolve(root, "dist/main.js");
|
|
1696
|
+
return cli.services.processes.spawn(executable, [
|
|
1697
|
+
...runtimeArgs,
|
|
1698
|
+
file,
|
|
1699
|
+
...passthrough
|
|
1700
|
+
], {
|
|
1701
|
+
cwd: root,
|
|
1702
|
+
env: {
|
|
1703
|
+
...env,
|
|
1704
|
+
...guard?.env,
|
|
1705
|
+
NODE_ENV: env["NODE_ENV"] ?? "development"
|
|
1706
|
+
},
|
|
1707
|
+
detached: true
|
|
1708
|
+
});
|
|
1709
|
+
},
|
|
1710
|
+
events: {
|
|
1711
|
+
onStart: (child, restart) => {
|
|
1712
|
+
const file = displayPath(entry ?? resolve(root, "dist/main.js"), cli.cwd);
|
|
1713
|
+
reporter.info(`${restart ? "Restarted" : "Started"} ${colors.bold(file)} ${colors.dim(`(${runtime}, pid ${child.pid ?? "?"})`)}`);
|
|
1714
|
+
},
|
|
1715
|
+
onStop: (_status, forced) => {
|
|
1716
|
+
if (forced) reporter.warn(`The application did not stop within ${killTimeoutMs}ms: killed`);
|
|
1717
|
+
},
|
|
1718
|
+
onExit: (status) => {
|
|
1719
|
+
if (status.error !== void 0) {
|
|
1720
|
+
reporter.error(`Cannot start ${runtime}: ${status.error.message}`);
|
|
1721
|
+
return;
|
|
1722
|
+
}
|
|
1723
|
+
const code = exitCodeOf(status);
|
|
1724
|
+
const message = `The application exited with code ${code}${status.signal === null ? "" : ` (${status.signal})`}`;
|
|
1725
|
+
if (code === 0) reporter.info(`${message}. Waiting for changes...`);
|
|
1726
|
+
else reporter.error(`${message}. Waiting for changes...`);
|
|
1727
|
+
},
|
|
1728
|
+
onError: (error) => reporter.error(`Cannot start the application: ${error instanceof Error ? error.message : String(error)}`)
|
|
1729
|
+
}
|
|
1730
|
+
});
|
|
1731
|
+
const state = {
|
|
1732
|
+
failed: false,
|
|
1733
|
+
first: true,
|
|
1734
|
+
started: 0
|
|
1735
|
+
};
|
|
1736
|
+
const onEvent = (event) => {
|
|
1737
|
+
switch (event.code) {
|
|
1738
|
+
case "START":
|
|
1739
|
+
state.failed = false;
|
|
1740
|
+
state.started = performance.now();
|
|
1741
|
+
if (!state.first) {
|
|
1742
|
+
if (args.clear === true && cli.stdout.isTTY === true) cli.stdout.write(CLEAR_SCREEN);
|
|
1743
|
+
reporter.info(colors.dim("Change detected, rebuilding..."));
|
|
1744
|
+
}
|
|
1745
|
+
break;
|
|
1746
|
+
case "ERROR":
|
|
1747
|
+
state.failed = true;
|
|
1748
|
+
state.first = false;
|
|
1749
|
+
Promise.resolve().then(() => event.result?.close()).catch(() => void 0);
|
|
1750
|
+
if (logLevel === "silent") reporter.logError(formatBuildError(event.error));
|
|
1751
|
+
reporter.error(`Build failed. ${supervisor.running ? "The last good build keeps running. " : ""}Waiting for changes...`);
|
|
1752
|
+
break;
|
|
1753
|
+
case "END": {
|
|
1754
|
+
state.first = false;
|
|
1755
|
+
if (state.failed) break;
|
|
1756
|
+
const compile = tracker.compile();
|
|
1757
|
+
if (compile !== void 0 && !compile.ok) {
|
|
1758
|
+
supervisor.cancelRestart();
|
|
1759
|
+
const errors = compile.diagnostics.filter((diagnostic) => diagnostic.severity === "error").length;
|
|
1760
|
+
(logLevel === "silent" ? printDiagnostics$2(cli, root, compile.diagnostics) : Promise.resolve()).then(() => reporter.error(`Compilation failed with ${plural(errors, "error")}. ${supervisor.running ? "The last good build keeps running. " : ""}Waiting for changes...`));
|
|
1761
|
+
break;
|
|
1762
|
+
}
|
|
1763
|
+
entry = args.entry === void 0 ? tracker.entry() ?? entry : resolve(root, args.entry);
|
|
1764
|
+
reporter.success(`Built in ${formatDuration(performance.now() - (tracker.startedAt() ?? state.started))}`);
|
|
1765
|
+
supervisor.scheduleRestart();
|
|
1766
|
+
break;
|
|
1767
|
+
}
|
|
1768
|
+
}
|
|
1769
|
+
};
|
|
1770
|
+
reporter.info(`${colors.bold("znight dev")} ${colors.dim(`v${cli.version}`)} — vite build --watch, ${runtime} runtime`);
|
|
1771
|
+
let watcher;
|
|
1772
|
+
try {
|
|
1773
|
+
watcher = await watchBuild(vite, {
|
|
1774
|
+
root,
|
|
1775
|
+
configFile: args.config,
|
|
1776
|
+
configLoader: args["config-loader"],
|
|
1777
|
+
mode: args.mode,
|
|
1778
|
+
logLevel,
|
|
1779
|
+
plugins: [tracker.plugin]
|
|
1780
|
+
});
|
|
1781
|
+
} catch (error) {
|
|
1782
|
+
if (error instanceof CliError) throw error;
|
|
1783
|
+
reporter.logError(formatBuildError(error));
|
|
1784
|
+
throw new CliError("Cannot start the Vite build", { cause: error });
|
|
1785
|
+
}
|
|
1786
|
+
watcher.on("event", onEvent);
|
|
1787
|
+
return waitForTermination(cli, watcher, supervisor);
|
|
1788
|
+
}
|
|
1789
|
+
});
|
|
1790
|
+
/** Prints compiler diagnostics with the project's `formatDiagnostics()` (a built-in formatter otherwise). */
|
|
1791
|
+
async function printDiagnostics$2(cli, root, diagnostics) {
|
|
1792
|
+
if (diagnostics.length === 0) return;
|
|
1793
|
+
const options = {
|
|
1794
|
+
color: cli.colors.isColorSupported,
|
|
1795
|
+
cwd: root,
|
|
1796
|
+
summary: false
|
|
1797
|
+
};
|
|
1798
|
+
let text;
|
|
1799
|
+
try {
|
|
1800
|
+
text = (await cli.services.loadCompiler(root)).formatDiagnostics(diagnostics, options);
|
|
1801
|
+
} catch {
|
|
1802
|
+
text = fallbackFormatDiagnostics(diagnostics, options);
|
|
1803
|
+
}
|
|
1804
|
+
cli.reporter.logError(text.trimEnd());
|
|
1805
|
+
}
|
|
1806
|
+
function waitForTermination(cli, watcher, supervisor) {
|
|
1807
|
+
return new Promise((resolveExit) => {
|
|
1808
|
+
const dispose = onTermination(cli, (signal) => {
|
|
1809
|
+
if (signal !== "SIGHUP") cli.reporter.info(cli.colors.dim("Stopping... (press Ctrl+C again to force)"));
|
|
1810
|
+
(async () => {
|
|
1811
|
+
try {
|
|
1812
|
+
await watcher.close();
|
|
1813
|
+
} finally {
|
|
1814
|
+
await supervisor.stop(signal === "SIGINT" ? "SIGINT" : "SIGTERM");
|
|
1815
|
+
dispose();
|
|
1816
|
+
resolveExit(0);
|
|
1817
|
+
}
|
|
1818
|
+
})();
|
|
1819
|
+
}, () => {
|
|
1820
|
+
supervisor.kill();
|
|
1821
|
+
dispose();
|
|
1822
|
+
resolveExit(130);
|
|
1823
|
+
});
|
|
1824
|
+
});
|
|
1825
|
+
}
|
|
1826
|
+
// src/commands/generate.ts
|
|
1827
|
+
/** `znight generate` */
|
|
1828
|
+
const generateCommand = defineCommand({
|
|
1829
|
+
meta: {
|
|
1830
|
+
name: "generate",
|
|
1831
|
+
description: "Generate the dependency injection container (src/.znight/container.generated.ts)",
|
|
1832
|
+
usage: "[options]",
|
|
1833
|
+
examples: [
|
|
1834
|
+
"znight generate",
|
|
1835
|
+
"znight generate --check # in CI, before tsc",
|
|
1836
|
+
"znight generate --watch"
|
|
1837
|
+
],
|
|
1838
|
+
details: [
|
|
1839
|
+
"The compiler options are read from znight.config.* (or the file named by ZNIGHT_CONFIG), the file",
|
|
1840
|
+
"shared with the Bun preload; without one, from the znight() plugin of vite.config.ts, so that the",
|
|
1841
|
+
"generated file is the same as the one of the Vite build. --config reads the given Vite config.",
|
|
1842
|
+
"\"znight generate <schematic> <name>\" is an alias of \"znight g <schematic> <name>\"."
|
|
1843
|
+
].join("\n")
|
|
1844
|
+
},
|
|
1845
|
+
args: {
|
|
1846
|
+
check: {
|
|
1847
|
+
type: "boolean",
|
|
1848
|
+
description: "Do not write: exit with code 1 when the generated file is missing or out of date"
|
|
1849
|
+
},
|
|
1850
|
+
watch: {
|
|
1851
|
+
type: "boolean",
|
|
1852
|
+
alias: "w",
|
|
1853
|
+
description: "Regenerate on every change"
|
|
1854
|
+
},
|
|
1855
|
+
root: {
|
|
1856
|
+
type: "string",
|
|
1857
|
+
valueHint: "dir",
|
|
1858
|
+
description: "Project root (default: the current directory)"
|
|
1859
|
+
},
|
|
1860
|
+
config: {
|
|
1861
|
+
type: "string",
|
|
1862
|
+
valueHint: "file",
|
|
1863
|
+
description: "Vite config file to read the znight() options from (instead of znight.config.*)"
|
|
1864
|
+
},
|
|
1865
|
+
"config-loader": {
|
|
1866
|
+
type: "enum",
|
|
1867
|
+
options: [
|
|
1868
|
+
"bundle",
|
|
1869
|
+
"runner",
|
|
1870
|
+
"native"
|
|
1871
|
+
],
|
|
1872
|
+
description: "How Vite loads the config file (default: bundle)"
|
|
1873
|
+
}
|
|
1874
|
+
},
|
|
1875
|
+
async run({ args, cli }) {
|
|
1876
|
+
const root = resolveRoot(cli, args.root);
|
|
1877
|
+
const compiler = await cli.services.loadCompiler(root);
|
|
1878
|
+
const configLoader = args["config-loader"];
|
|
1879
|
+
const options = await compilerOptions(cli, compiler, root, args.config, configLoader);
|
|
1880
|
+
if (args.check === true) return check(cli, compiler, options);
|
|
1881
|
+
if (args.watch === true) return watch$1(cli, compiler, options);
|
|
1882
|
+
return hasErrors((await generateOnce(cli, compiler, options)).diagnostics) ? 1 : 0;
|
|
1883
|
+
}
|
|
1884
|
+
});
|
|
1885
|
+
/**
|
|
1886
|
+
* The compiler options, with `root`: those of `znight.config.*` when the project has one (unless a
|
|
1887
|
+
* Vite config is given with `--config`), else those of `znight()` in the Vite config (when exposed),
|
|
1888
|
+
* else the defaults.
|
|
1889
|
+
*/
|
|
1890
|
+
async function compilerOptions(cli, compiler, root, configFile, configLoader) {
|
|
1891
|
+
if (configFile === void 0) {
|
|
1892
|
+
const shared = await znightConfigOptions(compiler, root);
|
|
1893
|
+
if (shared !== void 0) return shared;
|
|
1894
|
+
}
|
|
1895
|
+
let options;
|
|
1896
|
+
if (findViteConfig(root, configFile) === void 0) return { root };
|
|
1897
|
+
try {
|
|
1898
|
+
const lookup = await readZnightPluginOptions(await cli.services.loadVite(root), root, configFile, configLoader);
|
|
1899
|
+
if (lookup !== void 0 && !lookup.found) cli.reporter.warn(`${displayPath(lookup.configFile, cli.cwd)} does not register the znight() plugin`);
|
|
1900
|
+
options = lookup?.options;
|
|
1901
|
+
} catch (error) {
|
|
1902
|
+
if (configFile !== void 0) throw new CliError(`Cannot load the Vite config ${configFile}`, {
|
|
1903
|
+
cause: error,
|
|
1904
|
+
hints: [errorMessage(error)]
|
|
1905
|
+
});
|
|
1906
|
+
cli.reporter.warn(`Cannot read the znight() options from the Vite config, using the defaults: ${errorMessage(error)}`);
|
|
1907
|
+
}
|
|
1908
|
+
return {
|
|
1909
|
+
...options,
|
|
1910
|
+
root: resolve(root, options?.root ?? ".")
|
|
1911
|
+
};
|
|
1912
|
+
}
|
|
1913
|
+
/** Options of `znight.config.*` (`loadZnightConfig()` of the compiler), `undefined` without the file. */
|
|
1914
|
+
async function znightConfigOptions(compiler, root) {
|
|
1915
|
+
if (compiler.loadConfig === void 0) return void 0;
|
|
1916
|
+
let config;
|
|
1917
|
+
try {
|
|
1918
|
+
config = await compiler.loadConfig(root);
|
|
1919
|
+
} catch (error) {
|
|
1920
|
+
throw new CliError("Cannot load the Znight config (znight.config.*)", {
|
|
1921
|
+
cause: error,
|
|
1922
|
+
hints: [errorMessage(error)]
|
|
1923
|
+
});
|
|
1924
|
+
}
|
|
1925
|
+
if (config.file === void 0) return void 0;
|
|
1926
|
+
const { logLevel: _preloadLogLevel, ...options } = config.options;
|
|
1927
|
+
return {
|
|
1928
|
+
...options,
|
|
1929
|
+
root: resolve(root, options.root ?? ".")
|
|
1930
|
+
};
|
|
1931
|
+
}
|
|
1932
|
+
function printDiagnostics$1(cli, compiler, result, root) {
|
|
1933
|
+
if (result.diagnostics.length === 0) return;
|
|
1934
|
+
const text = compiler.formatDiagnostics(result.diagnostics, {
|
|
1935
|
+
color: cli.colors.isColorSupported,
|
|
1936
|
+
cwd: root,
|
|
1937
|
+
summary: false
|
|
1938
|
+
});
|
|
1939
|
+
cli.reporter.logError(text.trimEnd());
|
|
1940
|
+
cli.reporter.logError();
|
|
1941
|
+
}
|
|
1942
|
+
function statsOf(result, elapsedMs) {
|
|
1943
|
+
const parts = [];
|
|
1944
|
+
if (result.stats?.providers !== void 0) parts.push(plural(result.stats.providers, "provider"));
|
|
1945
|
+
if (result.stats?.files !== void 0) parts.push(plural(result.stats.files, "file"));
|
|
1946
|
+
parts.push(formatDuration(result.stats?.durationMs ?? elapsedMs));
|
|
1947
|
+
return parts.join(", ");
|
|
1948
|
+
}
|
|
1949
|
+
function failure(cli, result) {
|
|
1950
|
+
const errors = result.diagnostics.filter((diagnostic) => diagnostic.severity === "error").length;
|
|
1951
|
+
cli.reporter.error(`Generation failed with ${plural(errors, "error")}`);
|
|
1952
|
+
}
|
|
1953
|
+
/** Prints the outcome of a generation. */
|
|
1954
|
+
function report(cli, compiler, result, root, elapsed) {
|
|
1955
|
+
printDiagnostics$1(cli, compiler, result, root);
|
|
1956
|
+
if (hasErrors(result.diagnostics)) {
|
|
1957
|
+
failure(cli, result);
|
|
1958
|
+
return;
|
|
1959
|
+
}
|
|
1960
|
+
const file = displayPath(result.outFile, cli.cwd);
|
|
1961
|
+
cli.reporter.success(`${result.written ? "Generated" : "Up to date:"} ${cli.colors.bold(file)} ${cli.colors.dim(`(${statsOf(result, elapsed)})`)}`);
|
|
1962
|
+
}
|
|
1963
|
+
async function generateOnce(cli, compiler, options) {
|
|
1964
|
+
const started = performance.now();
|
|
1965
|
+
const result = await compiler.generate(options);
|
|
1966
|
+
report(cli, compiler, result, options.root ?? cli.cwd, performance.now() - started);
|
|
1967
|
+
return result;
|
|
1968
|
+
}
|
|
1969
|
+
/**
|
|
1970
|
+
* The generated files `--check` reports, the module first: those the compiler says are stale (the
|
|
1971
|
+
* module when it does not say which), and the module when it is missing.
|
|
1972
|
+
*/
|
|
1973
|
+
function staleFilesOf(result) {
|
|
1974
|
+
const stale = /* @__PURE__ */ new Set();
|
|
1975
|
+
if (!existsSync(result.outFile)) stale.add(result.outFile);
|
|
1976
|
+
if (result.stale) {
|
|
1977
|
+
const files = result.staleFiles ?? [];
|
|
1978
|
+
for (const file of files.length > 0 ? files : [result.outFile]) stale.add(file);
|
|
1979
|
+
}
|
|
1980
|
+
return [...stale].sort((a, b) => Number(b === result.outFile) - Number(a === result.outFile));
|
|
1981
|
+
}
|
|
1982
|
+
async function check(cli, compiler, options) {
|
|
1983
|
+
const root = options.root ?? cli.cwd;
|
|
1984
|
+
const started = performance.now();
|
|
1985
|
+
const result = await compiler.check(options);
|
|
1986
|
+
printDiagnostics$1(cli, compiler, result, root);
|
|
1987
|
+
if (hasErrors(result.diagnostics)) {
|
|
1988
|
+
failure(cli, result);
|
|
1989
|
+
return 1;
|
|
1990
|
+
}
|
|
1991
|
+
const file = displayPath(result.outFile, cli.cwd);
|
|
1992
|
+
const stale = staleFilesOf(result);
|
|
1993
|
+
for (const path of stale) {
|
|
1994
|
+
const shown = displayPath(path, cli.cwd);
|
|
1995
|
+
const hint = path === result.outFile ? "run \"znight generate\" (it is git-ignored: generate it before tsc and tests)" : "run \"znight generate\" after the build: a build cleaning dist/ (tsdown, tsc --build --clean...) deletes it";
|
|
1996
|
+
if (existsSync(path)) cli.reporter.error(`${shown} is out of date`, ["run \"znight generate\""]);
|
|
1997
|
+
else cli.reporter.error(`${shown} is missing`, [hint]);
|
|
1998
|
+
}
|
|
1999
|
+
if (stale.length > 0) return 1;
|
|
2000
|
+
cli.reporter.success(`${cli.colors.bold(file)} is up to date ${cli.colors.dim(`(${statsOf(result, performance.now() - started)})`)}`);
|
|
2001
|
+
return 0;
|
|
2002
|
+
}
|
|
2003
|
+
async function watch$1(cli, compiler, options) {
|
|
2004
|
+
const root = options.root ?? cli.cwd;
|
|
2005
|
+
const realRoot = realPath(root);
|
|
2006
|
+
const incremental = compiler.createIncremental(options);
|
|
2007
|
+
let state = {
|
|
2008
|
+
known: /* @__PURE__ */ new Set(),
|
|
2009
|
+
roots: []
|
|
2010
|
+
};
|
|
2011
|
+
let pending = /* @__PURE__ */ new Set();
|
|
2012
|
+
let running = false;
|
|
2013
|
+
let timer;
|
|
2014
|
+
const watchers = new TargetWatchers(cli.services.watch, (file) => {
|
|
2015
|
+
const path = realPath(file);
|
|
2016
|
+
if (!incremental.isRelevantChange(changeKind(path, state.known), path, state.known, state.roots)) return;
|
|
2017
|
+
pending.add(path);
|
|
2018
|
+
if (timer !== void 0) clearTimeout(timer);
|
|
2019
|
+
timer = setTimeout(() => {
|
|
2020
|
+
timer = void 0;
|
|
2021
|
+
run(false);
|
|
2022
|
+
}, 50);
|
|
2023
|
+
});
|
|
2024
|
+
const run = async (initial) => {
|
|
2025
|
+
if (running) return;
|
|
2026
|
+
running = true;
|
|
2027
|
+
let first = initial;
|
|
2028
|
+
try {
|
|
2029
|
+
do {
|
|
2030
|
+
const files = pending;
|
|
2031
|
+
pending = /* @__PURE__ */ new Set();
|
|
2032
|
+
const started = performance.now();
|
|
2033
|
+
let known = state.known;
|
|
2034
|
+
try {
|
|
2035
|
+
const result = await incremental.generate(first ? void 0 : classifyChanges(files, state.known));
|
|
2036
|
+
if (result.watchFiles !== void 0) known = new Set(result.watchFiles.map((file) => toPosixPath(resolve(file))));
|
|
2037
|
+
report(cli, compiler, result, root, performance.now() - started);
|
|
2038
|
+
} catch (error) {
|
|
2039
|
+
cli.reporter.error(`Generation failed: ${errorMessage(error)}`);
|
|
2040
|
+
}
|
|
2041
|
+
state = {
|
|
2042
|
+
known,
|
|
2043
|
+
roots: incremental.includeRoots().map(realPath)
|
|
2044
|
+
};
|
|
2045
|
+
watchers.update(watchTargets(realRoot, state.roots, state.known), files);
|
|
2046
|
+
first = false;
|
|
2047
|
+
} while (pending.size > 0);
|
|
2048
|
+
} finally {
|
|
2049
|
+
running = false;
|
|
2050
|
+
}
|
|
2051
|
+
};
|
|
2052
|
+
await run(true);
|
|
2053
|
+
cli.reporter.info(cli.colors.dim("Watching for changes... (Ctrl+C to stop)"));
|
|
2054
|
+
return new Promise((resolveExit) => {
|
|
2055
|
+
const dispose = onTermination(cli, () => {
|
|
2056
|
+
if (timer !== void 0) clearTimeout(timer);
|
|
2057
|
+
watchers.close();
|
|
2058
|
+
dispose();
|
|
2059
|
+
resolveExit(0);
|
|
2060
|
+
}, () => void 0);
|
|
2061
|
+
});
|
|
2062
|
+
}
|
|
2063
|
+
// src/scaffold/options.ts
|
|
2064
|
+
/** Every package manager, in display order. */
|
|
2065
|
+
const PACKAGE_MANAGERS = [
|
|
2066
|
+
"bun",
|
|
2067
|
+
"npm",
|
|
2068
|
+
"pnpm",
|
|
2069
|
+
"yarn"
|
|
2070
|
+
];
|
|
2071
|
+
/** Every HTTP adapter, in display order. */
|
|
2072
|
+
const HTTP_ADAPTERS = [
|
|
2073
|
+
"node",
|
|
2074
|
+
"fastify",
|
|
2075
|
+
"express",
|
|
2076
|
+
"hono"
|
|
2077
|
+
];
|
|
2078
|
+
/** Every feature, in display (and canonical) order. */
|
|
2079
|
+
const FEATURES = [
|
|
2080
|
+
"config",
|
|
2081
|
+
"observability",
|
|
2082
|
+
"messaging",
|
|
2083
|
+
"schedule",
|
|
2084
|
+
"openapi"
|
|
2085
|
+
];
|
|
2086
|
+
/** Features selected when none are given. */
|
|
2087
|
+
const DEFAULT_FEATURES = ["config", "observability"];
|
|
2088
|
+
/** Labels shown by the prompts and the help. */
|
|
2089
|
+
const ADAPTER_LABELS = {
|
|
2090
|
+
node: "node:http (built-in, zero dependency)",
|
|
2091
|
+
fastify: "Fastify",
|
|
2092
|
+
express: "Express",
|
|
2093
|
+
hono: "Hono"
|
|
2094
|
+
};
|
|
2095
|
+
/** Labels shown by the prompts. */
|
|
2096
|
+
const FEATURE_LABELS = {
|
|
2097
|
+
config: "Config: type-safe configuration classes (@znight/config)",
|
|
2098
|
+
observability: "Observability: structured logs, metrics, tracing (@znight/observability)",
|
|
2099
|
+
messaging: "Messaging: message consumers and publishing (@znight/messaging)",
|
|
2100
|
+
schedule: "Schedule: cron, interval and timeout jobs (@znight/schedule)",
|
|
2101
|
+
openapi: "OpenAPI: openapi.json and an API reference at /docs (@znight/openapi)"
|
|
2102
|
+
};
|
|
2103
|
+
/**
|
|
2104
|
+
* Parses `--features` (`config,observability`, `none`, `all`). Returns the features in canonical
|
|
2105
|
+
* order, or the unknown names.
|
|
2106
|
+
*/
|
|
2107
|
+
function parseFeatures(value) {
|
|
2108
|
+
const names = value.split(",").map((name) => name.trim().toLowerCase()).filter((name) => name !== "");
|
|
2109
|
+
if (names.length === 1 && names[0] === "none") return { features: [] };
|
|
2110
|
+
if (names.length === 1 && names[0] === "all") return { features: [...FEATURES] };
|
|
2111
|
+
const unknown = names.filter((name) => !FEATURES.includes(name));
|
|
2112
|
+
if (unknown.length > 0) return { unknown };
|
|
2113
|
+
return { features: FEATURES.filter((feature) => names.includes(feature)) };
|
|
2114
|
+
}
|
|
2115
|
+
/**
|
|
2116
|
+
* Default package manager: the one that launched the CLI (`npm_config_user_agent`: `pnpm dlx`,
|
|
2117
|
+
* `yarn dlx`, `bunx`, `npx`), else Bun when running on Bun, else npm.
|
|
2118
|
+
*/
|
|
2119
|
+
function detectPackageManager(env, runningOnBun) {
|
|
2120
|
+
const name = (env["npm_config_user_agent"] ?? "").split("/")[0];
|
|
2121
|
+
if (name === "bun" || name === "npm" || name === "pnpm" || name === "yarn") return name;
|
|
2122
|
+
return runningOnBun ? "bun" : "npm";
|
|
2123
|
+
}
|
|
2124
|
+
/** `bun install`, `npm install`... */
|
|
2125
|
+
function installCommand(pm) {
|
|
2126
|
+
return [pm, "install"];
|
|
2127
|
+
}
|
|
2128
|
+
/** `bun run dev`, `npm run dev`, `pnpm dev`, `yarn dev`. */
|
|
2129
|
+
function runScript(pm, script) {
|
|
2130
|
+
return pm === "pnpm" || pm === "yarn" ? `${pm} ${script}` : `${pm} run ${script}`;
|
|
2131
|
+
}
|
|
2132
|
+
/** Runs a binary of the project: `npx znight`, `bunx znight`, `pnpm znight`, `yarn znight`. */
|
|
2133
|
+
function execBinary(pm, binary) {
|
|
2134
|
+
switch (pm) {
|
|
2135
|
+
case "npm": return `npx ${binary}`;
|
|
2136
|
+
case "bun": return `bunx ${binary}`;
|
|
2137
|
+
default: return `${pm} ${binary}`;
|
|
2138
|
+
}
|
|
2139
|
+
}
|
|
2140
|
+
// src/scaffold/create.ts
|
|
2141
|
+
/** Entries tolerated in the target directory (a fresh clone, editor settings). */
|
|
2142
|
+
const IGNORABLE_ENTRIES = /* @__PURE__ */ new Set([
|
|
2143
|
+
".git",
|
|
2144
|
+
".DS_Store",
|
|
2145
|
+
"Thumbs.db",
|
|
2146
|
+
".idea",
|
|
2147
|
+
".vscode"
|
|
2148
|
+
]);
|
|
2149
|
+
/** Inspects the target directory. */
|
|
2150
|
+
function targetState(directory) {
|
|
2151
|
+
let stats;
|
|
2152
|
+
try {
|
|
2153
|
+
stats = statSync(directory);
|
|
2154
|
+
} catch {
|
|
2155
|
+
return "missing";
|
|
2156
|
+
}
|
|
2157
|
+
if (!stats.isDirectory()) return "file";
|
|
2158
|
+
return readdirSync(directory).every((entry) => IGNORABLE_ENTRIES.has(entry)) ? "empty" : "not-empty";
|
|
2159
|
+
}
|
|
2160
|
+
/** Writes `files` under `directory` (created when missing). Returns the written paths. */
|
|
2161
|
+
function writeProject(directory, files) {
|
|
2162
|
+
mkdirSync(directory, { recursive: true });
|
|
2163
|
+
for (const file of files) {
|
|
2164
|
+
const target = join(directory, file.path);
|
|
2165
|
+
mkdirSync(dirname(target), { recursive: true });
|
|
2166
|
+
writeFileSync(target, file.content);
|
|
2167
|
+
}
|
|
2168
|
+
return files.map((file) => file.path);
|
|
2169
|
+
}
|
|
2170
|
+
/** `git init` in `directory`, unless it already is inside a git work tree (monorepos). */
|
|
2171
|
+
async function initGit(processes, directory, env) {
|
|
2172
|
+
const inside = await processes.run("git", ["rev-parse", "--is-inside-work-tree"], {
|
|
2173
|
+
cwd: directory,
|
|
2174
|
+
env
|
|
2175
|
+
});
|
|
2176
|
+
if (inside.error !== void 0) return "unavailable";
|
|
2177
|
+
if (inside.code === 0 && inside.stdout.trim() === "true") return "inside-repository";
|
|
2178
|
+
return (await processes.run("git", ["init", "--quiet"], {
|
|
2179
|
+
cwd: directory,
|
|
2180
|
+
env
|
|
2181
|
+
})).code === 0 ? "initialized" : "failed";
|
|
2182
|
+
}
|
|
2183
|
+
/** `<pm> install` in `directory`, output captured. */
|
|
2184
|
+
function installDependencies(processes, directory, packageManager, env) {
|
|
2185
|
+
const [command, ...args] = installCommand(packageManager);
|
|
2186
|
+
return processes.run(command, args, {
|
|
2187
|
+
cwd: directory,
|
|
2188
|
+
env
|
|
2189
|
+
});
|
|
2190
|
+
}
|
|
2191
|
+
// src/scaffold/versions.ts
|
|
2192
|
+
/**
|
|
2193
|
+
* Version ranges of the third-party dependencies of new projects. `@znight/*` packages use
|
|
2194
|
+
* `^<version of @znight/cli>` (they are released in lockstep).
|
|
2195
|
+
*/
|
|
2196
|
+
const DEPENDENCY_VERSIONS = {
|
|
2197
|
+
"@hono/node-server": "^2.1.5",
|
|
2198
|
+
"@types/bun": "^1.3.0",
|
|
2199
|
+
"@types/express": "^5.0.6",
|
|
2200
|
+
"@types/node": "^24.0.0",
|
|
2201
|
+
express: "^5.3.0",
|
|
2202
|
+
fastify: "^5.12.7",
|
|
2203
|
+
hono: "^4.13.14",
|
|
2204
|
+
"light-my-request": "^6.8.0",
|
|
2205
|
+
typescript: "^7.0.0",
|
|
2206
|
+
vite: "^8.0.0",
|
|
2207
|
+
vitest: "^4.1.0",
|
|
2208
|
+
zod: "^4.6.0"
|
|
2209
|
+
};
|
|
2210
|
+
/** Range of a third-party dependency. */
|
|
2211
|
+
function versionOf(name) {
|
|
2212
|
+
const version = DEPENDENCY_VERSIONS[name];
|
|
2213
|
+
if (version === void 0) throw new Error(`[znight] no version declared for ${name}`);
|
|
2214
|
+
return version;
|
|
2215
|
+
}
|
|
2216
|
+
// src/scaffold/templates.ts
|
|
2217
|
+
function flagsOf(options) {
|
|
2218
|
+
const has = (feature) => options.features.includes(feature);
|
|
2219
|
+
return {
|
|
2220
|
+
config: has("config"),
|
|
2221
|
+
observability: has("observability"),
|
|
2222
|
+
messaging: has("messaging"),
|
|
2223
|
+
schedule: has("schedule"),
|
|
2224
|
+
openapi: has("openapi"),
|
|
2225
|
+
adapter: options.adapter,
|
|
2226
|
+
pm: options.packageManager,
|
|
2227
|
+
bun: options.packageManager === "bun"
|
|
2228
|
+
};
|
|
2229
|
+
}
|
|
2230
|
+
/** Every file of a new project, sorted by path. */
|
|
2231
|
+
function renderProject(options) {
|
|
2232
|
+
const flags = flagsOf(options);
|
|
2233
|
+
const files = [
|
|
2234
|
+
{
|
|
2235
|
+
path: "package.json",
|
|
2236
|
+
content: packageJson(options, flags)
|
|
2237
|
+
},
|
|
2238
|
+
{
|
|
2239
|
+
path: "tsconfig.json",
|
|
2240
|
+
content: tsconfigJson(flags)
|
|
2241
|
+
},
|
|
2242
|
+
{
|
|
2243
|
+
path: "vite.config.ts",
|
|
2244
|
+
content: viteConfig()
|
|
2245
|
+
},
|
|
2246
|
+
{
|
|
2247
|
+
path: "znight.config.ts",
|
|
2248
|
+
content: znightConfig(flags)
|
|
2249
|
+
},
|
|
2250
|
+
{
|
|
2251
|
+
path: ".gitignore",
|
|
2252
|
+
content: gitignore()
|
|
2253
|
+
},
|
|
2254
|
+
{
|
|
2255
|
+
path: "README.md",
|
|
2256
|
+
content: readme(options, flags)
|
|
2257
|
+
},
|
|
2258
|
+
{
|
|
2259
|
+
path: "src/main.ts",
|
|
2260
|
+
content: mainTs(flags)
|
|
2261
|
+
},
|
|
2262
|
+
{
|
|
2263
|
+
path: "src/app/greeting.schemas.ts",
|
|
2264
|
+
content: greetingSchemas(flags)
|
|
2265
|
+
},
|
|
2266
|
+
{
|
|
2267
|
+
path: "src/app/greeting.service.ts",
|
|
2268
|
+
content: greetingService(flags)
|
|
2269
|
+
},
|
|
2270
|
+
{
|
|
2271
|
+
path: "src/app/greeting.controller.ts",
|
|
2272
|
+
content: greetingController(flags)
|
|
2273
|
+
},
|
|
2274
|
+
{
|
|
2275
|
+
path: "src/app/health.controller.ts",
|
|
2276
|
+
content: healthController()
|
|
2277
|
+
},
|
|
2278
|
+
{
|
|
2279
|
+
path: "test/greeting.service.test.ts",
|
|
2280
|
+
content: greetingServiceTest(flags)
|
|
2281
|
+
},
|
|
2282
|
+
{
|
|
2283
|
+
path: "test/greeting.controller.test.ts",
|
|
2284
|
+
content: greetingControllerTest(flags)
|
|
2285
|
+
}
|
|
2286
|
+
];
|
|
2287
|
+
if (flags.config) files.push({
|
|
2288
|
+
path: "src/app/app.config.ts",
|
|
2289
|
+
content: appConfig()
|
|
2290
|
+
});
|
|
2291
|
+
if (flags.messaging) files.push({
|
|
2292
|
+
path: "src/app/greeting.consumer.ts",
|
|
2293
|
+
content: greetingConsumer(flags)
|
|
2294
|
+
});
|
|
2295
|
+
if (flags.schedule) {
|
|
2296
|
+
files.push({
|
|
2297
|
+
path: "src/app/heartbeat.jobs.ts",
|
|
2298
|
+
content: heartbeatJobs()
|
|
2299
|
+
});
|
|
2300
|
+
files.push({
|
|
2301
|
+
path: "test/heartbeat.jobs.test.ts",
|
|
2302
|
+
content: heartbeatJobsTest(flags)
|
|
2303
|
+
});
|
|
2304
|
+
}
|
|
2305
|
+
if (flags.openapi) files.push({
|
|
2306
|
+
path: "src/api.ts",
|
|
2307
|
+
content: apiDocument(options)
|
|
2308
|
+
});
|
|
2309
|
+
if (flags.bun) files.push({
|
|
2310
|
+
path: "bunfig.toml",
|
|
2311
|
+
content: bunfig(flags)
|
|
2312
|
+
});
|
|
2313
|
+
if (flags.pm === "yarn") files.push({
|
|
2314
|
+
path: ".yarnrc.yml",
|
|
2315
|
+
content: "nodeLinker: node-modules\n"
|
|
2316
|
+
});
|
|
2317
|
+
return files.sort((a, b) => a.path < b.path ? -1 : 1);
|
|
2318
|
+
}
|
|
2319
|
+
function sortRecord(record) {
|
|
2320
|
+
return Object.fromEntries(Object.entries(record).sort(([a], [b]) => a < b ? -1 : 1));
|
|
2321
|
+
}
|
|
2322
|
+
function packageJson(options, flags) {
|
|
2323
|
+
const znight = `^${options.znightVersion}`;
|
|
2324
|
+
const dependencies = {
|
|
2325
|
+
"@znight/core": znight,
|
|
2326
|
+
"@znight/http": znight,
|
|
2327
|
+
zod: versionOf("zod")
|
|
2328
|
+
};
|
|
2329
|
+
if (flags.config) dependencies["@znight/config"] = znight;
|
|
2330
|
+
if (flags.observability) dependencies["@znight/observability"] = znight;
|
|
2331
|
+
if (flags.messaging) dependencies["@znight/messaging"] = znight;
|
|
2332
|
+
if (flags.schedule) dependencies["@znight/schedule"] = znight;
|
|
2333
|
+
if (flags.openapi) dependencies["@znight/openapi"] = znight;
|
|
2334
|
+
if (flags.adapter === "fastify") dependencies["fastify"] = versionOf("fastify");
|
|
2335
|
+
if (flags.adapter === "express") dependencies["express"] = versionOf("express");
|
|
2336
|
+
if (flags.adapter === "hono") {
|
|
2337
|
+
dependencies["hono"] = versionOf("hono");
|
|
2338
|
+
dependencies["@hono/node-server"] = versionOf("@hono/node-server");
|
|
2339
|
+
}
|
|
2340
|
+
const devDependencies = {
|
|
2341
|
+
"@types/node": versionOf("@types/node"),
|
|
2342
|
+
"@znight/cli": znight,
|
|
2343
|
+
"@znight/compiler": znight,
|
|
2344
|
+
"@znight/testing": znight,
|
|
2345
|
+
typescript: versionOf("typescript"),
|
|
2346
|
+
vite: versionOf("vite")
|
|
2347
|
+
};
|
|
2348
|
+
if (flags.bun) devDependencies["@types/bun"] = versionOf("@types/bun");
|
|
2349
|
+
else devDependencies["vitest"] = versionOf("vitest");
|
|
2350
|
+
if (flags.adapter === "express") devDependencies["@types/express"] = versionOf("@types/express");
|
|
2351
|
+
if (flags.adapter === "node" || flags.adapter === "express") devDependencies["light-my-request"] = versionOf("light-my-request");
|
|
2352
|
+
const manifest = {
|
|
2353
|
+
name: options.name,
|
|
2354
|
+
version: "0.1.0",
|
|
2355
|
+
private: true,
|
|
2356
|
+
type: "module",
|
|
2357
|
+
engines: { node: ">=24" },
|
|
2358
|
+
scripts: {
|
|
2359
|
+
dev: flags.bun ? "znight dev --runtime bun" : "znight dev",
|
|
2360
|
+
build: "znight build",
|
|
2361
|
+
start: flags.bun ? "znight start --runtime bun" : "znight start",
|
|
2362
|
+
generate: "znight generate",
|
|
2363
|
+
typecheck: "znight generate && tsc --noEmit",
|
|
2364
|
+
test: flags.bun ? "bun test" : "vitest run",
|
|
2365
|
+
...flags.openapi ? {
|
|
2366
|
+
openapi: "znight openapi",
|
|
2367
|
+
"openapi:check": "znight openapi --check"
|
|
2368
|
+
} : {}
|
|
2369
|
+
},
|
|
2370
|
+
dependencies: sortRecord(dependencies),
|
|
2371
|
+
devDependencies: sortRecord(devDependencies)
|
|
2372
|
+
};
|
|
2373
|
+
return `${JSON.stringify(manifest, null, 2)}\n`;
|
|
2374
|
+
}
|
|
2375
|
+
function tsconfigJson(flags) {
|
|
2376
|
+
return `{
|
|
2377
|
+
"compilerOptions": {
|
|
2378
|
+
"target": "ES2024",
|
|
2379
|
+
"lib": ["ES2024", "ESNext.Disposable"],
|
|
2380
|
+
"module": "preserve",
|
|
2381
|
+
"moduleResolution": "bundler",
|
|
2382
|
+
"moduleDetection": "force",
|
|
2383
|
+
"types": ["${flags.bun ? "bun" : "node"}"],
|
|
2384
|
+
"strict": true,
|
|
2385
|
+
"noUncheckedIndexedAccess": true,
|
|
2386
|
+
"noImplicitOverride": true,
|
|
2387
|
+
// Znight decorators are TypeScript decorators: no emitDecoratorMetadata nor reflect-metadata
|
|
2388
|
+
"experimentalDecorators": true,
|
|
2389
|
+
"verbatimModuleSyntax": true,
|
|
2390
|
+
"isolatedModules": true,
|
|
2391
|
+
"resolveJsonModule": true,
|
|
2392
|
+
"skipLibCheck": true,
|
|
2393
|
+
"noEmit": true,
|
|
2394
|
+
// vite.config.ts imports ./znight.config.ts (also loaded as is by Node.js and Bun)
|
|
2395
|
+
"allowImportingTsExtensions": true
|
|
2396
|
+
},
|
|
2397
|
+
"include": ["src", "test", "vite.config.ts", "znight.config.ts"]
|
|
2398
|
+
}
|
|
2399
|
+
`;
|
|
2400
|
+
}
|
|
2401
|
+
function viteConfig() {
|
|
2402
|
+
return `import { znight } from '@znight/compiler'
|
|
2403
|
+
import { defineConfig } from 'vite'
|
|
2404
|
+
|
|
2405
|
+
import options from './znight.config.ts'
|
|
2406
|
+
|
|
2407
|
+
// znight() generates the dependency injection container at build time and configures a server
|
|
2408
|
+
// build: src/main.ts -> dist/main.js (Node.js 24 target, source maps).
|
|
2409
|
+
export default defineConfig({
|
|
2410
|
+
plugins: [znight(options)],
|
|
2411
|
+
})
|
|
2412
|
+
`;
|
|
2413
|
+
}
|
|
2414
|
+
function znightConfig(flags) {
|
|
2415
|
+
return `import type { ZnightOptions } from '@znight/compiler'
|
|
2416
|
+
|
|
2417
|
+
${flags.bun ? `// Options of the Znight compiler. The znight() Vite plugin (vite.config.ts), the Bun preload of the
|
|
2418
|
+
// tests (bunfig.toml) and \`znight generate\` read them, so they all generate the same container.` : `// Options of the Znight compiler. The znight() Vite plugin (vite.config.ts, also used by Vitest)
|
|
2419
|
+
// and \`znight generate\` read them, so they all generate the same container.`}
|
|
2420
|
+
// https://github.com/znight-org/znight/tree/main/packages/compiler#options
|
|
2421
|
+
export default {
|
|
2422
|
+
// reportUnused: true, // warn about providers nothing uses (ZNIGHT019)
|
|
2423
|
+
} satisfies ZnightOptions
|
|
2424
|
+
`;
|
|
2425
|
+
}
|
|
2426
|
+
function bunfig(flags) {
|
|
2427
|
+
return `${flags.config ? `# @znight/config loads the .env files (dotenv()), the same way on Node.js and Bun: Bun's own
|
|
2428
|
+
# loading would put them in process.env first, with its own precedence
|
|
2429
|
+
env = false
|
|
2430
|
+
|
|
2431
|
+
` : ""}[test]
|
|
2432
|
+
# Regenerates the DI container (src/.znight/container.generated.ts) before the tests run
|
|
2433
|
+
preload = ["@znight/compiler/bun"]
|
|
2434
|
+
`;
|
|
2435
|
+
}
|
|
2436
|
+
function gitignore() {
|
|
2437
|
+
return `node_modules
|
|
2438
|
+
dist
|
|
2439
|
+
coverage
|
|
2440
|
+
*.log
|
|
2441
|
+
|
|
2442
|
+
# generated by the Znight compiler (znight generate, vite build, bun test)
|
|
2443
|
+
.znight
|
|
2444
|
+
|
|
2445
|
+
# local environment
|
|
2446
|
+
.env.local
|
|
2447
|
+
.env.*.local
|
|
2448
|
+
`;
|
|
2449
|
+
}
|
|
2450
|
+
function adapterImport(adapter) {
|
|
2451
|
+
switch (adapter) {
|
|
2452
|
+
case "fastify": return "import { fastifyAdapter } from '@znight/http/fastify'";
|
|
2453
|
+
case "express": return "import { expressAdapter } from '@znight/http/express'";
|
|
2454
|
+
case "hono": return "import { honoAdapter } from '@znight/http/hono'";
|
|
2455
|
+
default: return;
|
|
2456
|
+
}
|
|
2457
|
+
}
|
|
2458
|
+
function adapterCall(adapter) {
|
|
2459
|
+
return adapter === "node" ? void 0 : `${adapter}Adapter()`;
|
|
2460
|
+
}
|
|
2461
|
+
function mainTs(flags) {
|
|
2462
|
+
const imports = [
|
|
2463
|
+
flags.config ? "import { ConfigPlugin } from '@znight/config'" : void 0,
|
|
2464
|
+
"import { Application } from '@znight/core'",
|
|
2465
|
+
"import { HttpPlugin } from '@znight/http'",
|
|
2466
|
+
adapterImport(flags.adapter),
|
|
2467
|
+
flags.messaging ? "import { MessagingPlugin } from '@znight/messaging'" : void 0,
|
|
2468
|
+
flags.messaging ? "import { memory } from '@znight/messaging/memory'" : void 0,
|
|
2469
|
+
flags.observability ? "import { ObservabilityPlugin } from '@znight/observability'" : void 0,
|
|
2470
|
+
flags.openapi ? "import { OpenApiPlugin } from '@znight/openapi'" : void 0,
|
|
2471
|
+
flags.schedule ? "import { SchedulePlugin } from '@znight/schedule'" : void 0
|
|
2472
|
+
].filter((line) => line !== void 0);
|
|
2473
|
+
const localImports = ["import { __ZNIGHT_METADATA__ } from './.znight/container.generated.js'", flags.config ? "import { AppConfig } from './app/app.config.js'" : void 0].filter((line) => line !== void 0);
|
|
2474
|
+
const plugins = [];
|
|
2475
|
+
if (flags.observability) plugins.push(" // first, so that its hooks wrap everything: request logs, metrics (with prom-client), traces", " ObservabilityPlugin.configure(),");
|
|
2476
|
+
if (flags.config) plugins.push(" // loads .env files and the environment, validates every @Config class before anything starts", " ConfigPlugin.configure(),");
|
|
2477
|
+
if (flags.messaging) plugins.push(" // in-memory broker for development: use rabbitmq(), sqs() or kafka() in production", " MessagingPlugin.configure({ driver: memory() }),");
|
|
2478
|
+
if (flags.schedule) plugins.push(" // before HttpPlugin: plugins stop in reverse order, so HTTP stops first, then running jobs drain", " SchedulePlugin.configure(),");
|
|
2479
|
+
const adapter = adapterCall(flags.adapter);
|
|
2480
|
+
if (flags.config) plugins.push(" HttpPlugin.configure(container => ({", ...adapter === void 0 ? [] : [` adapter: ${adapter},`], " port: container.get(AppConfig).port,", " })),");
|
|
2481
|
+
else {
|
|
2482
|
+
const port = "port: Number(process.env.PORT ?? 3000)";
|
|
2483
|
+
plugins.push(adapter === void 0 ? ` HttpPlugin.configure({ ${port} }),` : ` HttpPlugin.configure({ adapter: ${adapter}, ${port} }),`);
|
|
2484
|
+
}
|
|
2485
|
+
if (flags.openapi) plugins.push(" // GET /openapi.json and the API reference at /docs (document options: src/api.ts)", " OpenApiPlugin.configure(),");
|
|
2486
|
+
return `${imports.join("\n")}
|
|
2487
|
+
|
|
2488
|
+
${localImports.join("\n")}
|
|
2489
|
+
|
|
2490
|
+
const app = await Application.create({
|
|
2491
|
+
metadata: __ZNIGHT_METADATA__,
|
|
2492
|
+
plugins: [
|
|
2493
|
+
${plugins.join("\n")}
|
|
2494
|
+
],
|
|
2495
|
+
})
|
|
2496
|
+
|
|
2497
|
+
// SIGINT / SIGTERM stop the application gracefully (in-flight requests are drained)
|
|
2498
|
+
await app.start()
|
|
2499
|
+
`;
|
|
2500
|
+
}
|
|
2501
|
+
function appConfig() {
|
|
2502
|
+
return `import { Config, Env } from '@znight/config'
|
|
2503
|
+
|
|
2504
|
+
/**
|
|
2505
|
+
* Settings of the application, validated at boot by ConfigPlugin. Each property is read from the
|
|
2506
|
+
* environment (or a .env file): APP_<PROPERTY> by default, or the name given to @Env. Properties
|
|
2507
|
+
* with an initializer are optional; their type selects the coercion ("3000" -> 3000).
|
|
2508
|
+
*/
|
|
2509
|
+
@Config('app')
|
|
2510
|
+
export class AppConfig {
|
|
2511
|
+
/** HTTP port (PORT). */
|
|
2512
|
+
@Env('PORT')
|
|
2513
|
+
port = 3000
|
|
2514
|
+
|
|
2515
|
+
/** Greeting used by GreetingService (APP_GREETING). */
|
|
2516
|
+
greeting = 'Hello'
|
|
2517
|
+
}
|
|
2518
|
+
`;
|
|
2519
|
+
}
|
|
2520
|
+
function greetingSchemas(flags) {
|
|
2521
|
+
return `import { z } from 'zod'
|
|
2522
|
+
|
|
2523
|
+
/** ${flags.messaging ? "Body of POST /greetings and POST /greetings/async, payload of the \"greetings\" queue." : "Body of POST /greetings."} */
|
|
2524
|
+
export const CreateGreeting = z.object({
|
|
2525
|
+
name: z.string().trim().min(1).max(100),
|
|
2526
|
+
excited: z.boolean().default(false),
|
|
2527
|
+
})
|
|
2528
|
+
export type CreateGreeting = z.output<typeof CreateGreeting>
|
|
2529
|
+
`;
|
|
2530
|
+
}
|
|
2531
|
+
function greetingService(flags) {
|
|
2532
|
+
const imports = ["import { Injectable } from '@znight/core'"];
|
|
2533
|
+
if (flags.observability) imports.push("import { Logger } from '@znight/observability'");
|
|
2534
|
+
const local = flags.config ? "\nimport { AppConfig } from './app.config.js'\n" : "";
|
|
2535
|
+
const params = [];
|
|
2536
|
+
if (flags.config) params.push(" private readonly config: AppConfig,");
|
|
2537
|
+
if (flags.observability) params.push(" private readonly logger: Logger,");
|
|
2538
|
+
let members = "";
|
|
2539
|
+
if (params.length > 0) members = ` // dependencies are resolved at build time from the parameter types
|
|
2540
|
+
constructor(
|
|
2541
|
+
${params.join("\n")}
|
|
2542
|
+
) {}
|
|
2543
|
+
|
|
2544
|
+
`;
|
|
2545
|
+
const greeting = flags.config ? "this.config.greeting" : "'Hello'";
|
|
2546
|
+
const log = flags.observability ? " this.logger.debug('Greeting', { name, excited })\n" : "";
|
|
2547
|
+
return `${imports.join("\n")}
|
|
2548
|
+
${local}
|
|
2549
|
+
@Injectable()
|
|
2550
|
+
export class GreetingService {
|
|
2551
|
+
${members} greet(name: string, excited = false): string {
|
|
2552
|
+
${log} return \`\${${greeting}}, \${name}\${excited ? '!' : '.'}\`
|
|
2553
|
+
}
|
|
2554
|
+
}
|
|
2555
|
+
`;
|
|
2556
|
+
}
|
|
2557
|
+
function greetingController(flags) {
|
|
2558
|
+
const imports = [`import { ${flags.messaging ? "Body, Controller, Get, HttpCode, Param, Post, Query" : "Body, Controller, Get, Param, Post, Query"} } from '@znight/http'`];
|
|
2559
|
+
if (flags.messaging) imports.push("import { MessageBroker } from '@znight/messaging'");
|
|
2560
|
+
const ctor = flags.messaging ? ` constructor(
|
|
2561
|
+
private readonly greetings: GreetingService,
|
|
2562
|
+
private readonly broker: MessageBroker,
|
|
2563
|
+
) {}` : " constructor(private readonly greetings: GreetingService) {}";
|
|
2564
|
+
const enqueue = flags.messaging ? `
|
|
2565
|
+
|
|
2566
|
+
/** POST /greetings/async: published to the "greetings" queue, handled by GreetingConsumer. */
|
|
2567
|
+
@Post('/async')
|
|
2568
|
+
@HttpCode(202)
|
|
2569
|
+
async enqueue(@Body(CreateGreeting) input: CreateGreeting): Promise<{ id: string }> {
|
|
2570
|
+
const { id } = await this.broker.publish('greetings', input, { type: 'greeting.requested' })
|
|
2571
|
+
return { id }
|
|
2572
|
+
}` : "";
|
|
2573
|
+
return `${imports.join("\n")}
|
|
2574
|
+
|
|
2575
|
+
import { CreateGreeting } from './greeting.schemas.js'
|
|
2576
|
+
import { GreetingService } from './greeting.service.js'
|
|
2577
|
+
|
|
2578
|
+
@Controller('/greetings')
|
|
2579
|
+
export class GreetingController {
|
|
2580
|
+
${ctor}
|
|
2581
|
+
|
|
2582
|
+
/** GET /greetings/Ada?excited=true: route and query parameters are coerced to their types. */
|
|
2583
|
+
@Get('/:name')
|
|
2584
|
+
greet(@Param('name') name: string, @Query('excited') excited?: boolean): { message: string } {
|
|
2585
|
+
return { message: this.greetings.greet(name, excited) }
|
|
2586
|
+
}
|
|
2587
|
+
|
|
2588
|
+
/** POST /greetings {"name": "Ada"}: the body is validated by the schema (400 otherwise). */
|
|
2589
|
+
@Post()
|
|
2590
|
+
create(@Body(CreateGreeting) input: CreateGreeting): { message: string } {
|
|
2591
|
+
return { message: this.greetings.greet(input.name, input.excited) }
|
|
2592
|
+
}${enqueue}
|
|
2593
|
+
}
|
|
2594
|
+
`;
|
|
2595
|
+
}
|
|
2596
|
+
function healthController() {
|
|
2597
|
+
return `import { Controller, Get } from '@znight/http'
|
|
2598
|
+
|
|
2599
|
+
@Controller('/health')
|
|
2600
|
+
export class HealthController {
|
|
2601
|
+
/** GET /health */
|
|
2602
|
+
@Get()
|
|
2603
|
+
check(): { status: 'ok'; uptime: number } {
|
|
2604
|
+
return { status: 'ok', uptime: Math.round(process.uptime()) }
|
|
2605
|
+
}
|
|
2606
|
+
}
|
|
2607
|
+
`;
|
|
2608
|
+
}
|
|
2609
|
+
function greetingConsumer(flags) {
|
|
2610
|
+
const imports = ["import { Consumer, Message, OnMessage, Retry } from '@znight/messaging'"];
|
|
2611
|
+
if (flags.observability) imports.push("import { Logger } from '@znight/observability'");
|
|
2612
|
+
const ctor = flags.observability ? ` constructor(
|
|
2613
|
+
private readonly greetings: GreetingService,
|
|
2614
|
+
private readonly logger: Logger,
|
|
2615
|
+
) {}` : " constructor(private readonly greetings: GreetingService) {}";
|
|
2616
|
+
const output = flags.observability ? " this.logger.info(this.greetings.greet(request.name, request.excited))" : " console.log(this.greetings.greet(request.name, request.excited))";
|
|
2617
|
+
return `${imports.join("\n")}
|
|
2618
|
+
|
|
2619
|
+
import { CreateGreeting } from './greeting.schemas.js'
|
|
2620
|
+
import { GreetingService } from './greeting.service.js'
|
|
2621
|
+
|
|
2622
|
+
/**
|
|
2623
|
+
* Consumes the "greetings" queue. Delivery is at-least-once: handlers must be idempotent. A failing
|
|
2624
|
+
* handler is retried, then the message goes to the dead-letter queue (greetings.dlq).
|
|
2625
|
+
*/
|
|
2626
|
+
@Consumer('greetings')
|
|
2627
|
+
export class GreetingConsumer {
|
|
2628
|
+
${ctor}
|
|
2629
|
+
|
|
2630
|
+
@OnMessage('greeting.requested')
|
|
2631
|
+
@Retry({ attempts: 3, backoff: 'exponential', delay: 500 })
|
|
2632
|
+
handle(@Message(CreateGreeting) request: CreateGreeting): void {
|
|
2633
|
+
${output}
|
|
2634
|
+
}
|
|
2635
|
+
}
|
|
2636
|
+
`;
|
|
2637
|
+
}
|
|
2638
|
+
function heartbeatJobs() {
|
|
2639
|
+
return `import { Cron, type JobContext } from '@znight/schedule'
|
|
2640
|
+
|
|
2641
|
+
/**
|
|
2642
|
+
* Scheduled jobs. A class with @Cron, @Interval or @Timeout methods becomes a singleton provider
|
|
2643
|
+
* (no decorator needed): its dependencies are constructor parameters. Running several instances?
|
|
2644
|
+
* Give SchedulePlugin a lock store so that each occurrence runs on one of them.
|
|
2645
|
+
*/
|
|
2646
|
+
export class HeartbeatJobs {
|
|
2647
|
+
/** Number of runs. */
|
|
2648
|
+
beats = 0
|
|
2649
|
+
/** The occurrence of the last run. */
|
|
2650
|
+
last: Date | undefined
|
|
2651
|
+
|
|
2652
|
+
/** Every minute (cron expressions run in UTC unless they set a timeZone). */
|
|
2653
|
+
@Cron('* * * * *', { name: 'heartbeat' })
|
|
2654
|
+
beat(ctx: JobContext): void {
|
|
2655
|
+
this.beats++
|
|
2656
|
+
this.last = ctx.scheduledAt
|
|
2657
|
+
}
|
|
2658
|
+
}
|
|
2659
|
+
`;
|
|
2660
|
+
}
|
|
2661
|
+
function apiDocument(options) {
|
|
2662
|
+
return `import { OpenApiDocument } from '@znight/openapi'
|
|
2663
|
+
|
|
2664
|
+
/**
|
|
2665
|
+
* ${options.name}
|
|
2666
|
+
*
|
|
2667
|
+
* The HTTP API of ${options.name}. Title and description of the OpenAPI document come from this
|
|
2668
|
+
* comment, the version from package.json.
|
|
2669
|
+
*/
|
|
2670
|
+
@OpenApiDocument({
|
|
2671
|
+
tags: [{ name: 'Greeting', description: 'Greetings' }],
|
|
2672
|
+
})
|
|
2673
|
+
export class Api {}
|
|
2674
|
+
`;
|
|
2675
|
+
}
|
|
2676
|
+
function heartbeatJobsTest(flags) {
|
|
2677
|
+
return `${testImports(flags, "describe, expect, test")}
|
|
2678
|
+
import { SchedulePlugin, SchedulerRegistry } from '@znight/schedule'
|
|
2679
|
+
import { createTestClock } from '@znight/schedule/testing'
|
|
2680
|
+
import { TestApp } from '@znight/testing'
|
|
2681
|
+
|
|
2682
|
+
import { __ZNIGHT_METADATA__ as metadata } from '../src/.znight/container.generated.js'
|
|
2683
|
+
import { HeartbeatJobs } from '../src/app/heartbeat.jobs.js'
|
|
2684
|
+
|
|
2685
|
+
// virtual time: the clock only moves when the test advances it, and waits for the runs it starts
|
|
2686
|
+
describe('HeartbeatJobs', () => {
|
|
2687
|
+
test('beats every minute', async () => {
|
|
2688
|
+
const clock = createTestClock('2026-01-01T00:00:30Z')
|
|
2689
|
+
const app = await TestApp.create({
|
|
2690
|
+
metadata,
|
|
2691
|
+
plugins: [SchedulePlugin.configure({ clock })],
|
|
2692
|
+
only: [HeartbeatJobs],
|
|
2693
|
+
})
|
|
2694
|
+
try {
|
|
2695
|
+
await clock.advance('3m')
|
|
2696
|
+
|
|
2697
|
+
expect(app.get(HeartbeatJobs).beats).toBe(3)
|
|
2698
|
+
expect(app.get(HeartbeatJobs).last?.toISOString()).toBe('2026-01-01T00:03:00.000Z')
|
|
2699
|
+
const heartbeat = app.get(SchedulerRegistry).get('heartbeat')
|
|
2700
|
+
expect(heartbeat?.nextRun?.toISOString()).toBe('2026-01-01T00:04:00.000Z')
|
|
2701
|
+
} finally {
|
|
2702
|
+
await app.close()
|
|
2703
|
+
}
|
|
2704
|
+
})
|
|
2705
|
+
})
|
|
2706
|
+
`;
|
|
2707
|
+
}
|
|
2708
|
+
function testImports(flags, names) {
|
|
2709
|
+
return flags.bun ? `import { ${names} } from 'bun:test'` : `import { ${names} } from 'vitest'`;
|
|
2710
|
+
}
|
|
2711
|
+
function greetingServiceTest(flags) {
|
|
2712
|
+
const imports = [testImports(flags, "describe, expect, test")];
|
|
2713
|
+
if (flags.observability) imports.push("import { Logger } from '@znight/observability'");
|
|
2714
|
+
imports.push("import { TestBed } from '@znight/testing'");
|
|
2715
|
+
const configOption = flags.config ? "\n config: { app: { greeting: 'Hi' } }, // @Config('app') values" : "";
|
|
2716
|
+
const greeting = flags.config ? "Hi" : "Hello";
|
|
2717
|
+
const loggerTest = flags.observability ? `
|
|
2718
|
+
|
|
2719
|
+
test('logs every greeting', async () => {
|
|
2720
|
+
const { instance, mocks } = await TestBed.create(GreetingService, { metadata })
|
|
2721
|
+
|
|
2722
|
+
instance.greet('Grace')
|
|
2723
|
+
|
|
2724
|
+
// Logger is provided by ObservabilityPlugin at runtime: TestBed injects an auto-mock
|
|
2725
|
+
expect(mocks.get(Logger).debug).toHaveBeenCalledWith('Greeting', { name: 'Grace', excited: false })
|
|
2726
|
+
})` : "";
|
|
2727
|
+
return `${imports.join("\n")}
|
|
2728
|
+
|
|
2729
|
+
import { __ZNIGHT_METADATA__ as metadata } from '../src/.znight/container.generated.js'
|
|
2730
|
+
import { GreetingService } from '../src/app/greeting.service.js'
|
|
2731
|
+
|
|
2732
|
+
// TestBed builds GreetingService with its generated factory and auto-mocks its dependencies
|
|
2733
|
+
describe('GreetingService', () => {
|
|
2734
|
+
test('greets', async () => {
|
|
2735
|
+
const { instance } = await TestBed.create(GreetingService, {
|
|
2736
|
+
metadata,${configOption}
|
|
2737
|
+
})
|
|
2738
|
+
|
|
2739
|
+
expect(instance.greet('Ada')).toBe('${greeting}, Ada.')
|
|
2740
|
+
expect(instance.greet('Ada', true)).toBe('${greeting}, Ada!')
|
|
2741
|
+
})${loggerTest}
|
|
2742
|
+
})
|
|
2743
|
+
`;
|
|
2744
|
+
}
|
|
2745
|
+
function greetingControllerTest(flags) {
|
|
2746
|
+
const imports = [testImports(flags, "afterEach, beforeEach, describe, expect, test"), "import { HttpPlugin } from '@znight/http'"];
|
|
2747
|
+
if (flags.messaging) {
|
|
2748
|
+
imports.push("import { MessagingPlugin } from '@znight/messaging'");
|
|
2749
|
+
imports.push("import { memory } from '@znight/messaging/memory'");
|
|
2750
|
+
}
|
|
2751
|
+
if (flags.observability) imports.push("import { ObservabilityPlugin } from '@znight/observability'");
|
|
2752
|
+
imports.push("import { TestApp } from '@znight/testing'");
|
|
2753
|
+
const plugins = [];
|
|
2754
|
+
if (flags.observability) plugins.push("ObservabilityPlugin.configure({ logging: { level: 'silent' }, metrics: { server: false } })");
|
|
2755
|
+
if (flags.messaging) plugins.push("MessagingPlugin.configure({ driver: memory() })");
|
|
2756
|
+
plugins.push("HttpPlugin.configure({ port: 0 })");
|
|
2757
|
+
const pluginList = plugins.length === 1 ? `[${plugins[0]}]` : `[\n${plugins.map((plugin) => ` ${plugin},`).join("\n")}\n ]`;
|
|
2758
|
+
const greeting = "Hello";
|
|
2759
|
+
return `${imports.join("\n")}
|
|
2760
|
+
|
|
2761
|
+
import { __ZNIGHT_METADATA__ as metadata } from '../src/.znight/container.generated.js'
|
|
2762
|
+
|
|
2763
|
+
// TestApp starts the application (without signal handlers) and sends requests in memory
|
|
2764
|
+
describe('GreetingController', () => {
|
|
2765
|
+
let app: TestApp
|
|
2766
|
+
|
|
2767
|
+
beforeEach(async () => {
|
|
2768
|
+
app = await TestApp.create({
|
|
2769
|
+
metadata,
|
|
2770
|
+
plugins: ${pluginList},
|
|
2771
|
+
})
|
|
2772
|
+
})
|
|
2773
|
+
|
|
2774
|
+
afterEach(async () => {
|
|
2775
|
+
await app.close()
|
|
2776
|
+
})
|
|
2777
|
+
|
|
2778
|
+
test('GET /greetings/:name', async () => {
|
|
2779
|
+
const response = await app.inject({ method: 'GET', url: '/greetings/Ada?excited=true' })
|
|
2780
|
+
|
|
2781
|
+
expect(response.status).toBe(200)
|
|
2782
|
+
expect(response.json()).toEqual({ message: '${greeting}, Ada!' })
|
|
2783
|
+
})
|
|
2784
|
+
|
|
2785
|
+
test('POST /greetings validates the body', async () => {
|
|
2786
|
+
const created = await app.inject({ method: 'POST', url: '/greetings', body: { name: 'Grace' } })
|
|
2787
|
+
expect(created.status).toBe(201)
|
|
2788
|
+
expect(created.json()).toEqual({ message: '${greeting}, Grace.' })
|
|
2789
|
+
|
|
2790
|
+
const invalid = await app.inject({ method: 'POST', url: '/greetings', body: { name: '' } })
|
|
2791
|
+
expect(invalid.status).toBe(400)
|
|
2792
|
+
})
|
|
2793
|
+
})
|
|
2794
|
+
`;
|
|
2795
|
+
}
|
|
2796
|
+
function readme(options, flags) {
|
|
2797
|
+
const run = (script) => runScript(flags.pm, script);
|
|
2798
|
+
const znight = execBinary(flags.pm, "znight");
|
|
2799
|
+
const features = [
|
|
2800
|
+
`- **HTTP** with \`@znight/http\` (${flags.adapter === "node" ? "built-in node:http server" : `${flags.adapter} adapter`})`,
|
|
2801
|
+
flags.config ? "- **Configuration** with `@znight/config` (`src/app/app.config.ts`, `.env` files)" : void 0,
|
|
2802
|
+
flags.observability ? "- **Observability** with `@znight/observability` (structured logs, metrics, traces)" : void 0,
|
|
2803
|
+
flags.messaging ? "- **Messaging** with `@znight/messaging` (in-memory broker in `src/main.ts`)" : void 0,
|
|
2804
|
+
flags.schedule ? "- **Scheduled jobs** with `@znight/schedule` (`src/app/heartbeat.jobs.ts`)" : void 0,
|
|
2805
|
+
flags.openapi ? "- **OpenAPI** with `@znight/openapi` (`/openapi.json`, API reference at `/docs`, options in `src/api.ts`)" : void 0
|
|
2806
|
+
].filter((line) => line !== void 0);
|
|
2807
|
+
const tree = [
|
|
2808
|
+
"src/",
|
|
2809
|
+
"├─ main.ts # Application.create() + plugins + start()",
|
|
2810
|
+
flags.openapi ? "├─ api.ts # @OpenApiDocument: title, tags, security of openapi.json" : void 0,
|
|
2811
|
+
"├─ .znight/ # generated DI container (git-ignored)",
|
|
2812
|
+
"└─ app/",
|
|
2813
|
+
flags.config ? " ├─ app.config.ts # @Config: settings read from the environment" : void 0,
|
|
2814
|
+
flags.messaging ? " ├─ greeting.consumer.ts # @Consumer: handles the \"greetings\" queue" : void 0,
|
|
2815
|
+
" ├─ greeting.controller.ts # @Controller: /greetings routes",
|
|
2816
|
+
" ├─ greeting.schemas.ts # Zod schemas",
|
|
2817
|
+
" ├─ greeting.service.ts # @Injectable",
|
|
2818
|
+
flags.schedule ? " ├─ health.controller.ts # GET /health" : " └─ health.controller.ts # GET /health",
|
|
2819
|
+
flags.schedule ? " └─ heartbeat.jobs.ts # @Cron: a job every minute" : void 0,
|
|
2820
|
+
"test/ # TestBed (unit) and TestApp (HTTP) tests",
|
|
2821
|
+
"vite.config.ts # znight() plugin: generates the container, server build",
|
|
2822
|
+
"znight.config.ts # compiler options"
|
|
2823
|
+
].filter((line) => line !== void 0);
|
|
2824
|
+
const configNote = flags.config ? `
|
|
2825
|
+
## Configuration
|
|
2826
|
+
|
|
2827
|
+
\`AppConfig\` (\`src/app/app.config.ts\`) reads \`PORT\` and \`APP_GREETING\` from the environment or from
|
|
2828
|
+
\`.env\` / \`.env.local\` files. Missing or invalid values stop the application at boot with every
|
|
2829
|
+
problem listed.${flags.bun ? `
|
|
2830
|
+
|
|
2831
|
+
\`bunfig.toml\` turns Bun's own \`.env\` loading off (\`env = false\`): the files are read by
|
|
2832
|
+
\`@znight/config\`, the same way on Node.js and Bun.` : ""}
|
|
2833
|
+
` : "";
|
|
2834
|
+
const testNote = flags.bun ? "`bun test` regenerates the container first (`bunfig.toml` preloads `@znight/compiler/bun`)." : "Vitest uses `vite.config.ts`, so the `znight()` plugin regenerates the container before the tests.";
|
|
2835
|
+
const compilerNote = flags.bun ? "the `znight()` Vite plugin, the Bun preload of the tests and `znight generate`" : "the `znight()` Vite plugin (builds and Vitest) and `znight generate`";
|
|
2836
|
+
return `# ${options.name}
|
|
2837
|
+
|
|
2838
|
+
A [Znight](https://github.com/znight-org/znight) application: dependency injection resolved at **build
|
|
2839
|
+
time** — no \`reflect-metadata\`, errors reported by the compiler with their location.
|
|
2840
|
+
|
|
2841
|
+
${features.join("\n")}
|
|
2842
|
+
|
|
2843
|
+
## Scripts
|
|
2844
|
+
|
|
2845
|
+
| Command | Description |
|
|
2846
|
+
| --- | --- |
|
|
2847
|
+
| \`${run("dev")}\` | Build in watch mode and restart the application on every change |
|
|
2848
|
+
| \`${run("build")}\` | Production build (\`dist/main.js\`) |
|
|
2849
|
+
| \`${run("start")}\` | Run the production build |
|
|
2850
|
+
| \`${run("test")}\` | Run the tests |
|
|
2851
|
+
| \`${run("typecheck")}\` | Generate the container, then type-check with \`tsc\` |
|
|
2852
|
+
| \`${run("generate")}\` | Generate \`src/.znight/container.generated.ts\` (\`znight generate --check\` in CI) |
|
|
2853
|
+
${flags.openapi ? `| \`${run("openapi")}\` | Write \`openapi.json\` (commit it; \`${run("openapi:check")}\` in CI) |
|
|
2854
|
+
` : ""}
|
|
2855
|
+
\`\`\`sh
|
|
2856
|
+
${run("dev")}
|
|
2857
|
+
curl localhost:3000/health
|
|
2858
|
+
curl localhost:3000/greetings/Ada?excited=true
|
|
2859
|
+
curl -X POST localhost:3000/greetings -H 'content-type: application/json' -d '{"name":"Grace"}'${flags.openapi ? "\ncurl localhost:3000/openapi.json # the API reference is at http://localhost:3000/docs" : ""}
|
|
2860
|
+
\`\`\`
|
|
2861
|
+
|
|
2862
|
+
## Project structure
|
|
2863
|
+
|
|
2864
|
+
\`\`\`txt
|
|
2865
|
+
${tree.join("\n")}
|
|
2866
|
+
\`\`\`
|
|
2867
|
+
|
|
2868
|
+
Providers are discovered by the compiler from their decorators: there are no modules to declare.
|
|
2869
|
+
Its options live in \`znight.config.ts\`, read by ${compilerNote}.
|
|
2870
|
+
Add code with the generators:
|
|
2871
|
+
|
|
2872
|
+
\`\`\`sh
|
|
2873
|
+
${znight} g service users/user # src/users/user.service.ts
|
|
2874
|
+
${znight} g controller users/user # src/users/user.controller.ts${flags.schedule ? `\n${znight} g job reports/digest # src/reports/digest.jobs.ts` : ""}
|
|
2875
|
+
\`\`\`
|
|
2876
|
+
${configNote}
|
|
2877
|
+
## Tests
|
|
2878
|
+
|
|
2879
|
+
${testNote}
|
|
2880
|
+
|
|
2881
|
+
## Learn more
|
|
2882
|
+
|
|
2883
|
+
- [Znight documentation](https://github.com/znight-org/znight#readme)
|
|
2884
|
+
`;
|
|
2885
|
+
}
|
|
2886
|
+
// src/commands/new.ts
|
|
2887
|
+
/** `znight new` */
|
|
2888
|
+
const newCommand = defineCommand({
|
|
2889
|
+
meta: {
|
|
2890
|
+
name: "new",
|
|
2891
|
+
description: "Create a new Znight project",
|
|
2892
|
+
usage: "[name] [options]",
|
|
2893
|
+
aliases: ["create", "init"],
|
|
2894
|
+
examples: [
|
|
2895
|
+
"znight new my-service",
|
|
2896
|
+
"znight new my-service --yes --pm pnpm --adapter fastify --features config,observability,messaging",
|
|
2897
|
+
"znight new my-worker --yes --features config,schedule",
|
|
2898
|
+
"znight new . --yes --no-install"
|
|
2899
|
+
]
|
|
2900
|
+
},
|
|
2901
|
+
args: {
|
|
2902
|
+
name: {
|
|
2903
|
+
type: "positional",
|
|
2904
|
+
required: false,
|
|
2905
|
+
description: "Project name (npm package name), or \".\" for the current directory"
|
|
2906
|
+
},
|
|
2907
|
+
yes: {
|
|
2908
|
+
type: "boolean",
|
|
2909
|
+
alias: "y",
|
|
2910
|
+
description: "Do not ask questions: use the flags and the defaults"
|
|
2911
|
+
},
|
|
2912
|
+
pm: {
|
|
2913
|
+
type: "enum",
|
|
2914
|
+
options: [
|
|
2915
|
+
"bun",
|
|
2916
|
+
"npm",
|
|
2917
|
+
"pnpm",
|
|
2918
|
+
"yarn"
|
|
2919
|
+
],
|
|
2920
|
+
description: "Package manager (default: the one running the CLI, bun under Bun, else npm)"
|
|
2921
|
+
},
|
|
2922
|
+
adapter: {
|
|
2923
|
+
type: "enum",
|
|
2924
|
+
options: [
|
|
2925
|
+
"node",
|
|
2926
|
+
"fastify",
|
|
2927
|
+
"express",
|
|
2928
|
+
"hono"
|
|
2929
|
+
],
|
|
2930
|
+
description: "HTTP adapter (default: node, the built-in node:http server)"
|
|
2931
|
+
},
|
|
2932
|
+
features: {
|
|
2933
|
+
type: "string",
|
|
2934
|
+
valueHint: "list",
|
|
2935
|
+
description: "Comma-separated features: config, observability, messaging, schedule, openapi, or \"all\" / \"none\" (default: config,observability)"
|
|
2936
|
+
},
|
|
2937
|
+
install: {
|
|
2938
|
+
type: "boolean",
|
|
2939
|
+
default: true,
|
|
2940
|
+
description: "Do not install the dependencies"
|
|
2941
|
+
},
|
|
2942
|
+
git: {
|
|
2943
|
+
type: "boolean",
|
|
2944
|
+
default: true,
|
|
2945
|
+
description: "Do not initialise a git repository"
|
|
2946
|
+
},
|
|
2947
|
+
dir: {
|
|
2948
|
+
type: "string",
|
|
2949
|
+
valueHint: "path",
|
|
2950
|
+
description: "Target directory (default: ./<name>)"
|
|
2951
|
+
},
|
|
2952
|
+
force: {
|
|
2953
|
+
type: "boolean",
|
|
2954
|
+
description: "Write into a non-empty directory (existing files are overwritten)"
|
|
2955
|
+
}
|
|
2956
|
+
},
|
|
2957
|
+
async run({ args: options, cli, rawArgs }) {
|
|
2958
|
+
const prompts = cli.interactive && options.yes !== true ? await cli.services.loadPrompts() : void 0;
|
|
2959
|
+
prompts?.intro(cli.colors.bold(`Create a Znight project ${cli.colors.dim(`v${cli.version}`)}`));
|
|
2960
|
+
let name = options.name;
|
|
2961
|
+
if (name === void 0 || name === "") {
|
|
2962
|
+
if (prompts === void 0) throw new UsageError("Missing project name", ["znight new <name> (or \".\" to use the current directory)"]);
|
|
2963
|
+
name = await prompts.text({
|
|
2964
|
+
message: "Project name?",
|
|
2965
|
+
placeholder: "my-service",
|
|
2966
|
+
validate: (value) => value === "." ? void 0 : validatePackageName(value.trim())
|
|
2967
|
+
});
|
|
2968
|
+
}
|
|
2969
|
+
name = name.trim();
|
|
2970
|
+
const inPlace = name === ".";
|
|
2971
|
+
if (inPlace) name = toPackageName(basename(cli.cwd));
|
|
2972
|
+
const problem = validatePackageName(name);
|
|
2973
|
+
if (problem !== void 0) {
|
|
2974
|
+
const candidate = toPackageName(name);
|
|
2975
|
+
throw new UsageError(`Invalid project name "${name}": ${problem}`, candidate !== "" && validatePackageName(candidate) === void 0 ? [`try "${candidate}"`] : []);
|
|
2976
|
+
}
|
|
2977
|
+
const directory = resolve(cli.cwd, options.dir ?? (inPlace ? "." : unscopedName(name)));
|
|
2978
|
+
await checkTarget(directory, cli, prompts, options.force === true);
|
|
2979
|
+
const packageManager = options.pm ?? await prompts?.select({
|
|
2980
|
+
message: "Package manager?",
|
|
2981
|
+
choices: PACKAGE_MANAGERS.map((pm) => ({
|
|
2982
|
+
value: pm,
|
|
2983
|
+
label: pm
|
|
2984
|
+
})),
|
|
2985
|
+
initialValue: detectPackageManager(cli.env, cli.runtime.name === "bun")
|
|
2986
|
+
}) ?? detectPackageManager(cli.env, cli.runtime.name === "bun");
|
|
2987
|
+
const adapter = options.adapter ?? await prompts?.select({
|
|
2988
|
+
message: "HTTP adapter?",
|
|
2989
|
+
choices: HTTP_ADAPTERS.map((value) => ({
|
|
2990
|
+
value,
|
|
2991
|
+
label: ADAPTER_LABELS[value]
|
|
2992
|
+
})),
|
|
2993
|
+
initialValue: "node"
|
|
2994
|
+
}) ?? "node";
|
|
2995
|
+
const features = await chooseFeatures(options.features, prompts);
|
|
2996
|
+
const install = prompts === void 0 || hasFlag(rawArgs, "install") ? options.install !== false : await prompts.confirm({
|
|
2997
|
+
message: `Install the dependencies with ${packageManager}?`,
|
|
2998
|
+
initialValue: true
|
|
2999
|
+
});
|
|
3000
|
+
const git = prompts === void 0 || hasFlag(rawArgs, "git") ? options.git !== false : await prompts.confirm({
|
|
3001
|
+
message: "Initialise a git repository?",
|
|
3002
|
+
initialValue: true
|
|
3003
|
+
});
|
|
3004
|
+
const files = renderProject({
|
|
3005
|
+
name,
|
|
3006
|
+
packageManager,
|
|
3007
|
+
adapter,
|
|
3008
|
+
features,
|
|
3009
|
+
znightVersion: cli.version
|
|
3010
|
+
});
|
|
3011
|
+
writeProject(directory, files);
|
|
3012
|
+
const where = displayPath(directory, cli.cwd);
|
|
3013
|
+
const summary = `${cli.colors.bold(name)} (${packageManager}, ${adapter}${features.length > 0 ? `, ${features.join(", ")}` : ""})`;
|
|
3014
|
+
if (prompts === void 0) cli.reporter.success(`Created ${summary} in ${where}`);
|
|
3015
|
+
else prompts.note(files.map((file) => file.path).join("\n"), `Created ${summary} in ${where}`);
|
|
3016
|
+
if (git) await runGit(directory, cli, prompts);
|
|
3017
|
+
let installed = false;
|
|
3018
|
+
if (install) installed = await runInstall(directory, packageManager, cli, prompts);
|
|
3019
|
+
const steps = [
|
|
3020
|
+
...inPlace && options.dir === void 0 ? [] : [`cd ${where}`],
|
|
3021
|
+
...installed ? [] : [installCommand(packageManager).join(" ")],
|
|
3022
|
+
runScript(packageManager, "dev")
|
|
3023
|
+
];
|
|
3024
|
+
if (prompts === void 0) {
|
|
3025
|
+
cli.reporter.log();
|
|
3026
|
+
cli.reporter.log(cli.colors.bold("Next steps:"));
|
|
3027
|
+
for (const step of steps) cli.reporter.log(` ${cli.colors.cyan(step)}`);
|
|
3028
|
+
cli.reporter.log();
|
|
3029
|
+
} else prompts.outro(`Next steps:\n${steps.map((step) => ` ${cli.colors.cyan(step)}`).join("\n")}`);
|
|
3030
|
+
if (install && !installed) throw new CliError("The project was created but installing the dependencies failed", { hints: [`run "${installCommand(packageManager).join(" ")}" in ${where}`] });
|
|
3031
|
+
return 0;
|
|
3032
|
+
}
|
|
3033
|
+
});
|
|
3034
|
+
async function checkTarget(directory, cli, prompts, force) {
|
|
3035
|
+
const state = targetState(directory);
|
|
3036
|
+
const where = displayPath(directory, cli.cwd);
|
|
3037
|
+
if (state === "file") throw new CliError(`${where} exists and is not a directory`);
|
|
3038
|
+
if (state !== "not-empty" || force) return;
|
|
3039
|
+
if (prompts !== void 0) {
|
|
3040
|
+
if (await prompts.confirm({
|
|
3041
|
+
message: `${where} is not empty. Write the project into it anyway (existing files are overwritten)?`,
|
|
3042
|
+
initialValue: false
|
|
3043
|
+
})) return;
|
|
3044
|
+
}
|
|
3045
|
+
throw new CliError(`${where} is not empty`, { hints: ["choose another name or --dir, or pass --force to write into it anyway"] });
|
|
3046
|
+
}
|
|
3047
|
+
async function chooseFeatures(value, prompts) {
|
|
3048
|
+
if (value !== void 0) {
|
|
3049
|
+
const parsed = parseFeatures(value);
|
|
3050
|
+
if ("unknown" in parsed) throw new UsageError(`Unknown feature${parsed.unknown.length > 1 ? "s" : ""}: ${parsed.unknown.join(", ")}`, [`available features: ${FEATURES.join(", ")} (or "all", "none")`]);
|
|
3051
|
+
return parsed.features;
|
|
3052
|
+
}
|
|
3053
|
+
if (prompts === void 0) return [...DEFAULT_FEATURES];
|
|
3054
|
+
const selected = await prompts.multiselect({
|
|
3055
|
+
message: "Features? (space to toggle)",
|
|
3056
|
+
choices: FEATURES.map((feature) => ({
|
|
3057
|
+
value: feature,
|
|
3058
|
+
label: FEATURE_LABELS[feature]
|
|
3059
|
+
})),
|
|
3060
|
+
initialValues: DEFAULT_FEATURES
|
|
3061
|
+
});
|
|
3062
|
+
return FEATURES.filter((feature) => selected.includes(feature));
|
|
3063
|
+
}
|
|
3064
|
+
async function runGit(directory, cli, prompts) {
|
|
3065
|
+
const outcome = await initGit(cli.services.processes, directory, cli.env);
|
|
3066
|
+
const message = {
|
|
3067
|
+
initialized: "Initialised a git repository",
|
|
3068
|
+
"inside-repository": "Already inside a git repository: skipped git init",
|
|
3069
|
+
unavailable: "git is not available: skipped git init",
|
|
3070
|
+
failed: "git init failed: skipped"
|
|
3071
|
+
}[outcome];
|
|
3072
|
+
if (outcome === "initialized") {
|
|
3073
|
+
if (prompts === void 0) cli.reporter.success(message);
|
|
3074
|
+
} else cli.reporter.warn(message);
|
|
3075
|
+
}
|
|
3076
|
+
async function runInstall(directory, packageManager, cli, prompts) {
|
|
3077
|
+
const command = installCommand(packageManager).join(" ");
|
|
3078
|
+
const spinner = prompts?.spinner();
|
|
3079
|
+
if (spinner === void 0) cli.reporter.info(`Installing the dependencies (${command})...`);
|
|
3080
|
+
else spinner.start(`Installing the dependencies (${command})`);
|
|
3081
|
+
const result = await installDependencies(cli.services.processes, directory, packageManager, cli.env);
|
|
3082
|
+
if (result.code === 0 && result.error === void 0) {
|
|
3083
|
+
if (spinner === void 0) cli.reporter.success("Installed the dependencies");
|
|
3084
|
+
else spinner.stop("Installed the dependencies");
|
|
3085
|
+
return true;
|
|
3086
|
+
}
|
|
3087
|
+
const reason = result.error === void 0 ? `${command} exited with code ${result.code ?? result.signal}` : `${packageManager} could not be started (${result.error.message})`;
|
|
3088
|
+
if (spinner === void 0) cli.reporter.error(reason);
|
|
3089
|
+
else spinner.stop(reason, true);
|
|
3090
|
+
const output = `${result.stdout}${result.stderr}`.trim();
|
|
3091
|
+
if (output !== "") cli.reporter.logError(output.split("\n").slice(-30).join("\n"));
|
|
3092
|
+
return false;
|
|
3093
|
+
}
|
|
3094
|
+
// src/commands/openapi.ts
|
|
3095
|
+
/** Values of a repeatable string option (`-o a -o b`, `--output=a`, `--output b,c`). */
|
|
3096
|
+
function repeatedOption(rawArgs, name, alias) {
|
|
3097
|
+
const values = [];
|
|
3098
|
+
for (let index = 0; index < rawArgs.length; index++) {
|
|
3099
|
+
const token = rawArgs[index] ?? "";
|
|
3100
|
+
let value;
|
|
3101
|
+
if (token === `--${name}` || token === `-${alias}`) value = rawArgs[++index];
|
|
3102
|
+
else if (token.startsWith(`--${name}=`)) value = token.slice(name.length + 3);
|
|
3103
|
+
else if (token.startsWith(`-${alias}=`)) value = token.slice(alias.length + 2);
|
|
3104
|
+
if (value === void 0) continue;
|
|
3105
|
+
for (const part of value.split(",")) if (part.trim() !== "") values.push(part.trim());
|
|
3106
|
+
}
|
|
3107
|
+
return values;
|
|
3108
|
+
}
|
|
3109
|
+
/** `znight openapi` */
|
|
3110
|
+
const openapiCommand = defineCommand({
|
|
3111
|
+
meta: {
|
|
3112
|
+
name: "openapi",
|
|
3113
|
+
description: "Emit the OpenAPI document of the application (openapi.json, --check in CI)",
|
|
3114
|
+
usage: "[options]",
|
|
3115
|
+
examples: [
|
|
3116
|
+
"znight openapi # writes openapi.json at the project root",
|
|
3117
|
+
"znight openapi -o docs/openapi.yaml # YAML, chosen by the extension (needs yaml)",
|
|
3118
|
+
"znight openapi --check # CI: exit 1 when the file is missing or stale",
|
|
3119
|
+
"znight openapi --prefix /api # servers default when @OpenApiDocument declares none",
|
|
3120
|
+
"znight openapi --runtime bun # load the modules with Bun"
|
|
3121
|
+
],
|
|
3122
|
+
details: [
|
|
3123
|
+
"The application is compiled like \"znight generate\" (compile errors stop everything), then its",
|
|
3124
|
+
"generated module is loaded in a child process (Bun: the compiler preload; Node.js: a Vite server)",
|
|
3125
|
+
"and @znight/openapi assembles the document. Nothing is instantiated. Outputs are only written",
|
|
3126
|
+
"when they change. Requires @znight/openapi in the project."
|
|
3127
|
+
].join("\n")
|
|
3128
|
+
},
|
|
3129
|
+
args: {
|
|
3130
|
+
output: {
|
|
3131
|
+
type: "string",
|
|
3132
|
+
alias: "o",
|
|
3133
|
+
valueHint: "file",
|
|
3134
|
+
description: "Output file, repeatable (.yaml / .yml write YAML). Default: openapi.json"
|
|
3135
|
+
},
|
|
3136
|
+
check: {
|
|
3137
|
+
type: "boolean",
|
|
3138
|
+
description: "Do not write: exit with code 1 when an output is missing or stale"
|
|
3139
|
+
},
|
|
3140
|
+
prefix: {
|
|
3141
|
+
type: "string",
|
|
3142
|
+
valueHint: "path",
|
|
3143
|
+
description: "HTTP prefix: the servers default (/api)"
|
|
3144
|
+
},
|
|
3145
|
+
runtime: {
|
|
3146
|
+
type: "enum",
|
|
3147
|
+
options: [
|
|
3148
|
+
"auto",
|
|
3149
|
+
"node",
|
|
3150
|
+
"bun"
|
|
3151
|
+
],
|
|
3152
|
+
description: "Runtime loading the application modules (default: auto, the one running the CLI)"
|
|
3153
|
+
},
|
|
3154
|
+
converters: {
|
|
3155
|
+
type: "string",
|
|
3156
|
+
valueHint: "file",
|
|
3157
|
+
description: "Module whose default export is a SchemaConverter[] (custom schema libraries)"
|
|
3158
|
+
},
|
|
3159
|
+
root: {
|
|
3160
|
+
type: "string",
|
|
3161
|
+
valueHint: "dir",
|
|
3162
|
+
description: "Project root (default: the current directory)"
|
|
3163
|
+
},
|
|
3164
|
+
config: {
|
|
3165
|
+
type: "string",
|
|
3166
|
+
valueHint: "file",
|
|
3167
|
+
description: "Vite config file (default: vite.config.*)"
|
|
3168
|
+
},
|
|
3169
|
+
"config-loader": {
|
|
3170
|
+
type: "enum",
|
|
3171
|
+
options: [
|
|
3172
|
+
"bundle",
|
|
3173
|
+
"runner",
|
|
3174
|
+
"native"
|
|
3175
|
+
],
|
|
3176
|
+
description: "How Vite loads the config file (default: bundle)"
|
|
3177
|
+
}
|
|
3178
|
+
},
|
|
3179
|
+
async run({ args, cli, rawArgs }) {
|
|
3180
|
+
const root = resolveRoot(cli, args.root);
|
|
3181
|
+
const outputs = repeatedOption(rawArgs, "output", "o");
|
|
3182
|
+
const prefix = parseHttpPrefix(args.prefix, "prefix");
|
|
3183
|
+
const compiler = await cli.services.loadCompiler(root);
|
|
3184
|
+
const configLoader = args["config-loader"];
|
|
3185
|
+
const options = await compilerOptions(cli, compiler, root, args.config, configLoader);
|
|
3186
|
+
const openapi = await cli.services.loadOpenApi(root);
|
|
3187
|
+
const started = performance.now();
|
|
3188
|
+
let result;
|
|
3189
|
+
try {
|
|
3190
|
+
result = await openapi.emitOpenApi({
|
|
3191
|
+
root,
|
|
3192
|
+
output: outputs.length > 0 ? outputs : ["openapi.json"],
|
|
3193
|
+
...args.check === true ? { check: true } : {},
|
|
3194
|
+
...prefix === void 0 ? {} : { prefix },
|
|
3195
|
+
...args.runtime === void 0 ? {} : { runtime: args.runtime },
|
|
3196
|
+
...args.converters === void 0 ? {} : { converters: args.converters },
|
|
3197
|
+
...args.config === void 0 ? {} : { config: args.config },
|
|
3198
|
+
...configLoader === void 0 ? {} : { configLoader },
|
|
3199
|
+
compilerOptions: options
|
|
3200
|
+
});
|
|
3201
|
+
} catch (error) {
|
|
3202
|
+
const hints = error?.hints;
|
|
3203
|
+
throw new CliError(`OpenAPI emission failed: ${errorMessage(error)}`, {
|
|
3204
|
+
cause: error,
|
|
3205
|
+
hints: Array.isArray(hints) ? hints : []
|
|
3206
|
+
});
|
|
3207
|
+
}
|
|
3208
|
+
printDiagnostics(cli, compiler, result.diagnostics, root);
|
|
3209
|
+
if (!result.ok || hasErrors(result.diagnostics)) {
|
|
3210
|
+
const errors = result.diagnostics.filter((diagnostic) => diagnostic.severity === "error").length;
|
|
3211
|
+
cli.reporter.error(`OpenAPI emission failed: the application does not compile (${plural(errors, "error")})`);
|
|
3212
|
+
return 1;
|
|
3213
|
+
}
|
|
3214
|
+
for (const warning of result.warnings) cli.reporter.warn(`${warning.code}: ${warning.message}`);
|
|
3215
|
+
const elapsed = formatDuration(performance.now() - started);
|
|
3216
|
+
const operations = Object.values(result.document?.paths ?? {}).reduce((count, item) => count + Object.keys(item).length, 0);
|
|
3217
|
+
const stats = cli.colors.dim(`(${plural(operations, "operation")}, ${result.runtime}, ${elapsed})`);
|
|
3218
|
+
if (args.check === true) {
|
|
3219
|
+
for (const file of result.staleFiles) {
|
|
3220
|
+
const shown = displayPath(file, cli.cwd);
|
|
3221
|
+
if (existsSync(file)) cli.reporter.error(`${shown} is out of date`, ["run \"znight openapi\" and commit the result"]);
|
|
3222
|
+
else cli.reporter.error(`${shown} is missing`, ["run \"znight openapi\" and commit the result"]);
|
|
3223
|
+
}
|
|
3224
|
+
if (result.stale) return 1;
|
|
3225
|
+
cli.reporter.success(`${result.outputs.map((file) => cli.colors.bold(displayPath(file, cli.cwd))).join(", ")} up to date ${stats}`);
|
|
3226
|
+
return 0;
|
|
3227
|
+
}
|
|
3228
|
+
for (const file of result.outputs) {
|
|
3229
|
+
const shown = cli.colors.bold(displayPath(file, cli.cwd));
|
|
3230
|
+
cli.reporter.success(`${result.written.includes(file) ? "Wrote" : "Up to date:"} ${shown} ${stats}`);
|
|
3231
|
+
}
|
|
3232
|
+
return 0;
|
|
3233
|
+
}
|
|
3234
|
+
});
|
|
3235
|
+
function printDiagnostics(cli, compiler, diagnostics, root) {
|
|
3236
|
+
if (diagnostics.length === 0) return;
|
|
3237
|
+
const text = compiler.formatDiagnostics(diagnostics, {
|
|
3238
|
+
color: cli.colors.isColorSupported,
|
|
3239
|
+
cwd: root,
|
|
3240
|
+
summary: false
|
|
3241
|
+
});
|
|
3242
|
+
cli.reporter.logError(text.trimEnd());
|
|
3243
|
+
cli.reporter.logError();
|
|
3244
|
+
}
|
|
3245
|
+
/** Every schematic, in display order. */
|
|
3246
|
+
const SCHEMATICS = [
|
|
3247
|
+
{
|
|
3248
|
+
name: "service",
|
|
3249
|
+
aliases: ["s"],
|
|
3250
|
+
description: "An @Injectable() provider",
|
|
3251
|
+
suffix: "service",
|
|
3252
|
+
render: (names) => `import { Injectable } from '@znight/core'
|
|
3253
|
+
|
|
3254
|
+
@Injectable()
|
|
3255
|
+
export class ${names.className} {
|
|
3256
|
+
// Declare dependencies as constructor parameters: the compiler resolves them from their types.
|
|
3257
|
+
// constructor(private readonly repository: ${names.pascal}Repository) {}
|
|
3258
|
+
}
|
|
3259
|
+
`
|
|
3260
|
+
},
|
|
3261
|
+
{
|
|
3262
|
+
name: "controller",
|
|
3263
|
+
aliases: ["co"],
|
|
3264
|
+
description: "An HTTP @Controller() with two routes",
|
|
3265
|
+
suffix: "controller",
|
|
3266
|
+
render: (names) => `import { Controller, Get, Param } from '@znight/http'
|
|
3267
|
+
|
|
3268
|
+
@Controller('/${names.kebab}')
|
|
3269
|
+
export class ${names.className} {
|
|
3270
|
+
/** GET /${names.kebab} */
|
|
3271
|
+
@Get()
|
|
3272
|
+
list(): unknown[] {
|
|
3273
|
+
return []
|
|
3274
|
+
}
|
|
3275
|
+
|
|
3276
|
+
/** GET /${names.kebab}/:id */
|
|
3277
|
+
@Get('/:id')
|
|
3278
|
+
find(@Param('id') id: string): { id: string } {
|
|
3279
|
+
return { id }
|
|
3280
|
+
}
|
|
3281
|
+
}
|
|
3282
|
+
`
|
|
3283
|
+
},
|
|
3284
|
+
{
|
|
3285
|
+
name: "consumer",
|
|
3286
|
+
aliases: ["cs"],
|
|
3287
|
+
description: "A message @Consumer() (@znight/messaging)",
|
|
3288
|
+
suffix: "consumer",
|
|
3289
|
+
render: (names, context) => context.zod ? `import { Consumer, Message, OnMessage, Retry } from '@znight/messaging'
|
|
3290
|
+
import { z } from 'zod'
|
|
3291
|
+
|
|
3292
|
+
export const ${names.pascal}Message = z.object({
|
|
3293
|
+
id: z.string(),
|
|
3294
|
+
})
|
|
3295
|
+
export type ${names.pascal}Message = z.output<typeof ${names.pascal}Message>
|
|
3296
|
+
|
|
3297
|
+
/**
|
|
3298
|
+
* Consumes the "${names.kebab}" queue.
|
|
3299
|
+
* Delivery is at-least-once: make the handler idempotent. Throw to retry, throw a NonRetryableError
|
|
3300
|
+
* to dead-letter the message right away.
|
|
3301
|
+
*/
|
|
3302
|
+
@Consumer('${names.kebab}')
|
|
3303
|
+
export class ${names.className} {
|
|
3304
|
+
@OnMessage()
|
|
3305
|
+
@Retry({ attempts: 3, backoff: 'exponential', delay: 1000 })
|
|
3306
|
+
handle(@Message(${names.pascal}Message) message: ${names.pascal}Message): void {
|
|
3307
|
+
console.log('received', message.id)
|
|
3308
|
+
}
|
|
3309
|
+
}
|
|
3310
|
+
` : `import { Consumer, Ctx, type InboundMessage, OnMessage, Retry } from '@znight/messaging'
|
|
3311
|
+
|
|
3312
|
+
/**
|
|
3313
|
+
* Consumes the "${names.kebab}" queue.
|
|
3314
|
+
* Delivery is at-least-once: make the handler idempotent. Throw to retry, throw a NonRetryableError
|
|
3315
|
+
* to dead-letter the message right away.
|
|
3316
|
+
*/
|
|
3317
|
+
@Consumer('${names.kebab}')
|
|
3318
|
+
export class ${names.className} {
|
|
3319
|
+
@OnMessage()
|
|
3320
|
+
@Retry({ attempts: 3, backoff: 'exponential', delay: 1000 })
|
|
3321
|
+
handle(@Ctx() message: InboundMessage): void {
|
|
3322
|
+
console.log('received', message.id, message.payload)
|
|
3323
|
+
}
|
|
3324
|
+
}
|
|
3325
|
+
`,
|
|
3326
|
+
hint: () => "consumers run with MessagingPlugin.configure({ driver }) in src/main.ts"
|
|
3327
|
+
},
|
|
3328
|
+
{
|
|
3329
|
+
name: "config",
|
|
3330
|
+
aliases: ["cf"],
|
|
3331
|
+
description: "A @Config() class read from the environment (@znight/config)",
|
|
3332
|
+
suffix: "config",
|
|
3333
|
+
render: (names) => `import { Config } from '@znight/config'
|
|
3334
|
+
|
|
3335
|
+
/**
|
|
3336
|
+
* Settings read from the ${constantCase(names.camel)}_* environment variables (or .env files).
|
|
3337
|
+
* Validated at boot by ConfigPlugin: the type of a property selects its coercion, an initializer
|
|
3338
|
+
* makes it optional.
|
|
3339
|
+
*/
|
|
3340
|
+
@Config('${names.camel}')
|
|
3341
|
+
export class ${names.className} {
|
|
3342
|
+
/** ${constantCase(names.camel)}_ENABLED */
|
|
3343
|
+
enabled = true
|
|
3344
|
+
}
|
|
3345
|
+
`,
|
|
3346
|
+
hint: (names) => `inject ${names.className} anywhere; values are loaded by ConfigPlugin.configure() in src/main.ts`
|
|
3347
|
+
},
|
|
3348
|
+
{
|
|
3349
|
+
name: "guard",
|
|
3350
|
+
aliases: ["gu"],
|
|
3351
|
+
description: "An HTTP guard (CanActivate)",
|
|
3352
|
+
suffix: "guard",
|
|
3353
|
+
render: (names) => `import type { CanActivate, HttpContext } from '@znight/http'
|
|
3354
|
+
|
|
3355
|
+
import { Injectable } from '@znight/core'
|
|
3356
|
+
|
|
3357
|
+
@Injectable()
|
|
3358
|
+
export class ${names.className} implements CanActivate {
|
|
3359
|
+
// The route decisions this guard enforces (createRouteDecorator groups, see "Route decisions" in
|
|
3360
|
+
// the @znight/http README): the boot fails when a route of a required group has no guard
|
|
3361
|
+
// enforcing its decision.
|
|
3362
|
+
// static readonly enforces = [Public, Roles]
|
|
3363
|
+
|
|
3364
|
+
canActivate(ctx: HttpContext): boolean | Promise<boolean> {
|
|
3365
|
+
// Return false (403 Forbidden) or throw an HttpException (UnauthorizedException...) to reject
|
|
3366
|
+
// the request, e.g. return ctx.header('authorization') !== undefined
|
|
3367
|
+
return true
|
|
3368
|
+
}
|
|
3369
|
+
}
|
|
3370
|
+
`,
|
|
3371
|
+
hint: (names) => `use it with @UseGuard(${names.className}) on a controller or a route, or HttpPlugin.configure({ guards: [${names.className}] })`
|
|
3372
|
+
},
|
|
3373
|
+
{
|
|
3374
|
+
name: "interceptor",
|
|
3375
|
+
aliases: ["itc"],
|
|
3376
|
+
description: "An HTTP interceptor (HttpInterceptor), or a method interceptor with --aop",
|
|
3377
|
+
suffix: "interceptor",
|
|
3378
|
+
render: (names, context) => context.aop ? `import { Injectable, type MethodInterceptor, type MethodInvocation } from '@znight/core'
|
|
3379
|
+
|
|
3380
|
+
@Injectable()
|
|
3381
|
+
export class ${names.className} implements MethodInterceptor {
|
|
3382
|
+
intercept(invocation: MethodInvocation): unknown {
|
|
3383
|
+
// Runs around every intercepted call. For a synchronous method, return synchronously.
|
|
3384
|
+
return invocation.proceed()
|
|
3385
|
+
}
|
|
3386
|
+
}
|
|
3387
|
+
` : `import type { CallHandler, HttpContext, HttpInterceptor } from '@znight/http'
|
|
3388
|
+
|
|
3389
|
+
import { Injectable } from '@znight/core'
|
|
3390
|
+
|
|
3391
|
+
@Injectable()
|
|
3392
|
+
export class ${names.className} implements HttpInterceptor {
|
|
3393
|
+
async intercept(ctx: HttpContext, next: CallHandler): Promise<unknown> {
|
|
3394
|
+
const started = performance.now()
|
|
3395
|
+
try {
|
|
3396
|
+
// the handler result (before serialisation): return it, transform it or replace it
|
|
3397
|
+
return await next()
|
|
3398
|
+
} finally {
|
|
3399
|
+
ctx.setHeader('server-timing', \`app;dur=\${(performance.now() - started).toFixed(1)}\`)
|
|
3400
|
+
}
|
|
3401
|
+
}
|
|
3402
|
+
}
|
|
3403
|
+
`,
|
|
3404
|
+
hint: (names, context) => context.aop ? `use it with @Intercept(${names.className}) on any provider method` : `use it with @UseInterceptor(${names.className}) on a controller or a route, or HttpPlugin.configure({ interceptors: [${names.className}] })`
|
|
3405
|
+
},
|
|
3406
|
+
{
|
|
3407
|
+
name: "factory",
|
|
3408
|
+
aliases: ["f"],
|
|
3409
|
+
description: "A @Factory() whose @Provides() methods provide objects you do not own",
|
|
3410
|
+
suffix: "factory",
|
|
3411
|
+
render: (names) => `import { Factory, Provides } from '@znight/core'
|
|
3412
|
+
|
|
3413
|
+
/** Replace with the type you provide: a database pool, an SDK client... */
|
|
3414
|
+
export class ${names.pascal}Client {
|
|
3415
|
+
close(): void {}
|
|
3416
|
+
}
|
|
3417
|
+
|
|
3418
|
+
/**
|
|
3419
|
+
* @Provides methods create providers for classes you cannot decorate. Their parameters are injected,
|
|
3420
|
+
* their explicit return type is the provided type (Promise<T> for an async initialisation).
|
|
3421
|
+
*/
|
|
3422
|
+
@Factory()
|
|
3423
|
+
export class ${names.className} {
|
|
3424
|
+
@Provides({ dispose: 'close' })
|
|
3425
|
+
create${names.pascal}Client(): ${names.pascal}Client {
|
|
3426
|
+
return new ${names.pascal}Client()
|
|
3427
|
+
}
|
|
3428
|
+
}
|
|
3429
|
+
`
|
|
3430
|
+
},
|
|
3431
|
+
{
|
|
3432
|
+
name: "job",
|
|
3433
|
+
aliases: ["j"],
|
|
3434
|
+
description: "Scheduled jobs: @Cron, @Interval, @Timeout methods (@znight/schedule)",
|
|
3435
|
+
suffix: "jobs",
|
|
3436
|
+
render: (names) => `import { Cron, type JobContext } from '@znight/schedule'
|
|
3437
|
+
|
|
3438
|
+
/**
|
|
3439
|
+
* Scheduled jobs. A class with @Cron, @Interval or @Timeout methods becomes a singleton provider
|
|
3440
|
+
* (no decorator needed): declare its dependencies as constructor parameters.
|
|
3441
|
+
*/
|
|
3442
|
+
export class ${names.className} {
|
|
3443
|
+
/** Every day at 03:00 UTC (set timeZone to follow a local clock). A run still running is skipped. */
|
|
3444
|
+
@Cron('0 3 * * *', { name: '${names.qualified}.daily' })
|
|
3445
|
+
daily(ctx: JobContext): void {
|
|
3446
|
+
// checkpoint: throws when the run is aborted (shutdown, maxDuration or a lost lock)
|
|
3447
|
+
ctx.signal.throwIfAborted()
|
|
3448
|
+
}
|
|
3449
|
+
}
|
|
3450
|
+
`,
|
|
3451
|
+
hint: () => "jobs run with SchedulePlugin.configure() in src/main.ts; test them with createTestClock() from @znight/schedule/testing"
|
|
3452
|
+
}
|
|
3453
|
+
];
|
|
3454
|
+
/** Finds a schematic by name or alias. */
|
|
3455
|
+
function findSchematic(name) {
|
|
3456
|
+
const key = name.toLowerCase();
|
|
3457
|
+
return SCHEMATICS.find((schematic) => schematic.name === key || schematic.aliases.includes(key));
|
|
3458
|
+
}
|
|
3459
|
+
/**
|
|
3460
|
+
* Parses `users/user-profile`, `UserProfile`, `user_profile.service.ts`... A trailing suffix
|
|
3461
|
+
* matching the schematic (`user-service`, `UserService`, `user.service`) is dropped.
|
|
3462
|
+
*
|
|
3463
|
+
* @throws Error with a user-facing message when the name is invalid.
|
|
3464
|
+
*/
|
|
3465
|
+
function parseSchematicName(input, schematic) {
|
|
3466
|
+
const segments = input.trim().split(/[\\/]+/).filter((segment) => segment !== "");
|
|
3467
|
+
if (segments.length === 0) throw new Error("the name cannot be empty");
|
|
3468
|
+
if (isAbsolute(input) || segments.some((segment) => segment === "." || segment === "..")) throw new Error(`invalid name "${input}": use a path relative to src/ without "." or ".." segments`);
|
|
3469
|
+
let words = kebabCase((segments.at(-1) ?? "").replace(/\.[cm]?tsx?$/, ""));
|
|
3470
|
+
for (const suffix of [`-${schematic.suffix}`, `.${schematic.suffix}`]) if (words.endsWith(suffix) && words !== suffix.slice(1)) words = words.slice(0, -suffix.length);
|
|
3471
|
+
if (words === "") throw new Error(`invalid name "${input}"`);
|
|
3472
|
+
if (!/^[a-z]/.test(words)) throw new Error(`invalid name "${input}": it must start with a letter`);
|
|
3473
|
+
const directories = segments.slice(0, -1).map((segment) => kebabCase(segment));
|
|
3474
|
+
if (directories.some((directory) => directory === "")) throw new Error(`invalid name "${input}"`);
|
|
3475
|
+
const pascal = pascalCase(words);
|
|
3476
|
+
return {
|
|
3477
|
+
directories,
|
|
3478
|
+
names: {
|
|
3479
|
+
kebab: words,
|
|
3480
|
+
pascal,
|
|
3481
|
+
camel: camelCase(words),
|
|
3482
|
+
className: `${pascal}${pascalCase(schematic.suffix)}`,
|
|
3483
|
+
qualified: qualifiedName(directories, words)
|
|
3484
|
+
}
|
|
3485
|
+
};
|
|
3486
|
+
}
|
|
3487
|
+
/** `reports` + `digest` → `reports.digest`; the directory named after the element is left out. */
|
|
3488
|
+
function qualifiedName(directories, kebab) {
|
|
3489
|
+
return [...directories.at(-1) === kebab ? directories.slice(0, -1) : directories, kebab].join(".");
|
|
3490
|
+
}
|
|
3491
|
+
/**
|
|
3492
|
+
* Directories of `directory` relative to `<root>/src`, or to `root` outside `src/`, kebab-cased
|
|
3493
|
+
* (`undefined` outside the project).
|
|
3494
|
+
*/
|
|
3495
|
+
function sourceDirectories(root, directory) {
|
|
3496
|
+
for (const base of [join(root, "src"), root]) {
|
|
3497
|
+
const path = relative(base, directory);
|
|
3498
|
+
if (isAbsolute(path) || path === ".." || /^\.\.[\\/]/.test(path)) continue;
|
|
3499
|
+
return path.split(/[\\/]+/).map((segment) => kebabCase(segment)).filter((segment) => segment !== "");
|
|
3500
|
+
}
|
|
3501
|
+
}
|
|
3502
|
+
/**
|
|
3503
|
+
* Computes the file of a schematic:
|
|
3504
|
+
*
|
|
3505
|
+
* | Input | File |
|
|
3506
|
+
* | ---------------------------- | ------------------------------------- |
|
|
3507
|
+
* | `service user` | `src/user/user.service.ts` |
|
|
3508
|
+
* | `service users/user` | `src/users/user.service.ts` |
|
|
3509
|
+
* | `service user --flat` | `src/user.service.ts` |
|
|
3510
|
+
* | `service user --path lib/x` | `lib/x/user.service.ts` |
|
|
3511
|
+
*/
|
|
3512
|
+
function planSchematic(options) {
|
|
3513
|
+
const { schematic, context } = options;
|
|
3514
|
+
const parsed = parseSchematicName(options.name, schematic);
|
|
3515
|
+
const base = options.path ?? join(options.root, "src");
|
|
3516
|
+
let directories = [...parsed.directories];
|
|
3517
|
+
if (directories.length === 0 && options.path === void 0 && options.flat !== true) directories = [parsed.names.kebab];
|
|
3518
|
+
const file = normalize(join(base, ...directories, `${parsed.names.kebab}.${schematic.suffix}.ts`));
|
|
3519
|
+
const located = options.path === void 0 ? void 0 : sourceDirectories(options.root, dirname(file));
|
|
3520
|
+
const names = located === void 0 ? parsed.names : {
|
|
3521
|
+
...parsed.names,
|
|
3522
|
+
qualified: qualifiedName(located, parsed.names.kebab)
|
|
3523
|
+
};
|
|
3524
|
+
return {
|
|
3525
|
+
schematic,
|
|
3526
|
+
names,
|
|
3527
|
+
file,
|
|
3528
|
+
content: schematic.render(names, context),
|
|
3529
|
+
hint: schematic.hint?.(names, context)
|
|
3530
|
+
};
|
|
3531
|
+
}
|
|
3532
|
+
// src/commands/schematic.ts
|
|
3533
|
+
function schematicList(colors) {
|
|
3534
|
+
return table(SCHEMATICS.map((schematic) => [`${schematic.name} (${schematic.aliases.join(", ")})`, schematic.description]), colors).join("\n");
|
|
3535
|
+
}
|
|
3536
|
+
/** `znight g` */
|
|
3537
|
+
const schematicCommand = defineCommand({
|
|
3538
|
+
meta: {
|
|
3539
|
+
name: "g",
|
|
3540
|
+
description: "Generate a service, controller, consumer, config, guard, interceptor, factory or job",
|
|
3541
|
+
usage: "<schematic> <name> [options]",
|
|
3542
|
+
aliases: ["generate <schematic>"],
|
|
3543
|
+
details: (colors) => `${colors.bold("Schematics")}\n${schematicList(colors)}\n\nFiles are named <name>.<schematic>.ts (kebab-case), classes <Name><Schematic> (PascalCase).`,
|
|
3544
|
+
examples: [
|
|
3545
|
+
"znight g service users/user # src/users/user.service.ts (UserService)",
|
|
3546
|
+
"znight g controller order # src/order/order.controller.ts (OrderController)",
|
|
3547
|
+
"znight g guard auth --flat # src/auth.guard.ts (AuthGuard)",
|
|
3548
|
+
"znight g interceptor timing --aop # a method interceptor (@Intercept)",
|
|
3549
|
+
"znight g consumer orders --path src/messaging",
|
|
3550
|
+
"znight g job digest # src/digest/digest.jobs.ts (DigestJobs)"
|
|
3551
|
+
]
|
|
3552
|
+
},
|
|
3553
|
+
args: {
|
|
3554
|
+
schematic: {
|
|
3555
|
+
type: "positional",
|
|
3556
|
+
required: false,
|
|
3557
|
+
description: "What to generate (see Schematics below)"
|
|
3558
|
+
},
|
|
3559
|
+
name: {
|
|
3560
|
+
type: "positional",
|
|
3561
|
+
required: false,
|
|
3562
|
+
description: "Name, optionally prefixed with directories relative to src/ (users/user-profile)"
|
|
3563
|
+
},
|
|
3564
|
+
path: {
|
|
3565
|
+
type: "string",
|
|
3566
|
+
valueHint: "dir",
|
|
3567
|
+
description: "Directory of the file (default: src/<name>/)"
|
|
3568
|
+
},
|
|
3569
|
+
flat: {
|
|
3570
|
+
type: "boolean",
|
|
3571
|
+
description: "Do not create a directory named after the element"
|
|
3572
|
+
},
|
|
3573
|
+
force: {
|
|
3574
|
+
type: "boolean",
|
|
3575
|
+
alias: "f",
|
|
3576
|
+
description: "Overwrite an existing file"
|
|
3577
|
+
},
|
|
3578
|
+
"dry-run": {
|
|
3579
|
+
type: "boolean",
|
|
3580
|
+
alias: "d",
|
|
3581
|
+
description: "Print the file instead of writing it"
|
|
3582
|
+
},
|
|
3583
|
+
aop: {
|
|
3584
|
+
type: "boolean",
|
|
3585
|
+
description: "interceptor: a method interceptor (@Intercept) instead of an HTTP one"
|
|
3586
|
+
},
|
|
3587
|
+
root: {
|
|
3588
|
+
type: "string",
|
|
3589
|
+
valueHint: "dir",
|
|
3590
|
+
description: "Project root (default: the nearest package.json)"
|
|
3591
|
+
}
|
|
3592
|
+
},
|
|
3593
|
+
run({ args, cli }) {
|
|
3594
|
+
if (args.schematic === void 0 || args.schematic === "") throw new UsageError("Missing <schematic>", [`available schematics:\n${schematicList(cli.colors)}`]);
|
|
3595
|
+
const schematic = findSchematic(args.schematic);
|
|
3596
|
+
if (schematic === void 0) {
|
|
3597
|
+
const candidate = suggest(args.schematic, SCHEMATICS.map((item) => item.name));
|
|
3598
|
+
throw new UsageError(`Unknown schematic "${args.schematic}"`, [...candidate === void 0 ? [] : [`did you mean "${candidate}"?`], `available schematics: ${SCHEMATICS.map((item) => item.name).join(", ")}`]);
|
|
3599
|
+
}
|
|
3600
|
+
if (args.name === void 0 || args.name === "") throw new UsageError("Missing <name>", [`znight g ${schematic.name} <name>, e.g. znight g ${schematic.name} users/user`]);
|
|
3601
|
+
const root = args.root === void 0 ? findProjectRoot(cli.cwd) : resolveRoot(cli, args.root);
|
|
3602
|
+
const manifest = readManifest(root);
|
|
3603
|
+
const zod = manifest.dependencies?.["zod"] !== void 0 || manifest.devDependencies?.["zod"] !== void 0;
|
|
3604
|
+
let plan;
|
|
3605
|
+
try {
|
|
3606
|
+
plan = planSchematic({
|
|
3607
|
+
schematic,
|
|
3608
|
+
name: args.name,
|
|
3609
|
+
root,
|
|
3610
|
+
path: args.path === void 0 ? void 0 : resolve(cli.cwd, args.path),
|
|
3611
|
+
flat: args.flat === true,
|
|
3612
|
+
context: {
|
|
3613
|
+
zod,
|
|
3614
|
+
aop: args.aop === true
|
|
3615
|
+
}
|
|
3616
|
+
});
|
|
3617
|
+
} catch (error) {
|
|
3618
|
+
throw new UsageError(error instanceof Error ? error.message : String(error));
|
|
3619
|
+
}
|
|
3620
|
+
const file = displayPath(plan.file, cli.cwd);
|
|
3621
|
+
if (args["dry-run"] === true) {
|
|
3622
|
+
cli.reporter.info(`Would create ${cli.colors.bold(file)} (${plan.names.className})`);
|
|
3623
|
+
cli.reporter.log();
|
|
3624
|
+
cli.reporter.log(plan.content);
|
|
3625
|
+
return 0;
|
|
3626
|
+
}
|
|
3627
|
+
const exists = existsSync(plan.file);
|
|
3628
|
+
if (exists && args.force !== true) throw new CliError(`${file} already exists`, { hints: ["pass --force to overwrite it"] });
|
|
3629
|
+
mkdirSync(dirname(plan.file), { recursive: true });
|
|
3630
|
+
writeFileSync(plan.file, plan.content);
|
|
3631
|
+
cli.reporter.success(`${exists ? "Overwrote" : "Created"} ${cli.colors.bold(file)} ${cli.colors.dim(`(${plan.names.className})`)}`);
|
|
3632
|
+
if (plan.hint !== void 0) cli.reporter.log(` ${cli.colors.dim(plan.hint)}`);
|
|
3633
|
+
return 0;
|
|
3634
|
+
}
|
|
3635
|
+
});
|
|
3636
|
+
// src/commands/start.ts
|
|
3637
|
+
/** Entries tried when `--entry` is not given. */
|
|
3638
|
+
const DEFAULT_ENTRIES = ["dist/main.js", "dist/main.mjs"];
|
|
3639
|
+
/** `znight start` */
|
|
3640
|
+
const startCommand = defineCommand({
|
|
3641
|
+
meta: {
|
|
3642
|
+
name: "start",
|
|
3643
|
+
description: "Run the production build",
|
|
3644
|
+
usage: "[options] [-- <application arguments>]",
|
|
3645
|
+
examples: [
|
|
3646
|
+
"znight start",
|
|
3647
|
+
"znight start --runtime bun",
|
|
3648
|
+
"znight start --entry dist/worker.js -- --verbose"
|
|
3649
|
+
],
|
|
3650
|
+
details: "NODE_ENV defaults to \"production\". Signals (SIGINT, SIGTERM) reach the application, which stops gracefully."
|
|
3651
|
+
},
|
|
3652
|
+
args: {
|
|
3653
|
+
root: {
|
|
3654
|
+
type: "string",
|
|
3655
|
+
valueHint: "dir",
|
|
3656
|
+
description: "Project root (default: the current directory)"
|
|
3657
|
+
},
|
|
3658
|
+
entry: {
|
|
3659
|
+
type: "string",
|
|
3660
|
+
valueHint: "file",
|
|
3661
|
+
description: "File to run (default: dist/main.js)"
|
|
3662
|
+
},
|
|
3663
|
+
runtime: {
|
|
3664
|
+
type: "enum",
|
|
3665
|
+
options: [
|
|
3666
|
+
"auto",
|
|
3667
|
+
"node",
|
|
3668
|
+
"bun"
|
|
3669
|
+
],
|
|
3670
|
+
default: "auto",
|
|
3671
|
+
description: "Runtime of the application (auto: the runtime running the CLI)"
|
|
3672
|
+
}
|
|
3673
|
+
},
|
|
3674
|
+
async run({ args, cli, passthrough }) {
|
|
3675
|
+
const root = resolveRoot(cli, args.root);
|
|
3676
|
+
const candidates = args.entry === void 0 ? DEFAULT_ENTRIES : [args.entry];
|
|
3677
|
+
const entry = candidates.map((file) => resolve(root, file)).find((file) => existsSync(file));
|
|
3678
|
+
if (entry === void 0) throw new CliError(`${displayPath(resolve(root, candidates[0] ?? ""), cli.cwd)} not found`, { hints: ["build the application first: znight build"] });
|
|
3679
|
+
const runtime = resolveRuntime(args.runtime, cli.runtime);
|
|
3680
|
+
const executable = runtimeExecutable(runtime, cli.runtime, cli.env);
|
|
3681
|
+
if (executable === void 0) throw new CliError(`Cannot find "${runtime}" in PATH`, { hints: ["install it, or choose another --runtime"] });
|
|
3682
|
+
const argv = [
|
|
3683
|
+
...runtimeArguments(runtime),
|
|
3684
|
+
entry,
|
|
3685
|
+
...passthrough
|
|
3686
|
+
];
|
|
3687
|
+
const env = {
|
|
3688
|
+
...cli.env,
|
|
3689
|
+
NODE_ENV: cli.env["NODE_ENV"] ?? "production"
|
|
3690
|
+
};
|
|
3691
|
+
const { processes } = cli.services;
|
|
3692
|
+
if (processes.execve !== void 0) {
|
|
3693
|
+
const clean = {};
|
|
3694
|
+
for (const [key, value] of Object.entries(env)) if (value !== void 0) clean[key] = value;
|
|
3695
|
+
processes.execve(executable, [executable, ...argv], clean, root);
|
|
3696
|
+
return 0;
|
|
3697
|
+
}
|
|
3698
|
+
const child = processes.spawn(executable, argv, {
|
|
3699
|
+
cwd: root,
|
|
3700
|
+
env,
|
|
3701
|
+
detached: true
|
|
3702
|
+
});
|
|
3703
|
+
const forward = (signal) => () => {
|
|
3704
|
+
child.kill(signal);
|
|
3705
|
+
};
|
|
3706
|
+
const listeners = [
|
|
3707
|
+
"SIGINT",
|
|
3708
|
+
"SIGTERM",
|
|
3709
|
+
"SIGHUP"
|
|
3710
|
+
].map((signal) => [signal, forward(signal)]);
|
|
3711
|
+
for (const [signal, listener] of listeners) cli.signals.on(signal, listener);
|
|
3712
|
+
try {
|
|
3713
|
+
const status = await child.exited;
|
|
3714
|
+
if (status.error !== void 0) throw new CliError(`Cannot start ${runtime}: ${status.error.message}`);
|
|
3715
|
+
return exitCodeOf(status);
|
|
3716
|
+
} finally {
|
|
3717
|
+
for (const [signal, listener] of listeners) cli.signals.off(signal, listener);
|
|
3718
|
+
}
|
|
3719
|
+
}
|
|
3720
|
+
});
|
|
3721
|
+
// src/main.ts
|
|
3722
|
+
/** Every command, in help order. */
|
|
3723
|
+
const COMMANDS = [
|
|
3724
|
+
newCommand,
|
|
3725
|
+
generateCommand,
|
|
3726
|
+
schematicCommand,
|
|
3727
|
+
devCommand,
|
|
3728
|
+
buildCommand,
|
|
3729
|
+
startCommand,
|
|
3730
|
+
openapiCommand
|
|
3731
|
+
];
|
|
3732
|
+
/** Finds a command by name or alias. */
|
|
3733
|
+
function findCommand(name) {
|
|
3734
|
+
return COMMANDS.find((command) => command.meta.name === name || command.meta.aliases?.includes(name) === true);
|
|
3735
|
+
}
|
|
3736
|
+
/** Extracts `--color`, `--no-color` and `--debug` (anywhere before `--`). */
|
|
3737
|
+
function extractGlobalOptions(argv) {
|
|
3738
|
+
const separator = argv.indexOf("--");
|
|
3739
|
+
const before = separator === -1 ? argv : argv.slice(0, separator);
|
|
3740
|
+
const after = separator === -1 ? [] : argv.slice(separator);
|
|
3741
|
+
const result = {
|
|
3742
|
+
debug: false,
|
|
3743
|
+
rest: []
|
|
3744
|
+
};
|
|
3745
|
+
for (const arg of before) if (arg === "--color") result.color = true;
|
|
3746
|
+
else if (arg === "--no-color") result.color = false;
|
|
3747
|
+
else if (arg === "--debug") result.debug = true;
|
|
3748
|
+
else result.rest.push(arg);
|
|
3749
|
+
result.rest.push(...after);
|
|
3750
|
+
return result;
|
|
3751
|
+
}
|
|
3752
|
+
/**
|
|
3753
|
+
* Runs the CLI with `argv` (without the executable and script, i.e. `process.argv.slice(2)`).
|
|
3754
|
+
* Resolves with the exit code; never throws.
|
|
3755
|
+
*/
|
|
3756
|
+
async function main(argv, options = {}) {
|
|
3757
|
+
const globals = extractGlobalOptions(argv);
|
|
3758
|
+
const cli = createContext({
|
|
3759
|
+
...options,
|
|
3760
|
+
...globals.color === void 0 ? {} : { color: globals.color },
|
|
3761
|
+
...globals.debug ? { debug: true } : {}
|
|
3762
|
+
});
|
|
3763
|
+
try {
|
|
3764
|
+
return await dispatch(globals.rest, cli);
|
|
3765
|
+
} catch (error) {
|
|
3766
|
+
return reportError(error, cli);
|
|
3767
|
+
}
|
|
3768
|
+
}
|
|
3769
|
+
async function dispatch(argv, cli) {
|
|
3770
|
+
const separator = argv.indexOf("--");
|
|
3771
|
+
const args = separator === -1 ? [...argv] : argv.slice(0, separator);
|
|
3772
|
+
const passthrough = separator === -1 ? [] : argv.slice(separator + 1);
|
|
3773
|
+
const [first, ...rest] = args;
|
|
3774
|
+
if (first === void 0 || first === "-h" || first === "--help") {
|
|
3775
|
+
cli.reporter.log(renderRootHelp(cli));
|
|
3776
|
+
return ExitCode.Ok;
|
|
3777
|
+
}
|
|
3778
|
+
if (first === "-v" || first === "--version" || first === "version") {
|
|
3779
|
+
cli.reporter.log(cli.version);
|
|
3780
|
+
return ExitCode.Ok;
|
|
3781
|
+
}
|
|
3782
|
+
if (first === "help") {
|
|
3783
|
+
const target = rest[0] === void 0 ? void 0 : findCommand(rest[0]);
|
|
3784
|
+
if (rest[0] !== void 0 && target === void 0) throw unknownCommand(rest[0]);
|
|
3785
|
+
cli.reporter.log(target === void 0 ? renderRootHelp(cli) : renderCommandHelp(target, cli.colors));
|
|
3786
|
+
return ExitCode.Ok;
|
|
3787
|
+
}
|
|
3788
|
+
if (first.startsWith("-")) throw new UsageError(`Unknown option ${first}`, ["options go after the command: znight <command> [options]"]);
|
|
3789
|
+
let command = findCommand(first);
|
|
3790
|
+
let commandArgs = rest;
|
|
3791
|
+
if (command === void 0 && first.startsWith("generate:")) {
|
|
3792
|
+
command = schematicCommand;
|
|
3793
|
+
commandArgs = [first.slice(9), ...rest];
|
|
3794
|
+
} else if (command === generateCommand && rest[0] !== void 0 && !rest[0].startsWith("-")) command = schematicCommand;
|
|
3795
|
+
if (command === void 0) throw unknownCommand(first);
|
|
3796
|
+
if (commandArgs.includes("-h") || commandArgs.includes("--help")) {
|
|
3797
|
+
cli.reporter.log(renderCommandHelp(command, cli.colors));
|
|
3798
|
+
return ExitCode.Ok;
|
|
3799
|
+
}
|
|
3800
|
+
try {
|
|
3801
|
+
return await command.execute(commandArgs, passthrough, cli);
|
|
3802
|
+
} catch (error) {
|
|
3803
|
+
if (error instanceof UsageError) throw new UsageError(error.message, [...error.hints, `run "znight ${command.meta.name} --help" for the usage`]);
|
|
3804
|
+
throw error;
|
|
3805
|
+
}
|
|
3806
|
+
}
|
|
3807
|
+
function unknownCommand(name) {
|
|
3808
|
+
const candidate = suggest(name, COMMANDS.flatMap((command) => [command.meta.name, ...command.meta.aliases ?? []]));
|
|
3809
|
+
return new UsageError(`Unknown command "${name}"`, [...candidate === void 0 ? [] : [`did you mean "znight ${candidate}"?`], "run \"znight --help\" for the list of commands"]);
|
|
3810
|
+
}
|
|
3811
|
+
/** The help of `znight` (list of commands). */
|
|
3812
|
+
function renderRootHelp(cli) {
|
|
3813
|
+
const { colors } = cli;
|
|
3814
|
+
const commands = table([
|
|
3815
|
+
["new <name>", newCommand.meta.description],
|
|
3816
|
+
["generate", "Generate the DI container (--check in CI, --watch)"],
|
|
3817
|
+
["g <schematic> <name>", "Generate a service, controller, consumer, config, guard, interceptor, factory or job"],
|
|
3818
|
+
["dev", "Build in watch mode, restart the application on every change"],
|
|
3819
|
+
["build", buildCommand.meta.description],
|
|
3820
|
+
["start", startCommand.meta.description],
|
|
3821
|
+
["openapi", openapiCommand.meta.description]
|
|
3822
|
+
], colors);
|
|
3823
|
+
const options = table([
|
|
3824
|
+
["-h, --help", "Show the help (also: znight <command> --help)"],
|
|
3825
|
+
["-v, --version", "Show the version"],
|
|
3826
|
+
["--color, --no-color", "Force or disable colors (NO_COLOR and FORCE_COLOR are honoured)"],
|
|
3827
|
+
["--debug", "Print the stack trace of unexpected errors (or ZNIGHT_DEBUG=1)"]
|
|
3828
|
+
], colors);
|
|
3829
|
+
return [
|
|
3830
|
+
`${colors.bold("znight")} ${colors.dim(`v${cli.version}`)} ${colors.dim("—")} compile-time dependency injection for TypeScript`,
|
|
3831
|
+
"",
|
|
3832
|
+
colors.bold("Usage"),
|
|
3833
|
+
` ${colors.cyan("znight <command> [options]")}`,
|
|
3834
|
+
"",
|
|
3835
|
+
colors.bold("Commands"),
|
|
3836
|
+
...commands,
|
|
3837
|
+
"",
|
|
3838
|
+
colors.bold("Global options"),
|
|
3839
|
+
...options,
|
|
3840
|
+
"",
|
|
3841
|
+
`Run ${colors.cyan("znight <command> --help")} for the options of a command.`,
|
|
3842
|
+
""
|
|
3843
|
+
].join("\n");
|
|
3844
|
+
}
|
|
3845
|
+
function reportError(error, cli) {
|
|
3846
|
+
if (error instanceof CancelledError) {
|
|
3847
|
+
cli.reporter.warn(error.message);
|
|
3848
|
+
return error.exitCode;
|
|
3849
|
+
}
|
|
3850
|
+
if (error instanceof CliError) {
|
|
3851
|
+
cli.reporter.error(error.message, error.hints);
|
|
3852
|
+
if (cli.debug && error.cause !== void 0) cli.reporter.logError(stackOf(error.cause));
|
|
3853
|
+
return error.exitCode;
|
|
3854
|
+
}
|
|
3855
|
+
cli.reporter.error(`Unexpected error: ${errorMessage(error)}`);
|
|
3856
|
+
if (cli.debug) cli.reporter.logError(stackOf(error));
|
|
3857
|
+
else cli.reporter.logError(cli.colors.dim(" run with --debug (or ZNIGHT_DEBUG=1) for the stack trace"));
|
|
3858
|
+
return ExitCode.Failure;
|
|
3859
|
+
}
|
|
3860
|
+
function stackOf(error) {
|
|
3861
|
+
return error instanceof Error && error.stack !== void 0 ? error.stack : String(error);
|
|
3862
|
+
}
|
|
3863
|
+
export { CancelledError as _, planSchematic as a, UsageError as b, FEATURES as c, AppSupervisor as d, createContext as f, defineCommand as g, loadCompiler as h, findSchematic as i, HTTP_ADAPTERS as l, adaptCompilerModule as m, main as n, renderProject as o, cliVersion as p, SCHEMATICS as r, DEFAULT_FEATURES as s, COMMANDS as t, PACKAGE_MANAGERS as u, CliError as v, ExitCode as y };
|
|
3864
|
+
|
|
3865
|
+
//# sourceMappingURL=main-BWsiUsxN.mjs.map
|