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.
@@ -12,8 +12,10 @@
12
12
  * skip resolution and keep the old ref-keyed fast path. `latest` is resolved to
13
13
  * a concrete tag by resolveRef() before any fetch and is otherwise untouched.
14
14
  *
15
- * Resolutions are cached in <cache>/.ref-heads.json with a 1h TTL (same cadence
16
- * as the latest-tag cache) so repeated builds do not hit the GitHub API for
15
+ * Resolutions are cached in <cache>/.ref-heads.json. Their lifetime depends on
16
+ * the ref kind — a tag is cached forever, a branch keeps the 1h TTL (same
17
+ * cadence as the latest-tag cache) — and a parsed manifest `ref_ttl` can
18
+ * override that per entry, so repeated builds do not hit the GitHub API for
17
19
  * every repo.
18
20
  */
19
21
 
@@ -25,6 +27,9 @@ axios.defaults.timeout = 30000;
25
27
  const simpleGit = require('simple-git');
26
28
  const logger = require('./logger');
27
29
  const { getCacheDir } = require('./cache-dir');
30
+ const { getRefKind, isValidRepo } = require('./github-api');
31
+ // Top-level is safe: local-sources lazy-requires this module (no cycle).
32
+ const { isLocal } = require('./local-sources');
28
33
 
