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.
- package/AGENTS.md +13 -3
- package/README.md +276 -20
- package/action.yml +1 -1
- package/defaults/amxbuild.defaults.yml +7 -1
- package/mcp/handlers.js +342 -152
- package/mcp/registry.js +264 -25
- package/package.json +1 -1
- package/skills/amxb-migration/SKILL.md +126 -27
- package/skills/amxx-pawn-style/SKILL.md +422 -0
- package/src/agent-assets.js +206 -0
- package/src/asset-fetcher.js +33 -48
- package/src/build-plan.js +22 -5
- package/src/build-service.js +54 -11
- package/src/cli.js +38 -4
- package/src/collector.js +9 -1
- package/src/commands/build.js +5 -4
- package/src/commands/dry-run.js +28 -1
- package/src/commands/init.js +125 -11
- package/src/commands/opencode-skills.js +47 -0
- package/src/commands/serve.js +309 -111
- package/src/commands/skills-dir.js +21 -0
- package/src/commands/watch.js +66 -23
- package/src/compile-utils.js +119 -4
- package/src/compiler-fetcher.js +196 -41
- package/src/compiler.js +68 -29
- package/src/dep-graph.js +27 -0
- package/src/deployer.js +39 -15
- package/src/deps-resolver.js +151 -11
- package/src/deps-tree.js +20 -9
- package/src/download.js +132 -0
- package/src/fs-utils.js +35 -1
- package/src/fungun-fetcher.js +28 -8
- package/src/github-api.js +32 -0
- package/src/include-tree.js +30 -61
- package/src/ini-builder.js +1 -1
- package/src/jsonrpc-transport.js +53 -5
- package/src/local-sources.js +363 -0
- package/src/manifest-path.js +18 -1
- package/src/manifest.js +398 -40
- package/src/opencode-skills.js +262 -0
- package/src/release-fetcher.js +26 -33
- package/src/repo-fetcher.js +388 -37
- package/src/retry.js +3 -1
- package/src/schema.js +1 -1
- package/templates/init-deploy.env +9 -0
- package/templates/init-gitignore +22 -0
- package/src/dep-docs.js +0 -115
package/src/commands/watch.js
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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
|
-
|
|
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 (
|
|
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
|
|
package/src/compile-utils.js
CHANGED
|
@@ -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
|
-
/**
|
|
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) =>
|
|
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 };
|
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,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
|
-
|
|
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)
|
|
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
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
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
|
-
|
|
321
|
-
|
|
322
|
-
|
|
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 };
|