From dcb8f881838f0b961b2417958b06efcad5e1a02d Mon Sep 17 00:00:00 2001 From: Dara Adedeji Date: Fri, 11 Sep 2026 22:52:43 -0400 Subject: [PATCH] fix(android-utils): run avdmanager and sdkmanager on Windows On Windows the cmdline-tools ship as .bat wrappers. The path helpers pointed at the extension-less name, which Node cannot find, and even with the extension Node refuses to spawn a batch file without a shell. Append .bat on win32 and run those two tools through cmd.exe as a single quoted command line. adb and emulator are native binaries and already resolve to their .exe. Co-Authored-By: Claude Fable 5.1 --- .changeset/windows-cmdline-tools.md | 5 ++ .../src/__tests__/exec-sdk-tool.test.ts | 83 +++++++++++++++++++ .../src/__tests__/sdk-paths.test.ts | 16 +++- .../@expo/hub-android-utils/src/avdmanager.ts | 13 ++- .../hub-android-utils/src/exec-sdk-tool.ts | 47 +++++++++++ .../@expo/hub-android-utils/src/sdk-paths.ts | 43 +++++++--- .../@expo/hub-android-utils/src/sdkmanager.ts | 7 +- 7 files changed, 189 insertions(+), 25 deletions(-) create mode 100644 .changeset/windows-cmdline-tools.md create mode 100644 packages/@expo/hub-android-utils/src/__tests__/exec-sdk-tool.test.ts create mode 100644 packages/@expo/hub-android-utils/src/exec-sdk-tool.ts diff --git a/.changeset/windows-cmdline-tools.md b/.changeset/windows-cmdline-tools.md new file mode 100644 index 00000000..18b7f9b5 --- /dev/null +++ b/.changeset/windows-cmdline-tools.md @@ -0,0 +1,5 @@ +--- +"expo-device-hub": patch +--- + +Run `avdmanager` and `sdkmanager` on Windows, where they ship as `.bat` wrappers. diff --git a/packages/@expo/hub-android-utils/src/__tests__/exec-sdk-tool.test.ts b/packages/@expo/hub-android-utils/src/__tests__/exec-sdk-tool.test.ts new file mode 100644 index 00000000..d93d6292 --- /dev/null +++ b/packages/@expo/hub-android-utils/src/__tests__/exec-sdk-tool.test.ts @@ -0,0 +1,83 @@ +import { afterAll, beforeAll, describe, expect, test } from "bun:test"; +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { buildBatchCommand, execSdkTool, isBatchFile } from "../exec-sdk-tool"; + +describe("isBatchFile", () => { + test("matches .bat and .cmd regardless of case", () => { + expect(isBatchFile("C:\\sdk\\cmdline-tools\\latest\\bin\\avdmanager.bat")).toBe(true); + expect(isBatchFile("C:\\tool.CMD")).toBe(true); + }); + + test("ignores native binaries", () => { + expect(isBatchFile("/sdk/cmdline-tools/latest/bin/avdmanager")).toBe(false); + expect(isBatchFile("C:\\sdk\\platform-tools\\adb.exe")).toBe(false); + }); +}); + +describe("buildBatchCommand", () => { + test("leaves path-safe arguments bare", () => { + expect(buildBatchCommand("C:\\sdk\\avdmanager.bat", ["list", "avd"])).toBe( + "C:\\sdk\\avdmanager.bat list avd", + ); + }); + + test("quotes arguments cmd.exe would otherwise split", () => { + const command = buildBatchCommand("C:\\Program Files\\sdk\\avdmanager.bat", [ + "create", + "avd", + "--name", + "expo-emu-host-0", + "--package", + "system-images;android-36.1;google_apis_playstore;x86_64", + "--device", + "pixel 6", + ]); + + expect(command).toBe( + '"C:\\Program Files\\sdk\\avdmanager.bat" create avd --name expo-emu-host-0 ' + + '--package "system-images;android-36.1;google_apis_playstore;x86_64" --device "pixel 6"', + ); + }); + + test("escapes embedded double quotes", () => { + expect(buildBatchCommand("tool.bat", ['say "hi"'])).toBe('tool.bat "say ""hi"""'); + }); +}); + +describe("execSdkTool", () => { + test("spawns native binaries directly with their arguments", async () => { + const { stdout } = await execSdkTool(process.execPath, ["-e", "console.log('direct')"]); + expect(stdout.trim()).toBe("direct"); + }); + + describe.skipIf(process.platform !== "win32")("on Windows", () => { + let directory = ""; + let echoArgs = ""; + + beforeAll(() => { + // The space in the directory name exercises quoting of the tool path itself. + directory = mkdtempSync(join(tmpdir(), "hub android-")); + echoArgs = join(directory, "echo-args.bat"); + writeFileSync(echoArgs, "@echo off\r\necho %*\r\n"); + }); + + afterAll(() => { + rmSync(directory, { recursive: true, force: true }); + }); + + test("runs a .bat wrapper and keeps quoted arguments whole", async () => { + const { stdout } = await execSdkTool(echoArgs, [ + "--package", + "system-images;android-36.1;google_apis_playstore;x86_64", + "--device", + "pixel 6", + ]); + + expect(stdout.trim()).toBe( + '--package "system-images;android-36.1;google_apis_playstore;x86_64" --device "pixel 6"', + ); + }); + }); +}); diff --git a/packages/@expo/hub-android-utils/src/__tests__/sdk-paths.test.ts b/packages/@expo/hub-android-utils/src/__tests__/sdk-paths.test.ts index e3b307b5..cf962f66 100644 --- a/packages/@expo/hub-android-utils/src/__tests__/sdk-paths.test.ts +++ b/packages/@expo/hub-android-utils/src/__tests__/sdk-paths.test.ts @@ -39,7 +39,12 @@ describe("resolveSdkRoot", () => { describe("avdmanagerPath", () => { test("appends the cmdline-tools binary subpath", () => { - expect(avdmanagerPath("/sdk")).toBe("/sdk/cmdline-tools/latest/bin/avdmanager"); + expect(avdmanagerPath("/sdk", "darwin")).toBe("/sdk/cmdline-tools/latest/bin/avdmanager"); + }); + + test("targets the .bat wrapper on Windows", () => { + expect(avdmanagerPath("/sdk", "win32")).toEndWith("avdmanager.bat"); + expect(avdmanagerPath("/sdk", "linux")).toEndWith("avdmanager"); }); }); @@ -53,7 +58,14 @@ describe("resolveAvdmanagerPath", () => { describe("sdkmanagerPath", () => { test("appends the cmdline-tools binary subpath", () => { - expect(sdkmanagerPath("/sdk")).toBe("/sdk/cmdline-tools/latest/bin/sdkmanager"); + expect(sdkmanagerPath("/sdk", "darwin")).toBe("/sdk/cmdline-tools/latest/bin/sdkmanager"); + }); + + test("targets the .bat wrapper on Windows", () => { + expect(sdkmanagerPath("/sdk", "win32")).toEndWith("sdkmanager.bat"); + expect(resolveSdkmanagerPath({ ANDROID_HOME: "/sdk" }, HOME, "win32")).toEndWith( + "sdkmanager.bat", + ); }); }); diff --git a/packages/@expo/hub-android-utils/src/avdmanager.ts b/packages/@expo/hub-android-utils/src/avdmanager.ts index 79225647..55db7b16 100644 --- a/packages/@expo/hub-android-utils/src/avdmanager.ts +++ b/packages/@expo/hub-android-utils/src/avdmanager.ts @@ -1,13 +1,10 @@ -import { execFile } from "node:child_process"; import { readFile } from "node:fs/promises"; import { join } from "node:path"; -import { promisify } from "node:util"; import { type AndroidUtilsResult, reportError, result } from "./errors"; +import { execSdkTool } from "./exec-sdk-tool"; import { parseConfigIni } from "./parse-config-ini"; import type { CreateDeviceOptions } from "./types"; -const execFileAsync = promisify(execFile); - /** * Run `avdmanager list avd` and return its stdout, or `null` on failure. * Never throws. @@ -16,7 +13,7 @@ export async function runAvdmanagerListAvd( avdmanagerPath: string, ): Promise> { try { - const { stdout } = await execFileAsync(avdmanagerPath, ["list", "avd"]); + const { stdout } = await execSdkTool(avdmanagerPath, ["list", "avd"]); return result(stdout); } catch (error) { return result(null, reportError("[android-utils] Failed to run `avdmanager list avd`:", error)); @@ -31,7 +28,7 @@ export async function runAvdmanagerListDevice( avdmanagerPath: string, ): Promise> { try { - const { stdout } = await execFileAsync(avdmanagerPath, ["list", "device"]); + const { stdout } = await execSdkTool(avdmanagerPath, ["list", "device"]); return result(stdout); } catch (error) { return result( @@ -92,7 +89,7 @@ export async function runAvdmanagerCreateAvd( const args = buildCreateAvdArgs(options); try { - const { stdout } = await execFileAsync(avdmanagerPath, args); + const { stdout } = await execSdkTool(avdmanagerPath, args); return result(stdout); } catch (error) { return result( @@ -132,7 +129,7 @@ export async function runAvdmanagerDeleteAvd( const args = buildDeleteAvdArgs(name); try { - const { stdout } = await execFileAsync(avdmanagerPath, args); + const { stdout } = await execSdkTool(avdmanagerPath, args); return result(stdout); } catch (error) { return result( diff --git a/packages/@expo/hub-android-utils/src/exec-sdk-tool.ts b/packages/@expo/hub-android-utils/src/exec-sdk-tool.ts new file mode 100644 index 00000000..fd06fe49 --- /dev/null +++ b/packages/@expo/hub-android-utils/src/exec-sdk-tool.ts @@ -0,0 +1,47 @@ +import { execFile } from "node:child_process"; +import { promisify } from "node:util"; + +const execFileAsync = promisify(execFile); + +export interface ExecSdkToolOptions { + timeout?: number; + signal?: AbortSignal; +} + +/** + * Run an SDK command-line tool and resolve with its output. + * + * On Windows `avdmanager` and `sdkmanager` are `.bat` wrappers, which Node + * refuses to spawn directly (`spawn EINVAL` since 18.20 / 20.12). Those run + * through `cmd.exe` as one pre-quoted command line; native binaries such as + * `adb` spawn as-is. `timeout` and `signal` apply either way. + */ +export function execSdkTool( + toolPath: string, + args: string[], + options: ExecSdkToolOptions = {}, +): Promise<{ stdout: string; stderr: string }> { + if (!isBatchFile(toolPath)) return execFileAsync(toolPath, args, options); + return execFileAsync(buildBatchCommand(toolPath, args), [], { ...options, shell: true }); +} + +/** Whether `toolPath` is a Windows batch wrapper rather than a native binary. */ +export function isBatchFile(toolPath: string): boolean { + return /\.(bat|cmd)$/i.test(toolPath); +} + +/** + * Join a `.bat` path and its arguments into a single `cmd.exe` command line. + * + * Anything beyond path-safe characters is double-quoted so `cmd.exe` passes it + * through whole: the `;` in system image packages and spaces in `Program Files` + * would otherwise be split. + */ +export function buildBatchCommand(toolPath: string, args: string[]): string { + return [toolPath, ...args].map(quoteBatchArg).join(" "); +} + +function quoteBatchArg(arg: string): string { + if (/^[\w./:\\-]+$/.test(arg)) return arg; + return `"${arg.replace(/"/g, '""')}"`; +} diff --git a/packages/@expo/hub-android-utils/src/sdk-paths.ts b/packages/@expo/hub-android-utils/src/sdk-paths.ts index c5d6d2f5..8bec2309 100644 --- a/packages/@expo/hub-android-utils/src/sdk-paths.ts +++ b/packages/@expo/hub-android-utils/src/sdk-paths.ts @@ -22,24 +22,43 @@ export function resolveSdkRoot(env: NodeJS.ProcessEnv, homeDir: string): string return join(homeDir, DEFAULT_SDK_SUBPATH); } -/** Build the absolute path to the `avdmanager` binary inside an SDK root. */ -export function avdmanagerPath(sdkRoot: string): string { - return join(sdkRoot, AVDMANAGER_SUBPATH); +/** + * Build the absolute path to the `avdmanager` wrapper inside an SDK root. + * + * The cmdline-tools ship as `.bat` wrappers on Windows. `adb` and `emulator` + * need no such suffix: Windows resolves a missing `.exe` on its own. + */ +export function avdmanagerPath( + sdkRoot: string, + platform: NodeJS.Platform = process.platform, +): string { + return join(sdkRoot, cmdlineTool(AVDMANAGER_SUBPATH, platform)); } /** Resolve the `avdmanager` path directly from the environment. */ -export function resolveAvdmanagerPath(env: NodeJS.ProcessEnv, homeDir: string): string { - return avdmanagerPath(resolveSdkRoot(env, homeDir)); +export function resolveAvdmanagerPath( + env: NodeJS.ProcessEnv, + homeDir: string, + platform: NodeJS.Platform = process.platform, +): string { + return avdmanagerPath(resolveSdkRoot(env, homeDir), platform); } -/** Build the absolute path to the `sdkmanager` binary inside an SDK root. */ -export function sdkmanagerPath(sdkRoot: string): string { - return join(sdkRoot, SDKMANAGER_SUBPATH); +/** Build the absolute path to the `sdkmanager` wrapper inside an SDK root. */ +export function sdkmanagerPath( + sdkRoot: string, + platform: NodeJS.Platform = process.platform, +): string { + return join(sdkRoot, cmdlineTool(SDKMANAGER_SUBPATH, platform)); } /** Resolve the `sdkmanager` path directly from the environment. */ -export function resolveSdkmanagerPath(env: NodeJS.ProcessEnv, homeDir: string): string { - return sdkmanagerPath(resolveSdkRoot(env, homeDir)); +export function resolveSdkmanagerPath( + env: NodeJS.ProcessEnv, + homeDir: string, + platform: NodeJS.Platform = process.platform, +): string { + return sdkmanagerPath(resolveSdkRoot(env, homeDir), platform); } /** Build the absolute path to the `emulator` binary inside an SDK root. */ @@ -62,6 +81,10 @@ export function resolveAdbPath(env: NodeJS.ProcessEnv, homeDir: string): string return adbPath(resolveSdkRoot(env, homeDir)); } +function cmdlineTool(subpath: string, platform: NodeJS.Platform): string { + return platform === "win32" ? `${subpath}.bat` : subpath; +} + function nonEmpty(value: string | undefined): string | null { if (!value) return null; diff --git a/packages/@expo/hub-android-utils/src/sdkmanager.ts b/packages/@expo/hub-android-utils/src/sdkmanager.ts index bc4d082e..af423989 100644 --- a/packages/@expo/hub-android-utils/src/sdkmanager.ts +++ b/packages/@expo/hub-android-utils/src/sdkmanager.ts @@ -1,8 +1,5 @@ -import { execFile } from "node:child_process"; -import { promisify } from "node:util"; import { type AndroidUtilsResult, reportError, result } from "./errors"; - -const execFileAsync = promisify(execFile); +import { execSdkTool } from "./exec-sdk-tool"; /** * Run `sdkmanager --list_installed` and return its stdout, or `null` on failure. @@ -12,7 +9,7 @@ export async function runSdkmanagerListInstalled( sdkmanagerPath: string, ): Promise> { try { - const { stdout } = await execFileAsync(sdkmanagerPath, ["--list_installed"]); + const { stdout } = await execSdkTool(sdkmanagerPath, ["--list_installed"]); return result(stdout); } catch (error) { return result(