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.
package/src/deps-tree.js CHANGED
@@ -16,9 +16,10 @@
16
16
  const fs = require('fs');
17
17
  const path = require('path');
18
18
 
19
- const { fetchRepo, resolveRef } = require('./repo-fetcher');
19
+ const { fetchRepo, resolveRef, applicableRefTtl } = require('./repo-fetcher');
20
20
  const { parseDepsLines } = require('./manifest');
21
21
  const { normalize } = require('./deps-resolver');
22
+ const { isLocal } = require('./local-sources');
22
23
 
23
24
  // ─── Public API ────────────────────────────────────────────────────────────────
24
25
 
@@ -128,11 +129,17 @@ async function walkDep(dep, ctx) {
128
129
  // ── Resolve ref (e.g. "latest" → concrete tag) ──────────────────────────
129
130
  let resolvedRef;
130
131
  let refError = null;
131
- try {
132
- resolvedRef = await resolveRef(repo, dep.ref, token);
133
- } catch (err) {
134
- resolvedRef = null;
135
- refError = err.message;
132
+ if (isLocal(dep)) {
133
+ // Local sources have no remote ref to resolve — never touch the network.
134
+ // The 'local' sentinel is truthy, so a local repo's DEPS_LIST still expands.
135
+ resolvedRef = 'local';
136
+ } else {
137
+ try {
138
+ resolvedRef = await resolveRef(repo, dep.ref, token);
139
+ } catch (err) {
140
+ resolvedRef = null;
141
+ refError = err.message;
142
+ }
136
143
  }
137
144
 
138
145
  // ── Cycle vs shared detection ───────────────────────────────────────────
@@ -181,7 +188,8 @@ async function walkDep(dep, ctx) {
181
188
  repo,
182
189
  ref: dep.ref || null,
183
190
  resolvedRef,
184
- source: dep.source || 'git',
191
+ source: isLocal(dep) ? 'local' : (dep.source || 'git'),
192
+ localDir: dep._localDir || null,
185
193
  include_path: dep.include_path || null,
186
194
  asset: dep.asset != null ? dep.asset : null,
187
195
  from: ctx.from,
@@ -204,7 +212,9 @@ async function getSubDeps(dep, resolvedRef, token, noFetch, getDepsOverride) {
204
212
  }
205
213
 
206
214
  // 2. Clone repo (or use cache) and read DEPS_LIST
207
- const repoDir = await fetchRepo(dep.repo, resolvedRef, token, noFetch, false);
215
+ const repoDir = isLocal(dep)
216
+ ? dep._localDir
217
+ : await fetchRepo(dep.repo, resolvedRef, token, noFetch, false, applicableRefTtl(dep.ref, dep.ref_ttl));
208
218
  const depsPath = path.join(repoDir, 'DEPS_LIST');
209
219
 
210
220
  if (!fs.existsSync(depsPath)) {
package/src/github-api.js CHANGED
@@ -95,6 +95,37 @@ async function githubGet(url, { token } = {}) {
95
95
  }
96
96
  }
97
97
 
98
+ /**
99
+ * Determine the kind of a ref: `'tag'`, `'branch'`, or `null` when neither
100
+ * namespace has it.
101
+ *
102
+ * Tags are probed first: when a name exists as both a tag and a branch, the
103
+ * immutable tag wins (a source pinned to a tag must not follow a branch).
104
+ * 404 from both namespaces → `null` (no such ref); any other GitHub error
105
+ * propagates as `GithubError` so a network/rate-limit failure is never
106
+ * mistaken for a successful classification.
107
+ *
108
+ * @param {string} repo - "owner/repo"
109
+ * @param {string} ref - branch/tag name (slashes stay path separators)
110
+ * @param {{ token?: string|null }} [options]
111
+ * @returns {Promise<'tag'|'branch'|null>}
112
+ */
113
+ async function getRefKind(repo, ref, { token } = {}) {
114
+ // Slashes in the ref (feature/foo) stay path separators; each segment is
115
+ // encoded so '#', spaces, '?' in a ref name cannot break the URL.
116
+ const enc = String(ref).split('/').map(encodeURIComponent).join('/');
117
+ for (const [kind, ns] of [['tag', 'tags'], ['branch', 'heads']]) {
118
+ try {
119
+ await githubGet(`https://api.github.com/repos/${repo}/git/ref/${ns}/${enc}`, { token });
120
+ return kind;
121
+ } catch (err) {
122
+ if (err instanceof GithubError && err.status === 404) continue;
123
+ throw err;
124
+ }
125
+ }
126
+ return null;
127
+ }
128
+
98
129
  function notFoundResult(repo) {
99
130
  return { repo, exists: false, reason: 'not_found_or_no_access' };
100
131
  }
@@ -302,6 +333,7 @@ module.exports = {
302
333
  isValidRepo,
303
334
  validateRepoStructureOptions,
304
335
  githubGet,
336
+ getRefKind,
305
337
  getRepoInfo,
306
338
  listBranches,
307
339
  getRepoStructure,
@@ -29,6 +29,7 @@ const glob = require('fast-glob');
29
29
  const { parseManifest, resolveGithubToken } = require('./manifest');
30
30
  const { fetchCompiler, fetchLatestVersion } = require('./compiler-fetcher');
31
31
  const repoFetcher = require('./repo-fetcher');
32
+ const { ensureRepoDir } = require('./local-sources');
32
33
  const { normalize, normalizeRepo, depLabel, fetchDepIncludeDir, readDepsListFile } = require('./deps-resolver');
33
34
  const { loadEnv } = require('./env');
34
35
  const { resolveManifestPath } = require('./manifest-path');
@@ -593,7 +594,8 @@ async function buildIncludeTree(manifestPath, targetPath, options = {}) {
593
594
  if (repoDirs.has(key)) return repoDirs.get(key);
594
595
  let dir = null;
595
596
  try {
596
- dir = await repoFetcher.fetchRepo(repoConfig.repo, repoConfig._resolvedRef, tokenFor(repoConfig.repo), noFetch, manifest.github.ssh);
597
+ // ensureRepoDir short-circuits to `_localDir` for local entries.
598
+ dir = await ensureRepoDir(repoConfig, { token: tokenFor(repoConfig.repo), noFetch, ssh: manifest.github.ssh });
597
599
  } catch (_) { /* unresolvable — cached as null below */ }
598
600
  repoDirs.set(key, dir);
599
601
  return dir;
@@ -32,7 +32,7 @@ function buildIniFiles(compiledPlugins, buildDir) {
32
32
  lines.push('', `; Source: ${repoId}`);
33
33
  lastRepo = repoId;
34
34
  }
35
- lines.push(p.amxxName);
35
+ lines.push(p.debug ? `${p.amxxName} debug` : p.amxxName);
36
36
  }
37
37
  lines.push('');
38
38
 
@@ -0,0 +1,363 @@
1
+ /**
2
+ * Local sources: let `repos:` / `deps:` entries be satisfied by a local
3
+ * directory instead of GitHub, and let an env var redirect existing entries to
4
+ * local dirs without editing the manifest.
5
+ *
6
+ * Two ways to point an entry at a local dir:
7
+ *
8
+ * 1. Declared in the manifest — a repo/dep entry with `source: local` carries
9
+ * `_localPathRaw` after parsing. `applyLocalOverrides` resolves it to an
10
+ * absolute `_localDir`.
11
+ * 2. Redirected by env — `AMXB_LOCAL_SOURCES` maps an entry's logical id
12
+ * (`owner/repo` or `local/<name>`) to a path, overriding whatever the
13
+ * manifest declared. `AMXB_LOCAL_STRICT=1` turns an id with no matching
14
+ * entry from a warning into an error.
15
+ *
16
+ * Runtime discrimination is `entry._localDir` — consumers branch on it, not on
17
+ * the `source` field. `_resolvedRef` is set to the sentinel `'local'` so the
18
+ * shared dep-dedup / cache-key logic keeps working unchanged.
19
+ *
20
+ * Dependency-light on purpose: this module is imported by `./manifest`, so it
21
+ * must NOT top-level require `./manifest` or `./deps-resolver` (cycle).
22
+ */
23
+
24
+ const fs = require('fs');
25
+ const path = require('path');
26
+
27
+ const logger = require('./logger');
28
+
29
+ const ENV_VAR = 'AMXB_LOCAL_SOURCES';
30
+ const STRICT_VAR = 'AMXB_LOCAL_STRICT';
31
+ const LOCAL_ID_PREFIX = 'local/';
32
+
33
+ /**
34
+ * Normalize a logical id for map lookups. Local copy of deps-resolver's
35
+ * `normalize` — a top-level require of that module would create a cycle.
36
+ *
37
+ * @param {*} id
38
+ * @returns {string}
39
+ */
40
+ function normalizeId(id) {
41
+ return String(id).toLowerCase();
42
+ }
43
+
44
+ /**
45
+ * True when an entry has been resolved to a local directory.
46
+ *
47
+ * @param {object} entry — a repo/dep config
48
+ * @returns {boolean}
49
+ */
50
+ function isLocal(entry) {
51
+ return !!entry && typeof entry._localDir === 'string' && !!entry._localDir;
52
+ }
53
+
54
+ /**
55
+ * The absolute local directory of an entry, or null when it is not local.
56
+ *
57
+ * @param {object} entry — a repo/dep config
58
+ * @returns {string|null}
59
+ */
60
+ function localDirOf(entry) {
61
+ return (entry && entry._localDir) || null;
62
+ }
63
+
64
+ /**
65
+ * Build the synthetic logical id for a local repo/dep. Uses the explicit
66
+ * `name` when provided, otherwise the path's basename — mirroring how the
67
+ * fungun parser synthesizes `fungun.net/<id>` so shared dedup/cache-key logic
68
+ * keeps working.
69
+ *
70
+ * @param {*} name — explicit name (optional)
71
+ * @param {string} rawPath — the local path
72
+ * @returns {string}
73
+ */
74
+ function synthesizeLocalId(name, rawPath) {
75
+ const trimmed = typeof name === 'string' ? name.trim() : '';
76
+ if (trimmed !== '') return LOCAL_ID_PREFIX + trimmed;
77
+ return LOCAL_ID_PREFIX + path.basename(String(rawPath));
78
+ }
79
+
80
+ /**
81
+ * Resolve a manifest-relative (or absolute) local path to an absolute,
82
+ * normalized path.
83
+ *
84
+ * @param {string} manifestDir — directory containing the manifest
85
+ * @param {string} p — path from the manifest / env
86
+ * @returns {string}
87
+ */
88
+ function resolveLocalPath(manifestDir, p) {
89
+ const str = String(p);
90
+ return path.isAbsolute(str) ? path.normalize(str) : path.resolve(manifestDir, str);
91
+ }
92
+
93
+ /**
94
+ * Parse `AMXB_LOCAL_SOURCES` into an id → path map.
95
+ *
96
+ * Accepted shapes:
97
+ * - JSON object: `{"owner/repo": "path", ...}` (only when the trimmed value
98
+ * starts with `{`)
99
+ * - `id=path` pairs separated by `;` or newlines; blank lines and `#`
100
+ * comments are skipped.
101
+ *
102
+ * Invalid input is collected in `errors` — this function never throws.
103
+ *
104
+ * @param {object} [env] — environment map (default: process.env)
105
+ * @returns {{ map: Map<string, string>, strict: boolean, errors: string[] }}
106
+ */
107
+ function parseLocalSourcesEnv(env = process.env) {
108
+ const map = new Map();
109
+ const errors = [];
110
+ const value = env[ENV_VAR] == null ? '' : String(env[ENV_VAR]);
111
+ const trimmed = value.trim();
112
+
113
+ if (trimmed !== '') {
114
+ if (trimmed.startsWith('{')) {
115
+ let parsed;
116
+ try {
117
+ parsed = JSON.parse(trimmed);
118
+ } catch (err) {
119
+ errors.push(`Invalid JSON in ${ENV_VAR}: ${err.message}`);
120
+ }
121
+ if (parsed !== undefined) {
122
+ if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) {
123
+ errors.push(`${ENV_VAR}: JSON value must be an object of "id": "path" pairs`);
124
+ } else {
125
+ for (const [key, val] of Object.entries(parsed)) {
126
+ const id = normalizeId(key).trim();
127
+ if (id === '') {
128
+ errors.push(`${ENV_VAR}: entry has an empty id`);
129
+ continue;
130
+ }
131
+ if (typeof val !== 'string' || val.trim() === '') {
132
+ errors.push(`${ENV_VAR}: value for "${key}" must be a non-empty string path`);
133
+ continue;
134
+ }
135
+ map.set(id, val.trim());
136
+ }
137
+ }
138
+ }
139
+ } else {
140
+ for (const rawEntry of trimmed.split(/\n|;/)) {
141
+ const entry = rawEntry.trim();
142
+ if (entry === '' || entry.startsWith('#')) continue;
143
+ const eqIdx = entry.indexOf('=');
144
+ if (eqIdx === -1) {
145
+ errors.push(`${ENV_VAR}: invalid entry "${entry}" (expected id=path)`);
146
+ continue;
147
+ }
148
+ const id = normalizeId(entry.slice(0, eqIdx)).trim();
149
+ const p = entry.slice(eqIdx + 1).trim();
150
+ if (id === '' || p === '') {
151
+ errors.push(`${ENV_VAR}: invalid entry "${entry}" (id and path must be non-empty)`);
152
+ continue;
153
+ }
154
+ map.set(id, p);
155
+ }
156
+ }
157
+ }
158
+
159
+ const strict = ['1', 'true'].includes(String(env[STRICT_VAR]).toLowerCase());
160
+ return { map, strict, errors };
161
+ }
162
+
163
+ /**
164
+ * Throw a uniform error when a resolved local path is missing or not a dir.
165
+ *
166
+ * @param {string} absPath
167
+ * @param {string} id
168
+ * @param {string} context — human-readable note appended to the message
169
+ */
170
+ function assertLocalDir(absPath, id, context) {
171
+ let stat = null;
172
+ try { stat = fs.statSync(absPath); } catch { /* missing */ }
173
+ if (!stat || !stat.isDirectory()) {
174
+ throw new Error(
175
+ `Local source for "${id}" not found or not a directory: ${absPath} ${context}`
176
+ );
177
+ }
178
+ }
179
+
180
+ /**
181
+ * Collect every repo/dep entry that can carry a local path: manifest repos,
182
+ * manifest global deps, and each repo's `deps_override` entries. `makeRepo`
183
+ * parses `deps_override` through `parseDepsLines` → `parseLocalDepObject`, so a
184
+ * local dep there carries `_localPathRaw` exactly like a global one — it must be
185
+ * resolved too, or consumers fall through to the git branch.
186
+ *
187
+ * `DEPS_LIST` files are string-only and git-only — deliberately not included.
188
+ *
189
+ * @param {object} manifest
190
+ * @returns {object[]}
191
+ */
192
+ function localCandidateEntries(manifest) {
193
+ const repos = Array.isArray(manifest.repos) ? manifest.repos : [];
194
+ const entries = [
195
+ ...repos,
196
+ ...(Array.isArray(manifest.globalDeps) ? manifest.globalDeps : []),
197
+ ];
198
+ for (const repo of repos) {
199
+ if (repo && Array.isArray(repo.deps_override)) entries.push(...repo.deps_override);
200
+ }
201
+ return entries;
202
+ }
203
+
204
+ /**
205
+ * Resolve a parsed entry's declared `_localPathRaw` against `baseDir`, assert
206
+ * the directory exists, and mark it local. Idempotent: an entry already local
207
+ * (`_localDir` set) is returned untouched, and an entry with no declared path is
208
+ * a no-op.
209
+ *
210
+ * Single source of truth for interfaces (MCP, serve) that receive an ad-hoc dep
211
+ * object with `source: local` + `path` — they must not re-implement path handling.
212
+ *
213
+ * @param {object} entry — a parsed repo/dep config (may carry `_localPathRaw`)
214
+ * @param {string} baseDir — directory the raw path is relative to
215
+ * @returns {object} the same entry
216
+ */
217
+ function resolveLocalEntry(entry, baseDir) {
218
+ if (!entry || entry._localPathRaw == null || isLocal(entry)) return entry;
219
+ const absPath = resolveLocalPath(baseDir, entry._localPathRaw);
220
+ assertLocalDir(absPath, entry.repo, '(local source)');
221
+ entry._localDir = absPath;
222
+ entry._resolvedRef = 'local';
223
+ return entry;
224
+ }
225
+
226
+ /**
227
+ * Throw when one logical id is claimed by two local entries pointing at
228
+ * DIFFERENT directories. Two entries resolving to the same directory are a
229
+ * harmless dedup (the build treats them as one source); different dirs mean one
230
+ * source would be silently dropped — the synthetic `local/<name>` id collided,
231
+ * so the user must give one a unique `name`.
232
+ *
233
+ * @param {object[]} entries
234
+ */
235
+ function assertUniqueLocalIds(entries) {
236
+ const byId = new Map();
237
+ for (const entry of entries) {
238
+ if (!entry || !entry.repo || !isLocal(entry)) continue;
239
+ const id = normalizeId(entry.repo);
240
+ if (!byId.has(id)) byId.set(id, []);
241
+ byId.get(id).push(entry);
242
+ }
243
+ for (const [id, group] of byId) {
244
+ const dirs = [...new Set(group.map((e) => e._localDir))].sort();
245
+ if (dirs.length > 1) {
246
+ throw new Error(
247
+ `Duplicate local source id "${id}": ${dirs.map((d) => `"${d}"`).join(' and ')} ` +
248
+ `are different directories — set a unique "name" on one of them.`
249
+ );
250
+ }
251
+ }
252
+ }
253
+
254
+ /**
255
+ * Resolve declared local paths to absolute `_localDir`s and apply env
256
+ * redirects. Mutates `manifest` in place and returns it.
257
+ *
258
+ * Precedence (highest first):
259
+ * 1. `AMXB_LOCAL_SOURCES` redirect for the entry's logical id
260
+ * 2. the entry's own declared `_localPathRaw`
261
+ *
262
+ * Env is parsed before resolution so a redirect can rescue a stale/missing
263
+ * declared path. Unknown env ids are fatal under `AMXB_LOCAL_STRICT=1` (a
264
+ * warning otherwise), and duplicate synthetic ids pointing at different dirs
265
+ * are rejected.
266
+ *
267
+ * @param {object} manifest — parsed manifest (must carry `_path`)
268
+ * @param {object} [env] — environment map (default: process.env)
269
+ * @returns {object} the same manifest
270
+ */
271
+ function applyLocalOverrides(manifest, env = process.env) {
272
+ const manifestDir = path.dirname(manifest._path);
273
+ const entries = localCandidateEntries(manifest);
274
+
275
+ // Parse env first: a redirect must win over (and thus avoid asserting) a
276
+ // declared path that no longer exists.
277
+ const { map, strict, errors } = parseLocalSourcesEnv(env);
278
+ for (const message of errors) logger.warn(message);
279
+
280
+ // Unknown env ids keep their strict/warn behaviour. Known ids are collected
281
+ // from every repo/dep entry, including deps_override.
282
+ const knownIds = new Set();
283
+ for (const entry of entries) {
284
+ if (entry && entry.repo) knownIds.add(normalizeId(entry.repo));
285
+ }
286
+ for (const id of map.keys()) {
287
+ if (knownIds.has(id)) continue;
288
+ if (strict) {
289
+ throw new Error(
290
+ `No repo/dep with id "${id}" in this manifest (${STRICT_VAR} is set)`
291
+ );
292
+ }
293
+ logger.warn(`no repo/dep with id ${id} in this manifest — ignored`);
294
+ }
295
+
296
+ // Resolve each entry: env redirect (highest precedence) over declared path.
297
+ for (const entry of entries) {
298
+ if (!entry || !entry.repo) continue;
299
+ const id = normalizeId(entry.repo);
300
+
301
+ const envPath = map.get(id);
302
+ if (envPath != null) {
303
+ const absPath = resolveLocalPath(manifestDir, envPath);
304
+ assertLocalDir(absPath, id, `(${ENV_VAR})`);
305
+ entry._localDir = absPath;
306
+ entry._resolvedRef = 'local';
307
+ logger.warn(`${ENV_VAR}: ${id} -> ${absPath} (local override)`);
308
+ continue;
309
+ }
310
+
311
+ if (entry._localPathRaw == null) continue;
312
+ const absPath = resolveLocalPath(manifestDir, entry._localPathRaw);
313
+ assertLocalDir(absPath, entry.repo, '(declared in manifest)');
314
+ entry._localDir = absPath;
315
+ entry._resolvedRef = 'local';
316
+ }
317
+
318
+ assertUniqueLocalIds(entries);
319
+
320
+ return manifest;
321
+ }
322
+
323
+ /**
324
+ * Ensure an entry's directory exists locally and return its path.
325
+ *
326
+ * Local entries short-circuit to `_localDir` — no network, no `--no-fetch`
327
+ * check. Non-local entries delegate to the repo fetcher (lazy-required to
328
+ * avoid a cycle: repo-fetcher/deps-resolver import each other).
329
+ *
330
+ * @param {object} repoConfig — a repo/dep config
331
+ * @param {object} [opts]
332
+ * @param {string|null} [opts.token]
333
+ * @param {boolean} [opts.noFetch]
334
+ * @param {boolean} [opts.ssh]
335
+ * @returns {Promise<string>} absolute directory path
336
+ */
337
+ async function ensureRepoDir(repoConfig, { token, noFetch, ssh } = {}) {
338
+ if (isLocal(repoConfig)) return repoConfig._localDir;
339
+
340
+ // Lazy require: keep this module dependency-light and cycle-free.
341
+ const { fetchRepo, applicableRefTtl } = require('./repo-fetcher');
342
+ const ref = repoConfig._resolvedRef ?? repoConfig.ref ?? null;
343
+ // Applicability is judged on the original manifest ref: for `ref: latest`
344
+ // `_resolvedRef` is already the tag name, yet a configured ref_ttl still
345
+ // does not apply to the meta-ref.
346
+ const refTtl = applicableRefTtl(repoConfig.ref ?? null, repoConfig.ref_ttl);
347
+ return fetchRepo(repoConfig.repo, ref, token, noFetch, ssh, refTtl);
348
+ }
349
+
350
+ module.exports = {
351
+ ENV_VAR,
352
+ STRICT_VAR,
353
+ LOCAL_ID_PREFIX,
354
+ normalizeId,
355
+ isLocal,
356
+ localDirOf,
357
+ synthesizeLocalId,
358
+ resolveLocalPath,
359
+ resolveLocalEntry,
360
+ parseLocalSourcesEnv,
361
+ applyLocalOverrides,
362
+ ensureRepoDir,
363
+ };