amxx-builder 1.5.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.
Files changed (68) hide show
  1. package/AGENTS.md +111 -0
  2. package/README.md +485 -0
  3. package/action-entry.js +42 -0
  4. package/action.yml +55 -0
  5. package/defaults/amxbuild.defaults.yml +40 -0
  6. package/index.js +4 -0
  7. package/mcp/dep-resolver.js +75 -0
  8. package/mcp/handlers.js +862 -0
  9. package/mcp/mcp-server.js +112 -0
  10. package/mcp/registry.js +863 -0
  11. package/mcp/symbol-index.js +171 -0
  12. package/package.json +68 -0
  13. package/src/archiver.js +140 -0
  14. package/src/asset-fetcher.js +277 -0
  15. package/src/build-plan.js +101 -0
  16. package/src/build-service.js +188 -0
  17. package/src/cache-dir.js +21 -0
  18. package/src/cache-info.js +104 -0
  19. package/src/cli.js +302 -0
  20. package/src/collector.js +89 -0
  21. package/src/commands/build.js +49 -0
  22. package/src/commands/cache.js +77 -0
  23. package/src/commands/clean.js +33 -0
  24. package/src/commands/compile-renderer.js +38 -0
  25. package/src/commands/deploy.js +40 -0
  26. package/src/commands/deps-tree.js +92 -0
  27. package/src/commands/doctor.js +77 -0
  28. package/src/commands/dry-run.js +64 -0
  29. package/src/commands/init.js +228 -0
  30. package/src/commands/mcp.js +13 -0
  31. package/src/commands/releases.js +45 -0
  32. package/src/commands/resolve-manifest.js +27 -0
  33. package/src/commands/serve.js +489 -0
  34. package/src/commands/shared.js +24 -0
  35. package/src/commands/validate.js +34 -0
  36. package/src/commands/watch.js +209 -0
  37. package/src/compile-utils.js +65 -0
  38. package/src/compiler-fetcher.js +327 -0
  39. package/src/compiler.js +228 -0
  40. package/src/dep-graph.js +92 -0
  41. package/src/deployer.js +197 -0
  42. package/src/deps-resolver.js +127 -0
  43. package/src/deps-tree.js +202 -0
  44. package/src/env.js +18 -0
  45. package/src/events.js +29 -0
  46. package/src/format.js +23 -0
  47. package/src/fs-utils.js +87 -0
  48. package/src/include-tree.js +845 -0
  49. package/src/ini-builder.js +44 -0
  50. package/src/jsonrpc-transport.js +195 -0
  51. package/src/logger.js +50 -0
  52. package/src/manifest-path.js +34 -0
  53. package/src/manifest.js +373 -0
  54. package/src/progress.js +66 -0
  55. package/src/rcon.js +103 -0
  56. package/src/release-fetcher.js +206 -0
  57. package/src/release-lister.js +79 -0
  58. package/src/repo-fetcher.js +273 -0
  59. package/src/retry.js +50 -0
  60. package/src/schema.js +54 -0
  61. package/src/update-check.js +114 -0
  62. package/src/validate.js +69 -0
  63. package/src/watcher.js +135 -0
  64. package/templates/init-build.bat +11 -0
  65. package/templates/init-build.sh +7 -0
  66. package/templates/init-deploy.env +14 -0
  67. package/templates/init-manifest.yml +6 -0
  68. package/templates/init-workflow.yml +59 -0
