amxx-builder 1.5.2 → 1.6.0
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 +6 -0
- package/README.md +102 -2
- package/mcp/handlers.js +163 -26
- package/mcp/registry.js +125 -0
- package/package.json +2 -1
- package/skills/amxb-migration/SKILL.md +554 -0
- package/src/build-plan.js +3 -1
- package/src/cli.js +1 -0
- package/src/commands/deps-tree.js +4 -1
- package/src/commands/dry-run.js +4 -0
- package/src/commands/init.js +19 -18
- package/src/commands/serve.js +338 -25
- package/src/compiler-fetcher.js +34 -1
- package/src/dep-docs.js +115 -0
- package/src/dep-graph.js +23 -0
- package/src/deployer.js +11 -6
- package/src/deps-resolver.js +54 -3
- package/src/deps-tree.js +18 -0
- package/src/fungun-fetcher.js +347 -0
- package/src/github-api.js +309 -0
- package/src/include-tree.js +7 -2
- package/src/jsonrpc-transport.js +2 -1
- package/src/manifest.js +86 -5
- package/templates/init-workflow.yml +1 -1
package/src/dep-docs.js
ADDED
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const fs = require('fs');
|
|
4
|
+
const path = require('path');
|
|
5
|
+
|
|
6
|
+
const { fetchRepo, resolveRefIfLatest } = require('./repo-fetcher');
|
|
7
|
+
const { fetchReleaseDep } = require('./release-fetcher');
|
|
8
|
+
const { fetchDepRoot } = require('./deps-resolver');
|
|
9
|
+
const { parseDepString, parseDepObject } = require('./manifest');
|
|
10
|
+
|
|
11
|
+
// Fallback doc files a dependency repo may ship when it declares no explicit
|
|
12
|
+
// `docs:` paths. Checked in order at the repo root.
|
|
13
|
+
// Deliberately NOT AGENTS.md: that file is instructions for agents working
|
|
14
|
+
// INSIDE the repo (build commands, contribution rules), not consumer-facing
|
|
15
|
+
// API docs — feeding it to an outside agent leaks the wrong context.
|
|
16
|
+
const DOCS_CONVENTIONS = ['docs/API.md', 'API.md'];
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Normalize a raw dep value (string or dep object) into a parsed dep object.
|
|
20
|
+
* @param {string|object} raw
|
|
21
|
+
* @returns {object} parsed dep object
|
|
22
|
+
*/
|
|
23
|
+
function normalizeDep(raw) {
|
|
24
|
+
if (typeof raw === 'string') return parseDepString(raw);
|
|
25
|
+
if (raw && typeof raw === 'object') return parseDepObject(raw);
|
|
26
|
+
throw new Error('Dep must be a string or an object');
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Resolve which agent-facing doc files exist for a dependency inside rootDir.
|
|
31
|
+
* Declared `dep.docs` paths come first (in array order), then convention
|
|
32
|
+
* candidates that exist on disk. Deduped by rel path — declared wins.
|
|
33
|
+
*
|
|
34
|
+
* @param {object} dep - parsed dep object ({ docs: string[]|null, ... })
|
|
35
|
+
* @param {string} rootDir - local root of the fetched repo/asset
|
|
36
|
+
* @returns {{ files: Array<{ rel: string, abs: string, origin: 'declared'|'convention' }>, missing: string[] }}
|
|
37
|
+
*/
|
|
38
|
+
function resolveDepDocs(dep, rootDir) {
|
|
39
|
+
const files = [];
|
|
40
|
+
const missing = [];
|
|
41
|
+
const seen = new Set();
|
|
42
|
+
|
|
43
|
+
const declared = Array.isArray(dep.docs) ? dep.docs : [];
|
|
44
|
+
|
|
45
|
+
for (const rel of declared) {
|
|
46
|
+
const abs = path.resolve(rootDir, rel);
|
|
47
|
+
if (abs !== rootDir && !abs.startsWith(rootDir + path.sep)) {
|
|
48
|
+
throw new Error(`docs path escapes the repo root: "${rel}"`);
|
|
49
|
+
}
|
|
50
|
+
if (seen.has(rel)) continue;
|
|
51
|
+
seen.add(rel);
|
|
52
|
+
if (fs.existsSync(abs) && fs.statSync(abs).isFile()) {
|
|
53
|
+
files.push({ rel, abs, origin: 'declared' });
|
|
54
|
+
} else {
|
|
55
|
+
missing.push(rel);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
for (const rel of DOCS_CONVENTIONS) {
|
|
60
|
+
if (seen.has(rel)) continue;
|
|
61
|
+
const abs = path.join(rootDir, rel);
|
|
62
|
+
if (fs.existsSync(abs) && fs.statSync(abs).isFile()) {
|
|
63
|
+
seen.add(rel);
|
|
64
|
+
files.push({ rel, abs, origin: 'convention' });
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
return { files, missing };
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* UTF-8 read that never throws: binary files and read errors are returned as
|
|
73
|
+
* descriptive placeholder strings instead.
|
|
74
|
+
* @param {string} abs - absolute file path
|
|
75
|
+
* @returns {string}
|
|
76
|
+
*/
|
|
77
|
+
function safeRead(abs) {
|
|
78
|
+
try {
|
|
79
|
+
const buf = fs.readFileSync(abs);
|
|
80
|
+
try {
|
|
81
|
+
const text = buf.toString('utf8');
|
|
82
|
+
if (text.includes('\u0000')) return `[binary file, ${buf.length} bytes]`;
|
|
83
|
+
return text;
|
|
84
|
+
} catch (_) {
|
|
85
|
+
return `[binary file, ${buf.length} bytes]`;
|
|
86
|
+
}
|
|
87
|
+
} catch (err) {
|
|
88
|
+
return `[error reading file: ${err.message}]`;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Fetch a dependency and collect its agent-facing doc files with contents.
|
|
94
|
+
* `fetchRoot` is a test seam — defaults to the core fetchDepRoot.
|
|
95
|
+
*
|
|
96
|
+
* @param {object} dep - parsed dep object
|
|
97
|
+
* @param {object} [opts]
|
|
98
|
+
* @param {string} [opts.token]
|
|
99
|
+
* @param {boolean} [opts.noFetch]
|
|
100
|
+
* @param {boolean} [opts.ssh]
|
|
101
|
+
* @param {Function} [opts.fetchRoot] - ({ dep, ... }) => Promise<{ rootDir, label }>
|
|
102
|
+
* @returns {Promise<{ label: string, files: Array<{ rel: string, content: string, origin: string }>, missing: string[] }>}
|
|
103
|
+
*/
|
|
104
|
+
async function collectDepDocs(dep, { token, noFetch, ssh, fetchRoot } = {}) {
|
|
105
|
+
const fetch = fetchRoot || fetchDepRoot;
|
|
106
|
+
const { rootDir, label } = await fetch(dep, { token, noFetch, ssh });
|
|
107
|
+
const { files, missing } = resolveDepDocs(dep, rootDir);
|
|
108
|
+
return {
|
|
109
|
+
label,
|
|
110
|
+
files: files.map((f) => ({ rel: f.rel, content: safeRead(f.abs), origin: f.origin })),
|
|
111
|
+
missing,
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
module.exports = { DOCS_CONVENTIONS, resolveDepDocs, collectDepDocs, normalizeDep };
|
package/src/dep-graph.js
CHANGED
|
@@ -24,6 +24,8 @@ class DepGraph {
|
|
|
24
24
|
this._includeDirs = includeDirs;
|
|
25
25
|
// absPath → Set<absPath>: direct includes of each file
|
|
26
26
|
this._deps = new Map();
|
|
27
|
+
// absPath → [{name, isAngle}]: includes that could not be resolved
|
|
28
|
+
this._missing = new Map();
|
|
27
29
|
this._parsed = new Set();
|
|
28
30
|
}
|
|
29
31
|
|
|
@@ -34,23 +36,44 @@ class DepGraph {
|
|
|
34
36
|
if (!fs.existsSync(absPath)) return;
|
|
35
37
|
|
|
36
38
|
const directs = new Set();
|
|
39
|
+
const missing = [];
|
|
37
40
|
for (const { name, isAngle } of extractIncludes(absPath)) {
|
|
38
41
|
const resolved = this._resolve(absPath, name, isAngle);
|
|
39
42
|
if (resolved) {
|
|
40
43
|
directs.add(resolved);
|
|
41
44
|
this.parseFile(resolved);
|
|
45
|
+
} else {
|
|
46
|
+
missing.push({ name, isAngle });
|
|
42
47
|
}
|
|
43
48
|
}
|
|
44
49
|
this._deps.set(absPath, directs);
|
|
50
|
+
if (missing.length) this._missing.set(absPath, missing);
|
|
51
|
+
else this._missing.delete(absPath);
|
|
45
52
|
}
|
|
46
53
|
|
|
47
54
|
// Re-parse a changed file (drops stale edges, keeps the rest of the graph).
|
|
48
55
|
update(absPath) {
|
|
49
56
|
this._parsed.delete(absPath);
|
|
50
57
|
this._deps.delete(absPath);
|
|
58
|
+
this._missing.delete(absPath);
|
|
51
59
|
this.parseFile(absPath);
|
|
52
60
|
}
|
|
53
61
|
|
|
62
|
+
// Structured snapshot of the parsed graph: every parsed file with its direct
|
|
63
|
+
// (resolved) includes, plus includes that could not be resolved. Exposed to
|
|
64
|
+
// interface layers (serve/JSON-RPC) so the graph can be serialized.
|
|
65
|
+
snapshot() {
|
|
66
|
+
const files = [];
|
|
67
|
+
for (const [file, deps] of this._deps) {
|
|
68
|
+
files.push({ file, isSma: file.endsWith('.sma'), includes: [...deps].sort() });
|
|
69
|
+
}
|
|
70
|
+
const missing = [];
|
|
71
|
+
for (const [file, list] of this._missing) {
|
|
72
|
+
for (const { name, isAngle } of list) missing.push({ file, name, isAngle });
|
|
73
|
+
}
|
|
74
|
+
return { files, missing };
|
|
75
|
+
}
|
|
76
|
+
|
|
54
77
|
// Returns Set<absPath> of .sma files that transitively depend on incPath.
|
|
55
78
|
getSmasDependingOn(incPath) {
|
|
56
79
|
const smas = new Set();
|
package/src/deployer.js
CHANGED
|
@@ -92,9 +92,11 @@ function deployPlugin(manifest, buildDir, amxxName) {
|
|
|
92
92
|
/**
|
|
93
93
|
* Deploy a single changed local file (watch mode for amxmodx/ or assets/).
|
|
94
94
|
* relPath is relative to the section root (amxmodx/ or assets/).
|
|
95
|
+
* Returns the destination path, or null when not deployed (no deploy path,
|
|
96
|
+
* missing source, or excluded).
|
|
95
97
|
*/
|
|
96
98
|
function deployFile(manifest, buildDir, relPath, section) {
|
|
97
|
-
if (!manifest.deploy.path) return;
|
|
99
|
+
if (!manifest.deploy.path) return null;
|
|
98
100
|
|
|
99
101
|
const { amxmodxDest, assetsDest, deployRoot } = resolveDeployDirs(manifest);
|
|
100
102
|
|
|
@@ -104,24 +106,26 @@ function deployFile(manifest, buildDir, relPath, section) {
|
|
|
104
106
|
const src = path.join(srcBase, relPath);
|
|
105
107
|
const dest = path.join(destBase, relPath);
|
|
106
108
|
|
|
107
|
-
if (!fs.existsSync(src)) return;
|
|
109
|
+
if (!fs.existsSync(src)) return null;
|
|
108
110
|
if (isExcluded(dest, deployRoot, manifest.deploy.exclude || [])) {
|
|
109
111
|
logger.verbose(` skip (excluded): ${relPath}`);
|
|
110
|
-
return;
|
|
112
|
+
return null;
|
|
111
113
|
}
|
|
112
114
|
|
|
113
115
|
fs.mkdirSync(path.dirname(dest), { recursive: true });
|
|
114
116
|
fs.copyFileSync(src, dest);
|
|
115
117
|
logger.success(`Deployed: ${relPath}`);
|
|
116
118
|
logger.verbose(` → ${dest}`);
|
|
119
|
+
return dest;
|
|
117
120
|
}
|
|
118
121
|
|
|
119
122
|
/**
|
|
120
123
|
* Removes a deployed file that was deleted locally (watch mode).
|
|
121
124
|
* relPath is relative to the section root. Honours deploy.exclude.
|
|
125
|
+
* Returns the removed destination path, or null when nothing was removed.
|
|
122
126
|
*/
|
|
123
127
|
function removeDeployedFile(manifest, buildDir, relPath, section) {
|
|
124
|
-
if (!manifest.deploy.path) return;
|
|
128
|
+
if (!manifest.deploy.path) return null;
|
|
125
129
|
|
|
126
130
|
const { amxmodxDest, assetsDest, deployRoot } = resolveDeployDirs(manifest);
|
|
127
131
|
|
|
@@ -130,12 +134,13 @@ function removeDeployedFile(manifest, buildDir, relPath, section) {
|
|
|
130
134
|
|
|
131
135
|
if (isExcluded(dest, deployRoot, manifest.deploy.exclude || [])) {
|
|
132
136
|
logger.verbose(` skip delete (excluded): ${relPath}`);
|
|
133
|
-
return;
|
|
137
|
+
return null;
|
|
134
138
|
}
|
|
135
139
|
|
|
136
|
-
if (!fs.existsSync(dest)) return;
|
|
140
|
+
if (!fs.existsSync(dest)) return null;
|
|
137
141
|
fs.rmSync(dest, { force: true });
|
|
138
142
|
logger.success(`Removed: ${relPath}`);
|
|
143
|
+
return dest;
|
|
139
144
|
}
|
|
140
145
|
|
|
141
146
|
// ─── helpers ─────────────────────────────────────────────────────────────────
|
package/src/deps-resolver.js
CHANGED
|
@@ -5,6 +5,7 @@ const logger = require('./logger');
|
|
|
5
5
|
const { parseDepsLines, resolveGithubToken } = require('./manifest');
|
|
6
6
|
const { fetchRepo, resolveRefIfLatest } = require('./repo-fetcher');
|
|
7
7
|
const { fetchReleaseDep } = require('./release-fetcher');
|
|
8
|
+
const { fetchFungunDep } = require('./fungun-fetcher');
|
|
8
9
|
|
|
9
10
|
/**
|
|
10
11
|
* Resolves all deps, clones them, copies .inc files to build/_includes/,
|
|
@@ -52,11 +53,14 @@ async function resolveDeps(manifest, repoLocalDirs, noFetch, buildDir) {
|
|
|
52
53
|
const includeDirs = [];
|
|
53
54
|
|
|
54
55
|
for (const [k, dep] of merged) {
|
|
55
|
-
const token = resolveGithubToken(manifest, dep.repo);
|
|
56
56
|
let srcDir;
|
|
57
57
|
if (dep.source === 'release') {
|
|
58
|
+
const token = resolveGithubToken(manifest, dep.repo);
|
|
58
59
|
srcDir = await fetchReleaseDep(dep, token, noFetch);
|
|
60
|
+
} else if (dep.source === 'fungun') {
|
|
61
|
+
srcDir = await fetchFungunDep(dep, noFetch);
|
|
59
62
|
} else {
|
|
63
|
+
const token = resolveGithubToken(manifest, dep.repo);
|
|
60
64
|
const resolvedDepRef = await resolveRefIfLatest(dep.ref, dep.repo, token);
|
|
61
65
|
const depDir = await fetchRepo(dep.repo, resolvedDepRef, token, noFetch, manifest.github.ssh);
|
|
62
66
|
srcDir = resolveIncludePath(depDir, dep.include_path, dep.repo);
|
|
@@ -72,7 +76,7 @@ async function resolveDeps(manifest, repoLocalDirs, noFetch, buildDir) {
|
|
|
72
76
|
fs.copyFileSync(path.join(srcDir, f), dest);
|
|
73
77
|
}
|
|
74
78
|
|
|
75
|
-
logger.dim(` ${dep
|
|
79
|
+
logger.dim(` ${depLabel(dep)}: ${files.length} .inc files`);
|
|
76
80
|
includeDirs.push(destDir);
|
|
77
81
|
}
|
|
78
82
|
|
|
@@ -106,10 +110,57 @@ function resolveIncludePath(repoDir, explicitPath, repoName) {
|
|
|
106
110
|
return repoDir;
|
|
107
111
|
}
|
|
108
112
|
|
|
113
|
+
/**
|
|
114
|
+
* Fetch a dependency's root directory and return a human-readable label for it.
|
|
115
|
+
* Single source of truth shared by the build pipeline, the MCP server and
|
|
116
|
+
* dep-docs resolution.
|
|
117
|
+
*
|
|
118
|
+
* `dep` is a parsed dep OBJECT ({ repo, ref, source, include_path, asset }).
|
|
119
|
+
* GitHub token resolution (per-owner fallbacks) is an interface-layer concern —
|
|
120
|
+
* callers pass the already-resolved token, this function never calls fallbackToken.
|
|
121
|
+
*
|
|
122
|
+
* @param {object} dep - parsed dep object
|
|
123
|
+
* @param {object} [opts]
|
|
124
|
+
* @param {string} [opts.token] - GitHub token or null (anonymous)
|
|
125
|
+
* @param {boolean} [opts.noFetch] - only use cache, skip network fetches
|
|
126
|
+
* @param {boolean} [opts.ssh] - clone via system git instead of tarball
|
|
127
|
+
* @returns {Promise<{ rootDir: string, label: string }>}
|
|
128
|
+
*/
|
|
129
|
+
async function fetchDepRoot(dep, { token, noFetch, ssh = false } = {}) {
|
|
130
|
+
if (dep.source === 'release') {
|
|
131
|
+
const dir = await fetchReleaseDep(dep, token, noFetch);
|
|
132
|
+
return { rootDir: dir, label: `${dep.repo}@${dep.ref} (release)` };
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
if (dep.source === 'fungun') {
|
|
136
|
+
const dir = await fetchFungunDep(dep, noFetch);
|
|
137
|
+
return { rootDir: dir, label: depLabel(dep) };
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
const resolvedRef = await resolveRefIfLatest(dep.ref, dep.repo, token);
|
|
141
|
+
const repoDir = await fetchRepo(dep.repo, resolvedRef, token, noFetch, ssh);
|
|
142
|
+
if (dep.include_path) {
|
|
143
|
+
const sub = path.join(repoDir, dep.include_path);
|
|
144
|
+
if (!fs.existsSync(sub)) {
|
|
145
|
+
throw new Error(`include_path "${dep.include_path}" not found in ${dep.repo}`);
|
|
146
|
+
}
|
|
147
|
+
return { rootDir: sub, label: `${dep.repo}@${dep.ref || 'default branch'}` };
|
|
148
|
+
}
|
|
149
|
+
return { rootDir: repoDir, label: `${dep.repo}@${dep.ref || 'default branch'}` };
|
|
150
|
+
}
|
|
151
|
+
|
|
109
152
|
// Single source of truth for repo-name normalization (used for cache keys
|
|
110
153
|
// and dedup by core modules that previously inlined repo.toLowerCase()).
|
|
111
154
|
function normalize(repo) { return repo.toLowerCase(); }
|
|
112
155
|
|
|
156
|
+
// Human-readable dep label (fungun deps: no repo@ref — addressed by shop page id).
|
|
157
|
+
function depLabel(dep) {
|
|
158
|
+
if (dep && dep.source === 'fungun') {
|
|
159
|
+
return `fungun.net plugin #${dep.id}`;
|
|
160
|
+
}
|
|
161
|
+
return `${dep.repo}@${dep.ref || 'default branch'}`;
|
|
162
|
+
}
|
|
163
|
+
|
|
113
164
|
function repoKey(repoConfig) {
|
|
114
165
|
return `${repoConfig.repo}@${repoConfig._resolvedRef || repoConfig.ref || 'HEAD'}`;
|
|
115
166
|
}
|
|
@@ -124,4 +175,4 @@ function countIncFiles(dir) {
|
|
|
124
175
|
return n;
|
|
125
176
|
}
|
|
126
177
|
|
|
127
|
-
module.exports = { resolveDeps, readDepsListFile, normalize, repoKey };
|
|
178
|
+
module.exports = { resolveDeps, readDepsListFile, normalize, repoKey, fetchDepRoot, depLabel };
|
package/src/deps-tree.js
CHANGED
|
@@ -100,6 +100,24 @@ function assembleRootDeps(manifest) {
|
|
|
100
100
|
// ─── Recursive walk ────────────────────────────────────────────────────────────
|
|
101
101
|
|
|
102
102
|
async function walkDep(dep, ctx) {
|
|
103
|
+
// Fungun: closed-source page dep — no repo to resolve a ref on, no DEPS_LIST to recurse into.
|
|
104
|
+
if (dep.source === 'fungun') {
|
|
105
|
+
return {
|
|
106
|
+
repo: dep.repo || `fungun.net/#${dep.id}`,
|
|
107
|
+
ref: null,
|
|
108
|
+
resolvedRef: null,
|
|
109
|
+
id: dep.id,
|
|
110
|
+
source: 'fungun',
|
|
111
|
+
include_path: null,
|
|
112
|
+
asset: null,
|
|
113
|
+
from: ctx.from,
|
|
114
|
+
error: null,
|
|
115
|
+
cycle: false,
|
|
116
|
+
shared: false,
|
|
117
|
+
dependencies: [],
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
|
|
103
121
|
const { resolveToken, noFetch, depth, visited, pathStack, getDepsOverride } = ctx;
|
|
104
122
|
|
|
105
123
|
const repo = dep.repo;
|
|
@@ -0,0 +1,347 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Fungun dep source (fungun.net) — closed-source AMXX plugin shop.
|
|
5
|
+
*
|
|
6
|
+
* Plugins ship no archives and no direct file URLs. The only public artifact
|
|
7
|
+
* is the plugin "show" page, which always embeds one Bootstrap modal per
|
|
8
|
+
* shipped file (configs, lang, and — when the plugin exposes an API — exactly
|
|
9
|
+
* the .inc include). Modal DOM ids are page-specific (`ModalText<number>`),
|
|
10
|
+
* so a plugin is addressed by its page index and the .inc payload is located
|
|
11
|
+
* by scanning every modal for a `modal-title` filename ending in `.inc`:
|
|
12
|
+
*
|
|
13
|
+
* <div class="modal fade" id="ModalText267" ...>
|
|
14
|
+
* <div class="modal-body ...">
|
|
15
|
+
* <button ... close ...></button>
|
|
16
|
+
* <h3 class="modal-title">bonusmenu_rbs.inc</h3>
|
|
17
|
+
* <div class="border_text"></div>
|
|
18
|
+
* <pre class="language-cpp"><code>... include text ...</code></pre>
|
|
19
|
+
*
|
|
20
|
+
* Ownership (single source of truth for everything fungun):
|
|
21
|
+
* - id/url normalization and validation (parsePluginRef)
|
|
22
|
+
* - page fetch + modal extraction (fetchFungunPage, extractIncFiles)
|
|
23
|
+
* - on-disk cache: <CACHE_DIR>/fungun/<plugin-id>/ (sentinel `.fungun`)
|
|
24
|
+
*
|
|
25
|
+
* Manifest/dep parsing (`src/manifest.js`) and the fetch entry points in
|
|
26
|
+
* deps-resolver / include-tree delegate here — no other module re-parses
|
|
27
|
+
* fungun references or re-implements the extraction.
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
const fs = require('fs');
|
|
31
|
+
const path = require('path');
|
|
32
|
+
const axios = require('axios');
|
|
33
|
+
|
|
34
|
+
const logger = require('./logger');
|
|
35
|
+
const { getCacheDir } = require('./cache-dir');
|
|
36
|
+
const { withRetry } = require('./retry');
|
|
37
|
+
|
|
38
|
+
const FUNGUN_BASE_URL = 'https://fungun.net';
|
|
39
|
+
const FUNGUN_SHOW_PATH = '/shop/?p=show&id=';
|
|
40
|
+
|
|
41
|
+
// fungun serves plain HTML to browsers; a bare axios UA may get a different
|
|
42
|
+
// (login/captcha) response. Keep a realistic browser UA on every request.
|
|
43
|
+
const BROWSER_UA =
|
|
44
|
+
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 ' +
|
|
45
|
+
'(KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36';
|
|
46
|
+
|
|
47
|
+
const SENTINEL_FILE = '.fungun'; // content: plugin id — mirrors release-deps `.extracted`
|
|
48
|
+
|
|
49
|
+
// Fungun pages pin no version/ref — refetch the page cache daily (--no-fetch still uses the stale copy).
|
|
50
|
+
const FUNGUN_CACHE_TTL_MS = 24 * 60 * 60 * 1000;
|
|
51
|
+
|
|
52
|
+
function pluginPageUrl(id) {
|
|
53
|
+
return `${FUNGUN_BASE_URL}${FUNGUN_SHOW_PATH}${id}`;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Normalize a fungun plugin reference.
|
|
58
|
+
*
|
|
59
|
+
* Accepts:
|
|
60
|
+
* - a positive integer id (number or numeric string) — `106`
|
|
61
|
+
* - a full plugin page URL — `https://fungun.net/shop/?p=show&id=106`
|
|
62
|
+
*
|
|
63
|
+
* Returns `{ id, url }` (id as a numeric string; url is the canonical page URL
|
|
64
|
+
* for bare ids and the caller-provided URL otherwise). Throws on anything
|
|
65
|
+
* else — this is the single validation point used by both manifest parsing
|
|
66
|
+
* and the fetcher, so error messages are user-facing.
|
|
67
|
+
*
|
|
68
|
+
* @param {number|string} value
|
|
69
|
+
* @returns {{ id: string, url: string }|null}
|
|
70
|
+
*/
|
|
71
|
+
function parsePluginRef(value) {
|
|
72
|
+
if (value == null || value === '') return null;
|
|
73
|
+
|
|
74
|
+
let str;
|
|
75
|
+
if (typeof value === 'number') {
|
|
76
|
+
if (!Number.isInteger(value) || value <= 0) {
|
|
77
|
+
throw new Error(
|
|
78
|
+
`Invalid fungun plugin id: ${value} — expected a positive plugin index`
|
|
79
|
+
);
|
|
80
|
+
}
|
|
81
|
+
str = String(value);
|
|
82
|
+
return { id: str, url: pluginPageUrl(str) };
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
if (typeof value !== 'string') {
|
|
86
|
+
throw new Error(
|
|
87
|
+
`Invalid fungun plugin reference: ${JSON.stringify(value)} — expected a plugin id or fungun.net URL`
|
|
88
|
+
);
|
|
89
|
+
}
|
|
90
|
+
str = value.trim();
|
|
91
|
+
if (!str) return null;
|
|
92
|
+
|
|
93
|
+
// Bare plugin index
|
|
94
|
+
if (/^\d+$/.test(str)) return { id: str, url: pluginPageUrl(str) };
|
|
95
|
+
|
|
96
|
+
// Full page URL
|
|
97
|
+
if (/^https?:\/\//i.test(str)) {
|
|
98
|
+
let hostname;
|
|
99
|
+
try {
|
|
100
|
+
hostname = new URL(str).hostname;
|
|
101
|
+
} catch {
|
|
102
|
+
throw new Error(`Invalid fungun plugin URL: "${str}"`);
|
|
103
|
+
}
|
|
104
|
+
if (hostname !== 'fungun.net' && !hostname.endsWith('.fungun.net')) {
|
|
105
|
+
throw new Error(
|
|
106
|
+
`Invalid fungun dep URL "${str}" — expected a fungun.net page (host: ${hostname})`
|
|
107
|
+
);
|
|
108
|
+
}
|
|
109
|
+
const idMatch = str.match(/[?&]id=(\d+)/);
|
|
110
|
+
if (!idMatch) {
|
|
111
|
+
throw new Error(
|
|
112
|
+
`Invalid fungun plugin URL "${str}" — missing "id=<index>" parameter`
|
|
113
|
+
);
|
|
114
|
+
}
|
|
115
|
+
return { id: idMatch[1], url: str };
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
throw new Error(
|
|
119
|
+
`Invalid fungun plugin reference "${str}" — ` +
|
|
120
|
+
`expected a plugin index (e.g. 106) or a fungun.net page URL`
|
|
121
|
+
);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Fetch a fungun plugin page. `ref` is the output of parsePluginRef.
|
|
126
|
+
*
|
|
127
|
+
* @param {{ id: string, url: string }} ref
|
|
128
|
+
* @returns {Promise<string>} page HTML (UTF-8)
|
|
129
|
+
*/
|
|
130
|
+
async function fetchFungunPage(ref) {
|
|
131
|
+
const url = ref.url || pluginPageUrl(ref.id);
|
|
132
|
+
const response = await withRetry(
|
|
133
|
+
() => axios.get(url, {
|
|
134
|
+
headers: {
|
|
135
|
+
'User-Agent': BROWSER_UA,
|
|
136
|
+
Accept: 'text/html,application/xhtml+xml',
|
|
137
|
+
},
|
|
138
|
+
responseType: 'text',
|
|
139
|
+
timeout: 30000,
|
|
140
|
+
maxRedirects: 5,
|
|
141
|
+
}),
|
|
142
|
+
{ label: `fungun plugin #${ref.id}` }
|
|
143
|
+
);
|
|
144
|
+
|
|
145
|
+
if (response.status !== 200) {
|
|
146
|
+
throw new Error(`Failed to fetch fungun plugin page #${ref.id} — HTTP ${response.status}`);
|
|
147
|
+
}
|
|
148
|
+
return String(response.data);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// ─── HTML extraction ──────────────────────────────────────────────────────────
|
|
152
|
+
// Deliberately regex-based: the target markup is a fixed two-level shape
|
|
153
|
+
// (modal div → h3.modal-title + pre > code) inside page-generated ids.
|
|
154
|
+
// No HTML parser dependency is needed to stay correct on Node 18 + offline CI.
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Parse every "file modal" out of a plugin page. Returns entries in page
|
|
158
|
+
* order: `{ modalId, filename, content }` (entities decoded, content trimmed).
|
|
159
|
+
* Modals without an h3 filename or a code block are skipped.
|
|
160
|
+
*
|
|
161
|
+
* @param {string} html
|
|
162
|
+
* @returns {Array<{ modalId: string, filename: string, content: string }>}
|
|
163
|
+
*/
|
|
164
|
+
function extractIncModals(html) {
|
|
165
|
+
const opens = [];
|
|
166
|
+
const modalRe = /<div[^>]*\sid="(ModalText[^"]+)"[^>]*>/g;
|
|
167
|
+
let m;
|
|
168
|
+
while ((m = modalRe.exec(html))) opens.push({ id: m[1], start: m.index });
|
|
169
|
+
|
|
170
|
+
const modals = [];
|
|
171
|
+
for (let i = 0; i < opens.length; i++) {
|
|
172
|
+
const end = i + 1 < opens.length ? opens[i + 1].start : html.length;
|
|
173
|
+
const seg = html.slice(opens[i].start, end);
|
|
174
|
+
|
|
175
|
+
const filename = matchModalFilename(seg);
|
|
176
|
+
const content = matchModalCode(seg);
|
|
177
|
+
if (!filename || content === null) continue;
|
|
178
|
+
|
|
179
|
+
modals.push({ modalId: opens[i].id, filename, content: content.trim() });
|
|
180
|
+
}
|
|
181
|
+
return modals;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
function matchModalFilename(seg) {
|
|
185
|
+
const m = seg.match(/<h3[^>]*>([\s\S]*?)<\/h3>/);
|
|
186
|
+
return m ? decodeEntities(m[1]).trim() : null;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
function matchModalCode(seg) {
|
|
190
|
+
const m = seg.match(/<pre[^>]*>[\s\S]*?<code[^>]*>([\s\S]*?)<\/code>[\s\S]*?<\/pre>/);
|
|
191
|
+
return m ? decodeEntities(m[1]) : null;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* Extract the include files (.inc modals) from a plugin page.
|
|
196
|
+
*
|
|
197
|
+
* @param {string} html
|
|
198
|
+
* @returns {Array<{ modalId: string, filename: string, content: string }>}
|
|
199
|
+
*/
|
|
200
|
+
function extractIncFiles(html) {
|
|
201
|
+
return extractIncModals(html).filter((m) => m.filename.toLowerCase().endsWith('.inc'));
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
function decodeEntities(str) {
|
|
205
|
+
return String(str)
|
|
206
|
+
.replace(/&#x([0-9a-f]+);/gi, (_, hex) => codePointToString(parseInt(hex, 16)))
|
|
207
|
+
.replace(/&#(\d+);/g, (_, dec) => codePointToString(parseInt(dec, 10)))
|
|
208
|
+
.replace(/"/g, '"')
|
|
209
|
+
.replace(/'/g, "'")
|
|
210
|
+
.replace(/'/g, "'")
|
|
211
|
+
.replace(/</g, '<')
|
|
212
|
+
.replace(/>/g, '>')
|
|
213
|
+
.replace(/ /g, ' ')
|
|
214
|
+
.replace(/&/g, '&');
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
function codePointToString(cp) {
|
|
218
|
+
try {
|
|
219
|
+
return String.fromCodePoint(cp);
|
|
220
|
+
} catch {
|
|
221
|
+
return '\uFFFD';
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* Fetch the .inc payload of a fungun plugin into the local cache.
|
|
227
|
+
*
|
|
228
|
+
* Cache: <CACHE_DIR>/fungun/<plugin-id>/ — one file per .inc modal, named
|
|
229
|
+
* after the modal title (path traversal is stripped via basename). The
|
|
230
|
+
* sentinel `.fungun` marks a complete fetch. Because fungun pages have no
|
|
231
|
+
* version to pin, the cache expires after FUNGUN_CACHE_TTL_MS and is
|
|
232
|
+
* refetched on the next build. A refetch that fails (network, or the page
|
|
233
|
+
* no longer exposing the include) falls back to the last known-good copy,
|
|
234
|
+
* so an expired cache never breaks a build by itself.
|
|
235
|
+
*
|
|
236
|
+
* @param {object|number|string} dep - parsed fungun dep ({ id } / { url }) or a raw reference
|
|
237
|
+
* @param {boolean} [noFetch=false] - only use the cache, skip network
|
|
238
|
+
* @returns {Promise<string>} cache directory containing the .inc file(s)
|
|
239
|
+
*/
|
|
240
|
+
async function fetchFungunDep(dep, noFetch = false) {
|
|
241
|
+
if (dep && typeof dep === 'object' && dep.id == null && dep.url == null) {
|
|
242
|
+
throw new Error(
|
|
243
|
+
`Fungun dep entry missing a plugin reference (expected "id" or "url"): ${JSON.stringify(dep)}`
|
|
244
|
+
);
|
|
245
|
+
}
|
|
246
|
+
const raw = (dep && dep.id != null) ? dep.id
|
|
247
|
+
: (dep && dep.url != null) ? dep.url
|
|
248
|
+
: dep;
|
|
249
|
+
const ref = parsePluginRef(raw);
|
|
250
|
+
if (!ref) {
|
|
251
|
+
throw new Error(
|
|
252
|
+
`Fungun dep entry missing a plugin reference (expected "id" or "url"): ${JSON.stringify(dep)}`
|
|
253
|
+
);
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
const cacheDir = path.join(getCacheDir(), 'fungun', ref.id);
|
|
257
|
+
const sentinelFile = path.join(cacheDir, SENTINEL_FILE);
|
|
258
|
+
|
|
259
|
+
if (fs.existsSync(sentinelFile) && isFreshSentinel(sentinelFile)) {
|
|
260
|
+
logger.dim(` fungun.net plugin #${ref.id} (cached)`);
|
|
261
|
+
return cacheDir;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
const staleCache = fs.existsSync(sentinelFile);
|
|
265
|
+
|
|
266
|
+
if (noFetch) {
|
|
267
|
+
if (staleCache) {
|
|
268
|
+
// CI reuse: a day-old include is still a valid include.
|
|
269
|
+
logger.dim(` fungun.net plugin #${ref.id} (cached, stale — --no-fetch)`);
|
|
270
|
+
return cacheDir;
|
|
271
|
+
}
|
|
272
|
+
throw new Error(
|
|
273
|
+
`Fungun dep cache missing for plugin #${ref.id} and --no-fetch is set.\n` +
|
|
274
|
+
`Run without --no-fetch to populate the cache.`
|
|
275
|
+
);
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
if (staleCache) {
|
|
279
|
+
logger.dim(` fungun.net plugin #${ref.id}: cache expired, refetching...`);
|
|
280
|
+
}
|
|
281
|
+
logger.step(`Fungun dep: plugin #${ref.id} @ ${ref.url || pluginPageUrl(ref.id)}`);
|
|
282
|
+
|
|
283
|
+
try {
|
|
284
|
+
const html = await fetchFungunPage(ref);
|
|
285
|
+
const incs = extractIncFiles(html);
|
|
286
|
+
|
|
287
|
+
if (incs.length === 0) {
|
|
288
|
+
const pageUrl = ref.url || pluginPageUrl(ref.id);
|
|
289
|
+
const titles = extractIncModals(html).map((m) => m.filename);
|
|
290
|
+
throw new Error(
|
|
291
|
+
`No .inc include found on fungun.net plugin page #${ref.id} (${pageUrl}).\n` +
|
|
292
|
+
`Shipped file modals: ${titles.length ? titles.join(', ') : 'none'} — this plugin exposes no include file.`
|
|
293
|
+
);
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
fs.mkdirSync(cacheDir, { recursive: true });
|
|
297
|
+
for (const inc of incs) {
|
|
298
|
+
fs.writeFileSync(path.join(cacheDir, safeFileName(inc.filename)), inc.content, 'utf8');
|
|
299
|
+
}
|
|
300
|
+
touchSentinel(sentinelFile, ref.id);
|
|
301
|
+
|
|
302
|
+
logger.info(`Fungun dep: plugin #${ref.id} ready (${incs.map((f) => f.filename).join(', ')})`);
|
|
303
|
+
return cacheDir;
|
|
304
|
+
} catch (err) {
|
|
305
|
+
if (staleCache) {
|
|
306
|
+
// Refetch failed but the previous copy is still on disk — keep the build
|
|
307
|
+
// green and reset freshness so we don't hammer the site on every build.
|
|
308
|
+
logger.warn(`Fungun dep: refetch of plugin #${ref.id} failed (${err.message}) — using cached copy`);
|
|
309
|
+
touchSentinel(sentinelFile, ref.id);
|
|
310
|
+
return cacheDir;
|
|
311
|
+
}
|
|
312
|
+
throw err;
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
function isFreshSentinel(sentinelFile) {
|
|
317
|
+
try {
|
|
318
|
+
return Date.now() - fs.statSync(sentinelFile).mtimeMs < FUNGUN_CACHE_TTL_MS;
|
|
319
|
+
} catch {
|
|
320
|
+
return false;
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
function touchSentinel(sentinelFile, id) {
|
|
325
|
+
fs.mkdirSync(path.dirname(sentinelFile), { recursive: true });
|
|
326
|
+
const sentinelTmp = sentinelFile + '.tmp';
|
|
327
|
+
fs.writeFileSync(sentinelTmp, id, 'utf8');
|
|
328
|
+
fs.renameSync(sentinelTmp, sentinelFile);
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
function safeFileName(filename) {
|
|
332
|
+
const base = path.basename(String(filename || '').trim());
|
|
333
|
+
if (!base || base === '.' || base === '..') {
|
|
334
|
+
throw new Error(`Unsafe filename in fungun modal: ${JSON.stringify(filename)}`);
|
|
335
|
+
}
|
|
336
|
+
return base;
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
module.exports = {
|
|
340
|
+
FUNGUN_BASE_URL,
|
|
341
|
+
pluginPageUrl,
|
|
342
|
+
parsePluginRef,
|
|
343
|
+
fetchFungunPage,
|
|
344
|
+
extractIncModals,
|
|
345
|
+
extractIncFiles,
|
|
346
|
+
fetchFungunDep,
|
|
347
|
+
};
|