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/index.js CHANGED
@@ -33,12 +33,12 @@ import {
33
33
  BRIDGE_ROUTE,
34
34
  PREPARE_CANCEL_ROUTE,
35
35
  PREPARE_ROUTE,
36
- createPrepare,
37
36
  dependencySnapshot,
38
37
  windowsMcpExecutable,
39
38
  windowsMcpVersionPin,
40
39
  writeBridgeOverride,
41
40
  } from "./dependency.js";
41
+ import { UPDATE_ACTIONS, UPDATE_ROUTE, createUpdater } from "./updater.js";
42
42
 
43
43
  const PACKAGE_ID = "dsh-windows-mcp";
44
44
  const BRIDGE_ROW_ID = "windows-mcp-bridge";
@@ -67,8 +67,8 @@ const MAX_STATE_BODY_BYTES = 1024;
67
67
  * Desktop control is opt-in: a fresh install starts OFF. */
68
68
  const computerUse = { enabled: false };
69
69
 
70
- /** The `uv tool install` driver, created in apply() once the logger exists. */
71
- let dependencyManager = null;
70
+ /** The Windows-MCP version manager, created in apply() once the logger exists. */
71
+ let updater = null;
72
72
 
73
73
  /* ------------------------------------------------------------------ *
74
74
  * small, throw-proof helpers
@@ -311,6 +311,25 @@ function buildReport(tools) {
311
311
  }`,
312
312
  );
313
313
 
314
+ const update = updater === null ? null : updater.view();
315
+ if (update !== null) {
316
+ const managed = update.versions;
317
+ lines.push(
318
+ `managed install : ${
319
+ managed.active === null
320
+ ? "none yet — the global `uv tool` installation is used (installing or updating creates one)"
321
+ : `v${managed.active} under ${managed.toolchainRoot}`
322
+ }`,
323
+ );
324
+ if (managed.previous !== null) lines.push(` rollback to : v${managed.previous}`);
325
+ if (managed.available.length > 0) lines.push(` installed : ${managed.available.map((version) => `v${version}`).join(", ")}`);
326
+ if (update.check.latest === null) {
327
+ lines.push(" latest : not checked yet — press Check for updates in Settings → Plugins");
328
+ } else {
329
+ lines.push(` latest : v${update.check.latest}${managed.updateAvailable ? " — an update is available" : " — up to date"}`);
330
+ }
331
+ }
332
+
314
333
  if (uv === null && uvx === null) {
315
334
  advice.push(
316
335
  "Install uv (https://docs.astral.sh/uv/) and make sure `uvx` is on the PATH of the process that runs DSH — without it the bridge cannot start Windows-MCP.",
@@ -490,13 +509,34 @@ function registerStateRoute(ctx, warn) {
490
509
  }
491
510
  };
492
511
 
493
- const snapshot = () => ({
494
- enabled: computerUse.enabled,
495
- bridged: bridgedToolCount(scoped.get?.("tools")),
496
- serverName: "windows",
497
- dependency: dependencySnapshot(which, windowsMcpVersionPin(readText(patchPath))),
498
- prepare: dependencyManager === null ? null : dependencyManager.snapshot(),
499
- });
512
+ /** Map the version manager's install progress onto the prepare shape the page polls. */
513
+ const prepareSnapshot = () => {
514
+ if (updater === null) return null;
515
+ const install = updater.view().install;
516
+ return {
517
+ phase: install.phase === "installing" ? "running" : install.phase,
518
+ startedAt: install.startedAt,
519
+ finishedAt: install.finishedAt,
520
+ error: install.error,
521
+ log: install.log,
522
+ bridge: install.bridge,
523
+ version: install.version,
524
+ mirror: install.mirror,
525
+ };
526
+ };
527
+
528
+ const snapshot = () => {
529
+ const legacy = dependencySnapshot(which, windowsMcpVersionPin(readText(patchPath)));
530
+ const update = updater === null ? null : updater.view();
531
+ return {
532
+ enabled: computerUse.enabled,
533
+ bridged: bridgedToolCount(scoped.get?.("tools")),
534
+ serverName: "windows",
535
+ dependency: updater === null ? legacy : updater.dependencyView(legacy),
536
+ prepare: prepareSnapshot(),
537
+ update,
538
+ };
539
+ };
500
540
 
