amxx-builder 1.6.1 → 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.
@@ -8,6 +8,12 @@ const logger = require('../logger');
8
8
  const TEMPLATES_DIR = path.join(__dirname, '..', '..', 'templates');
9
9
  const SCHEMA_URL = 'https://raw.githubusercontent.com/AmxxModularEcosystem/amxx-builder/master/schema/amxbuild.schema.json';
10
10
 
11
+ // Written to .vscode/extensions.json by `amxb init --vscode`.
12
+ const VSCODE_EXTENSIONS = [
13
+ 'Faktor.amxx-pawn-all-in',
14
+ 'amxx-modular-ecosystem.amxb-vscode',
15
+ ];
16
+
11
17
  // Auto-discovered by opencode (.opencode/plugin/*.js). Registers skills from all
12
18
  // three sources in the config hook, with no machine-specific paths in opencode.json.
13
19
  const OPENCODE_BRIDGE_FILE = path.join('.opencode', 'plugin', 'amxb-skills.js');
@@ -98,6 +104,12 @@ async function runInitInteractive(options) {
98
104
  initial: true,
99
105
  }).run();
100
106
 
107
+ const doVscode = await new Confirm({
108
+ name: 'vscode',
109
+ message: 'Create .vscode/extensions.json with recommended extensions?',
110
+ initial: true,
111
+ }).run();
112
+
101
113
  const actions = [];
102
114
  actions.push('amxbuild.yml');
103
115
  if (doWorkflow) actions.push('.github/workflows/ci.yml');
