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.
- package/AGENTS.md +12 -2
- package/README.md +157 -3
- package/defaults/amxbuild.defaults.yml +7 -0
- package/mcp/handlers.js +378 -92
- package/mcp/registry.js +298 -0
- package/package.json +2 -1
- package/skills/amxb-migration/SKILL.md +568 -0
- package/src/agent-assets.js +206 -0
- package/src/asset-fetcher.js +33 -48
- package/src/build-plan.js +3 -1
- package/src/build-service.js +17 -8
- package/src/cli.js +37 -4
- package/src/collector.js +9 -1
- package/src/commands/deps-tree.js +4 -1
- package/src/commands/dry-run.js +4 -0
- package/src/commands/init.js +72 -20
- package/src/commands/opencode-skills.js +47 -0
- package/src/commands/serve.js +620 -116
- package/src/commands/skills-dir.js +21 -0
- package/src/commands/watch.js +48 -19
- package/src/compile-utils.js +11 -2
- package/src/compiler-fetcher.js +229 -41
- package/src/compiler.js +26 -14
- package/src/dep-graph.js +50 -0
- package/src/deployer.js +50 -21
- package/src/deps-resolver.js +160 -7
- package/src/deps-tree.js +20 -1
- package/src/download.js +132 -0
- package/src/fs-utils.js +35 -1
- package/src/fungun-fetcher.js +367 -0
- package/src/github-api.js +309 -0
- package/src/include-tree.js +29 -57
- package/src/jsonrpc-transport.js +55 -6
- package/src/manifest-path.js +18 -1
- package/src/manifest.js +144 -5
- package/src/opencode-skills.js +262 -0
- package/src/release-fetcher.js +26 -33
- package/src/repo-fetcher.js +276 -33
- package/src/retry.js +3 -1
- package/templates/init-workflow.yml +1 -1
|
@@ -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 };
|
package/src/commands/watch.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
-
|
|
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 (
|
|
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
|
|
package/src/compile-utils.js
CHANGED
|
@@ -57,9 +57,18 @@ function buildIncludeArgs({ scriptingDir, localIncDir, collectedIncDir, includeD
|
|
|
57
57
|
return includes;
|
|
58
58
|
}
|
|
59
59
|
|
|
60
|
-
/**
|
|
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) =>
|
|
71
|
+
return (defines || []).map((d) => (d.includes('=') ? d : `${d}=1`));
|
|
63
72
|
}
|
|
64
73
|
|
|
65
74
|
module.exports = { spawnCompiler, buildIncludeArgs, buildDefineArgs };
|
package/src/compiler-fetcher.js
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
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
|
-
|
|
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)
|
|
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
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
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
|
-
|
|
288
|
-
|
|
289
|
-
|
|
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
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
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
|
|