meocord 3.0.0 → 3.2.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.
Files changed (76) hide show
  1. package/AUTHOR.md +2 -3
  2. package/README.md +361 -55
  3. package/dist/cjs/_shared/controller.decorator-MUHA_A3z.cjs +529 -0
  4. package/dist/cjs/core/index.cjs +246 -86
  5. package/dist/cjs/decorator/index.cjs +4 -2
  6. package/dist/cjs/enum/index.cjs +17 -4
  7. package/dist/cjs/testing/index.cjs +88 -4
  8. package/dist/esm/bin/app-template/README.md.template +61 -0
  9. package/dist/esm/bin/app-template/_env.example.template +2 -0
  10. package/dist/esm/bin/app-template/_gitignore.template +22 -0
  11. package/dist/esm/bin/app-template/_prettierrc.mjs.template +10 -0
  12. package/dist/esm/bin/app-template/eslint.config.ts.template +25 -0
  13. package/dist/esm/bin/app-template/meocord.config.ts.template +25 -0
  14. package/dist/esm/bin/app-template/package.json.template +38 -0
  15. package/dist/esm/bin/app-template/src/app.ts.template +44 -0
  16. package/dist/esm/bin/app-template/src/controllers/button/sample.button.controller.spec.ts.template +17 -0
  17. package/dist/esm/bin/app-template/src/controllers/button/sample.button.controller.ts.template +19 -0
  18. package/dist/esm/bin/app-template/src/controllers/context-menu/builders/sample.builder.ts.template +10 -0
  19. package/dist/esm/bin/app-template/src/controllers/context-menu/sample.context-menu.controller.spec.ts.template +17 -0
  20. package/dist/esm/bin/app-template/src/controllers/context-menu/sample.context-menu.controller.ts.template +13 -0
  21. package/dist/esm/bin/app-template/src/controllers/message/sample.message.controller.spec.ts.template +17 -0
  22. package/dist/esm/bin/app-template/src/controllers/message/sample.message.controller.ts.template +28 -0
  23. package/dist/esm/bin/app-template/src/controllers/modal-submit/sample.modal-submit.controller.spec.ts.template +17 -0
  24. package/dist/esm/bin/app-template/src/controllers/modal-submit/sample.modal-submit.controller.ts.template +13 -0
  25. package/dist/esm/bin/app-template/src/controllers/reaction/sample.reaction.controller.spec.ts.template +17 -0
  26. package/dist/esm/bin/app-template/src/controllers/reaction/sample.reaction.controller.ts.template +29 -0
  27. package/dist/esm/bin/app-template/src/controllers/select-menu/sample.select-menu.controller.spec.ts.template +17 -0
  28. package/dist/esm/bin/app-template/src/controllers/select-menu/sample.select-menu.controller.ts.template +11 -0
  29. package/dist/esm/bin/app-template/src/controllers/slash/builders/sample.builder.ts.template +10 -0
  30. package/dist/esm/bin/app-template/src/controllers/slash/sample.slash.controller.spec.ts.template +17 -0
  31. package/dist/esm/bin/app-template/src/controllers/slash/sample.slash.controller.ts.template +19 -0
  32. package/dist/esm/bin/app-template/src/guards/rate-limit.guard.spec.ts.template +13 -0
  33. package/dist/esm/bin/app-template/src/guards/rate-limit.guard.ts.template +52 -0
  34. package/dist/esm/bin/app-template/src/main.ts.template +14 -0
  35. package/dist/esm/bin/app-template/src/services/sample.service.spec.ts.template +17 -0
  36. package/dist/esm/bin/app-template/src/services/sample.service.ts.template +9 -0
  37. package/dist/esm/bin/app-template/tsconfig.eslint.json.template +5 -0
  38. package/dist/esm/bin/app-template/tsconfig.json.template +30 -0
  39. package/dist/esm/bin/app-template/tsconfig.test.json.template +8 -0
  40. package/dist/esm/bin/app-template/vitest.config.ts.template +35 -0
  41. package/dist/esm/bin/builder-template/builder/primary-entry-point.builder.template +26 -0
  42. package/dist/esm/bin/builder-template/controller/autocomplete.controller.template +16 -0
  43. package/dist/esm/bin/builder-template/controller/button.controller.template +1 -1
  44. package/dist/esm/bin/builder-template/controller/channel-select-menu.controller.template +12 -0
  45. package/dist/esm/bin/builder-template/controller/context-menu.controller.template +2 -2
  46. package/dist/esm/bin/builder-template/controller/mentionable-select-menu.controller.template +12 -0
  47. package/dist/esm/bin/builder-template/controller/modal-submit.controller.template +1 -1
  48. package/dist/esm/bin/builder-template/controller/primary-entry-point.controller.template +12 -0
  49. package/dist/esm/bin/builder-template/controller/role-select-menu.controller.template +12 -0
  50. package/dist/esm/bin/builder-template/controller/slash.controller.template +1 -1
  51. package/dist/esm/bin/builder-template/controller/user-select-menu.controller.template +12 -0
  52. package/dist/esm/bin/generator.js +4 -9
  53. package/dist/esm/bin/helper/app-generator.helper.js +78 -0
  54. package/dist/esm/bin/helper/controller-generator.helper.js +24 -7
  55. package/dist/esm/bin/meocord.js +156 -75
  56. package/dist/esm/core/meocord.app.js +247 -86
  57. package/dist/esm/decorator/controller.decorator.js +73 -10
  58. package/dist/esm/decorator/guard.decorator.js +1 -1
  59. package/dist/esm/decorator/index.js +1 -1
  60. package/dist/esm/enum/controller.enum.js +23 -4
  61. package/dist/esm/testing/mock-interaction.js +89 -5
  62. package/dist/esm/util/common.util.js +11 -3
  63. package/dist/esm/util/generator-cli.util.js +21 -4
  64. package/dist/esm/util/interaction.util.js +174 -0
  65. package/dist/esm/util/package-manager.util.js +9 -2
  66. package/dist/esm/util/package-version.util.js +32 -0
  67. package/dist/esm/util/runtime.util.js +72 -0
  68. package/dist/types/controller.enum-DYfhYaat.d.ts +36 -0
  69. package/dist/types/core/index.d.ts +75 -2
  70. package/dist/types/decorator/index.d.ts +61 -49
  71. package/dist/types/enum/index.d.ts +1 -1
  72. package/dist/types/interface/index.d.ts +89 -3
  73. package/dist/types/testing/index.d.ts +3 -21
  74. package/package.json +11 -12
  75. package/dist/cjs/_shared/controller.decorator-CC6BjHkS.cjs +0 -288
  76. package/dist/types/controller.enum-QA-IuReF.d.ts +0 -18
