amxx-builder 1.5.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (68) hide show
  1. package/AGENTS.md +111 -0
  2. package/README.md +485 -0
  3. package/action-entry.js +42 -0
  4. package/action.yml +55 -0
  5. package/defaults/amxbuild.defaults.yml +40 -0
  6. package/index.js +4 -0
  7. package/mcp/dep-resolver.js +75 -0
  8. package/mcp/handlers.js +862 -0
  9. package/mcp/mcp-server.js +112 -0
  10. package/mcp/registry.js +863 -0
  11. package/mcp/symbol-index.js +171 -0
  12. package/package.json +68 -0
  13. package/src/archiver.js +140 -0
  14. package/src/asset-fetcher.js +277 -0
  15. package/src/build-plan.js +101 -0
  16. package/src/build-service.js +188 -0
  17. package/src/cache-dir.js +21 -0
  18. package/src/cache-info.js +104 -0
  19. package/src/cli.js +302 -0
  20. package/src/collector.js +89 -0
  21. package/src/commands/build.js +49 -0
  22. package/src/commands/cache.js +77 -0
  23. package/src/commands/clean.js +33 -0
  24. package/src/commands/compile-renderer.js +38 -0
  25. package/src/commands/deploy.js +40 -0
  26. package/src/commands/deps-tree.js +92 -0
  27. package/src/commands/doctor.js +77 -0
  28. package/src/commands/dry-run.js +64 -0
  29. package/src/commands/init.js +228 -0
  30. package/src/commands/mcp.js +13 -0
  31. package/src/commands/releases.js +45 -0
  32. package/src/commands/resolve-manifest.js +27 -0
  33. package/src/commands/serve.js +489 -0
  34. package/src/commands/shared.js +24 -0
  35. package/src/commands/validate.js +34 -0
  36. package/src/commands/watch.js +209 -0
  37. package/src/compile-utils.js +65 -0
  38. package/src/compiler-fetcher.js +327 -0
  39. package/src/compiler.js +228 -0
  40. package/src/dep-graph.js +92 -0
  41. package/src/deployer.js +197 -0
  42. package/src/deps-resolver.js +127 -0
  43. package/src/deps-tree.js +202 -0
  44. package/src/env.js +18 -0
  45. package/src/events.js +29 -0
  46. package/src/format.js +23 -0
  47. package/src/fs-utils.js +87 -0
  48. package/src/include-tree.js +845 -0
  49. package/src/ini-builder.js +44 -0
  50. package/src/jsonrpc-transport.js +195 -0
  51. package/src/logger.js +50 -0
  52. package/src/manifest-path.js +34 -0
  53. package/src/manifest.js +373 -0
  54. package/src/progress.js +66 -0
  55. package/src/rcon.js +103 -0
  56. package/src/release-fetcher.js +206 -0
  57. package/src/release-lister.js +79 -0
  58. package/src/repo-fetcher.js +273 -0
  59. package/src/retry.js +50 -0
  60. package/src/schema.js +54 -0
  61. package/src/update-check.js +114 -0
  62. package/src/validate.js +69 -0
  63. package/src/watcher.js +135 -0
  64. package/templates/init-build.bat +11 -0
  65. package/templates/init-build.sh +7 -0
  66. package/templates/init-deploy.env +14 -0
  67. package/templates/init-manifest.yml +6 -0
  68. package/templates/init-workflow.yml +59 -0
