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/AGENTS.md +7 -1
- package/README.md +219 -17
- package/action.yml +1 -1
- package/defaults/amxbuild.defaults.yml +0 -1
- package/mcp/handlers.js +51 -10
- package/mcp/registry.js +68 -2
- package/package.json +1 -1
- package/skills/amxb-migration/SKILL.md +108 -23
- package/skills/amxx-pawn-style/SKILL.md +422 -0
- package/src/build-plan.js +22 -5
- package/src/build-service.js +37 -3
- package/src/cli.js +2 -0
- package/src/commands/build.js +5 -4
- package/src/commands/dry-run.js +28 -1
- package/src/commands/init.js +69 -6
- package/src/commands/serve.js +10 -3
- package/src/commands/watch.js +18 -4
- package/src/compile-utils.js +108 -2
- package/src/compiler.js +43 -16
- package/src/deps-resolver.js +46 -8
- package/src/deps-tree.js +18 -8
- package/src/github-api.js +32 -0
- package/src/include-tree.js +3 -1
- package/src/ini-builder.js +1 -1
- package/src/local-sources.js +363 -0
- package/src/manifest.js +323 -23
- package/src/opencode-skills.js +1 -1
- package/src/repo-fetcher.js +119 -11
- package/src/schema.js +1 -1
- package/templates/init-deploy.env +9 -0
- package/templates/init-gitignore +22 -0
package/src/repo-fetcher.js
CHANGED
|
@@ -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
|
|
16
|
-
*
|
|
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).
|
|
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
|
|
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 <
|
|
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 = {
|
|
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/
|