@amxts/cli 0.1.0 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -11,6 +11,7 @@ its package manager:
11
11
  npx amxts --help
12
12
  npx amxts dev # build, deploy, again on every save
13
13
  npx amxts module add menu-core
14
+ npx amxts upgrade # the latest amxts: packages, code, build, server
14
15
  ```
15
16
 
16
17
  A new project: `npm create amxts@latest`. Installed globally
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@amxts/cli",
3
3
  "type": "module",
4
- "version": "0.1.0",
4
+ "version": "0.2.1",
5
5
  "description": "The amxts command: create a project or a module, add modules, build, deploy, type-check and test",
6
6
  "author": {
7
7
  "name": "kukson777",
package/src/catalog.mjs CHANGED
@@ -8,7 +8,7 @@
8
8
  // ../greeter, file:../x the package in that folder
9
9
  // menu-core --local the folder the core on this machine takes an
10
10
  // official module from (its package.json's
11
- // `file:` link) - until they are on npm
11
+ // `file:` link), for working on them
12
12
  import { spawnSync } from 'node:child_process';
13
13
  import { existsSync, readFileSync } from 'node:fs';
14
14
  import { isAbsolute, join, relative, resolve } from 'node:path';
package/src/core.mjs CHANGED
@@ -15,7 +15,7 @@ import { createRequire } from 'node:module';
15
15
  import { dirname, join, resolve } from 'node:path';
16
16
  import process from 'node:process';
17
17
  import { fileURLToPath, pathToFileURL } from 'node:url';
18
- import { addArgs, commandLine, detectPackageManager, versionOf } from './pm.mjs';
18
+ import { addArgs, commandLine, detectPackageManager, execAmxts, versionOf } from './pm.mjs';
19
19
  import { CliError, debug } from './ui.mjs';
20
20
 
21
21
  /** The command's own package folder: packages/cli in its repository. */
@@ -24,14 +24,15 @@ export const CLI_PACKAGE = readJson(join(CLI_DIR, 'package.json'));
24
24
  export const VERSION = String(CLI_PACKAGE.version);
25
25
 
26
26
  /** The cores this command works with: the range a new project asks for. */
27
- export const CORE_RANGE = '^0.1.0';
27
+ export const CORE_RANGE = '^0.2.0';
28
28
  /** The version of `@amxts/core/cli-api` this command speaks. */
29
- export const CLI_API = 3;
29
+ export const CLI_API = 5;
30
30
 
31
31
  /**
32
32
  * Run from a checkout of the command's repository (npm link, a clone) rather
33
33
  * than from npm: then a new project takes the core and the official modules
34
- * from this machine by default, as --local says - they are not on npm to fetch.
34
+ * from this machine by default, as --local says: the checkout is for working
35
+ * on them together.
35
36
  */
36
37
  export const FROM_SOURCE = existsSync(join(CLI_DIR, '..', '..', '.git')) && readJson(join(CLI_DIR, '..', '..', 'package.json'))?.name === 'amxts-cli';
37
38
 
@@ -85,14 +86,38 @@ function parts(version) {
85
86
  return String(version).split('-')[0].split('.').map(each => Number.parseInt(each, 10) || 0);
86
87
  }
87
88
 
88
- /** Whether a version is in a caret range: `^0.1.0` takes 0.1.x from 0.1.0 up. */
89
+ /** Two versions compared: below 0 when `a` comes first. */
90
+ export function compareVersions(a, b) {
91
+ const [x, y] = [parts(a), parts(b)];
92
+ return x[0] - y[0] || x[1] - y[1] || x[2] - y[2];
93
+ }
94
+
95
+ /**
96
+ * One comparator of a range as a test of a version: `^0.2.0` (0.2.x from
97
+ * 0.2.0 up - below 1.0 the minor is the breaking number), `~0.2.1`, `>=0.2.0`,
98
+ * `<1.0.0`, `0.2.0`, `*`.
99
+ */
100
+ function comparator(text) {
101
+ const [, op = '=', want = '*'] = /^([<>]=?|[\^~=])?v?(.*)$/.exec(text);
102
+ if (/^[*x]?$/i.test(want)) return () => true;
103
+ const [major, minor, patch] = parts(want);
104
+ const upper = op === '^' ? (major ? `${major + 1}.0.0` : minor ? `0.${minor + 1}.0` : `0.0.${patch + 1}`) : `${major}.${minor + 1}.0`;
105
+ const within = version => compareVersions(version, want) >= 0 && compareVersions(version, upper) < 0;
106
+ const tests = {
107
+ '^': within,
108
+ '~': within,
109
+ '>=': version => compareVersions(version, want) >= 0,
110
+ '<=': version => compareVersions(version, want) <= 0,
111
+ '>': version => compareVersions(version, want) > 0,
112
+ '<': version => compareVersions(version, want) < 0,
113
+ '=': version => compareVersions(version, want) === 0,
114
+ };
115
+ return tests[op];
116
+ }
117
+
118
+ /** Whether a range takes a version, as npm reads one: `^0.2.0`, `>=0.2.0 <0.4.0`, `^0.2.0 || ^0.3.0`. */
89
119
  export function satisfies(version, range = CORE_RANGE) {
90
- const [major, minor, patch] = parts(version);
91
- const [wantMajor, wantMinor, wantPatch] = parts(range.replace(/^\^/, ''));
92
- if (major !== wantMajor) return false;
93
- // Below 1.0 the minor is the breaking number: ^0.1.0 does not take 0.2.0.
94
- if (major === 0 && minor !== wantMinor) return false;
95
- return minor > wantMinor || (minor === wantMinor && patch >= wantPatch);
120
+ return range.split('||').some(alternative => alternative.trim().split(/\s+/).every(each => comparator(each)(version)));
96
121
  }
97
122
 
98
123
  /** Whether a version is past a caret range, not below it: its major, or below 1.0 its minor, is the higher. */
@@ -107,7 +132,7 @@ function updateHint(coreIsNewer) {
107
132
  const pm = detectPackageManager();
108
133
  return coreIsNewer
109
134
  ? `Update the command: npm install -g @amxts/cli@latest - a project's own ${pm === 'npm' ? 'npx amxts' : `${pm} amxts`} comes with its core`
