From c5cfe1a5fd9c65f2e1f6bf569af8a3ebcd542424 Mon Sep 17 00:00:00 2001 From: Polarj Sapkota Date: Sat, 14 Mar 2026 14:51:21 +0545 Subject: [PATCH] =?UTF-8?q?feat:=20Windows=20support=20=E2=80=94=20Playwri?= =?UTF-8?q?ght=20compat=20via=20Node=20polyfill=20layer?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bun on Windows cannot launch Playwright browsers (both IPC pipe and WebSocket transports fail). This adds automatic Windows detection that runs the browse server via Node+tsx instead, with a polyfill layer for Bun-specific APIs (Bun.serve, Bun.write, Bun.file, Bun.spawn, etc.). Changes: - Add bun-polyfill-win.ts: Node-compatible Bun API polyfills - Add server-node.ts: Node entry point that loads polyfills + server - cli.ts: detect Windows paths, spawn server via tsx on win32 - server.ts: import.meta.dir fallback for Node compatibility - cookie-import-browser.ts: conditional bun:sqlite import - setup: cross-platform (auto-symlinks from any location, Defender guidance, Node prerequisite check on Windows) - package.json: add tsx dep, fix build script glob on Windows - WINDOWS.md: setup guide and architecture docs Tested end-to-end on Windows 11 with Bun 1.2.18 + Node 22. Co-Authored-By: Claude Opus 4.6 (1M context) --- WINDOWS.md | 73 +++++++++++++++ browse/src/bun-polyfill-win.ts | 132 ++++++++++++++++++++++++++++ browse/src/cli.ts | 13 ++- browse/src/cookie-import-browser.ts | 9 +- browse/src/server-node.ts | 15 ++++ browse/src/server.ts | 2 +- package.json | 5 +- setup | 105 +++++++++++++++++----- 8 files changed, 328 insertions(+), 26 deletions(-) create mode 100644 WINDOWS.md create mode 100644 browse/src/bun-polyfill-win.ts create mode 100644 browse/src/server-node.ts diff --git a/WINDOWS.md b/WINDOWS.md new file mode 100644 index 0000000..258f2ab --- /dev/null +++ b/WINDOWS.md @@ -0,0 +1,73 @@ +# gstack on Windows + +gstack was built for macOS but works on Windows with automatic compatibility +handling. This document covers what's different and any limitations. + +## Prerequisites + +- **Bun** (>=1.0.0) — builds the CLI binary +- **Node.js** (>=18) — runs the browse server (Bun's Playwright support is broken on Windows) +- **Git Bash** or equivalent (MSYS2, WSL) — for the setup script + +## Setup + +```bash +git clone ~/.claude/skills/gstack +cd ~/.claude/skills/gstack +./setup +``` + +If the repo lives elsewhere (not inside `~/.claude/skills/`), setup will +automatically create a symlink from `~/.claude/skills/gstack` to your repo +and link all individual skills. + +### Windows Defender + +Playwright's Chromium may be blocked by Windows Defender on first run. +Add an exclusion for: + +``` +%LOCALAPPDATA%\ms-playwright +``` + +(Windows Security > Virus & threat protection > Manage settings > Exclusions > Add folder) + +## How it works + +Bun on Windows cannot launch Playwright browsers — both IPC pipe and WebSocket +transports fail. gstack works around this automatically: + +1. The **CLI binary** (`browse.exe`) is compiled with Bun as normal +2. When starting the browse server, the CLI detects Windows and spawns the + server via **Node + tsx** instead of Bun +3. A polyfill layer (`bun-polyfill-win.ts`) provides Node-compatible + implementations of `Bun.serve`, `Bun.write`, `Bun.file`, etc. +4. Playwright runs under Node where its transports work correctly + +This is transparent — you use gstack exactly the same way as on macOS. + +## Limitations + +- **`cookie-import-browser`** — importing cookies from installed browsers + (Chrome, Edge, etc.) is not supported. This feature requires `bun:sqlite` + which is unavailable under Node. Use `cookie-import ` instead. +- **Test suite** — browser integration tests (`commands.test.ts`) fail under + Bun on Windows for the same Playwright reason. Non-browser tests pass. + +## Files added for Windows support + +``` +browse/src/bun-polyfill-win.ts # Bun API polyfills for Node +browse/src/server-node.ts # Node entry point (loads polyfills + server) +WINDOWS.md # This file +``` + +## Files modified for Windows support + +``` +browse/src/cli.ts # Windows path detection + Node server spawn +browse/src/server.ts # import.meta.dir fallback for Node +browse/src/cookie-import-browser.ts # Conditional bun:sqlite import +package.json # tsx dependency, build script fix +setup # Cross-platform setup (symlinks, Defender guidance) +``` diff --git a/browse/src/bun-polyfill-win.ts b/browse/src/bun-polyfill-win.ts new file mode 100644 index 0000000..ec77a1b --- /dev/null +++ b/browse/src/bun-polyfill-win.ts @@ -0,0 +1,132 @@ +/** + * Bun API polyfills for running the browse server under Node/tsx on Windows. + * + * Bun's IPC pipe and WebSocket transports are broken on Windows, so the server + * must run under Node for Playwright to work. This file polyfills the Bun globals + * that the server uses: Bun.serve, Bun.write, Bun.file, Bun.sleep, Bun.spawn, + * Bun.spawnSync. + * + * Usage: import this file before anything else in the server entry point. + */ + +import * as http from 'http'; +import * as fs from 'fs'; +import * as childProcess from 'child_process'; + +// Only polyfill if Bun globals are missing (i.e., running under Node) +if (typeof globalThis.Bun === 'undefined') { + const Bun: any = {}; + + // Bun.serve — minimal HTTP server compatible with the browse server's usage + Bun.serve = (options: { + port: number; + hostname?: string; + fetch: (req: Request) => Promise | Response; + }) => { + const server = http.createServer(async (req, res) => { + try { + // Build a Web API Request from Node's IncomingMessage + const url = `http://${options.hostname || '127.0.0.1'}:${options.port}${req.url}`; + const headers = new Headers(); + for (const [key, val] of Object.entries(req.headers)) { + if (val) headers.set(key, Array.isArray(val) ? val.join(', ') : val); + } + + let body: string | null = null; + if (req.method !== 'GET' && req.method !== 'HEAD') { + body = await new Promise((resolve, reject) => { + const chunks: Buffer[] = []; + req.on('data', (c: Buffer) => chunks.push(c)); + req.on('end', () => resolve(Buffer.concat(chunks).toString())); + req.on('error', reject); + }); + } + + const webReq = new Request(url, { + method: req.method, + headers, + body, + }); + + const webRes = await options.fetch(webReq); + const resBody = await webRes.text(); + + res.writeHead(webRes.status, Object.fromEntries(webRes.headers.entries())); + res.end(resBody); + } catch (err: any) { + res.writeHead(500); + res.end(err.message); + } + }); + + server.listen(options.port, options.hostname || '127.0.0.1'); + + return { + port: options.port, + stop: () => { server.close(); }, + hostname: options.hostname || '127.0.0.1', + _nodeServer: server, + }; + }; + + // Bun.write — write string/buffer to a file path + Bun.write = async (path: string, content: string | Buffer) => { + fs.writeFileSync(path, content); + }; + + // Bun.file — returns an object with .text() method + Bun.file = (path: string) => ({ + text: async () => fs.readFileSync(path, 'utf-8'), + }); + + // Bun.sleep — returns a promise that resolves after ms + Bun.sleep = (ms: number) => new Promise(resolve => setTimeout(resolve, ms)); + + // Bun.spawn — async child process + Bun.spawn = (cmd: string[], options: any = {}) => { + const proc = childProcess.spawn(cmd[0], cmd.slice(1), { + stdio: options.stdio || 'pipe', + env: options.env, + detached: options.detached, + }); + return { + pid: proc.pid, + stdin: proc.stdin, + stdout: proc.stdout, + stderr: proc.stderr, + unref: () => proc.unref(), + kill: (sig?: string) => proc.kill(sig as any), + exited: new Promise((resolve) => { + proc.on('exit', (code) => resolve(code ?? 1)); + }), + }; + }; + + // Bun.spawnSync — synchronous child process + Bun.spawnSync = (cmd: string[], options: any = {}) => { + const result = childProcess.spawnSync(cmd[0], cmd.slice(1), { + stdio: options.stdio || 'pipe', + env: options.env, + timeout: options.timeout, + }); + return { + stdout: result.stdout || Buffer.from(''), + stderr: result.stderr || Buffer.from(''), + exitCode: result.status, + success: result.status === 0, + }; + }; + + // Bun.stdin — for reading from stdin + Bun.stdin = { + text: async () => { + return new Promise((resolve) => { + const chunks: Buffer[] = []; + process.stdin.on('data', (c: Buffer) => chunks.push(c)); + process.stdin.on('end', () => resolve(Buffer.concat(chunks).toString())); + }); + }, + }; + + globalThis.Bun = Bun; +} diff --git a/browse/src/cli.ts b/browse/src/cli.ts index f8b7902..bcd1c1e 100644 --- a/browse/src/cli.ts +++ b/browse/src/cli.ts @@ -26,7 +26,8 @@ export function resolveServerScript( } // Dev mode: cli.ts runs directly from browse/src - if (metaDir.startsWith('/') && !metaDir.includes('$bunfs')) { + const isRealPath = !metaDir.includes('$bunfs') && (metaDir.startsWith('/') || /^[A-Za-z]:/.test(metaDir)); + if (isRealPath) { const direct = path.resolve(metaDir, 'server.ts'); if (fs.existsSync(direct)) { return direct; @@ -140,7 +141,15 @@ async function startServer(): Promise { try { fs.unlinkSync(config.stateFile); } catch {} // Start server as detached background process - const proc = Bun.spawn(['bun', 'run', SERVER_SCRIPT], { + // On Windows, Bun's IPC pipes break Playwright — use Node+tsx instead + const isWindows = process.platform === 'win32'; + const serverScript = isWindows + ? path.resolve(path.dirname(SERVER_SCRIPT), 'server-node.ts') + : SERVER_SCRIPT; + const serverCmd = isWindows + ? ['npx', 'tsx', serverScript] + : ['bun', 'run', SERVER_SCRIPT]; + const proc = Bun.spawn(serverCmd, { stdio: ['ignore', 'pipe', 'pipe'], env: { ...process.env, BROWSE_STATE_FILE: config.stateFile }, }); diff --git a/browse/src/cookie-import-browser.ts b/browse/src/cookie-import-browser.ts index 29d9db3..f710eeb 100644 --- a/browse/src/cookie-import-browser.ts +++ b/browse/src/cookie-import-browser.ts @@ -32,7 +32,14 @@ * └──────────────────────────────────────────────────────────────────┘ */ -import { Database } from 'bun:sqlite'; +// Dynamic import — bun:sqlite is unavailable when running under Node/tsx on Windows +let Database: any; +try { + Database = require('bun:sqlite').Database; +} catch { + // Running under Node — cookie-import-browser commands won't work, but server can start + Database = null; +} import * as crypto from 'crypto'; import * as fs from 'fs'; import * as path from 'path'; diff --git a/browse/src/server-node.ts b/browse/src/server-node.ts new file mode 100644 index 0000000..f790e81 --- /dev/null +++ b/browse/src/server-node.ts @@ -0,0 +1,15 @@ +/** + * Node-compatible server entry point for Windows. + * Loads Bun polyfills, then runs the regular server. + */ + +// Must be imported before anything else to polyfill Bun globals +import './bun-polyfill-win'; + +// Polyfill import.meta.dir (used by server.ts for state file path) +if (!(import.meta as any).dir) { + (import.meta as any).dir = import.meta.dirname || __dirname; +} + +// Now load the actual server +import './server'; diff --git a/browse/src/server.ts b/browse/src/server.ts index 580bd67..052e8ce 100644 --- a/browse/src/server.ts +++ b/browse/src/server.ts @@ -324,7 +324,7 @@ async function start() { port, token: AUTH_TOKEN, startedAt: new Date().toISOString(), - serverPath: path.resolve(import.meta.dir, 'server.ts'), + serverPath: path.resolve(import.meta.dir ?? import.meta.dirname ?? path.dirname(new URL(import.meta.url).pathname), 'server.ts'), binaryVersion: readVersionHash() || undefined, }; const tmpFile = config.stateFile + '.tmp'; diff --git a/package.json b/package.json index 97614d2..f186528 100644 --- a/package.json +++ b/package.json @@ -8,7 +8,7 @@ "browse": "./browse/dist/browse" }, "scripts": { - "build": "bun run gen:skill-docs && bun build --compile browse/src/cli.ts --outfile browse/dist/browse && bun build --compile browse/src/find-browse.ts --outfile browse/dist/find-browse && git rev-parse HEAD > browse/dist/.version && rm -f .*.bun-build", + "build": "bun run gen:skill-docs && bun build --compile browse/src/cli.ts --outfile browse/dist/browse && bun build --compile browse/src/find-browse.ts --outfile browse/dist/find-browse && git rev-parse HEAD > browse/dist/.version", "gen:skill-docs": "bun run scripts/gen-skill-docs.ts", "dev": "bun run browse/src/cli.ts", "server": "bun run browse/src/server.ts", @@ -39,6 +39,7 @@ ], "devDependencies": { "@anthropic-ai/claude-agent-sdk": "^0.2.75", - "@anthropic-ai/sdk": "^0.78.0" + "@anthropic-ai/sdk": "^0.78.0", + "tsx": "^4.21.0" } } diff --git a/setup b/setup index 1f1ad09..2df87c9 100755 --- a/setup +++ b/setup @@ -1,21 +1,46 @@ #!/usr/bin/env bash # gstack setup — build browser binary + register all skills with Claude Code +# Works on macOS, Linux, and Windows (Git Bash / MSYS2 / WSL) set -e GSTACK_DIR="$(cd "$(dirname "$0")" && pwd)" SKILLS_DIR="$(dirname "$GSTACK_DIR")" BROWSE_BIN="$GSTACK_DIR/browse/dist/browse" +# Detect platform +IS_WINDOWS=0 +if [[ "$OSTYPE" == "msys" || "$OSTYPE" == "cygwin" || "$OSTYPE" == "win32" ]] || [[ "$(uname -s)" == MINGW* ]] || [[ "$(uname -s)" == MSYS* ]]; then + IS_WINDOWS=1 + BROWSE_BIN="$GSTACK_DIR/browse/dist/browse.exe" +fi + +# On Windows, Bun's IPC pipes break Playwright — verify Node+tsx are available +if [ "$IS_WINDOWS" -eq 1 ]; then + if ! command -v node >/dev/null 2>&1; then + echo "gstack setup failed: Node.js is required on Windows (Bun's Playwright support is broken on Windows)" >&2 + echo "Install Node.js from https://nodejs.org/" >&2 + exit 1 + fi +fi + ensure_playwright_browser() { - ( - cd "$GSTACK_DIR" - bun --eval 'import { chromium } from "playwright"; const browser = await chromium.launch(); await browser.close();' - ) >/dev/null 2>&1 + if [ "$IS_WINDOWS" -eq 1 ]; then + # On Windows, test with Node since Bun can't launch Playwright + ( + cd "$GSTACK_DIR" + node -e "const { chromium } = require('playwright'); (async () => { const b = await chromium.launch(); await b.close(); })()" + ) >/dev/null 2>&1 + else + ( + cd "$GSTACK_DIR" + bun --eval 'import { chromium } from "playwright"; const browser = await chromium.launch(); await browser.close();' + ) >/dev/null 2>&1 + fi } # 1. Build browse binary if needed (smart rebuild: stale sources, package.json, lock) NEEDS_BUILD=0 -if [ ! -x "$BROWSE_BIN" ]; then +if [ ! -f "$BROWSE_BIN" ]; then NEEDS_BUILD=1 elif [ -n "$(find "$GSTACK_DIR/browse/src" -type f -newer "$BROWSE_BIN" -print -quit 2>/dev/null)" ]; then NEEDS_BUILD=1 @@ -38,7 +63,7 @@ if [ "$NEEDS_BUILD" -eq 1 ]; then fi fi -if [ ! -x "$BROWSE_BIN" ]; then +if [ ! -f "$BROWSE_BIN" ]; then echo "gstack setup failed: browse binary missing at $BROWSE_BIN" >&2 exit 1 fi @@ -50,23 +75,40 @@ if ! ensure_playwright_browser; then cd "$GSTACK_DIR" bunx playwright install chromium ) -fi -if ! ensure_playwright_browser; then - echo "gstack setup failed: Playwright Chromium could not be launched" >&2 - exit 1 + # On Windows, remind about Defender exclusions if launch still fails + if ! ensure_playwright_browser; then + if [ "$IS_WINDOWS" -eq 1 ]; then + echo "" + echo "Playwright Chromium installed but failed to launch." + echo "On Windows, you may need to add a Windows Defender exclusion:" + echo " Windows Security > Virus & threat protection > Exclusions" + echo " Add folder: $(cygpath -w "$HOME/AppData/Local/ms-playwright" 2>/dev/null || echo '%LOCALAPPDATA%\ms-playwright')" + echo "" + echo "After adding the exclusion, re-run: ./setup" + exit 1 + else + echo "gstack setup failed: Playwright Chromium could not be launched" >&2 + exit 1 + fi + fi fi -# 3. Only create skill symlinks if we're inside a .claude/skills directory +# 3. Create skill symlinks +# If we're inside .claude/skills, link individual skills +# Otherwise, create symlink from ~/.claude/skills/gstack → this repo SKILLS_BASENAME="$(basename "$SKILLS_DIR")" -if [ "$SKILLS_BASENAME" = "skills" ]; then - linked=() +CLAUDE_SKILLS_DIR="$HOME/.claude/skills" + +link_skills() { + local base_dir="$1" + local linked=() for skill_dir in "$GSTACK_DIR"/*/; do if [ -f "$skill_dir/SKILL.md" ]; then skill_name="$(basename "$skill_dir")" # Skip node_modules [ "$skill_name" = "node_modules" ] && continue - target="$SKILLS_DIR/$skill_name" + target="$base_dir/$skill_name" # Create or update symlink; skip if a real file/directory exists if [ -L "$target" ] || [ ! -e "$target" ]; then ln -snf "gstack/$skill_name" "$target" @@ -74,14 +116,37 @@ if [ "$SKILLS_BASENAME" = "skills" ]; then fi fi done - - echo "gstack ready." - echo " browse: $BROWSE_BIN" if [ ${#linked[@]} -gt 0 ]; then echo " linked skills: ${linked[*]}" fi +} + +if [ "$SKILLS_BASENAME" = "skills" ]; then + # Already inside ~/.claude/skills/gstack — just link individual skills + link_skills "$SKILLS_DIR" else - echo "gstack ready." - echo " browse: $BROWSE_BIN" - echo " (skipped skill symlinks — not inside .claude/skills/)" + # Repo is elsewhere — create gstack symlink + individual skill links + mkdir -p "$CLAUDE_SKILLS_DIR" + + # Symlink gstack repo into skills dir + gstack_link="$CLAUDE_SKILLS_DIR/gstack" + if [ -L "$gstack_link" ] || [ ! -e "$gstack_link" ]; then + # Convert to Windows-compatible path if needed + if [ "$IS_WINDOWS" -eq 1 ]; then + ln -snf "$(cygpath -w "$GSTACK_DIR" 2>/dev/null || echo "$GSTACK_DIR")" "$gstack_link" + else + ln -snf "$GSTACK_DIR" "$gstack_link" + fi + echo " symlinked: $gstack_link → $GSTACK_DIR" + fi + + # Link individual skills + link_skills "$CLAUDE_SKILLS_DIR" +fi + +echo "" +echo "gstack ready." +echo " browse: $BROWSE_BIN" +if [ "$IS_WINDOWS" -eq 1 ]; then + echo " platform: Windows (server runs via Node+tsx for Playwright compatibility)" fi