@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.
Files changed (194) hide show
  1. package/README.md +112 -15
  2. package/TESTING.md +69 -0
  3. package/dist/cli.js +49 -13
  4. package/dist/cli.js.map +1 -1
  5. package/dist/curseforge.d.ts +9 -7
  6. package/dist/curseforge.d.ts.map +1 -1
  7. package/dist/curseforge.js +48 -55
  8. package/dist/curseforge.js.map +1 -1
  9. package/dist/db.d.ts.map +1 -1
  10. package/dist/db.js +31 -1
  11. package/dist/db.js.map +1 -1
  12. package/dist/embeddings.d.ts.map +1 -1
  13. package/dist/embeddings.js +1 -0
  14. package/dist/embeddings.js.map +1 -1
  15. package/dist/feed-the-beast.d.ts +5 -5
  16. package/dist/feed-the-beast.d.ts.map +1 -1
  17. package/dist/feed-the-beast.js +5 -21
  18. package/dist/feed-the-beast.js.map +1 -1
  19. package/dist/java-tools.d.ts +1 -0
  20. package/dist/java-tools.d.ts.map +1 -1
  21. package/dist/java-tools.js +31 -7
  22. package/dist/java-tools.js.map +1 -1
  23. package/dist/launcher.js +15 -0
  24. package/dist/launcher.js.map +1 -1
  25. package/dist/mappings.d.ts +4 -1
  26. package/dist/mappings.d.ts.map +1 -1
  27. package/dist/mappings.js +120 -52
  28. package/dist/mappings.js.map +1 -1
  29. package/dist/minecraft.d.ts +0 -4
  30. package/dist/minecraft.d.ts.map +1 -1
  31. package/dist/minecraft.js +7 -23
  32. package/dist/minecraft.js.map +1 -1
  33. package/dist/mod-artifacts.d.ts +4 -0
  34. package/dist/mod-artifacts.d.ts.map +1 -0
  35. package/dist/mod-artifacts.js +58 -0
  36. package/dist/mod-artifacts.js.map +1 -0
  37. package/dist/modpacks-ch.d.ts +72 -46
  38. package/dist/modpacks-ch.d.ts.map +1 -1
  39. package/dist/modpacks-ch.js +134 -20
  40. package/dist/modpacks-ch.js.map +1 -1
  41. package/dist/modrinth.d.ts +14 -6
  42. package/dist/modrinth.d.ts.map +1 -1
  43. package/dist/modrinth.js +70 -55
  44. package/dist/modrinth.js.map +1 -1
  45. package/dist/platform-adapter.d.ts +1 -0
  46. package/dist/platform-adapter.d.ts.map +1 -1
  47. package/dist/platform.d.ts.map +1 -1
  48. package/dist/platform.js +20 -44
  49. package/dist/platform.js.map +1 -1
  50. package/dist/primer-catalog.d.ts +15 -0
  51. package/dist/primer-catalog.d.ts.map +1 -0
  52. package/dist/primer-catalog.js +43 -0
  53. package/dist/primer-catalog.js.map +1 -0
  54. package/dist/primer-content.d.ts +7 -0
  55. package/dist/primer-content.d.ts.map +1 -0
  56. package/dist/primer-content.js +185 -0
  57. package/dist/primer-content.js.map +1 -0
  58. package/dist/processor.d.ts +5 -0
  59. package/dist/processor.d.ts.map +1 -1
  60. package/dist/processor.js +166 -46
  61. package/dist/processor.js.map +1 -1
  62. package/dist/registry-submission.d.ts +34 -0
  63. package/dist/registry-submission.d.ts.map +1 -0
  64. package/dist/registry-submission.js +128 -0
  65. package/dist/registry-submission.js.map +1 -0
  66. package/dist/repositories/array-fields.d.ts +3 -0
  67. package/dist/repositories/array-fields.d.ts.map +1 -0
  68. package/dist/repositories/array-fields.js +8 -0
  69. package/dist/repositories/array-fields.js.map +1 -0
  70. package/dist/repositories/embeddings-sqlite.d.ts +1 -0
  71. package/dist/repositories/embeddings-sqlite.d.ts.map +1 -1
  72. package/dist/repositories/embeddings-sqlite.js +26 -58
  73. package/dist/repositories/embeddings-sqlite.js.map +1 -1
  74. package/dist/repositories/embeddings.d.ts.map +1 -1
  75. package/dist/repositories/embeddings.js +26 -3
  76. package/dist/repositories/embeddings.js.map +1 -1
  77. package/dist/repositories/index.d.ts +1 -1
  78. package/dist/repositories/index.d.ts.map +1 -1
  79. package/dist/repositories/mod.d.ts +8 -5
  80. package/dist/repositories/mod.d.ts.map +1 -1
  81. package/dist/repositories/mod.js +25 -11
  82. package/dist/repositories/mod.js.map +1 -1
  83. package/dist/repositories/packs.d.ts.map +1 -1
  84. package/dist/repositories/packs.js +10 -2
  85. package/dist/repositories/packs.js.map +1 -1
  86. package/dist/search-adapter.d.ts +2 -0
  87. package/dist/search-adapter.d.ts.map +1 -1
  88. package/dist/search-adapter.js +21 -9
  89. package/dist/search-adapter.js.map +1 -1
  90. package/dist/security.d.ts +2 -0
  91. package/dist/security.d.ts.map +1 -1
  92. package/dist/security.js +5 -1
  93. package/dist/security.js.map +1 -1
  94. package/dist/server.js +80 -35
  95. package/dist/server.js.map +1 -1
  96. package/dist/setup.js +9 -4
  97. package/dist/setup.js.map +1 -1
  98. package/dist/sqlite-json.d.ts +3 -0
  99. package/dist/sqlite-json.d.ts.map +1 -0
  100. package/dist/sqlite-json.js +78 -0
  101. package/dist/sqlite-json.js.map +1 -0
  102. package/dist/sqlite-parameters.d.ts +3 -0
  103. package/dist/sqlite-parameters.d.ts.map +1 -0
  104. package/dist/sqlite-parameters.js +19 -0
  105. package/dist/sqlite-parameters.js.map +1 -0
  106. package/dist/sqlite-schema.d.ts +4 -0
  107. package/dist/sqlite-schema.d.ts.map +1 -0
  108. package/dist/sqlite-schema.js +76 -0
  109. package/dist/sqlite-schema.js.map +1 -0
  110. package/dist/tools/catalog.d.ts +1 -6
  111. package/dist/tools/catalog.d.ts.map +1 -1
  112. package/dist/tools/catalog.js +10 -14
  113. package/dist/tools/catalog.js.map +1 -1
  114. package/dist/tools/compat-check.d.ts.map +1 -1
  115. package/dist/tools/compat-check.js +18 -30
  116. package/dist/tools/compat-check.js.map +1 -1
  117. package/dist/tools/diagnostics.js.map +1 -1
  118. package/dist/tools/docs.d.ts.map +1 -1
  119. package/dist/tools/docs.js +8 -3
  120. package/dist/tools/docs.js.map +1 -1
  121. package/dist/tools/embed-registry.d.ts +20 -4
  122. package/dist/tools/embed-registry.d.ts.map +1 -1
  123. package/dist/tools/embed-registry.js +30 -19
  124. package/dist/tools/embed-registry.js.map +1 -1
  125. package/dist/tools/graphify.d.ts +19 -4
  126. package/dist/tools/graphify.d.ts.map +1 -1
  127. package/dist/tools/graphify.js +61 -13
  128. package/dist/tools/graphify.js.map +1 -1
  129. package/dist/tools/ingest.d.ts.map +1 -1
  130. package/dist/tools/ingest.js +4 -4
  131. package/dist/tools/ingest.js.map +1 -1
  132. package/dist/tools/kubejs.js +1 -1
  133. package/dist/tools/kubejs.js.map +1 -1
  134. package/dist/tools/mc-fts.d.ts.map +1 -1
  135. package/dist/tools/mc-fts.js +9 -4
  136. package/dist/tools/mc-fts.js.map +1 -1
  137. package/dist/tools/mcmeta.d.ts +10 -1
  138. package/dist/tools/mcmeta.d.ts.map +1 -1
  139. package/dist/tools/mcmeta.js +43 -29
  140. package/dist/tools/mcmeta.js.map +1 -1
  141. package/dist/tools/mixin-scan.d.ts +12 -6
  142. package/dist/tools/mixin-scan.d.ts.map +1 -1
  143. package/dist/tools/mixin-scan.js +12 -7
  144. package/dist/tools/mixin-scan.js.map +1 -1
  145. package/dist/tools/mod-data.d.ts.map +1 -1
  146. package/dist/tools/mod-data.js +40 -27
  147. package/dist/tools/mod-data.js.map +1 -1
  148. package/dist/tools/modpacks-ch.d.ts +9 -5
  149. package/dist/tools/modpacks-ch.d.ts.map +1 -1
  150. package/dist/tools/modpacks-ch.js +54 -33
  151. package/dist/tools/modpacks-ch.js.map +1 -1
  152. package/dist/tools/packtools.js +3 -3
  153. package/dist/tools/packtools.js.map +1 -1
  154. package/dist/tools/platform.d.ts +2 -2
  155. package/dist/tools/platform.d.ts.map +1 -1
  156. package/dist/tools/platform.js +33 -23
  157. package/dist/tools/platform.js.map +1 -1
  158. package/dist/tools/primers.d.ts +133 -22
  159. package/dist/tools/primers.d.ts.map +1 -1
  160. package/dist/tools/primers.js +391 -386
  161. package/dist/tools/primers.js.map +1 -1
  162. package/dist/tools/project.d.ts +342 -0
  163. package/dist/tools/project.d.ts.map +1 -0
  164. package/dist/tools/project.js +519 -0
  165. package/dist/tools/project.js.map +1 -0
  166. package/dist/tools/reports.d.ts.map +1 -1
  167. package/dist/tools/reports.js +5 -3
  168. package/dist/tools/reports.js.map +1 -1
  169. package/dist/tools/vanilla-data.d.ts.map +1 -1
  170. package/dist/tools/vanilla-data.js +3 -33
  171. package/dist/tools/vanilla-data.js.map +1 -1
  172. package/dist/tools/vanilla.d.ts +3 -1
  173. package/dist/tools/vanilla.d.ts.map +1 -1
  174. package/dist/tools/vanilla.js +26 -12
  175. package/dist/tools/vanilla.js.map +1 -1
  176. package/dist/tools/version-diff.d.ts.map +1 -1
  177. package/dist/tools/version-diff.js +4 -24
  178. package/dist/tools/version-diff.js.map +1 -1
  179. package/dist/version-ranges.d.ts +5 -0
  180. package/dist/version-ranges.d.ts.map +1 -0
  181. package/dist/version-ranges.js +93 -0
  182. package/dist/version-ranges.js.map +1 -0
  183. package/package.json +9 -1
  184. package/prisma/backends/template.db +0 -0
  185. package/scripts/build-template-db.mjs +3 -1
  186. package/scripts/enable-sqlite-vec.mjs +2 -106
  187. package/scripts/gradle/modlens.init.gradle +154 -0
  188. package/scripts/project-upload.mjs +74 -0
  189. package/scripts/test-minecraft-eras.mjs +202 -0
  190. package/scripts/test-package.mjs +105 -5
  191. package/scripts/test-primers-live.mjs +93 -0
  192. package/scripts/test-project-gradle.mjs +76 -0
  193. package/scripts/test-project-http.mjs +98 -0
  194. 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;
@@ -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, tools/list, database stats");
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) rmSync(root, { recursive: true, force: true, maxRetries: 5, retryDelay: 300 });
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
  }