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.
@@ -0,0 +1,309 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * GitHub REST API helpers for repo-scope metadata (core, interface-agnostic).
5
+ *
6
+ * Used by the serve interface (`repos.info` / `repos.branches` /
7
+ * `repos.structure`) and potentially other interfaces (MCP) — per AGENTS.md
8
+ * the HTTP calls live here, not in the interface layer.
9
+ *
10
+ * Conventions:
11
+ * - HTTP errors are normalized to `GithubError` with a numeric `status`
12
+ * (HTTP status when known, `null` for network/timeout errors).
13
+ * - 404 from GitHub is deliberately NOT an error for the repo-scope methods:
14
+ * GitHub returns 404 both for nonexistent and for private/no-access repos,
15
+ * and the two cannot be distinguished even with a token. Callers get
16
+ * `{ exists: false, reason: 'not_found_or_no_access' }` instead.
17
+ * - Tokens are never resolved here — callers pass the resolved token
18
+ * (see `resolveGithubToken` in src/manifest.js).
19
+ */
20
+
21
+ const axios = require('axios');
22
+ // Same global default as release-lister; re-setting is idempotent and keeps
23
+ // this module's calls bounded even when loaded without release-lister.
24
+ axios.defaults.timeout = 30000;
25
+
26
+ /** "owner/repo" — no slashes inside the parts, no whitespace. */
27
+ const REPO_RE = /^[^/\s]+\/[^/\s]+$/;
28
+
29
+ class GithubError extends Error {
30
+ /**
31
+ * @param {string} message - error text (GitHub's message when available)
32
+ * @param {number|null} status - HTTP status, or null for network/timeout errors
33
+ */
34
+ constructor(message, status) {
35
+ super(message);
36
+ this.name = 'GithubError';
37
+ this.status = status == null ? null : status;
38
+ }
39
+ }
40
+
41
+ /**
42
+ * @param {unknown} repo
43
+ * @returns {boolean} true if `repo` looks like "owner/repo"
44
+ */
45
+ function isValidRepo(repo) {
46
+ return typeof repo === 'string' && REPO_RE.test(repo);
47
+ }
48
+
49
+ /**
50
+ * Validate the filter/limit options of a repo-structure request.
51
+ *
52
+ * @param {{ depth?: unknown, dirsOnly?: unknown, ext?: unknown, maxEntries?: unknown }} options
53
+ * @returns {string|null} error message, or null when the options are valid
54
+ */
55
+ function validateRepoStructureOptions({ depth, dirsOnly, ext, maxEntries } = {}) {
56
+ if (depth != null && (!Number.isInteger(depth) || depth < 1)) {
57
+ return 'Invalid "depth": expected a positive integer';
58
+ }
59
+ if (maxEntries != null && (!Number.isInteger(maxEntries) || maxEntries < 1 || maxEntries > 2000)) {
60
+ return 'Invalid "maxEntries": expected an integer in 1..2000';
61
+ }
62
+ if (ext != null && (!Array.isArray(ext) || ext.some((e) => typeof e !== 'string' || e.length === 0))) {
63
+ return 'Invalid "ext": expected an array of non-empty strings';
64
+ }
65
+ if (dirsOnly != null && typeof dirsOnly !== 'boolean') {
66
+ return 'Invalid "dirsOnly": expected a boolean';
67
+ }
68
+ return null;
69
+ }
70
+
71
+ /**
72
+ * GET a GitHub API URL with the same headers as release-lister
73
+ * (`Accept: application/vnd.github+json`, Bearer token when present).
74
+ * Non-2xx responses and network errors are normalized to `GithubError`.
75
+ *
76
+ * @param {string} url
77
+ * @param {{ token?: string|null }} [options]
78
+ * @returns {Promise<any>} parsed JSON body
79
+ */
80
+ async function githubGet(url, { token } = {}) {
81
+ const headers = { Accept: 'application/vnd.github+json' };
82
+ if (token) headers.Authorization = `Bearer ${token}`;
83
+
84
+ try {
85
+ const { data } = await axios.get(url, { headers });
86
+ return data;
87
+ } catch (err) {
88
+ if (err.response) {
89
+ const status = err.response.status;
90
+ const message = (err.response.data && err.response.data.message) || err.message;
91
+ throw new GithubError(message, status);
92
+ }
93
+ // No response: network error, DNS, timeout, ...
94
+ throw new GithubError(err && err.message ? err.message : String(err), null);
95
+ }
96
+ }
97
+
98
+ function notFoundResult(repo) {
99
+ return { repo, exists: false, reason: 'not_found_or_no_access' };
100
+ }
101
+
102
+ /**
103
+ * Repository existence + metadata.
104
+ *
105
+ * @param {string} repo - "owner/repo"
106
+ * @param {{ token?: string|null }} [options]
107
+ * @returns {Promise<object>} `{ exists: true, ...metadata }` or
108
+ * `{ exists: false, reason: 'not_found_or_no_access' }` on 404.
109
+ * Throws `GithubError` for non-404 errors (403/429/5xx/network).
110
+ */
111
+ async function getRepoInfo(repo, { token } = {}) {
112
+ let data;
113
+ try {
114
+ data = await githubGet(`https://api.github.com/repos/${repo}`, { token });
115
+ } catch (err) {
116
+ if (err instanceof GithubError && err.status === 404) return notFoundResult(repo);
117
+ throw err;
118
+ }
119
+
120
+ return {
121
+ repo,
122
+ exists: true,
123
+ private: data.private === true,
124
+ archived: data.archived === true,
125
+ disabled: data.disabled === true,
126
+ defaultBranch: data.default_branch || null,
127
+ description: data.description ?? null,
128
+ pushedAt: data.pushed_at || null,
129
+ };
130
+ }
131
+
132
+ /**
133
+ * Branch list with the commit SHA of each branch head.
134
+ *
135
+ * @param {string} repo - "owner/repo"
136
+ * @param {{ token?: string|null, limit?: number, page?: number }} [options]
137
+ * @returns {Promise<object>} `{ repo, branches: [{ name, commitSha }] }`,
138
+ * `{ repo, branches: [] }` for an empty repo (409), or
139
+ * `{ exists: false, reason: 'not_found_or_no_access' }` on 404.
140
+ * Throws `GithubError` for non-404/409 errors.
141
+ */
142
+ async function listBranches(repo, { token, limit = 10, page = 1 } = {}) {
143
+ const perPage = Math.min(100, Math.max(1, limit));
144
+ const pageNum = Math.max(1, page);
145
+
146
+ let data;
147
+ try {
148
+ data = await githubGet(
149
+ `https://api.github.com/repos/${repo}/branches?per_page=${perPage}&page=${pageNum}`,
150
+ { token }
151
+ );
152
+ } catch (err) {
153
+ if (err instanceof GithubError && err.status === 404) return notFoundResult(repo);
154
+ if (err instanceof GithubError && err.status === 409) {
155
+ // Empty repository (no commits yet) — no branches, not an error.
156
+ return { repo, branches: [] };
157
+ }
158
+ throw err;
159
+ }
160
+
161
+ return {
162
+ repo,
163
+ branches: data.map((b) => ({
164
+ name: b.name,
165
+ commitSha: (b.commit && b.commit.sha) || null,
166
+ })),
167
+ };
168
+ }
169
+
170
+ /**
171
+ * Filter a GitHub tree (from the Git Trees API) into the structure response.
172
+ * Pure function — exported for tests.
173
+ *
174
+ * @param {Array<{ path: string, type: string, mode?: string }>} tree
175
+ * @param {{ depth?: number, dirsOnly?: boolean, ext?: string[], maxEntries?: number }} [options]
176
+ * @returns {{ entries: Array<{ path: string, type: 'dir'|'file' }>, truncated: boolean }}
177
+ */
178
+ function filterTreeEntries(tree, { depth, dirsOnly = false, ext, maxEntries = 500 } = {}) {
179
+ const cap = Math.max(1, Math.min(2000, maxEntries));
180
+ const suffixes = Array.isArray(ext) && ext.length > 0
181
+ ? ext.map((e) => String(e).replace(/^\./, '').toLowerCase())
182
+ : null;
183
+
184
+ const entries = [];
185
+ let truncated = false;
186
+
187
+ for (const item of tree) {
188
+ if (entries.length >= cap) {
189
+ truncated = true;
190
+ break;
191
+ }
192
+ // GitHub types: "blob" (file), "tree" (dir), "commit" (submodule) — submodules
193
+ // are skipped: they have no path content to copy.
194
+ const type = item.type === 'tree' ? 'dir' : item.type === 'blob' ? 'file' : null;
195
+ if (!type) continue;
196
+ if (dirsOnly && type !== 'dir') continue;
197
+ // "ext" restricts to matching files only — directories are filtered out too.
198
+ if (suffixes) {
199
+ if (type !== 'file') continue;
200
+ const lower = item.path.toLowerCase();
201
+ if (!suffixes.some((s) => lower.endsWith('.' + s))) continue;
202
+ }
203
+ if (depth != null && item.path.split('/').length > depth) continue;
204
+
205
+ entries.push({ path: item.path, type });
206
+ }
207
+
208
+ return { entries, truncated };
209
+ }
210
+
211
+ /**
212
+ * Repository file/folder structure via the Git Trees API
213
+ * (`GET /repos/{owner}/{repo}/git/trees/{tree_sha}?recursive=1` — one call for
214
+ * the whole tree; GitHub resolves branch/tag refs itself).
215
+ *
216
+ * When `ref` is omitted the default branch is resolved first via the repo
217
+ * endpoint — the same call also verifies repo existence, so no extra API call
218
+ * is spent on a separate existence check.
219
+ *
220
+ * @param {string} repo - "owner/repo"
221
+ * @param {{ token?: string|null, ref?: string|null, depth?: number, dirsOnly?: boolean,
222
+ * ext?: string[], maxEntries?: number }} [options]
223
+ * @returns {Promise<object>} `{ repo, ref, truncated, entries }`,
224
+ * `{ repo, ref, truncated: false, entries: [] }` for an empty repo (409), or
225
+ * `{ exists: false, reason: 'not_found_or_no_access' }` when the repo is
226
+ * gone/private. Throws `GithubError` for other errors — including a
227
+ * nonexistent `ref` on an existing repo (trees API answers 404, ambiguous
228
+ * with a missing repo; the repo endpoint is re-checked to disambiguate →
229
+ * `GithubError` 404 "Ref not found: <ref>").
230
+ */
231
+ async function getRepoStructure(repo, { token, ref = null, depth, dirsOnly = false, ext, maxEntries = 500 } = {}) {
232
+ let treeSha = ref;
233
+ let usedRef = ref;
234
+ let repoVerified = false;
235
+
236
+ // Default depth: top level only — unlimited when ext is given (the
237
+ // exclude-files use case needs the whole tree). Guards against fetching
238
+ // the full tree of a huge repo when only a shallow listing is needed.
239
+ const effectiveDepth = depth != null ? depth : (Array.isArray(ext) && ext.length > 0 ? undefined : 1);
240
+
241
+ if (!treeSha) {
242
+ let info;
243
+ try {
244
+ info = await getRepoInfo(repo, { token });
245
+ } catch (err) {
246
+ throw err; // non-404 GithubError → caller decides how to surface it
247
+ }
248
+ if (!info.exists) return notFoundResult(repo);
249
+ if (!info.defaultBranch) {
250
+ // Repo without a default branch configured — nothing to list.
251
+ return { repo, ref: null, truncated: false, entries: [] };
252
+ }
253
+ treeSha = info.defaultBranch;
254
+ usedRef = info.defaultBranch;
255
+ repoVerified = true;
256
+ }
257
+
258
+ let data;
259
+ try {
260
+ data = await githubGet(
261
+ `https://api.github.com/repos/${repo}/git/trees/${encodeURIComponent(treeSha)}?recursive=1`,
262
+ { token }
263
+ );
264
+ } catch (err) {
265
+ if (err instanceof GithubError && err.status === 404) {
266
+ // The trees API answers 404 both for a missing/private repo AND for a
267
+ // nonexistent branch/tag (422 is only for malformed 40-hex SHAs). When
268
+ // the repo was not just verified, re-check its existence to tell the
269
+ // two apart.
270
+ if (!repoVerified) {
271
+ let info;
272
+ try {
273
+ info = await getRepoInfo(repo, { token });
274
+ } catch (checkErr) {
275
+ throw checkErr; // non-404 GithubError from the repo re-check
276
+ }
277
+ if (!info.exists) return notFoundResult(repo);
278
+ }
279
+ // Repo is alive (just verified, or verified while resolving the default
280
+ // branch) → the ref itself is the problem.
281
+ throw new GithubError(`Ref not found: ${treeSha}`, 404);
282
+ }
283
+ if (err instanceof GithubError && err.status === 409) {
284
+ // Empty repository (no commits yet) — no tree.
285
+ return { repo, ref: usedRef, truncated: false, entries: [] };
286
+ }
287
+ throw err;
288
+ }
289
+
290
+ const filtered = filterTreeEntries(data.tree || [], { depth: effectiveDepth, dirsOnly, ext, maxEntries });
291
+ return {
292
+ repo,
293
+ ref: usedRef,
294
+ // GitHub-side truncation (100k entries) also makes the answer incomplete.
295
+ truncated: data.truncated === true || filtered.truncated,
296
+ entries: filtered.entries,
297
+ };
298
+ }
299
+
300
+ module.exports = {
301
+ GithubError,
302
+ isValidRepo,
303
+ validateRepoStructureOptions,
304
+ githubGet,
305
+ getRepoInfo,
306
+ listBranches,
307
+ getRepoStructure,
308
+ filterTreeEntries,
309
+ };
@@ -30,7 +30,8 @@ const { parseManifest, parseDepsLines, resolveGithubToken } = require('./manifes
30
30
  const { fetchCompiler, fetchLatestVersion } = require('./compiler-fetcher');
31
31
  const { fetchRepo, resolveRefIfLatest, resolveRepoRefs } = require('./repo-fetcher');
32
32
  const { fetchReleaseDep } = require('./release-fetcher');
33
- const { normalize } = require('./deps-resolver');
33
+ const { fetchFungunDep } = require('./fungun-fetcher');
34
+ const { normalize, depLabel } = require('./deps-resolver');
34
35
  const { loadEnv } = require('./env');
35
36
  const { resolveManifestPath } = require('./manifest-path');
36
37
 
@@ -614,7 +615,7 @@ async function buildIncludeTree(manifestPath, targetPath, options = {}) {
614
615
  seenDeps.add(key);
615
616
  try {
616
617
  const depDir = await fetchDepIncludeDir(dep, tokenFor(dep.repo), noFetch, manifest.github.ssh);
617
- graph.includeDirs.push({ path: depDir, label: `dep: ${dep.repo}@${dep.ref}` });
618
+ graph.includeDirs.push({ path: depDir, label: `dep: ${depLabel(dep)}` });
618
619
  } catch (_) { /* skip unresolvable */ }
619
620
  }
620
621
 
@@ -818,6 +819,10 @@ async function fetchDepIncludeDir(dep, token, noFetch, ssh = false) {
818
819
  );
819
820
  }
