@amxts/cli 0.1.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/LICENSE +21 -0
- package/README.md +20 -0
- package/bin/amxts.mjs +8 -0
- package/package.json +45 -0
- package/src/args.mjs +62 -0
- package/src/catalog.mjs +196 -0
- package/src/config.mjs +191 -0
- package/src/core.mjs +272 -0
- package/src/includes.mjs +181 -0
- package/src/info.mjs +85 -0
- package/src/init-module.mjs +117 -0
- package/src/init.mjs +316 -0
- package/src/lint.mjs +26 -0
- package/src/main.mjs +273 -0
- package/src/module.mjs +150 -0
- package/src/modules.json +47 -0
- package/src/pm.mjs +111 -0
- package/src/server.mjs +72 -0
- package/src/system.mjs +32 -0
- package/src/template.mjs +74 -0
- package/src/ui.mjs +101 -0
- package/templates/module/LICENSE +21 -0
- package/templates/module/README.md +81 -0
- package/templates/module/README.ru.md +81 -0
- package/templates/module/_gitattributes +5 -0
- package/templates/module/_gitignore +11 -0
- package/templates/module/_oxfmtrc.json +18 -0
- package/templates/module/_oxlintrc.json +784 -0
- package/templates/module/assets/logo.svg +8 -0
- package/templates/module/playground/amxts.config.ts +4 -0
- package/templates/module/playground/plugins/welcome.ts +3 -0
- package/templates/module/playground/tsconfig.json +3 -0
- package/templates/module/src/index.ts +25 -0
- package/templates/module/src/natives.ts +13 -0
- package/templates/module/src/types.ts +11 -0
- package/templates/module/test/tsconfig.json +13 -0
- package/templates/module/test/{{name}}.test.ts +25 -0
- package/templates/module/tsconfig.json +10 -0
- package/templates/project/README.md +28 -0
- package/templates/project/_gitattributes +7 -0
- package/templates/project/_gitignore +7 -0
- package/templates/project/_oxfmtrc.json +18 -0
- package/templates/project/_oxlintrc.json +784 -0
- package/templates/project/_vscode/extensions.json +3 -0
- package/templates/project/_vscode/settings.json +13 -0
- package/templates/project/amxts.config.ts +24 -0
- package/templates/project/knip.json +4 -0
- package/templates/project/plugins/hello.ts +39 -0
- package/templates/project/test/hello.test.ts +37 -0
- package/templates/project/test/tsconfig.json +13 -0
- package/templates/project/tsconfig.json +3 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 kukson777
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
# @amxts/cli
|
|
2
|
+
|
|
3
|
+
The `amxts` command of [amxts](https://amxts.github.io/): Counter-Strike 1.6
|
|
4
|
+
plugins in TypeScript. It creates projects and module packages, adds
|
|
5
|
+
modules, builds, deploys, type-checks and tests.
|
|
6
|
+
|
|
7
|
+
A project has it already - `@amxts/core` depends on it - and runs it through
|
|
8
|
+
its package manager:
|
|
9
|
+
|
|
10
|
+
```sh
|
|
11
|
+
npx amxts --help
|
|
12
|
+
npx amxts dev # build, deploy, again on every save
|
|
13
|
+
npx amxts module add menu-core
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
A new project: `npm create amxts@latest`. Installed globally
|
|
17
|
+
(`npm install -g @amxts/cli`), `amxts` works in any project with the
|
|
18
|
+
project's own core.
|
|
19
|
+
|
|
20
|
+
Every command: [the amxts command](https://amxts.github.io/docs/getting-started/cli).
|
package/bin/amxts.mjs
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// The amxts command: `npx amxts --help`. Plain JavaScript, so that npm, pnpm
|
|
3
|
+
// and yarn can start it with Node; the commands are in src/ (src/main.mjs
|
|
4
|
+
// lists them).
|
|
5
|
+
import process from 'node:process';
|
|
6
|
+
import { main } from '../src/main.mjs';
|
|
7
|
+
|
|
8
|
+
main(process.argv.slice(2));
|
package/package.json
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@amxts/cli",
|
|
3
|
+
"type": "module",
|
|
4
|
+
"version": "0.1.0",
|
|
5
|
+
"description": "The amxts command: create a project or a module, add modules, build, deploy, type-check and test",
|
|
6
|
+
"author": {
|
|
7
|
+
"name": "kukson777",
|
|
8
|
+
"url": "https://github.com/kukson777"
|
|
9
|
+
},
|
|
10
|
+
"license": "MIT",
|
|
11
|
+
"homepage": "https://amxts.github.io",
|
|
12
|
+
"repository": {
|
|
13
|
+
"type": "git",
|
|
14
|
+
"url": "git+https://github.com/amxts/amxts-cli.git",
|
|
15
|
+
"directory": "packages/cli"
|
|
16
|
+
},
|
|
17
|
+
"bugs": "https://github.com/amxts/amxts-cli/issues",
|
|
18
|
+
"keywords": [
|
|
19
|
+
"amxts",
|
|
20
|
+
"amxmodx",
|
|
21
|
+
"counter-strike",
|
|
22
|
+
"cli"
|
|
23
|
+
],
|
|
24
|
+
"exports": {
|
|
25
|
+
".": "./src/main.mjs",
|
|
26
|
+
"./package.json": "./package.json"
|
|
27
|
+
},
|
|
28
|
+
"bin": {
|
|
29
|
+
"amxts": "bin/amxts.mjs"
|
|
30
|
+
},
|
|
31
|
+
"files": [
|
|
32
|
+
"bin",
|
|
33
|
+
"src",
|
|
34
|
+
"templates"
|
|
35
|
+
],
|
|
36
|
+
"engines": {
|
|
37
|
+
"node": ">=20.12"
|
|
38
|
+
},
|
|
39
|
+
"dependencies": {
|
|
40
|
+
"@clack/prompts": "^1.8.1"
|
|
41
|
+
},
|
|
42
|
+
"publishConfig": {
|
|
43
|
+
"access": "public"
|
|
44
|
+
}
|
|
45
|
+
}
|
package/src/args.mjs
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
// A command's arguments: `--flag`, `--flag value`, `--flag=value`, `--no-flag`
|
|
2
|
+
// for a boolean, `-y` for an alias, and the rest as positionals. An unknown
|
|
3
|
+
// flag is an error that names the closest known one.
|
|
4
|
+
import { CliError, closest } from './ui.mjs';
|
|
5
|
+
|
|
6
|
+
/** Flags every command takes, as a command's help lists them. */
|
|
7
|
+
export const GLOBAL_FLAGS = {
|
|
8
|
+
help: { type: 'boolean', alias: 'h', description: 'Show this help' },
|
|
9
|
+
debug: { type: 'boolean', description: 'Show the stack of an error' },
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* @param {string[]} argv
|
|
14
|
+
* @param {Record<string, { type: 'boolean' | 'string', alias?: string }>} flags
|
|
15
|
+
* @param {string} command the command's name, for the error
|
|
16
|
+
*/
|
|
17
|
+
export function parseArgs(argv, flags, command) {
|
|
18
|
+
const all = { ...GLOBAL_FLAGS, ...flags };
|
|
19
|
+
const aliases = new Map(Object.entries(all).filter(([, flag]) => flag.alias).map(([name, flag]) => [flag.alias, name]));
|
|
20
|
+
/** @type {Record<string, unknown>} */
|
|
21
|
+
const values = {};
|
|
22
|
+
const positionals = [];
|
|
23
|
+
|
|
24
|
+
for (let i = 0; i < argv.length; i++) {
|
|
25
|
+
const arg = argv[i];
|
|
26
|
+
if (arg === '--') {
|
|
27
|
+
positionals.push(...argv.slice(i + 1));
|
|
28
|
+
break;
|
|
29
|
+
}
|
|
30
|
+
if (!arg.startsWith('-') || arg === '-') {
|
|
31
|
+
positionals.push(arg);
|
|
32
|
+
continue;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const [rawName, inline] = arg.includes('=') ? [arg.slice(0, arg.indexOf('=')), arg.slice(arg.indexOf('=') + 1)] : [arg, undefined];
|
|
36
|
+
let name = rawName.startsWith('--') ? rawName.slice(2) : aliases.get(rawName.slice(1)) ?? rawName.slice(1);
|
|
37
|
+
let negated = false;
|
|
38
|
+
if (!(name in all) && name.startsWith('no-') && all[name.slice(3)]?.type === 'boolean') {
|
|
39
|
+
name = name.slice(3);
|
|
40
|
+
negated = true;
|
|
41
|
+
}
|
|
42
|
+
const flag = all[name];
|
|
43
|
+
if (!flag) {
|
|
44
|
+
const guess = closest(name, Object.keys(all));
|
|
45
|
+
throw new CliError(
|
|
46
|
+
`Unknown option ${rawName} for ${command}`,
|
|
47
|
+
guess ? `Did you mean --${guess}?` : `amxts ${command} --help lists the options.`,
|
|
48
|
+
);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
if (flag.type === 'boolean') {
|
|
52
|
+
values[name] = negated ? false : inline === undefined ? true : !/^(?:false|0|no)$/i.test(inline);
|
|
53
|
+
continue;
|
|
54
|
+
}
|
|
55
|
+
const value = inline ?? argv[++i];
|
|
56
|
+
if (value === undefined || (inline === undefined && value.startsWith('--'))) {
|
|
57
|
+
throw new CliError(`${rawName} needs a value`, `amxts ${command} --help shows what it takes.`);
|
|
58
|
+
}
|
|
59
|
+
values[name] = value;
|
|
60
|
+
}
|
|
61
|
+
return { values, positionals };
|
|
62
|
+
}
|
package/src/catalog.mjs
ADDED
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
// The modules the amxts command knows by their names - the amxts catalog,
|
|
2
|
+
// the registry github.com/amxts/modules - and how a name given on the
|
|
3
|
+
// command line becomes a package to install.
|
|
4
|
+
//
|
|
5
|
+
// menu-core the catalog's menu-core: @amxts/menu-core, from npm
|
|
6
|
+
// @you/greeter that package, from npm - with a warning when
|
|
7
|
+
// the catalog does not list it
|
|
8
|
+
// ../greeter, file:../x the package in that folder
|
|
9
|
+
// menu-core --local the folder the core on this machine takes an
|
|
10
|
+
// official module from (its package.json's
|
|
11
|
+
// `file:` link) - until they are on npm
|
|
12
|
+
import { spawnSync } from 'node:child_process';
|
|
13
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
14
|
+
import { isAbsolute, join, relative, resolve } from 'node:path';
|
|
15
|
+
import process from 'node:process';
|
|
16
|
+
import { readJson } from './core.mjs';
|
|
17
|
+
import { CliError } from './ui.mjs';
|
|
18
|
+
|
|
19
|
+
export const OFFICIAL_SCOPE = '@amxts';
|
|
20
|
+
|
|
21
|
+
/** The registry's modules.json: CI rebuilds it on every change to the catalog. */
|
|
22
|
+
export const CATALOG_URL = 'https://raw.githubusercontent.com/amxts/modules/main/modules.json';
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* @typedef {object} CatalogModule a module of the catalog, as modules.json lists it
|
|
26
|
+
* @property {string} name its name in the catalog: "menu-core"
|
|
27
|
+
* @property {string} npm its package: "@amxts/menu-core"
|
|
28
|
+
* @property {string} repo its GitHub repository: "amxts/menu-core"
|
|
29
|
+
* @property {string} description what it is, in a line
|
|
30
|
+
* @property {string} category its category: "ui", "config"...
|
|
31
|
+
* @property {'official' | 'community'} type official: by the amxts team, under @amxts
|
|
32
|
+
* @property {string[]} requires the other modules of the catalog it needs, by npm name
|
|
33
|
+
*/
|
|
34
|
+
|
|
35
|
+
/** A modules.json as read: an array of modules with names, each with its `requires`; else an error. */
|
|
36
|
+
function checked(modules) {
|
|
37
|
+
if (!Array.isArray(modules) || !modules.every(each => typeof each?.name === 'string' && typeof each.npm === 'string')) throw new Error('not a modules.json');
|
|
38
|
+
return modules.map(each => ({ ...each, requires: each.requires ?? [] }));
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** A modules.json from a file: a path or a file: URL. */
|
|
42
|
+
const readCatalog = source => checked(JSON.parse(readFileSync(source, 'utf8')));
|
|
43
|
+
|
|
44
|
+
/** A modules.json from a URL, or from a file when it is not one. */
|
|
45
|
+
async function fetchCatalog(source) {
|
|
46
|
+
if (!/^https?:/.test(source)) return readCatalog(source);
|
|
47
|
+
const response = await fetch(source, { signal: AbortSignal.timeout(3000) });
|
|
48
|
+
if (!response.ok) throw new Error(`${source}: HTTP ${response.status}`);
|
|
49
|
+
return checked(await response.json());
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** @type {Promise<CatalogModule[]> | undefined} */
|
|
53
|
+
let catalog;
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* The catalog, read once per run: the registry's modules.json, or - offline,
|
|
57
|
+
* or when it does not answer in 3 seconds - the copy this package was
|
|
58
|
+
* published with. `AMXTS_CATALOG` reads another URL or a file instead.
|
|
59
|
+
* @returns {Promise<CatalogModule[]>} the catalog's modules
|
|
60
|
+
*/
|
|
61
|
+
export function loadCatalog() {
|
|
62
|
+
catalog ??= fetchCatalog(process.env.AMXTS_CATALOG || CATALOG_URL).catch(() => readCatalog(new URL('./modules.json', import.meta.url)));
|
|
63
|
+
return catalog;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export const shortName = name => name.replace(/^@[^/]+\//, '');
|
|
67
|
+
|
|
68
|
+
/** A package folder's tagline: the italic line under its README's title, else its description. */
|
|
69
|
+
export function taglineOf(dir) {
|
|
70
|
+
const readme = join(dir, 'README.md');
|
|
71
|
+
if (existsSync(readme)) {
|
|
72
|
+
const line = readFileSync(readme, 'utf8').split(/\r?\n/).find(each => /^\*[^*].*\*$/.test(each.trim()));
|
|
73
|
+
if (line) return line.trim().slice(1, -1);
|
|
74
|
+
}
|
|
75
|
+
return readJson(join(dir, 'package.json'))?.description ?? '';
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const looksLikePath = arg => arg.startsWith('file:') || arg.startsWith('.') || isAbsolute(arg) || /^[a-z]:[\\/]/i.test(arg);
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* @typedef {object} ModuleRef
|
|
82
|
+
* @property {string} name the package name: "@amxts/menu-core"
|
|
83
|
+
* @property {string | null} dir its folder, when it is installed from one
|
|
84
|
+
* @property {string} tagline what it is, in a line: its README's, else its description
|
|
85
|
+
* @property {string[]} requires the other modules it needs, as far as known before installing it
|
|
86
|
+
* @property {boolean} listed whether the catalog lists it
|
|
87
|
+
*/
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* What a name on the command line means.
|
|
91
|
+
* @param {string} arg
|
|
92
|
+
* @param {{ catalog?: CatalogModule[], local?: boolean, localModules?: Record<string, string>, cwd?: string }} options
|
|
93
|
+
* `catalog`: loadCatalog()'s modules; `localModules`: where the core on
|
|
94
|
+
* this machine takes the official modules from (its cli-api's
|
|
95
|
+
* localModules()), for `local`
|
|
96
|
+
* @returns {ModuleRef} the package, and its folder when it comes from one
|
|
97
|
+
*/
|
|
98
|
+
export function resolveModule(arg, { catalog = [], local = false, localModules = {}, cwd = process.cwd() } = {}) {
|
|
99
|
+
if (looksLikePath(arg)) {
|
|
100
|
+
const dir = resolve(cwd, arg.replace(/^file:/, ''));
|
|
101
|
+
const json = readJson(join(dir, 'package.json'));
|
|
102
|
+
if (!json?.name) throw new CliError(`${arg} is not a package: no package.json with a name there`);
|
|
103
|
+
return { name: json.name, dir, tagline: taglineOf(dir), requires: requiresOf(json), listed: catalog.some(each => each.npm === json.name) };
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const listed = catalog.find(each => each.name === arg || each.npm === arg);
|
|
107
|
+
const name = listed?.npm ?? arg;
|
|
108
|
+
if (!/^(?:@[a-z0-9][\w.-]*\/)?[a-z0-9][\w.-]*(?:@.+)?$/i.test(name)) {
|
|
109
|
+
throw new CliError(`"${arg}" is not a package name`, 'A module is named as npm names it: menu-core, @amxts/menu-core, @you/greeter, or a folder: ../greeter');
|
|
110
|
+
}
|
|
111
|
+
if (local && listed?.type === 'official') {
|
|
112
|
+
const dir = localModules[name];
|
|
113
|
+
if (!dir) throw new CliError(`--local: the core has no folder for ${name}`, `Its package.json links the official modules with file: - ${name} is not among them.`);
|
|
114
|
+
return { name, dir, tagline: taglineOf(dir), requires: listed.requires, listed: true };
|
|
115
|
+
}
|
|
116
|
+
return { name, dir: null, tagline: listed?.description ?? '', requires: listed?.requires ?? [], listed: Boolean(listed) };
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* The modules, each followed by what it requires and was not given - and
|
|
121
|
+
* what that requires, in turn: the order `modules` lists them in.
|
|
122
|
+
* @param {ModuleRef[]} modules
|
|
123
|
+
* @param {{ catalog?: CatalogModule[], local?: boolean, localModules?: Record<string, string> }} options how a required one is resolved
|
|
124
|
+
* @returns {ModuleRef[]} the modules with all they require
|
|
125
|
+
*/
|
|
126
|
+
export function withRequired(modules, options) {
|
|
127
|
+
const names = new Set(modules.map(each => each.name));
|
|
128
|
+
const all = [];
|
|
129
|
+
const next = modules.toReversed();
|
|
130
|
+
while (next.length) {
|
|
131
|
+
const module = next.pop();
|
|
132
|
+
all.push(module);
|
|
133
|
+
const required = module.requires.filter(name => !names.has(name)).map(name => resolveModule(name, options));
|
|
134
|
+
for (const each of required) names.add(each.name);
|
|
135
|
+
next.push(...required.toReversed());
|
|
136
|
+
}
|
|
137
|
+
return all;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* @typedef {object} ConfigEntry a module as `modules` in amxts.config.ts lists it
|
|
142
|
+
* @property {string} name its package: "@amxts/config-core"
|
|
143
|
+
* @property {string[]} [by] the modules that need it, when it is listed for them: "menu-core"
|
|
144
|
+
*/
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* What `modules` lists for the chosen modules: withRequired()'s order, a
|
|
148
|
+
* module that was not chosen with the ones that need it.
|
|
149
|
+
* @param {ModuleRef[]} all withRequired()'s modules
|
|
150
|
+
* @param {ModuleRef[]} chosen the ones asked for
|
|
151
|
+
* @returns {ConfigEntry[]} the entries
|
|
152
|
+
*/
|
|
153
|
+
export function configEntries(all, chosen) {
|
|
154
|
+
return all.map(module => ({
|
|
155
|
+
name: module.name,
|
|
156
|
+
by: chosen.includes(module) ? [] : all.filter(other => other.requires.includes(module.name)).map(other => shortName(other.name)),
|
|
157
|
+
}));
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/** The other @amxts modules a package says it needs: its peer dependencies but the core. */
|
|
161
|
+
function requiresOf(json) {
|
|
162
|
+
return Object.keys(json.peerDependencies ?? {}).filter(name => name.startsWith(`${OFFICIAL_SCOPE}/`) && name !== '@amxts/core');
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* The spec a package.json gives a package: the version asked for, or a link
|
|
167
|
+
* to its folder. A link, not a copy: a copy is what the package would publish
|
|
168
|
+
* (npm pack), and the core's folder has more than that - the patched
|
|
169
|
+
* compiler, the generated API. npm links a `file:` folder; pnpm and yarn copy
|
|
170
|
+
* it and link a `link:` one.
|
|
171
|
+
*/
|
|
172
|
+
export function specFor(ref, projectDir, pm = 'npm', version = 'latest') {
|
|
173
|
+
if (!ref.dir) return version;
|
|
174
|
+
// bun copies a file: folder and fails on it on Windows; its own link:
|
|
175
|
+
// takes a package registered with `bun link`, so it is registered first.
|
|
176
|
+
if (pm === 'bun') return bunLink(ref);
|
|
177
|
+
const path = relative(projectDir, ref.dir).replace(/\\/g, '/');
|
|
178
|
+
const protocol = pm === 'pnpm' || pm === 'yarn' ? 'link:' : 'file:';
|
|
179
|
+
return `${protocol}${isAbsolute(path) ? path : path.startsWith('.') ? path : `./${path}`}`;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/** What the package manager is given to install it: its folder or its name. */
|
|
183
|
+
export function installTarget(ref, projectDir, pm) {
|
|
184
|
+
return ref.dir ? specFor(ref, projectDir, pm) : ref.name;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
const bunLinked = new Set();
|
|
188
|
+
|
|
189
|
+
/** `link:<name>` for bun, after `bun link` in the package's folder registered it. */
|
|
190
|
+
function bunLink(ref) {
|
|
191
|
+
if (!bunLinked.has(ref.dir)) {
|
|
192
|
+
spawnSync('bun', ['link'], { cwd: ref.dir, stdio: 'ignore' });
|
|
193
|
+
bunLinked.add(ref.dir);
|
|
194
|
+
}
|
|
195
|
+
return `link:${ref.name}`;
|
|
196
|
+
}
|
package/src/config.mjs
ADDED
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
// amxts.config.ts, read and edited as source: the TypeScript parser finds
|
|
2
|
+
// `modules` in `export default defineConfig({ ... })`, and an edit touches
|
|
3
|
+
// only the text it adds - the user's formatting, comments and other options
|
|
4
|
+
// stay as they are. The parser is the core's TypeScript (core.mjs), so the
|
|
5
|
+
// command itself does not install it.
|
|
6
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
7
|
+
import { join } from 'node:path';
|
|
8
|
+
import { coreTypeScript } from './core.mjs';
|
|
9
|
+
import { CliError } from './ui.mjs';
|
|
10
|
+
|
|
11
|
+
export const CONFIG_FILE = 'amxts.config.ts';
|
|
12
|
+
|
|
13
|
+
/** @type {typeof import('typescript')} */
|
|
14
|
+
let ts;
|
|
15
|
+
|
|
16
|
+
/** Loads the parser, once. */
|
|
17
|
+
async function useTypeScript() {
|
|
18
|
+
ts ??= await coreTypeScript();
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** The object `export default defineConfig({ ... })` (or `export default { ... }`) gives. */
|
|
22
|
+
function configObject(file) {
|
|
23
|
+
for (const statement of file.statements) {
|
|
24
|
+
if (!ts.isExportAssignment(statement) || statement.isExportEquals) continue;
|
|
25
|
+
let expression = statement.expression;
|
|
26
|
+
while (ts.isParenthesizedExpression(expression) || ts.isAsExpression(expression) || ts.isSatisfiesExpression(expression)) expression = expression.expression;
|
|
27
|
+
if (ts.isCallExpression(expression)) expression = expression.arguments[0];
|
|
28
|
+
if (expression && ts.isObjectLiteralExpression(expression)) return expression;
|
|
29
|
+
}
|
|
30
|
+
return null;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function parse(text) {
|
|
34
|
+
return ts.createSourceFile(CONFIG_FILE, text, ts.ScriptTarget.Latest, true, ts.ScriptKind.TS);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** The object's `key: ...` property, or null. */
|
|
38
|
+
function propertyOf(object, key) {
|
|
39
|
+
return object.properties.find(property => ts.isPropertyAssignment(property)
|
|
40
|
+
&& (ts.isIdentifier(property.name) || ts.isStringLiteral(property.name))
|
|
41
|
+
&& property.name.text === key) ?? null;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** The names a `modules` property lists: none when it is missing or not a list written out. */
|
|
45
|
+
function namesIn(property) {
|
|
46
|
+
return property && ts.isArrayLiteralExpression(property.initializer)
|
|
47
|
+
? property.initializer.elements.filter(ts.isStringLiteralLike).map(element => element.text)
|
|
48
|
+
: [];
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** What `read` makes of the project's amxts.config.ts, or null without the file. */
|
|
52
|
+
function readConfig(dir, read) {
|
|
53
|
+
const path = join(dir, CONFIG_FILE);
|
|
54
|
+
return existsSync(path) ? read(readFileSync(path, 'utf8')) : null;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** The module names amxts.config.ts lists; null when it has no config object to read. */
|
|
58
|
+
export async function configModules(text) {
|
|
59
|
+
await useTypeScript();
|
|
60
|
+
const object = configObject(parse(text));
|
|
61
|
+
return object ? namesIn(propertyOf(object, 'modules')) : null;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** The project's amxts.config.ts modules, or null without the file. */
|
|
65
|
+
export async function readConfigModules(dir) {
|
|
66
|
+
return readConfig(dir, configModules);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** `target` as amxts.config.ts writes it - "rehlds" or "hlds" - or null when it does not. */
|
|
70
|
+
export async function configTarget(text) {
|
|
71
|
+
await useTypeScript();
|
|
72
|
+
const object = configObject(parse(text));
|
|
73
|
+
const property = object && propertyOf(object, 'target');
|
|
74
|
+
return property && ts.isStringLiteralLike(property.initializer) ? property.initializer.text : null;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** The project's amxts.config.ts target, or null without the file or the property. */
|
|
78
|
+
export async function readConfigTarget(dir) {
|
|
79
|
+
return readConfig(dir, configTarget);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* @typedef {import('./catalog.mjs').ConfigEntry} ConfigEntry
|
|
84
|
+
* @typedef {{ text: string, comment: string }} Item an entry as the list writes it: quoted, and its comment
|
|
85
|
+
*/
|
|
86
|
+
|
|
87
|
+
/** An entry in these quotes, with a comment naming the modules that need it. */
|
|
88
|
+
function itemOf(entry, quote = '"') {
|
|
89
|
+
return {
|
|
90
|
+
text: `${quote}${entry.name.replaceAll(quote, `\\${quote}`)}${quote}`,
|
|
91
|
+
comment: entry.by?.length ? ` // needed by ${entry.by.join(', ')}` : '',
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** Items one per line, each after a line break and this indent. */
|
|
96
|
+
function linesOf(items, indent) {
|
|
97
|
+
return items.map(each => `\n${indent}${each.text},${each.comment}`).join('');
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** A list of items for a property at `indent`: on one line, or one per line when one has a comment. */
|
|
101
|
+
function listOf(items, indent, unit) {
|
|
102
|
+
return items.some(each => each.comment)
|
|
103
|
+
? `[${linesOf(items, indent + unit)}\n${indent}]`
|
|
104
|
+
: `[${items.map(each => each.text).join(', ')}]`;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** `modules`' list for a new config, its property at `indent`. */
|
|
108
|
+
export function modulesList(entries, indent = '\t') {
|
|
109
|
+
return listOf(entries.map(each => itemOf(each)), indent, '\t');
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** A new amxts.config.ts with these modules. */
|
|
113
|
+
export function newConfig(entries) {
|
|
114
|
+
return `export default defineConfig({\n\tmodules: ${modulesList(entries)},\n});\n`;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** The whitespace a line starts with. */
|
|
118
|
+
function indentAt(text, position) {
|
|
119
|
+
const start = text.lastIndexOf('\n', position - 1) + 1;
|
|
120
|
+
return text.slice(start).match(/^[ \t]*/)[0];
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** The text's indent step: a tab, unless it indents with spaces. */
|
|
124
|
+
const unitOf = text => (/\n\t/.test(text) || !/\n {2}/.test(text) ? '\t' : ' ');
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* The config with these modules added to `modules`, in the quotes and the
|
|
128
|
+
* layout the list already has: one per line, or on one line - one per line
|
|
129
|
+
* when an entry has a comment.
|
|
130
|
+
*
|
|
131
|
+
* @param {string} text
|
|
132
|
+
* @param {(string | ConfigEntry)[]} modules the names, or entries that say who needs them
|
|
133
|
+
* @returns {Promise<{ text: string, added: string[] }>} the new text, and the names it did not list before
|
|
134
|
+
*/
|
|
135
|
+
export async function addToConfig(text, modules) {
|
|
136
|
+
await useTypeScript();
|
|
137
|
+
const entries = modules.map(each => (typeof each === 'string' ? { name: each } : each));
|
|
138
|
+
const file = parse(text);
|
|
139
|
+
const object = configObject(file);
|
|
140
|
+
if (!object) {
|
|
141
|
+
throw new CliError(`${CONFIG_FILE} has no \`export default defineConfig({ ... })\` to add the module to`, `Add it by hand: modules: ${modulesList(entries)}`);
|
|
142
|
+
}
|
|
143
|
+
const property = propertyOf(object, 'modules');
|
|
144
|
+
const listed = namesIn(property);
|
|
145
|
+
const adding = entries.filter((entry, i) => !listed.includes(entry.name) && entries.findIndex(each => each.name === entry.name) === i);
|
|
146
|
+
const added = adding.map(each => each.name);
|
|
147
|
+
if (added.length === 0) return { text, added };
|
|
148
|
+
|
|
149
|
+
if (property && !ts.isArrayLiteralExpression(property.initializer)) {
|
|
150
|
+
throw new CliError(`${CONFIG_FILE}: modules is not a list written out`, `Add it by hand: ${added.map(name => JSON.stringify(name)).join(', ')}`);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
const firstString = property?.initializer.elements.find(ts.isStringLiteralLike);
|
|
154
|
+
const items = adding.map(entry => itemOf(entry, firstString ? firstString.getText(file)[0] : '"'));
|
|
155
|
+
const commented = items.some(each => each.comment);
|
|
156
|
+
const unit = unitOf(text);
|
|
157
|
+
const replace = (start, end, insert) => ({ text: text.slice(0, start) + insert + text.slice(end), added });
|
|
158
|
+
|
|
159
|
+
if (property) {
|
|
160
|
+
const array = property.initializer;
|
|
161
|
+
const elements = array.elements;
|
|
162
|
+
const indent = indentAt(text, property.getStart(file));
|
|
163
|
+
const last = elements.at(-1);
|
|
164
|
+
if (!last) return replace(array.getStart(file), array.end, listOf(items, indent, unit));
|
|
165
|
+
const line = position => file.getLineAndCharacterOfPosition(position).line;
|
|
166
|
+
// The end of the last element's line, past its comma and its comment.
|
|
167
|
+
const lineEnd = last.end + text.slice(last.end).search(/\r?\n|$/);
|
|
168
|
+
if (line(elements[0].getStart(file)) > line(array.getStart(file)) && array.end - 1 > lineEnd) {
|
|
169
|
+
// One per line: the new ones go after the last one's line, with a
|
|
170
|
+
// comma after it when it has none.
|
|
171
|
+
const comma = elements.hasTrailingComma ? '' : ',';
|
|
172
|
+
const lines = linesOf(items, indentAt(text, elements[0].getStart(file)));
|
|
173
|
+
return { text: text.slice(0, last.end) + comma + text.slice(last.end, lineEnd) + lines + text.slice(lineEnd), added };
|
|
174
|
+
}
|
|
175
|
+
if (!commented) return replace(last.end, last.end, items.map(each => `, ${each.text}`).join(''));
|
|
176
|
+
// On one line, and a comment to write: the list goes one per line.
|
|
177
|
+
return replace(array.getStart(file), array.end, listOf([...elements.map(each => ({ text: each.getText(file), comment: '' })), ...items], indent, unit));
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// No `modules` yet: it goes first in the object.
|
|
181
|
+
const first = object.properties[0];
|
|
182
|
+
if (first) {
|
|
183
|
+
const start = first.getStart(file);
|
|
184
|
+
const indent = indentAt(text, start);
|
|
185
|
+
const entry = `modules: ${listOf(items, indent, unit)},`;
|
|
186
|
+
const sameLine = file.getLineAndCharacterOfPosition(start).line === file.getLineAndCharacterOfPosition(object.getStart(file)).line;
|
|
187
|
+
return replace(start, start, sameLine ? `${entry} ` : `${entry}\n${indent}`);
|
|
188
|
+
}
|
|
189
|
+
const indent = indentAt(text, object.getStart(file));
|
|
190
|
+
return replace(object.getStart(file), object.end, `{\n${indent}${unit}modules: ${listOf(items, indent + unit, unit)},\n${indent}}`);
|
|
191
|
+
}
|