amxx-builder 1.6.0 → 1.7.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 (47) hide show
  1. package/AGENTS.md +13 -3
  2. package/README.md +276 -20
  3. package/action.yml +1 -1
  4. package/defaults/amxbuild.defaults.yml +7 -1
  5. package/mcp/handlers.js +342 -152
  6. package/mcp/registry.js +264 -25
  7. package/package.json +1 -1
  8. package/skills/amxb-migration/SKILL.md +126 -27
  9. package/skills/amxx-pawn-style/SKILL.md +422 -0
  10. package/src/agent-assets.js +206 -0
  11. package/src/asset-fetcher.js +33 -48
  12. package/src/build-plan.js +22 -5
  13. package/src/build-service.js +54 -11
  14. package/src/cli.js +38 -4
  15. package/src/collector.js +9 -1
  16. package/src/commands/build.js +5 -4
  17. package/src/commands/dry-run.js +28 -1
  18. package/src/commands/init.js +125 -11
  19. package/src/commands/opencode-skills.js +47 -0
  20. package/src/commands/serve.js +309 -111
  21. package/src/commands/skills-dir.js +21 -0
  22. package/src/commands/watch.js +66 -23
  23. package/src/compile-utils.js +119 -4
  24. package/src/compiler-fetcher.js +196 -41
  25. package/src/compiler.js +68 -29
  26. package/src/dep-graph.js +27 -0
  27. package/src/deployer.js +39 -15
  28. package/src/deps-resolver.js +151 -11
  29. package/src/deps-tree.js +20 -9
  30. package/src/download.js +132 -0
  31. package/src/fs-utils.js +35 -1
  32. package/src/fungun-fetcher.js +28 -8
  33. package/src/github-api.js +32 -0
  34. package/src/include-tree.js +30 -61
  35. package/src/ini-builder.js +1 -1
  36. package/src/jsonrpc-transport.js +53 -5
  37. package/src/local-sources.js +363 -0
  38. package/src/manifest-path.js +18 -1
  39. package/src/manifest.js +398 -40
  40. package/src/opencode-skills.js +262 -0
  41. package/src/release-fetcher.js +26 -33
  42. package/src/repo-fetcher.js +388 -37
  43. package/src/retry.js +3 -1
  44. package/src/schema.js +1 -1
  45. package/templates/init-deploy.env +9 -0
  46. package/templates/init-gitignore +22 -0
  47. package/src/dep-docs.js +0 -115
