meocord 3.1.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 (48) hide show
  1. package/README.md +138 -15
  2. package/dist/cjs/decorator/index.cjs +1 -1
  3. package/dist/esm/bin/app-template/README.md.template +61 -0
  4. package/dist/esm/bin/app-template/_env.example.template +2 -0
  5. package/dist/esm/bin/app-template/_gitignore.template +22 -0
  6. package/dist/esm/bin/app-template/_prettierrc.mjs.template +10 -0
  7. package/dist/esm/bin/app-template/eslint.config.ts.template +25 -0
  8. package/dist/esm/bin/app-template/meocord.config.ts.template +25 -0
  9. package/dist/esm/bin/app-template/package.json.template +38 -0
  10. package/dist/esm/bin/app-template/src/app.ts.template +44 -0
  11. package/dist/esm/bin/app-template/src/controllers/button/sample.button.controller.spec.ts.template +17 -0
  12. package/dist/esm/bin/app-template/src/controllers/button/sample.button.controller.ts.template +19 -0
  13. package/dist/esm/bin/app-template/src/controllers/context-menu/builders/sample.builder.ts.template +10 -0
  14. package/dist/esm/bin/app-template/src/controllers/context-menu/sample.context-menu.controller.spec.ts.template +17 -0
  15. package/dist/esm/bin/app-template/src/controllers/context-menu/sample.context-menu.controller.ts.template +13 -0
  16. package/dist/esm/bin/app-template/src/controllers/message/sample.message.controller.spec.ts.template +17 -0
  17. package/dist/esm/bin/app-template/src/controllers/message/sample.message.controller.ts.template +28 -0
  18. package/dist/esm/bin/app-template/src/controllers/modal-submit/sample.modal-submit.controller.spec.ts.template +17 -0
  19. package/dist/esm/bin/app-template/src/controllers/modal-submit/sample.modal-submit.controller.ts.template +13 -0
  20. package/dist/esm/bin/app-template/src/controllers/reaction/sample.reaction.controller.spec.ts.template +17 -0
  21. package/dist/esm/bin/app-template/src/controllers/reaction/sample.reaction.controller.ts.template +29 -0
  22. package/dist/esm/bin/app-template/src/controllers/select-menu/sample.select-menu.controller.spec.ts.template +17 -0
  23. package/dist/esm/bin/app-template/src/controllers/select-menu/sample.select-menu.controller.ts.template +11 -0
  24. package/dist/esm/bin/app-template/src/controllers/slash/builders/sample.builder.ts.template +10 -0
  25. package/dist/esm/bin/app-template/src/controllers/slash/sample.slash.controller.spec.ts.template +17 -0
  26. package/dist/esm/bin/app-template/src/controllers/slash/sample.slash.controller.ts.template +19 -0
  27. package/dist/esm/bin/app-template/src/guards/rate-limit.guard.spec.ts.template +13 -0
  28. package/dist/esm/bin/app-template/src/guards/rate-limit.guard.ts.template +52 -0
  29. package/dist/esm/bin/app-template/src/main.ts.template +14 -0
  30. package/dist/esm/bin/app-template/src/services/sample.service.spec.ts.template +17 -0
  31. package/dist/esm/bin/app-template/src/services/sample.service.ts.template +9 -0
  32. package/dist/esm/bin/app-template/tsconfig.eslint.json.template +5 -0
  33. package/dist/esm/bin/app-template/tsconfig.json.template +30 -0
  34. package/dist/esm/bin/app-template/tsconfig.test.json.template +8 -0
  35. package/dist/esm/bin/app-template/vitest.config.ts.template +35 -0
  36. package/dist/esm/bin/builder-template/controller/button.controller.template +1 -1
  37. package/dist/esm/bin/helper/app-generator.helper.js +78 -0
  38. package/dist/esm/bin/meocord.js +156 -75
  39. package/dist/esm/decorator/guard.decorator.js +1 -1
  40. package/dist/esm/util/common.util.js +11 -3
  41. package/dist/esm/util/generator-cli.util.js +21 -4
  42. package/dist/esm/util/package-manager.util.js +9 -2
  43. package/dist/esm/util/package-version.util.js +32 -0
  44. package/dist/esm/util/runtime.util.js +72 -0
  45. package/dist/types/core/index.d.ts +2 -2
  46. package/dist/types/decorator/index.d.ts +1 -1
  47. package/dist/types/interface/index.d.ts +5 -3
  48. package/package.json +11 -12