501
541
  const handler = (request, response) => {
502
542
  if (request.method === "GET") {
@@ -536,7 +576,7 @@ function registerStateRoute(ctx, warn) {
536
576
  request.on("error", () => respond(response, 400, { error: "request failed" }));
537
577
  };
538
578
 
539
- /** Start the dependency install the Plugins page shows a button for. */
579
+ /** Start installing the pinned (or newest known) Windows-MCP into its own tool root. */
540
580
  const prepareHandler = (request, response) => {
541
581
  if (request.method === "GET") {
542
582
  respond(response, 200, snapshot());
@@ -546,17 +586,22 @@ function registerStateRoute(ctx, warn) {
546
586
  respond(response, 405, { error: "method not allowed" });
547
587
  return;
548
588
  }
549
- if (dependencyManager === null) {
550
- respond(response, 503, { error: "the dependency manager is not ready" });
589
+ if (updater === null) {
590
+ respond(response, 503, { error: "the version manager is not ready" });
551
591
  return;
552
592
  }
553
- const started = dependencyManager.start();
554
- respond(response, started.started ? 202 : 409, {
555
- started: started.started,
556
- reason: started.reason ?? null,
557
- spec: started.spec ?? null,
558
- prepare: dependencyManager.snapshot(),
559
- });
593
+ const view = updater.view();
594
+ const target = view.versions.pinned ?? view.check.latest;
595
+ if (target === null) {
596
+ respond(response, 409, { started: false, reason: "no Windows-MCP version is known yet", prepare: prepareSnapshot() });
597
+ return;
598
+ }
599
+ if (view.install.phase === "installing") {
600
+ respond(response, 409, { started: false, reason: "an install is already running", prepare: prepareSnapshot() });
601
+ return;
602
+ }
603
+ void updater.installVersion(target);
604
+ respond(response, 202, { started: true, spec: `windows-mcp@${target}`, prepare: prepareSnapshot() });
560
605
  };
561
606
 
562
607
  /** Stop a running install. */
@@ -565,31 +610,105 @@ function registerStateRoute(ctx, warn) {
565
610
  respond(response, 405, { error: "method not allowed" });
566
611
  return;
567
612
  }
568
- const cancelled = dependencyManager !== null && dependencyManager.cancel();
569
- respond(response, 200, { cancelled, prepare: dependencyManager === null ? null : dependencyManager.snapshot() });
613
+ const result = updater === null ? { cancelled: false } : updater.cancel();
614
+ respond(response, 200, { cancelled: result.cancelled === true, prepare: prepareSnapshot() });
570
615
  };
571
616
 
572
- /** Point the bridge at the installed executable (also done after install). */
617
+ /** Point the bridge at the active executable (managed install first). */
573
618
  const bridgeHandler = (request, response) => {
574
619
  if (request.method !== "POST") {
575
620
  respond(response, 405, { error: "method not allowed" });
576
621
  return;
577
622
  }
578
- const executable = windowsMcpExecutable();
623
+ const view = updater === null ? null : updater.view();
624
+ const executable = view?.versions?.activeExecutable ?? windowsMcpExecutable();
579
625
  if (executable === null) {
580
626
  respond(response, 409, {
581
627
  ok: false,
582
- error: "Windows-MCP is not installed through `uv tool` yet, so there is nothing to point the bridge at.",
628
+ error: "Windows-MCP is not installed yet, so there is nothing to point the bridge at.",
583
629
  });
584
630
  return;
585
631
  }
586
632
  respond(response, 200, writeBridgeOverride(executable, { warn, version: selfVersion() }));
587
633
  };
588
634
 
635
+ /** Check for, install, activate and roll back Windows-MCP versions. */
636
+ const updateHandler = (request, response) => {
637
+ if (request.method === "GET") {
638
+ respond(response, 200, updater === null ? { error: "the version manager is not ready" } : updater.view());
639
+ return;
640
+ }
641
+ if (request.method !== "POST") {
642
+ respond(response, 405, { error: "method not allowed" });
643
+ return;
644
+ }
645
+ if (updater === null) {
646
+ respond(response, 503, { error: "the version manager is not ready" });
647
+ return;
648
+ }
649
+ const chunks = [];
650
+ let size = 0;
651
+ request.on("data", (chunk) => {
652
+ size += chunk.length;
653
+ if (size > MAX_STATE_BODY_BYTES) {
654
+ request.destroy();
655
+ return;
656
+ }
657
+ chunks.push(chunk);
658
+ });
659
+ request.on("end", () => {
660
+ let body;
661
+ try {
662
+ body = JSON.parse(Buffer.concat(chunks).toString("utf8") || "{}");
663
+ } catch {
664
+ respond(response, 400, { error: "invalid JSON body" });
665
+ return;
666
+ }
667
+ const action = typeof body?.action === "string" ? body.action : "";
668
+ const version = typeof body?.version === "string" && body.version !== "" ? body.version : null;
669
+ const allowed = [...UPDATE_ACTIONS, "cancel"];
670
+ if (!allowed.includes(action)) {
671
+ respond(response, 400, { error: `expected { action } in ${allowed.join(", ")}` });
672
+ return;
673
+ }
674
+ if (action === "check") {
675
+ void updater.check({ force: true });
676
+ respond(response, 202, { started: true, action });
677
+ return;
678
+ }
679
+ if (action === "install") {
680
+ const view = updater.view();
681
+ const target = version ?? view.check.latest ?? view.versions.pinned;
682
+ if (target === null) {
683
+ respond(response, 409, { ok: false, error: "no Windows-MCP version is known yet — check for updates first" });
684
+ return;
685
+ }
686
+ void updater.installVersion(target);
687
+ respond(response, 202, { started: true, action, version: target });
688
+ return;
689
+ }
690
+ if (action === "cancel") {
691
+ respond(response, 200, updater.cancel());
692
+ return;
693
+ }
694
+ if (action === "activate") {
695
+ if (version === null) {
696
+ respond(response, 400, { error: "activate needs { version }" });
697
+ return;
698
+ }
699
+ respond(response, 200, updater.activateInstalled(version));
700
+ return;
701
+ }
702
+ respond(response, 200, updater.rollback());
703
+ });
704
+ request.on("error", () => respond(response, 400, { error: "request failed" }));
705
+ };
706
+
589
707
  scoped.webServer.register({ kind: "exact", path: STATE_ROUTE, handler });
590
708
  scoped.webServer.register({ kind: "exact", path: PREPARE_ROUTE, handler: prepareHandler });
591
709
  scoped.webServer.register({ kind: "exact", path: PREPARE_CANCEL_ROUTE, handler: cancelHandler });
592
710
  scoped.webServer.register({ kind: "exact", path: BRIDGE_ROUTE, handler: bridgeHandler });
711
+ scoped.webServer.register({ kind: "exact", path: UPDATE_ROUTE, handler: updateHandler });
593
712
  } catch (error) {
594
713
  warn(`could not register the Computer Use state route: ${String(error)}`);
595
714
  }
@@ -607,16 +726,24 @@ export function apply(ctx) {
607
726
  try {
608
727
  const warn = makeLogger(ctx);
609
728
  computerUse.enabled = readComputerUse().enabled;
610
- dependencyManager = createPrepare({
729
+ updater = createUpdater({
611
730
  warn,
612
731
  resolver: which,
613
732
  pinned: windowsMcpVersionPin(readText(patchPath)),
614
- afterInstall: (executable) => writeBridgeOverride(executable, { warn, version: selfVersion() }),
733
+ writeBridge: (executable) => writeBridgeOverride(executable, { warn, version: selfVersion() }),
615
734
  });
616
735
  auditStartup(warn);
617
736
  registerDoctor(ctx, warn);
618
737
  registerStateRoute(ctx, warn);
619
738
  registerToolGate(ctx, warn);
739
+ // Check for a newer Windows-MCP build in the background (cheap: a ranged
740
+ // read per mirror, cached for hours). The page shows the result; the boot
741
+ // never waits for it, and a failure only costs the last known answer.
742
+ try {
743
+ void updater.check({ force: false }).catch(() => {});
744
+ } catch {
745
+ /* a failed check is not a boot problem */
746
+ }
620
747
  } catch {
621
748
  /* apply() must not throw, whatever happens */
622
749
  }
@@ -0,0 +1,196 @@
1
+ /**
2
+ * dsh-windows-mcp — managed installs (host side).
3
+ *
4
+ * Every Windows-MCP version this plugin installs lives in its own `uv` tool
5
+ * root:
6
+ *
7
+ * <home>/dsh-windows-mcp/toolchain/<version>/ ← UV_TOOL_DIR
8
+ * <home>/dsh-windows-mcp/toolchain/<version>/windows-mcp/Scripts/windows-mcp.exe
9
+ * <home>/dsh-windows-mcp/toolchain/<version>/manifest.json
10
+ *
11
+ * Isolating by version is what makes "update" safe: the previous environment is
12
+ * never overwritten, so rolling back is a pointer move plus a bridge rewrite —
13
+ * no download, no reinstall. `<home>/dsh-windows-mcp.versions.json` remembers
14
+ * which version is active, which one it replaced, and what the last update
15
+ * check saw.
16
+ */
17
+ import { existsSync, mkdirSync, readFileSync, readdirSync, renameSync, writeFileSync } from "node:fs";
18
+ import { join } from "node:path";
19
+ import { dshHome, toolVersionIn } from "./dependency.js";
20
+
21
+ const VERSION_PATTERN = /^\d[0-9A-Za-z.+_-]*$/u;
22
+ const STATE_VERSION = 1;
23
+
24
+ /** Root that holds one tool root per Windows-MCP version. */
25
+ export function managedToolchainRoot(home = dshHome()) {
26
+ return join(home, "dsh-windows-mcp", "toolchain");
27
+ }
28
+
29
+ /** `…/<version>` — the directory handed to `UV_TOOL_DIR`. */
30
+ export function versionToolRoot(root, version) {
31
+ if (typeof root !== "string" || root === "") throw new TypeError("root must be a non-empty string");
32
+ if (typeof version !== "string" || !VERSION_PATTERN.test(version)) throw new TypeError(`invalid Windows-MCP version: ${String(version)}`);
33
+ return join(root, version);
34
+ }
35
+
36
+ /** `…/<version>/windows-mcp` — the tool environment inside the version root. */
37
+ export function toolDirectoryFor(root, version) {
38
+ return join(versionToolRoot(root, version), "windows-mcp");
39
+ }
40
+
41
+ /** Executable path for a tool directory, or null when the tool is not there. */
42
+ export function executableIn(toolDirectory) {
43
+ if (typeof toolDirectory !== "string" || toolDirectory === "") return null;
44
+ for (const candidate of [join(toolDirectory, "Scripts", "windows-mcp.exe"), join(toolDirectory, "bin", "windows-mcp")]) {
45
+ if (existsSync(candidate)) return candidate;
46
+ }
47
+ return null;
48
+ }
49
+
50
+ /** Executable of a managed version, or null when it is not installed. */
51
+ export function managedExecutable(root, version) {
52
+ return executableIn(toolDirectoryFor(root, version));
53
+ }
54
+
55
+ /** `…/<version>/manifest.json` */
56
+ export function manifestPath(root, version) {
57
+ return join(versionToolRoot(root, version), "manifest.json");
58
+ }
59
+
60
+ /** Read the install manifest of one managed version (null when absent). */
61
+ export function readManifest(root, version) {
62
+ try {
63
+ const parsed = JSON.parse(readFileSync(manifestPath(root, version), "utf8"));
64
+ return parsed !== null && typeof parsed === "object" ? parsed : null;
65
+ } catch {
66
+ return null;
67
+ }
68
+ }
69
+
70
+ /** Write the install manifest of one managed version. */
71
+ export function writeManifest(root, version, payload = {}) {
72
+ const target = manifestPath(root, version);
73
+ mkdirSync(versionToolRoot(root, version), { recursive: true });
74
+ const manifest = {
75
+ version,
76
+ installedAt: new Date().toISOString(),
77
+ executable: managedExecutable(root, version),
78
+ ...payload,
79
+ };
80
+ writeFileSync(target, `${JSON.stringify(manifest, null, 2)}\n`);
81
+ return manifest;
82
+ }
83
+
84
+ /** Every managed version, newest first. */
85
+ export function listManagedVersions(root = managedToolchainRoot()) {
86
+ let names = [];
87
+ try {
88
+ names = readdirSync(root, { withFileTypes: true })
89
+ .filter((entry) => entry.isDirectory() && VERSION_PATTERN.test(entry.name))
90
+ .map((entry) => entry.name);
91
+ } catch {
92
+ return [];
93
+ }
94
+ const versions = names.map((version) => {
95
+ const toolDirectory = toolDirectoryFor(root, version);
96
+ const manifest = readManifest(root, version);
97
+ const executable = executableIn(toolDirectory);
98
+ return {
99
+ version,
100
+ toolDirectory,
101
+ executable,
102
+ installedVersion: toolVersionIn(toolDirectory),
103
+ manifest,
104
+ installedAt: typeof manifest?.installedAt === "string" ? manifest.installedAt : null,
105
+ mirror: typeof manifest?.mirror === "string" ? manifest.mirror : null,
106
+ ready: executable !== null,
107
+ };
108
+ });
109
+ return versions
110
+ .filter((entry) => entry.ready)
111
+ .sort((left, right) => compareVersionsDescending(left.version, right.version));
112
+ }
113
+
114
+ /** Local sort helper — newest first, numeric segments. */
115
+ function compareVersionsDescending(left, right) {
116
+ const leftParts = left.split(/[.+-]/u).map((part) => (/^\d+$/u.test(part) ? Number(part) : part));
117
+ const rightParts = right.split(/[.+-]/u).map((part) => (/^\d+$/u.test(part) ? Number(part) : part));
118
+ for (let index = 0; index < Math.max(leftParts.length, rightParts.length); index += 1) {
119
+ const leftPart = leftParts[index] ?? 0;
120
+ const rightPart = rightParts[index] ?? 0;
121
+ if (leftPart === rightPart) continue;
122
+ if (typeof leftPart === "number" && typeof rightPart === "number") return rightPart - leftPart;
123
+ return String(rightPart).localeCompare(String(leftPart));
124
+ }
125
+ return 0;
126
+ }
127
+
128
+ /** `<home>/dsh-windows-mcp.versions.json` */
129
+ export function versionsStatePath(home = dshHome()) {
130
+ return join(home, "dsh-windows-mcp.versions.json");
131
+ }
132
+
133
+ /** A fresh, empty versions state. */
134
+ export function emptyVersionsState() {
135
+ return { version: STATE_VERSION, active: null, previous: null, history: [], lastCheck: null };
136
+ }
137
+
138
+ /** Normalise anything read from disk into a usable state object. */
139
+ function normalizeVersionsState(parsed) {
140
+ const state = emptyVersionsState();
141
+ if (parsed === null || typeof parsed !== "object") return state;
142
+ const version = (value) => (typeof value === "string" && VERSION_PATTERN.test(value) ? value : null);
143
+ state.active = version(parsed.active);
144
+ state.previous = version(parsed.previous);
145
+ if (Array.isArray(parsed.history)) {
146
+ state.history = parsed.history
147
+ .filter((entry) => entry !== null && typeof entry === "object" && version(entry.version) !== null)
148
+ .slice(-20)
149
+ .map((entry) => ({ version: version(entry.version), activatedAt: typeof entry.activatedAt === "string" ? entry.activatedAt : null }));
150
+ }
151
+ if (parsed.lastCheck !== null && typeof parsed.lastCheck === "object" && typeof parsed.lastCheck.at === "string") {
152
+ state.lastCheck = {
153
+ at: parsed.lastCheck.at,
154
+ latest: version(parsed.lastCheck.latest),
155
+ best: typeof parsed.lastCheck.best === "string" ? parsed.lastCheck.best : null,
156
+ mirrors: Array.isArray(parsed.lastCheck.mirrors) ? parsed.lastCheck.mirrors : [],
157
+ };
158
+ }
159
+ return state;
160
+ }
161
+
162
+ /** Read the versions state, tolerating a missing or damaged file. */
163
+ export function readVersionsState(home = dshHome()) {
164
+ try {
165
+ return normalizeVersionsState(JSON.parse(readFileSync(versionsStatePath(home), "utf8")));
166
+ } catch {
167
+ return emptyVersionsState();
168
+ }
169
+ }
170
+
171
+ /** Write the versions state atomically (write-then-rename). */
172
+ export function writeVersionsState(state, home = dshHome()) {
173
+ const target = versionsStatePath(home);
174
+ const temporary = `${target}.tmp`;
175
+ const payload = { ...emptyVersionsState(), ...state, version: STATE_VERSION, history: state.history?.slice(-20) ?? [] };
176
+ writeFileSync(temporary, `${JSON.stringify(payload, null, 2)}\n`);
177
+ renameSync(temporary, target);
178
+ return payload;
179
+ }
180
+
181
+ /** Record `version` as active and the previous active as its rollback target. */
182
+ export function activateVersion(state, version) {
183
+ if (typeof version !== "string" || !VERSION_PATTERN.test(version)) throw new TypeError(`invalid Windows-MCP version: ${String(version)}`);
184
+ const previous = state.active !== null && state.active !== version ? state.active : null;
185
+ const history = [...(state.history ?? []), { version, activatedAt: new Date().toISOString() }].slice(-20);
186
+ return { ...state, active: version, previous, history };
187
+ }
188
+
189
+ /** Where the bridge should point: the active managed version's executable. */
190
+ export function activeInstall(state, home = dshHome()) {
191
+ const version = state?.active ?? null;
192
+ if (version === null) return null;
193
+ const executable = managedExecutable(managedToolchainRoot(home), version);
194
+ if (executable === null) return null;
195
+ return { version, executable, toolDirectory: toolDirectoryFor(managedToolchainRoot(home), version) };
196
+ }