@creeperhost/modlens-mcp 1.6.18 → 1.6.20
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/README.md +112 -15
- package/TESTING.md +69 -0
- package/dist/cli.js +49 -13
- package/dist/cli.js.map +1 -1
- package/dist/curseforge.d.ts +9 -7
- package/dist/curseforge.d.ts.map +1 -1
- package/dist/curseforge.js +48 -55
- package/dist/curseforge.js.map +1 -1
- package/dist/db.d.ts.map +1 -1
- package/dist/db.js +31 -1
- package/dist/db.js.map +1 -1
- package/dist/embeddings.d.ts.map +1 -1
- package/dist/embeddings.js +1 -0
- package/dist/embeddings.js.map +1 -1
- package/dist/feed-the-beast.d.ts +5 -5
- package/dist/feed-the-beast.d.ts.map +1 -1
- package/dist/feed-the-beast.js +5 -21
- package/dist/feed-the-beast.js.map +1 -1
- package/dist/java-tools.d.ts +1 -0
- package/dist/java-tools.d.ts.map +1 -1
- package/dist/java-tools.js +31 -7
- package/dist/java-tools.js.map +1 -1
- package/dist/launcher.js +15 -0
- package/dist/launcher.js.map +1 -1
- package/dist/mappings.d.ts +4 -1
- package/dist/mappings.d.ts.map +1 -1
- package/dist/mappings.js +120 -52
- package/dist/mappings.js.map +1 -1
- package/dist/minecraft.d.ts +0 -4
- package/dist/minecraft.d.ts.map +1 -1
- package/dist/minecraft.js +7 -23
- package/dist/minecraft.js.map +1 -1
- package/dist/mod-artifacts.d.ts +4 -0
- package/dist/mod-artifacts.d.ts.map +1 -0
- package/dist/mod-artifacts.js +58 -0
- package/dist/mod-artifacts.js.map +1 -0
- package/dist/modpacks-ch.d.ts +72 -46
- package/dist/modpacks-ch.d.ts.map +1 -1
- package/dist/modpacks-ch.js +134 -20
- package/dist/modpacks-ch.js.map +1 -1
- package/dist/modrinth.d.ts +14 -6
- package/dist/modrinth.d.ts.map +1 -1
- package/dist/modrinth.js +70 -55
- package/dist/modrinth.js.map +1 -1
- package/dist/platform-adapter.d.ts +1 -0
- package/dist/platform-adapter.d.ts.map +1 -1
- package/dist/platform.d.ts.map +1 -1
- package/dist/platform.js +20 -44
- package/dist/platform.js.map +1 -1
- 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/processor.d.ts +5 -0
- package/dist/processor.d.ts.map +1 -1
- package/dist/processor.js +166 -46
- package/dist/processor.js.map +1 -1
- package/dist/registry-submission.d.ts +34 -0
- package/dist/registry-submission.d.ts.map +1 -0
- package/dist/registry-submission.js +128 -0
- package/dist/registry-submission.js.map +1 -0
- package/dist/repositories/array-fields.d.ts +3 -0
- package/dist/repositories/array-fields.d.ts.map +1 -0
- package/dist/repositories/array-fields.js +8 -0
- package/dist/repositories/array-fields.js.map +1 -0
- package/dist/repositories/embeddings-sqlite.d.ts +1 -0
- package/dist/repositories/embeddings-sqlite.d.ts.map +1 -1
- package/dist/repositories/embeddings-sqlite.js +26 -58
- package/dist/repositories/embeddings-sqlite.js.map +1 -1
- package/dist/repositories/embeddings.d.ts.map +1 -1
- package/dist/repositories/embeddings.js +26 -3
- package/dist/repositories/embeddings.js.map +1 -1
- package/dist/repositories/index.d.ts +1 -1
- package/dist/repositories/index.d.ts.map +1 -1
- package/dist/repositories/mod.d.ts +8 -5
- package/dist/repositories/mod.d.ts.map +1 -1
- package/dist/repositories/mod.js +25 -11
- package/dist/repositories/mod.js.map +1 -1
- package/dist/repositories/packs.d.ts.map +1 -1
- package/dist/repositories/packs.js +10 -2
- package/dist/repositories/packs.js.map +1 -1
- package/dist/search-adapter.d.ts +2 -0
- package/dist/search-adapter.d.ts.map +1 -1
- package/dist/search-adapter.js +21 -9
- package/dist/search-adapter.js.map +1 -1
- package/dist/security.d.ts +2 -0
- package/dist/security.d.ts.map +1 -1
- package/dist/security.js +5 -1
- package/dist/security.js.map +1 -1
- package/dist/server.js +80 -35
- package/dist/server.js.map +1 -1
- package/dist/setup.js +9 -4
- package/dist/setup.js.map +1 -1
- package/dist/sqlite-json.d.ts +3 -0
- package/dist/sqlite-json.d.ts.map +1 -0
- package/dist/sqlite-json.js +78 -0
- package/dist/sqlite-json.js.map +1 -0
- package/dist/sqlite-parameters.d.ts +3 -0
- package/dist/sqlite-parameters.d.ts.map +1 -0
- package/dist/sqlite-parameters.js +19 -0
- package/dist/sqlite-parameters.js.map +1 -0
- package/dist/sqlite-schema.d.ts +4 -0
- package/dist/sqlite-schema.d.ts.map +1 -0
- package/dist/sqlite-schema.js +76 -0
- package/dist/sqlite-schema.js.map +1 -0
- package/dist/tools/catalog.d.ts +1 -6
- package/dist/tools/catalog.d.ts.map +1 -1
- package/dist/tools/catalog.js +10 -14
- package/dist/tools/catalog.js.map +1 -1
- package/dist/tools/compat-check.d.ts.map +1 -1
- package/dist/tools/compat-check.js +18 -30
- package/dist/tools/compat-check.js.map +1 -1
- package/dist/tools/diagnostics.js.map +1 -1
- package/dist/tools/docs.d.ts.map +1 -1
- package/dist/tools/docs.js +8 -3
- package/dist/tools/docs.js.map +1 -1
- package/dist/tools/embed-registry.d.ts +20 -4
- package/dist/tools/embed-registry.d.ts.map +1 -1
- package/dist/tools/embed-registry.js +30 -19
- package/dist/tools/embed-registry.js.map +1 -1
- package/dist/tools/graphify.d.ts +19 -4
- package/dist/tools/graphify.d.ts.map +1 -1
- package/dist/tools/graphify.js +61 -13
- package/dist/tools/graphify.js.map +1 -1
- package/dist/tools/ingest.d.ts.map +1 -1
- package/dist/tools/ingest.js +4 -4
- package/dist/tools/ingest.js.map +1 -1
- package/dist/tools/kubejs.js +1 -1
- package/dist/tools/kubejs.js.map +1 -1
- package/dist/tools/mc-fts.d.ts.map +1 -1
- package/dist/tools/mc-fts.js +9 -4
- package/dist/tools/mc-fts.js.map +1 -1
- package/dist/tools/mcmeta.d.ts +10 -1
- package/dist/tools/mcmeta.d.ts.map +1 -1
- package/dist/tools/mcmeta.js +43 -29
- package/dist/tools/mcmeta.js.map +1 -1
- package/dist/tools/mixin-scan.d.ts +12 -6
- package/dist/tools/mixin-scan.d.ts.map +1 -1
- package/dist/tools/mixin-scan.js +12 -7
- package/dist/tools/mixin-scan.js.map +1 -1
- package/dist/tools/mod-data.d.ts.map +1 -1
- package/dist/tools/mod-data.js +40 -27
- package/dist/tools/mod-data.js.map +1 -1
- package/dist/tools/modpacks-ch.d.ts +9 -5
- package/dist/tools/modpacks-ch.d.ts.map +1 -1
- package/dist/tools/modpacks-ch.js +54 -33
- package/dist/tools/modpacks-ch.js.map +1 -1
- package/dist/tools/packtools.js +3 -3
- package/dist/tools/packtools.js.map +1 -1
- package/dist/tools/platform.d.ts +2 -2
- package/dist/tools/platform.d.ts.map +1 -1
- package/dist/tools/platform.js +33 -23
- package/dist/tools/platform.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 +342 -0
- package/dist/tools/project.d.ts.map +1 -0
- package/dist/tools/project.js +519 -0
- package/dist/tools/project.js.map +1 -0
- package/dist/tools/reports.d.ts.map +1 -1
- package/dist/tools/reports.js +5 -3
- package/dist/tools/reports.js.map +1 -1
- package/dist/tools/vanilla-data.d.ts.map +1 -1
- package/dist/tools/vanilla-data.js +3 -33
- package/dist/tools/vanilla-data.js.map +1 -1
- package/dist/tools/vanilla.d.ts +3 -1
- package/dist/tools/vanilla.d.ts.map +1 -1
- package/dist/tools/vanilla.js +26 -12
- package/dist/tools/vanilla.js.map +1 -1
- package/dist/tools/version-diff.d.ts.map +1 -1
- package/dist/tools/version-diff.js +4 -24
- package/dist/tools/version-diff.js.map +1 -1
- package/dist/version-ranges.d.ts +5 -0
- package/dist/version-ranges.d.ts.map +1 -0
- package/dist/version-ranges.js +93 -0
- package/dist/version-ranges.js.map +1 -0
- package/package.json +9 -1
- package/prisma/backends/template.db +0 -0
- package/scripts/build-template-db.mjs +3 -1
- package/scripts/enable-sqlite-vec.mjs +2 -106
- package/scripts/gradle/modlens.init.gradle +154 -0
- package/scripts/project-upload.mjs +74 -0
- package/scripts/test-minecraft-eras.mjs +202 -0
- package/scripts/test-package.mjs +105 -5
- package/scripts/test-primers-live.mjs +93 -0
- package/scripts/test-project-gradle.mjs +76 -0
- package/scripts/test-project-http.mjs +98 -0
- package/scripts/test-setup.mjs +18 -0
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
// Apply without modifying a project: ./gradlew -I /path/to/modlens.init.gradle :modlensExport
|
|
2
|
+
// ModDevGradle 2.x adapter plus a generic Java source-set compile-classpath exporter.
|
|
3
|
+
import groovy.json.JsonOutput
|
|
4
|
+
import java.security.MessageDigest
|
|
5
|
+
import java.security.SecureRandom
|
|
6
|
+
import java.nio.file.Files
|
|
7
|
+
import java.nio.file.StandardCopyOption
|
|
8
|
+
import java.nio.file.StandardOpenOption
|
|
9
|
+
import java.nio.file.FileAlreadyExistsException
|
|
10
|
+
import java.util.zip.ZipEntry
|
|
11
|
+
import java.util.zip.ZipOutputStream
|
|
12
|
+
import org.gradle.api.GradleException
|
|
13
|
+
import org.gradle.api.plugins.JavaPluginExtension
|
|
14
|
+
import org.gradle.api.provider.Provider
|
|
15
|
+
|
|
16
|
+
def sha256 = { File file ->
|
|
17
|
+
def md = MessageDigest.getInstance('SHA-256')
|
|
18
|
+
file.withInputStream { input ->
|
|
19
|
+
byte[] buffer = new byte[65536]
|
|
20
|
+
int count
|
|
21
|
+
while ((count = input.read(buffer)) != -1) md.update(buffer, 0, count)
|
|
22
|
+
}
|
|
23
|
+
md.digest().encodeHex().toString()
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
gradle.projectsEvaluated {
|
|
27
|
+
allprojects { p ->
|
|
28
|
+
def javaExtension = p.extensions.findByType(JavaPluginExtension)
|
|
29
|
+
if (javaExtension == null) return
|
|
30
|
+
String selected = p.findProperty('modlensSourceSet')?.toString() ?: 'main'
|
|
31
|
+
def sourceSet = javaExtension.sourceSets.findByName(selected)
|
|
32
|
+
if (sourceSet == null) {
|
|
33
|
+
// A sibling module may not have the source set selected for the requested module.
|
|
34
|
+
p.tasks.register('modlensExport') {
|
|
35
|
+
doLast { throw new GradleException("Unknown ModLens source set '${selected}' in ${p.path}") }
|
|
36
|
+
}
|
|
37
|
+
return
|
|
38
|
+
}
|
|
39
|
+
boolean moddev = p.plugins.hasPlugin('net.neoforged.moddev') || p.plugins.hasPlugin('net.neoforged.moddev.legacyforge')
|
|
40
|
+
def artifactsTask = moddev ? p.tasks.findByName('createMinecraftArtifacts') : null
|
|
41
|
+
if (moddev && artifactsTask == null) throw new GradleException('ModLens requires a ModDevGradle version with createMinecraftArtifacts (2.x).')
|
|
42
|
+
|
|
43
|
+
p.tasks.register('modlensExport') { task ->
|
|
44
|
+
group = 'modlens'
|
|
45
|
+
description = 'Export the resolved compile environment for local or remote ModLens.'
|
|
46
|
+
// Gradle resolves/builds the actual compilation inputs, including AT processing.
|
|
47
|
+
dependsOn(sourceSet.compileClasspath)
|
|
48
|
+
if (artifactsTask != null) dependsOn(artifactsTask)
|
|
49
|
+
// This task accesses the evaluated model and is intentionally not configuration-cache compatible.
|
|
50
|
+
notCompatibleWithConfigurationCache('ModLens exports the evaluated compile classpath and modding task outputs.')
|
|
51
|
+
doLast {
|
|
52
|
+
File output = p.layout.buildDirectory.dir("modlens/${selected}").get().asFile
|
|
53
|
+
output.mkdirs()
|
|
54
|
+
def artifacts = []
|
|
55
|
+
def files = []
|
|
56
|
+
def byFile = [:]
|
|
57
|
+
def addArtifact = { File file, String kind, String sourceFor ->
|
|
58
|
+
if (!file.isFile()) throw new GradleException("Missing ModLens artifact: ${file.name}")
|
|
59
|
+
String path = "artifacts/${artifacts.size()}.jar"
|
|
60
|
+
def info = [path: path, name: file.name, sha256: sha256(file), size: file.length(), kind: kind]
|
|
61
|
+
if (sourceFor != null) info.sourceFor = sourceFor
|
|
62
|
+
artifacts.add(info)
|
|
63
|
+
files.add(file)
|
|
64
|
+
if (kind == 'classpath') byFile[file.canonicalPath] = path
|
|
65
|
+
path
|
|
66
|
+
}
|
|
67
|
+
sourceSet.compileClasspath.files.eachWithIndex { File file, int index ->
|
|
68
|
+
if (!file.exists()) throw new GradleException("Compile classpath entry does not exist: ${file.name}")
|
|
69
|
+
if (file.isDirectory()) {
|
|
70
|
+
// Project dependencies often expose class directories instead of JARs.
|
|
71
|
+
File jar = new File(output, "directory-${index}.jar")
|
|
72
|
+
def entries = p.fileTree(file).matching { include '**/*.class'; include '**/*.java' }.files.sort { a, b ->
|
|
73
|
+
file.toPath().relativize(a.toPath()).toString() <=> file.toPath().relativize(b.toPath()).toString()
|
|
74
|
+
}
|
|
75
|
+
jar.withOutputStream { stream ->
|
|
76
|
+
def zip = new ZipOutputStream(stream)
|
|
77
|
+
entries.each { entry ->
|
|
78
|
+
def name = file.toPath().relativize(entry.toPath()).toString().replace('\\', '/')
|
|
79
|
+
def ze = new ZipEntry(name); ze.time = 0
|
|
80
|
+
zip.putNextEntry(ze); entry.withInputStream { zip << it }; zip.closeEntry()
|
|
81
|
+
}
|
|
82
|
+
zip.finish()
|
|
83
|
+
}
|
|
84
|
+
addArtifact(jar, 'classpath', null)
|
|
85
|
+
} else if (file.name.endsWith('.jar')) {
|
|
86
|
+
addArtifact(file, 'classpath', null)
|
|
87
|
+
} else {
|
|
88
|
+
throw new GradleException("Unsupported compile classpath entry: ${file.name}")
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
if (artifacts.empty) throw new GradleException('Compile classpath is empty; there is no dependency environment to export.')
|
|
92
|
+
|
|
93
|
+
def transforms = []
|
|
94
|
+
if (artifactsTask != null) {
|
|
95
|
+
// Bind sources to the binary actually selected by the compile classpath, not a guessed cache path.
|
|
96
|
+
def binaryPaths = ['gameJarArtifact', 'gameJarWithSourcesArtifact'].collect { property ->
|
|
97
|
+
artifactsTask.hasProperty(property) ? artifactsTask.property(property).orNull?.asFile?.canonicalPath : null
|
|
98
|
+
}.findAll { it != null && byFile.containsKey(it) }
|
|
99
|
+
if (binaryPaths.empty) throw new GradleException('ModLens cannot locate ModDevGradle Minecraft output on this source set compile classpath. Select a mod-enabled source set.')
|
|
100
|
+
def sourceFile = artifactsTask.hasProperty('gameSourcesArtifact') ? artifactsTask.gameSourcesArtifact.orNull?.asFile : null
|
|
101
|
+
if (sourceFile?.isFile()) binaryPaths.each { path -> addArtifact(sourceFile, 'sources', byFile[path]) }
|
|
102
|
+
if (artifactsTask.hasProperty('accessTransformers')) {
|
|
103
|
+
artifactsTask.accessTransformers.files.each { File file -> transforms.add([name: file.name, sha256: sha256(file)]) }
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
def neo = p.extensions.findByName('neoForge') ?: p.extensions.findByName('legacyForge')
|
|
107
|
+
def propertyText = { String name -> p.findProperty(name)?.toString() ?: '' }
|
|
108
|
+
String loaderVersion = propertyText('neo_version') ?: propertyText('forge_version')
|
|
109
|
+
if (neo != null && neo.hasProperty('version')) {
|
|
110
|
+
def version = neo.version
|
|
111
|
+
loaderVersion = (version instanceof Provider ? version.orNull : version)?.toString() ?: loaderVersion
|
|
112
|
+
}
|
|
113
|
+
String mappings = propertyText('modlensMappings')
|
|
114
|
+
if (!mappings && neo != null && neo.hasProperty('parchment') && neo.parchment.enabled.getOrElse(false)) {
|
|
115
|
+
mappings = "parchment:${neo.parchment.minecraftVersion.orNull ?: ''}:${neo.parchment.mappingsVersion.orNull ?: ''}"
|
|
116
|
+
}
|
|
117
|
+
if (!mappings) mappings = moddev ? 'toolchain-prepared (official names where applicable)' : 'compile-classpath'
|
|
118
|
+
def manifest = [format: 'modlens-project-v1', project: "${p.rootProject.name}${p.path}", sourceSet: selected,
|
|
119
|
+
toolchain: moddev ? 'moddevgradle' : 'gradle-compile-classpath',
|
|
120
|
+
minecraftVersion: propertyText('minecraft_version') ?: propertyText('modlensMinecraftVersion'),
|
|
121
|
+
loaderVersion: loaderVersion, mappings: mappings,
|
|
122
|
+
javaVersion: p.tasks.named(sourceSet.compileJavaTaskName).get().options.release.orNull ?: javaExtension.toolchain.languageVersion.orNull?.asInt() ?: Integer.parseInt(org.gradle.api.JavaVersion.current().majorVersion),
|
|
123
|
+
transformations: transforms, artifacts: artifacts]
|
|
124
|
+
File temporary = new File(output, 'environment.zip.tmp')
|
|
125
|
+
temporary.withOutputStream { stream ->
|
|
126
|
+
def zip = new ZipOutputStream(stream)
|
|
127
|
+
def metadata = new ZipEntry('manifest.json'); metadata.time = 0
|
|
128
|
+
zip.putNextEntry(metadata); zip.write(JsonOutput.toJson(manifest).getBytes('UTF-8')); zip.closeEntry()
|
|
129
|
+
artifacts.eachWithIndex { artifact, index ->
|
|
130
|
+
def entry = new ZipEntry(artifact.path); entry.time = 0
|
|
131
|
+
zip.putNextEntry(entry); files[index].withInputStream { zip << it }; zip.closeEntry()
|
|
132
|
+
}
|
|
133
|
+
zip.finish()
|
|
134
|
+
}
|
|
135
|
+
File destination = new File(output, 'environment.zip')
|
|
136
|
+
Files.move(temporary.toPath(), destination.toPath(), StandardCopyOption.REPLACE_EXISTING)
|
|
137
|
+
File keyFile = new File(p.projectDir, '.gradle/modlens/project-key.txt')
|
|
138
|
+
keyFile.parentFile.mkdirs()
|
|
139
|
+
if (!keyFile.exists()) {
|
|
140
|
+
byte[] key = new byte[32]; new SecureRandom().nextBytes(key)
|
|
141
|
+
// CREATE_NEW prevents concurrent exports from silently replacing a project key.
|
|
142
|
+
try { Files.write(keyFile.toPath(), (key.encodeHex().toString() + '\n').getBytes('UTF-8'), StandardOpenOption.CREATE_NEW) }
|
|
143
|
+
catch (FileAlreadyExistsException ignored) { }
|
|
144
|
+
keyFile.setReadable(false, false); keyFile.setWritable(false, false)
|
|
145
|
+
keyFile.setReadable(true, true); keyFile.setWritable(true, true)
|
|
146
|
+
}
|
|
147
|
+
if (!(keyFile.text.trim() ==~ /[a-f0-9]{64}/)) throw new GradleException('Invalid existing ModLens project key; expected 64 lowercase hex characters.')
|
|
148
|
+
logger.lifecycle("ModLens bundle: ${destination}")
|
|
149
|
+
logger.lifecycle("ModLens private key file: ${keyFile}")
|
|
150
|
+
logger.lifecycle('Re-export and import after changing dependencies, mappings or access transformers.')
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Transfer a Gradle export over MCP without exposing a local filesystem path to the server.
|
|
3
|
+
import { createHash } from 'node:crypto';
|
|
4
|
+
import { createReadStream } from 'node:fs';
|
|
5
|
+
import { readFile, open, stat } from 'node:fs/promises';
|
|
6
|
+
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
|
|
7
|
+
import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js';
|
|
8
|
+
|
|
9
|
+
const [endpoint, bundlePath, keyFile] = process.argv.slice(2);
|
|
10
|
+
if (!endpoint || !bundlePath || !keyFile || process.argv.length !== 5) {
|
|
11
|
+
console.error('Usage: node scripts/project-upload.mjs <https://host/mcp> <environment.zip> <project-key.txt>');
|
|
12
|
+
process.exit(1);
|
|
13
|
+
}
|
|
14
|
+
const url = new URL(endpoint);
|
|
15
|
+
if (url.protocol !== 'https:' && !(url.protocol === 'http:' && ['localhost', '127.0.0.1', '[::1]'].includes(url.hostname))) {
|
|
16
|
+
throw new Error('Use HTTPS for remote project uploads (HTTP is allowed only on loopback).');
|
|
17
|
+
}
|
|
18
|
+
if (url.username || url.password || url.search || url.hash) throw new Error('Endpoint must not contain credentials, query parameters or a fragment. Use MODLENS_AUTH_TOKEN for proxy authentication.');
|
|
19
|
+
const projectKey = (await readFile(keyFile, 'utf8')).trim();
|
|
20
|
+
if (!/^[a-f0-9]{64}$/.test(projectKey)) throw new Error('Invalid project key file');
|
|
21
|
+
const size = (await stat(bundlePath)).size;
|
|
22
|
+
if (!size || size > 512 * 1024 * 1024) throw new Error('Bundle must contain 1 byte to 512 MiB');
|
|
23
|
+
const hash = createHash('sha256');
|
|
24
|
+
for await (const chunk of createReadStream(bundlePath)) hash.update(chunk);
|
|
25
|
+
const client = new Client({ name: 'modlens-project-upload', version: '1.0.0' });
|
|
26
|
+
const transport = new StreamableHTTPClientTransport(url, { requestInit: {
|
|
27
|
+
// Never follow redirects carrying the private project capability in the request body.
|
|
28
|
+
redirect: 'error',
|
|
29
|
+
headers: process.env.MODLENS_AUTH_TOKEN ? { Authorization: `Bearer ${process.env.MODLENS_AUTH_TOKEN}` } : {},
|
|
30
|
+
} });
|
|
31
|
+
let uploadId;
|
|
32
|
+
try {
|
|
33
|
+
await client.connect(transport);
|
|
34
|
+
const call = async (args) => {
|
|
35
|
+
const response = await client.callTool({ name: 'project', arguments: { ...args, projectKey } }, undefined, { timeout: 300_000 });
|
|
36
|
+
const text = response.content?.filter(c => c.type === 'text').map(c => c.text).join('\n');
|
|
37
|
+
if (response.isError) throw new Error(text || 'Project upload failed');
|
|
38
|
+
return JSON.parse(text);
|
|
39
|
+
};
|
|
40
|
+
const begin = await call({ action: 'upload_begin', size, sha256: hash.digest('hex') });
|
|
41
|
+
uploadId = begin.uploadId;
|
|
42
|
+
const file = await open(bundlePath, 'r');
|
|
43
|
+
try {
|
|
44
|
+
let offset = 0;
|
|
45
|
+
while (offset < size) {
|
|
46
|
+
const bytes = Buffer.alloc(Math.min(begin.chunkBytes, size - offset));
|
|
47
|
+
const { bytesRead } = await file.read(bytes, 0, bytes.length, offset);
|
|
48
|
+
if (bytesRead !== bytes.length) throw new Error('Bundle changed during upload; re-export it.');
|
|
49
|
+
// Retrying an acknowledged range is checked byte-for-byte by the server.
|
|
50
|
+
let result;
|
|
51
|
+
for (let attempt = 0; attempt < 3; attempt++) {
|
|
52
|
+
try { result = await call({ action: 'upload_chunk', uploadId, offset, data: bytes.toString('base64') }); break; }
|
|
53
|
+
catch (error) { if (attempt === 2) throw error; }
|
|
54
|
+
}
|
|
55
|
+
if (result.offset !== offset + bytes.length) throw new Error('Unexpected upload offset');
|
|
56
|
+
offset = result.offset;
|
|
57
|
+
}
|
|
58
|
+
} finally { await file.close(); }
|
|
59
|
+
let result;
|
|
60
|
+
for (let attempt = 0; attempt < 3; attempt++) {
|
|
61
|
+
try { result = await call({ action: 'upload_finish', uploadId }); break; }
|
|
62
|
+
catch (error) { if (attempt === 2) throw error; }
|
|
63
|
+
}
|
|
64
|
+
console.log(JSON.stringify(result, null, 2));
|
|
65
|
+
await call({ action: 'upload_abort', uploadId }); // receipt no longer needed
|
|
66
|
+
uploadId = undefined;
|
|
67
|
+
} catch (error) {
|
|
68
|
+
if (uploadId) {
|
|
69
|
+
try { await client.callTool({ name: 'project', arguments: { action: 'upload_abort', projectKey, uploadId } }); } catch { }
|
|
70
|
+
}
|
|
71
|
+
// Do not print transport objects or request bodies, which contain the project capability.
|
|
72
|
+
console.error(`Project upload failed: ${String(error.message).split(projectKey).join('[redacted]')}`);
|
|
73
|
+
process.exitCode = 1;
|
|
74
|
+
} finally { await client.close(); }
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
/** Live MCP compatibility checks. Uses public downloads and an isolated database/cache. */
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
import { appendFile, copyFile, cp, mkdir, mkdtemp, writeFile } 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
|
+
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
|
|
8
|
+
import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
|
|
9
|
+
|
|
10
|
+
const repository = resolve(dirname(fileURLToPath(import.meta.url)), "..");
|
|
11
|
+
const arguments_ = process.argv.slice(2);
|
|
12
|
+
const runMods = !arguments_.length || arguments_.includes("--mods");
|
|
13
|
+
const selectedVersions = arguments_.filter(arg => arg !== "--mods");
|
|
14
|
+
const versions = selectedVersions.length ? selectedVersions : runMods && arguments_.length ? [] : [
|
|
15
|
+
"b1.7.3", "1.2.5", "1.5.2", "1.6.4", "1.7.10", "1.8.9", "1.12.2",
|
|
16
|
+
"1.13.2", "1.16.5", "1.18.2", "1.20.1", "1.20.6", "1.21.1", "26.1.2",
|
|
17
|
+
];
|
|
18
|
+
const root = await mkdtemp(join(tmpdir(), "modlens-minecraft-eras-"));
|
|
19
|
+
const cache = join(root, "cache");
|
|
20
|
+
await mkdir(cache, { recursive: true });
|
|
21
|
+
if (process.env.MODLENS_TEST_ARTIFACT_CACHE) {
|
|
22
|
+
for (const directory of ["mc-jars", "mappings", "tools", "jdk"]) {
|
|
23
|
+
await cp(join(process.env.MODLENS_TEST_ARTIFACT_CACHE, directory), join(cache, directory), { recursive: true }).catch(error => {
|
|
24
|
+
if (error.code !== "ENOENT") throw error;
|
|
25
|
+
});
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
await copyFile(join(repository, "prisma/backends/template.db"), join(root, "database.db"));
|
|
29
|
+
|
|
30
|
+
// An explicit read-only seed can avoid re-downloading the analysis tools.
|
|
31
|
+
if (process.env.MODLENS_TEST_TOOL_CACHE) {
|
|
32
|
+
await mkdir(join(cache, "tools"), { recursive: true });
|
|
33
|
+
for (const name of ["mcsrc-indexer.jar", "vineflower.jar", "tiny-remapper.jar", "SpecialSource.jar"]) {
|
|
34
|
+
await copyFile(join(process.env.MODLENS_TEST_TOOL_CACHE, name), join(cache, "tools", name)).catch(error => {
|
|
35
|
+
if (error.code !== "ENOENT") throw error;
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
const environment = Object.fromEntries(Object.entries(process.env).filter(([key, value]) =>
|
|
40
|
+
typeof value === "string" && !/TOKEN|API_KEY|SECRET|^(MCP_|DATABASE_URL$|MODLENS_|OLLAMA_|AUTO_)/i.test(key),
|
|
41
|
+
));
|
|
42
|
+
Object.assign(environment, {
|
|
43
|
+
DATABASE_URL: `file:${join(root, "database.db").replaceAll("\\", "/")}`,
|
|
44
|
+
MODLENS_HOME: join(root, "home"), MODLENS_CACHE_ROOT: cache,
|
|
45
|
+
MODLENS_AUTO_EMBED: "0", MODLENS_AUTO_GRAPH: "0", OLLAMA_URL: "http://127.0.0.1:1",
|
|
46
|
+
});
|
|
47
|
+
const client = new Client({ name: "minecraft-era-validation", version: "1.0" });
|
|
48
|
+
const transport = new StdioClientTransport({
|
|
49
|
+
command: process.execPath, args: [join(repository, "dist/server.js")],
|
|
50
|
+
cwd: root, env: environment, stderr: "pipe",
|
|
51
|
+
});
|
|
52
|
+
const errors = [];
|
|
53
|
+
const results = [];
|
|
54
|
+
let serverLog = "";
|
|
55
|
+
transport.stderr?.on("data", data => { serverLog += data; });
|
|
56
|
+
console.log(`Evidence: ${root}`);
|
|
57
|
+
|
|
58
|
+
async function call(name, args, validate, expectError = false) {
|
|
59
|
+
const start = Date.now();
|
|
60
|
+
const result = { name, args };
|
|
61
|
+
try {
|
|
62
|
+
const response = await client.callTool({ name, arguments: args }, undefined, { timeout: 300_000 });
|
|
63
|
+
const text = response.content.filter(item => item.type === "text").map(item => item.text).join("\n");
|
|
64
|
+
let data;
|
|
65
|
+
try { data = JSON.parse(text); } catch { data = text; }
|
|
66
|
+
if (args.action === "search_class" && typeof data === "string") data = data.split("\n").filter(Boolean);
|
|
67
|
+
assert.equal(response.isError === true, expectError, text);
|
|
68
|
+
validate?.(data);
|
|
69
|
+
result.passed = true;
|
|
70
|
+
return data;
|
|
71
|
+
} catch (error) {
|
|
72
|
+
result.passed = false;
|
|
73
|
+
result.error = error.message;
|
|
74
|
+
throw error;
|
|
75
|
+
} finally {
|
|
76
|
+
result.elapsedMs = Date.now() - start;
|
|
77
|
+
results.push(result);
|
|
78
|
+
await appendFile(join(root, "results.jsonl"), JSON.stringify(result) + "\n");
|
|
79
|
+
console.log(`${result.passed ? "PASS" : "FAIL"} ${args.version ?? ""} ${name}.${args.action} (${result.elapsedMs}ms)${result.error ? `: ${result.error.slice(0, 220)}` : ""}`);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
try {
|
|
84
|
+
await client.connect(transport);
|
|
85
|
+
await call("mc_versions", { action: "list_mc", type: "all" }, rows => {
|
|
86
|
+
for (const version of versions) assert.ok(rows.some(row => row.id === version), `Version list omits ${version}`);
|
|
87
|
+
}).catch(error => errors.push({ version: "catalog", error: error.message }));
|
|
88
|
+
for (const version of versions) {
|
|
89
|
+
try {
|
|
90
|
+
const classes = await call("mc_source", { action: "search_class", version, query: "Minecraft" }, rows => {
|
|
91
|
+
assert.ok(rows.some(name => /(?:^|\/)Minecraft$/.test(name)), "No named Minecraft class found");
|
|
92
|
+
});
|
|
93
|
+
const className = classes.find(name => /(?:^|\/)Minecraft$/.test(name));
|
|
94
|
+
await call("mc_source", { action: "get_source", version, className, maxLines: 20 }, text => {
|
|
95
|
+
assert.equal(typeof text, "string");
|
|
96
|
+
assert.match(text, /(?:package|import) net\.minecraft\./);
|
|
97
|
+
});
|
|
98
|
+
await call("mc_source", { action: "class_members", version, className }, data => {
|
|
99
|
+
assert.ok(data.methods.length > 5, "Missing class methods");
|
|
100
|
+
assert.ok(data.fields.length > 5, "Missing class fields");
|
|
101
|
+
});
|
|
102
|
+
await call("mc_source", { action: "bytecode", version, className }, text => {
|
|
103
|
+
assert.equal(typeof text, "string");
|
|
104
|
+
assert.match(text, /Minecraft/);
|
|
105
|
+
assert.match(text, /descriptor:/);
|
|
106
|
+
});
|
|
107
|
+
await call("mc_source", { action: "inheritance", version, className }, data => {
|
|
108
|
+
assert.equal(data.className, className);
|
|
109
|
+
assert.ok(data.superClass);
|
|
110
|
+
});
|
|
111
|
+
if (version === "1.5.2") {
|
|
112
|
+
for (const [className, method] of [
|
|
113
|
+
["net/minecraft/server/MinecraftServer", "tick"],
|
|
114
|
+
["net/minecraft/src/IntegratedServer", "tickIntegrated"],
|
|
115
|
+
]) await call("mc_source", { action: "class_members", version, className }, data => {
|
|
116
|
+
assert.ok(data.methods.some(entry => entry.name === method), `Lost explicit mapping for ${className}.${method}`);
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
} catch (error) {
|
|
120
|
+
errors.push({ version, error: error.message });
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
if (runMods) {
|
|
124
|
+
await call("mod", { action: "get" }, text => assert.match(text, /modId or dbId is required/), true);
|
|
125
|
+
const mods = [
|
|
126
|
+
{ project: 73488, version: "1.6.4", loader: "forge", name: "Waila" },
|
|
127
|
+
{ project: 73488, version: "1.7.10", loader: "forge", name: "Waila" },
|
|
128
|
+
{ project: 238222, version: "1.8.9", loader: "forge", name: "JEI" },
|
|
129
|
+
{ project: 238222, version: "1.12.2", loader: "forge", name: "JEI" },
|
|
130
|
+
{ project: 238222, version: "1.16.5", loader: "forge", name: "JEI" },
|
|
131
|
+
{ project: "AANobbMI", version: "1.16.5", loader: "fabric", name: "Sodium" },
|
|
132
|
+
{ project: 238222, version: "1.20.1", loader: "forge", name: "JEI" },
|
|
133
|
+
{ project: 238222, version: "1.21.1", loader: "neoforge", name: "JEI" },
|
|
134
|
+
];
|
|
135
|
+
for (const mod of mods) {
|
|
136
|
+
try {
|
|
137
|
+
const infoArgs = { modId: mod.project, mcVersionFilter: mod.version, loader: mod.loader };
|
|
138
|
+
const before = await call("mod", { action: "stats" });
|
|
139
|
+
const info = await call("modpacks_ch", { action: "mod_info", ...infoArgs }, data => {
|
|
140
|
+
assert.ok(data.versions.length, `No ${mod.name} versions for ${mod.version}/${mod.loader}`);
|
|
141
|
+
assert.equal(data.versionLimit, 20);
|
|
142
|
+
assert.ok(data.versions.length <= data.versionLimit);
|
|
143
|
+
assert.ok(data.versions.every(version => version.loaders.includes(mod.loader)), "Accepted an unverified loader");
|
|
144
|
+
});
|
|
145
|
+
const compatibilityAlias = await call("modpacks_ch", { action: "ftb_mod_info", ...infoArgs });
|
|
146
|
+
assert.deepEqual(compatibilityAlias, info);
|
|
147
|
+
if (mod.name === "Waila" && mod.version === "1.7.10") {
|
|
148
|
+
await call("platform", { action: "search", query: "Waila", mcVersion: mod.version, loader: "forge", limit: 5 }, data => {
|
|
149
|
+
assert.ok(data.results.some(hit => String(hit.projectId) === String(mod.project)), "Loader-filtered search omitted Waila");
|
|
150
|
+
assert.ok(data.results.every(hit => hit.loaders.includes("forge")), "Search returned an unverified loader");
|
|
151
|
+
});
|
|
152
|
+
await call("modpacks_ch", { action: "mod_info", ...infoArgs, loader: "fabric", limit: 1 }, data => {
|
|
153
|
+
assert.deepEqual(data.versions, [], "Accepted a Forge-only release for Fabric");
|
|
154
|
+
});
|
|
155
|
+
}
|
|
156
|
+
await call("mod", { action: "stats" }, data => assert.equal(data.total, before.total, "Metadata inspection ingested candidate files"));
|
|
157
|
+
const downloaded = await call("modpacks_ch", { action: "download_mod", ...infoArgs }, data => {
|
|
158
|
+
assert.ok(data.modId > 0, "Download did not produce an ingested mod");
|
|
159
|
+
});
|
|
160
|
+
const dbId = downloaded.modId;
|
|
161
|
+
await call("mod", { action: "get", dbId }, data => {
|
|
162
|
+
assert.equal(data.id, dbId, "Returned the wrong database record");
|
|
163
|
+
assert.equal(data.loader, mod.loader);
|
|
164
|
+
assert.ok(data.modId && data.modId !== "unknown");
|
|
165
|
+
assert.ok(data.version && !data.version.includes("${"), "Unresolved version placeholder");
|
|
166
|
+
});
|
|
167
|
+
const files = await call("mod_jar", { action: "list_files", modId: dbId });
|
|
168
|
+
const englishPath = files.entries.find(path => /(?:^|\/)lang\/en_us\.(json|lang)$/i.test(path));
|
|
169
|
+
const languageFile = englishPath ? await call("mod_jar", { action: "get_file", modId: dbId, path: englishPath }) : undefined;
|
|
170
|
+
const emptyLanguage = languageFile && (languageFile.data
|
|
171
|
+
? Object.keys(languageFile.data).length === 0 : !languageFile.raw?.trim());
|
|
172
|
+
await call("mod_jar", { action: "lang", modId: dbId }, data => {
|
|
173
|
+
if (emptyLanguage) assert.equal(data.total, 0, "Invented translations for an empty language file");
|
|
174
|
+
else if (englishPath) assert.ok(data.total > 0, "Existing language file was not read");
|
|
175
|
+
else assert.equal(data.found, false, "Reported language entries for a JAR without English translations");
|
|
176
|
+
});
|
|
177
|
+
await call("mod", { action: "reindex", dbId });
|
|
178
|
+
const query = mod.name === "JEI" ? "JustEnoughItems" : mod.name;
|
|
179
|
+
const classes = await call("mod_bytecode", { action: "search_class", dbId, query });
|
|
180
|
+
const className = classes.find(name => !name.includes("$"));
|
|
181
|
+
assert.ok(className, "No matching mod class found");
|
|
182
|
+
await call("mod_bytecode", { action: "class_members", dbId, className }, data => {
|
|
183
|
+
assert.equal(data.className, className);
|
|
184
|
+
assert.ok(data.methods.length, "No methods returned for mod class");
|
|
185
|
+
});
|
|
186
|
+
await call("mod_bytecode", { action: "bytecode", dbId, className }, text => assert.match(text, /descriptor:/));
|
|
187
|
+
await call("mod", { action: "decompile_class", dbId, className }, text => {
|
|
188
|
+
assert.match(text, /package /);
|
|
189
|
+
assert.ok(text.includes(className.split("/").at(-1)), "Decompiled the wrong class");
|
|
190
|
+
});
|
|
191
|
+
} catch (error) {
|
|
192
|
+
errors.push({ version: `${mod.name} ${mod.version}/${mod.loader}`, error: error.message });
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
} finally {
|
|
197
|
+
await client.close();
|
|
198
|
+
await writeFile(join(root, "server.log"), serverLog);
|
|
199
|
+
await writeFile(join(root, "summary.json"), JSON.stringify({ versions, results, errors }, null, 2));
|
|
200
|
+
}
|
|
201
|
+
console.log(`${results.filter(row => row.passed).length}/${results.length} checks passed; ${errors.length} failing versions/catalogs. Evidence: ${root}`);
|
|
202
|
+
if (errors.length) process.exitCode = 1;
|
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 = "";
|
|
@@ -109,14 +109,41 @@ try {
|
|
|
109
109
|
.filter(dir => existsSync(join(dir, "package.json")));
|
|
110
110
|
assert.equal(installs.length, 1);
|
|
111
111
|
const installed = installs[0];
|
|
112
|
+
const initScript = execFileSync(process.execPath, [join(installed, "dist/launcher.js"), "--gradle-init-script"], { env, encoding: "utf8" }).trim();
|
|
113
|
+
assert.equal(initScript, join(installed, "scripts/gradle/modlens.init.gradle"));
|
|
114
|
+
assert.ok(statSync(initScript).size > 0, "Gradle exporter must ship in the npm package");
|
|
115
|
+
assert.ok(statSync(join(installed, "scripts/project-upload.mjs")).size > 0, "Remote uploader must ship in the npm package");
|
|
116
|
+
const keyFile = join(root, "project-key.txt");
|
|
117
|
+
writeFileSync(keyFile, "a".repeat(64));
|
|
118
|
+
assert.deepEqual(JSON.parse(execFileSync(process.execPath, [join(installed, "dist/launcher.js"), "--project", "list", `--key-file=${keyFile}`], { env, encoding: "utf8" })), []);
|
|
112
119
|
const require = createRequire(join(installed, "package.json"));
|
|
113
120
|
const adapterRequire = createRequire(require.resolve("@prisma/adapter-better-sqlite3"));
|
|
114
121
|
assert.equal(require.resolve("better-sqlite3"), adapterRequire.resolve("better-sqlite3"), "Prisma must share the v12 driver");
|
|
115
122
|
assert.match(require("better-sqlite3/package.json").version, /^12\./);
|
|
116
123
|
|
|
117
124
|
const dataHome = join(root, "server-home");
|
|
125
|
+
// Deterministic upstream responses still exercise the installed Markdown dependencies,
|
|
126
|
+
// MCP argument schema, error flag, database writes, and reads after restart.
|
|
127
|
+
const primerFetchFixture = join(root, "primer-fetch-fixture.mjs");
|
|
128
|
+
writeFileSync(primerFetchFixture, `
|
|
129
|
+
const originalFetch = globalThis.fetch;
|
|
130
|
+
globalThis.fetch = async (input, init) => {
|
|
131
|
+
const url = String(input);
|
|
132
|
+
if (url === 'https://docs.neoforged.net/package-primer-missing') return new Response('missing', { status: 404 });
|
|
133
|
+
if (url === 'https://docs.neoforged.net/package-primer') return new Response(
|
|
134
|
+
'<article><h1>Package migration guide</h1><pre class="language-java"><code>' +
|
|
135
|
+
'<div class="token-line"><span>update();</span><br></div><div class="token-line"><span>finish();</span><br></div></code></pre>' +
|
|
136
|
+
'<p>Detailed instructions for migration and keeping code examples readable.</p><h2>Final section</h2></article>',
|
|
137
|
+
{ headers: { 'content-type': 'text/html' } });
|
|
138
|
+
if (url.includes('misode/mcmeta/summary/versions/data.json')) return Response.json([
|
|
139
|
+
{ id: '1.21.1', data_version: 3955 }, { id: '1.21.5', data_version: 4325 }
|
|
140
|
+
]);
|
|
141
|
+
if (url.endsWith('/api/tags')) return new Response(null, { status: 503 });
|
|
142
|
+
return originalFetch(input, init);
|
|
143
|
+
};
|
|
144
|
+
`);
|
|
118
145
|
async function checkServer() {
|
|
119
|
-
const run = start(args, dataHome);
|
|
146
|
+
const run = start(args, dataHome, { NODE_OPTIONS: `--import=${pathToFileURL(primerFetchFixture).href}` });
|
|
120
147
|
const pending = new Map();
|
|
121
148
|
let buffer = "";
|
|
122
149
|
let id = 0;
|
|
@@ -155,9 +182,79 @@ try {
|
|
|
155
182
|
send({ method: "notifications/initialized" });
|
|
156
183
|
const list = await request("tools/list", {});
|
|
157
184
|
assert.ok(list.tools.some(tool => tool.name === "mod"));
|
|
185
|
+
assert.ok(list.tools.some(tool => tool.name === "project"));
|
|
158
186
|
const stats = await request("tools/call", { name: "mod", arguments: { action: "stats" } });
|
|
159
187
|
assert.ok(!stats.isError, JSON.stringify(stats));
|
|
160
188
|
assert.equal(JSON.parse(stats.content.find(item => item.type === "text").text).total, 0);
|
|
189
|
+
const primerTool = list.tools.find(tool => tool.name === "primers");
|
|
190
|
+
assert.ok(primerTool.inputSchema.properties.refresh, "Packaged primer tool must expose cache refresh");
|
|
191
|
+
for (const field of ["includeContent", "maxChars", "cursor"]) {
|
|
192
|
+
assert.ok(primerTool.inputSchema.properties[field], `Packaged primer tool must expose ${field}`);
|
|
193
|
+
}
|
|
194
|
+
const decode = response => JSON.parse(response.content.find(item => item.type === "text").text);
|
|
195
|
+
const primerCall = arguments_ => request("tools/call", { name: "primers", arguments: arguments_ });
|
|
196
|
+
const saved = await primerCall({ action: "ingest", entries: [{
|
|
197
|
+
fromVersion: "1.21.1", toVersion: "1.21.5", title: "Package primer", url: "https://docs.neoforged.net/package-primer",
|
|
198
|
+
}] });
|
|
199
|
+
assert.ok(!saved.isError, JSON.stringify(saved));
|
|
200
|
+
const primerId = decode(saved).primers[0].id;
|
|
201
|
+
const page = await primerCall({ action: "get", id: primerId, maxLines: 3 });
|
|
202
|
+
assert.ok(!page.isError, JSON.stringify(page));
|
|
203
|
+
assert.equal(decode(page).contentStatus, "ready");
|
|
204
|
+
assert.match(decode(page).content, /# Package migration guide/);
|
|
205
|
+
assert.equal(decode(page).nextStartLine, 4);
|
|
206
|
+
const tail = decode(await primerCall({ action: "get", id: primerId, startLine: 4 }));
|
|
207
|
+
assert.match(tail.content, /update\(\);\nfinish\(\);/);
|
|
208
|
+
assert.match(tail.content, /## Final section/);
|
|
209
|
+
assert.equal(tail.nextStartLine, null);
|
|
210
|
+
const longContent = "# Long migration guide\n\n" + "x".repeat(2500) + "😀\nEnd.";
|
|
211
|
+
const added = decode(await primerCall({ action: "ingest", entries: [{
|
|
212
|
+
fromVersion: "1.21.5", toVersion: "26.1", title: "Long package primer",
|
|
213
|
+
url: "https://docs.neoforged.net/package-primer-long", content: longContent,
|
|
214
|
+
}] }));
|
|
215
|
+
const rangeArgs = { action: "by_version", fromVersion: "1.21.1", toVersion: "26.1", modloader: "neoforge" };
|
|
216
|
+
const metadata = decode(await primerCall(rangeArgs));
|
|
217
|
+
assert.equal(metadata.count, 2);
|
|
218
|
+
assert.ok(metadata.primers.every(primer => !("content" in primer)));
|
|
219
|
+
const chunks = new Map();
|
|
220
|
+
let cursor;
|
|
221
|
+
let pages = 0;
|
|
222
|
+
do {
|
|
223
|
+
const response = await primerCall({ ...rangeArgs, includeContent: true, maxChars: 1000, ...(cursor ? { cursor } : {}) });
|
|
224
|
+
assert.ok(!response.isError, JSON.stringify(response));
|
|
225
|
+
const bundle = decode(response);
|
|
226
|
+
assert.equal(bundle.count, 2);
|
|
227
|
+
assert.ok(bundle.contentChars <= 1000);
|
|
228
|
+
for (const primer of bundle.primers) chunks.set(primer.id, (chunks.get(primer.id) ?? "") + primer.content);
|
|
229
|
+
cursor = bundle.nextCursor;
|
|
230
|
+
assert.ok(++pages < 10);
|
|
231
|
+
} while (cursor);
|
|
232
|
+
assert.equal(chunks.get(added.primers[0].id), longContent);
|
|
233
|
+
assert.match(chunks.get(primerId), /update\(\);\nfinish\(\);/);
|
|
234
|
+
const cliRange = JSON.parse(execFileSync(process.execPath, [join(installed, "dist/cli.js"),
|
|
235
|
+
"primers", "by-version", "1.21.1", "26.1", "--modloader=neoforge", "--include-content", "--fetch-content=false"], {
|
|
236
|
+
env: { ...env, DATABASE_URL: `file:${join(dataHome, "data/modlens.db")}` }, encoding: "utf8", timeout: 30_000,
|
|
237
|
+
}));
|
|
238
|
+
assert.equal(cliRange.count, 2);
|
|
239
|
+
assert.equal(cliRange.nextCursor, null);
|
|
240
|
+
assert.equal(cliRange.primers.find(primer => primer.id === added.primers[0].id).content, longContent);
|
|
241
|
+
assert.equal((await primerCall({ ...rangeArgs, includeContent: true, cursor: "invalid" })).isError, true);
|
|
242
|
+
const failed = await primerCall({ action: "ingest", entries: [{
|
|
243
|
+
fromVersion: "1.21.1", toVersion: "1.21.5", title: "Missing primer",
|
|
244
|
+
url: "https://docs.neoforged.net/package-primer-missing", fetchContent: true,
|
|
245
|
+
}] });
|
|
246
|
+
assert.equal(failed.isError, true, "Requested content fetch failures must be MCP errors");
|
|
247
|
+
assert.equal(decode(failed).failed, 1);
|
|
248
|
+
const missing = decode(await primerCall({ action: "ingest", entries: [{
|
|
249
|
+
fromVersion: "1.21.1", toVersion: "1.21.5", title: "Missing bundled primer",
|
|
250
|
+
url: "https://docs.neoforged.net/package-primer-missing",
|
|
251
|
+
}] }));
|
|
252
|
+
const partial = await primerCall({ ...rangeArgs, includeContent: true });
|
|
253
|
+
assert.equal(partial.isError, true, "Bundle fetch failures must be MCP errors with successful content retained");
|
|
254
|
+
assert.equal(decode(partial).failed, 1);
|
|
255
|
+
assert.equal(decode(partial).primers.filter(primer => primer.contentStatus === "ready").length, 2);
|
|
256
|
+
await primerCall({ action: "delete", id: added.primers[0].id });
|
|
257
|
+
await primerCall({ action: "delete", id: missing.primers[0].id });
|
|
161
258
|
return run.output();
|
|
162
259
|
} finally { await stop(run); }
|
|
163
260
|
}
|
|
@@ -166,7 +263,7 @@ try {
|
|
|
166
263
|
assert.match(first, /First run/);
|
|
167
264
|
const database = join(dataHome, "data/modlens.db");
|
|
168
265
|
assert.ok(statSync(database).size > 0);
|
|
169
|
-
console.log("PASS: first-run SQLite bootstrap, MCP handshake,
|
|
266
|
+
console.log("PASS: first-run SQLite bootstrap, MCP handshake, primer bundles/pagination/errors, CLI ranges, database stats");
|
|
170
267
|
|
|
171
268
|
// Check the actual installed client/adapter with dates, BLOBs, and transactions.
|
|
172
269
|
// Use a child so Windows releases the native .node file before cleanup.
|
|
@@ -204,6 +301,9 @@ try {
|
|
|
204
301
|
console.log("PASS: second npx run preserves existing database contents");
|
|
205
302
|
passed = true;
|
|
206
303
|
} finally {
|
|
207
|
-
if (passed)
|
|
304
|
+
if (passed) {
|
|
305
|
+
assert.equal(dirname(resolve(root)), resolve(tmpdir()), "Cleanup must stay within the test temp directory");
|
|
306
|
+
rmSync(root, { recursive: true, force: true, maxRetries: 5, retryDelay: 300 });
|
|
307
|
+
}
|
|
208
308
|
else console.error(`Package test artifacts retained at ${root}`);
|
|
209
309
|
}
|