simple-dynamsoft-mcp 6.2.0 → 6.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.env.example +18 -0
- package/README.md +98 -19
- package/data/metadata/data-manifest.json +140 -0
- package/data/metadata/dynamsoft_sdks.json +203 -3
- package/package.json +11 -2
- package/scripts/prebuild-rag-index.mjs +24 -3
- package/scripts/run-gemini-tests.mjs +25 -0
- package/scripts/update-sdk-versions.mjs +342 -0
- package/scripts/verify-doc-resources.mjs +79 -0
- package/src/gemini-retry.js +148 -0
- package/src/index.js +293 -25
- package/src/normalizers.js +67 -8
- package/src/rag.js +649 -63
- package/src/resource-index/builders.js +294 -0
- package/src/resource-index/config.js +57 -0
- package/src/resource-index/paths.js +15 -0
- package/src/resource-index/samples.js +244 -2
- package/src/resource-index/uri.js +10 -0
- package/src/resource-index/version-policy.js +11 -1
- package/src/resource-index.js +188 -7
|
@@ -5,6 +5,10 @@ import {
|
|
|
5
5
|
DBR_SERVER_PLATFORM_CANDIDATES,
|
|
6
6
|
DBR_SERVER_PREFERRED_EXTS,
|
|
7
7
|
DBR_SERVER_PREFERRED_FILES,
|
|
8
|
+
DCV_MOBILE_PLATFORM_CANDIDATES,
|
|
9
|
+
DCV_SERVER_PLATFORM_CANDIDATES,
|
|
10
|
+
DCV_SERVER_PREFERRED_EXTS,
|
|
11
|
+
DCV_SERVER_PREFERRED_FILES,
|
|
8
12
|
CODE_FILE_EXTENSIONS
|
|
9
13
|
} from "./config.js";
|
|
10
14
|
import { SAMPLE_ROOTS, getExistingPath } from "./paths.js";
|
|
@@ -13,6 +17,7 @@ import { normalizePlatform } from "../normalizers.js";
|
|
|
13
17
|
let cachedWebFrameworkPlatforms = null;
|
|
14
18
|
let cachedDbrWebFrameworkPlatforms = null;
|
|
15
19
|
let cachedDdvWebFrameworkPlatforms = null;
|
|
20
|
+
let cachedDcvWebFrameworkPlatforms = null;
|
|
16
21
|
|
|
17
22
|
function getCodeFileExtensions() {
|
|
18
23
|
return CODE_FILE_EXTENSIONS;
|
|
@@ -41,6 +46,14 @@ function getDbrCrossMobileRoot(platform) {
|
|
|
41
46
|
return null;
|
|
42
47
|
}
|
|
43
48
|
|
|
49
|
+
function getDcvCrossMobileRoot(platform) {
|
|
50
|
+
if (platform === "maui") return SAMPLE_ROOTS.dcvMaui;
|
|
51
|
+
if (platform === "react-native") return SAMPLE_ROOTS.dcvReactNative;
|
|
52
|
+
if (platform === "flutter") return SAMPLE_ROOTS.dcvFlutter;
|
|
53
|
+
if (platform === "spm") return SAMPLE_ROOTS.dcvSpm;
|
|
54
|
+
return null;
|
|
55
|
+
}
|
|
56
|
+
|
|
44
57
|
function discoverCrossMobileSamples(platform) {
|
|
45
58
|
const samples = { "high-level": [], "low-level": [] };
|
|
46
59
|
const root = getDbrCrossMobileRoot(platform);
|
|
@@ -123,6 +136,14 @@ function discoverDirectoryNames(path) {
|
|
|
123
136
|
return sortUnique(names);
|
|
124
137
|
}
|
|
125
138
|
|
|
139
|
+
function discoverDirectoryNamesWithFilter(path, matcher) {
|
|
140
|
+
return discoverDirectoryNames(path).filter((name) => matcher(name));
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function isDcvScenarioSampleName(sampleName) {
|
|
144
|
+
return /scan|scanner|mrz|vin|driver|license|document|gs1/i.test(sampleName || "");
|
|
145
|
+
}
|
|
146
|
+
|
|
126
147
|
function discoverDbrServerSamples(platform) {
|
|
127
148
|
const normalizedPlatform = normalizePlatform(platform);
|
|
128
149
|
if (normalizedPlatform === "python") return discoverPythonSamples();
|
|
@@ -147,6 +168,95 @@ function getDbrServerPlatforms() {
|
|
|
147
168
|
return DBR_SERVER_PLATFORM_CANDIDATES.filter((platform) => discoverDbrServerSamples(platform).length > 0);
|
|
148
169
|
}
|
|
149
170
|
|
|
171
|
+
function discoverDcvCrossMobileSamples(platform) {
|
|
172
|
+
const root = getDcvCrossMobileRoot(platform);
|
|
173
|
+
if (!root || !existsSync(root)) return [];
|
|
174
|
+
if (platform === "spm") {
|
|
175
|
+
return existsSync(join(root, "Package.swift")) ? ["package-swift"] : [];
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
return discoverDirectoryNamesWithFilter(root, (name) => isDcvScenarioSampleName(name));
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
function discoverDcvMobileSamples(platform) {
|
|
182
|
+
const normalizedPlatform = normalizePlatform(platform);
|
|
183
|
+
|
|
184
|
+
if (["maui", "react-native", "flutter", "spm"].includes(normalizedPlatform)) {
|
|
185
|
+
return discoverDcvCrossMobileSamples(normalizedPlatform);
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
if (normalizedPlatform === "android") {
|
|
189
|
+
return discoverDirectoryNamesWithFilter(join(SAMPLE_ROOTS.dcvMobile, "Android"), (name) => isDcvScenarioSampleName(name));
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
if (normalizedPlatform === "ios") {
|
|
193
|
+
return discoverDirectoryNamesWithFilter(join(SAMPLE_ROOTS.dcvMobile, "ios"), (name) => isDcvScenarioSampleName(name));
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
return [];
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
function discoverDcvPythonSamples() {
|
|
200
|
+
const samples = [];
|
|
201
|
+
const pythonPath = join(SAMPLE_ROOTS.dcvPython, "Samples");
|
|
202
|
+
if (!existsSync(pythonPath)) return samples;
|
|
203
|
+
|
|
204
|
+
for (const entry of readdirSync(pythonPath, { withFileTypes: true })) {
|
|
205
|
+
if (entry.isFile() && entry.name.endsWith(".py")) {
|
|
206
|
+
samples.push(entry.name.replace(".py", ""));
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
return sortUnique(samples);
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
function getDcvServerSamplesRoot(platform) {
|
|
213
|
+
if (platform === "python") return SAMPLE_ROOTS.dcvPython;
|
|
214
|
+
if (platform === "dotnet") return SAMPLE_ROOTS.dcvDotnet;
|
|
215
|
+
if (platform === "java") return SAMPLE_ROOTS.dcvJava;
|
|
216
|
+
if (platform === "cpp") return SAMPLE_ROOTS.dcvCpp;
|
|
217
|
+
if (platform === "nodejs") return SAMPLE_ROOTS.dcvNodejs;
|
|
218
|
+
return null;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
function discoverDcvServerSamples(platform) {
|
|
222
|
+
const normalizedPlatform = normalizePlatform(platform);
|
|
223
|
+
if (normalizedPlatform === "python") return discoverDcvPythonSamples();
|
|
224
|
+
if (normalizedPlatform === "nodejs") return discoverDirectoryNames(getDcvServerSamplesRoot("nodejs"));
|
|
225
|
+
|
|
226
|
+
if (["dotnet", "java", "cpp"].includes(normalizedPlatform)) {
|
|
227
|
+
const root = getDcvServerSamplesRoot(normalizedPlatform);
|
|
228
|
+
return discoverDirectoryNames(join(root || "", "Samples"));
|
|
229
|
+
}
|
|
230
|
+
return [];
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
function getDcvMobilePlatforms() {
|
|
234
|
+
return DCV_MOBILE_PLATFORM_CANDIDATES.filter((platform) => discoverDcvMobileSamples(platform).length > 0);
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
function getDcvServerPlatforms() {
|
|
238
|
+
return DCV_SERVER_PLATFORM_CANDIDATES.filter((platform) => discoverDcvServerSamples(platform).length > 0);
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
function discoverDcvWebSamples() {
|
|
242
|
+
const sampleSet = new Set();
|
|
243
|
+
if (!existsSync(SAMPLE_ROOTS.dcvWeb)) return [];
|
|
244
|
+
|
|
245
|
+
for (const entry of readdirSync(SAMPLE_ROOTS.dcvWeb, { withFileTypes: true })) {
|
|
246
|
+
if (entry.name.startsWith(".")) continue;
|
|
247
|
+
if (entry.isDirectory()) sampleSet.add(entry.name);
|
|
248
|
+
if (entry.isFile() && entry.name.endsWith(".html")) sampleSet.add(entry.name.replace(".html", ""));
|
|
249
|
+
}
|
|
250
|
+
return Array.from(sampleSet).sort();
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
function getDcvWebFrameworkPlatforms() {
|
|
254
|
+
if (cachedDcvWebFrameworkPlatforms) return cachedDcvWebFrameworkPlatforms;
|
|
255
|
+
// Current DCV web samples are plain JS scenario samples.
|
|
256
|
+
cachedDcvWebFrameworkPlatforms = [];
|
|
257
|
+
return cachedDcvWebFrameworkPlatforms;
|
|
258
|
+
}
|
|
259
|
+
|
|
150
260
|
function discoverWebSamples() {
|
|
151
261
|
const categories = { root: [], frameworks: [], scenarios: [] };
|
|
152
262
|
const webPath = getDbrWebSamplesRoot();
|
|
@@ -273,7 +383,11 @@ function getDdvWebFrameworkPlatforms() {
|
|
|
273
383
|
|
|
274
384
|
function getWebFrameworkPlatforms() {
|
|
275
385
|
if (cachedWebFrameworkPlatforms) return cachedWebFrameworkPlatforms;
|
|
276
|
-
const frameworks = new Set([
|
|
386
|
+
const frameworks = new Set([
|
|
387
|
+
...getDbrWebFrameworkPlatforms(),
|
|
388
|
+
...getDdvWebFrameworkPlatforms(),
|
|
389
|
+
...getDcvWebFrameworkPlatforms()
|
|
390
|
+
]);
|
|
277
391
|
cachedWebFrameworkPlatforms = frameworks;
|
|
278
392
|
return cachedWebFrameworkPlatforms;
|
|
279
393
|
}
|
|
@@ -375,6 +489,85 @@ function getDdvSamplePath(sampleName) {
|
|
|
375
489
|
return null;
|
|
376
490
|
}
|
|
377
491
|
|
|
492
|
+
function getDcvMobileSamplePath(platform, sampleName) {
|
|
493
|
+
const normalizedPlatform = normalizePlatform(platform);
|
|
494
|
+
|
|
495
|
+
if (["maui", "react-native", "flutter"].includes(normalizedPlatform)) {
|
|
496
|
+
const root = getDcvCrossMobileRoot(normalizedPlatform);
|
|
497
|
+
const direct = root ? join(root, sampleName) : "";
|
|
498
|
+
return getExistingPath(direct) || direct;
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
if (normalizedPlatform === "spm") {
|
|
502
|
+
const root = getDcvCrossMobileRoot("spm");
|
|
503
|
+
if (!root) return "";
|
|
504
|
+
const packageFile = join(root, "Package.swift");
|
|
505
|
+
const readmeFile = join(root, "README.md");
|
|
506
|
+
if (sampleName === "package-swift") return getExistingPath(packageFile, readmeFile) || packageFile;
|
|
507
|
+
const direct = join(root, sampleName);
|
|
508
|
+
return getExistingPath(direct, packageFile, readmeFile) || direct;
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
if (normalizedPlatform === "android") {
|
|
512
|
+
const direct = join(SAMPLE_ROOTS.dcvMobile, "Android", sampleName);
|
|
513
|
+
return getExistingPath(direct) || direct;
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
if (normalizedPlatform === "ios") {
|
|
517
|
+
const direct = join(SAMPLE_ROOTS.dcvMobile, "ios", sampleName);
|
|
518
|
+
return getExistingPath(direct) || direct;
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
return "";
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
function getDcvServerSamplePath(platform, sampleName) {
|
|
525
|
+
const normalizedPlatform = normalizePlatform(platform) || "python";
|
|
526
|
+
|
|
527
|
+
if (normalizedPlatform === "python") {
|
|
528
|
+
const fileName = sampleName.endsWith(".py") ? sampleName : `${sampleName}.py`;
|
|
529
|
+
const primary = join(SAMPLE_ROOTS.dcvPython, "Samples", fileName);
|
|
530
|
+
return getExistingPath(primary) || primary;
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
if (normalizedPlatform === "nodejs") {
|
|
534
|
+
const root = getDcvServerSamplesRoot("nodejs");
|
|
535
|
+
if (!root) return "";
|
|
536
|
+
const direct = join(root, sampleName);
|
|
537
|
+
const js = join(root, `${sampleName}.js`);
|
|
538
|
+
const mjs = join(root, `${sampleName}.mjs`);
|
|
539
|
+
return getExistingPath(direct, js, mjs) || direct;
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
if (["dotnet", "java", "cpp"].includes(normalizedPlatform)) {
|
|
543
|
+
const root = getDcvServerSamplesRoot(normalizedPlatform);
|
|
544
|
+
if (!root) return "";
|
|
545
|
+
const direct = join(root, "Samples", sampleName);
|
|
546
|
+
return getExistingPath(direct) || direct;
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
return "";
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
function getDcvWebSamplePath(sampleName) {
|
|
553
|
+
if (!existsSync(SAMPLE_ROOTS.dcvWeb)) return null;
|
|
554
|
+
const directDir = join(SAMPLE_ROOTS.dcvWeb, sampleName);
|
|
555
|
+
if (existsSync(directDir) && statSync(directDir).isDirectory()) {
|
|
556
|
+
const indexPath = join(directDir, "index.html");
|
|
557
|
+
if (existsSync(indexPath)) return indexPath;
|
|
558
|
+
const readmePath = join(directDir, "README.md");
|
|
559
|
+
if (existsSync(readmePath)) return readmePath;
|
|
560
|
+
for (const entry of readdirSync(directDir, { withFileTypes: true })) {
|
|
561
|
+
if (entry.isFile() && entry.name.endsWith(".html")) return join(directDir, entry.name);
|
|
562
|
+
}
|
|
563
|
+
return directDir;
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
const htmlPath = join(SAMPLE_ROOTS.dcvWeb, `${sampleName}.html`);
|
|
567
|
+
if (existsSync(htmlPath)) return htmlPath;
|
|
568
|
+
return null;
|
|
569
|
+
}
|
|
570
|
+
|
|
378
571
|
function readCodeFile(filePath) {
|
|
379
572
|
if (!existsSync(filePath)) return null;
|
|
380
573
|
return readFileSync(filePath, "utf8");
|
|
@@ -452,30 +645,79 @@ function getDbrServerSampleContent(platform, sampleName) {
|
|
|
452
645
|
};
|
|
453
646
|
}
|
|
454
647
|
|
|
648
|
+
function getDcvServerSampleContent(platform, sampleName) {
|
|
649
|
+
const samplePath = getDcvServerSamplePath(platform, sampleName);
|
|
650
|
+
if (!samplePath || !existsSync(samplePath)) return { text: "Sample not found", mimeType: "text/plain" };
|
|
651
|
+
|
|
652
|
+
const stat = statSync(samplePath);
|
|
653
|
+
if (stat.isFile()) {
|
|
654
|
+
const ext = extname(samplePath).replace(".", "");
|
|
655
|
+
return { text: readCodeFile(samplePath), mimeType: getMimeTypeForExtension(ext) };
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
const normalizedPlatform = normalizePlatform(platform);
|
|
659
|
+
const preferredFiles = DCV_SERVER_PREFERRED_FILES[normalizedPlatform] || [];
|
|
660
|
+
for (const name of preferredFiles) {
|
|
661
|
+
const candidate = join(samplePath, name);
|
|
662
|
+
if (existsSync(candidate) && statSync(candidate).isFile()) {
|
|
663
|
+
const ext = extname(candidate).replace(".", "");
|
|
664
|
+
return { text: readCodeFile(candidate), mimeType: getMimeTypeForExtension(ext) };
|
|
665
|
+
}
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
const readmePath = join(samplePath, "README.md");
|
|
669
|
+
if (existsSync(readmePath)) return { text: readCodeFile(readmePath), mimeType: "text/markdown" };
|
|
670
|
+
|
|
671
|
+
const codeFiles = findCodeFilesInSample(samplePath);
|
|
672
|
+
if (codeFiles.length > 0) {
|
|
673
|
+
const preferredExts = DCV_SERVER_PREFERRED_EXTS[normalizedPlatform] || [];
|
|
674
|
+
const preferred = codeFiles.find((file) => preferredExts.includes(file.extension)) || codeFiles[0];
|
|
675
|
+
return { text: readCodeFile(preferred.path), mimeType: getMimeTypeForExtension(preferred.extension) };
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
const files = readdirSync(samplePath, { withFileTypes: true })
|
|
679
|
+
.filter((entry) => entry.isFile())
|
|
680
|
+
.map((entry) => entry.name);
|
|
681
|
+
return {
|
|
682
|
+
text: files.length > 0 ? files.join("\n") : "Sample found, but no code files detected.",
|
|
683
|
+
mimeType: "text/plain"
|
|
684
|
+
};
|
|
685
|
+
}
|
|
686
|
+
|
|
455
687
|
export {
|
|
456
688
|
getCodeFileExtensions,
|
|
457
689
|
isCodeFile,
|
|
458
690
|
discoverMobileSamples,
|
|
459
691
|
discoverDbrServerSamples,
|
|
460
692
|
discoverPythonSamples,
|
|
693
|
+
discoverDcvMobileSamples,
|
|
694
|
+
discoverDcvServerSamples,
|
|
695
|
+
discoverDcvWebSamples,
|
|
461
696
|
discoverWebSamples,
|
|
462
697
|
getWebSamplePath,
|
|
463
698
|
discoverDwtSamples,
|
|
464
699
|
discoverDdvSamples,
|
|
465
700
|
mapDdvSampleToFramework,
|
|
466
701
|
getDbrWebFrameworkPlatforms,
|
|
702
|
+
getDcvWebFrameworkPlatforms,
|
|
467
703
|
getDdvWebFrameworkPlatforms,
|
|
468
704
|
getWebFrameworkPlatforms,
|
|
469
705
|
findCodeFilesInSample,
|
|
470
706
|
getDbrMobilePlatforms,
|
|
471
707
|
getDbrServerPlatforms,
|
|
708
|
+
getDcvMobilePlatforms,
|
|
709
|
+
getDcvServerPlatforms,
|
|
472
710
|
getMobileSamplePath,
|
|
473
711
|
getPythonSamplePath,
|
|
474
712
|
getDbrServerSamplePath,
|
|
713
|
+
getDcvMobileSamplePath,
|
|
714
|
+
getDcvServerSamplePath,
|
|
715
|
+
getDcvWebSamplePath,
|
|
475
716
|
getDwtSamplePath,
|
|
476
717
|
getDdvSamplePath,
|
|
477
718
|
readCodeFile,
|
|
478
719
|
getMainCodeFile,
|
|
479
720
|
getMimeTypeForExtension,
|
|
480
|
-
getDbrServerSampleContent
|
|
721
|
+
getDbrServerSampleContent,
|
|
722
|
+
getDcvServerSampleContent
|
|
481
723
|
};
|
|
@@ -70,6 +70,16 @@ function parseSampleUri(uri) {
|
|
|
70
70
|
};
|
|
71
71
|
}
|
|
72
72
|
|
|
73
|
+
if (parsed.product === "dcv" && (parsed.edition === "mobile" || parsed.edition === "server" || parsed.edition === "web")) {
|
|
74
|
+
return {
|
|
75
|
+
product: "dcv",
|
|
76
|
+
edition: parsed.edition,
|
|
77
|
+
platform: parsed.platform,
|
|
78
|
+
version: parsed.version,
|
|
79
|
+
sampleName: parsed.parts[4]
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
|
|
73
83
|
return null;
|
|
74
84
|
}
|
|
75
85
|
|
|
@@ -12,7 +12,7 @@ function detectMajorFromQuery(query) {
|
|
|
12
12
|
if (!query) return null;
|
|
13
13
|
const text = String(query);
|
|
14
14
|
const explicit = text.match(/(?:\bv|\bversion\s*)(\d{1,2})(?:\.\d+)?/i);
|
|
15
|
-
const productScoped = text.match(/(?:dbr|dwt|ddv)[^0-9]*(\d{1,2})(?:\.\d+)?/i);
|
|
15
|
+
const productScoped = text.match(/(?:dbr|dcv|dwt|ddv)[^0-9]*(\d{1,2})(?:\.\d+)?/i);
|
|
16
16
|
const match = explicit || productScoped;
|
|
17
17
|
if (!match) return null;
|
|
18
18
|
const major = Number.parseInt(match[1], 10);
|
|
@@ -83,6 +83,13 @@ function ensureLatestMajor({ product, version, query, edition, platform, latestM
|
|
|
83
83
|
};
|
|
84
84
|
}
|
|
85
85
|
|
|
86
|
+
if (inferredProduct === "dcv") {
|
|
87
|
+
return {
|
|
88
|
+
ok: false,
|
|
89
|
+
message: `This MCP server only serves the latest major version of DCV (v${currentMajor}).`
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
|
|
86
93
|
if (inferredProduct === "dbr" && requestedMajor < 9) {
|
|
87
94
|
return {
|
|
88
95
|
ok: false,
|
|
@@ -133,12 +140,15 @@ function buildVersionPolicyText(latestMajor) {
|
|
|
133
140
|
"This MCP server only serves the latest major versions of each product.",
|
|
134
141
|
"",
|
|
135
142
|
`- DBR latest major: v${latestMajor.dbr}`,
|
|
143
|
+
`- DCV latest major: v${latestMajor.dcv}`,
|
|
136
144
|
`- DWT latest major: v${latestMajor.dwt}`,
|
|
137
145
|
`- DDV latest major: v${latestMajor.ddv}`,
|
|
138
146
|
"",
|
|
139
147
|
"Legacy support:",
|
|
140
148
|
"- DBR v9 and v10 docs are linked when requested.",
|
|
149
|
+
"- DCV has no legacy archive links in this server.",
|
|
141
150
|
`- DWT archived docs available: ${dwtLegacyVersions || "none"}.`,
|
|
151
|
+
"- DDV has no legacy archive links in this server.",
|
|
142
152
|
"",
|
|
143
153
|
"Requests for older major versions are refused with a helpful message."
|
|
144
154
|
].join("\n");
|