amxx-builder 1.5.2 → 1.6.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.
@@ -80,28 +80,28 @@ async function runInitInteractive(options) {
80
80
  const version = require('../../package.json').version;
81
81
  const actionTag = `v${version.split('.')[0]}`;
82
82
 
83
- writeIfAbsent('amxbuild.yml', renderTemplate('init-manifest.yml', { name, schemaUrl: SCHEMA_URL }));
83
+ writeIfAbsent('amxbuild.yml', renderTemplate('init-manifest.yml', { name, schemaUrl: SCHEMA_URL }), options.force);
84
84
 
85
85
  if (doWorkflow) {
86
86
  const dest = path.join('.github', 'workflows', 'ci.yml');
87
87
  fs.mkdirSync(path.dirname(dest), { recursive: true });
88
- writeIfAbsent(dest, renderTemplate('init-workflow.yml', { actionTag }));
88
+ writeIfAbsent(dest, renderTemplate('init-workflow.yml', { actionTag }), options.force);
89
89
  }
90
90
 
91
91
  if (pluginName) {
92
92
  const dest = path.join('amxmodx', 'scripting', `${pluginName}.sma`);
93
93
  fs.mkdirSync(path.dirname(dest), { recursive: true });
94
- writeIfAbsent(dest, '');
94
+ writeIfAbsent(dest, '', options.force);
95
95
  }
96
96
 
97
97
  if (doGitignore) {
98
98
  writeIfAbsent('.gitignore', [
99
99
  '*.amxx', '*.zip', '.env', '.amxb-cache', '.claude', 'build', 'dist', '',
100
- ].join('\n'));
100
+ ].join('\n'), options.force);
101
101
  }
102
102
 
103
103
  if (doDeploy) {
104
- writeIfAbsent('.env', renderTemplate('init-deploy.env'));
104
+ writeIfAbsent('.env', renderTemplate('init-deploy.env'), options.force);
105
105
  }
106
106
 
107
107
  if (doOpencode) {
@@ -109,7 +109,7 @@ async function runInitInteractive(options) {
109
109
  }
110
110
 
111
111
  if (doScript) {
112
- writeBuildScripts();
112
+ writeBuildScripts(options.force);
113
113
  }
114
114
  }
115
115
 
@@ -118,28 +118,28 @@ function runInit(options) {
118
118
  const version = require('../../package.json').version;
119
119
  const actionTag = `v${version.split('.')[0]}`;
120
120
 
121
- writeIfAbsent('amxbuild.yml', renderTemplate('init-manifest.yml', { name: pkgName, schemaUrl: SCHEMA_URL }));
121
+ writeIfAbsent('amxbuild.yml', renderTemplate('init-manifest.yml', { name: pkgName, schemaUrl: SCHEMA_URL }), options.force);
122
122
 
123
123
  if (options.workflow || options.ci) {
124
124
  const dest = path.join('.github', 'workflows', 'ci.yml');
125
125
  fs.mkdirSync(path.dirname(dest), { recursive: true });
126
- writeIfAbsent(dest, renderTemplate('init-workflow.yml', { actionTag }));
126
+ writeIfAbsent(dest, renderTemplate('init-workflow.yml', { actionTag }), options.force);
127
127
  }
128
128
 
129
129
  if (options.plugin) {
130
130
  const dest = path.join('amxmodx', 'scripting', `${options.plugin}.sma`);
131
131
  fs.mkdirSync(path.dirname(dest), { recursive: true });
132
- writeIfAbsent(dest, '');
132
+ writeIfAbsent(dest, '', options.force);
133
133
  }
134
134
 
135
135
  if (options.gitignore) {
136
136
  writeIfAbsent('.gitignore', [
137
137
  '*.amxx', '*.zip', '.env', '.amxb-cache', '.claude', 'build', 'dist', '',
138
- ].join('\n'));
138
+ ].join('\n'), options.force);
139
139
  }
140
140
 
