Files
software-mansion__argent/scripts/dev.cjs
Ignacy Łątka 7400179b5b Windows support: Android + Chromium host control plane (#421)
## What

Makes Argent run on Windows. iOS Simulator is macOS-only (Apple), so on
Windows that means the **Android + Chromium** host control plane. The
blocker was twofold: (1) no Windows `simulator-server` binary, and (2)
POSIX-only assumptions in the tool-server.

Companion radon PR builds the binary and runs the Android emulator E2E
on Windows: software-mansion/radon#134.

## Changes

**Windows-aware tool-server / resolver / bundling**
- New shared `commandOnPath()` helper — `where` on Windows, `command -v`
via `/bin/sh` on POSIX. `android-binary`, `check-deps`, and `vega-cli`
previously hardcoded `/bin/sh`, which never matches on Windows.
- `android-binary`: append `.exe` to `adb`/`emulator` in the
`$ANDROID_HOME` fallback; probe `%LOCALAPPDATA%\Android\Sdk` (Android
Studio's Windows default).
- simulator-server resolver + dispatcher + `bundle-tools` +
`download-simulator-server.sh`: ship and resolve `simulator-server.exe`
on `win32` (new `simulatorServerBinaryName()`), add `win32` to the
bundled host keys, add the `simulator-server-argent-windows.exe`
download target with PE arch validation.
- `stop-metro`: resolve listening PIDs via `netstat -ano` on Windows
(`lsof` is POSIX-only and threw ENOENT).
- `adb.ts`: AVD root resolution used `$HOME` + `startsWith("/")`
(rejects `C:\…`); now `os.homedir()` + `path.isAbsolute()`.
- **`boot-device`**: the emulator was spawned `detached` with
`stdio:"ignore"`; on Windows a detached emulator with NUL stdout/stderr
never reaches `sys.boot_completed` (verified on `windows-latest`: the
identical flag set boots fine with real handles, hangs with NUL).
Redirect the detached emulator's output to a temp log file on win32.
POSIX keeps `"ignore"`.

**Tests** — cross-platform unit coverage for `commandOnPath` (both
branches), win32 `.exe` Android resolution, win32 `.exe`
simulator-server resolution, and a win32 AVD drive-path case; dep-gate
tests now mock at the `commandOnPath` boundary so they're
platform-agnostic.

**CI** — `windows-e2e.yml` (`windows-chromium` on `windows-latest`):
runs the resolver/dep unit tests natively on Windows, confirms `adb.exe`
resolution via the SDK root, then drives a **real headless Chrome over
CDP** through the tool-server (discover → screenshot → describe → tap →
observe the DOM mutation). Flow in `scripts/ci/windows-chromium-e2e.mjs`
(also runnable locally / in Docker). The `simulator-server.exe` build +
the full **Android-emulator E2E on Windows** live in radon CI (the
binary's source is the private radon repo).

## Verification (all green)
- **Chromium control plane on real Windows** (`windows-latest`): `✓
discovered chromium-cdp-9222 → ✓ screenshot → ✓ describe → ✓ gesture-tap
→ ✓ DOM mutation observed`. Resolver/dep unit tests run natively;
`adb.exe` resolves via the SDK root (it's *not* on PATH on the runner —
the gap the resolver closes).
- **Android emulator on real Windows** (radon#134, `windows-latest` /
WHPX): the real Argent path — `boot-device` → `{ booted: true }` →
screenshot via `simulator-server.exe` → gesture-tap — all green. (The
boot-device fix above is what makes the detached emulator reach
`boot_completed`.)
- **Local clean-room E2E**: the Chromium flow runs green in a headless
Linux Docker container (fresh `npm ci`, no host pollution).
- **No regressions**: full tool-server suite (1624) +
native-devtools-ios (16); ESLint/Prettier/typecheck clean; **Wayland
E2E** (Linux AVD boot) and **Vega VVD E2E** still pass (they exercise
the changed `adb.ts` / `boot-device.ts` / `vega-cli.ts` paths).

## Notes
Android-emulator E2E needs WHPX nested virtualization — confirmed
available on the current hosted `windows-latest` image (Server 2025);
the emulator boots hardware-accelerated in ~3 min.

---------

Co-authored-by: Ignacy Łątka <claude-hubert.gancarczyk@swmansion.com>
Co-authored-by: Ignacy Łątka <latekvo@users.noreply.github.com>
2026-07-22 13:53:32 +02:00

385 lines
14 KiB
JavaScript

#!/usr/bin/env node
"use strict";
/**
* Full dev mode — no packing, no global install needed.
*
* What it does:
* 1. Builds native devtools dylibs (libArgentInjectionBootstrap, libNativeDevtoolsIos, libKeyboardPatch)
* 2. Builds the dispatcher TypeScript (tsc only, no esbuild bundles)
* 3. Sets up packages/argent/bin/ and packages/argent/dist/ for local use
* 4. Patches supported editor MCP configs to point argent at the local dist
* 5. Starts the tool-server from source via ts-node (no build needed)
* 6. Writes ~/.argent/tool-server.json so the local MCP picks it up
* 7. On exit: restores patched editor configs and stops the tool-server
*
* Usage:
* npm run dev
* PORT=4000 npm run dev
*/
const { execSync, spawn } = require("child_process");
const fs = require("fs");
const path = require("path");
const os = require("os");
const ROOT = path.resolve(__dirname, "..");
const ARGENT_PKG = path.join(ROOT, "packages", "argent");
const TOOL_SERVER_PKG = path.join(ROOT, "packages", "tool-server");
const NATIVE_DEVTOOLS_PKG = path.join(ROOT, "packages", "native-devtools-ios");
const STATE_DIR = path.join(os.homedir(), ".argent");
const STATE_FILE = path.join(STATE_DIR, "tool-server.json");
const CLAUDE_JSON = path.join(os.homedir(), ".claude.json");
const CURSOR_DIR = path.join(os.homedir(), ".cursor");
const CURSOR_MCP_JSON = path.join(CURSOR_DIR, "mcp.json");
const PORT = parseInt(process.env.PORT ?? "3001", 10);
// ── Helpers ───────────────────────────────────────────────────────────────────
function readJson(filePath) {
if (!fs.existsSync(filePath)) return {};
return JSON.parse(fs.readFileSync(filePath, "utf8"));
}
function writeJson(filePath, data) {
fs.mkdirSync(path.dirname(filePath), { recursive: true });
fs.writeFileSync(filePath, JSON.stringify(data, null, 2) + "\n");
}
function isProcessAlive(pid) {
try {
process.kill(pid, 0);
return true;
} catch {
return false;
}
}
async function waitForHttp(url, timeoutMs = 20_000) {
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
try {
const res = await fetch(url, { signal: AbortSignal.timeout(1000) });
if (res.ok) return true;
} catch {
/* not up yet — keep polling until the deadline */
}
await new Promise((r) => setTimeout(r, 400));
}
return false;
}
// ── Step 1: Build native devtools dylibs (macOS only) ───────────────────────
// Dylibs are macOS-only at runtime (DYLD_INSERT_LIBRARIES into iOS Simulator)
// and macOS-only at build time (Xcode). Skip on Linux/Windows so a contributor
// without `argent-private` SSH access can still run `npm run dev`.
if (process.platform === "darwin") {
const DYLIBS_DIR = path.join(NATIVE_DEVTOOLS_PKG, "dylibs");
const DYLIBS_EXIST = fs.existsSync(path.join(DYLIBS_DIR, "libNativeDevtoolsIos.dylib"));
const PRIVATE_NATIVE_DEVTOOLS_SRC = path.join(
ROOT,
"packages",
"argent-private",
"packages",
"native-devtools-ios",
"Sources",
"NativeDevtoolsIos"
);
// Try to init the submodule and rebuild. Failure is non-fatal if pre-built
// dylibs are already present — developers without argent-private access can
// still work on Argent using the committed binaries.
let submoduleReady = false;
try {
// Preserve an existing argent-private checkout so local branch switches
// are not reset back to the superproject's recorded gitlink on every dev run.
if (!fs.existsSync(PRIVATE_NATIVE_DEVTOOLS_SRC)) {
execSync("git submodule update --init packages/argent-private", {
cwd: ROOT,
stdio: "pipe",
});
}
submoduleReady = true;
} catch {
if (DYLIBS_EXIST) {
console.warn("⚠ argent-private submodule unavailable — using pre-built dylibs\n");
} else {
console.error("✗ argent-private submodule unavailable and no pre-built dylibs found.");
console.error(" Grant SSH access to github.com/software-mansion-labs/argent-private");
console.error(
" or obtain pre-built dylibs and place them in packages/native-devtools-ios/dylibs/"
);
process.exit(1);
}
}
if (submoduleReady) {
console.log("Building native devtools dylibs...");
execSync("bash scripts/build.sh dev", {
cwd: NATIVE_DEVTOOLS_PKG,
stdio: "inherit",
});
console.log("✓ Native devtools dylibs built\n");
}
} else {
console.log(`⊘ Skipping native devtools dylibs build (macOS-only on ${process.platform})\n`);
}
// ── Step 2: Build MCP TypeScript ─────────────────────────────────────────────
console.log("Building dispatcher TypeScript...");
execSync("npm run build:dispatcher -w @swmansion/argent", {
cwd: ROOT,
stdio: "inherit",
});
console.log("✓ Dispatcher TypeScript built\n");
// ── Step 3: Set up packages/argent/bin/ and skills/rules/agents ──────────────
// Copy the simulator-server binary for the current host platform into the
// platform-keyed subdir the runtime resolver looks at. Mirrors bundle-tools.cjs
// but only for the current host's key — dev iterations don't need every
// host's binary alongside. The key mirrors hostPlatformKey() in
// @argent/native-devtools-ios (process.platform, except "linux-arm64" on
// arm64 Linux).
const HOST_PLATFORM_KEY =
process.platform === "linux" && process.arch === "arm64" ? "linux-arm64" : process.platform;
const BIN_DIR = path.join(ARGENT_PKG, "bin", HOST_PLATFORM_KEY);
// win32 ships a PE `.exe` (simulator-server.exe); every other host an
// extensionless binary. Mirrors simulatorServerBinaryName() in
// @argent/native-devtools-ios and bundle-tools.cjs.
const BIN_BASENAME = process.platform === "win32" ? "simulator-server.exe" : "simulator-server";
const BIN_SRC = path.join(NATIVE_DEVTOOLS_PKG, "bin", HOST_PLATFORM_KEY, BIN_BASENAME);
const BIN_DEST = path.join(BIN_DIR, BIN_BASENAME);
fs.mkdirSync(BIN_DIR, { recursive: true });
if (fs.existsSync(BIN_SRC)) {
fs.copyFileSync(BIN_SRC, BIN_DEST);
fs.chmodSync(BIN_DEST, 0o755);
console.log(`✓ Copied simulator-server binary (${HOST_PLATFORM_KEY})`);
} else {
console.warn(
`⚠ simulator-server binary not found at ${BIN_SRC} — gestures won't work. ` +
`Run: bash scripts/download-simulator-server.sh`
);
}
for (const [srcName, destName] of [
["skills/skills", "skills"],
["skills/rules", "rules"],
["skills/agents", "agents"],
]) {
const src = path.join(ROOT, "packages", srcName);
const dest = path.join(ARGENT_PKG, destName);
if (fs.existsSync(src)) {
fs.rmSync(dest, { recursive: true, force: true });
fs.cpSync(src, dest, { recursive: true });
}
}
console.log("✓ Copied skills/rules/agents");
// Stub tool-server.cjs — the launcher won't use it as long as the dev server
// is registered in the state file, but it needs to exist to avoid a crash.
const STUB = path.join(ARGENT_PKG, "dist", "tool-server.cjs");
if (!fs.existsSync(STUB)) {
fs.mkdirSync(path.dirname(STUB), { recursive: true });
fs.writeFileSync(
STUB,
"throw new Error('dev mode: tool-server stub — start npm run dev first');\n"
);
}
function restoreMcpEntry(configPath, originalEntry, existedBefore) {
const config = readJson(configPath);
if (!config.mcpServers) config.mcpServers = {};
if (originalEntry) {
config.mcpServers.argent = originalEntry;
} else {
delete config.mcpServers.argent;
}
if (config.mcpServers && Object.keys(config.mcpServers).length === 0) {
delete config.mcpServers;
}
if (!existedBefore && Object.keys(config).length === 0) {
try {
fs.unlinkSync(configPath);
} catch {
/* file already absent — nothing to remove */
}
return;
}
writeJson(configPath, config);
}
// ── Step 4: Patch editor MCP configs to use local MCP dist ───────────────────
const LOCAL_MCP_ENTRY = path.join(ARGENT_PKG, "dist", "cli.js");
const LOG_FILE = path.join(STATE_DIR, "mcp-calls.log");
const claudeConfigExists = fs.existsSync(CLAUDE_JSON);
const claudeConfig = readJson(CLAUDE_JSON);
const originalArgentEntry = claudeConfig?.mcpServers?.argent ?? null;
const shouldPatchCursor = fs.existsSync(CURSOR_DIR);
const cursorConfigExists = fs.existsSync(CURSOR_MCP_JSON);
const cursorConfig = shouldPatchCursor ? readJson(CURSOR_MCP_JSON) : {};
const originalCursorArgentEntry = cursorConfig?.mcpServers?.argent ?? null;
const devMcpEntry = {
type: "stdio",
command: "node",
args: [LOCAL_MCP_ENTRY, "mcp"],
// Point the dev MCP straight at the running dev tool-server. Without this
// it calls ensureToolsServer(), which now treats the tokenless dev state
// as stale, SIGTERMs the dev server and tries to spawn the throwing dev
// stub — breaking `npm run dev`. The dev server runs auth-disabled, so no
// ARGENT_AUTH_TOKEN is needed here.
env: { ARGENT_MCP_LOG: LOG_FILE, ARGENT_TOOLS_URL: `http://127.0.0.1:${PORT}` },
};
if (!claudeConfig.mcpServers) claudeConfig.mcpServers = {};
claudeConfig.mcpServers.argent = devMcpEntry;
writeJson(CLAUDE_JSON, claudeConfig);
console.log(`✓ Patched ~/.claude.json → node ${LOCAL_MCP_ENTRY} mcp`);
if (shouldPatchCursor) {
if (!cursorConfig.mcpServers) cursorConfig.mcpServers = {};
cursorConfig.mcpServers.argent = {
command: "node",
args: [LOCAL_MCP_ENTRY, "mcp"],
// Same env as the Claude entry (incl. ARGENT_TOOLS_URL) so Cursor's dev
// MCP also reuses the running dev tool-server instead of killing it.
env: devMcpEntry.env,
};
writeJson(CURSOR_MCP_JSON, cursorConfig);
console.log(`✓ Patched ~/.cursor/mcp.json → node ${LOCAL_MCP_ENTRY} mcp\n`);
} else {
console.log("• Skipped Cursor patch (no ~/.cursor directory found)\n");
}
// ── Cleanup on exit ───────────────────────────────────────────────────────────
let toolServerPid = null;
function cleanup() {
console.log("\nCleaning up...");
// Stop tool-server
if (toolServerPid && isProcessAlive(toolServerPid)) {
process.kill(toolServerPid, "SIGTERM");
}
try {
fs.unlinkSync(STATE_FILE);
} catch {
/* state file already absent — nothing to remove */
}
// Restore editor configs
restoreMcpEntry(CLAUDE_JSON, originalArgentEntry, claudeConfigExists);
console.log("✓ Restored ~/.claude.json");
if (shouldPatchCursor) {
restoreMcpEntry(CURSOR_MCP_JSON, originalCursorArgentEntry, cursorConfigExists);
console.log("✓ Restored ~/.cursor/mcp.json");
}
console.log("Done.");
}
process.on("SIGINT", () => {
cleanup();
process.exit(0);
});
process.on("SIGTERM", () => {
cleanup();
process.exit(0);
});
process.on("exit", cleanup);
async function main() {
// ── Step 5: Kill any existing registered tool-server ───────────────────────
fs.mkdirSync(STATE_DIR, { recursive: true });
const existingState = readJson(STATE_FILE);
if (existingState.pid && isProcessAlive(existingState.pid)) {
console.log(`Stopping existing tool-server (PID ${existingState.pid})...`);
process.kill(existingState.pid, "SIGTERM");
await new Promise((r) => setTimeout(r, 600));
}
try {
fs.unlinkSync(STATE_FILE);
} catch {
/* state file already absent — nothing to remove */
}
// ── Step 6: Start tool-server from source ──────────────────────────────────
console.log(`Starting dev tool-server on port ${PORT}...`);
const toolServer = spawn("npx", ["ts-node", "src/index.ts"], {
cwd: TOOL_SERVER_PKG,
stdio: ["ignore", "pipe", "pipe"],
env: { ...process.env, PORT: String(PORT) },
});
toolServerPid = toolServer.pid;
const logStream = fs.createWriteStream(path.join(STATE_DIR, "tool-server.log"), { flags: "a" });
toolServer.stdout.pipe(logStream);
toolServer.stderr.pipe(logStream);
toolServer.on("exit", (code) => {
if (code !== 0 && code !== null) {
console.error(`\nTool-server exited with code ${code}. Check ~/.argent/tool-server.log`);
}
});
// ── Step 7: Wait for tool-server to be ready ───────────────────────────────
process.stdout.write("Waiting for tool-server");
const ready = await waitForHttp(`http://127.0.0.1:${PORT}/tools`);
if (!ready) {
console.error("\nTool-server failed to start. Check ~/.argent/tool-server.log");
cleanup();
process.exit(1);
}
console.log(" ready.");
// ── Step 8: Write state file ────────────────────────────────────────────────
writeJson(STATE_FILE, {
port: PORT,
pid: toolServerPid,
startedAt: new Date().toISOString(),
bundlePath: "dev",
});
// ── Done ────────────────────────────────────────────────────────────────────
console.log(`
✓ Dev environment ready
Tool-server: http://127.0.0.1:${PORT}/tools
MCP: ${LOCAL_MCP_ENTRY}
Logs: ~/.argent/tool-server.log
Start a new Claude Code or Cursor session to pick up the local MCP.
After tool-server code changes → Ctrl+C and re-run npm run dev
After MCP code changes → re-run npm run dev (rebuilds MCP automatically)
Press Ctrl+C to stop and restore global argent.
`);
// Keep alive until tool-server exits or Ctrl+C
await new Promise((resolve) => {
toolServer.on("exit", resolve);
});
}
main().catch((err) => {
console.error(err);
cleanup();
process.exit(1);
});