@amxts/cli 0.2.1 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/init.mjs CHANGED
@@ -23,7 +23,7 @@ import { npmRegistry, versionsFor } from './registry.mjs';
23
23
  import { askServer, canAsk, missingServer, serverEnv, serverFolder } from './server.mjs';
24
24
  import { HOST_SYSTEM, serverSystemOf, SYSTEMS } from './system.mjs';
25
25
  import { copyTemplate, gitAuthor, writeFile } from './template.mjs';
26
- import { c, CliError } from './ui.mjs';
26
+ import { banner, c, CliError } from './ui.mjs';
27
27
 
28
28
  /**
29
29
  * The tools a project gets besides the core and its modules. knip reads the
@@ -80,7 +80,8 @@ export async function initProject(options) {
80
80
  const localCore = local ? await needLocalCore() : null;
81
81
  const localModules = localCore?.api.localModules() ?? {};
82
82
 
83
- p.intro(`${c.bold(c.cyan('amxts'))} ${c.dim(VERSION)} ${c.dim('·')} a new project`);
83
+ banner(VERSION, 'a new project', { logo: true });
84
+ p.intro();
84
85
 
85
86
  // 1. The package manager, first: the rest is said in its words.
86
87
  const detected = packageManagerOfAgent() ?? 'npm';
package/src/lock.mjs ADDED
@@ -0,0 +1,94 @@
1
+ // One `amxts dev` per project: two would build and deploy at the same time.
2
+ // The one running holds .amxts/dev.lock - its pid, when it started and its
3
+ // script. A second one stops with where the first runs; --takeover stops the
4
+ // first instead, once its command line shows it is still that amxts dev - a
5
+ // pid alone could be a new process by now. A lock whose process is gone is
6
+ // taken over quietly.
7
+ import { execFileSync, spawnSync } from 'node:child_process';
8
+ import { mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
9
+ import { dirname, join } from 'node:path';
10
+ import process from 'node:process';
11
+ import { CliError } from './ui.mjs';
12
+
13
+ /** The lock of a project's dev. */
14
+ export const lockPath = root => join(root, '.amxts', 'dev.lock');
15
+
16
+ /** Whether a process is there: a signal 0 reaches it, or it is someone else's. */
17
+ function alive(pid) {
18
+ try {
19
+ process.kill(pid, 0);
20
+ return true;
21
+ } catch (error) {
22
+ return error.code === 'EPERM';
23
+ }
24
+ }
25
+
26
+ /** A process's command line, or '' when it cannot be read. */
27
+ export function commandLineOf(pid) {
28
+ try {
29
+ if (process.platform === 'win32') {
30
+ return execFileSync('powershell.exe', ['-NoProfile', '-NonInteractive', '-Command', `(Get-CimInstance Win32_Process -Filter 'ProcessId=${Number(pid)}').CommandLine`], { encoding: 'utf8', windowsHide: true }).trim();
31
+ }
32
+ if (process.platform === 'linux') return readFileSync(`/proc/${pid}/cmdline`, 'utf8').replace(/\0/g, ' ').trim();
33
+ return execFileSync('ps', ['-o', 'command=', '-p', String(pid)], { encoding: 'utf8' }).trim();
34
+ } catch {
35
+ return '';
36
+ }
37
+ }
38
+
39
+ /** The lock's holder, when it is still the dev that wrote it: alive, and running the same script. */
40
+ function holder(lock) {
41
+ if (!lock?.pid || lock.pid === process.pid || !alive(lock.pid)) return null;
42
+ const line = commandLineOf(lock.pid).replace(/\\/g, '/');
43
+ return line.includes(String(lock.script).replace(/\\/g, '/')) ? lock : null;
44
+ }
45
+
46
+ /** Stops a process and what it started: on Windows the whole tree, elsewhere it stops its own (dev does, on SIGTERM). */
47
+ export function stopTree(pid) {
48
+ if (process.platform === 'win32') {
49
+ spawnSync('taskkill', ['/PID', String(pid), '/T', '/F'], { stdio: 'ignore', windowsHide: true });
50
+ } else {
51
+ try {
52
+ process.kill(pid, 'SIGTERM');
53
+ } catch {}
54
+ }
55
+ }
56
+
57
+ /** Stops a process (stopTree) and waits up to `wait` ms for it to be gone. */
58
+ function stop(pid, wait = 5000) {
59
+ stopTree(pid);
60
+ const until = Date.now() + wait;
61
+ while (alive(pid) && Date.now() < until) Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 100);
62
+ return !alive(pid);
63
+ }
64
+
65
+ /**
66
+ * Takes the project's dev lock, released when this process exits.
67
+ * @param {string} root the project's folder
68
+ * @param {{ takeover?: boolean, script?: string, say?: (line: string) => void }} [options]
69
+ * @throws {CliError} when another dev holds it and `takeover` is not given
70
+ */
71
+ export function takeLock(root, { takeover = false, script = process.argv[1], say = () => {} } = {}) {
72
+ const path = lockPath(root);
73
+ let lock = null;
74
+ try {
75
+ lock = JSON.parse(readFileSync(path, 'utf8'));
76
+ } catch {}
77
+ const other = holder(lock);
78
+ if (other) {
79
+ const since = new Date(other.since).toLocaleString();
80
+ if (!takeover) {
81
+ throw new CliError(`amxts dev is already running in another terminal (pid ${other.pid}, since ${since})`, 'Stop it there, or take it over: amxts dev --takeover');
82
+ }
83
+ // Read again just before stopping it: the pid must still be that dev.
84
+ if (holder(other) && !stop(other.pid)) throw new CliError(`amxts dev (pid ${other.pid}) did not stop`, 'Stop it in its terminal, then run amxts dev again.');
85
+ say(`stopped the amxts dev that ran since ${since} (pid ${other.pid})`);
86
+ }
87
+ mkdirSync(dirname(path), { recursive: true });
88
+ writeFileSync(path, `${JSON.stringify({ pid: process.pid, since: new Date().toISOString(), script })}\n`);
89
+ process.on('exit', () => {
90
+ try {
91
+ if (JSON.parse(readFileSync(path, 'utf8')).pid === process.pid) rmSync(path, { force: true });
92
+ } catch {}
93
+ });
94
+ }
package/src/main.mjs CHANGED
@@ -11,13 +11,15 @@ import { existsSync } from 'node:fs';
11
11
  import { join } from 'node:path';
