amxx-builder 1.5.2 → 1.6.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.
@@ -16,18 +16,29 @@
16
16
  * Method table:
17
17
  * manifest.validate → validate.manifestFile
18
18
  * manifest.resolve → env.loadEnv + manifest.resolveManifest
19
+ * manifest.dep → agent-assets readDepManifest + parseDocEntries/parseSkillEntries
20
+ * docs.list / docs.get → agent-assets collectDepAssets / collectLocalAssets
21
+ * skills.list / skills.get → agent-assets collectDepAssets / collectLocalAssets
19
22
  * include.resolve → include-tree parseIncludeDirective + searchIncludeFile
20
- * include.list → include-tree fetchDepIncludeDir + collectIncFiles
23
+ * include.list → deps-resolver collectDepIncludeDirs + include-tree collectIncFiles
21
24
  * amxmodx.includes.list → compiler-fetcher fetchCompiler + glob
22
25
  * amxmodx.include.get → compiler-fetcher fetchCompiler + glob + read
23
26
  * deps.tree → deps-tree buildDepTree + assembleRootDeps
24
27
  * releases.list → release-lister listReleases / listTags
28
+ * repos.info → github-api getRepoInfo
29
+ * repos.branches → github-api listBranches
30
+ * repos.structure → github-api getRepoStructure
25
31
  * cache.info → cache-info getCacheInfo
32
+ * compiler.info → compiler-fetcher getCompilerInfo
33
+ * dep-graph.get → dep-graph DepGraph
26
34
  * build.plan → build-plan buildPlanData
27
35
  * build.start → build-service runBuild (+ event notifications)
28
36
  * build.cancel → abort the running build (AbortController)
29
- * compile.single → compiler.compileSingle
37
+ * compile.single → compiler.compileSingle (+ captured output)
38
+ * deploy.start / deploy.file / deploy.remove → deployer deployBuild/deployFile/removeDeployedFile
39
+ * rcon.send → rcon sendRcon
30
40
  * watch.start / watch.stop → watcher.startWatch (+ watch.changed notifications)
41
+ * serve.ping → process info (health check)
31
42
  */
32
43
 
33
44
  const fs = require('fs');
@@ -43,17 +54,27 @@ const { on, off, EVENTS } = require('../events');
43
54
 
44
55
  const { loadEnv } = require('../env');
45
56
  const { resolveManifestPath } = require('../manifest-path');
46
- const { resolveManifest, parseManifest, resolveGithubToken, parseDepString } = require('../manifest');
57
+ const { resolveManifest, parseManifest, resolveGithubToken, parseDepString, parseDocEntries, parseSkillEntries } = require('../manifest');
58
+ const { readDepManifest, collectDepAssets, collectLocalAssets } = require('../agent-assets');
47
59
  const { validateManifestFile } = require('../validate');
48
- const { fetchDepIncludeDir, collectIncFiles, parseIncludeDirective, searchIncludeFile } = require('../include-tree');
49
- const { fetchCompiler, resolveAmxmodxVersion } = require('../compiler-fetcher');
60
+ const { collectIncFiles, parseIncludeDirective, searchIncludeFile } = require('../include-tree');
61
+ const { depLabel, collectDepIncludeDirs } = require('../deps-resolver');
62
+ const { fetchCompiler, resolveAmxmodxVersion, getCompilerInfo, getHostPlatform, resolveStdlibVersion } = require('../compiler-fetcher');
50
63
  const { buildDepTree, assembleRootDeps } = require('../deps-tree');
51
64
  const { listReleases, listTags } = require('../release-lister');
65
+ const {
66
+ getRepoInfo, listBranches, getRepoStructure,
67
+ GithubError, isValidRepo, validateRepoStructureOptions,
68
+ } = require('../github-api');
52
69
  const { getCacheInfo } = require('../cache-info');
53
70
  const { buildPlanData } = require('../build-plan');
54
71
  const { runBuild } = require('../build-service');
55
72
  const { compileSingle } = require('../compiler');
73
+ const { deployBuild, deployFile, removeDeployedFile } = require('../deployer');
74
+ const { sendRcon } = require('../rcon');
75
+ const { DepGraph } = require('../dep-graph');
56
76
  const { startWatch } = require('../watcher');
77
+ const pkg = require('../../package.json');
57
78
 
58
79
  // ─── Small interface helpers (no domain logic) ────────────────────────────────
59
80
 
@@ -66,26 +87,175 @@ function readFileSafe(absPath) {
66
87
  }
67
88
  }
68
89
 