package/AUTHOR.md CHANGED
@@ -1,8 +1,8 @@
1
1
  # Authors
2
2
 
3
3
  - **Ukasyah Rahmatullah Zada**
4
- - Primary creator and maintainer of the MeoCord Framework.
5
- - Contact: [ukasyahrz@outlook.com](mailto:ukasyahrz@outlook.com)
4
+ - Primary creator and maintainer of the MeoCord Framework.
5
+ - Contact: [ukasyahrz@outlook.com](mailto:ukasyahrz@outlook.com)
6
6
 
7
7
  ---
8
8
 
@@ -10,4 +10,3 @@
10
10
 
11
11
  This project is currently maintained by the original author.
12
12
  Contributions, issues, and feature suggestions are always welcome!
13
-
package/README.md CHANGED
@@ -16,19 +16,23 @@
16
16
  - [meocord.config.ts](#meocordconfigts)
17
17
  - [ESLint](#eslint)
18
18
  - [CLI Reference](#cli-reference)
19
+ - [Command Types](#command-types)
19
20
  - [Command Parameters](#command-parameters)
21
+ - [Subcommands](#subcommands)
22
+ - [Autocomplete](#autocomplete)
20
23
  - [Guards](#guards)
21
24
  - [Custom Decorators](#custom-decorators)
22
25
  - [Testing](#testing)
23
26
  - [Deployment](#deployment)
24
27
  - [Contributing](#contributing)
28
+ - [Release Notes](#release-notes)
25
29
  - [License](#license)
26
30
 
27
31
  ---
28
32
 
29
33
  ## Features
30
34
 
31
- - **Decorator-based controllers** — Handle slash commands, buttons, modals, select menus, context menus, messages, and reactions with `@Command`, `@Controller`, and `@UseGuard` decorators. No routing boilerplate.
35
+ - **Decorator-based controllers** — Handle every Discord interaction type — slash commands and their subcommands, autocomplete, buttons, modals, all five select menus, context menus, activity entry points, messages, and reactions — with `@Command`, `@Autocomplete`, `@Controller`, and `@UseGuard` decorators. No routing boilerplate.
32
36
  - **Dependency injection** — Built on Inversify. Services are wired into controllers automatically; no manual instantiation or service locators.
33
37
  - **Guard system** — Pre-execution hooks for auth, rate limiting, metrics, and anything else. Apply per-method or per-class with `@UseGuard`. Guards receive the full interaction context.
34
38
  - **Full CLI** — `meocord create`, `build`, `start`, `generate`. Scaffolds controllers, services, and guards; handles Webpack builds for both development and production.
@@ -63,30 +67,57 @@ npx meocord create <your-app-name> --use-pnpm
63
67
  npx meocord create <your-app-name> --use-yarn
64
68
  ```
65
69
 
66
- Set your Discord bot token in `meocord.config.ts`, then start the bot:
70
+ The generated project is named after what you passed, pins the framework version that
71
+ created it, and comes with a working slash command, button, select menu, modal, context
72
+ menu, message and reaction controller, plus a guard, a service and a spec for each.
73
+
74
+ Add your bot token and start:
67
75
 
68
76
  ```shell
69
- npx meocord start --dev # development with live-reload
77
+ cd <your-app-name>
78
+ cp .env.example .env # then put your token in DISCORD_TOKEN
79
+ npx meocord start --dev # development with live-reload
70
80
  npx meocord start --build --prod # production build + start
71
81
  ```
72
82
 
83
+ The token is read from the environment rather than written into `meocord.config.ts`,
84
+ which is committed — `.env` is gitignored so a token cannot be pushed by accident.
85
+ Building needs no token; only starting does.
86
+
73
87
  ### Quick Example
74
88
 
75
- A minimal slash command controller:
89
+ A minimal slash command. A command Discord knows about needs a builder — that is what gets registered. The
90
+ builder receives the name from `@Command`, so the two cannot drift apart:
76
91
 
77
92
  ```typescript
78
- import { Controller, Command, UseGuard } from 'meocord/decorator'
93
+ import { SlashCommandBuilder } from 'discord.js'
94
+ import { CommandBuilder } from 'meocord/decorator'
79
95
  import { CommandType } from 'meocord/enum'
96
+
97
+ @CommandBuilder(CommandType.SLASH)
98
+ export class GreetingCommandBuilder {
99
+ build(commandName: string) {
100
+ return new SlashCommandBuilder()
101
+ .setName(commandName)
102
+ .setDescription('Greets someone')
103
+ .addStringOption(option => option.setName('name').setDescription('Who to greet').setRequired(true))
104
+ }
105
+ }
106
+ ```
107
+
108
+ ```typescript
109
+ import { Controller, Command, UseGuard } from 'meocord/decorator'
80
110
  import { type ChatInputCommandInteraction } from 'discord.js'
81
- import { RateLimiterGuard } from '@src/guards/rate-limiter.guard.js'
111
+ import { GreetingCommandBuilder } from '@src/controllers/slash/builders/greeting.builder.js'
112
+ import { RateLimitGuard } from '@src/guards/rate-limit.guard.js'
82
113
  import { GreetingService } from '@src/services/greeting.service.js'
83
114
 
84
115
  @Controller()
85
116
  export class GreetingSlashController {
86
117
  constructor(private readonly greetingService: GreetingService) {}
87
118
 
88
- @Command('greet', CommandType.SLASH)
89
- @UseGuard({ provide: RateLimiterGuard, params: { limit: 3, window: 10_000 } })
119
+ @Command('greet', GreetingCommandBuilder)
120
+ @UseGuard({ provide: RateLimitGuard, params: { limit: 3, windowInSeconds: 10 } })
90
121
  async greet(interaction: ChatInputCommandInteraction) {
91
122
  const name = interaction.options.getString('name', true)
92
123
  const message = await this.greetingService.buildGreeting(name)
@@ -123,6 +154,10 @@ export class App {}
123
154
 
124
155
  ```
125
156
  .
157
+ ├── README.md
158
+ ├── .env.example
159
+ ├── .gitignore
160
+ ├── .prettierrc.mjs
126
161
  ├── meocord.config.ts
127
162
  ├── eslint.config.ts
128
163
  ├── vitest.config.ts
@@ -220,23 +255,127 @@ export default [
220
255
  npx meocord --help
221
256
  ```
222
257
 
223
- | Command | Alias | Description |
224
- |------------|-------|--------------------------------------|
225
- | `create` | — | Scaffold a new MeoCord application |
226
- | `build` | — | Compile the application via Webpack |
227
- | `start` | — | Start the application |
258
+ | Command | Alias | Description |
259
+ | ---------- | ----- | -------------------------------------- |
260
+ | `create` | — | Scaffold a new MeoCord application |
261
+ | `build` | — | Compile the application via Webpack |
262
+ | `start` | — | Start the application |
228
263
  | `generate` | `g` | Scaffold controllers, services, guards |
229
- | `show` | — | Display framework info |
264
+ | `show` | — | Display framework info |
265
+
266
+ Every command's own flags:
267
+
268
+ | Command | Flags |
269
+ | ---------- | ------------------------------------------------------- |
270
+ | `create` | `--use-npm` · `--use-yarn` · `--use-pnpm` · `--use-bun` |
271
+ | `build` | `-d, --dev` · `-p, --prod` |
272
+ | `start` | `-b, --build` · `-d, --dev` · `-p, --prod` |
273
+ | `show` | `-w, --warranty` · `-c, --license` |
274
+ | `generate` | see the sub-commands below |
230
275
 
231
- **Common flags:**
276
+ `meocord -V` / `--version` prints the installed version.
277
+
278
+ `start` accepts one environment variable, `MEOCORD_RUNTIME`, which pins the binary the application is run with — see [Which runtime the bot runs on](#which-runtime-the-bot-runs-on).
232
279
 
233
280
  ```shell
234
281
  npx meocord build --prod # production build
235
- npx meocord build --dev # development build
236
282
  npx meocord start --dev # dev mode with live-reload
237
283
  npx meocord start --build --prod # production build + start
238
- npx meocord g co slash "profile" # generate a slash controller
239
- npx meocord g --help # list all generator sub-commands
284
+ ```
285
+
286
+ ### Generators
287
+
288
+ | Sub-command | Alias | Generates |
289
+ | ------------ | ----- | ----------------------------------- |
290
+ | `controller` | `co` | a controller, its spec, its builder |
291
+ | `service` | `s` | a service and its spec |
292
+ | `guard` | `gu` | a guard and its spec |
293
+
294
+ #### Controllers
295
+
296
+ ```shell
297
+ npx meocord g co <type> <name>
298
+ ```
299
+
300
+ `<type>` is one of:
301
+
302
+ `button` · `modal-submit` · `select-menu` · `user-select-menu` · `role-select-menu` · `mentionable-select-menu` · `channel-select-menu` · `reaction` · `message` · `slash` · `autocomplete` · `context-menu` · `primary-entry-point`
303
+
304
+ Each one lands in its own directory, named after the type:
305
+
306
+ ```
307
+ src/controllers/<type>/
308
+ ├── <name>.<type>.controller.ts
309
+ ├── <name>.<type>.controller.spec.ts
310
+ └── builders/sample.builder.ts # slash, context-menu and primary-entry-point only
311
+ ```
312
+
313
+ A builder is generated only for the three types Discord registers by name. Everything else is addressed by `customId` or, for autocomplete, by the command path it completes — there is nothing to register.
314
+
315
+ `<name>` may contain `/` to nest: `npx meocord g co button "admin/ban"` writes into `src/controllers/button/admin/`.
316
+
317
+ Directory layout is organisational only. Controllers are wired up by the `controllers` array on `@MeoCord()`, not by where they sit on disk.
318
+
319
+ ---
320
+
321
+ ## Command Types
322
+
323
+ `@Command` binds a method to one kind of interaction, and the interaction class the handler receives follows from that. Every type Discord sends is covered.
324
+
325
+ | `CommandType` | Handler receives | Routed by |
326
+ | ------------------------- | --------------------------------------------------------------------------- | ---------- |
327
+ | `SLASH` | `ChatInputCommandInteraction` | name |
328
+ | `CONTEXT_MENU` | `UserContextMenuCommandInteraction \| MessageContextMenuCommandInteraction` | name |
329
+ | `PRIMARY_ENTRY_POINT` | `PrimaryEntryPointCommandInteraction` | name |
330
+ | `BUTTON` | `ButtonInteraction` | `customId` |
331
+ | `SELECT_MENU` | `StringSelectMenuInteraction` | `customId` |
332
+ | `USER_SELECT_MENU` | `UserSelectMenuInteraction` | `customId` |
333
+ | `ROLE_SELECT_MENU` | `RoleSelectMenuInteraction` | `customId` |
334
+ | `MENTIONABLE_SELECT_MENU` | `MentionableSelectMenuInteraction` | `customId` |
335
+ | `CHANNEL_SELECT_MENU` | `ChannelSelectMenuInteraction` | `customId` |
336
+ | `MODAL_SUBMIT` | `ModalSubmitInteraction` | `customId` |
337
+
338
+ Autocomplete has its own decorator — see [Autocomplete](#autocomplete). It has no `CommandType` member, because it registers nothing and is answered with `respond()` rather than a reply. `@MessageHandler` and `@ReactionHandler` are outside `CommandType` for the same reason: `CommandType` is the set of things `@Command` can bind to, not the set of things MeoCord handles.
339
+
340
+ The kebab-case `ControllerType` used by the CLI is a wider list — it names every kind of controller that can be scaffolded, including the three that are not commands.
341
+
342
+ The four entity select menus are separate types because Discord sends them as separate component types carrying different resolved data. Declaring `SELECT_MENU` for a user select menu is a type error, not a silent mismatch:
343
+
344
+ ```typescript
345
+ @Command('assign/{taskId}', CommandType.USER_SELECT_MENU)
346
+ async assign(interaction: UserSelectMenuInteraction, { taskId }) {
347
+ await interaction.reply(`Assigned to ${interaction.users.map(user => user.username).join(', ')}`)
348
+ }
349
+ ```
350
+
351
+ ### Slash command options
352
+
353
+ A slash handler's second argument holds the options the command was invoked with, keyed by name. Entity options arrive resolved — a `User`, `Role`, `GuildChannel` or `Attachment`, not the snowflake:
354
+
355
+ ```typescript
356
+ @Command('kick', KickCommandBuilder)
357
+ async kick(interaction: ChatInputCommandInteraction, { target, reason }) {
358
+ // target is a User, reason is a string
359
+ await interaction.reply(`Kicked ${target.username}: ${reason}`)
360
+ }
361
+ ```
362
+
363
+ ### Entry point commands
364
+
365
+ Activity entry points have no builder class in `@discordjs/builders`, so their builder returns the REST body directly. `handler: AppHandler` is what makes Discord send the interaction to the bot at all:
366
+
367
+ ```typescript
368
+ @CommandBuilder(CommandType.PRIMARY_ENTRY_POINT)
369
+ export class LaunchCommandBuilder {
370
+ build() {
371
+ return {
372
+ type: ApplicationCommandType.PrimaryEntryPoint as const,
373
+ name: 'launch',
374
+ description: 'Launch the activity',
375
+ handler: EntryPointCommandHandlerType.AppHandler,
376
+ }
377
+ }
378
+ }
240
379
  ```
241
380
 
242
381
  ---
@@ -303,6 +442,76 @@ Where two patterns trade a literal for a parameter in opposite positions — `a/
303
442
 
304
443
  An unroutable interaction replies "Command not found!" to the user and logs a warning naming the `customId` or command that failed to match. If a control appears dead, that log line is the first place to look.
305
444
 
445
+ Autocomplete cannot be replied to, so an unclaimed option is answered with an empty list instead and the warning names the command and option.
446
+
447
+ A handler that throws is logged and gets the same treatment — except when it had already replied or deferred, in which case MeoCord leaves the response alone rather than sending a second one Discord would reject.
448
+
449
+ ### Failures never take the bot down
450
+
451
+ discord.js calls event listeners without awaiting them, so anything that rejects out of one is an unhandled rejection — which terminates the process by default. MeoCord wraps every listener it registers, so one bad interaction, one unresolvable controller, or one reaction on a deleted message costs that event and nothing else. The error is logged against the event that produced it, so a genuine misconfiguration still shows up on the first interaction rather than staying hidden.
452
+
453
+ Where the failure happened before the handler ran, the user is still told: a command interaction gets the error reply, an autocomplete gets its window closed. A reaction whose message can no longer be fetched — deleted, or in a channel the bot lost access to — is skipped quietly, since that is an ordinary outcome rather than a fault.
454
+
455
+ ---
456
+
457
+ ## Subcommands
458
+
459
+ Discord sends `/settings notify email` as a single interaction named `settings`, so a command with subcommands would otherwise have one handler for all of them. Name the full path — parts separated by a space, the way Discord displays them — to give each subcommand its own method:
460
+
461
+ ```typescript
462
+ @Controller()
463
+ export class SettingsController {
464
+ // The builder is declared once, on the command itself.
465
+ @Command('settings', SettingsCommandBuilder)
466
+ async settings(interaction: ChatInputCommandInteraction) {
467
+ await interaction.reply('Pick a subcommand.')
468
+ }
469
+
470
+ @Command('settings notify email', CommandType.SLASH)
471
+ async notifyEmail(interaction: ChatInputCommandInteraction, { enabled }) {
472
+ await interaction.reply(`Email notifications ${enabled ? 'on' : 'off'}`)
473
+ }
474
+ }
475
+ ```
476
+
477
+ Subcommand handlers take the plain `CommandType.SLASH` and no builder: the subcommand is already described by the parent's builder, and registering a second command for it would be rejected by Discord. Options are flattened, so `notifyEmail` receives `{ enabled }` rather than the wrapping subcommand.
478
+
479
+ The full path is always tried before the bare command name, whatever order the controllers were registered in, and a subcommand nobody claimed falls back to the command's own handler. A group is never dropped on the way down — `settings notify email` does not fall back to `settings email`, because another group could declare its own `email`.
480
+
481
+ ---
482
+
483
+ ## Autocomplete
484
+
485
+ Autocomplete is a separate interaction from the command it belongs to: Discord sends it while the user is still typing, it is answered with `respond()` rather than a reply, and the window closes after three seconds. `@Autocomplete` binds a handler to it.
486
+
487
+ ```typescript
488
+ @Controller()
489
+ export class SearchController {
490
+ constructor(private catalog: CatalogService) {}
491
+
492
+ @Autocomplete('search', 'query')
493
+ async completeQuery(interaction: AutocompleteInteraction) {
494
+ const { value } = interaction.options.getFocused(true)
495
+ const matches = this.catalog.find(value).slice(0, 25)
496
+
497
+ await interaction.respond(matches.map(name => ({ name, value: name })))
498
+ }
499
+ }
500
+ ```
501
+
502
+ The option must be declared with `.setAutocomplete(true)` on the command builder — that is what makes Discord send the interaction.
503
+
504
+ Omit the option name to handle every option of a command and branch on `getFocused(true)` yourself. An option-specific handler always wins over a command-wide one, so the two can coexist. The first argument is the command path, so subcommands work the same way as they do for `@Command`:
505
+
506
+ ```typescript
507
+ @Autocomplete('settings notify email', 'address')
508
+ async completeAddress(interaction: AutocompleteInteraction, { region }) { /* … */ }
509
+ ```
510
+
511
+ The second argument holds the options already filled in, which is what lets one option's suggestions depend on another's value.
512
+
513
+ If no handler claims an option, MeoCord answers with an empty list and logs which command and option are missing one — a visibly empty menu rather than a client stuck loading.
514
+
306
515
  ---
307
516
 
308
517
  ## Guards
@@ -359,9 +568,7 @@ import { UseGuard } from 'meocord/decorator'
359
568
  import { DefaultGuard, RateLimiterGuard } from '@src/guards/index.js'
360
569
 
361
570
  export const Protected = (limit = 5) =>
362
- applyDecorators(
363
- UseGuard(DefaultGuard, { provide: RateLimiterGuard, params: { limit } }),
364
- )
571
+ applyDecorators(UseGuard(DefaultGuard, { provide: RateLimiterGuard, params: { limit } }))
365
572
  ```
366
573
 
367
574
  ```typescript
@@ -441,7 +648,11 @@ Creates a smart mock instance of any discord.js class. The full prototype chain
441
648
 
442
649
  **Type guards run real logic** — `isButton()`, `isRepliable()`, `isChatInputCommand()`, etc. are backed by the actual discord.js prototype methods. The right fields (`type`, `componentType`, `commandType`) are set based on the class you pass in, so no manual `.mockReturnValue(true)` setup is needed. All type guard methods are still mock functions and can be overridden per test.
443
650
 
444
- **Reply state machine** — for repliable interactions, `replied` and `deferred` start as `false`. Calling `reply()` or `deferReply()` twice throws, just like a real interaction. `followUp()`, `editReply()`, and `deleteReply()` throw if called before any reply. The ephemeral flag is tracked on `interaction.ephemeral`. All reply methods are still mock functions so call assertions work normally.
651
+ **Reply state machine** — for repliable interactions, `replied` and `deferred` start as `false`. Calling `reply()` or `deferReply()` twice throws, just like a real interaction. `followUp()`, `editReply()`, and `deleteReply()` throw if called before any reply. The ephemeral flag is tracked on `interaction.ephemeral`, read from `flags` only — the deprecated `ephemeral: true` reply option is not honoured. All reply methods are still mock functions so call assertions work normally.
652
+
653
+ Autocomplete interactions are not repliable but get the equivalent for their own single-shot response: `responded` starts as `false`, `respond()` sets it, and a second call throws.
654
+
655
+ Guards discord.js has deprecated are deliberately left unwired — `isSelectMenu()` returns `undefined` rather than reproducing behaviour the library is removing. Use `isStringSelectMenu()`.
445
656
 
446
657
  > **Framework-agnostic** — the mocks returned here are plain mock functions that stamp `_isMockFunction` and expose `.mock.calls`, the exact contract both `jest` and `vitest` check. Use them with either framework's `expect(...).toHaveBeenCalledWith(...)` / `toHaveBeenCalledTimes(...)` — no jest or vitest import is required to produce them. For typed stubs in your own code, import `MockedFunction`, `createMockFn`, and `DeepMocked` from `meocord/testing`.
447
658
 
@@ -453,17 +664,17 @@ const interaction = createMockInteraction(ChatInputCommandInteraction)
453
664
 
454
665
  // instanceof works at every level
455
666
  expect(interaction).toBeInstanceOf(ChatInputCommandInteraction) // true
456
- expect(interaction).toBeInstanceOf(BaseInteraction) // true
667
+ expect(interaction).toBeInstanceOf(BaseInteraction) // true
457
668
 
458
669
  // type guards work — no manual setup needed
459
670
  interaction.isChatInputCommand() // → true
460
- interaction.isRepliable() // → true
461
- interaction.isButton() // → false
671
+ interaction.isRepliable() // → true
672
+ interaction.isButton() // → false
462
673
 
463
674
  // reply state machine
464
- interaction.replied // → false
675
+ interaction.replied // → false
465
676
  await interaction.reply({ content: 'hi' })
466
- interaction.replied // → true
677
+ interaction.replied // → true
467
678
  await interaction.reply({ content: 'again' }) // → throws (already replied)
468
679
 
469
680
  // still a mock fn — call assertions work normally
@@ -519,15 +730,38 @@ interaction.options = createChatInputOptions({
519
730
  duration: 7,
520
731
  })
521
732
 
522
- interaction.options.getSubcommandGroup() // → 'admin'
523
- interaction.options.getSubcommand(true) // → 'ban'
524
- interaction.options.getUser('user') // → { id: '123456789' }
525
- interaction.options.getString('reason') // → 'spam'
526
- interaction.options.getNumber('duration') // → 7
527
- interaction.options.getString('duration') // → null (wrong type)
528
- interaction.options.getNumber('x', true) // → throws (absent + required)
733
+ interaction.options.getSubcommandGroup() // → 'admin'
734
+ interaction.options.getSubcommand(true) // → 'ban'
735
+ interaction.options.getUser('user') // → { id: '123456789' }
736
+ interaction.options.getString('reason') // → 'spam'
737
+ interaction.options.getNumber('duration') // → 7
738
+ interaction.options.getString('duration') // → null (wrong type)
739
+ interaction.options.getNumber('x', true) // → throws (absent + required)
529
740
  ```
530
741
 
742
+ `data` is materialised too, nested under the subcommand path exactly as Discord sends it. That is what the framework reads to build a handler's second argument, so a params assertion sees the same record production would:
743
+
744
+ ```typescript
745
+ interaction.options.data
746
+ // → [{ name: 'admin', type: SubcommandGroup, options: [{ name: 'ban', type: Subcommand, options: [...] }] }]
747
+ ```
748
+
749
+ Entity options are set on both `value` (the snowflake) and their own resolved field, so a handler that reads only one of the two is caught rather than silently passing. Pass a `createMockInteraction(User, …)`, `Role`, channel or `Attachment` mock and it lands on `user`/`role`/`channel`/`attachment`.
750
+
751
+ For autocomplete, `focused` names the option being typed:
752
+
753
+ ```typescript
754
+ const interaction = createMockInteraction(AutocompleteInteraction)
755
+ interaction.options = createChatInputOptions({ focused: 'query', query: 'ad' })
756
+
757
+ interaction.options.getFocused(true) // → { name: 'query', value: 'ad', focused: true, … }
758
+ interaction.options.getFocused() // → 'ad'
759
+ ```
760
+
761
+ Omit it and `getFocused` throws, the same as the real resolver does when no option is focused.
762
+
763
+ `subcommandGroup`, `subcommand` and `focused` are reserved keys — an option of your own cannot use those names.
764
+
531
765
  All methods are mock functions — override any per test with `.mockReturnValue()`.
532
766
 
533
767
  ### `createMockUser` / `createMockClient` / `createMockGuild` / `createMockChannel`
@@ -535,18 +769,12 @@ All methods are mock functions — override any per test with `.mockReturnValue(
535
769
  Convenience wrappers for common discord.js classes. All methods are auto-stubbed as mock functions. Nested managers (`client.users`, `guild.members`, etc.) are independent nested stubs.
536
770
 
537
771
  ```typescript
538
- import {
539
- createMockFn,
540
- createMockUser,
541
- createMockClient,
542
- createMockGuild,
543
- createMockChannel,
544
- } from 'meocord/testing'
772
+ import { createMockFn, createMockUser, createMockClient, createMockGuild, createMockChannel } from 'meocord/testing'
545
773
  import { TextChannel } from 'discord.js'
546
774
 
547
- const user = createMockUser()
548
- const client = createMockClient()
549
- const guild = createMockGuild()
775
+ const user = createMockUser()
776
+ const client = createMockClient()
777
+ const guild = createMockGuild()
550
778
  const channel = createMockChannel(TextChannel)
551
779
 
552
780
  // override nested manager methods per test (createMockFn works with vitest and jest matchers)
@@ -564,15 +792,15 @@ import { createMockMessage } from 'meocord/testing'
564
792
 
565
793
  const msg = createMockMessage()
566
794
 
567
- msg.deleted // → false
795
+ msg.deleted // → false
568
796
  await msg.delete()
569
- msg.deleted // → true
570
- await msg.delete() // → throws (already deleted)
797
+ msg.deleted // → true
798
+ await msg.delete() // → throws (already deleted)
571
799
  await msg.edit({ content: 'x' }) // → throws (already deleted)
572
800
 
573
801
  // edit() and reply() resolve to a new Message mock
574
802
  const edited = await createMockMessage().edit({ content: 'updated' })
575
- edited.delete // → a mock fn
803
+ edited.delete // → a mock fn
576
804
 
577
805
  // still a mock fn — assertions work
578
806
  expect(msg.delete).toHaveBeenCalledTimes(1)
@@ -610,8 +838,10 @@ const module = MeoCordTestingModule.create({
610
838
  controllers: [GreetingSlashController],
611
839
  providers: [{ provide: GreetingService, useValue: mockGreetingService }],
612
840
  })
613
- .overrideGuard(MetricsGuard).useValue({ canActivate: () => true })
614
- .overrideGuard(RateLimiterGuard).useValue({ canActivate: () => true })
841
+ .overrideGuard(MetricsGuard)
842
+ .useValue({ canActivate: () => true })
843
+ .overrideGuard(RateLimiterGuard)
844
+ .useValue({ canActivate: () => true })
615
845
  .compile()
616
846
  ```
617
847
 
@@ -626,7 +856,8 @@ const module = MeoCordTestingModule.create({
626
856
  controllers: [GreetingSlashController],
627
857
  providers: [{ provide: GreetingService, useValue: realGreetingService }],
628
858
  })
629
- .overrideProvider(GreetingService).useValue({ buildGreeting: createMockFn() })
859
+ .overrideProvider(GreetingService)
860
+ .useValue({ buildGreeting: createMockFn() })
630
861
  .compile()
631
862
  ```
632
863
 
@@ -643,7 +874,7 @@ import {
643
874
  import { ChatInputCommandInteraction } from 'discord.js'
644
875
  import { GreetingSlashController } from '@src/controllers/slash/greeting.slash.controller.js'
645
876
  import { GreetingService } from '@src/services/greeting.service.js'
646
- import { RateLimiterGuard } from '@src/guards/rate-limiter.guard.js'
877
+ import { RateLimitGuard } from '@src/guards/rate-limit.guard.js'
647
878
 
648
879
  describe('GreetingSlashController', () => {
649
880
  let controller: GreetingSlashController
@@ -656,7 +887,8 @@ describe('GreetingSlashController', () => {
656
887
  controllers: [GreetingSlashController],
657
888
  providers: [{ provide: GreetingService, useValue: greetingService }],
658
889
  })
659
- .overrideGuard(RateLimiterGuard).useValue({ canActivate: () => true })
890
+ .overrideGuard(RateLimiterGuard)
891
+ .useValue({ canActivate: () => true })
660
892
  .compile()
661
893
 
662
894
  controller = module.get(GreetingSlashController)
@@ -711,6 +943,62 @@ Start in production:
711
943
  npx meocord start --prod
712
944
  ```
713
945
 
946
+ ### Which runtime the bot runs on
947
+
948
+ `start` runs the bot on **the runtime you launched it with**. There is nothing to configure and no config key to set — if you typed `bun`, you get a bun process:
949
+
950
+ ```shell
951
+ bun run start # dist/main.js runs under bun
952
+ npm run start # dist/main.js runs under node
953
+ ```
954
+
955
+ Two signals decide it, most explicit first: the runtime executing the CLI, and — when the CLI itself was handed to node — the runner that launched it. `bun run` honours the bin's `#!/usr/bin/env node` shebang, so bun sets `npm_execpath` to its own binary and that is what the bot is spawned with. npm, pnpm and yarn point it at a `.js` file instead, which cannot run the bundle, so those fall through to node as expected.
956
+
957
+ That matters for more than tidiness. Pinning `node` would oblige a bun-only image to install a second runtime purely to launch, or to carry `--bun` on every command. It also decides the allocator: for a bot doing heavy native work — canvas rendering through a napi module, say — glibc's malloc and bun's mimalloc produce very different resident-memory curves on the same workload, because they differ in how eagerly freed pages go back to the OS.
958
+
959
+ Development works the same way. The watcher runs the bundle through the same command production does, so a runtime that works in `--dev` cannot quietly differ from the one that ships.
960
+
961
+ #### Running the CLI itself on bun
962
+
963
+ The resolution above decides what the _bot_ runs on. The CLI process is decided earlier,
964
+ by the interpreter line `#!/usr/bin/env node`, which nothing in the package can influence
965
+ — it is read before any of the program exists. On a machine with no node at all,
966
+ invoking the CLI directly fails before it starts:
967
+
968
+ ```
969
+ $ meocord start --prod
970
+ env: node: No such file or directory
971
+ ```
972
+
973
+ That line stays as it is because Windows depends on it: npm there never runs the file
974
+ through its shebang, it parses the line and writes a `.cmd` invoking the program named in
975
+ it. `#!/usr/bin/env node` yields `node`; anything else yields a program Windows cannot
976
+ resolve.
977
+
978
+ So on a bun-only image, tell bun to ignore the line. Either per command:
979
+
980
+ ```shell
981
+ bun --bun meocord start --prod
982
+ ```
983
+
984
+ or once for the project, which is what a bun-only Dockerfile wants:
985
+
986
+ ```toml
987
+ # bunfig.toml
988
+ [run]
989
+ bun = true
990
+ ```
991
+
992
+ Then plain `bun run start` runs the CLI and the bot on bun, and node need not exist.
993
+
994
+ #### Pinning a specific binary
995
+
996
+ To override both signals — a particular install, or a different runtime for comparison — set `MEOCORD_RUNTIME`:
997
+
998
+ ```shell
999
+ MEOCORD_RUNTIME=/usr/local/bin/bun npm run start
1000
+ ```
1001
+
714
1002
  ---
715
1003
 
716
1004
  ## Contributing
@@ -719,9 +1007,27 @@ npx meocord start --prod
719
1007
  2. Create a feature branch: `git checkout -b feat/your-feature`
720
1008
  3. Commit with conventional commits: `git commit -m "feat: add X"`
721
1009
  4. Run `bun run lint` and `bun run test` before pushing
722
- 5. Push and open a pull request against `main`
1010
+ 5. If you touched anything under `src/bin/`, also run `bun run build && bun run verify:generated`
1011
+ 6. Push and open a pull request against `main`
1012
+
1013
+ Include a description of what changed and why, and add tests for any new behaviour.
1014
+
1015
+ `verify:generated` generates one controller of every type through the built CLI — flat and nested, in separate throwaway projects — and typechecks the result against the published package. Rendering a template says nothing about whether the code it produces compiles, and two bugs lived behind exactly that gap. It runs in CI as part of the Build job, so you do not have to remember it; running it locally is just faster than waiting.
1016
+
1017
+ ### Commit messages and releases
1018
+
1019
+ Commit messages drive versioning through [semantic-release](https://semantic-release.gitbook.io/). Only these publish:
1020
+
1021
+ | Prefix | Release |
1022
+ | ------------------------------ | ------- |
1023
+ | `feat:` | minor |
1024
+ | `fix:` | patch |
1025
+ | `perf:` | patch |
1026
+ | `BREAKING CHANGE:` in the body | major |
1027
+
1028
+ Everything else — `docs:`, `test:`, `ci:`, `chore:`, `refactor:`, `style:` — lands on `main` without publishing and ships with whatever releasable commit comes next.
723
1029
 
724
- Include a description of what changed and why, and add tests for any new behaviour. Use `fix:` / `feat:` / `docs:` prefixes so [semantic-release](https://semantic-release.gitbook.io/) can version correctly — `test:` commits do not trigger a release.
1030
+ Pick the prefix by what reaches the installed package, not by which file you edited. JSDoc is compiled into the published `.d.ts` and is what a user reads in their editor, so correcting a wrong `@example` is a `fix:` even though you only touched a comment. A README-only change is `docs:`.
725
1031
 
726
1032
  ---
727
1033