@amxts/cli 0.2.1 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/core.mjs +35 -12
- package/src/dev-feed.mjs +147 -0
- package/src/dev-panel.mjs +686 -0
- package/src/init.mjs +3 -2
- package/src/lock.mjs +94 -0
- package/src/main.mjs +77 -8
- package/src/modules.json +15 -0
- package/src/rcon.mjs +134 -0
- package/src/server-update.mjs +13 -8
- package/src/ui.mjs +134 -3
- package/src/update-check.mjs +64 -0
- package/src/upgrade.mjs +2 -0
- package/templates/module/_oxlintrc.json +5 -26
- package/templates/module/src/index.ts +1 -1
- package/templates/project/_oxlintrc.json +5 -26
- package/templates/project/plugins/hello.ts +14 -4
- package/templates/project/test/hello.test.ts +11 -0
|
@@ -0,0 +1,686 @@
|
|
|
1
|
+
// `amxts dev` in a terminal: the build's lines scroll, and a panel stays on
|
|
2
|
+
// the window's last rows - the server, the plugins, the last rebuild, the
|
|
3
|
+
// state and the keys. The build is the core's task, its output read through
|
|
4
|
+
// dev-feed.mjs; the server is asked over rcon (rcon.mjs).
|
|
5
|
+
//
|
|
6
|
+
// Drawn with plain ANSI: the rows above the panel are the terminal's
|
|
7
|
+
// scrolling region, where the lines go and scroll into its history; the panel
|
|
8
|
+
// is written at the bottom rows by their numbers.
|
|
9
|
+
//
|
|
10
|
+
// Without a terminal, in CI, in a small window, with --no-tui or
|
|
11
|
+
// AMXTS_TUI=plain, dev prints the build's lines as they come (main.mjs).
|
|
12
|
+
import { spawn } from 'node:child_process';
|
|
13
|
+
import { readFileSync } from 'node:fs';
|
|
14
|
+
import { resolve } from 'node:path';
|
|
15
|
+
import process from 'node:process';
|
|
16
|
+
import { createInterface, emitKeypressEvents } from 'node:readline';
|
|
17
|
+
import { startTask } from './core.mjs';
|
|
18
|
+
import { createFeed, plain } from './dev-feed.mjs';
|
|
19
|
+
import { stopTree } from './lock.mjs';
|
|
20
|
+
import { mapNames, pluginStates, rcon, serverState } from './rcon.mjs';
|
|
21
|
+
import { badge, c, editorLink, isCI, link, logoGlyphs, logoLines, logoPaint, vivid } from './ui.mjs';
|
|
22
|
+
|
|
23
|
+
/** The page `o` opens. */
|
|
24
|
+
export const DOCS = 'https://amxts.github.io/docs/getting-started/cli#dev';
|
|
25
|
+
/** How often the server is asked how it is, in ms. */
|
|
26
|
+
const POLL = 10_000;
|
|
27
|
+
/** The build's lines kept for `l`. */
|
|
28
|
+
const HISTORY = 2000;
|
|
29
|
+
|
|
30
|
+
const KEYS = [
|
|
31
|
+
['r', 'rebuild', 'build every plugin again, deploy it'],
|
|
32
|
+
['p', 'plugins', 'each plugin\'s state on the server'],
|
|
33
|
+
['l', 'log', 'the build\'s lines, each compile too'],
|
|
34
|
+
['e', 'last error', 'the last error again'],
|
|
35
|
+
['c', 'clear', 'clear the screen'],
|
|
36
|
+
['m', 'map', 'change the map'],
|
|
37
|
+
['R', 'round', 'restart the round (sv_restart 1)'],
|
|
38
|
+
['o', 'docs', 'open the docs in the browser'],
|
|
39
|
+
['?', 'help', 'this help: ? or Esc closes it'],
|
|
40
|
+
['q', 'quit', 'stop dev (Ctrl+C too)'],
|
|
41
|
+
];
|
|
42
|
+
|
|
43
|
+
// The Russian layout's letters as the keys they sit on: `к` is `r`, `К` is `R`,
|
|
44
|
+
// `,` (Shift+/ there) is `?` - a key works whichever layout is on.
|
|
45
|
+
const RU = 'йцукенгшщзхъфывапролджэячсмитьбю';
|
|
46
|
+
const EN = 'qwertyuiop[]asdfghjkl;\'zxcvbnm,.';
|
|
47
|
+
const LAYOUT = new Map([...RU].flatMap((letter, index) => [[letter, EN[index]], [letter.toUpperCase(), EN[index].toUpperCase()]]));
|
|
48
|
+
LAYOUT.set(',', '?');
|
|
49
|
+
|
|
50
|
+
/** A typed character as the key it is on a Latin layout. */
|
|
51
|
+
export function latin(text) {
|
|
52
|
+
return LAYOUT.get(text) ?? text;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// The compiler's names for types a plugin writes in TypeScript's words, where
|
|
56
|
+
// one stands for one only. The core's messages are the compiler's; this is
|
|
57
|
+
// the panel's reading of them until the core says them itself.
|
|
58
|
+
const TYPE_NAMES = [[/~lib\/string\/String/g, 'string'], [/'f64'/g, '\'number\''], [/'bool'/g, '\'boolean\'']];
|
|
59
|
+
|
|
60
|
+
// What a line of TypeScript is made of, as an error shows it: a comment,
|
|
61
|
+
// a string, a keyword, a number, a type.
|
|
62
|
+
const TOKENS = /(\/\/.*$)|("(?:[^"\\]|\\.)*"?|'(?:[^'\\]|\\.)*'?|`(?:[^`\\]|\\.)*`?)|\b(const|let|var|function|return|if|else|for|while|do|switch|case|break|continue|import|export|from|default|new|class|extends|implements|interface|type|enum|true|false|null|undefined|typeof|instanceof|as|async|await|of|in|this|throw|try|catch|finally)\b|\b(\d+(?:\.\d+)?)\b|\b(number|string|boolean|void|any|never|unknown|[A-Z]\w*)\b/g;
|
|
63
|
+
|
|
64
|
+
/** A line of source in colours: keywords magenta, strings green, numbers yellow, types cyan, comments dim. */
|
|
65
|
+
export function highlight(line) {
|
|
66
|
+
return line.replace(TOKENS, (token, comment, text, keyword, number) => {
|
|
67
|
+
if (comment) return c.dim(token);
|
|
68
|
+
if (text) return c.green(token);
|
|
69
|
+
if (keyword) return c.magenta(token);
|
|
70
|
+
if (number) return c.yellow(token);
|
|
71
|
+
return c.cyan(token);
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** The lines of a source file, or none when it cannot be read. */
|
|
76
|
+
function sourceLines(file) {
|
|
77
|
+
try {
|
|
78
|
+
return readFileSync(file, 'utf8').split(/\r?\n/);
|
|
79
|
+
} catch {
|
|
80
|
+
return [];
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* The last error as the panel shows it: what does not compile in bold, then
|
|
86
|
+
* each problem - its message in TypeScript's words with its code, the place,
|
|
87
|
+
* and the source with a line around it (`context`), the part at fault red -
|
|
88
|
+
* then what the build adds. The core's lines (`file:line:col - CODE message`,
|
|
89
|
+
* the source line, `~` under it) are read here; an error of another shape is
|
|
90
|
+
* shown as it came.
|
|
91
|
+
* @param {string[]} lines the error's lines, without colours: feed.error
|
|
92
|
+
* @param {{ context?: number, brief?: boolean, read?: (file: string) => string[] }} [options] lines of source around; `brief` - no blank lines, nothing the build adds (for `e`); how a file is read
|
|
93
|
+
* @returns {string[]} the lines to show
|
|
94
|
+
*/
|
|
95
|
+
export function errorBlock(lines, { context = 1, brief = false, read = sourceLines } = {}) {
|
|
96
|
+
const shaped = lines.some(line => line.endsWith(' does not compile:') || /^\S+:\d+:\d+ - \w+ /.test(line));
|
|
97
|
+
if (!shaped) return [`${c.red('✖')} ${lines[0] ?? ''}`, ...lines.slice(1)];
|
|
98
|
+
const out = [];
|
|
99
|
+
let problem = null;
|
|
100
|
+
const flush = () => {
|
|
101
|
+
if (!problem) return;
|
|
102
|
+
const { file, row, col, code, message, snippet, width } = problem;
|
|
103
|
+
const source = read(file);
|
|
104
|
+
const at = Number(row);
|
|
105
|
+
const text = source[at - 1] ?? snippet;
|
|
106
|
+
const from = Number(col) - 1;
|
|
107
|
+
const rows = [...Array.from({ length: context }, (_, index) => at - context + index), at, ...Array.from({ length: context }, (_, index) => at + 1 + index)]
|
|
108
|
+
.filter(number => number === at || source[number - 1] !== undefined);
|
|
109
|
+
const gutter = String(Math.max(...rows)).length;
|
|
110
|
+
const numbered = number => `${c.dim(`${String(number).padStart(gutter)} │`)}`;
|
|
111
|
+
// The place opens the file at it in the editor, where the terminal takes links.
|
|
112
|
+
out.push(` ${c.bold(message)} ${c.dim(code)}`, ` ${link(c.cyan(`${file}:${row}:${col}`), editorLink(resolve(file), row, col))}`);
|
|
113
|
+
for (const number of rows) {
|
|
114
|
+
if (number !== at) {
|
|
115
|
+
out.push(` ${numbered(number)} ${highlight(source[number - 1])}`);
|
|
116
|
+
continue;
|
|
117
|
+
}
|
|
118
|
+
out.push(` ${c.red('❯')} ${numbered(number)} ${highlight(text.slice(0, from))}${c.red(text.slice(from, from + width))}${highlight(text.slice(from + width))}`);
|
|
119
|
+
if (width) out.push(` ${c.dim(`${' '.repeat(gutter)} │`)} ${' '.repeat(from)}${c.red('~'.repeat(width))}`);
|
|
120
|
+
}
|
|
121
|
+
problem = null;
|
|
122
|
+
};
|
|
123
|
+
for (const raw of lines) {
|
|
124
|
+
const line = TYPE_NAMES.reduce((text, [name, word]) => text.replace(name, word), raw);
|
|
125
|
+
const title = /^(?:✖ )?(\S+) does not compile:$/.exec(line);
|
|
126
|
+
const place = /^(\S+?):(\d+):(\d+) - (\w+) (.*)$/.exec(line);
|
|
127
|
+
const source = /^\s*\d+\s*│ ?(.*)$/.exec(line);
|
|
128
|
+
const marks = /^\s*│(\s*)(~+)\s*$/.exec(line);
|
|
129
|
+
if (title) {
|
|
130
|
+
flush();
|
|
131
|
+
out.push(`${out.length && !brief ? '\n' : ''}${c.red('✖')} ${c.bold(title[1])} ${c.red('does not compile')}`, ...(brief ? [] : ['']));
|
|
132
|
+
} else if (place) {
|
|
133
|
+
flush();
|
|
134
|
+
problem = { file: place[1], row: place[2], col: place[3], code: place[4], message: place[5], snippet: '', width: 0 };
|
|
135
|
+
} else if (problem && marks) {
|
|
136
|
+
problem.width = marks[2].length;
|
|
137
|
+
} else if (problem && source) {
|
|
138
|
+
problem.snippet = source[1];
|
|
139
|
+
} else if (!line.trim()) {
|
|
140
|
+
flush();
|
|
141
|
+
} else {
|
|
142
|
+
flush();
|
|
143
|
+
if (!brief) out.push('', ` ${c.dim(line.trim())}`);
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
flush();
|
|
147
|
+
return out.flatMap(line => line.split('\n'));
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/** Whether dev shows the panel: a terminal both ways, big enough, not CI, not asked for plain lines. */
|
|
151
|
+
export function wantsPanel({ tui = true, env = process.env, stdout = process.stdout, stdin = process.stdin } = {}) {
|
|
152
|
+
if (tui === false || env.AMXTS_TUI === 'plain' || isCI(env) || !stdout.isTTY || !stdin.isTTY) return false;
|
|
153
|
+
return (stdout.columns ?? 0) >= 60 && (stdout.rows ?? 0) >= 16;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/** A coloured line cut to `width` columns, its colours kept and closed. */
|
|
157
|
+
export function fit(line, width) {
|
|
158
|
+
if ([...plain(line)].length <= width) return line;
|
|
159
|
+
let seen = 0;
|
|
160
|
+
let out = '';
|
|
161
|
+
// eslint-disable-next-line no-control-regex
|
|
162
|
+
for (const [token] of line.matchAll(/\x1B\[[\d;]*m|\x1B\]8;[^\x1B]*\x1B\\|[\s\S]/gu)) {
|
|
163
|
+
if (token.length > 1 && token.startsWith('\x1B')) {
|
|
164
|
+
out += token;
|
|
165
|
+
continue;
|
|
166
|
+
}
|
|
167
|
+
if (seen === width - 1) return `${out}…\x1B[0m`;
|
|
168
|
+
out += token;
|
|
169
|
+
seen++;
|
|
170
|
+
}
|
|
171
|
+
return out;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
const seconds = value => `${value.toFixed(1)}s`;
|
|
175
|
+
const dot = c.dim(' · ');
|
|
176
|
+
|
|
177
|
+
/** The server's folder from its addons/amxts, its last two folders when longer: `…/steam/hlds`. */
|
|
178
|
+
export function serverName(deployTo) {
|
|
179
|
+
const folder = deployTo.replace(/\/+$/, '').replace(/\/(?:cstrike\/)?addons\/amxts$/i, '');
|
|
180
|
+
const parts = folder.split('/');
|
|
181
|
+
return parts.length > 3 ? `…/${parts.slice(-2).join('/')}` : folder;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/** The server's part of the panel. */
|
|
185
|
+
function serverText(server, deployTo) {
|
|
186
|
+
const where = deployTo ? c.dim(serverName(deployTo)) : c.dim('dist/ - the Docker server reads it');
|
|
187
|
+
const how = {
|
|
188
|
+
none: '',
|
|
189
|
+
unset: c.yellow(`rcon is not set up: no rcon_password in ${server.cfg}`),
|
|
190
|
+
checking: c.dim('asking it over rcon…'),
|
|
191
|
+
down: c.yellow('not running'),
|
|
192
|
+
bad: c.red(`refused the rcon_password of ${server.cfg}`),
|
|
193
|
+
up: [server.map && c.bold(server.map), server.players !== undefined && `${server.players}/${server.max} players`, server.fps !== undefined && `${Math.round(server.fps)} fps`].filter(Boolean).join(dot),
|
|
194
|
+
}[server.status];
|
|
195
|
+
// The folder last: a long one is what the window cuts.
|
|
196
|
+
return how ? `${how}${dot}${where}` : where;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/** The last good rebuild: what changed, what it built, how long each part took, what the server did. */
|
|
200
|
+
function rebuildText(last, players) {
|
|
201
|
+
if (!last) return c.dim('nothing deployed yet');
|
|
202
|
+
const deploy = last.compile === undefined ? last.total : Math.max(0, last.total - last.compile);
|
|
203
|
+
const outcome = last.reloaded
|
|
204
|
+
? c.green(`reloaded on the server${players === undefined ? '' : `, ${players} player${players === 1 ? '' : 's'} kept`}`)
|
|
205
|
+
: last.outcome.includes(' - ') ? c.yellow(last.outcome.slice(last.outcome.indexOf(' - ') + 3).replace(/:$/, ' - see above')) : c.dim(last.outcome);
|
|
206
|
+
return [
|
|
207
|
+
`${last.files.length ? last.files.join(', ') : 'start'} ${c.dim('→')} ${c.bold(last.plugins.join(', ') || '-')}`,
|
|
208
|
+
last.boot !== undefined && `launch ${seconds(last.boot)}`,
|
|
209
|
+
last.compile !== undefined && `compile ${seconds(last.compile)}`,
|
|
210
|
+
`${last.outcome.startsWith('deployed') ? 'deploy' : 'write'} ${seconds(deploy)}`,
|
|
211
|
+
outcome,
|
|
212
|
+
last.at && c.dim(last.at),
|
|
213
|
+
].filter(Boolean).join(dot);
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/** Whether a build runs: one compiles, or the first has not ended. */
|
|
217
|
+
const busy = feed => Boolean(feed.building) || (!feed.watching && !feed.broken);
|
|
218
|
+
|
|
219
|
+
/** A bar of `=`, lit to `part` (0 to 1), `width` cells. */
|
|
220
|
+
export function bar(part, width) {
|
|
221
|
+
const full = Math.round(width * Math.min(1, Math.max(0, part)));
|
|
222
|
+
return `${logoPaint()('='.repeat(full))}${c.dim('='.repeat(width - full))}`;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/** A time as a build line says it: `321ms` under a second, `3.1s` from one. */
|
|
226
|
+
export function took(ms) {
|
|
227
|
+
return ms < 1000 ? `${Math.round(ms)}ms` : seconds(ms / 1000);
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/**
|
|
231
|
+
* How far a build is, 0 to 1: the plugins done of all, or the time so far
|
|
232
|
+
* against the time the last compile took (they compile side by side) - short
|
|
233
|
+
* of done until the core says so; a structured feed from it would say each
|
|
234
|
+
* step. Compiled, it deploys: 0.95.
|
|
235
|
+
*/
|
|
236
|
+
export function buildPart({ done, total, since }, expected, now = Date.now()) {
|
|
237
|
+
return Math.min(0.95, Math.max(done / total, (now - since) / 1000 / expected));
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/**
|
|
241
|
+
* How far a build with nothing compiling yet is, 0 to 1: the time so far
|
|
242
|
+
* against the whole of the last one (10 seconds before there is one), short of
|
|
243
|
+
* done until the core says so.
|
|
244
|
+
*/
|
|
245
|
+
export function runPart(last, since, now = Date.now()) {
|
|
246
|
+
const expected = last ? last.total + (last.boot ?? 0) : 10;
|
|
247
|
+
return Math.min(0.95, (now - since) / 1000 / expected);
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/**
|
|
251
|
+
* The build's bar from one moment to the next, `{ part, base }`: it never goes
|
|
252
|
+
* back. What comes before a compile fills it by the time, and a compile, once
|
|
253
|
+
* it starts, fills what is left from where the bar is (`base`).
|
|
254
|
+
* @param {{ part: number, base: number | null }} was
|
|
255
|
+
*/
|
|
256
|
+
export function buildProgress(was, feed, since, now = Date.now()) {
|
|
257
|
+
if (!busy(feed)) return { part: 0, base: null };
|
|
258
|
+
const base = feed.building && was.base === null ? was.part : was.base;
|
|
259
|
+
const raw = feed.building ? buildPart(feed.building, feed.expected, now) : runPart(feed.last, since, now);
|
|
260
|
+
const part = base === null ? raw : base + (0.95 - base) * (raw / 0.95);
|
|
261
|
+
return { part: Math.max(was.part, Math.min(0.95, part)), base };
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
const SPINNER = { box: '⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏', bullet: '|/-\\', ascii: '|/-\\' };
|
|
265
|
+
|
|
266
|
+
/**
|
|
267
|
+
* The state, on a badge: building - a spinner, a bar, the plugins done of all
|
|
268
|
+
* and the time so far - an error, the map changing (the bar by the last
|
|
269
|
+
* change's time, 5 seconds before one), or ready.
|
|
270
|
+
* @param {{ map: string, since: number } | null} changing the map `m` changes to
|
|
271
|
+
* @param {number | undefined} mapTime seconds the last change took
|
|
272
|
+
* @param {number | undefined} shown how far the build's bar is (buildProgress), else worked out from this moment alone
|
|
273
|
+
*/
|
|
274
|
+
function stateText(feed, tick, since, changing, mapTime, shown) {
|
|
275
|
+
const frames = SPINNER[logoGlyphs()];
|
|
276
|
+
if (feed.broken) return `${c.red('✖')} ${badge('ERROR', 'error')} ${feed.error[0]}${c.dim(` · e shows it${feed.last ? ' · the server keeps the last good build' : ''}`)}`;
|
|
277
|
+
if (changing) {
|
|
278
|
+
const part = Math.min(0.95, (Date.now() - changing.since) / 1000 / (mapTime ?? 5));
|
|
279
|
+
return `${c.cyan(frames[tick % frames.length])} ${badge('MAP', 'busy')} changing to ${c.bold(changing.map)} ${bar(part, 24)} ${Math.round(part * 100)}%${dot}${c.dim(took(Date.now() - changing.since))}`;
|
|
280
|
+
}
|
|
281
|
+
if (busy(feed)) {
|
|
282
|
+
const building = feed.building;
|
|
283
|
+
const doing = !building ? 'building and deploying' : building.done >= building.total ? 'deploying' : `compiling ${c.bold(building.what)}`;
|
|
284
|
+
const part = shown ?? (building ? buildPart(building, feed.expected) : runPart(feed.last, since));
|
|
285
|
+
const count = building ? `${dot}${c.dim(`${building.done}/${building.total}`)}` : '';
|
|
286
|
+
return `${c.cyan(frames[tick % frames.length])} ${badge('BUILDING', 'busy')} ${doing} ${bar(part, 24)} ${Math.round(part * 100)}%${count}${dot}${c.dim(took(Date.now() - (building?.since ?? since)))}`;
|
|
287
|
+
}
|
|
288
|
+
return `${c.green('✔')} ${badge('READY', 'ready')} ${c.dim('watching for changes')}`;
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
/**
|
|
292
|
+
* The panel's lines, before they are cut to the window.
|
|
293
|
+
* @param {object} state
|
|
294
|
+
* @param {ReturnType<typeof createFeed>} state.feed
|
|
295
|
+
* @param {object} state.server what rcon said: status, map, players, max, fps, cfg
|
|
296
|
+
* @param {string} state.deployTo AMXTS_SERVER, '' for none
|
|
297
|
+
* @param {boolean} state.choosing whether `m` asks for a map
|
|
298
|
+
* @param {null | '?' | 'p' | 'e'} [state.view] what the key opened in the panel: the keys, the plugins, the last error
|
|
299
|
+
* @param {{ name: string, state: string, about: string }[]} [state.states] what amxts_plugins said, for `p`
|
|
300
|
+
* @param {{ map: string, since: number } | null} [state.changing] the map `m` changes to, till the server is on it
|
|
301
|
+
* @param {number} [state.mapTime] seconds the last change of the map took
|
|
302
|
+
* @param {number} [state.part] how far the build's bar is (buildProgress)
|
|
303
|
+
* @param {string} state.input what is typed for `m`
|
|
304
|
+
* @param {string[]} state.maps the server's maps
|
|
305
|
+
* @param {number | undefined} state.players the players the last reload kept
|
|
306
|
+
* @param {number} state.tick the spinner's frame
|
|
307
|
+
* @param {number} state.since when the build started, in ms
|
|
308
|
+
* @param {string} state.title `amxts 0.2.4 · dev`, beside the logo
|
|
309
|
+
* @param {object} [state.where] the output, as logoLines takes it
|
|
310
|
+
* @param {string} [state.glyphs] what it draws with (logoGlyphs)
|
|
311
|
+
* @returns {string[]} always as many: a panel that changes its height moves the log
|
|
312
|
+
*/
|
|
313
|
+
export function panelLines({ feed, server, deployTo, choosing, view = null, states = [], changing = null, mapTime, part, input, maps, players, tick = 0, since = Date.now(), title = '', where, glyphs = logoGlyphs() }) {
|
|
314
|
+
const key = logoPaint(where);
|
|
315
|
+
const label = text => c.dim(text.padEnd(9));
|
|
316
|
+
const { built, failed } = feed.counts();
|
|
317
|
+
const found = maps.filter(map => map.includes(input));
|
|
318
|
+
// The logo on the left, a light running across it: from beyond its left
|
|
319
|
+
// edge to beyond its right one (it is 8 wide), so a round starts unseen.
|
|
320
|
+
const logo = logoLines((tick * 0.5) % 16 - 4, { glyphs, where });
|
|
321
|
+
if (view) {
|
|
322
|
+
// A view takes the place of the server and the plugins: as tall as ever, its rows five.
|
|
323
|
+
const cell = ([letter, , what]) => `${c.bold(key(letter))} ${what.padEnd(36)}`;
|
|
324
|
+
const half = KEYS.length / 2;
|
|
325
|
+
const views = {
|
|
326
|
+
'?': ['the help', KEYS.slice(0, half).map((each, index) => ` ${cell(each)} ${cell(KEYS[half + index])}`)],
|
|
327
|
+
'p': ['the plugins', pluginLines(states, feed)],
|
|
328
|
+
'e': ['the error', feed.error.length ? errorBlock(feed.error, { context: 0, brief: true }).map(line => ` ${line}`) : [` ${c.green('✔')} no error since dev started`]],
|
|
329
|
+
};
|
|
330
|
+
const [what, rows] = views[view];
|
|
331
|
+
const shown = rows.length > 5 ? [...rows.slice(0, 4), c.dim(` … ${rows.length - 4} more${view === 'e' ? ' - l shows the log' : ''}`)] : rows;
|
|
332
|
+
return ['', ...shown, ...Array.from({ length: 5 - shown.length }).fill(''), '', `${c.bold(key(view))} ${c.dim('or')} ${c.bold(key('Esc'))} ${c.dim(`closes ${what}`)}`];
|
|
333
|
+
}
|
|
334
|
+
return [
|
|
335
|
+
'',
|
|
336
|
+
...[
|
|
337
|
+
title,
|
|
338
|
+
`${label('server')}${serverText(server, deployTo)}`,
|
|
339
|
+
`${label('plugins')}${c.green(`✔ ${built} built`)} ${failed ? c.red(`✖ ${failed} failed`) : c.dim('✖ 0 failed')}${feed.count ? c.dim(` · ${feed.count} in the project`) : ''}`,
|
|
340
|
+
`${label('last')}${rebuildText(feed.last, players)}`,
|
|
341
|
+
].map((line, index) => `${logo[index]} ${line}`),
|
|
342
|
+
'',
|
|
343
|
+
stateText(feed, tick, since, changing, mapTime, part),
|
|
344
|
+
choosing
|
|
345
|
+
? `${c.bold(key('map'))} ${input}${key('█')} ${c.dim(found.slice(0, 12).join(' ') || 'no such map')}${dot}${c.dim('Enter changes it · Esc cancels')}`
|
|
346
|
+
: KEYS.map(([letter, name]) => `${c.bold(key(letter))} ${c.dim(name)}`).join(dot),
|
|
347
|
+
];
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
/**
|
|
351
|
+
* Each plugin and its state, as `p` prints them: what the server's
|
|
352
|
+
* `amxts_plugins` said, else what the build knows.
|
|
353
|
+
* @param {{ name: string, state: string, about: string }[]} states
|
|
354
|
+
* @param {ReturnType<typeof createFeed>} feed
|
|
355
|
+
*/
|
|
356
|
+
export function pluginLines(states, feed) {
|
|
357
|
+
const rows = states.length
|
|
358
|
+
? states.map(each => [each.name, each.state, each.about])
|
|
359
|
+
: [...feed.plugins].map(([name, state]) => [name, state, 'the server does not say: rcon']);
|
|
360
|
+
if (!rows.length) return [c.dim(' no plugin built yet')];
|
|
361
|
+
const wide = Math.max(...rows.map(([name]) => name.length));
|
|
362
|
+
const paint = { running: c.green, refused: c.red, failed: c.red, unloaded: c.yellow };
|
|
363
|
+
return rows.map(([name, state, about]) => ` ${c.bold(name.padEnd(wide))} ${(paint[state] ?? c.dim)(state.padEnd(9))}${c.dim(about)}`);
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
/** Opens a page in the browser by the system's own means: no shell. */
|
|
367
|
+
function openPage(url) {
|
|
368
|
+
const [file, args] = process.platform === 'win32'
|
|
369
|
+
? ['rundll32.exe', ['url.dll,FileProtocolHandler', url]]
|
|
370
|
+
: [process.platform === 'darwin' ? 'open' : 'xdg-open', [url]];
|
|
371
|
+
const child = spawn(file, args, { detached: true, stdio: 'ignore', windowsHide: true });
|
|
372
|
+
child.on('error', () => {});
|
|
373
|
+
child.unref();
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
/**
|
|
377
|
+
* Runs the build under the panel until `q`.
|
|
378
|
+
* @param {import('./core.mjs').Core} core
|
|
379
|
+
* @param {string[]} args the build task's
|
|
380
|
+
* @param {{ target: import('./rcon.mjs').RconTarget | null, deployTo: string }} server
|
|
381
|
+
*/
|
|
382
|
+
export function devPanel(core, args, { target, deployTo }) {
|
|
383
|
+
const out = process.stdout;
|
|
384
|
+
const stdin = process.stdin;
|
|
385
|
+
const feed = createFeed();
|
|
386
|
+
/** @type {{ line: string, hidden: boolean }[]} */
|
|
387
|
+
const history = [];
|
|
388
|
+
const state = {
|
|
389
|
+
feed,
|
|
390
|
+
server: { status: !target ? 'none' : target.password ? 'checking' : 'unset', cfg: target?.cfg },
|
|
391
|
+
deployTo,
|
|
392
|
+
choosing: false,
|
|
393
|
+
view: null,
|
|
394
|
+
states: [],
|
|
395
|
+
changing: null,
|
|
396
|
+
mapTime: undefined,
|
|
397
|
+
input: '',
|
|
398
|
+
maps: [],
|
|
399
|
+
players: undefined,
|
|
400
|
+
tick: 0,
|
|
401
|
+
title: `${c.bold('amxts')} ${c.dim(`${core.version} · dev`)}`,
|
|
402
|
+
since: Date.now(),
|
|
403
|
+
};
|
|
404
|
+
/** Whether the panel's rows are set apart: the first draw does it, and again after `c` or a new window size. */
|
|
405
|
+
let placed = false;
|
|
406
|
+
/** The window's clear, written with the next draw. */
|
|
407
|
+
let wipe = '';
|
|
408
|
+
let queued = [];
|
|
409
|
+
let timer = null;
|
|
410
|
+
let child = null;
|
|
411
|
+
let restarting = false;
|
|
412
|
+
let polling = false;
|
|
413
|
+
/** The build's bar, which never goes back (buildProgress). */
|
|
414
|
+
let progress = { part: 0, base: null };
|
|
415
|
+
|
|
416
|
+
// The log's place is kept with the cursor's save (ESC 7) and restore (ESC 8);
|
|
417
|
+
// the panel is always as tall, so the region is set once.
|
|
418
|
+
const flush = () => {
|
|
419
|
+
timer = null;
|
|
420
|
+
const { rows, columns } = out;
|
|
421
|
+
progress = buildProgress(progress, feed, state.since);
|
|
422
|
+
const lines = panelLines({ ...state, part: busy(feed) ? progress.part : undefined }).map(line => vivid(fit(line, columns - 1)));
|
|
423
|
+
const top = rows - lines.length;
|
|
424
|
+
// A clear goes in the same write as what is drawn after it: nothing jumps.
|
|
425
|
+
let text = wipe;
|
|
426
|
+
wipe = '';
|
|
427
|
+
if (!placed) {
|
|
428
|
+
// Room at the bottom, wherever the cursor is, and the rows above it for the log.
|
|
429
|
+
text += `${'\n'.repeat(lines.length)}\x1B[${lines.length}A\x1B7\x1B[1;${top}r`;
|
|
430
|
+
placed = true;
|
|
431
|
+
}
|
|
432
|
+
text += `\x1B8${queued.map(line => `\x1B[2K${vivid(line)}\n`).join('')}\x1B7`;
|
|
433
|
+
text += lines.map((line, index) => `\x1B[${top + 1 + index};1H\x1B[2K${line}`).join('');
|
|
434
|
+
text += '\x1B8';
|
|
435
|
+
out.write(text);
|
|
436
|
+
queued = [];
|
|
437
|
+
};
|
|
438
|
+
const redraw = () => {
|
|
439
|
+
timer ??= setTimeout(flush, 16);
|
|
440
|
+
};
|
|
441
|
+
const print = (...lines) => {
|
|
442
|
+
queued.push(...lines);
|
|
443
|
+
redraw();
|
|
444
|
+
};
|
|
445
|
+
/** The window cleared - with the terminal's history too, for `c`. */
|
|
446
|
+
const clear = (scrollback = true) => {
|
|
447
|
+
queued = [];
|
|
448
|
+
placed = false;
|
|
449
|
+
wipe = `\x1B[r\x1B[2J${scrollback ? '\x1B[3J' : ''}\x1B[H`;
|
|
450
|
+
redraw();
|
|
451
|
+
};
|
|
452
|
+
|
|
453
|
+
const ask = async (command) => {
|
|
454
|
+
if (!target?.password) {
|
|
455
|
+
print(`${c.yellow('▲')} ${state.server.status === 'none' ? 'no server to ask: AMXTS_SERVER is not set' : `rcon is not set up: add rcon_password "something" to ${target.cfg} and restart the server`}`);
|
|
456
|
+
return null;
|
|
457
|
+
}
|
|
458
|
+
const answer = await rcon(target, command);
|
|
459
|
+
if ('error' in answer) {
|
|
460
|
+
print(`${c.red('✖')} rcon ${command}: ${answer.error === 'no answer' ? 'the server does not answer' : `the server refused the rcon_password of ${target.cfg}`}`);
|
|
461
|
+
return null;
|
|
462
|
+
}
|
|
463
|
+
return answer.reply;
|
|
464
|
+
};
|
|
465
|
+
/** A command of the keys: said, sent, and what the server answered under it. */
|
|
466
|
+
const run = async (command) => {
|
|
467
|
+
print(`${c.cyan('◇')} ${command}`);
|
|
468
|
+
const reply = await ask(command);
|
|
469
|
+
if (reply?.trim()) print(...reply.trimEnd().split('\n').map(line => c.dim(` ${line}`)));
|
|
470
|
+
return reply;
|
|
471
|
+
};
|
|
472
|
+
const poll = async () => {
|
|
473
|
+
if (!target?.password || polling) return;
|
|
474
|
+
polling = true;
|
|
475
|
+
try {
|
|
476
|
+
const status = await rcon(target, 'status');
|
|
477
|
+
if ('error' in status) {
|
|
478
|
+
state.server = { status: status.error === 'no answer' ? 'down' : 'bad', cfg: target.cfg };
|
|
479
|
+
} else {
|
|
480
|
+
const stats = await rcon(target, 'stats');
|
|
481
|
+
state.server = { status: 'up', cfg: target.cfg, ...serverState(status.reply, 'reply' in stats ? stats.reply : '') };
|
|
482
|
+
if (state.changing && state.server.map === state.changing.map) {
|
|
483
|
+
state.mapTime = (Date.now() - state.changing.since) / 1000;
|
|
484
|
+
print(`${c.green('✔')} the server is on ${c.bold(state.changing.map)} ${c.dim(`(${seconds(state.mapTime)})`)}`);
|
|
485
|
+
state.changing = null;
|
|
486
|
+
}
|
|
487
|
+
if (state.view === 'p') {
|
|
488
|
+
const list = await rcon(target, 'amxts_plugins');
|
|
489
|
+
state.states = 'reply' in list ? pluginStates(list.reply) : [];
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
} finally {
|
|
493
|
+
polling = false;
|
|
494
|
+
redraw();
|
|
495
|
+
}
|
|
496
|
+
};
|
|
497
|
+
/** `changelevel`, then the server asked every half a second till it is on the map - a minute at most. */
|
|
498
|
+
const changeMap = async (map) => {
|
|
499
|
+
state.changing = { map, since: Date.now() };
|
|
500
|
+
if (await run(`changelevel ${map}`) === null) {
|
|
501
|
+
state.changing = null;
|
|
502
|
+
return;
|
|
503
|
+
}
|
|
504
|
+
const watch = setInterval(() => {
|
|
505
|
+
if (state.changing && Date.now() - state.changing.since > 60_000) {
|
|
506
|
+
print(`${c.yellow('▲')} the server is not on ${c.bold(map)} after a minute`);
|
|
507
|
+
state.changing = null;
|
|
508
|
+
}
|
|
509
|
+
if (!state.changing) clearInterval(watch);
|
|
510
|
+
else void poll();
|
|
511
|
+
}, 500);
|
|
512
|
+
};
|
|
513
|
+
|
|
514
|
+
let errorTimer = null;
|
|
515
|
+
const showError = () => {
|
|
516
|
+
clearTimeout(errorTimer);
|
|
517
|
+
errorTimer = null;
|
|
518
|
+
print(...errorBlock(feed.error));
|
|
519
|
+
};
|
|
520
|
+
|
|
521
|
+
const start = () => {
|
|
522
|
+
state.since = Date.now();
|
|
523
|
+
// A build started again is busy until it watches, even with nothing to compile.
|
|
524
|
+
feed.watching = false;
|
|
525
|
+
feed.started = Date.now();
|
|
526
|
+
feed.broken = false;
|
|
527
|
+
// Blank lines before the build says anything of its own are the header's.
|
|
528
|
+
let leading = true;
|
|
529
|
+
child = startTask(core, 'build', args, { pipe: true });
|
|
530
|
+
for (const stream of [child.stdout, child.stderr]) {
|
|
531
|
+
createInterface({ input: stream }).on('line', (line) => {
|
|
532
|
+
const kind = feed.push(line);
|
|
533
|
+
// The header and watching are the panel's to say, in no log; what
|
|
534
|
+
// compiles the panel says too, and `l` shows it.
|
|
535
|
+
const panels = kind === 'header' || kind === 'watching' || (leading && !line.trim());
|
|
536
|
+
const hidden = panels || kind === 'compiling';
|
|
537
|
+
if (!hidden) leading = false;
|
|
538
|
+
if (!panels) history.push({ line, hidden });
|
|
539
|
+
if (history.length > HISTORY) history.shift();
|
|
540
|
+
if (kind === 'rebuilt') {
|
|
541
|
+
state.players = feed.last?.reloaded && state.server.status === 'up' ? state.server.players : undefined;
|
|
542
|
+
void poll();
|
|
543
|
+
}
|
|
544
|
+
// An error is shown whole once its lines stop coming: a block, read from them all.
|
|
545
|
+
if (kind === 'error') {
|
|
546
|
+
clearTimeout(errorTimer);
|
|
547
|
+
errorTimer = setTimeout(showError, 40);
|
|
548
|
+
return;
|
|
549
|
+
}
|
|
550
|
+
if (errorTimer) showError();
|
|
551
|
+
if (hidden) redraw();
|
|
552
|
+
else print(line);
|
|
553
|
+
});
|
|
554
|
+
}
|
|
555
|
+
child.on('exit', (code) => {
|
|
556
|
+
if (restarting) {
|
|
557
|
+
restarting = false;
|
|
558
|
+
start();
|
|
559
|
+
return;
|
|
560
|
+
}
|
|
561
|
+
quit(code || 1);
|
|
562
|
+
});
|
|
563
|
+
};
|
|
564
|
+
|
|
565
|
+
/**
|
|
566
|
+
* Stops dev: the window cleared - its history kept - and a line of what
|
|
567
|
+
* happened left, the build's error with it when the build stopped on its own.
|
|
568
|
+
*/
|
|
569
|
+
function quit(code = 0) {
|
|
570
|
+
if (timer) clearTimeout(timer);
|
|
571
|
+
const words = code
|
|
572
|
+
? [...(feed.error.length ? [...errorBlock(feed.error), ''] : []), `${c.red('✖')} the build stopped${code === 1 ? '' : ` (exit code ${code})`}`]
|
|
573
|
+
: [`${c.green('✔')} amxts dev stopped`];
|
|
574
|
+
out.write(`\x1B[r\x1B[2J\x1B[H\x1B[?25h\x1B[?2004l${words.map(vivid).join('\n')}\n`);
|
|
575
|
+
if (stdin.isTTY) stdin.setRawMode(false);
|
|
576
|
+
if (child && child.exitCode === null) {
|
|
577
|
+
child.removeAllListeners('exit');
|
|
578
|
+
stopTree(child.pid);
|
|
579
|
+
}
|
|
580
|
+
process.exit(code);
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
const mapKey = (text, key) => {
|
|
584
|
+
if (key.name === 'escape') {
|
|
585
|
+
state.choosing = false;
|
|
586
|
+
} else if (key.name === 'backspace') {
|
|
587
|
+
state.input = state.input.slice(0, -1);
|
|
588
|
+
} else if (key.name === 'return') {
|
|
589
|
+
const found = state.maps.filter(map => map.includes(state.input));
|
|
590
|
+
const map = found.includes(state.input) ? state.input : found[0];
|
|
591
|
+
state.choosing = false;
|
|
592
|
+
if (map) void changeMap(map);
|
|
593
|
+
} else if (text && /^[\w.-]$/.test(text)) {
|
|
594
|
+
state.input += text;
|
|
595
|
+
}
|
|
596
|
+
redraw();
|
|
597
|
+
};
|
|
598
|
+
|
|
599
|
+
/** Opens a view in the panel - `?`, `p` or `e` - or closes it when it is open. */
|
|
600
|
+
const toggle = (view) => {
|
|
601
|
+
state.view = state.view === view ? null : view;
|
|
602
|
+
redraw();
|
|
603
|
+
};
|
|
604
|
+
|
|
605
|
+
const actions = {
|
|
606
|
+
r() {
|
|
607
|
+
// One at a time: a build that runs is let finish.
|
|
608
|
+
if (restarting || busy(feed)) return;
|
|
609
|
+
print(`${c.cyan('◇')} rebuilding every plugin`);
|
|
610
|
+
restarting = true;
|
|
611
|
+
stopTree(child.pid);
|
|
612
|
+
},
|
|
613
|
+
p() {
|
|
614
|
+
toggle('p');
|
|
615
|
+
void poll();
|
|
616
|
+
},
|
|
617
|
+
l() {
|
|
618
|
+
print(...(history.length ? [c.dim(`── the build's last ${history.length} lines ──`), ...history.map(each => each.line), c.dim('──')] : [c.dim('the build has said nothing yet')]));
|
|
619
|
+
},
|
|
620
|
+
'e': () => toggle('e'),
|
|
621
|
+
c() {
|
|
622
|
+
clear();
|
|
623
|
+
},
|
|
624
|
+
async m() {
|
|
625
|
+
const reply = await ask('maps *');
|
|
626
|
+
if (reply === null) return;
|
|
627
|
+
state.maps = mapNames(reply);
|
|
628
|
+
state.input = '';
|
|
629
|
+
state.choosing = true;
|
|
630
|
+
redraw();
|
|
631
|
+
},
|
|
632
|
+
'R': () => run('sv_restart 1'),
|
|
633
|
+
o() {
|
|
634
|
+
print(`${c.cyan('◇')} opened the docs of amxts dev in the browser ${c.dim(`(${DOCS})`)}`);
|
|
635
|
+
openPage(DOCS);
|
|
636
|
+
},
|
|
637
|
+
'?': () => toggle('?'),
|
|
638
|
+
'q': () => quit(0),
|
|
639
|
+
};
|
|
640
|
+
|
|
641
|
+
// Esc alone is told from the start of a key's sequence after 50 ms, not readline's 500.
|
|
642
|
+
emitKeypressEvents(stdin, { escapeCodeTimeout: 50 });
|
|
643
|
+
stdin.setRawMode(true);
|
|
644
|
+
stdin.resume();
|
|
645
|
+
// Pasted text is no keys: a terminal brackets a paste (ESC[200~ … ESC[201~,
|
|
646
|
+
// asked for below), and one without brackets sends it in one piece of
|
|
647
|
+
// several characters, where a key is one.
|
|
648
|
+
let pasting = false;
|
|
649
|
+
let piece = false;
|
|
650
|
+
stdin.prependListener('data', (chunk) => {
|
|
651
|
+
const text = String(chunk);
|
|
652
|
+
piece = text.length > 1 && !text.startsWith('\x1B');
|
|
653
|
+
});
|
|
654
|
+
stdin.on('keypress', (text, key = {}) => {
|
|
655
|
+
if (key.name === 'paste-start' || key.name === 'paste-end') {
|
|
656
|
+
pasting = key.name === 'paste-start';
|
|
657
|
+
return undefined;
|
|
658
|
+
}
|
|
659
|
+
if (key.ctrl && key.name === 'c') return quit(0);
|
|
660
|
+
// What is pasted goes into the map's name only.
|
|
661
|
+
if (pasting || piece) return state.choosing && !key.ctrl ? mapKey(latin(text), {}) : undefined;
|
|
662
|
+
if (key.ctrl || key.meta) return undefined;
|
|
663
|
+
if (state.choosing) return mapKey(latin(text), key);
|
|
664
|
+
if (key.name === 'escape' && state.view) return toggle(state.view);
|
|
665
|
+
return actions[latin(text)]?.();
|
|
666
|
+
});
|
|
667
|
+
// A window that changed size reflows what is on it: drawn anew from the log.
|
|
668
|
+
out.on('resize', () => {
|
|
669
|
+
clear(false);
|
|
670
|
+
print(...history.filter(each => !each.hidden).slice(-(out.rows - 12)).map(each => each.line));
|
|
671
|
+
});
|
|
672
|
+
for (const signal of ['SIGTERM', 'SIGHUP']) process.on(signal, () => quit(0));
|
|
673
|
+
process.on('exit', () => out.write('\x1B[r\x1B[?25h\x1B[?2004l'));
|
|
674
|
+
|
|
675
|
+
// The cursor hidden, pastes bracketed.
|
|
676
|
+
out.write('\x1B[?25l\x1B[?2004h');
|
|
677
|
+
start();
|
|
678
|
+
void poll();
|
|
679
|
+
setInterval(poll, POLL).unref();
|
|
680
|
+
// The light runs across the logo, and the spinner turns while a build runs.
|
|
681
|
+
setInterval(() => {
|
|
682
|
+
state.tick++;
|
|
683
|
+
redraw();
|
|
684
|
+
}, 100).unref();
|
|
685
|
+
redraw();
|
|
686
|
+
}
|