@@ -106,6 +118,7 @@ async function runInitInteractive(options) {
106
118
  if (doDeploy) actions.push('.env');
107
119
  if (doOpencode) actions.push('.opencode/opencode.json');
108
120
  if (doScript) actions.push('build.bat', 'build.sh');
121
+ if (doVscode) actions.push('.vscode/extensions.json');
109
122
 
110
123
  logger.info('Creating:');
111
124
  for (const a of actions) logger.dim(` ${a}`);
@@ -128,9 +141,7 @@ async function runInitInteractive(options) {
128
141
  }
129
142
 
130
143
  if (doGitignore) {
131
- writeIfAbsent('.gitignore', [
132
- '*.amxx', '*.zip', '.env', '.amxb-cache', '.claude', 'build', 'dist', '',
133
- ].join('\n'), options.force);
144
+ writeGitignore(options.force);
134
145
  }
135
146
 
136
147
  if (doDeploy) {
@@ -145,6 +156,10 @@ async function runInitInteractive(options) {
145
156
  if (doScript) {
146
157
  writeBuildScripts(options.force);
147
158
  }
159
+
160
+ if (doVscode) {
161
+ writeVscodeExtensions();
162
+ }
148
163
  }
149
164
 
150
165
  function runInit(options) {
@@ -167,9 +182,7 @@ function runInit(options) {
167
182
  }
168
183
 
169
184
  if (options.gitignore) {
170
- writeIfAbsent('.gitignore', [
171
- '*.amxx', '*.zip', '.env', '.amxb-cache', '.claude', 'build', 'dist', '',
172
- ].join('\n'), options.force);
185
+ writeGitignore(options.force);
173
186
  }
174
187
 
175
188
  if (options.deploy) {
@@ -184,6 +197,16 @@ function runInit(options) {
184
197
  if (options.script) {
185
198
  writeBuildScripts(options.force);
186
199
  }
200
+
201
+ if (options.vscode || options.vsc) {
202
+ writeVscodeExtensions();
203
+ }
204
+ }
205
+
206
+ // Shared by both init paths; also the .gitignore basis the amxb-migration skill
207
+ // reuses, so keep templates/init-gitignore in sync with its step 6.
208
+ function writeGitignore(force) {
209
+ return writeIfAbsent('.gitignore', renderTemplate('init-gitignore'), force);
187
210
  }
188
211
 
189
212
  function writeBuildScripts(force) {
@@ -269,6 +292,46 @@ function writeOpencodeBridge(force) {
269
292
  writeIfAbsent(OPENCODE_BRIDGE_FILE, OPENCODE_BRIDGE_PLUGIN, force);
270
293
  }
271
294
 
295
+ // The extensions file is shared with the user's own editor setup, so it is
296
+ // merged rather than overwritten: existing recommendations are preserved and
297
+ // only the missing amxb entries are appended.
298
+ function writeVscodeExtensions() {
299
+ const dir = '.vscode';
300
+ const file = path.join(dir, 'extensions.json');
301
+
302
+ if (!fs.existsSync(file)) {
303
+ fs.mkdirSync(dir, { recursive: true });
304
+ fs.writeFileSync(file, JSON.stringify({ recommendations: VSCODE_EXTENSIONS }, null, 2) + '\n');
305
+ logger.success(`Created ${file}`);
306
+ return;
307
+ }
308
+
309
+ let cfg;
310
+ try {
311
+ cfg = JSON.parse(fs.readFileSync(file, 'utf8'));
312
+ } catch (err) {
313
+ logger.warn(`${file} exists but is invalid JSON, skipping`);
314
+ return;
315
+ }
316
+
317
+ if (cfg === null || typeof cfg !== 'object' || Array.isArray(cfg)) {
318
+ logger.warn(`${file} is not a JSON object, skipping`);
319
+ return;
320
+ }
321
+
322
+ const current = Array.isArray(cfg.recommendations) ? cfg.recommendations : [];
323
+ const missing = VSCODE_EXTENSIONS.filter((id) => !current.includes(id));
324
+
325
+ if (missing.length === 0) {
326
+ logger.warn(`${file} already recommends the amxb extensions, skipping`);
327
+ return;
328
+ }
329
+
330
+ cfg.recommendations = current.concat(missing);
331
+ fs.writeFileSync(file, JSON.stringify(cfg, null, 2) + '\n');
332
+ logger.success(`Updated ${file} with recommended extensions`);
333
+ }
334
+
272
335
  function renderTemplate(name, vars = {}) {
273
336
  let content = fs.readFileSync(path.join(TEMPLATES_DIR, name), 'utf8');
274
337
  for (const [key, value] of Object.entries(vars)) {
@@ -54,7 +54,8 @@ const { on, off, EVENTS } = require('../events');
54
54
 
55
55
  const { loadEnv } = require('../env');
56
56
  const { resolveManifestPath } = require('../manifest-path');
57
- const { resolveManifest, parseManifest, resolveGithubToken, parseDepString, parseDocEntries, parseSkillEntries } = require('../manifest');
57
+ const { resolveManifest, parseManifest, resolveGithubToken, parseDepString, parseDepObject, parseRefTtl, parseDocEntries, parseSkillEntries } = require('../manifest');
58
+ const { resolveLocalEntry } = require('../local-sources');
58
59
  const { readDepManifest, collectDepAssets, collectLocalAssets } = require('../agent-assets');
59
60
  const { validateManifestFile } = require('../validate');
60
61
  const { collectIncFiles, parseIncludeDirective, searchIncludeFile } = require('../include-tree');
@@ -184,7 +185,9 @@ function resolveGithubTokenFor(params, repo) {
184
185
  function depFromParams(params) {
185
186
  let dep;
186
187
  if (params?.dep) {
187
- dep = typeof params.dep === 'string' ? parseDepString(params.dep) : { ...params.dep };
188
+ if (typeof params.dep === 'string') dep = parseDepString(params.dep);
189
+ else if (params.dep.source === 'local') dep = parseDepObject(params.dep);
190
+ else dep = { ...params.dep };
188
191
  } else {
189
192
  if (!params?.repo) throw new Error('Provide either "dep" or "repo"');
190
193
  const source = params.source || 'git';
@@ -194,7 +197,10 @@ function depFromParams(params) {
194
197
  if (params?.source) dep.source = params.source;
195
198
  if (params?.include_path) dep.include_path = params.include_path;
196
199
  if (params?.asset != null) dep.asset = params.asset;
197
- return dep;
200
+ return resolveLocalEntry(
201
+ dep,
202
+ params?.manifest ? path.dirname(path.resolve(params.manifest)) : process.cwd()
203
+ );
198
204
  }
199
205
 
200
206
  // Agent docs/skills: dep mode when dep/repo is present, otherwise the local
@@ -540,6 +546,7 @@ function createServeServer() {
540
546
  source: entry.source || 'git',
541
547
  include_path: entry.include_path || null,
542
548
  asset: entry.asset != null ? entry.asset : null,
549
+ ...(entry.ref_ttl != null ? { ref_ttl: parseRefTtl(entry.ref_ttl, `deps entry ${entry.repo}`) } : {}),
543
550
  };
544
551
  });
545
552
  return buildDepTree(rootDeps, { token: params?.token, noFetch, depth, from: 'user' });
@@ -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 { fetchRepo, resolveRepoRefs } = require('../repo-fetcher');
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
- fetchRepo(repoConfig.repo, repoConfig._resolvedRef, resolveGithubToken(manifest, repoConfig.repo), true, manifest.github.ssh)
66
+ ensureRepoDir(repoConfig, {
67
+ token: resolveGithubToken(manifest, repoConfig.repo),
68
+ noFetch: true,
69
+ ssh: manifest.github.ssh,
70
+ })
57
71
  );
58
72
  }
59
73
  }
@@ -144,7 +158,7 @@ async function runWatch(options) {
144
158
  return enqueue(async () => {
145
159
  state.depGraph.update(smaPath);
146
160
  const smaRel = path.relative(state.scriptingRootDir, smaPath).split(path.sep).join('/');
147
- const pluginRule = applyPluginRule(smaRel, state.manifest.pluginRules, state.manifest.globalPostfix);
161
+ const pluginRule = applyPluginRule(smaRel, state.manifest.plugins.rules, pluginRuleBase(state.manifest));
148
162
  if (!pluginRule) {
149
163
  logger.dim(` Skipped by plugin rule: ${smaRel}`);
150
164
  return;
@@ -178,7 +192,7 @@ async function runWatch(options) {
178
192
  const compiled = [];
179
193
  for (const smaPath of affected) {
180
194
  const smaRel = path.relative(state.scriptingRootDir, smaPath).split(path.sep).join('/');
181
- const pluginRule = applyPluginRule(smaRel, state.manifest.pluginRules, state.manifest.globalPostfix);
195
+ const pluginRule = applyPluginRule(smaRel, state.manifest.plugins.rules, pluginRuleBase(state.manifest));
182
196
  if (!pluginRule) {
183
197
  logger.dim(` Skipped by plugin rule: ${smaRel}`);
184
198
  continue;
@@ -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') {
@@ -71,4 +177,4 @@ function buildDefineArgs(defines) {
71
177
  return (defines || []).map((d) => (d.includes('=') ? d : `${d}=1`));
72
178
  }
73
179
 
74
- module.exports = { spawnCompiler, buildIncludeArgs, buildDefineArgs };
180
+ module.exports = { spawnCompiler, buildIncludeArgs, buildDefineArgs, checkCompilerInputs, parse9pMountpoints };
package/src/compiler.js CHANGED
@@ -9,19 +9,31 @@ const { repoKey, normalizeRepo } = require('./deps-resolver');
9
9
 
10
10
  /**
11
11
  * Applies plugin rules to a local .sma file path (relative to scripting/).
12
- * Returns null if the plugin should be skipped (enabled: false),
13
- * or { postfix, skipIni } where postfix is the INI postfix (false = skip INI).
12
+ *
13
+ * @param {string} smaRelPath - .sma path relative to scripting/
14
+ * @param {Array} rules - manifest.plugins.rules (first matching rule wins)
15
+ * @param {{ ini: false|string, debug: boolean }} base - plugins.defaults layer:
16
+ * ini '' → plugins.ini, ini false → excluded from every INI
17
+ * @returns {null|{ postfix: string, skipIni: boolean, debug: boolean }} null
18
+ * when the matching rule has enabled:false (skip the plugin entirely);
19
+ * otherwise the effective INI postfix, whether the plugin is excluded from
20
+ * every INI (ini:false), and the effective debug flag. `ini` unset/null and
21
+ * `debug` unset/null inherit `base`.
14
22
  */
15
- function applyPluginRule(smaRelPath, rules, defaultPostfix) {
23
+ function applyPluginRule(smaRelPath, rules, base) {
16
24
  const normalized = smaRelPath.split(path.sep).join('/');
17
25
  for (const rule of rules) {
18
26
  if (micromatch.isMatch(normalized, rule.match, { dot: true })) {
19
- if (!rule.enabled) return null;
20
- const postfix = rule.ini !== null ? rule.ini : defaultPostfix;
21
- return { postfix, skipIni: rule.ini === false };
27
+ if (rule.enabled === false) return null;
28
+ const ini = rule.ini != null ? rule.ini : base.ini;
29
+ return {
30
+ postfix: ini === false ? '' : ini,
31
+ skipIni: ini === false,
32
+ debug: rule.debug != null ? rule.debug : base.debug,
33
+ };
22
34
  }
23
35
  }
24
- return { postfix: defaultPostfix, skipIni: false };
36
+ return { postfix: base.ini === false ? '' : base.ini, skipIni: base.ini === false, debug: base.debug };
25
37
  }
26
38
 
27
39
  async function compilePlugins(manifest, repoLocalDirs, compilerPath, includeDirs, buildDir) {
@@ -30,6 +42,12 @@ async function compilePlugins(manifest, repoLocalDirs, compilerPath, includeDirs
30
42
 
31
43
  const collectedIncDir = path.join(buildDir, 'amxmodx', 'scripting', 'include');
32
44
 
45
+ // plugins.defaults is the base layer for every plugin (local + repo).
46
+ const pluginIni = manifest.pluginIni || { defaultIni: false, defaultDebug: false };
47
+ const base = { ini: pluginIni.defaultIni, debug: pluginIni.defaultDebug === true };
48
+ // AMXB_PLUGINS_DEBUG outranks rule/repo debug when set (true or false).
49
+ const forceDebug = pluginIni.forceDebug != null ? pluginIni.forceDebug : null;
50
+
33
51
  // ── Build unified source list ──────────────────────────────────────────────
34
52
  const seenSources = new Set(); // case-insensitive repo identity (dedupe)
35
53
  const sources = [];
@@ -46,7 +64,7 @@ async function compilePlugins(manifest, repoLocalDirs, compilerPath, includeDirs
46
64
  'scripting'
47
65
  ),
48
66
  exclude: repoConfig.exclude,
49
- postfix: repoConfig.plugins_ini_postfix,
67
+ settings: repoConfig._pluginSettings || { ini: null, debug: null },
50
68
  });
51
69
  }
52
70
 
@@ -54,7 +72,7 @@ async function compilePlugins(manifest, repoLocalDirs, compilerPath, includeDirs
54
72
  if (fs.existsSync(localScriptingDir)) {
55
73
  sources.push({
56
74
  label: '(local)', ref: 'local', isLocal: true, scriptingDir: localScriptingDir,
57
- exclude: [], postfix: manifest.globalPostfix,
75
+ exclude: [], settings: null,
58
76
  });
59
77
  }
60
78
 
@@ -62,7 +80,7 @@ async function compilePlugins(manifest, repoLocalDirs, compilerPath, includeDirs
62
80
  const onConflict = manifest.output.on_conflict || 'last_wins';
63
81
  const tasksByOut = new Map(); // outPath → task (dedupe cross-source collisions)
64
82
  for (const src of sources) {
65
- const { scriptingDir, exclude, postfix, label, ref, isLocal = false } = src;
83
+ const { scriptingDir, exclude, settings, label, ref, isLocal = false } = src;
66
84
 
67
85
  if (!fs.existsSync(scriptingDir)) {
68
86
  logger.dim(` ${label}: no scripting/ dir`);
@@ -86,23 +104,32 @@ async function compilePlugins(manifest, repoLocalDirs, compilerPath, includeDirs
86
104
  }
87
105
 
88
106
  for (const smaRel of smaFiles) {
89
- let taskPostfix = postfix;
90
- let skipIni = false;
107
+ let taskPostfix;
108
+ let skipIni = false;
109
+ let taskDebug = base.debug;
91
110
 
92
111
  if (isLocal) {
93
- const ruleResult = applyPluginRule(smaRel, manifest.pluginRules, postfix);
112
+ const ruleResult = applyPluginRule(smaRel, manifest.plugins.rules, base);
94
113
  if (!ruleResult) {
95
114
  logger.skip(`Skipped (plugin rule): ${smaRel}`);
96
115
  continue;
97
116
  }
98
117
  taskPostfix = ruleResult.postfix;
99
118
  skipIni = ruleResult.skipIni;
119
+ taskDebug = forceDebug != null ? forceDebug : ruleResult.debug;
120
+ } else {
121
+ const ini = settings.ini != null ? settings.ini : base.ini;
122
+ taskPostfix = ini === false ? '' : ini;
123
+ skipIni = ini === false;
124
+ taskDebug = forceDebug != null
125
+ ? forceDebug
126
+ : (settings.debug != null ? settings.debug : base.debug);
100
127
  }
101
128
 
102
129
  const baseName = path.basename(smaRel);
103
130
  const outName = smaRel.replace(/\.sma$/, '.amxx').split(path.sep).join('/');
104
131
  const task = {
105
- label, ref, postfix: taskPostfix, skipIni, baseName,
132
+ label, ref, postfix: taskPostfix, skipIni, debug: taskDebug, baseName,
106
133
  srcPath: path.join(scriptingDir, smaRel),
107
134
  outName,
108
135
  outPath: path.join(pluginsDir, ...outName.split('/')),
@@ -172,7 +199,7 @@ async function mapLimit(items, limit, fn) {
172
199
  }
173
200
 
174
201
  async function runCompile(compilerPath, task) {
175
- const { srcPath, outPath, outName, includes, defines, baseName, postfix, skipIni, label, ref } = task;
202
+ const { srcPath, outPath, outName, includes, defines, baseName, postfix, skipIni, debug, label, ref } = task;
176
203
 
177
204
  fs.mkdirSync(path.dirname(outPath), { recursive: true });
178
205
  const args = [srcPath, `-o${outPath}`, ...includes, ...defines];
@@ -189,7 +216,7 @@ async function runCompile(compilerPath, task) {
189
216
 
190
217
  emit(EVENTS.COMPILED, { baseName, ok: true, output, amxxName: outName, repo: label, ref, outName });
191
218
 
192
- return { amxxName: outName, plugins_ini_postfix: postfix, skipIni: skipIni || false, repo: label, ref };
219
+ return { amxxName: outName, plugins_ini_postfix: postfix, skipIni: skipIni || false, debug: debug === true, repo: label, ref };
193
220
  }
194
221
 
195
222
  async function findExcluded(dir, patterns) {
@@ -3,7 +3,8 @@ const path = require('path');
3
3
  const glob = require('fast-glob');
4
4
  const logger = require('./logger');
5
5
  const { parseDepsLines, resolveGithubToken } = require('./manifest');
6
- const { fetchRepo, resolveRefIfLatest } = require('./repo-fetcher');
6
+ const { isLocal } = require('./local-sources');
7
+ const { fetchRepo, resolveRefIfLatest, applicableRefTtl } = require('./repo-fetcher');
7
8
  const { fetchReleaseDep } = require('./release-fetcher');
8
9
  const { fetchFungunDep } = require('./fungun-fetcher');
9
10
 
@@ -11,6 +12,9 @@ const { fetchFungunDep } = require('./fungun-fetcher');
11
12
  * Resolves all deps, clones them, copies .inc files to build/_includes/,
12
13
  * and returns an array of include-dir paths to pass to the compiler (-i flags).
13
14
  *
15
+ * Local deps (from ./local-sources) short-circuit first: their include dir is
16
+ * resolved from disk — no ref resolution, fetch or token lookup.
17
+ *
14
18
  * Priority: manifest.globalDeps > repo.deps_override > DEPS_LIST file in repo root
15
19
  */
16
20
  async function resolveDeps(manifest, repoLocalDirs, noFetch, buildDir) {
@@ -54,7 +58,9 @@ async function resolveDeps(manifest, repoLocalDirs, noFetch, buildDir) {
54
58
 
55
59
  for (const [k, dep] of merged) {
56
60
  let srcDir;
57
- if (dep.source === 'release') {
61
+ if (isLocal(dep)) {
62
+ srcDir = resolveIncludePath(dep._localDir, dep.include_path, dep.repo);
63
+ } else if (dep.source === 'release') {
58
64
  const token = resolveGithubToken(manifest, dep.repo);
59
65
  srcDir = await fetchReleaseDep(dep, token, noFetch);
60
66
  } else if (dep.source === 'fungun') {
@@ -62,7 +68,10 @@ async function resolveDeps(manifest, repoLocalDirs, noFetch, buildDir) {
62
68
  } else {
63
69
  const token = resolveGithubToken(manifest, dep.repo);
64
70
  const resolvedDepRef = await resolveRefIfLatest(dep.ref, dep.repo, token);
65
- const depDir = await fetchRepo(dep.repo, resolvedDepRef, token, noFetch, manifest.github.ssh);
71
+ const depDir = await fetchRepo(
72
+ dep.repo, resolvedDepRef, token, noFetch, manifest.github.ssh,
73
+ applicableRefTtl(dep.ref, dep.ref_ttl)
74
+ );
66
75
  srcDir = resolveIncludePath(depDir, dep.include_path, dep.repo);
67
76
  }
68
77
 
@@ -138,6 +147,9 @@ function resolveIncludePath(repoDir, explicitPath, repoName) {
138
147
  * Single source of truth shared by the build pipeline, the MCP server and
139
148
  * agent-assets resolution.
140
149
  *
150
+ * Local deps short-circuit on disk before any release/fungun/git branch — they
151
+ * need no token, ref, fetch or `--no-fetch` handling.
152
+ *
141
153
  * `dep` is a parsed dep OBJECT ({ repo, ref, source, include_path, asset }).
142
154
  * GitHub token resolution (per-owner fallbacks) is an interface-layer concern —
143
155
  * callers pass the already-resolved token, this function never calls fallbackToken.
@@ -150,6 +162,17 @@ function resolveIncludePath(repoDir, explicitPath, repoName) {
150
162
  * @returns {Promise<{ rootDir: string, label: string }>}
151
163
  */
152
164
  async function fetchDepRoot(dep, { token, noFetch, ssh = false } = {}) {
165
+ if (isLocal(dep)) {
166
+ if (dep.include_path) {
167
+ const sub = path.join(dep._localDir, dep.include_path);
168
+ if (!fs.existsSync(sub)) {
169
+ throw new Error(`include_path "${dep.include_path}" not found in ${dep.repo}`);
170
+ }
171
+ return { rootDir: sub, label: `${dep.repo} (local)` };
172
+ }
173
+ return { rootDir: dep._localDir, label: `${dep.repo} (local)` };
174
+ }
175
+
153
176
  if (dep.source === 'release') {
154
177
  const dir = await fetchReleaseDep(dep, token, noFetch);
155
178
  return { rootDir: dir, label: `${dep.repo}@${dep.ref} (release)` };
@@ -161,7 +184,9 @@ async function fetchDepRoot(dep, { token, noFetch, ssh = false } = {}) {
161
184
  }
162
185
 
163
186
  const resolvedRef = await resolveRefIfLatest(dep.ref, dep.repo, token);
164
- const repoDir = await fetchRepo(dep.repo, resolvedRef, token, noFetch, ssh);
187
+ const repoDir = await fetchRepo(
188
+ dep.repo, resolvedRef, token, noFetch, ssh, applicableRefTtl(dep.ref, dep.ref_ttl)
189
+ );
165
190
  if (dep.include_path) {
166
191
  const sub = path.join(repoDir, dep.include_path);
167
192
  if (!fs.existsSync(sub)) {
@@ -173,11 +198,14 @@ async function fetchDepRoot(dep, { token, noFetch, ssh = false } = {}) {
173
198
  }
174
199
 
175
200
  /**
176
- * Fetch a dependency's include directory (release/fungun/git), using the
201
+ * Fetch a dependency's include directory (local/release/fungun/git), using the
177
202
  * fetch cache where possible.
178
203
  * Canonical single-source-of-truth shared by the build pipeline (include-tree),
179
204
  * the CLI, the serve interface and the MCP layer.
180
205
  *
206
+ * Local deps are always on disk — short-circuit to their `_localDir` before any
207
+ * token/ref/fetch logic (`--no-fetch` is irrelevant for them).
208
+ *
181
209
  * Explicit-include_path semantics: silently falls back to the repo root when
182
210
  * the given path does not exist (the interface callers rely on this).
183
211
  *
@@ -188,6 +216,8 @@ async function fetchDepRoot(dep, { token, noFetch, ssh = false } = {}) {
188
216
  * @returns {Promise<string>} directory to use as the include dir
189
217
  */
190
218
  async function fetchDepIncludeDir(dep, token, noFetch, ssh = false) {
219
+ if (isLocal(dep)) return findDepIncludeDir(dep._localDir, dep.include_path);
220
+
191
221
  if (dep.source === 'release') {
192
222
  return fetchReleaseDep(
193
223
  { repo: dep.repo, ref: dep.ref, include_path: dep.include_path, asset: dep.asset },
@@ -201,7 +231,9 @@ async function fetchDepIncludeDir(dep, token, noFetch, ssh = false) {
201
231
  }
202
232
 
203
233
  const resolvedRef = await resolveRefIfLatest(dep.ref, dep.repo, token);
204
- const repoDir = await fetchRepo(dep.repo, resolvedRef, token, noFetch, ssh);
234
+ const repoDir = await fetchRepo(
235
+ dep.repo, resolvedRef, token, noFetch, ssh, applicableRefTtl(dep.ref, dep.ref_ttl)
236
+ );
205
237
  return findDepIncludeDir(repoDir, dep.include_path);
206
238
  }
207
239
 
@@ -218,6 +250,7 @@ async function fetchDepIncludeDir(dep, token, noFetch, ssh = false) {
218
250
  * interface's existing formatting.
219
251
  *
220
252
  * GitHub tokens are resolved per-owner via resolveGithubToken(manifest, repo);
253
+ * local deps skip the lookup entirely (null token — they never leave disk).
221
254
  * ssh must be passed explicitly by the caller (manifest.github.ssh).
222
255
  *
223
256
  * @param {object|null} manifest - parsed manifest (null → no deps)
@@ -233,7 +266,8 @@ async function collectDepIncludeDirs(manifest, { noFetch = false, ssh = false }
233
266
  for (let i = 0; i < deps.length; i++) {
234
267
  const dep = deps[i];
235
268
  try {
236
- dirs[i] = await fetchDepIncludeDir(dep, resolveGithubToken(manifest, dep.repo), noFetch, ssh);
269
+ const token = isLocal(dep) ? null : resolveGithubToken(manifest, dep.repo);
270
+ dirs[i] = await fetchDepIncludeDir(dep, token, noFetch, ssh);
237
271
  } catch (err) {
238
272
  errors[i] = err && err.message ? err.message : String(err);
239
273
  }
@@ -245,11 +279,15 @@ async function collectDepIncludeDirs(manifest, { noFetch = false, ssh = false }
245
279
  // and dedup by core modules that previously inlined repo.toLowerCase()).
246
280
  function normalize(repo) { return repo.toLowerCase(); }
247
281
 
248
- // Human-readable dep label (fungun deps: no repo@ref — addressed by shop page id).
282
+ // Human-readable dep label (fungun deps: no repo@ref — addressed by shop page
283
+ // id; local deps: synthetic local/<name> id, versioned by the working tree).
249
284
  function depLabel(dep) {
250
285
  if (dep && dep.source === 'fungun') {
251
286
  return `fungun.net plugin #${dep.id}`;
252
287
  }
288
+ if (isLocal(dep)) {
289
+ return `${dep.repo} (local)`;
290
+ }
253
291
  return `${dep.repo}@${dep.ref || 'default branch'}`;
254
292
  }
255
293