@@ -0,0 +1,209 @@
1
+ 'use strict';
2
+
3
+ const fs = require('fs');
4
+ const path = require('path');
5
+
6
+ const logger = require('../logger');
7
+ const { parseManifest, resolveGithubToken } = require('../manifest');
8
+ const { fetchCompiler } = require('../compiler-fetcher');
9
+ const { compileSingle, applyPluginRule } = require('../compiler');
10
+ const { deployBuild, deployPlugin, deployFile, removeDeployedFile } = require('../deployer');
11
+ const { sendRconForPlugins } = require('../rcon');
12
+ const { DepGraph } = require('../dep-graph');
13
+ const { startWatch } = require('../watcher');
14
+ const { fetchRepo, resolveRepoRefs } = require('../repo-fetcher');
15
+ const { resolveDeps, repoKey } = require('../deps-resolver');
16
+ const { resolveManifestPath, loadEnv } = require('./shared');
17
+ const { runBuild } = require('./build');
18
+ const { subscribeCompiledRendering } = require('./compile-renderer');
19
+
20
+ // Render compiler 'compiled' events (previously direct stdout/stderr writes).
21
+ subscribeCompiledRendering();
22
+
23
+ async function runWatch(options) {
24
+ const manifestPath = resolveManifestPath(options.manifest);
25
+ const buildDir = path.resolve(options.buildDir || './build');
26
+ const doDeploy = options.deploy !== false;
27
+
28
+ loadEnv(manifestPath);
29
+
30
+ logger.info('Running initial build...');
31
+ await runBuild({ manifest: manifestPath, buildDir: options.buildDir });
32
+
33
+ if (options.verbose) logger.setVerbose(true);
34
+
35
+ // Mutable watch state — rebuilt after every manifest-triggered full rebuild
36
+ // so the compiler version, include dirs and dep graph never go stale.
37
+ let state = null;
38
+
39
+ async function buildWatchState() {
40
+ const manifest = parseManifest(manifestPath);
41
+ const { compilerPath, includeDir: compilerIncludeDir } = await fetchCompiler(manifest.amxmodx.version);
42
+
43
+ // Include dirs come from the same single-source helpers as the build
44
+ // pipeline (src/build-service.js): clone manifest repos (cache-only — the
45
+ // initial build just populated the clone cache), then resolveDeps collects
46
+ // dep .inc files into build/_includes/ and returns those dirs; the compiler
47
+ // bundle is appended last (deps before stdlib, matching the real build).
48
+ const repoLocalDirs = {};
49
+ if (manifest.repos.length > 0) {
50
+ await resolveRepoRefs(manifest.repos, (repo) => resolveGithubToken(manifest, repo));
51
+ const cloneJobs = new Map();
52
+ for (const repoConfig of manifest.repos) {
53
+ const key = repoKey(repoConfig);
54
+ if (!cloneJobs.has(key)) {
55
+ cloneJobs.set(key,
56
+ fetchRepo(repoConfig.repo, repoConfig._resolvedRef, resolveGithubToken(manifest, repoConfig.repo), true, manifest.github.ssh)
57
+ );
58
+ }
59
+ }
60
+ const cloned = await Promise.all(
61
+ [...cloneJobs.entries()].map(async ([key, p]) => ({ key, dir: await p }))
62
+ );
63
+ for (const { key, dir } of cloned) repoLocalDirs[key] = dir;
64
+ }
65
+
66
+ const depsIncludeDirs = await resolveDeps(manifest, repoLocalDirs, true, buildDir);
67
+ const includeDirs = compilerIncludeDir ? [...depsIncludeDirs, compilerIncludeDir] : depsIncludeDirs;
68
+
69
+ const manifestDir = path.dirname(path.resolve(manifestPath));
70
+ const scriptingRootDir = path.join(manifestDir, manifest.amxmodx.dir, 'scripting');
71
+
72
+ const localIncDir = path.join(scriptingRootDir, 'include');
73
+ const collectedIncDir = path.join(buildDir, 'amxmodx', 'scripting', 'include');
74
+ const graphIncludeDirs = [
75
+ scriptingRootDir,
76
+ ...(fs.existsSync(localIncDir) ? [localIncDir] : []),
77
+ ...(fs.existsSync(collectedIncDir) ? [collectedIncDir] : []),
78
+ ...includeDirs,
79
+ ];
80
+ const depGraph = new DepGraph(graphIncludeDirs);
81
+
82
+ const glob = require('fast-glob');
83
+ if (fs.existsSync(scriptingRootDir)) {
84
+ const smaFiles = await glob('**/*.sma', { cwd: scriptingRootDir, absolute: true });
85
+ for (const f of smaFiles) depGraph.parseFile(f);
86
+ logger.dim(` Dep graph: ${smaFiles.length} .sma file(s) indexed`);
87
+ }
88
+
89
+ return { manifest, compilerPath, includeDirs, scriptingRootDir, manifestDir, depGraph };
90
+ }
91
+
92
+ state = await buildWatchState();
93
+
94
+ if (doDeploy && state.manifest.deploy.path) {
95
+ await deployBuild(state.manifest, buildDir, { incremental: true });
96
+ }
97
+
98
+ // Serialize incremental work; full rebuilds flush the queue before wiping build/.
99
+ let queue = Promise.resolve();
100
+ const enqueue = (fn) => {
101
+ queue = queue.then(() => fn()).catch((err) => logger.error(`Watch task error: ${err.message}`));
102
+ return queue;
103
+ };
104
+ const flushQueue = () => queue.catch(() => {});
105
+
106
+ const handlers = {
107
+ onSmaChange(smaPath) {
108
+ return enqueue(async () => {
109
+ state.depGraph.update(smaPath);
110
+ const smaRel = path.relative(state.scriptingRootDir, smaPath).split(path.sep).join('/');
111
+ const pluginRule = applyPluginRule(smaRel, state.manifest.pluginRules, state.manifest.globalPostfix);
112
+ if (!pluginRule) {
113
+ logger.dim(` Skipped by plugin rule: ${smaRel}`);
114
+ return;
115
+ }
116
+ const amxxName = await compileSingle(state.manifest, smaPath, state.compilerPath, state.includeDirs, buildDir, state.scriptingRootDir);
117
+ if (!amxxName) return;
118
+ if (doDeploy && state.manifest.deploy.path) {
119
+ deployPlugin(state.manifest, buildDir, amxxName);
120
+ const pluginName = path.basename(amxxName).replace(/\.amxx$/, '');
121
+ await sendRconForPlugins(state.manifest.deploy, [pluginName]);
122
+ }
123
+ });
124
+ },
125
+
126
+ onIncChange(incPath) {
127
+ return enqueue(async () => {
128
+ state.depGraph.update(incPath);
129
+ const affected = state.depGraph.getSmasDependingOn(incPath);
130
+
131
+ if (affected.size === 0) {
132
+ logger.dim(` No plugins depend on ${path.relative(state.manifestDir, incPath)}, skipping`);
133
+ return;
134
+ }
135
+
136
+ try {
137
+ const compiled = [];
138
+ for (const smaPath of affected) {
139
+ const smaRel = path.relative(state.scriptingRootDir, smaPath).split(path.sep).join('/');
140
+ const pluginRule = applyPluginRule(smaRel, state.manifest.pluginRules, state.manifest.globalPostfix);
141
+ if (!pluginRule) {
142
+ logger.dim(` Skipped by plugin rule: ${smaRel}`);
143
+ continue;
144
+ }
145
+ const amxxName = await compileSingle(state.manifest, smaPath, state.compilerPath, state.includeDirs, buildDir, state.scriptingRootDir);
146
+ if (amxxName) compiled.push(amxxName);
147
+ }
148
+ if (doDeploy && state.manifest.deploy.path) {
149
+ const pluginNames = [];
150
+ for (const amxxName of compiled) {
151
+ deployPlugin(state.manifest, buildDir, amxxName);
152
+ pluginNames.push(path.basename(amxxName).replace(/\.amxx$/, ''));
153
+ }
154
+ await sendRconForPlugins(state.manifest.deploy, pluginNames);
155
+ }
156
+ } catch (err) {
157
+ logger.error(err.message);
158
+ }
159
+ });
160
+ },
161
+
162
+ onFileChange(relPath, section) {
163
+ return enqueue(() => {
164
+ if (doDeploy && state.manifest.deploy.path) {
165
+ deployFile(state.manifest, buildDir, relPath, section);
166
+ }
167
+ });
168
+ },
169
+
170
+ onFileDelete(relPath, section) {
171
+ return enqueue(() => {
172
+ if (doDeploy && state.manifest.deploy.path) {
173
+ removeDeployedFile(state.manifest, buildDir, relPath, section);
174
+ }
175
+ });
176
+ },
177
+
178
+ onManifestChange() {
179
+ return (async () => {
180
+ try {
181
+ await flushQueue(); // let in-flight compiles finish before build/ is wiped
182
+ logger.info('Rebuilding...');
183
+ await runBuild({ manifest: manifestPath, buildDir: options.buildDir });
184
+ state = await buildWatchState();
185
+ if (doDeploy && state.manifest.deploy.path) {
186
+ await deployBuild(state.manifest, buildDir, { incremental: true });
187
+ const pluginNames = gatherPluginNames(buildDir);
188
+ await sendRconForPlugins(state.manifest.deploy, pluginNames);
189
+ }
190
+ logger.warn('Note: if new watch paths were added, restart amxb watch to pick them up');
191
+ } catch (err) {
192
+ logger.error(err.message);
193
+ }
194
+ })();
195
+ },
196
+ };
197
+
198
+ startWatch(state.manifest, manifestPath, handlers);
199
+ }
200
+
201
+ function gatherPluginNames(buildDir) {
202
+ const pluginsDir = path.join(buildDir, 'amxmodx', 'plugins');
203
+ if (!fs.existsSync(pluginsDir)) return [];
204
+ return fs.readdirSync(pluginsDir)
205
+ .filter(f => f.endsWith('.amxx'))
206
+ .map(f => f.replace(/\.amxx$/, ''));
207
+ }
208
+
209
+ module.exports = { runWatch };
@@ -0,0 +1,65 @@
1
+ 'use strict';
2
+
3
+ const fs = require('fs');
4
+ const path = require('path');
5
+ const { execFile } = require('child_process');
6
+
7
+ /**
8
+ * Unified compiler spawn. Interface-agnostic core helper (used by the CLI build,
9
+ * watch mode and the MCP compile tool).
10
+ *
11
+ * ALWAYS resolves — never rejects:
12
+ * - on spawn error (ENOENT etc.): { status: 1, output: String(err.message) }
13
+ * - on close (including non-zero exit): { status: code, output: stdout+stderr merged }
14
+ *
15
+ * On linux, prepends the compiler's directory to LD_LIBRARY_PATH (32-bit
16
+ * amxxpc needs its bundled libs). windowsHide: true keeps a console from
17
+ * flashing on Windows.
18
+ */
19
+ function spawnCompiler(cmd, args, { maxBuffer = 10 * 1024 * 1024 } = {}) {
20
+ return new Promise((resolve) => {
21
+ const env = { ...process.env };
22
+ if (process.platform === 'linux') {
23
+ const compilerDir = path.dirname(cmd);
24
+ env.LD_LIBRARY_PATH = env.LD_LIBRARY_PATH
25
+ ? `${compilerDir}:${env.LD_LIBRARY_PATH}`
26
+ : compilerDir;
27
+ }
28
+ execFile(cmd, args, { env, windowsHide: true, maxBuffer }, (err, stdout, stderr) => {
29
+ if (err) {
30
+ // execFile sets err.code to a number when the process ran and exited
31
+ // non-zero (or was signal-killed → null); string codes (ENOENT, …)
32
+ // and ERR_CHILD_PROCESS_STDIO_MAXBUFFER are spawn/runtime failures.
33
+ if (typeof err.code === 'number') {
34
+ resolve({ status: err.code, output: String(stdout || '') + String(stderr || '') });
35
+ } else {
36
+ resolve({ status: 1, output: String(err.message || err) });
37
+ }
38
+ return;
39
+ }
40
+ resolve({ status: 0, output: String(stdout || '') + String(stderr || '') });
41
+ });
42
+ });
43
+ }
44
+
45
+ /**
46
+ * Assembles the `-i<dir>` compiler include-flag array in the canonical order:
47
+ * scripting dir → local include/ (if it exists) → collected include dir (if it
48
+ * exists) → each entry of includeDirs. Single source of truth for both the CLI
49
+ * build paths and the MCP compile tool.
50
+ */
51
+ function buildIncludeArgs({ scriptingDir, localIncDir, collectedIncDir, includeDirs }) {
52
+ const includes = [];
53
+ includes.push(`-i${scriptingDir}`);
54
+ if (localIncDir && fs.existsSync(localIncDir)) includes.push(`-i${localIncDir}`);
55
+ if (collectedIncDir && fs.existsSync(collectedIncDir)) includes.push(`-i${collectedIncDir}`);
56
+ for (const d of (includeDirs || [])) includes.push(`-i${d}`);
57
+ return includes;
58
+ }
59
+
60
+ /** Turns manifest defines (e.g. ['DEBUG']) into `-DDEBUG` compiler flags. */
61
+ function buildDefineArgs(defines) {
62
+ return (defines || []).map((d) => `-D${d}`);
63
+ }
64
+
65
+ module.exports = { spawnCompiler, buildIncludeArgs, buildDefineArgs };
@@ -0,0 +1,327 @@
1
+ const fs = require('fs');
2
+ const path = require('path');
3
+ const axios = require('axios');
4
+ // Default for API calls; download sites pass their own longer timeout.
5
+ axios.defaults.timeout = 30000;
6
+ const AdmZip = require('adm-zip');
7
+ const chalk = require('chalk');
8
+ const logger = require('./logger');
9
+ const { getCacheDir } = require('./cache-dir');
10
+ const { copyDirContents, safeExtractTar } = require('./fs-utils');
11
+ const { withRetry } = require('./retry');
12
+
13
+ const AMXX_DROP = 'https://www.amxmodx.org/amxxdrop/';
14
+
15
+ const LATEST_VERSION_TTL_MS = 60 * 60 * 1000; // 1 hour — amxxdrop updates rarely
16
+
17
+ let _latestMem = null; // { version, at } — process-lifetime cache
18
+ let _latestFetch = null; // in-flight promise, dedupes concurrent calls
19
+
20
+ /**
21
+ * Ensures the amxxpc compiler is available locally.
22
+ * Downloads from amxmodx.org/amxxdrop/ (official nightly drop, no auth needed).
23
+ *
24
+ * Returns { compilerPath, includeDir } where includeDir points to the
25
+ * bundled standard includes (amxmodx.inc etc.) extracted alongside the binary.
26
+ */
27
+ async function fetchCompiler(version, options = {}) {
28
+ const resolvedVersion = version || await fetchLatestVersion(options);
29
+ const platform = getPlatform();
30
+ const cacheDir = path.join(getCacheDir(), 'amxxpc', resolvedVersion, platform);
31
+ const binaryName = platform === 'windows' ? 'amxxpc.exe' : 'amxxpc';
32
+ const binaryPath = path.join(cacheDir, binaryName);
33
+ const includeDir = path.join(cacheDir, 'include');
34
+
35
+ if (fs.existsSync(binaryPath)) {
36
+ logger.info(`Compiler: amxxpc ${resolvedVersion} (${process.platform}, cached)`);
37
+ return { compilerPath: binaryPath, includeDir: fs.existsSync(includeDir) ? includeDir : null };
38
+ }
39
+
40
+ const { major, minor, build } = parseVersion(resolvedVersion);
41
+ const downloadUrl = buildDownloadUrl(major, minor, build, platform);
42
+
43
+ logger.step(`Compiler: downloading amxxpc ${resolvedVersion} for ${platform}...`);
44
+ logger.dim(` ${downloadUrl}`);
45
+
46
+ fs.mkdirSync(cacheDir, { recursive: true });
47
+ const archivePath = path.join(cacheDir, path.basename(downloadUrl));
48
+
49
+ await downloadFile(downloadUrl, archivePath);
50
+ extractWithPrefix(archivePath, cacheDir, {
51
+ prefix: 'addons/amxmodx/scripting/',
52
+ onDone: (d) => makeBinaryExecutable(d, platform),
53
+ });
54
+ fs.rmSync(archivePath, { force: true });
55
+
56
+ if (!fs.existsSync(binaryPath)) {
57
+ throw new Error(
58
+ `amxxpc binary not found after extraction.\n` +
59
+ `Expected "${binaryName}" in ${cacheDir}.\n` +
60
+ `Archive: ${path.basename(downloadUrl)}`
61
+ );
62
+ }
63
+
64
+ logger.success(`Compiler: amxxpc ${resolvedVersion} ready`);
65
+ return { compilerPath: binaryPath, includeDir: fs.existsSync(includeDir) ? includeDir : null };
66
+ }
67
+
68
+ // "1.10.5428" → { major: '1', minor: '10', build: '5428' }
69
+ function parseVersion(versionStr) {
70
+ const parts = String(versionStr).split('.');
71
+ if (parts.length !== 3) {
72
+ throw new Error(
73
+ `Invalid amxmodx version: "${versionStr}". ` +
74
+ `Expected major.minor.build format (e.g. "1.10.5428").`
75
+ );
76
+ }
77
+ return { major: parts[0], minor: parts[1], build: parts[2] };
78
+ }
79
+
80
+ // https://www.amxmodx.org/amxxdrop/1.10/amxmodx-1.10.0-git5428-base-windows.zip
81
+ function buildDownloadUrl(major, minor, build, platform) {
82
+ const ext = platform === 'windows' ? 'zip' : 'tar.gz';
83
+ return `${AMXX_DROP}${major}.${minor}/amxmodx-${major}.${minor}.0-git${build}-base-${platform}.${ext}`;
84
+ }
85
+
86
+ async function fetchLatestVersion(options = {}) {
87
+ const { noFetch } = options;
88
+ const now = Date.now();
89
+
90
+ if (_latestMem && now - _latestMem.at < LATEST_VERSION_TTL_MS) {
91
+ return _latestMem.version;
92
+ }
93
+
94
+ const cacheFile = path.join(getCacheDir(), 'amxxpc', `.latest-version-${getPlatform()}`);
95
+ try {
96
+ const cached = JSON.parse(fs.readFileSync(cacheFile, 'utf8'));
97
+ if (cached && cached.version && now - cached.at < LATEST_VERSION_TTL_MS) {
98
+ _latestMem = cached;
99
+ return cached.version;
100
+ }
101
+ } catch (_) {} // no/invalid cache file — resolve from web
102
+
103
+ if (noFetch) {
104
+ throw new Error(
105
+ 'Latest amxmodx version is not cached and no-fetch is set.\n' +
106
+ 'Run once without --no-fetch (or set amxmodx.version explicitly) to populate the cache.'
107
+ );
108
+ }
109
+
110
+ if (_latestFetch) return _latestFetch;
111
+
112
+ _latestFetch = resolveLatestVersionFromWeb().then((version) => {
113
+ _latestMem = { version, at: Date.now() };
114
+ try {
115
+ fs.mkdirSync(path.dirname(cacheFile), { recursive: true });
116
+ fs.writeFileSync(cacheFile, JSON.stringify(_latestMem));
117
+ } catch (_) {} // cache write is best-effort
118
+ return version;
119
+ }).finally(() => {
120
+ _latestFetch = null;
121
+ });
122
+
123
+ return _latestFetch;
124
+ }
125
+
126
+ async function resolveLatestVersionFromWeb() {
127
+ logger.step('Compiler: resolving latest amxmodx version...');
128
+ const platform = getPlatform();
129
+
130
+ // 1 — list of major.minor dirs on the drop page
131
+ const { data: mainPage } = await axios.get(AMXX_DROP).catch((e) => {
132
+ throw new Error(`Failed to fetch ${AMXX_DROP}: ${e.message}\n → Check your internet connection or set amxmodx.version explicitly`);
133
+ });
134
+
135
+ const mmPattern = /href="(\d+\.\d+)\/"/g;
136
+ const majorMinors = [];
137
+ let m;
138
+ while ((m = mmPattern.exec(mainPage)) !== null) majorMinors.push(m[1]);
139
+
140
+ if (!majorMinors.length) {
141
+ throw new Error(`No amxmodx version directories found at ${AMXX_DROP}`);
142
+ }
143
+
144
+ majorMinors.sort((a, b) => {
145
+ const [aMaj, aMin] = a.split('.').map(Number);
146
+ const [bMaj, bMin] = b.split('.').map(Number);
147
+ return aMaj !== bMaj ? aMaj - bMaj : aMin - bMin;
148
+ });
149
+ const latestMM = majorMinors[majorMinors.length - 1];
150
+
151
+ // 2 — find the highest build number for the current platform
152
+ const { data: dirPage } = await axios.get(`${AMXX_DROP}${latestMM}/`).catch((e) => {
153
+ throw new Error(`Failed to fetch ${AMXX_DROP}${latestMM}/: ${e.message}`);
154
+ });
155
+
156
+ const buildPattern = new RegExp(
157
+ `href="amxmodx-[\\d.]+-git(\\d+)-base-${platform}(?:\\.\\w+){1,2}"`,
158
+ 'g'
159
+ );
160
+ const builds = [];
161
+ while ((m = buildPattern.exec(dirPage)) !== null) builds.push(parseInt(m[1], 10));
162
+
163
+ if (!builds.length) {
164
+ throw new Error(
165
+ `No amxmodx builds found for platform "${platform}" in ` +
166
+ `${AMXX_DROP}${latestMM}/`
167
+ );
168
+ }
169
+
170
+ builds.sort((a, b) => a - b);
171
+ const version = `${latestMM}.${builds[builds.length - 1]}`;
172
+ logger.dim(` Latest: ${version}`);
173
+ return version;
174
+ }
175
+
176
+ /**
177
+ * Resolve the AMX Mod X version to use.
178
+ * Priority: explicit `version` option → manifest `amxmodx.version` → latest.
179
+ * Single source of truth shared by the CLI build, include-tree and the MCP
180
+ * amxmodx-include / resolve_include / compile_sma tools.
181
+ *
182
+ * @param {object|null} manifest - parsed manifest (pass null when absent or unparseable)
183
+ * @param {object} [options]
184
+ * @param {string} [options.version] - explicit version override (highest priority)
185
+ * @param {boolean} [options.noFetch] - skip network when resolving "latest"
186
+ * @returns {Promise<string>}
187
+ */
188
+ async function resolveAmxmodxVersion(manifest, options = {}) {
189
+ const { version, noFetch } = options;
190
+ if (version) return version;
191
+ if (manifest && manifest.amxmodx && manifest.amxmodx.version) return manifest.amxmodx.version;
192
+ return fetchLatestVersion({ noFetch });
193
+ }
194
+
195
+ function getPlatform() {
196
+ return getHostPlatform();
197
+ }
198
+
199
+ function getHostPlatform() {
200
+ if (process.platform === 'win32') return 'windows';
201
+ if (process.platform === 'darwin') return 'mac';
202
+ return 'linux';
203
+ }
204
+
205
+ /**
206
+ * Returns the path to the full amxmodx addons/ tree for the given target platform.
207
+ * Downloads and extracts the base package if not yet cached.
208
+ * Used by asset-fetcher when source: amxmodx is specified.
209
+ *
210
+ * The returned directory contains addons/amxmodx/{plugins,configs,modules,...}
211
+ */
212
+ async function getAmxmodxFullDir(version, platform) {
213
+ const cacheDir = path.join(getCacheDir(), 'amxxpc', version, platform);
214
+ const sentinel = path.join(cacheDir, '.addons-extracted');
215
+
216
+ if (fs.existsSync(sentinel)) {
217
+ return cacheDir;
218
+ }
219
+
220
+ const { major, minor, build } = parseVersion(version);
221
+ const downloadUrl = buildDownloadUrl(major, minor, build, platform);
222
+
223
+ logger.step(`Assets: downloading amxmodx ${version} (${platform}) for asset extraction...`);
224
+ logger.dim(` ${downloadUrl}`);
225
+
226
+ fs.mkdirSync(cacheDir, { recursive: true });
227
+ const archivePath = path.join(cacheDir, path.basename(downloadUrl));
228
+
229
+ await downloadFile(downloadUrl, archivePath);
230
+ extractWithPrefix(archivePath, cacheDir, { prefix: 'addons/', destSubdir: 'addons' });
231
+ fs.rmSync(archivePath, { force: true });
232
+ const sentinelTmp = sentinel + '.tmp';
233
+ fs.writeFileSync(sentinelTmp, '');
234
+ fs.renameSync(sentinelTmp, sentinel);
235
+
236
+ logger.success(`Assets: amxmodx ${version} (${platform}) ready`);
237
+ return cacheDir;
238
+ }
239
+
240
+ /**
241
+ * Filtered extraction from an AMXX base archive.
242
+ *
243
+ * For .zip: iterates entries matching `prefix`, strips prefix, saves to destDir.
244
+ * For .tar.*: extracts to temp dir, finds `findDirName`, copies contents.
245
+ *
246
+ * @param {string} archivePath — path to the archive
247
+ * @param {string} destDir — destination directory
248
+ * @param {object} opts
249
+ * @param {string} opts.prefix — entry path prefix to filter (zip) / find in tar
250
+ * @param {string} [opts.destSubdir] — optional sub-path under destDir for zip entries
251
+ * @param {string} [opts.tmpSuffix] — suffix for temp extraction dir (default: prefix-derived)
252
+ * @param {function} [opts.onDone] — called after extraction with (destDir)
253
+ */
254
+ function extractWithPrefix(archivePath, destDir, opts) {
255
+ const { prefix, destSubdir, tmpSuffix, onDone } = opts;
256
+
257
+ if (archivePath.endsWith('.zip')) {
258
+ const zip = new AdmZip(archivePath);
259
+ for (const entry of zip.getEntries()) {
260
+ const name = entry.entryName.replace(/\\/g, '/');
261
+ if (entry.isDirectory || !name.startsWith(prefix)) continue;
262
+ const rel = name.slice(prefix.length);
263
+ if (!rel || rel.split('/').includes('..')) continue; // zip-slip guard
264
+ const dest = destSubdir ? path.join(destDir, destSubdir, rel) : path.join(destDir, rel);
265
+ fs.mkdirSync(path.dirname(dest), { recursive: true });
266
+ fs.writeFileSync(dest, entry.getData());
267
+ }
268
+ } else {
269
+ const findName = prefix.replace(/\/$/, '').split('/').pop();
270
+ const tmpDir = destDir + '_' + (tmpSuffix || prefix.replace(/[\/]+/g, '_').replace(/_$/, ''));
271
+ try {
272
+ fs.mkdirSync(tmpDir, { recursive: true });
273
+ safeExtractTar(archivePath, tmpDir);
274
+ const src = findDir(tmpDir, findName);
275
+ if (!src) throw new Error(`${findName}/ dir not found in archive ${path.basename(archivePath)}`);
276
+ copyDirContents(src, destSubdir ? path.join(destDir, destSubdir) : destDir);
277
+ } finally {
278
+ fs.rmSync(tmpDir, { recursive: true, force: true });
279
+ }
280
+ }
281
+
282
+ if (onDone) onDone(destDir);
283
+ }
284
+
285
+ async function downloadFile(url, dest) {
286
+ const filename = path.basename(url);
287
+ const bar = require('./progress').createBar(100, ` ${chalk.cyan('Downloading')} ${(filename || 'file').padEnd(30)}`);
288
+
289
+ const response = await withRetry(
290
+ () => axios.get(url, {
291
+ responseType: 'arraybuffer',
292
+ maxRedirects: 5,
293
+ timeout: 600000, // large archives — allow slow links, still bound hangs
294
+ onDownloadProgress: (e) => {
295
+ if (bar && e.total) {
296
+ bar.update(Math.round(e.loaded / e.total * 100));
297
+ }
298
+ },
299
+ }),
300
+ { label: filename }
301
+ );
302
+ if (bar) bar.stop();
303
+ const part = dest + '.part';
304
+ fs.writeFileSync(part, Buffer.from(response.data));
305
+ fs.renameSync(part, dest);
306
+ }
307
+
308
+ function makeBinaryExecutable(destDir, platform) {
309
+ const binaryName = platform === 'windows' ? 'amxxpc.exe' : 'amxxpc';
310
+ const binaryPath = path.join(destDir, binaryName);
311
+ if (fs.existsSync(binaryPath) && platform !== 'windows') {
312
+ fs.chmodSync(binaryPath, 0o755);
313
+ }
314
+ }
315
+
316
+ function findDir(root, name) {
317
+ for (const entry of fs.readdirSync(root, { withFileTypes: true })) {
318
+ if (!entry.isDirectory()) continue;
319
+ const full = path.join(root, entry.name);
320
+ if (entry.name === name) return full;
321
+ const nested = findDir(full, name);
322
+ if (nested) return nested;
323
+ }
324
+ return null;
325
+ }
326
+
327
+ module.exports = { fetchCompiler, getAmxmodxFullDir, getHostPlatform, fetchLatestVersion, resolveAmxmodxVersion };