create-bot-ts 1.0.7 → 1.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.
Files changed (3) hide show
  1. package/README.md +41 -12
  2. package/index.js +727 -276
  3. package/package.json +5 -2
package/README.md CHANGED
@@ -1,20 +1,49 @@
1
1
  # create-bot-ts
2
2
 
3
- This is a package that I use as some sort of starter template for Discord bots in typescript.
3
+ Scaffold a modern, **type-driven** Discord.js bot with the folder structure MahoMuri likes.
4
4
 
5
- #### Its main features are
5
+ ## What you get
6
6
 
7
- - Yarn Berry
8
- - Git
9
- - Typescript
10
- - yarn version
11
- - yarn upgrade-interactive
12
- - husky
13
- - list-staged
14
- - eslit & prettier
7
+ ```
8
+ src/
9
+ ├── index.ts # entry point: creates BotClient, calls init()
10
+ ├── client/ # BotClient class - extends Client; init() loads + logs in
11
+ ├── commands/ # slash commands, auto-loaded from this folder
12
+ ├── events/ # event handlers, auto-loaded from this folder
13
+ ├── interfaces/ # shared types: Command, BotEvent
14
+ └── utils/ # module loaders: loadCommands(), loadEvents()
15
+ ```
16
+
17
+ - Strict, type-driven TypeScript — `verbatimModuleSyntax`, `exactOptionalPropertyTypes`, `noUncheckedIndexedAccess`, `NodeNext` module resolution
18
+ - Named exports only — every command/event file exports a single `const` named after the file (`ping.ts` → `ping`), so imports autocomplete and nothing depends on a default export
19
+ - `BotClient extends Client` with `init()` — calls `loadCommands` + `loadEvents` from `src/utils`, then logs in
20
+ - discord.js v14 + `bufferutil` / `zlib-sync`, `dotenv`, `consola`, `common-tags`, `table`
21
+ - ESLint 9 **flat config** with type-aware linting from `typescript-eslint` (works out of the box, no manual config step)
22
+ - Prettier + Husky + lint-staged pre-commit hook
23
+ - Yarn 4 using the `node-modules` linker (fewer tooling quirks than PnP)
24
+ - `tsx watch` for a modern dev runner (no `ts-node`)
15
25
 
16
- After installation run:
26
+ ## Usage
27
+
28
+ Run it inside an **empty directory**:
17
29
 
18
30
  ```sh
19
- yarn create @eslint/config
31
+ # npm
32
+ npx create-bot-ts@latest
33
+
34
+ # yarn
35
+ yarn create bot-ts
20
36
  ```
37
+
38
+ The script refuses to run in a non-empty directory unless you pass `--force`.
39
+
40
+ ```
41
+ Node.js >= 24.17 required (discord.js v14 minimum)
42
+ ```
43
+
44
+ ## Structure
45
+
46
+ | Location | Purpose |
47
+ | ---------- | -------------------------------- |
48
+ | `bin` | `create-bot-ts` → `index.js` |
49
+ | `index.js` | the scaffolder itself |
package/index.js CHANGED
@@ -1,294 +1,745 @@
1
1
  #!/usr/bin/env node
2
- const cp = require("child_process");
3
- const fs = require("fs");
4
- const path = require("path");
5
- const outdent = require("outdent");
2
+ const { exec } = require("node:child_process");
3
+ const fs = require("node:fs");
4
+ const os = require("node:os");
5
+ const path = require("node:path");
6
6
  const { createSpinner } = require("nanospinner");
7
+ const outdent = require("outdent");
7
8
 