29
34
  function getRepoCacheDir(repo, ref) {
30
35
  // Lowercased key: GitHub repo names are case-insensitive, but filesystems
@@ -44,6 +49,48 @@ const LATEST_TAG_TTL_MS = 60 * 60 * 1000; // releases update rarely
44
49
  // Ref→SHA resolutions are cheap to refresh and share the 1h cadence.
45
50
  const REF_HEAD_TTL_MS = 60 * 60 * 1000;
46
51
 
52
+ /**
53
+ * Effective TTL of a cached ref→SHA resolution.
54
+ *
55
+ * An explicit manifest `ref_ttl` always wins (`'never'` → forever, a number is
56
+ * already in milliseconds). Without one, a ref confirmed as an immutable tag
57
+ * is cached forever; a branch and an unclassified ref (legacy entry, failed
58
+ * probe) keep the hourly refresh.
59
+ *
60
+ * @param {number|'never'|undefined} refTtl - parsed manifest `ref_ttl`
61
+ * @param {'tag'|'branch'|undefined|null} kind - cached ref classification
62
+ * @returns {number} milliseconds, or Infinity for "never expires"
63
+ */
64
+ function refTtlMs(refTtl, kind) {
65
+ if (refTtl === 'never') return Infinity;
66
+ if (typeof refTtl === 'number') return refTtl;
67
+ if (kind === 'tag') return Infinity;
68
+ return REF_HEAD_TTL_MS;
69
+ }
70
+
71
+ /**
72
+ * Decide whether a per-entry `ref_ttl` applies to the ref as written in the
73
+ * manifest — the single place that decision (and its warning) lives.
74
+ *
75
+ * `ref_ttl` only governs a named, moving ref: a pinned SHA is never resolved,
76
+ * a null ref is the default branch, and `latest` is a meta-ref resolved to a
77
+ * tag name before any fetch. A configured value on any of those is ignored
78
+ * with one warning; call sites forward the result to fetchRepo.
79
+ *
80
+ * @param {string|null|undefined} ref - original manifest ref (not `_resolvedRef`)
81
+ * @param {number|'never'|undefined|null} refTtl - parsed manifest `ref_ttl`
82
+ * @returns {number|'never'|undefined} refTtl when applicable, else undefined
83
+ */
84
+ function applicableRefTtl(ref, refTtl) {
85
+ if (refTtl === undefined || refTtl === null) return undefined;
86
+ if (ref == null || ref === 'latest' || SHA_REF_RE.test(ref)) {
87
+ const shown = ref == null ? 'the default branch' : `"${ref}"`;
88
+ logger.warn(` ref_ttl does not apply to ${shown} — ignored`);
89
+ return undefined;
90
+ }
91
+ return refTtl;
92
+ }
93
+
47
94
  function latestTagIndexPath() {
48
95
  return path.join(getCacheDir(), '.latest-tags.json');
49
96
  }
@@ -138,14 +185,22 @@ async function resolveRefIfLatest(ref, repo, token) {
138
185
  * Single source of the "for each repo: ref === 'latest' → resolve tag" loop
139
186
  * shared by the build pipeline, deps-tree and include-tree. Repos with a
140
187
  * concrete ref get `_resolvedRef = repoConfig.ref`; `latest` refs are resolved
141
- * via the GitHub API (cached 1h). Rejects if any resolution fails.
188
+ * via the GitHub API (cached 1h). Local entries (`_localDir`, see
189
+ * ./local-sources) have no remote ref — they short-circuit to the `'local'`
190
+ * sentinel with no network call. Rejects if any resolution fails.
142
191
  *
143
- * @param {Object[]} repos - manifest.repos entries ({ repo, ref, ... })
192
+ * @param {Object[]} repos - manifest.repos entries ({ repo, ref, _localDir, ... })
144
193
  * @param {(repo: string) => string|null} tokenFor - per-repo token resolver,
145
194
  * e.g. (repo) => resolveGithubToken(manifest, repo)
146
195
  */
147
196
  async function resolveRepoRefs(repos, tokenFor) {
148
197
  await Promise.all(repos.map(async (repoConfig) => {
198
+ // Local sources are never fetched, but downstream dedup/cache-key logic
199
+ // reads `_resolvedRef` — keep the sentinel stable.
200
+ if (isLocal(repoConfig)) {
201
+ repoConfig._resolvedRef = 'local';
202
+ return;
203
+ }
149
204
  repoConfig._resolvedRef = await resolveRefIfLatest(
150
205
  repoConfig.ref,
151
206
  repoConfig.repo,
@@ -203,10 +258,46 @@ function isNotFound(err) {
203
258
  // In-process dedup: parallel fetches of the same repo@ref resolve once.
204
259
  const inflightRefHeads = new Map();
205
260
 
261
+ // In-process memo of refs whose kind probe failed: one build can resolve the
262
+ // same repo@ref several times (manifest deps, DEPS_LIST, interfaces), and a
263
+ // failed probe must neither hammer the API nor ever grant an eternal TTL.
264
+ // Only failures are memoized — the next build retries; success is persisted
265
+ // in .ref-heads.json.
266
+ const failedRefKindProbes = new Set();
267
+
268
+ // Classify a moving ref, never failing the resolution: an unknown kind (null)
269
+ // falls back to the branch TTL. ref === null is the default branch by
270
+ // definition, so it is classified without a probe.
271
+ async function detectRefKind(repo, ref, key, cached, token) {
272
+ if (ref == null) return 'branch';
273
+ if (cached && cached.kind) return cached.kind;
274
+ if (failedRefKindProbes.has(key)) return null;
275
+ try {
276
+ return await getRefKind(repo, ref, { token });
277
+ } catch (err) {
278
+ failedRefKindProbes.add(key);
279
+ logger.warn(` ${repo} @ ${ref}: ref kind probe failed (${err.message}); using the branch TTL`);
280
+ return null;
281
+ }
282
+ }
283
+
206
284
  async function resolveRefHeadFromNetwork(repo, ref, key, cached, token) {
207
285
  const headers = apiHeaders(token);
208
286
  const label = ref || 'HEAD';
209
287
  try {
288
+ // SECURITY GUARD — must stay before the first network call: repo and ref
289
+ // are interpolated into api.github.com URL paths and encodeURIComponent
290
+ // leaves "." unescaped, so a ref like "../../../tarball/main" would be
291
+ // normalized out of the intended path into an unintended API GET. The
292
+ // catch below wraps this (or falls back to the cached SHA) like any other
293
+ // resolve failure.
294
+ if (!isValidRepo(repo)) {
295
+ throw new Error(`Invalid repo "${repo}" — expected "owner/repo"`);
296
+ }
297
+ if (ref != null && String(ref).split('/').some((s) => s === '' || s === '.' || s === '..')) {
298
+ throw new Error(`Unsafe ref "${ref}" — refs must not contain empty, "." or ".." path segments`);
299
+ }
300
+ const kind = await detectRefKind(repo, ref, key, cached, token);
210
301
  let branch = ref || (cached && cached.branch) || null;
211
302
  if (!branch) branch = await resolveDefaultBranch(repo, null, headers);
212
303
  let sha;
@@ -223,7 +314,7 @@ async function resolveRefHeadFromNetwork(repo, ref, key, cached, token) {
223
314
  }
224
315
  }
225
316
  logger.dim(` ${repo} @ ${label} = ${sha} (resolved)`);
226
- await updateRefHeadIndex(key, { sha, branch: ref ? null : branch, at: Date.now() });
317
+ await updateRefHeadIndex(key, { sha, branch: ref ? null : branch, at: Date.now(), kind });
227
318
  return { sha };
228
319
  } catch (err) {
229
320
  if (cached) {
@@ -238,17 +329,19 @@ async function resolveRefHeadFromNetwork(repo, ref, key, cached, token) {
238
329
 
239
330
  /**
240
331
  * Resolve a moving ref to the commit SHA to fetch. Results are cached in
241
- * .ref-heads.json for 1h; a cached resolution is also reused when it is stale
332
+ * .ref-heads.json; an entry is trusted while its effective TTL has not expired
333
+ * (see refTtlMs). A cached resolution is also reused when it is stale
242
334
  * (--no-fetch never hits the network) or when a refresh fails but a previous
243
335
  * resolution exists — both keep the build on the last-known good head.
244
336
  * Returns null when --no-fetch has nothing cached to go on.
245
337
  */
246
- async function resolveRefHead(repo, ref, token, noFetch) {
338
+ async function resolveRefHead(repo, ref, token, noFetch, refTtl) {
247
339
  const key = refHeadKey(repo, ref);
248
340
  const label = ref || 'HEAD';
249
341
  const cached = readRefHeadsIndex()[key];
342
+ const ttl = refTtlMs(refTtl, cached && cached.kind);
250
343
 
251
- if (cached && Date.now() - cached.at < REF_HEAD_TTL_MS) {
344
+ if (cached && (ttl === Infinity || Date.now() - cached.at < ttl)) {
252
345
  logger.dim(` ${repo} @ ${label} = ${cached.sha} (cached)`);
253
346
  return { sha: cached.sha };
254
347
  }
@@ -314,6 +407,10 @@ function isCacheValidSync(cacheDir, ref, gitBased) {
314
407
  * branch that moved upstream is re-downloaded into a fresh dir while the old
315
408
  * dir stays untouched. Pinned SHA refs skip resolution entirely.
316
409
  *
410
+ * `refTtl` (a parsed manifest `ref_ttl`: `'never'` or milliseconds) overrides
411
+ * how long a resolved head is trusted, and is ignored for refs that never
412
+ * resolve (pinned SHA, default branch) — see refTtlMs/applicableRefTtl.
413
+ *
317
414
  * Two fetch paths:
318
415
  * ssh=true → clone via system git (simple-git): URL is always
319
416
  * git@github.com:owner/repo.git, no token handling.
@@ -321,15 +418,23 @@ function isCacheValidSync(cacheDir, ref, gitBased) {
321
418
  * extract it — no system git needed. A 404 with a token present
322
419
  * retries once through the API tarball endpoint (private repos).
323
420
  */
324
- async function fetchRepo(repo, ref, token, noFetch, ssh = false) {
421
+ async function fetchRepo(repo, ref, token, noFetch, ssh = false, refTtl = undefined) {
325
422
  const resolvedRef = ref || null; // null = default branch
326
423
  const refLabel = resolvedRef || 'HEAD';
327
424
  let fetchRef = resolvedRef;
328
425
  let resolveError = null;
329
426
 
427
+ // A direct caller can still pass a TTL for a ref that never consults the
428
+ // ref→SHA cache; ignore it with one warning. Interface call sites pre-filter
429
+ // through applicableRefTtl(), so this never double-warns.
430
+ if (refTtl !== undefined && (resolvedRef == null || SHA_REF_RE.test(resolvedRef))) {
431
+ logger.warn(` ${repo} @ ${refLabel}: ref_ttl does not apply to this ref — ignored`);
432
+ refTtl = undefined;
433
+ }
434
+
330
435
  if (isMovingRef(resolvedRef)) {
331
436
  // null (no recorded head) only happens under --no-fetch.
332
- const head = await resolveRefHead(repo, resolvedRef, token, noFetch);
437
+ const head = await resolveRefHead(repo, resolvedRef, token, noFetch, refTtl);
333
438
  if (head) {
334
439
  fetchRef = head.sha;
335
440
  resolveError = head.resolveError || null;
@@ -513,4 +618,7 @@ function wrapResolveError(err, repo, refLabel, token) {
513
618
  return new Error(`Failed to resolve ref ${refLabel} of ${repo}: ${msg}${errorHint(err)}`);
514
619
  }
515
620
 
516
- module.exports = { fetchRepo, resolveRef, resolveRefIfLatest, resolveRepoRefs, getRepoCacheDir, isCacheValid };
621
+ module.exports = {
622
+ fetchRepo, resolveRef, resolveRefIfLatest, resolveRepoRefs, getRepoCacheDir, isCacheValid,
623
+ refTtlMs, applicableRefTtl,
624
+ };
package/src/schema.js CHANGED
@@ -31,7 +31,7 @@ try {
31
31
  function validateManifest(raw) {
32
32
  if (!schemaCache) return { valid: true, errors: [] };
33
33
 
34
- const ajv = new Ajv({ allErrors: true });
34
+ const ajv = new Ajv({ allErrors: true, allowUnionTypes: true });
35
35
  addFormats(ajv);
36
36
  const validate = ajv.compile(schemaCache);
37
37
  const valid = validate(raw);
@@ -12,3 +12,12 @@ AMXB_DEPLOY_RCON_PASSWORD=
12
12
  # {plugin} is replaced with the plugin name (without .amxx extension)
13
13
  # Leave blank to disable RCON commands
14
14
  AMXB_DEPLOY_RCON_CMD=amxx load {plugin}
15
+
16
+ # Plugins debug (local only) — this .env is gitignored, never committed
17
+ # Forces " debug" in generated plugins-*.ini for every plugin entry:
18
+ # 1 / true / yes / on → force ON
19
+ # 0 / false / no / off → force OFF
20
+ # blank / other → no override (manifest decides)
21
+ # Overrides per-rule/per-repo debug and plugins.defaults.debug; only affects
22
+ # plugins already included in an INI (does not enable INI generation).
23
+ AMXB_PLUGINS_DEBUG=
@@ -0,0 +1,22 @@
1
+ # Build artifacts
2
+ *.amxx
3
+ *.zip
4
+ build/
5
+ dist/
6
+ plugins-*.ini
7
+
8
+ # Legacy bat build output (projects migrated onto amxb)
9
+ .build
10
+
11
+ # amxb: secrets and cache
12
+ .env
13
+ .env.local
14
+ .amxb-cache/
15
+
16
+ # Tooling / editor
17
+ .omo/
18
+ .codegraph/
19
+ .vscode/*
20
+ !.vscode/extensions.json
21
+ .claude/
22
+ node_modules/