69
- // Resolve the AMX Mod X version for a request: explicit `version` arg wins,
70
- // then the project manifest's amxmodx.version, then latest. Priority logic
71
- // lives in core (compiler-fetcher.resolveAmxmodxVersion).
90
+ // JSON-RPC params spell no-fetch either `noFetch` (serve convention) or
91
+ // `no_fetch` (snake_case convention) — normalize once here.
92
+ function noFetchParam(params) {
93
+ return params?.noFetch === true || params?.no_fetch === true;
94
+ }
95
+
96
+ // Resolve a concrete compiler version string for handlers that go on to call
97
+ // fetchCompiler() (dep-graph.get, compile.single). Explicit `version` arg wins,
98
+ // then the project manifest's amxmodx.version, then latest — priority lives in
99
+ // core (compiler-fetcher.resolveAmxmodxVersion). An invalid explicit version is
100
+ // a JSON-RPC param error (-32602); everything else (incl. LATEST_NOT_CACHED)
101
+ // propagates — these handlers fetch a real compiler, so a graceful empty state
102
+ // is meaningless here.
72
103
  async function resolveVersionFromParams(params) {
73
- if (params?.version) return resolveAmxmodxVersion(null, { version: params.version });
104
+ try {
105
+ if (params?.version) {
106
+ // await is required: a returned non-awaited promise bypasses this
107
+ // try/catch, so INVALID_AMXMODX_VERSION would never map to -32602.
108
+ return await resolveAmxmodxVersion(null, { version: params.version, noFetch: noFetchParam(params) });
109
+ }
110
+
111
+ const manifestPath = params?.manifest
112
+ ? path.resolve(params.manifest)
113
+ : resolveManifestPath().path;
114
+ let manifest = null;
115
+ if (fs.existsSync(manifestPath)) {
116
+ try { manifest = parseManifest(manifestPath); } catch { manifest = null; }
117
+ }
118
+ return await resolveAmxmodxVersion(manifest, { noFetch: noFetchParam(params) });
119
+ } catch (err) {
120
+ if (err && err.code === 'INVALID_AMXMODX_VERSION') {
121
+ err.code = -32602;
122
+ throw err;
123
+ }
124
+ throw err;
125
+ }
126
+ }
74
127
 