@@ -0,0 +1,171 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Symbol index for AMXX include/source files.
5
+ * Extracts declarations (native, stock, forward, public, #define, enum members,
6
+ * const, plain functions) for search_symbol.
7
+ *
8
+ * No caching: parsing the whole AMXX stdlib takes ~20ms, so the index is
9
+ * rebuilt on every call. Network fetches (clones, releases, compiler) have
10
+ * their own cache in src/.
11
+ */
12
+
13
+ const fs = require('fs');
14
+ const path = require('path');
15
+ const glob = require('fast-glob');
16
+
17
+ const KEYWORDS = new Set([
18
+ 'assert', 'break', 'case', 'continue', 'default', 'do', 'else', 'emit',
19
+ 'exit', 'for', 'goto', 'if', 'new', 'return', 'sizeof', 'sleep', 'state',
20
+ 'switch', 'typedef', 'while',
21
+ ]);
22
+
23
+ function stripComments(src) {
24
+ return src
25
+ .replace(/\/\*[\s\S]*?\*\//g, ' ')
26
+ .replace(/\/\/[^\n]*/g, '');
27
+ }
28
+
29
+ function collectEnumMembers(chunk, lineNo, found) {
30
+ for (const item of chunk.split(',')) {
31
+ const m = item.match(/([A-Za-z_]\w*)/);
32
+ if (m) found.push({ name: m[1], kind: 'enum', line: lineNo, signature: 'enum member' });
33
+ }
34
+ }
35
+
36
+ /**
37
+ * Parse .sma/.inc source text into symbol declarations.
38
+ * Heuristic line-based parser covering AMXX declaration forms:
39
+ * tags (Float:), operator overloads, stacked modifiers (public stock const),
40
+ * functag, const arrays, multi-line enums.
41
+ */
42
+ function parseInclude(text) {
43
+ const clean = stripComments(text);
44
+ const lines = clean.split('\n');
45
+ const found = [];
46
+ let inEnum = null;
47
+
48
+ for (let i = 0; i < lines.length; i++) {
49
+ const raw = lines[i];
50
+ const lineNo = i + 1;
51
+
52
+ if (inEnum) {
53
+ const closeIdx = raw.indexOf('}');
54
+ collectEnumMembers(closeIdx === -1 ? raw : raw.slice(0, closeIdx), lineNo, found);
55
+ if (closeIdx !== -1) inEnum = null;
56
+ continue;
57
+ }
58
+
59
+ // operator overloads: native Float:operator*(...) = floatmul;
60
+ let m = raw.match(/^\s*(native|stock|forward|public)\s+(?:const\s+)?(?:[A-Za-z_]\w*\s*:)?operator\s*([*+\-/%=!<>^&|~]+)/);
61
+ if (m) {
62
+ found.push({ name: 'operator' + m[2], kind: m[1], line: lineNo, signature: raw.trim() });
63
+ continue;
64
+ }
65
+
66
+ m = raw.match(/^\s*(native|forward|public)\s+(?:(?:const|stock)\s+)*(?:[A-Za-z_]\w*\s*:)?(\w+)/);
67
+ if (m) {
68
+ found.push({ name: m[2], kind: m[1], line: lineNo, signature: raw.trim() });
69
+ continue;
70
+ }
71
+
72
+ m = raw.match(/^\s*stock\s+(?:const\s+)?(?:[A-Za-z_]\w*\s*:)?(\w+)/);
73
+ if (m) {
74
+ const kind = /\(/.test(raw) ? 'stock' : 'stock const';
75
+ found.push({ name: m[1], kind, line: lineNo, signature: raw.trim() });
76
+ continue;
77
+ }
78
+
79
+ m = raw.match(/^\s*functag\s+(?:(?:public|native)\s+)?(\w+)/);
80
+ if (m) {
81
+ found.push({ name: m[1], kind: 'functag', line: lineNo, signature: raw.trim() });
82
+ continue;
83
+ }
84
+
85
+ m = raw.match(/^\s*#define\s+(\w+)/);
86
+ if (m) {
87
+ found.push({ name: m[1], kind: 'define', line: lineNo, signature: raw.trim() });
88
+ continue;
89
+ }
90
+
91
+ m = raw.match(/^\s*enum\s*(?:\(\s*[+\-*/=]+\s*\))?\s*(\w*)\s*\{/);
92
+ if (m) {
93
+ const name = m[1];
94
+ const rest = raw.slice(raw.indexOf('{') + 1);
95
+ const closeIdx = rest.indexOf('}');
96
+ if (name) found.push({ name, kind: 'enum', line: lineNo, signature: raw.trim() });
97
+ collectEnumMembers(closeIdx === -1 ? rest : rest.slice(0, closeIdx), lineNo, found);
98
+ if (closeIdx === -1) inEnum = { name, lineNo };
99
+ continue;
100
+ }
101
+
102
+ m = raw.match(/^\s*const\s+(\w+)(?:\[[^\]]*\])*\s*=/);
103
+ if (m) {
104
+ found.push({ name: m[1], kind: 'const', line: lineNo, signature: raw.trim() });
105
+ continue;
106
+ }
107
+
108
+ m = raw.match(/^([A-Za-z_]\w*(?:\s*:\s*[A-Za-z_]\w*)?)\s*\([^)]*\)\s*\{/);
109
+ if (m) {
110
+ const name = m[1].split(':').pop().trim();
111
+ if (name && !KEYWORDS.has(name)) {
112
+ found.push({ name, kind: 'function', line: lineNo, signature: raw.trim() });
113
+ }
114
+ }
115
+ }
116
+
117
+ return found;
118
+ }
119
+
120
+ /**
121
+ * Scan dirs and build { symbols: Map<name, [{file, line, kind, signature}]>, fileCount }.
122
+ */
123
+ async function buildIndex(dirs, pattern = '**/*.{inc,sma}') {
124
+ const symbolMap = new Map();
125
+ let fileCount = 0;
126
+
127
+ for (const dir of dirs) {
128
+ let files;
129
+ try {
130
+ files = await glob(pattern, { cwd: dir, dot: false });
131
+ } catch (_) {
132
+ continue;
133
+ }
134
+ for (const rel of files) {
135
+ fileCount++;
136
+ let text;
137
+ try {
138
+ text = fs.readFileSync(path.join(dir, rel), 'utf8');
139
+ } catch (_) {
140
+ continue;
141
+ }
142
+ for (const s of parseInclude(text)) {
143
+ const entry = { file: rel, line: s.line, kind: s.kind, signature: s.signature };
144
+ if (!symbolMap.has(s.name)) symbolMap.set(s.name, []);
145
+ symbolMap.get(s.name).push(entry);
146
+ }
147
+ }
148
+ }
149
+
150
+ return { symbols: symbolMap, fileCount };
151
+ }
152
+
153
+ /**
154
+ * Search an index for a symbol.
155
+ * Exact (case-sensitive) by default; `partial` does case-insensitive substring.
156
+ */
157
+ function searchIndex(index, query, { partial = false } = {}) {
158
+ const results = [];
159
+ if (partial) {
160
+ const q = query.toLowerCase();
161
+ for (const [name, matches] of index.symbols) {
162
+ if (name.toLowerCase().includes(q)) results.push({ name, matches });
163
+ }
164
+ } else if (index.symbols.has(query)) {
165
+ results.push({ name: query, matches: index.symbols.get(query) });
166
+ }
167
+ results.sort((a, b) => a.name.localeCompare(b.name));
168
+ return results;
169
+ }
170
+
171
+ module.exports = { parseInclude, buildIndex, searchIndex };
package/package.json ADDED
@@ -0,0 +1,68 @@
1
+ {
2
+ "name": "amxx-builder",
3
+ "version": "1.5.1",
4
+ "author": {
5
+ "name": "ArKaNeMaN"
6
+ },
7
+ "description": "CLI tool to build and package AMX Mod X server plugins",
8
+ "repository": {
9
+ "url": "git+https://github.com/AmxxModularEcosystem/amxx-builder.git"
10
+ },
11
+ "keywords": [
12
+ "amxx",
13
+ "amxmodx",
14
+ "cs",
15
+ "hlds",
16
+ "builder",
17
+ "amxx-builder",
18
+ "amxb",
19
+ "mcp",
20
+ "amxbuild",
21
+ "half-life"
22
+ ],
23
+ "main": "index.js",
24
+ "bin": {
25
+ "amxx-builder": "index.js",
26
+ "amxb": "index.js"
27
+ },
28
+ "engines": {
29
+ "node": ">=18"
30
+ },
31
+ "scripts": {
32
+ "start": "node index.js",
33
+ "test": "node --test",
34
+ "bundle": "esbuild action-entry.js --bundle --platform=node --target=node24 --format=cjs --minify --legal-comments=inline --outfile=dist/index.js && node scripts/gen-licenses.js"
35
+ },
36
+ "dependencies": {
37
+ "@actions/core": "3.0.1",
38
+ "adm-zip": "^0.6.0",
39
+ "ajv": "^8.20.0",
40
+ "ajv-formats": "^3.0.1",
41
+ "archiver": "^7.0.1",
42
+ "axios": "^1.7.2",
43
+ "chalk": "^4.1.2",
44
+ "chokidar": "^3.6.0",
45
+ "cli-progress": "^3.12.0",
46
+ "commander": "^12.1.0",
47
+ "dotenv": "^17.4.2",
48
+ "enquirer": "^2.4.1",
49
+ "fast-glob": "^3.3.2",
50
+ "js-yaml": "^4.3.1",
51
+ "simple-git": "^3.22.0"
52
+ },
53
+ "files": [
54
+ "index.js",
55
+ "mcp/",
56
+ "src/",
57
+ "action.yml",
58
+ "action-entry.js",
59
+ "templates/",
60
+ "defaults/",
61
+ "package.json",
62
+ "README.md",
63
+ "AGENTS.md"
64
+ ],
65
+ "devDependencies": {
66
+ "esbuild": "^0.28.2"
67
+ }
68
+ }
@@ -0,0 +1,140 @@
1
+ const fs = require('fs');
2
+ const path = require('path');
3
+ const archiver = require('archiver');
4
+ const logger = require('./logger');
5
+ const { createBar } = require('./progress');
6
+ const { countFiles, copyDirContents } = require('./fs-utils');
7
+
8
+ /**
9
+ * Creates the output .zip.
10
+ *
11
+ * {name} and {version} are expanded in amxmodx_path and assets_path.
12
+ *
13
+ * build/amxmodx/** → <amxmodx_path>/** e.g. "addons/amxmodx" or "{name}/addons/amxmodx"
14
+ * build/assets/** → <assets_path>/** e.g. "" (root) or "{name}"
15
+ * README.md → <assets_path>/ if output.readme = true
16
+ */
17
+ async function createArchive(manifest, buildDir) {
18
+ const out = manifest.output;
19
+
20
+ const expand = (tpl) => tpl
21
+ .replaceAll('{name}', manifest.name)
22
+ .replaceAll('{version}', manifest.version);
23
+
24
+ const archiveName = sanitizeArchiveName(expand(out.archive_name));
25
+ const amxmodxDest = expand(out.amxmodx_path).replace(/\/?$/, '/');
26
+ const assetsDest = expand(out.assets_path); // '' = root
27
+
28
+ fs.mkdirSync(path.resolve(out.dir), { recursive: true });
29
+ const archivePath = path.join(path.resolve(out.dir), archiveName);
30
+
31
+ const output = fs.createWriteStream(archivePath);
32
+ const archive = archiver('zip', { zlib: { level: 9 } });
33
+
34
+ const fileList = [];
35
+
36
+ // Count files for progress bar
37
+ const amxmodxBuildDir = path.join(buildDir, 'amxmodx');
38
+ const assetsBuildDir = path.join(buildDir, 'assets');
39
+ let totalFiles = 0;
40
+ if (fs.existsSync(amxmodxBuildDir)) totalFiles += countFiles(amxmodxBuildDir);
41
+ if (fs.existsSync(assetsBuildDir)) totalFiles += countFiles(assetsBuildDir);
42
+ if (out.readme && fs.existsSync(path.join(path.dirname(manifest._path), 'README.md'))) totalFiles++;
43
+
44
+ const bar = createBar(totalFiles || 1, ' Archiving files');
45
+ let archivedCount = 0;
46
+
47
+ archive.on('entry', (entry) => {
48
+ if (!entry.stats || !entry.stats.isDirectory()) {
49
+ fileList.push(entry.name);
50
+ archivedCount++;
51
+ if (bar) bar.update(archivedCount);
52
+ }
53
+ });
54
+
55
+ await new Promise((resolve, reject) => {
56
+ output.on('close', () => { if (bar) bar.stop(); resolve(); });
57
+ archive.on('error', reject);
58
+ archive.pipe(output);
59
+
60
+ if (fs.existsSync(amxmodxBuildDir)) {
61
+ archive.directory(amxmodxBuildDir + path.sep, amxmodxDest);
62
+ }
63
+
64
+ if (fs.existsSync(assetsBuildDir)) {
65
+ archive.directory(assetsBuildDir + path.sep, assetsDest || false);
66
+ }
67
+
68
+ if (out.readme) {
69
+ const readmeSrc = path.join(path.dirname(manifest._path), 'README.md');
70
+ if (fs.existsSync(readmeSrc)) {
71
+ archive.file(readmeSrc, { name: 'README.md' });
72
+ } else {
73
+ logger.warn('readme: true but README.md not found next to manifest');
74
+ }
75
+ }
76
+
77
+ archive.finalize();
78
+ });
79
+
80
+ const sizeKb = Math.round(fs.statSync(archivePath).size / 1024);
81
+ logger.success(`Archive: ${path.join(out.dir, archiveName)} (${sizeKb} KB)`);
82
+ printFileListing(fileList);
83
+ }
84
+
85
+ // Keeps the archive filename inside out.dir: strips any directory components
86
+ // (both / and \ separators) and removes characters illegal on Windows.
87
+ function sanitizeArchiveName(name) {
88
+ const base = String(name).replace(/\\/g, '/').split('/').pop() || '';
89
+ if (!base || base === '.' || base === '..') return 'archive.zip';
90
+ return base.replace(/[<>:"|?*]/g, '_');
91
+ }
92
+
93
+ function printFileListing(files) { const grouped = new Map();
94
+ for (const f of files) {
95
+ const parts = f.split('/');
96
+ const key = parts.length > 1 ? parts.slice(0, parts.length - 1).join('/') : '.';
97
+ if (!grouped.has(key)) grouped.set(key, []);
98
+ grouped.get(key).push(f);
99
+ }
100
+ for (const [dir, dirFiles] of grouped) {
101
+ if (dirFiles.length === 1) logger.dim(` ${dirFiles[0]}`);
102
+ else logger.dim(` ${dir}/ (${dirFiles.length} files)`);
103
+ }
104
+ }
105
+
106
+ /**
107
+ * Same layout as createArchive but copies files to output.dir instead of zipping.
108
+ * Used when output.pack = false (e.g. in CI to avoid artifact double-wrapping).
109
+ */
110
+ function copyOutput(manifest, buildDir) {
111
+ const out = manifest.output;
112
+ const expand = (tpl) => tpl
113
+ .replaceAll('{name}', manifest.name)
114
+ .replaceAll('{version}', manifest.version);
115
+
116
+ const outDir = path.resolve(out.dir);
117
+ const amxmodxDst = path.join(outDir, expand(out.amxmodx_path));
118
+ const assetsDst = out.assets_path
119
+ ? path.join(outDir, expand(out.assets_path))
120
+ : outDir;
121
+
122
+ const amxmodxSrc = path.join(buildDir, 'amxmodx');
123
+ if (fs.existsSync(amxmodxSrc)) copyDirContents(amxmodxSrc, amxmodxDst);
124
+
125
+ const assetsSrc = path.join(buildDir, 'assets');
126
+ if (fs.existsSync(assetsSrc)) copyDirContents(assetsSrc, assetsDst);
127
+
128
+ if (out.readme) {
129
+ const readmeSrc = path.join(path.dirname(manifest._path), 'README.md');
130
+ if (fs.existsSync(readmeSrc)) {
131
+ fs.copyFileSync(readmeSrc, path.join(outDir, 'README.md'));
132
+ } else {
133
+ logger.warn('readme: true but README.md not found next to manifest');
134
+ }
135
+ }
136
+
137
+ logger.success(`Output dir: ${out.dir}`);
138
+ }
139
+
140
+ module.exports = { createArchive, copyOutput };
@@ -0,0 +1,277 @@
1
+ 'use strict';
2
+
3
+ const fs = require('fs');
4
+ const path = require('path');
5
+ const crypto = require('crypto');
6
+ const axios = require('axios');
7
+ // Default for API calls; download sites pass their own longer timeout.
8
+ axios.defaults.timeout = 30000;
9
+ const AdmZip = require('adm-zip');
10
+
11
+ const chalk = require('chalk');
12
+ const { safeExtractTar } = require('./fs-utils');
13
+ const logger = require('./logger');
14
+ const { getCacheDir } = require('./cache-dir');
15
+ const { withRetry } = require('./retry');
16
+ const { getAmxmodxFullDir, getHostPlatform } = require('./compiler-fetcher');
17
+ const { getReleaseCacheDir } = require('./release-fetcher');
18
+ const { resolveGithubToken } = require('./manifest');
19
+
20
+ /**
21
+ * Processes all asset sources defined in manifest.assets.sources in order.
22
+ * Sources are written directly to build/assets/ with on_conflict resolution.
23
+ * source: local copies from assets/ next to the manifest.
24
+ */
25
+ async function fetchAssets(manifest, buildDir, noFetch = false) {
26
+ const { sources, on_conflict } = manifest.assets;
27
+ if (!sources.length) return;
28
+
29
+ const manifestDir = path.dirname(manifest._path);
30
+ const assetsDir = path.join(buildDir, 'assets');
31
+
32
+ fs.mkdirSync(assetsDir, { recursive: true });
33
+
34
+ logger.info(`Assets: processing ${sources.length} source(s)...`);
35
+
36
+ const origins = new Map(); // relPath → source label (for conflict tracking)
37
+
38
+ // Resolve all sources in parallel (network-bound: release, url), apply maps sequentially
39
+ const resolved = await Promise.all(
40
+ sources.map(src => resolveSource(src, manifest, manifestDir, buildDir, noFetch))
41
+ );
42
+ for (let i = 0; i < sources.length; i++) {
43
+ const srcDir = resolved[i];
44
+ if (!srcDir) continue;
45
+ applyMap(srcDir, assetsDir, sources[i].map, sourceLabel(sources[i]), on_conflict, origins);
46
+ }
47
+ }
48
+
49
+ function sourceLabel(source) {
50
+ if (source.type === 'local') return 'local';
51
+ if (source.type === 'amxmodx') return 'amxmodx';
52
+ if (source.type === 'release') return `${source.repo}@${source.ref}`;
53
+ return source.url;
54
+ }
55
+
56
+ // ─── source resolution ────────────────────────────────────────────────────────
57
+
58
+ async function resolveSource(source, manifest, manifestDir, buildDir, noFetch) {
59
+ if (source.type === 'local') {
60
+ const localAssetsDir = path.join(manifestDir, 'assets');
61
+ if (!fs.existsSync(localAssetsDir)) return null;
62
+ logger.dim(` local assets/`);
63
+ return localAssetsDir;
64
+ }
65
+ if (source.type === 'amxmodx') {
66
+ const version = manifest.amxmodx.version;
67
+ const platform = manifest.platform || getHostPlatform();
68
+ if (!version) throw new Error('assets: source: amxmodx requires amxmodx.version to be set');
69
+ logger.step(`Assets: amxmodx ${version} (${platform})...`);
70
+ return getAmxmodxFullDir(version, platform);
71
+ }
72
+ if (source.type === 'release') {
73
+ return getReleaseCacheDir(source, resolveGithubToken(manifest, source.repo), noFetch);
74
+ }
75
+ return resolveUrlSource(source, manifestDir, buildDir, noFetch);
76
+ }
77
+
78
+ async function resolveUrlSource(source, manifestDir, buildDir, noFetch) {
79
+ const cacheDir = getCacheDirForUrl(source.url, source.cache, manifestDir, buildDir);
80
+ const sentinel = path.join(cacheDir, '.cached');
81
+
82
+ if (fs.existsSync(sentinel)) {
83
+ logger.dim(` ${source.url} (cached)`);
84
+ return cacheDir;
85
+ }
86
+
87
+ if (noFetch) {
88
+ logger.warn(`Assets: skipping ${source.url} (--no-fetch, cache: none)`);
89
+ return null;
90
+ }
91
+
92
+ const filename = getFilenameFromUrl(source.url);
93
+ logger.step(`Assets: downloading ${filename}...`);
94
+ fs.mkdirSync(cacheDir, { recursive: true });
95
+
96
+ const bar = require('./progress').createBar(100, ` ${chalk.cyan('Downloading')} ${(filename || 'file').padEnd(30)}`);
97
+
98
+ try {
99
+ const response = await withRetry(
100
+ () => axios.get(source.url, {
101
+ responseType: 'arraybuffer',
102
+ maxRedirects: 5,
103
+ timeout: 600000, // large archives — allow slow links, still bound hangs
104
+ onDownloadProgress: (e) => {
105
+ if (bar && e.total) {
106
+ bar.update(Math.round(e.loaded / e.total * 100));
107
+ }
108
+ },
109
+ }),
110
+ { label: filename }
111
+ );
112
+ if (bar) bar.stop();
113
+ const contentType = response.headers['content-type'] || '';
114
+ const data = Buffer.from(response.data);
115
+
116
+ if (isArchive(filename, contentType)) {
117
+ extractArchive(data, filename, cacheDir);
118
+ } else {
119
+ const filePath = path.join(cacheDir, filename);
120
+ const part = filePath + '.part';
121
+ fs.writeFileSync(part, data);
122
+ fs.renameSync(part, filePath);
123
+ }
124
+
125
+ const sentinelTmp = sentinel + '.tmp';
126
+ fs.writeFileSync(sentinelTmp, JSON.stringify({ url: source.url, cached_at: new Date().toISOString() }));
127
+ fs.renameSync(sentinelTmp, sentinel);
128
+ logger.info(`Assets: ${filename} ready`);
129
+ return cacheDir;
130
+ } catch (err) {
131
+ // Never rmSync the whole cache dir: it may be a shared 'global' entry used
132
+ // by parallel sources or other projects. Invalidate the sentinel only and
133
+ // leave the content to be overwritten on the next fetch.
134
+ try { fs.rmSync(sentinel, { force: true }); } catch (_) {}
135
+ throw new Error(`Failed to fetch asset ${source.url}: ${err.message}`);
136
+ }
137
+ }
138
+
139
+ function getCacheDirForUrl(url, cacheType, manifestDir, buildDir) {
140
+ const hash = crypto.createHash('sha256').update(url).digest('hex').slice(0, 16);
141
+ if (cacheType === 'global') return path.join(getCacheDir(), 'assets', hash);
142
+ if (cacheType === 'local') return path.join(manifestDir, '.amxb-cache', 'assets', hash);
143
+ return path.join(buildDir, '_assets_dl', hash); // 'none'
144
+ }
145
+
146
+ // ─── archive detection & extraction ──────────────────────────────────────────
147
+
148
+ function getFilenameFromUrl(url) {
149
+ try {
150
+ const base = path.basename(new URL(url).pathname) || 'download';
151
+ try { return decodeURIComponent(base); } catch { return base; }
152
+ } catch { return 'download'; }
153
+ }
154
+
155
+ function isArchive(filename, contentType) {
156
+ if (/\.(zip|tar\.gz|tgz|tar\.bz2)$/i.test(filename)) return true;
157
+ return /zip|tar|gzip|x-compressed/.test(contentType);
158
+ }
159
+
160
+ function extractArchive(data, filename, destDir) {
161
+ const isZip = /\.zip$/i.test(filename) || isZipMagic(data);
162
+ if (isZip) {
163
+ new AdmZip(data).extractAllTo(destDir, true);
164
+ return;
165
+ }
166
+ const tmpFile = path.join(destDir, filename);
167
+ fs.writeFileSync(tmpFile, data);
168
+ try {
169
+ safeExtractTar(tmpFile, destDir);
170
+ } finally {
171
+ fs.rmSync(tmpFile, { force: true });
172
+ }
173
+ }
174
+
175
+ // ZIP archives start with "PK" — filename extensions are unreliable for
176
+ // CDN/redirect URLs, so sniff the actual bytes when the name is inconclusive.
177
+ function isZipMagic(buf) {
178
+ return buf.length >= 2 && buf[0] === 0x50 && buf[1] === 0x4b;
179
+ }
180
+
181
+ // ─── map application ──────────────────────────────────────────────────────────
182
+
183
+ function applyMap(srcDir, destDir, mapEntries, label, onConflict, origins) {
184
+ for (const entry of mapEntries) {
185
+ applyMapEntry(srcDir, destDir, entry, label, onConflict, origins);
186
+ }
187
+ }
188
+
189
+ /**
190
+ * Trailing-slash semantics (rsync-style):
191
+ *
192
+ * from: null → take entire srcDir contents
193
+ * from: "models/" → take contents of srcDir/models/
194
+ * from: "models" → take srcDir/models itself (dir placed as destDir/models/)
195
+ * from: "a/b.wav" → take single file srcDir/a/b.wav
196
+ *
197
+ * to: null → place into destDir root
198
+ * to: "models/" → place into destDir/models/
199
+ * to: "models" → same as "models/" for dirs; for single file, rename to "models"
200
+ */
201
+ function applyMapEntry(baseDir, destBase, { from, to }, label, onConflict, origins) {
202
+ const fromTrailing = from && from.endsWith('/');
203
+ const fromRel = from ? from.replace(/\/$/, '') : null;
204
+ const fromPath = fromRel ? path.join(baseDir, fromRel) : baseDir;
205
+
206
+ if (!fs.existsSync(fromPath)) {
207
+ logger.warn(`Assets: path not found in source: ${from || '(root)'}`);
208
+ return;
209
+ }
210
+
211
+ const toRel = to ? to.replace(/\/$/, '') : null;
212
+ const toTrailing = !to || to.endsWith('/');
213
+ const destPath = toRel ? path.join(destBase, toRel) : destBase;
214
+
215
+ const stat = fs.statSync(fromPath);
216
+
217
+ if (!fromRel || fromTrailing || stat.isDirectory()) {
218
+ // Copy directory contents or the directory itself
219
+ const contentsOnly = !fromRel || fromTrailing;
220
+ const actualDest = contentsOnly ? destPath : path.join(destPath, path.basename(fromPath));
221
+ copyDirWithConflict(fromPath, actualDest, destBase, label, onConflict, origins);
222
+ } else {
223
+ // Single file
224
+ const fileDest = toTrailing
225
+ ? path.join(destPath, path.basename(fromPath))
226
+ : destPath; // no trailing slash on to → rename
227
+
228
+ const relKey = path.relative(destBase, fileDest).replace(/\\/g, '/');
229
+ if (origins.has(relKey)) {
230
+ const prev = origins.get(relKey);
231
+ if (onConflict === 'error') {
232
+ throw new Error(`Asset conflict: "${relKey}" — provided by both "${prev}" and "${label}"`);
233
+ }
234
+ if (onConflict === 'first_wins') {
235
+ logger.warn(`Asset conflict (kept "${prev}"): ${relKey}`);
236
+ return;
237
+ }
238
+ logger.warn(`Asset conflict (overwriting "${prev}"): ${relKey}`);
239
+ }
240
+ fs.mkdirSync(path.dirname(fileDest), { recursive: true });
241
+ fs.copyFileSync(fromPath, fileDest);
242
+ origins.set(relKey, label);
243
+ }
244
+ }
245
+
246
+ function copyDirWithConflict(srcDir, destDir, trackBase, label, onConflict, origins) {
247
+ if (!fs.existsSync(srcDir)) return;
248
+ fs.mkdirSync(destDir, { recursive: true });
249
+
250
+ for (const entry of fs.readdirSync(srcDir, { withFileTypes: true })) {
251
+ const srcPath = path.join(srcDir, entry.name);
252
+ const destPath = path.join(destDir, entry.name);
253
+
254
+ if (entry.isDirectory()) {
255
+ copyDirWithConflict(srcPath, destPath, trackBase, label, onConflict, origins);
256
+ } else {
257
+ const relKey = path.relative(trackBase, destPath).replace(/\\/g, '/');
258
+ if (origins.has(relKey)) {
259
+ const prev = origins.get(relKey);
260
+ if (onConflict === 'error') {
261
+ throw new Error(`Asset conflict: "${relKey}" — provided by both "${prev}" and "${label}"`);
262
+ }
263
+ if (onConflict === 'first_wins') {
264
+ logger.warn(`Asset conflict (kept "${prev}"): ${relKey}`);
265
+ continue;
266
+ }
267
+ logger.warn(`Asset conflict (overwriting "${prev}"): ${relKey}`);
268
+ }
269
+ fs.mkdirSync(path.dirname(destPath), { recursive: true });
270
+ fs.copyFileSync(srcPath, destPath);
271
+ origins.set(relKey, label);
272
+ }
273
+ }
274
+ }
275
+
276
+
277
+ module.exports = { fetchAssets };