820
821
 
822
+ if (dep.source === 'fungun') {
823
+ return fetchFungunDep(dep, noFetch);
824
+ }
825
+
821
826
  const resolvedRef = await resolveRefIfLatest(dep.ref, dep.repo, token);
822
827
  const repoDir = await fetchRepo(dep.repo, resolvedRef, token, noFetch, ssh);
823
828
  const candidates = dep.include_path
@@ -167,7 +167,8 @@ class JsonRpcServer {
167
167
  result = await handler(params);
168
168
  } catch (err) {
169
169
  if (typeof err.code === 'number') {
170
- this.sendError(id, err.code, err.message || 'Error');
170
+ // Handlers may attach structured details via err.data (e.g. GitHub status).
171
+ this.sendError(id, err.code, err.message || 'Error', err.data);
171
172
  } else {
172
173
  this.sendError(
173
174
  id,
package/src/manifest.js CHANGED
@@ -3,6 +3,7 @@ const yaml = require('js-yaml');
3
3
  const path = require('path');
4
4
 
5
5
  const { validateManifest: validateSchema } = require('./schema');
6
+ const { parsePluginRef } = require('./fungun-fetcher');
6
7
 
7
8
  const DEFAULTS_PATH = path.join(__dirname, '..', 'defaults', 'amxbuild.defaults.yml');
8
9
 
@@ -171,25 +172,105 @@ function makeRepo(r, globalPostfix, globalAmxDir) {
171
172
  */
172
173
  const DEP_STRING_RE = /^([^@\s]+)@([^:\s]+)(?::(.+))?$/;
173
174
 
175
+ /**
176
+ * Normalize a `docs` value (long-form dep entries only) to an array of
177
+ * trimmed, non-empty doc paths, or null when nothing is specified.
178
+ *
179
+ * @param {*} val
180
+ * @returns {string[]|null}
181
+ */
182
+ function normalizeDocs(val) {
183
+ if (val == null) return null;
184
+ const list = Array.isArray(val) ? val.map(String) : [String(val)];
185
+ const trimmed = list.map((s) => s.trim()).filter(Boolean);
186
+ return trimmed.length > 0 ? trimmed : null;
187
+ }
188
+
174
189
  /**
175
190
  * Parse a long-form dep object (manifest `deps` entries).
176
191
  *
192
+ * git / release entries: `{ repo, ref, source?, include_path?, asset?, docs? }`
193
+ * fungun entries: `{ source: 'fungun', id: <index> }` or
194
+ * `{ source: 'fungun', url: <page link> }`
195
+ *
177
196
  * @param {object} line
178
- * @returns {{ repo: string, ref: string, include_path: string|null, source: string, asset: * }}
197
+ * @returns {{ repo: string, ref: string|null, include_path: string|null, source: string, asset: *, docs: string[]|null }}
179
198
  */
180
199
  function parseDepObject(line) {
181
- if (!line.repo) throw new Error(`Dep entry missing "repo": ${JSON.stringify(line)}`);
182
- if (!line.ref) throw new Error(`Dep entry missing "ref": ${JSON.stringify(line)}`);
183
200
  const source = line.source || 'git';
201
+
202
+ if (source === 'fungun') return parseFungunDepObject(line);
203
+
184
204
  if (!['git', 'release'].includes(source)) {
185
- throw new Error(`Dep entry "source" must be "git" or "release": ${JSON.stringify(line)}`);
205
+ throw new Error(`Dep entry "source" must be "git", "release" or "fungun": ${JSON.stringify(line)}`);
206
+ }
207
+ if (line.id != null || line.url != null) {
208
+ throw new Error(`Dep entry "id"/"url" are only valid with "source: fungun": ${JSON.stringify(line)}`);
186
209
  }
210
+ if (!line.repo) throw new Error(`Dep entry missing "repo": ${JSON.stringify(line)}`);
211
+ if (!line.ref) throw new Error(`Dep entry missing "ref": ${JSON.stringify(line)}`);
187
212
  return {
188
213
  repo: String(line.repo).trim(),
189
214
  ref: String(line.ref).trim(),
190
215
  include_path: line.include_path ? String(line.include_path).trim() : null,
191
216
  source,
192
217
  asset: line.asset != null ? line.asset : null,
218
+ docs: normalizeDocs(line.docs),
219
+ };
220
+ }
221
+
222
+ /**
223
+ * Parse a long-form fungun dep. fungun.net plugins are closed-source and are
224
+ * addressed by their shop page index (`id`) or page URL (`url`) — there is no
225
+ * GitHub repo/ref. The parsed object carries a synthetic stable `repo`
226
+ * (`fungun.net/<id>`) so shared dep-dedup / dest-dir / cache-key logic in
227
+ * deps-resolver and include-tree keeps working unchanged.
228
+ */
229
+ function parseFungunDepObject(line) {
230
+ const rawId = line.id != null ? String(line.id).trim() : '';
231
+ const rawUrl = line.url != null ? String(line.url).trim() : '';
232
+ const hasId = rawId !== '';
233
+ const hasUrl = rawUrl !== '';
234
+
235
+ if (hasId && hasUrl) {
236
+ throw new Error(
237
+ `Dep entry source "fungun" — give either "id" or "url", not both: ${JSON.stringify(line)}`
238
+ );
239
+ }
240
+ if (!hasId && !hasUrl) {
241
+ throw new Error(
242
+ `Dep entry source "fungun" requires "id" (plugin index) or "url" (page link): ${JSON.stringify(line)}`
243
+ );
244
+ }
245
+ if (line.include_path != null || line.asset != null) {
246
+ throw new Error(
247
+ `Dep entry source "fungun" does not support include_path/asset — ` +
248
+ `the .inc files come from the plugin page: ${JSON.stringify(line)}`
249
+ );
250
+ }
251
+ if (line.repo != null || line.ref != null) {
252
+ throw new Error(
253
+ `Dep entry source "fungun" does not support repo/ref — ` +
254
+ `address the plugin by "id" or "url": ${JSON.stringify(line)}`
255
+ );
256
+ }
257
+
258
+ let ref;
259
+ try {
260
+ ref = parsePluginRef(hasId ? rawId : rawUrl);
261
+ } catch (err) {
262
+ throw new Error(`Dep entry source "fungun": ${err.message}`);
263
+ }
264
+
265
+ return {
266
+ repo: `fungun.net/${ref.id}`,
267
+ ref: null,
268
+ source: 'fungun',
269
+ id: ref.id,
270
+ url: ref.url,
271
+ include_path: null,
272
+ asset: null,
273
+ docs: normalizeDocs(line.docs),
193
274
  };
194
275
  }
195
276
 
@@ -370,4 +451,4 @@ function resolveManifest(manifestPath, options = {}) {
370
451
  return manifest;
371
452
  }
372
453
 
373
- module.exports = { parseManifest, parseDepsLines, parseDepString, parseDepObject, applyOverrides, parseOverrideValue, resolveManifest, resolveGithubToken, loadDefaultsRaw, deepMerge };
454
+ module.exports = { parseManifest, parseDepsLines, parseDepString, parseDepObject, normalizeDocs, applyOverrides, parseOverrideValue, resolveManifest, resolveGithubToken, loadDefaultsRaw, deepMerge };
@@ -2,7 +2,7 @@ name: CI
2
2
 
3
3
  on:
4
4
  push:
5
- branches: [master, feature/**, fix/**]
5
+ branches: [master, main, feature/**, fix/**]
6
6
  paths-ignore:
7
7
  - "**.md"
8
8
  pull_request: