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,228 @@
1
+ const fs = require('fs');
2
+ const path = require('path');
3
+ const glob = require('fast-glob');
4
+ const micromatch = require('micromatch');
5
+ const logger = require('./logger');
6
+ const { emit, EVENTS } = require('./events');
7
+ const { spawnCompiler, buildIncludeArgs, buildDefineArgs } = require('./compile-utils');
8
+
9
+ /**
10
+ * Applies plugin rules to a local .sma file path (relative to scripting/).
11
+ * Returns null if the plugin should be skipped (enabled: false),
12
+ * or { postfix, skipIni } where postfix is the INI postfix (false = skip INI).
13
+ */
14
+ function applyPluginRule(smaRelPath, rules, defaultPostfix) {
15
+ const normalized = smaRelPath.split(path.sep).join('/');
16
+ for (const rule of rules) {
17
+ if (micromatch.isMatch(normalized, rule.match, { dot: true })) {
18
+ if (!rule.enabled) return null;
19
+ const postfix = rule.ini !== null ? rule.ini : defaultPostfix;
20
+ return { postfix, skipIni: rule.ini === false };
21
+ }
22
+ }
23
+ return { postfix: defaultPostfix, skipIni: false };
24
+ }
25
+
26
+ async function compilePlugins(manifest, repoLocalDirs, compilerPath, includeDirs, buildDir) {
27
+ const pluginsDir = path.join(buildDir, 'amxmodx', 'plugins');
28
+ fs.mkdirSync(pluginsDir, { recursive: true });
29
+
30
+ const collectedIncDir = path.join(buildDir, 'amxmodx', 'scripting', 'include');
31
+
32
+ // ── 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
+ }));
44
+
45
+ const localScriptingDir = path.join(path.dirname(manifest._path), manifest.amxmodx.dir, 'scripting');
46
+ if (fs.existsSync(localScriptingDir)) {
47
+ sources.push({
48
+ label: '(local)', ref: 'local', isLocal: true, scriptingDir: localScriptingDir,
49
+ exclude: [], postfix: manifest.globalPostfix,
50
+ });
51
+ }
52
+
53
+ // ── Collect all .sma tasks ─────────────────────────────────────────────────
54
+ const onConflict = manifest.output.on_conflict || 'last_wins';
55
+ const tasksByOut = new Map(); // outPath → task (dedupe cross-source collisions)
56
+ for (const src of sources) {
57
+ const { scriptingDir, exclude, postfix, label, ref, isLocal = false } = src;
58
+
59
+ if (!fs.existsSync(scriptingDir)) {
60
+ logger.dim(` ${label}: no scripting/ dir`);
61
+ continue;
62
+ }
63
+
64
+ const excludePatterns = exclude.map((e) => `!${e}`);
65
+ const smaFiles = await glob(['**/*.sma', ...excludePatterns], { cwd: scriptingDir, dot: false });
66
+
67
+ const excluded = await findExcluded(scriptingDir, exclude);
68
+ for (const ex of excluded) logger.skip(`Skipped (excluded): ${ex}`);
69
+
70
+ const localIncDir = path.join(scriptingDir, 'include');
71
+ const includes = buildIncludeArgs({ scriptingDir, localIncDir, collectedIncDir, includeDirs });
72
+
73
+ const defines = buildDefineArgs(manifest.amxmodx.defines);
74
+
75
+ if (logger.isVerbose()) {
76
+ logger.verbose(` includes: ${includes.join(', ') || '(none)'}`);
77
+ if (defines.length) logger.verbose(` defines: ${defines.join(', ')}`);
78
+ }
79
+
80
+ for (const smaRel of smaFiles) {
81
+ let taskPostfix = postfix;
82
+ let skipIni = false;
83
+
84
+ if (isLocal) {
85
+ const ruleResult = applyPluginRule(smaRel, manifest.pluginRules, postfix);
86
+ if (!ruleResult) {
87
+ logger.skip(`Skipped (plugin rule): ${smaRel}`);
88
+ continue;
89
+ }
90
+ taskPostfix = ruleResult.postfix;
91
+ skipIni = ruleResult.skipIni;
92
+ }
93
+
94
+ const baseName = path.basename(smaRel);
95
+ const outName = smaRel.replace(/\.sma$/, '.amxx').split(path.sep).join('/');
96
+ const task = {
97
+ label, ref, postfix: taskPostfix, skipIni, baseName,
98
+ srcPath: path.join(scriptingDir, smaRel),
99
+ outName,
100
+ outPath: path.join(pluginsDir, ...outName.split('/')),
101
+ includes,
102
+ defines,
103
+ };
104
+ const prev = tasksByOut.get(task.outPath);
105
+ if (prev) {
106
+ if (onConflict === 'error') {
107
+ throw new Error(`Plugin output conflict: "${outName}" — provided by both "${prev.label}" and "${label}"`);
108
+ }
109
+ if (onConflict === 'first_wins') {
110
+ logger.warn(`Plugin conflict (kept "${prev.label}"): ${outName}`);
111
+ continue;
112
+ }
113
+ logger.warn(`Plugin conflict (overwriting "${prev.label}"): ${outName}`);
114
+ }
115
+ tasksByOut.set(task.outPath, task);
116
+ }
117
+ }
118
+
119
+ const tasks = [...tasksByOut.values()];
120
+
121
+ if (!tasks.length) return [];
122
+
123
+ logger.info(`Compiling ${tasks.length} plugin(s)...`);
124
+
125
+ // ── Run compilations with a bounded worker pool ───────────────────────────
126
+ const settled = await mapLimit(tasks, 8, (task) => runCompile(compilerPath, task)
127
+ .then((value) => ({ status: 'fulfilled', value }))
128
+ .catch((reason) => ({ status: 'rejected', reason }))
129
+ );
130
+
131
+ const compiled = [];
132
+ const failed = [];
133
+
134
+ for (let i = 0; i < settled.length; i++) {
135
+ if (settled[i].status === 'fulfilled') {
136
+ compiled.push(settled[i].value);
137
+ } else {
138
+ failed.push({ task: tasks[i], err: settled[i].reason });
139
+ }
140
+ }
141
+
142
+ if (failed.length) {
143
+ // Per-plugin FAILED rendering is handled by the CLI renderer via EVENTS.COMPILED.
144
+ throw new Error(
145
+ `Compilation failed (${failed.length}/${tasks.length}): ` +
146
+ failed.map(({ task }) => task.baseName).join(', ')
147
+ );
148
+ }
149
+
150
+ return compiled;
151
+ }
152
+
153
+ async function mapLimit(items, limit, fn) {
154
+ const results = new Array(items.length);
155
+ let next = 0;
156
+ const workers = Array.from({ length: Math.min(limit, items.length) }, async () => {
157
+ while (next < items.length) {
158
+ const i = next++;
159
+ results[i] = await fn(items[i], i);
160
+ }
161
+ });
162
+ await Promise.all(workers);
163
+ return results;
164
+ }
165
+
166
+ async function runCompile(compilerPath, task) {
167
+ const { srcPath, outPath, outName, includes, defines, baseName, postfix, skipIni, label, ref } = task;
168
+
169
+ fs.mkdirSync(path.dirname(outPath), { recursive: true });
170
+ const args = [srcPath, `-o${outPath}`, ...includes, ...defines];
171
+ logger.verbose(` cmd: ${compilerPath} ${args.join(' ')}`);
172
+
173
+ const { status, output } = await spawnCompiler(compilerPath, args);
174
+
175
+ if (status !== 0) {
176
+ emit(EVENTS.COMPILED, { baseName, ok: false, output, amxxName: null, repo: label, ref, outName });
177
+ const err = new Error(`Compilation failed: ${baseName}`);
178
+ err.compilerOutput = output;
179
+ throw err;
180
+ }
181
+
182
+ emit(EVENTS.COMPILED, { baseName, ok: true, output, amxxName: outName, repo: label, ref, outName });
183
+
184
+ return { amxxName: outName, plugins_ini_postfix: postfix, skipIni: skipIni || false, repo: label, ref };
185
+ }
186
+
187
+ async function findExcluded(dir, patterns) {
188
+ if (!patterns.length) return [];
189
+ const all = await glob('**/*.sma', { cwd: dir });
190
+ const kept = new Set(await glob(['**/*.sma', ...patterns.map((e) => `!${e}`)], { cwd: dir }));
191
+ return all.filter((f) => !kept.has(f)).map((f) => path.basename(f));
192
+ }
193
+
194
+ /**
195
+ * Compiles a single .sma file. Used by watch mode.
196
+ * Returns the .amxx filename on success, null on failure.
197
+ */
198
+ async function compileSingle(manifest, smaPath, compilerPath, includeDirs, buildDir, scriptingRootDir) {
199
+ const pluginsDir = path.join(buildDir, 'amxmodx', 'plugins');
200
+ const collectedIncDir = path.join(buildDir, 'amxmodx', 'scripting', 'include');
201
+
202
+ const baseName = path.basename(smaPath);
203
+ const rel = scriptingRootDir
204
+ ? path.relative(scriptingRootDir, smaPath)
205
+ : baseName;
206
+ const outName = rel.replace(/\.sma$/, '.amxx').split(path.sep).join('/');
207
+ const outPath = path.join(pluginsDir, ...outName.split('/'));
208
+
209
+ const scriptingDir = scriptingRootDir || path.dirname(smaPath);
210
+ const localIncDir = path.join(scriptingDir, 'include');
211
+ const includes = buildIncludeArgs({ scriptingDir, localIncDir, collectedIncDir, includeDirs });
212
+
213
+ const defines = buildDefineArgs(manifest.amxmodx.defines);
214
+
215
+ fs.mkdirSync(path.dirname(outPath), { recursive: true });
216
+
217
+ const { status, output } = await spawnCompiler(compilerPath, [smaPath, `-o${outPath}`, ...includes, ...defines]);
218
+
219
+ if (status !== 0) {
220
+ emit(EVENTS.COMPILED, { baseName, ok: false, output, amxxName: null, repo: null, ref: null, outName });
221
+ return null;
222
+ }
223
+
224
+ emit(EVENTS.COMPILED, { baseName, ok: true, output, amxxName: outName, repo: null, ref: null, outName });
225
+ return outName;
226
+ }
227
+
228
+ module.exports = { compilePlugins, compileSingle, applyPluginRule };
@@ -0,0 +1,92 @@
1
+ 'use strict';
2
+
3
+ const fs = require('fs');
4
+ const path = require('path');
5
+
6
+ // Matches: #include <file> #include "file" #tryinclude variants
7
+ const RE_INCLUDE = /^[ \t]*#(?:try)?include[ \t]+([<"])([^>"]+)[>"][ \t]*(?:\/\/.*)?$/gm;
8
+
9
+ function extractIncludes(filePath) {
10
+ let content;
11
+ try { content = fs.readFileSync(filePath, 'utf8'); } catch { return []; }
12
+ const result = [];
13
+ let m;
14
+ RE_INCLUDE.lastIndex = 0;
15
+ while ((m = RE_INCLUDE.exec(content)) !== null) {
16
+ result.push({ name: m[2].trim(), isAngle: m[1] === '<' });
17
+ }
18
+ return result;
19
+ }
20
+
21
+ class DepGraph {
22
+ constructor(includeDirs) {
23
+ // includeDirs: ordered list of dirs to search for <angle> includes
24
+ this._includeDirs = includeDirs;
25
+ // absPath → Set<absPath>: direct includes of each file
26
+ this._deps = new Map();
27
+ this._parsed = new Set();
28
+ }
29
+
30
+ // Parse a file and all its includes recursively. Safe to call multiple times.
31
+ parseFile(absPath) {
32
+ if (this._parsed.has(absPath)) return;
33
+ this._parsed.add(absPath);
34
+ if (!fs.existsSync(absPath)) return;
35
+
36
+ const directs = new Set();
37
+ for (const { name, isAngle } of extractIncludes(absPath)) {
38
+ const resolved = this._resolve(absPath, name, isAngle);
39
+ if (resolved) {
40
+ directs.add(resolved);
41
+ this.parseFile(resolved);
42
+ }
43
+ }
44
+ this._deps.set(absPath, directs);
45
+ }
46
+
47
+ // Re-parse a changed file (drops stale edges, keeps the rest of the graph).
48
+ update(absPath) {
49
+ this._parsed.delete(absPath);
50
+ this._deps.delete(absPath);
51
+ this.parseFile(absPath);
52
+ }
53
+
54
+ // Returns Set<absPath> of .sma files that transitively depend on incPath.
55
+ getSmasDependingOn(incPath) {
56
+ const smas = new Set();
57
+ const visited = new Set();
58
+
59
+ const visit = (target) => {
60
+ if (visited.has(target)) return;
61
+ visited.add(target);
62
+ for (const [file, deps] of this._deps) {
63
+ if (!deps.has(target)) continue;
64
+ if (file.endsWith('.sma')) smas.add(file);
65
+ else visit(file); // .inc depending on .inc — traverse upward
66
+ }
67
+ };
68
+
69
+ visit(incPath);
70
+ return smas;
71
+ }
72
+
73
+ _resolve(fromFile, name, isAngle) {
74
+ const withExt = /\.inc$/i.test(name) ? name : name + '.inc';
75
+
76
+ if (!isAngle) {
77
+ // "quoted" → relative to current file's directory first
78
+ const rel = path.resolve(path.dirname(fromFile), withExt);
79
+ if (fs.existsSync(rel)) return rel;
80
+ }
81
+
82
+ // <angle> or fallback: search include dirs in order
83
+ for (const dir of this._includeDirs) {
84
+ const full = path.join(dir, withExt);
85
+ if (fs.existsSync(full)) return full;
86
+ }
87
+
88
+ return null; // system / external include — not tracked
89
+ }
90
+ }
91
+
92
+ module.exports = { DepGraph };
@@ -0,0 +1,197 @@
1
+ 'use strict';
2
+
3
+ const fs = require('fs');
4
+ const path = require('path');
5
+ const logger = require('./logger');
6
+
7
+ function expand(manifest, tpl) {
8
+ return tpl
9
+ .replaceAll('{name}', manifest.name)
10
+ .replaceAll('{version}', manifest.version);
11
+ }
12
+
13
+ function resolveDeployDirs(manifest) {
14
+ const deploy = manifest.deploy;
15
+ const out = manifest.output;
16
+
17
+ // Absolute root: exclusion matching (isExcluded) compares against the deploy
18
+ // root, so a relative deploy.path would break path.relative on every dest.
19
+ const deployRoot = path.resolve(deploy.path);
20
+
21
+ const amxmodxDest = path.join(
22
+ deployRoot,
23
+ expand(manifest, deploy.amxmodx_path)
24
+ );
25
+
26
+ const assetsDest = deploy.assets_path != null
27
+ ? path.join(deployRoot, expand(manifest, deploy.assets_path))
28
+ : out.assets_path
29
+ ? path.join(deployRoot, expand(manifest, out.assets_path))
30
+ : deployRoot; // root
31
+
32
+ return { amxmodxDest, assetsDest, deployRoot };
33
+ }
34
+
35
+ /**
36
+ * Full deploy: copies build/amxmodx/ and build/assets/ to the deploy path.
37
+ * Returns number of files copied.
38
+ */
39
+ async function deployBuild(manifest, buildDir, { incremental = false } = {}) {
40
+ assertDeployPath(manifest);
41
+
42
+ const { amxmodxDest, assetsDest, deployRoot } = resolveDeployDirs(manifest);
43
+
44
+ logger.step(`Deploying to ${manifest.deploy.path}${incremental ? ' (incremental)' : ''}...`);
45
+
46
+ let count = 0;
47
+
48
+ const excludePatterns = manifest.deploy.exclude || [];
49
+
50
+ const amxmodxSrc = path.join(buildDir, 'amxmodx');
51
+ if (fs.existsSync(amxmodxSrc)) {
52
+ count += copyDir(amxmodxSrc, amxmodxDest, incremental, deployRoot, excludePatterns);
53
+ }
54
+
55
+ const assetsSrc = path.join(buildDir, 'assets');
56
+ if (fs.existsSync(assetsSrc)) {
57
+ count += copyDir(assetsSrc, assetsDest, incremental, deployRoot, excludePatterns);
58
+ }
59
+
60
+ logger.success(`Deployed ${count} file(s) → ${manifest.deploy.path}`);
61
+ return count;
62
+ }
63
+
64
+ /**
65
+ * Deploy a single compiled .amxx file (watch mode after recompile).
66
+ * Returns the dest path or null if not deployed.
67
+ */
68
+ function deployPlugin(manifest, buildDir, amxxName) {
69
+ if (!manifest.deploy.path) return null;
70
+
71
+ const { amxmodxDest, deployRoot } = resolveDeployDirs(manifest);
72
+ const src = path.join(buildDir, 'amxmodx', 'plugins', amxxName);
73
+ const dest = path.join(amxmodxDest, 'plugins', amxxName);
74
+
75
+ if (!fs.existsSync(src)) {
76
+ logger.warn(`Deploy: plugin not found in build: ${amxxName}`);
77
+ return null;
78
+ }
79
+
80
+ if (isExcluded(dest, deployRoot, manifest.deploy.exclude || [])) {
81
+ logger.verbose(` skip (excluded): ${amxxName}`);
82
+ return null;
83
+ }
84
+
85
+ fs.mkdirSync(path.dirname(dest), { recursive: true });
86
+ fs.copyFileSync(src, dest);
87
+ logger.success(`Deployed: ${amxxName}`);
88
+ logger.verbose(` → ${dest}`);
89
+ return dest;
90
+ }
91
+
92
+ /**
93
+ * Deploy a single changed local file (watch mode for amxmodx/ or assets/).
94
+ * relPath is relative to the section root (amxmodx/ or assets/).
95
+ */
96
+ function deployFile(manifest, buildDir, relPath, section) {
97
+ if (!manifest.deploy.path) return;
98
+
99
+ const { amxmodxDest, assetsDest, deployRoot } = resolveDeployDirs(manifest);
100
+
101
+ const srcBase = path.join(buildDir, section === 'assets' ? 'assets' : 'amxmodx');
102
+ const destBase = section === 'assets' ? assetsDest : amxmodxDest;
103
+
104
+ const src = path.join(srcBase, relPath);
105
+ const dest = path.join(destBase, relPath);
106
+
107
+ if (!fs.existsSync(src)) return;
108
+ if (isExcluded(dest, deployRoot, manifest.deploy.exclude || [])) {
109
+ logger.verbose(` skip (excluded): ${relPath}`);
110
+ return;
111
+ }
112
+
113
+ fs.mkdirSync(path.dirname(dest), { recursive: true });
114
+ fs.copyFileSync(src, dest);
115
+ logger.success(`Deployed: ${relPath}`);
116
+ logger.verbose(` → ${dest}`);
117
+ }
118
+
119
+ /**
120
+ * Removes a deployed file that was deleted locally (watch mode).
121
+ * relPath is relative to the section root. Honours deploy.exclude.
122
+ */
123
+ function removeDeployedFile(manifest, buildDir, relPath, section) {
124
+ if (!manifest.deploy.path) return;
125
+
126
+ const { amxmodxDest, assetsDest, deployRoot } = resolveDeployDirs(manifest);
127
+
128
+ const destBase = section === 'assets' ? assetsDest : amxmodxDest;
129
+ const dest = path.join(destBase, relPath);
130
+
131
+ if (isExcluded(dest, deployRoot, manifest.deploy.exclude || [])) {
132
+ logger.verbose(` skip delete (excluded): ${relPath}`);
133
+ return;
134
+ }
135
+
136
+ if (!fs.existsSync(dest)) return;
137
+ fs.rmSync(dest, { force: true });
138
+ logger.success(`Removed: ${relPath}`);
139
+ }
140
+
141
+ // ─── helpers ─────────────────────────────────────────────────────────────────
142
+
143
+ function isExcluded(absDestPath, deployRoot, patterns) {
144
+ if (!patterns.length) return false;
145
+ const rel = path.relative(deployRoot, absDestPath).split(path.sep).join('/');
146
+ return patterns.some((pat) => {
147
+ const np = pat.replace(/\\/g, '/').replace(/\/$/, '');
148
+ return rel === np || rel.startsWith(np + '/');
149
+ });
150
+ }
151
+
152
+ function copyDir(srcDir, destDir, incremental, deployRoot, excludePatterns) {
153
+ if (!fs.existsSync(srcDir)) return 0;
154
+ fs.mkdirSync(destDir, { recursive: true });
155
+ let count = 0;
156
+
157
+ for (const entry of fs.readdirSync(srcDir, { withFileTypes: true })) {
158
+ const srcPath = path.join(srcDir, entry.name);
159
+ const destPath = path.join(destDir, entry.name);
160
+
161
+ if (isExcluded(destPath, deployRoot, excludePatterns)) {
162
+ logger.verbose(` skip (excluded): ${path.relative(deployRoot, destPath)}`);
163
+ continue;
164
+ }
165
+
166
+ if (entry.isDirectory()) {
167
+ count += copyDir(srcPath, destPath, incremental, deployRoot, excludePatterns);
168
+ } else {
169
+ if (incremental && isUpToDate(srcPath, destPath)) continue;
170
+ fs.mkdirSync(path.dirname(destPath), { recursive: true });
171
+ fs.copyFileSync(srcPath, destPath);
172
+ logger.verbose(` → ${destPath}`);
173
+ count++;
174
+ }
175
+ }
176
+ return count;
177
+ }
178
+
179
+ function isUpToDate(src, dest) {
180
+ if (!fs.existsSync(dest)) return false;
181
+ const s = fs.statSync(src);
182
+ const d = fs.statSync(dest);
183
+ // Equal mtimes (coarse FAT/exFAT granularity) must NOT be treated as
184
+ // up-to-date — a recompiled file in the same 2s tick would be skipped.
185
+ return s.size === d.size && s.mtimeMs < d.mtimeMs;
186
+ }
187
+
188
+ function assertDeployPath(manifest) {
189
+ if (!manifest.deploy.path) {
190
+ throw new Error(
191
+ 'Deploy path not configured.\n' +
192
+ ' → Set AMXB_DEPLOY_PATH in .env, or add deploy.path to your manifest'
193
+ );
194
+ }
195
+ }
196
+
197
+ module.exports = { deployBuild, deployPlugin, deployFile, removeDeployedFile };
@@ -0,0 +1,127 @@
1
+ const fs = require('fs');
2
+ const path = require('path');
3
+ const glob = require('fast-glob');
4
+ const logger = require('./logger');
5
+ const { parseDepsLines, resolveGithubToken } = require('./manifest');
6
+ const { fetchRepo, resolveRefIfLatest } = require('./repo-fetcher');
7
+ const { fetchReleaseDep } = require('./release-fetcher');
8
+
9
+ /**
10
+ * Resolves all deps, clones them, copies .inc files to build/_includes/,
11
+ * and returns an array of include-dir paths to pass to the compiler (-i flags).
12
+ *
13
+ * Priority: manifest.globalDeps > repo.deps_override > DEPS_LIST file in repo root
14
+ */
15
+ async function resolveDeps(manifest, repoLocalDirs, noFetch, buildDir) {
16
+ const merged = new Map(); // normalised "owner/repo" → dep entry
17
+
18
+ // Add repo-level deps first (lowest priority)
19
+ for (const repoConfig of manifest.repos) {
20
+ const localDir = repoLocalDirs[repoKey(repoConfig)];
21
+ let repoDeps;
22
+
23
+ if (repoConfig.deps_override) {
24
+ repoDeps = repoConfig.deps_override;
25
+ logger.info(`Deps for ${shortName(repoConfig.repo)}: deps_override (${repoDeps.length} entries)`);
26
+ } else {
27
+ repoDeps = readDepsListFile(localDir, repoConfig.repo);
28
+ }
29
+
30
+ for (const dep of repoDeps) {
31
+ const k = normalize(dep.repo);
32
+ if (!merged.has(k)) merged.set(k, { ...dep, _from: 'repo' });
33
+ }
34
+ }
35
+
36
+ // manifest.globalDeps win over everything
37
+ for (const dep of manifest.globalDeps) {
38
+ merged.set(normalize(dep.repo), { ...dep, _from: 'manifest' });
39
+ }
40
+
41
+ if (merged.size === 0) return [];
42
+
43
+ const overridden = [...merged.values()].filter((d) => d._from === 'manifest').length;
44
+ logger.info(
45
+ `Merged deps: ${merged.size} unique` +
46
+ (overridden ? ` (${overridden} overridden by manifest)` : '')
47
+ );
48
+
49
+ const includesRoot = path.join(buildDir, '_includes');
50
+ fs.mkdirSync(includesRoot, { recursive: true });
51
+
52
+ const includeDirs = [];
53
+
54
+ for (const [k, dep] of merged) {
55
+ const token = resolveGithubToken(manifest, dep.repo);
56
+ let srcDir;
57
+ if (dep.source === 'release') {
58
+ srcDir = await fetchReleaseDep(dep, token, noFetch);
59
+ } else {
60
+ const resolvedDepRef = await resolveRefIfLatest(dep.ref, dep.repo, token);
61
+ const depDir = await fetchRepo(dep.repo, resolvedDepRef, token, noFetch, manifest.github.ssh);
62
+ srcDir = resolveIncludePath(depDir, dep.include_path, dep.repo);
63
+ }
64
+
65
+ const destDir = path.join(includesRoot, k.replace('/', '__'));
66
+ fs.mkdirSync(destDir, { recursive: true });
67
+
68
+ const files = await glob('**/*.inc', { cwd: srcDir, dot: false });
69
+ for (const f of files) {
70
+ const dest = path.join(destDir, f);
71
+ fs.mkdirSync(path.dirname(dest), { recursive: true });
72
+ fs.copyFileSync(path.join(srcDir, f), dest);
73
+ }
74
+
75
+ logger.dim(` ${dep.repo}@${dep.ref}: ${files.length} .inc files`);
76
+ includeDirs.push(destDir);
77
+ }
78
+
79
+ const total = includeDirs.reduce((s, d) => s + countIncFiles(d), 0);
80
+ logger.info(`Includes collected: ${total} .inc files → build/_includes/`);
81
+
82
+ return includeDirs;
83
+ }
84
+
85
+ function readDepsListFile(repoDir, repoName) {
86
+ const p = path.join(repoDir, 'DEPS_LIST');
87
+ if (!fs.existsSync(p)) {
88
+ logger.dim(` Deps for ${shortName(repoName)}: no DEPS_LIST file`);
89
+ return [];
90
+ }
91
+ const deps = parseDepsLines(fs.readFileSync(p, 'utf8').split(/\r?\n/));
92
+ logger.info(`Deps for ${shortName(repoName)}: DEPS_LIST found (${deps.length} entries)`);
93
+ return deps;
94
+ }
95
+
96
+ function resolveIncludePath(repoDir, explicitPath, repoName) {
97
+ if (explicitPath) {
98
+ const full = path.join(repoDir, explicitPath);
99
+ if (!fs.existsSync(full)) throw new Error(`Include path "${explicitPath}" not found in ${repoName}`);
100
+ return full;
101
+ }
102
+ for (const candidate of ['scripting/include', 'amxmodx/scripting/include', 'include', '.']) {
103
+ const full = path.join(repoDir, candidate);
104
+ if (fs.existsSync(full)) return full;
105
+ }
106
+ return repoDir;
107
+ }
108
+
109
+ // Single source of truth for repo-name normalization (used for cache keys
110
+ // and dedup by core modules that previously inlined repo.toLowerCase()).
111
+ function normalize(repo) { return repo.toLowerCase(); }
112
+
113
+ function repoKey(repoConfig) {
114
+ return `${repoConfig.repo}@${repoConfig._resolvedRef || repoConfig.ref || 'HEAD'}`;
115
+ }
116
+ function shortName(repo) { return repo.split('/').pop(); }
117
+
118
+ function countIncFiles(dir) {
119
+ let n = 0;
120
+ for (const e of fs.readdirSync(dir, { withFileTypes: true })) {
121
+ if (e.isDirectory()) n += countIncFiles(path.join(dir, e.name));
122
+ else if (e.name.endsWith('.inc')) n++;
123
+ }
124
+ return n;
125
+ }
126
+
127
+ module.exports = { resolveDeps, readDepsListFile, normalize, repoKey };