fix(create-webclaw): repair binary install on Windows (and all platforms)

`npx create-webclaw` never used the prebuilt binary on any platform and
silently fell back to `cargo install`, which fails with "'cargo' is not
recognized" / "cargo: not found" unless Rust is installed. Four bugs:

1. Asset name mismatch: getAssetName() hardcoded `webclaw-mcp-<target>`,
   but release assets are `webclaw-<tag>-<target>` (versioned, no `mcp-`
   infix). The `find()` always returned undefined, so the prebuilt path
   was never taken — on every OS, not just Windows. Now the asset name is
   built from the release tag_name + a platform→target map.

2. `unzip` is absent on Windows. The `.zip` branch now uses PowerShell
   `Expand-Archive` (ships with Windows 10/11) and keeps `unzip` only for
   the non-Windows case.

3. The prebuilt failure was swallowed by a bare `catch {}`, hiding the
   real cause (a 403 is almost always a GitHub API rate limit). The error
   is now surfaced, with a rate-limit hint + GITHUB_TOKEN support on the
   api.github.com request (token dropped on CDN redirects).

4. (missed by the report's own suggested fix) Archives extract into a
   `webclaw-<tag>-<target>/` subdirectory holding three binaries, so the
   old `chmod(BINARY_PATH)` hit a nonexistent path. webclaw-mcp is now
   lifted out of that subdir to BINARY_PATH and the rest is cleaned up.
   BINARY_NAME/BINARY_PATH also gain the `.exe` suffix on Windows so the
   written MCP config points at a real file.

Tested in Docker (no Windows machine available):
- Linux amd64 + arm64 on Debian trixie: full flow installs the binary and
  it answers a real MCP initialize handshake (serverInfo webclaw-mcp
  0.6.13, 12 tools).
- Windows .zip path validated against the real release zip: Expand-Archive
  equivalent extraction, nested `.exe` resolved + lifted, PE header `MZ`.
  Executing the .exe needs Windows (the reporter confirmed that on Win11).
- Bug 3: with the GitHub API blocked, the new build prints the real reason
  instead of "No pre-built binary found".

Closes #71

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Valerio 2026-06-27 11:58:14 +02:00
parent 1137419a09
commit 9af55c2a2d
2 changed files with 112 additions and 65 deletions

View file

@ -1,6 +1,13 @@
#!/usr/bin/env node
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "fs";
import {
existsSync,
mkdirSync,
readFileSync,
writeFileSync,
copyFileSync,
rmSync,
} from "fs";
import { createInterface } from "readline";
import { homedir, platform, arch } from "os";
import { join, dirname } from "path";
@ -13,10 +20,10 @@ import http from "http";
// ── Constants ──
const REPO = "0xMassi/webclaw";
const BINARY_NAME = "webclaw-mcp";
const IS_WINDOWS = platform() === "win32";
const BINARY_NAME = IS_WINDOWS ? "webclaw-mcp.exe" : "webclaw-mcp";
const INSTALL_DIR = join(homedir(), ".webclaw");
const BINARY_PATH = join(INSTALL_DIR, BINARY_NAME);
const VERSION = "latest";
const COLORS = {
reset: "\x1b[0m",
@ -166,12 +173,14 @@ function ask(question) {
});
}
function download(url) {
function download(url, extraHeaders = {}) {
return new Promise((resolve, reject) => {
const client = url.startsWith("https") ? https : http;
const headers = { "User-Agent": "create-webclaw", ...extraHeaders };
client
.get(url, { headers: { "User-Agent": "create-webclaw" } }, (res) => {
// Follow redirects
.get(url, { headers }, (res) => {
// Follow redirects, dropping extra headers so an Authorization token
// never leaks to the release CDN (its signed URLs reject it anyway).
if (
res.statusCode >= 300 &&
res.statusCode < 400 &&
@ -220,22 +229,19 @@ async function downloadFile(url, dest) {
});
}
function getAssetName() {
const os = platform();
const a = arch();
if (os === "darwin" && a === "arm64")
return `webclaw-mcp-aarch64-apple-darwin.tar.gz`;
if (os === "darwin" && a === "x64")
return `webclaw-mcp-x86_64-apple-darwin.tar.gz`;
if (os === "linux" && a === "x64")
return `webclaw-mcp-x86_64-unknown-linux-gnu.tar.gz`;
if (os === "linux" && a === "arm64")
return `webclaw-mcp-aarch64-unknown-linux-gnu.tar.gz`;
if (os === "win32" && a === "x64")
return `webclaw-mcp-x86_64-pc-windows-msvc.zip`;
return null;
// Map the current platform to its Rust release target triple. Release assets
// are named `webclaw-<tag>-<target>.<ext>` (e.g.
// webclaw-v0.6.13-x86_64-unknown-linux-gnu.tar.gz), so the asset name is built
// from the release's tag_name at fetch time — it can't be hardcoded here.
function getTarget() {
const targets = {
"darwin-arm64": "aarch64-apple-darwin",
"darwin-x64": "x86_64-apple-darwin",
"linux-x64": "x86_64-unknown-linux-gnu",
"linux-arm64": "aarch64-unknown-linux-gnu",
"win32-x64": "x86_64-pc-windows-msvc",
};
return targets[`${platform()}-${arch()}`] || null;
}
function readJsonFile(path) {
@ -439,8 +445,8 @@ async function main() {
// 3. Download binary
console.log(c("bold", " Downloading webclaw-mcp..."));
const assetName = getAssetName();
if (!assetName) {
const target = getTarget();
if (!target) {
console.log(c("red", ` Unsupported platform: ${platform()}-${arch()}`));
console.log(
c(
@ -456,62 +462,103 @@ async function main() {
}
let downloaded = false;
let prebuiltError = null;
try {
// Get latest release URL
const releaseData = await download(
`https://api.github.com/repos/${REPO}/releases/latest`,
// Resolve the latest release. Its tag_name drives the asset name. An
// unauthenticated GitHub API call is rate-limited to 60/hour per IP, so
// honour GITHUB_TOKEN when set — but only on this api.github.com request,
// never on the asset download (which redirects to a CDN).
const apiHeaders = process.env.GITHUB_TOKEN
? { Authorization: `Bearer ${process.env.GITHUB_TOKEN}` }
: {};
const release = JSON.parse(
(
await download(
`https://api.github.com/repos/${REPO}/releases/latest`,
apiHeaders,
)
).toString(),
);
const release = JSON.parse(releaseData.toString());
const version = release.tag_name; // e.g. "v0.6.13"
const ext = IS_WINDOWS ? "zip" : "tar.gz";
const assetName = `webclaw-${version}-${target}.${ext}`;
const asset = release.assets?.find((a) => a.name === assetName);
if (asset) {
const tarPath = join(INSTALL_DIR, assetName);
await downloadFile(asset.browser_download_url, tarPath);
// Extract
if (assetName.endsWith(".tar.gz")) {
execSync(`tar xzf "${tarPath}" -C "${INSTALL_DIR}"`, {
stdio: "ignore",
});
} else if (assetName.endsWith(".zip")) {
execSync(`unzip -o "${tarPath}" -d "${INSTALL_DIR}"`, {
stdio: "ignore",
});
}
// Make executable
await chmod(BINARY_PATH, 0o755);
// Cleanup archive
try {
execSync(`rm "${tarPath}"`, { stdio: "ignore" });
} catch {}
console.log(c("green", ` ✓ Installed to ${BINARY_PATH}`));
downloaded = true;
if (!asset) {
throw new Error(`asset ${assetName} not found in release ${version}`);
}
const archivePath = join(INSTALL_DIR, assetName);
await downloadFile(asset.browser_download_url, archivePath);
// Each archive holds a top-level `webclaw-<version>-<target>/` directory
// containing webclaw, webclaw-mcp, webclaw-server, and docs.
if (ext === "tar.gz") {
execSync(`tar xzf "${archivePath}" -C "${INSTALL_DIR}"`, {
stdio: "ignore",
});
} else if (IS_WINDOWS) {
// Windows ships no `unzip`; Expand-Archive comes with PowerShell 5+.
execSync(
`powershell -NoProfile -Command "Expand-Archive -Path '${archivePath}' -DestinationPath '${INSTALL_DIR}' -Force"`,
{ stdio: "ignore" },
);
} else {
execSync(`unzip -o "${archivePath}" -d "${INSTALL_DIR}"`, {
stdio: "ignore",
});
}
// Lift webclaw-mcp out of the extracted directory to BINARY_PATH, then
// drop the rest (the other two binaries + docs).
const extractedDir = join(INSTALL_DIR, `webclaw-${version}-${target}`);
const extractedBin = join(extractedDir, BINARY_NAME);
if (!existsSync(extractedBin)) {
throw new Error(`binary missing after extract: ${extractedBin}`);
}
copyFileSync(extractedBin, BINARY_PATH);
if (!IS_WINDOWS) await chmod(BINARY_PATH, 0o755);
try {
rmSync(extractedDir, { recursive: true, force: true });
rmSync(archivePath, { force: true });
} catch {}
console.log(c("green", ` ✓ Installed to ${BINARY_PATH}`));
downloaded = true;
} catch (e) {
// Release not available yet — expected before first release
prebuiltError = e;
}
if (!downloaded) {
// Try cargo install as fallback
console.log(
c("yellow", " No pre-built binary found. Trying cargo install..."),
);
// Surface why the prebuilt path failed instead of hiding it — a 403 here
// is almost always a GitHub API rate limit, which Rust can't fix.
if (prebuiltError) {
const m = prebuiltError.message || String(prebuiltError);
if (m.includes("403") || /rate limit/i.test(m)) {
console.log(
c(
"yellow",
" GitHub API rate limit hit. Retry in a few minutes, or set GITHUB_TOKEN.",
),
);
} else {
console.log(c("yellow", ` Prebuilt binary unavailable (${m}).`));
}
}
// Fall back to building from source.
console.log(c("yellow", " Trying cargo install..."));
try {
execSync(
`cargo install --git https://github.com/${REPO} webclaw-mcp --root "${INSTALL_DIR}"`,
{ stdio: "inherit" },
);
// cargo install puts binary in INSTALL_DIR/bin/
// cargo install puts the binary in INSTALL_DIR/bin/
const cargoPath = join(INSTALL_DIR, "bin", BINARY_NAME);
if (existsSync(cargoPath)) {
// Move to expected location
execSync(`mv "${cargoPath}" "${BINARY_PATH}"`, {
stdio: "ignore",
});
copyFileSync(cargoPath, BINARY_PATH);
console.log(c("green", ` ✓ Built and installed to ${BINARY_PATH}`));
downloaded = true;
}

View file

@ -1,6 +1,6 @@
{
"name": "create-webclaw",
"version": "0.1.4",
"version": "0.1.5",
"mcpName": "io.github.0xMassi/webclaw",
"description": "Set up webclaw MCP server for AI agents (Claude, Cursor, Windsurf, OpenCode, Codex, Antigravity)",
"bin": {