amxx-builder 1.5.2 → 1.6.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.
@@ -0,0 +1,21 @@
1
+ 'use strict';
2
+
3
+ const fs = require('fs');
4
+ const path = require('path');
5
+
6
+ // Absolute path to the skills/ directory bundled with this amxb installation.
7
+ // Resolved relative to the running code, so it works for any install method
8
+ // (npm -g, install.sh/ps1, linked dev checkout) without consulting npm.
9
+ const SKILLS_DIR = path.join(__dirname, '..', '..', 'skills');
10
+
11
+ function runSkillsDir() {
12
+ if (!fs.existsSync(SKILLS_DIR)) {
13
+ process.stderr.write(`skills directory not found: ${SKILLS_DIR}\n`);
14
+ process.exitCode = 1;
15
+ return;
16
+ }
17
+ // Pure stdout — consumers (e.g. the opencode bridge plugin) parse exactly this line.
18
+ process.stdout.write(SKILLS_DIR + '\n');
19
+ }
20
+
21
+ module.exports = { runSkillsDir, SKILLS_DIR };
@@ -95,13 +95,49 @@ async function runWatch(options) {
95
95
  await deployBuild(state.manifest, buildDir, { incremental: true });
96
96
  }
97
97
 
98
- // Serialize incremental work; full rebuilds flush the queue before wiping build/.
98
+ // Serialize all incremental work through a single promise chain so no two
99
+ // tasks ever touch build/ at the same time.
99
100
  let queue = Promise.resolve();
100
101
  const enqueue = (fn) => {
101
102
  queue = queue.then(() => fn()).catch((err) => logger.error(`Watch task error: ${err.message}`));
102
103
  return queue;
103
104
  };
104
- const flushQueue = () => queue.catch(() => {});
105
+
106
+ // Full rebuilds (manifest change) run on the same chain. Coalescing: a second
107
+ // manifest save while a rebuild is queued/running only marks a re-run instead
108
+ // of starting a second concurrent rebuild over the same build/.
109
+ let rebuildQueued = false;
110
+ let rebuildAgain = false;
111
+
112
+ async function performRebuild() {
113
+ logger.info('Rebuilding...');
114
+ await runBuild({ manifest: manifestPath, buildDir: options.buildDir });
115
+ state = await buildWatchState();
116
+ if (doDeploy && state.manifest.deploy.path) {
117
+ await deployBuild(state.manifest, buildDir, { incremental: true });
118
+ const pluginNames = gatherPluginNames(buildDir);
119
+ await sendRconForPlugins(state.manifest.deploy, pluginNames);
120
+ }
121
+ logger.warn('Note: if new watch paths were added, restart amxb watch to pick them up');
122
+ }
123
+
124
+ function scheduleRebuild() {
125
+ if (rebuildQueued) {
126
+ rebuildAgain = true;
127
+ return;
128
+ }
129
+ rebuildQueued = true;
130
+ return enqueue(async () => {
131
+ try {
132
+ do {
133
+ rebuildAgain = false;
134
+ await performRebuild();
135
+ } while (rebuildAgain);
136
+ } finally {
137
+ rebuildQueued = false;
138
+ }
139
+ });
140
+ }
105
141
 
