@amxts/cli 0.2.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/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. */
@@ -0,0 +1,64 @@
1
+ // The line that says a newer amxts is out: `amxts 0.2.5 is out: npx amxts
2
+ // upgrade`. Read from a file the last check wrote, so it costs a command
3
+ // nothing; once a day a check runs in a process of its own, left behind, and
4
+ // writes it again. Only in a terminal, not in CI; AMXTS_IGNORE_UPDATE_CHECK=1
5
+ // turns it off.
6
+ import { spawn } from 'node:child_process';
7
+ import { mkdirSync, readFileSync, writeFileSync } from 'node:fs';
8
+ import { homedir } from 'node:os';
9
+ import { dirname, join } from 'node:path';
10
+ import process from 'node:process';
11
+ import { fileURLToPath } from 'node:url';
12
+ import { compareVersions } from './core.mjs';
13
+ import { execAmxts } from './pm.mjs';
14
+ import { isCI } from './ui.mjs';
15
+
16
+ /** Where the last check is kept: `{ checked, latest }`. */
17
+ export const UPDATE_FILE = join(homedir(), '.cache', 'amxts', 'update.json');
18
+ const DAY = 24 * 60 * 60 * 1000;
19
+
20
+ function save(file, data) {
21
+ try {
22
+ mkdirSync(dirname(file), { recursive: true });
23
+ writeFileSync(file, `${JSON.stringify(data)}\n`);
24
+ } catch {}
25
+ }
26
+
27
+ /**
28
+ * The line about a newer core than the project's, or null; starts the day's
29
+ * check when the last one is older.
30
+ * @param {string} version the project's core
31
+ * @param {string} pm its package manager, for the words
32
+ * @param {{ env?: Record<string, string | undefined>, tty?: boolean, now?: number, file?: string, check?: (file: string) => void }} [options]
33
+ */
34
+ export function updateLine(version, pm, { env = process.env, tty = Boolean(process.stdout.isTTY), now = Date.now(), file = UPDATE_FILE, check = startCheck } = {}) {
35
+ if (env.AMXTS_IGNORE_UPDATE_CHECK === '1' || !tty || isCI(env)) return null;
36
+ let last = {};
37
+ try {
38
+ last = JSON.parse(readFileSync(file, 'utf8'));
39
+ } catch {}
40
+ if (!(now - (last.checked ?? 0) < DAY)) {
41
+ // Written first: a second command in the same minute does not check again.
42
+ save(file, { ...last, checked: now });
43
+ check(file);
44
+ }
45
+ return last.latest && compareVersions(last.latest, version) > 0 ? `amxts ${last.latest} is out: ${execAmxts(pm)} upgrade` : null;
46
+ }
47
+
48
+ /** Starts the check in a process of its own, which the command does not wait for. */
49
+ function startCheck(file) {
50
+ try {
51
+ spawn(process.execPath, [fileURLToPath(import.meta.url), file], { detached: true, stdio: 'ignore', windowsHide: true }).unref();
52
+ } catch {}
53
+ }
54
+
55
+ // The check itself: the latest @amxts/core on the registry npm is set to.
56
+ if (process.argv[1] === fileURLToPath(import.meta.url)) {
57
+ const file = process.argv[2];
58
+ const registry = (process.env.npm_config_registry || 'https://registry.npmjs.org/').replace(/\/?$/, '/');
59
+ try {
60
+ const response = await fetch(`${registry}@amxts/core/latest`, { signal: AbortSignal.timeout(10_000) });
61
+ const { version } = await response.json();
62
+ if (typeof version === 'string') save(file, { checked: Date.now(), latest: version });
63
+ } catch {}
64
+ }
package/src/upgrade.mjs CHANGED
@@ -240,6 +240,8 @@ export async function upgrade({ to, server = true, serverOnly = false, dryRun =
240
240
  : { ok: true, line: `the module of amxts ${version} goes in once @amxts/core ${version} is installed` };
241
241
  }
242
242
  console.log(['', ...summary(outcome)].join('\n'));
243
+ // A script that upgrades and goes on hears that the build failed.
244
+ if (outcome.build === 'failed') process.exitCode = 1;
243
245
  return outcome;
244
246
  }
245
247
 
@@ -18,41 +18,20 @@
18
18
  "**/node_modules/",
19
19
  "**/*.tgz",
20
20
  "**/bun.lock",
21
+ "**/bun.lockb",
21
22
  "**/package-lock.json",
22
23
  "**/pnpm-lock.yaml",
23
24
  "**/yarn.lock",
25
+ "**/.yarn",
24
26
  "**/.amxts/",
25
27
  "**/dist/",
26
- "**/node_modules",
27
- "**/dist",
28
- "**/bun.lockb",
29
- "**/output",
30
28
  "**/coverage",
31
- "**/temp",
32
- "**/.temp",
33
- "**/tmp",
34
- "**/.tmp",
35
- "**/.history",
36
- "**/.vitepress/cache",
37
- "**/.nuxt",
38
- "**/.next",
39
- "**/.svelte-kit",
40
- "**/.vercel",
41
- "**/.changeset",
42
- "**/.idea",
43
29
  "**/.cache",
44
- "**/.output",
45
- "**/.vite-inspect",
46
- "**/.yarn",
30
+ "**/__snapshots__",
47
31
  "**/CHANGELOG*.md",
48
32
  "**/LICENSE*",
49
- "**/*.min.*",
50
- "**/__snapshots__",
51
- "**/vite.config.*.timestamp-*",
52
- "**/auto-import.d.ts",
53
- "**/auto-imports.d.ts",
54
- "**/components.d.ts",
55
- "**/.context",
33
+ "**/.history",
34
+ "**/.idea",
56
35
  "**/.claude",
57
36
  "**/.agents",
58
37
  "**/.*/skills"
@@ -21,5 +21,5 @@ let greeting = "";
21
21
 
22
22
  /** Greets the player in chat. */
23
23
  export function greet(player: Player) {
24
- print(player, `${greeting}, ${player.name}!`);
24
+ player.print(`${greeting}, ${player.name}!`);
25
25
  }
@@ -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.
@@ -18,41 +18,20 @@
18
18
  "**/node_modules/",
19
19
  "**/*.tgz",
20
20
  "**/bun.lock",
21
+ "**/bun.lockb",
21
22
  "**/package-lock.json",
22
23
  "**/pnpm-lock.yaml",
23
24
  "**/yarn.lock",
25
+ "**/.yarn",
24
26
  "**/.amxts/",
25
27
  "**/dist/",
26
- "**/node_modules",
27
- "**/dist",
28
- "**/bun.lockb",
29
- "**/output",
30
28
  "**/coverage",
31
- "**/temp",
32
- "**/.temp",
33
- "**/tmp",
34
- "**/.tmp",
35
- "**/.history",
36
- "**/.vitepress/cache",
37
- "**/.nuxt",
38
- "**/.next",
39
- "**/.svelte-kit",
40
- "**/.vercel",
41
- "**/.changeset",
42
- "**/.idea",
43
29
  "**/.cache",
44
- "**/.output",
45
- "**/.vite-inspect",
46
- "**/.yarn",
30
+ "**/__snapshots__",
47
31
  "**/CHANGELOG*.md",
48
32
  "**/LICENSE*",
49
- "**/*.min.*",
50
- "**/__snapshots__",
51
- "**/vite.config.*.timestamp-*",
52
- "**/auto-import.d.ts",
53
- "**/auto-imports.d.ts",
54
- "**/components.d.ts",
55
- "**/.context",
33
+ "**/.history",
34
+ "**/.idea",
56
35
  "**/.claude",
57
36
  "**/.agents",
58
37
  "**/.*/skills"
@@ -14,12 +14,17 @@ const greeting = "Welcome";
14
14
  // #endif
15
15
 
16
16
  server.addCommand("/hp", ({ player }) => sayHp(player));
17
- server.addEventListener("putInServer", event => print(0, `${greeting}, ${event.player.name}!`));
17
+ server.addEventListener("putInServer", event => event.player.print(`${greeting}, ${event.player.name}!`));
18
18
  // #if menu-core
19
19
 
20
20
  // A menu made in code: its title and items can be functions of the player it is shown to.
21
21
  const hello = menus.create("HELLO", { title: ({ player }) => `Hello, ${player.name}` });
22
- hello.addItem({ title: "Wave", onSelect: ({ player }) => print(0, `${player.name} waves`) });
22
+ hello.addItem({
23
+ title: "Wave",
24
+ onSelect: ({ player }) => {
25
+ server.print(`${player.name} waves`);
26
+ },
27
+ });
23
28
  hello.addItem({
24
29
  title: ({ player }) => `Heal (${player.health} HP)`,
25
30
  visible: ({ player }) => player.health < 100,
@@ -34,7 +39,12 @@ server.addCommand("/menu", ({ player }) => hello.show(player));
34
39
 
35
40
  // A menu: its title and items can be functions of the player it is shown to.
36
41
  const hello = new Menu(({ player }) => `Hello, ${player.name}`);
37
- hello.addItem({ title: "Wave", onSelect: ({ player }) => print(0, `${player.name} waves`) });
42
+ hello.addItem({
43
+ title: "Wave",
44
+ onSelect: ({ player }) => {
45
+ server.print(`${player.name} waves`);
46
+ },
47
+ });
38
48
  hello.addItem({
39
49
  title: ({ player }) => `Heal (${player.health} HP)`,
40
50
  visible: ({ player }) => player.health < 100,
@@ -47,5 +57,5 @@ server.addCommand("/menu", ({ player }) => hello.show(player));
47
57
  // #endif
48
58
 
49
59
  function sayHp(player: Player) {
50
- print(player, `${player.name}, your HP: ${player.health}`);
60
+ player.print(`${player.name}, your HP: ${player.health}`);
51
61
  }
@@ -1,11 +1,21 @@
1
+ // #if bun
1
2
  import { expect, setDefaultTimeout, test } from "bun:test";
3
+ // #endif
4
+ // #if !bun
5
+ import { expect, test, vi } from "vitest";
6
+ // #endif
2
7
  import { setup } from "@amxts/core/test-utils";
3
8
  // #if menu-core
4
9
  import { menusOf } from "@amxts/menu-core/testing";
5
10
  // #endif
6
11
 
7
12
  // The first run compiles the plugins; the next ones take them from the cache.
13
+ // #if bun
8
14
  setDefaultTimeout(120_000);
15
+ // #endif
16
+ // #if !bun
17
+ vi.setConfig({ testTimeout: 120_000 });
18
+ // #endif
9
19
 
10
20
  test("a player who joins is greeted", async () => {
11
21
  const server = await setup();
@@ -34,4 +44,15 @@ test("/menu heals a hurt player", async () => {
34
44
  menus.press(alice, 2);
35
45
  expect(alice.health).toBe(100);
36
46
  });
47
+
48
+ test("Wave in /menu tells everyone", async () => {
49
+ const server = await setup();
50
+ const menus = menusOf(server);
51
+ const alice = server.join("Alice");
52
+ const bob = server.join("Bob");
53
+
54
+ alice.say("/menu");
55
+ menus.press(alice, 1);
56
+ expect(bob.chat).toContain("Alice waves");
57
+ });
37
58
  // #endif
@@ -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