141
141
  if (options.deploy) {
142
- writeIfAbsent('.env', renderTemplate('init-deploy.env'));
142
+ writeIfAbsent('.env', renderTemplate('init-deploy.env'), options.force);
143
143
  }
144
144
 
145
145
  if (options.opencode) {
@@ -147,13 +147,13 @@ function runInit(options) {
147
147
  }
148
148
 
149
149
  if (options.script) {
150
- writeBuildScripts();
150
+ writeBuildScripts(options.force);
151
151
  }
152
152
  }
153
153
 
154
- function writeBuildScripts() {
155
- const batCreated = writeIfAbsent('build.bat', renderTemplate('init-build.bat').replace(/\r?\n/g, '\r\n'));
156
- const shCreated = writeIfAbsent('build.sh', renderTemplate('init-build.sh'));
154
+ function writeBuildScripts(force) {
155
+ const batCreated = writeIfAbsent('build.bat', renderTemplate('init-build.bat').replace(/\r?\n/g, '\r\n'), force);
156
+ const shCreated = writeIfAbsent('build.sh', renderTemplate('init-build.sh'), force);
157
157
 
158
158
  if (shCreated && process.platform !== 'win32') {
159
159
  try {
@@ -167,13 +167,14 @@ function writeBuildScripts() {
167
167
  return batCreated || shCreated;
168
168
  }
169
169
 
170
- function writeIfAbsent(filePath, content) {
171
- if (fs.existsSync(filePath)) {
170
+ function writeIfAbsent(filePath, content, force) {
171
+ const existed = fs.existsSync(filePath);
172
+ if (existed && !force) {
172
173
  logger.warn(`${filePath} already exists, skipping`);
173
174
  return false;
174
175
  }
175
176
  fs.writeFileSync(filePath, content);
176
- logger.success(`Created ${filePath}`);
177
+ logger.success(existed ? `Overwritten ${filePath}` : `Created ${filePath}`);
177
178
  return true;
178
179
  }
179
180
 
@@ -22,12 +22,20 @@
22
22
  * amxmodx.include.get → compiler-fetcher fetchCompiler + glob + read
23
23
  * deps.tree → deps-tree buildDepTree + assembleRootDeps
24
24
  * releases.list → release-lister listReleases / listTags
25
+ * repos.info → github-api getRepoInfo
26
+ * repos.branches → github-api listBranches
27
+ * repos.structure → github-api getRepoStructure
25
28
  * cache.info → cache-info getCacheInfo
29
+ * compiler.info → compiler-fetcher getCompilerInfo
30
+ * dep-graph.get → dep-graph DepGraph
26
31
  * build.plan → build-plan buildPlanData
27
32
  * build.start → build-service runBuild (+ event notifications)
28
33
  * build.cancel → abort the running build (AbortController)
29
- * compile.single → compiler.compileSingle
34
+ * compile.single → compiler.compileSingle (+ captured output)
35
+ * deploy.start / deploy.file / deploy.remove → deployer deployBuild/deployFile/removeDeployedFile
36
+ * rcon.send → rcon sendRcon
30
37
  * watch.start / watch.stop → watcher.startWatch (+ watch.changed notifications)
38
+ * serve.ping → process info (health check)
31
39
  */
32
40
 
33
41
  const fs = require('fs');
@@ -46,14 +54,23 @@ const { resolveManifestPath } = require('../manifest-path');
46
54
  const { resolveManifest, parseManifest, resolveGithubToken, parseDepString } = require('../manifest');
47
55
  const { validateManifestFile } = require('../validate');
48
56
  const { fetchDepIncludeDir, collectIncFiles, parseIncludeDirective, searchIncludeFile } = require('../include-tree');
49
- const { fetchCompiler, resolveAmxmodxVersion } = require('../compiler-fetcher');
57
+ const { depLabel } = require('../deps-resolver');
58
+ const { fetchCompiler, resolveAmxmodxVersion, getCompilerInfo } = require('../compiler-fetcher');
50
59
  const { buildDepTree, assembleRootDeps } = require('../deps-tree');
51
60
  const { listReleases, listTags } = require('../release-lister');
61
+ const {
62
+ getRepoInfo, listBranches, getRepoStructure,
63
+ GithubError, isValidRepo, validateRepoStructureOptions,
64
+ } = require('../github-api');
52
65
  const { getCacheInfo } = require('../cache-info');
53
66
  const { buildPlanData } = require('../build-plan');
54
67
  const { runBuild } = require('../build-service');
55
68
  const { compileSingle } = require('../compiler');
69
+ const { deployBuild, deployFile, removeDeployedFile } = require('../deployer');
70
+ const { sendRcon } = require('../rcon');
71
+ const { DepGraph } = require('../dep-graph');
56
72
  const { startWatch } = require('../watcher');
73
+ const pkg = require('../../package.json');
57
74
 
58
75
  // ─── Small interface helpers (no domain logic) ────────────────────────────────
59
76
 
@@ -86,6 +103,69 @@ function manifestPathFor(params) {
86
103
  return params?.manifest ? path.resolve(params.manifest) : resolveManifestPath().path;
87
104
  }
88
105
 
106
+ /**
107
+ * GitHub token for repo-scope methods. Manifest tokens win: the .env next to
108
+ * the manifest is loaded first (it overrides the cwd .env loaded at startup —
109
+ * per the env convention: manifest .env is primary, cwd is the fallback).
110
+ * Then the explicit `token` param, then the process env, then anonymous.
111
+ */
112
+ function resolveGithubTokenFor(params, repo) {
113
+ const manifestPath = manifestPathFor(params);
114
+ if (fs.existsSync(manifestPath)) {
115
+ loadEnvQuiet(manifestPath);
116
+ try {
117
+ const fromManifest = resolveGithubToken(parseManifest(manifestPath), repo);
118
+ if (fromManifest) return fromManifest;
119
+ } catch { /* unparseable manifest — fall back to explicit/env token */ }
120
+ }
121
+ return params?.token || process.env.GITHUB_TOKEN || null;
122
+ }
123
+
124
+ /**
125
+ * Shape a GitHub API error into the JSON-RPC error contract: -32603 with
126
+ * error.data = { status, repo, message }. Returns null for non-GitHub errors
127
+ * so the caller rethrows them unchanged.
128
+ */
129
+ function githubRpcError(err, repo) {
130
+ if (err instanceof GithubError) {
131
+ err.code = -32603;
132
+ err.data = { status: err.status, repo, message: err.message };
133
+ return err;
134
+ }
135
+ if (err && err.response && typeof err.response.status === 'number') {
136
+ // Raw axios error (release-lister path) — normalize to the same contract.
137
+ const message = (err.response.data && err.response.data.message) || err.message;
138
+ const shaped = new Error(message);
139
+ shaped.code = -32603;
140
+ shaped.data = { status: err.response.status, repo, message };
141
+ return shaped;
142
+ }
143
+ return null;
144
+ }
145
+
146
+ function repoParamError(params) {
147
+ if (!params?.repo) return 'Missing required "repo" field';
148
+ if (!isValidRepo(params.repo)) return 'Invalid "repo": expected "owner/repo"';
149
+ return null;
150
+ }
151
+
152
+ // dotenv@17 prints an "injected env" line to stdout by default, which would
153
+ // break the pure-JSON-RPC stdout contract — always load quietly here.
154
+ function loadEnvQuiet(manifestPath) {
155
+ loadEnv(manifestPath, { quiet: true });
156
+ }
157
+
158
+ // Full manifest (defaults merged, set/define applied) for deploy methods.
159
+ function deployRequestManifest(params) {
160
+ const manifestPath = manifestPathFor(params);
161
+ loadEnvQuiet(manifestPath);
162
+ return resolveManifest(manifestPath, { set: params?.set, define: params?.define });
163
+ }
164
+
165
+ function buildDirFor(params) {
166
+ return params?.buildDir ? path.resolve(params.buildDir) : path.join(process.cwd(), 'build');
167
+ }
168
+
89
169
  /**
90
170
  * Create and configure the JSON-RPC server with all methods wired to core.
91
171
  * Does NOT connect or set up the environment — call runServe() for that.
@@ -97,6 +177,15 @@ function createServeServer() {
97
177
  let activeBuild = null; // AbortController for the running build
98
178
  let activeWatcher = null; // chokidar watcher instance
99
179
 
180
+ // ─── Health check ────────────────────────────────────────────────────────
181
+
182
+ server.onRequest('serve.ping', () => ({
183
+ ok: true,
184
+ pid: process.pid,
185
+ version: pkg.version,
186
+ node: process.version,
187
+ }));
188
+
100
189
  // ─── Read-only: manifest ──────────────────────────────────────────────────
101
190
 
102
191
  server.onRequest('manifest.validate', (params) => {
@@ -105,7 +194,7 @@ function createServeServer() {
105
194
 
106
195
  server.onRequest('manifest.resolve', (params) => {
107
196
  const manifestPath = manifestPathFor(params);
108
- loadEnv(manifestPath);
197
+ loadEnvQuiet(manifestPath);
109
198
  return resolveManifest(manifestPath, { set: params?.set, define: params?.define });
110
199
  });
111
200
 
@@ -138,6 +227,7 @@ function createServeServer() {
138
227
  let manifest = null;
139
228
  const manifestPath = manifestPathFor(params);
140
229
  if (fs.existsSync(manifestPath)) {
230
+ loadEnvQuiet(manifestPath);
141
231
  try {
142
232
  manifest = parseManifest(manifestPath);
143
233
  for (const dep of manifest.globalDeps) {
@@ -146,9 +236,9 @@ function createServeServer() {
146
236
  dep, resolveGithubToken(manifest, dep.repo),
147
237
  params?.noFetch === true, manifest.github.ssh
148
238
  );
149
- searchPaths.push({ path: depDir, label: `${dep.repo}@${dep.ref}` });
239
+ searchPaths.push({ path: depDir, label: depLabel(dep) });
150
240
  } catch (err) {
151
- errors.push(`${dep.repo}@${dep.ref}: ${err.message}`);
241
+ errors.push(`${depLabel(dep)}: ${err.message}`);
152
242
  }
153
243
  }
154
244
  } catch (err) {
@@ -186,10 +276,14 @@ function createServeServer() {
186
276
  err.code = -32602;
187
277
  throw err;
188
278
  }
279
+ loadEnvQuiet(manifestPath);
189
280
  const manifest = parseManifest(manifestPath);
190
281
 
191
282
  const deps = [];
192
283
  for (const dep of manifest.globalDeps) {
284
+ const base = dep.source === 'fungun'
285
+ ? { source: 'fungun', id: dep.id, url: dep.url }
286
+ : { source: dep.source || 'git', repo: dep.repo, ref: dep.ref };
193
287
  try {
194
288
  const includeDir = await fetchDepIncludeDir(
195
289
  dep, resolveGithubToken(manifest, dep.repo),
@@ -197,15 +291,14 @@ function createServeServer() {
197
291
  );
198
292
  const files = await collectIncFiles(includeDir);
199
293
  deps.push({
200
- repo: dep.repo,
201
- ref: dep.ref,
294
+ ...base,
202
295
  include_path: dep.include_path || null,
203
296
  include_dir: includeDir,
204
297
  count: files.length,
205
298
  files: files.map((f) => ({ rel: f.rel, abs: f.abs })),
206
299
  });
207
300
  } catch (err) {
208
- deps.push({ repo: dep.repo, ref: dep.ref, error: err.message, files: [], count: 0 });
301
+ deps.push({ ...base, error: err.message, files: [], count: 0 });
209
302
  }
210
303
  }
211
304
  return { manifest: manifestPath, deps };
@@ -266,7 +359,7 @@ function createServeServer() {
266
359
  }
267
360
 
268
361
  const manifestPath = manifestPathFor(params);
269
- loadEnv(manifestPath);
362
+ loadEnvQuiet(manifestPath);
270
363
  const manifest = parseManifest(manifestPath);
271
364
  const assembled = assembleRootDeps(manifest);
272
365
  return buildDepTree(assembled.rootDeps, {
@@ -279,18 +372,141 @@ function createServeServer() {
279
372
  });
280
373
  });
281
374
 
282
- // ─── Releases / cache / plan ─────────────────────────────────────────────
375
+ // ─── Include dependency graph ────────────────────────────────────────────
376
+
377
+ server.onRequest('dep-graph.get', async (params) => {
378
+ if (!params?.sma_file) {
379
+ const err = new Error('Missing required "sma_file" parameter');
380
+ err.code = -32602;
381
+ throw err;
382
+ }
383
+ const smaPath = path.resolve(params.sma_file);
384
+ if (!fs.existsSync(smaPath)) {
385
+ const err = new Error(`File not found: ${smaPath}`);
386
+ err.code = -32602;
387
+ throw err;
388
+ }
389
+
390
+ const noFetch = params?.noFetch === true;
391
+
392
+ const manifestPath = manifestPathFor(params);
393
+ let manifest = null;
394
+ if (fs.existsSync(manifestPath)) {
395
+ loadEnvQuiet(manifestPath);
396
+ try { manifest = parseManifest(manifestPath); } catch { manifest = null; }
397
+ }
398
+
399
+ const version = await resolveVersionFromParams(params);
400
+ const { includeDir } = await fetchCompiler(version);
401
+
402
+ // Dep includes come BEFORE the stdlib — matching the real build's search order.
403
+ const includeDirs = [];
404
+ if (manifest) {
405
+ for (const dep of manifest.globalDeps) {
406
+ try {
407
+ includeDirs.push(await fetchDepIncludeDir(
408
+ dep, resolveGithubToken(manifest, dep.repo), noFetch, manifest.github.ssh
409
+ ));
410
+ } catch (err) { /* keep the rest of the dirs on partial failure */ }
411
+ }
412
+ }
413
+ if (includeDir) includeDirs.push(includeDir);
414
+ for (const d of (params?.include_dirs || [])) includeDirs.push(path.resolve(d));
415
+
416
+ const graph = new DepGraph(includeDirs);
417
+ graph.parseFile(smaPath);
418
+
419
+ const result = {
420
+ sma_file: smaPath,
421
+ version,
422
+ include_dirs: includeDirs,
423
+ ...graph.snapshot(),
424
+ };
425
+
426
+ // Reverse query: which .sma files transitively depend on this .inc?
427
+ if (params?.inc) {
428
+ const incAbs = path.resolve(params.inc);
429
+ result.smas_depending_on = [...graph.getSmasDependingOn(incAbs)].sort();
430
+ }
431
+
432
+ return result;
433
+ });
434
+
435
+ // ─── Releases / repos / cache / plan ─────────────────────────────────────
283
436
 
284
437
  server.onRequest('releases.list', async (params) => {
285
- if (!params?.repo) {
286
- const err = new Error('Missing required "repo" field');
438
+ const invalid = repoParamError(params);
439
+ if (invalid) {
440
+ const err = new Error(invalid);
287
441
  err.code = -32602;
288
442
  throw err;
289
443
  }
290
- const token = params?.token || process.env.GITHUB_TOKEN || null;
444
+ const repo = params.repo;
445
+ const token = resolveGithubTokenFor(params, repo);
291
446
  const limit = params?.limit || 10;
292
- if (params?.tags) return listTags(params.repo, { token, limit });
293
- return listReleases(params.repo, { token, limit, includeAssets: params?.includeAssets });
447
+ try {
448
+ if (params?.tags) return await listTags(repo, { token, limit });
449
+ return await listReleases(repo, { token, limit, includeAssets: params?.includeAssets });
450
+ } catch (err) {
451
+ throw githubRpcError(err, repo) || err;
452
+ }
453
+ });
454
+
455
+ server.onRequest('repos.info', async (params) => {
456
+ const invalid = repoParamError(params);
457
+ if (invalid) {
458
+ const err = new Error(invalid);
459
+ err.code = -32602;
460
+ throw err;
461
+ }
462
+ const repo = params.repo;
463
+ const token = resolveGithubTokenFor(params, repo);
464
+ try {
465
+ return await getRepoInfo(repo, { token });
466
+ } catch (err) {
467
+ throw githubRpcError(err, repo) || err;
468
+ }
469
+ });
470
+
471
+ server.onRequest('repos.branches', async (params) => {
472
+ const invalid = repoParamError(params);
473
+ if (invalid) {
474
+ const err = new Error(invalid);
475
+ err.code = -32602;
476
+ throw err;
477
+ }
478
+ const repo = params.repo;
479
+ const token = resolveGithubTokenFor(params, repo);
480
+ const limit = Math.min(100, Math.max(1, params?.limit ?? 10));
481
+ const page = Math.max(1, params?.page ?? 1);
482
+ try {
483
+ return await listBranches(repo, { token, limit, page });
484
+ } catch (err) {
485
+ throw githubRpcError(err, repo) || err;
486
+ }
487
+ });
488
+
489
+ server.onRequest('repos.structure', async (params) => {
490
+ const invalid = repoParamError(params) || validateRepoStructureOptions(params);
491
+ if (invalid) {
492
+ const err = new Error(invalid);
493
+ err.code = -32602;
494
+ throw err;
495
+ }
496
+ const repo = params.repo;
497
+ const token = resolveGithubTokenFor(params, repo);
498
+ try {
499
+ return await getRepoStructure(repo, {
500
+ token,
501
+ ref: params?.ref || null,
502
+ depth: params?.depth,
503
+ dirsOnly: params?.dirsOnly === true,
504
+ ext: params?.ext,
505
+ maxEntries: params?.maxEntries,
506
+ });
507
+ } catch (err) {
508
+ throw githubRpcError(err, repo) || err;
509
+ }
294
510
  });
295
511
 
296
512
  server.onRequest('cache.info', (params) => {
@@ -298,6 +514,11 @@ function createServeServer() {
298
514
  return getCacheInfo(manifestPath);
299
515
  });
300
516
 
517
+ server.onRequest('compiler.info', async (params) => {
518
+ const version = await resolveVersionFromParams(params);
519
+ return getCompilerInfo(version, { noFetch: params?.noFetch === true });
520
+ });
521
+
301
522
  server.onRequest('build.plan', (params) => {
302
523
  const manifestPath = manifestPathFor(params);
303
524
  loadEnv(manifestPath, { quiet: true, override: false });
@@ -321,7 +542,7 @@ function createServeServer() {
321
542
  activeBuild = controller;
322
543
 
323
544
  const manifestPath = manifestPathFor(params);
324
- loadEnv(manifestPath);
545
+ loadEnvQuiet(manifestPath);
325
546
  const manifest = resolveManifest(manifestPath, { set: params?.set, define: params?.define });
326
547
 
327
548
  // Forward core lifecycle events as server→client notifications while the
@@ -361,6 +582,85 @@ function createServeServer() {
361
582
  return { ok: true };
362
583
  });
363
584
 
585
+ // ─── Deploy ──────────────────────────────────────────────────────────────
586
+
587
+ server.onRequest('deploy.start', async (params) => {
588
+ const manifest = deployRequestManifest(params);
589
+ if (!manifest.deploy || !manifest.deploy.path) {
590
+ return { ok: false, message: 'Deploy path not configured.\n → Set AMXB_DEPLOY_PATH in .env, or add deploy.path to your manifest' };
591
+ }
592
+ const copied = await deployBuild(manifest, buildDirFor(params), {
593
+ incremental: params?.incremental === true,
594
+ });
595
+ return { ok: true, copied };
596
+ });
597
+
598
+ server.onRequest('deploy.file', (params) => {
599
+ if (!params?.relPath) {
600
+ const err = new Error('Missing required "relPath" parameter');
601
+ err.code = -32602;
602
+ throw err;
603
+ }
604
+ const section = params?.section === 'assets' ? 'assets' : 'amxmodx';
605
+ const manifest = deployRequestManifest(params);
606
+ if (!manifest.deploy || !manifest.deploy.path) {
607
+ return { ok: false, message: 'Deploy path not configured' };
608
+ }
609
+ const dest = deployFile(manifest, buildDirFor(params), params.relPath, section);
610
+ return { ok: dest != null, dest: dest || null };
611
+ });
612
+
613
+ server.onRequest('deploy.remove', (params) => {
614
+ if (!params?.relPath) {
615
+ const err = new Error('Missing required "relPath" parameter');
616
+ err.code = -32602;
617
+ throw err;
618
+ }
619
+ const section = params?.section === 'assets' ? 'assets' : 'amxmodx';
620
+ const manifest = deployRequestManifest(params);
621
+ if (!manifest.deploy || !manifest.deploy.path) {
622
+ return { ok: false, message: 'Deploy path not configured' };
623
+ }
624
+ const dest = removeDeployedFile(manifest, buildDirFor(params), params.relPath, section);
625
+ return { ok: dest != null, dest: dest || null };
626
+ });
627
+
628
+ // ─── RCON ────────────────────────────────────────────────────────────────
629
+
630
+ server.onRequest('rcon.send', async (params) => {
631
+ if (!params?.command) {
632
+ const err = new Error('Missing required "command" parameter');
633
+ err.code = -32602;
634
+ throw err;
635
+ }
636
+
637
+ let host = params?.host;
638
+ let port = params?.port;
639
+ let password = params?.password;
640
+
641
+ // Fall back to the manifest's deploy.rcon config when host/password are omitted.
642
+ if ((host == null || password == null) && fs.existsSync(manifestPathFor(params))) {
643
+ try {
644
+ const rconCfg = parseManifest(manifestPathFor(params)).deploy?.rcon;
645
+ if (rconCfg) {
646
+ if (host == null) host = rconCfg.host;
647
+ if (port == null) port = rconCfg.port;
648
+ if (password == null) password = rconCfg.password;
649
+ }
650
+ } catch { /* unparseable manifest — explicit params only */ }
651
+ }
652
+ if (port == null) port = 27015;
653
+
654
+ if (!host || !password) {
655
+ const err = new Error('RCON host/password not provided (pass explicitly or configure deploy.rcon in the manifest)');
656
+ err.code = -32602;
657
+ throw err;
658
+ }
659
+
660
+ const response = await sendRcon({ host, port, password, command: params.command });
661
+ return { ok: true, response };
662
+ });
663
+
364
664
  // ─── Single-file compile ─────────────────────────────────────────────────
365
665
 
366
666
  server.onRequest('compile.single', async (params) => {
@@ -381,6 +681,7 @@ function createServeServer() {
381
681
  const manifestPath = manifestPathFor(params);
382
682
  let manifest = null;
383
683
  if (fs.existsSync(manifestPath)) {
684
+ loadEnvQuiet(manifestPath);
384
685
  try { manifest = parseManifest(manifestPath); } catch { manifest = null; }
385
686
  }
386
687
 
@@ -397,7 +698,7 @@ function createServeServer() {
397
698
  dep, resolveGithubToken(manifest, dep.repo), noFetch, manifest.github.ssh
398
699
  ));
399
700
  } catch (err) {
400
- depErrors.push(`${dep.repo}@${dep.ref}: ${err.message}`);
701
+ depErrors.push(`${depLabel(dep)}: ${err.message}`);
401
702
  }
402
703
  }
403
704
  }
@@ -408,18 +709,30 @@ function createServeServer() {
408
709
 
409
710
  const buildDir = path.join(os.tmpdir(), 'amxb-serve-compile');
410
711
  const compileManifest = manifest || { amxmodx: { defines: [] } };
411
- const amxxName = await compileSingle(
412
- compileManifest,
413
- smaPath,
414
- compilerPath,
415
- includeDirs,
416
- buildDir,
417
- params?.scripting_root ? path.resolve(params.scripting_root) : undefined
418
- );
712
+
713
+ // Capture the compiler output emitted as an event by compileSingle.
714
+ const baseName = path.basename(smaPath);
715
+ let compiled = null;
716
+ const onCompiled = (p) => { if (p.baseName === baseName) compiled = p; };
717
+ on(EVENTS.COMPILED, onCompiled);
718
+ let amxxName;
719
+ try {
720
+ amxxName = await compileSingle(
721
+ compileManifest,
722
+ smaPath,
723
+ compilerPath,
724
+ includeDirs,
725
+ buildDir,
726
+ params?.scripting_root ? path.resolve(params.scripting_root) : undefined
727
+ );
728
+ } finally {
729
+ off(EVENTS.COMPILED, onCompiled);
730
+ }
419
731
 
420
732
  return {
421
733
  ok: amxxName != null,
422
734
  amxxName,
735
+ output: compiled ? compiled.output : undefined,
423
736
  output_path: amxxName ? path.join(buildDir, 'amxmodx', 'plugins', amxxName) : null,
424
737
  dep_errors: depErrors.length ? depErrors : undefined,
425
738
  };
@@ -65,6 +65,39 @@ async function fetchCompiler(version, options = {}) {
65
65
  return { compilerPath: binaryPath, includeDir: fs.existsSync(includeDir) ? includeDir : null };
66
66
  }
67
67
 
68
+ /**
69
+ * Compiler info without necessarily downloading. With noFetch the report only
70
+ * reflects what is already cached; otherwise the compiler is ensured to be
71
+ * present (downloaded on first use), exactly like fetchCompiler.
72
+ *
73
+ * @param {string} [version] - explicit version (else resolved like the CLI: manifest → latest)
74
+ * @param {object} [options]
75
+ * @param {boolean} [options.noFetch] - don't download; report cache state only
76
+ * @returns {Promise<{version: string, platform: string, compilerPath: string|null, includeDir: string|null, cached: boolean}>}
77
+ */
78
+ async function getCompilerInfo(version, options = {}) {
79
+ const { noFetch = false } = options;
80
+ const resolved = version || await fetchLatestVersion({ noFetch });
81
+ const platform = getPlatform();
82
+ const cacheDir = path.join(getCacheDir(), 'amxxpc', resolved, platform);
83
+ const binaryName = platform === 'windows' ? 'amxxpc.exe' : 'amxxpc';
84
+ const compilerPath = path.join(cacheDir, binaryName);
85
+ const includeDir = path.join(cacheDir, 'include');
86
+
87
+ if (!fs.existsSync(compilerPath) && !noFetch) {
88
+ await fetchCompiler(resolved);
89
+ }
90
+
91
+ const cached = fs.existsSync(compilerPath);
92
+ return {
93
+ version: resolved,
94
+ platform,
95
+ compilerPath: cached ? compilerPath : null,
96
+ includeDir: fs.existsSync(includeDir) ? includeDir : null,
97
+ cached,
98
+ };
99
+ }
100
+
68
101
  // "1.10.5428" → { major: '1', minor: '10', build: '5428' }
69
102
  function parseVersion(versionStr) {
70
103
  const parts = String(versionStr).split('.');
@@ -324,4 +357,4 @@ function findDir(root, name) {
324
357
  return null;
325
358
  }
326
359
 
327
- module.exports = { fetchCompiler, getAmxmodxFullDir, getHostPlatform, fetchLatestVersion, resolveAmxmodxVersion };
360
+ module.exports = { fetchCompiler, getCompilerInfo, getAmxmodxFullDir, getHostPlatform, fetchLatestVersion, resolveAmxmodxVersion };