@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.
@@ -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's addons/amxts folder. `required`: an empty answer is
20
- * not taken. The answer, or clack's cancel symbol on Ctrl+C.
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
- return p.text({
26
- message: `Where is the server? ${c.dim(`its addons/amxts folder, for ${runScript(pm, 'dev')}${required ? '' : ' - empty to skip'}`)}`,
27
- placeholder: process.platform === 'win32' ? 'D:/hlds/cstrike/addons/amxts' : '/srv/hlds/cstrike/addons/amxts',
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: value => (required && !value?.trim() ? 'The plugins are deployed there: the server\'s addons/amxts folder' : undefined),
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.replace(/\\/g, '/')} to .env`);
116
+ p.log.success(`Wrote AMXTS_SERVER=${slashes(server)} to .env`);
71
117
  if (!existsSync(server)) p.log.warn(missingServer(server));
72
118
  }
@@ -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,3 +1,3 @@
1
1
  plugin({ name: "Welcome", version: "0.1.0", author: "{{author}}", description: "Tries {{title}} out" });
2
2
 
3
- server.addCommand("/hello", {{camel}}.greet);
3
+ server.addCommand("/hello", ({ player }) => {{camel}}.greet(player));
@@ -4,7 +4,7 @@
4
4
  "lib": ["ES2022"],
5
5
  "module": "ESNext",
6
6
  "moduleResolution": "bundler",
7
- "types": ["bun"],
7
+ "types": ["{{test.types}}"],
8
8
  "strict": true,
9
9
  "noEmit": true,
10
10
  "skipLibCheck": true
@@ -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
- const { greeting } = configs.load("hello", { greeting: "Welcome" });
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("putinserver", event => print(0, `${greeting}, ${event.player.name}!`));
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", { onSelect: wave });
19
- hello.addItem(player => `Heal (${player.health} HP)`, {
20
- visible: player => player.health < 100,
21
- onSelect: heal,
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
- function sayHp(player: Player) {
28
- print(player, `${player.name}, your HP: ${player.health}`);
29
- }
30
- // #if menu-core
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
- function wave(player: Player) {
33
- print(0, `${player.name} waves`);
34
- }
46
+ server.addCommand("/menu", ({ player }) => hello.show(player));
47
+ // #endif
35
48
 
36
- function heal(player: Player) {
37
- player.health = 100;
49
+ function sayHp(player: Player) {
50
+ print(player, `${player.name}, your HP: ${player.health}`);
38
51
  }
39
- // #endif