8
- // Start the spinner
9
- const spinner = createSpinner(
10
- "Creating template! Do not exit the process..."
11
- ).start({
12
- color: "green",
13
- });
14
-
15
- const NS_PER_SEC = 1e9;
16
- const MS_PER_NS = 1e-6;
17
- const time = process.hrtime();
18
-
19
- // Make the src and child directories
20
- fs.mkdirSync(path.join(process.cwd(), "src"));
21
- fs.mkdirSync(path.join(process.cwd(), "src", "client"));
22
- fs.mkdirSync(path.join(process.cwd(), "src", "commands"));
23
- fs.mkdirSync(path.join(process.cwd(), "src", "events"));
24
- fs.mkdirSync(path.join(process.cwd(), "src", "utils"));
25
- fs.mkdirSync(path.join(process.cwd(), "src", "interfaces"));
26
- fs.mkdirSync(path.join(process.cwd(), "src", "models"));
27
-
28
- // Make index.ts in src and client.ts
29
- fs.writeFileSync(path.join(process.cwd(), "src", "index.ts"), "");
30
- fs.writeFileSync(path.join(process.cwd(), "src", "client", "index.ts"), "");
31
-
32
- // Declare function that writes files
33
- const writeFiles = (files) => {
34
- files.map((f) => {
35
- fs.writeFileSync(
36
- path.join(process.cwd(), f.path),
37
- outdent`${f.content}`
9
+ // ---------------------------------------------------------------------------
10
+ // Small helpers
11
+ // ---------------------------------------------------------------------------
12
+
13
+ /**
14
+ * Promisified exec. Resolves with trimmed stdout and rejects on non-zero exit.
15
+ * stderr is appended to the error message so failures stay debuggable.
16
+ */
17
+ const run = (command, options = {}) =>
18
+ new Promise((resolve, reject) => {
19
+ exec(
20
+ command,
21
+ {
22
+ cwd: process.cwd(),
23
+ maxBuffer: 50 * 1024 * 1024,
24
+ ...options,
25
+ },
26
+ (error, stdout, stderr) => {
27
+ if (error) {
28
+ const detail = `${stderr || stdout}`.trim();
29
+ error.message = detail
30
+ ? `${error.message}\n${detail}`
31
+ : error.message;
32
+ reject(error);
33
+ return;
34
+ }
35
+
36
+ resolve(stdout.trim());
37
+ },
38
38
  );
39
39
  });
40
+
41
+ /** Write a batch of templated files, each ending with a single newline. */
42
+ const writeFiles = (files) => {
43
+ for (const file of files) {
44
+ const content = outdent`${file.content}`.trimEnd() + os.EOL;
45
+ fs.writeFileSync(path.join(process.cwd(), file.path), content);
46
+ }
40
47
  };
41
48
 
42
- // Use the declared function above here.
43
- writeFiles([
49
+ // ---------------------------------------------------------------------------
50
+ // Preflight — refuse to scaffold into a non-empty directory unless forced
51
+ // ---------------------------------------------------------------------------
52
+
53
+ const FORCE = process.argv.includes("--force");
54
+
55
+ const existingEntries = fs
56
+ .readdirSync(process.cwd())
57
+ .filter((entry) => entry !== ".git");
58
+
59
+ if (existingEntries.length > 0 && !FORCE) {
60
+ const list = existingEntries
61
+ .slice(0, 4)
62
+ .map((entry) => `"${entry}"`)
63
+ .join(", ");
64
+ const more =
65
+ existingEntries.length > 4
66
+ ? `, and ${existingEntries.length - 4} more`
67
+ : "";
68
+ console.error(
69
+ `[create-bot-ts] refusing to scaffold into a non-empty directory (${list}${more}).\n` +
70
+ "Run this inside an empty directory, or pass --force to overwrite.",
71
+ );
72
+ process.exit(1);
73
+ }
74
+
75
+ const projectName =
76
+ path
77
+ .basename(process.cwd())
78
+ .toLowerCase()
79
+ .replace(/[^a-z0-9-_]+/g, "-")
80
+ .replace(/-+/g, "-")
81
+ .replace(/^-+|-+$/g, "") || "discord-bot";
82
+
83
+ // ---------------------------------------------------------------------------
84
+ // Generated file templates (kept formatting-clean for prettier --check)
85
+ // ---------------------------------------------------------------------------
86
+
87
+ const TS_CONFIG = outdent`
44
88
  {
45
- path: "tsconfig.json",
46
- content: `{
47
- "compilerOptions": {
89
+ "compilerOptions": {
48
90
  "lib": ["ESNext"],
49
- "module": "commonjs",
50
- "moduleResolution": "node",
51
91
  "target": "ESNext",
52
- "sourceMap": true,
92
+ "module": "NodeNext",
93
+ "moduleResolution": "NodeNext",
94
+ "moduleDetection": "force",
95
+ "rootDir": "src",
96
+ "outDir": "dist",
97
+ "strict": true,
98
+ "noUncheckedIndexedAccess": true,
99
+ "exactOptionalPropertyTypes": true,
100
+ "noImplicitOverride": true,
101
+ "noFallthroughCasesInSwitch": true,
102
+ "noUnusedLocals": true,
103
+ "noUnusedParameters": true,
104
+ "verbatimModuleSyntax": true,
105
+ "resolveJsonModule": true,
53
106
  "esModuleInterop": true,
54
- "experimentalDecorators": true,
55
- "emitDecoratorMetadata": true,
56
- "allowSyntheticDefaultImports": true,
107
+ "forceConsistentCasingInFileNames": true,
57
108
  "skipLibCheck": true,
58
- "skipDefaultLibCheck": true,
59
- "declaration": true,
60
- "resolveJsonModule": true
61
- },
62
- "include": ["src"]
63
- }
64
- `,
65
- },
66
- {
67
- path: ".prettierrc",
68
- content: `{
69
- "semi": true,
70
- "singleQuote": false,
71
- "tabWidth": 4,
72
- "printWidth": 80,
73
- "useTabs": false,
74
- "endOfLine": "lf"
75
- }
109
+ "sourceMap": true,
110
+ "noEmitOnError": true
111
+ },
112
+ "include": ["src"]
113
+ }
114
+ `;
76
115
 
77
- `,
78
- },
116
+ const PRETTIER_CONFIG = outdent`
79
117
  {
80
- path: ".gitignore",
81
- content: `# @see https://git-scm.com/docs/gitignore
82
-
83
- # \`.DS_Store\` is a file that stores custom attributes of its containing folder
84
- .DS_Store
85
-
86
- # Logs
87
- logs
88
- *.log
89
-
90
- # Dependencies
91
- node_modules
92
- bower_components
93
- vendor
94
-
95
- # yarn v2
96
- .yarn/*
97
- !.yarn/cache
98
- !.yarn/patches
99
- !.yarn/plugins
100
- !.yarn/releases
101
- !.yarn/sdks
102
- !.yarn/versions
103
-
104
- # Envs
105
- .env
106
-
107
- # Databases
108
- *.db
109
-
110
- # Caches
111
- .cache
112
- .npm
113
- .eslintcache
114
-
115
- # Temporaries
116
- .tmp
117
- .temp
118
-
119
- # Built
120
- dist
121
- target
122
- built
123
- output
124
- out
125
-
126
- # Editor directories and files
127
- .idea
128
- .templates
129
- template.config.js
130
- *.suo
131
- *.ntvs*
132
- *.njsproj
133
- *.sln
134
- *.sw?
135
-
136
- # Push Scripts
137
- push.bat
138
- push.sh
139
-
140
- `,
141
- },
142
- ]);
143
-
144
- spinner.update({
145
- text: "Initializing npm package...",
146
- });
147
-
148
- // Initialize the npm package with -y to default to yes to all prompts
149
- cp.exec("npm init -y", { cwd: process.cwd() }, () => {
150
- spinner.update({
151
- text: "Initializing Yarn Berry...",
152
- color: "blue",
118
+ "semi": true,
119
+ "singleQuote": true,
120
+ "tabWidth": 2,
121
+ "printWidth": 100,
122
+ "trailingComma": "all",
123
+ "endOfLine": "lf"
124
+ }
125
+ `;
126
+
127
+ const PRETTIER_IGNORE = outdent`
128
+ dist
129
+ node_modules
130
+ .yarn
131
+ .husky
132
+ package.json
133
+ yarn.lock
134
+ `;
135
+
136
+ const GITIGNORE = outdent`
137
+ # Dependencies
138
+ node_modules
139
+
140
+ # Yarn (node-modules linker keeps some metadata in .yarn)
141
+ .yarn/*
142
+ !.yarn/plugins
143
+ !.yarn/releases
144
+ !.yarn/sdks
145
+ !.yarn/patches
146
+ !.yarn/versions
147
+
148
+ # Build output
149
+ dist
150
+
151
+ # Environment
152
+ .env
153
+ .env.*
154
+ !.env.example
155
+
156
+ # Logs
157
+ logs
158
+ *.log
159
+
160
+ # Husky runtime
161
+ .husky/_
162
+
163
+ # Editor directories and files
164
+ .idea
165
+ .vscode/*
166
+ !.vscode/extensions.json
167
+ .DS_Store
168
+ `;
169
+
170
+ const ENV_EXAMPLE = outdent`
171
+ # Bot token from the Discord Developer Portal
172
+ # https://discord.com/developers/applications
173
+ DISCORD_TOKEN=
174
+ `;
175
+
176
+ const ESLINT_CONFIG = outdent`
177
+ // @ts-check
178
+ import eslint from '@eslint/js';
179
+ import prettierConfig from 'eslint-config-prettier/flat';
180
+ import globals from 'globals';
181
+ import tseslint from 'typescript-eslint';
182
+
183
+ export default tseslint.config(
184
+ {
185
+ ignores: ['dist', 'node_modules', '.yarn', '.husky', 'eslint.config.mjs'],
186
+ },
187
+ eslint.configs.recommended,
188
+ ...tseslint.configs.recommended,
189
+ ...tseslint.configs.recommendedTypeChecked,
190
+ {
191
+ languageOptions: {
192
+ globals: globals.node,
193
+ parserOptions: {
194
+ projectService: true,
195
+ },
196
+ },
197
+ rules: {
198
+ // Type-driven codebase: type-only imports must use \`import type\`.
199
+ '@typescript-eslint/consistent-type-imports': 'error',
200
+ // discord.js is promise based; never drop a floating promise.
201
+ '@typescript-eslint/no-floating-promises': 'error',
202
+ // Async event handlers are idiomatic in discord.js — keep the rule
203
+ // for promise misuse, but allow promise-returning listeners (\`checksVoidReturn\`).
204
+ '@typescript-eslint/no-misused-promises': ['error', { checksVoidReturn: false }],
205
+ // Event listeners often take unused args - allow the \`_\` prefix.
206
+ '@typescript-eslint/no-unused-vars': [
207
+ 'error',
208
+ {
209
+ argsIgnorePattern: '^_',
210
+ caughtErrorsIgnorePattern: '^_',
211
+ varsIgnorePattern: '^_',
212
+ },
213
+ ],
214
+ },
215
+ },
216
+ prettierConfig,
217
+ );
218
+ `;
219
+
220
+ const CLIENT_TS = outdent`
221
+ import { dirname, join } from 'node:path';
222
+ import { fileURLToPath } from 'node:url';
223
+
224
+ import { Client, Collection } from 'discord.js';
225
+
226
+ import type { ClientOptions } from 'discord.js';
227
+
228
+ import { loadCommands } from '../utils/loadCommands.js';
229
+ import { loadEvents } from '../utils/loadEvents.js';
230
+
231
+ import type { Command } from '../interfaces/command.js';
232
+
233
+ export class BotClient extends Client {
234
+ /** Commands loaded from src/commands, keyed by command name. */
235
+ public readonly commands: Collection<string, Command> = new Collection();
236
+
237
+ private readonly commandsPath: string;
238
+ private readonly eventsPath: string;
239
+
240
+ public constructor(options: ClientOptions) {
241
+ super(options);
242
+
243
+ // Anchor to this file so paths resolve from src/ (tsx) and dist/ (build).
244
+ const directory = dirname(fileURLToPath(import.meta.url));
245
+ this.commandsPath = join(directory, '..', 'commands');
246
+ this.eventsPath = join(directory, '..', 'events');
247
+ }
248
+
249
+ /** Loads every command + event, then logs the bot in. */
250
+ public async init(token: string): Promise<void> {
251
+ await Promise.all([
252
+ loadCommands(this.commandsPath, this.commands),
253
+ loadEvents(this, this.eventsPath),
254
+ ]);
255
+
256
+ await super.login(token);
257
+ }
258
+ }
259
+ `;
260
+
261
+ const COMMAND_INTERFACE_TS = outdent`
262
+ import type { ChatInputCommandInteraction, SlashCommandBuilder } from 'discord.js';
263
+
264
+ export interface Command {
265
+ data: SlashCommandBuilder;
266
+ execute(interaction: ChatInputCommandInteraction): Promise<void>;
267
+ }
268
+ `;
269
+
270
+ const EVENT_INTERFACE_TS = outdent`
271
+ import type { ClientEvents } from 'discord.js';
272
+
273
+ export interface BotEvent<T extends keyof ClientEvents> {
274
+ name: T;
275
+ once?: boolean;
276
+ execute(...args: ClientEvents[T]): void | Promise<void>;
277
+ }
278
+ `;
279
+
280
+ const MODULES_TS = outdent`
281
+ import { readdir } from 'node:fs/promises';
282
+ import { join, parse } from 'node:path';
283
+ import { pathToFileURL } from 'node:url';
284
+
285
+ /** Dev runs \`.ts\` sources, a build runs \`.js\`. Accept both. */
286
+ export const isModuleFile = (file: string): boolean => file.endsWith('.ts') || file.endsWith('.js');
287
+
288
+ /** \`ping.ts\` -> \`ping\`: the named export every module is expected to provide. */
289
+ export const moduleName = (file: string): string => parse(file).name;
290
+
291
+ /**
292
+ * Lists the loadable files in \`folder\`, sorted so the load order stays stable
293
+ * instead of depending on the order the filesystem hands entries back.
294
+ */
295
+ export const listModules = async (folder: string): Promise<string[]> =>
296
+ (await readdir(folder)).filter(isModuleFile).sort();
297
+
298
+ /** Imports a module namespace. A file URL keeps this working on every platform. */
299
+ export const importModule = async (
300
+ folder: string,
301
+ file: string,
302
+ ): Promise<Record<string, unknown>> => {
303
+ const target = pathToFileURL(join(folder, file)).href;
304
+ return (await import(target)) as Record<string, unknown>;
305
+ };
306
+
307
+ /**
308
+ * Picks the value a module exports under \`name\`, falling back to the first export
309
+ * that \`isModule\` accepts. The convention is that \`ping.ts\` exports \`ping\`, but a
310
+ * mismatch still loads instead of silently registering nothing.
311
+ */
312
+ export const resolveExport = <T>(
313
+ loaded: Record<string, unknown>,
314
+ name: string,
315
+ isModule: (value: unknown) => value is T,
316
+ ): T | undefined => {
317
+ const named = loaded[name];
318
+ if (isModule(named)) return named;
319
+ return Object.values(loaded).find(isModule);
320
+ };
321
+ `;
322
+
323
+ const LOAD_COMMANDS_TS = outdent`
324
+ import { consola } from 'consola';
325
+
326
+ import type { Collection } from 'discord.js';
327
+
328
+ import type { Command } from '../interfaces/command.js';
329
+
330
+ import { importModule, listModules, moduleName, resolveExport } from './modules.js';
331
+
332
+ /** A module is a command when it exposes both \`data\` and \`execute\`. */
333
+ const isCommand = (value: unknown): value is Command =>
334
+ typeof value === 'object' && value !== null && 'data' in value && 'execute' in value;
335
+
336
+ /**
337
+ * Imports every command in \`folder\` into \`commands\`, keyed by \`data.name\`.
338
+ * A broken file is logged and skipped so one bad command never stops the bot.
339
+ */
340
+ export const loadCommands = async (
341
+ folder: string,
342
+ commands: Collection<string, Command>,
343
+ ): Promise<Collection<string, Command>> => {
344
+ for (const file of await listModules(folder)) {
345
+ const name = moduleName(file);
346
+
347
+ try {
348
+ const loaded = await importModule(folder, file);
349
+ const command = resolveExport(loaded, name, isCommand);
350
+
351
+ if (command === undefined) {
352
+ consola.warn(\`[commands] skipping "\${file}": no "\${name}" command export\`);
353
+ continue;
354
+ }
355
+
356
+ commands.set(command.data.name, command);
357
+ } catch (error) {
358
+ consola.error(\`[commands] failed to load "\${file}"\`);
359
+ consola.error(error);
360
+ }
361
+ }
362
+
363
+ consola.info(\`[commands] loaded \${commands.size} command(s)\`);
364
+
365
+ return commands;
366
+ };
367
+ `;
368
+
369
+ const LOAD_EVENTS_TS = outdent`
370
+ import { consola } from 'consola';
371
+
372
+ import type { Client, ClientEvents } from 'discord.js';
373
+
374
+ import type { BotEvent } from '../interfaces/event.js';
375
+
376
+ import { importModule, listModules, moduleName, resolveExport } from './modules.js';
377
+
378
+ /** A module is an event when it exposes both \`name\` and \`execute\`. */
379
+ const isBotEvent = (value: unknown): value is BotEvent<keyof ClientEvents> =>
380
+ typeof value === 'object' && value !== null && 'name' in value && 'execute' in value;
381
+
382
+ /**
383
+ * Registers every event in \`folder\` on \`client\`, honouring \`once\`.
384
+ * A broken file is logged and skipped so one bad handler never stops the bot.
385
+ */
386
+ export const loadEvents = async (client: Client, folder: string): Promise<number> => {
387
+ let registered = 0;
388
+
389
+ for (const file of await listModules(folder)) {
390
+ const name = moduleName(file);
391
+
392
+ try {
393
+ const loaded = await importModule(folder, file);
394
+ const botEvent = resolveExport(loaded, name, isBotEvent);
395
+
396
+ if (botEvent === undefined) {
397
+ consola.warn(\`[events] skipping "\${file}": no "\${name}" event export\`);
398
+ continue;
399
+ }
400
+
401
+ if (botEvent.once) {
402
+ client.once(botEvent.name, (...args: ClientEvents[typeof botEvent.name]) => {
403
+ void botEvent.execute(...args);
404
+ });
405
+ } else {
406
+ client.on(botEvent.name, (...args: ClientEvents[typeof botEvent.name]) => {
407
+ void botEvent.execute(...args);
408
+ });
409
+ }
410
+
411
+ registered += 1;
412
+ } catch (error) {
413
+ consola.error(\`[events] failed to load "\${file}"\`);
414
+ consola.error(error);
415
+ }
416
+ }
417
+
418
+ consola.info(\`[events] registered \${registered} event(s)\`);
419
+
420
+ return registered;
421
+ };
422
+ `;
423
+
424
+ const PING_COMMAND_TS = outdent`
425
+ import { SlashCommandBuilder } from 'discord.js';
426
+
427
+ import type { Command } from '../interfaces/command.js';
428
+
429
+ export const ping: Command = {
430
+ data: new SlashCommandBuilder().setName('ping').setDescription('Replies with Pong!'),
431
+ async execute(interaction) {
432
+ await interaction.reply({ content: 'Pong!' });
433
+ },
434
+ };
435
+ `;
436
+
437
+ const READY_EVENT_TS = outdent`
438
+ import { consola } from 'consola';
439
+ import { Events } from 'discord.js';
440
+
441
+ import type { BotEvent } from '../interfaces/event.js';
442
+
443
+ export const ready: BotEvent<Events.ClientReady> = {
444
+ name: Events.ClientReady,
445
+ once: true,
446
+ execute(client) {
447
+ consola.success(\`Logged in as \${client.user.username}\`);
448
+ },
449
+ };
450
+ `;
451
+
452
+ const INTERACTION_CREATE_TS = outdent`
453
+ import { consola } from 'consola';
454
+ import { Events } from 'discord.js';
455
+
456
+ import type { BotClient } from '../client/index.js';
457
+ import type { BotEvent } from '../interfaces/event.js';
458
+
459
+ export const interactionCreate: BotEvent<Events.InteractionCreate> = {
460
+ name: Events.InteractionCreate,
461
+ async execute(interaction) {
462
+ if (!interaction.isChatInputCommand()) return;
463
+
464
+ const client = interaction.client as BotClient;
465
+ const command = client.commands.get(interaction.commandName);
466
+
467
+ if (command === undefined) {
468
+ await interaction.reply({ content: 'Command not found.', ephemeral: true });
469
+ return;
470
+ }
471
+
472
+ try {
473
+ await command.execute(interaction);
474
+ } catch (error) {
475
+ consola.error(\`[commands] "\${interaction.commandName}" threw an error\`);
476
+ consola.error(error);
477
+
478
+ const message = 'There was an error while executing this command.';
479
+ if (interaction.replied || interaction.deferred) {
480
+ await interaction.followUp({ content: message, ephemeral: true });
481
+ } else {
482
+ await interaction.reply({ content: message, ephemeral: true });
483
+ }
484
+ }
485
+ },
486
+ };
487
+ `;
488
+
489
+ const INDEX_TS = outdent`
490
+ import 'dotenv/config';
491
+
492
+ import { consola } from 'consola';
493
+ import { GatewayIntentBits } from 'discord.js';
494
+
495
+ import { BotClient } from './client/index.js';
496
+
497
+ const token = process.env.DISCORD_TOKEN;
498
+
499
+ if (token === undefined) {
500
+ consola.error('DISCORD_TOKEN is missing. Copy .env.example to .env and set your bot token.');
501
+ process.exit(1);
502
+ }
503
+
504
+ const client = new BotClient({
505
+ intents: [GatewayIntentBits.Guilds],
153
506
  });
154
- // Set yarn version to berry
155
- cp.exec("yarn set version berry", { cwd: process.cwd() }, () => {
156
- spinner.update({
157
- text: "Adding git...",
158
- color: "red",
159
- });
160
-
161
- // Synchronize git
162
- cp.execSync("git init", { cwd: process.cwd() });
163
-
164
- cp.exec("yarn", { cwd: process.cwd() }, () => {
165
- spinner.update({
166
- text: "Adding discord dependencies",
167
- color: "blue",
507
+
508
+ await client.init(token);
509
+ `;
510
+
511
+ const README = outdent`
512
+ # ${projectName}
513
+
514
+ A Discord bot scaffolded with [create-bot-ts](https://github.com/MahoMuri/create-bot-ts).
515
+
516
+ ## Features
517
+
518
+ - strict, type-driven TypeScript (\`verbatimModuleSyntax\`, \`exactOptionalPropertyTypes\`, \`noUncheckedIndexedAccess\`)
519
+ - discord.js v14 with typed events
520
+ - ESLint 9 flat config with type-aware linting (typescript-eslint)
521
+ - Prettier for consistent formatting
522
+ - Husky + lint-staged pre-commit hook
523
+ - Yarn 4 with the node-modules linker
524
+ - \`dotenv\`, \`consola\`, \`common-tags\`, \`table\` utilities
525
+
526
+ ## Getting started
527
+
528
+ \`\`\`sh
529
+ cp .env.example .env
530
+ \`\`\`
531
+
532
+ Open \`.env\` and set \`DISCORD_TOKEN\` to your bot token, then run:
533
+
534
+ \`\`\`sh
535
+ yarn dev
536
+ \`\`\`
537
+
538
+ ## Scripts
539
+
540
+ - \`yarn dev\` — start the bot with hot-reload (\`tsx watch\`)
541
+ - \`yarn build\` — type-check and compile to \`dist/\`
542
+ - \`yarn start\` — run the compiled bot
543
+ - \`yarn typecheck\` — run \`tsc --noEmit\`
544
+ - \`yarn lint\` / \`yarn lint:fix\` — lint with ESLint
545
+ - \`yarn format\` / \`yarn format:check\` — format with Prettier
546
+
547
+ ## Project structure
548
+
549
+ \`\`\`
550
+ src/
551
+ ├── index.ts # entry point: creates BotClient, calls init()
552
+ ├── client/ # BotClient class - extends Client; init() loads + logs in
553
+ ├── commands/ # slash commands, auto-loaded from this folder
554
+ ├── events/ # event handlers, auto-loaded from this folder
555
+ ├── interfaces/ # shared types: Command, BotEvent
556
+ └── utils/ # module loaders: loadCommands(), loadEvents(), resolveExport()
557
+ \`\`\`
558
+
559
+ ## How it works
560
+
561
+ - Every file in \`src/commands\` exports one named \`Command\` (\`ping.ts\` -> \`ping\`).
562
+ \`loadCommands\` (\`src/utils/loadCommands.ts\`) imports them into \`client.commands\`,
563
+ keyed by \`data.name\`.
564
+ - Every file in \`src/events\` exports one named \`BotEvent\` (\`ready.ts\` -> \`ready\`).
565
+ \`loadEvents\` (\`src/utils/loadEvents.ts\`) registers them, honouring \`once\`.
566
+ - \`client.init(token)\` runs both loaders, then calls \`client.login(token)\`.
567
+
568
+ Each command is a \`data\` (\`SlashCommandBuilder\`) + \`execute\` pair. To make slash
569
+ commands show up in your server, register each command's \`data\` JSON with the
570
+ Discord REST API (see the discord.js command deployment guide).
571
+ `;
572
+
573
+ // ---------------------------------------------------------------------------
574
+ // Main flow
575
+ // ---------------------------------------------------------------------------
576
+
577
+ const main = async () => {
578
+ const spinner = createSpinner(
579
+ "Creating template! Do not exit the process...",
580
+ ).start({ color: "green" });
581
+
582
+ const startedAt = process.hrtime.bigint();
583
+
584
+ const step = async (label, color, fn) => {
585
+ spinner.update({ text: label, color });
586
+ try {
587
+ await fn();
588
+ } catch (error) {
589
+ spinner.error({
590
+ text: `Failed to ${label.toLowerCase()}`,
591
+ color: "red",
168
592
  });
169
- cp.exec(
170
- "yarn add discord.js bufferutil zlib-sync utf-8-validate",
171
- { cwd: process.cwd() },
172
- () => {
173
- spinner.update({
174
- text: "Adding utility dependencies",
175
- color: "blue",
176
- });
177
- cp.exec(
178
- "yarn add consola common-tags table dotenv",
179
- {
180
- cwd: process.cwd(),
181
- },
182
- () => {
183
- spinner.update({
184
- text: "Adding necessary devDependencies",
185
- color: "yellow",
186
- });
187
- cp.exec(
188
- `yarn add --dev eslint eslint-config-airbnb-base eslint-config-prettier eslint-import-resolver-node eslint-plugin-import eslint-plugin-prettier @typescript-eslint/eslint-plugin @types/node @typescript-eslint/parser husky lint-staged node-notifier prettier ts-node ts-node-dev typescript`,
189
- { cwd: process.cwd() },
190
- () => {
191
- spinner.update({
192
- text: "Setting up husky...",
193
- color: "cyan",
194
- });
195
- cp.execSync("yarn dlx husky-init --yarn2", {
196
- cwd: process.cwd(),
197
- });
198
- spinner.update({
199
- text: "Setting up sdks...",
200
- color: "cyan",
201
- });
202
-
203
- cp.execSync(
204
- "yarn dlx @yarnpkg/sdks vscode",
205
- {
206
- cwd: process.cwd(),
207
- }
208
- );
209
-
210
- spinner.update({
211
- text: "Adding scripts...",
212
- color: "magenta",
213
- });
214
- const package = JSON.parse(
215
- fs.readFileSync(
216
- path.join(
217
- process.cwd(),
218
- "package.json"
219
- ),
220
- { encoding: "utf-8" }
221
- )
222
- );
223
- package.scripts = {
224
- test: "yarn eslint",
225
- prod: "ts-node --transpile-only ./src/index.ts",
226
- dev: "ts-node-dev --respawn --transpile-only --notify --rs ./src/index.ts",
227
- prettier: "prettier ./src/**/*.ts",
228
- "prettier:fix":
229
- "prettier --write ./src/**/*.ts",
230
- eslint: "eslint ./src/**/*.ts",
231
- "eslint:fix":
232
- "eslint --fix ./src/**/*.ts",
233
- };
234
- package["lint-staged"] = {
235
- "./src/**/*.ts": ["eslint --fix"],
236
- };u
237
-
238
- fs.writeFileSync(
239
- path.join(
240
- process.cwd(),
241
- "package.json"
242
- ),
243
- JSON.stringify(package, null, 4)
244
- );
245
-
246
- spinner.update({
247
- text: "Generating README.md...",
248
- color: "magenta",
249
- });
250
-
251
- fs.writeFileSync(
252
- path.join(process.cwd(), "README.md"),
253
- outdent`
254
- # ${package.name}
255
-
256
- Created with created with [create-bot-ts](https://github.com/MahoMuri/create-bot-ts)
257
-
258
- Based from [create-ts-pro](https://github.com/Milo123456789/create-ts-pro)
259
-
260
- Features:
261
- - Yarn PnP
262
- - Husky
263
- - ESlint and prettier
264
- - TypeScript
265
- - Version Plugin
266
- - Upgrade-interactive plugin
267
-
268
- Next Steps, run:
269
- \`\`\`sh
270
- yarn create @eslint/config
271
- \`\`\`
272
-
273
- to fully initialize eslint.
274
- `
275
- );
276
- const diff = process.hrtime(time);
277
- const seconds =
278
- (diff[0] * NS_PER_SEC +
279
- diff[1] * MS_PER_NS) /
280
- NS_PER_SEC;
281
- spinner.success({
282
- text: `Successfully Generated ${
283
- package.name
284
- } template in ${seconds.toFixed(3)}s`,
285
- });
286
- }
287
- );
288
- }
289
- );
290
- }
593
+ const message =
594
+ error instanceof Error ? error.message : String(error);
595
+ console.error(`\n${message}`);
596
+ process.exit(1);
597
+ }
598
+ };
599
+
600
+ // 1. Folder structure (kept from the original template on purpose)
601
+ await step("Scaffolding project structure", "green", () => {
602
+ for (const dir of [
603
+ "src",
604
+ "src/client",
605
+ "src/commands",
606
+ "src/events",
607
+ "src/utils",
608
+ "src/interfaces",
609
+ ]) {
610
+ fs.mkdirSync(path.join(process.cwd(), dir), { recursive: true });
611
+ }
612
+
613
+ writeFiles([
614
+ { path: "src/index.ts", content: INDEX_TS },
615
+ { path: "src/client/index.ts", content: CLIENT_TS },
616
+ { path: "src/commands/ping.ts", content: PING_COMMAND_TS },
617
+ { path: "src/events/ready.ts", content: READY_EVENT_TS },
618
+ {
619
+ path: "src/events/interactionCreate.ts",
620
+ content: INTERACTION_CREATE_TS,
621
+ },
622
+ { path: "src/interfaces/command.ts", content: COMMAND_INTERFACE_TS },
623
+ { path: "src/interfaces/event.ts", content: EVENT_INTERFACE_TS },
624
+ { path: "src/utils/modules.ts", content: MODULES_TS },
625
+ { path: "src/utils/loadCommands.ts", content: LOAD_COMMANDS_TS },
626
+ { path: "src/utils/loadEvents.ts", content: LOAD_EVENTS_TS },
627
+ ]);
628
+ });
629
+
630
+ // 2. Config files + package.json
631
+ await step("Writing configuration files", "cyan", () => {
632
+ writeFiles([
633
+ { path: "tsconfig.json", content: TS_CONFIG },
634
+ { path: ".prettierrc.json", content: PRETTIER_CONFIG },
635
+ { path: ".prettierignore", content: PRETTIER_IGNORE },
636
+ { path: ".gitignore", content: GITIGNORE },
637
+ { path: ".env.example", content: ENV_EXAMPLE },
638
+ { path: "eslint.config.mjs", content: ESLINT_CONFIG },
639
+ ]);
640
+
641
+ fs.writeFileSync(
642
+ path.join(process.cwd(), "package.json"),
643
+ `${JSON.stringify(
644
+ {
645
+ name: projectName,
646
+ version: "0.1.0",
647
+ private: true,
648
+ description: "A Discord bot scaffolded with create-bot-ts",
649
+ type: "module",
650
+ engines: { node: ">=24.17.0" },
651
+ scripts: {
652
+ dev: "tsx watch src/index.ts",
653
+ build: "tsc",
654
+ start: "node dist/index.js",
655
+ typecheck: "tsc --noEmit",
656
+ lint: "eslint .",
657
+ "lint:fix": "eslint . --fix",
658
+ format: "prettier --write .",
659
+ "format:check": "prettier --check .",
660
+ },
661
+ "lint-staged": {
662
+ "*.ts": ["eslint --fix", "prettier --write"],
663
+ },
664
+ },
665
+ null,
666
+ 2,
667
+ )}\n`,
668
+ );
669
+ });
670
+
671
+ // 3. Git + Yarn 4
672
+ await step("Initializing git repository", "red", () =>
673
+ run("git init -b main"),
674
+ );
675
+ await step("Enabling Yarn 4 (stable)", "blue", () =>
676
+ run("yarn set version stable"),
677
+ );
678
+
679
+ // Keep whatever `yarn set version` wrote (e.g. yarnPath), then add the linker.
680
+ await step("Configuring Yarn (node-modules linker)", "blue", () => {
681
+ const yarnrcPath = path.join(process.cwd(), ".yarnrc.yml");
682
+ const existing = fs.existsSync(yarnrcPath)
683
+ ? fs.readFileSync(yarnrcPath, "utf8")
684
+ : "";
685
+ if (!existing.includes("nodeLinker")) {
686
+ const linker =
687
+ "# Install dependencies into node_modules for\n" +
688
+ "# maximum tooling compatibility.\n" +
689
+ "nodeLinker: node-modules\n";
690
+ fs.writeFileSync(
691
+ yarnrcPath,
692
+ existing.trimEnd() === ""
693
+ ? linker
694
+ : `${existing.trimEnd()}\n\n${linker}`,
291
695
  );
292
- });
696
+ }
697
+ });
698
+
699
+ // 4. Dependencies
700
+ await step("Adding runtime dependencies", "blue", () =>
701
+ run(
702
+ "yarn add discord.js bufferutil zlib-sync consola common-tags table dotenv",
703
+ ),
704
+ );
705
+
706
+ await step("Adding development dependencies", "yellow", () =>
707
+ run(
708
+ "yarn add -D 'typescript@^6' tsx @types/node eslint @eslint/js typescript-eslint eslint-config-prettier prettier globals husky lint-staged",
709
+ ),
710
+ );
711
+
712
+ // 5. Husky. Yarn does not run the `prepare` lifecycle, so husky is wired
713
+ // through `postinstall` instead (https://typicode.github.io/husky/how-to.html).
714
+ await step("Setting up husky + lint-staged", "cyan", async () => {
715
+ fs.mkdirSync(path.join(process.cwd(), ".husky"), { recursive: true });
716
+ fs.writeFileSync(
717
+ path.join(process.cwd(), ".husky", "pre-commit"),
718
+ "yarn lint-staged\n",
719
+ );
720
+
721
+ const pkgPath = path.join(process.cwd(), "package.json");
722
+ const pkg = JSON.parse(fs.readFileSync(pkgPath, "utf8"));
723
+ pkg.scripts.postinstall = "husky";
724
+ fs.writeFileSync(pkgPath, `${JSON.stringify(pkg, null, 2)}\n`);
725
+
726
+ await run("yarn postinstall");
727
+ });
728
+
729
+ // 6. README
730
+ await step("Generating README.md", "magenta", () => {
731
+ fs.writeFileSync(
732
+ path.join(process.cwd(), "README.md"),
733
+ `${README.trimEnd()}\n`,
734
+ );
293
735
  });
294
- });
736
+
737
+ const seconds = Number(process.hrtime.bigint() - startedAt) / 1_000_000_000;
738
+ spinner.success({
739
+ text: `Successfully generated ${projectName} template in ${seconds.toFixed(3)}s`,
740
+ });
741
+ };
742
+
743
+ if (require.main === module) {
744
+ main();
745
+ }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "create-bot-ts",
3
- "version": "1.0.7",
4
- "description": "\"MahoMuri's personal template for Discord Bots\"",
3
+ "version": "1.1.0",
4
+ "description": "MahoMuri's personal template for Discord bots",
5
5
  "main": "index.js",
6
6
  "author": "MahoMuri",
7
7
  "license": "MIT",
@@ -9,6 +9,9 @@
9
9
  "create-bot-ts": "index.js"
10
10
  },
11
11
  "private": false,
12
+ "engines": {
13
+ "node": ">=24.17.0"
14
+ },
12
15
  "dependencies": {
13
16
  "nanospinner": "^1.0.0",
14
17
  "outdent": "^0.8.0"