@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.
Files changed (51) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +20 -0
  3. package/bin/amxts.mjs +8 -0
  4. package/package.json +45 -0
  5. package/src/args.mjs +62 -0
  6. package/src/catalog.mjs +196 -0
  7. package/src/config.mjs +191 -0
  8. package/src/core.mjs +272 -0
  9. package/src/includes.mjs +181 -0
  10. package/src/info.mjs +85 -0
  11. package/src/init-module.mjs +117 -0
  12. package/src/init.mjs +316 -0
  13. package/src/lint.mjs +26 -0
  14. package/src/main.mjs +273 -0
  15. package/src/module.mjs +150 -0
  16. package/src/modules.json +47 -0
  17. package/src/pm.mjs +111 -0
  18. package/src/server.mjs +72 -0
  19. package/src/system.mjs +32 -0
  20. package/src/template.mjs +74 -0
  21. package/src/ui.mjs +101 -0
  22. package/templates/module/LICENSE +21 -0
  23. package/templates/module/README.md +81 -0
  24. package/templates/module/README.ru.md +81 -0
  25. package/templates/module/_gitattributes +5 -0
  26. package/templates/module/_gitignore +11 -0
  27. package/templates/module/_oxfmtrc.json +18 -0
  28. package/templates/module/_oxlintrc.json +784 -0
  29. package/templates/module/assets/logo.svg +8 -0
  30. package/templates/module/playground/amxts.config.ts +4 -0
  31. package/templates/module/playground/plugins/welcome.ts +3 -0
  32. package/templates/module/playground/tsconfig.json +3 -0
  33. package/templates/module/src/index.ts +25 -0
  34. package/templates/module/src/natives.ts +13 -0
  35. package/templates/module/src/types.ts +11 -0
  36. package/templates/module/test/tsconfig.json +13 -0
  37. package/templates/module/test/{{name}}.test.ts +25 -0
  38. package/templates/module/tsconfig.json +10 -0
  39. package/templates/project/README.md +28 -0
  40. package/templates/project/_gitattributes +7 -0
  41. package/templates/project/_gitignore +7 -0
  42. package/templates/project/_oxfmtrc.json +18 -0
  43. package/templates/project/_oxlintrc.json +784 -0
  44. package/templates/project/_vscode/extensions.json +3 -0
  45. package/templates/project/_vscode/settings.json +13 -0
  46. package/templates/project/amxts.config.ts +24 -0
  47. package/templates/project/knip.json +4 -0
  48. package/templates/project/plugins/hello.ts +39 -0
  49. package/templates/project/test/hello.test.ts +37 -0
  50. package/templates/project/test/tsconfig.json +13 -0
  51. 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
+ }
@@ -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
+ }