dsh-windows-mcp 0.1.0 → 0.2.1

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/updater.js ADDED
@@ -0,0 +1,502 @@
1
+ /**
2
+ * dsh-windows-mcp — Windows-MCP version manager (host side).
3
+ *
4
+ * Responsibilities:
5
+ * - **Check**: probe the PyPI mirrors, pick the fastest one that works, read
6
+ * its `windows-mcp` index and report the newest stable release.
7
+ * - **Install**: install one version into its own tool root
8
+ * (`installs.js`) and activate it, rewriting the bridge row so the freshly
9
+ * installed executable is what the MCP client spawns. The previous version
10
+ * is kept, so a bad release is one click away from being rolled back.
11
+ * - **Rollback / activate**: switch the bridge to an already installed
12
+ * version — no download, no reinstall.
13
+ *
14
+ * The manager is deliberately synchronous-state/async-work, mirroring
15
+ * `createPrepare`: the page polls a plain snapshot, actions are POSTs.
16
+ */
17
+ import {
18
+ WINDOWS_MCP_TOOL,
19
+ dshHome,
20
+ spawnUvInstall,
21
+ toolVersionIn,
22
+ } from "./dependency.js";
23
+ import {
24
+ activateVersion,
25
+ activeInstall,
26
+ executableIn,
27
+ listManagedVersions,
28
+ managedToolchainRoot,
29
+ readVersionsState,
30
+ toolDirectoryFor,
31
+ versionToolRoot,
32
+ writeManifest,
33
+ writeVersionsState,
34
+ } from "./installs.js";
35
+ import { fetchProjectIndex, describeMirror, formatBytesPerSecond, mirrorCandidates, mirrorOrder, probeMirrors } from "./mirrors.js";
36
+ import { latestStableVersion } from "./pypi.js";
37
+
38
+ /** Route the page posts update actions to. */
39
+ export const UPDATE_ROUTE = "/dsh-windows-mcp/update";
40
+
41
+ /** A check result older than this is refreshed when the page or host asks. */
42
+ export const CHECK_TTL_MS = 6 * 60 * 60 * 1000;
43
+
44
+ /** Install log kept in memory (the page renders the tail). */
45
+ const MAX_INSTALL_LOG_LINES = 60;
46
+ const MAX_LOG_LINE = 400;
47
+ const INSTALL_TIMEOUT_MS = 30 * 60 * 1000;
48
+
49
+ /** Actions accepted by the update route. */
50
+ export const UPDATE_ACTIONS = Object.freeze(["check", "install", "activate", "rollback"]);
51
+
52
+ /** Compact mirror results for the persisted check record / the page. */
53
+ function summarizeProbe(probe) {
54
+ if (probe === null || probe === undefined) return { ranked: [], results: [] };
55
+ const results = (Array.isArray(probe.results) ? probe.results : []).map((result) => ({
56
+ id: result.id,
57
+ label: result.label,
58
+ ok: result.ok === true,
59
+ speedBps: typeof result.speedBps === "number" ? result.speedBps : 0,
60
+ indexMs: typeof result.indexMs === "number" ? result.indexMs : null,
61
+ error: result.error ?? null,
62
+ }));
63
+ return { ranked: Array.isArray(probe.ranked) ? probe.ranked : [], results };
64
+ }
65
+
66
+ /**
67
+ * Create the version manager.
68
+ * @param options.home - DSH home (tests inject a temp directory).
69
+ * @param options.warn - warning sink.
70
+ * @param options.resolver - PATH lookup used to find `uv`.
71
+ * @param options.pinned - upstream version pin from the bridge row.
72
+ * @param options.writeBridge - `(executable) => bridge result`; rewrites the
73
+ * profile patch so the MCP client spawns the given executable.
74
+ * @param options.probe - mirror probe implementation (tests inject one).
75
+ * @param options.readIndex - project index reader (tests inject one).
76
+ * @param options.spawnInstall - `uv tool install` starter (tests inject one).
77
+ * @param options.now - clock (tests inject one).
78
+ * @param options.preferredMirror - `() => id`; a mirror the user pinned, or
79
+ * `"auto"` to keep whatever the speed probe ranks first.
80
+ */
81
+ export function createUpdater(options = {}) {
82
+ const home = options.home ?? dshHome();
83
+ const warn = typeof options.warn === "function" ? options.warn : () => {};
84
+ const resolver = typeof options.resolver === "function" ? options.resolver : () => null;
85
+ const writeBridge = typeof options.writeBridge === "function" ? options.writeBridge : () => ({ ok: false, error: "bridge writer is not configured" });
86
+ const probe = typeof options.probe === "function" ? options.probe : probeMirrors;
87
+ const readIndex = typeof options.readIndex === "function" ? options.readIndex : fetchProjectIndex;
88
+ const startInstall = typeof options.spawnInstall === "function" ? options.spawnInstall : spawnUvInstall;
89
+ const now = typeof options.now === "function" ? options.now : () => Date.now();
90
+ const preferredMirrorId = typeof options.preferredMirror === "function" ? options.preferredMirror : () => "auto";
91
+ const pinned = typeof options.pinned === "string" && options.pinned !== "" ? options.pinned : null;
92
+ const root = managedToolchainRoot(home);
93
+
94
+ const checkState = { phase: "idle", error: null, at: null, latest: null, best: null, mirrors: [] };
95
+ const installState = {
96
+ phase: "idle",
97
+ version: null,
98
+ mirror: null,
99
+ startedAt: null,
100
+ finishedAt: null,
101
+ error: null,
102
+ log: [],
103
+ bridge: null,
104
+ };
105
+ let lastProbe = null;
106
+ let child = null;
107
+ let timer = null;
108
+ let checkPromise = null;
109
+ let installPromise = null;
110
+
111
+ const append = (chunk) => {
112
+ for (const line of String(chunk).split(/\r?\n/u)) {
113
+ const trimmed = line.trimEnd();
114
+ if (trimmed === "") continue;
115
+ installState.log.push(trimmed.slice(0, MAX_LOG_LINE));
116
+ }
117
+ if (installState.log.length > MAX_INSTALL_LOG_LINES) installState.log.splice(0, installState.log.length - MAX_INSTALL_LOG_LINES);
118
+ };
119
+
120
+ const clearTimer = () => {
121
+ if (timer !== null) {
122
+ try {
123
+ clearTimeout(timer);
124
+ } catch {
125
+ /* nothing to clear */
126
+ }
127
+ timer = null;
128
+ }
129
+ };
130
+
131
+ const settleInstall = (phase, error) => {
132
+ installState.phase = phase;
133
+ installState.error = error ?? null;
134
+ installState.finishedAt = new Date().toISOString();
135
+ child = null;
136
+ clearTimer();
137
+ if (phase === "failed" && error !== null) warn(`Windows-MCP update failed: ${error}`);
138
+ };
139
+
140
+ const persistedCheck = () => {
141
+ const state = readVersionsState(home);
142
+ return state.lastCheck ?? null;
143
+ };
144
+
145
+ const isFresh = (record) => record !== null && Number.isFinite(Date.parse(record.at ?? "")) && now() - Date.parse(record.at) < CHECK_TTL_MS;
146
+
147
+ /** Snapshot for the page: versions, check result, install progress. */
148
+ function view() {
149
+ const state = readVersionsState(home);
150
+ const managed = listManagedVersions(root);
151
+ const active = activeInstall(state, home);
152
+ const persisted = state.lastCheck ?? null;
153
+ const latest = checkState.phase === "ok" && typeof checkState.latest === "string" ? checkState.latest : persisted?.latest ?? null;
154
+ const checkAt = checkState.phase === "ok" ? checkState.at : persisted?.at ?? null;
155
+ const mirrors = Array.isArray(checkState.mirrors) ? checkState.mirrors : persisted?.mirrors ?? [];
156
+ return {
157
+ check: {
158
+ phase: checkState.phase,
159
+ at: checkAt,
160
+ fresh: checkAt !== null && Number.isFinite(Date.parse(checkAt)) && now() - Date.parse(checkAt) < CHECK_TTL_MS,
161
+ latest,
162
+ best: checkState.phase === "ok" ? checkState.best ?? null : persisted?.best ?? null,
163
+ error: checkState.error,
164
+ mirrors,
165
+ },
166
+ install: {
167
+ phase: installState.phase,
168
+ version: installState.version,
169
+ mirror: installState.mirror,
170
+ startedAt: installState.startedAt,
171
+ finishedAt: installState.finishedAt,
172
+ error: installState.error,
173
+ bridge: installState.bridge,
174
+ log: installState.log.slice(-MAX_INSTALL_LOG_LINES),
175
+ },
176
+ versions: {
177
+ pinned,
178
+ active: state.active,
179
+ previous: state.previous,
180
+ history: state.history,
181
+ available: managed.map((entry) => entry.version),
182
+ installed: managed.map((entry) => ({
183
+ version: entry.version,
184
+ executable: entry.executable,
185
+ installedAt: entry.installedAt,
186
+ mirror: entry.mirror,
187
+ })),
188
+ toolchainRoot: root,
189
+ activeExecutable: active?.executable ?? null,
190
+ updateAvailable: latest !== null && state.active !== latest,
191
+ },
192
+ };
193
+ }
194
+
195
+ /** Probe mirrors, then read the newest stable version from the fastest one. */
196
+ async function runCheck() {
197
+ checkState.phase = "checking";
198
+ checkState.error = null;
199
+ let probeResult = null;
200
+ let candidates = mirrorCandidates("auto");
201
+ try {
202
+ const preferred = preferredMirrorId();
203
+ candidates = mirrorCandidates(typeof preferred === "string" ? preferred : "auto");
204
+ probeResult = await probe({ project: WINDOWS_MCP_TOOL, candidates });
205
+ lastProbe = probeResult;
206
+ } catch (error) {
207
+ checkState.phase = "failed";
208
+ checkState.error = error instanceof Error ? error.message : String(error);
209
+ return view();
210
+ }
211
+ const summarized = summarizeProbe(probeResult);
212
+ let latest = null;
213
+ let best = null;
214
+ let lastError = "no mirror answered";
215
+ for (const mirror of mirrorOrder(probeResult, candidates)) {
216
+ try {
217
+ const index = await readIndex({ indexUrl: mirror.indexUrl, project: WINDOWS_MCP_TOOL });
218
+ latest = index.latest ?? latestStableVersion(index.versions ?? []);
219
+ if (latest !== null) {
220
+ best = mirror.id;
221
+ break;
222
+ }
223
+ lastError = `${mirror.label} lists no stable windows-mcp version`;
224
+ } catch (error) {
225
+ lastError = `${mirror.label}: ${error instanceof Error ? error.message : String(error)}`;
226
+ }
227
+ }
228
+ checkState.at = new Date(now()).toISOString();
229
+ if (latest === null) {
230
+ checkState.phase = "failed";
231
+ checkState.error = lastError;
232
+ } else {
233
+ checkState.phase = "ok";
234
+ checkState.latest = latest;
235
+ checkState.best = best;
236
+ checkState.mirrors = summarized.results;
237
+ }
238
+ const state = readVersionsState(home);
239
+ try {
240
+ writeVersionsState(
241
+ {
242
+ ...state,
243
+ lastCheck: {
244
+ at: checkState.at,
245
+ latest,
246
+ best,
247
+ mirrors: summarized.results,
248
+ },
249
+ },
250
+ home,
251
+ );
252
+ } catch (error) {
253
+ warn(`could not persist the Windows-MCP update check: ${String(error)}`);
254
+ }
255
+ return view();
256
+ }
257
+
258
+ /** Ensure a check result exists; `force` bypasses the TTL. */
259
+ async function check(scope = {}) {
260
+ const force = scope.force === true;
261
+ if (checkPromise !== null) return checkPromise;
262
+ const persisted = persistedCheck();
263
+ if (!force && isFresh(persisted) && checkState.phase !== "failed") {
264
+ checkState.phase = "ok";
265
+ checkState.at = persisted.at;
266
+ checkState.latest = persisted.latest;
267
+ checkState.best = persisted.best;
268
+ checkState.mirrors = Array.isArray(persisted.mirrors) ? persisted.mirrors : [];
269
+ return view();
270
+ }
271
+ checkPromise = runCheck().finally(() => {
272
+ checkPromise = null;
273
+ });
274
+ return checkPromise;
275
+ }
276
+
277
+ /** One `uv tool install` attempt from one mirror; resolves with the outcome. */
278
+ function installAttempt({ uv, version, mirror }) {
279
+ return new Promise((resolve) => {
280
+ const toolRoot = versionToolRoot(root, version);
281
+ const toolDirectory = toolDirectoryFor(root, version);
282
+ const spec = `${WINDOWS_MCP_TOOL}@${version}`;
283
+ append(`$ ${uv} tool install --force --default-index ${mirror.indexUrl} ${spec} (UV_TOOL_DIR=${toolRoot})`);
284
+ let running = null;
285
+ let finished = false;
286
+ const finish = (result) => {
287
+ if (finished) return;
288
+ finished = true;
289
+ child = null;
290
+ clearTimer();
291
+ resolve(result);
292
+ };
293
+ try {
294
+ const started = startInstall({
295
+ uv,
296
+ spec,
297
+ indexUrl: mirror.indexUrl,
298
+ toolDirectory: toolRoot,
299
+ onLine: append,
300
+ });
301
+ running = started.child;
302
+ child = running;
303
+ } catch (error) {
304
+ finish({ ok: false, error: error instanceof Error ? error.message : String(error) });
305
+ return;
306
+ }
307
+ running.on("error", (error) => finish({ ok: false, error: String(error) }));
308
+ running.on("close", (code) => {
309
+ if (code !== 0) {
310
+ finish({ ok: false, error: `uv exited with code ${code}` });
311
+ return;
312
+ }
313
+ const installedVersion = toolVersionIn(toolDirectory);
314
+ const executable = executableIn(toolDirectory);
315
+ if (installedVersion !== version || executable === null) {
316
+ finish({
317
+ ok: false,
318
+ error: `uv finished, but ${toolDirectory} does not contain windows-mcp ${version}`,
319
+ });
320
+ return;
321
+ }
322
+ finish({ ok: true, executable, toolDirectory });
323
+ });
324
+ timer = setTimeout(() => {
325
+ append("install timed out after 30 minutes");
326
+ try {
327
+ running.kill();
328
+ } catch {
329
+ /* already gone */
330
+ }
331
+ finish({ ok: false, error: "install timed out" });
332
+ }, INSTALL_TIMEOUT_MS);
333
+ if (typeof timer.unref === "function") timer.unref();
334
+ });
335
+ }
336
+
337
+ /** Rewrite the bridge to the given managed version and remember it. */
338
+ function activate(version) {
339
+ const executable = activeInstall({ active: version }, home)?.executable ?? null;
340
+ if (executable === null) {
341
+ return { ok: false, error: `Windows-MCP ${version} is not installed under ${root}` };
342
+ }
343
+ let bridge;
344
+ try {
345
+ bridge = writeBridge(executable);
346
+ } catch (error) {
347
+ bridge = { ok: false, error: String(error) };
348
+ }
349
+ if (bridge?.ok !== true) return { ok: false, error: bridge?.error ?? "the bridge row could not be written", bridge };
350
+ const state = readVersionsState(home);
351
+ const next = activateVersion(state, version);
352
+ try {
353
+ writeVersionsState(next, home);
354
+ } catch (error) {
355
+ return { ok: false, error: `the bridge was written, but the versions file could not be updated: ${String(error)}`, bridge };
356
+ }
357
+ installState.bridge = bridge;
358
+ return { ok: true, version, executable, bridge };
359
+ }
360
+
361
+ /** Install `version` from the fastest mirror that works, then activate it. */
362
+ async function installVersion(version) {
363
+ if (typeof version !== "string" || version === "") return { ok: false, error: "a version is required" };
364
+ if (installPromise !== null) return { ok: false, error: "an install is already running" };
365
+ const uv = resolver("uv");
366
+ if (uv === null) return { ok: false, error: "`uv` was not found on the PATH of this DSH process" };
367
+ installPromise = (async () => {
368
+ installState.phase = "installing";
369
+ installState.version = version;
370
+ installState.mirror = null;
371
+ installState.startedAt = new Date().toISOString();
372
+ installState.finishedAt = null;
373
+ installState.error = null;
374
+ installState.bridge = null;
375
+ installState.log = [];
376
+ if (lastProbe === null) {
377
+ try {
378
+ lastProbe = await probe({ project: WINDOWS_MCP_TOOL });
379
+ } catch {
380
+ lastProbe = null;
381
+ }
382
+ }
383
+ const order = mirrorOrder(lastProbe);
384
+ let lastError = "no mirror was available";
385
+ for (const mirror of order) {
386
+ installState.mirror = mirror.id;
387
+ append(`trying ${mirror.label} (${formatBytesPerSecond(mirrorSpeed(lastProbe, mirror.id))})`);
388
+ const attempt = await installAttempt({ uv, version, mirror });
389
+ if (attempt.ok !== true) {
390
+ lastError = `${mirror.label}: ${attempt.error}`;
391
+ append(`failed: ${lastError}`);
392
+ continue;
393
+ }
394
+ try {
395
+ writeManifest(root, version, {
396
+ executable: attempt.executable,
397
+ toolDirectory: attempt.toolDirectory,
398
+ indexUrl: mirror.indexUrl,
399
+ mirror: mirror.id,
400
+ });
401
+ } catch (error) {
402
+ warn(`could not write the Windows-MCP install manifest: ${String(error)}`);
403
+ }
404
+ const activated = activate(version);
405
+ if (activated.ok !== true) {
406
+ settleInstall("failed", activated.error ?? "activation failed");
407
+ return { ok: false, error: installState.error, view: view() };
408
+ }
409
+ settleInstall("ok", null);
410
+ return { ok: true, version, mirror: mirror.id, view: view() };
411
+ }
412
+ settleInstall("failed", lastError);
413
+ return { ok: false, error: installState.error, view: view() };
414
+ })().finally(() => {
415
+ installPromise = null;
416
+ });
417
+ return installPromise;
418
+ }
419
+
420
+ /** Speed of one mirror from the last probe (0 when unknown). */
421
+ function mirrorSpeed(probeResult, id) {
422
+ const result = (probeResult?.results ?? []).find((entry) => entry.id === id);
423
+ return typeof result?.speedBps === "number" ? result.speedBps : 0;
424
+ }
425
+
426
+ /** Switch to a version that is already installed (no download). */
427
+ function activateInstalled(version) {
428
+ if (installState.phase === "installing") return { ok: false, error: "an install is already running" };
429
+ const activated = activate(version);
430
+ return { ...activated, view: view() };
431
+ }
432
+
433
+ /** Go back to the version that the active one replaced. */
434
+ function rollback() {
435
+ const state = readVersionsState(home);
436
+ if (state.previous === null) return { ok: false, error: "there is no previous version to go back to", view: view() };
437
+ const target = state.previous;
438
+ const activated = activate(target);
439
+ return { ...activated, rolledBackFrom: state.active, view: view() };
440
+ }
441
+
442
+ /** Stop a running install. */
443
+ function cancel() {
444
+ if (installState.phase !== "installing") return { cancelled: false, state: view() };
445
+ installState.phase = "cancelled";
446
+ append("install cancelled by the user");
447
+ if (child !== null) {
448
+ try {
449
+ child.kill();
450
+ } catch {
451
+ /* already gone */
452
+ }
453
+ child = null;
454
+ }
455
+ clearTimer();
456
+ installState.finishedAt = new Date().toISOString();
457
+ return { cancelled: true, state: view() };
458
+ }
459
+
460
+ /**
461
+ * Dependency view for `/state`: the existing snapshot shape, with managed
462
+ * installs taking precedence over the global `uv tool` installation.
463
+ */
464
+ function dependencyView(legacy) {
465
+ const state = readVersionsState(home);
466
+ const active = activeInstall(state, home);
467
+ const managed = listManagedVersions(root);
468
+ const dependency = {
469
+ ...legacy,
470
+ managed: {
471
+ toolchainRoot: root,
472
+ active: state.active,
473
+ previous: state.previous,
474
+ available: managed.map((entry) => entry.version),
475
+ },
476
+ };
477
+ if (active !== null) {
478
+ dependency.installed = true;
479
+ dependency.executable = active.executable;
480
+ dependency.version = active.version;
481
+ dependency.source = "managed";
482
+ } else {
483
+ dependency.source = "uv-tool";
484
+ }
485
+ return dependency;
486
+ }
487
+
488
+ return {
489
+ view,
490
+ check,
491
+ installVersion,
492
+ activateInstalled,
493
+ rollback,
494
+ cancel,
495
+ dependencyView,
496
+ describeMirror: (result) => describeMirror(result),
497
+ /** Test seam: wait until no check/install is in flight. */
498
+ async whenIdle() {
499
+ await Promise.all([checkPromise, installPromise]);
500
+ },
501
+ };
502
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-windows-mcp",
3
- "version": "0.1.0",
3
+ "version": "0.2.1",
4
4
  "description": "Windows desktop automation for DeepSeek Harness — plugs CursorTouch/Windows-MCP (screenshot, UI automation, input, clipboard, processes, registry) into DSH through the official MCP client bridge.",
5
5
  "license": "MIT",
6
6
  "author": "dsh-windows-mcp contributors",