@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/core.mjs ADDED
@@ -0,0 +1,272 @@
1
+ // The core a command works with, and Bun, which runs the core's build.
2
+ //
3
+ // The command is a package of its own, @amxts/cli; the core is the project's
4
+ // - its node_modules/@amxts/core, found from the folder the command runs in.
5
+ // The command talks to it only through
6
+ // `@amxts/core/cli-api`: the core's version, the contract version (`cliApi`),
7
+ // and the tasks it runs (`task()`). A core this command cannot drive is an
8
+ // error that says which of the two to update.
9
+ //
10
+ // Creating a project or a module needs no core: they run on their own.
11
+ // `--local` takes the core from this machine instead of npm (localCore()).
12
+ import { spawnSync } from 'node:child_process';
13
+ import { existsSync, readFileSync } from 'node:fs';
14
+ import { createRequire } from 'node:module';
15
+ import { dirname, join, resolve } from 'node:path';
16
+ import process from 'node:process';
17
+ import { fileURLToPath, pathToFileURL } from 'node:url';
18
+ import { addArgs, commandLine, detectPackageManager, versionOf } from './pm.mjs';
19
+ import { CliError, debug } from './ui.mjs';
20
+
21
+ /** The command's own package folder: packages/cli in its repository. */
22
+ export const CLI_DIR = join(dirname(fileURLToPath(import.meta.url)), '..');
23
+ export const CLI_PACKAGE = readJson(join(CLI_DIR, 'package.json'));
24
+ export const VERSION = String(CLI_PACKAGE.version);
25
+
26
+ /** The cores this command works with: the range a new project asks for. */
27
+ export const CORE_RANGE = '^0.1.0';
28
+ /** The version of `@amxts/core/cli-api` this command speaks. */
29
+ export const CLI_API = 3;
30
+
31
+ /**
32
+ * Run from a checkout of the command's repository (npm link, a clone) rather
33
+ * than from npm: then a new project takes the core and the official modules
34
+ * from this machine by default, as --local says - they are not on npm to fetch.
35
+ */
36
+ export const FROM_SOURCE = existsSync(join(CLI_DIR, '..', '..', '.git')) && readJson(join(CLI_DIR, '..', '..', 'package.json'))?.name === 'amxts-cli';
37
+
38
+ /** A JSON file's contents, or null when it is missing or not JSON. */
39
+ export function readJson(path) {
40
+ try {
41
+ return JSON.parse(readFileSync(path, 'utf8'));
42
+ } catch {
43
+ return null;
44
+ }
45
+ }
46
+
47
+ /** A setting as the build sees it: the environment, else the project's .env (which Bun reads). */
48
+ export function setting(dir, name) {
49
+ if (process.env[name]) return process.env[name];
50
+ const env = join(dir, '.env');
51
+ if (!existsSync(env)) return '';
52
+ const line = readFileSync(env, 'utf8').split(/\r?\n/).find(each => each.trim().split('=')[0].trim() === name);
53
+ return line ? line.slice(line.indexOf('=') + 1).trim().replace(/^(["'])(.*)\1$/, '$2') : '';
54
+ }
55
+
56
+ /**
57
+ * @typedef {object} Core
58
+ * @property {string} dir its folder (a link resolved to its target)
59
+ * @property {string} version its version
60
+ * @property {boolean} fromSource whether it is a checkout rather than a package from npm
61
+ * @property {typeof import('@amxts/core/cli-api')} api what it offers the command
62
+ */
63
+
64
+ /** The project's folder: the nearest one up with a package.json, or null. */
65
+ export function findProject(from = process.cwd()) {
66
+ for (let at = resolve(from); ; at = dirname(at)) {
67
+ if (existsSync(join(at, 'package.json'))) return at;
68
+ if (dirname(at) === at) return null;
69
+ }
70
+ }
71
+
72
+ /** Where @amxts/core is installed for a folder, resolved from it as Node resolves it; null when it is not. */
73
+ export function coreDirFrom(dir) {
74
+ try {
75
+ return dirname(createRequire(join(dir, 'package.json')).resolve('@amxts/core/package.json'));
76
+ } catch {
77
+ return null;
78
+ }
79
+ }
80
+
81
+ const loaded = new Map();
82
+
83
+ /** "2.1.0" as [2, 1, 0]; a pre-release tag is left out. */
84
+ function parts(version) {
85
+ return String(version).split('-')[0].split('.').map(each => Number.parseInt(each, 10) || 0);
86
+ }
87
+
88
+ /** Whether a version is in a caret range: `^0.1.0` takes 0.1.x from 0.1.0 up. */
89
+ export function satisfies(version, range = CORE_RANGE) {
90
+ const [major, minor, patch] = parts(version);
91
+ const [wantMajor, wantMinor, wantPatch] = parts(range.replace(/^\^/, ''));
92
+ if (major !== wantMajor) return false;
93
+ // Below 1.0 the minor is the breaking number: ^0.1.0 does not take 0.2.0.
94
+ if (major === 0 && minor !== wantMinor) return false;
95
+ return minor > wantMinor || (minor === wantMinor && patch >= wantPatch);
96
+ }
97
+
98
+ /** Whether a version is past a caret range, not below it: its major, or below 1.0 its minor, is the higher. */
99
+ function pastRange(version, range = CORE_RANGE) {
100
+ const [major, minor] = parts(version);
101
+ const [wantMajor, wantMinor] = parts(range.replace(/^\^/, ''));
102
+ return major !== wantMajor ? major > wantMajor : major === 0 && minor > wantMinor;
103
+ }
104
+
105
+ /** What to do about a core this command cannot drive: update the command when the core is the newer, else the core. */
106
+ function updateHint(coreIsNewer) {
107
+ const pm = detectPackageManager();
108
+ return coreIsNewer
109
+ ? `Update the command: npm install -g @amxts/cli@latest - a project's own ${pm === 'npm' ? 'npx amxts' : `${pm} amxts`} comes with its core`
110
+ : `Update the core: ${commandLine(addArgs(pm, ['@amxts/core@latest']))}`;
111
+ }
112
+
113
+ /**
114
+ * The core in a folder, checked: its version is one this command works with,
115
+ * and it speaks the same `cli-api`.
116
+ * @param {string} dir the core's folder
117
+ * @returns {Promise<Core>} the core, ready to drive
118
+ */
119
+ export async function loadCore(dir) {
120
+ if (loaded.has(dir)) return loaded.get(dir);
121
+ const version = String(readJson(join(dir, 'package.json'))?.version ?? '0.0.0');
122
+ if (!satisfies(version)) {
123
+ throw new CliError(`@amxts/core ${version} is not a core amxts ${VERSION} works with (${CORE_RANGE})`, updateHint(pastRange(version)));
124
+ }
125
+ let api;
126
+ try {
127
+ const entry = createRequire(join(dir, 'package.json')).resolve('@amxts/core/cli-api');
128
+ api = await import(pathToFileURL(entry).href);
129
+ } catch (error) {
130
+ if (debug()) throw error;
131
+ throw new CliError(`@amxts/core ${version} has no cli-api, which amxts ${VERSION} runs it through`, updateHint(false));
132
+ }
133
+ if (api.cliApi !== CLI_API) {
134
+ throw new CliError(`@amxts/core ${version} speaks cli-api ${api.cliApi}, amxts ${VERSION} speaks ${CLI_API}`, updateHint(api.cliApi > CLI_API));
135
+ }
136
+ /** @type {Core} */
137
+ const core = { dir, version, fromSource: Boolean(api.fromSource), api };
138
+ loaded.set(dir, core);
139
+ return core;
140
+ }
141
+
142
+ /** The project's folder, or an error that says how to create one. */
143
+ export function needProject(from = process.cwd()) {
144
+ const project = findProject(from);
145
+ if (project) return project;
146
+ throw new CliError('This folder is not in a project: no package.json here or above', 'Create one with: npm create amxts@latest');
147
+ }
148
+
149
+ /**
150
+ * The core installed for a folder, checked; null when there is none.
151
+ * @returns {Promise<Core | null>} the core, or null
152
+ */
153
+ export async function installedCore(from = process.cwd()) {
154
+ const dir = coreDirFrom(from);
155
+ return dir ? loadCore(dir) : null;
156
+ }
157
+
158
+ /**
159
+ * The project's core: @amxts/core as the folder the command runs in has it
160
+ * installed. An error that says how to install it when it is not.
161
+ * @returns {Promise<Core>} the core, ready to drive
162
+ */
163
+ export async function projectCore(from = process.cwd()) {
164
+ const core = await installedCore(from);
165
+ if (core) return core;
166
+ const project = needProject(from);
167
+ throw new CliError('@amxts/core is not installed in this project', `Install it: ${commandLine(addArgs(detectPackageManager(project), ['@amxts/core']))}`);
168
+ }
169
+
170
+ /**
171
+ * The core on this machine, for --local: the folder AMXTS_CORE names, else the
172
+ * one the command itself resolves - its checkout links the core beside it
173
+ * (its package.json's `file:` link), and an installed command sits next to
174
+ * the project's core. Null when there is none.
175
+ * @returns {Promise<Core | null>} the core, or null
176
+ */
177
+ export async function localCore() {
178
+ const dir = process.env.AMXTS_CORE ? resolve(process.env.AMXTS_CORE) : coreDirFrom(CLI_DIR);
179
+ return dir && existsSync(join(dir, 'package.json')) ? loadCore(dir) : null;
180
+ }
181
+
182
+ /** The local core, or an error that says how to point the command at one. */
183
+ export async function needLocalCore() {
184
+ const core = await localCore();
185
+ if (core) return core;
186
+ throw new CliError('--local: there is no core on this machine to take', 'Set AMXTS_CORE to the core\'s folder (a checkout of @amxts/core), or run npm install in the command\'s checkout, which links the core beside it.');
187
+ }
188
+
189
+ /**
190
+ * The TypeScript parser, which amxts.config.ts is read with: the project's
191
+ * core's, else the one on this machine.
192
+ * @returns {Promise<typeof import('typescript')>} the typescript module
193
+ */
194
+ export async function coreTypeScript(from = process.cwd()) {
195
+ const core = await installedCore(from) ?? await localCore();
196
+ if (!core) throw new CliError('amxts reads amxts.config.ts with the core\'s TypeScript, and @amxts/core is not installed here', 'Install the project\'s dependencies first.');
197
+ return core.api.typescript();
198
+ }
199
+
200
+ const BUN_HINT = [
201
+ 'Bun comes with @amxts/core: install the project\'s dependencies again, letting',
202
+ 'the package manager run bun\'s install script. Where it cannot (no package',
203
+ 'for this platform), install Bun yourself and run the command again:',
204
+ ' npm install -g bun',
205
+ process.platform === 'win32' ? ' or: powershell -c "irm bun.sh/install.ps1 | iex"' : ' or: curl -fsSL https://bun.sh/install | bash',
206
+ ].join('\n');
207
+
208
+ let pathVersion;
209
+
210
+ /** The version of a Bun on PATH, or null when there is none. */
211
+ function bunOnPath() {
212
+ pathVersion ??= versionOf('bun');
213
+ return pathVersion;
214
+ }
215
+
216
+ /**
217
+ * The Bun the core's tasks and tests run on: the one installed with the core
218
+ * (its cli-api's bunBinary(), from the `bun` package it depends on), else a
219
+ * Bun on PATH, as `bun`; null when there is neither.
220
+ * @param {Core | null} [core]
221
+ * @returns {string | null} the binary, or `bun`
222
+ */
223
+ export function bunFor(core) {
224
+ const own = core?.api.bunBinary?.();
225
+ if (own) return own;
226
+ return bunOnPath() ? 'bun' : null;
227
+ }
228
+
229
+ /** The version of the Bun bunFor() gives, or null. */
230
+ export function bunVersion(core) {
231
+ const binary = bunFor(core);
232
+ if (!binary) return null;
233
+ if (binary === 'bun') return bunOnPath();
234
+ const run = spawnSync(binary, ['--version'], { encoding: 'utf8' });
235
+ return run.status === 0 ? run.stdout.trim() : null;
236
+ }
237
+
238
+ /** The Bun to run, or an error that says how to get one. */
239
+ export function needBun(core, what = 'builds plugins') {
240
+ const binary = bunFor(core);
241
+ if (!binary) throw new CliError(`amxts ${what} with Bun, and there is none: not with @amxts/core, not on PATH`, BUN_HINT);
242
+ return binary;
243
+ }
244
+
245
+ /**
246
+ * Runs one of the core's tasks in the current folder, its output straight to
247
+ * the terminal: `prepare`, `build`, `check`, `typecheck`. Returns its exit code.
248
+ * @param {Core} core
249
+ * @param {string} name
250
+ * @param {string[]} [args]
251
+ */
252
+ export function runTask(core, name, args = []) {
253
+ const task = core.api.task(name, args);
254
+ const env = { ...process.env, ...(debug() ? { AMXTS_DEBUG: '1' } : {}) };
255
+ if (task.runtime === 'node') {
256
+ return spawnSync(process.execPath, task.args, { stdio: 'inherit', env }).status ?? 1;
257
+ }
258
+ const binary = needBun(core);
259
+ // A Bun on PATH goes through the shell on Windows, where it may be
260
+ // bun.cmd; a path with a space is quoted for it.
261
+ const result = binary === 'bun' && process.platform === 'win32'
262
+ ? spawnSync(commandLine(['bun', ...task.args]), { stdio: 'inherit', shell: true, env })
263
+ : spawnSync(binary, task.args, { stdio: 'inherit', env });
264
+ if (result.error) throw new CliError(`Bun did not start: ${result.error.message}`, BUN_HINT);
265
+ return result.status ?? 1;
266
+ }
267
+
268
+ /** Runs a task and stops with its exit code when it fails. */
269
+ export function runTaskOrExit(core, name, args = []) {
270
+ const code = runTask(core, name, args);
271
+ if (code !== 0) process.exit(code);
272
+ }
@@ -0,0 +1,181 @@
1
+ // The includes a project's build looks in besides its own includes/: the ones
2
+ // its server has. They serve what needs Pawn's declarations - a plugin's
3
+ // include contract, the natives and forwards of other Pawn plugins, a Pawn
4
+ // test suite - while the API itself comes generated with the core.
5
+ //
6
+ // - AMXTS_SERVER set, and addons/amxmodx/scripting/include beside it: the
7
+ // build reads the server's own, exactly what is installed there. Nothing
8
+ // is fetched.
9
+ // - Otherwise `target` in amxts.config.ts says which server the project is
10
+ // for: "rehlds" (ReHLDS, ReGameDLL and ReAPI; the default) - ReAPI's
11
+ // includes, fetched into .amxts/include from the release the core's API is
12
+ // generated from (its cli-api's includeSources()), sha256 checked; "hlds" -
13
+ // nothing to fetch, AMX Mod X's own includes come with the core.
14
+ //
15
+ // `amxts prepare` (after every install) and every build make sure of it;
16
+ // `amxts init` does it as it creates the project, and asks the target when no
17
+ // server is known. Everything here is plain Node - fetch and node:zlib - the
18
+ // same on Windows and Linux.
19
+ import { createHash } from 'node:crypto';
20
+ import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
21
+ import { basename, join, resolve } from 'node:path';
22
+ import process from 'node:process';
23
+ import { inflateRawSync } from 'node:zlib';
24
+ import { readConfigTarget } from './config.mjs';
25
+ import { findProject, runTaskOrExit, setting } from './core.mjs';
26
+ import { c, CliError, log } from './ui.mjs';
27
+
28
+ /** The servers a project can be for, as `target` in amxts.config.ts and `--target` name them. */
29
+ export const TARGETS = {
30
+ rehlds: 'ReHLDS + ReGameDLL + ReAPI',
31
+ hlds: 'plain HLDS',
32
+ };
33
+
34
+ /** Where the command puts the includes it fetches, in the project. */
35
+ export const FETCHED = '.amxts/include';
36
+
37
+ const STAMP = 'source.json';
38
+
39
+ /** The server's own includes: addons/amxmodx/scripting/include beside AMXTS_SERVER (addons/amxts), or null when it is not there. */
40
+ export function serverIncludes(server) {
41
+ if (!server) return null;
42
+ const dir = resolve(server, '..', 'amxmodx', 'scripting', 'include');
43
+ return existsSync(dir) ? dir : null;
44
+ }
45
+
46
+ /** Which server a folder of includes is from: ReAPI's reapi.inc says rehlds. */
47
+ export function targetOf(includes) {
48
+ return existsSync(join(includes, 'reapi.inc')) ? 'rehlds' : 'hlds';
49
+ }
50
+
51
+ /**
52
+ * The files of a .zip, by their path inside it: stored and deflated
53
+ * entries, which is what a release archive has.
54
+ * @param {Uint8Array} zip
55
+ * @returns {Map<string, Uint8Array>} the files
56
+ */
57
+ export function unzip(zip) {
58
+ const view = new DataView(zip.buffer, zip.byteOffset, zip.byteLength);
59
+ let end = -1;
60
+ for (let at = zip.length - 22; at >= Math.max(0, zip.length - 22 - 0xFFFF); at--) {
61
+ if (view.getUint32(at, true) === 0x06054B50) {
62
+ end = at;
63
+ break;
64
+ }
65
+ }
66
+ if (end < 0) throw new Error('not a zip archive');
67
+ const files = new Map();
68
+ const decoder = new TextDecoder();
69
+ let at = view.getUint32(end + 16, true);
70
+ for (let i = view.getUint16(end + 10, true); i > 0; i--) {
71
+ if (view.getUint32(at, true) !== 0x02014B50) throw new Error('a broken zip archive');
72
+ const method = view.getUint16(at + 10, true);
73
+ const size = view.getUint32(at + 20, true);
74
+ const nameLength = view.getUint16(at + 28, true);
75
+ const local = view.getUint32(at + 42, true);
76
+ const name = decoder.decode(zip.subarray(at + 46, at + 46 + nameLength));
77
+ at += 46 + nameLength + view.getUint16(at + 30, true) + view.getUint16(at + 32, true);
78
+ if (name.endsWith('/')) continue;
79
+ const start = local + 30 + view.getUint16(local + 26, true) + view.getUint16(local + 28, true);
80
+ const data = zip.subarray(start, start + size);
81
+ if (method === 0) files.set(name, data);
82
+ else if (method === 8) files.set(name, new Uint8Array(inflateRawSync(data)));
83
+ else throw new Error(`${name}: compression method ${method}`);
84
+ }
85
+ return files;
86
+ }
87
+
88
+ /**
89
+ * Downloads a source as the core pins it and gives its .inc files by name.
90
+ * @param {{ name: string, version: string, url: string, sha256: string, include?: string }} source
91
+ */
92
+ export async function fetchIncludes(source) {
93
+ const hint = `Check the connection, then run amxts prepare - or set AMXTS_SERVER to a server with ${source.name}, whose own includes the build takes.`;
94
+ let data;
95
+ try {
96
+ const response = await fetch(source.url, { signal: AbortSignal.timeout(60_000) });
97
+ if (!response.ok) throw new Error(`${source.url} answered ${response.status}`);
98
+ data = new Uint8Array(await response.arrayBuffer());
99
+ } catch (error) {
100
+ throw new CliError(`Could not download ${source.name} ${source.version}'s includes: ${error.message}`, hint);
101
+ }
102
+ const sha256 = createHash('sha256').update(data).digest('hex');
103
+ if (sha256 !== source.sha256) {
104
+ throw new CliError(`${source.name} ${source.version} from ${source.url} is not the file the core pins (sha256 ${sha256})`, 'The release was changed under the same URL; update @amxts/core, or set AMXTS_SERVER to use the server\'s own includes.');
105
+ }
106
+ const files = new Map();
107
+ if (!source.url.endsWith('.zip')) {
108
+ files.set(basename(new URL(source.url).pathname), data);
109
+ return files;
110
+ }
111
+ for (const [path, content] of unzip(data)) {
112
+ if (path.startsWith(source.include ?? '') && path.endsWith('.inc')) files.set(basename(path), content);
113
+ }
114
+ return files;
115
+ }
116
+
117
+ /**
118
+ * @typedef {object} Includes
119
+ * @property {'server' | 'fetched' | 'core'} from the server's own, fetched into .amxts/include, or AMX Mod X's that come with the core
120
+ * @property {'rehlds' | 'hlds'} target the server they are for
121
+ * @property {string | null} dir where they are
122
+ * @property {string} [version] what was fetched: "ReAPI 5.26.0.338"
123
+ * @property {boolean} [fetched] fetched just now
124
+ */
125
+
126
+ /**
127
+ * Makes sure the project has the includes of the server it is for, and says
128
+ * where they are. A download that fails is a CliError that says what to do.
129
+ * @param {import('./core.mjs').Core} core the project's core
130
+ * @param {string} root the project's folder
131
+ * @param {{ target?: string }} [options] the target, when the config is not written yet
132
+ * @returns {Promise<Includes>} where the includes are
133
+ */
134
+ export async function ensureIncludes(core, root, options = {}) {
135
+ const own = serverIncludes(setting(root, 'AMXTS_SERVER'));
136
+ if (own) return { from: 'server', target: targetOf(own), dir: own };
137
+
138
+ const target = options.target ?? (await readConfigTarget(root)) ?? 'rehlds';
139
+ const dir = join(root, FETCHED);
140
+ if (target === 'hlds') {
141
+ rmSync(dir, { recursive: true, force: true });
142
+ return { from: 'core', target, dir: null };
143
+ }
144
+ const source = core.api.includeSources?.().reapi;
145
+ if (!source) throw new CliError(`@amxts/core ${core.version} does not say which ReAPI release to fetch`, 'Update the core.');
146
+ const version = `${source.name} ${source.version}`;
147
+ const stamp = join(dir, STAMP);
148
+ try {
149
+ if (JSON.parse(readFileSync(stamp, 'utf8')).sha256 === source.sha256) return { from: 'fetched', target, dir, version };
150
+ } catch {}
151
+
152
+ const files = await fetchIncludes(source);
153
+ rmSync(dir, { recursive: true, force: true });
154
+ mkdirSync(dir, { recursive: true });
155
+ for (const [name, content] of files) writeFileSync(join(dir, name), content);
156
+ writeFileSync(stamp, `${JSON.stringify({ name: source.name, version: source.version, license: source.license, home: source.home, sha256: source.sha256 }, null, '\t')}\n`);
157
+ return { from: 'fetched', target, dir, version, fetched: true };
158
+ }
159
+
160
+ /**
161
+ * What `amxts prepare` does, and dev, build and typecheck first: the
162
+ * server's includes, then the core's prepare task (the editor config). An
163
+ * include that cannot be fetched is a warning - the build needs one only
164
+ * where a plugin names it, and says so there. `quiet`, for a command that
165
+ * says what the project is itself (dev, build, typecheck): the core's line
166
+ * about the editor config only with --debug.
167
+ * @param {import('./core.mjs').Core} core the project's core
168
+ * @param {{ quiet?: boolean }} [options]
169
+ */
170
+ export async function prepare(core, { quiet = false } = {}) {
171
+ const root = findProject() ?? process.cwd();
172
+ try {
173
+ const includes = await ensureIncludes(core, root);
174
+ if (includes.fetched) log.success(`Fetched ${includes.version}'s includes ${c.dim(`(${FETCHED})`)}`);
175
+ } catch (error) {
176
+ if (!(error instanceof CliError)) throw error;
177
+ log.warn(error.message);
178
+ if (error.hint) log.hint(error.hint);
179
+ }
180
+ runTaskOrExit(core, 'prepare', quiet ? ['--quiet'] : []);
181
+ }
package/src/info.mjs ADDED
@@ -0,0 +1,85 @@
1
+ // `amxts info`: what a bug report needs - the versions of
2
+ // everything the build depends on, the project, its modules and its server.
3
+ // It works anywhere: outside a project it says so and goes on.
4
+ import { existsSync } from 'node:fs';
5
+ import { release } from 'node:os';
6
+ import { join } from 'node:path';
7
+ import process from 'node:process';
8
+ import { readConfigModules } from './config.mjs';
9
+ import { bunFor, bunVersion, CLI_DIR, installedCore, readJson, setting, VERSION } from './core.mjs';
10
+ import { detectPackageManager, versionOf } from './pm.mjs';
11
+ import { c, CliError } from './ui.mjs';
12
+
13
+ /** The project's core, or why it cannot be used: not installed, or not one this command works with. */
14
+ async function core(dir) {
15
+ try {
16
+ const found = await installedCore(dir);
17
+ return { core: found, problem: found ? '' : c.dim('not installed here') };
18
+ } catch (error) {
19
+ if (error instanceof CliError) return { core: null, problem: c.red(`${error.message} - ${error.hint}`) };
20
+ throw error;
21
+ }
22
+ }
23
+
24
+ /**
25
+ * The system the build compiles for, as the core decides it from .env and the
26
+ * server's folder: "Linux (hlds_linux)". A core without serverSystem() builds
27
+ * for this machine.
28
+ */
29
+ function serverSystem(found, dir, server) {
30
+ if (!found) return c.dim('unknown without the core');
31
+ if (!found.api.serverSystem) return c.dim('this machine\'s (the core does not tell)');
32
+ try {
33
+ const env = { ...process.env, AMXTS_SERVER: server, AMXTS_SERVER_OS: setting(dir, 'AMXTS_SERVER_OS') };
34
+ const decided = found.api.serverSystem([], env);
35
+ const note = decided.from === 'host' ? c.dim(' - set AMXTS_SERVER, AMXTS_SERVER_OS or pass --os when the server runs another') : '';
36
+ return `${found.api.describeSystem(decided)}${note}`;
37
+ } catch (error) {
38
+ return c.red(error.message);
39
+ }
40
+ }
41
+
42
+ export async function info() {
43
+ const dir = process.cwd();
44
+ const project = readJson(join(dir, 'package.json'));
45
+ const pm = detectPackageManager(dir);
46
+ const { core: found, problem } = await core(dir);
47
+ const tools = found?.api.toolchain();
48
+ const modules = found ? await readConfigModules(dir) : null;
49
+ const server = setting(dir, 'AMXTS_SERVER');
50
+ const system = serverSystem(found, dir, server);
51
+ const bun = bunFor(found);
52
+ const bunV = bunVersion(found);
53
+
54
+ const rows = [
55
+ ['Operating system', `${process.platform === 'win32' ? 'Windows' : process.platform} ${release()} (${process.arch})`],
56
+ ['Node.js', process.version.replace(/^v/, '')],
57
+ ['Bun', bunV ? `${bunV} ${c.dim(bun === 'bun' ? '(on PATH)' : bun)}` : c.red('none - not with @amxts/core, not on PATH; the build needs it')],
58
+ ['Package manager', `${pm} ${versionOf(pm) ?? c.red('not installed')}`],
59
+ ['@amxts/cli', `${VERSION} ${c.dim(CLI_DIR)}`],
60
+ ['@amxts/core', found ? `${found.version} ${c.dim(found.dir)}` : problem],
61
+ ...(tools
62
+ ? [
63
+ ['AssemblyScript', `${tools.assemblyscript.version ?? '?'} + amxts patch${tools.assemblyscript.built ? '' : c.red(' (not built: runtime/deps/assemblyscript)')}`],
64
+ ['WAMR', `${tools.wamr.version ?? '?'} + amxts patch, wamrc ${tools.wamr.wamrc ? 'found' : c.red('missing')}`],
65
+ ]
66
+ : []),
67
+ ['Project', project ? `${project.name ?? '(no name)'} ${c.dim(dir)}` : c.dim('none here (no package.json)')],
68
+ ['Modules', modules === null
69
+ ? c.dim(found ? 'no amxts.config.ts' : 'unknown without the core')
70
+ : modules.length === 0
71
+ ? c.dim('none')
72
+ : modules.map((name) => {
73
+ const version = readJson(join(dir, 'node_modules', name, 'package.json'))?.version;
74
+ return version ? `${name} ${version}` : `${name} ${c.red('(not installed)')}`;
75
+ }).join(', ')],
76
+ ['AMXTS_SERVER', server ? `${server}${existsSync(server) ? '' : c.red(' (not found)')}` : c.dim('not set')],
77
+ ['Server system', system],
78
+ ['AMXTS_DOCS_LANG', setting(dir, 'AMXTS_DOCS_LANG') || c.dim('en')],
79
+ ];
80
+ const width = Math.max(...rows.map(([label]) => label.length));
81
+ console.log(c.bold('amxts info') + c.dim(' - paste this into a bug report'));
82
+ console.log();
83
+ for (const [label, value] of rows) console.log(` ${c.dim(label.padEnd(width))} ${value}`);
84
+ console.log();
85
+ }
@@ -0,0 +1,117 @@
1
+ // A new module package from templates/module/ - a working module, its
2
+ // playground project and a test:
3
+ //
4
+ // amxts init --module greeter ./greeter, package "greeter"
5
+ // amxts init --module @you/greeter ./greeter, package "@you/greeter"
6
+ // amxts init --module greeter --natives with src/natives.ts: natives for Pawn plugins
7
+ // amxts init --module greeter --dir x into ./x
8
+ //
9
+ // The template has the name in its forms and the author (git config
10
+ // user.name) as {{placeholders}}; `// #if natives` blocks are kept only with
11
+ // --natives. The core is a peer dependency, the range this command works with;
12
+ // with --local (the default from a checkout) it is also a dev dependency
13
+ // linked to the core on this machine, since it is not on npm to install.
14
+ import { existsSync, readdirSync } from 'node:fs';
15
+ import { join, relative, resolve } from 'node:path';
16
+ import process from 'node:process';
17
+ import { shortName, specFor } from './catalog.mjs';
18
+ import { CLI_DIR, CORE_RANGE, FROM_SOURCE, needLocalCore } from './core.mjs';
19
+ import { LINT_DEPENDENCIES, LINT_SCRIPTS } from './lint.mjs';
20
+ import { detectPackageManager, execAmxts, installArgs, runScript } from './pm.mjs';
21
+ import { copyTemplate, gitAuthor, writeFile } from './template.mjs';
22
+ import { c, CliError, log } from './ui.mjs';
23
+
24
+ export const MODULE_NAME = /^(?:@[a-z0-9][\w.-]*\/)?[a-z][a-z0-9-]*$/;
25
+
26
+ /**
27
+ * @param {{ module: string, natives?: boolean, dir?: string, pm?: string, local?: boolean }} options
28
+ */
29
+ export async function initModule(options) {
30
+ const pkgName = options.module;
31
+ if (!MODULE_NAME.test(pkgName)) {
32
+ throw new CliError(`"${pkgName}" is not a package name for a module`, 'Lowercase letters, digits and dashes: greeter, @you/greeter');
33
+ }
34
+ const natives = Boolean(options.natives);
35
+
36
+ /** The package's name without its scope: the module's name - "kill-feed". */
37
+ const name = shortName(pkgName);
38
+ const words = name.split('-').filter(Boolean);
39
+ const camel = words.map((word, i) => (i === 0 ? word : word[0].toUpperCase() + word.slice(1))).join('');
40
+ const title = words.map(word => word[0].toUpperCase() + word.slice(1)).join(' ');
41
+
42
+ const values = {
43
+ name,
44
+ package: pkgName,
45
+ title,
46
+ camel,
47
+ configKey: camel,
48
+ Options: `${title.replace(/ /g, '')}Options`,
49
+ include: name.replace(/-/g, '_'),
50
+ year: String(new Date().getFullYear()),
51
+ author: gitAuthor(),
52
+ };
53
+
54
+ const dir = resolve(options.dir ?? name);
55
+ if (existsSync(dir) && readdirSync(dir).length > 0) {
56
+ throw new CliError(`${dir} is there and not empty`, 'Pick another folder with --dir.');
57
+ }
58
+
59
+ const pm = options.pm ?? detectPackageManager();
60
+ const localCore = (options.local ?? FROM_SOURCE) ? await needLocalCore() : null;
61
+
62
+ const written = copyTemplate(join(CLI_DIR, 'templates', 'module'), dir, values, { natives }, path => path === 'src/natives.ts' && !natives);
63
+
64
+ const amxts = { module: 'src/index.ts' };
65
+ if (natives) Object.assign(amxts, { natives: 'src/natives.ts', include: `include/${values.include}.inc` });
66
+
67
+ writeFile(join(dir, 'package.json'), `${JSON.stringify({
68
+ name: pkgName,
69
+ type: 'module',
70
+ version: '0.1.0',
71
+ description: `${title} for amxts plugins.`,
72
+ license: 'MIT',
73
+ keywords: ['amxts-module', 'amxmodx', 'counter-strike'],
74
+ exports: { '.': './src/index.ts' },
75
+ types: './src/index.ts',
76
+ files: ['README.md', 'README.ru.md', ...(natives ? ['include'] : []), 'src'],
77
+ amxts,
78
+ scripts: {
79
+ build: 'amxts build',
80
+ check: 'amxts check',
81
+ test: 'amxts test',
82
+ ...LINT_SCRIPTS,
83
+ },
84
+ peerDependencies: { '@amxts/core': CORE_RANGE },
85
+ devDependencies: Object.fromEntries(Object.entries({
86
+ ...(localCore ? { '@amxts/core': specFor({ name: '@amxts/core', dir: localCore.dir }, dir, pm) } : {}),
87
+ ...LINT_DEPENDENCIES,
88
+ '@types/bun': '^1.4.2',
89
+ 'typescript': '^6.0.3',
90
+ }).sort(([a], [b]) => a.localeCompare(b))),
91
+ }, null, '\t')}\n`);
92
+ written.push('package.json');
93
+
94
+ // The playground is a project of its own that takes the module by its path,
95
+ // as a project takes one from npm.
96
+ writeFile(join(dir, 'playground', 'package.json'), `${JSON.stringify({
97
+ name: `${name}-playground`,
98
+ type: 'module',
99
+ private: true,
100
+ devDependencies: { [pkgName]: 'file:..' },
101
+ }, null, '\t')}\n`);
102
+ written.push('playground/package.json');
103
+
104
+ const at = relative(process.cwd(), dir) || '.';
105
+ for (const file of written.sort()) console.log(` ${c.dim(`${at}/`)}${file}`);
106
+ console.log();
107
+ log.success(`${c.bold(pkgName)} is in ${c.cyan(at)}`);
108
+
109
+ console.log(`\n Next:\n`);
110
+ for (const line of [
111
+ `cd ${at}`,
112
+ installArgs(pm).join(' '),
113
+ ...(natives ? [`${execAmxts(pm)} build ${c.dim(`# writes include/${values.include}.inc from src/natives.ts`)}`] : []),
114
+ runScript(pm, 'test'),
115
+ ]) console.log(` ${c.cyan(line)}`);
116
+ console.log();
117
+ }