12
12
  import process from 'node:process';
13
13
  import { GLOBAL_FLAGS, parseArgs } from './args.mjs';
14
- import { needBun, needProject, projectCore, runTask, runTaskOrExit, VERSION } from './core.mjs';
14
+ import { needBun, needProject, projectCore, runTask, runTaskOrExit, setting, startTask, VERSION } from './core.mjs';
15
15
  import { prepare, TARGETS } from './includes.mjs';
16
+ import { stopTree, takeLock } from './lock.mjs';
16
17
  import { detectPackageManager, PACKAGE_MANAGERS, run } from './pm.mjs';
17
18
  import { serverMismatch } from './server-update.mjs';
18
19
  import { ensureServer } from './server.mjs';
19
20
  import { SYSTEMS } from './system.mjs';
20
21
  import { banner, c, CliError, closest, log, report } from './ui.mjs';
22
+ import { updateLine } from './update-check.mjs';
21
23
 
22
24
  /** --os, for the commands that compile: the plugins are built for that system. */
23
25
  const OS_FLAG = { type: 'string', value: Object.keys(SYSTEMS).join('|'), description: 'The server\'s system, when AMXTS_SERVER does not show it: the plugins are compiled for it' };
@@ -28,6 +30,12 @@ function warnServer(core, os) {
28
30
  if (line) log.warn(line);
29
31
  }
30
32
 
33
+ /** The line about a newer amxts, once a day at most (update-check.mjs). */
34
+ function nudge(core) {
35
+ const line = updateLine(core.version, detectPackageManager());
36
+ if (line) log.info(line);
37
+ }
38
+
31
39
  /** --os as the build task takes it. */