75
- const manifestPath = params?.manifest
76
- ? path.resolve(params.manifest)
77
- : resolveManifestPath().path;
128
+ // Informational stdlib lookup for manifest-less / offline clients. Never
129
+ // downloads when noFetch; if latest cannot be resolved offline, degrades to
130
+ // the newest cached compiler instead of failing (degraded: true). Nothing
131
+ // cached at all → graceful empty state (version: null), not an error.
132
+ // Version-priority + offline degradation live in core
133
+ // (compiler-fetcher.resolveStdlibVersion); this wrapper only does arg/manifest
134
+ // discovery, the INVALID→-32602 remap and getCompilerInfo state shaping.
135
+ async function resolveStdlibState(params) {
136
+ const noFetch = noFetchParam(params);
78
137
  let manifest = null;
79
- if (fs.existsSync(manifestPath)) {
80
- try { manifest = parseManifest(manifestPath); } catch { manifest = null; }
138
+ if (!params?.version) {
139
+ const manifestPath = manifestPathFor(params);
140
+ if (fs.existsSync(manifestPath)) {
141
+ try { manifest = parseManifest(manifestPath); } catch { manifest = null; }
142
+ }
143
+ }
144
+ const { version, degraded, error } = await resolveStdlibVersion({
145
+ version: params?.version,
146
+ manifest,
147
+ noFetch,
148
+ });
149
+ if (error) {
150
+ if (error.code === 'INVALID_AMXMODX_VERSION') error.code = -32602;
151
+ throw error;
152
+ }
153
+ if (version === null) {
154
+ return { version: null, degraded: false, platform: getHostPlatform(), compilerPath: null, includeDir: null, cached: false };
81
155
  }
82
- return resolveAmxmodxVersion(manifest, { noFetch: params?.noFetch === true });
156
+ const info = await getCompilerInfo(version, { noFetch });
157
+ return { version: info.version, degraded, platform: info.platform, compilerPath: info.compilerPath, includeDir: info.includeDir, cached: info.cached };
83
158
  }
84
159
 
85
160
  function manifestPathFor(params) {
86
161
  return params?.manifest ? path.resolve(params.manifest) : resolveManifestPath().path;
87
162
  }
88
163
 
164
+ /**
165
+ * GitHub token for repo-scope methods. Manifest tokens win: the .env next to
166
+ * the manifest is loaded first (it overrides the cwd .env loaded at startup —
167
+ * per the env convention: manifest .env is primary, cwd is the fallback).
168
+ * Then the explicit `token` param, then the process env, then anonymous.
169
+ */
170
+ function resolveGithubTokenFor(params, repo) {
171
+ const manifestPath = manifestPathFor(params);
172
+ if (fs.existsSync(manifestPath)) {
173
+ loadEnvQuiet(manifestPath);
174
+ try {
175
+ const fromManifest = resolveGithubToken(parseManifest(manifestPath), repo);
176
+ if (fromManifest) return fromManifest;
177
+ } catch { /* unparseable manifest — fall back to explicit/env token */ }
178
+ }
179
+ return params?.token || process.env.GITHUB_TOKEN || null;
180
+ }
181
+
182
+ // Build a parsed dep object from params: either a full `dep` string/object or
183
+ // explicit { repo, ref?, source?, include_path?, asset? } fields.
184
+ function depFromParams(params) {
185
+ let dep;
186
+ if (params?.dep) {
187
+ dep = typeof params.dep === 'string' ? parseDepString(params.dep) : { ...params.dep };
188
+ } else {
189
+ if (!params?.repo) throw new Error('Provide either "dep" or "repo"');
190
+ const source = params.source || 'git';
191
+ const ref = params.ref || (source === 'release' ? 'latest' : null);
192
+ dep = { repo: params.repo, ref, source, include_path: params.include_path || null, asset: params.asset ?? null };
193
+ }
194
+ if (params?.source) dep.source = params.source;
195
+ if (params?.include_path) dep.include_path = params.include_path;
196
+ if (params?.asset != null) dep.asset = params.asset;
197
+ return dep;
198
+ }
199
+
200
+ // Agent docs/skills: dep mode when dep/repo is present, otherwise the local
201
+ // project's own manifest.
202
+ async function collectAgentAssets(params) {
203
+ const noFetch = noFetchParam(params);
204
+ if (params?.dep || params?.repo) {
205
+ const dep = depFromParams(params);
206
+ const token = resolveGithubTokenFor(params, dep.repo);
207
+ return collectDepAssets(dep, { token, noFetch });
208
+ }
209
+ const manifestPath = manifestPathFor(params);
210
+ loadEnvQuiet(manifestPath);
211
+ return collectLocalAssets(parseManifest(manifestPath));
212
+ }
213
+
214
+ /**
215
+ * Shape a GitHub API error into the JSON-RPC error contract: -32603 with
216
+ * error.data = { status, repo, message }. Returns null for non-GitHub errors
217
+ * so the caller rethrows them unchanged.
218
+ */
219
+ function githubRpcError(err, repo) {
220
+ if (err instanceof GithubError) {
221
+ err.code = -32603;
222
+ err.data = { status: err.status, repo, message: err.message };
223
+ return err;
224
+ }
225
+ if (err && err.response && typeof err.response.status === 'number') {
226
+ // Raw axios error (release-lister path) — normalize to the same contract.
227
+ const message = (err.response.data && err.response.data.message) || err.message;
228
+ const shaped = new Error(message);
229
+ shaped.code = -32603;
230
+ shaped.data = { status: err.response.status, repo, message };
231
+ return shaped;
232
+ }
233
+ return null;
234
+ }
235
+
236
+ function repoParamError(params) {
237
+ if (!params?.repo) return 'Missing required "repo" field';
238
+ if (!isValidRepo(params.repo)) return 'Invalid "repo": expected "owner/repo"';
239
+ return null;
240
+ }
241
+
242
+ // dotenv@17 prints an "injected env" line to stdout by default, which would
243
+ // break the pure-JSON-RPC stdout contract — always load quietly here.
244
+ function loadEnvQuiet(manifestPath) {
245
+ loadEnv(manifestPath, { quiet: true });
246
+ }
247
+
248
+ // Full manifest (defaults merged, set/define applied) for deploy methods.
249
+ function deployRequestManifest(params) {
250
+ const manifestPath = manifestPathFor(params);
251
+ loadEnvQuiet(manifestPath);
252
+ return resolveManifest(manifestPath, { set: params?.set, define: params?.define });
253
+ }
254
+
255
+ function buildDirFor(params) {
256
+ return params?.buildDir ? path.resolve(params.buildDir) : path.join(process.cwd(), 'build');
257
+ }
258
+
89
259
  /**
90
260
  * Create and configure the JSON-RPC server with all methods wired to core.
91
261
  * Does NOT connect or set up the environment — call runServe() for that.
@@ -97,6 +267,15 @@ function createServeServer() {
97
267
  let activeBuild = null; // AbortController for the running build
98
268
  let activeWatcher = null; // chokidar watcher instance
99
269
 
270
+ // ─── Health check ────────────────────────────────────────────────────────
271
+
272
+ server.onRequest('serve.ping', () => ({
273
+ ok: true,
274
+ pid: process.pid,
275
+ version: pkg.version,
276
+ node: process.version,
277
+ }));
278
+
100
279
  // ─── Read-only: manifest ──────────────────────────────────────────────────
101
280
 
102
281
  server.onRequest('manifest.validate', (params) => {
@@ -105,10 +284,93 @@ function createServeServer() {
105
284
 
106
285
  server.onRequest('manifest.resolve', (params) => {
107
286
  const manifestPath = manifestPathFor(params);
108
- loadEnv(manifestPath);
287
+ loadEnvQuiet(manifestPath);
109
288
  return resolveManifest(manifestPath, { set: params?.set, define: params?.define });
110
289
  });
111
290
 
291
+ // ─── Dependency manifest + agent assets ──────────────────────────────────
292
+
293
+ server.onRequest('manifest.dep', async (params) => {
294
+ let dep;
295
+ try {
296
+ dep = depFromParams(params);
297
+ } catch (err) {
298
+ err.code = -32602;
299
+ throw err;
300
+ }
301
+ const token = resolveGithubTokenFor(params, dep.repo);
302
+ const m = await readDepManifest(dep, { token, noFetch: noFetchParam(params) });
303
+
304
+ let docs = [];
305
+ let skills = [];
306
+ try { docs = parseDocEntries(m.raw?.docs || []); } catch { docs = []; }
307
+ try { skills = parseSkillEntries(m.raw?.skills || []); } catch { skills = []; }
308
+
309
+ return {
310
+ label: m.label,
311
+ manifestPath: m.manifestPath,
312
+ manifestName: m.raw?.name || null,
313
+ raw: m.raw,
314
+ docs: docs.map((d) => ({ name: d.name, description: d.description, file: d.file })),
315
+ skills: skills.map((s) => ({ name: s.name, description: s.description, file: s.file, dir: s.dir })),
316
+ };
317
+ });
318
+
319
+ server.onRequest('docs.list', async (params) => {
320
+ const assets = await collectAgentAssets(params);
321
+ return {
322
+ label: assets.label || null,
323
+ docs: assets.docs.map((d) => ({ name: d.name, description: d.description, file: d.file })),
324
+ missing: assets.missing,
325
+ };
326
+ });
327
+
328
+ server.onRequest('docs.get', async (params) => {
329
+ const assets = await collectAgentAssets(params);
330
+ let docs = assets.docs;
331
+ if (params?.name || params?.file) {
332
+ docs = docs.filter(
333
+ (d) => (params.name && d.name === params.name) || (params.file && d.file === params.file)
334
+ );
335
+ }
336
+ return {
337
+ label: assets.label || null,
338
+ docs: docs.map((d) => ({ name: d.name, description: d.description, file: d.file, content: d.content })),
339
+ missing: assets.missing,
340
+ };
341
+ });
342
+
343
+ server.onRequest('skills.list', async (params) => {
344
+ const assets = await collectAgentAssets(params);
345
+ return {
346
+ label: assets.label || null,
347
+ skills: assets.skills.map((s) => ({
348
+ name: s.name,
349
+ description: s.description,
350
+ kind: s.kind,
351
+ file: s.file,
352
+ dir: s.dir,
353
+ files: s.kind === 'dir' ? s.files.map((f) => f.rel) : undefined,
354
+ })),
355
+ missing: assets.missing,
356
+ };
357
+ });
358
+
359
+ server.onRequest('skills.get', async (params) => {
360
+ const assets = await collectAgentAssets(params);
361
+ let skills = assets.skills;
362
+ if (params?.name) skills = skills.filter((s) => s.name === params.name);
363
+ return {
364
+ label: assets.label || null,
365
+ skills: skills.map((s) => (
366
+ s.kind === 'dir'
367
+ ? { name: s.name, description: s.description, kind: 'dir', dir: s.dir, files: s.files.map((f) => ({ rel: f.rel, content: f.content })) }
368
+ : { name: s.name, description: s.description, kind: 'file', file: s.file, content: s.content }
369
+ )),
370
+ missing: assets.missing,
371
+ };
372
+ });
373
+
112
374
  // ─── Include resolution ──────────────────────────────────────────────────
113
375
 
114
376
  server.onRequest('include.resolve', async (params) => {
@@ -138,27 +400,26 @@ function createServeServer() {
138
400
  let manifest = null;
139
401
  const manifestPath = manifestPathFor(params);
140
402
  if (fs.existsSync(manifestPath)) {
403
+ loadEnvQuiet(manifestPath);
141
404
  try {
142
405
  manifest = parseManifest(manifestPath);
143
- for (const dep of manifest.globalDeps) {
144
- try {
145
- const depDir = await fetchDepIncludeDir(
146
- dep, resolveGithubToken(manifest, dep.repo),
147
- params?.noFetch === true, manifest.github.ssh
148
- );
149
- searchPaths.push({ path: depDir, label: `${dep.repo}@${dep.ref}` });
150
- } catch (err) {
151
- errors.push(`${dep.repo}@${dep.ref}: ${err.message}`);
152
- }
153
- }
154
406
  } catch (err) {
155
407
  errors.push(`manifest ${manifestPath}: ${err.message}`);
156
408
  }
157
409
  }
410
+ if (manifest) {
411
+ const { dirs, errors: depErrors } = await collectDepIncludeDirs(manifest, {
412
+ noFetch: noFetchParam(params),
413
+ ssh: manifest.github.ssh,
414
+ });
415
+ manifest.globalDeps.forEach((dep, i) => {
416
+ if (dirs[i]) searchPaths.push({ path: dirs[i], label: depLabel(dep) });
417
+ else if (depErrors[i]) errors.push(`${depLabel(dep)}: ${depErrors[i]}`);
418
+ });
419
+ }
158
420
 
159
- const version = await resolveVersionFromParams(params);
160
- const { includeDir } = await fetchCompiler(version);
161
- if (includeDir) searchPaths.push({ path: includeDir, label: `AMXX stdlib ${version}` });
421
+ const stdlib = await resolveStdlibState(params);
422
+ if (stdlib.includeDir) searchPaths.push({ path: stdlib.includeDir, label: `AMXX stdlib ${stdlib.version}` });
162
423
 
163
424
  const result = searchIncludeFile(searchPaths, filename);
164
425
  if (!result) {
@@ -186,26 +447,35 @@ function createServeServer() {
186
447
  err.code = -32602;
187
448
  throw err;
188
449
  }
450
+ loadEnvQuiet(manifestPath);
189
451
  const manifest = parseManifest(manifestPath);
190
452
 
453
+ const { dirs, errors } = await collectDepIncludeDirs(manifest, {
454
+ noFetch: noFetchParam(params),
455
+ ssh: manifest.github.ssh,
456
+ });
457
+
191
458
  const deps = [];
192
- for (const dep of manifest.globalDeps) {
459
+ for (let i = 0; i < manifest.globalDeps.length; i++) {
460
+ const dep = manifest.globalDeps[i];
461
+ const base = dep.source === 'fungun'
462
+ ? { source: 'fungun', id: dep.id, url: dep.url }
463
+ : { source: dep.source || 'git', repo: dep.repo, ref: dep.ref };
464
+ if (errors[i]) {
465
+ deps.push({ ...base, error: errors[i], files: [], count: 0 });
466
+ continue;
467
+ }
193
468
  try {
194
- const includeDir = await fetchDepIncludeDir(
195
- dep, resolveGithubToken(manifest, dep.repo),
196
- params?.noFetch === true, manifest.github.ssh
197
- );
198
- const files = await collectIncFiles(includeDir);
469
+ const files = await collectIncFiles(dirs[i]);
199
470
  deps.push({
200
- repo: dep.repo,
201
- ref: dep.ref,
471
+ ...base,
202
472
  include_path: dep.include_path || null,
203
- include_dir: includeDir,
473
+ include_dir: dirs[i],
204
474
  count: files.length,
205
475
  files: files.map((f) => ({ rel: f.rel, abs: f.abs })),
206
476
  });
207
477
  } catch (err) {
208
- deps.push({ repo: dep.repo, ref: dep.ref, error: err.message, files: [], count: 0 });
478
+ deps.push({ ...base, error: err.message, files: [], count: 0 });
209
479
  }
210
480
  }
211
481
  return { manifest: manifestPath, deps };
@@ -214,39 +484,49 @@ function createServeServer() {
214
484
  // ─── AMXX standard includes ──────────────────────────────────────────────
215
485
 
216
486
  server.onRequest('amxmodx.includes.list', async (params) => {
217
- const version = await resolveVersionFromParams(params);
487
+ const state = await resolveStdlibState(params);
218
488
  const pattern = params?.pattern || '*.inc';
219
489
 
220
- const { includeDir } = await fetchCompiler(version);
221
- if (!includeDir) return { version, includeDir: null, pattern, count: 0, files: [] };
490
+ if (!state.includeDir) {
491
+ const result = { version: state.version, includeDir: null, pattern, count: 0, files: [] };
492
+ if (state.degraded) result.degraded = true;
493
+ return result;
494
+ }
222
495
 
223
- const files = await glob(pattern, { cwd: includeDir, dot: false });
496
+ const files = await glob(pattern, { cwd: state.includeDir, dot: false });
224
497
  files.sort();
225
- return { version, includeDir, pattern, count: files.length, files };
498
+ const result = { version: state.version, includeDir: state.includeDir, pattern, count: files.length, files };
499
+ if (state.degraded) result.degraded = true;
500
+ return result;
226
501
  });
227
502
 
228
503
  server.onRequest('amxmodx.include.get', async (params) => {
229
- const version = await resolveVersionFromParams(params);
504
+ const state = await resolveStdlibState(params);
230
505
  const pattern = params?.file || params?.pattern || '*.inc';
231
506
 
232
- const { includeDir } = await fetchCompiler(version);
233
- if (!includeDir) return { version, includeDir: null, count: 0, files: [] };
507
+ if (!state.includeDir) {
508
+ const result = { version: state.version, includeDir: null, count: 0, files: [] };
509
+ if (state.degraded) result.degraded = true;
510
+ return result;
511
+ }
234
512
 
235
- const files = await glob(pattern, { cwd: includeDir, dot: false });
513
+ const files = await glob(pattern, { cwd: state.includeDir, dot: false });
236
514
  files.sort();
237
- return {
238
- version,
239
- includeDir,
515
+ const result = {
516
+ version: state.version,
517
+ includeDir: state.includeDir,
240
518
  count: files.length,
241
- files: files.map((rel) => ({ rel, content: readFileSafe(path.join(includeDir, rel)) })),
519
+ files: files.map((rel) => ({ rel, content: readFileSafe(path.join(state.includeDir, rel)) })),
242
520
  };
521
+ if (state.degraded) result.degraded = true;
522
+ return result;
243
523
  });
244
524
 
245
525
  // ─── Deps tree ───────────────────────────────────────────────────────────
246
526
 
247
527
  server.onRequest('deps.tree', async (params) => {
248
528
  const depth = params?.depth || 0;
249
- const noFetch = params?.noFetch === true;
529
+ const noFetch = noFetchParam(params);
250
530
 
251
531
  if (params?.deps) {
252
532
  const rootDeps = params.deps.map((entry) => {
@@ -266,7 +546,7 @@ function createServeServer() {
266
546
  }
267
547
 
268
548
  const manifestPath = manifestPathFor(params);
269
- loadEnv(manifestPath);
549
+ loadEnvQuiet(manifestPath);
270
550
  const manifest = parseManifest(manifestPath);
271
551
  const assembled = assembleRootDeps(manifest);
272
552
  return buildDepTree(assembled.rootDeps, {
@@ -279,18 +559,136 @@ function createServeServer() {
279
559
  });
280
560
  });
281
561
 
282
- // ─── Releases / cache / plan ─────────────────────────────────────────────
562
+ // ─── Include dependency graph ────────────────────────────────────────────
563
+
564
+ server.onRequest('dep-graph.get', async (params) => {
565
+ if (!params?.sma_file) {
566
+ const err = new Error('Missing required "sma_file" parameter');
567
+ err.code = -32602;
568
+ throw err;
569
+ }
570
+ const smaPath = path.resolve(params.sma_file);
571
+ if (!fs.existsSync(smaPath)) {
572
+ const err = new Error(`File not found: ${smaPath}`);
573
+ err.code = -32602;
574
+ throw err;
575
+ }
576
+
577
+ const noFetch = noFetchParam(params);
578
+
579
+ const manifestPath = manifestPathFor(params);
580
+ let manifest = null;
581
+ if (fs.existsSync(manifestPath)) {
582
+ loadEnvQuiet(manifestPath);
583
+ try { manifest = parseManifest(manifestPath); } catch { manifest = null; }
584
+ }
585
+
586
+ const version = await resolveVersionFromParams(params);
587
+ const { includeDir } = await fetchCompiler(version);
588
+
589
+ // Dep includes come BEFORE the stdlib — matching the real build's search order.
590
+ const includeDirs = [];
591
+ if (manifest) {
592
+ const { dirs } = await collectDepIncludeDirs(manifest, { noFetch, ssh: manifest.github.ssh });
593
+ for (const dir of dirs) if (dir) includeDirs.push(dir);
594
+ }
595
+ if (includeDir) includeDirs.push(includeDir);
596
+ for (const d of (params?.include_dirs || [])) includeDirs.push(path.resolve(d));
597
+
598
+ const graph = new DepGraph(includeDirs);
599
+ graph.parseFile(smaPath);
600
+
601
+ const result = {
602
+ sma_file: smaPath,
603
+ version,
604
+ include_dirs: includeDirs,
605
+ ...graph.snapshot(),
606
+ };
607
+
608
+ // Reverse query: which .sma files transitively depend on this .inc?
609
+ if (params?.inc) {
610
+ const incAbs = path.resolve(params.inc);
611
+ result.smas_depending_on = [...graph.getSmasDependingOn(incAbs)].sort();
612
+ }
613
+
614
+ return result;
615
+ });
616
+
617
+ // ─── Releases / repos / cache / plan ─────────────────────────────────────
283
618
 
284
619
  server.onRequest('releases.list', async (params) => {
285
- if (!params?.repo) {
286
- const err = new Error('Missing required "repo" field');
620
+ const invalid = repoParamError(params);
621
+ if (invalid) {
622
+ const err = new Error(invalid);
287
623
  err.code = -32602;
288
624
  throw err;
289
625
  }
290
- const token = params?.token || process.env.GITHUB_TOKEN || null;
626
+ const repo = params.repo;
627
+ const token = resolveGithubTokenFor(params, repo);
291
628
  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 });
629
+ try {
630
+ if (params?.tags) return await listTags(repo, { token, limit });
631
+ return await listReleases(repo, { token, limit, includeAssets: params?.includeAssets });
632
+ } catch (err) {
633
+ throw githubRpcError(err, repo) || err;
634
+ }
635
+ });
636
+
637
+ server.onRequest('repos.info', async (params) => {
638
+ const invalid = repoParamError(params);
639
+ if (invalid) {
640
+ const err = new Error(invalid);
641
+ err.code = -32602;
642
+ throw err;
643
+ }
644
+ const repo = params.repo;
645
+ const token = resolveGithubTokenFor(params, repo);
646
+ try {
647
+ return await getRepoInfo(repo, { token });
648
+ } catch (err) {
649
+ throw githubRpcError(err, repo) || err;
650
+ }
651
+ });
652
+
653
+ server.onRequest('repos.branches', async (params) => {
654
+ const invalid = repoParamError(params);
655
+ if (invalid) {
656
+ const err = new Error(invalid);
657
+ err.code = -32602;
658
+ throw err;
659
+ }
660
+ const repo = params.repo;
661
+ const token = resolveGithubTokenFor(params, repo);
662
+ const limit = Math.min(100, Math.max(1, params?.limit ?? 10));
663
+ const page = Math.max(1, params?.page ?? 1);
664
+ try {
665
+ return await listBranches(repo, { token, limit, page });
666
+ } catch (err) {
667
+ throw githubRpcError(err, repo) || err;
668
+ }
669
+ });
670
+
671
+ server.onRequest('repos.structure', async (params) => {
672
+ const invalid = repoParamError(params) || validateRepoStructureOptions(params);
673
+ if (invalid) {
674
+ const err = new Error(invalid);
675
+ err.code = -32602;
676
+ throw err;
677
+ }
678
+ const repo = params.repo;
679
+ const token = resolveGithubTokenFor(params, repo);
680
+ try {
681
+ return await getRepoStructure(repo, {
682
+ token,
683
+ ref: params?.ref || null,
684
+ depth: params?.depth,
685
+ dirsOnly: params?.dirsOnly === true,
686
+ ext: params?.ext,
687
+ maxEntries: params?.maxEntries,
688
+ });
689
+ } catch (err) {
690
+ throw githubRpcError(err, repo) || err;
691
+ }
294
692
  });
295
693
 
296
694
  server.onRequest('cache.info', (params) => {
@@ -298,6 +696,13 @@ function createServeServer() {
298
696
  return getCacheInfo(manifestPath);
299
697
  });
300
698
 
699
+ server.onRequest('compiler.info', async (params) => {
700
+ const state = await resolveStdlibState(params);
701
+ const result = { version: state.version, platform: state.platform, compilerPath: state.compilerPath, includeDir: state.includeDir, cached: state.cached };
702
+ if (state.degraded) result.degraded = true;
703
+ return result;
704
+ });
705
+
301
706
  server.onRequest('build.plan', (params) => {
302
707
  const manifestPath = manifestPathFor(params);
303
708
  loadEnv(manifestPath, { quiet: true, override: false });
@@ -320,37 +725,43 @@ function createServeServer() {
320
725
  const controller = new AbortController();
321
726
  activeBuild = controller;
322
727
 
323
- const manifestPath = manifestPathFor(params);
324
- loadEnv(manifestPath);
325
- const manifest = resolveManifest(manifestPath, { set: params?.set, define: params?.define });
326
-
327
- // Forward core lifecycle events as server→client notifications while the
328
- // build runs. COMPILED/PROGRESS are emitted by compiler.js/progress.js on
329
- // the bus; STAGE/DONE/ERROR by build-service.
330
- const listeners = [
331
- [EVENTS.STAGE, (p) => server.notify('build.stage', p)],
332
- [EVENTS.COMPILED, (p) => server.notify('build.compiled', p)],
333
- [EVENTS.PROGRESS, (p) => server.notify('build.progress', p)],
334
- [EVENTS.DONE, (p) => server.notify('build.done', p)],
335
- [EVENTS.ERROR, (p) => server.notify('build.error', p)],
336
- ];
337
- for (const [ev, fn] of listeners) on(ev, fn);
338
-
339
728
  try {
340
- const result = await runBuild(manifest, {
341
- buildDir: params?.buildDir,
342
- fetch: params?.fetch,
343
- archive: params?.archive,
344
- signal: controller.signal,
345
- });
346
- return result;
347
- } catch (err) {
348
- if (err.code === 'CANCELLED') {
349
- return { ok: false, cancelled: true, message: err.message };
729
+ const manifestPath = manifestPathFor(params);
730
+ loadEnvQuiet(manifestPath);
731
+ const manifest = resolveManifest(manifestPath, { set: params?.set, define: params?.define });
732
+
733
+ // Forward core lifecycle events as server→client notifications while the
734
+ // build runs. COMPILED/PROGRESS are emitted by compiler.js/progress.js on
735
+ // the bus; STAGE/DONE/ERROR by build-service.
736
+ const listeners = [
737
+ [EVENTS.STAGE, (p) => server.notify('build.stage', p)],
738
+ [EVENTS.COMPILED, (p) => server.notify('build.compiled', p)],
739
+ [EVENTS.PROGRESS, (p) => server.notify('build.progress', p)],
740
+ [EVENTS.DONE, (p) => server.notify('build.done', p)],
741
+ [EVENTS.ERROR, (p) => server.notify('build.error', p)],
742
+ ];
743
+ for (const [ev, fn] of listeners) on(ev, fn);
744
+
745
+ try {
746
+ const result = await runBuild(manifest, {
747
+ buildDir: params?.buildDir,
748
+ fetch: params?.fetch,
749
+ archive: params?.archive,
750
+ signal: controller.signal,
751
+ });
752
+ return result;
753
+ } catch (err) {
754
+ if (err.code === 'CANCELLED') {
755
+ return { ok: false, cancelled: true, message: err.message };
756
+ }
757
+ return { ok: false, message: err.message };
758
+ } finally {
759
+ for (const [ev, fn] of listeners) off(ev, fn);
350
760
  }
351
- return { ok: false, message: err.message };
352
761
  } finally {
353
- for (const [ev, fn] of listeners) off(ev, fn);
762
+ // Even a pre-build failure (manifest missing, bad --set) must release the
763
+ // single-build lock, or every later build.start dies with "Build already
764
+ // running" until the process is restarted.
354
765
  activeBuild = null;
355
766
  }
356
767
  });
@@ -361,6 +772,85 @@ function createServeServer() {
361
772
  return { ok: true };
362
773
  });
363
774
 
775
+ // ─── Deploy ──────────────────────────────────────────────────────────────
776
+
777
+ server.onRequest('deploy.start', async (params) => {
778
+ const manifest = deployRequestManifest(params);
779
+ if (!manifest.deploy || !manifest.deploy.path) {
780
+ return { ok: false, message: 'Deploy path not configured.\n → Set AMXB_DEPLOY_PATH in .env, or add deploy.path to your manifest' };
781
+ }
782
+ const copied = await deployBuild(manifest, buildDirFor(params), {
783
+ incremental: params?.incremental === true,
784
+ });
785
+ return { ok: true, copied };
786
+ });
787
+
788
+ server.onRequest('deploy.file', (params) => {
789
+ if (!params?.relPath) {
790
+ const err = new Error('Missing required "relPath" parameter');
791
+ err.code = -32602;
792
+ throw err;
793
+ }
794
+ const section = params?.section === 'assets' ? 'assets' : 'amxmodx';
795
+ const manifest = deployRequestManifest(params);
796
+ if (!manifest.deploy || !manifest.deploy.path) {
797
+ return { ok: false, message: 'Deploy path not configured' };
798
+ }
799
+ const dest = deployFile(manifest, buildDirFor(params), params.relPath, section);
800
+ return { ok: dest != null, dest: dest || null };
801
+ });
802
+
803
+ server.onRequest('deploy.remove', (params) => {
804
+ if (!params?.relPath) {
805
+ const err = new Error('Missing required "relPath" parameter');
806
+ err.code = -32602;
807
+ throw err;
808
+ }
809
+ const section = params?.section === 'assets' ? 'assets' : 'amxmodx';
810
+ const manifest = deployRequestManifest(params);
811
+ if (!manifest.deploy || !manifest.deploy.path) {
812
+ return { ok: false, message: 'Deploy path not configured' };
813
+ }
814
+ const dest = removeDeployedFile(manifest, buildDirFor(params), params.relPath, section);
815
+ return { ok: dest != null, dest: dest || null };
816
+ });
817
+
818
+ // ─── RCON ────────────────────────────────────────────────────────────────
819
+
820
+ server.onRequest('rcon.send', async (params) => {
821
+ if (!params?.command) {
822
+ const err = new Error('Missing required "command" parameter');
823
+ err.code = -32602;
824
+ throw err;
825
+ }
826
+
827
+ let host = params?.host;
828
+ let port = params?.port;
829
+ let password = params?.password;
830
+
831
+ // Fall back to the manifest's deploy.rcon config when host/password are omitted.
832
+ if ((host == null || password == null) && fs.existsSync(manifestPathFor(params))) {
833
+ try {
834
+ const rconCfg = parseManifest(manifestPathFor(params)).deploy?.rcon;
835
+ if (rconCfg) {
836
+ if (host == null) host = rconCfg.host;
837
+ if (port == null) port = rconCfg.port;
838
+ if (password == null) password = rconCfg.password;
839
+ }
840
+ } catch { /* unparseable manifest — explicit params only */ }
841
+ }
842
+ if (port == null) port = 27015;
843
+
844
+ if (!host || !password) {
845
+ const err = new Error('RCON host/password not provided (pass explicitly or configure deploy.rcon in the manifest)');
846
+ err.code = -32602;
847
+ throw err;
848
+ }
849
+
850
+ const response = await sendRcon({ host, port, password, command: params.command });
851
+ return { ok: true, response };
852
+ });
853
+
364
854
  // ─── Single-file compile ─────────────────────────────────────────────────
