dsh-windows-mcp 0.1.0 → 0.2.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/lib/mirrors.js ADDED
@@ -0,0 +1,236 @@
1
+ /**
2
+ * dsh-windows-mcp — PyPI mirror discovery (host side).
3
+ *
4
+ * Downloading `windows-mcp` from PyPI directly is unreliable on some networks
5
+ * (measured on this project's own machine: the PyPI JSON API took 22 s and the
6
+ * index page timed out, while mainland mirrors answered in ~100–500 ms). The
7
+ * install and the version check therefore both run through this module:
8
+ *
9
+ * 1. **Probe.** For every mirror in `MIRROR_CANDIDATES`, fetch just
10
+ * `<index>/windows-mcp/` (≈10 KB — never the 47 MB root page) and download a
11
+ * ranged sample of a real wheel, timing both.
12
+ * 2. **Rank.** Successful mirrors are ordered by measured throughput, index
13
+ * latency breaking ties. A hidden official fallback stays in the order
14
+ * list so a mirror that is fast now but fails mid-install still has a next
15
+ * address to try.
16
+ *
17
+ * Measured on this project's machine (2026-10, 256 KB sample): USTC ≈1.7 MB/s,
18
+ * Tencent ≈1.4 MB/s, Aliyun ≈1.0 MB/s, Huawei ≈0.8 MB/s, NJU ≈0.6 MB/s; PyPI
19
+ * itself timed out and TUNA answered 403 (IP-blocked for this network).
20
+ *
21
+ * Network access is injected (`options.fetchImpl`), so the whole module is
22
+ * unit-testable without a socket, and every failure is reported as a value
23
+ * instead of an exception.
24
+ */
25
+ import { entryForVersion, latestStableEntry, latestStableVersion, parseIndex, projectIndexUrl } from "./pypi.js";
26
+
27
+ /** Mirrors this plugin knows about. `official` is always kept as last resort. */
28
+ export const MIRROR_CANDIDATES = Object.freeze([
29
+ Object.freeze({ id: "pypi", label: "PyPI", indexUrl: "https://pypi.org/simple", official: true }),
30
+ Object.freeze({ id: "ustc", label: "USTC 中科大", indexUrl: "https://mirrors.ustc.edu.cn/pypi/simple", official: false }),
31
+ Object.freeze({ id: "aliyun", label: "阿里云", indexUrl: "https://mirrors.aliyun.com/pypi/simple", official: false }),
32
+ Object.freeze({ id: "tencent", label: "腾讯云", indexUrl: "https://mirrors.cloud.tencent.com/pypi/simple", official: false }),
33
+ Object.freeze({ id: "huawei", label: "华为云", indexUrl: "https://repo.huaweicloud.com/repository/pypi/simple", official: false }),
34
+ Object.freeze({ id: "nju", label: "南京大学", indexUrl: "https://mirrors.nju.edu.cn/pypi/web/simple", official: false }),
35
+ Object.freeze({ id: "tuna", label: "清华 TUNA", indexUrl: "https://pypi.tuna.tsinghua.edu.cn/simple", official: false }),
36
+ ]);
37
+
38
+ /** Probe knobs: a small ranged read is enough to tell a fast mirror from a slow one. */
39
+ export const PROBE_DEFAULTS = Object.freeze({
40
+ project: "windows-mcp",
41
+ version: null,
42
+ timeoutMs: 8000,
43
+ sampleBytes: 96 * 1024,
44
+ concurrency: 4,
45
+ userAgent: "dsh-windows-mcp-index-probe",
46
+ });
47
+
48
+ /** No mirror describes itself in a way we can show; this is the fallback label. */
49
+ export function mirrorById(id, candidates = MIRROR_CANDIDATES) {
50
+ return candidates.find((entry) => entry.id === id) ?? null;
51
+ }
52
+
53
+ /** Human line for the Plugins page: `USTC 中科大 · 1.7 MB/s`. */
54
+ export function describeMirror(result) {
55
+ if (result === null || result === undefined) return "—";
56
+ if (result.ok !== true) return `${result.label} · 不可用`;
57
+ const speed = typeof result.speedBps === "number" ? result.speedBps : 0;
58
+ return `${result.label} · ${formatBytesPerSecond(speed)}`;
59
+ }
60
+
61
+ /** Bytes per second → `1.7 MB/s` / `820 KB/s`. */
62
+ export function formatBytesPerSecond(bytesPerSecond) {
63
+ const value = Number(bytesPerSecond);
64
+ if (!Number.isFinite(value) || value <= 0) return "—";
65
+ if (value >= 1024 * 1024) return `${(value / (1024 * 1024)).toFixed(1)} MB/s`;
66
+ return `${Math.max(1, Math.round(value / 1024))} KB/s`;
67
+ }
68
+
69
+ /** `fetch` with a hard timeout; rejects with a readable message. */
70
+ async function timedFetch(fetchImpl, url, init, timeoutMs) {
71
+ const options = { ...init, signal: AbortSignal.timeout(timeoutMs) };
72
+ try {
73
+ return await fetchImpl(url, options);
74
+ } catch (error) {
75
+ const message = error instanceof Error ? error.message : String(error);
76
+ throw new Error(`${message} (${url})`);
77
+ }
78
+ }
79
+
80
+ /** Probe one mirror: index latency, then a ranged wheel sample for throughput. */
81
+ async function probeMirror(mirror, settings) {
82
+ const startedAt = Date.now();
83
+ const base = {
84
+ id: mirror.id,
85
+ label: mirror.label,
86
+ indexUrl: mirror.indexUrl,
87
+ official: mirror.official === true,
88
+ };
89
+ try {
90
+ const pageUrl = projectIndexUrl(mirror.indexUrl, settings.project);
91
+ const page = await timedFetch(
92
+ settings.fetchImpl,
93
+ pageUrl,
94
+ { headers: { accept: "text/html", "user-agent": settings.userAgent } },
95
+ settings.timeoutMs,
96
+ );
97
+ const indexMs = Date.now() - startedAt;
98
+ if (page?.ok !== true) throw new Error(`index answered HTTP ${page?.status ?? "?"}`);
99
+ const html = typeof page.text === "function" ? await page.text() : "";
100
+ const entries = parseIndex(html, pageUrl);
101
+ if (entries.length === 0) throw new Error("the index lists no windows-mcp files");
102
+ const wanted = settings.version === null || settings.version === undefined
103
+ ? latestStableEntry(entries)
104
+ : entryForVersion(entries, settings.version);
105
+ if (wanted === null) throw new Error(`version ${settings.version} is not on this mirror`);
106
+ const downloadStartedAt = Date.now();
107
+ const sample = await timedFetch(
108
+ settings.fetchImpl,
109
+ wanted.url,
110
+ {
111
+ headers: {
112
+ range: `bytes=0-${Math.max(0, settings.sampleBytes - 1)}`,
113
+ "user-agent": settings.userAgent,
114
+ },
115
+ },
116
+ settings.timeoutMs,
117
+ );
118
+ const status = sample?.status ?? 0;
119
+ if (status !== 200 && status !== 206) throw new Error(`wheel sample answered HTTP ${status}`);
120
+ const body = typeof sample.arrayBuffer === "function" ? new Uint8Array(await sample.arrayBuffer()) : new Uint8Array();
121
+ const downloadMs = Math.max(1, Date.now() - downloadStartedAt);
122
+ return {
123
+ ...base,
124
+ ok: true,
125
+ indexMs,
126
+ downloadMs,
127
+ bytes: body.byteLength,
128
+ speedBps: body.byteLength === 0 ? 0 : Math.round(body.byteLength / (downloadMs / 1000)),
129
+ version: wanted.version,
130
+ filename: wanted.filename,
131
+ error: null,
132
+ };
133
+ } catch (error) {
134
+ return {
135
+ ...base,
136
+ ok: false,
137
+ indexMs: null,
138
+ downloadMs: null,
139
+ bytes: 0,
140
+ speedBps: 0,
141
+ version: null,
142
+ filename: null,
143
+ error: error instanceof Error ? error.message : String(error),
144
+ };
145
+ }
146
+ }
147
+
148
+ /**
149
+ * Probe every candidate concurrently (bounded), then rank the survivors.
150
+ * @returns `{ probedAt, results, ranked, best, project, version }`.
151
+ */
152
+ export async function probeMirrors(options = {}) {
153
+ const settings = {
154
+ ...PROBE_DEFAULTS,
155
+ ...options,
156
+ fetchImpl: options.fetchImpl ?? globalThis.fetch,
157
+ candidates: Array.isArray(options.candidates) ? options.candidates : MIRROR_CANDIDATES,
158
+ };
159
+ if (typeof settings.fetchImpl !== "function") {
160
+ return { probedAt: new Date().toISOString(), results: [], ranked: [], best: null, project: settings.project, version: settings.version, error: "no fetch implementation" };
161
+ }
162
+ const queue = settings.candidates.slice();
163
+ const results = [];
164
+ const workerCount = Math.max(1, Math.min(settings.concurrency, queue.length));
165
+ const workers = Array.from({ length: workerCount }, async () => {
166
+ while (queue.length > 0) {
167
+ const mirror = queue.shift();
168
+ if (mirror === undefined) return;
169
+ results.push(await probeMirror(mirror, settings));
170
+ }
171
+ });
172
+ await Promise.all(workers);
173
+ const ranked = results
174
+ .filter((result) => result.ok === true)
175
+ .sort((left, right) => {
176
+ if (right.speedBps !== left.speedBps) return right.speedBps - left.speedBps;
177
+ return (left.indexMs ?? Number.MAX_SAFE_INTEGER) - (right.indexMs ?? Number.MAX_SAFE_INTEGER);
178
+ });
179
+ return {
180
+ probedAt: new Date().toISOString(),
181
+ results,
182
+ ranked: ranked.map((result) => result.id),
183
+ best: ranked[0] ?? null,
184
+ project: settings.project,
185
+ version: settings.version,
186
+ };
187
+ }
188
+
189
+ /**
190
+ * Read the full project index from one mirror: every file for every version.
191
+ * @returns `{ indexUrl, pageUrl, entries, versions, latest }` — `versions` is
192
+ * newest-first and includes prereleases, `latest` is the newest stable one.
193
+ */
194
+ export async function fetchProjectIndex(options = {}) {
195
+ const fetchImpl = options.fetchImpl ?? globalThis.fetch;
196
+ const project = options.project ?? PROBE_DEFAULTS.project;
197
+ const indexUrl = options.indexUrl;
198
+ const timeoutMs = Number.isFinite(options.timeoutMs) ? options.timeoutMs : PROBE_DEFAULTS.timeoutMs;
199
+ const userAgent = options.userAgent ?? PROBE_DEFAULTS.userAgent;
200
+ const pageUrl = projectIndexUrl(indexUrl, project);
201
+ const response = await timedFetch(fetchImpl, pageUrl, { headers: { accept: "text/html", "user-agent": userAgent } }, timeoutMs);
202
+ if (response?.ok !== true) throw new Error(`${indexUrl} answered HTTP ${response?.status ?? "?"}`);
203
+ const entries = parseIndex(typeof response.text === "function" ? await response.text() : "", pageUrl);
204
+ const versions = [...new Set(entries.map((entry) => entry.version))];
205
+ return { indexUrl, pageUrl, entries, versions, latest: latestStableVersion(versions) };
206
+ }
207
+
208
+ /** Mirrors in the order an install should try them: ranked first, official last. */
209
+ export function mirrorOrder(probe, candidates = MIRROR_CANDIDATES) { const seen = new Set();
210
+ const order = [];
211
+ for (const id of Array.isArray(probe?.ranked) ? probe.ranked : []) {
212
+ const mirror = mirrorById(id, candidates);
213
+ if (mirror !== null && !seen.has(mirror.id)) {
214
+ seen.add(mirror.id);
215
+ order.push(mirror);
216
+ }
217
+ }
218
+ for (const mirror of candidates) {
219
+ if (mirror.official === true && !seen.has(mirror.id)) {
220
+ seen.add(mirror.id);
221
+ order.push(mirror);
222
+ }
223
+ }
224
+ for (const mirror of candidates) {
225
+ if (!seen.has(mirror.id)) {
226
+ seen.add(mirror.id);
227
+ order.push(mirror);
228
+ }
229
+ }
230
+ return order;
231
+ }
232
+
233
+ /** The mirror an install should try first, or null when nothing was probed. */
234
+ export function preferredMirror(probe, candidates = MIRROR_CANDIDATES) {
235
+ return mirrorOrder(probe, candidates)[0] ?? null;
236
+ }
package/lib/pypi.js ADDED
@@ -0,0 +1,171 @@
1
+ /**
2
+ * dsh-windows-mcp — PyPI index parsing and version maths (host side).
3
+ *
4
+ * The upstream program (`windows-mcp`) is published on PyPI, so the plugin needs
5
+ * two things that look like string processing but must be exactly right:
6
+ *
7
+ * 1. **Which versions exist, and which is the newest stable one?** — a PEP 503
8
+ * `/simple/<project>/` page is plain HTML; this module parses it instead of
9
+ * the JSON API, because some mirrors do not serve the JSON API at all and
10
+ * the simple page is the one file every mirror does serve.
11
+ * 2. **Is 0.9.0 newer than 0.8.10?** — no `semver` dependency here, so the
12
+ * comparison is written down once and tested: numeric component-wise, with
13
+ * a stable release sorting above a pre-release of the same core.
14
+ *
15
+ * Everything is pure: no I/O, no network, no throwing. A malformed page simply
16
+ * yields fewer entries.
17
+ */
18
+
19
+ /** Path of the project's index on a mirror, e.g. `https://…/simple/windows-mcp/`. */
20
+ export function projectIndexUrl(indexUrl, project) {
21
+ return `${String(indexUrl ?? "").replace(/\/+$/u, "")}/${String(project ?? "").replace(/^\/+/u, "")}/`;
22
+ }
23
+
24
+ /**
25
+ * Numeric core of a version plus whatever trails it.
26
+ * @returns `{ parts: number[], rest: string }` or null.
27
+ */
28
+ export function versionParts(version) {
29
+ const match = /^v?(\d+(?:\.\d+)*)(.*)$/u.exec(String(version ?? "").trim());
30
+ if (match === null) return null;
31
+ const parts = match[1].split(".").map((piece) => Number.parseInt(piece, 10));
32
+ if (parts.some((piece) => !Number.isFinite(piece))) return null;
33
+ return { parts, rest: match[2].trim() };
34
+ }
35
+
36
+ /** A release without a pre-release/dev/post marker: `0.8.8`, not `0.9.0rc1`. */
37
+ export function isStableVersion(version) {
38
+ return /^\d+(?:\.\d+)*$/u.test(String(version ?? "").trim());
39
+ }
40
+
41
+ /**
42
+ * Compare two versions: -1, 0 or 1. Non-numeric strings fall back to a plain
43
+ * string compare so the caller still gets a total order.
44
+ */
45
+ export function compareVersions(left, right) {
46
+ const a = versionParts(left);
47
+ const b = versionParts(right);
48
+ if (a === null || b === null) {
49
+ const x = String(left ?? "");
50
+ const y = String(right ?? "");
51
+ if (x === y) return 0;
52
+ return x < y ? -1 : 1;
53
+ }
54
+ const length = Math.max(a.parts.length, b.parts.length);
55
+ for (let index = 0; index < length; index += 1) {
56
+ const x = a.parts[index] ?? 0;
57
+ const y = b.parts[index] ?? 0;
58
+ if (x !== y) return x < y ? -1 : 1;
59
+ }
60
+ // Same numeric core: a bare release beats a pre-release (`0.9.0` > `0.9.0rc1`).
61
+ const ax = a.rest === "" ? 1 : 0;
62
+ const bx = b.rest === "" ? 1 : 0;
63
+ if (ax === bx) return 0;
64
+ return ax < bx ? -1 : 1;
65
+ }
66
+
67
+ /** The newest stable version of a list of versions, or null. */
68
+ export function latestStableVersion(versions) {
69
+ let best = null;
70
+ for (const version of Array.isArray(versions) ? versions : []) {
71
+ if (typeof version !== "string" || !isStableVersion(version)) continue;
72
+ if (best === null || compareVersions(version, best) > 0) best = version;
73
+ }
74
+ return best;
75
+ }
76
+
77
+ /**
78
+ * The upstream version a PyPI filename refers to, or null.
79
+ * Handles wheels (`windows_mcp-0.8.8-py3-none-any.whl`) and sdists
80
+ * (`windows_mcp-0.8.8.tar.gz`); the distribution name must be this project's.
81
+ */
82
+ export function versionFromFilename(filename, project = "windows-mcp") {
83
+ let clean;
84
+ try {
85
+ clean = decodeURIComponent(String(filename ?? "").replace(/^.*\//u, ""));
86
+ } catch {
87
+ clean = String(filename ?? "").replace(/^.*\//u, "");
88
+ }
89
+ if (clean === "") return null;
90
+ const distribution = String(project ?? "windows-mcp").replace(/-/gu, "[_-]");
91
+ const name = new RegExp(`^${distribution}$`, "iu");
92
+ const lower = clean.toLowerCase();
93
+ if (lower.endsWith(".whl")) {
94
+ const parts = clean.slice(0, -".whl".length).split("-");
95
+ if (parts.length < 2 || !name.test(parts[0])) return null;
96
+ return parts[1] === "" ? null : parts[1];
97
+ }
98
+ if (lower.endsWith(".tar.gz")) {
99
+ const parts = clean.slice(0, -".tar.gz".length).split("-");
100
+ if (parts.length < 2) return null;
101
+ if (!name.test(parts.slice(0, -1).join("-"))) return null;
102
+ return parts[parts.length - 1] === "" ? null : parts[parts.length - 1];
103
+ }
104
+ return null;
105
+ }
106
+
107
+ /**
108
+ * Parse a PEP 503 simple index page into entries, newest first.
109
+ * @param html - page body.
110
+ * @param pageUrl - absolute URL the page was served from (relative hrefs resolve against it).
111
+ * @returns `[{ filename, version, url, stable, yanked }]`.
112
+ */
113
+ export function parseIndex(html, pageUrl) {
114
+ const entries = [];
115
+ const anchor = /<a\b[^>]*>/giu;
116
+ const body = String(html ?? "");
117
+ let match;
118
+ while ((match = anchor.exec(body)) !== null) {
119
+ const tag = match[0];
120
+ const href = /href\s*=\s*"([^"]+)"/iu.exec(tag);
121
+ if (href === null) continue;
122
+ const target = String(href[1]);
123
+ // PEP 503 links may carry a `#sha256=…` fragment (and occasionally a query);
124
+ // the *file name* is the path's last segment, never the fragment.
125
+ const rawPath = target.split("#")[0].split("?")[0];
126
+ let filename = rawPath.replace(/^.*\//u, "");
127
+ try {
128
+ filename = decodeURIComponent(filename);
129
+ } catch {
130
+ /* keep it percent-encoded then */
131
+ }
132
+ const version = versionFromFilename(filename);
133
+ if (version === null) continue;
134
+ let url = target;
135
+ try {
136
+ url = new URL(target, pageUrl).href;
137
+ } catch {
138
+ /* keep the raw href; the caller reports a failure */
139
+ }
140
+ entries.push({
141
+ filename,
142
+ version,
143
+ url,
144
+ stable: isStableVersion(version),
145
+ yanked: /data-yanked\s*=/iu.test(tag),
146
+ });
147
+ }
148
+ return entries.sort((left, right) => compareVersions(right.version, left.version));
149
+ }
150
+
151
+ /** Newest stable entry of a parsed index, or null. */
152
+ export function latestStableEntry(entries) {
153
+ let best = null;
154
+ for (const entry of Array.isArray(entries) ? entries : []) {
155
+ if (entry?.stable !== true || entry?.yanked === true) continue;
156
+ if (best === null || compareVersions(entry.version, best.version) > 0) best = entry;
157
+ }
158
+ return best;
159
+ }
160
+
161
+ /** A wheel for `version` if the index has one, else the sdist, else any entry. */
162
+ export function entryForVersion(entries, version) {
163
+ if (!Array.isArray(entries)) return null;
164
+ const matching = entries.filter((entry) => entry?.version === version && entry?.yanked !== true);
165
+ if (matching.length === 0) return null;
166
+ return (
167
+ matching.find((entry) => entry.filename.toLowerCase().endsWith(".whl")) ??
168
+ matching.find((entry) => entry.filename.toLowerCase().endsWith(".tar.gz")) ??
169
+ matching[0]
170
+ );
171
+ }