@@ -7,8 +7,9 @@ import { spawn } from 'node:child_process';
7
7
  import { capitalize } from 'lodash-es';
8
8
  import wait from '../util/wait.util.js';
9
9
  import { GeneratorCLI } from './generator.js';
10
+ import { runtimePrefixFor, AppGeneratorHelper } from './helper/app-generator.helper.js';
10
11
  import * as fs from 'node:fs';
11
- import { setEnvironment, compileAndValidateConfig } from '../util/common.util.js';
12
+ import { setEnvironment, compileAndValidateConfig, validateDiscordToken } from '../util/common.util.js';
12
13
  import { prepareModifiedTsConfig } from '../util/tsconfig.util.js';
13
14
  import { Command } from 'commander';
14
15
  import { simpleGit } from 'simple-git';
@@ -16,6 +17,8 @@ import { execSync } from 'child_process';
16
17
  import * as p from '@clack/prompts';
17
18
  import { detectInstalledPMs, getInstallCommand } from '../util/package-manager.util.js';
18
19
  import { ensureReady, configureCommandHelp } from '../util/meocord-cli.util.js';
20
+ import { resolveOwnVersion } from '../util/package-version.util.js';
21
+ import { buildAppCommand, resolveRuntime } from '../util/runtime.util.js';
19
22
  import packageJson from '../package.json.js';
20
23
  import { fileURLToPath } from 'url';
21
24
  import TsconfigPathsPlugin from 'tsconfig-paths-webpack-plugin';
@@ -25,7 +28,35 @@ const __filename$1 = fileURLToPath(import.meta.url);
25
28
  const __dirname$1 = path.dirname(__filename$1);
