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.
@@ -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
+ };
@@ -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 response = await withRetry(
100
- () => axios.get(source.url, {
101
- responseType: 'arraybuffer',
102
- maxRedirects: 5,
103
- timeout: 600000, // large archives — allow slow links, still bound hangs
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
- extractArchive(data, filename, cacheDir);
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
- const sentinelTmp = sentinel + '.tmp';
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, sentinel);
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. Invalidate the sentinel only and
133
- // leave the content to be overwritten on the next fetch.
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 extractArchive(data, filename, destDir) {
161
- const isZip = /\.zip$/i.test(filename) || isZipMagic(data);
144
+ function extractArchiveFile(archivePath, destDir) {
145
+ const isZip = /\.zip$/i.test(archivePath) || isZipMagicFile(archivePath);
162
146
  if (isZip) {
163
- new AdmZip(data).extractAllTo(destDir, true);
164
- return;
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 isZipMagic(buf) {
178
- return buf.length >= 2 && buf[0] === 0x50 && buf[1] === 0x4b;
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
  })),
@@ -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 key = repoKey(repoConfig);
124
- if (!cloneJobs.has(key)) {
125
- cloneJobs.set(key,
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 = await Promise.all(
131
- [...cloneJobs.entries()].map(async ([key, p]) => ({ key, dir: await p }))
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
- for (const { key, dir } of cloned) repoLocalDirs[key] = dir;
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 and the serve JSON-RPC server own stdout; an update notice
32
- // would corrupt the protocol stream.
33
- if (program.args[0] === 'mcp' || program.args[0] === 'serve') return;
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 with MCP config (amxb mcp)')
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
- logger.dim(`${prefix}${connector}${node.repo}@${node.ref || 'HEAD'}${tag}`);
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
 
@@ -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
  }