@amxts/cli 0.2.0 → 0.3.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.
@@ -17,7 +17,7 @@ import process from 'node:process';
17
17
  import { shortName, specFor } from './catalog.mjs';
18
18
  import { CLI_DIR, CORE_RANGE, FROM_SOURCE, needLocalCore } from './core.mjs';
19
19
  import { LINT_DEPENDENCIES, LINT_SCRIPTS } from './lint.mjs';
20
- import { detectPackageManager, execAmxts, installArgs, runScript } from './pm.mjs';
20
+ import { detectPackageManager, execAmxts, installArgs, runScript, testRunner } from './pm.mjs';
21
21
  import { copyTemplate, gitAuthor, writeFile } from './template.mjs';
22
22
  import { c, CliError, log } from './ui.mjs';
23
23
 
@@ -57,9 +57,11 @@ export async function initModule(options) {
57
57
  }
58
58
 
59
59
  const pm = options.pm ?? detectPackageManager();
60
+ const runner = testRunner(pm);
61
+ values['test.types'] = runner.types;
60
62
  const localCore = (options.local ?? FROM_SOURCE) ? await needLocalCore() : null;
61
63
 
62
- const written = copyTemplate(join(CLI_DIR, 'templates', 'module'), dir, values, { natives }, path => path === 'src/natives.ts' && !natives);
64
+ const written = copyTemplate(join(CLI_DIR, 'templates', 'module'), dir, values, { natives, bun: pm === 'bun' }, path => path === 'src/natives.ts' && !natives);
63
65
 
64
66
  const amxts = { module: 'src/index.ts' };
65
67
  if (natives) Object.assign(amxts, { natives: 'src/natives.ts', include: `include/${values.include}.inc` });
