amxx-builder 1.5.2 → 1.6.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 +12 -2
- package/README.md +157 -3
- package/defaults/amxbuild.defaults.yml +7 -0
- package/mcp/handlers.js +378 -92
- package/mcp/registry.js +298 -0
- package/package.json +2 -1
- package/skills/amxb-migration/SKILL.md +568 -0
- package/src/agent-assets.js +206 -0
- package/src/asset-fetcher.js +33 -48
- package/src/build-plan.js +3 -1
- package/src/build-service.js +17 -8
- package/src/cli.js +37 -4
- package/src/collector.js +9 -1
- package/src/commands/deps-tree.js +4 -1
- package/src/commands/dry-run.js +4 -0
- package/src/commands/init.js +72 -20
- package/src/commands/opencode-skills.js +47 -0
- package/src/commands/serve.js +620 -116
- package/src/commands/skills-dir.js +21 -0
- package/src/commands/watch.js +48 -19
- package/src/compile-utils.js +11 -2
- package/src/compiler-fetcher.js +229 -41
- package/src/compiler.js +26 -14
- package/src/dep-graph.js +50 -0
- package/src/deployer.js +50 -21
- package/src/deps-resolver.js +160 -7
- package/src/deps-tree.js +20 -1
- package/src/download.js +132 -0
- package/src/fs-utils.js +35 -1
- package/src/fungun-fetcher.js +367 -0
- package/src/github-api.js +309 -0
- package/src/include-tree.js +29 -57
- package/src/jsonrpc-transport.js +55 -6
- package/src/manifest-path.js +18 -1
- package/src/manifest.js +144 -5
- package/src/opencode-skills.js +262 -0
- package/src/release-fetcher.js +26 -33
- package/src/repo-fetcher.js +276 -33
- package/src/retry.js +3 -1
- package/templates/init-workflow.yml +1 -1
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const fs = require('fs');
|
|
4
|
+
const path = require('path');
|
|
5
|
+
const glob = require('fast-glob');
|
|
6
|
+
const yaml = require('js-yaml');
|
|
7
|
+
|
|
8
|
+
const { fetchDepRoot } = require('./deps-resolver');
|
|
9
|
+
const { parseDocEntries, parseSkillEntries } = require('./manifest');
|
|
10
|
+
const { findManifestInDir } = require('./manifest-path');
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* UTF-8 read that never throws. Binary files (NUL byte) and read errors are
|
|
14
|
+
* returned as descriptive placeholder strings instead.
|
|
15
|
+
*
|
|
16
|
+
* @param {string} abs - absolute file path
|
|
17
|
+
* @returns {string}
|
|
18
|
+
*/
|
|
19
|
+
function safeRead(abs) {
|
|
20
|
+
try {
|
|
21
|
+
const buf = fs.readFileSync(abs);
|
|
22
|
+
const text = buf.toString('utf8');
|
|
23
|
+
if (text.includes('\u0000')) return `[binary file, ${buf.length} bytes]`;
|
|
24
|
+
return text;
|
|
25
|
+
} catch (err) {
|
|
26
|
+
return `[error reading file: ${err.message}]`;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Resolve a repo-relative path against the repo root, rejecting traversal.
|
|
32
|
+
*
|
|
33
|
+
* @param {string} rootDir - repo root (absolute)
|
|
34
|
+
* @param {string} rel - declared relative path
|
|
35
|
+
* @returns {string} absolute path
|
|
36
|
+
*/
|
|
37
|
+
function safeResolve(rootDir, rel) {
|
|
38
|
+
const abs = path.resolve(rootDir, rel);
|
|
39
|
+
if (abs !== rootDir && !abs.startsWith(rootDir + path.sep)) {
|
|
40
|
+
throw new Error(`docs path escapes the repo root: "${rel}"`);
|
|
41
|
+
}
|
|
42
|
+
return abs;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function sortBundleFiles(files) {
|
|
46
|
+
return files.sort((a, b) => {
|
|
47
|
+
if (a === 'SKILL.md') return b === 'SKILL.md' ? 0 : -1;
|
|
48
|
+
if (b === 'SKILL.md') return 1;
|
|
49
|
+
return a < b ? -1 : a > b ? 1 : 0;
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Resolve declared docs/skills against a repo root without reading content.
|
|
55
|
+
*
|
|
56
|
+
* Docs and single-file skills that do not exist land in `missing` (no throw);
|
|
57
|
+
* directory skills are enumerated recursively (SKILL.md first). Paths that
|
|
58
|
+
* escape rootDir throw.
|
|
59
|
+
*
|
|
60
|
+
* @param {{ docs?: Array<object>, skills?: Array<object> }} assets
|
|
61
|
+
* @param {string} rootDir - absolute repo root
|
|
62
|
+
* @returns {{ docs: Array<object>, skills: Array<object>, missing: string[] }}
|
|
63
|
+
*/
|
|
64
|
+
function resolveAssets({ docs, skills }, rootDir) {
|
|
65
|
+
const docInputs = Array.isArray(docs) ? docs : [];
|
|
66
|
+
const skillInputs = Array.isArray(skills) ? skills : [];
|
|
67
|
+
const outDocs = [];
|
|
68
|
+
const outSkills = [];
|
|
69
|
+
const missing = [];
|
|
70
|
+
|
|
71
|
+
for (const d of docInputs) {
|
|
72
|
+
const abs = safeResolve(rootDir, d.file);
|
|
73
|
+
if (fs.existsSync(abs) && fs.statSync(abs).isFile()) {
|
|
74
|
+
outDocs.push({ name: d.name, description: d.description, file: d.file, abs });
|
|
75
|
+
} else {
|
|
76
|
+
missing.push(d.file);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
for (const s of skillInputs) {
|
|
81
|
+
if (s.file) {
|
|
82
|
+
const abs = safeResolve(rootDir, s.file);
|
|
83
|
+
if (fs.existsSync(abs) && fs.statSync(abs).isFile()) {
|
|
84
|
+
outSkills.push({ name: s.name, description: s.description, kind: 'file', file: s.file, dir: null, abs });
|
|
85
|
+
} else {
|
|
86
|
+
missing.push(s.file);
|
|
87
|
+
}
|
|
88
|
+
continue;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const abs = safeResolve(rootDir, s.dir);
|
|
92
|
+
if (fs.existsSync(abs) && fs.statSync(abs).isDirectory()) {
|
|
93
|
+
const files = sortBundleFiles(glob.sync('**/*', { cwd: abs, dot: false, onlyFiles: true }));
|
|
94
|
+
outSkills.push({
|
|
95
|
+
name: s.name,
|
|
96
|
+
description: s.description,
|
|
97
|
+
kind: 'dir',
|
|
98
|
+
file: null,
|
|
99
|
+
dir: s.dir,
|
|
100
|
+
abs,
|
|
101
|
+
files: files.map((rel) => ({ rel, abs: path.join(abs, rel) })),
|
|
102
|
+
});
|
|
103
|
+
} else {
|
|
104
|
+
missing.push(s.dir);
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
return { docs: outDocs, skills: outSkills, missing };
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Attach file contents to a resolved assets object.
|
|
113
|
+
*
|
|
114
|
+
* @param {{ docs: Array<object>, skills: Array<object>, missing: string[] }} resolved
|
|
115
|
+
* @returns {{ docs: Array<object>, skills: Array<object>, missing: string[] }}
|
|
116
|
+
*/
|
|
117
|
+
function readAssets(resolved) {
|
|
118
|
+
return {
|
|
119
|
+
docs: resolved.docs.map((d) => ({ ...d, content: safeRead(d.abs) })),
|
|
120
|
+
skills: resolved.skills.map((s) => {
|
|
121
|
+
if (s.kind === 'dir') {
|
|
122
|
+
return { ...s, files: s.files.map((f) => ({ rel: f.rel, content: safeRead(f.abs) })) };
|
|
123
|
+
}
|
|
124
|
+
return { ...s, content: safeRead(s.abs) };
|
|
125
|
+
}),
|
|
126
|
+
missing: resolved.missing,
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Fetch a dependency and read its own manifest, if any.
|
|
132
|
+
*
|
|
133
|
+
* Lenient: absence or unparseable YAML yields `manifestPath: null, raw: null`
|
|
134
|
+
* rather than throwing. `fetchRoot` is a test seam.
|
|
135
|
+
*
|
|
136
|
+
* @param {object} dep - parsed dep object
|
|
137
|
+
* @param {object} [opts]
|
|
138
|
+
* @param {string} [opts.token]
|
|
139
|
+
* @param {boolean} [opts.noFetch]
|
|
140
|
+
* @param {boolean} [opts.ssh]
|
|
141
|
+
* @param {Function} [opts.fetchRoot]
|
|
142
|
+
* @returns {Promise<{ label: string, rootDir: string, manifestPath: string|null, raw: object|null }>}
|
|
143
|
+
*/
|
|
144
|
+
async function readDepManifest(dep, { token, noFetch, ssh, fetchRoot } = {}) {
|
|
145
|
+
const fetch = fetchRoot || fetchDepRoot;
|
|
146
|
+
const { rootDir, label } = await fetch(dep, { token, noFetch, ssh });
|
|
147
|
+
|
|
148
|
+
const manifestPath = findManifestInDir(rootDir);
|
|
149
|
+
let raw = null;
|
|
150
|
+
if (manifestPath) {
|
|
151
|
+
try {
|
|
152
|
+
raw = yaml.load(fs.readFileSync(manifestPath, 'utf8')) || null;
|
|
153
|
+
} catch (_) {
|
|
154
|
+
raw = null;
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
return { label, rootDir, manifestPath, raw };
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Fetch a dependency and collect its declared agent docs/skills with content.
|
|
162
|
+
*
|
|
163
|
+
* @param {object} dep - parsed dep object
|
|
164
|
+
* @param {object} [opts] - same options as readDepManifest
|
|
165
|
+
* @returns {Promise<{ label: string, manifestPath: string|null, manifestName: string|null, docs: Array<object>, skills: Array<object>, missing: string[] }>}
|
|
166
|
+
*/
|
|
167
|
+
async function collectDepAssets(dep, opts) {
|
|
168
|
+
const m = await readDepManifest(dep, opts);
|
|
169
|
+
if (!m.raw) {
|
|
170
|
+
return { label: m.label, manifestPath: null, manifestName: null, docs: [], skills: [], missing: [] };
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
let docs;
|
|
174
|
+
let skills;
|
|
175
|
+
try { docs = parseDocEntries(m.raw.docs || []); } catch (_) { docs = []; }
|
|
176
|
+
try { skills = parseSkillEntries(m.raw.skills || []); } catch (_) { skills = []; }
|
|
177
|
+
|
|
178
|
+
const read = readAssets(resolveAssets({ docs, skills }, m.rootDir));
|
|
179
|
+
return {
|
|
180
|
+
label: m.label,
|
|
181
|
+
manifestPath: m.manifestPath,
|
|
182
|
+
manifestName: m.raw.name || null,
|
|
183
|
+
docs: read.docs,
|
|
184
|
+
skills: read.skills,
|
|
185
|
+
missing: read.missing,
|
|
186
|
+
};
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* Collect the local project's declared agent docs/skills with content.
|
|
191
|
+
*
|
|
192
|
+
* @param {object} manifest - parsed manifest (uses `manifest._path` for the root)
|
|
193
|
+
* @returns {{ docs: Array<object>, skills: Array<object>, missing: string[] }}
|
|
194
|
+
*/
|
|
195
|
+
function collectLocalAssets(manifest) {
|
|
196
|
+
const rootDir = path.dirname(manifest._path);
|
|
197
|
+
return readAssets(resolveAssets({ docs: manifest.docs, skills: manifest.skills }, rootDir));
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
module.exports = {
|
|
201
|
+
resolveAssets,
|
|
202
|
+
readAssets,
|
|
203
|
+
readDepManifest,
|
|
204
|
+
collectDepAssets,
|
|
205
|
+
collectLocalAssets,
|
|
206
|
+
};
|
package/src/asset-fetcher.js
CHANGED
|
@@ -3,16 +3,13 @@
|
|
|
3
3
|
const fs = require('fs');
|
|
4
4
|
const path = require('path');
|
|
5
5
|
const crypto = require('crypto');
|
|
6
|
-
const axios = require('axios');
|
|
7
|
-
// Default for API calls; download sites pass their own longer timeout.
|
|
8
|
-
axios.defaults.timeout = 30000;
|
|
9
6
|
const AdmZip = require('adm-zip');
|
|
10
7
|
|
|
11
8
|
const chalk = require('chalk');
|
|
12
|
-
const { safeExtractTar } = require('./fs-utils');
|
|
9
|
+
const { safeExtractTar, makeSiblingTmpDir, publishDir } = require('./fs-utils');
|
|
10
|
+
const { downloadToFile } = require('./download');
|
|
13
11
|
const logger = require('./logger');
|
|
14
12
|
const { getCacheDir } = require('./cache-dir');
|
|
15
|
-
const { withRetry } = require('./retry');
|
|
16
13
|
const { getAmxmodxFullDir, getHostPlatform } = require('./compiler-fetcher');
|
|
17
14
|
const { getReleaseCacheDir } = require('./release-fetcher');
|
|
18
15
|
const { resolveGithubToken } = require('./manifest');
|
|
@@ -91,47 +88,34 @@ async function resolveUrlSource(source, manifestDir, buildDir, noFetch) {
|
|
|
91
88
|
|
|
92
89
|
const filename = getFilenameFromUrl(source.url);
|
|
93
90
|
logger.step(`Assets: downloading ${filename}...`);
|
|
94
|
-
fs.mkdirSync(cacheDir, { recursive: true });
|
|
95
|
-
|
|
96
|
-
const bar = require('./progress').createBar(100, ` ${chalk.cyan('Downloading')} ${(filename || 'file').padEnd(30)}`);
|
|
97
91
|
|
|
92
|
+
// Download + extract into a unique sibling temp dir, then rename into place:
|
|
93
|
+
// an interrupted run leaves only the temp dir (never a sentinel-marked cache)
|
|
94
|
+
// and concurrent fetches of the same URL cannot interleave writes.
|
|
95
|
+
const tmpDir = makeSiblingTmpDir(cacheDir);
|
|
98
96
|
try {
|
|
99
|
-
const
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
onDownloadProgress: (e) => {
|
|
105
|
-
if (bar && e.total) {
|
|
106
|
-
bar.update(Math.round(e.loaded / e.total * 100));
|
|
107
|
-
}
|
|
108
|
-
},
|
|
109
|
-
}),
|
|
110
|
-
{ label: filename }
|
|
111
|
-
);
|
|
112
|
-
if (bar) bar.stop();
|
|
113
|
-
const contentType = response.headers['content-type'] || '';
|
|
114
|
-
const data = Buffer.from(response.data);
|
|
97
|
+
const downloadPath = path.join(tmpDir, filename);
|
|
98
|
+
const { headers } = await downloadToFile(source.url, downloadPath, {
|
|
99
|
+
progressLabel: ` ${chalk.cyan('Downloading')} ${(filename || 'file').padEnd(30)}`,
|
|
100
|
+
});
|
|
101
|
+
const contentType = headers['content-type'] || '';
|
|
115
102
|
|
|
116
103
|
if (isArchive(filename, contentType)) {
|
|
117
|
-
|
|
118
|
-
} else {
|
|
119
|
-
const filePath = path.join(cacheDir, filename);
|
|
120
|
-
const part = filePath + '.part';
|
|
121
|
-
fs.writeFileSync(part, data);
|
|
122
|
-
fs.renameSync(part, filePath);
|
|
104
|
+
extractArchiveFile(downloadPath, tmpDir);
|
|
123
105
|
}
|
|
124
106
|
|
|
125
|
-
|
|
107
|
+
// Sentinel written into tmpDir first: only complete dirs become the cache.
|
|
108
|
+
const sentinelTmp = path.join(tmpDir, '.cached.tmp');
|
|
126
109
|
fs.writeFileSync(sentinelTmp, JSON.stringify({ url: source.url, cached_at: new Date().toISOString() }));
|
|
127
|
-
fs.renameSync(sentinelTmp,
|
|
110
|
+
fs.renameSync(sentinelTmp, path.join(tmpDir, '.cached'));
|
|
111
|
+
publishDir(tmpDir, cacheDir, () => fs.existsSync(sentinel));
|
|
128
112
|
logger.info(`Assets: ${filename} ready`);
|
|
129
113
|
return cacheDir;
|
|
130
114
|
} catch (err) {
|
|
115
|
+
try { fs.rmSync(tmpDir, { recursive: true, force: true }); } catch (_) {}
|
|
131
116
|
// Never rmSync the whole cache dir: it may be a shared 'global' entry used
|
|
132
|
-
// by parallel sources or other projects.
|
|
133
|
-
//
|
|
134
|
-
try { fs.rmSync(sentinel, { force: true }); } catch (_) {}
|
|
117
|
+
// by parallel sources or other projects. Only the unique temp dir above is
|
|
118
|
+
// ours to remove; a stale marker-less cache dir is replaced on next fetch.
|
|
135
119
|
throw new Error(`Failed to fetch asset ${source.url}: ${err.message}`);
|
|
136
120
|
}
|
|
137
121
|
}
|
|
@@ -157,25 +141,26 @@ function isArchive(filename, contentType) {
|
|
|
157
141
|
return /zip|tar|gzip|x-compressed/.test(contentType);
|
|
158
142
|
}
|
|
159
143
|
|
|
160
|
-
function
|
|
161
|
-
const isZip = /\.zip$/i.test(
|
|
144
|
+
function extractArchiveFile(archivePath, destDir) {
|
|
145
|
+
const isZip = /\.zip$/i.test(archivePath) || isZipMagicFile(archivePath);
|
|
162
146
|
if (isZip) {
|
|
163
|
-
new AdmZip(
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
const tmpFile = path.join(destDir, filename);
|
|
167
|
-
fs.writeFileSync(tmpFile, data);
|
|
168
|
-
try {
|
|
169
|
-
safeExtractTar(tmpFile, destDir);
|
|
170
|
-
} finally {
|
|
171
|
-
fs.rmSync(tmpFile, { force: true });
|
|
147
|
+
new AdmZip(archivePath).extractAllTo(destDir, true);
|
|
148
|
+
} else {
|
|
149
|
+
safeExtractTar(archivePath, destDir);
|
|
172
150
|
}
|
|
151
|
+
fs.rmSync(archivePath, { force: true });
|
|
173
152
|
}
|
|
174
153
|
|
|
175
154
|
// ZIP archives start with "PK" — filename extensions are unreliable for
|
|
176
155
|
// CDN/redirect URLs, so sniff the actual bytes when the name is inconclusive.
|
|
177
|
-
function
|
|
178
|
-
|
|
156
|
+
function isZipMagicFile(filePath) {
|
|
157
|
+
try {
|
|
158
|
+
const fd = fs.openSync(filePath, 'r');
|
|
159
|
+
const buf = Buffer.alloc(2);
|
|
160
|
+
fs.readSync(fd, buf, 0, 2, 0);
|
|
161
|
+
fs.closeSync(fd);
|
|
162
|
+
return buf[0] === 0x50 && buf[1] === 0x4b;
|
|
163
|
+
} catch { return false; }
|
|
179
164
|
}
|
|
180
165
|
|
|
181
166
|
// ─── map application ──────────────────────────────────────────────────────────
|
package/src/build-plan.js
CHANGED
|
@@ -41,8 +41,10 @@ function buildPlanData(manifest, options = {}) {
|
|
|
41
41
|
})),
|
|
42
42
|
globalDeps: manifest.globalDeps.map((d) => ({
|
|
43
43
|
source: d.source,
|
|
44
|
-
repo: d.repo,
|
|
44
|
+
repo: d.source === 'fungun' ? null : d.repo,
|
|
45
45
|
ref: d.ref,
|
|
46
|
+
id: d.id ?? null,
|
|
47
|
+
url: d.url ?? null,
|
|
46
48
|
include_path: d.include_path || null,
|
|
47
49
|
asset: d.asset ?? null,
|
|
48
50
|
})),
|
package/src/build-service.js
CHANGED
|
@@ -38,7 +38,7 @@ const { emit, EVENTS } = require('./events');
|
|
|
38
38
|
const { resolveGithubToken } = require('./manifest');
|
|
39
39
|
const { fetchCompiler } = require('./compiler-fetcher');
|
|
40
40
|
const { fetchRepo, resolveRepoRefs } = require('./repo-fetcher');
|
|
41
|
-
const { resolveDeps, repoKey } = require('./deps-resolver');
|
|
41
|
+
const { resolveDeps, repoKey, normalizeRepo } = require('./deps-resolver');
|
|
42
42
|
const { compilePlugins } = require('./compiler');
|
|
43
43
|
const { collectAll } = require('./collector');
|
|
44
44
|
const { fetchAssets } = require('./asset-fetcher');
|
|
@@ -118,19 +118,27 @@ async function runBuild(manifest, options = {}) {
|
|
|
118
118
|
await resolveRepoRefs(manifest.repos, (repo) => resolveGithubToken(manifest, repo));
|
|
119
119
|
checkCancelled();
|
|
120
120
|
|
|
121
|
-
const cloneJobs = new Map();
|
|
121
|
+
const cloneJobs = new Map(); // normalized repo identity → fetch promise
|
|
122
122
|
for (const repoConfig of manifest.repos) {
|
|
123
|
-
const
|
|
124
|
-
if (!cloneJobs.has(
|
|
125
|
-
cloneJobs.set(
|
|
123
|
+
const identity = normalizeRepo(repoConfig);
|
|
124
|
+
if (!cloneJobs.has(identity)) {
|
|
125
|
+
cloneJobs.set(identity,
|
|
126
126
|
fetchRepo(repoConfig.repo, repoConfig._resolvedRef, resolveGithubToken(manifest, repoConfig.repo), noFetch, manifest.github.ssh)
|
|
127
127
|
);
|
|
128
128
|
}
|
|
129
129
|
}
|
|
130
|
-
const cloned =
|
|
131
|
-
|
|
130
|
+
const cloned = new Map();
|
|
131
|
+
await Promise.all(
|
|
132
|
+
[...cloneJobs.entries()].map(async ([identity, p]) => {
|
|
133
|
+
const dir = await p;
|
|
134
|
+
cloned.set(identity, dir);
|
|
135
|
+
})
|
|
132
136
|
);
|
|
133
|
-
|
|
137
|
+
// Map every manifest entry (raw spelling) to its clone dir; duplicate
|
|
138
|
+
// spellings of the same repo share the clone but each repoKey stays valid.
|
|
139
|
+
for (const repoConfig of manifest.repos) {
|
|
140
|
+
repoLocalDirs[repoKey(repoConfig)] = cloned.get(normalizeRepo(repoConfig));
|
|
141
|
+
}
|
|
134
142
|
checkCancelled();
|
|
135
143
|
}
|
|
136
144
|
|
|
@@ -166,6 +174,7 @@ async function runBuild(manifest, options = {}) {
|
|
|
166
174
|
if (noArchive) {
|
|
167
175
|
logger.info('--no-archive: skipping zip creation');
|
|
168
176
|
const elapsed = ((Date.now() - buildStart) / 1000).toFixed(1);
|
|
177
|
+
emitEvent(EVENTS.DONE, { ok: true, elapsed, noArchive: true, message: `Done in ${elapsed}s` });
|
|
169
178
|
return { ok: true, elapsed, noArchive: true };
|
|
170
179
|
}
|
|
171
180
|
|
package/src/cli.js
CHANGED
|
@@ -19,6 +19,8 @@ const { runReleases } = require('./commands/releases');
|
|
|
19
19
|
const { runInit, runInitInteractive } = require('./commands/init');
|
|
20
20
|
const { runMcp } = require('./commands/mcp');
|
|
21
21
|
const { runServe } = require('./commands/serve');
|
|
22
|
+
const { runSkillsDir } = require('./commands/skills-dir');
|
|
23
|
+
const { runOpencodeSkills } = require('./commands/opencode-skills');
|
|
22
24
|
|
|
23
25
|
program
|
|
24
26
|
.name('amxx-builder')
|
|
@@ -28,9 +30,13 @@ program
|
|
|
28
30
|
// ─── Update check ──────────────────────────────────────────────────────────────
|
|
29
31
|
|
|
30
32
|
program.hook('preAction', async () => {
|
|
31
|
-
// The MCP server
|
|
32
|
-
// would corrupt the protocol stream.
|
|
33
|
-
|
|
33
|
+
// The MCP server, the serve JSON-RPC server, skills-dir and opencode-skills
|
|
34
|
+
// own stdout; an update notice would corrupt the protocol/parseable stream.
|
|
35
|
+
// The GitHub Action already pins a version and shouldn't make an extra
|
|
36
|
+
// network call.
|
|
37
|
+
if (program.args[0] === 'mcp' || program.args[0] === 'serve'
|
|
38
|
+
|| program.args[0] === 'skills-dir' || program.args[0] === 'opencode-skills') return;
|
|
39
|
+
if (process.env.GITHUB_ACTIONS === 'true') return;
|
|
34
40
|
try {
|
|
35
41
|
const latest = await checkForUpdate();
|
|
36
42
|
if (latest) {
|
|
@@ -245,9 +251,11 @@ program
|
|
|
245
251
|
.option('--ci', 'Alias for --workflow')
|
|
246
252
|
.option('--plugin <name>', 'Create amxmodx/scripting/<name>.sma')
|
|
247
253
|
.option('--gitignore', 'Create .gitignore')
|
|
248
|
-
.option('--opencode', 'Create .opencode/opencode.json
|
|
254
|
+
.option('--opencode', 'Create .opencode/opencode.json (MCP config) + skills bridge plugin (amxb mcp)')
|
|
249
255
|
.option('--deploy', 'Create .env with deploy stubs (AMXB_DEPLOY_*)')
|
|
250
256
|
.option('--script', 'Create build.bat and build.sh quick-build scripts')
|
|
257
|
+
.option('-f, --force', 'Overwrite existing files instead of skipping them')
|
|
258
|
+
.option('--with-manifest', 'With --force, also overwrite an existing amxbuild.yml')
|
|
251
259
|
.option('-i, --interactive', 'Interactive mode with prompts')
|
|
252
260
|
.action(async (options) => {
|
|
253
261
|
try {
|
|
@@ -290,6 +298,31 @@ program
|
|
|
290
298
|
}
|
|
291
299
|
});
|
|
292
300
|
|
|
301
|
+
// ─── skills-dir ────────────────────────────────────────────────────────────────
|
|
302
|
+
|
|
303
|
+
program
|
|
304
|
+
.command('skills-dir')
|
|
305
|
+
.description('Print the absolute path to the bundled skills directory (used by the opencode skills bridge plugin)')
|
|
306
|
+
.action(() => {
|
|
307
|
+
runSkillsDir();
|
|
308
|
+
});
|
|
309
|
+
|
|
310
|
+
// ─── opencode-skills ─────────────────────────────────────────────────────────
|
|
311
|
+
|
|
312
|
+
program
|
|
313
|
+
.command('opencode-skills')
|
|
314
|
+
.description('Materialize project + dependency skills for the opencode bridge (stdout: container paths)')
|
|
315
|
+
.option('--manifest <path>', 'Path to manifest file')
|
|
316
|
+
.option('--no-fetch', 'Use cached repos without re-cloning')
|
|
317
|
+
.action(async (options) => {
|
|
318
|
+
try {
|
|
319
|
+
await runOpencodeSkills(options);
|
|
320
|
+
} catch (err) {
|
|
321
|
+
logger.error(err.message);
|
|
322
|
+
process.exit(1);
|
|
323
|
+
}
|
|
324
|
+
});
|
|
325
|
+
|
|
293
326
|
// ─── version ───────────────────────────────────────────────────────────────────
|
|
294
327
|
|
|
295
328
|
program
|
package/src/collector.js
CHANGED
|
@@ -2,7 +2,7 @@ const fs = require('fs');
|
|
|
2
2
|
const path = require('path');
|
|
3
3
|
const glob = require('fast-glob');
|
|
4
4
|
const logger = require('./logger');
|
|
5
|
-
const { repoKey } = require('./deps-resolver');
|
|
5
|
+
const { repoKey, normalizeRepo } = require('./deps-resolver');
|
|
6
6
|
|
|
7
7
|
/**
|
|
8
8
|
* Copies everything from each repo's amxmodx_dir into build/amxmodx/,
|
|
@@ -25,9 +25,17 @@ async function collectAll(manifest, repoLocalDirs, buildDir) {
|
|
|
25
25
|
fs.mkdirSync(amxmodxBuildDir, { recursive: true });
|
|
26
26
|
|
|
27
27
|
const origins = new Map(); // rel path → repo label (conflict tracking)
|
|
28
|
+
const seenRepos = new Set(); // case-insensitive repo identity (dedupe)
|
|
28
29
|
|
|
29
30
|
// Copy from each remote repo
|
|
30
31
|
for (const repoConfig of manifest.repos) {
|
|
32
|
+
const repoIdentity = normalizeRepo(repoConfig);
|
|
33
|
+
if (seenRepos.has(repoIdentity)) {
|
|
34
|
+
logger.dim(` ${repoConfig.repo}: duplicate entry, skipping`);
|
|
35
|
+
continue;
|
|
36
|
+
}
|
|
37
|
+
seenRepos.add(repoIdentity);
|
|
38
|
+
|
|
31
39
|
const repoDir = repoLocalDirs[repoKey(repoConfig)];
|
|
32
40
|
const srcDir = path.join(repoDir, repoConfig.amxmodx_dir);
|
|
33
41
|
|
|
@@ -56,7 +56,10 @@ function printNode(node, prefix, isLast) {
|
|
|
56
56
|
const childPrefix = isLast ? ' ' : '│ ';
|
|
57
57
|
|
|
58
58
|
const tag = buildNodeTag(node);
|
|
59
|
-
|
|
59
|
+
const label = node.source === 'fungun'
|
|
60
|
+
? `[fungun] plugin #${node.id}`
|
|
61
|
+
: `${node.repo}@${node.ref || 'HEAD'}`;
|
|
62
|
+
logger.dim(`${prefix}${connector}${label}${tag}`);
|
|
60
63
|
|
|
61
64
|
if (node.cycle) return;
|
|
62
65
|
|
package/src/commands/dry-run.js
CHANGED
|
@@ -28,6 +28,10 @@ function printDryRun(manifest) {
|
|
|
28
28
|
if (manifest.globalDeps.length) {
|
|
29
29
|
logger.info(`\nGlobal deps (${manifest.globalDeps.length}):`);
|
|
30
30
|
for (const d of manifest.globalDeps) {
|
|
31
|
+
if (d.source === 'fungun') {
|
|
32
|
+
logger.dim(` [fungun] plugin #${d.id} (${d.url})`);
|
|
33
|
+
continue;
|
|
34
|
+
}
|
|
31
35
|
const src = d.source === 'release' ? 'release' : 'git';
|
|
32
36
|
logger.dim(` [${src}] ${d.repo}@${d.ref}${d.include_path ? ':' + d.include_path : ''}`);
|
|
33
37
|
}
|