110
- : `Update the core: ${commandLine(addArgs(pm, ['@amxts/core@latest']))}`;
135
+ : `Move the project to amxts ${VERSION}: ${execAmxts(pm)} upgrade`;
111
136
  }
112
137
 
113
138
  /**
package/src/includes.mjs CHANGED
@@ -23,6 +23,7 @@ import process from 'node:process';
23
23
  import { inflateRawSync } from 'node:zlib';
24
24
  import { readConfigTarget } from './config.mjs';
25
25
  import { findProject, runTaskOrExit, setting } from './core.mjs';
26
+ import { serverFolderOrNull } from './server.mjs';
26
27
  import { c, CliError, log } from './ui.mjs';
27
28
 
28
29
  /** The servers a project can be for, as `target` in amxts.config.ts and `--target` name them. */
@@ -132,7 +133,7 @@ export async function fetchIncludes(source) {
132
133
  * @returns {Promise<Includes>} where the includes are
133
134
  */
134
135
  export async function ensureIncludes(core, root, options = {}) {
135
- const own = serverIncludes(setting(root, 'AMXTS_SERVER'));
136
+ const own = serverIncludes(serverFolderOrNull(setting(root, 'AMXTS_SERVER')));
136
137
  if (own) return { from: 'server', target: targetOf(own), dir: own };
137
138
 
138
139
  const target = options.target ?? (await readConfigTarget(root)) ?? 'rehlds';
package/src/info.mjs CHANGED
@@ -8,6 +8,7 @@ import process from 'node:process';
8
8
  import { readConfigModules } from './config.mjs';
9
9
  import { bunFor, bunVersion, CLI_DIR, installedCore, readJson, setting, VERSION } from './core.mjs';
10
10
  import { detectPackageManager, versionOf } from './pm.mjs';
11
+ import { serverFolderOrNull } from './server.mjs';
11
12
  import { c, CliError } from './ui.mjs';
12
13
 
13
14
  /** The project's core, or why it cannot be used: not installed, or not one this command works with. */
@@ -21,6 +22,14 @@ async function core(dir) {
21
22
  }
22
23
  }
23
24
 
25
+ /** After AMXTS_SERVER: the addons/amxts it names when it is a folder above it, or why it is no server. */
26
+ function serverNote(server) {
27
+ const folder = serverFolderOrNull(server);
28
+ if (folder === null) return c.red(' (not a server)');
29
+ const shown = folder === server ? '' : ` - ${folder.replace(/\\/g, '/')}`;
30
+ return existsSync(folder) ? c.dim(shown) : c.red(`${shown} (not found)`);
31
+ }
32
+
24
33
  /**
25
34
  * The system the build compiles for, as the core decides it from .env and the
26
35
  * server's folder: "Linux (hlds_linux)". A core without serverSystem() builds
@@ -73,7 +82,7 @@ export async function info() {
73
82
  const version = readJson(join(dir, 'node_modules', name, 'package.json'))?.version;
74
83
  return version ? `${name} ${version}` : `${name} ${c.red('(not installed)')}`;
75
84
  }).join(', ')],
76
- ['AMXTS_SERVER', server ? `${server}${existsSync(server) ? '' : c.red(' (not found)')}` : c.dim('not set')],
85
+ ['AMXTS_SERVER', server ? `${server}${serverNote(server)}` : c.dim('not set')],
77
86
  ['Server system', system],
78
87
  ['AMXTS_DOCS_LANG', setting(dir, 'AMXTS_DOCS_LANG') || c.dim('en')],
79
88
  ];
@@ -10,14 +10,14 @@
10
10
  // user.name) as {{placeholders}}; `// #if natives` blocks are kept only with
11
11
  // --natives. The core is a peer dependency, the range this command works with;
12
12
  // with --local (the default from a checkout) it is also a dev dependency
13
- // linked to the core on this machine, since it is not on npm to install.
13
+ // linked to the core on this machine, which it is developed against.
14
14
  import { existsSync, readdirSync } from 'node:fs';
15
15
  import { join, relative, resolve } from 'node:path';
16
16
  import process from 'node:process';
17
17
  import { shortName, specFor } from './catalog.mjs';
18
18
  import { CLI_DIR, CORE_RANGE, FROM_SOURCE, needLocalCore } from './core.mjs';
19
19
  import { LINT_DEPENDENCIES, LINT_SCRIPTS } from './lint.mjs';
20
- import { detectPackageManager, execAmxts, installArgs, runScript } from './pm.mjs';
20
+ import { detectPackageManager, execAmxts, installArgs, runScript, testRunner } from './pm.mjs';
21
21
  import { copyTemplate, gitAuthor, writeFile } from './template.mjs';
22
22
  import { c, CliError, log } from './ui.mjs';
23
23
 
@@ -57,9 +57,11 @@ export async function initModule(options) {
57
57
  }
58
58
 
59
59
  const pm = options.pm ?? detectPackageManager();
60
+ const runner = testRunner(pm);
61
+ values['test.types'] = runner.types;
60
62
  const localCore = (options.local ?? FROM_SOURCE) ? await needLocalCore() : null;
61
63
 
62
- const written = copyTemplate(join(CLI_DIR, 'templates', 'module'), dir, values, { natives }, path => path === 'src/natives.ts' && !natives);
64
+ const written = copyTemplate(join(CLI_DIR, 'templates', 'module'), dir, values, { natives, bun: pm === 'bun' }, path => path === 'src/natives.ts' && !natives);
63
65
 
64
66
  const amxts = { module: 'src/index.ts' };
65
67
  if (natives) Object.assign(amxts, { natives: 'src/natives.ts', include: `include/${values.include}.inc` });
@@ -78,15 +80,15 @@ export async function initModule(options) {
78
80
  scripts: {
79
81
  build: 'amxts build',
80
82
  check: 'amxts check',
81
- test: 'amxts test',
83
+ test: runner.script,
82
84
  ...LINT_SCRIPTS,
83
85
  },
84
86
  peerDependencies: { '@amxts/core': CORE_RANGE },
85
87
  devDependencies: Object.fromEntries(Object.entries({
86
88
  ...(localCore ? { '@amxts/core': specFor({ name: '@amxts/core', dir: localCore.dir }, dir, pm) } : {}),
87
89
  ...LINT_DEPENDENCIES,
88
- '@types/bun': '^1.4.2',
89
- 'typescript': '^6.0.3',
90
+ ...runner.devDependencies,
91
+ typescript: '^6.0.3',
90
92
  }).sort(([a], [b]) => a.localeCompare(b))),
91
93
  }, null, '\t')}\n`);
92
94
  written.push('package.json');
package/src/init.mjs CHANGED
@@ -18,15 +18,21 @@ import { modulesList } from './config.mjs';
18
18
  import { bunFor, CLI_DIR, CORE_RANGE, coreDirFrom, FROM_SOURCE, loadCore, needLocalCore, readJson, VERSION } from './core.mjs';
19
19
  import { ensureIncludes, FETCHED, serverIncludes, targetOf, TARGETS } from './includes.mjs';
20
20
  import { LINT_DEPENDENCIES, LINT_FILES, LINT_SCRIPTS } from './lint.mjs';
21
- import { commandLine, execAmxts, installArgs, PACKAGE_MANAGERS, packageManagerOfAgent, run, runScript, versionOf } from './pm.mjs';
22
- import { askServer, canAsk, missingServer, serverEnv } from './server.mjs';
21
+ import { commandLine, execAmxts, installArgs, installLinked, linkedFolders, PACKAGE_MANAGERS, packageManagerOfAgent, run, runScript, testRunner, versionOf } from './pm.mjs';
22
+ import { npmRegistry, versionsFor } from './registry.mjs';
23
+ import { askServer, canAsk, missingServer, serverEnv, serverFolder } from './server.mjs';
23
24
  import { HOST_SYSTEM, serverSystemOf, SYSTEMS } from './system.mjs';
24
25
  import { copyTemplate, gitAuthor, writeFile } from './template.mjs';
25
26
  import { c, CliError } from './ui.mjs';
26
27
 
27
- /** The tools a project gets besides the core and its modules. */
28
+ /**
29
+ * The tools a project gets besides the core and its modules. knip reads the
30
+ * knip.json the template writes; from 6 on it counts a package imported by
31
+ * its name as used wherever the name resolves, so a core and modules linked
32
+ * from this machine's folders (--local) are not called unused.
33
+ */
28
34
  const TOOLS = {
29
- '@types/bun': '^1.4.2',
35
+ knip: '^6.39.0',
30
36
  };
31
37
 
32
38
  const DEFAULT_DIR = 'my-server';
@@ -144,7 +150,8 @@ export async function initProject(options) {
144
150
  const git = hasGit && (options.git ?? (interactive
145
151
  ? answer(await p.confirm({ message: 'Initialize a git repository?', initialValue: true }))
146
152
  : true));
147
- const server = options.server ?? (interactive ? answer(await askServer(pm)) : '');
153
+ // Its folder, cstrike or addons/amxts: .env keeps the addons/amxts.
154
+ const server = options.server === undefined ? (interactive ? answer(await askServer(pm)) : '') : serverFolder(options.server);
148
155
 
149
156
  // 7. Which server the project is for: its includes say, when it has them
150
157
  // (addons/amxmodx/scripting/include beside AMXTS_SERVER); else asked.
@@ -179,6 +186,7 @@ export async function initProject(options) {
179
186
  // The files.
180
187
  const amxts = execAmxts(pm);
181
188
  const install = installArgs(pm).join(' ');
189
+ const runner = testRunner(pm);
182
190
  const values = {
183
191
  'name': packageName(root),
184
192
  'author': gitAuthor(),
@@ -191,15 +199,18 @@ export async function initProject(options) {
191
199
  'run.typecheck': runScript(pm, 'typecheck'),
192
200
  'run.test': runScript(pm, 'test'),
193
201
  'run.lint': runScript(pm, 'lint'),
202
+ 'test.types': runner.types,
194
203
  };
195
- const flags = { lint, ...Object.fromEntries(installed.map(module => [shortName(module.name), true])) };
204
+ const flags = { lint, bun: pm === 'bun', ...Object.fromEntries(installed.map(module => [shortName(module.name), true])) };
196
205
  const written = copyTemplate(join(CLI_DIR, 'templates', 'project'), root, values, flags, path => LINT_FILES.includes(path) && !lint);
197
206
 
198
207
  const core = { name: '@amxts/core', dir: localCore?.dir ?? null };
208
+ const versions = await moduleVersions(installed);
199
209
  const devDependencies = {
200
210
  [core.name]: specFor(core, root, pm, CORE_RANGE),
201
- ...Object.fromEntries(installed.map(module => [module.name, specFor(module, root, pm)])),
211
+ ...Object.fromEntries(installed.map(module => [module.name, specFor(module, root, pm, versions[module.name] ? `^${versions[module.name]}` : 'latest')])),
202
212
  ...TOOLS,
213
+ ...runner.devDependencies,
203
214
  ...(lint ? LINT_DEPENDENCIES : {}),
204
215
  };
205
216
  writeFile(join(root, 'package.json'), `${JSON.stringify({
@@ -212,7 +223,7 @@ export async function initProject(options) {
212
223
  dev: 'amxts dev',
213
224
  build: 'amxts build',
214
225
  typecheck: 'amxts typecheck',
215
- test: 'amxts test',
226
+ test: runner.script,
216
227
  ...(lint ? LINT_SCRIPTS : {}),
217
228
  },
218
229
  devDependencies: Object.fromEntries(Object.entries(devDependencies).sort(([a], [b]) => a.localeCompare(b))),
@@ -245,7 +256,7 @@ export async function initProject(options) {
245
256
  // A spinner in a terminal; in a log (CI, a pipe) one line before and one after.
246
257
  const spinner = process.stdout.isTTY ? p.spinner() : { start: text => p.log.step(text), stop: (text, code) => (code ? p.log.error(text) : p.log.success(text)) };
247
258
  spinner.start(`Installing dependencies with ${pm}`);
248
- const result = await run(installArgs(pm), { cwd: root, quiet: true });
259
+ const result = await installLinked(installArgs(pm), linkedFolders(root), { cwd: root, quiet: true });
249
260
  if (result.code === 0) {
250
261
  pinLatest(root);
251
262
  installedOk = true;
@@ -301,6 +312,25 @@ export async function initProject(options) {
301
312
  if (options.install !== false && pmVersion && !installedOk) process.exitCode = 1;
302
313
  }
303
314
 
315
+ /**
316
+ * The modules from the registry, each at its newest version that works with
317
+ * the newest core of CORE_RANGE - the core the project gets; a module from a
318
+ * folder, or a package named with its version, is left to its spec. When the
319
+ * registry does not answer, none: `latest`, and the install says why.
320
+ * @param {import('./catalog.mjs').ModuleRef[]} modules
321
+ * @returns {Promise<Record<string, string>>} the versions, by name
322
+ */
323
+ async function moduleVersions(modules) {
324
+ const names = modules.filter(module => !module.dir && !/^@?[^@]+@/.test(module.name)).map(module => module.name);
325
+ if (!names.length) return {};
326
+ try {
327
+ return (await versionsFor(npmRegistry(process.cwd()), CORE_RANGE, names)).versions;
328
+ } catch (error) {
329
+ if (error instanceof CliError && !/npm view/.test(error.message)) throw error;
330
+ return {};
331
+ }
332
+ }
333
+
304
334
  /** `"latest"` in package.json, after the install, as the version it installed: `^1.6.2`. */
305
335
  function pinLatest(root) {
306
336
  const path = join(root, 'package.json');
package/src/main.mjs CHANGED
@@ -6,12 +6,15 @@
6
6
  // It runs on Node. Commands that make or change a project (init, module,
7
7
  // info) run here; the ones that compile (build, dev, typecheck, check,
8
8
  // prepare) are tasks of the project's core, which runs them on Bun
9
- // (core.mjs); test runs bun test.
9
+ // (core.mjs); test runs the project's Vitest, or bun test.
10
+ import { existsSync } from 'node:fs';
11
+ import { join } from 'node:path';
10
12
  import process from 'node:process';
11
13
  import { GLOBAL_FLAGS, parseArgs } from './args.mjs';
12
14
  import { needBun, needProject, projectCore, runTask, runTaskOrExit, VERSION } from './core.mjs';
13
15
  import { prepare, TARGETS } from './includes.mjs';
14
16
  import { detectPackageManager, PACKAGE_MANAGERS, run } from './pm.mjs';
17
+ import { serverMismatch } from './server-update.mjs';
15
18
  import { ensureServer } from './server.mjs';
16
19
  import { SYSTEMS } from './system.mjs';
17
20
  import { banner, c, CliError, closest, log, report } from './ui.mjs';
@@ -19,6 +22,12 @@ import { banner, c, CliError, closest, log, report } from './ui.mjs';
19
22
  /** --os, for the commands that compile: the plugins are built for that system. */
20
23
  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' };
21
24
 
25
+ /** The line for a server whose module is of another release than the project's core. */
26
+ function warnServer(core, os) {
27
+ const line = serverMismatch(core, needProject(), os);
28
+ if (line) log.warn(line);
29
+ }
30
+
22
31
  /** --os as the build task takes it. */
23
32
  function osArgs(values) {
24
33
  if (values.os === undefined) return [];
@@ -31,7 +40,7 @@ const INIT_FLAGS = {
31
40
  modules: { type: 'string', value: 'list', description: 'Modules to add, comma-separated: menu-core,config-core ("" for none)' },
32
41
  lint: { type: 'boolean', description: 'oxlint and oxfmt, with @antfu/eslint-config\'s rules (--no-lint to leave them out)' },
33
42
  git: { type: 'boolean', description: 'git init (--no-git to skip)' },
34
- server: { type: 'string', value: 'path', description: 'The server\'s addons/amxts folder, written to .env as AMXTS_SERVER; the build takes its includes' },
43
+ server: { type: 'string', value: 'path', description: 'The server: its folder, its cstrike or its addons/amxts; .env keeps the addons/amxts as AMXTS_SERVER, and the build takes its includes' },
35
44
  os: { type: 'string', value: Object.keys(SYSTEMS).join('|'), description: 'The server\'s system, when the server\'s folder does not show it: written to .env as AMXTS_SERVER_OS' },
36
45
  target: { type: 'string', value: Object.keys(TARGETS).join('|'), description: 'The server the project is for, when no server is given: rehlds - ReHLDS, ReGameDLL and ReAPI (recommended) - or hlds' },
37
46
  install: { type: 'boolean', description: 'Install the dependencies (--no-install to skip)' },
@@ -87,7 +96,10 @@ export const COMMANDS = {
87
96
  const core = await projectCore();
88
97
  banner(core.version, 'dev');
89
98
  // The container reads dist/ where it is: nothing to deploy, and AMXTS_SERVER is not asked.
90
- if (!values.docker) await ensureServer(needProject(), detectPackageManager());
99
+ if (!values.docker) {
100
+ await ensureServer(needProject(), detectPackageManager());
101
+ warnServer(core, osArgs(values));
102
+ }
91
103
  await prepare(core, { quiet: true });
92
104
  if (values.docker) runTaskOrExit(core, 'build', ['--watch', '--docker', '--os', 'linux']);
93
105
  else runTaskOrExit(core, 'build', ['--deploy', '--watch', ...osArgs(values)]);
@@ -106,6 +118,7 @@ export const COMMANDS = {
106
118
  const os = osArgs(values);
107
119
  const core = await projectCore();
108
120
  banner(core.version, 'build');
121
+ warnServer(core, os);
109
122
  if (values.deploy) await ensureServer(needProject(), detectPackageManager());
110
123
  await prepare(core, { quiet: true });
111
124
  runTaskOrExit(core, 'build', [...(values.deploy ? ['--deploy'] : []), ...(values.watch ? ['--watch'] : []), ...os]);
@@ -126,12 +139,17 @@ export const COMMANDS = {
126
139
  },
127
140
  },
128
141
  test: {
129
- description: 'Run the project\'s tests on a fake server (bun test)',
130
- usage: 'amxts test [bun test options]',
142
+ description: 'Run the project\'s tests on a fake server (Vitest when the project has it, else bun test)',
143
+ usage: 'amxts test [test runner options]',
131
144
  passThrough: true,
132
145
  examples: ['amxts test', 'amxts test hello', 'amxts test --watch'],
133
146
  async run({ raw }) {
134
- const result = await run([needBun(await projectCore(), 'runs tests'), 'test', ...raw]);
147
+ const core = await projectCore();
148
+ // A project with Vitest tests on Node; any other on bun test.
149
+ const vitest = join(needProject(), 'node_modules', 'vitest', 'vitest.mjs');
150
+ const result = await run(existsSync(vitest)
151
+ ? [process.execPath, vitest, ...(raw.includes('--watch') ? [] : ['run']), ...raw]
152
+ : [needBun(core, 'runs tests'), 'test', ...raw]);
135
153
  process.exitCode = result.code;
136
154
  },
137
155
  },
@@ -168,6 +186,21 @@ export const COMMANDS = {
168
186
  await prepare(await projectCore());
169
187
  },
170
188
  },
189
+ upgrade: {
190
+ description: 'Move the project to the latest amxts: its packages, its code, the build and the server',
191
+ usage: 'amxts upgrade [--to <version>] [--no-server] [--server-only] [--dry-run]',
192
+ flags: {
193
+ to: { type: 'string', value: 'version', description: 'The version to move to (the latest on the registry by default)' },
194
+ server: { type: 'boolean', description: 'Update the server in AMXTS_SERVER: its module, from the release (--no-server to leave it)' },
195
+ serverOnly: { type: 'boolean', description: 'Only update the server - once it is stopped, when the upgrade found it running' },
196
+ dryRun: { type: 'boolean', description: 'Say what would change, and change nothing' },
197
+ },
198
+ examples: ['amxts upgrade', 'amxts upgrade --dry-run', 'amxts upgrade --to 0.2.0 --no-server', 'amxts upgrade --server-only'],
199
+ async run({ values }) {
200
+ const { upgrade } = await import('./upgrade.mjs');
201
+ await upgrade({ to: values.to, server: values.server !== false, serverOnly: values.serverOnly, dryRun: values.dryRun });
202
+ },
203
+ },
171
204
  info: {
172
205
  description: 'Versions and settings, for a bug report',
173
206
  usage: 'amxts info',
package/src/module.mjs CHANGED
@@ -16,7 +16,8 @@ import { configEntries, installTarget, loadCatalog, resolveModule, taglineOf, wi
16
16
  import { addToConfig, CONFIG_FILE, newConfig, readConfigModules } from './config.mjs';
17
17
  import { FROM_SOURCE, installedCore, needLocalCore, needProject, readJson } from './core.mjs';
18
18
  import { prepare } from './includes.mjs';
19
- import { addArgs, commandLine, detectPackageManager, execAmxts, run, versionOf } from './pm.mjs';
19
+ import { addArgs, commandLine, detectPackageManager, execAmxts, installLinked, linkedFolders, versionOf } from './pm.mjs';
20
+ import { npmRegistry, versionsFor } from './registry.mjs';
20
21
  import { c, CliError, log } from './ui.mjs';
21
22
 
22
23
  /**
@@ -46,9 +47,12 @@ export async function moduleAdd(names, options = {}) {
46
47
 
47
48
  if (!options.skipInstall) {
48
49
  if (!versionOf(pm)) throw new CliError(`${pm} is not installed, and this project uses it`, `Install ${pm}, or add the package by hand and run amxts module add ${names.join(' ')} --skip-install.`);
49
- const argv = addArgs(pm, toInstall.map(module => installTarget(module, dir, pm)));
50
+ // A module from the registry at its newest version that works with the project's core.
51
+ const fromRegistry = core ? toInstall.filter(module => !module.dir && !/^@?[^@]+@/.test(module.name)).map(module => module.name) : [];
52
+ const { versions } = fromRegistry.length ? await versionsFor(npmRegistry(dir), core.version, fromRegistry) : { versions: {} };
53
+ const argv = addArgs(pm, toInstall.map(module => (versions[module.name] ? `${module.name}@^${versions[module.name]}` : installTarget(module, dir, pm))));
50
54
  log.step(`${commandLine(argv)}`);
51
- const result = await run(argv, { cwd: dir });
55
+ const result = await installLinked(argv, linkedFolders(dir, toInstall.map(module => module.dir).filter(Boolean)), { cwd: dir });
52
56
  if (result.code !== 0) throw new CliError(`${commandLine(argv)} failed`, 'The package manager says why, above.');
53
57
  }
54
58
 
package/src/pm.mjs CHANGED
@@ -1,7 +1,7 @@
1
1
  // The package manager a project uses - npm, pnpm, yarn or bun - and the
2
2
  // commands each one spells its own way.
3
3
  import { spawn, spawnSync } from 'node:child_process';
4
- import { existsSync, readFileSync } from 'node:fs';
4
+ import { existsSync, lstatSync, mkdirSync, readdirSync, readFileSync, readlinkSync, rmSync, symlinkSync, writeFileSync } from 'node:fs';
5
5
  import { dirname, join, resolve } from 'node:path';
6
6
  import process from 'node:process';
7
7
 
@@ -75,9 +75,75 @@ export function execAmxts(pm) {
75
75
  return { npm: 'npx amxts', pnpm: 'pnpm amxts', yarn: 'yarn amxts', bun: 'bunx amxts' }[pm] ?? 'npx amxts';
76
76
  }
77
77
 
78
- /** A command line as it is shown: arguments with spaces quoted. */
78
+ /**
79
+ * What a new project's or module's tests run on: `bun test` with Bun as the
80
+ * package manager, Vitest on Node with any other. `types` is the test
81
+ * folder's tsconfig `types`.
82
+ */
83
+ export function testRunner(pm) {
84
+ return pm === 'bun'
85
+ ? { script: 'amxts test', types: 'bun', devDependencies: { '@types/bun': '^1.4.2' } }
86
+ : { script: 'vitest run', types: 'node', devDependencies: { '@types/node': '^24.0.0', 'vitest': '^5.0.3' } };
87
+ }
88
+
89
+ /**
90
+ * The folders a project's package.json takes packages from - `link:` and
91
+ * `file:` specs: the core and the modules of this machine (--local).
92
+ * @param {string} projectDir
93
+ * @param {string[]} [more] folders about to be added
94
+ * @returns {string[]} the folders
95
+ */
96
+ export function linkedFolders(projectDir, more = []) {
97
+ let json = {};
98
+ try {
99
+ json = JSON.parse(readFileSync(join(projectDir, 'package.json'), 'utf8'));
100
+ } catch {}
101
+ const specs = Object.values({ ...json.dependencies, ...json.devDependencies });
102
+ const linked = specs.filter(spec => /^(?:link|file):(?:[./]|[a-z]:)/i.test(spec)).map(spec => resolve(projectDir, spec.replace(/^\w+:/, '')));
103
+ return [...new Set([...linked, ...more])];
104
+ }
105
+
106
+ /**
107
+ * Runs an install and leaves the `node_modules/.bin` of each linked folder
108
+ * as it was. yarn 1 installs a `link:` folder's dependencies into the
109
+ * project and points that folder's own `.bin` at them: the core's `tsc`,
110
+ * `amxts` and `bun` would start the project's copies.
111
+ * @param {string[]} argv
112
+ * @param {string[]} linked the linked folders, `linkedFolders`
113
+ * @param {Parameters<typeof run>[1]} [options]
114
+ */
115
+ export async function installLinked(argv, linked, options) {
116
+ const bins = linked.map(dir => join(dir, 'node_modules', '.bin'));
117
+ const saved = bins.map(binsOf);
118
+ const result = await run(argv, options);
119
+ bins.forEach((dir, i) => putBack(dir, saved[i]));
120
+ return result;
121
+ }
122
+
123
+ /** A `.bin` folder's entries: a link's target, or a file's bytes. Null when there is none. */
124
+ function binsOf(dir) {
125
+ if (!existsSync(dir)) return null;
126
+ return new Map(readdirSync(dir).map((name) => {
127
+ const path = join(dir, name);
128
+ const stat = lstatSync(path);
129
+ return [name, stat.isSymbolicLink() ? { link: readlinkSync(path) } : { data: readFileSync(path), mode: stat.mode }];
130
+ }));
131
+ }
132
+
133
+ /** Writes a `.bin` folder back as `binsOf` read it; what the install added goes. */
134
+ function putBack(dir, saved) {
135
+ if (!saved) return;
136
+ mkdirSync(dir, { recursive: true });
137
+ for (const name of readdirSync(dir)) rmSync(join(dir, name), { force: true });
138
+ for (const [name, entry] of saved) {
139
+ if (entry.link) symlinkSync(entry.link, join(dir, name));
140
+ else writeFileSync(join(dir, name), entry.data, { mode: entry.mode });
141
+ }
142
+ }
143
+
144
+ /** A command line as it is shown, and as Windows' shell runs it: arguments with spaces, or with what that shell reads (`^0.2.0`), quoted. */
79
145
  export function commandLine(argv) {
80
- return argv.map(arg => (/\s/.test(arg) ? `"${arg}"` : arg)).join(' ');
146
+ return argv.map(arg => (/[\s^&|<>]/.test(arg) ? `"${arg}"` : arg)).join(' ');
81
147
  }
82
148
 
83
149
  /**
@@ -85,7 +151,7 @@ export function commandLine(argv) {
85
151
  * .cmd files; `quiet` collects the output instead of showing it, for a
86
152
  * spinner, and hands it back for an error.
87
153
  *
88
- * @returns {Promise<{ code: number, output: string }>} its exit code, and its output when quiet
154
+ * @returns {Promise<{ code: number, output: string, stdout: string }>} its exit code, and its output when quiet - all of it, and what went to stdout alone
89
155
  */
90
156
  export function run(argv, { cwd = process.cwd(), quiet = false, env } = {}) {
91
157
  return new Promise((done) => {
@@ -99,13 +165,15 @@ export function run(argv, { cwd = process.cwd(), quiet = false, env } = {}) {
99
165
  windowsHide: true,
100
166
  });
101
167
  let output = '';
168
+ let stdout = '';
102
169
  child.stdout?.on('data', (chunk) => {
103
170
  output += chunk;
171
+ stdout += chunk;
104
172
  });
105
173
  child.stderr?.on('data', (chunk) => {
106
174
  output += chunk;
107
175
  });
108
- child.on('error', error => done({ code: 127, output: `${output}${error.message}` }));
109
- child.on('close', code => done({ code: code ?? 1, output }));
176
+ child.on('error', error => done({ code: 127, output: `${output}${error.message}`, stdout }));
177
+ child.on('close', code => done({ code: code ?? 1, output, stdout }));
110
178
  });
111
179
  }
@@ -0,0 +1,83 @@
1
+ // The versions of the @amxts packages that go together. Every package has a
2
+ // version of its own: a module versions by its own API and names the cores it
3
+ // works with as a range (`peerDependencies`: `"@amxts/core": "^0.2.0"`); the
4
+ // core names the command and wamrc it comes with the same way. So for a core,
5
+ // each package's newest version that works with it - what `amxts upgrade`
6
+ // moves a project to, and what a new project and `amxts module add` install.
7
+ import { compareVersions, satisfies } from './core.mjs';
8
+ import { run } from './pm.mjs';
9
+ import { CliError } from './ui.mjs';
10
+
11
+ export const CORE = '@amxts/core';
12
+
13
+ /**
14
+ * @typedef {object} Release a version of a package, as the registry has it
15
+ * @property {string} version its version
16
+ * @property {Record<string, string>} [dependencies] what it depends on, by range
17
+ * @property {Record<string, string>} [peerDependencies] what it needs beside it: a module's core and modules
18
+ * @property {Record<string, string>} [optionalDependencies] what it takes when there is one for the system: the core's wamrc
19
+ */
20
+
21
+ /**
22
+ * @typedef {object} Registry
23
+ * @property {(name: string, spec?: string) => Promise<Release[]>} versions a package's versions of `spec` - `*`, a version, a tag - with what they depend on; none when it has none
24
+ */
25
+
26
+ /**
27
+ * The registry npm is set to - NPM_CONFIG_REGISTRY, .npmrc - through npm itself.
28
+ * @param {string} dir the folder whose .npmrc npm reads
29
+ * @returns {Registry} the registry
30
+ */
31
+ export function npmRegistry(dir) {
32
+ return {
33
+ versions: async (name, spec = '*') => {
34
+ const result = await run(['npm', 'view', `${name}@${spec}`, 'version', 'dependencies', 'peerDependencies', 'optionalDependencies', '--json'], { cwd: dir, quiet: true });
35
+ if (result.code !== 0) {
36
+ if (/E404|404 Not Found|No match found/.test(result.output)) return [];
37
+ throw new CliError(`npm view ${name}@${spec} failed`, result.output.trim().split('\n').slice(-5).join('\n'));
38
+ }
39
+ // One version is an object, more a list; a version that depends on nothing, its version alone.
40
+ return [JSON.parse(result.stdout.trim() || '[]')].flat().map(each => (typeof each === 'string' ? { version: each } : each));
41
+ },
42
+ };
43
+ }
44
+
45
+ /**
46
+ * The newest of a package's releases that works with a core: one whose own
47
+ * range for the core takes the core's version; for a package that names no
48
+ * core, one the core's own range for it takes (the command, wamrc); else the
49
+ * newest.
50
+ * @param {Release[]} releases
51
+ * @param {string} name
52
+ * @param {Release} core
53
+ * @returns {string | null} the version; null when none works with the core
54
+ */
55
+ export function newestFor(releases, name, core) {
56
+ const asked = core.dependencies?.[name] ?? core.optionalDependencies?.[name];
57
+ const fits = (release) => {
58
+ const range = release.peerDependencies?.[CORE] ?? release.dependencies?.[CORE];
59
+ return range ? satisfies(core.version, range) : !asked || satisfies(release.version, asked);
60
+ };
61
+ return releases.filter(fits).map(each => each.version).sort(compareVersions).at(-1) ?? null;
62
+ }
63
+
64
+ /**
65
+ * A core and, for it, the newest version of each package that works with it,
66
+ * asked of the registry all at once. The core is a version, a tag (`latest`),
67
+ * or a range, whose newest version it is. Throws, saying which, when the core
68
+ * or a package has no such version.
69
+ * @param {Registry} registry
70
+ * @param {string} core
71
+ * @param {string[]} names
72
+ * @returns {Promise<{ core: string, versions: Record<string, string> }>} the core's version, and each package's
73
+ */
74
+ export async function versionsFor(registry, core, names) {
75
+ const range = !/^(?:\d+\.\d+\.\d+(?:-[\w.-]+)?|[a-z][\w-]*)$/i.test(core);
76
+ const [cores, ...lists] = await Promise.all([registry.versions(CORE, range ? '*' : core), ...names.map(name => registry.versions(name))]);
77
+ const found = cores.filter(each => !range || satisfies(each.version, core)).sort((a, b) => compareVersions(a.version, b.version)).at(-1);
78
+ if (!found) throw new CliError(`${CORE} ${core} is not on the registry`, 'npm config get registry says which one npm asks.');
79
+ const versions = names.map((name, i) => newestFor(lists[i], name, found));
80
+ const missing = names.map((name, i) => !versions[i] && (lists[i].length ? `${name} has no version for ${CORE} ${found.version}` : `${name} is not on the registry`)).filter(Boolean);
81
+ if (missing.length) throw new CliError(missing.join('\n'), `Each package takes its newest version that works with the core; a module that has none is not released for ${CORE} ${found.version} yet.`);
82
+ return { core: found.version, versions: Object.fromEntries(names.map((name, i) => [name, versions[i]])) };
83
+ }