@amxts/cli 0.2.1 → 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.
- package/package.json +1 -1
- package/src/core.mjs +35 -12
- package/src/dev-feed.mjs +147 -0
- package/src/dev-panel.mjs +686 -0
- package/src/init.mjs +3 -2
- package/src/lock.mjs +94 -0
- package/src/main.mjs +77 -8
- package/src/modules.json +15 -0
- package/src/rcon.mjs +134 -0
- package/src/server-update.mjs +13 -8
- package/src/ui.mjs +134 -3
- package/src/update-check.mjs +64 -0
- package/src/upgrade.mjs +2 -0
- package/templates/module/_oxlintrc.json +5 -26
- package/templates/module/src/index.ts +1 -1
- package/templates/project/_oxlintrc.json +5 -26
- package/templates/project/plugins/hello.ts +14 -4
- package/templates/project/test/hello.test.ts +11 -0
package/src/init.mjs
CHANGED
|
@@ -23,7 +23,7 @@ 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
|
|
@@ -80,7 +80,8 @@ export async function initProject(options) {
|
|
|
80
80
|
const localCore = local ? await needLocalCore() : null;
|
|
81
81
|
const localModules = localCore?.api.localModules() ?? {};
|
|
82
82
|
|
|
83
|
-
|
|
83
|
+
banner(VERSION, 'a new project', { logo: true });
|
|
84
|
+
p.intro();
|
|
84
85
|
|
|
85
86
|
// 1. The package manager, first: the rest is said in its words.
|
|
86
87
|
const detected = packageManagerOfAgent() ?? 'npm';
|
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
|
@@ -11,13 +11,15 @@ import { existsSync } from 'node:fs';
|
|
|
11
11
|
import { join } from 'node:path';
|
|
12
12
|
import process from 'node:process';
|
|
13
13
|
import { GLOBAL_FLAGS, parseArgs } from './args.mjs';
|
|
14
|
-
import { needBun, needProject, projectCore, runTask, runTaskOrExit, VERSION } from './core.mjs';
|
|
14
|
+
import { needBun, needProject, projectCore, runTask, runTaskOrExit, setting, startTask, VERSION } from './core.mjs';
|
|
15
15
|
import { prepare, TARGETS } from './includes.mjs';
|
|
16
|
+
import { stopTree, takeLock } from './lock.mjs';
|
|
16
17
|
import { detectPackageManager, PACKAGE_MANAGERS, run } from './pm.mjs';
|
|
17
18
|
import { serverMismatch } from './server-update.mjs';
|
|
18
19
|
import { ensureServer } from './server.mjs';
|
|
19
20
|
import { SYSTEMS } from './system.mjs';
|
|
20
21
|
import { banner, c, CliError, closest, log, report } from './ui.mjs';
|
|
22
|
+
import { updateLine } from './update-check.mjs';
|
|
21
23
|
|
|
22
24
|
/** --os, for the commands that compile: the plugins are built for that system. */
|
|
23
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' };
|
|
@@ -28,6 +30,12 @@ function warnServer(core, os) {
|
|
|
28
30
|
if (line) log.warn(line);
|
|
29
31
|
}
|
|
30
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
|
+
|
|
31
39
|
/** --os as the build task takes it. */
|
|
32
40
|
function osArgs(values) {
|
|
33
41
|
if (values.os === undefined) return [];
|
|
@@ -84,25 +92,76 @@ export const COMMANDS = {
|
|
|
84
92
|
},
|
|
85
93
|
dev: {
|
|
86
94
|
description: 'Build, deploy to the server in AMXTS_SERVER, and again on every save',
|
|
87
|
-
usage: 'amxts dev [--os windows|linux] [--docker]',
|
|
95
|
+
usage: 'amxts dev [--os windows|linux] [--docker] [--no-tui] [--takeover]',
|
|
88
96
|
flags: {
|
|
89
97
|
os: OS_FLAG,
|
|
90
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' },
|
|
91
101
|
},
|
|
92
|
-
examples: ['amxts dev', 'amxts dev --os linux', 'amxts dev --docker'],
|
|
102
|
+
examples: ['amxts dev', 'amxts dev --os linux', 'amxts dev --docker', 'amxts dev --takeover'],
|
|
93
103
|
async run({ values }) {
|
|
94
104
|
if (values.docker && values.os && values.os !== 'linux')
|
|
95
105
|
throw new CliError('The Docker server is Linux: --docker builds for Linux.', 'Leave out --os.');
|
|
96
106
|
const core = await projectCore();
|
|
97
|
-
|
|
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);
|
|
98
114
|
// The container reads dist/ where it is: nothing to deploy, and AMXTS_SERVER is not asked.
|
|
99
115
|
if (!values.docker) {
|
|
100
|
-
await ensureServer(
|
|
116
|
+
await ensureServer(root, detectPackageManager());
|
|
101
117
|
warnServer(core, osArgs(values));
|
|
102
118
|
}
|
|
103
119
|
await prepare(core, { quiet: true });
|
|
104
|
-
|
|
105
|
-
|
|
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.');
|
|
106
165
|
},
|
|
107
166
|
},
|
|
108
167
|
build: {
|
|
@@ -118,6 +177,7 @@ export const COMMANDS = {
|
|
|
118
177
|
const os = osArgs(values);
|
|
119
178
|
const core = await projectCore();
|
|
120
179
|
banner(core.version, 'build');
|
|
180
|
+
nudge(core);
|
|
121
181
|
warnServer(core, os);
|
|
122
182
|
if (values.deploy) await ensureServer(needProject(), detectPackageManager());
|
|
123
183
|
await prepare(core, { quiet: true });
|
|
@@ -131,6 +191,7 @@ export const COMMANDS = {
|
|
|
131
191
|
async run() {
|
|
132
192
|
const core = await projectCore();
|
|
133
193
|
banner(core.version, 'typecheck');
|
|
194
|
+
nudge(core);
|
|
134
195
|
await prepare(core, { quiet: true });
|
|
135
196
|
if (runTask(core, 'typecheck') !== 0) {
|
|
136
197
|
throw new CliError('The plugins have type errors', 'They are listed above, file:line first.');
|
|
@@ -183,7 +244,15 @@ export const COMMANDS = {
|
|
|
183
244
|
description: 'Fetch the server\'s includes and write .amxts/tsconfig.json for the editor (build does it too)',
|
|
184
245
|
usage: 'amxts prepare',
|
|
185
246
|
async run() {
|
|
186
|
-
|
|
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
|
+
}
|
|
187
256
|
},
|
|
188
257
|
},
|
|
189
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/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
|
+
}
|
package/src/server-update.mjs
CHANGED
|
@@ -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
|
|
11
|
-
// `amxts_host.amxx` line
|
|
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 (
|
|
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
|
-
|
|
169
|
-
|
|
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
|
}
|
package/src/ui.mjs
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
// per step, and an error as one line with a hint under it. The stack of an
|
|
3
3
|
// error is printed only with --debug (or AMXTS_DEBUG=1).
|
|
4
4
|
import process from 'node:process';
|
|
5
|
+
import { pathToFileURL } from 'node:url';
|
|
5
6
|
|
|
6
7
|
/** Whether the output takes colors: a terminal, and NO_COLOR not set; FORCE_COLOR wins. */
|
|
7
8
|
function colorful(stream = process.stdout) {
|
|
@@ -66,9 +67,139 @@ export function report(error) {
|
|
|
66
67
|
if (debug() && error instanceof Error && error.stack) process.stderr.write(`${c.gray(error.stack)}\n`);
|
|
67
68
|
}
|
|
68
69
|
|
|
69
|
-
/**
|
|
70
|
-
export function
|
|
71
|
-
|
|
70
|
+
/** Whether a run is CI's: `CI` set to anything but `false`. */
|
|
71
|
+
export function isCI(env = process.env) {
|
|
72
|
+
return Boolean(env.CI) && env.CI !== 'false' && env.CI !== '0';
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* What characters the output can draw: `box` - any (the panel's spinner and
|
|
77
|
+
* bar); `bullet` - the logo's `{•}` but not the box characters, for Windows'
|
|
78
|
+
* own console, whose fonts may miss them; `ascii` - none of them, where the
|
|
79
|
+
* text is not UTF-8: the logo is `{*}`.
|
|
80
|
+
*/
|
|
81
|
+
export function logoGlyphs(env = process.env, platform = process.platform) {
|
|
82
|
+
if (platform === 'win32') {
|
|
83
|
+
const modern = env.WT_SESSION || env.TERM_PROGRAM || env.ConEmuTask || env.TERMINAL_EMULATOR === 'JetBrains-JediTerm' || /^(?:xterm|alacritty)/.test(env.TERM ?? '');
|
|
84
|
+
return modern ? 'box' : 'bullet';
|
|
85
|
+
}
|
|
86
|
+
if (!/utf-?8/i.test(env.LC_ALL || env.LC_CTYPE || env.LANG || 'UTF-8')) return 'ascii';
|
|
87
|
+
return env.TERM === 'linux' ? 'bullet' : 'box';
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* The logo's colour, amxts yellow (#F2C94C): in 24 bits where the terminal
|
|
92
|
+
* has them, plain yellow in one of 16 colours, none without a terminal, in CI
|
|
93
|
+
* or with NO_COLOR - FORCE_COLOR wins, as for every colour here.
|
|
94
|
+
* @param {{ tty?: boolean, env?: Record<string, string | undefined>, depth?: number }} [where] the output: a terminal, its environment and its colour depth in bits
|
|
95
|
+
* @returns {(text: string) => string} what paints a text
|
|
96
|
+
*/
|
|
97
|
+
export function logoPaint(where) {
|
|
98
|
+
return [text => text, text => `\x1B[33m${text}\x1B[39m`, text => `\x1B[38;2;242;201;76m${text}\x1B[39m`][brandDepth(where)];
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** How amxts yellow is drawn (logoPaint): 0 - not at all, 1 - plain yellow, 2 - in 24 bits. */
|
|
102
|
+
function brandDepth({ tty = Boolean(process.stdout.isTTY), env = process.env, depth = process.stdout.getColorDepth?.(env) ?? 4 } = {}) {
|
|
103
|
+
const forced = env.FORCE_COLOR && env.FORCE_COLOR !== '0';
|
|
104
|
+
if (!forced && (!tty || isCI(env) || env.NO_COLOR !== undefined || env.TERM === 'dumb')) return 0;
|
|
105
|
+
return depth >= 24 || env.COLORTERM === 'truecolor' || env.COLORTERM === '24bit' ? 2 : 1;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// The 16 colours as amxts draws them in 24 bits: green, red, yellow (amxts
|
|
109
|
+
// yellow), blue, magenta, cyan - lit, where a terminal's own may be pale.
|
|
110
|
+
const VIVID = { 31: '248;113;113', 32: '74;222;128', 33: '242;201;76', 34: '96;165;250', 35: '232;121;249', 36: '34;211;238' };
|
|
111
|
+
|
|
112
|
+
/** A line with its 16 colours drawn vivid, where the terminal has 24 bits; as it is elsewhere. */
|
|
113
|
+
export function vivid(line, where) {
|
|
114
|
+
if (brandDepth(where) !== 2) return line;
|
|
115
|
+
// eslint-disable-next-line no-control-regex
|
|
116
|
+
return line.replace(/\x1B\[(3[1-6])m/g, (_, code) => `\x1B[38;2;${VIVID[code]}m`);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* A text the terminal makes a link of (OSC 8), where colours are on; the text
|
|
121
|
+
* alone elsewhere.
|
|
122
|
+
*/
|
|
123
|
+
export function link(text, url, where) {
|
|
124
|
+
return brandDepth(where) ? `\x1B]8;;${url}\x1B\\${text}\x1B]8;;\x1B\\` : text;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* The link that opens a file at a line and column in the editor: Orca's own
|
|
129
|
+
* in Orca's terminal, else VS Code's, or Cursor's where the terminal or EDITOR
|
|
130
|
+
* is Cursor's. A `file://` link elsewhere would open a `.ts` in whatever the
|
|
131
|
+
* system opens it with - a video player, on some.
|
|
132
|
+
*/
|
|
133
|
+
export function editorLink(file, line, col, env = process.env) {
|
|
134
|
+
// Orca opens a file:// link in its own editor, the line and column after it.
|
|
135
|
+
if (env.TERM_PROGRAM === 'Orca') return `${pathToFileURL(file).href}:${line}:${col}`;
|
|
136
|
+
const scheme = /cursor/i.test(`${env.TERM_PROGRAM ?? ''} ${env.EDITOR ?? ''} ${env.VISUAL ?? ''}`) ? 'cursor' : 'vscode';
|
|
137
|
+
// `vscode://file/D:/x.ts:3:26`, `vscode://file/home/me/x.ts:3:26`: the path absolute.
|
|
138
|
+
const path = file.replace(/\\/g, '/');
|
|
139
|
+
return `${scheme}://file${path.startsWith('/') ? '' : '/'}${encodeURI(path)}:${line}:${col}`;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* A state as a word on a ground of its colour - ` READY ` on amxts yellow,
|
|
144
|
+
* ` BUILDING ` on cyan, ` ERROR ` on red; `[READY]` without colours.
|
|
145
|
+
* @param {string} text
|
|
146
|
+
* @param {'ready' | 'busy' | 'error'} tone
|
|
147
|
+
*/
|
|
148
|
+
export function badge(text, tone, where) {
|
|
149
|
+
const depth = brandDepth(where);
|
|
150
|
+
if (!depth) return `[${text}]`;
|
|
151
|
+
const ground = {
|
|
152
|
+
ready: depth === 2 ? '48;2;242;201;76;38;2;21;24;29' : '43;30',
|
|
153
|
+
busy: depth === 2 ? '48;2;34;211;238;38;2;21;24;29' : '46;30',
|
|
154
|
+
error: depth === 2 ? '48;2;239;68;68;97' : '41;97',
|
|
155
|
+
}[tone];
|
|
156
|
+
return `\x1B[1;${ground}m ${text} \x1B[0m`;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// The logo in a line: the braces and the dot.
|
|
160
|
+
const MARK = { box: '{•}', bullet: '{•}', ascii: '{*}' };
|
|
161
|
+
|
|
162
|
+
// The logo - the crosshair: its arms, the braces and the dot - in braille
|
|
163
|
+
// dots, four lines of them, the logo's SVG rasterised to 16 by 16.
|
|
164
|
+
export const LOGO = ['⠀⢀⡀⢸⡇⢀⡀⠀', '⣀⡿⠁⣀⣀⠈⢿⣀', '⠉⣷⡀⠉⠉⢀⣾⠉', '⠀⠈⠁⢸⡇⠈⠁⠀'];
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* The logo's four lines, painted. `wave`: a light that runs across it - its
|
|
168
|
+
* place, in columns - while something runs, in 24 bits; the plain colour
|
|
169
|
+
* without it. Where the font may have no braille, the mark in a line instead
|
|
170
|
+
* (`{•}` on the second).
|
|
171
|
+
* @param {number | null} [wave]
|
|
172
|
+
* @param {{ glyphs?: string, where?: object }} [options] how the output draws (logoGlyphs, and the output as logoPaint takes it)
|
|
173
|
+
* @returns {string[]} four lines, each as wide
|
|
174
|
+
*/
|
|
175
|
+
export function logoLines(wave = null, { glyphs = logoGlyphs(), where } = {}) {
|
|
176
|
+
if (glyphs !== 'box') return [' ', logoPaint(where)(MARK[glyphs]), ' ', ' '];
|
|
177
|
+
const depth = brandDepth(where);
|
|
178
|
+
if (depth < 2 || wave === null) return LOGO.map(line => logoPaint(where)(line));
|
|
179
|
+
// From a darker gold to a pale one, amxts yellow between.
|
|
180
|
+
// One spot of light, gone a few columns away from its place.
|
|
181
|
+
const shade = column => Math.exp(-((column - wave) ** 2) / 4);
|
|
182
|
+
const mix = light => [[150, 110, 30], [255, 236, 150]].reduce((low, high) => low.map((each, index) => Math.round(each + (high[index] - each) * light)));
|
|
183
|
+
return LOGO.map(line => `${[...line].map((dot, column) => `\x1B[38;2;${mix(shade(column)).join(';')}m${dot}`).join('')}\x1B[39m`);
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* The line a command starts with: the logo, `{•} amxts 0.2.4 · build`. With
|
|
188
|
+
* `logo` (dev, a new project) the logo in its four lines, the words beside the
|
|
189
|
+
* second.
|
|
190
|
+
* @param {string} version
|
|
191
|
+
* @param {string} [what]
|
|
192
|
+
* @param {{ logo?: boolean, glyphs?: string, where?: object }} [options] how the output draws (logoGlyphs, and the output as logoPaint takes it)
|
|
193
|
+
*/
|
|
194
|
+
export function bannerText(version, what, { logo = false, glyphs = logoGlyphs(), where } = {}) {
|
|
195
|
+
const words = `${c.bold('amxts')} ${c.dim(version)}${what ? c.dim(` · ${what}`) : ''}`;
|
|
196
|
+
if (!logo || glyphs !== 'box') return `${logoPaint(where)(MARK[glyphs] ?? MARK.bullet)} ${words}`;
|
|
197
|
+
return logoLines(null, { glyphs, where }).map((line, index) => (index === 1 ? `${line} ${words}` : line)).join('\n');
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/** Prints the line a command starts with (bannerText). */
|
|
201
|
+
export function banner(version, what, options) {
|
|
202
|
+
console.log(bannerText(version, what, options));
|
|
72
203
|
}
|
|
73
204
|
|
|
74
205
|
/** The closest of `known` to `given`, when it is close enough to be a typo. */
|