@@ -78,15 +80,15 @@ export async function initModule(options) {
78
80
  scripts: {
79
81
  build: 'amxts build',
80
82
  check: 'amxts check',
81
- test: 'amxts test',
83
+ test: runner.script,
82
84
  ...LINT_SCRIPTS,
83
85
  },
84
86
  peerDependencies: { '@amxts/core': CORE_RANGE },
85
87
  devDependencies: Object.fromEntries(Object.entries({
86
88
  ...(localCore ? { '@amxts/core': specFor({ name: '@amxts/core', dir: localCore.dir }, dir, pm) } : {}),
87
89
  ...LINT_DEPENDENCIES,
88
- '@types/bun': '^1.4.2',
89
- 'typescript': '^6.0.3',
90
+ ...runner.devDependencies,
91
+ typescript: '^6.0.3',
90
92
  }).sort(([a], [b]) => a.localeCompare(b))),
91
93
  }, null, '\t')}\n`);
92
94
  written.push('package.json');
package/src/init.mjs CHANGED
@@ -18,12 +18,12 @@ import { modulesList } from './config.mjs';
18
18
  import { bunFor, CLI_DIR, CORE_RANGE, coreDirFrom, FROM_SOURCE, loadCore, needLocalCore, readJson, VERSION } from './core.mjs';
19
19
  import { ensureIncludes, FETCHED, serverIncludes, targetOf, TARGETS } from './includes.mjs';
20
20
  import { LINT_DEPENDENCIES, LINT_FILES, LINT_SCRIPTS } from './lint.mjs';
21
- import { commandLine, execAmxts, installArgs, installLinked, linkedFolders, PACKAGE_MANAGERS, packageManagerOfAgent, run, runScript, versionOf } from './pm.mjs';
21
+ import { commandLine, execAmxts, installArgs, installLinked, linkedFolders, PACKAGE_MANAGERS, packageManagerOfAgent, run, runScript, testRunner, versionOf } from './pm.mjs';
22
22
  import { npmRegistry, versionsFor } from './registry.mjs';
23
23
  import { askServer, canAsk, missingServer, serverEnv, serverFolder } from './server.mjs';
24
24
  import { HOST_SYSTEM, serverSystemOf, SYSTEMS } from './system.mjs';
25
25
  import { copyTemplate, gitAuthor, writeFile } from './template.mjs';
26
- import { c, CliError } from './ui.mjs';
26
+ import { banner, c, CliError } from './ui.mjs';
27
27
 
28
28
  /**
29
29
  * The tools a project gets besides the core and its modules. knip reads the
@@ -32,8 +32,7 @@ import { c, CliError } from './ui.mjs';
32
32
  * from this machine's folders (--local) are not called unused.
33
33
  */
34
34
  const TOOLS = {
35
- '@types/bun': '^1.4.2',
36
- 'knip': '^6.39.0',
35
+ knip: '^6.39.0',
37
36
  };
38
37
 
39
38
  const DEFAULT_DIR = 'my-server';
@@ -81,7 +80,8 @@ export async function initProject(options) {
81
80
  const localCore = local ? await needLocalCore() : null;
82
81
  const localModules = localCore?.api.localModules() ?? {};
83
82
 
84
- p.intro(`${c.bold(c.cyan('amxts'))} ${c.dim(VERSION)} ${c.dim('·')} a new project`);
83
+ banner(VERSION, 'a new project', { logo: true });
84
+ p.intro();
85
85
 
86
86
  // 1. The package manager, first: the rest is said in its words.
87
87
  const detected = packageManagerOfAgent() ?? 'npm';
@@ -187,6 +187,7 @@ export async function initProject(options) {
187
187
  // The files.
188
188
  const amxts = execAmxts(pm);
189
189
  const install = installArgs(pm).join(' ');
190
+ const runner = testRunner(pm);
190
191
  const values = {
191
192
  'name': packageName(root),
192
193
  'author': gitAuthor(),
@@ -199,8 +200,9 @@ export async function initProject(options) {
199
200
  'run.typecheck': runScript(pm, 'typecheck'),
200
201
  'run.test': runScript(pm, 'test'),
201
202
  'run.lint': runScript(pm, 'lint'),
203
+ 'test.types': runner.types,
202
204
  };
203
- const flags = { lint, ...Object.fromEntries(installed.map(module => [shortName(module.name), true])) };
205
+ const flags = { lint, bun: pm === 'bun', ...Object.fromEntries(installed.map(module => [shortName(module.name), true])) };
204
206
  const written = copyTemplate(join(CLI_DIR, 'templates', 'project'), root, values, flags, path => LINT_FILES.includes(path) && !lint);
205
207
 
206
208
  const core = { name: '@amxts/core', dir: localCore?.dir ?? null };
@@ -209,6 +211,7 @@ export async function initProject(options) {
209
211
  [core.name]: specFor(core, root, pm, CORE_RANGE),
210
212
  ...Object.fromEntries(installed.map(module => [module.name, specFor(module, root, pm, versions[module.name] ? `^${versions[module.name]}` : 'latest')])),
211
213
  ...TOOLS,
214
+ ...runner.devDependencies,
212
215
  ...(lint ? LINT_DEPENDENCIES : {}),
213
216
  };
214
217
  writeFile(join(root, 'package.json'), `${JSON.stringify({
@@ -221,7 +224,7 @@ export async function initProject(options) {
221
224
  dev: 'amxts dev',
222
225
  build: 'amxts build',
223
226
  typecheck: 'amxts typecheck',
224
- test: 'amxts test',
227
+ test: runner.script,
225
228
  ...(lint ? LINT_SCRIPTS : {}),
226
229
  },
227
230
  devDependencies: Object.fromEntries(Object.entries(devDependencies).sort(([a], [b]) => a.localeCompare(b))),
package/src/lock.mjs ADDED
@@ -0,0 +1,94 @@
1
+ // One `amxts dev` per project: two would build and deploy at the same time.
2
+ // The one running holds .amxts/dev.lock - its pid, when it started and its
3
+ // script. A second one stops with where the first runs; --takeover stops the
4
+ // first instead, once its command line shows it is still that amxts dev - a
5
+ // pid alone could be a new process by now. A lock whose process is gone is
6
+ // taken over quietly.
7
+ import { execFileSync, spawnSync } from 'node:child_process';
8
+ import { mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
9
+ import { dirname, join } from 'node:path';
10
+ import process from 'node:process';
11
+ import { CliError } from './ui.mjs';
12
+
13
+ /** The lock of a project's dev. */
14
+ export const lockPath = root => join(root, '.amxts', 'dev.lock');
15
+
16
+ /** Whether a process is there: a signal 0 reaches it, or it is someone else's. */
17
+ function alive(pid) {
18
+ try {
19
+ process.kill(pid, 0);
20
+ return true;
21
+ } catch (error) {
22
+ return error.code === 'EPERM';
23
+ }
24
+ }
25
+
26
+ /** A process's command line, or '' when it cannot be read. */
27
+ export function commandLineOf(pid) {
28
+ try {
29
+ if (process.platform === 'win32') {
30
+ return execFileSync('powershell.exe', ['-NoProfile', '-NonInteractive', '-Command', `(Get-CimInstance Win32_Process -Filter 'ProcessId=${Number(pid)}').CommandLine`], { encoding: 'utf8', windowsHide: true }).trim();
31
+ }
32
+ if (process.platform === 'linux') return readFileSync(`/proc/${pid}/cmdline`, 'utf8').replace(/\0/g, ' ').trim();
33
+ return execFileSync('ps', ['-o', 'command=', '-p', String(pid)], { encoding: 'utf8' }).trim();
34
+ } catch {
35
+ return '';
36
+ }
37
+ }
38
+
39
+ /** The lock's holder, when it is still the dev that wrote it: alive, and running the same script. */
40
+ function holder(lock) {
41
+ if (!lock?.pid || lock.pid === process.pid || !alive(lock.pid)) return null;
42
+ const line = commandLineOf(lock.pid).replace(/\\/g, '/');
43
+ return line.includes(String(lock.script).replace(/\\/g, '/')) ? lock : null;
44
+ }
45
+
46
+ /** Stops a process and what it started: on Windows the whole tree, elsewhere it stops its own (dev does, on SIGTERM). */
47
+ export function stopTree(pid) {
48
+ if (process.platform === 'win32') {
49
+ spawnSync('taskkill', ['/PID', String(pid), '/T', '/F'], { stdio: 'ignore', windowsHide: true });
50
+ } else {
51
+ try {
52
+ process.kill(pid, 'SIGTERM');
53
+ } catch {}
54
+ }
55
+ }
56
+
57
+ /** Stops a process (stopTree) and waits up to `wait` ms for it to be gone. */
58
+ function stop(pid, wait = 5000) {
59
+ stopTree(pid);
60
+ const until = Date.now() + wait;
61
+ while (alive(pid) && Date.now() < until) Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 100);
62
+ return !alive(pid);
63
+ }
64
+
65
+ /**
66
+ * Takes the project's dev lock, released when this process exits.
67
+ * @param {string} root the project's folder
68
+ * @param {{ takeover?: boolean, script?: string, say?: (line: string) => void }} [options]
69
+ * @throws {CliError} when another dev holds it and `takeover` is not given
70
+ */
71
+ export function takeLock(root, { takeover = false, script = process.argv[1], say = () => {} } = {}) {
72
+ const path = lockPath(root);
73
+ let lock = null;
74
+ try {
75
+ lock = JSON.parse(readFileSync(path, 'utf8'));
76
+ } catch {}
77
+ const other = holder(lock);
78
+ if (other) {
79
+ const since = new Date(other.since).toLocaleString();
80
+ if (!takeover) {
81
+ throw new CliError(`amxts dev is already running in another terminal (pid ${other.pid}, since ${since})`, 'Stop it there, or take it over: amxts dev --takeover');
82
+ }
83
+ // Read again just before stopping it: the pid must still be that dev.
84
+ if (holder(other) && !stop(other.pid)) throw new CliError(`amxts dev (pid ${other.pid}) did not stop`, 'Stop it in its terminal, then run amxts dev again.');
85
+ say(`stopped the amxts dev that ran since ${since} (pid ${other.pid})`);
86
+ }
87
+ mkdirSync(dirname(path), { recursive: true });
88
+ writeFileSync(path, `${JSON.stringify({ pid: process.pid, since: new Date().toISOString(), script })}\n`);
89
+ process.on('exit', () => {
90
+ try {
91
+ if (JSON.parse(readFileSync(path, 'utf8')).pid === process.pid) rmSync(path, { force: true });
92
+ } catch {}
93
+ });
94
+ }
package/src/main.mjs CHANGED
@@ -6,16 +6,20 @@
6
6
  // It runs on Node. Commands that make or change a project (init, module,
7
7
  // info) run here; the ones that compile (build, dev, typecheck, check,
8
8
  // prepare) are tasks of the project's core, which runs them on Bun
9
- // (core.mjs); test runs bun test.
9
+ // (core.mjs); test runs the project's Vitest, or bun test.
10
+ import { existsSync } from 'node:fs';
11
+ import { join } from 'node:path';
10
12
  import process from 'node:process';
11
13
  import { GLOBAL_FLAGS, parseArgs } from './args.mjs';
12
- import { needBun, needProject, projectCore, runTask, runTaskOrExit, VERSION } from './core.mjs';
14
+ import { needBun, needProject, projectCore, runTask, runTaskOrExit, setting, startTask, VERSION } from './core.mjs';
13
15
  import { prepare, TARGETS } from './includes.mjs';
16
+ import { stopTree, takeLock } from './lock.mjs';
14
17
  import { detectPackageManager, PACKAGE_MANAGERS, run } from './pm.mjs';
15
18
  import { serverMismatch } from './server-update.mjs';
16
19
  import { ensureServer } from './server.mjs';
17
20
  import { SYSTEMS } from './system.mjs';
18
21
  import { banner, c, CliError, closest, log, report } from './ui.mjs';
22
+ import { updateLine } from './update-check.mjs';
19
23
 
20
24
  /** --os, for the commands that compile: the plugins are built for that system. */
21
25
  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' };
@@ -26,6 +30,12 @@ function warnServer(core, os) {
26
30
  if (line) log.warn(line);
27
31
  }
28
32
 
33
+ /** The line about a newer amxts, once a day at most (update-check.mjs). */
34
+ function nudge(core) {
35
+ const line = updateLine(core.version, detectPackageManager());
36
+ if (line) log.info(line);
37
+ }
38
+
29
39
  /** --os as the build task takes it. */
30
40
  function osArgs(values) {
31
41
  if (values.os === undefined) return [];
@@ -82,25 +92,76 @@ export const COMMANDS = {
82
92
  },
83
93
  dev: {
84
94
  description: 'Build, deploy to the server in AMXTS_SERVER, and again on every save',
85
- usage: 'amxts dev [--os windows|linux] [--docker]',
95
+ usage: 'amxts dev [--os windows|linux] [--docker] [--no-tui] [--takeover]',
86
96
  flags: {
87
97
  os: OS_FLAG,
88
98
  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' },
99
+ tui: { type: 'boolean', description: 'The panel under the build\'s lines, in a terminal (--no-tui, or AMXTS_TUI=plain, for the lines alone)' },
100
+ takeover: { type: 'boolean', description: 'Stop the amxts dev already running for this project, and run here' },
89
101
  },
90
- examples: ['amxts dev', 'amxts dev --os linux', 'amxts dev --docker'],
102
+ examples: ['amxts dev', 'amxts dev --os linux', 'amxts dev --docker', 'amxts dev --takeover'],
91
103
  async run({ values }) {
92
104
  if (values.docker && values.os && values.os !== 'linux')
93
105
  throw new CliError('The Docker server is Linux: --docker builds for Linux.', 'Leave out --os.');
94
106
  const core = await projectCore();
95
- banner(core.version, 'dev');
107
+ const root = needProject();
108
+ const { devPanel, wantsPanel } = await import('./dev-panel.mjs');
109
+ // The panel has the logo in it; the build's lines alone start with it.
110
+ const panel = wantsPanel({ tui: values.tui });
111
+ if (!panel) banner(core.version, 'dev', { logo: true });
112
+ takeLock(root, { takeover: values.takeover, say: log.info });
113
+ nudge(core);
96
114
  // The container reads dist/ where it is: nothing to deploy, and AMXTS_SERVER is not asked.
97
115
  if (!values.docker) {
98
- await ensureServer(needProject(), detectPackageManager());
116
+ await ensureServer(root, detectPackageManager());
99
117
  warnServer(core, osArgs(values));
100
118
  }
101
119
  await prepare(core, { quiet: true });
102
- if (values.docker) runTaskOrExit(core, 'build', ['--watch', '--docker', '--os', 'linux']);
103
- else runTaskOrExit(core, 'build', ['--deploy', '--watch', ...osArgs(values)]);
120
+ const args = values.docker ? ['--watch', '--docker', '--os', 'linux'] : ['--deploy', '--watch', ...osArgs(values)];
121
+ if (panel) {
122
+ const { rconTarget } = await import('./rcon.mjs');
123
+ devPanel(core, args, { target: rconTarget(root), deployTo: values.docker ? '' : setting(root, 'AMXTS_SERVER').replace(/\\/g, '/') });
124
+ return;
125
+ }
126
+ const child = startTask(core, 'build', args);
127
+ // --takeover from another terminal: the build goes with this.
128
+ for (const signal of ['SIGTERM', 'SIGHUP']) {
129
+ process.on(signal, () => {
130
+ stopTree(child.pid);
131
+ process.exit(0);
132
+ });
133
+ }
134
+ child.on('exit', code => process.exit(code ?? 1));
135
+ },
136
+ },
137
+ rcon: {
138
+ description: 'Send a command to the project\'s server over rcon and print its answer',
139
+ usage: 'amxts rcon <command> [--json]',
140
+ flags: {
141
+ json: { type: 'boolean', description: 'Print { server, command, reply } - or { server, command, error } - as JSON' },
142
+ },
143
+ examples: ['amxts rcon amxts_plugins', 'amxts rcon status --json', 'amxts rcon changelevel de_dust2'],
144
+ async run({ values, positionals }) {
145
+ const command = positionals.join(' ').trim();
146
+ if (!command) throw new CliError('amxts rcon: which command?', 'amxts rcon amxts_plugins');
147
+ const { rcon, rconTarget } = await import('./rcon.mjs');
148
+ const target = rconTarget(needProject());
149
+ if (!target) throw new CliError('AMXTS_SERVER is not set: rcon reaches the server it names', 'Set it in .env beside package.json: AMXTS_SERVER=D:/hlds/cstrike/addons/amxts');
150
+ if (!target.password) throw new CliError(`rcon is not set up: no rcon_password in ${target.cfg}`, 'Add a line rcon_password "something" to it and restart the server.');
151
+ const server = `${target.host}:${target.port}`;
152
+ const answer = await rcon(target, command);
153
+ if (values.json) {
154
+ console.log(JSON.stringify({ server, command, ...answer }));
155
+ if ('error' in answer) process.exitCode = 1;
156
+ return;
157
+ }
158
+ if ('reply' in answer) {
159
+ process.stdout.write(answer.reply.endsWith('\n') || !answer.reply ? answer.reply : `${answer.reply}\n`);
160
+ return;
161
+ }
162
+ throw answer.error === 'no answer'
163
+ ? new CliError(`The server at ${server} does not answer`, 'Is it running? AMXTS_PORT in .env names a port other than 27015.')
164
+ : new CliError(`The server refused the rcon_password of ${target.cfg}`, 'The server reads it as it starts: restart it after a change.');
104
165
  },
105
166
  },
106
167
  build: {
@@ -116,6 +177,7 @@ export const COMMANDS = {
116
177
  const os = osArgs(values);
117
178
  const core = await projectCore();
118
179
  banner(core.version, 'build');
180
+ nudge(core);
119
181
  warnServer(core, os);
120
182
  if (values.deploy) await ensureServer(needProject(), detectPackageManager());
121
183
  await prepare(core, { quiet: true });
@@ -129,6 +191,7 @@ export const COMMANDS = {
129
191
  async run() {
130
192
  const core = await projectCore();
131
193
  banner(core.version, 'typecheck');
194
+ nudge(core);
132
195
  await prepare(core, { quiet: true });
133
196
  if (runTask(core, 'typecheck') !== 0) {
134
197
  throw new CliError('The plugins have type errors', 'They are listed above, file:line first.');
@@ -137,12 +200,17 @@ export const COMMANDS = {
137
200
  },
138
201
  },
139
202
  test: {
140
- description: 'Run the project\'s tests on a fake server (bun test)',
141
- usage: 'amxts test [bun test options]',
203
+ description: 'Run the project\'s tests on a fake server (Vitest when the project has it, else bun test)',
204
+ usage: 'amxts test [test runner options]',
142
205
  passThrough: true,
143
206
  examples: ['amxts test', 'amxts test hello', 'amxts test --watch'],
144
207
  async run({ raw }) {
145
- const result = await run([needBun(await projectCore(), 'runs tests'), 'test', ...raw]);
208
+ const core = await projectCore();
209
+ // A project with Vitest tests on Node; any other on bun test.
210
+ const vitest = join(needProject(), 'node_modules', 'vitest', 'vitest.mjs');
211
+ const result = await run(existsSync(vitest)
212
+ ? [process.execPath, vitest, ...(raw.includes('--watch') ? [] : ['run']), ...raw]
213
+ : [needBun(core, 'runs tests'), 'test', ...raw]);
146
214
  process.exitCode = result.code;
147
215
  },
148
216
  },
@@ -176,7 +244,15 @@ export const COMMANDS = {
176
244
  description: 'Fetch the server\'s includes and write .amxts/tsconfig.json for the editor (build does it too)',
177
245
  usage: 'amxts prepare',
178
246
  async run() {
179
- await prepare(await projectCore());
247
+ try {
248
+ await prepare(await projectCore());
249
+ } catch (error) {
250
+ // A project's postinstall: installing a newer command into a project of
251
+ // an older core - the upgrade's first step - is no failed install.
252
+ if (!(error instanceof CliError) || process.env.npm_lifecycle_event !== 'postinstall') throw error;
253
+ log.warn(error.message);
254
+ if (error.hint) log.hint(error.hint);
255
+ }
180
256
  },
181
257
  },
182
258
  upgrade: {
package/src/modules.json CHANGED
@@ -14,6 +14,21 @@
14
14
  ],
15
15
  "requires": []
16
16
  },
17
+ {
18
+ "name": "ftp",
19
+ "npm": "@amxts/ftp",
20
+ "repo": "amxts/ftp",
21
+ "description": "FTP, FTPS and SFTP - upload, download and list files on another server, every call a promise",
22
+ "category": "network",
23
+ "type": "official",
24
+ "logo": "https://raw.githubusercontent.com/amxts/ftp/HEAD/assets/logo.svg",
25
+ "maintainers": [
26
+ {
27
+ "github": "kukson777"
28
+ }
29
+ ],
30
+ "requires": []
31
+ },
17
32
  {
18
33
  "name": "menu-core",
19
34
  "npm": "@amxts/menu-core",
package/src/pm.mjs CHANGED
@@ -75,6 +75,17 @@ export function execAmxts(pm) {
75
75
  return { npm: 'npx amxts', pnpm: 'pnpm amxts', yarn: 'yarn amxts', bun: 'bunx amxts' }[pm] ?? 'npx amxts';
76
76
  }
77
77
 
78
+ /**
79
+ * What a new project's or module's tests run on: `bun test` with Bun as the
80
+ * package manager, Vitest on Node with any other. `types` is the test
81
+ * folder's tsconfig `types`.
82
+ */
83
+ export function testRunner(pm) {
84
+ return pm === 'bun'
85
+ ? { script: 'amxts test', types: 'bun', devDependencies: { '@types/bun': '^1.4.2' } }
86
+ : { script: 'vitest run', types: 'node', devDependencies: { '@types/node': '^24.0.0', 'vitest': '^5.0.3' } };
87
+ }
88
+
78
89
  /**
79
90
  * The folders a project's package.json takes packages from - `link:` and
80
91
  * `file:` specs: the core and the modules of this machine (--local).
package/src/rcon.mjs ADDED
@@ -0,0 +1,134 @@
1
+ // rcon to the project's server: GoldSrc's, over UDP. A packet starts with four
2
+ // 0xFF; `challenge rcon` is answered with a number, and `rcon <number>
3
+ // "<password>" <command>` with what the command printed, an `l` after the
4
+ // 0xFFs, in as many packets as it takes.
5
+ //
6
+ // The server is the one AMXTS_SERVER names: on this machine (127.0.0.1), on
7
+ // AMXTS_PORT (27015 by default), with the rcon_password of its server.cfg -
8
+ // what the build reloads plugins with.
9
+ import { Buffer } from 'node:buffer';
10
+ import { createSocket } from 'node:dgram';
11
+ import { existsSync, readFileSync } from 'node:fs';
12
+ import { join } from 'node:path';
13
+ import { setting } from './core.mjs';
14
+ import { serverFolderOrNull } from './server.mjs';
15
+
16
+ const HEAD = Buffer.from([255, 255, 255, 255]);
17
+
18
+ /** A connectionless packet: four 0xFF, then the text. */
19
+ export function packet(text) {
20
+ return Buffer.concat([HEAD, Buffer.from(`${text}\n`, 'latin1')]);
21
+ }
22
+
23
+ /** The text of a reply: after the four 0xFF and an `l` (a printed reply), without the trailing NULs. */
24
+ export function replyText(buffer) {
25
+ if (buffer.length < 4 || !buffer.subarray(0, 4).equals(HEAD)) return '';
26
+ return buffer.subarray(4).toString('latin1').replace(/^l/, '').replace(/\0+$/, '');
27
+ }
28
+
29
+ /** The rcon_password a server.cfg sets, or ''. */
30
+ export function cfgPassword(text) {
31
+ return text.match(/^\s*rcon_password\s+"?([^"\r\n]*)"?/m)?.[1]?.trim() ?? '';
32
+ }
33
+
34
+ /**
35
+ * @typedef {object} RconTarget
36
+ * @property {string} host its address
37
+ * @property {number} port its UDP port
38
+ * @property {string} password '' when none is set
39
+ * @property {string} cfg the server.cfg the password is read from, for the words
40
+ */
41
+
42
+ /** The project's server as rcon reaches it; null without AMXTS_SERVER. */
43
+ export function rconTarget(root) {
44
+ const server = serverFolderOrNull(setting(root, 'AMXTS_SERVER'));
45
+ if (!server) return null;
46
+ const cfg = join(server, '..', '..', 'server.cfg').replace(/\\/g, '/');
47
+ return {
48
+ host: '127.0.0.1',
49
+ port: Number(setting(root, 'AMXTS_PORT') || 27015),
50
+ password: existsSync(cfg) ? cfgPassword(readFileSync(cfg, 'utf8')) : '',
51
+ cfg,
52
+ };
53
+ }
54
+
55
+ /**
56
+ * Sends one packet and collects what comes back until it goes quiet; null
57
+ * when nothing does.
58
+ */
59
+ function ask(socket, target, text, wait) {
60
+ return new Promise((done) => {
61
+ let reply = null;
62
+ let timer;
63
+ function finish() {
64
+ clearTimeout(timer);
65
+ socket.off('message', heard);
66
+ socket.off('error', finish);
67
+ done(reply);
68
+ }
69
+ function heard(buffer) {
70
+ reply = (reply ?? '') + replyText(buffer);
71
+ clearTimeout(timer);
72
+ timer = setTimeout(finish, 150);
73
+ }
74
+ timer = setTimeout(finish, wait);
75
+ socket.on('message', heard);
76
+ // Windows answers a closed port with an error rather than silence.
77
+ socket.once('error', finish);
78
+ socket.send(packet(text), target.port, target.host);
79
+ });
80
+ }
81
+
82
+ /**
83
+ * Runs a command on the server: what it printed, or why it did not run -
84
+ * `no answer` (the server is not running there) or `bad password`.
85
+ * @param {RconTarget} target
86
+ * @param {string} command
87
+ * @returns {Promise<{ reply: string } | { error: 'no answer' | 'bad password' }>} the reply, or why there is none
88
+ */
89
+ export async function rcon(target, command, wait = 2000) {
90
+ const socket = createSocket('udp4');
91
+ try {
92
+ const challenge = (await ask(socket, target, 'challenge rcon', 1000))?.match(/challenge rcon (\d+)/)?.[1];
93
+ if (!challenge) return { error: 'no answer' };
94
+ const reply = await ask(socket, target, `rcon ${challenge} "${target.password}" ${command}`, wait);
95
+ if (reply === null) return { error: 'no answer' };
96
+ if (/^Bad rcon_password/i.test(reply)) return { error: 'bad password' };
97
+ return { reply };
98
+ } finally {
99
+ socket.close();
100
+ }
101
+ }
102
+
103
+ /**
104
+ * What `status` and `stats` say of the server: its map, players and FPS.
105
+ * @returns {{ map?: string, players?: number, max?: number, fps?: number }} what was found
106
+ */
107
+ export function serverState(status, stats = '') {
108
+ const map = /^map\s*:\s*(\S+)/m.exec(status)?.[1];
109
+ const players = /^players\s*:\s*(\d+) active \((\d+) max\)/m.exec(status);
110
+ // stats: a row of headings, then a row of numbers under them.
111
+ const rows = stats.split('\n').map(line => line.trim().split(/\s+/));
112
+ const at = rows.findIndex(row => row.includes('FPS'));
113
+ const fps = at >= 0 ? Number(rows[at + 1]?.[rows[at].indexOf('FPS')]) : Number.NaN;
114
+ return {
115
+ map,
116
+ players: players ? Number(players[1]) : undefined,
117
+ max: players ? Number(players[2]) : undefined,
118
+ fps: Number.isFinite(fps) ? fps : undefined,
119
+ };
120
+ }
121
+
122
+ /**
123
+ * The plugins `amxts_plugins` lists: ` name state about`.
124
+ * @returns {{ name: string, state: string, about: string }[]} one per plugin
125
+ */
126
+ export function pluginStates(reply) {
127
+ return [...reply.matchAll(/^ {2}(\S+)\s+(running|unloaded|refused|waiting|loading)(?: +(\S.*))?$/gm)]
128
+ .map(([, name, state, about = '']) => ({ name, state, about: about.trim() }));
129
+ }
130
+
131
+ /** The maps `maps *` lists, without `.bsp`. */
132
+ export function mapNames(reply) {
133
+ return [...new Set([...reply.matchAll(/^\s*([\w.-]+)\.bsp\b/gim)].map(match => match[1]))].sort();
134
+ }
@@ -7,8 +7,9 @@
7
7
  // downloaded and checked, and none of the files it replaces is in use: a
8
8
  // running Windows server holds its module, which cannot be written then.
9
9
  // The file it replaces stays beside it, with its version
10
- // (amxts_amxx.dll.0.1.0). AMX Mod X's plugins.ini loses an
11
- // `amxts_host.amxx` line: the module loads its host plugin itself.
10
+ // (amxts_amxx.dll.0.1.0). What an older release put into AMX Mod X goes: an
11
+ // `amxts_host.amxx` line of its plugins.ini, that plugin and its list -
12
+ // amxts needs no plugin.
12
13
  //
13
14
  // Also the check `amxts dev` and `build` start with: the release the
14
15
  // server's module is of, read from the module file (the core's
@@ -47,6 +48,9 @@ export function writable(path) {
47
48
  }
48
49
  }
49
50
 
51
+ /** The files of an older release's plugin in AMX Mod X, from the game folder. */
52
+ const LEFTOVERS = ['addons/amxmodx/plugins/amxts_host.amxx', 'addons/amxmodx/configs/plugins-amxts.ini'];
53
+
50
54
  /** AMX Mod X's plugins.ini with its `amxts_host.amxx` lines left out; null when it has none. */
51
55
  export function withoutHost(text) {
52
56
  const lines = text.split(/(?<=\n)/);
@@ -134,11 +138,13 @@ export async function updateServer(api, dir, { dryRun = false, canWrite = writab
134
138
  const changing = files.filter(file => !file.same);
135
139
  const pluginsIni = join(game, 'addons', 'amxmodx', 'configs', 'plugins.ini');
136
140
  const cleaned = existsSync(pluginsIni) ? withoutHost(readFileSync(pluginsIni, 'utf8')) : null;
141
+ const leftovers = LEFTOVERS.map(path => join(game, path)).filter(path => existsSync(path));
142
+ const tidied = [...(cleaned === null ? [] : [pluginsIni]), ...leftovers].map(slashes).join(', ');
137
143
  const names = changing.map(file => file.path.split(/[\\/]/).pop()).join(', ');
138
144
 
139
145
  if (dryRun) {
140
146
  for (const file of changing) log.info(`would put ${file.asset} of ${version} at ${slashes(file.path)}`);
141
- if (cleaned !== null) log.info(`would take amxts_host.amxx out of ${slashes(pluginsIni)}`);
147
+ if (tidied) log.info(`would take an older amxts's plugin out of ${tidied}`);
142
148
  return { ok: true, line: changing.length ? `${names} would go from amxts ${before} to ${version}` : `already amxts ${version}` };
143
149
  }
144
150
 
@@ -164,10 +170,9 @@ export async function updateServer(api, dir, { dryRun = false, canWrite = writab
164
170
  writeFileSync(file.path, file.data, { mode: old?.mode ?? 0o755 });
165
171
  log.success(`${file.path.split(/[\\/]/).pop()} ${c.dim(`${before} →`)} ${version}${old ? c.dim(` (the old one: ${file.path.split(/[\\/]/).pop()}.${before})`) : ''}`);
166
172
  }
167
- if (cleaned !== null) {
168
- writeFileSync(pluginsIni, cleaned);
169
- log.success(`Took amxts_host.amxx out of ${slashes(pluginsIni)}: the module loads it itself`);
170
- }
171
- if (!changing.length) return { ok: true, line: `already amxts ${version}${cleaned !== null ? ', plugins.ini cleaned' : ''}` };
173
+ if (cleaned !== null) writeFileSync(pluginsIni, cleaned);
174
+ for (const path of leftovers) rmSync(path, { force: true });
175
+ if (tidied) log.success(`Took an older amxts's plugin out of ${tidied}: amxts needs no plugin`);
176
+ if (!changing.length) return { ok: true, line: `already amxts ${version}${tidied ? ', the old plugin taken out' : ''}` };
172
177
  return { ok: true, line: `${names} ${before} → ${version} - restart the server to load it` };
173
178
  }