@amxts/cli 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (51) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +20 -0
  3. package/bin/amxts.mjs +8 -0
  4. package/package.json +45 -0
  5. package/src/args.mjs +62 -0
  6. package/src/catalog.mjs +196 -0
  7. package/src/config.mjs +191 -0
  8. package/src/core.mjs +272 -0
  9. package/src/includes.mjs +181 -0
  10. package/src/info.mjs +85 -0
  11. package/src/init-module.mjs +117 -0
  12. package/src/init.mjs +316 -0
  13. package/src/lint.mjs +26 -0
  14. package/src/main.mjs +273 -0
  15. package/src/module.mjs +150 -0
  16. package/src/modules.json +47 -0
  17. package/src/pm.mjs +111 -0
  18. package/src/server.mjs +72 -0
  19. package/src/system.mjs +32 -0
  20. package/src/template.mjs +74 -0
  21. package/src/ui.mjs +101 -0
  22. package/templates/module/LICENSE +21 -0
  23. package/templates/module/README.md +81 -0
  24. package/templates/module/README.ru.md +81 -0
  25. package/templates/module/_gitattributes +5 -0
  26. package/templates/module/_gitignore +11 -0
  27. package/templates/module/_oxfmtrc.json +18 -0
  28. package/templates/module/_oxlintrc.json +784 -0
  29. package/templates/module/assets/logo.svg +8 -0
  30. package/templates/module/playground/amxts.config.ts +4 -0
  31. package/templates/module/playground/plugins/welcome.ts +3 -0
  32. package/templates/module/playground/tsconfig.json +3 -0
  33. package/templates/module/src/index.ts +25 -0
  34. package/templates/module/src/natives.ts +13 -0
  35. package/templates/module/src/types.ts +11 -0
  36. package/templates/module/test/tsconfig.json +13 -0
  37. package/templates/module/test/{{name}}.test.ts +25 -0
  38. package/templates/module/tsconfig.json +10 -0
  39. package/templates/project/README.md +28 -0
  40. package/templates/project/_gitattributes +7 -0
  41. package/templates/project/_gitignore +7 -0
  42. package/templates/project/_oxfmtrc.json +18 -0
  43. package/templates/project/_oxlintrc.json +784 -0
  44. package/templates/project/_vscode/extensions.json +3 -0
  45. package/templates/project/_vscode/settings.json +13 -0
  46. package/templates/project/amxts.config.ts +24 -0
  47. package/templates/project/knip.json +4 -0
  48. package/templates/project/plugins/hello.ts +39 -0
  49. package/templates/project/test/hello.test.ts +37 -0
  50. package/templates/project/test/tsconfig.json +13 -0
  51. package/templates/project/tsconfig.json +3 -0
