amxx-builder 1.5.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.
- package/AGENTS.md +111 -0
- package/README.md +485 -0
- package/action-entry.js +42 -0
- package/action.yml +55 -0
- package/defaults/amxbuild.defaults.yml +40 -0
- package/index.js +4 -0
- package/mcp/dep-resolver.js +75 -0
- package/mcp/handlers.js +862 -0
- package/mcp/mcp-server.js +112 -0
- package/mcp/registry.js +863 -0
- package/mcp/symbol-index.js +171 -0
- package/package.json +68 -0
- package/src/archiver.js +140 -0
- package/src/asset-fetcher.js +277 -0
- package/src/build-plan.js +101 -0
- package/src/build-service.js +188 -0
- package/src/cache-dir.js +21 -0
- package/src/cache-info.js +104 -0
- package/src/cli.js +302 -0
- package/src/collector.js +89 -0
- package/src/commands/build.js +49 -0
- package/src/commands/cache.js +77 -0
- package/src/commands/clean.js +33 -0
- package/src/commands/compile-renderer.js +38 -0
- package/src/commands/deploy.js +40 -0
- package/src/commands/deps-tree.js +92 -0
- package/src/commands/doctor.js +77 -0
- package/src/commands/dry-run.js +64 -0
- package/src/commands/init.js +228 -0
- package/src/commands/mcp.js +13 -0
- package/src/commands/releases.js +45 -0
- package/src/commands/resolve-manifest.js +27 -0
- package/src/commands/serve.js +489 -0
- package/src/commands/shared.js +24 -0
- package/src/commands/validate.js +34 -0
- package/src/commands/watch.js +209 -0
- package/src/compile-utils.js +65 -0
- package/src/compiler-fetcher.js +327 -0
- package/src/compiler.js +228 -0
- package/src/dep-graph.js +92 -0
- package/src/deployer.js +197 -0
- package/src/deps-resolver.js +127 -0
- package/src/deps-tree.js +202 -0
- package/src/env.js +18 -0
- package/src/events.js +29 -0
- package/src/format.js +23 -0
- package/src/fs-utils.js +87 -0
- package/src/include-tree.js +845 -0
- package/src/ini-builder.js +44 -0
- package/src/jsonrpc-transport.js +195 -0
- package/src/logger.js +50 -0
- package/src/manifest-path.js +34 -0
- package/src/manifest.js +373 -0
- package/src/progress.js +66 -0
- package/src/rcon.js +103 -0
- package/src/release-fetcher.js +206 -0
- package/src/release-lister.js +79 -0
- package/src/repo-fetcher.js +273 -0
- package/src/retry.js +50 -0
- package/src/schema.js +54 -0
- package/src/update-check.js +114 -0
- package/src/validate.js +69 -0
- package/src/watcher.js +135 -0
- package/templates/init-build.bat +11 -0
- package/templates/init-build.sh +7 -0
- package/templates/init-deploy.env +14 -0
- package/templates/init-manifest.yml +6 -0
- package/templates/init-workflow.yml +59 -0
|
@@ -0,0 +1,273 @@
|
|
|
1
|
+
const fs = require('fs');
|
|
2
|
+
const path = require('path');
|
|
3
|
+
const axios = require('axios');
|
|
4
|
+
// Default for API calls; download sites pass their own longer timeout.
|
|
5
|
+
axios.defaults.timeout = 30000;
|
|
6
|
+
const simpleGit = require('simple-git');
|
|
7
|
+
const logger = require('./logger');
|
|
8
|
+
const { getCacheDir } = require('./cache-dir');
|
|
9
|
+
|
|
10
|
+
function getRepoCacheDir(repo, ref) {
|
|
11
|
+
// Lowercased key: GitHub repo names are case-insensitive, but filesystems
|
|
12
|
+
// (NTFS/APFS) and the repo/ref dedup may not be — normalize to avoid
|
|
13
|
+
// duplicate clones on Linux and dir collisions on Windows/macOS.
|
|
14
|
+
// Lazy require: deps-resolver imports us, so a top-level import would cycle.
|
|
15
|
+
const { normalize } = require('./deps-resolver');
|
|
16
|
+
const key = normalize(repo).replace('/', '__') + '__' + String(ref).replace(/[^a-zA-Z0-9._-]/g, '_');
|
|
17
|
+
return path.join(getCacheDir(), 'repos', key);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
// Matches full or abbreviated commit SHAs (7-40 hex chars).
|
|
21
|
+
const SHA_REF_RE = /^[0-9a-f]{7,40}$/i;
|
|
22
|
+
|
|
23
|
+
const LATEST_TAG_TTL_MS = 60 * 60 * 1000; // releases update rarely
|
|
24
|
+
|
|
25
|
+
function latestTagIndexPath() {
|
|
26
|
+
return path.join(getCacheDir(), '.latest-tags.json');
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function readLatestTagIndex() {
|
|
30
|
+
try { return JSON.parse(fs.readFileSync(latestTagIndexPath(), 'utf8')); } catch { return {}; }
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function writeLatestTagIndex(index) {
|
|
34
|
+
try {
|
|
35
|
+
fs.mkdirSync(path.dirname(latestTagIndexPath()), { recursive: true });
|
|
36
|
+
const tmp = latestTagIndexPath() + '.tmp';
|
|
37
|
+
fs.writeFileSync(tmp, JSON.stringify(index));
|
|
38
|
+
fs.renameSync(tmp, latestTagIndexPath());
|
|
39
|
+
} catch (_) { /* best-effort */ }
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Resolves "latest" ref to the actual release tag via GitHub API.
|
|
44
|
+
* Cached per-repo (1h TTL) so repeated builds don't burn the rate limit.
|
|
45
|
+
*/
|
|
46
|
+
async function resolveRef(repo, ref, token) {
|
|
47
|
+
if (ref !== 'latest') return ref;
|
|
48
|
+
|
|
49
|
+
// Lazy require: deps-resolver imports us, so a top-level import would cycle.
|
|
50
|
+
const { normalize } = require('./deps-resolver');
|
|
51
|
+
const key = normalize(repo);
|
|
52
|
+
const index = readLatestTagIndex();
|
|
53
|
+
const cached = index[key];
|
|
54
|
+
if (cached && Date.now() - cached.at < LATEST_TAG_TTL_MS) {
|
|
55
|
+
logger.dim(` ${repo}: latest = ${cached.tag} (cached)`);
|
|
56
|
+
return cached.tag;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
logger.dim(` ${repo}: resolving latest release tag...`);
|
|
60
|
+
const headers = token ? { Authorization: `Bearer ${token}`, Accept: 'application/vnd.github+json' } : {};
|
|
61
|
+
try {
|
|
62
|
+
const { data } = await axios.get(
|
|
63
|
+
`https://api.github.com/repos/${repo}/releases/latest`,
|
|
64
|
+
{ headers }
|
|
65
|
+
);
|
|
66
|
+
logger.dim(` ${repo}: latest = ${data.tag_name}`);
|
|
67
|
+
logger.verbose(` ${repo}: resolved via GET /repos/${repo}/releases/latest`);
|
|
68
|
+
index[key] = { tag: data.tag_name, at: Date.now() };
|
|
69
|
+
writeLatestTagIndex(index);
|
|
70
|
+
return data.tag_name;
|
|
71
|
+
} catch (err) {
|
|
72
|
+
throw new Error(`Failed to resolve latest release for ${repo}: ${err.message}`);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Resolve a ref to a concrete tag only when it is 'latest'.
|
|
78
|
+
* Single-source-of-truth for the `ref === 'latest' ? resolveRef(...) : ref`
|
|
79
|
+
* pattern shared by the build pipeline, include-tree and MCP.
|
|
80
|
+
*/
|
|
81
|
+
async function resolveRefIfLatest(ref, repo, token) {
|
|
82
|
+
return ref !== 'latest' ? ref : resolveRef(repo, ref, token);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Resolve the ref for every manifest repo and record it as `_resolvedRef`.
|
|
87
|
+
* Single source of the "for each repo: ref === 'latest' → resolve tag" loop
|
|
88
|
+
* shared by the build pipeline, deps-tree and include-tree. Repos with a
|
|
89
|
+
* concrete ref get `_resolvedRef = repoConfig.ref`; `latest` refs are resolved
|
|
90
|
+
* via the GitHub API (cached 1h). Rejects if any resolution fails.
|
|
91
|
+
*
|
|
92
|
+
* @param {Object[]} repos - manifest.repos entries ({ repo, ref, ... })
|
|
93
|
+
* @param {(repo: string) => string|null} tokenFor - per-repo token resolver,
|
|
94
|
+
* e.g. (repo) => resolveGithubToken(manifest, repo)
|
|
95
|
+
*/
|
|
96
|
+
async function resolveRepoRefs(repos, tokenFor) {
|
|
97
|
+
await Promise.all(repos.map(async (repoConfig) => {
|
|
98
|
+
repoConfig._resolvedRef = await resolveRefIfLatest(
|
|
99
|
+
repoConfig.ref,
|
|
100
|
+
repoConfig.repo,
|
|
101
|
+
tokenFor(repoConfig.repo)
|
|
102
|
+
);
|
|
103
|
+
}));
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Ensures the repo is available locally. Returns the local path.
|
|
108
|
+
*
|
|
109
|
+
* Two paths:
|
|
110
|
+
* ssh=true → clone via system git (simple-git): URL is always
|
|
111
|
+
* git@github.com:owner/repo.git, no token handling.
|
|
112
|
+
* otherwise → download the GitHub tarball (codeload) over plain HTTP and
|
|
113
|
+
* extract it — no system git needed. A 404 with a token present
|
|
114
|
+
* retries once through the API tarball endpoint (private repos).
|
|
115
|
+
*/
|
|
116
|
+
async function fetchRepo(repo, ref, token, noFetch, ssh = false) {
|
|
117
|
+
const resolvedRef = ref || null; // null = default branch
|
|
118
|
+
const cacheKey = resolvedRef || 'HEAD';
|
|
119
|
+
const cacheDir = getRepoCacheDir(repo, cacheKey);
|
|
120
|
+
|
|
121
|
+
if (await isCacheValid(cacheDir, resolvedRef, ssh)) {
|
|
122
|
+
logger.dim(` ${repo} @ ${cacheKey} (cached)`);
|
|
123
|
+
return cacheDir;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
if (noFetch) {
|
|
127
|
+
throw new Error(
|
|
128
|
+
`Repo cache missing for ${repo}@${cacheKey} and --no-fetch is set.\n` +
|
|
129
|
+
`Run without --no-fetch to populate the cache.`
|
|
130
|
+
);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
logger.step(`Fetching ${repo} @ ${cacheKey} ...`);
|
|
134
|
+
|
|
135
|
+
// Fetch into a temp dir and atomically rename into place: a concurrent build
|
|
136
|
+
// fetching the same repo never sees — or deletes — a half-written cache.
|
|
137
|
+
const tmpDir = `${cacheDir}.tmp-${process.pid}-${Math.random().toString(36).slice(2, 8)}`;
|
|
138
|
+
fs.mkdirSync(path.dirname(tmpDir), { recursive: true });
|
|
139
|
+
|
|
140
|
+
try {
|
|
141
|
+
if (ssh) {
|
|
142
|
+
await cloneViaGit(repo, resolvedRef, tmpDir);
|
|
143
|
+
} else {
|
|
144
|
+
await fetchTarball(repo, resolvedRef, token, tmpDir);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
try {
|
|
148
|
+
fs.renameSync(tmpDir, cacheDir);
|
|
149
|
+
} catch {
|
|
150
|
+
// cacheDir already exists — a concurrent fetch (valid) or stale junk.
|
|
151
|
+
if (await isCacheValid(cacheDir, resolvedRef, ssh)) {
|
|
152
|
+
try { fs.rmSync(tmpDir, { recursive: true, force: true }); } catch (_) {}
|
|
153
|
+
} else {
|
|
154
|
+
try { fs.rmSync(cacheDir, { recursive: true, force: true }); } catch (_) {}
|
|
155
|
+
fs.renameSync(tmpDir, cacheDir);
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
} catch (err) {
|
|
159
|
+
try { fs.rmSync(tmpDir, { recursive: true, force: true }); } catch (_) {}
|
|
160
|
+
throw wrapFetchError(err, repo, cacheKey, token);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
logger.info(`Fetching ${repo} @ ${cacheKey} ... done`);
|
|
164
|
+
return cacheDir;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* SSH path (github.ssh: true): clone via system git — kept verbatim.
|
|
169
|
+
* Shallow clone of the default branch (--branch for tags/branches); SHA refs
|
|
170
|
+
* are fetched + checked out explicitly because shallow clones cannot fetch
|
|
171
|
+
* arbitrary SHAs via --branch.
|
|
172
|
+
*/
|
|
173
|
+
async function cloneViaGit(repo, resolvedRef, tmpDir) {
|
|
174
|
+
const isShaRef = SHA_REF_RE.test(resolvedRef || '');
|
|
175
|
+
// Windows: allow >260-char paths and keep file contents identical across OSes
|
|
176
|
+
// (core.autocrlf would rewrite .sma/.inc/.cfg to CRLF and break hashing/output).
|
|
177
|
+
const cloneArgs = ['--depth=1', '-c', 'core.longpaths=true', '-c', 'core.autocrlf=false'];
|
|
178
|
+
if (resolvedRef && !isShaRef) cloneArgs.push('--branch', resolvedRef);
|
|
179
|
+
|
|
180
|
+
const env = { ...process.env, GIT_TERMINAL_PROMPT: '0', GIT_ASKPASS: 'echo' };
|
|
181
|
+
const git = simpleGit({ env });
|
|
182
|
+
await git.clone(`git@github.com:${repo}.git`, tmpDir, cloneArgs);
|
|
183
|
+
if (isShaRef) {
|
|
184
|
+
const shaGit = simpleGit({ baseDir: tmpDir, env });
|
|
185
|
+
await shaGit.fetch(['--depth=1', 'origin', resolvedRef]);
|
|
186
|
+
await shaGit.checkout(resolvedRef);
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* Non-ssh path: download the repo tarball as a plain HTTP GET and extract it
|
|
192
|
+
* into tmpDir. downloadRef is passed straight to codeload — it accepts
|
|
193
|
+
* branches, tags, short/full SHAs and HEAD, so no SHA-resolution calls.
|
|
194
|
+
* A 404 with a token present means the repo is private (or the ref needs
|
|
195
|
+
* auth): retry once via the API tarball endpoint, which 302-redirects to a
|
|
196
|
+
* signed codeload URL (axios follows the redirect). The token only ever
|
|
197
|
+
* travels in request headers — never in a URL or on disk.
|
|
198
|
+
*/
|
|
199
|
+
async function fetchTarball(repo, resolvedRef, token, tmpDir) {
|
|
200
|
+
const downloadRef = resolvedRef || 'HEAD';
|
|
201
|
+
// Lazy require: release-fetcher imports repo-fetcher at top level.
|
|
202
|
+
const { downloadAsset } = require('./release-fetcher');
|
|
203
|
+
const { safeExtractTar } = require('./fs-utils');
|
|
204
|
+
|
|
205
|
+
// downloadAsset writes <archivePath>.part — the parent dir must exist.
|
|
206
|
+
fs.mkdirSync(tmpDir, { recursive: true });
|
|
207
|
+
|
|
208
|
+
const archivePath = path.join(tmpDir, 'repo.tar.gz');
|
|
209
|
+
try {
|
|
210
|
+
await downloadAsset(`https://codeload.github.com/${repo}/tar.gz/${downloadRef}`, archivePath, {});
|
|
211
|
+
} catch (err) {
|
|
212
|
+
if (!(err.response && err.response.status === 404 && token)) throw err;
|
|
213
|
+
await downloadAsset(
|
|
214
|
+
`https://api.github.com/repos/${repo}/tarball/${downloadRef}`,
|
|
215
|
+
archivePath,
|
|
216
|
+
{ Accept: 'application/vnd.github+json', Authorization: `Bearer ${token}` }
|
|
217
|
+
);
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
safeExtractTar(archivePath, tmpDir, { stripComponents: 1 });
|
|
221
|
+
fs.rmSync(archivePath, { force: true });
|
|
222
|
+
// Sentinel written inside tmpDir BEFORE the rename: only complete dirs
|
|
223
|
+
// ever become the cache.
|
|
224
|
+
const sentinelTmp = path.join(tmpDir, '.extracted.tmp');
|
|
225
|
+
fs.writeFileSync(sentinelTmp, downloadRef, 'utf8');
|
|
226
|
+
fs.renameSync(sentinelTmp, path.join(tmpDir, '.extracted'));
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* True when the fetch cache at cacheDir exists and is usable.
|
|
231
|
+
* gitBased (github.ssh: true) → a real git clone whose ref resolves
|
|
232
|
+
* (guards against partial clones left by a crashed process).
|
|
233
|
+
* Non-git → an extracted tarball (sentinel) or a legacy git clone — old
|
|
234
|
+
* caches stay valid so no mass re-download after upgrading.
|
|
235
|
+
*/
|
|
236
|
+
async function isCacheValid(cacheDir, ref, gitBased = false) {
|
|
237
|
+
if (!gitBased) {
|
|
238
|
+
return fs.existsSync(path.join(cacheDir, '.extracted')) ||
|
|
239
|
+
fs.existsSync(path.join(cacheDir, '.git'));
|
|
240
|
+
}
|
|
241
|
+
if (!fs.existsSync(path.join(cacheDir, '.git'))) return false;
|
|
242
|
+
try {
|
|
243
|
+
const verifyRef = ref && ref !== 'HEAD' ? `${ref}^{commit}` : 'HEAD';
|
|
244
|
+
await simpleGit({ baseDir: cacheDir }).revparse(['--verify', verifyRef]);
|
|
245
|
+
return true;
|
|
246
|
+
} catch {
|
|
247
|
+
return false;
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
function redactToken(msg, token) {
|
|
252
|
+
return token ? String(msg).split(token).join('***') : String(msg);
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/**
|
|
256
|
+
* Maps failures to the established hints: HTTP status when available
|
|
257
|
+
* (axios errors), otherwise treat as a network problem.
|
|
258
|
+
*/
|
|
259
|
+
function wrapFetchError(err, repo, cacheKey, token) {
|
|
260
|
+
const msg = redactToken(err.message || '', token);
|
|
261
|
+
const status = err.response && err.response.status;
|
|
262
|
+
let hint;
|
|
263
|
+
if (status === 404) {
|
|
264
|
+
hint = '\n → Check the repo name/ref, or set github.token_env if the repo is private';
|
|
265
|
+
} else if (status === 401 || status === 403) {
|
|
266
|
+
hint = '\n → Check your GitHub token (github.token_env / GITHUB_TOKEN)';
|
|
267
|
+
} else {
|
|
268
|
+
hint = '\n → Check your internet connection';
|
|
269
|
+
}
|
|
270
|
+
return new Error(`Failed to fetch ${repo}@${cacheKey}: ${msg}${hint}`);
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
module.exports = { fetchRepo, resolveRef, resolveRefIfLatest, resolveRepoRefs, getRepoCacheDir, isCacheValid };
|
package/src/retry.js
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const logger = require('./logger');
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Retries an async function up to `attempts` times with exponential backoff.
|
|
7
|
+
* Retries on network/timeout errors, 5xx, 408/429 and GitHub rate-limit 403s;
|
|
8
|
+
* other 4xx responses are NOT retried.
|
|
9
|
+
*/
|
|
10
|
+
async function withRetry(fn, { attempts = 3, baseDelayMs = 1000, label = '' } = {}) {
|
|
11
|
+
let lastErr;
|
|
12
|
+
for (let i = 0; i < attempts; i++) {
|
|
13
|
+
try {
|
|
14
|
+
return await fn();
|
|
15
|
+
} catch (err) {
|
|
16
|
+
lastErr = err;
|
|
17
|
+
if (!isTransient(err)) throw err;
|
|
18
|
+
|
|
19
|
+
if (i < attempts - 1) {
|
|
20
|
+
const delay = retryDelayMs(err, baseDelayMs, i);
|
|
21
|
+
const tag = label ? ` (${label})` : '';
|
|
22
|
+
logger.warn(`Retrying${tag} in ${Math.round(delay / 1000)}s... (attempt ${i + 2}/${attempts})`);
|
|
23
|
+
await new Promise((r) => setTimeout(r, delay));
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
throw lastErr;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// 408/429 and GitHub rate-limit 403s (X-RateLimit-Remaining: 0) are transient;
|
|
31
|
+
// any other 4xx is a permanent failure.
|
|
32
|
+
function isTransient(err) {
|
|
33
|
+
const status = err.response?.status;
|
|
34
|
+
if (!status || status >= 500) return true;
|
|
35
|
+
if (status === 408 || status === 429) return true;
|
|
36
|
+
if (status === 403 && String(err.response?.headers?.['x-ratelimit-remaining']) === '0') return true;
|
|
37
|
+
return false;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function retryDelayMs(err, baseDelayMs, attempt) {
|
|
41
|
+
const retryAfter = err.response?.headers?.['retry-after'];
|
|
42
|
+
const secs = retryAfter !== undefined ? parseInt(retryAfter, 10) : NaN;
|
|
43
|
+
if (Number.isFinite(secs) && secs >= 0) {
|
|
44
|
+
return Math.min(secs, 60) * 1000; // HTTP-date Retry-After falls back below
|
|
45
|
+
}
|
|
46
|
+
const exp = baseDelayMs * 2 ** attempt;
|
|
47
|
+
return Math.random() * exp; // full jitter — parallel calls don't retry in lockstep
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
module.exports = { withRetry };
|
package/src/schema.js
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Shared AJV-based manifest validation.
|
|
5
|
+
*
|
|
6
|
+
* Loads the JSON schema once (cached), compiles AJV with all errors and
|
|
7
|
+
* format support. Used by both manifest.js (parse-time validation, throws)
|
|
8
|
+
* and validate.js (diagnostic validation, returns structured result).
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
const fs = require('fs');
|
|
12
|
+
const path = require('path');
|
|
13
|
+
const Ajv = require('ajv');
|
|
14
|
+
const addFormats = require('ajv-formats');
|
|
15
|
+
|
|
16
|
+
const SCHEMA_PATH = path.join(__dirname, '..', 'schema', 'amxbuild.schema.json');
|
|
17
|
+
|
|
18
|
+
let schemaCache = null;
|
|
19
|
+
try {
|
|
20
|
+
if (fs.existsSync(SCHEMA_PATH)) {
|
|
21
|
+
schemaCache = JSON.parse(fs.readFileSync(SCHEMA_PATH, 'utf8'));
|
|
22
|
+
}
|
|
23
|
+
} catch { /* no schema file — AJV validation skipped */ }
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Validate a raw manifest object against the JSON schema.
|
|
27
|
+
*
|
|
28
|
+
* @param {object} raw — parsed YAML manifest merged with defaults
|
|
29
|
+
* @returns {{ valid: boolean, errors: Array<{ path: string, message: string }> }}
|
|
30
|
+
*/
|
|
31
|
+
function validateManifest(raw) {
|
|
32
|
+
if (!schemaCache) return { valid: true, errors: [] };
|
|
33
|
+
|
|
34
|
+
const ajv = new Ajv({ allErrors: true });
|
|
35
|
+
addFormats(ajv);
|
|
36
|
+
const validate = ajv.compile(schemaCache);
|
|
37
|
+
const valid = validate(raw);
|
|
38
|
+
|
|
39
|
+
if (valid) return { valid: true, errors: [] };
|
|
40
|
+
|
|
41
|
+
return {
|
|
42
|
+
valid: false,
|
|
43
|
+
errors: validate.errors.map((e) => ({
|
|
44
|
+
path: e.instancePath || '(root)',
|
|
45
|
+
message: e.message,
|
|
46
|
+
})),
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function getManifestSchema() {
|
|
51
|
+
return schemaCache;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
module.exports = { validateManifest, getManifestSchema };
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Checks npm-style (GitHub) version once per 24h and returns newer version if found.
|
|
5
|
+
* Fires a single GET to GitHub API — respects rate limits by caching check timestamps.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
const fs = require('fs');
|
|
9
|
+
const path = require('path');
|
|
10
|
+
const https = require('https');
|
|
11
|
+
const { getCacheDir } = require('./cache-dir');
|
|
12
|
+
|
|
13
|
+
const PKG = require('../package.json');
|
|
14
|
+
const CHECK_FILE = 'update-check.json';
|
|
15
|
+
const CHECK_TTL_MS = 24 * 60 * 60 * 1000; // 24 hours
|
|
16
|
+
const GITHUB_API = 'https://api.github.com/repos/AmxxModularEcosystem/amxx-builder/releases/latest';
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Simple semver compare (major.minor.patch).
|
|
20
|
+
* Returns 1 if a > b, -1 if a < b, 0 if equal.
|
|
21
|
+
*/
|
|
22
|
+
function cmpVersions(a, b) {
|
|
23
|
+
const pa = a.split('.').map(Number);
|
|
24
|
+
const pb = b.split('.').map(Number);
|
|
25
|
+
for (let i = 0; i < 3; i++) {
|
|
26
|
+
const va = pa[i] || 0;
|
|
27
|
+
const vb = pb[i] || 0;
|
|
28
|
+
if (va > vb) return 1;
|
|
29
|
+
if (va < vb) return -1;
|
|
30
|
+
}
|
|
31
|
+
return 0;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function readCheckCache() {
|
|
35
|
+
const cacheDir = getCacheDir();
|
|
36
|
+
const file = path.join(cacheDir, CHECK_FILE);
|
|
37
|
+
try {
|
|
38
|
+
const raw = fs.readFileSync(file, 'utf8');
|
|
39
|
+
return JSON.parse(raw);
|
|
40
|
+
} catch {
|
|
41
|
+
return null;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function writeCheckCache(data) {
|
|
46
|
+
const cacheDir = getCacheDir();
|
|
47
|
+
fs.mkdirSync(cacheDir, { recursive: true });
|
|
48
|
+
const file = path.join(cacheDir, CHECK_FILE);
|
|
49
|
+
fs.writeFileSync(file, JSON.stringify(data));
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Fetch latest release tag from GitHub API.
|
|
54
|
+
* Returns tag string (e.g. "v1.4.0") or null on failure.
|
|
55
|
+
*/
|
|
56
|
+
function fetchLatestTag() {
|
|
57
|
+
return new Promise((resolve) => {
|
|
58
|
+
const req = https.get(GITHUB_API, {
|
|
59
|
+
headers: {
|
|
60
|
+
'User-Agent': 'amxx-builder',
|
|
61
|
+
Accept: 'application/vnd.github.v3+json',
|
|
62
|
+
},
|
|
63
|
+
timeout: 5000,
|
|
64
|
+
}, (res) => {
|
|
65
|
+
let body = '';
|
|
66
|
+
res.on('data', (chunk) => { body += chunk; });
|
|
67
|
+
res.on('end', () => {
|
|
68
|
+
if (res.statusCode !== 200) return resolve(null);
|
|
69
|
+
try {
|
|
70
|
+
const data = JSON.parse(body);
|
|
71
|
+
if (data.tag_name) return resolve(data.tag_name);
|
|
72
|
+
} catch { /* ignore parse errors */ }
|
|
73
|
+
resolve(null);
|
|
74
|
+
});
|
|
75
|
+
res.on('error', () => resolve(null));
|
|
76
|
+
});
|
|
77
|
+
req.on('error', () => resolve(null));
|
|
78
|
+
req.on('timeout', () => { req.destroy(); resolve(null); });
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Check for a newer version. Returns the newer version string or null.
|
|
84
|
+
* Respects 24h cache — does not call GitHub API if checked recently.
|
|
85
|
+
*/
|
|
86
|
+
async function checkForUpdate() {
|
|
87
|
+
// Respect opt-out env
|
|
88
|
+
if (process.env.AMXB_NO_UPDATE_CHECK) return null;
|
|
89
|
+
|
|
90
|
+
const cached = readCheckCache();
|
|
91
|
+
const now = Date.now();
|
|
92
|
+
|
|
93
|
+
if (cached && (now - cached.ts) < CHECK_TTL_MS) {
|
|
94
|
+
// Still within cooldown
|
|
95
|
+
return null;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// Mark checked even if API call fails — prevents retry on every command
|
|
99
|
+
writeCheckCache({ ts: now });
|
|
100
|
+
|
|
101
|
+
const latestTag = await fetchLatestTag();
|
|
102
|
+
if (!latestTag) return null;
|
|
103
|
+
|
|
104
|
+
const latestVer = latestTag.replace(/^v/i, '');
|
|
105
|
+
const currentVer = PKG.version;
|
|
106
|
+
|
|
107
|
+
if (cmpVersions(latestVer, currentVer) > 0) {
|
|
108
|
+
return latestVer;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
return null;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
module.exports = { checkForUpdate };
|
package/src/validate.js
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const fs = require('fs');
|
|
4
|
+
const path = require('path');
|
|
5
|
+
const yaml = require('js-yaml');
|
|
6
|
+
|
|
7
|
+
const { validateManifest: validateSchema } = require('./schema');
|
|
8
|
+
const { loadDefaultsRaw, deepMerge } = require('./manifest');
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Validate a manifest file and return structured diagnostics.
|
|
12
|
+
* Never throws — returns { valid, errors, warnings }.
|
|
13
|
+
*/
|
|
14
|
+
function validateManifestFile(manifestPath) {
|
|
15
|
+
const errors = [];
|
|
16
|
+
const warnings = [];
|
|
17
|
+
|
|
18
|
+
// 1. File exists
|
|
19
|
+
const absPath = path.resolve(manifestPath);
|
|
20
|
+
if (!fs.existsSync(absPath)) {
|
|
21
|
+
errors.push({ path: '(root)', message: `Manifest not found: ${absPath}` });
|
|
22
|
+
return { valid: false, errors, warnings };
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// 2. Parse YAML
|
|
26
|
+
let projectRaw;
|
|
27
|
+
try {
|
|
28
|
+
projectRaw = yaml.load(fs.readFileSync(absPath, 'utf8'));
|
|
29
|
+
} catch (err) {
|
|
30
|
+
errors.push({ path: '(root)', message: `YAML parse error: ${err.message}` });
|
|
31
|
+
return { valid: false, errors, warnings };
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
if (!projectRaw || typeof projectRaw !== 'object' || Array.isArray(projectRaw)) {
|
|
35
|
+
errors.push({ path: '(root)', message: 'Manifest is empty or not a valid YAML object' });
|
|
36
|
+
return { valid: false, errors, warnings };
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// 3. Check name
|
|
40
|
+
if (!projectRaw.name || typeof projectRaw.name !== 'string') {
|
|
41
|
+
errors.push({ path: '/name', message: 'Missing required field "name"' });
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// 4. Merge with defaults
|
|
45
|
+
const defaults = loadDefaultsRaw();
|
|
46
|
+
const raw = deepMerge(defaults, projectRaw);
|
|
47
|
+
|
|
48
|
+
// 5. AJV schema validation (via shared schema module)
|
|
49
|
+
const schemaResult = validateSchema(raw);
|
|
50
|
+
for (const e of schemaResult.errors) {
|
|
51
|
+
errors.push(e);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// 6. version must be a string (common mistake: YAML parses unquoted as number)
|
|
55
|
+
if (raw.version != null && typeof raw.version !== 'string') {
|
|
56
|
+
errors.push({
|
|
57
|
+
path: '/version',
|
|
58
|
+
message: `"version" must be a quoted string in YAML, got ${typeof raw.version}`,
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
return {
|
|
63
|
+
valid: errors.length === 0,
|
|
64
|
+
errors,
|
|
65
|
+
warnings,
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
module.exports = { validateManifestFile };
|
package/src/watcher.js
ADDED
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const fs = require('fs');
|
|
4
|
+
const path = require('path');
|
|
5
|
+
const crypto = require('crypto');
|
|
6
|
+
const logger = require('./logger');
|
|
7
|
+
|
|
8
|
+
const fileHashes = new Map();
|
|
9
|
+
|
|
10
|
+
function contentChanged(filePath) {
|
|
11
|
+
let data;
|
|
12
|
+
try { data = fs.readFileSync(filePath); } catch { return false; }
|
|
13
|
+
const hash = crypto.createHash('sha1').update(data).digest('hex');
|
|
14
|
+
if (fileHashes.get(filePath) === hash) return false;
|
|
15
|
+
fileHashes.set(filePath, hash);
|
|
16
|
+
return true;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Starts watching local project files for changes.
|
|
21
|
+
*
|
|
22
|
+
* Uses chokidar's awaitWriteFinish as the debounce mechanism —
|
|
23
|
+
* a file is only reported stable after it hasn't changed for debounceMs.
|
|
24
|
+
* This handles VSCode auto-save correctly: rapid saves are coalesced.
|
|
25
|
+
*
|
|
26
|
+
* Watch targets:
|
|
27
|
+
* - amxmodx/scripting/**/*.sma → onSmaChange(absPath)
|
|
28
|
+
* - amxmodx/** (non-.sma) → onFileChange(relPath, 'amxmodx')
|
|
29
|
+
* - assets/** → onFileChange(relPath, 'assets')
|
|
30
|
+
* - amxbuild.yml / amxbuild.yaml → onManifestChange()
|
|
31
|
+
*
|
|
32
|
+
* Returns the chokidar watcher instance.
|
|
33
|
+
*/
|
|
34
|
+
function startWatch(manifest, manifestPath, handlers) {
|
|
35
|
+
const chokidar = require('chokidar');
|
|
36
|
+
|
|
37
|
+
const debounceMs = manifest.deploy.watch_debounce_ms;
|
|
38
|
+
const manifestDir = path.dirname(path.resolve(manifestPath));
|
|
39
|
+
|
|
40
|
+
const localAmxmodxDir = path.join(manifestDir, manifest.amxmodx.dir);
|
|
41
|
+
const localAssetsDir = path.join(manifestDir, 'assets');
|
|
42
|
+
|
|
43
|
+
const watchPaths = [path.resolve(manifestPath)];
|
|
44
|
+
if (fs.existsSync(localAmxmodxDir)) watchPaths.push(localAmxmodxDir);
|
|
45
|
+
if (fs.existsSync(localAssetsDir)) watchPaths.push(localAssetsDir);
|
|
46
|
+
|
|
47
|
+
logger.info('Watching for changes (Ctrl+C to stop)...');
|
|
48
|
+
for (const p of watchPaths) logger.dim(` ${p}`);
|
|
49
|
+
|
|
50
|
+
const watcher = chokidar.watch(watchPaths, {
|
|
51
|
+
ignoreInitial: true,
|
|
52
|
+
awaitWriteFinish: {
|
|
53
|
+
stabilityThreshold: debounceMs,
|
|
54
|
+
pollInterval: 100,
|
|
55
|
+
},
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
const caseNorm = process.platform === 'win32' ? (p) => p.toLowerCase() : (p) => p;
|
|
59
|
+
|
|
60
|
+
// Invoke a watch handler without letting a rejection/throw kill the process.
|
|
61
|
+
const safeCall = (fn) => {
|
|
62
|
+
try {
|
|
63
|
+
const p = fn();
|
|
64
|
+
if (p && typeof p.catch === 'function') {
|
|
65
|
+
p.catch((err) => logger.error(`Watch handler error: ${err.message}`));
|
|
66
|
+
}
|
|
67
|
+
} catch (err) {
|
|
68
|
+
logger.error(`Watch handler error: ${err.message}`);
|
|
69
|
+
}
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
watcher.on('all', (event, filePath) => {
|
|
73
|
+
const absPath = path.resolve(filePath);
|
|
74
|
+
const rel = path.relative(manifestDir, absPath);
|
|
75
|
+
|
|
76
|
+
// Deleted file → remove it from the deploy target (Windows rename fires
|
|
77
|
+
// unlink+add, so handling unlink also covers renames of the old name).
|
|
78
|
+
if (event === 'unlink') {
|
|
79
|
+
if (absPath === path.resolve(manifestPath)) return;
|
|
80
|
+
fileHashes.delete(absPath);
|
|
81
|
+
const inAmxmodx = caseNorm(absPath).startsWith(caseNorm(localAmxmodxDir) + path.sep);
|
|
82
|
+
if (!inAmxmodx && !caseNorm(absPath).startsWith(caseNorm(localAssetsDir) + path.sep)) return;
|
|
83
|
+
const section = inAmxmodx ? 'amxmodx' : 'assets';
|
|
84
|
+
const baseDir = inAmxmodx ? localAmxmodxDir : localAssetsDir;
|
|
85
|
+
let relToBase = path.relative(baseDir, absPath);
|
|
86
|
+
if (inAmxmodx && caseNorm(relToBase).endsWith('.inc')) return; // includes are never deployed
|
|
87
|
+
if (inAmxmodx && caseNorm(relToBase).endsWith('.sma')) {
|
|
88
|
+
// A deleted .sma should remove its compiled output, not the source copy.
|
|
89
|
+
relToBase = relToBase.replace(/\.sma$/i, '').replace(/^scripting\//, 'plugins/') + '.amxx';
|
|
90
|
+
}
|
|
91
|
+
if (typeof handlers.onFileDelete === 'function') {
|
|
92
|
+
logger.step(`Deleted: ${rel}`);
|
|
93
|
+
safeCall(() => handlers.onFileDelete(relToBase, section));
|
|
94
|
+
}
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
if (!['add', 'change'].includes(event)) return;
|
|
99
|
+
|
|
100
|
+
if (!contentChanged(absPath)) return;
|
|
101
|
+
|
|
102
|
+
// Manifest changed → full rebuild
|
|
103
|
+
if (absPath === path.resolve(manifestPath)) {
|
|
104
|
+
logger.step(`Manifest changed → full rebuild`);
|
|
105
|
+
safeCall(() => handlers.onManifestChange());
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const inAmxmodx = caseNorm(absPath).startsWith(caseNorm(localAmxmodxDir) + path.sep);
|
|
110
|
+
const section = inAmxmodx ? 'amxmodx' : 'assets';
|
|
111
|
+
const baseDir = inAmxmodx ? localAmxmodxDir : localAssetsDir;
|
|
112
|
+
const relToBase = path.relative(baseDir, absPath);
|
|
113
|
+
|
|
114
|
+
if (inAmxmodx && caseNorm(filePath).endsWith('.sma')) {
|
|
115
|
+
logger.step(`Changed: ${rel}`);
|
|
116
|
+
safeCall(() => handlers.onSmaChange(absPath));
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
if (inAmxmodx && caseNorm(filePath).endsWith('.inc')) {
|
|
121
|
+
logger.step(`Include changed: ${rel}`);
|
|
122
|
+
safeCall(() => handlers.onIncChange(absPath));
|
|
123
|
+
return;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
logger.step(`Changed: ${rel}`);
|
|
127
|
+
safeCall(() => handlers.onFileChange(relToBase, section));
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
watcher.on('error', (err) => logger.warn(`Watcher error: ${err.message}`));
|
|
131
|
+
|
|
132
|
+
return watcher;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
module.exports = { startWatch };
|