26
29
  /**
27
30
  * A Command Line Interface (CLI) for managing the MeoCord application.
28
- */ class MeoCordCLI {
31
+ */ /**
32
+ * Oldest Node the framework supports, mirroring `engines.node`.
33
+ *
34
+ * A test holds the two together, since a floor that drifted from the manifest would warn
35
+ * about versions npm is happy to install on.
36
+ */ const MINIMUM_NODE_MAJOR = 22;
37
+ /**
38
+ * Warns when the running Node is older than the framework supports.
39
+ *
40
+ * Advisory only. Comparing against the newest LTS release meant warning about being a
41
+ * patch behind, and reaching the network to find out meant a failed request could stop
42
+ * the command outright — for a check whose only outcome is a warning.
43
+ */ function warnIfNodeIsBelowSupported() {
44
+ const major = Number.parseInt(process.versions.node.split('.')[0], 10);
45
+ if (Number.isFinite(major) && major < MINIMUM_NODE_MAJOR) {
46
+ p.log.warn(`Node ${process.versions.node} is older than the supported minimum (v${MINIMUM_NODE_MAJOR}). ` + `The app will be created, but may not run.`);
47
+ }
48
+ }
49
+ /**
50
+ * The package manager's own complaint, which `execSync` buries on the thrown object.
51
+ *
52
+ * Its `message` says only that a command exited non-zero, which is never the part worth
53
+ * reading.
54
+ */ function installFailure(error) {
55
+ const { stderr, message } = error ?? {};
56
+ const reported = stderr?.toString().trim();
57
+ return new Error(reported || message || String(error));
58
+ }
59
+ class MeoCordCLI {
29
60
  /**
30
61
  * Configures and runs the MeoCord CLI.
31
62
  */ async run() {
@@ -81,6 +112,9 @@ copies or substantial portions of the Software.
81
112
  if (options.build || options.dev) {
82
113
  await compileAndValidateConfig();
83
114
  }
115
+ // Checked for every start, including one that skips the build: this is the point
116
+ // where the application actually needs to log in.
117
+ await validateDiscordToken();
84
118
  if (options.build) {
85
119
  await this.build(mode);
86
120
  await this.compileConfig();
@@ -94,7 +128,6 @@ copies or substantial portions of the Software.
94
128
  async createApp(appName, options) {
95
129
  const kebabCaseAppName = appName.replace(/([a-z])([A-Z])/g, '$1-$2').toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '');
96
130
  const appPath = path.resolve(process.cwd(), kebabCaseAppName);
97
- const gitRepo = 'https://github.com/l7aromeo/meocord-template.git';
98
131
  p.intro(`meocord v${this.version}`);
99
132
  // Validate directory
100
133
  if (fs.existsSync(appPath)) {
@@ -102,24 +135,7 @@ copies or substantial portions of the Software.
102
135
  await wait(100);
103
136
  process.exit(1);
104
137
  }
105
- let latestLTS = null;
106
- try {
107
- const res = await fetch('https://nodejs.org/dist/index.json');
108
- const releases = await res.json();
109
- const ltsRelease = releases.find((r)=>r.lts !== false);
110
- if (ltsRelease) latestLTS = ltsRelease.version.slice(1);
111
- } catch {
112
- p.cancel('No internet connection. Creating a MeoCord app requires network access.');
113
- await wait(100);
114
- process.exit(1);
115
- }
116
- if (latestLTS) {
117
- const [major, minor, patch] = process.version.slice(1).split('.').map(Number);
118
- const [minMajor, minMinor, minPatch] = latestLTS.split('.').map(Number);
119
- if (major < minMajor || major === minMajor && minor < minMinor || major === minMajor && minor === minMinor && patch < minPatch) {
120
- p.log.warn(`Your Node.js (${process.version}) is behind the latest LTS (v${latestLTS}). Consider upgrading for best compatibility.`);
121
- }
122
- }
138
+ warnIfNodeIsBelowSupported();
123
139
  // Determine package manager
124
140
  const installedPMs = detectInstalledPMs();
125
141
  let pm;
@@ -155,47 +171,42 @@ copies or substantial portions of the Software.
155
171
  pm = selected;
156
172
  }
157
173
  const s = p.spinner();
158
- // Clone template
159
174
  s.start(`Creating a new MeoCord app: ${kebabCaseAppName}`);
160
175
  try {
161
- await simpleGit().clone(gitRepo, appPath);
176
+ this.appGeneratorHelper.generateApp(appPath, {
177
+ appName: kebabCaseAppName,
178
+ displayName: appName,
179
+ version: this.version,
180
+ packageManager: pm,
181
+ runtimePrefix: runtimePrefixFor(pm)
182
+ });
162
183
  } catch (error) {
163
- s.stop('Failed to fetch template.');
164
- p.cancel(error instanceof Error ? error.message : String(error));
165
- await wait(100);
166
- process.exit(1);
184
+ s.stop('Failed to create the app.');
185
+ await this.abortCreate(appPath, error);
167
186
  }
168
187
  s.stop(`App created at: ${appPath}`);
169
- // Initialize git
170
188
  s.start('Initializing Git repository...');
171
189
  try {
172
- fs.rmSync(path.join(appPath, '.git'), {
173
- recursive: true,
174
- force: true
175
- });
176
190
  const git = simpleGit(appPath);
177
191
  await git.init();
178
192
  await git.add('./*');
179
193
  await git.commit('Initial commit');
180
194
  } catch (error) {
181
195
  s.stop('Failed to initialize Git.');
182
- p.cancel(error instanceof Error ? error.message : String(error));
183
- await wait(100);
184
- process.exit(1);
196
+ await this.abortCreate(appPath, error);
185
197
  }
186
198
  s.stop('Git repository initialized.');
187
- // Install dependencies
188
199
  s.start(`Installing dependencies with ${pm}...`);
189
200
  try {
201
+ // Output is captured rather than discarded: a failed install is only actionable
202
+ // if the resolver's complaint survives to the error message.
190
203
  execSync(getInstallCommand(pm), {
191
204
  cwd: appPath,
192
- stdio: 'ignore'
205
+ stdio: 'pipe'
193
206
  });
194
207
  } catch (error) {
195
208
  s.stop('Failed to install dependencies.');
196
- p.cancel(error instanceof Error ? error.message : String(error));
197
- await wait(100);
198
- process.exit(1);
209
+ await this.abortCreate(appPath, installFailure(error));
199
210
  }
200
211
  s.stop('Dependencies installed.');
201
212
  p.outro(`MeoCord app "${kebabCaseAppName}" is ready!`);
@@ -341,6 +352,40 @@ copies or substantial portions of the Software.
341
352
  }
342
353
  }
343
354
  /**
355
+ * Replaces the running application with one built from the current sources.
356
+ *
357
+ * The replacement is spawned only once the previous process has exited. Both would
358
+ * hold the same gateway session, and claiming it before the first lets go produces a
359
+ * login conflict rather than a reload.
360
+ */ restartApp() {
361
+ const previous = this.appProcess;
362
+ this.appProcess = null;
363
+ if (!previous || previous.killed) {
364
+ this.appProcess = this.spawnApp();
365
+ return;
366
+ }
367
+ previous.removeAllListeners('exit');
368
+ previous.once('exit', ()=>{
369
+ this.appProcess = this.spawnApp();
370
+ });
371
+ previous.kill();
372
+ }
373
+ /**
374
+ * Runs the built application.
375
+ *
376
+ * Shared by both start modes so watching and production launch the bundle the same
377
+ * way; a watcher that spawned it differently could pass in development and fail in
378
+ * production on the difference alone.
379
+ *
380
+ * @returns The application process.
381
+ */ spawnApp() {
382
+ const { command, args } = buildAppCommand(this.runtime, this.mainJSPath);
383
+ return spawn(command, args, {
384
+ cwd: this.projectRoot,
385
+ stdio: 'inherit'
386
+ });
387
+ }
388
+ /**
344
389
  * Starts the MeoCord application in development mode with live updates.
345
390
  */ async startDev() {
346
391
  try {
@@ -357,7 +402,6 @@ copies or substantial portions of the Software.
357
402
  await wait(100);
358
403
  process.exit(1);
359
404
  }
360
- let nodemonProcess = null;
361
405
  let isRunning = false;
362
406
  const watch = ()=>compiler.watch({}, (err, stats)=>{
363
407
  if (err) {
@@ -367,18 +411,7 @@ copies or substantial portions of the Software.
367
411
  if (stats?.hasErrors()) {
368
412
  this.logger.error('Build failed due to errors in the compilation process:', stats.compilation.errors);
369
413
  } else {
370
- if (nodemonProcess) {
371
- nodemonProcess.kill();
372
- nodemonProcess = null;
373
- }
374
- nodemonProcess = spawn('npx -y nodemon', [
375
- '-q',
376
- this.mainJSPath
377
- ], {
378
- shell: true,
379
- cwd: this.projectRoot,
380
- stdio: 'inherit'
381
- });
414
+ this.restartApp();
382
415
  isRunning = true;
383
416
  }
384
417
  });
@@ -387,12 +420,12 @@ copies or substantial portions of the Software.
387
420
  const fsWatcher = fs.watch(path.resolve(process.cwd(), 'meocord.config.ts'), ()=>{
388
421
  clearTimeout(debounceWatcher);
389
422
  debounceWatcher = setTimeout(async ()=>{
390
- if (isRunning && nodemonProcess) {
423
+ if (isRunning && this.appProcess) {
391
424
  isRunning = false;
392
425
  this.logger.log('MeoCord config change detected, reloading config...');
393
- if (nodemonProcess && !nodemonProcess.killed) {
394
- nodemonProcess.kill();
395
- nodemonProcess = null;
426
+ if (this.appProcess && !this.appProcess.killed) {
427
+ this.appProcess.kill();
428
+ this.appProcess = null;
396
429
  }
397
430
  await new Promise((resolve)=>compiler.close(resolve));
398
431
  watch();
@@ -403,15 +436,15 @@ copies or substantial portions of the Software.
403
436
  process.on('SIGINT', async ()=>{
404
437
  if (sigintReceived) {
405
438
  // Second Ctrl+C — force kill and exit immediately
406
- if (nodemonProcess && !nodemonProcess.killed) nodemonProcess.kill('SIGKILL');
439
+ if (this.appProcess && !this.appProcess.killed) this.appProcess.kill('SIGKILL');
407
440
  process.exit(1);
408
441
  }
409
442
  sigintReceived = true;
410
- // Nodemon and the bot already received SIGINT from the process group.
411
- // Clean up parent-owned resources and wait for nodemon to exit.
443
+ // The application already received SIGINT from the process group. Clean up
444
+ // parent-owned resources and wait for it to exit on its own terms.
412
445
  fsWatcher.close();
413
- if (nodemonProcess && !nodemonProcess.killed) {
414
- nodemonProcess.on('exit', async ()=>{
446
+ if (this.appProcess && !this.appProcess.killed) {
447
+ this.appProcess.on('exit', async ()=>{
415
448
  await new Promise((resolve)=>compiler.close(resolve));
416
449
  process.exit(0);
417
450
  });
@@ -436,11 +469,7 @@ copies or substantial portions of the Software.
436
469
  }
437
470
  this.clearConsole();
438
471
  this.logger.log('Starting...');
439
- const start = spawn(`node ${this.mainJSPath}`, {
440
- shell: true,
441
- cwd: this.projectRoot,
442
- stdio: 'inherit'
443
- }).on('spawn', this.clearConsole);
472
+ const start = this.spawnApp().on('spawn', this.clearConsole);
444
473
  start.on('exit', (code)=>{
445
474
  process.exit(code ?? 0);
446
475
  });
@@ -462,6 +491,20 @@ copies or substantial portions of the Software.
462
491
  }
463
492
  }
464
493
  /**
494
+ * Reports why creation failed and removes what was written.
495
+ *
496
+ * A directory left behind from a failed attempt is indistinguishable from one the user
497
+ * meant to keep, and the next attempt refuses to start because the name is taken.
498
+ */ async abortCreate(appPath, error) {
499
+ fs.rmSync(appPath, {
500
+ recursive: true,
501
+ force: true
502
+ });
503
+ p.cancel(error instanceof Error ? error.message : String(error));
504
+ await wait(100);
505
+ process.exit(1);
506
+ }
507
+ /**
465
508
  * Clears the console on all platforms.
466
509
  */ clearConsole() {
467
510
  process.stdout.write('\u001b[3J\u001b[2J\u001b[H');
@@ -473,13 +516,51 @@ copies or substantial portions of the Software.
473
516
  this.mainJSPath = path.join(this.projectRoot, 'dist', 'main.js');
474
517
  this.webpackConfigPath = path.resolve(__dirname$1, '..', '..', '..', 'webpack.config.js');
475
518
  this.generatorCLI = new GeneratorCLI(this.appName);
476
- this.version = packageJson.version;
519
+ this.appGeneratorHelper = new AppGeneratorHelper();
520
+ this.version = resolveOwnVersion(__dirname$1, packageJson.version);
521
+ /**
522
+ * Binary the application is spawned with, so it runs on the same runtime as the CLI
523
+ * rather than on whichever one happens to be named in the source.
524
+ */ this.runtime = resolveRuntime(process.env, process.execPath);
525
+ /** The running application, while a watch session owns one. */ this.appProcess = null;
526
+ }
527
+ }
528
+ /**
529
+ * Whether this file is what the process was started with.
530
+ *
531
+ * The bin ships behind a `node_modules/.bin` symlink, so `argv[1]` is the link while
532
+ * `import.meta.url` is its target; the two only agree once the link is resolved.
533
+ * Anything undecidable counts as a launch, so the published CLI still starts in cases
534
+ * this cannot classify.
535
+ */ function isProcessEntry() {
536
+ const invoked = process.argv[1];
537
+ if (invoked === undefined) return true;
538
+ try {
539
+ return samePath(fs.realpathSync(invoked), __filename$1);
540
+ } catch {
541
+ return true;
477
542
  }
478
543
  }
479
- // Create an instance of the CLI and run it
480
- const cli = new MeoCordCLI();
481
- cli.run().catch(async (error)=>{
482
- cli.logger.error('Failed to initialize CLI:', error?.message || error);
483
- await wait(100);
484
- process.exit(1);
485
- });
544
+ /**
545
+ * Whether two resolved paths name the same file.
546
+ *
547
+ * Windows paths differ in case and separator without naming different files — npm's
548
+ * shim passes the script path in whatever form it recorded — and a comparison that
549
+ * missed would leave the CLI exiting without a word, since it would decide it was
550
+ * merely being imported.
551
+ */ function samePath(left, right) {
552
+ const normalise = (value)=>process.platform === 'win32' ? path.resolve(value).toLowerCase() : value;
553
+ return normalise(left) === normalise(right);
554
+ }
555
+ // Importing this module must not launch the command parser: its own tests do exactly
556
+ // that to inspect how the application is spawned.
557
+ if (isProcessEntry()) {
558
+ const cli = new MeoCordCLI();
559
+ cli.run().catch(async (error)=>{
560
+ cli.logger.error('Failed to initialize CLI:', error?.message || error);
561
+ await wait(100);
562
+ process.exit(1);
563
+ });
564
+ }
565
+
566
+ export { MINIMUM_NODE_MAJOR, MeoCordCLI };
@@ -99,7 +99,7 @@ export class ButtonInteractionGuard implements GuardInterface {
99
99
  * @example
100
100
  * ```typescript
101
101
  * // Method-level usage
102
- * @Command('profile-{id}', CommandType.BUTTON)
102
+ * @Command('profile/{id}', CommandType.BUTTON)
103
103
  * @UseGuard(
104
104
  * { provide: RateLimiterGuard, params: { limit: 2, window: 3000 } },
105
105
  * ButtonInteractionGuard
@@ -47,8 +47,16 @@ import chalk from 'chalk';
47
47
  await wait(100);
48
48
  process.exit(1);
49
49
  }
50
- const meocordConfig = loadMeoCordConfig();
51
- if (!meocordConfig?.discordToken) {
50
+ loadMeoCordConfig();
51
+ }
52
+ /**
53
+ * Ensures a Discord token is configured.
54
+ *
55
+ * Kept apart from {@link compileAndValidateConfig} because producing a bundle needs no
56
+ * credentials — only connecting to the gateway does. Requiring one to build meant a
57
+ * freshly created application could not be built until a token had been obtained.
58
+ */ async function validateDiscordToken() {
59
+ if (!loadMeoCordConfig()?.discordToken) {
52
60
  console.error(chalk.red('Discord token is missing!'));
53
61
  await wait(100);
54
62
  process.exit(1);
@@ -65,4 +73,4 @@ import chalk from 'chalk';
65
73
  }
66
74
  }
67
75
 
68
- export { compileAndValidateConfig, findModulePackageDir, setEnvironment };
76
+ export { compileAndValidateConfig, findModulePackageDir, setEnvironment, validateDiscordToken };
@@ -1,6 +1,6 @@
1
1
  import fs from 'fs';
2
2
  import path from 'path';
3
- import { exec } from 'child_process';
3
+ import { execFile } from 'child_process';
4
4
  import { Logger } from '../common/logger.js';
5
5
  import '../common/theme.js';
6
6
  import { kebabCase, startCase, camelCase } from 'lodash-es';
@@ -67,12 +67,29 @@ const logger = new Logger('MeoCord');
67
67
  */ function generateFile(filePath, content) {
68
68
  try {
69
69
  fs.writeFileSync(filePath, content);
70
- logger.log(`Guard file created at: ${path.relative(process.cwd(), filePath)}`);
71
- exec(`npx eslint --fix ${filePath}`);
70
+ logger.log(`Created ${path.relative(process.cwd(), filePath)}`);
71
+ formatWithLocalESLint(filePath);
72
72
  } catch (error) {
73
- logger.error(`Failed to create guard file at ${filePath}`, error);
73
+ logger.error(`Failed to create ${filePath}`, error);
74
74
  }
75
75
  }
76
+ /**
77
+ * Formats a generated file with the project's own ESLint, when it has one.
78
+ *
79
+ * Reaching for `npx` instead would start downloading ESLint into a project that
80
+ * deliberately does not have it, once per generated file, with no way to see it happen —
81
+ * the call is not awaited. A project with its own rules still gets them applied.
82
+ */ function formatWithLocalESLint(filePath) {
83
+ const binary = path.resolve(process.cwd(), 'node_modules', '.bin', process.platform === 'win32' ? 'eslint.cmd' : 'eslint');
84
+ if (!fs.existsSync(binary)) return;
85
+ execFile(binary, [
86
+ '--fix',
87
+ filePath
88
+ ], ()=>{
89
+ // Formatting is a courtesy; a project whose rules reject the template should still
90
+ // end up with the file it asked for.
91
+ });
92
+ }
76
93
  /**
77
94
  * Builds and returns a template string for a given class name using a specific template file.
78
95
  * @param className - The name of the class to insert into the template.
@@ -6,10 +6,17 @@ const ALL_PACKAGE_MANAGERS = [
6
6
  'yarn',
7
7
  'pnpm'
8
8
  ];
9
- function detectInstalledPMs() {
9
+ /**
10
+ * The package managers available on this machine.
11
+ *
12
+ * Each candidate is asked for its version rather than looked up with `which`: `which` is
13
+ * a separate binary that a minimal image need not carry, and it is absent on Windows
14
+ * entirely. Running the tool also answers the question actually being asked — whether it
15
+ * works — rather than whether something with that name sits on the path.
16
+ */ function detectInstalledPMs() {
10
17
  return ALL_PACKAGE_MANAGERS.filter((pm)=>{
11
18
  try {
12
- execSync(`which ${pm}`, {
19
+ execSync(`${pm} --version`, {
13
20
  stdio: 'ignore'
14
21
  });
15
22
  return true;
@@ -0,0 +1,32 @@
1
+ import fs__default from 'node:fs';
2
+ import path from 'node:path';
3
+
4
+ /** The package this CLI ships in, which is where the walk below stops. */ const OWN_PACKAGE_NAME = 'meocord';
5
+ /**
6
+ * The version recorded in the package this CLI ships in.
7
+ *
8
+ * Read at run time rather than compiled in. The release bumps the manifest after the
9
+ * bundle has already been built, so a version baked into the bundle is always the one
10
+ * from before the release that published it.
11
+ *
12
+ * The walk stops at the first manifest naming this package: the repository root when
13
+ * running from source, the installed package root when running from `dist`.
14
+ *
15
+ * @param startDir - Directory to walk up from.
16
+ * @param fallback - Returned when no manifest for this package can be read.
17
+ */ function resolveOwnVersion(startDir, fallback) {
18
+ let current = path.resolve(startDir);
19
+ while(true){
20
+ try {
21
+ const manifest = JSON.parse(fs__default.readFileSync(path.join(current, 'package.json'), 'utf8'));
22
+ if (manifest.name === OWN_PACKAGE_NAME && typeof manifest.version === 'string') return manifest.version;
23
+ } catch {
24
+ // Nothing readable here; the next directory up may still hold the manifest.
25
+ }
26
+ const parent = path.dirname(current);
27
+ if (parent === current) return fallback;
28
+ current = parent;
29
+ }
30
+ }
31
+
32
+ export { resolveOwnVersion };
@@ -0,0 +1,72 @@
1
+ /**
2
+ * MeoCord Framework
3
+ * Copyright (c) 2025 Ukasyah Rahmatullah Zada
4
+ * SPDX-License-Identifier: MIT
5
+ */ /**
6
+ * Environment variable that pins the binary the application is spawned with.
7
+ *
8
+ * An escape hatch for pinning a specific install, or for running the application under a
9
+ * different runtime than the CLI to compare behaviour. Unset is the normal case.
10
+ */ const RUNTIME_OVERRIDE_ENV = 'MEOCORD_RUNTIME';
11
+ /**
12
+ * Runners that are themselves JavaScript runtimes.
13
+ *
14
+ * `npm_execpath` names the binary that launched the script. npm, pnpm and yarn set it to
15
+ * a `.js` file, which cannot run the application, so only the runners listed here are
16
+ * read as a runtime. An entry belongs here only once it has been checked against that
17
+ * runner's real environment, since guessing wrong means spawning something that cannot
18
+ * execute the bundle.
19
+ */ const RUNTIME_RUNNERS = new Set([
20
+ 'bun'
21
+ ]);
22
+ /**
23
+ * The runtime that launched the CLI, when the CLI is not itself running on it.
24
+ *
25
+ * `bun run` honours the bin's `#!/usr/bin/env node` shebang, so the CLI lands on node
26
+ * even though the user asked for bun. What they chose is still recoverable: bun sets
27
+ * `npm_config_user_agent` to `bun/<version> …` and `npm_execpath` to its own binary.
28
+ *
29
+ * @param env - Environment the CLI was launched with.
30
+ * @returns The launcher's binary, or `undefined` when it cannot run the application.
31
+ */ function launcherRuntime(env) {
32
+ const runner = env.npm_config_user_agent?.split('/')[0];
33
+ const launcher = env.npm_execpath?.trim();
34
+ if (runner === undefined || !launcher) return undefined;
35
+ return RUNTIME_RUNNERS.has(runner) ? launcher : undefined;
36
+ }
37
+ /**
38
+ * The binary the application should be spawned with.
39
+ *
40
+ * Follows the runtime the user chose rather than naming one. Someone who typed `bun`
41
+ * gets a bun process, and an image built on bun alone stays that way — pinning `node`
42
+ * would oblige them to install a second runtime beside the one they picked, or to
43
+ * remember `--bun` on every command.
44
+ *
45
+ * Preference runs from the most explicit signal to the least: an override, then the
46
+ * runner that launched the CLI, then the binary executing it.
47
+ *
48
+ * @param env - Environment the CLI was launched with.
49
+ * @param execPath - Binary executing the CLI, i.e. `process.execPath`.
50
+ * @returns The binary to spawn.
51
+ */ function resolveRuntime(env, execPath) {
52
+ const override = env[RUNTIME_OVERRIDE_ENV]?.trim();
53
+ // An override of only whitespace would spawn '' and fail with an ENOENT naming nothing,
54
+ // so it is treated as absent rather than passed through.
55
+ if (override) return override;
56
+ return launcherRuntime(env) ?? execPath;
57
+ }
58
+ /**
59
+ * Runs the built application directly.
60
+ *
61
+ * @param runtime - Binary to run the application with.
62
+ * @param mainJsPath - Absolute path to the built entry file.
63
+ */ function buildAppCommand(runtime, mainJsPath) {
64
+ return {
65
+ command: runtime,
66
+ args: [
67
+ mainJsPath
68
+ ]
69
+ };
70
+ }
71
+
72
+ export { RUNTIME_OVERRIDE_ENV, buildAppCommand, resolveRuntime };
@@ -52,8 +52,8 @@ declare class MeoCordApp {
52
52
  *
53
53
  * Built once and cached, so dispatch stays a single ordered walk with an early exit
54
54
  * rather than paying to rank anything per interaction. Ordering here is what lets
55
- * `gi-profile-summary-{ownerId}-{uid}` keep the ids it owns when
56
- * `gi-profile-{uuid}-{uid}` would also match them — without it, the winner would be
55
+ * `gi-profile/summary/{ownerId}/{uid}` keep the ids it owns when
56
+ * `gi-profile/{uuid}/{uid}` would also match them — without it, the winner would be
57
57
  * whichever controller happened to be registered first.
58
58
  */
59
59
  private componentRoutes?;
@@ -295,7 +295,7 @@ interface GuardWithParams {
295
295
  * @example
296
296
  * ```typescript
297
297
  * // Method-level usage
298
- * @Command('profile-{id}', CommandType.BUTTON)
298
+ * @Command('profile/{id}', CommandType.BUTTON)
299
299
  * @UseGuard(
300
300
  * { provide: RateLimiterGuard, params: { limit: 2, window: 3000 } },
301
301
  * ButtonInteractionGuard
@@ -57,9 +57,11 @@ interface CommandMetadata<T extends string = string> {
57
57
  dynamicParams?: T[];
58
58
  /**
59
59
  * How specific this pattern is; higher wins when more than one route matches the
60
- * same customId. A greedy parameter can overlap a more literal sibling —
61
- * `gi-profile-{uuid:*}-{uid}` also matches an id meant for
62
- * `gi-profile-summary-{ownerId}-{uid}` — so dispatch cannot rely on declaration order.
60
+ * same customId. A parameter takes whatever fills its segment, so a broad pattern can
61
+ * also match an id a more literal sibling owns — `gi-profile/{uuid}/{uid}` matches
62
+ * `gi-profile/summary/123/456` just as `gi-profile/summary/{ownerId}/{uid}` does — and
63
+ * ranking is what settles it, since declaration order would make the winner depend on
64
+ * file layout.
63
65
  */
64
66
  specificity?: number;
65
67
  }