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,44 @@
|
|
|
1
|
+
const fs = require('fs');
|
|
2
|
+
const path = require('path');
|
|
3
|
+
const logger = require('./logger');
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Groups compiled plugins by plugins_ini_postfix and writes .ini files
|
|
7
|
+
* into build/amxmodx/configs/.
|
|
8
|
+
*/
|
|
9
|
+
function buildIniFiles(compiledPlugins, buildDir) {
|
|
10
|
+
if (!compiledPlugins.length) return;
|
|
11
|
+
|
|
12
|
+
const configsDir = path.join(buildDir, 'amxmodx', 'configs');
|
|
13
|
+
fs.mkdirSync(configsDir, { recursive: true });
|
|
14
|
+
|
|
15
|
+
const groups = new Map(); // postfix → [plugin]
|
|
16
|
+
for (const plugin of compiledPlugins) {
|
|
17
|
+
if (plugin.skipIni) continue;
|
|
18
|
+
const k = plugin.plugins_ini_postfix;
|
|
19
|
+
if (!groups.has(k)) groups.set(k, []);
|
|
20
|
+
groups.get(k).push(plugin);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
for (const [postfix, plugins] of groups) {
|
|
24
|
+
const iniName = postfix ? `plugins-${postfix}.ini` : 'plugins.ini';
|
|
25
|
+
const iniPath = path.join(configsDir, iniName);
|
|
26
|
+
|
|
27
|
+
const lines = ['; Generated by amxx-builder'];
|
|
28
|
+
let lastRepo = null;
|
|
29
|
+
for (const p of plugins) {
|
|
30
|
+
const repoId = `${p.repo} @ ${p.ref}`;
|
|
31
|
+
if (repoId !== lastRepo) {
|
|
32
|
+
lines.push('', `; Source: ${repoId}`);
|
|
33
|
+
lastRepo = repoId;
|
|
34
|
+
}
|
|
35
|
+
lines.push(p.amxxName);
|
|
36
|
+
}
|
|
37
|
+
lines.push('');
|
|
38
|
+
|
|
39
|
+
fs.writeFileSync(iniPath, lines.join('\n'), 'utf8');
|
|
40
|
+
logger.info(`Generated: ${iniName} (${plugins.length} ${plugins.length === 1 ? 'entry' : 'entries'})`);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
module.exports = { buildIniFiles };
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
'use strict';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Generic JSON-RPC 2.0 server over stdio.
|
|
6
|
+
*
|
|
7
|
+
* Line-delimited JSON-RPC 2.0: each line on stdin is one request, response or
|
|
8
|
+
* notification; each response/notification is written as one JSON line on
|
|
9
|
+
* stdout. This is pure transport — it knows nothing about MCP, protocols,
|
|
10
|
+
* or domain methods. Method semantics live in the adapter layer (e.g.
|
|
11
|
+
* mcp/mcp-server.js), which registers request/notification handlers.
|
|
12
|
+
*
|
|
13
|
+
* No external dependencies.
|
|
14
|
+
*
|
|
15
|
+
* Usage:
|
|
16
|
+
* const { JsonRpcServer } = require('./jsonrpc-transport');
|
|
17
|
+
* const rpc = new JsonRpcServer();
|
|
18
|
+
* rpc.onRequest('greet', (params) => ({ hello: params?.name || 'world' }));
|
|
19
|
+
* rpc.onRequest('fail', () => { const e = new Error('nope'); e.code = -32000; throw e; });
|
|
20
|
+
* rpc.onNotification('log', (params) => console.error('log:', params));
|
|
21
|
+
* rpc.notify('event', { kind: 'done' }); // server → client push
|
|
22
|
+
* rpc.connect();
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
const readline = require('readline');
|
|
26
|
+
|
|
27
|
+
class JsonRpcServer {
|
|
28
|
+
constructor() {
|
|
29
|
+
this._requests = new Map();
|
|
30
|
+
this._notifications = new Map();
|
|
31
|
+
this._rl = null;
|
|
32
|
+
this._closed = false;
|
|
33
|
+
// EPIPE when the client dies — exit cleanly instead of crashing.
|
|
34
|
+
process.stdout.on('error', () => process.exit(0));
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Register a handler for a request method (messages WITH id).
|
|
39
|
+
* Handler receives the decoded params object; may be async.
|
|
40
|
+
* Its return value is sent as `result`. A thrown Error is sent as
|
|
41
|
+
* `error` with code -32603 (message = err.message), unless the Error
|
|
42
|
+
* carries a numeric `.code` property, which is then used verbatim.
|
|
43
|
+
* Returns this for chaining.
|
|
44
|
+
*/
|
|
45
|
+
onRequest(method, handler) {
|
|
46
|
+
this._requests.set(method, handler);
|
|
47
|
+
return this;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Register a handler for a notification method (messages WITHOUT id).
|
|
52
|
+
* Handler receives the decoded params object; may be async. Errors are
|
|
53
|
+
* swallowed and logged to stderr (never sent — there is no id to reply to).
|
|
54
|
+
* Returns this for chaining.
|
|
55
|
+
*/
|
|
56
|
+
onNotification(method, handler) {
|
|
57
|
+
this._notifications.set(method, handler);
|
|
58
|
+
return this;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Start listening on stdin. Never resolves (runs until close/EOF).
|
|
63
|
+
*/
|
|
64
|
+
async connect() {
|
|
65
|
+
this._rl = readline.createInterface({
|
|
66
|
+
input: process.stdin,
|
|
67
|
+
terminal: false,
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
for await (const line of this._rl) {
|
|
71
|
+
if (this._closed) break;
|
|
72
|
+
if (!line.trim()) continue;
|
|
73
|
+
|
|
74
|
+
let msg;
|
|
75
|
+
try {
|
|
76
|
+
msg = JSON.parse(line);
|
|
77
|
+
} catch (_) {
|
|
78
|
+
// JSON parse error — try to extract id from the raw line
|
|
79
|
+
const id = this._extractId(line);
|
|
80
|
+
if (id != null) {
|
|
81
|
+
this.sendError(id, -32700, 'Parse error');
|
|
82
|
+
}
|
|
83
|
+
continue;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// Dispatch without awaiting so a long-running handler does not block
|
|
87
|
+
// subsequent notifications/requests. stdout writes are queued by Node,
|
|
88
|
+
// so per-message response order is preserved.
|
|
89
|
+
Promise.resolve()
|
|
90
|
+
.then(() => this._handleMessage(msg))
|
|
91
|
+
.catch((err) => {
|
|
92
|
+
if (msg.id != null) {
|
|
93
|
+
this.sendError(msg.id, -32603, 'Internal error: ' + err.message);
|
|
94
|
+
}
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// stdin EOF — client closed the pipe; exit so we don't hang on open stdout.
|
|
99
|
+
process.exit(0);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
close() {
|
|
103
|
+
this._closed = true;
|
|
104
|
+
if (this._rl) this._rl.close();
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// ─── Server → client ─────────────────────────────────────────────────────
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Send a server-initiated notification (no id).
|
|
111
|
+
*/
|
|
112
|
+
notify(method, params) {
|
|
113
|
+
const msg = { jsonrpc: '2.0', method };
|
|
114
|
+
if (params !== undefined) msg.params = params;
|
|
115
|
+
process.stdout.write(JSON.stringify(msg) + '\n');
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Send a successful response for a request id.
|
|
120
|
+
*/
|
|
121
|
+
sendResult(id, result) {
|
|
122
|
+
process.stdout.write(
|
|
123
|
+
JSON.stringify({ jsonrpc: '2.0', id, result }) + '\n'
|
|
124
|
+
);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Send an error response for a request id.
|
|
129
|
+
*/
|
|
130
|
+
sendError(id, code, message, data) {
|
|
131
|
+
const err = { jsonrpc: '2.0', id, error: { code, message } };
|
|
132
|
+
if (data !== undefined) err.error.data = data;
|
|
133
|
+
process.stdout.write(JSON.stringify(err) + '\n');
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// ─── Message dispatch ────────────────────────────────────────────────────
|
|
137
|
+
|
|
138
|
+
async _handleMessage(msg) {
|
|
139
|
+
if (!msg || typeof msg !== 'object' || !msg.method) return;
|
|
140
|
+
|
|
141
|
+
const { method, id, params } = msg;
|
|
142
|
+
|
|
143
|
+
// Notification (no id): fire handler, swallow errors — nothing to reply to.
|
|
144
|
+
if (id == null) {
|
|
145
|
+
const handler = this._notifications.get(method);
|
|
146
|
+
if (handler) {
|
|
147
|
+
try {
|
|
148
|
+
await handler(params);
|
|
149
|
+
} catch (err) {
|
|
150
|
+
process.stderr.write(
|
|
151
|
+
`[jsonrpc] notification "${method}" failed: ${err && err.message ? err.message : String(err)}\n`
|
|
152
|
+
);
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
return;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// Request (has id): reply with result, or an error on throw.
|
|
159
|
+
const handler = this._requests.get(method);
|
|
160
|
+
if (!handler) {
|
|
161
|
+
this.sendError(id, -32601, `Method not found: ${method}`);
|
|
162
|
+
return;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
let result;
|
|
166
|
+
try {
|
|
167
|
+
result = await handler(params);
|
|
168
|
+
} catch (err) {
|
|
169
|
+
if (typeof err.code === 'number') {
|
|
170
|
+
this.sendError(id, err.code, err.message || 'Error');
|
|
171
|
+
} else {
|
|
172
|
+
this.sendError(
|
|
173
|
+
id,
|
|
174
|
+
-32603,
|
|
175
|
+
'Internal error: ' + (err && err.message ? err.message : String(err))
|
|
176
|
+
);
|
|
177
|
+
}
|
|
178
|
+
return;
|
|
179
|
+
}
|
|
180
|
+
this.sendResult(id, result);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* Best-effort extraction of JSON-RPC id from a malformed JSON line.
|
|
185
|
+
*/
|
|
186
|
+
_extractId(raw) {
|
|
187
|
+
try {
|
|
188
|
+
const m = raw.match(/"id"\s*:\s*(\d+|"[^"]+")/);
|
|
189
|
+
if (m) return JSON.parse(m[1]);
|
|
190
|
+
} catch (_) {}
|
|
191
|
+
return null;
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
module.exports = { JsonRpcServer };
|
package/src/logger.js
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
const chalk = require('chalk');
|
|
2
|
+
const { emit, EVENTS } = require('./events');
|
|
3
|
+
|
|
4
|
+
// Respect NO_COLOR env var (https://no-color.org/) and --no-color CLI flag
|
|
5
|
+
const noColor = process.env.NO_COLOR !== undefined
|
|
6
|
+
|| process.argv.includes('--no-color');
|
|
7
|
+
|
|
8
|
+
if (noColor) chalk.level = 0;
|
|
9
|
+
|
|
10
|
+
const PREFIX = chalk.bold.white('[amxx-builder]');
|
|
11
|
+
|
|
12
|
+
let _verbose = false;
|
|
13
|
+
let _stderr = false;
|
|
14
|
+
|
|
15
|
+
function out(msg) {
|
|
16
|
+
if (_stderr) {
|
|
17
|
+
console.error(`${PREFIX} ${msg}`);
|
|
18
|
+
} else {
|
|
19
|
+
console.log(`${PREFIX} ${msg}`);
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function emitLog(level, message) {
|
|
24
|
+
emit(EVENTS.LOG, { level, message });
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const logger = {
|
|
28
|
+
setVerbose: (v) => { _verbose = v; },
|
|
29
|
+
isVerbose: () => _verbose,
|
|
30
|
+
|
|
31
|
+
// MCP: stdout is the JSON-RPC channel — logs must go to stderr
|
|
32
|
+
setStderr: (v = true) => { _stderr = !!v; },
|
|
33
|
+
isStderr: () => _stderr,
|
|
34
|
+
|
|
35
|
+
info: (msg) => { out(msg); emitLog('info', msg); },
|
|
36
|
+
success: (msg) => { out(chalk.green(msg)); emitLog('success', msg); },
|
|
37
|
+
warn: (msg) => { out(chalk.yellow(msg)); emitLog('warn', msg); },
|
|
38
|
+
error: (msg) => { console.error(`${PREFIX} ${chalk.red(msg)}`); emitLog('error', msg); },
|
|
39
|
+
step: (msg) => { out(chalk.cyan(msg)); emitLog('step', msg); },
|
|
40
|
+
skip: (msg) => { out(chalk.gray(msg)); emitLog('skip', msg); },
|
|
41
|
+
dim: (msg) => { out(chalk.dim(msg)); emitLog('dim', msg); },
|
|
42
|
+
verbose: (msg) => { if (_verbose) { out(chalk.dim(msg)); emitLog('verbose', msg); } },
|
|
43
|
+
|
|
44
|
+
// Verbatim passthrough (compiler output): no [amxx-builder] prefix, no color,
|
|
45
|
+
// no added newline. The CLI writes exactly what the producer emitted.
|
|
46
|
+
raw: (text) => { process.stdout.write(text); emitLog('raw', text); },
|
|
47
|
+
rawError: (text) => { process.stderr.write(text); emitLog('rawError', text); },
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
module.exports = logger;
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const fs = require('fs');
|
|
4
|
+
const path = require('path');
|
|
5
|
+
|
|
6
|
+
const MANIFEST_CANDIDATES = ['amxbuild.yml', 'amxbuild.yaml', 'manifest.yml'];
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Resolve the manifest file path from an explicit arg or auto-detection.
|
|
10
|
+
*
|
|
11
|
+
* Single-source-of-truth for manifest discovery. Returns ABSOLUTE paths.
|
|
12
|
+
* No warnings are emitted here — deprecation/fallback rendering is the job of
|
|
13
|
+
* the calling interface (CLI renders the manifest.yml deprecation warning).
|
|
14
|
+
*
|
|
15
|
+
* @param {string} [explicit] - Explicit manifest path (may not exist yet —
|
|
16
|
+
* parseManifest will report it).
|
|
17
|
+
* @returns {{ path: string, usedDefault: boolean }}
|
|
18
|
+
* usedDefault: true when a fallback was chosen — either the deprecated
|
|
19
|
+
* manifest.yml, or the amxbuild.yml default when nothing was found.
|
|
20
|
+
*/
|
|
21
|
+
function resolveManifestPath(explicit) {
|
|
22
|
+
if (explicit) return { path: path.resolve(explicit), usedDefault: false };
|
|
23
|
+
|
|
24
|
+
const cwd = process.cwd();
|
|
25
|
+
for (const name of MANIFEST_CANDIDATES) {
|
|
26
|
+
const p = path.join(cwd, name);
|
|
27
|
+
if (fs.existsSync(p)) {
|
|
28
|
+
return { path: p, usedDefault: name === 'manifest.yml' };
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
return { path: path.join(cwd, 'amxbuild.yml'), usedDefault: true };
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
module.exports = { resolveManifestPath };
|
package/src/manifest.js
ADDED
|
@@ -0,0 +1,373 @@
|
|
|
1
|
+
const fs = require('fs');
|
|
2
|
+
const yaml = require('js-yaml');
|
|
3
|
+
const path = require('path');
|
|
4
|
+
|
|
5
|
+
const { validateManifest: validateSchema } = require('./schema');
|
|
6
|
+
|
|
7
|
+
const DEFAULTS_PATH = path.join(__dirname, '..', 'defaults', 'amxbuild.defaults.yml');
|
|
8
|
+
|
|
9
|
+
function loadDefaultsRaw() {
|
|
10
|
+
if (!fs.existsSync(DEFAULTS_PATH)) return {};
|
|
11
|
+
return yaml.load(fs.readFileSync(DEFAULTS_PATH, 'utf8')) || {};
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function deepMerge(base, overlay) {
|
|
15
|
+
if (overlay === null || overlay === undefined) return base;
|
|
16
|
+
if (base === null || base === undefined) return overlay;
|
|
17
|
+
if (Array.isArray(overlay)) return overlay;
|
|
18
|
+
if (typeof overlay === 'object' && typeof base === 'object') {
|
|
19
|
+
const result = { ...base };
|
|
20
|
+
for (const [k, v] of Object.entries(overlay)) {
|
|
21
|
+
result[k] = deepMerge(base[k], v);
|
|
22
|
+
}
|
|
23
|
+
return result;
|
|
24
|
+
}
|
|
25
|
+
return overlay;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function validateManifest(raw) {
|
|
29
|
+
const result = validateSchema(raw);
|
|
30
|
+
if (!result.valid) {
|
|
31
|
+
const errors = result.errors.map(e => ` ${e.path}: ${e.message}`);
|
|
32
|
+
throw new Error(`Manifest validation failed:\n${errors.join('\n')}`);
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function parseManifest(manifestPath) {
|
|
37
|
+
const absPath = path.resolve(manifestPath);
|
|
38
|
+
if (!fs.existsSync(absPath)) {
|
|
39
|
+
throw new Error(`Manifest not found: ${absPath}\n → Run "amxb init" to create one`);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const projectRaw = yaml.load(fs.readFileSync(absPath, 'utf8'));
|
|
43
|
+
const raw = deepMerge(loadDefaultsRaw(), projectRaw);
|
|
44
|
+
validateManifest(raw);
|
|
45
|
+
|
|
46
|
+
if (!raw.name) throw new Error('manifest: missing required field "name"');
|
|
47
|
+
|
|
48
|
+
const platform = parsePlatform(raw.platform);
|
|
49
|
+
const gh = raw.github || {};
|
|
50
|
+
const tokenEnv = gh.token_env || 'GITHUB_TOKEN';
|
|
51
|
+
const token = process.env[tokenEnv] || null;
|
|
52
|
+
const ssh = !!gh.ssh;
|
|
53
|
+
const tokens = parseTokenMap(gh.tokens);
|
|
54
|
+
|
|
55
|
+
const globalPostfix = raw.plugins_ini_postfix != null ? String(raw.plugins_ini_postfix) : '';
|
|
56
|
+
const globalAmxDir = (raw.amxmodx && raw.amxmodx.dir) || 'amxmodx';
|
|
57
|
+
const globalDeps = parseDepsLines(raw.deps || []);
|
|
58
|
+
|
|
59
|
+
const repos = (raw.repos || []).map((r) => parseRepoEntry(r, globalPostfix, globalAmxDir));
|
|
60
|
+
const output = raw.output || {};
|
|
61
|
+
|
|
62
|
+
return {
|
|
63
|
+
_path: absPath,
|
|
64
|
+
name: raw.name,
|
|
65
|
+
version: parseVersion(raw.version),
|
|
66
|
+
platform,
|
|
67
|
+
amxmodx: {
|
|
68
|
+
version: (raw.amxmodx && raw.amxmodx.version) ? String(raw.amxmodx.version) : null,
|
|
69
|
+
dir: globalAmxDir,
|
|
70
|
+
defines: (raw.amxmodx && Array.isArray(raw.amxmodx.defines))
|
|
71
|
+
? raw.amxmodx.defines.map(String)
|
|
72
|
+
: [],
|
|
73
|
+
},
|
|
74
|
+
github: { token_env: tokenEnv, tokens, token, ssh },
|
|
75
|
+
globalDeps,
|
|
76
|
+
globalPostfix,
|
|
77
|
+
repos,
|
|
78
|
+
assets: parseAssets(raw.assets || {}),
|
|
79
|
+
pluginRules: parsePluginRules(raw.plugins || []),
|
|
80
|
+
deploy: parseDeploy(raw),
|
|
81
|
+
output: {
|
|
82
|
+
dir: String(output.dir),
|
|
83
|
+
archive_name: String(output.archive_name),
|
|
84
|
+
amxmodx_path: String(output.amxmodx_path),
|
|
85
|
+
assets_path: output.assets_path != null ? String(output.assets_path) : '',
|
|
86
|
+
readme: Boolean(output.readme),
|
|
87
|
+
generate_ini: Boolean(output.generate_ini),
|
|
88
|
+
pack: Boolean(output.pack),
|
|
89
|
+
on_conflict: validateOnConflict(output.on_conflict),
|
|
90
|
+
},
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function parseVersion(val) {
|
|
95
|
+
if (typeof val !== 'string') {
|
|
96
|
+
throw new Error(`manifest: "version" must be a string — wrap it in quotes: version: "${val}"`);
|
|
97
|
+
}
|
|
98
|
+
return val;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function validateOnConflict(val) {
|
|
102
|
+
const valid = ['last_wins', 'first_wins', 'error'];
|
|
103
|
+
if (val == null) return 'last_wins';
|
|
104
|
+
if (!valid.includes(val)) {
|
|
105
|
+
throw new Error(`manifest: output.on_conflict must be one of: ${valid.join(', ')}`);
|
|
106
|
+
}
|
|
107
|
+
return val;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function parseTokenMap(map) {
|
|
111
|
+
if (map == null) return {};
|
|
112
|
+
if (typeof map !== 'object' || Array.isArray(map)) {
|
|
113
|
+
throw new Error(`manifest: github.tokens must be a map of owner → env var name`);
|
|
114
|
+
}
|
|
115
|
+
const result = {};
|
|
116
|
+
for (const [owner, envName] of Object.entries(map)) {
|
|
117
|
+
if (envName == null || String(envName).trim() === '') {
|
|
118
|
+
throw new Error(`manifest: github.tokens.${owner} must name an env variable`);
|
|
119
|
+
}
|
|
120
|
+
result[String(owner).trim()] = interpolateEnv(String(envName).trim());
|
|
121
|
+
}
|
|
122
|
+
return result;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Resolve the GitHub token for a specific "owner/repo" path.
|
|
127
|
+
*
|
|
128
|
+
* Priority:
|
|
129
|
+
* 1. github.tokens[owner] — per-owner env var (e.g. GITHUB_TOKEN_ORGA)
|
|
130
|
+
* 2. github.token_env — global token env var (default "GITHUB_TOKEN")
|
|
131
|
+
* 3. null — anonymous (public repos)
|
|
132
|
+
*
|
|
133
|
+
* @param {object} manifest — parsed manifest
|
|
134
|
+
* @param {string} repoPath — "owner/repo" or any string starting with the owner
|
|
135
|
+
* @returns {string|null}
|
|
136
|
+
*/
|
|
137
|
+
function resolveGithubToken(manifest, repoPath) {
|
|
138
|
+
const gh = manifest.github || {};
|
|
139
|
+
const owner = String(repoPath || '').split('/')[0];
|
|
140
|
+
const envName = (gh.tokens && gh.tokens[owner]) || gh.token_env || 'GITHUB_TOKEN';
|
|
141
|
+
return process.env[envName] || null;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function parseRepoEntry(r, globalPostfix, globalAmxDir) {
|
|
145
|
+
// Shorthand: "owner/repo" or "owner/repo@ref"
|
|
146
|
+
if (typeof r === 'string') {
|
|
147
|
+
const atIdx = r.indexOf('@');
|
|
148
|
+
const repo = atIdx === -1 ? r.trim() : r.slice(0, atIdx).trim();
|
|
149
|
+
const ref = atIdx === -1 ? null : r.slice(atIdx + 1).trim() || null;
|
|
150
|
+
return makeRepo({ repo, ref }, globalPostfix, globalAmxDir);
|
|
151
|
+
}
|
|
152
|
+
if (!r.repo) throw new Error(`manifest: repo entry missing "repo" field: ${JSON.stringify(r)}`);
|
|
153
|
+
return makeRepo(r, globalPostfix, globalAmxDir);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function makeRepo(r, globalPostfix, globalAmxDir) {
|
|
157
|
+
return {
|
|
158
|
+
repo: r.repo,
|
|
159
|
+
ref: r.ref || null,
|
|
160
|
+
amxmodx_dir: r.amxmodx_dir || globalAmxDir,
|
|
161
|
+
plugins_ini_postfix: r.plugins_ini_postfix != null ? String(r.plugins_ini_postfix) : globalPostfix,
|
|
162
|
+
exclude: r.exclude || [],
|
|
163
|
+
exclude_files: r.exclude_files || [],
|
|
164
|
+
deps_override: r.deps_override ? parseDepsLines(r.deps_override) : null,
|
|
165
|
+
};
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* Dep string shorthand: "owner/repo@ref[:include_path]".
|
|
170
|
+
* Strict — rejects internal whitespace in the repo and ref parts.
|
|
171
|
+
*/
|
|
172
|
+
const DEP_STRING_RE = /^([^@\s]+)@([^:\s]+)(?::(.+))?$/;
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* Parse a long-form dep object (manifest `deps` entries).
|
|
176
|
+
*
|
|
177
|
+
* @param {object} line
|
|
178
|
+
* @returns {{ repo: string, ref: string, include_path: string|null, source: string, asset: * }}
|
|
179
|
+
*/
|
|
180
|
+
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
|
+
const source = line.source || 'git';
|
|
184
|
+
if (!['git', 'release'].includes(source)) {
|
|
185
|
+
throw new Error(`Dep entry "source" must be "git" or "release": ${JSON.stringify(line)}`);
|
|
186
|
+
}
|
|
187
|
+
return {
|
|
188
|
+
repo: String(line.repo).trim(),
|
|
189
|
+
ref: String(line.ref).trim(),
|
|
190
|
+
include_path: line.include_path ? String(line.include_path).trim() : null,
|
|
191
|
+
source,
|
|
192
|
+
asset: line.asset != null ? line.asset : null,
|
|
193
|
+
};
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* Strict parse of a SINGLE dep string: "owner/repo@ref[:include_path]".
|
|
198
|
+
*
|
|
199
|
+
* @param {string} str
|
|
200
|
+
* @returns {{ repo: string, ref: string, include_path: string|null, source: string, asset: null }}
|
|
201
|
+
*/
|
|
202
|
+
function parseDepString(str) {
|
|
203
|
+
const trimmed = String(str).trim();
|
|
204
|
+
const match = trimmed.match(DEP_STRING_RE);
|
|
205
|
+
if (!trimmed || !match) {
|
|
206
|
+
throw new Error(
|
|
207
|
+
`Invalid dep string: "${trimmed}". Expected format: "owner/repo@ref" or "owner/repo@ref:include_path"`
|
|
208
|
+
);
|
|
209
|
+
}
|
|
210
|
+
const [, repo, ref, includePath] = match;
|
|
211
|
+
return {
|
|
212
|
+
repo: repo.trim(),
|
|
213
|
+
ref: ref.trim(),
|
|
214
|
+
include_path: includePath ? includePath.trim() : null,
|
|
215
|
+
source: 'git',
|
|
216
|
+
asset: null,
|
|
217
|
+
};
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
function parseDepsLines(lines) {
|
|
221
|
+
const result = [];
|
|
222
|
+
for (const line of lines) {
|
|
223
|
+
// Long-form object (manifest only — DEPS_LIST files are always strings)
|
|
224
|
+
if (line && typeof line === 'object') {
|
|
225
|
+
result.push(parseDepObject(line));
|
|
226
|
+
continue;
|
|
227
|
+
}
|
|
228
|
+
// Short-form string: "owner/repo@ref[:include_path]" (always git)
|
|
229
|
+
const trimmed = String(line).trim();
|
|
230
|
+
if (!trimmed || trimmed.startsWith('#')) continue;
|
|
231
|
+
const match = trimmed.match(DEP_STRING_RE);
|
|
232
|
+
if (!match) throw new Error(`Invalid dep entry: "${trimmed}"`);
|
|
233
|
+
const [, repoPath, ref, includePath] = match;
|
|
234
|
+
result.push({
|
|
235
|
+
repo: repoPath.trim(),
|
|
236
|
+
ref: ref.trim(),
|
|
237
|
+
include_path: includePath ? includePath.trim() : null,
|
|
238
|
+
source: 'git',
|
|
239
|
+
asset: null,
|
|
240
|
+
});
|
|
241
|
+
}
|
|
242
|
+
return result;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
function parsePlatform(val) {
|
|
246
|
+
const valid = ['linux', 'windows', 'mac'];
|
|
247
|
+
if (val == null) return null; // null = auto-detect host at runtime
|
|
248
|
+
if (!valid.includes(val)) throw new Error(`manifest: platform must be one of: ${valid.join(', ')}`);
|
|
249
|
+
return val;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
function parseAssets(raw) {
|
|
253
|
+
const valid = ['last_wins', 'first_wins'];
|
|
254
|
+
const onConflict = raw.on_conflict || 'last_wins';
|
|
255
|
+
if (!valid.includes(onConflict)) {
|
|
256
|
+
throw new Error(`manifest: assets.on_conflict must be one of: ${valid.join(', ')}`);
|
|
257
|
+
}
|
|
258
|
+
return {
|
|
259
|
+
on_conflict: onConflict,
|
|
260
|
+
sources: (raw.sources || []).map(parseAssetSource),
|
|
261
|
+
};
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
function parseAssetSource(s) {
|
|
265
|
+
if (s.source === 'local') {
|
|
266
|
+
return { type: 'local', map: parseAssetMap(s) };
|
|
267
|
+
}
|
|
268
|
+
if (s.source === 'amxmodx') {
|
|
269
|
+
return { type: 'amxmodx', map: parseAssetMap(s), cache: parseAssetCache(s.cache) };
|
|
270
|
+
}
|
|
271
|
+
if (s.source === 'release') {
|
|
272
|
+
if (!s.repo) throw new Error(`asset source: release requires "repo": ${JSON.stringify(s)}`);
|
|
273
|
+
if (!s.ref) throw new Error(`asset source: release requires "ref": ${JSON.stringify(s)}`);
|
|
274
|
+
return {
|
|
275
|
+
type: 'release',
|
|
276
|
+
repo: String(s.repo).trim(),
|
|
277
|
+
ref: String(s.ref).trim(),
|
|
278
|
+
asset: s.asset != null ? s.asset : null,
|
|
279
|
+
map: parseAssetMap(s),
|
|
280
|
+
};
|
|
281
|
+
}
|
|
282
|
+
if (!s.url) throw new Error(`asset source missing "url" or "source": ${JSON.stringify(s)}`);
|
|
283
|
+
return { type: 'url', url: s.url, map: parseAssetMap(s), cache: parseAssetCache(s.cache) };
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
function parseAssetMap(s) {
|
|
287
|
+
if (s.map) return s.map.map(e => ({ from: e.from || null, to: e.to || null }));
|
|
288
|
+
return [{ from: s.from || null, to: s.to || null }];
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
function parseAssetCache(val) {
|
|
292
|
+
const valid = ['none', 'local', 'global'];
|
|
293
|
+
if (val == null) return 'none';
|
|
294
|
+
if (!valid.includes(val)) throw new Error(`asset source cache must be one of: ${valid.join(', ')}`);
|
|
295
|
+
return val;
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
function parsePluginRules(rules) {
|
|
299
|
+
if (!Array.isArray(rules)) return [];
|
|
300
|
+
return rules.map((r, i) => {
|
|
301
|
+
if (!r.match) throw new Error(`plugins[${i}]: missing "match" field`);
|
|
302
|
+
const ini = r.ini === false ? false : (r.ini != null ? String(r.ini) : null);
|
|
303
|
+
return {
|
|
304
|
+
match: String(r.match),
|
|
305
|
+
enabled: r.enabled !== false,
|
|
306
|
+
ini,
|
|
307
|
+
};
|
|
308
|
+
});
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
function interpolateEnv(val) {
|
|
312
|
+
if (typeof val !== 'string') return val;
|
|
313
|
+
return val.replace(/\$\{([^}]+)\}/g, (_, name) => process.env[name] ?? '');
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
function parseDeploy(raw) {
|
|
317
|
+
const d = raw.deploy || {};
|
|
318
|
+
const r = d.rcon || {};
|
|
319
|
+
return {
|
|
320
|
+
path: interpolateEnv(d.path) || process.env.AMXB_DEPLOY_PATH || null,
|
|
321
|
+
amxmodx_path: interpolateEnv(d.amxmodx_path) || null,
|
|
322
|
+
assets_path: interpolateEnv(d.assets_path) ?? null,
|
|
323
|
+
watch_debounce_ms: Number(d.watch_debounce_ms),
|
|
324
|
+
exclude: Array.isArray(d.exclude) ? d.exclude.map(String) : [],
|
|
325
|
+
rcon: {
|
|
326
|
+
host: interpolateEnv(r.host) || process.env.AMXB_DEPLOY_RCON_HOST || null,
|
|
327
|
+
port: Number(r.port || process.env.AMXB_DEPLOY_RCON_PORT),
|
|
328
|
+
password: interpolateEnv(r.password) || process.env.AMXB_DEPLOY_RCON_PASSWORD || null,
|
|
329
|
+
command: interpolateEnv(r.command) || process.env.AMXB_DEPLOY_RCON_CMD || null,
|
|
330
|
+
},
|
|
331
|
+
};
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
// ─── Manifest overrides ────────────────────────────────────────────────────────
|
|
335
|
+
|
|
336
|
+
function applyOverrides(manifest, pairs) {
|
|
337
|
+
for (const pair of pairs) {
|
|
338
|
+
const eqIdx = pair.indexOf('=');
|
|
339
|
+
if (eqIdx === -1) throw new Error(`--set: invalid format "${pair}" (expected key=value)`);
|
|
340
|
+
const keys = pair.slice(0, eqIdx).trim().split('.');
|
|
341
|
+
const value = parseOverrideValue(pair.slice(eqIdx + 1));
|
|
342
|
+
let node = manifest;
|
|
343
|
+
for (let i = 0; i < keys.length - 1; i++) {
|
|
344
|
+
if (node[keys[i]] == null) node[keys[i]] = {};
|
|
345
|
+
node = node[keys[i]];
|
|
346
|
+
}
|
|
347
|
+
node[keys[keys.length - 1]] = value;
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
function parseOverrideValue(str) {
|
|
352
|
+
if (str === 'true') return true;
|
|
353
|
+
if (str === 'false') return false;
|
|
354
|
+
if (str === 'null') return null;
|
|
355
|
+
if (/^\d+$/.test(str)) return parseInt(str, 10);
|
|
356
|
+
return str;
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
function resolveManifest(manifestPath, options = {}) {
|
|
360
|
+
const manifest = parseManifest(manifestPath);
|
|
361
|
+
|
|
362
|
+
if (options.set && options.set.length > 0) {
|
|
363
|
+
applyOverrides(manifest, options.set);
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
if (options.define && options.define.length > 0) {
|
|
367
|
+
manifest.amxmodx.defines.push(...options.define);
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
return manifest;
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
module.exports = { parseManifest, parseDepsLines, parseDepString, parseDepObject, applyOverrides, parseOverrideValue, resolveManifest, resolveGithubToken, loadDefaultsRaw, deepMerge };
|