Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
73 changes: 73 additions & 0 deletions WINDOWS.md
Original file line number Diff line number Diff line change
@@ -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 <repo> ~/.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 <json-file>` 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)
```
132 changes: 132 additions & 0 deletions browse/src/bun-polyfill-win.ts
Original file line number Diff line number Diff line change
@@ -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> | 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<string>((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<number>((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<string>((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;
}
13 changes: 11 additions & 2 deletions browse/src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -140,7 +141,15 @@ async function startServer(): Promise<ServerState> {
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 },
});
Expand Down
9 changes: 8 additions & 1 deletion browse/src/cookie-import-browser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
15 changes: 15 additions & 0 deletions browse/src/server-node.ts
Original file line number Diff line number Diff line change
@@ -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';
2 changes: 1 addition & 1 deletion browse/src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
5 changes: 3 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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"
}
}
Loading