@nectar-js/nectar 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.
@@ -0,0 +1,2225 @@
1
+ import { T as version, _ as parseSegment, a as MANIFEST_FILE, b as loadModule, c as writeManifest, d as checkDeclaredRoute, f as compileComponents, g as formatSegment, i as pluginGraph, o as stableStringify, r as applyPlugins, s as toManifest, t as PluginError, v as enableModuleReloading, x as Diagnostics, y as invalidateModuleGraph } from "./plugins-CGvM19v9.js";
2
+ import { a as checkIntents, c as configFor, i as scopeKey, n as syncCommands, r as RegistrationError, s as ConfigError, t as UnsafeSyncError, u as validateConfig } from "./registration-CaE0QBT6.js";
3
+ import { i as createLogger, l as createSignals, n as createRuntime, r as manifestFiles, s as HandlerLoadError, t as LoginError, u as loadManifest } from "./runtime-CZJeZvSL.js";
4
+ import path from "node:path";
5
+ import { createHash } from "node:crypto";
6
+ import { existsSync, mkdirSync, readdirSync, rmSync, watch, writeFileSync } from "node:fs";
7
+ import { Events } from "discord.js";
8
+ import { ApplicationCommandOptionType, ApplicationCommandType } from "discord-api-types/v10";
9
+ import { parseArgs, styleText } from "node:util";
10
+ //#region src/registration/targets.ts
11
+ /**
12
+ * Where this environment registers commands. Development and test use `dev.guilds` only, so a
13
+ * project without dev guilds registers nothing until some are configured. Production uses
14
+ * `commands.target`, global by default.
15
+ */
16
+ function registrationScopes(config, env) {
17
+ if (env !== "production") return (config.dev?.guilds ?? []).map((guild) => ({ guild }));
18
+ const target = config.commands?.target ?? "global";
19
+ return target === "global" ? ["global"] : target.map((guild) => ({ guild }));
20
+ }
21
+ //#endregion
22
+ //#region src/typegen/emit.ts
23
+ const TYPES_FILE = "types.d.ts";
24
+ const PACKAGE = "@nectar-js/nectar";
25
+ const COMMAND_TYPE$1 = {
26
+ [ApplicationCommandType.ChatInput]: "chatInput",
27
+ [ApplicationCommandType.User]: "user",
28
+ [ApplicationCommandType.Message]: "message"
29
+ };
30
+ const OPTION_TYPE$1 = {
31
+ [ApplicationCommandOptionType.String]: "string",
32
+ [ApplicationCommandOptionType.Integer]: "integer",
33
+ [ApplicationCommandOptionType.Number]: "number",
34
+ [ApplicationCommandOptionType.Boolean]: "boolean",
35
+ [ApplicationCommandOptionType.User]: "user",
36
+ [ApplicationCommandOptionType.Channel]: "channel",
37
+ [ApplicationCommandOptionType.Role]: "role",
38
+ [ApplicationCommandOptionType.Mentionable]: "mentionable",
39
+ [ApplicationCommandOptionType.Attachment]: "attachment"
40
+ };
41
+ /**
42
+ * Renders `types.d.ts`: a module augmentation of `@nectar-js/nectar` that lists every route with
43
+ * its parameters, options, and the context its middleware chain adds. Handler and middleware
44
+ * types are pulled in with `import()` type queries relative to `outDir`. Whatever a plugin's
45
+ * `types` hook returns is appended after the augmentation.
46
+ */
47
+ function toTypes(graph, outDir, plugins = []) {
48
+ const absoluteOut = path.resolve(outDir);
49
+ const middlewareAliases = /* @__PURE__ */ new Map();
50
+ const aliasFor = (file) => {
51
+ let alias = middlewareAliases.get(file);
52
+ if (alias === void 0) {
53
+ alias = `M${middlewareAliases.size}`;
54
+ middlewareAliases.set(file, alias);
55
+ }
56
+ return alias;
57
+ };
58
+ const contextOf = (route) => {
59
+ const files = graph.chains.get(route.file)?.middleware ?? [];
60
+ return files.length === 0 ? "{}" : files.map(aliasFor).join(" & ");
61
+ };
62
+ const commands = [];
63
+ for (const command of [...graph.commands].sort((a, b) => a.name.localeCompare(b.name))) for (const [key, route] of Object.entries(command.handlers)) {
64
+ const options = Object.entries(optionsAt(command.payload, key)).map(([name, type]) => `${quote(name)}: ${quote(type)}`).join("; ");
65
+ commands.push(` ${quote(route.path)}: { type: ${quote(COMMAND_TYPE$1[command.type] ?? "chatInput")}; options: {${options === "" ? "" : ` ${options} `}}; context: ${contextOf(route)} };`);
66
+ }
67
+ commands.sort();
68
+ const components = [...graph.components].sort((a, b) => a.path.localeCompare(b.path)).map((route) => {
69
+ const params = route.params.map((name) => `${quote(name)}: ${name === route.catchAll ? "string[]" : "string"}`).join("; ");
70
+ const kind = route.kind === "select" ? `select:${route.selectKind}` : route.kind;
71
+ return ` ${quote(route.path)}: { kind: ${quote(kind)}; params: {${params === "" ? "" : ` ${params} `}}; context: ${contextOf(route)} };`;
72
+ });
73
+ const events = graph.events.map((e) => ` ${quote(e.name)}: true;`);
74
+ const autocomplete = graph.autocomplete.map((a) => ` ${quote(a.route.path)}: ${a.options.map(quote).join(" | ") || "never"};`).sort();
75
+ const aliases = [...middlewareAliases].map(([file, alias]) => `type ${alias} = MiddlewareExtension<typeof import(${quote(importPath(absoluteOut, file))})>;`);
76
+ return [
77
+ "// Generated by Nectar. Do not edit.",
78
+ `import type { MiddlewareExtension } from ${quote(PACKAGE)};`,
79
+ ...aliases.length === 0 ? [] : ["", ...aliases],
80
+ "",
81
+ `declare module ${quote(PACKAGE)} {`,
82
+ " interface NectarRoutes {",
83
+ " commands: {",
84
+ ...commands.map(indent$1),
85
+ " };",
86
+ " components: {",
87
+ ...components.map(indent$1),
88
+ " };",
89
+ " events: {",
90
+ ...events.map(indent$1),
91
+ " };",
92
+ " autocomplete: {",
93
+ ...autocomplete.map(indent$1),
94
+ " };",
95
+ " }",
96
+ "}",
97
+ "",
98
+ "export {};",
99
+ "",
100
+ ...pluginTypes(graph, plugins)
101
+ ].join("\n");
102
+ }
103
+ function pluginTypes(graph, plugins) {
104
+ const lines = [];
105
+ for (const plugin of plugins) {
106
+ if (plugin.types === void 0) continue;
107
+ let extra;
108
+ try {
109
+ extra = plugin.types(pluginGraph(graph));
110
+ } catch (error) {
111
+ const detail = error instanceof Error ? error.message : String(error);
112
+ throw new PluginError(plugin.name, `types failed: ${detail}`);
113
+ }
114
+ if (typeof extra !== "string" || extra.trim() === "") continue;
115
+ lines.push(`// From plugin ${JSON.stringify(plugin.name)}`, extra.trim(), "");
116
+ }
117
+ return lines;
118
+ }
119
+ function writeTypes(graph, outDir, plugins = []) {
120
+ mkdirSync(outDir, { recursive: true });
121
+ const file = path.join(outDir, TYPES_FILE);
122
+ writeFileSync(file, toTypes(graph, outDir, plugins));
123
+ return file;
124
+ }
125
+ /** Option name to option type name for one handler position of a command payload. */
126
+ function optionsAt(payload, key) {
127
+ let options = payload.options;
128
+ for (const part of key === "" ? [] : key.split("/")) options = options?.find((o) => o.name === part)?.options;
129
+ const out = {};
130
+ for (const option of options ?? []) {
131
+ const type = option.type === void 0 ? void 0 : OPTION_TYPE$1[option.type];
132
+ if (option.name !== void 0 && type !== void 0) out[option.name] = type;
133
+ }
134
+ return out;
135
+ }
136
+ /** `../app/middleware.ts` becomes `../app/middleware.js`, which NodeNext resolves back to the source. */
137
+ function importPath(fromDir, file) {
138
+ const mapped = path.relative(fromDir, file).split(path.sep).join("/").replace(/\.mts$/, ".mjs").replace(/\.ts$/, ".js");
139
+ return mapped.startsWith(".") ? mapped : `./${mapped}`;
140
+ }
141
+ function quote(value) {
142
+ return JSON.stringify(value);
143
+ }
144
+ function indent$1(line) {
145
+ return ` ${line}`;
146
+ }
147
+ //#endregion
148
+ //#region src/autocomplete/compile.ts
149
+ /**
150
+ * Links every `autocomplete.ts` to its sibling command and checks that each named export
151
+ * matches an option declared with `autocomplete: true`, and that no such option is left
152
+ * without a handler.
153
+ */
154
+ async function compileAutocomplete(table, commands) {
155
+ const diagnostics = new Diagnostics();
156
+ const routes = table.routes.filter((r) => r.kind === "autocomplete");
157
+ const targets = /* @__PURE__ */ new Map();
158
+ for (const command of commands) for (const [key, route] of Object.entries(command.handlers)) targets.set(route.id, {
159
+ command: route,
160
+ options: autocompleteOptions(command, key)
161
+ });
162
+ const results = await Promise.all(routes.map(async (route) => {
163
+ const target = targets.get(route.id);
164
+ if (target === void 0) {
165
+ diagnostics.error("autocomplete-without-command", `autocomplete.ts needs a command.ts in the same directory. None was found for "${route.path}".`, {
166
+ file: route.file,
167
+ route: route.id
168
+ });
169
+ return null;
170
+ }
171
+ let module;
172
+ try {
173
+ module = await loadModule(route.file);
174
+ } catch (error) {
175
+ diagnostics.error("module-load-failed", `Could not import this file: ${error instanceof Error ? error.message : String(error)}`, {
176
+ file: route.file,
177
+ route: route.id
178
+ });
179
+ return null;
180
+ }
181
+ const exported = Object.keys(module).filter((name) => name !== "default").sort();
182
+ let ok = true;
183
+ for (const name of exported) {
184
+ if (typeof module[name] !== "function") {
185
+ diagnostics.error("autocomplete-export-not-function", `Export "${name}" must be a function that answers autocomplete for the "${name}" option.`, {
186
+ file: route.file,
187
+ route: route.id
188
+ });
189
+ ok = false;
190
+ continue;
191
+ }
192
+ if (!target.options.has(name)) {
193
+ diagnostics.error("autocomplete-unknown-option", `Export "${name}" does not match an option with \`autocomplete: true\` in ${relative$2(target.command.file)}. ${expected(target.options)}`, {
194
+ file: route.file,
195
+ route: route.id
196
+ });
197
+ ok = false;
198
+ }
199
+ }
200
+ for (const name of [...target.options].sort()) {
201
+ if (exported.includes(name)) continue;
202
+ diagnostics.error("autocomplete-missing-handler", `Option "${name}" has \`autocomplete: true\` but ${relative$2(route.file)} does not export a "${name}" function.`, {
203
+ file: route.file,
204
+ route: route.id
205
+ });
206
+ ok = false;
207
+ }
208
+ return ok ? {
209
+ route,
210
+ command: target.command,
211
+ options: exported
212
+ } : null;
213
+ }));
214
+ for (const [id, target] of targets) {
215
+ if (target.options.size === 0 || routes.some((r) => r.id === id)) continue;
216
+ diagnostics.error("autocomplete-missing-file", `${[...target.options].map((o) => `"${o}"`).join(", ")} ${target.options.size === 1 ? "has" : "have"} \`autocomplete: true\` but there is no autocomplete.ts next to this command.`, {
217
+ file: target.command.file,
218
+ route: id
219
+ });
220
+ }
221
+ return {
222
+ autocomplete: results.filter((r) => r !== null),
223
+ diagnostics
224
+ };
225
+ }
226
+ /** Names of the options with `autocomplete: true` for one handler position of a command. */
227
+ function autocompleteOptions(command, key) {
228
+ let options = command.payload.options;
229
+ for (const part of key === "" ? [] : key.split("/")) options = options?.find((o) => o.name === part)?.options;
230
+ return new Set((options ?? []).filter((o) => o.autocomplete === true).map((o) => o.name));
231
+ }
232
+ function expected(options) {
233
+ return options.size === 0 ? "The command declares no autocomplete options." : `Expected one of: ${[...options].sort().join(", ")}.`;
234
+ }
235
+ function relative$2(file) {
236
+ return path.relative(process.cwd(), file).split(path.sep).join("/");
237
+ }
238
+ //#endregion
239
+ //#region src/commands/validate.ts
240
+ const COMMAND_NAME = /^[-_\p{L}\p{N}\p{sc=Deva}\p{sc=Thai}]{1,32}$/u;
241
+ const OPTION_TYPES = /* @__PURE__ */ new Set([
242
+ "string",
243
+ "integer",
244
+ "number",
245
+ "boolean",
246
+ "user",
247
+ "channel",
248
+ "role",
249
+ "mentionable",
250
+ "attachment"
251
+ ]);
252
+ const COMMAND_TYPES = /* @__PURE__ */ new Set([
253
+ "chatInput",
254
+ "user",
255
+ "message"
256
+ ]);
257
+ const TOP_LEVEL_KEYS = [
258
+ "defaultMemberPermissions",
259
+ "nsfw",
260
+ "contexts",
261
+ "integrationTypes"
262
+ ];
263
+ function isRecord(value) {
264
+ return typeof value === "object" && value !== null && !Array.isArray(value);
265
+ }
266
+ /** Validates the `meta` export of a `command.ts`. Returns null after reporting when unusable. */
267
+ function validateCommandMeta(value, file, diagnostics) {
268
+ const ctx = {
269
+ diagnostics,
270
+ file
271
+ };
272
+ if (value === void 0) {
273
+ diagnostics.error("missing-meta", "command.ts must export a `meta` object with at least a description.", { file });
274
+ return null;
275
+ }
276
+ if (!isRecord(value)) {
277
+ fail$1(ctx, "invalid-meta", "`meta` must be an object.");
278
+ return null;
279
+ }
280
+ let ok = true;
281
+ const type = value.type ?? "chatInput";
282
+ if (typeof type !== "string" || !COMMAND_TYPES.has(type)) ok = fail$1(ctx, "invalid-meta", "`meta.type` must be \"chatInput\", \"user\", or \"message\".");
283
+ if (value.name !== void 0) ok = checkName(ctx, value.name, "meta.name", type === "chatInput") && ok;
284
+ if (type === "chatInput") {
285
+ ok = checkDescription(ctx, value.description, "meta.description") && ok;
286
+ if (value.options !== void 0) ok = checkOptions(ctx, value.options) && ok;
287
+ } else {
288
+ if (value.description !== void 0 && value.description !== "") ok = fail$1(ctx, "invalid-meta", "Context menu commands cannot have a description. Remove `meta.description`.");
289
+ if (value.options !== void 0) ok = fail$1(ctx, "invalid-meta", "Context menu commands cannot have options.");
290
+ }
291
+ ok = checkLocalizations(ctx, value.nameLocalizations, "meta.nameLocalizations") && ok;
292
+ ok = checkLocalizations(ctx, value.descriptionLocalizations, "meta.descriptionLocalizations") && ok;
293
+ ok = checkTopLevel(ctx, value) && ok;
294
+ return ok ? value : null;
295
+ }
296
+ /** Validates the `meta` export of a `route.ts` under `commands/`. */
297
+ function validateCommandRouteMeta(value, file, diagnostics) {
298
+ const ctx = {
299
+ diagnostics,
300
+ file
301
+ };
302
+ if (value === void 0) {
303
+ diagnostics.error("missing-meta", "route.ts must export a `meta` object with a description.", { file });
304
+ return null;
305
+ }
306
+ if (!isRecord(value)) {
307
+ fail$1(ctx, "invalid-meta", "`meta` must be an object.");
308
+ return null;
309
+ }
310
+ let ok = checkDescription(ctx, value.description, "meta.description");
311
+ if (value.name !== void 0) ok = checkName(ctx, value.name, "meta.name", true) && ok;
312
+ ok = checkLocalizations(ctx, value.nameLocalizations, "meta.nameLocalizations") && ok;
313
+ ok = checkLocalizations(ctx, value.descriptionLocalizations, "meta.descriptionLocalizations") && ok;
314
+ ok = checkTopLevel(ctx, value) && ok;
315
+ return ok ? value : null;
316
+ }
317
+ /** Reports any top-level-only field so callers can reject them on nested commands. */
318
+ function topLevelKeysUsed(meta) {
319
+ return TOP_LEVEL_KEYS.filter((key) => key in meta && meta[key] !== void 0);
320
+ }
321
+ function fail$1(ctx, code, message) {
322
+ ctx.diagnostics.error(code, message, { file: ctx.file });
323
+ return false;
324
+ }
325
+ function checkName(ctx, name, label, chatInput) {
326
+ if (typeof name !== "string" || name.length === 0 || name.length > 32) return fail$1(ctx, "invalid-name", `${label} must be a string of 1 to 32 characters.`);
327
+ if (chatInput) {
328
+ if (!COMMAND_NAME.test(name) || name !== name.toLowerCase()) return fail$1(ctx, "invalid-name", `${label} "${name}" is not a valid chat input command name. Discord requires lowercase letters, digits, hyphens, and underscores.`);
329
+ }
330
+ return true;
331
+ }
332
+ function checkDescription(ctx, description, label) {
333
+ if (typeof description !== "string" || description.length === 0 || description.length > 100) return fail$1(ctx, "invalid-description", `${label} must be a string of 1 to 100 characters.`);
334
+ return true;
335
+ }
336
+ function checkLocalizations(ctx, value, label) {
337
+ if (value === void 0) return true;
338
+ if (!isRecord(value)) return fail$1(ctx, "invalid-meta", `${label} must be an object of locale to string.`);
339
+ for (const [locale, text] of Object.entries(value)) if (typeof text !== "string") return fail$1(ctx, "invalid-meta", `${label}.${locale} must be a string.`);
340
+ return true;
341
+ }
342
+ function checkTopLevel(ctx, value) {
343
+ let ok = true;
344
+ const perms = value.defaultMemberPermissions;
345
+ if (perms !== void 0 && perms !== null && typeof perms !== "bigint" && typeof perms !== "string" && typeof perms !== "number") ok = fail$1(ctx, "invalid-meta", "`meta.defaultMemberPermissions` must be a permission bitfield (bigint, string, or number) or null.");
346
+ if (value.nsfw !== void 0 && typeof value.nsfw !== "boolean") ok = fail$1(ctx, "invalid-meta", "`meta.nsfw` must be a boolean.");
347
+ ok = checkEnumArray(ctx, value.contexts, "meta.contexts", [
348
+ 0,
349
+ 1,
350
+ 2
351
+ ]) && ok;
352
+ ok = checkEnumArray(ctx, value.integrationTypes, "meta.integrationTypes", [0, 1]) && ok;
353
+ return ok;
354
+ }
355
+ function checkEnumArray(ctx, value, label, allowed) {
356
+ if (value === void 0) return true;
357
+ if (!Array.isArray(value) || value.some((v) => !allowed.includes(v))) return fail$1(ctx, "invalid-meta", `${label} must be an array of ${allowed.join(", ")}. Use the discord.js enum values.`);
358
+ return true;
359
+ }
360
+ function checkOptions(ctx, options) {
361
+ if (!Array.isArray(options)) return fail$1(ctx, "invalid-option", "`meta.options` must be an array.");
362
+ if (options.length > 25) return fail$1(ctx, "invalid-option", "A command can have at most 25 options.");
363
+ let ok = true;
364
+ const names = /* @__PURE__ */ new Set();
365
+ let seenOptional = false;
366
+ for (const [index, option] of options.entries()) {
367
+ const label = `meta.options[${index}]`;
368
+ if (!isRecord(option)) {
369
+ ok = fail$1(ctx, "invalid-option", `${label} must be an object.`);
370
+ continue;
371
+ }
372
+ const typedOk = checkOption(ctx, option, label);
373
+ ok = typedOk && ok;
374
+ if (!typedOk) continue;
375
+ const typed = option;
376
+ if (names.has(typed.name)) ok = fail$1(ctx, "invalid-option", `${label}: option name "${typed.name}" is used twice.`);
377
+ names.add(typed.name);
378
+ if (typed.required) {
379
+ if (seenOptional) ok = fail$1(ctx, "invalid-option", `${label}: required option "${typed.name}" comes after an optional one. Discord requires all required options first.`);
380
+ } else seenOptional = true;
381
+ }
382
+ return ok;
383
+ }
384
+ function checkOption(ctx, option, label) {
385
+ let ok = true;
386
+ if (typeof option.type !== "string" || !OPTION_TYPES.has(option.type)) return fail$1(ctx, "invalid-option", `${label}.type must be one of ${[...OPTION_TYPES].map((t) => `"${t}"`).join(", ")}.`);
387
+ ok = checkName(ctx, option.name, `${label}.name`, true) && ok;
388
+ ok = checkDescription(ctx, option.description, `${label}.description`) && ok;
389
+ if (option.required !== void 0 && typeof option.required !== "boolean") ok = fail$1(ctx, "invalid-option", `${label}.required must be a boolean.`);
390
+ ok = checkLocalizations(ctx, option.nameLocalizations, `${label}.nameLocalizations`) && ok;
391
+ ok = checkLocalizations(ctx, option.descriptionLocalizations, `${label}.descriptionLocalizations`) && ok;
392
+ const type = option.type;
393
+ const numeric = type === "integer" || type === "number";
394
+ const choosable = type === "string" || numeric;
395
+ for (const key of [
396
+ "choices",
397
+ "autocomplete",
398
+ "minLength",
399
+ "maxLength",
400
+ "minValue",
401
+ "maxValue",
402
+ "channelTypes"
403
+ ]) {
404
+ if (option[key] === void 0) continue;
405
+ if (!((key === "choices" || key === "autocomplete") && choosable ? true : (key === "minLength" || key === "maxLength") && type === "string" ? true : (key === "minValue" || key === "maxValue") && numeric ? true : key === "channelTypes" && type === "channel")) ok = fail$1(ctx, "invalid-option", `${label}.${key} is not valid for a "${type}" option.`);
406
+ }
407
+ if (option.autocomplete !== void 0 && typeof option.autocomplete !== "boolean") ok = fail$1(ctx, "invalid-option", `${label}.autocomplete must be a boolean.`);
408
+ if (option.choices !== void 0) {
409
+ if (option.autocomplete === true) ok = fail$1(ctx, "invalid-option", `${label} cannot have both choices and autocomplete.`);
410
+ ok = checkChoices(ctx, option.choices, `${label}.choices`, type === "string") && ok;
411
+ }
412
+ for (const key of [
413
+ "minLength",
414
+ "maxLength",
415
+ "minValue",
416
+ "maxValue"
417
+ ]) {
418
+ const v = option[key];
419
+ if (v !== void 0 && typeof v !== "number") ok = fail$1(ctx, "invalid-option", `${label}.${key} must be a number.`);
420
+ }
421
+ if (typeof option.minLength === "number" && (option.minLength < 0 || option.minLength > 6e3)) ok = fail$1(ctx, "invalid-option", `${label}.minLength must be between 0 and 6000.`);
422
+ if (typeof option.maxLength === "number" && (option.maxLength < 1 || option.maxLength > 6e3)) ok = fail$1(ctx, "invalid-option", `${label}.maxLength must be between 1 and 6000.`);
423
+ if (option.channelTypes !== void 0) {
424
+ if (!Array.isArray(option.channelTypes) || option.channelTypes.some((c) => typeof c !== "number")) ok = fail$1(ctx, "invalid-option", `${label}.channelTypes must be an array of ChannelType values.`);
425
+ }
426
+ return ok;
427
+ }
428
+ function checkChoices(ctx, choices, label, isString) {
429
+ if (!Array.isArray(choices)) return fail$1(ctx, "invalid-option", `${label} must be an array.`);
430
+ if (choices.length > 25) return fail$1(ctx, "invalid-option", `${label} can have at most 25 entries.`);
431
+ let ok = true;
432
+ for (const [index, choice] of choices.entries()) {
433
+ const at = `${label}[${index}]`;
434
+ if (!isRecord(choice)) {
435
+ ok = fail$1(ctx, "invalid-option", `${at} must be an object with name and value.`);
436
+ continue;
437
+ }
438
+ const typed = choice;
439
+ if (typeof typed.name !== "string" || typed.name.length === 0 || typed.name.length > 100) ok = fail$1(ctx, "invalid-option", `${at}.name must be a string of 1 to 100 characters.`);
440
+ if (isString) {
441
+ if (typeof typed.value !== "string" || typed.value.length === 0 || typed.value.length > 100) ok = fail$1(ctx, "invalid-option", `${at}.value must be a string of 1 to 100 characters.`);
442
+ } else if (typeof typed.value !== "number") ok = fail$1(ctx, "invalid-option", `${at}.value must be a number.`);
443
+ ok = checkLocalizations(ctx, typed.nameLocalizations, `${at}.nameLocalizations`) && ok;
444
+ }
445
+ return ok;
446
+ }
447
+ //#endregion
448
+ //#region src/commands/compile.ts
449
+ const OPTION_TYPE = {
450
+ string: ApplicationCommandOptionType.String,
451
+ integer: ApplicationCommandOptionType.Integer,
452
+ number: ApplicationCommandOptionType.Number,
453
+ boolean: ApplicationCommandOptionType.Boolean,
454
+ user: ApplicationCommandOptionType.User,
455
+ channel: ApplicationCommandOptionType.Channel,
456
+ role: ApplicationCommandOptionType.Role,
457
+ mentionable: ApplicationCommandOptionType.Mentionable,
458
+ attachment: ApplicationCommandOptionType.Attachment
459
+ };
460
+ const COMMAND_TYPE = {
461
+ chatInput: ApplicationCommandType.ChatInput,
462
+ user: ApplicationCommandType.User,
463
+ message: ApplicationCommandType.Message
464
+ };
465
+ /** Compiles the command routes of a route table into Discord command definitions. */
466
+ async function compileCommands(table) {
467
+ const diagnostics = new Diagnostics();
468
+ const commands = [];
469
+ const [loaded, routeMetas] = await Promise.all([loadRoutes(table.routes, diagnostics), loadRouteMetas(table.boundaries, diagnostics)]);
470
+ const byTopLevel = /* @__PURE__ */ new Map();
471
+ for (const entry of loaded) {
472
+ const top = entry.parts[0];
473
+ byTopLevel.set(top, [...byTopLevel.get(top) ?? [], entry]);
474
+ }
475
+ for (const [top, entries] of [...byTopLevel].sort(([a], [b]) => a.localeCompare(b))) {
476
+ const command = compileTopLevel(top, entries, routeMetas, diagnostics);
477
+ if (command !== null) commands.push(command);
478
+ }
479
+ for (const entry of routeMetas.values()) if (!entry.used) diagnostics.warn("unused-route-meta", "This route.ts does not describe a command with subcommands and has no effect.", { file: entry.boundary.file });
480
+ detectDuplicateNames(commands, diagnostics);
481
+ commands.sort((a, b) => a.type - b.type || a.name.localeCompare(b.name));
482
+ return {
483
+ commands,
484
+ diagnostics
485
+ };
486
+ }
487
+ async function loadRoutes(routes, diagnostics) {
488
+ const commandRoutes = routes.filter((r) => r.kind === "command");
489
+ return (await Promise.all(commandRoutes.map(async (route) => {
490
+ const module = await importOrReport(route.file, diagnostics);
491
+ if (module === null) return null;
492
+ if (!checkDeclaredRoute(module, route, diagnostics)) return null;
493
+ const meta = validateCommandMeta(module.meta, route.file, diagnostics);
494
+ if (meta === null) return null;
495
+ return {
496
+ route,
497
+ parts: route.path.split("/"),
498
+ meta
499
+ };
500
+ }))).filter((r) => r !== null);
501
+ }
502
+ async function loadRouteMetas(boundaries, diagnostics) {
503
+ const map = /* @__PURE__ */ new Map();
504
+ const relevant = boundaries.filter((b) => b.kind === "route" && b.category === "command");
505
+ await Promise.all(relevant.map(async (boundary) => {
506
+ const key = boundary.segments.filter((s) => s.type !== "group").map(formatSegment).join("/");
507
+ if (key === "") {
508
+ diagnostics.error("route-meta-without-path", "route.ts must live inside a command directory. At the commands root it describes nothing.", { file: boundary.file });
509
+ return;
510
+ }
511
+ const module = await importOrReport(boundary.file, diagnostics);
512
+ if (module === null) return;
513
+ const meta = validateCommandRouteMeta(module.meta, boundary.file, diagnostics);
514
+ if (meta === null) return;
515
+ map.set(key, {
516
+ boundary,
517
+ meta,
518
+ used: false
519
+ });
520
+ }));
521
+ return map;
522
+ }
523
+ async function importOrReport(file, diagnostics) {
524
+ try {
525
+ return await loadModule(file);
526
+ } catch (error) {
527
+ diagnostics.error("module-load-failed", `Could not import this file: ${error instanceof Error ? error.message : String(error)}`, { file });
528
+ return null;
529
+ }
530
+ }
531
+ function compileTopLevel(top, entries, routeMetas, diagnostics) {
532
+ const direct = entries.filter((e) => e.parts.length === 1);
533
+ const nested = entries.filter((e) => e.parts.length > 1);
534
+ if (direct.length > 0 && nested.length > 0) {
535
+ for (const entry of nested) diagnostics.error("mixed-command-and-subcommands", `"${top}" has its own command.ts and also subcommands. Discord does not allow both. Either remove ${relative$1(direct[0]?.route.file)} or move this handler out of ${top}/.`, {
536
+ file: entry.route.file,
537
+ route: entry.route.id
538
+ });
539
+ return null;
540
+ }
541
+ const tooDeep = nested.filter((e) => e.parts.length > 3);
542
+ if (tooDeep.length > 0) {
543
+ for (const entry of tooDeep) diagnostics.error("command-too-deep", `Commands can nest at most three levels (command / group / subcommand). "${entry.route.path}" has ${entry.parts.length}.`, {
544
+ file: entry.route.file,
545
+ route: entry.route.id
546
+ });
547
+ return null;
548
+ }
549
+ if (direct.length === 1) return compilePlainCommand(direct[0], diagnostics);
550
+ return compileParentCommand(top, nested, routeMetas, diagnostics);
551
+ }
552
+ function compilePlainCommand(entry, diagnostics) {
553
+ const { route, meta } = entry;
554
+ const name = meta.name ?? route.path;
555
+ const type = COMMAND_TYPE[meta.type ?? "chatInput"];
556
+ if (type === ApplicationCommandType.ChatInput && !isValidChatInputName(name)) {
557
+ reportDirectoryName(route, name, diagnostics);
558
+ return null;
559
+ }
560
+ const payload = {
561
+ name,
562
+ type,
563
+ ...localizations(meta),
564
+ ...topLevelPayload(meta)
565
+ };
566
+ if (type === ApplicationCommandType.ChatInput) {
567
+ payload.description = meta.description;
568
+ if (meta.options !== void 0) payload.options = meta.options.map(optionPayload);
569
+ }
570
+ return {
571
+ name,
572
+ type,
573
+ handlers: { "": route },
574
+ payload: compact(payload),
575
+ files: [route.file]
576
+ };
577
+ }
578
+ function compileParentCommand(top, nested, routeMetas, diagnostics) {
579
+ const parent = requireRouteMeta(top, nested[0], routeMetas, diagnostics);
580
+ if (parent === null) return null;
581
+ const name = parent.meta.name ?? top;
582
+ if (!isValidChatInputName(name)) {
583
+ reportDirectoryName(nested[0]?.route, name, diagnostics, parent.boundary.file);
584
+ return null;
585
+ }
586
+ const handlers = {};
587
+ const files = [parent.boundary.file];
588
+ const options = [];
589
+ let ok = true;
590
+ const bySecond = /* @__PURE__ */ new Map();
591
+ for (const entry of nested) {
592
+ const second = entry.parts[1];
593
+ bySecond.set(second, [...bySecond.get(second) ?? [], entry]);
594
+ }
595
+ if (bySecond.size > 25) {
596
+ diagnostics.error("too-many-subcommands", `"${top}" has ${bySecond.size} subcommands and groups. Discord allows at most 25.`, { file: parent.boundary.file });
597
+ ok = false;
598
+ }
599
+ for (const [second, entries] of [...bySecond].sort(([a], [b]) => a.localeCompare(b))) {
600
+ const subs = entries.filter((e) => e.parts.length === 2);
601
+ const grouped = entries.filter((e) => e.parts.length === 3);
602
+ if (subs.length > 0 && grouped.length > 0) {
603
+ for (const entry of grouped) diagnostics.error("mixed-subcommand-and-group", `"${top}/${second}" is both a subcommand (${relative$1(subs[0]?.route.file)}) and a subcommand group. Discord does not allow both.`, {
604
+ file: entry.route.file,
605
+ route: entry.route.id
606
+ });
607
+ ok = false;
608
+ continue;
609
+ }
610
+ if (subs.length === 1) {
611
+ const sub = compileSubcommand(subs[0], diagnostics);
612
+ if (sub === null) {
613
+ ok = false;
614
+ continue;
615
+ }
616
+ handlers[sub.name] = sub.route;
617
+ files.push(sub.route.file);
618
+ options.push(sub.payload);
619
+ continue;
620
+ }
621
+ const groupKey = `${top}/${second}`;
622
+ const group = requireRouteMeta(groupKey, grouped[0], routeMetas, diagnostics);
623
+ if (group === null) {
624
+ ok = false;
625
+ continue;
626
+ }
627
+ const groupName = group.meta.name ?? second;
628
+ if (!isValidChatInputName(groupName)) {
629
+ reportDirectoryName(grouped[0]?.route, groupName, diagnostics, group.boundary.file);
630
+ ok = false;
631
+ continue;
632
+ }
633
+ const extra = topLevelKeysUsed(group.meta);
634
+ if (extra.length > 0) {
635
+ diagnostics.error("top-level-field-on-group", `${extra.map((k) => `meta.${k}`).join(", ")} only applies to top-level commands. Move it to ${relative$1(parent.boundary.file)}.`, { file: group.boundary.file });
636
+ ok = false;
637
+ continue;
638
+ }
639
+ if (grouped.length > 25) {
640
+ diagnostics.error("too-many-subcommands", `Group "${groupKey}" has ${grouped.length} subcommands. Discord allows at most 25.`, { file: group.boundary.file });
641
+ ok = false;
642
+ continue;
643
+ }
644
+ files.push(group.boundary.file);
645
+ const groupOptions = [];
646
+ for (const entry of grouped.sort((a, b) => a.route.path.localeCompare(b.route.path))) {
647
+ const sub = compileSubcommand(entry, diagnostics);
648
+ if (sub === null) {
649
+ ok = false;
650
+ continue;
651
+ }
652
+ handlers[`${groupName}/${sub.name}`] = sub.route;
653
+ files.push(sub.route.file);
654
+ groupOptions.push(sub.payload);
655
+ }
656
+ options.push(compact({
657
+ type: ApplicationCommandOptionType.SubcommandGroup,
658
+ name: groupName,
659
+ description: group.meta.description,
660
+ ...localizations(group.meta),
661
+ options: groupOptions
662
+ }));
663
+ }
664
+ if (!ok) return null;
665
+ const payload = compact({
666
+ name,
667
+ type: ApplicationCommandType.ChatInput,
668
+ description: parent.meta.description,
669
+ ...localizations(parent.meta),
670
+ ...topLevelPayload(parent.meta),
671
+ options
672
+ });
673
+ return {
674
+ name,
675
+ type: ApplicationCommandType.ChatInput,
676
+ handlers,
677
+ payload,
678
+ files
679
+ };
680
+ }
681
+ function compileSubcommand(entry, diagnostics) {
682
+ const { route, meta } = entry;
683
+ if (meta.type !== void 0 && meta.type !== "chatInput") {
684
+ diagnostics.error("context-menu-nested", `Context menu commands cannot be subcommands. Move ${relative$1(route.file)} to the top of commands/.`, {
685
+ file: route.file,
686
+ route: route.id
687
+ });
688
+ return null;
689
+ }
690
+ const extra = topLevelKeysUsed(meta);
691
+ if (extra.length > 0) {
692
+ diagnostics.error("top-level-field-on-subcommand", `${extra.map((k) => `meta.${k}`).join(", ")} only applies to top-level commands. Move it to the route.ts of "${entry.parts[0]}".`, {
693
+ file: route.file,
694
+ route: route.id
695
+ });
696
+ return null;
697
+ }
698
+ const name = meta.name ?? entry.parts.at(-1);
699
+ if (!isValidChatInputName(name)) {
700
+ reportDirectoryName(route, name, diagnostics);
701
+ return null;
702
+ }
703
+ return {
704
+ name,
705
+ route,
706
+ payload: compact({
707
+ type: ApplicationCommandOptionType.Subcommand,
708
+ name,
709
+ description: meta.description,
710
+ ...localizations(meta),
711
+ options: meta.options?.map(optionPayload)
712
+ })
713
+ };
714
+ }
715
+ function requireRouteMeta(key, child, routeMetas, diagnostics) {
716
+ const entry = routeMetas.get(key);
717
+ if (entry !== void 0) {
718
+ entry.used = true;
719
+ return entry;
720
+ }
721
+ const dir = directoryForPath(child.route, key.split("/").length);
722
+ diagnostics.error("missing-route-meta", `"${key}" has subcommands but no route.ts. Discord needs a description for it. Add ${path.join(dir, "route.ts")} exporting \`meta\` with a description.`, {
723
+ file: child.route.file,
724
+ route: child.route.id
725
+ });
726
+ return null;
727
+ }
728
+ /** Directory of the Nth non-group segment of a route, walking up from the handler file. */
729
+ function directoryForPath(route, depth) {
730
+ let seen = 0;
731
+ let index = route.segments.length;
732
+ for (let i = 0; i < route.segments.length; i++) {
733
+ if (route.segments[i]?.type === "group") continue;
734
+ seen++;
735
+ if (seen === depth) {
736
+ index = i;
737
+ break;
738
+ }
739
+ }
740
+ const levelsUp = route.segments.length - index - 1;
741
+ let dir = path.dirname(route.file);
742
+ for (let i = 0; i < levelsUp; i++) dir = path.dirname(dir);
743
+ return dir;
744
+ }
745
+ function isValidChatInputName(name) {
746
+ return /^[-_\p{L}\p{N}\p{sc=Deva}\p{sc=Thai}]{1,32}$/u.test(name) && name === name.toLowerCase();
747
+ }
748
+ function reportDirectoryName(route, name, diagnostics, file) {
749
+ diagnostics.error("invalid-name", `"${name}" is not a valid chat input command name. Discord requires lowercase letters, digits, hyphens, and underscores, 1 to 32 characters. Rename the directory or set \`meta.name\`.`, {
750
+ file: file ?? route.file,
751
+ route: route.id
752
+ });
753
+ }
754
+ function detectDuplicateNames(commands, diagnostics) {
755
+ const seen = /* @__PURE__ */ new Map();
756
+ for (const command of commands) {
757
+ const key = `${command.type}:${command.name}`;
758
+ const existing = seen.get(key);
759
+ if (existing === void 0) {
760
+ seen.set(key, command);
761
+ continue;
762
+ }
763
+ diagnostics.error("duplicate-command-name", `Two commands register as "${command.name}": ${relative$1(existing.files[0])} and ${relative$1(command.files[0])}. Check \`meta.name\` overrides.`, { file: command.files[0] });
764
+ }
765
+ }
766
+ function optionPayload(option) {
767
+ const base = {
768
+ type: OPTION_TYPE[option.type],
769
+ name: option.name,
770
+ description: option.description,
771
+ required: option.required,
772
+ ...localizations(option)
773
+ };
774
+ switch (option.type) {
775
+ case "string":
776
+ base.choices = option.choices?.map(choicePayload);
777
+ base.autocomplete = option.autocomplete;
778
+ base.min_length = option.minLength;
779
+ base.max_length = option.maxLength;
780
+ break;
781
+ case "integer":
782
+ case "number":
783
+ base.choices = option.choices?.map(choicePayload);
784
+ base.autocomplete = option.autocomplete;
785
+ base.min_value = option.minValue;
786
+ base.max_value = option.maxValue;
787
+ break;
788
+ case "channel": base.channel_types = option.channelTypes;
789
+ }
790
+ return compact(base);
791
+ }
792
+ function choicePayload(choice) {
793
+ return compact({
794
+ name: choice.name,
795
+ value: choice.value,
796
+ name_localizations: choice.nameLocalizations
797
+ });
798
+ }
799
+ function localizations(meta) {
800
+ return {
801
+ name_localizations: meta.nameLocalizations,
802
+ description_localizations: meta.descriptionLocalizations
803
+ };
804
+ }
805
+ function topLevelPayload(meta) {
806
+ const perms = meta.defaultMemberPermissions;
807
+ return {
808
+ default_member_permissions: perms === void 0 ? void 0 : perms === null ? null : String(perms),
809
+ nsfw: meta.nsfw,
810
+ contexts: meta.contexts,
811
+ integration_types: meta.integrationTypes
812
+ };
813
+ }
814
+ function compact(object) {
815
+ const out = {};
816
+ for (const [key, value] of Object.entries(object)) if (value !== void 0) out[key] = value;
817
+ return out;
818
+ }
819
+ function relative$1(file) {
820
+ return file === void 0 ? "?" : path.relative(process.cwd(), file).split(path.sep).join("/");
821
+ }
822
+ //#endregion
823
+ //#region src/events/compile.ts
824
+ const EVENT_NAMES = new Set(Object.values(Events));
825
+ /** Renamed events whose old name still shows up in older discord.js code. */
826
+ const RENAMED = { ready: Events.ClientReady };
827
+ /** Groups event routes by discord.js event and validates their names and `meta`. */
828
+ async function compileEvents(table) {
829
+ const diagnostics = new Diagnostics();
830
+ const routes = table.routes.filter((r) => r.kind === "event");
831
+ const loaded = await Promise.all(routes.map((route) => loadHandler(route, diagnostics)));
832
+ const byName = /* @__PURE__ */ new Map();
833
+ for (const handler of loaded) {
834
+ if (handler === null) continue;
835
+ byName.set(handler.name, [...byName.get(handler.name) ?? [], handler]);
836
+ }
837
+ const events = [];
838
+ for (const [name, handlers] of [...byName].sort(([a], [b]) => a.localeCompare(b))) {
839
+ const modes = new Set(handlers.map((h) => h.mode).filter((m) => m !== void 0));
840
+ if (modes.size > 1) {
841
+ for (const handler of handlers) {
842
+ if (handler.mode === void 0) continue;
843
+ diagnostics.error("event-mode-conflict", `Handlers of "${name}" disagree on \`meta.mode\`: ${[...modes].map((m) => `"${m}"`).join(" and ")}. All handlers of one event share a mode; set it on one file or make them agree.`, {
844
+ file: handler.route.file,
845
+ route: handler.route.id
846
+ });
847
+ }
848
+ continue;
849
+ }
850
+ handlers.sort((a, b) => a.order - b.order || a.route.id.localeCompare(b.route.id));
851
+ events.push({
852
+ name,
853
+ mode: modes.values().next().value ?? "sequential",
854
+ handlers: handlers.map(({ route, once, order }) => ({
855
+ route,
856
+ once,
857
+ order
858
+ }))
859
+ });
860
+ }
861
+ return {
862
+ events,
863
+ diagnostics
864
+ };
865
+ }
866
+ async function loadHandler(route, diagnostics) {
867
+ const name = eventName(route, diagnostics);
868
+ if (name === null) return null;
869
+ let module;
870
+ try {
871
+ module = await loadModule(route.file);
872
+ } catch (error) {
873
+ diagnostics.error("module-load-failed", `Could not import this file: ${error instanceof Error ? error.message : String(error)}`, {
874
+ file: route.file,
875
+ route: route.id
876
+ });
877
+ return null;
878
+ }
879
+ if (!checkDeclaredRoute(module, route, diagnostics, name)) return null;
880
+ const meta = validateEventMeta(module.meta, route, diagnostics);
881
+ if (meta === null) return null;
882
+ return {
883
+ route,
884
+ name,
885
+ once: meta.once ?? false,
886
+ order: meta.order ?? 0,
887
+ mode: meta.mode
888
+ };
889
+ }
890
+ function eventName(route, diagnostics) {
891
+ const statics = route.segments.filter((s) => s.type !== "group");
892
+ const first = statics[0];
893
+ if (first === void 0 || first.type !== "static") return null;
894
+ if (statics.length > 1) {
895
+ diagnostics.error("event-nested-path", `Event handlers live directly under events/<eventName>/. "${route.path}" adds ${statics.slice(1).map(formatSegment).join("/")} below the event name. Use a route group like (${statics[1]?.name}) to keep several handlers apart.`, {
896
+ file: route.file,
897
+ route: route.id
898
+ });
899
+ return null;
900
+ }
901
+ const name = first.name;
902
+ if (EVENT_NAMES.has(name)) return name;
903
+ const renamed = RENAMED[name];
904
+ const hint = renamed !== void 0 ? `discord.js renamed it to "${renamed}".` : closest(name) ? `Did you mean "${closest(name)}"?` : "Event names are the lowerCamelCase values of discord.js's `Events` enum.";
905
+ diagnostics.error("unknown-event", `"${name}" is not a discord.js event. ${hint} Rename the directory.`, {
906
+ file: route.file,
907
+ route: route.id
908
+ });
909
+ return null;
910
+ }
911
+ function validateEventMeta(value, route, diagnostics) {
912
+ if (value === void 0) return {};
913
+ const fail = (message) => {
914
+ diagnostics.error("invalid-meta", message, {
915
+ file: route.file,
916
+ route: route.id
917
+ });
918
+ return null;
919
+ };
920
+ if (typeof value !== "object" || value === null || Array.isArray(value)) return fail("`meta` must be an object.");
921
+ const meta = value;
922
+ if (meta.once !== void 0 && typeof meta.once !== "boolean") return fail("`meta.once` must be a boolean.");
923
+ if (meta.order !== void 0 && (typeof meta.order !== "number" || !Number.isFinite(meta.order))) return fail("`meta.order` must be a finite number.");
924
+ if (meta.mode !== void 0 && meta.mode !== "sequential" && meta.mode !== "concurrent") return fail("`meta.mode` must be \"sequential\" or \"concurrent\".");
925
+ return meta;
926
+ }
927
+ /** Case-insensitive match against known events, to catch `GuildMemberAdd` or `guildmemberadd`. */
928
+ function closest(name) {
929
+ const lower = name.toLowerCase();
930
+ for (const known of EVENT_NAMES) if (known.toLowerCase() === lower) return known;
931
+ return null;
932
+ }
933
+ //#endregion
934
+ //#region src/compiler/chains.ts
935
+ /**
936
+ * Picks the boundaries that apply to a route: everything at the app root, plus every
937
+ * boundary in the route's category whose directory is an ancestor of (or equal to) the
938
+ * route's directory. Route groups count as directories here, so a middleware inside
939
+ * `(admin)/` covers only that group.
940
+ */
941
+ function resolveChains(route, boundaries) {
942
+ const applicable = boundaries.filter((b) => (b.category === null || b.category === route.category) && isPrefix(b.segments, route.segments));
943
+ const byDepth = (a, b) => depth(a) - depth(b);
944
+ return {
945
+ middleware: route.category === "event" ? [] : applicable.filter((b) => b.kind === "middleware").sort(byDepth).map((b) => b.file),
946
+ errors: applicable.filter((b) => b.kind === "error").sort(byDepth).reverse().map((b) => b.file)
947
+ };
948
+ }
949
+ /** Root boundaries sit above the category directory, so they sort before category-level ones. */
950
+ function depth(boundary) {
951
+ return boundary.category === null ? -1 : boundary.segments.length;
952
+ }
953
+ function isPrefix(prefix, segments) {
954
+ if (prefix.length > segments.length) return false;
955
+ return prefix.every((s, i) => s.type === segments[i]?.type && s.name === segments[i]?.name);
956
+ }
957
+ //#endregion
958
+ //#region src/compiler/discover.ts
959
+ const RESERVED = {
960
+ command: "command",
961
+ autocomplete: "autocomplete",
962
+ button: "button",
963
+ select: "select",
964
+ modal: "modal",
965
+ event: "event",
966
+ middleware: "middleware",
967
+ error: "error",
968
+ route: "route"
969
+ };
970
+ const EXTENSIONS = /* @__PURE__ */ new Set([
971
+ ".ts",
972
+ ".js",
973
+ ".mts",
974
+ ".mjs"
975
+ ]);
976
+ /** The route file kind a filename denotes, or `undefined` for ordinary application code. */
977
+ function reservedKind(fileName) {
978
+ const ext = path.extname(fileName);
979
+ if (!EXTENSIONS.has(ext)) return void 0;
980
+ const base = fileName.slice(0, -ext.length);
981
+ if (base.endsWith(".test") || base.endsWith(".spec")) return void 0;
982
+ return RESERVED[base];
983
+ }
984
+ /**
985
+ * Walks the app directory and returns every reserved file, sorted by path.
986
+ * Anything that is not a reserved filename is ordinary application code and is skipped.
987
+ */
988
+ function discover(appDir) {
989
+ const files = [];
990
+ walk(path.resolve(appDir), [], files);
991
+ files.sort((a, b) => a.file < b.file ? -1 : a.file > b.file ? 1 : 0);
992
+ return files;
993
+ }
994
+ function walk(dir, dirs, out) {
995
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
996
+ if (entry.name.startsWith(".") || entry.name === "node_modules") continue;
997
+ const full = path.join(dir, entry.name);
998
+ if (entry.isDirectory()) {
999
+ walk(full, [...dirs, entry.name], out);
1000
+ continue;
1001
+ }
1002
+ if (!entry.isFile()) continue;
1003
+ const kind = reservedKind(entry.name);
1004
+ if (kind === void 0) continue;
1005
+ out.push({
1006
+ kind,
1007
+ file: full,
1008
+ dirs
1009
+ });
1010
+ }
1011
+ }
1012
+ //#endregion
1013
+ //#region src/compiler/identity.ts
1014
+ /**
1015
+ * Short, stable identifier used inside custom IDs.
1016
+ * First 6 base36 characters of the SHA-256 of the canonical route identity.
1017
+ */
1018
+ function shortId(routeId) {
1019
+ const hex = createHash("sha256").update(routeId).digest("hex").slice(0, 16);
1020
+ return BigInt(`0x${hex}`).toString(36).padStart(6, "0").slice(0, 6);
1021
+ }
1022
+ //#endregion
1023
+ //#region src/compiler/routes.ts
1024
+ const CATEGORY_DIRS = {
1025
+ commands: "command",
1026
+ components: "component",
1027
+ events: "event"
1028
+ };
1029
+ const HANDLER_KINDS = {
1030
+ command: /* @__PURE__ */ new Set(["command", "autocomplete"]),
1031
+ component: /* @__PURE__ */ new Set([
1032
+ "button",
1033
+ "select",
1034
+ "modal"
1035
+ ]),
1036
+ event: /* @__PURE__ */ new Set(["event"])
1037
+ };
1038
+ const BOUNDARY_KINDS = /* @__PURE__ */ new Set([
1039
+ "middleware",
1040
+ "error",
1041
+ "route"
1042
+ ]);
1043
+ /** Discovers the app directory and builds the route table. */
1044
+ function buildRouteTable(appDir) {
1045
+ return buildRouteTableFromFiles(discover(appDir));
1046
+ }
1047
+ function buildRouteTableFromFiles(files) {
1048
+ const diagnostics = new Diagnostics();
1049
+ const routes = [];
1050
+ const boundaries = [];
1051
+ for (const source of files) {
1052
+ const [categoryDir, ...rest] = source.dirs;
1053
+ if (categoryDir === void 0) {
1054
+ if (source.kind === "middleware" || source.kind === "error") boundaries.push({
1055
+ kind: source.kind,
1056
+ category: null,
1057
+ segments: [],
1058
+ file: source.file
1059
+ });
1060
+ else diagnostics.error("file-outside-category", `${path.basename(source.file)} must live under commands/, components/, or events/. Only middleware and error files may sit at the app root.`, { file: source.file });
1061
+ continue;
1062
+ }
1063
+ const category = CATEGORY_DIRS[categoryDir];
1064
+ if (category === void 0) {
1065
+ diagnostics.error("unknown-category", `"${categoryDir}/" is not a route area. Reserved files must live under commands/, components/, or events/.`, { file: source.file });
1066
+ continue;
1067
+ }
1068
+ const segments = parseSegments(rest, source, diagnostics);
1069
+ if (segments === null) continue;
1070
+ if (BOUNDARY_KINDS.has(source.kind)) {
1071
+ boundaries.push({
1072
+ kind: source.kind,
1073
+ category,
1074
+ segments,
1075
+ file: source.file
1076
+ });
1077
+ continue;
1078
+ }
1079
+ if (!HANDLER_KINDS[category].has(source.kind)) {
1080
+ diagnostics.error("file-in-wrong-category", `${path.basename(source.file)} does not belong under ${categoryDir}/. Expected one of: ${[...HANDLER_KINDS[category]].map((k) => `${k}.ts`).join(", ")}.`, { file: source.file });
1081
+ continue;
1082
+ }
1083
+ const route = makeRoute(category, source.kind, segments, source.file, diagnostics);
1084
+ if (route !== null) routes.push(route);
1085
+ }
1086
+ detectDuplicates(routes, diagnostics);
1087
+ return {
1088
+ routes,
1089
+ boundaries,
1090
+ diagnostics
1091
+ };
1092
+ }
1093
+ function parseSegments(dirs, source, diagnostics) {
1094
+ const segments = [];
1095
+ for (const dir of dirs) {
1096
+ const result = parseSegment(dir);
1097
+ if (!result.ok) {
1098
+ diagnostics.error("invalid-segment", result.reason, { file: source.file });
1099
+ return null;
1100
+ }
1101
+ segments.push(result.segment);
1102
+ }
1103
+ return segments;
1104
+ }
1105
+ function makeRoute(category, kind, segments, file, diagnostics) {
1106
+ if (segments.length === 0) {
1107
+ diagnostics.error("route-without-path", `${path.basename(file)} needs a named directory. Files directly inside ${category}s/ have no route path.`, { file });
1108
+ return null;
1109
+ }
1110
+ const params = [];
1111
+ for (const [index, segment] of segments.entries()) if (segment.type === "dynamic" || segment.type === "catchAll") {
1112
+ if (category !== "component") {
1113
+ diagnostics.error("dynamic-segment-not-allowed", `${formatSegment(segment)} is a dynamic segment, but ${category} routes cannot carry parameters. Only component routes can.`, { file });
1114
+ return null;
1115
+ }
1116
+ if (params.includes(segment.name)) {
1117
+ diagnostics.error("duplicate-param", `Parameter "${segment.name}" appears twice in the same route.`, { file });
1118
+ return null;
1119
+ }
1120
+ if (segment.type === "catchAll" && index !== segments.length - 1) {
1121
+ diagnostics.error("catch-all-not-last", `${formatSegment(segment)} must be the last segment of the route.`, { file });
1122
+ return null;
1123
+ }
1124
+ params.push(segment.name);
1125
+ }
1126
+ const routePath = segments.filter((segment) => segment.type !== "group" || category === "event").map(formatSegment).join("/");
1127
+ if (routePath === "") {
1128
+ diagnostics.error("route-without-path", `${path.basename(file)} sits only inside route groups. Groups do not contribute to the route, so this route has no path.`, { file });
1129
+ return null;
1130
+ }
1131
+ const id = `${category}:${routePath}`;
1132
+ return {
1133
+ id,
1134
+ shortId: shortId(id),
1135
+ category,
1136
+ kind,
1137
+ path: routePath,
1138
+ segments,
1139
+ params,
1140
+ file
1141
+ };
1142
+ }
1143
+ /**
1144
+ * A command and its autocomplete share an ID. A component route has exactly one handler of any
1145
+ * kind, since the handler's types come from the path alone.
1146
+ */
1147
+ function detectDuplicates(routes, diagnostics) {
1148
+ const seen = /* @__PURE__ */ new Map();
1149
+ for (const route of routes) {
1150
+ const key = route.category === "component" ? route.id : `${route.id}#${route.kind}`;
1151
+ const existing = seen.get(key);
1152
+ if (existing === void 0) {
1153
+ seen.set(key, route);
1154
+ continue;
1155
+ }
1156
+ diagnostics.error("duplicate-route", existing.kind === route.kind ? `Route ${route.id} is defined twice: ${existing.file} and ${route.file}. Route groups do not make paths distinct.` : `Route ${route.id} has two handlers: ${existing.file} and ${route.file}. A component route takes one button.ts, select.ts, or modal.ts. Move one into its own directory.`, {
1157
+ file: route.file,
1158
+ route: route.id
1159
+ });
1160
+ }
1161
+ }
1162
+ //#endregion
1163
+ //#region src/compiler/graph.ts
1164
+ /** Runs the whole compiler pipeline on an app directory. */
1165
+ async function buildGraph(appDir) {
1166
+ const absolute = path.resolve(appDir);
1167
+ const table = buildRouteTable(absolute);
1168
+ const diagnostics = new Diagnostics();
1169
+ diagnostics.items.push(...table.diagnostics.items);
1170
+ const [commands, components, events] = await Promise.all([
1171
+ compileCommands(table),
1172
+ compileComponents(table),
1173
+ compileEvents(table)
1174
+ ]);
1175
+ const autocomplete = await compileAutocomplete(table, commands.commands);
1176
+ for (const stage of [
1177
+ commands,
1178
+ components,
1179
+ events,
1180
+ autocomplete
1181
+ ]) diagnostics.items.push(...stage.diagnostics.items);
1182
+ const chains = /* @__PURE__ */ new Map();
1183
+ for (const route of table.routes) chains.set(route.file, resolveChains(route, table.boundaries));
1184
+ return {
1185
+ appDir: absolute,
1186
+ routes: table.routes,
1187
+ boundaries: table.boundaries,
1188
+ chains,
1189
+ commands: commands.commands,
1190
+ components: components.routes,
1191
+ events: events.events,
1192
+ autocomplete: autocomplete.autocomplete,
1193
+ plugins: /* @__PURE__ */ new Map(),
1194
+ diagnostics
1195
+ };
1196
+ }
1197
+ //#endregion
1198
+ //#region src/cli/ui.ts
1199
+ /**
1200
+ * Terminal formatting for the CLI and dev server. Colors are off until the bin turns them on
1201
+ * for a TTY, so tests and piped output see plain text with the same glyphs.
1202
+ */
1203
+ let colors = false;
1204
+ function setColors(enabled) {
1205
+ colors = enabled;
1206
+ }
1207
+ const paint = (style) => (text) => colors ? styleText(style, text) : text;
1208
+ const c = {
1209
+ bold: paint("bold"),
1210
+ dim: paint("dim"),
1211
+ red: paint("red"),
1212
+ green: paint("green"),
1213
+ yellow: paint("yellow"),
1214
+ cyan: paint("cyan"),
1215
+ magenta: paint("magenta"),
1216
+ underline: paint("underline")
1217
+ };
1218
+ const ok = (text) => `${c.green("✔")} ${text}`;
1219
+ const fail = (text) => `${c.red("✖")} ${text}`;
1220
+ const warn = (text) => `${c.yellow("▲")} ${text}`;
1221
+ const info$1 = (text) => `${c.cyan("›")} ${text}`;
1222
+ const link = (url) => c.underline(c.cyan(url));
1223
+ const PORTAL_URL = "https://discord.com/developers/applications";
1224
+ function indent(lines, by = 2) {
1225
+ return lines.map((line) => line === "" ? "" : `${" ".repeat(by)}${line}`);
1226
+ }
1227
+ /** A headline followed by indented detail lines, as one multi-line string. */
1228
+ function block(head, details = []) {
1229
+ return details.length === 0 ? head : [
1230
+ head,
1231
+ "",
1232
+ ...indent(details)
1233
+ ].join("\n");
1234
+ }
1235
+ /** Two-column rows with dim keys, aligned on the widest key. */
1236
+ function table(rows) {
1237
+ const width = Math.max(0, ...rows.map(([key]) => key.length));
1238
+ return rows.map(([key, value]) => `${c.dim(key.padEnd(width))} ${value}`);
1239
+ }
1240
+ /** `HH:MM:SS`, dimmed. Prefix for dev server lines that happen while it runs. */
1241
+ function stamp() {
1242
+ return c.dim((/* @__PURE__ */ new Date()).toTimeString().slice(0, 8));
1243
+ }
1244
+ /** How to supply a credential, shared by every command that needs one. */
1245
+ function credentialHint(kind, configName) {
1246
+ const variable = kind === "token" ? "DISCORD_TOKEN" : "DISCORD_APPLICATION_ID";
1247
+ const where = kind === "token" ? "your application → Bot → Reset Token" : "your application → General Information → Application ID";
1248
+ return [
1249
+ `Put ${c.bold(`${variable}=...`)} in ${c.bold(".env")} next to ${configName}, or export it.`,
1250
+ `The config reads it with ${c.bold(`${kind}: process.env.${variable}`)}; without that line the`,
1251
+ "variable is used directly.",
1252
+ `Get it from the Developer Portal under ${where}:`,
1253
+ link(PORTAL_URL)
1254
+ ];
1255
+ }
1256
+ //#endregion
1257
+ //#region src/cli/compile.ts
1258
+ /**
1259
+ * Compiles the app, runs plugin transforms, and prints every diagnostic. Returns `null` when
1260
+ * any is an error.
1261
+ */
1262
+ async function compileProject(project, io) {
1263
+ const graph = await buildGraph(project.appDir);
1264
+ graph.diagnostics.items.push(...checkIntents(graph.events, project.config.intents, path.basename(project.configFile)));
1265
+ if (!graph.diagnostics.hasErrors) await applyPlugins(graph, project.config.plugins ?? []);
1266
+ for (const diagnostic of graph.diagnostics.items) io.err(formatDiagnostic(diagnostic, project.root));
1267
+ if (graph.diagnostics.hasErrors) {
1268
+ const errors = graph.diagnostics.items.filter((d) => d.severity === "error").length;
1269
+ io.err(fail(`${errors} error${errors === 1 ? "" : "s"}. Fix the files above and run again.`));
1270
+ return null;
1271
+ }
1272
+ return graph;
1273
+ }
1274
+ /**
1275
+ * One diagnostic as a headline and an indented message:
1276
+ *
1277
+ * ✖ error invalid-name app/commands/Bad Name/command.ts
1278
+ * Command names must be lowercase ...
1279
+ */
1280
+ function formatDiagnostic(diagnostic, root) {
1281
+ const mark = diagnostic.severity === "error" ? fail(c.red("error")) : warn(c.yellow("warning"));
1282
+ const where = diagnostic.file === void 0 ? "" : ` ${relative(root, diagnostic.file)}`;
1283
+ return [`${mark} ${c.dim(diagnostic.code)}${where}`, ...indent([diagnostic.message])].join("\n");
1284
+ }
1285
+ function relative(root, file) {
1286
+ return path.relative(root, file).split(path.sep).join("/");
1287
+ }
1288
+ function summary(graph) {
1289
+ const n = (count, noun) => `${count} ${noun}${count === 1 ? "" : "s"}`;
1290
+ return [
1291
+ n(graph.commands.length, "command"),
1292
+ n(graph.components.length, "component route"),
1293
+ n(graph.events.length, "event")
1294
+ ].join(", ");
1295
+ }
1296
+ /**
1297
+ * A problem the user can fix. Printed without a stack trace: the message as the headline,
1298
+ * then `details` indented under it (what went wrong in full, and what to do about it).
1299
+ */
1300
+ var CliError = class extends Error {
1301
+ code;
1302
+ details;
1303
+ constructor(message, options = {}) {
1304
+ super(message);
1305
+ this.name = "CliError";
1306
+ this.code = options.code ?? 1;
1307
+ this.details = options.details ?? [];
1308
+ }
1309
+ };
1310
+ //#endregion
1311
+ //#region src/cli/project.ts
1312
+ const CONFIG_FILES = ["nectar.config.ts", "nectar.config.js"];
1313
+ /** Finds and validates `nectar.config.ts` in `cwd`. */
1314
+ async function loadProject(cwd, env) {
1315
+ const configFile = CONFIG_FILES.map((name) => path.join(cwd, name)).find((f) => existsSync(f));
1316
+ if (configFile === void 0) throw new CliError(`No ${CONFIG_FILES[0]} in ${cwd}.`, { details: ["Run nectar from the directory that holds your config file.", `Starting fresh? ${c.bold("npm create @nectar-js")} sets up a project.`] });
1317
+ const name = path.basename(configFile);
1318
+ let loaded;
1319
+ try {
1320
+ const module = await loadModule(configFile);
1321
+ loaded = validateConfig(module.default, name);
1322
+ } catch (error) {
1323
+ if (error instanceof ConfigError) throw new CliError(`${name} is not valid.`, { details: [error.detail] });
1324
+ throw new CliError(`Could not load ${name}.`, { details: [describe(error)] });
1325
+ }
1326
+ const projectEnv = loaded.env ?? envFrom(env.NODE_ENV);
1327
+ const config = configFor(loaded, projectEnv);
1328
+ const root = path.dirname(configFile);
1329
+ const appDir = path.resolve(root, config.appDir ?? "app");
1330
+ if (!existsSync(appDir)) throw new CliError(`App directory ${path.relative(root, appDir) || "."}/ does not exist.`, { details: [`Create it, or point ${c.bold("appDir")} in ${name} at the right place.`] });
1331
+ return {
1332
+ root,
1333
+ configFile,
1334
+ config,
1335
+ appDir,
1336
+ outDir: path.resolve(root, config.outDir ?? ".nectar"),
1337
+ env: projectEnv
1338
+ };
1339
+ }
1340
+ function envFrom(value) {
1341
+ return value === "production" || value === "test" ? value : "development";
1342
+ }
1343
+ function describe(error) {
1344
+ return error instanceof Error ? error.message : String(error);
1345
+ }
1346
+ //#endregion
1347
+ //#region src/cli/build.ts
1348
+ /** `.mjs` so it loads as ESM whatever the project's `package.json` says, and PM2 imports it. */
1349
+ const START_FILE = "start.mjs";
1350
+ /** `nectar build`: compile, then write the manifest, generated types, and `start.mjs` into `outDir`. */
1351
+ async function build(io) {
1352
+ const project = await loadProject(io.cwd, io.env);
1353
+ const graph = await compileProject(project, io);
1354
+ if (graph === null) return 1;
1355
+ const manifestFile = writeManifest(toManifest(graph, project.outDir), project.outDir);
1356
+ const typesFile = writeTypes(graph, project.outDir, project.config.plugins);
1357
+ const startFile = writeStart(project);
1358
+ io.out(ok(`Built ${summary(graph)}.`));
1359
+ for (const file of [
1360
+ manifestFile,
1361
+ typesFile,
1362
+ startFile
1363
+ ]) io.out(` ${c.dim(relative(project.root, file))}`);
1364
+ return 0;
1365
+ }
1366
+ /**
1367
+ * `node .nectar/start.mjs` does what `nectar start` does, from any working directory. It is the
1368
+ * file to hand a ShardingManager or cluster manager: each shard loads the manifest itself and
1369
+ * none of them registers commands.
1370
+ */
1371
+ function writeStart(project) {
1372
+ const root = path.relative(project.outDir, project.root).split(path.sep).join("/");
1373
+ const file = path.join(project.outDir, START_FILE);
1374
+ writeFileSync(file, [
1375
+ "// Written by nectar build. Runs the bot from this build, like `nectar start`.",
1376
+ "import { start } from \"@nectar-js/nectar/start\";",
1377
+ "",
1378
+ `await start(new URL(${JSON.stringify(`${root || "."}/`)}, import.meta.url));`,
1379
+ ""
1380
+ ].join("\n"));
1381
+ return file;
1382
+ }
1383
+ /** `nectar check`: compile and report, writing nothing. */
1384
+ async function check(io) {
1385
+ const project = await loadProject(io.cwd, io.env);
1386
+ const graph = await compileProject(project, io);
1387
+ if (graph === null) return 1;
1388
+ const warnings = graph.diagnostics.items.length;
1389
+ io.out(warnings === 0 ? ok(`No problems. ${summary(graph)} in ${path.relative(project.root, project.appDir) || "."}/.`) : warn(`${warnings} warning${warnings === 1 ? "" : "s"}, no errors.`));
1390
+ return 0;
1391
+ }
1392
+ //#endregion
1393
+ //#region src/cli/routes.ts
1394
+ /** `nectar routes`: print the app tree with what every file and directory means. */
1395
+ async function routes(io) {
1396
+ const project = await loadProject(io.cwd, io.env);
1397
+ const graph = await compileProject(project, io);
1398
+ if (graph === null) return 1;
1399
+ io.out(renderRoutes(graph, project.root));
1400
+ return 0;
1401
+ }
1402
+ /**
1403
+ * A directory tree of the app. Handler files annotate their directory; middleware, error, and
1404
+ * route files appear as leaves so the scope of each is where it sits in the tree.
1405
+ */
1406
+ function renderRoutes(graph, root) {
1407
+ const tree = {
1408
+ name: relative(root, graph.appDir) || ".",
1409
+ notes: [],
1410
+ children: /* @__PURE__ */ new Map(),
1411
+ file: false
1412
+ };
1413
+ const nodeFor = (file, asFile) => {
1414
+ const parts = relative(graph.appDir, asFile ? file : path.dirname(file)).split("/");
1415
+ let node = tree;
1416
+ for (const part of parts.filter((p) => p !== "" && p !== ".")) {
1417
+ let child = node.children.get(part);
1418
+ if (child === void 0) {
1419
+ child = {
1420
+ name: part,
1421
+ notes: [],
1422
+ children: /* @__PURE__ */ new Map(),
1423
+ file: false
1424
+ };
1425
+ node.children.set(part, child);
1426
+ }
1427
+ node = child;
1428
+ }
1429
+ node.file = asFile;
1430
+ return node;
1431
+ };
1432
+ const annotate = (route, note) => nodeFor(route.file, false).notes.push(note);
1433
+ for (const command of graph.commands) for (const [position, route] of Object.entries(command.handlers)) annotate(route, commandLabel(command.type, command.name, position));
1434
+ for (const entry of graph.autocomplete) annotate(entry.route, `autocomplete: ${entry.options.join(", ")}`);
1435
+ for (const route of graph.components) annotate(route, componentLabel(route));
1436
+ for (const event of graph.events) for (const handler of event.handlers) {
1437
+ const flags = [handler.once ? "once" : null, event.handlers.length > 1 ? event.mode : null];
1438
+ annotate(handler.route, `event ${event.name}${suffix(flags)}`);
1439
+ }
1440
+ for (const boundary of graph.boundaries) {
1441
+ const node = nodeFor(boundary.file, true);
1442
+ if (boundary.kind === "route") {
1443
+ node.notes.push("command metadata");
1444
+ continue;
1445
+ }
1446
+ const covered = graph.routes.filter((r) => {
1447
+ const chain = graph.chains.get(r.file);
1448
+ return (boundary.kind === "middleware" ? chain?.middleware : chain?.errors)?.includes(boundary.file);
1449
+ }).length;
1450
+ node.notes.push(`${boundary.kind === "middleware" ? "middleware" : "error boundary"} for ${covered} route${covered === 1 ? "" : "s"}`);
1451
+ }
1452
+ const lines = [];
1453
+ print(tree, "", true, true, lines);
1454
+ const width = Math.max(...lines.map(([left]) => left.length));
1455
+ return lines.map(([left, right]) => right === "" ? left : `${left.padEnd(width)} ${c.dim(right)}`).join("\n");
1456
+ }
1457
+ function print(node, prefix, last, isRoot, out) {
1458
+ const branch = isRoot ? "" : last ? "└── " : "├── ";
1459
+ out.push([`${prefix}${branch}${node.name}`, node.notes.join(" · ")]);
1460
+ const children = [...node.children.values()].sort((a, b) => Number(b.file) - Number(a.file) || a.name.localeCompare(b.name));
1461
+ const childPrefix = isRoot ? "" : `${prefix}${last ? " " : "│ "}`;
1462
+ children.forEach((child, i) => {
1463
+ print(child, childPrefix, i === children.length - 1, false, out);
1464
+ });
1465
+ }
1466
+ function commandLabel(type, name, position) {
1467
+ if (type === ApplicationCommandType.User) return `user context menu "${name}"`;
1468
+ if (type === ApplicationCommandType.Message) return `message context menu "${name}"`;
1469
+ return `/${[name, ...position.split("/").filter((p) => p !== "")].join(" ")}`;
1470
+ }
1471
+ function componentLabel(route) {
1472
+ return `${route.kind === "select" ? `select (${route.selectKind})` : route.kind} ${[`n:${route.shortId}`, ...route.params.map((p) => p === route.catchAll ? `<...${p}>` : `<${p}>`)].join(":")}`;
1473
+ }
1474
+ function suffix(flags) {
1475
+ const present = flags.filter((f) => f !== null);
1476
+ return present.length === 0 ? "" : ` (${present.join(", ")})`;
1477
+ }
1478
+ //#endregion
1479
+ //#region src/cli/sync.ts
1480
+ const TOKEN_VAR = "DISCORD_TOKEN";
1481
+ const APPLICATION_ID_VAR = "DISCORD_APPLICATION_ID";
1482
+ /** `nectar sync [--dry-run] [--force]`: register the compiled commands with Discord. */
1483
+ async function sync(io, dryRun, force) {
1484
+ const project = await loadProject(io.cwd, io.env);
1485
+ const graph = await compileProject(project, io);
1486
+ if (graph === null) return 1;
1487
+ if (registrationScopes(project.config, project.env).length === 0) throw new CliError(`No registration target for ${project.env}.`, { details: registrationHint(project) });
1488
+ const result = await registerCommands(project, graph, io, {
1489
+ dryRun,
1490
+ force
1491
+ });
1492
+ for (const scope of result.scopes) io.out(describeScope(scope, dryRun));
1493
+ if (result.unsafe.length > 0) {
1494
+ io.err(warn(force ? "Forced past the safety guard:" : "The safety guard would refuse this:"));
1495
+ for (const reason of result.unsafe) io.err(` ${reason}`);
1496
+ }
1497
+ return 0;
1498
+ }
1499
+ /**
1500
+ * Registers a compiled graph's commands in this environment's scopes. Missing credentials,
1501
+ * the safety guard, and Discord validation failures all surface as `CliError`.
1502
+ */
1503
+ async function registerCommands(project, graph, io, options = {}) {
1504
+ const token = credential(project, io, "token");
1505
+ const applicationId = credential(project, io, "applicationId");
1506
+ const rest = await (io.rest ?? discordRest)(token);
1507
+ try {
1508
+ return await syncCommands({
1509
+ rest,
1510
+ applicationId,
1511
+ commands: graph.commands.map((cmd) => cmd.payload),
1512
+ scopes: registrationScopes(project.config, project.env),
1513
+ cacheDir: project.outDir,
1514
+ dryRun: options.dryRun ?? false,
1515
+ force: options.force ?? false
1516
+ });
1517
+ } catch (error) {
1518
+ if (error instanceof UnsafeSyncError) throw new CliError("Refusing to register commands: this looks destructive.", { details: [
1519
+ ...error.reasons,
1520
+ "",
1521
+ `If that is what you want, run again with ${c.bold("--force")}.`
1522
+ ] });
1523
+ if (error instanceof RegistrationError) throw new CliError(`Discord rejected the ${scopeKey(error.scope)} command registration.`, { details: error.problems.map((p) => `${c.bold(p.command ?? "(request)")}${p.field === "" ? "" : ` ${c.dim(p.field)}`}: ${p.message}`) });
1524
+ throw error;
1525
+ }
1526
+ }
1527
+ /** One line per scope: what changed there and whether it was written. */
1528
+ function describeScope({ scope, diff, applied }, dryRun = false) {
1529
+ const key = c.bold(scopeKey(scope));
1530
+ if (diff === null) return ok(`${key}: unchanged since last sync.`);
1531
+ if (!diff.hasChanges) return ok(`${key}: up to date, ${diff.unchanged.length} command(s).`);
1532
+ const parts = [
1533
+ ...diff.added.map((n) => c.green(`+${n}`)),
1534
+ ...diff.changed.map((n) => c.yellow(`~${n}`)),
1535
+ ...diff.removed.map((n) => c.red(`-${n}`))
1536
+ ].join(" ");
1537
+ if (dryRun) return info$1(`${key}: ${parts} ${c.dim("(would apply)")}`);
1538
+ return applied ? ok(`${key}: ${parts} ${c.dim("(applied)")}`) : warn(`${key}: ${parts} ${c.dim("(not applied)")}`);
1539
+ }
1540
+ const CREDENTIAL_VARS = {
1541
+ token: TOKEN_VAR,
1542
+ applicationId: APPLICATION_ID_VAR
1543
+ };
1544
+ /** The config field wins; the env var is the fallback. Empty strings count as unset. */
1545
+ function findCredential(project, io, kind) {
1546
+ const value = project.config[kind] || io.env[CREDENTIAL_VARS[kind]];
1547
+ return value === void 0 || value === "" ? null : value;
1548
+ }
1549
+ function credential(project, io, kind) {
1550
+ const value = findCredential(project, io, kind);
1551
+ if (value === null) throw new CliError(`${CREDENTIAL_VARS[kind]} is not set.`, { details: credentialHint(kind, projectConfigName(project)) });
1552
+ return value;
1553
+ }
1554
+ /** A failed login as something to fix: the token, or the connection to Discord. */
1555
+ function loginFailure(error, project) {
1556
+ if (!error.invalidToken) return new CliError("Could not log in to Discord.", { details: [describe(error.cause)] });
1557
+ return new CliError("Discord rejected the bot token.", { details: [
1558
+ `The token from ${project.config.token ? `${c.bold("token")} in ${projectConfigName(project)}` : c.bold(TOKEN_VAR)} is wrong, or it was reset.`,
1559
+ "Get a new one from the Developer Portal under your application → Bot → Reset Token:",
1560
+ link(PORTAL_URL)
1561
+ ] });
1562
+ }
1563
+ function registrationHint(project) {
1564
+ return [
1565
+ `Add your test server's ID to ${c.bold("dev.guilds")} in ${projectConfigName(project)}.`,
1566
+ "Find it in Discord: Server Settings → Widget → Server ID, or right-click the server",
1567
+ "with Developer Mode on and choose Copy Server ID."
1568
+ ];
1569
+ }
1570
+ async function discordRest(token) {
1571
+ const { REST } = await import("discord.js");
1572
+ return new REST().setToken(token);
1573
+ }
1574
+ function projectConfigName(project) {
1575
+ return project.configFile.split(/[\\/]/).at(-1) ?? "nectar.config.ts";
1576
+ }
1577
+ //#endregion
1578
+ //#region src/dev/classify.ts
1579
+ const SOURCE_EXTENSIONS = /* @__PURE__ */ new Set([
1580
+ ".ts",
1581
+ ".js",
1582
+ ".mts",
1583
+ ".mjs",
1584
+ ".cts",
1585
+ ".cjs",
1586
+ ".json"
1587
+ ]);
1588
+ function classifyPath(file, project) {
1589
+ const absolute = path.resolve(file);
1590
+ if (absolute === path.resolve(project.configFile)) return "config";
1591
+ const parts = absolute.split(path.sep);
1592
+ if (parts.includes("node_modules") || parts.includes(".git") || within(project.outDir, absolute)) return "ignored";
1593
+ const name = path.basename(absolute);
1594
+ if (within(project.appDir, absolute)) {
1595
+ if (path.extname(name) === "" || reservedKind(name) !== void 0) return "route";
1596
+ }
1597
+ if (!SOURCE_EXTENSIONS.has(path.extname(name))) return "ignored";
1598
+ if (/\.(test|spec)\.[cm]?[jt]s$/.test(name)) return "ignored";
1599
+ return "dependency";
1600
+ }
1601
+ function within(dir, file) {
1602
+ const rel = path.relative(path.resolve(dir), file);
1603
+ return rel !== "" && !rel.startsWith("..") && !path.isAbsolute(rel);
1604
+ }
1605
+ function diffManifests(before, after) {
1606
+ const payloads = (m) => stableStringify(m.commands.map((c) => c.payload));
1607
+ const shape = (m) => stableStringify({
1608
+ routes: m.routes,
1609
+ events: m.events,
1610
+ commands: m.commands.map(({ payload: _payload, ...rest }) => rest)
1611
+ });
1612
+ return {
1613
+ structure: shape(before) !== shape(after),
1614
+ commands: payloads(before) !== payloads(after)
1615
+ };
1616
+ }
1617
+ //#endregion
1618
+ //#region src/dev/server.ts
1619
+ /**
1620
+ * The state behind `nectar dev`: the compiled manifest and the runtime serving it. Changes are
1621
+ * handled in the smallest way that keeps the running bot correct, and the gateway connection
1622
+ * survives everything but a config change.
1623
+ */
1624
+ function createDevServer(project, io, options = {}) {
1625
+ const verbose = options.verbose ?? false;
1626
+ let current = project;
1627
+ let manifest = null;
1628
+ let runtime = null;
1629
+ let token = "";
1630
+ let started = false;
1631
+ const warned = /* @__PURE__ */ new Set();
1632
+ /** Lines printed while the server is running carry a timestamp; startup lines do not. */
1633
+ const say = (line) => io.out(started ? `${stamp()} ${line}` : line);
1634
+ const complain = (line) => io.err(started ? `${stamp()} ${line}` : line);
1635
+ /** Framework log records in the dev server's format. The default when the config has no sink. */
1636
+ const print = ({ level, message, fields }) => {
1637
+ if (level === "debug") {
1638
+ const pairs = Object.entries(fields).filter(([key, value]) => key !== "error" && value !== void 0 && value !== null).map(([key, value]) => `${key}=${String(value)}`);
1639
+ say(c.dim([message, ...pairs].join(" ")));
1640
+ } else if (level === "info") say(info$1(message));
1641
+ else if (level === "warn") complain(warn(message));
1642
+ else {
1643
+ const [head = "", ...rest] = message.split("\n");
1644
+ const stack = fields.error === void 0 ? [] : describeError(fields.error);
1645
+ complain(block(fail(c.bold(head)), [...rest.map((l) => l.replace(/^ {2}/, "")), ...stack]));
1646
+ }
1647
+ };
1648
+ /** The config's `logger`, with `--verbose` lowering the level to debug. */
1649
+ const loggerFor = ({ config }) => createLogger({
1650
+ level: verbose ? "debug" : config.logger?.level ?? "info",
1651
+ sink: config.logger?.sink ?? print
1652
+ });
1653
+ let logger = loggerFor(current);
1654
+ let signals = createSignals(logger);
1655
+ function warnOnce(head, details = []) {
1656
+ if (warned.has(head)) return;
1657
+ warned.add(head);
1658
+ complain(block(warn(head), details));
1659
+ }
1660
+ function emit(graph) {
1661
+ const next = toManifest(graph, current.outDir);
1662
+ writeManifest(next, current.outDir);
1663
+ writeTypes(graph, current.outDir, current.config.plugins);
1664
+ return next;
1665
+ }
1666
+ async function register(graph) {
1667
+ const scopes = registrationScopes(current.config, current.env);
1668
+ if (scopes.length === 0) {
1669
+ warnOnce("Commands are not registered anywhere yet.", registrationHint(current));
1670
+ return;
1671
+ }
1672
+ if (findCredential(current, io, "applicationId") === null) {
1673
+ warnOnce(`Commands are not registered: ${APPLICATION_ID_VAR} is not set.`, credentialHint("applicationId", projectConfigName(current)));
1674
+ return;
1675
+ }
1676
+ signals.emit({
1677
+ type: "registration:start",
1678
+ scopes: scopes.map(scopeKey)
1679
+ });
1680
+ const startedAt = Date.now();
1681
+ try {
1682
+ const result = await registerCommands(current, graph, io);
1683
+ signals.emit({
1684
+ type: "registration:complete",
1685
+ scopes: result.scopes.map((s) => ({
1686
+ scope: scopeKey(s.scope),
1687
+ applied: s.applied
1688
+ })),
1689
+ duration: Date.now() - startedAt
1690
+ });
1691
+ for (const scope of result.scopes) if (scope.diff !== null || verbose) say(describeScope(scope));
1692
+ } catch (error) {
1693
+ if (!(error instanceof CliError)) throw error;
1694
+ complain(block(fail(error.message), error.details.map((line) => line.replace("run again with", "run nectar sync with"))));
1695
+ }
1696
+ }
1697
+ async function launch() {
1698
+ if (manifest === null) return;
1699
+ const next = createRuntime({
1700
+ manifest,
1701
+ appDir: current.appDir,
1702
+ config: current.config,
1703
+ env: current.env,
1704
+ logger,
1705
+ signals,
1706
+ ...io.client === void 0 ? {} : { client: io.client(current.config) }
1707
+ });
1708
+ watchConnection(next.client);
1709
+ runtime = next;
1710
+ try {
1711
+ await next.start({
1712
+ token,
1713
+ signals: false
1714
+ });
1715
+ } catch (error) {
1716
+ throw error instanceof LoginError ? loginFailure(error, current) : error;
1717
+ }
1718
+ }
1719
+ function watchConnection(client) {
1720
+ client.once(Events.ClientReady, (ready) => say(ok(`Logged in as ${c.bold(ready.user.tag)}.`)));
1721
+ client.on(Events.ShardDisconnect, (event) => {
1722
+ complain(warn(`Disconnected from the gateway ${c.dim(`(code ${event.code})`)}.`));
1723
+ });
1724
+ client.on(Events.ShardReconnecting, () => say(info$1("Reconnecting to the gateway.")));
1725
+ client.on(Events.ShardResume, () => say(ok("Gateway connection resumed.")));
1726
+ client.on(Events.Error, (error) => complain(fail(`Gateway error: ${error.message}`)));
1727
+ if (verbose) client.on(Events.Warn, (message) => complain(warn(`discord.js: ${message}`)));
1728
+ }
1729
+ /** Full start: compile, write output, print routes, register, run. */
1730
+ async function boot() {
1731
+ logger = loggerFor(current);
1732
+ signals = createSignals(logger);
1733
+ const graph = await compileProject(current, io);
1734
+ if (graph === null) {
1735
+ complain(warn("Waiting for changes."));
1736
+ return;
1737
+ }
1738
+ manifest = emit(graph);
1739
+ say(ok(`${summary(graph)} in ${c.bold(appLabel())}`));
1740
+ if (verbose) for (const line of indent(renderRoutes(graph, current.root).split("\n"))) say(line);
1741
+ await register(graph);
1742
+ await launch();
1743
+ }
1744
+ async function stopRuntime() {
1745
+ const running = runtime;
1746
+ runtime = null;
1747
+ manifest = null;
1748
+ if (running !== null) await running.stop();
1749
+ }
1750
+ async function restart() {
1751
+ await stopRuntime();
1752
+ try {
1753
+ current = await loadProject(io.cwd, io.env);
1754
+ } catch (error) {
1755
+ if (!(error instanceof CliError)) throw error;
1756
+ complain(block(fail(error.message), error.details));
1757
+ complain(warn("Waiting for changes."));
1758
+ return;
1759
+ }
1760
+ say(info$1(`Restarting with the new ${c.bold(projectConfigName(current))}.`));
1761
+ await boot();
1762
+ }
1763
+ /** Imports files now so a broken module shows up here, not on the next interaction. */
1764
+ async function preload(files) {
1765
+ if (runtime === null) return;
1766
+ const results = await Promise.allSettled(files.map((file) => runtime?.modules.load(file)));
1767
+ for (const result of results) {
1768
+ if (result.status === "fulfilled") continue;
1769
+ const error = result.reason;
1770
+ complain(error instanceof HandlerLoadError ? block(fail(`${c.bold(relative(current.root, error.file))} failed to load.`), [error.detail]) : fail(String(error)));
1771
+ }
1772
+ }
1773
+ function appLabel() {
1774
+ return `${relative(current.root, current.appDir) || "."}/`;
1775
+ }
1776
+ return {
1777
+ async start() {
1778
+ token = credential(current, io, "token");
1779
+ enableModuleReloading(current.root);
1780
+ await boot();
1781
+ started = true;
1782
+ },
1783
+ async apply(files) {
1784
+ const kinds = new Map(files.map((file) => [file, classifyPath(file, current)]));
1785
+ const changed = files.filter((file) => kinds.get(file) !== "ignored");
1786
+ if (changed.length === 0) return;
1787
+ for (const file of changed) {
1788
+ const gone = !existsSync(file);
1789
+ say(`${gone ? c.red("-") : c.yellow("~")} ${relative(current.root, file)}`);
1790
+ }
1791
+ if (changed.some((file) => kinds.get(file) === "config")) {
1792
+ await restart();
1793
+ return;
1794
+ }
1795
+ const dependency = changed.some((file) => kinds.get(file) === "dependency");
1796
+ if (dependency) invalidateModuleGraph();
1797
+ if (runtime === null || manifest === null) {
1798
+ await boot();
1799
+ return;
1800
+ }
1801
+ const graph = await compileProject(current, io);
1802
+ if (graph === null) {
1803
+ complain(warn("Keeping the previous routes until this is fixed."));
1804
+ return;
1805
+ }
1806
+ const next = toManifest(graph, current.outDir);
1807
+ const delta = diffManifests(manifest, next);
1808
+ const done = [];
1809
+ if (delta.structure || delta.commands) manifest = emit(graph);
1810
+ if (delta.structure) {
1811
+ runtime.update(manifest);
1812
+ done.push(`routes rebuilt ${c.dim(`(${summary(graph)})`)}`);
1813
+ if (verbose) for (const line of indent(renderRoutes(graph, current.root).split("\n"))) say(line);
1814
+ }
1815
+ if (delta.commands) await register(graph);
1816
+ const all = manifestFiles(manifest, current.appDir);
1817
+ const stale = dependency ? [...all] : changed.filter((file) => all.has(file));
1818
+ runtime.modules.invalidate(dependency ? void 0 : stale);
1819
+ await preload(stale);
1820
+ if (dependency) done.push("every module reloaded");
1821
+ else if (stale.length > 0) done.push(`${stale.length} handler module${stale.length === 1 ? "" : "s"} reloaded`);
1822
+ say(done.length === 0 ? info$1("Nothing to reload.") : ok(`${capitalize(done.join(", "))}.`));
1823
+ },
1824
+ stop: stopRuntime
1825
+ };
1826
+ }
1827
+ function capitalize(text) {
1828
+ return text.charAt(0).toUpperCase() + text.slice(1);
1829
+ }
1830
+ function describeError(error) {
1831
+ return (error instanceof Error ? error.stack ?? error.message : String(error)).split("\n").map((line) => c.dim(line));
1832
+ }
1833
+ //#endregion
1834
+ //#region src/dev/watch.ts
1835
+ /**
1836
+ * Watches a directory tree and reports changed paths in debounced batches. Editors write a
1837
+ * file in several steps and `fs.watch` reports each one, so a batch collapses them to one
1838
+ * change and lets several files saved together be handled together.
1839
+ */
1840
+ function watchTree(root, onChange, options = {}) {
1841
+ const { debounce = 80, onError } = options;
1842
+ const pending = /* @__PURE__ */ new Set();
1843
+ let timer = null;
1844
+ const flush = () => {
1845
+ timer = null;
1846
+ const files = [...pending];
1847
+ pending.clear();
1848
+ onChange(files);
1849
+ };
1850
+ const watcher = watch(root, { recursive: true }, (_event, filename) => {
1851
+ if (filename === null) return;
1852
+ pending.add(path.join(root, filename.toString()));
1853
+ if (timer !== null) clearTimeout(timer);
1854
+ timer = setTimeout(flush, debounce);
1855
+ });
1856
+ if (onError !== void 0) watcher.on("error", onError);
1857
+ return { close() {
1858
+ if (timer !== null) clearTimeout(timer);
1859
+ watcher.close();
1860
+ } };
1861
+ }
1862
+ //#endregion
1863
+ //#region src/cli/dev.ts
1864
+ /** `nectar dev [--verbose]`: compile, register dev guild commands, run, and react to file changes. */
1865
+ async function dev(io, verbose) {
1866
+ io.out(`${c.bold("nectar dev")} ${c.dim(`v${version}`)}`);
1867
+ io.out("");
1868
+ const project = await loadProject(io.cwd, io.env);
1869
+ const server = createDevServer(project, io, { verbose });
1870
+ await server.start();
1871
+ io.out(info$1(`Watching ${c.bold(`${relative(project.root, project.appDir) || "."}/`)}, ${c.bold(relative(project.root, project.configFile))}, and the files they import. ${c.dim("Ctrl+C stops.")}`));
1872
+ let queue = Promise.resolve();
1873
+ const watcher = watchTree(project.root, (files) => {
1874
+ queue = queue.then(() => server.apply(files)).catch((error) => {
1875
+ io.err(`${stamp()} ${error instanceof CliError ? block(fail(error.message), error.details) : fail(describe(error))}`);
1876
+ });
1877
+ }, { onError: (error) => io.err(`${stamp()} ${fail(`Watcher error: ${error.message}`)}`) });
1878
+ await new Promise((resolve) => {
1879
+ process.once("SIGINT", () => resolve());
1880
+ process.once("SIGTERM", () => resolve());
1881
+ });
1882
+ io.out("");
1883
+ io.out(info$1("Stopping."));
1884
+ watcher.close();
1885
+ await queue;
1886
+ await server.stop();
1887
+ return 0;
1888
+ }
1889
+ //#endregion
1890
+ //#region src/cli/manifest.ts
1891
+ /** `nectar manifest [--route <id>]`: print the compiled manifest, or everything about one route. */
1892
+ async function manifest(io, route) {
1893
+ const project = await loadProject(io.cwd, io.env);
1894
+ const graph = await compileProject(project, io);
1895
+ if (graph === null) return 1;
1896
+ const compiled = toManifest(graph, project.outDir);
1897
+ if (route === void 0) {
1898
+ io.out(stableStringify(compiled));
1899
+ return 0;
1900
+ }
1901
+ const matches = compiled.routes.filter((r) => r.id === route || r.path === route);
1902
+ if (matches.length === 0) {
1903
+ const known = [...new Set(compiled.routes.map((r) => r.id))].sort();
1904
+ throw new CliError(`No route "${route}".`, { details: ["Known routes:", ...known.map((id) => ` ${id}`)] });
1905
+ }
1906
+ io.out(stableStringify(matches.map((r) => describeRoute(r, compiled))));
1907
+ return 0;
1908
+ }
1909
+ /** The route record plus what the manifest links it to: its command payload, custom ID, or event. */
1910
+ function describeRoute(route, compiled) {
1911
+ const detail = { ...route };
1912
+ if (route.kind === "command" || route.kind === "autocomplete") for (const command of compiled.commands) {
1913
+ const position = Object.entries(command.handlers).find(([, id]) => id === route.id)?.[0];
1914
+ if (position === void 0) continue;
1915
+ detail.command = {
1916
+ name: command.name,
1917
+ position,
1918
+ payload: command.payload
1919
+ };
1920
+ if (route.kind === "autocomplete") detail.commandRoute = compiled.routes.find((r) => r.kind === "command" && r.id === route.id)?.file;
1921
+ }
1922
+ else if (route.kind === "event") {
1923
+ const event = compiled.events.find((e) => e.name === route.event);
1924
+ if (event !== void 0) detail.eventHandlers = {
1925
+ mode: event.mode,
1926
+ handlers: event.handlers
1927
+ };
1928
+ } else detail.customId = [`n:${route.shortId}`, ...route.params.map((p) => p === route.catchAll ? `<...${p}>` : `<${p}>`)].join(":");
1929
+ return detail;
1930
+ }
1931
+ //#endregion
1932
+ //#region src/cli/misc.ts
1933
+ /** `nectar clean`: delete the build output directory. */
1934
+ async function clean(io) {
1935
+ const project = await loadProject(io.cwd, io.env);
1936
+ const label = `${relative(project.root, project.outDir)}/`;
1937
+ if (!existsSync(project.outDir)) {
1938
+ io.out(info$1(`Nothing to remove, ${c.bold(label)} does not exist.`));
1939
+ return 0;
1940
+ }
1941
+ rmSync(project.outDir, {
1942
+ recursive: true,
1943
+ force: true
1944
+ });
1945
+ io.out(ok(`Removed ${c.bold(label)}`));
1946
+ return 0;
1947
+ }
1948
+ /** `nectar info`: versions, environment, and the config values that decide runtime behaviour. */
1949
+ async function info(io) {
1950
+ const rows = [
1951
+ ["nectar", version],
1952
+ ["node", process.version],
1953
+ ["discord.js", await discordVersion()],
1954
+ ["platform", `${process.platform} ${process.arch}`]
1955
+ ];
1956
+ try {
1957
+ const project = await loadProject(io.cwd, io.env);
1958
+ const { config } = project;
1959
+ const scopes = registrationScopes(config, project.env);
1960
+ rows.push([TOKEN_VAR, present(findCredential(project, io, "token"))], [APPLICATION_ID_VAR, present(findCredential(project, io, "applicationId"))], ["config", relative(project.root, project.configFile)], ["env", project.env], ["appDir", relative(project.root, project.appDir) || "."], ["outDir", relative(project.root, project.outDir)], ["intents", describeBitfield(config.intents)], ["partials", config.partials === void 0 ? "none" : String(config.partials.length)], ["eager", String(config.eager ?? project.env === "production")], ["registration", scopes.length === 0 ? c.yellow("none") : scopes.map(scopeKey).join(", ")], ["plugins", (config.plugins ?? []).map((p) => p.name).join(", ") || "none"]);
1961
+ } catch (error) {
1962
+ if (!(error instanceof CliError)) throw error;
1963
+ rows.push([TOKEN_VAR, present(io.env["DISCORD_TOKEN"] || null)], [APPLICATION_ID_VAR, present(io.env["DISCORD_APPLICATION_ID"] || null)], ["config", c.yellow(error.message)]);
1964
+ }
1965
+ for (const line of table(rows)) io.out(line);
1966
+ return 0;
1967
+ }
1968
+ async function discordVersion() {
1969
+ try {
1970
+ const { version: v } = await import("discord.js");
1971
+ return v;
1972
+ } catch {
1973
+ return c.yellow("not installed");
1974
+ }
1975
+ }
1976
+ function present(value) {
1977
+ return value === null || value === "" ? c.yellow("not set") : c.green("set");
1978
+ }
1979
+ function describeBitfield(value) {
1980
+ if (Array.isArray(value)) return value.length === 0 ? "none" : value.map(String).join(", ");
1981
+ return String(value);
1982
+ }
1983
+ //#endregion
1984
+ //#region src/cli/start.ts
1985
+ /** `nectar start`: run the bot from the last `nectar build`. No source discovery happens here. */
1986
+ async function start(io) {
1987
+ const project = await loadProject(io.cwd, io.env);
1988
+ const manifestFile = path.join(project.outDir, MANIFEST_FILE);
1989
+ if (!existsSync(manifestFile)) throw new CliError(`${relative(project.root, manifestFile)} not found.`, { details: [`Run ${c.bold("nectar build")} first, then ${c.bold("nectar start")} again.`] });
1990
+ const token = credential(project, io, "token");
1991
+ const { manifest, appDir } = loadManifest(manifestFile);
1992
+ const runtime = createRuntime({
1993
+ manifest,
1994
+ appDir,
1995
+ config: project.config,
1996
+ env: project.env,
1997
+ ...io.client === void 0 ? {} : { client: io.client(project.config) }
1998
+ });
1999
+ runtime.client.once("clientReady", (client) => {
2000
+ io.out(ok(`Logged in as ${c.bold(client.user.tag)} (${project.env}${shards(client)}).`));
2001
+ });
2002
+ try {
2003
+ await runtime.start({ token });
2004
+ } catch (error) {
2005
+ throw error instanceof LoginError ? loginFailure(error, project) : error;
2006
+ }
2007
+ return 0;
2008
+ }
2009
+ /** `, shard 2 of 4` when the bot is sharded, so each shard process's line says which it is. */
2010
+ function shards(client) {
2011
+ const { shards: ids, shardCount } = client.options;
2012
+ if (!Array.isArray(ids) || shardCount === void 0 || shardCount < 2) return "";
2013
+ return `, shard${ids.length === 1 ? "" : "s"} ${ids.join(", ")} of ${shardCount}`;
2014
+ }
2015
+ //#endregion
2016
+ //#region src/cli/index.ts
2017
+ const COMMANDS = {
2018
+ dev: {
2019
+ usage: "dev [--verbose]",
2020
+ description: "Compile, register dev guild commands, run the bot, and reload on changes.",
2021
+ options: { verbose: {
2022
+ type: "boolean",
2023
+ description: "Print the route tree and discord.js warnings."
2024
+ } },
2025
+ run: (io, flags) => dev(io, flags.verbose === true)
2026
+ },
2027
+ build: {
2028
+ usage: "build",
2029
+ description: "Compile the app and write the manifest and types.",
2030
+ run: (io) => build(io)
2031
+ },
2032
+ check: {
2033
+ usage: "check",
2034
+ description: "Compile and report problems without writing anything.",
2035
+ run: (io) => check(io)
2036
+ },
2037
+ routes: {
2038
+ usage: "routes",
2039
+ description: "Show the app tree: commands, components, events, middleware, error boundaries.",
2040
+ run: (io) => routes(io)
2041
+ },
2042
+ manifest: {
2043
+ usage: "manifest [--route <id>]",
2044
+ description: "Print the compiled manifest, or everything about one route.",
2045
+ options: { route: {
2046
+ type: "string",
2047
+ description: "Route ID or path, e.g. command:moderation/ban."
2048
+ } },
2049
+ run: (io, flags) => manifest(io, flags.route)
2050
+ },
2051
+ sync: {
2052
+ usage: "sync [--dry-run] [--force]",
2053
+ description: "Register commands with Discord, writing only scopes that changed.",
2054
+ options: {
2055
+ "dry-run": {
2056
+ type: "boolean",
2057
+ description: "Show the diff without changing anything."
2058
+ },
2059
+ force: {
2060
+ type: "boolean",
2061
+ description: "Proceed even when the change looks destructive."
2062
+ }
2063
+ },
2064
+ run: (io, flags) => sync(io, flags["dry-run"] === true, flags.force === true)
2065
+ },
2066
+ start: {
2067
+ usage: "start",
2068
+ description: "Run the bot from the last build.",
2069
+ run: (io) => start(io)
2070
+ },
2071
+ clean: {
2072
+ usage: "clean",
2073
+ description: "Delete the build output directory.",
2074
+ run: (io) => clean(io)
2075
+ },
2076
+ info: {
2077
+ usage: "info",
2078
+ description: "Show versions, environment, and the effective config.",
2079
+ run: (io) => info(io)
2080
+ }
2081
+ };
2082
+ /**
2083
+ * Commands contributed by the config's plugins. Loading the config can fail; for help that is
2084
+ * silent, for a command it is the error the user needs to see.
2085
+ */
2086
+ async function pluginCommands(io, tolerant) {
2087
+ if (!CONFIG_FILES.some((file) => existsSync(path.join(io.cwd, file)))) return {};
2088
+ const commands = {};
2089
+ try {
2090
+ const project = await loadProject(io.cwd, io.env);
2091
+ for (const plugin of project.config.plugins ?? []) for (const command of plugin.commands ?? []) {
2092
+ const options = command.options ?? {};
2093
+ const usage = [command.name, ...Object.entries(options).map(([key, opt]) => `[--${key}${opt.type === "string" ? " <value>" : ""}]`)].join(" ");
2094
+ commands[command.name] = {
2095
+ usage,
2096
+ description: command.description,
2097
+ options,
2098
+ run: async (io, flags) => command.run({
2099
+ project,
2100
+ flags,
2101
+ out: io.out,
2102
+ err: io.err
2103
+ })
2104
+ };
2105
+ }
2106
+ } catch (error) {
2107
+ if (!tolerant || !(error instanceof CliError)) throw error;
2108
+ }
2109
+ return commands;
2110
+ }
2111
+ /** Runs `run` for this process: loads `cwd/.env`, colors a TTY, prints to the console. */
2112
+ async function main(argv, cwd) {
2113
+ const envFile = path.join(cwd, ".env");
2114
+ if (existsSync(envFile)) process.loadEnvFile(envFile);
2115
+ const wantsColor = process.env.NO_COLOR === void 0 || process.env.NO_COLOR === "";
2116
+ setColors(process.env.FORCE_COLOR !== void 0 || wantsColor && process.stdout.isTTY === true);
2117
+ return run(argv, {
2118
+ cwd,
2119
+ env: process.env,
2120
+ out: (line) => console.log(line),
2121
+ err: (line) => console.error(line)
2122
+ });
2123
+ }
2124
+ /** Runs one CLI invocation. `argv` excludes the node and script entries. */
2125
+ async function run(argv, io) {
2126
+ const [name, ...rest] = argv;
2127
+ if (name === void 0 || name === "--help" || name === "-h" || name === "help") {
2128
+ io.out(help(await pluginCommands(io, true)));
2129
+ return name === void 0 ? 2 : 0;
2130
+ }
2131
+ if (name === "--version" || name === "-v") {
2132
+ io.out(version);
2133
+ return 0;
2134
+ }
2135
+ let command = COMMANDS[name];
2136
+ if (command === void 0) try {
2137
+ command = (await pluginCommands(io, false))[name];
2138
+ } catch (error) {
2139
+ if (!(error instanceof CliError)) throw error;
2140
+ io.err(block(fail(error.message), error.details));
2141
+ return error.code;
2142
+ }
2143
+ if (command === void 0) {
2144
+ io.err(block(fail(`Unknown command ${c.bold(`"${name}"`)}.`), [`Run ${c.bold("nectar --help")} to see the commands.`]));
2145
+ return 2;
2146
+ }
2147
+ let flags;
2148
+ try {
2149
+ flags = parseArgs({
2150
+ args: rest,
2151
+ options: {
2152
+ ...Object.fromEntries(Object.entries(command.options ?? {}).map(([key, opt]) => [key, { type: opt.type }])),
2153
+ help: { type: "boolean" }
2154
+ },
2155
+ strict: true,
2156
+ allowPositionals: false
2157
+ }).values;
2158
+ } catch (error) {
2159
+ io.err(fail(describe(error)));
2160
+ io.err("");
2161
+ io.err(commandHelp(command));
2162
+ return 2;
2163
+ }
2164
+ if (flags.help === true) {
2165
+ io.out(commandHelp(command));
2166
+ return 0;
2167
+ }
2168
+ try {
2169
+ return await command.run(io, flags);
2170
+ } catch (error) {
2171
+ if (error instanceof CliError) {
2172
+ io.err(block(fail(error.message), error.details));
2173
+ return error.code;
2174
+ }
2175
+ if (error instanceof PluginError) {
2176
+ io.err(block(fail(error.message), [`Fix or remove the plugin in your config.`]));
2177
+ return 1;
2178
+ }
2179
+ io.err(block(fail("Something went wrong inside Nectar."), [
2180
+ "This is a bug in Nectar, not in your app. The details:",
2181
+ "",
2182
+ ...(error instanceof Error ? error.stack ?? error.message : String(error)).split("\n").map((line) => c.dim(line))
2183
+ ]));
2184
+ return 1;
2185
+ }
2186
+ }
2187
+ function help(plugins) {
2188
+ const all = [...Object.values(COMMANDS), ...Object.values(plugins)];
2189
+ const width = Math.max(...all.map((cmd) => cmd.usage.length));
2190
+ const row = (cmd) => ` ${c.cyan(cmd.usage.padEnd(width))} ${c.dim(cmd.description)}`;
2191
+ return [
2192
+ `${c.bold("nectar")} ${c.dim(`v${version}`)} A filesystem-based meta-framework for discord.js.`,
2193
+ "",
2194
+ `${c.bold("Usage:")} nectar <command> [options]`,
2195
+ "",
2196
+ c.bold("Commands:"),
2197
+ ...Object.values(COMMANDS).map(row),
2198
+ ...Object.keys(plugins).length === 0 ? [] : [
2199
+ "",
2200
+ c.bold("Plugin commands:"),
2201
+ ...Object.values(plugins).map(row)
2202
+ ],
2203
+ "",
2204
+ c.bold("Options:"),
2205
+ ` ${c.cyan("--help, -h".padEnd(width))} ${c.dim("Show help for nectar or a command.")}`,
2206
+ ` ${c.cyan("--version, -v".padEnd(width))} ${c.dim("Print the version.")}`
2207
+ ].join("\n");
2208
+ }
2209
+ function commandHelp(command) {
2210
+ const options = Object.entries(command.options ?? {});
2211
+ const lines = [
2212
+ `${c.bold("Usage:")} nectar ${command.usage}`,
2213
+ "",
2214
+ command.description
2215
+ ];
2216
+ if (options.length > 0) {
2217
+ const width = Math.max(...options.map(([key]) => key.length));
2218
+ lines.push("", c.bold("Options:"), ...indent(options.map(([key, opt]) => `${c.cyan(`--${key.padEnd(width)}`)} ${c.dim(opt.description)}`)));
2219
+ }
2220
+ return lines.join("\n");
2221
+ }
2222
+ //#endregion
2223
+ export { main as t };
2224
+
2225
+ //# sourceMappingURL=cli-Ce-ZUj6M.js.map