@creeperhost/modlens-mcp 1.6.19 → 1.6.21
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/ATTRIBUTION.md +8 -1
- package/README.md +82 -5
- package/RUNTIME.md +262 -0
- package/TESTING.md +8 -0
- package/dist/cli.js +28 -8
- package/dist/cli.js.map +1 -1
- package/dist/hosted-mod-license.d.ts +36 -0
- package/dist/hosted-mod-license.d.ts.map +1 -0
- package/dist/hosted-mod-license.js +65 -0
- package/dist/hosted-mod-license.js.map +1 -0
- package/dist/hosted-policy.d.ts +32 -0
- package/dist/hosted-policy.d.ts.map +1 -0
- package/dist/hosted-policy.js +257 -0
- package/dist/hosted-policy.js.map +1 -0
- package/dist/launcher.js +104 -89
- package/dist/launcher.js.map +1 -1
- package/dist/license-notices.d.ts +8 -0
- package/dist/license-notices.d.ts.map +1 -0
- package/dist/license-notices.js +10 -0
- package/dist/license-notices.js.map +1 -0
- package/dist/license-source-references.d.ts +12 -0
- package/dist/license-source-references.d.ts.map +1 -0
- package/dist/license-source-references.js +18 -0
- package/dist/license-source-references.js.map +1 -0
- package/dist/license-templates.d.ts +6 -0
- package/dist/license-templates.d.ts.map +1 -0
- package/dist/license-templates.js +55 -0
- package/dist/license-templates.js.map +1 -0
- package/dist/local-mod.d.ts +31 -0
- package/dist/local-mod.d.ts.map +1 -0
- package/dist/local-mod.js +71 -0
- package/dist/local-mod.js.map +1 -0
- package/dist/mod-license.d.ts +53 -0
- package/dist/mod-license.d.ts.map +1 -0
- package/dist/mod-license.js +617 -0
- package/dist/mod-license.js.map +1 -0
- package/dist/primer-catalog.d.ts +15 -0
- package/dist/primer-catalog.d.ts.map +1 -0
- package/dist/primer-catalog.js +43 -0
- package/dist/primer-catalog.js.map +1 -0
- package/dist/primer-content.d.ts +7 -0
- package/dist/primer-content.d.ts.map +1 -0
- package/dist/primer-content.js +185 -0
- package/dist/primer-content.js.map +1 -0
- package/dist/runtime/cli.d.ts +2 -0
- package/dist/runtime/cli.d.ts.map +1 -0
- package/dist/runtime/cli.js +163 -0
- package/dist/runtime/cli.js.map +1 -0
- package/dist/runtime/companion.d.ts +32 -0
- package/dist/runtime/companion.d.ts.map +1 -0
- package/dist/runtime/companion.js +135 -0
- package/dist/runtime/companion.js.map +1 -0
- package/dist/runtime/guidance.d.ts +73 -0
- package/dist/runtime/guidance.d.ts.map +1 -0
- package/dist/runtime/guidance.js +40 -0
- package/dist/runtime/guidance.js.map +1 -0
- package/dist/runtime/hub.d.ts +150 -0
- package/dist/runtime/hub.d.ts.map +1 -0
- package/dist/runtime/hub.js +561 -0
- package/dist/runtime/hub.js.map +1 -0
- package/dist/runtime/modlens-agent.jar +0 -0
- package/dist/runtime/protocol.d.ts +202 -0
- package/dist/runtime/protocol.d.ts.map +1 -0
- package/dist/runtime/protocol.js +91 -0
- package/dist/runtime/protocol.js.map +1 -0
- package/dist/runtime/requests.d.ts +367 -0
- package/dist/runtime/requests.d.ts.map +1 -0
- package/dist/runtime/requests.js +57 -0
- package/dist/runtime/requests.js.map +1 -0
- package/dist/server.js +141 -45
- package/dist/server.js.map +1 -1
- package/dist/setup.js +9 -4
- package/dist/setup.js.map +1 -1
- package/dist/tools/primers.d.ts +133 -22
- package/dist/tools/primers.d.ts.map +1 -1
- package/dist/tools/primers.js +391 -386
- package/dist/tools/primers.js.map +1 -1
- package/dist/tools/project.d.ts +11 -7
- package/dist/tools/project.d.ts.map +1 -1
- package/dist/tools/project.js +45 -3
- package/dist/tools/project.js.map +1 -1
- package/dist/tools/report-issue.d.ts +77 -0
- package/dist/tools/report-issue.d.ts.map +1 -0
- package/dist/tools/report-issue.js +92 -0
- package/dist/tools/report-issue.js.map +1 -0
- package/dist/tools/runtime.d.ts +6 -0
- package/dist/tools/runtime.d.ts.map +1 -0
- package/dist/tools/runtime.js +18 -0
- package/dist/tools/runtime.js.map +1 -0
- package/dist/tools/source.d.ts +1 -1
- package/dist/tools/source.d.ts.map +1 -1
- package/dist/tools/source.js +5 -1
- package/dist/tools/source.js.map +1 -1
- package/package.json +8 -1
- package/scripts/build-runtime-agent.mjs +64 -0
- package/scripts/fixtures/mod-license-corpus.json +154 -0
- package/scripts/gradle/modlens-runtime.init.gradle +17 -0
- package/scripts/runtime-test-client.mjs +36 -0
- package/scripts/test-hosted-policy.mjs +123 -0
- package/scripts/test-mod-licenses-live.mjs +86 -0
- package/scripts/test-package.mjs +141 -5
- package/scripts/test-primers-live.mjs +93 -0
- package/scripts/test-project-http.mjs +46 -3
- package/scripts/test-runtime-gradle.mjs +107 -0
- package/scripts/test-runtime-minecraft.mjs +190 -0
- package/scripts/test-runtime-native.mjs +120 -0
- package/scripts/test-runtime-remote.mjs +209 -0
- package/scripts/update-license-templates.mjs +14 -0
package/scripts/test-package.mjs
CHANGED
|
@@ -30,9 +30,9 @@ Object.assign(env, {
|
|
|
30
30
|
MODLENS_CACHE_ROOT: join(root, "modlens-cache"),
|
|
31
31
|
});
|
|
32
32
|
|
|
33
|
-
function start(args, home) {
|
|
33
|
+
function start(args, home, extraEnv = {}) {
|
|
34
34
|
const child = spawn(process.execPath, [npm, ...args], {
|
|
35
|
-
cwd: consumer, env: { ...env, MODLENS_HOME: home }, stdio: ["pipe", "pipe", "pipe"],
|
|
35
|
+
cwd: consumer, env: { ...env, MODLENS_HOME: home, ...extraEnv }, stdio: ["pipe", "pipe", "pipe"],
|
|
36
36
|
detached: process.platform !== "win32",
|
|
37
37
|
});
|
|
38
38
|
let output = "";
|
|
@@ -113,6 +113,36 @@ try {
|
|
|
113
113
|
assert.equal(initScript, join(installed, "scripts/gradle/modlens.init.gradle"));
|
|
114
114
|
assert.ok(statSync(initScript).size > 0, "Gradle exporter must ship in the npm package");
|
|
115
115
|
assert.ok(statSync(join(installed, "scripts/project-upload.mjs")).size > 0, "Remote uploader must ship in the npm package");
|
|
116
|
+
assert.ok(statSync(join(installed, "dist/runtime/modlens-agent.jar")).size > 0, "Optional runtime agent must ship prebuilt");
|
|
117
|
+
assert.ok(statSync(join(installed, "scripts/gradle/modlens-runtime.init.gradle")).size > 0, "Runtime launch integration must ship");
|
|
118
|
+
assert.ok(statSync(join(installed, "RUNTIME.md")).size > 0, "Runtime instructions must ship");
|
|
119
|
+
assert.ok(statSync(join(installed, "dist/license-templates.js")).size > 0, "License reference data must ship");
|
|
120
|
+
const localModRequest = join(root, "local-mod-request.json");
|
|
121
|
+
writeFileSync(localModRequest, JSON.stringify({ localJarPath: join(root, "not-opened.jar"), sha256: "a".repeat(64), className: "example.Fixture", acceptedLocalDecompilation: false }));
|
|
122
|
+
assert.throws(() => execFileSync(process.execPath, [join(installed, "dist/launcher.js"), "--local-mod", "--request-file", localModRequest], {
|
|
123
|
+
env: { ...env, MODLENS_HOME: join(root, "local-mod-home"), MCP_PORT: "9999" }, encoding: "utf8", stdio: "pipe", timeout: 30000,
|
|
124
|
+
}), error => error.status === 1 && String(error.stderr).includes("User acceptance required"));
|
|
125
|
+
assert.ok(!existsSync(join(root, "local-mod-home", "data")), "Local helper must not bootstrap a hosted database");
|
|
126
|
+
console.log("PASS: installed local decompilation helper refuses execution without user acceptance");
|
|
127
|
+
const runtimeHome=join(root,"runtime-only-home");
|
|
128
|
+
const runtimeEnv={...env,MODLENS_HOME:runtimeHome,MODLENS_CACHE_ROOT:join(root,"runtime-only-cache"),MCP_PORT:"9999"};
|
|
129
|
+
const runtime=(...arguments_)=>JSON.parse(execFileSync(process.execPath,[join(installed,"dist/launcher.js"),"--runtime",...arguments_],{env:runtimeEnv,encoding:"utf8",timeout:30000}));
|
|
130
|
+
const runtimeProject=join(root,"runtime project");
|
|
131
|
+
mkdirSync(join(runtimeProject,"gradle/wrapper"),{recursive:true});
|
|
132
|
+
writeFileSync(join(runtimeProject,"gradle/wrapper/gradle-wrapper.jar"),"fixture");
|
|
133
|
+
const runtimeRequest=join(root,"runtime-request.json");
|
|
134
|
+
writeFileSync(runtimeRequest,JSON.stringify({action:"setup",projectDir:runtimeProject,mode:"hidden"}));
|
|
135
|
+
try {
|
|
136
|
+
assert.ok(runtime("help").execution.includes("Remote MCP"));
|
|
137
|
+
const configuredRuntime = runtime("--request-file",runtimeRequest);
|
|
138
|
+
assert.equal(configuredRuntime.state,"configured");
|
|
139
|
+
assert.deepEqual(configuredRuntime.vmOptions,[configuredRuntime.vmOption,"-XX:StackShadowPages=32"]);
|
|
140
|
+
assert.equal(runtime("status").projects.length,1);
|
|
141
|
+
assert.deepEqual(runtime("sessions"),[]);
|
|
142
|
+
assert.ok(!existsSync(join(runtimeHome,"data")),"Runtime helper must not create a source database");
|
|
143
|
+
assert.ok(!existsSync(join(runtimeHome,".env")),"Runtime helper must not create MCP configuration");
|
|
144
|
+
} finally {runtime("stop");}
|
|
145
|
+
console.log("PASS: installed runtime helper setup, persistence and stop without local MCP/database setup");
|
|
116
146
|
const keyFile = join(root, "project-key.txt");
|
|
117
147
|
writeFileSync(keyFile, "a".repeat(64));
|
|
118
148
|
assert.deepEqual(JSON.parse(execFileSync(process.execPath, [join(installed, "dist/launcher.js"), "--project", "list", `--key-file=${keyFile}`], { env, encoding: "utf8" })), []);
|
|
@@ -122,8 +152,28 @@ try {
|
|
|
122
152
|
assert.match(require("better-sqlite3/package.json").version, /^12\./);
|
|
123
153
|
|
|
124
154
|
const dataHome = join(root, "server-home");
|
|
155
|
+
// Deterministic upstream responses still exercise the installed Markdown dependencies,
|
|
156
|
+
// MCP argument schema, error flag, database writes, and reads after restart.
|
|
157
|
+
const primerFetchFixture = join(root, "primer-fetch-fixture.mjs");
|
|
158
|
+
writeFileSync(primerFetchFixture, `
|
|
159
|
+
const originalFetch = globalThis.fetch;
|
|
160
|
+
globalThis.fetch = async (input, init) => {
|
|
161
|
+
const url = String(input);
|
|
162
|
+
if (url === 'https://docs.neoforged.net/package-primer-missing') return new Response('missing', { status: 404 });
|
|
163
|
+
if (url === 'https://docs.neoforged.net/package-primer') return new Response(
|
|
164
|
+
'<article><h1>Package migration guide</h1><pre class="language-java"><code>' +
|
|
165
|
+
'<div class="token-line"><span>update();</span><br></div><div class="token-line"><span>finish();</span><br></div></code></pre>' +
|
|
166
|
+
'<p>Detailed instructions for migration and keeping code examples readable.</p><h2>Final section</h2></article>',
|
|
167
|
+
{ headers: { 'content-type': 'text/html' } });
|
|
168
|
+
if (url.includes('misode/mcmeta/summary/versions/data.json')) return Response.json([
|
|
169
|
+
{ id: '1.21.1', data_version: 3955 }, { id: '1.21.5', data_version: 4325 }
|
|
170
|
+
]);
|
|
171
|
+
if (url.endsWith('/api/tags')) return new Response(null, { status: 503 });
|
|
172
|
+
return originalFetch(input, init);
|
|
173
|
+
};
|
|
174
|
+
`);
|
|
125
175
|
async function checkServer() {
|
|
126
|
-
const run = start(args, dataHome);
|
|
176
|
+
const run = start(args, dataHome, { NODE_OPTIONS: `--import=${pathToFileURL(primerFetchFixture).href}` });
|
|
127
177
|
const pending = new Map();
|
|
128
178
|
let buffer = "";
|
|
129
179
|
let id = 0;
|
|
@@ -161,11 +211,94 @@ try {
|
|
|
161
211
|
assert.ok(initialized.serverInfo);
|
|
162
212
|
send({ method: "notifications/initialized" });
|
|
163
213
|
const list = await request("tools/list", {});
|
|
214
|
+
assert.ok(list.tools.some(tool => tool.name === "runtime" && tool.description.includes("setup")), "AI must discover runtime setup through MCP");
|
|
215
|
+
assert.match(initialized.instructions, /runtime/);
|
|
216
|
+
assert.ok(list.tools.some(tool => tool.name === "report_issue"), "AI must discover GitHub issue reporting");
|
|
217
|
+
const issueDraft = await request("tools/call", { name: "report_issue", arguments: {
|
|
218
|
+
action: "prepare", title: "Package test fixture", summary: "A synthetic report; do not publish.",
|
|
219
|
+
} });
|
|
220
|
+
assert.ok(!issueDraft.isError);
|
|
221
|
+
const issuePlan = JSON.parse(issueDraft.content[0].text);
|
|
222
|
+
assert.equal(issuePlan.executed, false);
|
|
223
|
+
assert.equal(issuePlan.state, "draft_prepared");
|
|
224
|
+
assert.equal(issuePlan.repository.repo, "modlens-mcp");
|
|
225
|
+
const runtimeHelp = await request("tools/call", { name: "runtime", arguments: { action: "help" } });
|
|
226
|
+
assert.ok(!runtimeHelp.isError);
|
|
227
|
+
assert.match(runtimeHelp.content[0].text, /26\.3/);
|
|
164
228
|
assert.ok(list.tools.some(tool => tool.name === "mod"));
|
|
165
229
|
assert.ok(list.tools.some(tool => tool.name === "project"));
|
|
166
230
|
const stats = await request("tools/call", { name: "mod", arguments: { action: "stats" } });
|
|
167
231
|
assert.ok(!stats.isError, JSON.stringify(stats));
|
|
168
232
|
assert.equal(JSON.parse(stats.content.find(item => item.type === "text").text).total, 0);
|
|
233
|
+
const primerTool = list.tools.find(tool => tool.name === "primers");
|
|
234
|
+
assert.ok(primerTool.inputSchema.properties.refresh, "Packaged primer tool must expose cache refresh");
|
|
235
|
+
for (const field of ["includeContent", "maxChars", "cursor"]) {
|
|
236
|
+
assert.ok(primerTool.inputSchema.properties[field], `Packaged primer tool must expose ${field}`);
|
|
237
|
+
}
|
|
238
|
+
const decode = response => JSON.parse(response.content.find(item => item.type === "text").text);
|
|
239
|
+
const primerCall = arguments_ => request("tools/call", { name: "primers", arguments: arguments_ });
|
|
240
|
+
const saved = await primerCall({ action: "ingest", entries: [{
|
|
241
|
+
fromVersion: "1.21.1", toVersion: "1.21.5", title: "Package primer", url: "https://docs.neoforged.net/package-primer",
|
|
242
|
+
}] });
|
|
243
|
+
assert.ok(!saved.isError, JSON.stringify(saved));
|
|
244
|
+
const primerId = decode(saved).primers[0].id;
|
|
245
|
+
const page = await primerCall({ action: "get", id: primerId, maxLines: 3 });
|
|
246
|
+
assert.ok(!page.isError, JSON.stringify(page));
|
|
247
|
+
assert.equal(decode(page).contentStatus, "ready");
|
|
248
|
+
assert.match(decode(page).content, /# Package migration guide/);
|
|
249
|
+
assert.equal(decode(page).nextStartLine, 4);
|
|
250
|
+
const tail = decode(await primerCall({ action: "get", id: primerId, startLine: 4 }));
|
|
251
|
+
assert.match(tail.content, /update\(\);\nfinish\(\);/);
|
|
252
|
+
assert.match(tail.content, /## Final section/);
|
|
253
|
+
assert.equal(tail.nextStartLine, null);
|
|
254
|
+
const longContent = "# Long migration guide\n\n" + "x".repeat(2500) + "😀\nEnd.";
|
|
255
|
+
const added = decode(await primerCall({ action: "ingest", entries: [{
|
|
256
|
+
fromVersion: "1.21.5", toVersion: "26.1", title: "Long package primer",
|
|
257
|
+
url: "https://docs.neoforged.net/package-primer-long", content: longContent,
|
|
258
|
+
}] }));
|
|
259
|
+
const rangeArgs = { action: "by_version", fromVersion: "1.21.1", toVersion: "26.1", modloader: "neoforge" };
|
|
260
|
+
const metadata = decode(await primerCall(rangeArgs));
|
|
261
|
+
assert.equal(metadata.count, 2);
|
|
262
|
+
assert.ok(metadata.primers.every(primer => !("content" in primer)));
|
|
263
|
+
const chunks = new Map();
|
|
264
|
+
let cursor;
|
|
265
|
+
let pages = 0;
|
|
266
|
+
do {
|
|
267
|
+
const response = await primerCall({ ...rangeArgs, includeContent: true, maxChars: 1000, ...(cursor ? { cursor } : {}) });
|
|
268
|
+
assert.ok(!response.isError, JSON.stringify(response));
|
|
269
|
+
const bundle = decode(response);
|
|
270
|
+
assert.equal(bundle.count, 2);
|
|
271
|
+
assert.ok(bundle.contentChars <= 1000);
|
|
272
|
+
for (const primer of bundle.primers) chunks.set(primer.id, (chunks.get(primer.id) ?? "") + primer.content);
|
|
273
|
+
cursor = bundle.nextCursor;
|
|
274
|
+
assert.ok(++pages < 10);
|
|
275
|
+
} while (cursor);
|
|
276
|
+
assert.equal(chunks.get(added.primers[0].id), longContent);
|
|
277
|
+
assert.match(chunks.get(primerId), /update\(\);\nfinish\(\);/);
|
|
278
|
+
const cliRange = JSON.parse(execFileSync(process.execPath, [join(installed, "dist/cli.js"),
|
|
279
|
+
"primers", "by-version", "1.21.1", "26.1", "--modloader=neoforge", "--include-content", "--fetch-content=false"], {
|
|
280
|
+
env: { ...env, DATABASE_URL: `file:${join(dataHome, "data/modlens.db")}` }, encoding: "utf8", timeout: 30_000,
|
|
281
|
+
}));
|
|
282
|
+
assert.equal(cliRange.count, 2);
|
|
283
|
+
assert.equal(cliRange.nextCursor, null);
|
|
284
|
+
assert.equal(cliRange.primers.find(primer => primer.id === added.primers[0].id).content, longContent);
|
|
285
|
+
assert.equal((await primerCall({ ...rangeArgs, includeContent: true, cursor: "invalid" })).isError, true);
|
|
286
|
+
const failed = await primerCall({ action: "ingest", entries: [{
|
|
287
|
+
fromVersion: "1.21.1", toVersion: "1.21.5", title: "Missing primer",
|
|
288
|
+
url: "https://docs.neoforged.net/package-primer-missing", fetchContent: true,
|
|
289
|
+
}] });
|
|
290
|
+
assert.equal(failed.isError, true, "Requested content fetch failures must be MCP errors");
|
|
291
|
+
assert.equal(decode(failed).failed, 1);
|
|
292
|
+
const missing = decode(await primerCall({ action: "ingest", entries: [{
|
|
293
|
+
fromVersion: "1.21.1", toVersion: "1.21.5", title: "Missing bundled primer",
|
|
294
|
+
url: "https://docs.neoforged.net/package-primer-missing",
|
|
295
|
+
}] }));
|
|
296
|
+
const partial = await primerCall({ ...rangeArgs, includeContent: true });
|
|
297
|
+
assert.equal(partial.isError, true, "Bundle fetch failures must be MCP errors with successful content retained");
|
|
298
|
+
assert.equal(decode(partial).failed, 1);
|
|
299
|
+
assert.equal(decode(partial).primers.filter(primer => primer.contentStatus === "ready").length, 2);
|
|
300
|
+
await primerCall({ action: "delete", id: added.primers[0].id });
|
|
301
|
+
await primerCall({ action: "delete", id: missing.primers[0].id });
|
|
169
302
|
return run.output();
|
|
170
303
|
} finally { await stop(run); }
|
|
171
304
|
}
|
|
@@ -174,7 +307,7 @@ try {
|
|
|
174
307
|
assert.match(first, /First run/);
|
|
175
308
|
const database = join(dataHome, "data/modlens.db");
|
|
176
309
|
assert.ok(statSync(database).size > 0);
|
|
177
|
-
console.log("PASS: first-run SQLite bootstrap, MCP handshake,
|
|
310
|
+
console.log("PASS: first-run SQLite bootstrap, MCP handshake, primer bundles/pagination/errors, CLI ranges, database stats");
|
|
178
311
|
|
|
179
312
|
// Check the actual installed client/adapter with dates, BLOBs, and transactions.
|
|
180
313
|
// Use a child so Windows releases the native .node file before cleanup.
|
|
@@ -212,6 +345,9 @@ try {
|
|
|
212
345
|
console.log("PASS: second npx run preserves existing database contents");
|
|
213
346
|
passed = true;
|
|
214
347
|
} finally {
|
|
215
|
-
if (passed)
|
|
348
|
+
if (passed) {
|
|
349
|
+
assert.equal(dirname(resolve(root)), resolve(tmpdir()), "Cleanup must stay within the test temp directory");
|
|
350
|
+
rmSync(root, { recursive: true, force: true, maxRetries: 5, retryDelay: 300 });
|
|
351
|
+
}
|
|
216
352
|
else console.error(`Package test artifacts retained at ${root}`);
|
|
217
353
|
}
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
// Live upstream check, isolated from the user's database/cache. Run after npm run build.
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
import { copyFile, mkdtemp, rm } from "node:fs/promises";
|
|
4
|
+
import { tmpdir } from "node:os";
|
|
5
|
+
import { dirname, join, resolve } from "node:path";
|
|
6
|
+
import { fileURLToPath } from "node:url";
|
|
7
|
+
|
|
8
|
+
const repository = dirname(dirname(fileURLToPath(import.meta.url)));
|
|
9
|
+
const root = await mkdtemp(join(tmpdir(), "modlens-primers-live-"));
|
|
10
|
+
await copyFile(join(repository, "prisma/backends/template.db"), join(root, "test.db"));
|
|
11
|
+
process.env.DATABASE_URL = "file:" + join(root, "test.db");
|
|
12
|
+
process.env.MODLENS_CACHE_ROOT = join(root, "cache");
|
|
13
|
+
process.env.OLLAMA_URL = "http://127.0.0.1:1"; // Embeddings are independent of this check.
|
|
14
|
+
const { getDb, disconnect } = await import("../dist/db.js");
|
|
15
|
+
const { seedDefaultPrimers, getPrimer, getPrimersByVersionRange } = await import("../dist/tools/primers.js");
|
|
16
|
+
const { SEED_PRIMERS } = await import("../dist/primer-catalog.js");
|
|
17
|
+
const db = await getDb();
|
|
18
|
+
let passed = false;
|
|
19
|
+
try {
|
|
20
|
+
await db.primer.create({ data: {
|
|
21
|
+
id: 3, fromVersion: "1.21.1", toVersion: "1.21.5", modloader: "neoforge",
|
|
22
|
+
title: "NeoForge Migration Guide - 1.21.1 to 1.21.5 (content)",
|
|
23
|
+
url: "https://docs.neoforged.net/docs/1.21.5/migrationguide/", source: "seed",
|
|
24
|
+
} });
|
|
25
|
+
console.log("Fetching the official primer catalogue into a disposable SQLite database...");
|
|
26
|
+
const seeded = await seedDefaultPrimers();
|
|
27
|
+
console.log(JSON.stringify({ guides: seeded.ingested, ready: seeded.ready, failed: seeded.failed,
|
|
28
|
+
errors: seeded.primers.filter(p => p.error) }, null, 2));
|
|
29
|
+
assert.equal(seeded.ready, SEED_PRIMERS.length);
|
|
30
|
+
assert.equal(seeded.failed, 0);
|
|
31
|
+
const range = await getPrimersByVersionRange("1.21.1", "1.21.5", "neoforge");
|
|
32
|
+
assert.equal(range.count, 6);
|
|
33
|
+
const fullRange = await getPrimersByVersionRange("1.21.1", "26.1", "neoforge");
|
|
34
|
+
assert.equal(fullRange.count, 17);
|
|
35
|
+
const bundledContent = new Map();
|
|
36
|
+
let cursor;
|
|
37
|
+
let bundlePages = 0;
|
|
38
|
+
do {
|
|
39
|
+
const bundle = await getPrimersByVersionRange("1.21.1", "26.1", "neoforge", { includeContent: true, maxChars: 60_000, cursor });
|
|
40
|
+
assert.equal(bundle.failed, 0);
|
|
41
|
+
assert.equal(bundle.missing, 0);
|
|
42
|
+
assert.equal(bundle.count, 17);
|
|
43
|
+
assert.ok(bundle.contentChars <= 60_000);
|
|
44
|
+
for (const primer of bundle.primers) {
|
|
45
|
+
const previous = bundledContent.get(primer.id) ?? "";
|
|
46
|
+
assert.equal(primer.startOffset, previous.length);
|
|
47
|
+
bundledContent.set(primer.id, previous + primer.content);
|
|
48
|
+
}
|
|
49
|
+
cursor = bundle.nextCursor ?? undefined;
|
|
50
|
+
assert.ok(++bundlePages < 100, "Bundle pagination must terminate");
|
|
51
|
+
} while (cursor);
|
|
52
|
+
for (const primer of fullRange.primers) {
|
|
53
|
+
const stored = await db.primer.findUniqueOrThrow({ where: { id: primer.id } });
|
|
54
|
+
assert.equal(bundledContent.get(primer.id), stored.content, `Bundle must retain all content for ${primer.title}`);
|
|
55
|
+
}
|
|
56
|
+
const reported = await getPrimer(3);
|
|
57
|
+
assert.equal(reported.url, "https://docs.neoforged.net/primer/docs/1.21.5/");
|
|
58
|
+
assert.equal(reported.contentStatus, "ready");
|
|
59
|
+
const saved = await db.primer.findUniqueOrThrow({ where: { id: 3 } });
|
|
60
|
+
assert.ok(saved.content.length > 50_000, "The guide must survive the old 50K truncation limit");
|
|
61
|
+
assert.ok(saved.content.includes(String.fromCharCode(96).repeat(3)), "Code fences must survive extraction");
|
|
62
|
+
assert.match(saved.content, /var blocker = new BlocksAttacks\(\n\s+\/\//, "Highlighted code must preserve line breaks before comments");
|
|
63
|
+
let page = reported;
|
|
64
|
+
const parts = [];
|
|
65
|
+
while (true) {
|
|
66
|
+
parts.push(page.content);
|
|
67
|
+
if (page.nextStartLine === null) break;
|
|
68
|
+
page = await getPrimer(3, { startLine: page.nextStartLine });
|
|
69
|
+
}
|
|
70
|
+
assert.equal(parts.join("\n"), saved.content);
|
|
71
|
+
const neo = range.primers.find(p => p.toVersion === "1.21.5" && p.modloader === "neoforge");
|
|
72
|
+
const loader = await db.primer.findUniqueOrThrow({ where: { id: neo.id } });
|
|
73
|
+
assert.match(loader.content, /Embedded source: https:\/\/neoforged.net\/news\/21.5release\//);
|
|
74
|
+
// A restarted, disconnected client should still be able to read the cached body.
|
|
75
|
+
const originalFetch = globalThis.fetch;
|
|
76
|
+
try {
|
|
77
|
+
globalThis.fetch = async () => { throw new Error("Offline check"); };
|
|
78
|
+
await disconnect();
|
|
79
|
+
assert.equal((await getPrimer(3)).content, reported.content);
|
|
80
|
+
const offlineBundle = await getPrimersByVersionRange("1.21.1", "26.1", "neoforge", { includeContent: true });
|
|
81
|
+
assert.equal(offlineBundle.failed, 0);
|
|
82
|
+
assert.ok(offlineBundle.contentChars > 0);
|
|
83
|
+
} finally { globalThis.fetch = originalFetch; }
|
|
84
|
+
console.log("PASS: legacy ID repaired, live guide extraction, 17-guide range bundling, lossless pagination, and offline cached reads");
|
|
85
|
+
console.log(JSON.stringify({ reportGuideCharacters: saved.content.length, reportGuideLines: reported.totalLines, pages: parts.length, bundleGuides: bundledContent.size, bundlePages }));
|
|
86
|
+
passed = true;
|
|
87
|
+
} finally {
|
|
88
|
+
await disconnect();
|
|
89
|
+
if (passed) {
|
|
90
|
+
assert.equal(dirname(resolve(root)), resolve(tmpdir()), "Cleanup must stay within the test temp directory");
|
|
91
|
+
await rm(root, { recursive: true, force: true, maxRetries: 5 });
|
|
92
|
+
} else console.error("Live primer test artifacts retained at " + root);
|
|
93
|
+
}
|
|
@@ -11,6 +11,8 @@ import { once } from 'node:events';
|
|
|
11
11
|
import AdmZip from 'adm-zip';
|
|
12
12
|
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
|
|
13
13
|
import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js';
|
|
14
|
+
import { licenseTemplates } from '../dist/license-templates.js';
|
|
15
|
+
import { ElicitRequestSchema } from '@modelcontextprotocol/sdk/types.js';
|
|
14
16
|
|
|
15
17
|
const repo = resolve(dirname(fileURLToPath(import.meta.url)), '..');
|
|
16
18
|
const root = await mkdtemp(join(tmpdir(), 'modlens-http-smoke-'));
|
|
@@ -28,14 +30,22 @@ if (!process.argv[2]) {
|
|
|
28
30
|
const jar = new AdmZip();
|
|
29
31
|
jar.addFile('example/Target.class', Buffer.from('test class entry'));
|
|
30
32
|
jar.addFile('example/Target.java', Buffer.from('package example; public class Target { public int counter; }'));
|
|
33
|
+
jar.addFile('LICENSE', Buffer.from(licenseTemplates['0BSD'].text));
|
|
31
34
|
// An incompressible payload exercises multiple full upload chunks.
|
|
32
35
|
jar.addFile('payload.bin', randomBytes(2 * 1024 * 1024));
|
|
33
36
|
const bytes = jar.toBuffer();
|
|
34
37
|
const bundle = new AdmZip();
|
|
35
38
|
bundle.addFile('artifacts/0.jar', bytes);
|
|
39
|
+
const restricted = new AdmZip();
|
|
40
|
+
restricted.addFile('LICENSE', Buffer.from('All Rights Reserved'));
|
|
41
|
+
restricted.addFile('example/Restricted.class', Buffer.from('restricted class fixture'));
|
|
42
|
+
restricted.addFile('example/Restricted.java', Buffer.from('RESTRICTED_SOURCE_MUST_STAY_LOCAL'));
|
|
43
|
+
const restrictedBytes = restricted.toBuffer();
|
|
44
|
+
bundle.addFile('artifacts/1.jar', restrictedBytes);
|
|
36
45
|
bundle.addFile('manifest.json', Buffer.from(JSON.stringify({ format: 'modlens-project-v1', project: 'http-smoke:', sourceSet: 'main',
|
|
37
46
|
toolchain: 'gradle-compile-classpath', minecraftVersion: '', loaderVersion: '', mappings: 'compile-classpath', javaVersion: 21, transformations: [],
|
|
38
|
-
artifacts: [{ path: 'artifacts/0.jar', name: 'prepared.jar', size: bytes.length, sha256: createHash('sha256').update(bytes).digest('hex'), kind: 'classpath' }
|
|
47
|
+
artifacts: [{ path: 'artifacts/0.jar', name: 'prepared.jar', size: bytes.length, sha256: createHash('sha256').update(bytes).digest('hex'), kind: 'classpath' },
|
|
48
|
+
{ path: 'artifacts/1.jar', name: 'restricted.jar', size: restrictedBytes.length, sha256: createHash('sha256').update(restrictedBytes).digest('hex'), kind: 'classpath' }] })));
|
|
39
49
|
await writeFile(bundlePath, bundle.toBuffer());
|
|
40
50
|
}
|
|
41
51
|
const env = { ...process.env, MCP_PORT: String(port), MCP_HOST: '127.0.0.1', MODLENS_AUTO_EMBED: '0',
|
|
@@ -44,7 +54,9 @@ const server = spawn(process.execPath, [join(repo, 'dist/server.js')], { cwd: ro
|
|
|
44
54
|
let logs = '';
|
|
45
55
|
server.stderr.on('data', b => { logs += b; });
|
|
46
56
|
const stopped = once(server, 'exit');
|
|
47
|
-
const client = new Client({ name: 'project-http-test', version: '1.0.0' });
|
|
57
|
+
const client = new Client({ name: 'project-http-test', version: '1.0.0' }, { capabilities: { elicitation: { form: {} } } });
|
|
58
|
+
let acceptLocal = true;
|
|
59
|
+
client.setRequestHandler(ElicitRequestSchema, async () => acceptLocal ? { action: 'accept', content: { acceptLocal: true } } : { action: 'decline' });
|
|
48
60
|
let passed = false;
|
|
49
61
|
try {
|
|
50
62
|
await new Promise((resolveReady, reject) => {
|
|
@@ -69,6 +81,37 @@ try {
|
|
|
69
81
|
const source = await call('source', { className: target });
|
|
70
82
|
assert.ok(!source.isError, JSON.stringify(source));
|
|
71
83
|
assert.ok(JSON.parse(source.content[0].text).source.length > 0);
|
|
84
|
+
const complianceBlock = source.content.find(c => c.type === 'text' && c.text.includes('licenseCompliance'));
|
|
85
|
+
assert.ok(complianceBlock, 'required license notices must accompany source');
|
|
86
|
+
assert.ok(JSON.parse(source.content[0].text).source.split(/\r?\n/).length <= 200);
|
|
87
|
+
if (process.env.MODLENS_TEST_EXPECTED_LICENSE) {
|
|
88
|
+
const compliance = JSON.parse(complianceBlock.text).licenseCompliance[0];
|
|
89
|
+
assert.equal(compliance.license, process.env.MODLENS_TEST_EXPECTED_LICENSE);
|
|
90
|
+
assert.equal(compliance.conditions.outputLicense, compliance.license);
|
|
91
|
+
assert.ok(compliance.notices.every(n => n.licenseText.length > 0));
|
|
92
|
+
assert.ok(compliance.modifiedAt);
|
|
93
|
+
if (compliance.license === 'LGPL-3.0-only') assert.ok(compliance.conditions.sourceArchive);
|
|
94
|
+
if (compliance.license === 'CC-BY-NC-SA-4.0') assert.equal(compliance.conditions.noncommercialOnly, true);
|
|
95
|
+
}
|
|
96
|
+
if (!process.argv[2]) {
|
|
97
|
+
assert.ok(source.content.some(c => c.type === 'text' && c.text.includes('licenseCompliance')), 'required license notices must accompany source');
|
|
98
|
+
const restricted = await call('source', { className: 'example.Restricted' });
|
|
99
|
+
const fallback = JSON.parse(restricted.content[0].text);
|
|
100
|
+
assert.equal(fallback.licenseReview.disposition, 'local_only');
|
|
101
|
+
assert.equal(fallback.local.requiresUserConsent, true);
|
|
102
|
+
assert.ok(!JSON.stringify(restricted).includes('RESTRICTED_SOURCE_MUST_STAY_LOCAL'));
|
|
103
|
+
const searched = await call('search', { query: 'RESTRICTED_SOURCE' });
|
|
104
|
+
assert.deepEqual(JSON.parse(searched.content[0].text).results, []);
|
|
105
|
+
const consent = () => client.callTool({ name: 'mod_license', arguments: {
|
|
106
|
+
action: 'local_plan', projectKey: key, environmentId: imported.environmentId, className: 'example.Restricted',
|
|
107
|
+
} });
|
|
108
|
+
const accepted = JSON.parse((await consent()).content[0].text).local;
|
|
109
|
+
assert.equal(accepted.executed, false);
|
|
110
|
+
assert.equal(accepted.request.acceptedLocalDecompilation, true);
|
|
111
|
+
assert.equal(accepted.requiresUserConsent, false);
|
|
112
|
+
acceptLocal = false;
|
|
113
|
+
assert.equal(JSON.parse((await consent()).content[0].text).declined, true);
|
|
114
|
+
}
|
|
72
115
|
if (process.argv[5]) {
|
|
73
116
|
const members = await call('members', { className: target });
|
|
74
117
|
assert.ok(!members.isError, JSON.stringify(members));
|
|
@@ -88,7 +131,7 @@ try {
|
|
|
88
131
|
assert.equal(large.status, 413);
|
|
89
132
|
assert.ok(!logs.includes(key), 'server logs must redact project key');
|
|
90
133
|
assert.ok(!logs.includes('SENSITIVE_PAYLOAD'), 'server logs must redact upload bytes');
|
|
91
|
-
console.log(`PASS: HTTP upload, ${imported.classCount} classes, source query, project isolation, host-path rejection, body limits and log redaction`);
|
|
134
|
+
console.log(`PASS: HTTP upload, ${imported.classCount} classes, source query and complete notices, project isolation, host-path rejection, body limits and log redaction${process.argv[2] ? '' : ', restricted-source gating and local consent'}`);
|
|
92
135
|
passed = true;
|
|
93
136
|
} finally {
|
|
94
137
|
await client.close();
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
// Validates the same wrapper/init-script path used by MCP and IntelliJ.
|
|
2
|
+
// Requires JAVA_HOME (25+) and MODLENS_TEST_GRADLE_HOME (a Gradle 9.6 distribution).
|
|
3
|
+
import { mkdtemp, mkdir, writeFile, readdir, readFile } from "node:fs/promises";
|
|
4
|
+
import { tmpdir } from "node:os";
|
|
5
|
+
import { join } from "node:path";
|
|
6
|
+
import { execFileSync } from "node:child_process";
|
|
7
|
+
import assert from "node:assert/strict";
|
|
8
|
+
import { RuntimeHub } from "../dist/runtime/hub.js";
|
|
9
|
+
import { runtimeTestClient } from "./runtime-test-client.mjs";
|
|
10
|
+
|
|
11
|
+
const gradleHome = process.env.MODLENS_TEST_GRADLE_HOME;
|
|
12
|
+
const javaHome = process.env.JAVA_HOME;
|
|
13
|
+
if (!gradleHome || !javaHome) throw new Error("Set JAVA_HOME and MODLENS_TEST_GRADLE_HOME");
|
|
14
|
+
const root = await mkdtemp(join(tmpdir(), "modlens-runtime-gradle-"));
|
|
15
|
+
const project = join(root, "project with spaces");
|
|
16
|
+
await mkdir(join(project, "src/main/java"), { recursive: true });
|
|
17
|
+
await writeFile(join(project, "settings.gradle"), "rootProject.name = 'runtime-smoke'\n");
|
|
18
|
+
await writeFile(join(project, "gradle.properties"), "org.gradle.daemon=false\n");
|
|
19
|
+
await writeFile(
|
|
20
|
+
join(project, "build.gradle"),
|
|
21
|
+
`
|
|
22
|
+
plugins { id 'java' }
|
|
23
|
+
tasks.register('runClient', JavaExec) {
|
|
24
|
+
classpath = sourceSets.main.runtimeClasspath
|
|
25
|
+
mainClass = 'Fixture'
|
|
26
|
+
args 'agent'
|
|
27
|
+
}
|
|
28
|
+
tasks.register('plainRun', JavaExec) {
|
|
29
|
+
classpath = sourceSets.main.runtimeClasspath
|
|
30
|
+
mainClass = 'Fixture'
|
|
31
|
+
args 'plain'
|
|
32
|
+
}
|
|
33
|
+
`,
|
|
34
|
+
);
|
|
35
|
+
await writeFile(
|
|
36
|
+
join(project, "src/main/java/Fixture.java"),
|
|
37
|
+
`
|
|
38
|
+
public class Fixture {
|
|
39
|
+
public static void main(String[] args) throws Exception {
|
|
40
|
+
long count = java.lang.management.ManagementFactory.getRuntimeMXBean().getInputArguments()
|
|
41
|
+
.stream().filter(s -> s.startsWith("-javaagent:") && s.contains(".modlens")).count();
|
|
42
|
+
if (count != (args[0].equals("agent") ? 1 : 0)) throw new AssertionError("agent count " + count);
|
|
43
|
+
System.out.println("AGENT_COUNT=" + count);
|
|
44
|
+
var vmArgs = java.lang.management.ManagementFactory.getRuntimeMXBean().getInputArguments();
|
|
45
|
+
boolean hasShadowOption = vmArgs.stream().anyMatch(s -> s.startsWith("-XX:StackShadowPages="));
|
|
46
|
+
if (hasShadowOption != args[0].equals("agent")) throw new AssertionError("Stack setting leaked or missing: " + vmArgs);
|
|
47
|
+
if (count == 1) {
|
|
48
|
+
String shadowPages = java.lang.management.ManagementFactory.getPlatformMXBean(
|
|
49
|
+
com.sun.management.HotSpotDiagnosticMXBean.class).getVMOption("StackShadowPages").getValue();
|
|
50
|
+
if (!shadowPages.equals("32")) throw new AssertionError("Effective StackShadowPages=" + shadowPages);
|
|
51
|
+
System.out.println("STACK_SHADOW_PAGES=" + shadowPages);
|
|
52
|
+
}
|
|
53
|
+
if (count == 1) Thread.sleep(4000);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
`,
|
|
57
|
+
);
|
|
58
|
+
const launcher = (await readdir(join(gradleHome, "lib"))).find((n) =>
|
|
59
|
+
/^gradle-gradle-cli-main-.*\.jar$/.test(n),
|
|
60
|
+
);
|
|
61
|
+
if (!launcher) throw new Error("Expected a Gradle 9.6 distribution");
|
|
62
|
+
const java = join(javaHome, "bin", process.platform === "win32" ? "java.exe" : "java");
|
|
63
|
+
execFileSync(java, ["-jar", join(gradleHome, "lib", launcher), "--no-daemon", "--offline", "wrapper"], {
|
|
64
|
+
cwd: project,
|
|
65
|
+
stdio: "inherit",
|
|
66
|
+
timeout: 120000,
|
|
67
|
+
});
|
|
68
|
+
const hub = process.argv.includes("--helper")
|
|
69
|
+
? runtimeTestClient(root)
|
|
70
|
+
: new RuntimeHub(join(root, "bridge"));
|
|
71
|
+
try {
|
|
72
|
+
const setup = await hub.setup(project, "hidden");
|
|
73
|
+
const init = join(project, ".modlens/runtime/init.gradle");
|
|
74
|
+
const plain = execFileSync(
|
|
75
|
+
java,
|
|
76
|
+
[
|
|
77
|
+
"-jar",
|
|
78
|
+
join(gradleHome, "lib", launcher),
|
|
79
|
+
"--no-daemon",
|
|
80
|
+
"--offline",
|
|
81
|
+
"--no-configuration-cache",
|
|
82
|
+
"-I",
|
|
83
|
+
init,
|
|
84
|
+
"plainRun",
|
|
85
|
+
],
|
|
86
|
+
{ cwd: project, encoding: "utf8", timeout: 120000 },
|
|
87
|
+
);
|
|
88
|
+
assert.match(plain, /AGENT_COUNT=0/);
|
|
89
|
+
await hub.launch(setup.projectId, javaHome);
|
|
90
|
+
const deadline = Date.now() + 120000;
|
|
91
|
+
let done;
|
|
92
|
+
while (Date.now() < deadline) {
|
|
93
|
+
const events = (await hub.events()).events;
|
|
94
|
+
done = events.find((e) => e.type === "launch_log");
|
|
95
|
+
if (done) break;
|
|
96
|
+
await new Promise((r) => setTimeout(r, 250));
|
|
97
|
+
}
|
|
98
|
+
assert.ok(done, "Gradle launch did not finish");
|
|
99
|
+
assert.match(done.data.text, /AGENT_COUNT=1/);
|
|
100
|
+
assert.match(done.data.text, /STACK_SHADOW_PAGES=32/);
|
|
101
|
+
assert.match(done.data.text, /BUILD SUCCESSFUL/);
|
|
102
|
+
assert.equal((await hub.list()).length, 1, "Only the game task should register");
|
|
103
|
+
assert.match(await readFile(setup.runConfiguration, "utf8"), /--init-script/);
|
|
104
|
+
console.log("Gradle optional agent launch passed. Evidence: " + root);
|
|
105
|
+
} finally {
|
|
106
|
+
await hub.close();
|
|
107
|
+
}
|