@amxts/cli 0.1.0 → 0.2.1
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/README.md +1 -0
- package/package.json +1 -1
- package/src/catalog.mjs +1 -1
- package/src/core.mjs +37 -12
- package/src/includes.mjs +2 -1
- package/src/info.mjs +10 -1
- package/src/init-module.mjs +8 -6
- package/src/init.mjs +39 -9
- package/src/main.mjs +39 -6
- package/src/module.mjs +7 -3
- package/src/pm.mjs +74 -6
- package/src/registry.mjs +83 -0
- package/src/server-update.mjs +173 -0
- package/src/server.mjs +56 -10
- package/src/upgrade.mjs +266 -0
- package/templates/module/README.md +1 -1
- package/templates/module/README.ru.md +1 -1
- package/templates/module/playground/plugins/welcome.ts +1 -1
- package/templates/module/test/tsconfig.json +1 -1
- package/templates/module/test/{{name}}.test.ts +5 -0
- package/templates/project/README.md +2 -1
- package/templates/project/plugins/hello.ts +32 -20
- package/templates/project/test/hello.test.ts +10 -0
- package/templates/project/test/tsconfig.json +1 -1
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
// The server step of `amxts upgrade`: the server in AMXTS_SERVER gets the
|
|
2
|
+
// module of the project's core - and the compiler for `.ts` written on the
|
|
3
|
+
// server, where it has one - from that version's GitHub Release.
|
|
4
|
+
//
|
|
5
|
+
// The release's manifest (amxts-<system>.json) names each file with its
|
|
6
|
+
// sha256. Nothing on the server changes until every file it needs is
|
|
7
|
+
// downloaded and checked, and none of the files it replaces is in use: a
|
|
8
|
+
// running Windows server holds its module, which cannot be written then.
|
|
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.
|
|
12
|
+
//
|
|
13
|
+
// Also the check `amxts dev` and `build` start with: the release the
|
|
14
|
+
// server's module is of, read from the module file (the core's
|
|
15
|
+
// moduleVersion()), against the project's core.
|
|
16
|
+
import { Buffer } from 'node:buffer';
|
|
17
|
+
import { createHash } from 'node:crypto';
|
|
18
|
+
import { closeSync, existsSync, openSync, readFileSync, renameSync, rmSync, statSync, writeFileSync } from 'node:fs';
|
|
19
|
+
import { dirname, join, resolve } from 'node:path';
|
|
20
|
+
import { setting } from './core.mjs';
|
|
21
|
+
import { serverFolderOrNull } from './server.mjs';
|
|
22
|
+
import { c, CliError, log } from './ui.mjs';
|
|
23
|
+
|
|
24
|
+
/** A path as the messages write it. */
|
|
25
|
+
const slashes = path => path.replace(/\\/g, '/');
|
|
26
|
+
|
|
27
|
+
const sha256 = data => createHash('sha256').update(data).digest('hex');
|
|
28
|
+
|
|
29
|
+
/** A file of a release: a URL's, or a folder's (AMXTS_RELEASE_URL may name one). */
|
|
30
|
+
export async function releaseFile(base, name) {
|
|
31
|
+
if (!/^https?:\/\//.test(base)) return readFileSync(join(base, name));
|
|
32
|
+
const url = new URL(name, base.endsWith('/') ? base : `${base}/`);
|
|
33
|
+
const response = await fetch(url);
|
|
34
|
+
if (!response.ok) throw new CliError(`${url} answered ${response.status}`, 'The release may not be published yet, or the network is down.');
|
|
35
|
+
return Buffer.from(await response.arrayBuffer());
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Whether a file can be written: false while a process holds it - a module a running server loaded. */
|
|
39
|
+
export function writable(path) {
|
|
40
|
+
try {
|
|
41
|
+
closeSync(openSync(path, 'r+'));
|
|
42
|
+
return true;
|
|
43
|
+
} catch (error) {
|
|
44
|
+
// EBUSY: a module Windows has loaded; ETXTBSY: a tool Linux is running.
|
|
45
|
+
if (['EBUSY', 'ETXTBSY', 'EPERM', 'EACCES'].includes(error.code)) return false;
|
|
46
|
+
throw error;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** AMX Mod X's plugins.ini with its `amxts_host.amxx` lines left out; null when it has none. */
|
|
51
|
+
export function withoutHost(text) {
|
|
52
|
+
const lines = text.split(/(?<=\n)/);
|
|
53
|
+
const kept = lines.filter(line => !/^\s*amxts_host\.amxx\b/i.test(line));
|
|
54
|
+
return kept.length === lines.length ? null : kept.join('');
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** The settings the core's serverSystem() reads, as the build sees them: the environment, else .env. */
|
|
58
|
+
function serverEnv(dir) {
|
|
59
|
+
return { AMXTS_SERVER: setting(dir, 'AMXTS_SERVER'), AMXTS_SERVER_OS: setting(dir, 'AMXTS_SERVER_OS') };
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* The server's game folder and the files of `api`'s release for its system, or null when .env names no server that is there.
|
|
64
|
+
* @param {typeof import('@amxts/core/cli-api')} api
|
|
65
|
+
* @param {string} dir the project
|
|
66
|
+
* @param {string[]} [argv] --os, when the command was given one
|
|
67
|
+
*/
|
|
68
|
+
function serverOf(api, dir, argv = []) {
|
|
69
|
+
const amxts = serverFolderOrNull(setting(dir, 'AMXTS_SERVER'));
|
|
70
|
+
if (!amxts || !existsSync(amxts)) return null;
|
|
71
|
+
const { system } = api.serverSystem(argv, serverEnv(dir));
|
|
72
|
+
return { amxts, game: resolve(amxts, '..', '..'), system, release: api.release(system) };
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* The line `amxts dev` and `build` start with when the server's module is of
|
|
77
|
+
* another release than the project's core; null when it is the same, or
|
|
78
|
+
* there is no module to read.
|
|
79
|
+
* @param {import('./core.mjs').Core} core
|
|
80
|
+
* @param {string} dir the project
|
|
81
|
+
* @param {string[]} [argv] --os, when the command was given one
|
|
82
|
+
*/
|
|
83
|
+
export function serverMismatch(core, dir, argv = []) {
|
|
84
|
+
const server = serverOf(core.api, dir, argv);
|
|
85
|
+
const running = server && core.api.moduleVersion(join(server.game, server.release.files[0].path));
|
|
86
|
+
return running && running !== core.version ? `the server runs amxts ${running}, this project ${core.version} - run amxts upgrade` : null;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* @typedef {object} ServerOutcome
|
|
91
|
+
* @property {boolean} ok whether the server is as it should be, or nothing was asked of it
|
|
92
|
+
* @property {string} line what happened, for the summary
|
|
93
|
+
*/
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* The server step.
|
|
97
|
+
* @param {typeof import('@amxts/core/cli-api')} api the project's core, of the version the server moves to
|
|
98
|
+
* @param {string} dir the project
|
|
99
|
+
* @param {{ dryRun?: boolean, canWrite?: typeof writable }} [options] `canWrite`: what the tests replace
|
|
100
|
+
* @returns {Promise<ServerOutcome>} what happened
|
|
101
|
+
*/
|
|
102
|
+
export async function updateServer(api, dir, { dryRun = false, canWrite = writable } = {}) {
|
|
103
|
+
const named = setting(dir, 'AMXTS_SERVER');
|
|
104
|
+
const version = api.version;
|
|
105
|
+
if (!named) {
|
|
106
|
+
const image = api.release('linux').image;
|
|
107
|
+
log.step('Server: no AMXTS_SERVER in .env');
|
|
108
|
+
return { ok: true, line: `none in .env; a server in Docker: docker pull ${image}` };
|
|
109
|
+
}
|
|
110
|
+
const server = serverOf(api, dir);
|
|
111
|
+
if (!server) {
|
|
112
|
+
log.step(`Server: ${slashes(named)} is not there`);
|
|
113
|
+
return { ok: false, line: `${slashes(named)} is not there: nothing was updated` };
|
|
114
|
+
}
|
|
115
|
+
const { game, system, release } = server;
|
|
116
|
+
const [module] = release.files;
|
|
117
|
+
const modulePath = join(game, module.path);
|
|
118
|
+
log.step(`Server: ${slashes(game)} ${c.dim(`(${system})`)}`);
|
|
119
|
+
if (!existsSync(modulePath)) {
|
|
120
|
+
return { ok: false, line: `there is no ${module.asset} in ${slashes(dirname(modulePath))}: install the server kit of amxts ${version}` };
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const before = api.moduleVersion(modulePath) ?? 'old';
|
|
124
|
+
const manifest = JSON.parse((await releaseFile(release.url, release.manifest)).toString('utf8'));
|
|
125
|
+
if (manifest.version !== version) throw new CliError(`${release.manifest} is of amxts ${manifest.version}, not ${version}`, `It was read from ${release.url}.`);
|
|
126
|
+
const files = release.files
|
|
127
|
+
.filter(file => !file.tool || existsSync(join(game, file.path)))
|
|
128
|
+
.map((file) => {
|
|
129
|
+
const entry = manifest.files.find(one => one.name === file.asset);
|
|
130
|
+
if (!entry) throw new CliError(`${release.manifest} lists no ${file.asset}`, `It was read from ${release.url}.`);
|
|
131
|
+
const path = join(game, file.path);
|
|
132
|
+
return { ...file, path, sha256: entry.sha256, same: existsSync(path) && sha256(readFileSync(path)) === entry.sha256 };
|
|
133
|
+
});
|
|
134
|
+
const changing = files.filter(file => !file.same);
|
|
135
|
+
const pluginsIni = join(game, 'addons', 'amxmodx', 'configs', 'plugins.ini');
|
|
136
|
+
const cleaned = existsSync(pluginsIni) ? withoutHost(readFileSync(pluginsIni, 'utf8')) : null;
|
|
137
|
+
const names = changing.map(file => file.path.split(/[\\/]/).pop()).join(', ');
|
|
138
|
+
|
|
139
|
+
if (dryRun) {
|
|
140
|
+
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)}`);
|
|
142
|
+
return { ok: true, line: changing.length ? `${names} would go from amxts ${before} to ${version}` : `already amxts ${version}` };
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
const busy = changing.filter(file => existsSync(file.path) && !canWrite(file.path));
|
|
146
|
+
if (busy.length) {
|
|
147
|
+
log.warn(`${busy.map(file => slashes(file.path)).join(', ')} is in use: the server is running`);
|
|
148
|
+
log.hint(`Stop the server, then run: amxts upgrade --server-only`);
|
|
149
|
+
return { ok: false, line: `in use - stop the server and run amxts upgrade --server-only; nothing on it was changed` };
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// Every file downloaded and checked before the first is replaced.
|
|
153
|
+
const downloads = await Promise.all(changing.map(async (file) => {
|
|
154
|
+
const data = await releaseFile(release.url, file.asset);
|
|
155
|
+
if (sha256(data) !== file.sha256) throw new CliError(`${file.asset} from ${release.url} is not the one ${release.manifest} lists (sha256)`, 'Nothing on the server was changed. Run it again; if it says the same, the download is broken.');
|
|
156
|
+
return { ...file, data };
|
|
157
|
+
}));
|
|
158
|
+
for (const file of downloads) {
|
|
159
|
+
const old = existsSync(file.path) ? statSync(file.path) : null;
|
|
160
|
+
if (old) {
|
|
161
|
+
rmSync(`${file.path}.${before}`, { force: true });
|
|
162
|
+
renameSync(file.path, `${file.path}.${before}`);
|
|
163
|
+
}
|
|
164
|
+
writeFileSync(file.path, file.data, { mode: old?.mode ?? 0o755 });
|
|
165
|
+
log.success(`${file.path.split(/[\\/]/).pop()} ${c.dim(`${before} →`)} ${version}${old ? c.dim(` (the old one: ${file.path.split(/[\\/]/).pop()}.${before})`) : ''}`);
|
|
166
|
+
}
|
|
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' : ''}` };
|
|
172
|
+
return { ok: true, line: `${names} ${before} → ${version} - restart the server to load it` };
|
|
173
|
+
}
|
package/src/server.mjs
CHANGED
|
@@ -3,12 +3,48 @@
|
|
|
3
3
|
// ask the same when it is not set - in a terminal. Elsewhere (CI, a pipe)
|
|
4
4
|
// nothing is asked, and the build says it is missing.
|
|
5
5
|
import { existsSync, readFileSync, writeFileSync } from 'node:fs';
|
|
6
|
-
import { join } from 'node:path';
|
|
6
|
+
import { basename, join, resolve } from 'node:path';
|
|
7
7
|
import process from 'node:process';
|
|
8
8
|
import * as p from '@clack/prompts';
|
|
9
9
|
import { setting } from './core.mjs';
|
|
10
10
|
import { runScript } from './pm.mjs';
|
|
11
|
-
import { c } from './ui.mjs';
|
|
11
|
+
import { c, CliError } from './ui.mjs';
|
|
12
|
+
|
|
13
|
+
// The folders a server path may name above its addons/amxts, by what is below
|
|
14
|
+
// them: hlds's own (cstrike/addons), the game's (addons).
|
|
15
|
+
const ABOVE_ADDONS = [join('cstrike', 'addons'), 'addons'];
|
|
16
|
+
|
|
17
|
+
/** A path as .env and the messages write it: with forward slashes. */
|
|
18
|
+
const slashes = path => path.replace(/\\/g, '/');
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* The server's addons/amxts folder from a path a person gave: that folder
|
|
22
|
+
* itself, or the hlds folder, cstrike or cstrike/addons above it - as the
|
|
23
|
+
* core's build reads AMXTS_SERVER. A path that is not there yet is taken as
|
|
24
|
+
* it is, and so is a folder named amxts or holding the module's plugins.ini.
|
|
25
|
+
* @param {string} path
|
|
26
|
+
* @returns {string} the folder; '' for ''
|
|
27
|
+
* @throws {CliError} for a folder that is there and none of them, naming where it looked
|
|
28
|
+
*/
|
|
29
|
+
export function serverFolder(path) {
|
|
30
|
+
if (!path) return '';
|
|
31
|
+
const dir = resolve(path);
|
|
32
|
+
if (!existsSync(dir) || basename(dir).toLowerCase() === 'amxts' || existsSync(join(dir, 'plugins.ini'))) return path;
|
|
33
|
+
if (basename(dir).toLowerCase() === 'addons') return join(dir, 'amxts');
|
|
34
|
+
const addons = ABOVE_ADDONS.map(below => join(dir, below)).find(folder => existsSync(folder));
|
|
35
|
+
if (addons) return join(addons, 'amxts');
|
|
36
|
+
const looked = [...ABOVE_ADDONS, 'plugins.ini'].map(below => slashes(join(dir, below)));
|
|
37
|
+
throw new CliError(`${slashes(path)} is not a server: there is no ${looked.join(', ')}`, 'Give the server\'s folder (where hlds is), its cstrike, or cstrike/addons/amxts.');
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** The server's addons/amxts from a path, or null when the path is no server: for what only reads it. */
|
|
41
|
+
export function serverFolderOrNull(path) {
|
|
42
|
+
try {
|
|
43
|
+
return serverFolder(path);
|
|
44
|
+
} catch {
|
|
45
|
+
return null;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
12
48
|
|
|
13
49
|
/** Whether a command may ask: a terminal, and not CI. */
|
|
14
50
|
export function canAsk() {
|
|
@@ -16,18 +52,28 @@ export function canAsk() {
|
|
|
16
52
|
}
|
|
17
53
|
|
|
18
54
|
/**
|
|
19
|
-
* Asks for the server
|
|
20
|
-
* not taken. The
|
|
55
|
+
* Asks for the server: its folder, its cstrike or its addons/amxts.
|
|
56
|
+
* `required`: an empty answer is not taken. The server's addons/amxts
|
|
57
|
+
* (serverFolder), '' for none, or clack's cancel symbol on Ctrl+C.
|
|
21
58
|
* @param {string} pm the project's package manager, for the words
|
|
22
59
|
* @param {{ required?: boolean }} [options]
|
|
23
60
|
*/
|
|
24
|
-
export function askServer(pm, { required = false } = {}) {
|
|
25
|
-
|
|
26
|
-
message: `Where is the server? ${c.dim(`its addons/amxts
|
|
27
|
-
placeholder: process.platform === 'win32' ? 'D:/hlds
|
|
61
|
+
export async function askServer(pm, { required = false } = {}) {
|
|
62
|
+
const answer = await p.text({
|
|
63
|
+
message: `Where is the server? ${c.dim(`its folder or its addons/amxts, for ${runScript(pm, 'dev')}${required ? '' : ' - empty to skip'}`)}`,
|
|
64
|
+
placeholder: process.platform === 'win32' ? 'D:/hlds' : '/srv/hlds',
|
|
28
65
|
defaultValue: '',
|
|
29
|
-
validate:
|
|
66
|
+
validate: (value) => {
|
|
67
|
+
if (!value?.trim()) return required ? 'The plugins are deployed there: the server\'s folder' : undefined;
|
|
68
|
+
try {
|
|
69
|
+
serverFolder(value.trim());
|
|
70
|
+
return undefined;
|
|
71
|
+
} catch (error) {
|
|
72
|
+
return `${error.message}. ${error.hint}`;
|
|
73
|
+
}
|
|
74
|
+
},
|
|
30
75
|
});
|
|
76
|
+
return p.isCancel(answer) ? answer : serverFolder(answer.trim());
|
|
31
77
|
}
|
|
32
78
|
|
|
33
79
|
/** The warning for a server folder that is not there. */
|
|
@@ -67,6 +113,6 @@ export async function ensureServer(root, pm) {
|
|
|
67
113
|
}
|
|
68
114
|
saveServer(root, server);
|
|
69
115
|
process.env.AMXTS_SERVER = server;
|
|
70
|
-
p.log.success(`Wrote AMXTS_SERVER=${server
|
|
116
|
+
p.log.success(`Wrote AMXTS_SERVER=${slashes(server)} to .env`);
|
|
71
117
|
if (!existsSync(server)) p.log.warn(missingServer(server));
|
|
72
118
|
}
|
package/src/upgrade.mjs
ADDED
|
@@ -0,0 +1,266 @@
|
|
|
1
|
+
// `amxts upgrade`: a project moved to another amxts release in one command.
|
|
2
|
+
//
|
|
3
|
+
// amxts upgrade [--to <version>] [--no-server] [--server-only] [--dry-run]
|
|
4
|
+
//
|
|
5
|
+
// 1. The packages: the core to the latest version on the registry npm is set
|
|
6
|
+
// to, or to --to; every other @amxts/ package in package.json - the
|
|
7
|
+
// official modules, the command - to its own newest version that works
|
|
8
|
+
// with that core (registry.mjs), in the style the project writes its
|
|
9
|
+
// ranges (`^0.1.0` → `^0.2.0`); then the project's own package manager
|
|
10
|
+
// installs.
|
|
11
|
+
// 2. The code: the new core's rewrites (its `upgrade` task), each change listed.
|
|
12
|
+
// 3. The build: `amxts build`.
|
|
13
|
+
// 4. The server in AMXTS_SERVER: its module, and its compiler where it has
|
|
14
|
+
// one, from the release of the new version (server-update.mjs).
|
|
15
|
+
// 5. A summary: the versions, the files rewritten, what to check by hand and
|
|
16
|
+
// the server's result.
|
|
17
|
+
//
|
|
18
|
+
// A command of one release drives cores of that release only, so once the
|
|
19
|
+
// packages are installed a command of another version hands the rest to the
|
|
20
|
+
// project's new one: `amxts upgrade --to <version>` again, with the versions
|
|
21
|
+
// it started from in AMXTS_UPGRADE_FROM.
|
|
22
|
+
import { spawnSync } from 'node:child_process';
|
|
23
|
+
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
|
24
|
+
import { createRequire } from 'node:module';
|
|
25
|
+
import { tmpdir } from 'node:os';
|
|
26
|
+
import { join } from 'node:path';
|
|
27
|
+
import process from 'node:process';
|
|
28
|
+
import { coreDirFrom, loadCore, needProject, readJson, runTask, satisfies, VERSION } from './core.mjs';
|
|
29
|
+
import { prepare } from './includes.mjs';
|
|
30
|
+
import { commandLine, detectPackageManager, installArgs, installLinked, linkedFolders } from './pm.mjs';
|
|
31
|
+
import { CORE, npmRegistry, versionsFor } from './registry.mjs';
|
|
32
|
+
import { updateServer } from './server-update.mjs';
|
|
33
|
+
import { banner, c, CliError, log } from './ui.mjs';
|
|
34
|
+
|
|
35
|
+
const VERSION_TEXT = /^v?(\d+\.\d+\.\d+(?:-[\d.a-z-]+)?)$/i;
|
|
36
|
+
|
|
37
|
+
/** A spec upgrade moves: a version, with `^`, `~`, `=` or `>=` before it. */
|
|
38
|
+
const MOVABLE = /^([~^]|>?=)?\d+\.\d+\.\d+(?:-[\d.a-z-]+)?$/i;
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* A spec at another version, in the style it was written: `^0.1.0` →
|
|
42
|
+
* `^0.2.0`, `~0.1.0` → `~0.2.0`, `0.1.0` → `0.2.0`. Null for a spec upgrade
|
|
43
|
+
* leaves as it is: a folder (`file:`, `link:`), a tag, a range of its own.
|
|
44
|
+
* @param {string} spec
|
|
45
|
+
* @param {string} version
|
|
46
|
+
*/
|
|
47
|
+
export function bumpSpec(spec, version) {
|
|
48
|
+
const match = MOVABLE.exec(spec.trim());
|
|
49
|
+
return match ? `${match[1] ?? ''}${version}` : null;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* @typedef {object} Bump
|
|
54
|
+
* @property {string} name the package
|
|
55
|
+
* @property {string} spec as package.json has it
|
|
56
|
+
* @property {string | null} version the version it goes to; null when it is left as it is
|
|
57
|
+
* @property {string | null} to the spec at that version; null when it is left as it is
|
|
58
|
+
*/
|
|
59
|
+
|
|
60
|
+
/** The @amxts/ packages of a project's package.json, with their specs. */
|
|
61
|
+
function amxtsPackages(pkg) {
|
|
62
|
+
return Object.entries({ ...pkg.dependencies, ...pkg.devDependencies }).filter(([name]) => name.startsWith('@amxts/')).map(([name, spec]) => ({ name, spec: String(spec) }));
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* The @amxts/ packages of a project's package.json, and their specs at the versions they go to.
|
|
67
|
+
* @param {Record<string, any>} pkg
|
|
68
|
+
* @param {Record<string, string | null>} versions each package's new version, by name
|
|
69
|
+
* @returns {Bump[]} the packages
|
|
70
|
+
*/
|
|
71
|
+
export function packageBumps(pkg, versions) {
|
|
72
|
+
return amxtsPackages(pkg).map(({ name, spec }) => {
|
|
73
|
+
const to = versions[name] ? bumpSpec(spec, versions[name]) : null;
|
|
74
|
+
return { name, spec, version: to && versions[name], to };
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** package.json's text with the specs moved, everything else as it was written. */
|
|
79
|
+
export function writeBumps(text, bumps) {
|
|
80
|
+
return bumps.filter(bump => bump.to && bump.to !== bump.spec).reduce((out, bump) => {
|
|
81
|
+
const escape = value => value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
82
|
+
return out.replace(new RegExp(`("${escape(bump.name)}"\\s*:\\s*")${escape(bump.spec)}"`, 'g'), `$1${bump.to}"`);
|
|
83
|
+
}, text);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** The version of a package the project has installed, or null. */
|
|
87
|
+
function installedVersion(dir, name) {
|
|
88
|
+
return readJson(join(dir, 'node_modules', name, 'package.json'))?.version ?? null;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** Runs the project's install, quietly, with a line before and after; its output when it fails. */
|
|
92
|
+
async function installProject(dir, pm) {
|
|
93
|
+
const argv = installArgs(pm);
|
|
94
|
+
log.step(`Installing with ${pm}`);
|
|
95
|
+
const result = await installLinked(argv, linkedFolders(dir), { cwd: dir, quiet: true });
|
|
96
|
+
if (result.code !== 0) {
|
|
97
|
+
throw new CliError(`${commandLine(argv)} failed`, result.output.trim().split('\n').slice(-15).join('\n'));
|
|
98
|
+
}
|
|
99
|
+
log.success(`Installed with ${pm}`);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** The command a project's core runs - `npx amxts` - by its folder, or null. */
|
|
103
|
+
function projectCommand(coreDir) {
|
|
104
|
+
try {
|
|
105
|
+
return join(createRequire(join(coreDir, 'package.json')).resolve('@amxts/cli/package.json'), '..');
|
|
106
|
+
} catch {
|
|
107
|
+
return null;
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* The code step: the core's rewrites, listed as they are made; with
|
|
113
|
+
* `dryRun`, listed only. What they changed and left, from the task's report.
|
|
114
|
+
*/
|
|
115
|
+
function rewriteCode(core, dryRun) {
|
|
116
|
+
const dir = mkdtempSync(join(tmpdir(), 'amxts-upgrade-'));
|
|
117
|
+
try {
|
|
118
|
+
const report = join(dir, 'report.json');
|
|
119
|
+
if (runTask(core, 'upgrade', ['--report', report, ...(dryRun ? ['--dry-run'] : [])]) !== 0) {
|
|
120
|
+
throw new CliError('The code was not upgraded', 'The error is above.');
|
|
121
|
+
}
|
|
122
|
+
return JSON.parse(readFileSync(report, 'utf8'));
|
|
123
|
+
} finally {
|
|
124
|
+
rmSync(dir, { recursive: true, force: true });
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* @typedef {object} Outcome
|
|
130
|
+
* @property {string} version the release the project moves to: the core's version
|
|
131
|
+
* @property {{ name: string, from: string | null, version: string | null, to: string | null, spec: string }[]} packages each @amxts/ package, and the version and spec it goes to; null when left as it is
|
|
132
|
+
* @property {{ files: string[], left: { file: string, line: number, why: string }[] } | null} code what the rewrites changed and left; null when they did not run
|
|
133
|
+
* @property {'built' | 'failed' | null} build how the build went; null when it did not run
|
|
134
|
+
* @property {import('./server-update.mjs').ServerOutcome | null} server the server step's result; null with --no-server
|
|
135
|
+
* @property {boolean} dryRun whether nothing was changed
|
|
136
|
+
*/
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* What `amxts upgrade` prints last.
|
|
140
|
+
* @param {Outcome} outcome
|
|
141
|
+
* @returns {string[]} the lines
|
|
142
|
+
*/
|
|
143
|
+
export function summary(outcome) {
|
|
144
|
+
const lines = [];
|
|
145
|
+
const would = outcome.dryRun ? 'would be ' : '';
|
|
146
|
+
const moved = outcome.packages.filter(each => each.to);
|
|
147
|
+
const width = Math.max(0, ...moved.map(each => each.name.length));
|
|
148
|
+
lines.push(c.bold(outcome.dryRun ? `Dry run: amxts ${outcome.version}, nothing was changed` : `Upgraded to amxts ${outcome.version}`));
|
|
149
|
+
for (const each of moved) lines.push(` ${each.name.padEnd(width)} ${each.from === each.version ? `${each.version} ${c.dim('(already)')}` : `${each.from ?? each.spec} ${c.dim('→')} ${each.version}`}`);
|
|
150
|
+
for (const each of outcome.packages.filter(one => !one.to)) lines.push(` ${each.name.padEnd(width)} ${each.spec} ${c.dim('(left as it is)')}`);
|
|
151
|
+
if (outcome.code) {
|
|
152
|
+
lines.push(outcome.code.files.length ? ` ${c.green('✔')} ${would}rewritten: ${outcome.code.files.join(', ')}` : ` ${c.green('✔')} the code already uses this API`);
|
|
153
|
+
if (outcome.code.left.length) {
|
|
154
|
+
lines.push(` ${c.yellow('▲')} to check by hand:`);
|
|
155
|
+
for (const each of outcome.code.left) lines.push(` ${c.dim(`${each.file}:${each.line}`)} ${each.why}`);
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
if (outcome.build) lines.push(outcome.build === 'built' ? ` ${c.green('✔')} built` : ` ${c.red('✖')} the build failed: see above`);
|
|
159
|
+
if (outcome.server) lines.push(` ${outcome.server.ok ? c.green('✔') : c.yellow('▲')} server: ${outcome.server.line}`);
|
|
160
|
+
return lines;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* `amxts upgrade`.
|
|
165
|
+
* @param {{ to?: string, server?: boolean, serverOnly?: boolean, dryRun?: boolean }} options
|
|
166
|
+
* @param {{ dir?: string, registry?: import('./registry.mjs').Registry, install?: typeof installProject }} [deps] what the tests replace
|
|
167
|
+
* @returns {Promise<Outcome | null>} what was done; null when it was handed to the project's command
|
|
168
|
+
*/
|
|
169
|
+
export async function upgrade({ to, server = true, serverOnly = false, dryRun = false }, { dir = needProject(), registry = npmRegistry(dir), install = installProject } = {}) {
|
|
170
|
+
const handedFrom = process.env.AMXTS_UPGRADE_FROM ? JSON.parse(process.env.AMXTS_UPGRADE_FROM) : null;
|
|
171
|
+
if (!handedFrom) banner(VERSION, dryRun ? 'upgrade --dry-run' : 'upgrade');
|
|
172
|
+
if (to !== undefined && !VERSION_TEXT.test(to)) throw new CliError(`--to ${to}: a version, such as 0.2.0`);
|
|
173
|
+
|
|
174
|
+
if (serverOnly) {
|
|
175
|
+
const core = await loadCore(coreDirFrom(dir) ?? needCore());
|
|
176
|
+
/** @type {Outcome} */
|
|
177
|
+
const outcome = { version: core.version, packages: [], code: null, build: null, server: await updateServer(core.api, dir, { dryRun }), dryRun };
|
|
178
|
+
console.log(['', ...summary(outcome)].join('\n'));
|
|
179
|
+
return outcome;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
const pkg = readJson(join(dir, 'package.json')) ?? {};
|
|
183
|
+
const { version, versions } = handedFrom ? installed(dir, pkg, VERSION_TEXT.exec(to)[1]) : await newest(registry, dir, pkg, to ? VERSION_TEXT.exec(to)[1] : 'latest');
|
|
184
|
+
const bumps = packageBumps(pkg, versions);
|
|
185
|
+
const from = handedFrom ?? Object.fromEntries(bumps.map(bump => [bump.name, installedVersion(dir, bump.name)]));
|
|
186
|
+
const moving = bumps.filter(bump => bump.to && (bump.to !== bump.spec || from[bump.name] !== bump.version));
|
|
187
|
+
|
|
188
|
+
if (!handedFrom) {
|
|
189
|
+
log.step(`Packages: amxts ${version}${to ? '' : c.dim(' (latest)')}`);
|
|
190
|
+
for (const bump of bumps) log.info(`${bump.name} ${bump.spec} ${!bump.to ? c.dim('(left as it is)') : moving.includes(bump) ? `${c.dim('→')} ${bump.to}` : c.dim('(already)')}`);
|
|
191
|
+
if (!dryRun && moving.length) {
|
|
192
|
+
const path = join(dir, 'package.json');
|
|
193
|
+
writeFileSync(path, writeBumps(readFileSync(path, 'utf8'), bumps));
|
|
194
|
+
await install(dir, detectPackageManager(dir));
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// A core this command cannot drive is handed, with the rest, to the command it came with.
|
|
199
|
+
const coreDir = coreDirFrom(dir);
|
|
200
|
+
const coreVersion = coreDir ? readJson(join(coreDir, 'package.json'))?.version : null;
|
|
201
|
+
const command = !dryRun && coreVersion && !satisfies(coreVersion) ? projectCommand(coreDir) : null;
|
|
202
|
+
const commandVersion = command ? readJson(join(command, 'package.json'))?.version : null;
|
|
203
|
+
if (command && commandVersion && commandVersion !== VERSION) {
|
|
204
|
+
log.step(`Going on with the project's amxts ${commandVersion}`);
|
|
205
|
+
const args = [join(command, 'bin', 'amxts.mjs'), 'upgrade', '--to', version, ...(server ? [] : ['--no-server'])];
|
|
206
|
+
const result = spawnSync(process.execPath, args, { cwd: dir, stdio: 'inherit', env: { ...process.env, AMXTS_UPGRADE_FROM: JSON.stringify(from) } });
|
|
207
|
+
process.exitCode = result.status ?? 1;
|
|
208
|
+
return null;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// With the new core installed - or, in a dry run, the installed one when it is already that version.
|
|
212
|
+
const core = coreDir && (!dryRun || from[CORE] === version) ? await loadCore(coreDir) : null;
|
|
213
|
+
/** @type {Outcome} */
|
|
214
|
+
const outcome = {
|
|
215
|
+
version,
|
|
216
|
+
packages: bumps.map(bump => ({ name: bump.name, spec: bump.spec, from: from[bump.name], version: bump.version, to: bump.to })),
|
|
217
|
+
code: null,
|
|
218
|
+
build: null,
|
|
219
|
+
server: null,
|
|
220
|
+
dryRun,
|
|
221
|
+
};
|
|
222
|
+
|
|
223
|
+
if (core) {
|
|
224
|
+
log.step(dryRun ? 'Code: what the rewrites would change' : 'Code');
|
|
225
|
+
const report = rewriteCode(core, dryRun);
|
|
226
|
+
outcome.code = { files: [...new Set(report.changes.map(change => change.file))], left: report.left };
|
|
227
|
+
} else {
|
|
228
|
+
log.step(`Code: rewritten by @amxts/core ${version} once it is installed`);
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
if (core && !dryRun) {
|
|
232
|
+
log.step('Build');
|
|
233
|
+
await prepare(core, { quiet: true });
|
|
234
|
+
outcome.build = runTask(core, 'build') === 0 ? 'built' : 'failed';
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
if (server) {
|
|
238
|
+
outcome.server = core
|
|
239
|
+
? await updateServer(core.api, dir, { dryRun })
|
|
240
|
+
: { ok: true, line: `the module of amxts ${version} goes in once @amxts/core ${version} is installed` };
|
|
241
|
+
}
|
|
242
|
+
console.log(['', ...summary(outcome)].join('\n'));
|
|
243
|
+
return outcome;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/**
|
|
247
|
+
* The core of `core` - a version, or `latest` - and the version each @amxts/
|
|
248
|
+
* package of the project goes to: the core's own, the others' newest that
|
|
249
|
+
* works with it. Only a package whose spec moves is asked for, and nothing
|
|
250
|
+
* when the project has that core already and nothing else moves.
|
|
251
|
+
*/
|
|
252
|
+
async function newest(registry, dir, pkg, core) {
|
|
253
|
+
const names = amxtsPackages(pkg).filter(each => each.name !== CORE && MOVABLE.test(each.spec.trim())).map(each => each.name);
|
|
254
|
+
if (!names.length && installedVersion(dir, CORE) === core) return { version: core, versions: { [CORE]: core } };
|
|
255
|
+
const found = await versionsFor(registry, core, names);
|
|
256
|
+
return { version: found.core, versions: { ...found.versions, [CORE]: found.core } };
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/** The versions the command that handed the upgrade on installed: the core's, and each package's. */
|
|
260
|
+
function installed(dir, pkg, version) {
|
|
261
|
+
return { version, versions: Object.fromEntries(amxtsPackages(pkg).map(each => [each.name, installedVersion(dir, each.name)])) };
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
function needCore() {
|
|
265
|
+
throw new CliError('@amxts/core is not installed in this project', 'Run amxts upgrade without --server-only first.');
|
|
266
|
+
}
|
|
@@ -47,7 +47,7 @@ export default defineConfig({
|
|
|
47
47
|
A plugin of a project that lists the module uses it as `{{camel}}`, without an import:
|
|
48
48
|
|
|
49
49
|
```ts
|
|
50
|
-
server.addCommand("/hello", {{camel}}.greet);
|
|
50
|
+
server.addCommand("/hello", ({ player }) => {{camel}}.greet(player));
|
|
51
51
|
```
|
|
52
52
|
|
|
53
53
|
| Function | What it does |
|
|
@@ -47,7 +47,7 @@ export default defineConfig({
|
|
|
47
47
|
Плагин проекта, в котором модуль указан, пользуется им как `{{camel}}`, без импорта:
|
|
48
48
|
|
|
49
49
|
```ts
|
|
50
|
-
server.addCommand("/hello", {{camel}}.greet);
|
|
50
|
+
server.addCommand("/hello", ({ player }) => {{camel}}.greet(player));
|
|
51
51
|
```
|
|
52
52
|
|
|
53
53
|
| Функция | Что делает |
|
|
@@ -1,4 +1,9 @@
|
|
|
1
|
+
// #if bun
|
|
1
2
|
import { describe, expect, test } from "bun:test";
|
|
3
|
+
// #endif
|
|
4
|
+
// #if !bun
|
|
5
|
+
import { describe, expect, test } from "vitest";
|
|
6
|
+
// #endif
|
|
2
7
|
import { setup } from "@amxts/core/test-utils";
|
|
3
8
|
|
|
4
9
|
// The first test compiles the plugins; the next runs take them from the cache.
|
|
@@ -15,7 +15,8 @@ Counter-Strike 1.6 plugins in TypeScript, built with [amxts](https://amxts.githu
|
|
|
15
15
|
- `{{amxts}} module add <name>` - installs a module and lists it in `amxts.config.ts`
|
|
16
16
|
|
|
17
17
|
`AMXTS_SERVER` in `.env` is the `addons/amxts` folder of the server `dev`
|
|
18
|
-
deploys to:
|
|
18
|
+
deploys to. The server's own folder or its `cstrike` will do too: the build
|
|
19
|
+
finds `cstrike/addons/amxts` under it.
|
|
19
20
|
|
|
20
21
|
```sh
|
|
21
22
|
AMXTS_SERVER=D:/hlds/cstrike/addons/amxts
|
|
@@ -3,37 +3,49 @@ plugin({ name: "Hello", version: "1.0.0", author: "{{author}}", description: "Th
|
|
|
3
3
|
// #if config-core
|
|
4
4
|
// configs/hello.yaml (or hello.json) says what a player is greeted with -
|
|
5
5
|
// "greeting: Hi" - and "Welcome" is what it is when the file does not say.
|
|
6
|
-
|
|
6
|
+
interface HelloConfig {
|
|
7
|
+
greeting: string;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
const { greeting } = configs.load<HelloConfig>("hello", { greeting: "Welcome" });
|
|
7
11
|
// #endif
|
|
8
12
|
// #if !config-core
|
|
9
13
|
const greeting = "Welcome";
|
|
10
14
|
// #endif
|
|
11
15
|
|
|
12
|
-
server.addCommand("/hp", sayHp);
|
|
13
|
-
server.addEventListener("
|
|
16
|
+
server.addCommand("/hp", ({ player }) => sayHp(player));
|
|
17
|
+
server.addEventListener("putInServer", event => print(0, `${greeting}, ${event.player.name}!`));
|
|
14
18
|
// #if menu-core
|
|
15
19
|
|
|
16
|
-
// A menu made in code: its title and items can be functions of the player.
|
|
17
|
-
const hello = menus.create("HELLO", { title: player => `Hello, ${player.name}` });
|
|
18
|
-
hello.addItem("Wave",
|
|
19
|
-
hello.addItem(
|
|
20
|
-
|
|
21
|
-
|
|
20
|
+
// A menu made in code: its title and items can be functions of the player it is shown to.
|
|
21
|
+
const hello = menus.create("HELLO", { title: ({ player }) => `Hello, ${player.name}` });
|
|
22
|
+
hello.addItem({ title: "Wave", onSelect: ({ player }) => print(0, `${player.name} waves`) });
|
|
23
|
+
hello.addItem({
|
|
24
|
+
title: ({ player }) => `Heal (${player.health} HP)`,
|
|
25
|
+
visible: ({ player }) => player.health < 100,
|
|
26
|
+
onSelect: ({ player }) => {
|
|
27
|
+
player.health = 100;
|
|
28
|
+
},
|
|
22
29
|
});
|
|
23
30
|
|
|
24
|
-
server.addCommand("/menu", player => hello.show(player));
|
|
31
|
+
server.addCommand("/menu", ({ player }) => hello.show(player));
|
|
25
32
|
// #endif
|
|
33
|
+
// #if !menu-core
|
|
26
34
|
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
}
|
|
30
|
-
|
|
35
|
+
// A menu: its title and items can be functions of the player it is shown to.
|
|
36
|
+
const hello = new Menu(({ player }) => `Hello, ${player.name}`);
|
|
37
|
+
hello.addItem({ title: "Wave", onSelect: ({ player }) => print(0, `${player.name} waves`) });
|
|
38
|
+
hello.addItem({
|
|
39
|
+
title: ({ player }) => `Heal (${player.health} HP)`,
|
|
40
|
+
visible: ({ player }) => player.health < 100,
|
|
41
|
+
onSelect: ({ player }) => {
|
|
42
|
+
player.health = 100;
|
|
43
|
+
},
|
|
44
|
+
});
|
|
31
45
|
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
}
|
|
46
|
+
server.addCommand("/menu", ({ player }) => hello.show(player));
|
|
47
|
+
// #endif
|
|
35
48
|
|
|
36
|
-
function
|
|
37
|
-
player.
|
|
49
|
+
function sayHp(player: Player) {
|
|
50
|
+
print(player, `${player.name}, your HP: ${player.health}`);
|
|
38
51
|
}
|
|
39
|
-
// #endif
|