package/src/init.mjs ADDED
@@ -0,0 +1,316 @@
1
+ // A new project - `npm create amxts@latest`, `amxts init`:
2
+ // a few questions (every one also a flag), the files, the install, and what
3
+ // to run next.
4
+ //
5
+ // amxts init asks
6
+ // amxts init my-server --pm npm --modules menu-core --no-lint --no-git --server D:/hlds/cstrike/addons/amxts --yes
7
+ // amxts init my-server --target hlds --yes no server: the includes of plain HLDS
8
+ // amxts init my-server --os linux --yes no server here: plugins built for a Linux one
9
+ //
10
+ // The package manager is asked first: everything shown afterwards - the
11
+ // install, the next steps, the README - is written with it.
12
+ import { existsSync, readdirSync, readFileSync, writeFileSync } from 'node:fs';
13
+ import { basename, join, relative, resolve } from 'node:path';
14
+ import process from 'node:process';
15
+ import * as p from '@clack/prompts';
16
+ import { configEntries, loadCatalog, resolveModule, shortName, specFor, withRequired } from './catalog.mjs';
17
+ import { modulesList } from './config.mjs';
18
+ import { bunFor, CLI_DIR, CORE_RANGE, coreDirFrom, FROM_SOURCE, loadCore, needLocalCore, readJson, VERSION } from './core.mjs';
19
+ import { ensureIncludes, FETCHED, serverIncludes, targetOf, TARGETS } from './includes.mjs';
20
+ import { LINT_DEPENDENCIES, LINT_FILES, LINT_SCRIPTS } from './lint.mjs';
21
+ import { commandLine, execAmxts, installArgs, PACKAGE_MANAGERS, packageManagerOfAgent, run, runScript, versionOf } from './pm.mjs';
22
+ import { askServer, canAsk, missingServer, serverEnv } from './server.mjs';
23
+ import { HOST_SYSTEM, serverSystemOf, SYSTEMS } from './system.mjs';
24
+ import { copyTemplate, gitAuthor, writeFile } from './template.mjs';
25
+ import { c, CliError } from './ui.mjs';
26
+
27
+ /** The tools a project gets besides the core and its modules. */
28
+ const TOOLS = {
29
+ '@types/bun': '^1.4.2',
30
+ };
31
+
32
+ const DEFAULT_DIR = 'my-server';
33
+
34
+ /**
35
+ * @typedef {object} InitOptions
36
+ * @property {string} [dir] the project's folder
37
+ * @property {string} [pm] npm, pnpm, yarn or bun
38
+ * @property {string} [modules] the modules, comma-separated
39
+ * @property {boolean} [lint] oxlint and oxfmt, with @antfu/eslint-config's rules
40
+ * @property {boolean} [git] git init
41
+ * @property {string} [server] AMXTS_SERVER for .env
42
+ * @property {string} [target] the server the project is for without one: rehlds or hlds
43
+ * @property {string} [os] the server's system, windows or linux, when its folder does not show it
44
+ * @property {boolean} [install] install the dependencies
45
+ * @property {boolean} [yes] ask nothing
46
+ * @property {boolean} [force] write into a folder that is not empty
47
+ * @property {boolean} [local] the core and the modules from this machine's folders
48
+ */
49
+
50
+ /** Stops on Ctrl+C in a prompt. */
51
+ function answer(value) {
52
+ if (p.isCancel(value)) {
53
+ p.cancel('Nothing was created.');
54
+ process.exit(1);
55
+ }
56
+ return value;
57
+ }
58
+
59
+ function isEmptyFolder(dir) {
60
+ return !existsSync(dir) || readdirSync(dir).length === 0;
61
+ }
62
+
63
+ /** A folder's name as a package name: lowercase, dashes. */
64
+ function packageName(dir) {
65
+ return basename(resolve(dir)).toLowerCase().replace(/[^a-z0-9._-]+/g, '-').replace(/^[._-]+/, '') || 'amxts-project';
66
+ }
67
+
68
+ /** @param {InitOptions} options */
69
+ export async function initProject(options) {
70
+ const interactive = !options.yes && canAsk();
71
+ const local = options.local ?? FROM_SOURCE;
72
+ // --local: the core on this machine, and the official modules from the
73
+ // folders it takes them from.
74
+ const localCore = local ? await needLocalCore() : null;
75
+ const localModules = localCore?.api.localModules() ?? {};
76
+
77
+ p.intro(`${c.bold(c.cyan('amxts'))} ${c.dim(VERSION)} ${c.dim('·')} a new project`);
78
+
79
+ // 1. The package manager, first: the rest is said in its words.
80
+ const detected = packageManagerOfAgent() ?? 'npm';
81
+ if (options.pm && !PACKAGE_MANAGERS.includes(options.pm)) {
82
+ throw new CliError(`--pm ${options.pm}: amxts knows ${PACKAGE_MANAGERS.join(', ')}`);
83
+ }
84
+ if (options.target && !TARGETS[options.target]) {
85
+ throw new CliError(`--target ${options.target}: rehlds (ReHLDS, ReGameDLL and ReAPI) or hlds (plain HLDS)`);
86
+ }
87
+ if (options.os && !SYSTEMS[options.os]) {
88
+ throw new CliError(`--os ${options.os}: windows or linux`);
89
+ }
90
+ const pm = options.pm ?? (interactive
91
+ ? answer(await p.select({
92
+ message: 'Which package manager?',
93
+ initialValue: detected,
94
+ options: PACKAGE_MANAGERS.map(name => ({ value: name, label: name, hint: name === detected ? 'detected' : undefined })),
95
+ }))
96
+ : detected);
97
+ const pmVersion = versionOf(pm);
98
+
99
+ // 2. Where.
100
+ const dir = options.dir ?? (interactive
101
+ ? answer(await p.text({
102
+ message: 'Where should the project go?',
103
+ placeholder: DEFAULT_DIR,
104
+ defaultValue: DEFAULT_DIR,
105
+ validate: value => (!options.force && !isEmptyFolder(value || DEFAULT_DIR) ? 'That folder is not empty - pick another one' : undefined),
106
+ }))
107
+ : DEFAULT_DIR);
108
+ const root = resolve(dir);
109
+ if (!options.force && !isEmptyFolder(root)) {
110
+ throw new CliError(`${root} is there and not empty`, 'Pick another folder, or add --force to write into it.');
111
+ }
112
+
113
+ // 3. Modules: the amxts catalog's.
114
+ const catalog = await loadCatalog();
115
+ const from = { catalog, local, localModules };
116
+ let chosen;
117
+ if (options.modules !== undefined) {
118
+ chosen = options.modules.split(',').map(each => each.trim()).filter(Boolean);
119
+ } else if (interactive) {
120
+ chosen = answer(await p.multiselect({
121
+ message: `Which modules? ${c.dim('(space to pick, enter to go on)')}`,
122
+ required: false,
123
+ options: catalog.map(module => ({
124
+ value: module.name,
125
+ label: module.name,
126
+ hint: [module.description, module.type === 'community' && 'community', ...module.requires.map(each => `brings ${shortName(each)}`)].filter(Boolean).join(' - '),
127
+ })),
128
+ }));
129
+ } else {
130
+ chosen = [];
131
+ }
132
+ const modules = chosen.map(name => resolveModule(name, from));
133
+ for (const module of modules.filter(each => !each.dir && !each.listed)) p.log.warn(`${module.name} is not in the amxts catalog: it is installed from npm as it is`);
134
+ // What a chosen module requires is installed with it, and the config
135
+ // lists it right after, with a comment naming who needs it.
136
+ const installed = withRequired(modules, from);
137
+ const entries = configEntries(installed, modules);
138
+
139
+ // 4. oxlint and oxfmt, 5. git, 6. the server.
140
+ const lint = options.lint ?? (interactive
141
+ ? answer(await p.confirm({ message: `Add oxlint and oxfmt? ${c.dim('(lint and format)')}`, initialValue: true }))
142
+ : true);
143
+ const hasGit = Boolean(versionOf('git'));
144
+ const git = hasGit && (options.git ?? (interactive
145
+ ? answer(await p.confirm({ message: 'Initialize a git repository?', initialValue: true }))
146
+ : true));
147
+ const server = options.server ?? (interactive ? answer(await askServer(pm)) : '');
148
+
149
+ // 7. Which server the project is for: its includes say, when it has them
150
+ // (addons/amxmodx/scripting/include beside AMXTS_SERVER); else asked.
151
+ const ownIncludes = serverIncludes(server);
152
+ const target = options.target ?? (ownIncludes
153
+ ? targetOf(ownIncludes)
154
+ : interactive
155
+ ? answer(await p.select({
156
+ message: 'Which server is the project for?',
157
+ initialValue: 'rehlds',
158
+ options: [
159
+ { value: 'rehlds', label: TARGETS.rehlds, hint: 'recommended' },
160
+ { value: 'hlds', label: TARGETS.hlds },
161
+ ],
162
+ }))
163
+ : 'rehlds');
164
+
165
+ // 8. The server's system: its folder shows it (hlds_linux or hlds.exe);
166
+ // else asked, and kept in .env - the build compiles plugins for it.
167
+ const shown = serverSystemOf(server);
168
+ const os = shown ?? options.os ?? (interactive
169
+ ? answer(await p.select({
170
+ message: 'Which system does the server run?',
171
+ initialValue: HOST_SYSTEM,
172
+ options: Object.entries(SYSTEMS).map(([value, label]) => ({ value, label, hint: value === HOST_SYSTEM ? 'this machine' : undefined })),
173
+ }))
174
+ : null);
175
+ const osLine = os && !shown
176
+ ? `# The server's system: plugins are compiled for it.\nAMXTS_SERVER_OS=${os}\n`
177
+ : '';
178
+
179
+ // The files.
180
+ const amxts = execAmxts(pm);
181
+ const install = installArgs(pm).join(' ');
182
+ const values = {
183
+ 'name': packageName(root),
184
+ 'author': gitAuthor(),
185
+ 'modules': modulesList(entries),
186
+ 'target': target,
187
+ 'install': install,
188
+ 'amxts': amxts,
189
+ 'run.dev': runScript(pm, 'dev'),
190
+ 'run.build': runScript(pm, 'build'),
191
+ 'run.typecheck': runScript(pm, 'typecheck'),
192
+ 'run.test': runScript(pm, 'test'),
193
+ 'run.lint': runScript(pm, 'lint'),
194
+ };
195
+ const flags = { lint, ...Object.fromEntries(installed.map(module => [shortName(module.name), true])) };
196
+ const written = copyTemplate(join(CLI_DIR, 'templates', 'project'), root, values, flags, path => LINT_FILES.includes(path) && !lint);
197
+
198
+ const core = { name: '@amxts/core', dir: localCore?.dir ?? null };
199
+ const devDependencies = {
200
+ [core.name]: specFor(core, root, pm, CORE_RANGE),
201
+ ...Object.fromEntries(installed.map(module => [module.name, specFor(module, root, pm)])),
202
+ ...TOOLS,
203
+ ...(lint ? LINT_DEPENDENCIES : {}),
204
+ };
205
+ writeFile(join(root, 'package.json'), `${JSON.stringify({
206
+ name: values.name,
207
+ type: 'module',
208
+ private: true,
209
+ scripts: {
210
+ // The editor config in .amxts/ is not committed: it is written after every install, by amxts prepare.
211
+ postinstall: 'amxts prepare',
212
+ dev: 'amxts dev',
213
+ build: 'amxts build',
214
+ typecheck: 'amxts typecheck',
215
+ test: 'amxts test',
216
+ ...(lint ? LINT_SCRIPTS : {}),
217
+ },
218
+ devDependencies: Object.fromEntries(Object.entries(devDependencies).sort(([a], [b]) => a.localeCompare(b))),
219
+ }, null, '\t')}\n`);
220
+ written.push('package.json');
221
+ if (pm === 'pnpm') {
222
+ // pnpm stops an install at a dependency's build script nobody approved.
223
+ // bun's is not needed: amxts runs the binary of its platform package.
224
+ writeFile(join(root, 'pnpm-workspace.yaml'), '# bun\'s install script is not needed: amxts runs the binary of its platform package.\nallowBuilds:\n bun: false\n');
225
+ written.push('pnpm-workspace.yaml');
226
+ }
227
+ // Always written, the server's line empty when it was not given: the file
228
+ // shows where it goes, and amxts dev asks for it.
229
+ writeFile(join(root, '.env'), `${serverEnv(server ?? '')}${osLine}`);
230
+ written.push('.env');
231
+
232
+ const at = relative(process.cwd(), root) || '.';
233
+ p.log.success(`Created ${c.bold(values.name)} in ${c.cyan(at)}\n${c.dim(written.sort().join('\n'))}`);
234
+ if (server && !existsSync(server)) p.log.warn(missingServer(server));
235
+ const required = entries.filter(each => each.by.length);
236
+ if (required.length) p.log.info(required.map(each => `${shortName(each.name)} is listed too: ${each.by.join(', ')} needs it`).join('\n'));
237
+
238
+ // The install.
239
+ let installedOk = false;
240
+ if (options.install === false) {
241
+ p.log.info(`Not installed (--no-install): ${c.cyan(install)} does it.`);
242
+ } else if (!pmVersion) {
243
+ p.log.warn(`${pm} is not installed on this machine: install it, then run ${c.cyan(install)} in the project.`);
244
+ } else {
245
+ // A spinner in a terminal; in a log (CI, a pipe) one line before and one after.
246
+ const spinner = process.stdout.isTTY ? p.spinner() : { start: text => p.log.step(text), stop: (text, code) => (code ? p.log.error(text) : p.log.success(text)) };
247
+ spinner.start(`Installing dependencies with ${pm}`);
248
+ const result = await run(installArgs(pm), { cwd: root, quiet: true });
249
+ if (result.code === 0) {
250
+ pinLatest(root);
251
+ installedOk = true;
252
+ spinner.stop(`Installed dependencies with ${pm} ${c.dim(pmVersion)}`);
253
+ } else {
254
+ spinner.stop(`${commandLine(installArgs(pm))} failed`, 1);
255
+ p.log.message(result.output.trim().split('\n').slice(-15).join('\n'));
256
+ }
257
+ }
258
+
259
+ // The project's core, installed: the server's includes (its own, or the
260
+ // ones of the target fetched) and the Bun it builds with come with it.
261
+ let projectCore = null;
262
+ if (installedOk) {
263
+ try {
264
+ projectCore = await loadCore(coreDirFrom(root));
265
+ const includes = await ensureIncludes(projectCore, root, { target });
266
+ p.log.success(includes.from === 'server'
267
+ ? `Includes: the server's own ${c.dim(`(${includes.dir})`)}`
268
+ : includes.from === 'fetched'
269
+ ? `Fetched ${includes.version}'s includes ${c.dim(`(${FETCHED})`)}`
270
+ : `Includes: AMX Mod X's own, which come with the core ${c.dim(`(${TARGETS.hlds})`)}`);
271
+ } catch (error) {
272
+ if (!(error instanceof CliError)) throw error;
273
+ p.log.warn(`${error.message}\n${c.dim(error.hint ?? '')}`);
274
+ }
275
+ }
276
+
277
+ if (git) {
278
+ const result = await run(['git', 'init'], { cwd: root, quiet: true });
279
+ if (result.code === 0) p.log.success('Initialized a git repository');
280
+ else p.log.warn(`git init failed: ${result.output.trim().split('\n')[0]}`);
281
+ }
282
+
283
+ // The editor config (.amxts/tsconfig.json), which the build writes too:
284
+ // this command's own prepare, run in the project, with the project's core.
285
+ if (projectCore && bunFor(projectCore)) {
286
+ const result = await run([process.execPath, join(CLI_DIR, 'bin', 'amxts.mjs'), 'prepare'], { cwd: root, quiet: true });
287
+ if (result.code === 0) p.log.success(`Prepared the editor config ${c.dim('(.amxts/tsconfig.json)')}`);
288
+ else p.log.warn(`The editor config was not written: ${c.cyan(`${amxts} prepare`)} in the project says why.`);
289
+ } else if (projectCore) {
290
+ p.log.warn(`The Bun that comes with @amxts/core is not there, and amxts builds with it: run ${c.cyan(install)} again, letting ${pm} run bun's install script`);
291
+ }
292
+
293
+ const steps = [
294
+ ...(at === '.' ? [] : [`cd ${at}`]),
295
+ ...(installedOk ? [] : [install]),
296
+ ...(server ? [] : [`${c.dim('# in .env:')} AMXTS_SERVER=<the server's addons/amxts>`]),
297
+ `${runScript(pm, 'dev')} ${c.dim('# build, deploy, rebuild on save')}`,
298
+ ];
299
+ p.note(steps.join('\n'), 'Next steps');
300
+ p.outro(`Docs: ${c.cyan('https://amxts.github.io/docs/getting-started/quick-start')}`);
301
+ if (options.install !== false && pmVersion && !installedOk) process.exitCode = 1;
302
+ }
303
+
304
+ /** `"latest"` in package.json, after the install, as the version it installed: `^1.6.2`. */
305
+ function pinLatest(root) {
306
+ const path = join(root, 'package.json');
307
+ const json = JSON.parse(readFileSync(path, 'utf8'));
308
+ let changed = false;
309
+ for (const [name, spec] of Object.entries(json.devDependencies ?? {})) {
310
+ const version = spec === 'latest' && readJson(join(root, 'node_modules', name, 'package.json'))?.version;
311
+ if (!version) continue;
312
+ json.devDependencies[name] = `^${version}`;
313
+ changed = true;
314
+ }
315
+ if (changed) writeFileSync(path, `${JSON.stringify(json, null, '\t')}\n`);
316
+ }
package/src/lint.mjs ADDED
@@ -0,0 +1,26 @@
1
+ // What a project and a module get to lint and format with: oxlint running
2
+ // @antfu/eslint-config's rules - its own where it has them, the ESLint
3
+ // plugins as JS plugins where it has not - and oxfmt for JSON and YAML.
4
+ // The rules are in the templates' .oxlintrc.json.
5
+
6
+ export const LINT_DEPENDENCIES = {
7
+ '@e18e/eslint-plugin': '^0.8.1',
8
+ '@eslint-community/eslint-plugin-eslint-comments': '^4.8.1',
9
+ '@stylistic/eslint-plugin': '^5.10.0',
10
+ 'eslint-plugin-antfu': '^3.2.3',
11
+ 'eslint-plugin-command': '^4.0.0',
12
+ 'eslint-plugin-n': '^18.4.0',
13
+ 'eslint-plugin-perfectionist': '^5.12.1',
14
+ 'eslint-plugin-regexp': '^3.3.1',
15
+ 'oxfmt': '^0.70.0',
16
+ 'oxlint': '^1.85.0',
17
+ };
18
+
19
+ export const LINT_SCRIPTS = {
20
+ 'lint': 'oxlint && oxfmt --check',
21
+ // Up to two more oxlint passes fix what overlapped in the first.
22
+ 'lint:fix': 'oxfmt && oxlint --fix --silent || oxlint --fix --silent || oxlint --fix',
23
+ };
24
+
25
+ /** The files in a template that are there only for the lint. */
26
+ export const LINT_FILES = ['_oxlintrc.json', '_oxfmtrc.json'];
package/src/main.mjs ADDED
@@ -0,0 +1,273 @@
1
+ // The amxts command: the table of its commands, their help, and one place
2
+ // where a failure becomes one line with a hint.
3
+ //
4
+ // amxts <command> [options] amxts --help, amxts <command> --help
5
+ //
6
+ // It runs on Node. Commands that make or change a project (init, module,
7
+ // info) run here; the ones that compile (build, dev, typecheck, check,
8
+ // prepare) are tasks of the project's core, which runs them on Bun
9
+ // (core.mjs); test runs bun test.
10
+ import process from 'node:process';
11
+ import { GLOBAL_FLAGS, parseArgs } from './args.mjs';
12
+ import { needBun, needProject, projectCore, runTask, runTaskOrExit, VERSION } from './core.mjs';
13
+ import { prepare, TARGETS } from './includes.mjs';
14
+ import { detectPackageManager, PACKAGE_MANAGERS, run } from './pm.mjs';
15
+ import { ensureServer } from './server.mjs';
16
+ import { SYSTEMS } from './system.mjs';
17
+ import { banner, c, CliError, closest, log, report } from './ui.mjs';
18
+
19
+ /** --os, for the commands that compile: the plugins are built for that system. */
20
+ const OS_FLAG = { type: 'string', value: Object.keys(SYSTEMS).join('|'), description: 'The server\'s system, when AMXTS_SERVER does not show it: the plugins are compiled for it' };
21
+
22
+ /** --os as the build task takes it. */
23
+ function osArgs(values) {
24
+ if (values.os === undefined) return [];
25
+ if (!SYSTEMS[values.os]) throw new CliError(`--os ${values.os}: windows or linux`);
26
+ return ['--os', values.os];
27
+ }
28
+
29
+ const INIT_FLAGS = {
30
+ pm: { type: 'string', value: PACKAGE_MANAGERS.join('|'), description: 'The package manager (asked first; the one running the command is picked)' },
31
+ modules: { type: 'string', value: 'list', description: 'Modules to add, comma-separated: menu-core,config-core ("" for none)' },
32
+ lint: { type: 'boolean', description: 'oxlint and oxfmt, with @antfu/eslint-config\'s rules (--no-lint to leave them out)' },
33
+ git: { type: 'boolean', description: 'git init (--no-git to skip)' },
34
+ server: { type: 'string', value: 'path', description: 'The server\'s addons/amxts folder, written to .env as AMXTS_SERVER; the build takes its includes' },
35
+ os: { type: 'string', value: Object.keys(SYSTEMS).join('|'), description: 'The server\'s system, when the server\'s folder does not show it: written to .env as AMXTS_SERVER_OS' },
36
+ target: { type: 'string', value: Object.keys(TARGETS).join('|'), description: 'The server the project is for, when no server is given: rehlds - ReHLDS, ReGameDLL and ReAPI (recommended) - or hlds' },
37
+ install: { type: 'boolean', description: 'Install the dependencies (--no-install to skip)' },
38
+ yes: { type: 'boolean', alias: 'y', description: 'Ask nothing: the defaults for what no flag says' },
39
+ force: { type: 'boolean', description: 'Write into a folder that is not empty' },
40
+ local: { type: 'boolean', description: 'Take the core and the official modules from the folders on this machine, not npm; AMXTS_CORE names the core folder' },
41
+ module: { type: 'string', value: 'name', description: 'Create a module package instead: greeter, @you/greeter' },
42
+ natives: { type: 'boolean', description: 'With --module: natives for Pawn plugins (src/natives.ts)' },
43
+ dir: { type: 'string', value: 'folder', description: 'With --module: the folder (the module\'s name by default)' },
44
+ };
45
+
46
+ /**
47
+ * @typedef {object} Command
48
+ * @property {string} description a line for the command list
49
+ * @property {string} usage how it is called
50
+ * @property {Record<string, any>} [flags] its options, for the parser and the help
51
+ * @property {string[]} [examples] command lines for the help
52
+ * @property {boolean} [passThrough] its arguments go to the tool it runs as they are
53
+ * @property {(args: { values: Record<string, any>, positionals: string[], raw?: string[] }) => unknown} run what it does
54
+ */
55
+
56
+ /** @type {Record<string, Command>} */
57
+ export const COMMANDS = {
58
+ init: {
59
+ description: 'Create a project - or a module package, with --module',
60
+ usage: 'amxts init [folder] [options]',
61
+ flags: INIT_FLAGS,
62
+ examples: [
63
+ 'amxts init',
64
+ 'amxts init my-server --pm npm --modules menu-core --no-git --yes',
65
+ 'amxts init --module @you/greeter --natives',
66
+ ],
67
+ async run({ values, positionals }) {
68
+ if (values.module !== undefined) {
69
+ const { initModule } = await import('./init-module.mjs');
70
+ return initModule({ module: values.module, natives: values.natives, dir: values.dir ?? positionals[0], pm: values.pm, local: values.local });
71
+ }
72
+ const { initProject } = await import('./init.mjs');
73
+ return initProject({ ...values, dir: positionals[0] });
74
+ },
75
+ },
76
+ dev: {
77
+ description: 'Build, deploy to the server in AMXTS_SERVER, and again on every save',
78
+ usage: 'amxts dev [--os windows|linux] [--docker]',
79
+ flags: {
80
+ os: OS_FLAG,
81
+ docker: { type: 'boolean', description: 'For the Docker server that mounts this project: build for Linux into dist/ on every save, which it reloads, and show its console' },
82
+ },
83
+ examples: ['amxts dev', 'amxts dev --os linux', 'amxts dev --docker'],
84
+ async run({ values }) {
85
+ if (values.docker && values.os && values.os !== 'linux')
86
+ throw new CliError('The Docker server is Linux: --docker builds for Linux.', 'Leave out --os.');
87
+ const core = await projectCore();
88
+ banner(core.version, 'dev');
89
+ // The container reads dist/ where it is: nothing to deploy, and AMXTS_SERVER is not asked.
90
+ if (!values.docker) await ensureServer(needProject(), detectPackageManager());
91
+ await prepare(core, { quiet: true });
92
+ if (values.docker) runTaskOrExit(core, 'build', ['--watch', '--docker', '--os', 'linux']);
93
+ else runTaskOrExit(core, 'build', ['--deploy', '--watch', ...osArgs(values)]);
94
+ },
95
+ },
96
+ build: {
97
+ description: 'Build the plugins and the modules into outDir (dist/), with plugins.ini',
98
+ usage: 'amxts build [--deploy] [--watch] [--os windows|linux]',
99
+ flags: {
100
+ deploy: { type: 'boolean', description: 'Also copy them to AMXTS_SERVER and reload the server' },
101
+ watch: { type: 'boolean', description: 'Build again on every save - for a server that reads dist/ itself, such as the Docker one' },
102
+ os: OS_FLAG,
103
+ },
104
+ examples: ['amxts build', 'amxts build --deploy', 'amxts build --os linux', 'amxts build --os linux --watch'],
105
+ async run({ values }) {
106
+ const os = osArgs(values);
107
+ const core = await projectCore();
108
+ banner(core.version, 'build');
109
+ if (values.deploy) await ensureServer(needProject(), detectPackageManager());
110
+ await prepare(core, { quiet: true });
111
+ runTaskOrExit(core, 'build', [...(values.deploy ? ['--deploy'] : []), ...(values.watch ? ['--watch'] : []), ...os]);
112
+ },
113
+ },
114
+ typecheck: {
115
+ description: 'Check the plugins and amxts.config.ts as the editor does',
116
+ usage: 'amxts typecheck',
117
+ examples: ['amxts typecheck'],
118
+ async run() {
119
+ const core = await projectCore();
120
+ banner(core.version, 'typecheck');
121
+ await prepare(core, { quiet: true });
122
+ if (runTask(core, 'typecheck') !== 0) {
123
+ throw new CliError('The plugins have type errors', 'They are listed above, file:line first.');
124
+ }
125
+ log.success('No type errors');
126
+ },
127
+ },
128
+ test: {
129
+ description: 'Run the project\'s tests on a fake server (bun test)',
130
+ usage: 'amxts test [bun test options]',
131
+ passThrough: true,
132
+ examples: ['amxts test', 'amxts test hello', 'amxts test --watch'],
133
+ async run({ raw }) {
134
+ const result = await run([needBun(await projectCore(), 'runs tests'), 'test', ...raw]);
135
+ process.exitCode = result.code;
136
+ },
137
+ },
138
+ module: {
139
+ description: 'Add a module to the project, or list its modules',
140
+ usage: 'amxts module add <name...> | amxts module list',
141
+ flags: {
142
+ local: { type: 'boolean', description: 'add: an official module from the folder the core takes it from, not npm' },
143
+ skipInstall: { type: 'boolean', description: 'add: only list it in amxts.config.ts' },
144
+ skipConfig: { type: 'boolean', description: 'add: only install it' },
145
+ },
146
+ examples: ['amxts module add menu-core', 'amxts module add @you/greeter', 'amxts module add ../greeter', 'amxts module list'],
147
+ async run({ values, positionals }) {
148
+ const [action, ...names] = positionals;
149
+ const { moduleAdd, moduleList } = await import('./module.mjs');
150
+ if (action === 'add') return moduleAdd(names, values);
151
+ if (action === 'list' || action === 'ls') return moduleList();
152
+ const guess = action ? closest(action, ['add', 'list']) : null;
153
+ throw new CliError(action ? `amxts module has no "${action}"` : 'amxts module: add or list?', guess ? `Did you mean amxts module ${guess}?` : 'amxts module add <name> | amxts module list');
154
+ },
155
+ },
156
+ check: {
157
+ description: 'In a module package: check it is ready to publish',
158
+ usage: 'amxts check',
159
+ examples: ['amxts check'],
160
+ async run() {
161
+ runTaskOrExit(await projectCore(), 'check');
162
+ },
163
+ },
164
+ prepare: {
165
+ description: 'Fetch the server\'s includes and write .amxts/tsconfig.json for the editor (build does it too)',
166
+ usage: 'amxts prepare',
167
+ async run() {
168
+ await prepare(await projectCore());
169
+ },
170
+ },
171
+ info: {
172
+ description: 'Versions and settings, for a bug report',
173
+ usage: 'amxts info',
174
+ async run() {
175
+ const { info } = await import('./info.mjs');
176
+ await info();
177
+ },
178
+ },
179
+ };
180
+
181
+ /** `skipInstall` as the command line spells it: `skip-install`. */
182
+ function kebab(name) {
183
+ return name.replace(/[A-Z]/g, letter => `-${letter.toLowerCase()}`);
184
+ }
185
+
186
+ function flagLines(flags) {
187
+ const rows = Object.entries({ ...flags, ...GLOBAL_FLAGS })
188
+ .map(([name, flag]) => [`${flag.alias ? `-${flag.alias}, ` : ' '}--${kebab(name)}${flag.type === 'string' ? ` <${flag.value ?? 'value'}>` : ''}`, flag.description ?? '']);
189
+ const width = Math.max(...rows.map(([left]) => left.length));
190
+ return rows.map(([left, right]) => ` ${c.cyan(left.padEnd(width))} ${right}`);
191
+ }
192
+
193
+ export function printHelp(name) {
194
+ if (name) {
195
+ const command = COMMANDS[name];
196
+ console.log([
197
+ `${command.description}`,
198
+ '',
199
+ `${c.bold('Usage')} ${c.cyan(command.usage)}`,
200
+ '',
201
+ c.bold('Options'),
202
+ ...flagLines(command.flags ?? {}),
203
+ ...(command.examples ? ['', c.bold('Examples'), ...command.examples.map(example => ` ${c.dim('$')} ${example}`)] : []),
204
+ '',
205
+ ].join('\n'));
206
+ return;
207
+ }
208
+ const names = Object.keys(COMMANDS);
209
+ const width = Math.max(...names.map(each => each.length));
210
+ console.log([
211
+ `${c.bold(c.cyan('amxts'))} ${c.dim(VERSION)} - TypeScript plugins for AMX Mod X`,
212
+ '',
213
+ `${c.bold('Usage')} ${c.cyan('amxts <command> [options]')}`,
214
+ '',
215
+ c.bold('Commands'),
216
+ ...names.map(each => ` ${c.cyan(each.padEnd(width))} ${COMMANDS[each].description}`),
217
+ '',
218
+ c.bold('Options'),
219
+ ` ${c.cyan('-h, --help'.padEnd(13))} Show help`,
220
+ ` ${c.cyan('-v, --version'.padEnd(13))} Show the version`,
221
+ ` ${c.cyan(' --debug'.padEnd(13))} Show the stack of an error`,
222
+ '',
223
+ `${c.dim('A command\'s options and examples:')} amxts <command> --help`,
224
+ `${c.dim('A new project:')} npm create amxts@latest`,
225
+ '',
226
+ ].join('\n'));
227
+ }
228
+
229
+ /** Flags as the parser takes them: `skip-install` for skipInstall. */
230
+ function parserFlags(flags = {}) {
231
+ return Object.fromEntries(Object.entries(flags).map(([name, flag]) => [kebab(name), flag]));
232
+ }
233
+
234
+ function camel(values) {
235
+ return Object.fromEntries(Object.entries(values).map(([name, value]) => [name.replace(/-([a-z])/g, (_, letter) => letter.toUpperCase()), value]));
236
+ }
237
+
238
+ /**
239
+ * Runs a command line: `["build", "--deploy"]`.
240
+ * @param {string[]} argv
241
+ */
242
+ export async function main(argv) {
243
+ try {
244
+ const [name, ...rest] = argv;
245
+ if (!name || name === '--help' || name === '-h' || name === 'help') {
246
+ const topic = name === 'help' ? rest[0] : undefined;
247
+ printHelp(topic && COMMANDS[topic] ? topic : undefined);
248
+ return;
249
+ }
250
+ if (name === '--version' || name === '-v') {
251
+ console.log(VERSION);
252
+ return;
253
+ }
254
+ const command = COMMANDS[name];
255
+ if (!command) {
256
+ const guess = closest(name, Object.keys(COMMANDS));
257
+ throw new CliError(`Unknown command ${c.bold(name)}`, guess ? `Did you mean ${c.cyan(`amxts ${guess}`)}?` : 'amxts --help lists the commands.');
258
+ }
259
+ if (rest.includes('--help') || rest.includes('-h')) {
260
+ printHelp(name);
261
+ return;
262
+ }
263
+ if (command.passThrough) {
264
+ await command.run({ values: {}, positionals: [], raw: rest.filter(arg => arg !== '--debug') });
265
+ return;
266
+ }
267
+ const { values, positionals } = parseArgs(rest, parserFlags(command.flags), name);
268
+ await command.run({ values: camel(values), positionals });
269
+ } catch (error) {
270
+ report(error);
271
+ process.exitCode = 1;
272
+ }
273
+ }