@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.
- 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-module.mjs +7 -5
- package/src/init.mjs +10 -7
- package/src/lock.mjs +94 -0
- package/src/main.mjs +88 -12
- package/src/modules.json +15 -0
- package/src/pm.mjs +11 -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/module/test/tsconfig.json +1 -1
- package/templates/module/test/{{name}}.test.ts +5 -0
- package/templates/project/_oxlintrc.json +5 -26
- package/templates/project/plugins/hello.ts +14 -4
- package/templates/project/test/hello.test.ts +21 -0
- package/templates/project/test/tsconfig.json +1 -1
package/src/init-module.mjs
CHANGED
|
@@ -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:
|
|
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
|
-
|
|
89
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
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(
|
|
116
|
+
await ensureServer(root, detectPackageManager());
|
|
99
117
|
warnServer(core, osArgs(values));
|
|
100
118
|
}
|
|
101
119
|
await prepare(core, { quiet: true });
|
|
102
|
-
|
|
103
|
-
|
|
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 [
|
|
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
|
|
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
|
-
|
|
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
|
+
}
|
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
|
}
|