@@ -11,7 +11,8 @@ const { deployBuild, deployPlugin, deployFile, removeDeployedFile } = require('.
11
11
  const { sendRconForPlugins } = require('../rcon');
12
12
  const { DepGraph } = require('../dep-graph');
13
13
  const { startWatch } = require('../watcher');
14
- const { fetchRepo, resolveRepoRefs } = require('../repo-fetcher');
14
+ const { resolveRepoRefs } = require('../repo-fetcher');
15
+ const { ensureRepoDir } = require('../local-sources');
15
16
  const { resolveDeps, repoKey } = require('../deps-resolver');
16
17
  const { resolveManifestPath, loadEnv } = require('./shared');
17
18
  const { runBuild } = require('./build');
@@ -20,6 +21,13 @@ const { subscribeCompiledRendering } = require('./compile-renderer');
20
21
  // Render compiler 'compiled' events (previously direct stdout/stderr writes).
21
22
  subscribeCompiledRendering();
22
23
 
24
+ // Watch never regenerates INI files, so applyPluginRule is only used as a skip
25
+ // check here; the base only has to be a valid defaults layer.
26
+ function pluginRuleBase(manifest) {
27
+ const { defaultIni, defaultDebug } = manifest.pluginIni;
28
+ return { ini: defaultIni, debug: defaultDebug === true };
29
+ }
30
+
23
31
  async function runWatch(options) {
24
32
  const manifestPath = resolveManifestPath(options.manifest);
25
33
  const buildDir = path.resolve(options.buildDir || './build');
@@ -52,8 +60,14 @@ async function runWatch(options) {
52
60
  for (const repoConfig of manifest.repos) {
53
61
  const key = repoKey(repoConfig);
54
62
  if (!cloneJobs.has(key)) {
63
+ // Cache-only, like before; ensureRepoDir returns `_localDir` for
64
+ // local entries (no network, no noFetch check).
55
65
  cloneJobs.set(key,
56
- fetchRepo(repoConfig.repo, repoConfig._resolvedRef, resolveGithubToken(manifest, repoConfig.repo), true, manifest.github.ssh)
66
+ ensureRepoDir(repoConfig, {
67
+ token: resolveGithubToken(manifest, repoConfig.repo),
68
+ noFetch: true,
69
+ ssh: manifest.github.ssh,
70
+ })
57
71
  );
58
72
  }
59
73
  }
@@ -95,20 +109,56 @@ async function runWatch(options) {
95
109
  await deployBuild(state.manifest, buildDir, { incremental: true });
96
110
  }
97
111
 
98
- // Serialize incremental work; full rebuilds flush the queue before wiping build/.
112
+ // Serialize all incremental work through a single promise chain so no two
113
+ // tasks ever touch build/ at the same time.
99
114
  let queue = Promise.resolve();
100
115
  const enqueue = (fn) => {
101
116
  queue = queue.then(() => fn()).catch((err) => logger.error(`Watch task error: ${err.message}`));
102
117
  return queue;
103
118
  };
104
- const flushQueue = () => queue.catch(() => {});
119
+
120
+ // Full rebuilds (manifest change) run on the same chain. Coalescing: a second
121
+ // manifest save while a rebuild is queued/running only marks a re-run instead
122
+ // of starting a second concurrent rebuild over the same build/.
123
+ let rebuildQueued = false;
124
+ let rebuildAgain = false;
125
+
126
+ async function performRebuild() {
127
+ logger.info('Rebuilding...');
128
+ await runBuild({ manifest: manifestPath, buildDir: options.buildDir });
129
+ state = await buildWatchState();
130
+ if (doDeploy && state.manifest.deploy.path) {
131
+ await deployBuild(state.manifest, buildDir, { incremental: true });
132
+ const pluginNames = gatherPluginNames(buildDir);
133
+ await sendRconForPlugins(state.manifest.deploy, pluginNames);
134
+ }
135
+ logger.warn('Note: if new watch paths were added, restart amxb watch to pick them up');
136
+ }
137
+
138
+ function scheduleRebuild() {
139
+ if (rebuildQueued) {
140
+ rebuildAgain = true;
141
+ return;
142
+ }
143
+ rebuildQueued = true;
144
+ return enqueue(async () => {
145
+ try {
146
+ do {
147
+ rebuildAgain = false;
148
+ await performRebuild();
149
+ } while (rebuildAgain);
150
+ } finally {
151
+ rebuildQueued = false;
152
+ }
153
+ });
154
+ }
105
155
 
106
156
  const handlers = {
107
157
  onSmaChange(smaPath) {
108
158
  return enqueue(async () => {
109
159
  state.depGraph.update(smaPath);
110
160
  const smaRel = path.relative(state.scriptingRootDir, smaPath).split(path.sep).join('/');
111
- const pluginRule = applyPluginRule(smaRel, state.manifest.pluginRules, state.manifest.globalPostfix);
161
+ const pluginRule = applyPluginRule(smaRel, state.manifest.plugins.rules, pluginRuleBase(state.manifest));
112
162
  if (!pluginRule) {
113
163
  logger.dim(` Skipped by plugin rule: ${smaRel}`);
114
164
  return;
@@ -127,6 +177,11 @@ async function runWatch(options) {
127
177
  return enqueue(async () => {
128
178
  state.depGraph.update(incPath);
129
179
  const affected = state.depGraph.getSmasDependingOn(incPath);
180
+ // A newly-added .inc can satisfy includes that were previously missing
181
+ // (e.g. a plugin saved with #include <newlib> before newlib.inc existed).
182
+ for (const smaPath of state.depGraph.reattachMissingIncludes(incPath)) {
183
+ affected.add(smaPath);
184
+ }
130
185
 
131
186
  if (affected.size === 0) {
132
187
  logger.dim(` No plugins depend on ${path.relative(state.manifestDir, incPath)}, skipping`);
@@ -137,7 +192,7 @@ async function runWatch(options) {
137
192
  const compiled = [];
138
193
  for (const smaPath of affected) {
139
194
  const smaRel = path.relative(state.scriptingRootDir, smaPath).split(path.sep).join('/');
140
- const pluginRule = applyPluginRule(smaRel, state.manifest.pluginRules, state.manifest.globalPostfix);
195
+ const pluginRule = applyPluginRule(smaRel, state.manifest.plugins.rules, pluginRuleBase(state.manifest));
141
196
  if (!pluginRule) {
142
197
  logger.dim(` Skipped by plugin rule: ${smaRel}`);
143
198
  continue;
@@ -162,7 +217,10 @@ async function runWatch(options) {
162
217
  onFileChange(relPath, section) {
163
218
  return enqueue(() => {
164
219
  if (doDeploy && state.manifest.deploy.path) {
165
- deployFile(state.manifest, buildDir, relPath, section);
220
+ const srcRoot = section === 'assets'
221
+ ? path.join(state.manifestDir, 'assets')
222
+ : path.join(state.manifestDir, state.manifest.amxmodx.dir);
223
+ deployFile(state.manifest, buildDir, relPath, section, srcRoot);
166
224
  }
167
225
  });
168
226
  },
@@ -176,22 +234,7 @@ async function runWatch(options) {
176
234
  },
177
235
 
178
236
  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
- })();
237
+ return scheduleRebuild();
195
238
  },
196
239
  };
197
240
 
@@ -4,11 +4,113 @@ const fs = require('fs');
4
4
  const path = require('path');
5
5
  const { execFile } = require('child_process');
6
6
 
7
+ // ─── WSL DrvFs/9p input guard ────────────────────────────────────────────────
8
+ //
9
+ // amxxpc is a 32-bit Linux binary that cannot READ files on WSL DrvFs/9p mounts
10
+ // (/mnt/c, /mnt/d, /mnt/j, …). A source or include dir there makes it fail with
11
+ // "fatal error 100: cannot read from file" (a directory used as -i aborts it
12
+ // with std::bad_alloc), even though the file exists and Node reads it fine.
13
+ // Writing output to such a mount works. Catch this up-front and hand back an
14
+ // actionable message instead of the cryptic compiler output.
15
+
16
+ let _isWsl;
17
+ let _mountpoints = null;
18
+
19
+ function isWsl() {
20
+ if (_isWsl !== undefined) return _isWsl;
21
+ _isWsl = false;
22
+ if (process.platform === 'linux') {
23
+ if (process.env.WSL_DISTRO_NAME || process.env.WSL_INTEROP) {
24
+ _isWsl = true;
25
+ } else {
26
+ try {
27
+ _isWsl = /microsoft|wsl/i.test(fs.readFileSync('/proc/version', 'utf8'));
28
+ } catch (_) { /* no /proc/version — assume not WSL */ }
29
+ }
30
+ }
31
+ return _isWsl;
32
+ }
33
+
34
+ // "/proc/mounts" text → mountpoints whose fstype is 9p (WSL DrvFs).
35
+ // Mountpoints escape spaces/tabs/backslashes as \040 / \011 / \134.
36
+ function parse9pMountpoints(mountsText) {
37
+ const out = [];
38
+ for (const line of String(mountsText).split('\n')) {
39
+ const parts = line.split(/\s+/);
40
+ if (parts.length >= 4 && parts[2] === '9p') {
41
+ out.push(parts[1].replace(/\\([0-7]{3})/g, (_, oct) => String.fromCharCode(parseInt(oct, 8))));
42
+ }
43
+ }
44
+ return out;
45
+ }
46
+
47
+ function get9pMountpoints() {
48
+ if (_mountpoints) return _mountpoints;
49
+ _mountpoints = [];
50
+ if (process.platform === 'linux') {
51
+ try { _mountpoints = parse9pMountpoints(fs.readFileSync('/proc/mounts', 'utf8')); }
52
+ catch (_) { /* no /proc/mounts — nothing to guard */ }
53
+ }
54
+ return _mountpoints;
55
+ }
56
+
57
+ // Compiler argv → the paths amxxpc must READ (source + `-i` include dirs).
58
+ // `-o` output paths are ignored — writing to a 9p mount works.
59
+ function collectCompilerInputs(args) {
60
+ const inputs = [];
61
+ for (let i = 0; i < args.length; i++) {
62
+ const arg = args[i];
63
+ if (arg === '-i') { if (args[i + 1]) inputs.push(args[++i]); continue; }
64
+ if (arg.startsWith('-i')) { inputs.push(arg.slice(2)); continue; }
65
+ if (arg === '-o') { i++; continue; }
66
+ if (arg.startsWith('-o')) continue;
67
+ if (i === 0 && !arg.startsWith('-')) inputs.push(arg); // source is argv[0]
68
+ }
69
+ return inputs.filter(Boolean);
70
+ }
71
+
72
+ function isUnder(p, dir) {
73
+ const abs = path.resolve(p);
74
+ if (abs === dir) return true;
75
+ return abs.startsWith(dir.endsWith('/') ? dir : dir + '/');
76
+ }
77
+
78
+ function findNonNativeInputs(args, mountpoints) {
79
+ return collectCompilerInputs(args).filter((p) => mountpoints.some((mp) => isUnder(p, mp)));
80
+ }
81
+
82
+ function nonNativeInputError(inputs) {
83
+ return [
84
+ 'amxxpc cannot read files on WSL DrvFs/9p mounts (/mnt/*).',
85
+ 'Unreadable input(s):',
86
+ ...inputs.map((p) => ` ${p}`),
87
+ 'The compiler is a 32-bit Linux binary; on these mounts it fails with',
88
+ '"fatal error 100: cannot read from file" (an include directory aborts it with',
89
+ 'std::bad_alloc), although the file exists and Node can read it.',
90
+ 'Run the build from the Linux-native filesystem instead — copy the project to',
91
+ 'e.g. ~/ (the ~/.cache/amxx-builder cache is shared, nothing re-downloads), or',
92
+ 'run the build on Windows.',
93
+ ].join('\n');
94
+ }
95
+
96
+ /**
97
+ * Preflight for a compiler invocation. On WSL, when the source or any `-i`
98
+ * include dir sits on a DrvFs/9p mount, `ok` is false and `error` is a
99
+ * ready-to-print explanation. `wsl`/`mountpoints` are injectable for tests.
100
+ */
101
+ function checkCompilerInputs(args, { wsl = isWsl(), mountpoints = get9pMountpoints() } = {}) {
102
+ if (!wsl || !mountpoints.length) return { ok: true, inputs: [], error: null };
103
+ const inputs = findNonNativeInputs(args, mountpoints);
104
+ if (!inputs.length) return { ok: true, inputs: [], error: null };
105
+ return { ok: false, inputs, error: nonNativeInputError(inputs) };
106
+ }
107
+
7
108
  /**
8
109
  * Unified compiler spawn. Interface-agnostic core helper (used by the CLI build,
9
110
  * watch mode and the MCP compile tool).
10
111
  *
11
112
  * ALWAYS resolves — never rejects:
113
+ * - WSL + input on a DrvFs/9p mount (/mnt/*): { status: 1, output: <explanation> }
12
114
  * - on spawn error (ENOENT etc.): { status: 1, output: String(err.message) }
13
115
  * - on close (including non-zero exit): { status: code, output: stdout+stderr merged }
14
116
  *
@@ -16,7 +118,11 @@ const { execFile } = require('child_process');
16
118
  * amxxpc needs its bundled libs). windowsHide: true keeps a console from
17
119
  * flashing on Windows.
18
120
  */
19
- function spawnCompiler(cmd, args, { maxBuffer = 10 * 1024 * 1024 } = {}) {
121
+ function spawnCompiler(cmd, args, { maxBuffer = 10 * 1024 * 1024, ...guardOptions } = {}) {
122
+ const guard = checkCompilerInputs(args, guardOptions);
123
+ if (!guard.ok) {
124
+ return Promise.resolve({ status: 1, output: guard.error });
125
+ }
20
126
  return new Promise((resolve) => {
21
127
  const env = { ...process.env };
22
128
  if (process.platform === 'linux') {
@@ -57,9 +163,18 @@ function buildIncludeArgs({ scriptingDir, localIncDir, collectedIncDir, includeD
57
163
  return includes;
58
164
  }
59
165
 
60
- /** Turns manifest defines (e.g. ['DEBUG']) into `-DDEBUG` compiler flags. */
166
+ /**
167
+ * Turns manifest defines (e.g. ['DEBUG', 'VERSION=2']) into amxxpc CLI args.
168
+ *
169
+ * amxxpc (Pawn compiler fork) does NOT accept `-D<name>` defines: on Linux the
170
+ * -D flag is compiled out (dead dos_setdrive code) and rejected; on Windows it
171
+ * is silently treated as chdir. Its only CLI define syntax is a bare
172
+ * `sym=val` argument (sc1.c parseoptions). A value-less flag is therefore
173
+ * normalized to `NAME=1` (semantics of `-DNAME` elsewhere: `#if NAME` and
174
+ * `#if defined NAME` both see it enabled).
175
+ */
61
176
  function buildDefineArgs(defines) {
62
- return (defines || []).map((d) => `-D${d}`);
177
+ return (defines || []).map((d) => (d.includes('=') ? d : `${d}=1`));
63
178
  }
64
179
 
65
- module.exports = { spawnCompiler, buildIncludeArgs, buildDefineArgs };
180
+ module.exports = { spawnCompiler, buildIncludeArgs, buildDefineArgs, checkCompilerInputs, parse9pMountpoints };
@@ -7,8 +7,8 @@ const AdmZip = require('adm-zip');
7
7
  const chalk = require('chalk');
8
8
  const logger = require('./logger');
9
9
  const { getCacheDir } = require('./cache-dir');
10
- const { copyDirContents, safeExtractTar } = require('./fs-utils');
11
- const { withRetry } = require('./retry');
10
+ const { copyDirContents, safeExtractTar, makeSiblingTmpDir } = require('./fs-utils');
11
+ const { downloadToFile } = require('./download');
12
12
 
13
13
  const AMXX_DROP = 'https://www.amxmodx.org/amxxdrop/';
14
14
 
@@ -31,8 +31,15 @@ async function fetchCompiler(version, options = {}) {
31
31
  const binaryName = platform === 'windows' ? 'amxxpc.exe' : 'amxxpc';
32
32
  const binaryPath = path.join(cacheDir, binaryName);
33
33
  const includeDir = path.join(cacheDir, 'include');
34
+ const completeFile = path.join(cacheDir, '.complete');
34
35
 
35
36
  if (fs.existsSync(binaryPath)) {
37
+ // Backfill the completion marker on legacy (pre-`.complete`) caches so an
38
+ // interrupted extraction is detectable going forward; a fresh install only
39
+ // gains the marker after a successful atomic extract.
40
+ if (!fs.existsSync(completeFile)) {
41
+ try { writeFileSyncAtomic(completeFile, resolvedVersion); } catch (_) {}
42
+ }
36
43
  logger.info(`Compiler: amxxpc ${resolvedVersion} (${process.platform}, cached)`);
37
44
  return { compilerPath: binaryPath, includeDir: fs.existsSync(includeDir) ? includeDir : null };
38
45
  }
@@ -43,15 +50,20 @@ async function fetchCompiler(version, options = {}) {
43
50
  logger.step(`Compiler: downloading amxxpc ${resolvedVersion} for ${platform}...`);
44
51
  logger.dim(` ${downloadUrl}`);
45
52
 
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
+ await installCompilerCache(cacheDir, {
54
+ isFinalValid: () => fs.existsSync(binaryPath) && fs.existsSync(completeFile),
55
+ sentinelName: '.complete',
56
+ sentinelContent: resolvedVersion,
57
+ populate: async (stagingDir) => {
58
+ const archivePath = path.join(stagingDir, path.basename(downloadUrl));
59
+ await downloadFile(downloadUrl, archivePath);
60
+ extractWithPrefix(archivePath, stagingDir, {
61
+ prefix: 'addons/amxmodx/scripting/',
62
+ onDone: (d) => makeBinaryExecutable(d, platform),
63
+ });
64
+ fs.rmSync(archivePath, { force: true });
65
+ },
53
66
  });
54
- fs.rmSync(archivePath, { force: true });
55
67
 
56
68
  if (!fs.existsSync(binaryPath)) {
57
69
  throw new Error(
@@ -65,6 +77,52 @@ async function fetchCompiler(version, options = {}) {
65
77
  return { compilerPath: binaryPath, includeDir: fs.existsSync(includeDir) ? includeDir : null };
66
78
  }
67
79
 
80
+ /**
81
+ * Atomic cache-write path shared by fetchCompiler (scripting/) and
82
+ * getAmxmodxFullDir (addons/). Stages the download+extract into a unique
83
+ * sibling temp dir, then:
84
+ * - fresh cache dir → whole-dir rename (a kill can only orphan the temp dir,
85
+ * never leave a partial cache),
86
+ * - cache dir already populated (the two variants share one dir, or a
87
+ * concurrent run won) → merge the staged content and write the completion
88
+ * sentinel LAST, so an interrupt mid-merge cannot mark partial files valid.
89
+ * Cleans up its own temp dir on failure.
90
+ */
91
+ async function installCompilerCache(cacheDir, { isFinalValid, sentinelName, sentinelContent, populate }) {
92
+ const tmpDir = makeSiblingTmpDir(cacheDir);
93
+ try {
94
+ await populate(tmpDir);
95
+
96
+ if (!fs.existsSync(cacheDir)) {
97
+ writeFileSyncAtomic(path.join(tmpDir, sentinelName), sentinelContent);
98
+ try {
99
+ fs.renameSync(tmpDir, cacheDir);
100
+ return;
101
+ } catch (_) {
102
+ // A concurrent run created cacheDir between the check and the rename.
103
+ }
104
+ }
105
+
106
+ if (isFinalValid()) {
107
+ try { fs.rmSync(tmpDir, { recursive: true, force: true }); } catch (_) {}
108
+ return;
109
+ }
110
+
111
+ copyDirContents(tmpDir, cacheDir);
112
+ try { fs.rmSync(tmpDir, { recursive: true, force: true }); } catch (_) {}
113
+ writeFileSyncAtomic(path.join(cacheDir, sentinelName), sentinelContent);
114
+ } catch (err) {
115
+ try { fs.rmSync(tmpDir, { recursive: true, force: true }); } catch (_) {}
116
+ throw err;
117
+ }
118
+ }
119
+
120
+ function writeFileSyncAtomic(file, content) {
121
+ const tmp = file + '.tmp';
122
+ fs.writeFileSync(tmp, content);
123
+ fs.renameSync(tmp, file);
124
+ }
125
+
68
126
  /**
69
127
  * Compiler info without necessarily downloading. With noFetch the report only
70
128
  * reflects what is already cached; otherwise the compiler is ensured to be
@@ -134,10 +192,12 @@ async function fetchLatestVersion(options = {}) {
134
192
  } catch (_) {} // no/invalid cache file — resolve from web
135
193
 
136
194
  if (noFetch) {
137
- throw new Error(
195
+ const err = new Error(
138
196
  'Latest amxmodx version is not cached and no-fetch is set.\n' +
139
197
  'Run once without --no-fetch (or set amxmodx.version explicitly) to populate the cache.'
140
198
  );
199
+ err.code = 'LATEST_NOT_CACHED';
200
+ throw err;
141
201
  }
142
202
 
143
203
  if (_latestFetch) return _latestFetch;
@@ -220,11 +280,67 @@ async function resolveLatestVersionFromWeb() {
220
280
  */
221
281
  async function resolveAmxmodxVersion(manifest, options = {}) {
222
282
  const { version, noFetch } = options;
223
- if (version) return version;
283
+ if (version) {
284
+ if (version === 'latest') {
285
+ return fetchLatestVersion({ noFetch });
286
+ }
287
+ try {
288
+ parseVersion(version); // validate major.minor.build shape
289
+ } catch (err) {
290
+ err.code = 'INVALID_AMXMODX_VERSION';
291
+ throw err;
292
+ }
293
+ return version;
294
+ }
224
295
  if (manifest && manifest.amxmodx && manifest.amxmodx.version) return manifest.amxmodx.version;
225
296
  return fetchLatestVersion({ noFetch });
226
297
  }
227
298
 
299
+ /**
300
+ * Resolve the AMX Mod X version for an informational stdlib lookup with
301
+ * graceful offline degradation (noFetch). The logic previously lived inline in
302
+ * src/commands/serve.js (resolveVersionFromParams + resolveStdlibState) and is
303
+ * duplicated — without the degradation — in the MCP layer.
304
+ *
305
+ * Priority: explicit `version` → manifest `amxmodx.version` (unless the literal
306
+ * 'latest') → the latest release.
307
+ *
308
+ * Offline (noFetch) and latest cannot be resolved:
309
+ * - nothing cached → { version: null, degraded: false } (graceful empty)
310
+ * - a cached compiler exists → { version: <newest cached>, degraded: true }
311
+ * Invalid explicit versions and other real failures are returned as `error`
312
+ * (with code INVALID_AMXMODX_VERSION for malformed explicit versions) rather
313
+ * than thrown, so each interface can shape them per its convention.
314
+ *
315
+ * @param {object} [opts]
316
+ * @param {string} [opts.version] - explicit version override
317
+ * @param {object|null} [opts.manifest] - parsed manifest (null when absent/unparseable)
318
+ * @param {boolean} [opts.noFetch=false] - skip network when resolving "latest"
319
+ * @returns {Promise<{ version: string|null, degraded: boolean, error: Error|null }>}
320
+ */
321
+ async function resolveStdlibVersion({ version = null, manifest = null, noFetch = false } = {}) {
322
+ let degraded = false;
323
+ let resolved;
324
+ try {
325
+ const manifestVersion = manifest && manifest.amxmodx && manifest.amxmodx.version;
326
+ if (version) {
327
+ resolved = await resolveAmxmodxVersion(null, { version, noFetch });
328
+ } else if (manifestVersion && manifestVersion !== 'latest') {
329
+ resolved = manifestVersion;
330
+ } else {
331
+ resolved = await resolveAmxmodxVersion(manifestVersion === 'latest' ? null : manifest, { noFetch });
332
+ }
333
+ } catch (err) {
334
+ if (noFetch && err && err.code === 'LATEST_NOT_CACHED') {
335
+ const fallback = findNewestCachedCompiler();
336
+ if (!fallback) return { version: null, degraded: false, error: null };
337
+ return { version: fallback.version, degraded: true, error: null };
338
+ }
339
+ return { version: null, degraded: false, error: err };
340
+ }
341
+ return { version: resolved, degraded, error: null };
342
+ }
343
+
228
344
  function getPlatform() {
229
345
  return getHostPlatform();
230
346
  }
@@ -235,6 +351,59 @@ function getHostPlatform() {
235
351
  return 'linux';
236
352
  }
237
353
 
354
+ /**
355
+ * Find the newest usable cached compiler for a platform, without downloading.
356
+ *
357
+ * Cache layout: <cache>/amxxpc/<version>/<platform>/amxxpc[.exe] (+ include/).
358
+ * Files directly inside amxxpc/ (e.g. .latest-version-<platform>) and any
359
+ * non-dotted-numeric dirs are ignored. Candidates with an include/ dir are
360
+ * preferred; otherwise the numerically newest version wins (longer tuple is
361
+ * newer when the common prefix is equal, e.g. 1.9.0.5299 > 1.9.0).
362
+ *
363
+ * @param {string} [platform=getHostPlatform()]
364
+ * @returns {{version: string, compilerPath: string, includeDir: string}|null}
365
+ */
366
+ function findNewestCachedCompiler(platform = getHostPlatform()) {
367
+ const amxxpcDir = path.join(getCacheDir(), 'amxxpc');
368
+ if (!fs.existsSync(amxxpcDir)) return null;
369
+
370
+ const binaryName = platform === 'windows' ? 'amxxpc.exe' : 'amxxpc';
371
+ const candidates = [];
372
+
373
+ for (const entry of fs.readdirSync(amxxpcDir, { withFileTypes: true })) {
374
+ if (!entry.isDirectory() || !/^\d+(\.\d+)+$/.test(entry.name)) continue;
375
+ const verDir = path.join(amxxpcDir, entry.name);
376
+ const platformDir = path.join(verDir, platform);
377
+ const compilerPath = path.join(platformDir, binaryName);
378
+ if (!fs.existsSync(compilerPath)) continue;
379
+ const includeDir = path.join(platformDir, 'include');
380
+ candidates.push({
381
+ version: entry.name,
382
+ compilerPath,
383
+ includeDir,
384
+ hasInclude: fs.existsSync(includeDir),
385
+ parts: entry.name.split('.').map(Number),
386
+ });
387
+ }
388
+
389
+ if (!candidates.length) return null;
390
+
391
+ candidates.sort((a, b) => {
392
+ if (a.hasInclude !== b.hasInclude) return a.hasInclude ? -1 : 1;
393
+ const common = Math.min(a.parts.length, b.parts.length);
394
+ for (let i = 0; i < common; i++) {
395
+ if (a.parts[i] !== b.parts[i]) return b.parts[i] - a.parts[i];
396
+ }
397
+ return b.parts.length - a.parts.length; // longer tuple is newer on equal prefix
398
+ });
399
+
400
+ return {
401
+ version: candidates[0].version,
402
+ compilerPath: candidates[0].compilerPath,
403
+ includeDir: candidates[0].includeDir,
404
+ };
405
+ }
406
+
238
407
  /**
239
408
  * Returns the path to the full amxmodx addons/ tree for the given target platform.
240
409
  * Downloads and extracts the base package if not yet cached.
@@ -256,15 +425,17 @@ async function getAmxmodxFullDir(version, platform) {
256
425
  logger.step(`Assets: downloading amxmodx ${version} (${platform}) for asset extraction...`);
257
426
  logger.dim(` ${downloadUrl}`);
258
427
 
259
- fs.mkdirSync(cacheDir, { recursive: true });
260
- const archivePath = path.join(cacheDir, path.basename(downloadUrl));
261
-
262
- await downloadFile(downloadUrl, archivePath);
263
- extractWithPrefix(archivePath, cacheDir, { prefix: 'addons/', destSubdir: 'addons' });
264
- fs.rmSync(archivePath, { force: true });
265
- const sentinelTmp = sentinel + '.tmp';
266
- fs.writeFileSync(sentinelTmp, '');
267
- fs.renameSync(sentinelTmp, sentinel);
428
+ await installCompilerCache(cacheDir, {
429
+ isFinalValid: () => fs.existsSync(sentinel),
430
+ sentinelName: '.addons-extracted',
431
+ sentinelContent: '',
432
+ populate: async (stagingDir) => {
433
+ const archivePath = path.join(stagingDir, path.basename(downloadUrl));
434
+ await downloadFile(downloadUrl, archivePath);
435
+ extractWithPrefix(archivePath, stagingDir, { prefix: 'addons/', destSubdir: 'addons' });
436
+ fs.rmSync(archivePath, { force: true });
437
+ },
438
+ });
268
439
 
269
440
  logger.success(`Assets: amxmodx ${version} (${platform}) ready`);
270
441
  return cacheDir;
@@ -317,25 +488,9 @@ function extractWithPrefix(archivePath, destDir, opts) {
317
488
 
318
489
  async function downloadFile(url, dest) {
319
490
  const filename = path.basename(url);
320
- const bar = require('./progress').createBar(100, ` ${chalk.cyan('Downloading')} ${(filename || 'file').padEnd(30)}`);
321
-
322
- const response = await withRetry(
323
- () => axios.get(url, {
324
- responseType: 'arraybuffer',
325
- maxRedirects: 5,
326
- timeout: 600000, // large archives — allow slow links, still bound hangs
327
- onDownloadProgress: (e) => {
328
- if (bar && e.total) {
329
- bar.update(Math.round(e.loaded / e.total * 100));
330
- }
331
- },
332
- }),
333
- { label: filename }
334
- );
335
- if (bar) bar.stop();
336
- const part = dest + '.part';
337
- fs.writeFileSync(part, Buffer.from(response.data));
338
- fs.renameSync(part, dest);
491
+ return downloadToFile(url, dest, {
492
+ progressLabel: ` ${chalk.cyan('Downloading')} ${(filename || 'file').padEnd(30)}`,
493
+ });
339
494
  }
340
495
 
341
496
  function makeBinaryExecutable(destDir, platform) {
@@ -357,4 +512,4 @@ function findDir(root, name) {
357
512
  return null;
358
513
  }
359
514
 
360
- module.exports = { fetchCompiler, getCompilerInfo, getAmxmodxFullDir, getHostPlatform, fetchLatestVersion, resolveAmxmodxVersion };
515
+ module.exports = { fetchCompiler, getCompilerInfo, getAmxmodxFullDir, getHostPlatform, fetchLatestVersion, findNewestCachedCompiler, resolveAmxmodxVersion, resolveStdlibVersion };