From 0ac3cd02b0be16bab45c661bd230c385211b8d5b Mon Sep 17 00:00:00 2001 From: CRT Date: Wed, 21 Oct 2026 22:21:49 +0800 Subject: [PATCH] Windows: MCP entries start through cmd /c npx or bun cli/index.ts (npx.cmd and the sh launcher cannot be spawned, pitfall 94); desktop download streams with progress and fails after 60 s without data (pitfall 95); getting-started Windows note --- cli/desktop.ts | 34 +++++++++++++++++++++++++++++++--- cli/install.ts | 14 +++++++++++++- docs/getting-started.md | 4 ++++ skills/store-pitfalls/SKILL.md | 18 ++++++++++++++++++ tests/desktop-dist.test.ts | 3 +++ tests/wave1.test.ts | 13 ++++++++----- 6 files changed, 77 insertions(+), 9 deletions(-) diff --git a/cli/desktop.ts b/cli/desktop.ts index 256ca56..8089388 100644 --- a/cli/desktop.ts +++ b/cli/desktop.ts @@ -58,10 +58,10 @@ export function pick(m: Manifest, keys: DownloadKey[], format?: string): [Downlo } /** Downloads one installer and keeps it only when its size, SHA-256 and minisign signature are right. */ -export async function download(d: Download, dir: string, get: Fetch = fetch, publicKey = PUBLIC_KEY): Promise { - const r = await get(d.url); +export async function download(d: Download, dir: string, get: Fetch = fetch, publicKey = PUBLIC_KEY, stallMs = 60_000, say: (s: string) => void = s => console.log(s)): Promise { + const r = await withStall(get(d.url), stallMs, d.url); if (!r.ok) throw new Error(`${d.url}: HTTP ${r.status}`); - const data = new Uint8Array(await r.arrayBuffer()); + const data = await readBody(r, d, stallMs, say); if (data.length !== d.size) throw new Error(`${d.file}: ${data.length} bytes, latest.json says ${d.size}; not kept`); const sha = createHash('sha256').update(data).digest('hex'); if (sha !== d.sha256) throw new Error(`${d.file}: SHA-256 ${sha} does not match latest.json; not kept`); @@ -75,6 +75,34 @@ export async function download(d: Download, dir: string, get: Fetch = fetch, pub return out; } +function withStall(p: Promise, ms: number, url: string): Promise { + let t: ReturnType | undefined; + const stall = new Promise((_, no) => { t = setTimeout(() => no(new Error(`${url}: no data for ${ms / 1000} s; the download stalled (try again; pitfall 95)`)), ms); }); + return Promise.race([p, stall]).finally(() => clearTimeout(t)); +} + +/** Reads the body in chunks: a progress line every 10 % and a stall error when no byte arrives for `stallMs` + * (a cold CDN cache once sent nothing for minutes and the CLI waited forever, pitfall 95). */ +async function readBody(r: Response, d: Download, stallMs: number, say: (s: string) => void): Promise { + if (!r.body) return new Uint8Array(await r.arrayBuffer()); + const reader = r.body.getReader(); + const parts: Uint8Array[] = []; + let n = 0, shown = 0; + try { + for (;;) { + const { done, value } = await withStall(reader.read(), stallMs, d.url); + if (done) break; + parts.push(value); n += value.length; + const pct = d.size ? Math.floor((n / d.size) * 10) * 10 : 0; + if (pct > shown && pct < 100) { shown = pct; say(` ${pct}% (${(n / 1e6).toFixed(1)} MB)`); } + } + } catch (e) { void reader.cancel().catch(() => {}); throw e; } + const out = new Uint8Array(n); + let o = 0; + for (const p of parts) { out.set(p, o); o += p.length; } + return out; +} + const VERSION_RE = /\b(\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?)\b/; /** The version of an installed app: Info.plist on macOS, `storebound-desktop --version` elsewhere (15 s at most). */ diff --git a/cli/install.ts b/cli/install.ts index 3ad841a..72c9e29 100644 --- a/cli/install.ts +++ b/cli/install.ts @@ -29,7 +29,19 @@ function snapshot(dir: string, base = dir, out = new Map()): Map } const same = (a: Map, b: Map) => a.size === b.size && [...a].every(([k, v]) => b.get(k) === v); -export function mcpCommand(project: string, override?: string, runtime: 'bun' | 'node' = rt.runtime.name, version = pkg.version): McpCommand { +/** Windows cannot start `npx` (a .cmd shim) or the /bin/sh launcher as a stdio command (pitfall 94). */ +export function winWrap(c: McpCommand, platform: string = process.platform): McpCommand { + if (platform !== 'win32') return c; + if (c.command === 'npx') return { command: 'cmd', args: ['/c', 'npx', ...c.args] }; + if (c.command === join(TOOLCHAIN, 'bin', 'storebound')) return { command: 'bun', args: [join(TOOLCHAIN, 'cli', 'index.ts'), ...c.args] }; + return c; +} + +export function mcpCommand(project: string, override?: string, runtime: 'bun' | 'node' = rt.runtime.name, version = pkg.version, platform: string = process.platform): McpCommand { + return winWrap(mcpCommand0(project, override, runtime, version), platform); +} + +function mcpCommand0(project: string, override?: string, runtime: 'bun' | 'node' = rt.runtime.name, version = pkg.version): McpCommand { if (override) { const [command, ...args] = override.split(/\s+/).filter(Boolean); return { command: command!, args: [...args, 'mcp'] }; } if (existsSync(join(project, 'node_modules', 'storebound', 'package.json'))) return { command: runtime === 'bun' ? 'bunx' : 'npx', args: ['storebound', 'mcp'] }; if (runtime === 'node') return { command: 'npx', args: ['-y', `storebound@${version}`, 'mcp'] }; diff --git a/docs/getting-started.md b/docs/getting-started.md index 5b6177d..02f2c56 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -152,6 +152,10 @@ plain Markdown and the CLI is a normal command. and `--command ""` to choose how the MCP server starts. It only adds or updates the `storebound` entry and its own skill folders; everything else in those files stays as it is, and re-running it is safe. +**Windows:** an MCP client cannot start `npx` directly (it is `npx.cmd`). `install` writes the working form for you; +by hand use `"command": "cmd", "args": ["/c", "npx", "-y", "storebound@", "mcp"]` (or `bunx storebound mcp` +with Bun). If the server shows "connection closed", check this first ([pitfall 94](../skills/store-pitfalls/SKILL.md)). + --- [All docs](README.md) ยท [README](../README.md) diff --git a/skills/store-pitfalls/SKILL.md b/skills/store-pitfalls/SKILL.md index f6131d3..68af0d1 100644 --- a/skills/store-pitfalls/SKILL.md +++ b/skills/store-pitfalls/SKILL.md @@ -1148,3 +1148,21 @@ that fails without the fix (see CONTRIBUTING.md). `cli/shots/lib.ts`) and exits 1 if any file failed to load while rendering; `shots check` and `validate` raise a `frame-missing` error for every render whose report entry has failed loads (`cli/shots/report.ts`, `tests/shots-missing-frames.test.ts`). + +## 94. The MCP server never starts on Windows (`npx` as the stdio command) +- **Symptom:** the agent shows the `storebound` MCP server as "failed" / "connection closed" on Windows; the same + entry works on macOS and Linux. An old entry pinned to an old version (`storebound@0.8.2`) also hides new tools. +- **Cause:** on Windows `npx` is `npx.cmd`, which an MCP client cannot spawn directly as a stdio command; the + `bin/storebound` launcher is a /bin/sh script and cannot be spawned either. +- **Do:** use `{"command": "cmd", "args": ["/c", "npx", "-y", "storebound@", "mcp"]}`, or re-run + `storebound install --agent `, which writes that form (or `bun /cli/index.ts mcp`) on Windows. Keep the version current. +- **Guard:** `winWrap` / `mcpCommand` in `cli/install.ts`, `tests/wave1.test.ts`. + +## 95. `storebound desktop` stayed on "downloading ..." forever +- **Symptom:** `npx storebound desktop` printed `downloading storebound__x64-setup-unsigned.exe (37.0 MB)` and + then nothing, for many minutes. +- **Cause:** the installer was not in the CDN cache yet (`cf-cache-status: MISS`) and the origin sent no bytes; the + CLI read the whole body at once, with no progress and no timeout. +- **Do:** run the command again later; after a release, warm the CDN cache by fetching every installer once. +- **Guard:** `download` in `cli/desktop.ts` reads in chunks, prints progress every 10 % and fails after 60 s without + data (`tests/desktop-dist.test.ts`). diff --git a/tests/desktop-dist.test.ts b/tests/desktop-dist.test.ts index ff2771d..53887b2 100644 --- a/tests/desktop-dist.test.ts +++ b/tests/desktop-dist.test.ts @@ -106,6 +106,9 @@ describe('latest.json and downloads', () => { await expect(download({ ...d, file: 'c.dmg', sha256: sha }, dir, serve(evil), k.pub)).rejects.toThrow(/signature/); await expect(download({ ...d, file: 'e.dmg' }, dir, serve(data.subarray(1)), k.pub)).rejects.toThrow(/bytes/); await expect(download({ ...d, file: 'f.dmg' }, dir, async () => new Response('', { status: 404 }), k.pub)).rejects.toThrow(/404/); + // a body that never sends a byte fails after the stall timeout instead of hanging forever (pitfall 95) + const silent = async () => new Response(new ReadableStream({ start() {} })); + await expect(download({ ...d, file: 'g.dmg' }, dir, silent, k.pub, 50, () => {})).rejects.toThrow(/stalled/); for (const f of ['b.dmg', 'c.dmg', 'e.dmg', 'f.dmg']) expect(existsSync(join(dir, f))).toBe(false); }); }); diff --git a/tests/wave1.test.ts b/tests/wave1.test.ts index 21faa86..894e88b 100644 --- a/tests/wave1.test.ts +++ b/tests/wave1.test.ts @@ -455,21 +455,24 @@ describe('install', () => { const vs = JSON.parse(readFileSync(join(dir, '.vscode/mcp.json'), 'utf8')); expect(Object.keys(vs.servers)).toEqual(['keep', 'storebound']); expect(readFileSync(join(dir, '.codex/config.toml'), 'utf8')).toContain('[mcp_servers.storebound]'); - expect(JSON.parse(readFileSync(join(home, '.config/devin/mcp_config.json'), 'utf8')).mcpServers.storebound.args).toEqual(['mcp']); + expect(JSON.parse(readFileSync(join(home, '.config/devin/mcp_config.json'), 'utf8')).mcpServers.storebound.args.at(-1)).toBe('mcp'); const before = readFileSync(join(dir, '.vscode/mcp.json'), 'utf8'); await install(parseArgs(['--agent', 'copilot', '--dir', dir, '--home', home])); expect(readFileSync(join(dir, '.vscode/mcp.json'), 'utf8')).toBe(before); }); test('mcp command choice', () => { const dir = tmp(); - expect(mcpCommand(dir).command).toMatch(/bin\/storebound$/); + expect(mcpCommand(dir, undefined, 'bun', undefined, 'linux').command).toMatch(/bin[\\/]storebound$/); + expect(mcpCommand(dir, undefined, 'bun', undefined, 'win32')).toEqual({ command: 'bun', args: [expect.stringMatching(/cli[\\/]index\.ts$/), 'mcp'] }); // run with Node (npx storebound install): the toolchain sits in npx's cache, so the pinned package instead - expect(mcpCommand(dir, undefined, 'node', '1.2.3')).toEqual({ command: 'npx', args: ['-y', 'storebound@1.2.3', 'mcp'] }); + expect(mcpCommand(dir, undefined, 'node', '1.2.3', 'linux')).toEqual({ command: 'npx', args: ['-y', 'storebound@1.2.3', 'mcp'] }); mkdirSync(join(dir, 'node_modules/storebound'), { recursive: true }); writeFileSync(join(dir, 'node_modules/storebound/package.json'), '{}'); expect(mcpCommand(dir)).toEqual({ command: 'bunx', args: ['storebound', 'mcp'] }); - expect(mcpCommand(dir, undefined, 'node')).toEqual({ command: 'npx', args: ['storebound', 'mcp'] }); - expect(mcpCommand(dir, 'npx -y storebound')).toEqual({ command: 'npx', args: ['-y', 'storebound', 'mcp'] }); + expect(mcpCommand(dir, undefined, 'node', undefined, 'linux')).toEqual({ command: 'npx', args: ['storebound', 'mcp'] }); + expect(mcpCommand(dir, 'npx -y storebound', undefined, undefined, 'linux')).toEqual({ command: 'npx', args: ['-y', 'storebound', 'mcp'] }); + expect(mcpCommand(dir, 'npx -y storebound', undefined, undefined, 'win32')).toEqual({ command: 'cmd', args: ['/c', 'npx', '-y', 'storebound', 'mcp'] }); + expect(mcpCommand(dir, undefined, 'bun', undefined, 'win32').command).not.toBe('cmd'); }); });