365
855
 
366
856
  server.onRequest('compile.single', async (params) => {
@@ -376,11 +866,12 @@ function createServeServer() {
376
866
  throw err;
377
867
  }
378
868
 
379
- const noFetch = params?.noFetch === true;
869
+ const noFetch = noFetchParam(params);
380
870
 
381
871
  const manifestPath = manifestPathFor(params);
382
872
  let manifest = null;
383
873
  if (fs.existsSync(manifestPath)) {
874
+ loadEnvQuiet(manifestPath);
384
875
  try { manifest = parseManifest(manifestPath); } catch { manifest = null; }
385
876
  }
386
877
 
@@ -391,36 +882,48 @@ function createServeServer() {
391
882
  const depDirs = [];
392
883
  const depErrors = [];
393
884
  if (manifest) {
394
- for (const dep of manifest.globalDeps) {
395
- try {
396
- depDirs.push(await fetchDepIncludeDir(
397
- dep, resolveGithubToken(manifest, dep.repo), noFetch, manifest.github.ssh
398
- ));
399
- } catch (err) {
400
- depErrors.push(`${dep.repo}@${dep.ref}: ${err.message}`);
401
- }
402
- }
885
+ const { dirs, errors } = await collectDepIncludeDirs(manifest, { noFetch, ssh: manifest.github.ssh });
886
+ manifest.globalDeps.forEach((dep, i) => {
887
+ if (dirs[i]) depDirs.push(dirs[i]);
888
+ else if (errors[i]) depErrors.push(`${depLabel(dep)}: ${errors[i]}`);
889
+ });
403
890
  }
404
891
 
405
892
  const includeDirs = [...depDirs];
406
893
  if (includeDir) includeDirs.push(includeDir);
407
894
  for (const d of (params?.include_dirs || [])) includeDirs.push(path.resolve(d));
408
895
 
409
- const buildDir = path.join(os.tmpdir(), 'amxb-serve-compile');
896
+ // Unique per-call build dir + event tag: the transport dispatches requests
897
+ // concurrently, so two compile.single calls for same-named plugins must not
898
+ // share an output path nor mis-attribute COMPILED events (cf. MCP).
899
+ const runId = `${process.pid}_${Date.now().toString(36)}${Math.random().toString(36).slice(2, 6)}`;
900
+ const buildDir = path.join(os.tmpdir(), 'amxb-serve-compile', runId);
410
901
  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
- );
419
902
 
903
+ let compiled = null;
904
+ const onCompiled = (p) => { if (p.tag === runId) compiled = p; };
905
+ on(EVENTS.COMPILED, onCompiled);
906
+ let amxxName;
907
+ try {
908
+ amxxName = await compileSingle(
909
+ compileManifest,
910
+ smaPath,
911
+ compilerPath,
912
+ includeDirs,
913
+ buildDir,
914
+ params?.scripting_root ? path.resolve(params.scripting_root) : undefined,
915
+ runId
916
+ );
917
+ } finally {
918
+ off(EVENTS.COMPILED, onCompiled);
919
+ }
920
+
921
+ const outputPath = amxxName ? path.join(buildDir, 'amxmodx', 'plugins', amxxName) : null;
420
922
  return {
421
923
  ok: amxxName != null,
422
924
  amxxName,
423
- output_path: amxxName ? path.join(buildDir, 'amxmodx', 'plugins', amxxName) : null,
925
+ output: compiled ? compiled.output : undefined,
926
+ output_path: outputPath,
424
927
  dep_errors: depErrors.length ? depErrors : undefined,
425
928
  };
426
929
  });