32
40
  function osArgs(values) {
33
41
  if (values.os === undefined) return [];
@@ -84,25 +92,76 @@ export const COMMANDS = {
84
92
  },
85
93
  dev: {
86
94
  description: 'Build, deploy to the server in AMXTS_SERVER, and again on every save',
87
- usage: 'amxts dev [--os windows|linux] [--docker]',
95
+ usage: 'amxts dev [--os windows|linux] [--docker] [--no-tui] [--takeover]',
88
96
  flags: {
89
97
  os: OS_FLAG,
90
98
  docker: { type: 'boolean', description: 'For the Docker server that mounts this project: build for Linux into dist/ on every save, which it reloads, and show its console' },
99
+ tui: { type: 'boolean', description: 'The panel under the build\'s lines, in a terminal (--no-tui, or AMXTS_TUI=plain, for the lines alone)' },
100
+ takeover: { type: 'boolean', description: 'Stop the amxts dev already running for this project, and run here' },
91
101
  },
92
- examples: ['amxts dev', 'amxts dev --os linux', 'amxts dev --docker'],
102
+ examples: ['amxts dev', 'amxts dev --os linux', 'amxts dev --docker', 'amxts dev --takeover'],
93
103
  async run({ values }) {
94
104
  if (values.docker && values.os && values.os !== 'linux')
95
105
  throw new CliError('The Docker server is Linux: --docker builds for Linux.', 'Leave out --os.');
96
106
  const core = await projectCore();
97
- banner(core.version, 'dev');
107
+ const root = needProject();
108
+ const { devPanel, wantsPanel } = await import('./dev-panel.mjs');
109
+ // The panel has the logo in it; the build's lines alone start with it.
110
+ const panel = wantsPanel({ tui: values.tui });
111
+ if (!panel) banner(core.version, 'dev', { logo: true });
112
+ takeLock(root, { takeover: values.takeover, say: log.info });
113
+ nudge(core);
98
114
  // The container reads dist/ where it is: nothing to deploy, and AMXTS_SERVER is not asked.
99
115
  if (!values.docker) {
100
- await ensureServer(needProject(), detectPackageManager());
116
+ await ensureServer(root, detectPackageManager());
101
117
  warnServer(core, osArgs(values));
102
118
  }
103
119
  await prepare(core, { quiet: true });
104
- if (values.docker) runTaskOrExit(core, 'build', ['--watch', '--docker', '--os', 'linux']);
105
- else runTaskOrExit(core, 'build', ['--deploy', '--watch', ...osArgs(values)]);
120
+ const args = values.docker ? ['--watch', '--docker', '--os', 'linux'] : ['--deploy', '--watch', ...osArgs(values)];
121
+ if (panel) {
122
+ const { rconTarget } = await import('./rcon.mjs');
123
+ devPanel(core, args, { target: rconTarget(root), deployTo: values.docker ? '' : setting(root, 'AMXTS_SERVER').replace(/\\/g, '/') });
124
+ return;
125
+ }
126
+ const child = startTask(core, 'build', args);
127
+ // --takeover from another terminal: the build goes with this.
128
+ for (const signal of ['SIGTERM', 'SIGHUP']) {
129
+ process.on(signal, () => {
130
+ stopTree(child.pid);
131
+ process.exit(0);
132
+ });
133
+ }
134
+ child.on('exit', code => process.exit(code ?? 1));
135
+ },
136
+ },
137
+ rcon: {
138
+ description: 'Send a command to the project\'s server over rcon and print its answer',
139
+ usage: 'amxts rcon <command> [--json]',
140
+ flags: {
141
+ json: { type: 'boolean', description: 'Print { server, command, reply } - or { server, command, error } - as JSON' },
142
+ },
143
+ examples: ['amxts rcon amxts_plugins', 'amxts rcon status --json', 'amxts rcon changelevel de_dust2'],
144
+ async run({ values, positionals }) {
145
+ const command = positionals.join(' ').trim();
146
+ if (!command) throw new CliError('amxts rcon: which command?', 'amxts rcon amxts_plugins');
147
+ const { rcon, rconTarget } = await import('./rcon.mjs');
148
+ const target = rconTarget(needProject());
149
+ if (!target) throw new CliError('AMXTS_SERVER is not set: rcon reaches the server it names', 'Set it in .env beside package.json: AMXTS_SERVER=D:/hlds/cstrike/addons/amxts');
150
+ if (!target.password) throw new CliError(`rcon is not set up: no rcon_password in ${target.cfg}`, 'Add a line rcon_password "something" to it and restart the server.');
151
+ const server = `${target.host}:${target.port}`;
152
+ const answer = await rcon(target, command);
153
+ if (values.json) {
154
+ console.log(JSON.stringify({ server, command, ...answer }));
155
+ if ('error' in answer) process.exitCode = 1;
156
+ return;
157
+ }
158
+ if ('reply' in answer) {
159
+ process.stdout.write(answer.reply.endsWith('\n') || !answer.reply ? answer.reply : `${answer.reply}\n`);
160
+ return;
161
+ }
162
+ throw answer.error === 'no answer'
163
+ ? new CliError(`The server at ${server} does not answer`, 'Is it running? AMXTS_PORT in .env names a port other than 27015.')
164
+ : new CliError(`The server refused the rcon_password of ${target.cfg}`, 'The server reads it as it starts: restart it after a change.');
106
165
  },
107
166
  },
108
167
  build: {
@@ -118,6 +177,7 @@ export const COMMANDS = {
118
177
  const os = osArgs(values);
119
178
  const core = await projectCore();
120
179
  banner(core.version, 'build');
180
+ nudge(core);
121
181
  warnServer(core, os);
122
182
  if (values.deploy) await ensureServer(needProject(), detectPackageManager());
123
183
  await prepare(core, { quiet: true });
@@ -131,6 +191,7 @@ export const COMMANDS = {
131
191
  async run() {
132
192
  const core = await projectCore();
133
193
  banner(core.version, 'typecheck');
194
+ nudge(core);
134
195
  await prepare(core, { quiet: true });
135
196
  if (runTask(core, 'typecheck') !== 0) {
136
197
  throw new CliError('The plugins have type errors', 'They are listed above, file:line first.');
@@ -183,7 +244,15 @@ export const COMMANDS = {
183
244
  description: 'Fetch the server\'s includes and write .amxts/tsconfig.json for the editor (build does it too)',
184
245
  usage: 'amxts prepare',
185
246
  async run() {
186
- await prepare(await projectCore());
247
+ try {
248
+ await prepare(await projectCore());
249
+ } catch (error) {
250
+ // A project's postinstall: installing a newer command into a project of
251
+ // an older core - the upgrade's first step - is no failed install.
252
+ if (!(error instanceof CliError) || process.env.npm_lifecycle_event !== 'postinstall') throw error;
253
+ log.warn(error.message);
254
+ if (error.hint) log.hint(error.hint);
255
+ }
187
256
  },
188
257
  },
189
258
  upgrade: {
package/src/modules.json CHANGED
@@ -14,6 +14,21 @@
14
14
  ],
15
15
  "requires": []
16
16
  },
17
+ {
18
+ "name": "ftp",
19
+ "npm": "@amxts/ftp",
20
+ "repo": "amxts/ftp",
21
+ "description": "FTP, FTPS and SFTP - upload, download and list files on another server, every call a promise",
22
+ "category": "network",
23
+ "type": "official",
24
+ "logo": "https://raw.githubusercontent.com/amxts/ftp/HEAD/assets/logo.svg",
25
+ "maintainers": [
26
+ {
27
+ "github": "kukson777"
28
+ }
29
+ ],
30
+ "requires": []
31
+ },
17
32
  {
18
33
  "name": "menu-core",
19
34
  "npm": "@amxts/menu-core",
package/src/rcon.mjs ADDED
@@ -0,0 +1,134 @@
1
+ // rcon to the project's server: GoldSrc's, over UDP. A packet starts with four
2
+ // 0xFF; `challenge rcon` is answered with a number, and `rcon <number>
3
+ // "<password>" <command>` with what the command printed, an `l` after the
4
+ // 0xFFs, in as many packets as it takes.
5
+ //
6
+ // The server is the one AMXTS_SERVER names: on this machine (127.0.0.1), on
7
+ // AMXTS_PORT (27015 by default), with the rcon_password of its server.cfg -
8
+ // what the build reloads plugins with.
9
+ import { Buffer } from 'node:buffer';
10
+ import { createSocket } from 'node:dgram';
11
+ import { existsSync, readFileSync } from 'node:fs';
12
+ import { join } from 'node:path';
13
+ import { setting } from './core.mjs';
14
+ import { serverFolderOrNull } from './server.mjs';
15
+
16
+ const HEAD = Buffer.from([255, 255, 255, 255]);
17
+
18
+ /** A connectionless packet: four 0xFF, then the text. */
19
+ export function packet(text) {
20
+ return Buffer.concat([HEAD, Buffer.from(`${text}\n`, 'latin1')]);
21
+ }
22
+
23
+ /** The text of a reply: after the four 0xFF and an `l` (a printed reply), without the trailing NULs. */
24
+ export function replyText(buffer) {
25
+ if (buffer.length < 4 || !buffer.subarray(0, 4).equals(HEAD)) return '';
26
+ return buffer.subarray(4).toString('latin1').replace(/^l/, '').replace(/\0+$/, '');
27
+ }
28
+
29
+ /** The rcon_password a server.cfg sets, or ''. */
30
+ export function cfgPassword(text) {
31
+ return text.match(/^\s*rcon_password\s+"?([^"\r\n]*)"?/m)?.[1]?.trim() ?? '';
32
+ }
33
+
34
+ /**
35
+ * @typedef {object} RconTarget
36
+ * @property {string} host its address
37
+ * @property {number} port its UDP port
38
+ * @property {string} password '' when none is set
39
+ * @property {string} cfg the server.cfg the password is read from, for the words
40
+ */
41
+
42
+ /** The project's server as rcon reaches it; null without AMXTS_SERVER. */
43
+ export function rconTarget(root) {
44
+ const server = serverFolderOrNull(setting(root, 'AMXTS_SERVER'));
45
+ if (!server) return null;
46
+ const cfg = join(server, '..', '..', 'server.cfg').replace(/\\/g, '/');
47
+ return {
48
+ host: '127.0.0.1',
49
+ port: Number(setting(root, 'AMXTS_PORT') || 27015),
50
+ password: existsSync(cfg) ? cfgPassword(readFileSync(cfg, 'utf8')) : '',
51
+ cfg,
52
+ };
53
+ }
54
+
55
+ /**
56
+ * Sends one packet and collects what comes back until it goes quiet; null
57
+ * when nothing does.
58
+ */
59
+ function ask(socket, target, text, wait) {
60
+ return new Promise((done) => {
61
+ let reply = null;
62
+ let timer;
63
+ function finish() {
64
+ clearTimeout(timer);
65
+ socket.off('message', heard);
66
+ socket.off('error', finish);
67
+ done(reply);
68
+ }
69
+ function heard(buffer) {
70
+ reply = (reply ?? '') + replyText(buffer);
71
+ clearTimeout(timer);
72
+ timer = setTimeout(finish, 150);
73
+ }
74
+ timer = setTimeout(finish, wait);
75
+ socket.on('message', heard);
76
+ // Windows answers a closed port with an error rather than silence.
77
+ socket.once('error', finish);
78
+ socket.send(packet(text), target.port, target.host);
79
+ });
80
+ }
81
+
82
+ /**
83
+ * Runs a command on the server: what it printed, or why it did not run -
84
+ * `no answer` (the server is not running there) or `bad password`.
85
+ * @param {RconTarget} target
86
+ * @param {string} command
87
+ * @returns {Promise<{ reply: string } | { error: 'no answer' | 'bad password' }>} the reply, or why there is none
88
+ */
89
+ export async function rcon(target, command, wait = 2000) {
90
+ const socket = createSocket('udp4');
91
+ try {
92
+ const challenge = (await ask(socket, target, 'challenge rcon', 1000))?.match(/challenge rcon (\d+)/)?.[1];
93
+ if (!challenge) return { error: 'no answer' };
94
+ const reply = await ask(socket, target, `rcon ${challenge} "${target.password}" ${command}`, wait);
95
+ if (reply === null) return { error: 'no answer' };
96
+ if (/^Bad rcon_password/i.test(reply)) return { error: 'bad password' };
97
+ return { reply };
98
+ } finally {
99
+ socket.close();
100
+ }
101
+ }
102
+
103
+ /**
104
+ * What `status` and `stats` say of the server: its map, players and FPS.
105
+ * @returns {{ map?: string, players?: number, max?: number, fps?: number }} what was found
106
+ */
107
+ export function serverState(status, stats = '') {
108
+ const map = /^map\s*:\s*(\S+)/m.exec(status)?.[1];
109
+ const players = /^players\s*:\s*(\d+) active \((\d+) max\)/m.exec(status);
110
+ // stats: a row of headings, then a row of numbers under them.
111
+ const rows = stats.split('\n').map(line => line.trim().split(/\s+/));
112
+ const at = rows.findIndex(row => row.includes('FPS'));
113
+ const fps = at >= 0 ? Number(rows[at + 1]?.[rows[at].indexOf('FPS')]) : Number.NaN;
114
+ return {
115
+ map,
116
+ players: players ? Number(players[1]) : undefined,
117
+ max: players ? Number(players[2]) : undefined,
118
+ fps: Number.isFinite(fps) ? fps : undefined,
119
+ };
120
+ }
121
+
122
+ /**
123
+ * The plugins `amxts_plugins` lists: ` name state about`.
124
+ * @returns {{ name: string, state: string, about: string }[]} one per plugin
125
+ */
126
+ export function pluginStates(reply) {
127
+ return [...reply.matchAll(/^ {2}(\S+)\s+(running|unloaded|refused|waiting|loading)(?: +(\S.*))?$/gm)]
128
+ .map(([, name, state, about = '']) => ({ name, state, about: about.trim() }));
129
+ }
130
+
131
+ /** The maps `maps *` lists, without `.bsp`. */
132
+ export function mapNames(reply) {
133
+ return [...new Set([...reply.matchAll(/^\s*([\w.-]+)\.bsp\b/gim)].map(match => match[1]))].sort();
134
+ }
@@ -7,8 +7,9 @@
7
7
  // downloaded and checked, and none of the files it replaces is in use: a
8
8
  // running Windows server holds its module, which cannot be written then.
9
9
  // The file it replaces stays beside it, with its version
10
- // (amxts_amxx.dll.0.1.0). AMX Mod X's plugins.ini loses an
11
- // `amxts_host.amxx` line: the module loads its host plugin itself.
10
+ // (amxts_amxx.dll.0.1.0). What an older release put into AMX Mod X goes: an
11
+ // `amxts_host.amxx` line of its plugins.ini, that plugin and its list -
12
+ // amxts needs no plugin.
12
13
  //
13
14
  // Also the check `amxts dev` and `build` start with: the release the
14
15
  // server's module is of, read from the module file (the core's
@@ -47,6 +48,9 @@ export function writable(path) {
47
48
  }
48
49
  }
49
50
 
51
+ /** The files of an older release's plugin in AMX Mod X, from the game folder. */
52
+ const LEFTOVERS = ['addons/amxmodx/plugins/amxts_host.amxx', 'addons/amxmodx/configs/plugins-amxts.ini'];
53
+
50
54
  /** AMX Mod X's plugins.ini with its `amxts_host.amxx` lines left out; null when it has none. */
51
55
  export function withoutHost(text) {
52
56
  const lines = text.split(/(?<=\n)/);
@@ -134,11 +138,13 @@ export async function updateServer(api, dir, { dryRun = false, canWrite = writab
134
138
  const changing = files.filter(file => !file.same);
135
139
  const pluginsIni = join(game, 'addons', 'amxmodx', 'configs', 'plugins.ini');
136
140
  const cleaned = existsSync(pluginsIni) ? withoutHost(readFileSync(pluginsIni, 'utf8')) : null;
141
+ const leftovers = LEFTOVERS.map(path => join(game, path)).filter(path => existsSync(path));
142
+ const tidied = [...(cleaned === null ? [] : [pluginsIni]), ...leftovers].map(slashes).join(', ');
137
143
  const names = changing.map(file => file.path.split(/[\\/]/).pop()).join(', ');
138
144
 
139
145
  if (dryRun) {
140
146
  for (const file of changing) log.info(`would put ${file.asset} of ${version} at ${slashes(file.path)}`);
141
- if (cleaned !== null) log.info(`would take amxts_host.amxx out of ${slashes(pluginsIni)}`);
147
+ if (tidied) log.info(`would take an older amxts's plugin out of ${tidied}`);
142
148
  return { ok: true, line: changing.length ? `${names} would go from amxts ${before} to ${version}` : `already amxts ${version}` };
143
149
  }
144
150
 
@@ -164,10 +170,9 @@ export async function updateServer(api, dir, { dryRun = false, canWrite = writab
164
170
  writeFileSync(file.path, file.data, { mode: old?.mode ?? 0o755 });
165
171
  log.success(`${file.path.split(/[\\/]/).pop()} ${c.dim(`${before} →`)} ${version}${old ? c.dim(` (the old one: ${file.path.split(/[\\/]/).pop()}.${before})`) : ''}`);
166
172
  }
167
- if (cleaned !== null) {
168
- writeFileSync(pluginsIni, cleaned);
169
- log.success(`Took amxts_host.amxx out of ${slashes(pluginsIni)}: the module loads it itself`);
170
- }
171
- if (!changing.length) return { ok: true, line: `already amxts ${version}${cleaned !== null ? ', plugins.ini cleaned' : ''}` };
173
+ if (cleaned !== null) writeFileSync(pluginsIni, cleaned);
174
+ for (const path of leftovers) rmSync(path, { force: true });
175
+ if (tidied) log.success(`Took an older amxts's plugin out of ${tidied}: amxts needs no plugin`);
176
+ if (!changing.length) return { ok: true, line: `already amxts ${version}${tidied ? ', the old plugin taken out' : ''}` };
172
177
  return { ok: true, line: `${names} ${before} → ${version} - restart the server to load it` };
173
178
  }
package/src/ui.mjs CHANGED
@@ -2,6 +2,7 @@
2
2
  // per step, and an error as one line with a hint under it. The stack of an
3
3
  // error is printed only with --debug (or AMXTS_DEBUG=1).
4
4
  import process from 'node:process';
5
+ import { pathToFileURL } from 'node:url';
5
6
 
6
7
  /** Whether the output takes colors: a terminal, and NO_COLOR not set; FORCE_COLOR wins. */
7
8
  function colorful(stream = process.stdout) {
@@ -66,9 +67,139 @@ export function report(error) {
66
67
  if (debug() && error instanceof Error && error.stack) process.stderr.write(`${c.gray(error.stack)}\n`);
67
68
  }
68
69
 
69
- /** The line a command starts with: `amxts 0.1.0 · build`. */
70
- export function banner(version, what) {
71
- console.log(`${c.bold(c.cyan('amxts'))} ${c.dim(version)}${what ? c.dim(` · ${what}`) : ''}`);
70
+ /** Whether a run is CI's: `CI` set to anything but `false`. */
71
+ export function isCI(env = process.env) {
72
+ return Boolean(env.CI) && env.CI !== 'false' && env.CI !== '0';
73
+ }
74
+
75
+ /**
76
+ * What characters the output can draw: `box` - any (the panel's spinner and
77
+ * bar); `bullet` - the logo's `{•}` but not the box characters, for Windows'
78
+ * own console, whose fonts may miss them; `ascii` - none of them, where the
79
+ * text is not UTF-8: the logo is `{*}`.
80
+ */
81
+ export function logoGlyphs(env = process.env, platform = process.platform) {
82
+ if (platform === 'win32') {
83
+ const modern = env.WT_SESSION || env.TERM_PROGRAM || env.ConEmuTask || env.TERMINAL_EMULATOR === 'JetBrains-JediTerm' || /^(?:xterm|alacritty)/.test(env.TERM ?? '');
84
+ return modern ? 'box' : 'bullet';
85
+ }
86
+ if (!/utf-?8/i.test(env.LC_ALL || env.LC_CTYPE || env.LANG || 'UTF-8')) return 'ascii';
87
+ return env.TERM === 'linux' ? 'bullet' : 'box';
88
+ }
89
+
90
+ /**
91
+ * The logo's colour, amxts yellow (#F2C94C): in 24 bits where the terminal
92
+ * has them, plain yellow in one of 16 colours, none without a terminal, in CI
93
+ * or with NO_COLOR - FORCE_COLOR wins, as for every colour here.
94
+ * @param {{ tty?: boolean, env?: Record<string, string | undefined>, depth?: number }} [where] the output: a terminal, its environment and its colour depth in bits
95
+ * @returns {(text: string) => string} what paints a text
96
+ */
97
+ export function logoPaint(where) {
98
+ return [text => text, text => `\x1B[33m${text}\x1B[39m`, text => `\x1B[38;2;242;201;76m${text}\x1B[39m`][brandDepth(where)];
99
+ }
100
+
101
+ /** How amxts yellow is drawn (logoPaint): 0 - not at all, 1 - plain yellow, 2 - in 24 bits. */
102
+ function brandDepth({ tty = Boolean(process.stdout.isTTY), env = process.env, depth = process.stdout.getColorDepth?.(env) ?? 4 } = {}) {
103
+ const forced = env.FORCE_COLOR && env.FORCE_COLOR !== '0';
104
+ if (!forced && (!tty || isCI(env) || env.NO_COLOR !== undefined || env.TERM === 'dumb')) return 0;
105
+ return depth >= 24 || env.COLORTERM === 'truecolor' || env.COLORTERM === '24bit' ? 2 : 1;
106
+ }
107
+
108
+ // The 16 colours as amxts draws them in 24 bits: green, red, yellow (amxts
109
+ // yellow), blue, magenta, cyan - lit, where a terminal's own may be pale.
110
+ const VIVID = { 31: '248;113;113', 32: '74;222;128', 33: '242;201;76', 34: '96;165;250', 35: '232;121;249', 36: '34;211;238' };
111
+
112
+ /** A line with its 16 colours drawn vivid, where the terminal has 24 bits; as it is elsewhere. */
113
+ export function vivid(line, where) {
114
+ if (brandDepth(where) !== 2) return line;
115
+ // eslint-disable-next-line no-control-regex
116
+ return line.replace(/\x1B\[(3[1-6])m/g, (_, code) => `\x1B[38;2;${VIVID[code]}m`);
117
+ }
118
+
119
+ /**
120
+ * A text the terminal makes a link of (OSC 8), where colours are on; the text
121
+ * alone elsewhere.
122
+ */
123
+ export function link(text, url, where) {
124
+ return brandDepth(where) ? `\x1B]8;;${url}\x1B\\${text}\x1B]8;;\x1B\\` : text;
125
+ }
126
+
127
+ /**
128
+ * The link that opens a file at a line and column in the editor: Orca's own
129
+ * in Orca's terminal, else VS Code's, or Cursor's where the terminal or EDITOR
130
+ * is Cursor's. A `file://` link elsewhere would open a `.ts` in whatever the
131
+ * system opens it with - a video player, on some.
132
+ */
133
+ export function editorLink(file, line, col, env = process.env) {
134
+ // Orca opens a file:// link in its own editor, the line and column after it.
135
+ if (env.TERM_PROGRAM === 'Orca') return `${pathToFileURL(file).href}:${line}:${col}`;
136
+ const scheme = /cursor/i.test(`${env.TERM_PROGRAM ?? ''} ${env.EDITOR ?? ''} ${env.VISUAL ?? ''}`) ? 'cursor' : 'vscode';
137
+ // `vscode://file/D:/x.ts:3:26`, `vscode://file/home/me/x.ts:3:26`: the path absolute.
138
+ const path = file.replace(/\\/g, '/');
139
+ return `${scheme}://file${path.startsWith('/') ? '' : '/'}${encodeURI(path)}:${line}:${col}`;
140
+ }
141
+
142
+ /**
143
+ * A state as a word on a ground of its colour - ` READY ` on amxts yellow,
144
+ * ` BUILDING ` on cyan, ` ERROR ` on red; `[READY]` without colours.
145
+ * @param {string} text
146
+ * @param {'ready' | 'busy' | 'error'} tone
147
+ */
148
+ export function badge(text, tone, where) {
149
+ const depth = brandDepth(where);
150
+ if (!depth) return `[${text}]`;
151
+ const ground = {
152
+ ready: depth === 2 ? '48;2;242;201;76;38;2;21;24;29' : '43;30',
153
+ busy: depth === 2 ? '48;2;34;211;238;38;2;21;24;29' : '46;30',
154
+ error: depth === 2 ? '48;2;239;68;68;97' : '41;97',
155
+ }[tone];
156
+ return `\x1B[1;${ground}m ${text} \x1B[0m`;
157
+ }
158
+
159
+ // The logo in a line: the braces and the dot.
160
+ const MARK = { box: '{•}', bullet: '{•}', ascii: '{*}' };
161
+
162
+ // The logo - the crosshair: its arms, the braces and the dot - in braille
163
+ // dots, four lines of them, the logo's SVG rasterised to 16 by 16.
164
+ export const LOGO = ['⠀⢀⡀⢸⡇⢀⡀⠀', '⣀⡿⠁⣀⣀⠈⢿⣀', '⠉⣷⡀⠉⠉⢀⣾⠉', '⠀⠈⠁⢸⡇⠈⠁⠀'];
165
+
166
+ /**
167
+ * The logo's four lines, painted. `wave`: a light that runs across it - its
168
+ * place, in columns - while something runs, in 24 bits; the plain colour
169
+ * without it. Where the font may have no braille, the mark in a line instead
170
+ * (`{•}` on the second).
171
+ * @param {number | null} [wave]
172
+ * @param {{ glyphs?: string, where?: object }} [options] how the output draws (logoGlyphs, and the output as logoPaint takes it)
173
+ * @returns {string[]} four lines, each as wide
174
+ */
175
+ export function logoLines(wave = null, { glyphs = logoGlyphs(), where } = {}) {
176
+ if (glyphs !== 'box') return [' ', logoPaint(where)(MARK[glyphs]), ' ', ' '];
177
+ const depth = brandDepth(where);
178
+ if (depth < 2 || wave === null) return LOGO.map(line => logoPaint(where)(line));
179
+ // From a darker gold to a pale one, amxts yellow between.
180
+ // One spot of light, gone a few columns away from its place.
181
+ const shade = column => Math.exp(-((column - wave) ** 2) / 4);
182
+ const mix = light => [[150, 110, 30], [255, 236, 150]].reduce((low, high) => low.map((each, index) => Math.round(each + (high[index] - each) * light)));
183
+ return LOGO.map(line => `${[...line].map((dot, column) => `\x1B[38;2;${mix(shade(column)).join(';')}m${dot}`).join('')}\x1B[39m`);
184
+ }
185
+
186
+ /**
187
+ * The line a command starts with: the logo, `{•} amxts 0.2.4 · build`. With
188
+ * `logo` (dev, a new project) the logo in its four lines, the words beside the
189
+ * second.
190
+ * @param {string} version
191
+ * @param {string} [what]
192
+ * @param {{ logo?: boolean, glyphs?: string, where?: object }} [options] how the output draws (logoGlyphs, and the output as logoPaint takes it)
193
+ */
194
+ export function bannerText(version, what, { logo = false, glyphs = logoGlyphs(), where } = {}) {
195
+ const words = `${c.bold('amxts')} ${c.dim(version)}${what ? c.dim(` · ${what}`) : ''}`;
196
+ if (!logo || glyphs !== 'box') return `${logoPaint(where)(MARK[glyphs] ?? MARK.bullet)} ${words}`;
197
+ return logoLines(null, { glyphs, where }).map((line, index) => (index === 1 ? `${line} ${words}` : line)).join('\n');
198
+ }
199
+
200
+ /** Prints the line a command starts with (bannerText). */
201
+ export function banner(version, what, options) {
202
+ console.log(bannerText(version, what, options));
72
203
  }
73
204
 
74
205
  /** The closest of `known` to `given`, when it is close enough to be a typo. */