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
package/src/progress.js
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { emit, EVENTS } = require('./events');
|
|
4
|
+
|
|
5
|
+
const BAR_LEN = 20;
|
|
6
|
+
|
|
7
|
+
function formatBar(ratio) {
|
|
8
|
+
const filled = Math.round(ratio * BAR_LEN);
|
|
9
|
+
return '\u2588'.repeat(filled) + '\u2591'.repeat(BAR_LEN - filled);
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function formatPct(ratio) {
|
|
13
|
+
const pct = Math.round(ratio * 100);
|
|
14
|
+
return `${pct}%`.padStart(4);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
let _enabled = true;
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Disable progress bars entirely — createBar() returns a noop.
|
|
21
|
+
* Used by the MCP server: bars write \r/control chars to stdout,
|
|
22
|
+
* which would corrupt the JSON-RPC stream.
|
|
23
|
+
*/
|
|
24
|
+
function setEnabled(v) {
|
|
25
|
+
_enabled = !!v;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Simple in-place progress bar using only \r (carriage return).
|
|
30
|
+
* Works in all terminals — no ANSI escape sequences.
|
|
31
|
+
*
|
|
32
|
+
* Designed for downloads and archiving where there is no
|
|
33
|
+
* interleaved stdout output. Each update overwrites the same line.
|
|
34
|
+
*/
|
|
35
|
+
function createBar(total, label) {
|
|
36
|
+
if (!_enabled) return { update() {}, stop() {} };
|
|
37
|
+
|
|
38
|
+
const stream = process.stdout;
|
|
39
|
+
let lastLen = 0;
|
|
40
|
+
|
|
41
|
+
function writeLine(val) {
|
|
42
|
+
const ratio = val / total;
|
|
43
|
+
const bar = formatBar(ratio);
|
|
44
|
+
const pct = formatPct(ratio);
|
|
45
|
+
const line = `${label} ${bar} ${pct} (${val}/${total})`;
|
|
46
|
+
|
|
47
|
+
if (lastLen > 0) stream.write('\r' + ' '.repeat(lastLen) + '\r');
|
|
48
|
+
stream.write(line);
|
|
49
|
+
lastLen = line.length;
|
|
50
|
+
|
|
51
|
+
emit(EVENTS.PROGRESS, { label, current: val, total });
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
writeLine(0);
|
|
55
|
+
|
|
56
|
+
return {
|
|
57
|
+
update(val) { writeLine(val); },
|
|
58
|
+
stop() {
|
|
59
|
+
if (lastLen > 0) stream.write('\r' + ' '.repeat(lastLen) + '\r');
|
|
60
|
+
stream.write('\n');
|
|
61
|
+
emit(EVENTS.PROGRESS, { label, current: total, total, done: true });
|
|
62
|
+
},
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
module.exports = { createBar, setEnabled };
|
package/src/rcon.js
ADDED
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Minimal GoldSrc (HL1 / CS 1.6) UDP RCON client.
|
|
5
|
+
*
|
|
6
|
+
* Protocol:
|
|
7
|
+
* 1. Client → Server: \xff\xff\xff\xff challenge rcon\n
|
|
8
|
+
* 2. Server → Client: \xff\xff\xff\xff challenge rcon <number>\n
|
|
9
|
+
* 3. Client → Server: \xff\xff\xff\xff rcon <number> "<password>" "<command>"\n
|
|
10
|
+
* 4. Server → Client: \xff\xff\xff\xff <response>\n
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
const dgram = require('dgram');
|
|
14
|
+
const logger = require('./logger');
|
|
15
|
+
|
|
16
|
+
const HL_HEADER = Buffer.from([0xff, 0xff, 0xff, 0xff]);
|
|
17
|
+
|
|
18
|
+
function makePacket(str) {
|
|
19
|
+
return Buffer.concat([HL_HEADER, Buffer.from(str + '\n', 'utf8')]);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
async function sendRcon({ host, port, password, command }, timeoutMs = 5000) {
|
|
23
|
+
if (typeof password === 'string' && password.includes('"')) {
|
|
24
|
+
throw new Error('RCON password must not contain double quotes');
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
return new Promise((resolve, reject) => {
|
|
28
|
+
const sock = dgram.createSocket('udp4');
|
|
29
|
+
let settled = false;
|
|
30
|
+
|
|
31
|
+
const done = (err, result) => {
|
|
32
|
+
if (settled) return;
|
|
33
|
+
settled = true;
|
|
34
|
+
clearTimeout(timer);
|
|
35
|
+
sock.close(() => {});
|
|
36
|
+
err ? reject(err) : resolve(result);
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
const timer = setTimeout(
|
|
40
|
+
() => done(new Error(`RCON timeout (${host}:${port})`)),
|
|
41
|
+
timeoutMs
|
|
42
|
+
);
|
|
43
|
+
|
|
44
|
+
sock.on('error', done);
|
|
45
|
+
|
|
46
|
+
sock.on('message', (buf) => {
|
|
47
|
+
if (buf.length < 5) return;
|
|
48
|
+
// Strip null bytes (GoldSrc packets are null-terminated) then whitespace
|
|
49
|
+
const body = buf.slice(4).toString('utf8').replace(/\0/g, '').trim();
|
|
50
|
+
|
|
51
|
+
if (body.startsWith('challenge rcon ')) {
|
|
52
|
+
const challenge = body.split(' ')[2];
|
|
53
|
+
const cmd = makePacket(`rcon ${challenge} "${password}" "${command}"`);
|
|
54
|
+
sock.send(cmd, 0, cmd.length, port, host);
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// All non-challenge responses are print packets with a 1-byte type prefix — strip it
|
|
59
|
+
const text = body.slice(1).trim();
|
|
60
|
+
logger.verbose(` RCON ← ${text}`);
|
|
61
|
+
done(null, text);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
const req = makePacket('challenge rcon');
|
|
65
|
+
sock.send(req, 0, req.length, port, host);
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Sends the deploy.rcon.command with {plugin} interpolated.
|
|
71
|
+
* No-ops silently if RCON is not configured.
|
|
72
|
+
*/
|
|
73
|
+
async function sendRconCommand(deployConfig, pluginName) {
|
|
74
|
+
const { host, port, password, command } = deployConfig.rcon;
|
|
75
|
+
if (!command || !host || !password) return;
|
|
76
|
+
|
|
77
|
+
const cmd = command.replace(/\{plugin\}/g, pluginName);
|
|
78
|
+
logger.step(`RCON → ${cmd}`);
|
|
79
|
+
try {
|
|
80
|
+
const response = await sendRcon({ host, port, password, command: cmd });
|
|
81
|
+
if (response) logger.dim(` RCON response: ${response}`);
|
|
82
|
+
} catch (err) {
|
|
83
|
+
logger.warn(`RCON failed: ${err.message}`);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Sends RCON after deploying one or more plugins.
|
|
89
|
+
* If the command contains {plugin} — sends once per plugin name.
|
|
90
|
+
* Otherwise — sends the command once regardless of how many plugins were deployed.
|
|
91
|
+
*/
|
|
92
|
+
async function sendRconForPlugins(deployConfig, pluginNames) {
|
|
93
|
+
const command = deployConfig.rcon && deployConfig.rcon.command;
|
|
94
|
+
if (!command) return;
|
|
95
|
+
|
|
96
|
+
if (command.includes('{plugin}')) {
|
|
97
|
+
await Promise.all(pluginNames.map((name) => sendRconCommand(deployConfig, name)));
|
|
98
|
+
} else {
|
|
99
|
+
await sendRconCommand(deployConfig, '');
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
module.exports = { sendRcon, sendRconCommand, sendRconForPlugins };
|
|
@@ -0,0 +1,206 @@
|
|
|
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 AdmZip = require('adm-zip');
|
|
7
|
+
const chalk = require('chalk');
|
|
8
|
+
const logger = require('./logger');
|
|
9
|
+
const { getCacheDir } = require('./cache-dir');
|
|
10
|
+
const { safeExtractTar } = require('./fs-utils');
|
|
11
|
+
const { withRetry } = require('./retry');
|
|
12
|
+
const { resolveRefIfLatest } = require('./repo-fetcher');
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Downloads a GitHub release asset and extracts it locally.
|
|
16
|
+
* Returns the path to the directory that should be used as an include dir.
|
|
17
|
+
*
|
|
18
|
+
* Cache: <CACHE_DIR>/release-deps/<owner>__<repo>__<tag>/
|
|
19
|
+
*
|
|
20
|
+
* Asset selection (dep.asset):
|
|
21
|
+
* null / undefined — first .zip asset, falling back to assets[0]
|
|
22
|
+
* number — assets[N] by index
|
|
23
|
+
* string — first asset whose name matches the glob pattern (*, ?)
|
|
24
|
+
*/
|
|
25
|
+
async function fetchReleaseDep(dep, token, noFetch) {
|
|
26
|
+
const { repo, ref, include_path, asset: assetSelector } = dep;
|
|
27
|
+
const cacheDir = await ensureReleaseCacheDir(repo, ref, assetSelector, token, noFetch, 'Release dep');
|
|
28
|
+
return resolveIncludePath(cacheDir, include_path, repo);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Ensures the release is downloaded and extracted; returns the cache dir root.
|
|
33
|
+
* Used by asset-fetcher for source: release — shares the same cache as deps.
|
|
34
|
+
*/
|
|
35
|
+
async function getReleaseCacheDir(source, token, noFetch) {
|
|
36
|
+
const { repo, ref, asset: assetSelector } = source;
|
|
37
|
+
return ensureReleaseCacheDir(repo, ref, assetSelector, token, noFetch, 'Release asset');
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
async function ensureReleaseCacheDir(repo, ref, assetSelector, token, noFetch, label) {
|
|
41
|
+
const resolvedRef = await resolveRefIfLatest(ref, repo, token);
|
|
42
|
+
// Include the asset selector in the key: the same repo@tag with different
|
|
43
|
+
// assets must not share a cache dir (first-extracted content would win).
|
|
44
|
+
const assetKey = assetSelector == null ? '' : '--' + String(assetSelector).replace(/[^a-zA-Z0-9._-]/g, '_');
|
|
45
|
+
// Lazy require: deps-resolver imports us, so a top-level import would cycle.
|
|
46
|
+
const { normalize } = require('./deps-resolver');
|
|
47
|
+
const cacheKey = normalize(repo).replace('/', '__') + '__' +
|
|
48
|
+
resolvedRef.replace(/[^a-zA-Z0-9._-]/g, '_') + assetKey;
|
|
49
|
+
const cacheDir = path.join(getCacheDir(), 'release-deps', cacheKey);
|
|
50
|
+
const sentinelFile = path.join(cacheDir, '.extracted');
|
|
51
|
+
|
|
52
|
+
if (fs.existsSync(sentinelFile)) {
|
|
53
|
+
logger.dim(` ${repo}@${resolvedRef} (release, cached)`);
|
|
54
|
+
return cacheDir;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
if (noFetch) {
|
|
58
|
+
throw new Error(
|
|
59
|
+
`Release dep cache missing for ${repo}@${resolvedRef} and --no-fetch is set.\n` +
|
|
60
|
+
`Run without --no-fetch to populate the cache.`
|
|
61
|
+
);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
logger.step(`${label}: ${repo} @ ${resolvedRef}`);
|
|
65
|
+
|
|
66
|
+
const headers = buildHeaders(token);
|
|
67
|
+
const release = await fetchRelease(repo, resolvedRef, headers);
|
|
68
|
+
const asset = selectAsset(release.assets, assetSelector, repo);
|
|
69
|
+
|
|
70
|
+
logger.dim(` Asset: ${asset.name}`);
|
|
71
|
+
|
|
72
|
+
fs.mkdirSync(cacheDir, { recursive: true });
|
|
73
|
+
const archivePath = path.join(cacheDir, asset.name);
|
|
74
|
+
|
|
75
|
+
await downloadAsset(asset.browser_download_url, archivePath, headers);
|
|
76
|
+
extractArchive(archivePath, cacheDir);
|
|
77
|
+
fs.rmSync(archivePath, { force: true });
|
|
78
|
+
const sentinelTmp = sentinelFile + '.tmp';
|
|
79
|
+
fs.writeFileSync(sentinelTmp, resolvedRef, 'utf8');
|
|
80
|
+
fs.renameSync(sentinelTmp, sentinelFile);
|
|
81
|
+
|
|
82
|
+
logger.info(`${label}: ${repo}@${resolvedRef} ready`);
|
|
83
|
+
return cacheDir;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
async function fetchRelease(repo, tag, headers) {
|
|
87
|
+
try {
|
|
88
|
+
const { data } = await axios.get(
|
|
89
|
+
`https://api.github.com/repos/${repo}/releases/tags/${tag}`,
|
|
90
|
+
{ headers }
|
|
91
|
+
);
|
|
92
|
+
return data;
|
|
93
|
+
} catch (err) {
|
|
94
|
+
throw new Error(`Failed to fetch release "${tag}" for ${repo}: ${err.message}`);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function selectAsset(assets, selector, repo) {
|
|
99
|
+
if (!assets || !assets.length) {
|
|
100
|
+
throw new Error(`Release for ${repo} has no assets`);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
if (selector == null) {
|
|
104
|
+
// Default: first .zip, then anything
|
|
105
|
+
return assets.find((a) => a.name.endsWith('.zip')) || assets[0];
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
if (typeof selector === 'number') {
|
|
109
|
+
if (selector >= assets.length) {
|
|
110
|
+
throw new Error(
|
|
111
|
+
`Asset index ${selector} out of range for ${repo} — ` +
|
|
112
|
+
`release has ${assets.length} asset(s)`
|
|
113
|
+
);
|
|
114
|
+
}
|
|
115
|
+
return assets[selector];
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// String glob pattern
|
|
119
|
+
const matched = assets.find((a) => matchGlob(selector, a.name));
|
|
120
|
+
if (!matched) {
|
|
121
|
+
throw new Error(
|
|
122
|
+
`No asset matching "${selector}" in release for ${repo}.\n` +
|
|
123
|
+
`Available: ${assets.map((a) => a.name).join(', ')}`
|
|
124
|
+
);
|
|
125
|
+
}
|
|
126
|
+
return matched;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function matchGlob(pattern, name) {
|
|
130
|
+
const re = new RegExp(
|
|
131
|
+
'^' +
|
|
132
|
+
pattern.replace(/[.+^${}()|[\]\\]/g, '\\$&').replace(/\*/g, '.*').replace(/\?/g, '.') +
|
|
133
|
+
'$'
|
|
134
|
+
);
|
|
135
|
+
return re.test(name);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function resolveIncludePath(cacheDir, includePath, repo) {
|
|
139
|
+
if (includePath) {
|
|
140
|
+
const full = path.join(cacheDir, includePath);
|
|
141
|
+
if (!fs.existsSync(full)) {
|
|
142
|
+
throw new Error(
|
|
143
|
+
`include_path "${includePath}" not found in extracted release for ${repo}`
|
|
144
|
+
);
|
|
145
|
+
}
|
|
146
|
+
return full;
|
|
147
|
+
}
|
|
148
|
+
// Auto-detect standard AMXX layouts
|
|
149
|
+
for (const candidate of ['addons/amxmodx/scripting/include', 'scripting/include', 'include']) {
|
|
150
|
+
const full = path.join(cacheDir, candidate);
|
|
151
|
+
if (fs.existsSync(full)) return full;
|
|
152
|
+
}
|
|
153
|
+
return cacheDir;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function buildHeaders(token) {
|
|
157
|
+
const h = { Accept: 'application/vnd.github+json' };
|
|
158
|
+
if (token) h['Authorization'] = `Bearer ${token}`;
|
|
159
|
+
return h;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
async function downloadAsset(url, dest, headers) {
|
|
163
|
+
const filename = path.basename(url);
|
|
164
|
+
const bar = require('./progress').createBar(100, ` ${chalk.cyan('Downloading')} ${(filename || 'file').padEnd(30)}`);
|
|
165
|
+
|
|
166
|
+
const response = await withRetry(
|
|
167
|
+
() => axios.get(url, {
|
|
168
|
+
headers: { ...headers, Accept: 'application/octet-stream' },
|
|
169
|
+
responseType: 'arraybuffer',
|
|
170
|
+
maxRedirects: 5,
|
|
171
|
+
timeout: 600000, // large assets — allow slow links, still bound hangs
|
|
172
|
+
onDownloadProgress: (e) => {
|
|
173
|
+
if (bar && e.total) {
|
|
174
|
+
bar.update(Math.round(e.loaded / e.total * 100));
|
|
175
|
+
}
|
|
176
|
+
},
|
|
177
|
+
}),
|
|
178
|
+
{ label: filename }
|
|
179
|
+
);
|
|
180
|
+
if (bar) bar.stop();
|
|
181
|
+
const part = dest + '.part';
|
|
182
|
+
fs.writeFileSync(part, Buffer.from(response.data));
|
|
183
|
+
fs.renameSync(part, dest);
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
function extractArchive(archivePath, destDir) {
|
|
187
|
+
if (archivePath.endsWith('.zip') || hasZipMagic(archivePath)) {
|
|
188
|
+
new AdmZip(archivePath).extractAllTo(destDir, true);
|
|
189
|
+
} else {
|
|
190
|
+
safeExtractTar(archivePath, destDir);
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// ZIP archives start with "PK" — sniff the bytes so an asset named without
|
|
195
|
+
// the .zip extension (redirects, CDNs) is still extracted correctly.
|
|
196
|
+
function hasZipMagic(filePath) {
|
|
197
|
+
try {
|
|
198
|
+
const fd = fs.openSync(filePath, 'r');
|
|
199
|
+
const buf = Buffer.alloc(2);
|
|
200
|
+
fs.readSync(fd, buf, 0, 2, 0);
|
|
201
|
+
fs.closeSync(fd);
|
|
202
|
+
return buf[0] === 0x50 && buf[1] === 0x4b;
|
|
203
|
+
} catch { return false; }
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
module.exports = { fetchReleaseDep, getReleaseCacheDir, downloadAsset };
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const axios = require('axios');
|
|
4
|
+
// Default for API calls; download sites pass their own longer timeout.
|
|
5
|
+
axios.defaults.timeout = 30000;
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* List releases for a GitHub repository.
|
|
9
|
+
*
|
|
10
|
+
* @param {string} repo — "owner/repo"
|
|
11
|
+
* @param {Object} [options]
|
|
12
|
+
* @param {string} [options.token] — GitHub PAT
|
|
13
|
+
* @param {number} [options.limit=10] — max releases to return
|
|
14
|
+
* @param {boolean} [options.includeAssets] — include asset details per release
|
|
15
|
+
* @returns {Promise<Object[]>}
|
|
16
|
+
*/
|
|
17
|
+
async function listReleases(repo, options = {}) {
|
|
18
|
+
const { token, limit = 10, includeAssets = false } = options;
|
|
19
|
+
const headers = buildHeaders(token);
|
|
20
|
+
const perPage = Math.min(100, Math.max(1, limit)); // GitHub caps per_page at 100
|
|
21
|
+
|
|
22
|
+
const { data } = await axios.get(
|
|
23
|
+
`https://api.github.com/repos/${repo}/releases?per_page=${perPage}`,
|
|
24
|
+
{ headers }
|
|
25
|
+
);
|
|
26
|
+
|
|
27
|
+
return data.map((r) => {
|
|
28
|
+
const entry = {
|
|
29
|
+
tagName: r.tag_name,
|
|
30
|
+
name: r.name || r.tag_name,
|
|
31
|
+
publishedAt: r.published_at,
|
|
32
|
+
prerelease: r.prerelease || false,
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
if (includeAssets && r.assets && r.assets.length > 0) {
|
|
36
|
+
entry.assets = r.assets.map((a) => ({
|
|
37
|
+
name: a.name,
|
|
38
|
+
size: a.size,
|
|
39
|
+
downloadCount: a.download_count,
|
|
40
|
+
}));
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
return entry;
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* List git tags for a GitHub repository.
|
|
49
|
+
* Useful for repos that don't publish GitHub Releases.
|
|
50
|
+
*
|
|
51
|
+
* @param {string} repo — "owner/repo"
|
|
52
|
+
* @param {Object} [options]
|
|
53
|
+
* @param {string} [options.token] — GitHub PAT
|
|
54
|
+
* @param {number} [options.limit=10] — max tags to return
|
|
55
|
+
* @returns {Promise<Object[]>}
|
|
56
|
+
*/
|
|
57
|
+
async function listTags(repo, options = {}) {
|
|
58
|
+
const { token, limit = 10 } = options;
|
|
59
|
+
const headers = buildHeaders(token);
|
|
60
|
+
const perPage = Math.min(100, Math.max(1, limit)); // GitHub caps per_page at 100
|
|
61
|
+
|
|
62
|
+
const { data } = await axios.get(
|
|
63
|
+
`https://api.github.com/repos/${repo}/tags?per_page=${perPage}`,
|
|
64
|
+
{ headers }
|
|
65
|
+
);
|
|
66
|
+
|
|
67
|
+
return data.map((t) => ({
|
|
68
|
+
name: t.name,
|
|
69
|
+
commitSha: t.commit.sha,
|
|
70
|
+
}));
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function buildHeaders(token) {
|
|
74
|
+
const h = { Accept: 'application/vnd.github+json' };
|
|
75
|
+
if (token) h['Authorization'] = `Bearer ${token}`;
|
|
76
|
+
return h;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
module.exports = { listReleases, listTags };
|