@@ -435,6 +938,7 @@ function createServeServer() {
435
938
  }
436
939
 
437
940
  const manifestPath = manifestPathFor(params);
941
+ loadEnvQuiet(manifestPath);
438
942
  const manifest = parseManifest(manifestPath);
439
943
 
440
944
  const notify = (kind, extra = {}) => server.notify('watch.changed', { kind, ...extra });
@@ -460,13 +964,13 @@ function createServeServer() {
460
964
  return server;
461
965
  }
462
966
 
463
- // Load project .env from the workspace root like the CLI does; keep stdout free
464
- // for JSON-RPC (logs → stderr, progress bars disabled).
465
- function prepareEnvironment() {
466
- dotenv.config({ path: path.join(process.cwd(), '.env'), quiet: true });
467
- logger.setStderr(true);
468
- progress.setEnabled(false);
469
- }
967
+ // Load project .env from the workspace root like the CLI does; keep stdout free
968
+ // for JSON-RPC (logs → stderr, progress bars disabled).
969
+ function prepareEnvironment() {
970
+ dotenv.config({ path: path.join(process.cwd(), '.env'), quiet: true });
971
+ logger.setStderr(true);
972
+ progress.setEnabled(false);
973
+ }
470
974
 
471
975
  /**
472
976
  * Start the serve server — listens on stdin/stdout forever.