106
142
  const handlers = {
107
143
  onSmaChange(smaPath) {
@@ -127,6 +163,11 @@ async function runWatch(options) {
127
163
  return enqueue(async () => {
128
164
  state.depGraph.update(incPath);
129
165
  const affected = state.depGraph.getSmasDependingOn(incPath);
166
+ // A newly-added .inc can satisfy includes that were previously missing
167
+ // (e.g. a plugin saved with #include <newlib> before newlib.inc existed).
168
+ for (const smaPath of state.depGraph.reattachMissingIncludes(incPath)) {
169
+ affected.add(smaPath);
170
+ }
130
171
 
131
172
  if (affected.size === 0) {
132
173
  logger.dim(` No plugins depend on ${path.relative(state.manifestDir, incPath)}, skipping`);
@@ -162,7 +203,10 @@ async function runWatch(options) {
162
203
  onFileChange(relPath, section) {
163
204
  return enqueue(() => {
164
205
  if (doDeploy && state.manifest.deploy.path) {
165
- deployFile(state.manifest, buildDir, relPath, section);
206
+ const srcRoot = section === 'assets'
207
+ ? path.join(state.manifestDir, 'assets')
208
+ : path.join(state.manifestDir, state.manifest.amxmodx.dir);
209
+ deployFile(state.manifest, buildDir, relPath, section, srcRoot);
166
210
  }
167
211
  });
168
212
  },
@@ -176,22 +220,7 @@ async function runWatch(options) {
176
220
  },
177
221
 
178
222
  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
- })();
223
+ return scheduleRebuild();
195
224
  },
196
225
  };
197
226
 
@@ -57,9 +57,18 @@ function buildIncludeArgs({ scriptingDir, localIncDir, collectedIncDir, includeD
57
57
  return includes;
58
58
  }
59
59
 
60
- /** Turns manifest defines (e.g. ['DEBUG']) into `-DDEBUG` compiler flags. */
60
+ /**
61
+ * Turns manifest defines (e.g. ['DEBUG', 'VERSION=2']) into amxxpc CLI args.
62
+ *
63
+ * amxxpc (Pawn compiler fork) does NOT accept `-D<name>` defines: on Linux the
64
+ * -D flag is compiled out (dead dos_setdrive code) and rejected; on Windows it
65
+ * is silently treated as chdir. Its only CLI define syntax is a bare
66
+ * `sym=val` argument (sc1.c parseoptions). A value-less flag is therefore
67
+ * normalized to `NAME=1` (semantics of `-DNAME` elsewhere: `#if NAME` and
68
+ * `#if defined NAME` both see it enabled).
69
+ */
61
70
  function buildDefineArgs(defines) {
62
- return (defines || []).map((d) => `-D${d}`);
71
+ return (defines || []).map((d) => (d.includes('=') ? d : `${d}=1`));
63
72
  }
64
73
 
65
74
  module.exports = { spawnCompiler, buildIncludeArgs, buildDefineArgs };
@@ -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,85 @@ 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
+
126
+ /**
127
+ * Compiler info without necessarily downloading. With noFetch the report only
128
+ * reflects what is already cached; otherwise the compiler is ensured to be
129
+ * present (downloaded on first use), exactly like fetchCompiler.
130
+ *
131
+ * @param {string} [version] - explicit version (else resolved like the CLI: manifest → latest)
132
+ * @param {object} [options]
133
+ * @param {boolean} [options.noFetch] - don't download; report cache state only
134
+ * @returns {Promise<{version: string, platform: string, compilerPath: string|null, includeDir: string|null, cached: boolean}>}
135
+ */
136
+ async function getCompilerInfo(version, options = {}) {
137
+ const { noFetch = false } = options;
138
+ const resolved = version || await fetchLatestVersion({ noFetch });
139
+ const platform = getPlatform();
140
+ const cacheDir = path.join(getCacheDir(), 'amxxpc', resolved, platform);
141
+ const binaryName = platform === 'windows' ? 'amxxpc.exe' : 'amxxpc';
142
+ const compilerPath = path.join(cacheDir, binaryName);
143
+ const includeDir = path.join(cacheDir, 'include');
144
+
145
+ if (!fs.existsSync(compilerPath) && !noFetch) {
146
+ await fetchCompiler(resolved);
147
+ }
148
+
149
+ const cached = fs.existsSync(compilerPath);
150
+ return {
151
+ version: resolved,
152
+ platform,
153
+ compilerPath: cached ? compilerPath : null,
154
+ includeDir: fs.existsSync(includeDir) ? includeDir : null,
155
+ cached,
156
+ };
157
+ }
158
+
68
159
  // "1.10.5428" → { major: '1', minor: '10', build: '5428' }
69
160
  function parseVersion(versionStr) {
70
161
  const parts = String(versionStr).split('.');
@@ -101,10 +192,12 @@ async function fetchLatestVersion(options = {}) {
101
192
  } catch (_) {} // no/invalid cache file — resolve from web
102
193
 
103
194
  if (noFetch) {
104
- throw new Error(
195
+ const err = new Error(
105
196
  'Latest amxmodx version is not cached and no-fetch is set.\n' +
106
197
  'Run once without --no-fetch (or set amxmodx.version explicitly) to populate the cache.'
107
198
  );
199
+ err.code = 'LATEST_NOT_CACHED';
200
+ throw err;
108
201
  }
109
202
 
110
203
  if (_latestFetch) return _latestFetch;
@@ -187,11 +280,67 @@ async function resolveLatestVersionFromWeb() {
187
280
  */
188
281
  async function resolveAmxmodxVersion(manifest, options = {}) {
189
282
  const { version, noFetch } = options;
190
- 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
+ }
191
295
  if (manifest && manifest.amxmodx && manifest.amxmodx.version) return manifest.amxmodx.version;
192
296
  return fetchLatestVersion({ noFetch });
193
297
  }
194
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
+
195
344
  function getPlatform() {
196
345
  return getHostPlatform();
197
346
  }
@@ -202,6 +351,59 @@ function getHostPlatform() {
202
351
  return 'linux';
203
352
  }
204
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
+
205
407
  /**
206
408
  * Returns the path to the full amxmodx addons/ tree for the given target platform.
207
409
  * Downloads and extracts the base package if not yet cached.
@@ -223,15 +425,17 @@ async function getAmxmodxFullDir(version, platform) {
223
425
  logger.step(`Assets: downloading amxmodx ${version} (${platform}) for asset extraction...`);
224
426
  logger.dim(` ${downloadUrl}`);
225
427
 
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);
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
+ });
235
439
 
236
440
  logger.success(`Assets: amxmodx ${version} (${platform}) ready`);
237
441
  return cacheDir;
@@ -284,25 +488,9 @@ function extractWithPrefix(archivePath, destDir, opts) {
284
488
 
285
489
  async function downloadFile(url, dest) {
286
490
  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);
491
+ return downloadToFile(url, dest, {
492
+ progressLabel: ` ${chalk.cyan('Downloading')} ${(filename || 'file').padEnd(30)}`,
493
+ });
306
494
  }
307
495
 
308
496
  function makeBinaryExecutable(destDir, platform) {
@@ -324,4 +512,4 @@ function findDir(root, name) {
324
512
  return null;
325
513
  }
326
514
 
327
- module.exports = { fetchCompiler, getAmxmodxFullDir, getHostPlatform, fetchLatestVersion, resolveAmxmodxVersion };
515
+ module.exports = { fetchCompiler, getCompilerInfo, getAmxmodxFullDir, getHostPlatform, fetchLatestVersion, findNewestCachedCompiler, resolveAmxmodxVersion, resolveStdlibVersion };
package/src/compiler.js CHANGED
@@ -5,6 +5,7 @@ const micromatch = require('micromatch');
5
5
  const logger = require('./logger');
6
6
  const { emit, EVENTS } = require('./events');
7
7
  const { spawnCompiler, buildIncludeArgs, buildDefineArgs } = require('./compile-utils');
8
+ const { repoKey, normalizeRepo } = require('./deps-resolver');
8
9
 
9
10
  /**
10
11
  * Applies plugin rules to a local .sma file path (relative to scripting/).
@@ -30,17 +31,24 @@ async function compilePlugins(manifest, repoLocalDirs, compilerPath, includeDirs
30
31
  const collectedIncDir = path.join(buildDir, 'amxmodx', 'scripting', 'include');
31
32
 
32
33
  // ── Build unified source list ──────────────────────────────────────────────
33
- const sources = manifest.repos.map((repoConfig) => ({
34
- label: repoConfig.repo,
35
- ref: repoConfig._resolvedRef || repoConfig.ref || 'HEAD',
36
- scriptingDir: path.join(
37
- repoLocalDirs[`${repoConfig.repo}@${repoConfig._resolvedRef || repoConfig.ref || 'HEAD'}`],
38
- repoConfig.amxmodx_dir,
39
- 'scripting'
40
- ),
41
- exclude: repoConfig.exclude,
42
- postfix: repoConfig.plugins_ini_postfix,
43
- }));
34
+ const seenSources = new Set(); // case-insensitive repo identity (dedupe)
35
+ const sources = [];
36
+ for (const repoConfig of manifest.repos) {
37
+ const identity = normalizeRepo(repoConfig);
38
+ if (seenSources.has(identity)) continue;
39
+ seenSources.add(identity);
40
+ sources.push({
41
+ label: repoConfig.repo,
42
+ ref: repoConfig._resolvedRef || repoConfig.ref || 'HEAD',
43
+ scriptingDir: path.join(
44
+ repoLocalDirs[repoKey(repoConfig)],
45
+ repoConfig.amxmodx_dir,
46
+ 'scripting'
47
+ ),
48
+ exclude: repoConfig.exclude,
49
+ postfix: repoConfig.plugins_ini_postfix,
50
+ });
51
+ }
44
52
 
45
53
  const localScriptingDir = path.join(path.dirname(manifest._path), manifest.amxmodx.dir, 'scripting');
46
54
  if (fs.existsSync(localScriptingDir)) {
@@ -194,8 +202,12 @@ async function findExcluded(dir, patterns) {
194
202
  /**
195
203
  * Compiles a single .sma file. Used by watch mode.
196
204
  * Returns the .amxx filename on success, null on failure.
205
+ *
206
+ * eventTag (optional) is echoed in the emitted EVENTS.COMPILED payload so a
207
+ * subscriber can attribute the event to a specific call — needed when several
208
+ * compiles of same-named files run concurrently (serve compile.single).
197
209
  */
198
- async function compileSingle(manifest, smaPath, compilerPath, includeDirs, buildDir, scriptingRootDir) {
210
+ async function compileSingle(manifest, smaPath, compilerPath, includeDirs, buildDir, scriptingRootDir, eventTag = null) {
199
211
  const pluginsDir = path.join(buildDir, 'amxmodx', 'plugins');
200
212
  const collectedIncDir = path.join(buildDir, 'amxmodx', 'scripting', 'include');
201
213
 
@@ -217,11 +229,11 @@ async function compileSingle(manifest, smaPath, compilerPath, includeDirs, build
217
229
  const { status, output } = await spawnCompiler(compilerPath, [smaPath, `-o${outPath}`, ...includes, ...defines]);
218
230
 
219
231
  if (status !== 0) {
220
- emit(EVENTS.COMPILED, { baseName, ok: false, output, amxxName: null, repo: null, ref: null, outName });
232
+ emit(EVENTS.COMPILED, { baseName, ok: false, output, amxxName: null, repo: null, ref: null, outName, tag: eventTag });
221
233
  return null;
222
234
  }
223
235
 
224
- emit(EVENTS.COMPILED, { baseName, ok: true, output, amxxName: outName, repo: null, ref: null, outName });
236
+ emit(EVENTS.COMPILED, { baseName, ok: true, output, amxxName: outName, repo: null, ref: null, outName, tag: eventTag });
225
237
  return outName;
226
238
  }
227
239
 
package/src/dep-graph.js CHANGED
@@ -24,6 +24,8 @@ class DepGraph {
24
24
  this._includeDirs = includeDirs;
25
25
  // absPath → Set<absPath>: direct includes of each file
26
26
  this._deps = new Map();
27
+ // absPath → [{name, isAngle}]: includes that could not be resolved
28
+ this._missing = new Map();
27
29
  this._parsed = new Set();
28
30
  }
29
31
 
@@ -34,23 +36,44 @@ class DepGraph {
34
36
  if (!fs.existsSync(absPath)) return;
35
37
 
36
38
  const directs = new Set();
39
+ const missing = [];
37
40
  for (const { name, isAngle } of extractIncludes(absPath)) {
38
41
  const resolved = this._resolve(absPath, name, isAngle);
39
42
  if (resolved) {
40
43
  directs.add(resolved);
41
44
  this.parseFile(resolved);
45
+ } else {
46
+ missing.push({ name, isAngle });
42
47
  }
43
48
  }
44
49
  this._deps.set(absPath, directs);
50
+ if (missing.length) this._missing.set(absPath, missing);
51
+ else this._missing.delete(absPath);
45
52
  }
46
53
 
47
54
  // Re-parse a changed file (drops stale edges, keeps the rest of the graph).
48
55
  update(absPath) {
49
56
  this._parsed.delete(absPath);
50
57
  this._deps.delete(absPath);
58
+ this._missing.delete(absPath);
51
59
  this.parseFile(absPath);
52
60
  }
53
61
 
62
+ // Structured snapshot of the parsed graph: every parsed file with its direct
63
+ // (resolved) includes, plus includes that could not be resolved. Exposed to
64
+ // interface layers (serve/JSON-RPC) so the graph can be serialized.
65
+ snapshot() {
66
+ const files = [];
67
+ for (const [file, deps] of this._deps) {
68
+ files.push({ file, isSma: file.endsWith('.sma'), includes: [...deps].sort() });
69
+ }
70
+ const missing = [];
71
+ for (const [file, list] of this._missing) {
72
+ for (const { name, isAngle } of list) missing.push({ file, name, isAngle });
73
+ }
74
+ return { files, missing };
75
+ }
76
+
54
77
  // Returns Set<absPath> of .sma files that transitively depend on incPath.
55
78
  getSmasDependingOn(incPath) {
56
79
  const smas = new Set();
@@ -70,6 +93,33 @@ class DepGraph {
70
93
  return smas;
71
94
  }
72
95
 
96
+ // A previously-missing include has just appeared (watch 'add' on a new .inc).
97
+ // Re-resolve outstanding missing includes; files whose include now resolves
98
+ // to incPath are re-parsed and every depending .sma is returned.
99
+ reattachMissingIncludes(incPath) {
100
+ const referencing = [];
101
+ for (const [file, missingList] of this._missing) {
102
+ if (referencing.includes(file)) continue;
103
+ for (const { name, isAngle } of missingList) {
104
+ if (this._resolve(file, name, isAngle) === incPath) {
105
+ referencing.push(file);
106
+ break;
107
+ }
108
+ }
109
+ }
110
+
111
+ const smas = new Set();
112
+ for (const file of referencing) {
113
+ this.update(file);
114
+ if (file.endsWith('.sma')) smas.add(file);
115
+ }
116
+ for (const file of referencing) {
117
+ if (file.endsWith('.sma')) continue;
118
+ for (const sma of this.getSmasDependingOn(file)) smas.add(sma);
119
+ }
120
+ return smas;
121
+ }
122
+
73
123
  _resolve(fromFile, name, isAngle) {
74
124
  const withExt = /\.inc$/i.test(name) ? name : name + '.inc';
75
125