Compare commits

...
8 Commits
Author SHA1 Message Date
Expo CI 43068db22a v24.5.0 2026-09-15 09:41:35 +00:00
Stanisław Chmiela 2b7f1954fc [build-tools] Execute sandbox commands over MCP connection (#4347)
## Why

Clients are telling sandboxes through MCP server what to do. Let's handle the requests!

## How

The endeavour starts from a (relatively, after all) complex task of starting commands and reading/writing to them.

Since commands may live longer than a tool call (request followed by a response), we need some kind of manager of running commands — Shell Session Manager. (Session is the running command.)

This is where most of the complexity lays, the rest is mostly a wrapper.

## Test plan

CI should pass. Tested manually.
2026-09-15 11:34:05 +02:00
Expo CI 3b64599aea bump STAGING and PRODUCTION to 24.4.2 in cli-versions.json 2026-09-15 05:07:36 +00:00
Expo CI 96fe9a239e update CHANGELOG.md for v24.4.2 2026-09-15 05:05:39 +00:00
Expo CI 195807643f v24.4.2 2026-09-15 05:03:15 +00:00
Brent Vatne 21256e6d55 [eas-cli] Support Xcode 27 Device Hub when running iOS simulator builds (#4405) 2026-09-14 22:00:43 -07:00
Expo CI 705fa875d3 bump STAGING and PRODUCTION to 24.4.1 in cli-versions.json 2026-09-15 04:14:14 +00:00
Expo CI 498384357e update CHANGELOG.md for v24.4.1 2026-09-15 04:10:19 +00:00
27 changed files with 1515 additions and 203 deletions

No files matched your search

+12
View File
@@ -8,10 +8,22 @@ This is the log of notable changes to EAS CLI and related packages.
### 🎉 New features
- [build-tools] Let connected sandboxes run shell commands through the Expo MCP server. ([#4347](https://github.com/expo/eas-cli/pull/4347) by [@sjchmiela](https://github.com/sjchmiela))
### 🐛 Bug fixes
### 🧹 Chores
## [24.4.2](https://github.com/expo/eas-cli/releases/tag/v24.4.2) - 2026-09-15
### 🐛 Bug fixes
- [eas-cli] Support Xcode 27, which replaces Simulator.app with Device Hub, in `build:dev`, `build:run`, and `run` for iOS simulator builds. ([#4405](https://github.com/expo/eas-cli/pull/4405) by [@brentvatne](https://github.com/brentvatne))
## [24.4.1](https://github.com/expo/eas-cli/releases/tag/v24.4.1) - 2026-09-15
### 🧹 Chores
- [eas-cli] Bump `@expo/apple-utils` to `2.2.1`. ([#4404](https://github.com/expo/eas-cli/pull/4404) by [@brentvatne](https://github.com/brentvatne))
## [24.4.0](https://github.com/expo/eas-cli/releases/tag/v24.4.0) - 2026-09-14
+2 -2
View File
@@ -1,4 +1,4 @@
{
"STAGING": "24.4.0",
"PRODUCTION": "24.4.0"
"STAGING": "24.4.2",
"PRODUCTION": "24.4.2"
}
+1 -1
View File
@@ -1,5 +1,5 @@
{
"npmClient": "yarn",
"version": "24.4.1",
"version": "24.5.0",
"$schema": "node_modules/lerna/schemas/lerna-schema.json"
}
+4 -3
View File
@@ -1,6 +1,6 @@
{
"name": "@expo/build-tools",
"version": "24.4.0",
"version": "24.5.0",
"bugs": "https://github.com/expo/eas-cli/issues",
"license": "BUSL-1.1",
"author": "Expo <[email protected]>",
@@ -42,14 +42,14 @@
"@expo/config": "55.0.10",
"@expo/config-plugins": "55.0.7",
"@expo/downloader": "24.0.0",
"@expo/eas-build-job": "24.3.0",
"@expo/eas-build-job": "24.5.0",
"@expo/env": "^0.4.0",
"@expo/logger": "24.0.0",
"@expo/package-manager": "1.9.10",
"@expo/plist": "^0.3.5",
"@expo/results": "^1.0.0",
"@expo/spawn-async": "1.7.2",
"@expo/steps": "24.3.0",
"@expo/steps": "24.5.0",
"@expo/template-file": "24.0.0",
"@expo/turtle-spawn": "24.0.0",
"@expo/xcpretty": "^4.3.1",
@@ -67,6 +67,7 @@
"lodash": "^4.18.1",
"node-fetch": "^2.7.0",
"node-forge": "^1.3.1",
"node-pty": "1.2.0-beta.15",
"node-stream-zip": "1.15.0",
"nullthrows": "^1.1.1",
"plist": "^3.1.0",
@@ -59,19 +59,47 @@ describe('sandbox build functions', () => {
fn.fn!({ logger: {} } as any, {
inputs: { sandbox_id: { value: 'sandbox-id' } },
outputs: {},
env: {},
env: { __EAS_SANDBOX_MCP_TOKEN: 'token' },
signal: controller.signal,
})
).rejects.toThrow();
).rejects.toMatchObject({ name: 'AbortError' });
expect(mutation).not.toHaveBeenCalled();
expect(stopAsync).toHaveBeenCalledTimes(1);
} finally {
start.mockRestore();
}
});
it('provides one long-running step', () => {
const ctx = { job: {} } as any;
expect(createStartSandboxBuildFunction(ctx).getFullId()).toBe('eas/start_sandbox');
it('excludes the MCP token from the command environment', async () => {
const env = {
PREPARED_BY_EARLIER_STEP: 'value',
EXPO_TOKEN: 'expo-token',
__EAS_SANDBOX_MCP_TOKEN: 'token',
};
const startupError = new Error('stop after checking options');
const start = jest
.spyOn(sandboxDaemon, 'startSandboxDaemonAsync')
.mockRejectedValue(startupError);
const fn = createStartSandboxBuildFunction({
env: { __EAS_SANDBOX_MCP_TOKEN: 'token' },
mcpServerUrl: 'ws://localhost:8787',
} as any);
try {
await expect(
fn.fn!({ logger: {} } as any, {
inputs: { sandbox_id: { value: 'sandbox-id' } },
outputs: {},
env,
})
).rejects.toBe(startupError);
expect(start.mock.calls[0][0]).toMatchObject({ credential: 'token' });
expect(start.mock.calls[0][0].env).toEqual({
PREPARED_BY_EARLIER_STEP: 'value',
EXPO_TOKEN: 'expo-token',
});
expect(env.__EAS_SANDBOX_MCP_TOKEN).toBe('token');
} finally {
start.mockRestore();
}
});
it('throws a system error when the sandbox token is missing', async () => {
@@ -100,9 +128,9 @@ describe('sandbox build functions', () => {
fn.fn!({} as any, {
inputs: { sandbox_id: { value: 'sandbox-id' } },
outputs: {},
env: {},
env: { __EAS_SANDBOX_MCP_TOKEN: 'sandbox-token' },
})
).rejects.toBeInstanceOf(SystemError);
).rejects.toThrow('MCP server URL is required to start the sandbox daemon.');
});
it('marks the sandbox as ready', async () => {
@@ -32,8 +32,9 @@ export function createStartSandboxBuildFunction(ctx: CustomBuildContext): BuildF
allowedValueTypeName: BuildStepInputValueTypeName.STRING,
}),
],
fn: async (stepCtx, { inputs, signal }) => {
const sandboxToken = ctx.env.__EAS_SANDBOX_MCP_TOKEN;
fn: async (stepCtx, { inputs, signal, env }) => {
// The daemon needs this credential, but shell commands do not.
const { __EAS_SANDBOX_MCP_TOKEN: sandboxToken, ...commandEnv } = env;
if (!sandboxToken) {
throw new SystemError('__EAS_SANDBOX_MCP_TOKEN is required to start the sandbox daemon.');
}
@@ -48,6 +49,8 @@ export function createStartSandboxBuildFunction(ctx: CustomBuildContext): BuildF
reconnectDelayMs: RECONNECT_DELAY_MS,
logger: stepCtx.logger,
signal,
workingDirectory: stepCtx.workingDirectory,
env: commandEnv,
});
try {
await daemon.ready;
@@ -1,4 +1,4 @@
import { SystemError } from '@expo/eas-build-job';
import { SandboxDaemonResponseZ, SystemError } from '@expo/eas-build-job';
import Log from '@expo/logger';
import http from 'node:http';
import net, { AddressInfo } from 'node:net';
@@ -7,6 +7,10 @@ import WebSocket, { WebSocketServer } from 'ws';
import { startSandboxDaemonAsync } from '../sandboxDaemon';
jest.unmock('@expo/logger');
jest.unmock('fs');
jest.unmock('fs/promises');
jest.unmock('node:fs');
jest.unmock('node:fs/promises');
describe(startSandboxDaemonAsync.name, () => {
const logger = Log.child({ buildStepId: 'sandbox-test' });
@@ -17,6 +21,8 @@ describe(startSandboxDaemonAsync.name, () => {
serverUrl,
reconnectDelayMs: 10,
logger,
workingDirectory: process.cwd(),
env: process.env,
});
try {
await expect(daemon.ready).rejects.toBeInstanceOf(SystemError);
@@ -40,6 +46,8 @@ describe(startSandboxDaemonAsync.name, () => {
serverUrl: `ws://127.0.0.1:${(server.address() as AddressInfo).port}`,
reconnectDelayMs: 60_000,
logger,
workingDirectory: process.cwd(),
env: process.env,
});
try {
const socket = await connection;
@@ -72,6 +80,8 @@ describe(startSandboxDaemonAsync.name, () => {
serverUrl: `ws://127.0.0.1:${address.port}`,
reconnectDelayMs: 10,
logger,
workingDirectory: process.cwd(),
env: process.env,
});
try {
await expect(daemon.ready).rejects.toBeInstanceOf(SystemError);
@@ -100,6 +110,8 @@ describe(startSandboxDaemonAsync.name, () => {
reconnectDelayMs: 60_000,
logger,
signal: controller.signal,
workingDirectory: process.cwd(),
env: process.env,
});
const connectionError = daemon.ready.catch(error => error);
@@ -126,6 +138,8 @@ describe(startSandboxDaemonAsync.name, () => {
serverUrl: `ws://127.0.0.1:${address.port}`,
reconnectDelayMs: 60_000,
logger,
workingDirectory: process.cwd(),
env: process.env,
});
await expect(daemon.ready).rejects.toThrow('Sandbox MCP server connection failed');
@@ -157,6 +171,8 @@ describe(startSandboxDaemonAsync.name, () => {
serverUrl: `ws://127.0.0.1:${address.port}`,
reconnectDelayMs: 10,
logger,
workingDirectory: process.cwd(),
env: process.env,
});
const socket = await connection;
const daemon = await daemonPromise;
@@ -170,6 +186,117 @@ describe(startSandboxDaemonAsync.name, () => {
await new Promise<void>(resolve => httpServer.close(() => resolve()));
});
it('executes command messages', async () => {
const httpServer = http.createServer();
const mcpServer = new WebSocketServer({ noServer: true });
httpServer.on('upgrade', (request, socket, head) => {
mcpServer.handleUpgrade(request, socket, head, client =>
mcpServer.emit('connection', client)
);
});
await new Promise<void>(resolve => httpServer.listen(0, '127.0.0.1', resolve));
const address = httpServer.address() as AddressInfo;
const connection = new Promise<WebSocket>(resolve => mcpServer.once('connection', resolve));
const daemon = await startSandboxDaemonAsync({
credential: 'secret-token',
serverUrl: `ws://127.0.0.1:${address.port}`,
reconnectDelayMs: 10,
logger,
workingDirectory: process.cwd(),
env: process.env,
});
const socket = await connection;
await daemon.ready;
const commandResponse = new Promise<string>(resolve =>
socket.once('message', data => resolve(`${data}`))
);
socket.send(
JSON.stringify({
jsonrpc: '2.0',
id: 'command-1',
method: 'execCommand',
params: { cmd: 'printf hello' },
})
);
expect(JSON.parse(await commandResponse)).toMatchObject({
jsonrpc: '2.0',
id: 'command-1',
result: { output: 'hello', exitCode: 0 },
});
await daemon.stopAsync();
mcpServer.close();
await new Promise<void>(resolve => httpServer.close(() => resolve()));
});
it('returns JSON-RPC errors for invalid messages', async () => {
const httpServer = http.createServer();
const mcpServer = new WebSocketServer({ noServer: true });
httpServer.on('upgrade', (request, socket, head) => {
mcpServer.handleUpgrade(request, socket, head, client =>
mcpServer.emit('connection', client)
);
});
await new Promise<void>(resolve => httpServer.listen(0, '127.0.0.1', resolve));
const address = httpServer.address() as AddressInfo;
const connection = new Promise<WebSocket>(resolve => mcpServer.once('connection', resolve));
const daemon = await startSandboxDaemonAsync({
credential: 'secret-token',
serverUrl: `ws://127.0.0.1:${address.port}`,
reconnectDelayMs: 10,
logger,
workingDirectory: process.cwd(),
env: process.env,
});
const socket = await connection;
await daemon.ready;
const sendAsync = async (message: string): Promise<unknown> => {
const response = new Promise<string>(resolve =>
socket.once('message', data => resolve(`${data}`))
);
socket.send(message);
return SandboxDaemonResponseZ.parse(JSON.parse(await response));
};
await expect(sendAsync('{')).resolves.toMatchObject({
id: null,
error: { code: -32700, message: 'Parse error' },
});
await expect(sendAsync('{}')).resolves.toMatchObject({
id: null,
error: { code: -32600, message: 'Invalid request' },
});
await expect(
sendAsync(JSON.stringify({ jsonrpc: '2.0', id: '1', method: 'unknown' }))
).resolves.toMatchObject({
id: '1',
error: { code: -32601, message: 'Method not found' },
});
await expect(
sendAsync(JSON.stringify({ jsonrpc: '2.0', id: '2', method: 'execCommand', params: {} }))
).resolves.toMatchObject({
id: '2',
error: { code: -32602, message: 'Invalid params' },
});
await expect(
sendAsync(
JSON.stringify({
jsonrpc: '2.0',
id: '3',
method: 'writeStdin',
params: { sessionId: 999 },
})
)
).resolves.toMatchObject({
id: '3',
error: { code: -32603, message: 'Command session 999 does not exist.' },
});
await daemon.stopAsync();
mcpServer.close();
await new Promise<void>(resolve => httpServer.close(() => resolve()));
});
it('cancels the reconnect delay when stopped', async () => {
const httpServer = http.createServer();
const mcpServer = new WebSocketServer({ noServer: true });
@@ -186,6 +313,8 @@ describe(startSandboxDaemonAsync.name, () => {
serverUrl: `ws://127.0.0.1:${address.port}`,
reconnectDelayMs: 60_000,
logger,
workingDirectory: process.cwd(),
env: process.env,
});
const socket = await connection;
const daemon = await daemonPromise;
@@ -0,0 +1,286 @@
jest.unmock('fs');
jest.unmock('fs/promises');
jest.unmock('node:fs');
jest.unmock('node:fs/promises');
import { type SandboxDaemonCommandResult, type SandboxDaemonMethod } from '@expo/eas-build-job';
import fs from 'node:fs/promises';
import { spawnSync } from 'node:child_process';
import os from 'node:os';
import path from 'node:path';
import { performance } from 'node:perf_hooks';
import { setTimeout as setTimeoutAsync } from 'node:timers/promises';
import {
type SandboxDaemonCommandImplementations,
createSandboxCommandImplementations,
} from '../sandboxCommandImplementations';
describe('sandbox daemon commands', () => {
let commandImplementations: SandboxDaemonCommandImplementations;
let stopAsync: () => Promise<void>;
let workingDirectory: string;
beforeEach(async () => {
workingDirectory = await fs.mkdtemp(path.join(os.tmpdir(), 'sandbox-command-executor-'));
const controller = new AbortController();
const commands = createSandboxCommandImplementations({
workingDirectory,
env: process.env,
signal: controller.signal,
});
commandImplementations = commands.commandImplementations;
stopAsync = async () => {
controller.abort();
await commands.stoppedPromise;
};
});
afterEach(async () => {
await stopAsync();
await fs.chmod(workingDirectory, 0o700);
await fs.rm(workingDirectory, { recursive: true, force: true });
});
it('returns output and the exit code for a completed command', async () => {
const result = await commandImplementations.execCommand({ cmd: 'printf hello' });
expect(result).toMatchObject({ output: 'hello', exitCode: 0 });
expect('sessionId' in result).toBe(false);
});
it('rejects a command canceled during directory validation', async () => {
const controller = new AbortController();
const commands = createSandboxCommandImplementations({
workingDirectory,
env: process.env,
signal: controller.signal,
});
const command = commands.commandImplementations.execCommand({ cmd: 'printf should-not-run' });
controller.abort();
await commands.stoppedPromise;
await expect(command).rejects.toMatchObject({ name: 'AbortError' });
await expect(
commands.commandImplementations.writeStdin({ sessionId: 1 })
).rejects.toMatchObject({ name: 'AbortError' });
});
it('returns a session id and accepts stdin for a running command', async () => {
const started = await commandImplementations.execCommand({
cmd: `node -e "process.stdin.once('data', data => process.stdout.write(data.toString().toUpperCase(), () => process.exit(0)))"`,
yieldTimeMs: 10,
});
const sessionId = getSessionId(started);
const completed = await commandImplementations.writeStdin({
sessionId,
chars: 'hello',
yieldTimeMs: 1_000,
});
expect(completed).toMatchObject({ output: 'HELLO', exitCode: 0 });
expect('sessionId' in completed).toBe(false);
});
it('returns only output produced since the previous call', async () => {
const started = await commandImplementations.execCommand({
cmd: `node -e "process.stdin.once('data', () => process.stdout.write('second\\n', () => process.exit(0))); console.log('first')"`,
yieldTimeMs: 50,
});
const sessionId = getSessionId(started);
expect(await readUntilAsync(started, output => output.includes('first\n'))).toMatchObject({
output: 'first\n',
});
const completed = await readUntilAsync(
await commandImplementations.writeStdin({ sessionId, chars: 'continue', yieldTimeMs: 100 }),
(_, result) => !('sessionId' in result)
);
expect(completed).toMatchObject({ output: 'second\n', exitCode: 0 });
});
it('provides a TTY when requested', async () => {
const result = await commandImplementations.execCommand({
cmd: 'test -t 0 && test -t 1 && test -t 2',
tty: true,
});
expect(result).toMatchObject({ exitCode: 0 });
});
it('uses pipes by default', async () => {
const result = await commandImplementations.execCommand({ cmd: 'test -t 0' });
expect(result).toMatchObject({ exitCode: 1 });
});
it('sends Ctrl+C to the foreground PTY process', async () => {
const started = await commandImplementations.execCommand({
cmd: `exec node -e "process.on('SIGINT', () => process.stdout.write('interrupted\\n', () => process.exit(0))); console.log('ready'); setInterval(() => {}, 1000)"`,
tty: true,
yieldTimeMs: 50,
});
await readUntilAsync(started, output => output.includes('ready'));
const completed = await readUntilAsync(
await commandImplementations.writeStdin({
sessionId: getSessionId(started),
chars: '\u0003',
yieldTimeMs: 1_000,
}),
(_, result) => !('sessionId' in result)
);
expect(completed.output).toContain('interrupted');
expect(completed).toMatchObject({ exitCode: 0 });
});
it('writes U+0003 as literal input in pipe mode', async () => {
const started = await commandImplementations.execCommand({
cmd: `node -e "process.stdin.once('data', data => process.stdout.write(data[0] + '\\n', () => process.exit(0)))"`,
yieldTimeMs: 10,
});
const completed = await readUntilAsync(
await commandImplementations.writeStdin({
sessionId: getSessionId(started),
chars: '\u0003',
yieldTimeMs: 1_000,
}),
(_, result) => !('sessionId' in result)
);
expect(completed).toMatchObject({ output: '3\n', exitCode: 0 });
});
it('reports a termination signal without an exit code', async () => {
const result = await commandImplementations.execCommand({ cmd: 'kill -TERM $$' });
expect(result).toMatchObject({ terminationSignal: 'SIGTERM' });
expect('exitCode' in result).toBe(false);
});
it('measures each call separately', async () => {
const now = jest.spyOn(performance, 'now');
try {
now.mockReturnValueOnce(1_000).mockReturnValueOnce(1_250);
const started = await commandImplementations.execCommand({
cmd: 'read value',
yieldTimeMs: 0,
});
expect(started.wallTimeSeconds).toBe(0.25);
now.mockReturnValueOnce(5_000).mockReturnValueOnce(5_100);
const polled = await commandImplementations.writeStdin({
sessionId: getSessionId(started),
yieldTimeMs: 0,
});
expect(polled.wallTimeSeconds).toBe(0.1);
} finally {
now.mockRestore();
}
});
it('uses the requested working directory', async () => {
const childDirectory = path.join(workingDirectory, 'child');
await fs.mkdir(childDirectory);
const result = await commandImplementations.execCommand({ cmd: 'pwd', workdir: 'child' });
expect(result).toMatchObject({ output: `${await fs.realpath(childDirectory)}\n`, exitCode: 0 });
});
it('reports a missing working directory', async () => {
await expect(
commandImplementations.execCommand({ cmd: 'true', workdir: 'missing' })
).rejects.toThrow(
`Working directory does not exist: ${path.join(workingDirectory, 'missing')}`
);
});
it('reports a working directory that is not a directory', async () => {
const file = path.join(workingDirectory, 'file');
await fs.writeFile(file, 'content');
await expect(
commandImplementations.execCommand({ cmd: 'true', workdir: 'file' })
).rejects.toThrow(`Working directory is not a directory: ${file}`);
});
it('reports an inaccessible working directory', async () => {
if (process.platform === 'win32') {
return;
}
const directory = path.join(workingDirectory, 'inaccessible');
await fs.mkdir(directory, 0o000);
await expect(
commandImplementations.execCommand({ cmd: 'true', workdir: 'inaccessible' })
).rejects.toThrow(`Working directory is not accessible: ${directory}`);
await fs.chmod(directory, 0o700);
});
it.each([false, true])('stops command descendants when tty is %s', async tty => {
const started = await commandImplementations.execCommand({
cmd: `node -e "const { spawn } = require('node:child_process'); const child = spawn(process.execPath, ['-e', 'setInterval(() => {}, 1000)'], { stdio: 'ignore' }); process.stdout.write(String(child.pid) + '\\n'); setInterval(() => {}, 1000)"`,
tty,
yieldTimeMs: 100,
});
const ready = await readUntilAsync(started, output => /\d+\r?\n/.test(output));
const childPid = Number(/\d+/.exec(ready.output)?.[0]);
expect(childPid).toBeGreaterThan(0);
await stopAsync();
await setTimeoutAsync(50);
expect(isProcessRunning(childPid)).toBe(false);
});
async function readUntilAsync(
initial: SandboxDaemonCommandResult<SandboxDaemonMethod>,
isReady: (output: string, result: SandboxDaemonCommandResult<SandboxDaemonMethod>) => boolean
): Promise<SandboxDaemonCommandResult<SandboxDaemonMethod>> {
let result = initial;
let output = result.output;
const deadline = Date.now() + 10_000;
while (!isReady(output, result)) {
if (!('sessionId' in result) || Date.now() >= deadline) {
throw new Error(
`Command did not reach the expected state: ${JSON.stringify({ ...result, output })}`
);
}
result = await commandImplementations.writeStdin({
sessionId: result.sessionId,
yieldTimeMs: 100,
});
output += result.output;
}
return { ...result, output };
}
});
function getSessionId(result: unknown): number {
if (
typeof result !== 'object' ||
result === null ||
!('sessionId' in result) ||
typeof result.sessionId !== 'number'
) {
throw new Error('Expected the command to still be running.');
}
return result.sessionId;
}
function isProcessRunning(pid: number): boolean {
const result = spawnSync('ps', ['-o', 'stat=', '-p', String(pid)], { encoding: 'utf8' });
if (result.error) {
throw result.error;
}
if (result.status === 1 && !result.stdout.trim()) {
return false;
}
expect(result.status).toBe(0);
// A zombie has exited, but its parent has not yet collected its exit status.
return !result.stdout.trim().startsWith('Z');
}
@@ -0,0 +1,105 @@
jest.unmock('node:fs/promises');
import { ShellSessionManager } from '../shellSessionManager';
import * as processes from '../../../utils/processes';
describe('ShellSessionManager', () => {
it.each([false, true])('uses only the supplied environment (tty: %s)', async tty => {
const controller = new AbortController();
const previous = process.env.SANDBOX_WORKER_ONLY;
process.env.SANDBOX_WORKER_ONLY = 'worker-secret';
const manager = new ShellSessionManager({
workingDirectory: process.cwd(),
env: { SHELL: '/bin/sh', SANDBOX_PREPARED: 'prepared-value' },
signal: controller.signal,
});
try {
const sessionId = await manager.startAsync({
cmd: 'printf "%s:%s" "$SANDBOX_PREPARED" "${SANDBOX_WORKER_ONLY-unset}"',
tty,
});
expect(await manager.readAsync(sessionId, 10_000)).toEqual({
output: 'prepared-value:unset',
exitCode: 0,
});
} finally {
controller.abort();
await manager.stoppedPromise;
if (previous === undefined) {
delete process.env.SANDBOX_WORKER_ONLY;
} else {
process.env.SANDBOX_WORKER_ONLY = previous;
}
}
});
it.each([false, true])('does not signal an exited leader (tty: %s)', async tty => {
const controller = new AbortController();
const manager = new ShellSessionManager({
workingDirectory: process.cwd(),
env: process.env,
signal: controller.signal,
});
const kill = jest.spyOn(processes, 'killProcessGroup');
try {
const sessionId = await manager.startAsync({ cmd: 'exit 0', tty });
expect(await manager.readAsync(sessionId, 10_000)).toMatchObject({ exitCode: 0 });
controller.abort();
await manager.stoppedPromise;
expect(kill).not.toHaveBeenCalled();
} finally {
controller.abort();
await manager.stoppedPromise;
kill.mockRestore();
}
});
it('keeps completed sessions without repeating their output', async () => {
const controller = new AbortController();
const manager = new ShellSessionManager({
workingDirectory: process.cwd(),
env: process.env,
signal: controller.signal,
});
try {
const sessionId = await manager.startAsync({ cmd: 'printf hello' });
expect(await manager.readAsync(sessionId, 1_000)).toEqual({ output: 'hello', exitCode: 0 });
expect(await manager.readAsync(sessionId, 1_000)).toEqual({ output: '', exitCode: 0 });
} finally {
controller.abort();
await manager.stoppedPromise;
}
});
it('keeps sessions separate and stops only its own manager', async () => {
const firstController = new AbortController();
const secondController = new AbortController();
const first = new ShellSessionManager({
workingDirectory: process.cwd(),
env: process.env,
signal: firstController.signal,
});
const second = new ShellSessionManager({
workingDirectory: process.cwd(),
env: process.env,
signal: secondController.signal,
});
try {
const firstId = await first.startAsync({ cmd: 'printf first' });
const secondId = await second.startAsync({ cmd: 'read value; printf "%s" "$value"' });
expect(await first.readAsync(firstId, 1_000)).toEqual({ output: 'first', exitCode: 0 });
firstController.abort();
await first.stoppedPromise;
await expect(first.startAsync({ cmd: 'printf stopped' })).rejects.toMatchObject({
name: 'AbortError',
});
expect(await second.readAsync(secondId, 0)).toEqual({ output: '', sessionId: secondId });
second.write(secondId, 'second\n');
expect(await second.readAsync(secondId, 1_000)).toEqual({ output: 'second', exitCode: 0 });
} finally {
firstController.abort();
secondController.abort();
await Promise.all([first.stoppedPromise, second.stoppedPromise]);
}
});
});
@@ -0,0 +1,61 @@
import {
type SandboxDaemonCommandParams,
type SandboxDaemonCommandResult,
type SandboxDaemonMethod,
} from '@expo/eas-build-job';
import { performance } from 'node:perf_hooks';
import { ShellSessionManager } from './shellSessionManager';
const DEFAULT_EXEC_YIELD_TIME_MS = 10_000;
const DEFAULT_WRITE_YIELD_TIME_MS = 250;
export type SandboxDaemonCommandImplementations = {
[Method in SandboxDaemonMethod]: (
params: SandboxDaemonCommandParams<Method>
) => Promise<SandboxDaemonCommandResult<Method>>;
};
export function createSandboxCommandImplementations({
workingDirectory,
env,
signal,
}: {
workingDirectory: string;
env: NodeJS.ProcessEnv;
signal: AbortSignal;
}): {
commandImplementations: SandboxDaemonCommandImplementations;
stoppedPromise: Promise<void>;
} {
const sessions = new ShellSessionManager({ workingDirectory, env, signal });
return {
commandImplementations: {
async execCommand(params) {
const callStartedAt = performance.now();
const sessionId = await sessions.startAsync({
cmd: params.cmd,
workdir: params.workdir,
tty: params.tty,
});
const result = await sessions.readAsync(
sessionId,
params.yieldTimeMs ?? DEFAULT_EXEC_YIELD_TIME_MS
);
return { ...result, wallTimeSeconds: (performance.now() - callStartedAt) / 1_000 };
},
async writeStdin(params) {
const callStartedAt = performance.now();
if (params.chars !== undefined) {
sessions.write(params.sessionId, params.chars);
}
const result = await sessions.readAsync(
params.sessionId,
params.yieldTimeMs ?? DEFAULT_WRITE_YIELD_TIME_MS
);
return { ...result, wallTimeSeconds: (performance.now() - callStartedAt) / 1_000 };
},
},
stoppedPromise: sessions.stoppedPromise,
};
}
@@ -1,14 +1,28 @@
import { SystemError } from '@expo/eas-build-job';
import {
type SandboxDaemonCommandResult,
SandboxDaemonCommands,
type SandboxDaemonMethod,
SandboxDaemonRequestZ,
type SandboxDaemonResponse,
SystemError,
} from '@expo/eas-build-job';
import { type bunyan } from '@expo/logger';
import { setTimeout as setTimeoutAsync } from 'node:timers/promises';
import WebSocket from 'ws';
import {
type SandboxDaemonCommandImplementations,
createSandboxCommandImplementations,
} from './sandboxCommandImplementations';
export interface SandboxDaemonOptions {
credential: string;
serverUrl: string;
reconnectDelayMs: number;
logger: bunyan;
signal?: AbortSignal;
workingDirectory: string;
env: NodeJS.ProcessEnv;
}
export interface SandboxDaemon {
@@ -25,6 +39,12 @@ export async function startSandboxDaemonAsync(
let hasConnected = false;
let resolveConnected!: () => void;
let rejectConnected!: (error: Error) => void;
const { commandImplementations, stoppedPromise: commandsStoppedPromise } =
createSandboxCommandImplementations({
workingDirectory: options.workingDirectory,
env: options.env,
signal: abortController.signal,
});
const connected = new Promise<void>((resolve, reject) => {
resolveConnected = resolve;
rejectConnected = reject;
@@ -38,15 +58,39 @@ export async function startSandboxDaemonAsync(
const connectionLoop = (async () => {
while (!abortController.signal.aborted) {
try {
socket = new WebSocket(new URL('/sandbox/connect', options.serverUrl), {
const connectedSocket = new WebSocket(new URL('/sandbox/connect', options.serverUrl), {
handshakeTimeout: 10_000,
headers: { Authorization: `Bearer ${options.credential}` },
});
await waitForOpen(socket);
socket = connectedSocket;
await waitForOpen(connectedSocket);
options.logger.info('Sandbox MCP server connected.');
connectedSocket.on('message', async message => {
try {
const response = await handleMessageAsync(commandImplementations, message.toString());
// MCP fails pending calls on disconnect. Responses belong to this socket only.
if (connectedSocket.readyState !== WebSocket.OPEN) {
options.logger.warn(
'Sandbox command response was lost after disconnect. The command may have run and output may have been consumed.'
);
return;
}
await new Promise<void>((resolve, reject) => {
connectedSocket.send(JSON.stringify(response), error => {
if (error) {
reject(error);
} else {
resolve();
}
});
});
} catch (error) {
options.logger.warn({ err: error }, 'Could not send sandbox command response.');
}
});
hasConnected = true;
resolveConnected();
await waitForClose(socket);
await waitForClose(connectedSocket);
} catch (error: any) {
if (!hasConnected) {
const message = `Sandbox MCP server connection failed: ${error?.message ?? 'unknown error'}`;
@@ -82,6 +126,7 @@ export async function startSandboxDaemonAsync(
async stopAsync(): Promise<void> {
options.signal?.removeEventListener('abort', stop);
stop();
await commandsStoppedPromise;
await connectionLoop;
},
};
@@ -104,3 +149,48 @@ function waitForClose(socket: WebSocket): Promise<void> {
socket.once('error', reject);
});
}
async function handleMessageAsync(
commandImplementations: SandboxDaemonCommandImplementations,
message: string
): Promise<SandboxDaemonResponse> {
let rawRequest: unknown;
try {
rawRequest = JSON.parse(message);
} catch {
return { jsonrpc: '2.0', id: null, error: { code: -32700, message: 'Parse error' } };
}
const request = SandboxDaemonRequestZ.safeParse(rawRequest);
if (!request.success) {
return { jsonrpc: '2.0', id: null, error: { code: -32600, message: 'Invalid request' } };
}
const { id, method, params } = request.data;
if (!Object.hasOwn(SandboxDaemonCommands, method)) {
return { jsonrpc: '2.0', id, error: { code: -32601, message: 'Method not found' } };
}
const commandMethod = method as SandboxDaemonMethod;
const parsedParams = SandboxDaemonCommands[commandMethod].params.safeParse(params);
if (!parsedParams.success) {
return { jsonrpc: '2.0', id, error: { code: -32602, message: 'Invalid params' } };
}
try {
return {
jsonrpc: '2.0',
id,
result: await (
commandImplementations[commandMethod] as (
params: typeof parsedParams.data
) => Promise<SandboxDaemonCommandResult<typeof commandMethod>>
)(parsedParams.data),
};
} catch (error) {
return {
jsonrpc: '2.0',
id,
error: { code: -32603, message: error instanceof Error ? error.message : 'Internal error' },
};
}
}
@@ -0,0 +1,293 @@
import * as pty from 'node-pty';
import { type ChildProcess, spawn } from 'node:child_process';
import fs from 'node:fs/promises';
import { constants as osConstants } from 'node:os';
import path from 'node:path';
import { killProcessGroup } from '../../utils/processes';
const PROCESS_STOP_GRACE_PERIOD_MS = 1_000;
const SIGNAL_NAMES = new Map(
Object.entries(osConstants.signals)
.reverse()
.map(([name, number]) => [number, name])
);
export class ShellSessionManager {
// Keep completed sessions so callers can still read their output and exit status.
private readonly sessions = new Map<number, CommandSession>();
private nextSessionId = 1;
public readonly stoppedPromise: Promise<void>;
public constructor(
private readonly options: {
workingDirectory: string;
env: NodeJS.ProcessEnv;
signal: AbortSignal;
}
) {
this.stoppedPromise = (async () => {
if (!options.signal.aborted) {
await new Promise<void>(resolve => {
options.signal.addEventListener('abort', () => resolve(), { once: true });
});
}
await this.stopAsync();
})();
// Cleanup can fail before the owner awaits it.
this.stoppedPromise.catch(() => {});
}
public async startAsync({
cmd,
workdir,
tty,
}: {
cmd: string;
workdir?: string;
tty?: boolean;
}): Promise<number> {
this.throwIfStopped();
const workingDirectory = workdir
? path.resolve(this.options.workingDirectory, workdir)
: this.options.workingDirectory;
await validateWorkingDirectoryAsync(workingDirectory);
this.throwIfStopped();
const sessionId = this.nextSessionId++;
const session = tty
? startPtyCommand({ command: cmd, workingDirectory, env: this.options.env })
: startPipeCommand({ command: cmd, workingDirectory, env: this.options.env });
this.sessions.set(sessionId, session);
return sessionId;
}
public write(sessionId: number, chars: string): void {
this.throwIfStopped();
const session = this.getSession(sessionId);
if (!session.isCompleted && !session.error) {
session.write(chars);
}
}
public async readAsync(
sessionId: number,
yieldTimeMs: number
): Promise<
{ output: string } & (
| { exitCode: number }
| { terminationSignal: string }
| { sessionId: number }
)
> {
this.throwIfStopped();
const session = this.getSession(sessionId);
await waitForSessionsAsync([session], yieldTimeMs);
if (session.error) {
throw session.error;
}
const output = session.output;
session.output = '';
const result = {
output,
};
if (session.terminationSignal !== undefined) {
return { ...result, terminationSignal: session.terminationSignal };
}
if (session.exitCode !== undefined) {
return { ...result, exitCode: session.exitCode };
}
return { ...result, sessionId };
}
private async stopAsync(): Promise<void> {
const sessions = [...this.sessions.values()];
// Only signal groups while their leaders are alive. After a leader exits, its PID may
// be reused. Any surviving descendants are left to VM teardown.
for (const session of sessions) {
if (!session.hasLeaderExited) {
killProcessGroup(session.process, 'SIGTERM');
}
}
await waitForSessionsAsync(sessions, PROCESS_STOP_GRACE_PERIOD_MS);
for (const session of sessions) {
if (!session.hasLeaderExited) {
killProcessGroup(session.process, 'SIGKILL');
}
}
await waitForSessionsAsync(sessions, PROCESS_STOP_GRACE_PERIOD_MS);
this.sessions.clear();
}
private throwIfStopped(): void {
this.options.signal.throwIfAborted();
}
private getSession(sessionId: number): CommandSession {
const session = this.sessions.get(sessionId);
if (!session) {
throw new Error(`Command session ${sessionId} does not exist.`);
}
return session;
}
}
type CommandSession = {
process: ChildProcess | pty.IPty;
completed: Promise<void>;
resolveCompleted: () => void;
isCompleted: boolean;
hasLeaderExited: boolean;
// Unread output is intentionally unlimited for now and can exhaust memory. Reading clears it.
output: string;
exitCode?: number;
terminationSignal?: string;
error?: Error;
write(chars: string): void;
};
function startPipeCommand({
command,
workingDirectory,
env,
}: {
command: string;
workingDirectory: string;
env: NodeJS.ProcessEnv;
}): CommandSession {
const child = spawn(command, {
cwd: workingDirectory,
env,
shell: env.SHELL ?? true,
detached: true,
});
const session = createSession(child);
session.write = (chars: string): void => {
child.stdin.write(chars);
};
child.stdout.setEncoding('utf8');
child.stderr.setEncoding('utf8');
child.stdout.on('data', data => {
session.output += data;
});
child.stderr.on('data', data => {
session.output += data;
});
child.stdin.on('error', () => {});
child.once('exit', () => {
session.hasLeaderExited = true;
});
child.once('error', error => {
if (!session.isCompleted) {
session.hasLeaderExited = true;
session.isCompleted = true;
session.error = error;
session.resolveCompleted();
}
});
child.once('close', (exitCode, signal) => {
if (!session.isCompleted) {
session.isCompleted = true;
if (signal) {
session.terminationSignal = signal;
} else {
session.exitCode = exitCode ?? 1;
}
session.resolveCompleted();
}
});
return session;
}
function startPtyCommand({
command,
workingDirectory,
env,
}: {
command: string;
workingDirectory: string;
env: NodeJS.ProcessEnv;
}): CommandSession {
const shell = env.SHELL ?? '/bin/sh';
const terminal = pty.spawn(shell, ['-c', command], {
cwd: workingDirectory,
env,
name: 'xterm-256color',
cols: 80,
rows: 24,
});
const session = createSession(terminal);
session.write = (chars: string): void => {
terminal.write(chars);
};
terminal.onData(data => {
session.output += data;
});
terminal.onExit(({ exitCode, signal }) => {
session.hasLeaderExited = true;
session.isCompleted = true;
if (signal) {
session.terminationSignal = SIGNAL_NAMES.get(signal) ?? `SIGNAL_${signal}`;
} else {
session.exitCode = exitCode;
}
session.resolveCompleted();
});
return session;
}
function createSession(process: CommandSession['process']): CommandSession {
let resolveCompleted!: () => void;
const completed = new Promise<void>(resolve => {
resolveCompleted = resolve;
});
return {
process,
completed,
resolveCompleted,
isCompleted: false,
hasLeaderExited: false,
output: '',
write: () => {},
};
}
async function validateWorkingDirectoryAsync(workingDirectory: string): Promise<void> {
let stats;
try {
stats = await fs.stat(workingDirectory);
} catch (error: any) {
if (error?.code === 'ENOENT') {
throw new Error(`Working directory does not exist: ${workingDirectory}`);
}
if (error?.code === 'EACCES') {
throw new Error(`Working directory is not accessible: ${workingDirectory}`);
}
throw error;
}
if (!stats.isDirectory()) {
throw new Error(`Working directory is not a directory: ${workingDirectory}`);
}
try {
await fs.access(workingDirectory, fs.constants.R_OK | fs.constants.X_OK);
} catch (error: any) {
if (error?.code === 'EACCES') {
throw new Error(`Working directory is not accessible: ${workingDirectory}`);
}
throw error;
}
}
async function waitForSessionsAsync(sessions: CommandSession[], timeoutMs: number): Promise<void> {
let timer: NodeJS.Timeout | undefined;
try {
await Promise.race([
Promise.all(sessions.map(session => session.completed)),
new Promise<void>(resolve => {
timer = setTimeout(resolve, timeoutMs);
}),
]);
} finally {
clearTimeout(timer);
}
}
+10 -4
View File
@@ -10,14 +10,20 @@ export function isChildProcessAlive(child: ChildProcess): boolean {
* Kill a detached spawn's process group. Negated pid targets the group so bash/sleep
* children cannot survive after the parent is gone (e.g. across upterm redial).
*/
export function killProcessGroup(child: ChildProcess): void {
export function killProcessGroup(
child: { pid?: number; kill(signal?: NodeJS.Signals): void },
signal: NodeJS.Signals = 'SIGTERM'
): void {
if (child.pid == null) {
return;
}
try {
process.kill(-child.pid, 'SIGTERM');
} catch {
child.kill();
process.kill(-child.pid, signal);
} catch (error: any) {
// ESRCH means the process group no longer exists, so there is nothing left to stop.
if (error?.code !== 'ESRCH') {
child.kill(signal);
}
}
}
@@ -1,6 +1,6 @@
{
"name": "create-eas-build-function",
"version": "24.3.0",
"version": "24.5.0",
"description": "Create functions for use in EAS Build custom builds.",
"keywords": [
"expo",
@@ -35,7 +35,7 @@
"clean": "rimraf ./build/ \"*.tsbuildinfo\""
},
"dependencies": {
"@expo/steps": "24.3.0"
"@expo/steps": "24.5.0"
},
"devDependencies": {
"@expo/package-manager": "1.9.10",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@expo/eas-build-job",
"version": "24.3.0",
"version": "24.5.0",
"bugs": "https://github.com/expo/eas-cli/issues",
"license": "MIT",
"author": "Expo <[email protected]>",
+5 -2
View File
@@ -75,8 +75,11 @@ export const SandboxDaemonResponseZ = z.union([
z.strictObject({ jsonrpc: z.literal('2.0'), id: z.string(), result: z.unknown().nonoptional() }),
z.strictObject({
jsonrpc: z.literal('2.0'),
id: z.string(),
error: z.strictObject({ code: z.number().int(), message: z.string() }),
id: z.string().nullable(),
error: z.strictObject({
code: z.number().int(),
message: z.string(),
}),
}),
]);
+133 -133
View File
File diff suppressed because it is too large. Load diff
+4 -4
View File
@@ -1,6 +1,6 @@
{
"name": "eas-cli",
"version": "24.4.1",
"version": "24.5.0",
"description": "EAS command line tool",
"keywords": [
"cli",
@@ -48,8 +48,8 @@
"@expo/code-signing-certificates": "0.0.5",
"@expo/config": "55.0.10",
"@expo/config-plugins": "55.0.7",
"@expo/eas-build-job": "24.3.0",
"@expo/eas-json": "24.3.0",
"@expo/eas-build-job": "24.5.0",
"@expo/eas-json": "24.5.0",
"@expo/env": "^1.0.0",
"@expo/json-file": "8.3.3",
"@expo/logger": "24.0.0",
@@ -64,7 +64,7 @@
"@expo/results": "1.0.0",
"@expo/rudder-sdk-node": "1.1.1",
"@expo/spawn-async": "1.7.2",
"@expo/steps": "24.3.0",
"@expo/steps": "24.5.0",
"@expo/timeago.js": "1.0.0",
"@oclif/core": "^4.8.3",
"@oclif/plugin-autocomplete": "^3.2.40",
@@ -0,0 +1,173 @@
import * as osascript from '@expo/osascript';
import spawnAsync from '@expo/spawn-async';
import {
ensureSimulatorAppOpenedAsync,
getSimulatorAppIdAsync,
openSimulatorAppAsync,
} from '../simulator';
jest.mock('@expo/osascript');
jest.mock('@expo/spawn-async');
jest.mock('../../../log');
const UDID = '5B3F6A41-9F2A-4C7B-8B26-2A2E39B3A1D0';
beforeEach(() => {
jest.mocked(osascript.safeIdOfAppAsync).mockReset();
jest.mocked(osascript.execAsync).mockReset();
jest.mocked(spawnAsync).mockReset();
});
describe(getSimulatorAppIdAsync, () => {
it('returns the Simulator.app id on Xcode 26 and older', async () => {
jest.mocked(osascript.safeIdOfAppAsync).mockResolvedValueOnce('com.apple.iphonesimulator');
await expect(getSimulatorAppIdAsync()).resolves.toBe('com.apple.iphonesimulator');
expect(osascript.safeIdOfAppAsync).toHaveBeenCalledTimes(1);
expect(osascript.safeIdOfAppAsync).toHaveBeenCalledWith('Simulator');
expect(spawnAsync).not.toHaveBeenCalled();
});
it('returns the DeviceHub.app id on Xcode 27', async () => {
jest
.mocked(osascript.safeIdOfAppAsync)
.mockResolvedValueOnce(null)
.mockResolvedValueOnce('com.apple.dt.Devices');
await expect(getSimulatorAppIdAsync()).resolves.toBe('com.apple.dt.Devices');
expect(osascript.safeIdOfAppAsync).toHaveBeenNthCalledWith(1, 'Simulator');
expect(osascript.safeIdOfAppAsync).toHaveBeenNthCalledWith(2, 'DeviceHub');
expect(spawnAsync).not.toHaveBeenCalled();
});
it('still checks DeviceHub when the Simulator lookup returns an empty id', async () => {
jest
.mocked(osascript.safeIdOfAppAsync)
.mockResolvedValueOnce('')
.mockResolvedValueOnce('com.apple.dt.Devices');
await expect(getSimulatorAppIdAsync()).resolves.toBe('com.apple.dt.Devices');
});
it('falls back to the Simulator.app Info.plist inside the selected Xcode', async () => {
jest.mocked(osascript.safeIdOfAppAsync).mockResolvedValue(null);
jest
.mocked(spawnAsync)
.mockResolvedValueOnce({ stdout: '/Applications/Xcode.app/Contents/Developer\n' } as any)
.mockResolvedValueOnce({ stdout: 'com.apple.iphonesimulator\n' } as any);
await expect(getSimulatorAppIdAsync()).resolves.toBe('com.apple.iphonesimulator');
expect(spawnAsync).toHaveBeenNthCalledWith(1, 'xcode-select', ['--print-path']);
expect(spawnAsync).toHaveBeenNthCalledWith(2, 'defaults', [
'read',
'/Applications/Xcode.app/Contents/Developer/Applications/Simulator.app/Contents/Info.plist',
'CFBundleIdentifier',
]);
});
it('falls back to the DeviceHub.app Info.plist inside the selected Xcode', async () => {
jest.mocked(osascript.safeIdOfAppAsync).mockResolvedValue(null);
jest
.mocked(spawnAsync)
.mockResolvedValueOnce({ stdout: '/Applications/Xcode.app/Contents/Developer\n' } as any)
.mockRejectedValueOnce(new Error('does not exist'))
.mockResolvedValueOnce({ stdout: 'com.apple.dt.Devices\n' } as any);
await expect(getSimulatorAppIdAsync()).resolves.toBe('com.apple.dt.Devices');
expect(spawnAsync).toHaveBeenNthCalledWith(3, 'defaults', [
'read',
'/Applications/Xcode.app/Contents/Applications/DeviceHub.app/Contents/Info.plist',
'CFBundleIdentifier',
]);
});
it('returns undefined when neither app can be found', async () => {
jest.mocked(osascript.safeIdOfAppAsync).mockResolvedValue(null);
jest.mocked(spawnAsync).mockRejectedValue(new Error('not found'));
await expect(getSimulatorAppIdAsync()).resolves.toBeUndefined();
});
});
describe(openSimulatorAppAsync, () => {
it('opens Simulator.app focused on the device on Xcode 26 and older', async () => {
jest.mocked(spawnAsync).mockResolvedValue({} as any);
await openSimulatorAppAsync(UDID);
expect(spawnAsync).toHaveBeenCalledTimes(1);
expect(spawnAsync).toHaveBeenCalledWith('open', [
'-a',
'Simulator',
'--args',
'-CurrentDeviceUDID',
UDID,
]);
});
it('opens Device Hub focused on the device when Simulator.app is missing', async () => {
jest
.mocked(spawnAsync)
.mockRejectedValueOnce(new Error('Unable to find application named Simulator'))
.mockResolvedValueOnce({} as any);
await openSimulatorAppAsync(UDID);
expect(spawnAsync).toHaveBeenNthCalledWith(2, 'open', [`devices://device/open?id=${UDID}`]);
});
it('opens Device Hub without focusing when no device is given', async () => {
jest
.mocked(spawnAsync)
.mockRejectedValueOnce(new Error('Unable to find application named Simulator'))
.mockResolvedValueOnce({} as any);
await openSimulatorAppAsync('');
expect(spawnAsync).toHaveBeenNthCalledWith(2, 'open', ['-a', 'DeviceHub']);
});
it('rethrows the Simulator.app error when Device Hub cannot be opened either', async () => {
jest
.mocked(spawnAsync)
.mockRejectedValueOnce(new Error('LaunchServices failed to open Simulator'))
.mockRejectedValueOnce(new Error('Unable to find application named DeviceHub'));
await expect(openSimulatorAppAsync(UDID)).rejects.toThrow(
'LaunchServices failed to open Simulator'
);
});
});
describe(ensureSimulatorAppOpenedAsync, () => {
it('treats a running Device Hub process as the simulator app being open', async () => {
jest.mocked(osascript.execAsync).mockResolvedValue('1');
await ensureSimulatorAppOpenedAsync(UDID);
expect(osascript.execAsync).toHaveBeenCalledWith(
'tell app "System Events" to count processes whose name is "Simulator" or name is "DeviceHub"'
);
expect(spawnAsync).not.toHaveBeenCalled();
});
it('opens the simulator app when neither process is running', async () => {
jest.mocked(osascript.execAsync).mockResolvedValueOnce('0').mockResolvedValue('1');
jest.mocked(spawnAsync).mockResolvedValue({} as any);
await ensureSimulatorAppOpenedAsync(UDID);
expect(spawnAsync).toHaveBeenCalledWith('open', [
'-a',
'Simulator',
'--args',
'-CurrentDeviceUDID',
UDID,
]);
});
});
@@ -0,0 +1,55 @@
import spawnAsync from '@expo/spawn-async';
import { getSimulatorAppIdAsync } from '../simulator';
import { validateSystemRequirementsAsync } from '../systemRequirements';
import { getXcodeVersionAsync } from '../xcode';
import { isXcrunInstalledAsync } from '../xcrun';
jest.mock('@expo/spawn-async');
jest.mock('../simulator');
jest.mock('../xcode');
jest.mock('../xcrun');
jest.mock('../../../log');
const originalPlatform = process.platform;
beforeEach(() => {
Object.defineProperty(process, 'platform', { value: 'darwin' });
jest.mocked(getXcodeVersionAsync).mockResolvedValue('27.0.0');
jest.mocked(isXcrunInstalledAsync).mockResolvedValue(true);
jest.mocked(spawnAsync).mockResolvedValue({} as any);
});
afterAll(() => {
Object.defineProperty(process, 'platform', { value: originalPlatform });
});
describe(validateSystemRequirementsAsync, () => {
it.each([
['Simulator.app', 'com.apple.iphonesimulator'],
['Simulator.app trampoline', 'com.apple.CoreSimulator.SimulatorTrampoline'],
['Device Hub', 'com.apple.dt.Devices'],
])('accepts %s', async (_label, appId) => {
jest.mocked(getSimulatorAppIdAsync).mockResolvedValue(appId);
await expect(validateSystemRequirementsAsync()).resolves.toBeUndefined();
expect(spawnAsync).toHaveBeenCalledWith('xcrun', ['simctl', 'help']);
});
it('rejects an unknown app id', async () => {
jest.mocked(getSimulatorAppIdAsync).mockResolvedValue('com.example.Other');
await expect(validateSystemRequirementsAsync()).rejects.toThrow(
"identified as 'com.example.Other'"
);
});
it('rejects when no simulator host app can be found', async () => {
jest.mocked(getSimulatorAppIdAsync).mockResolvedValue(undefined);
await expect(validateSystemRequirementsAsync()).rejects.toThrow(
"Can't determine id of Device Hub or Simulator app"
);
});
});
+53 -7
View File
@@ -1,5 +1,6 @@
import * as osascript from '@expo/osascript';
import spawnAsync from '@expo/spawn-async';
import path from 'path';
import { simctlAsync } from './simctl';
import Log from '../../log';
@@ -129,13 +130,26 @@ export async function ensureSimulatorBootedAsync(simulator: IosSimulator): Promi
await simctlAsync(['boot', simulator.udid]);
}
// TODO: once Xcode 27 is stable, try DeviceHub before Simulator here and in getSimulatorAppIdAsync,
// in lockstep with @expo/cli (see SimulatorAppPrerequisite.ts there).
export async function openSimulatorAppAsync(simulatorUdid: string): Promise<void> {
const args = ['-a', 'Simulator'];
if (simulatorUdid) {
// This has no effect if the app is already running.
args.push('--args', '-CurrentDeviceUDID', simulatorUdid);
try {
const args = ['-a', 'Simulator'];
if (simulatorUdid) {
// This has no effect if the app is already running.
args.push('--args', '-CurrentDeviceUDID', simulatorUdid);
}
await spawnAsync('open', args);
} catch (error) {
const deviceHubArgs = simulatorUdid
? [`devices://device/open?id=${simulatorUdid}`]
: ['-a', 'DeviceHub'];
try {
await spawnAsync('open', deviceHubArgs);
} catch {
throw error;
}
}
await spawnAsync('open', args);
}
export async function launchAppAsync(
@@ -171,7 +185,7 @@ async function waitForSimulatorAppToStartAsync(
async function isSimulatorAppRunningAsync(): Promise<boolean> {
try {
const result = await osascript.execAsync(
'tell app "System Events" to count processes whose name is "Simulator"'
'tell app "System Events" to count processes whose name is "Simulator" or name is "DeviceHub"'
);
if (result.trim() === '0') {
@@ -205,9 +219,41 @@ export async function installAppAsync(deviceId: string, filePath: string): Promi
Log.succeed('Successfully installed your app on the simulator!');
}
const XCODE_SIMULATOR_INFO_PLIST_PATH = './Applications/Simulator.app/Contents/Info.plist';
const XCODE_DEVICE_HUB_INFO_PLIST_PATH = '../Applications/DeviceHub.app/Contents/Info.plist';
export async function getSimulatorAppIdAsync(): Promise<string | undefined> {
// TODO: resolve DeviceHub before Simulator once Xcode 27 is stable, see openSimulatorAppAsync
const launchServicesAppId =
(await osascript.safeIdOfAppAsync('Simulator')) ||
(await osascript.safeIdOfAppAsync('DeviceHub'));
if (launchServicesAppId) {
return launchServicesAppId;
}
const xcodePath = await getXcodeSelectPathAsync();
if (!xcodePath) {
return undefined;
}
return (
(await getInfoPlistBundleIdAsync(path.join(xcodePath, XCODE_SIMULATOR_INFO_PLIST_PATH))) ||
(await getInfoPlistBundleIdAsync(path.join(xcodePath, XCODE_DEVICE_HUB_INFO_PLIST_PATH)))
);
}
async function getXcodeSelectPathAsync(): Promise<string | undefined> {
try {
return (await osascript.execAsync('id of app "Simulator"')).trim();
const { stdout } = await spawnAsync('xcode-select', ['--print-path']);
return stdout.trim() || undefined;
} catch {
return undefined;
}
}
async function getInfoPlistBundleIdAsync(infoPlistPath: string): Promise<string | undefined> {
try {
const { stdout } = await spawnAsync('defaults', ['read', infoPlistPath, 'CFBundleIdentifier']);
return stdout.trim() || undefined;
} catch {
return undefined;
}
@@ -70,16 +70,17 @@ async function assertSimulatorAppInstalledAsync(): Promise<void> {
const simulatorAppId = await getSimulatorAppIdAsync();
if (!simulatorAppId) {
throw new Error(
`Can't determine id of Simulator app; the Simulator is most likely not installed on this machine. Run 'sudo xcode-select -s /Applications/Xcode.app/Contents/Developer'`
`Can't determine id of Device Hub or Simulator app; it is most likely not installed on this machine. Run 'sudo xcode-select -s /Applications/Xcode.app/Contents/Developer'`
);
}
if (
simulatorAppId !== 'com.apple.dt.Devices' &&
simulatorAppId !== 'com.apple.iphonesimulator' &&
simulatorAppId !== 'com.apple.CoreSimulator.SimulatorTrampoline'
) {
throw new Error(
`Simulator is installed but is identified as '${simulatorAppId}', can't recognize what that is`
`Device Hub or Simulator is installed but is identified as '${simulatorAppId}', can't recognize what that is`
);
}
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@expo/eas-json",
"version": "24.3.0",
"version": "24.5.0",
"description": "A library for interacting with eas.json",
"homepage": "https://github.com/expo/eas-cli",
"bugs": "https://github.com/expo/eas-cli/issues",
@@ -32,7 +32,7 @@
},
"dependencies": {
"@babel/code-frame": "7.23.5",
"@expo/eas-build-job": "24.3.0",
"@expo/eas-build-job": "24.5.0",
"chalk": "4.1.2",
"env-string": "1.0.1",
"fs-extra": "11.2.0",
+3 -3
View File
@@ -1,6 +1,6 @@
{
"name": "eas-cli-local-build-plugin",
"version": "24.4.0",
"version": "24.5.0",
"description": "Tool for running EAS compatible builds on a local machine.",
"bugs": "https://github.com/expo/eas-cli/issues",
"license": "BUSL-1.1",
@@ -26,9 +26,9 @@
"clean": "rimraf node_modules dist coverage \"*.tsbuildinfo\""
},
"dependencies": {
"@expo/build-tools": "24.4.0",
"@expo/build-tools": "24.5.0",
"@expo/bunyan": "^4.0.1",
"@expo/eas-build-job": "24.3.0",
"@expo/eas-build-job": "24.5.0",
"@expo/logger": "24.0.0",
"@expo/spawn-async": "^1.7.2",
"@expo/turtle-spawn": "24.0.0",
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@expo/steps",
"version": "24.3.0",
"version": "24.5.0",
"bugs": "https://github.com/expo/eas-cli/issues",
"license": "BUSL-1.1",
"author": "Expo <[email protected]>",
@@ -27,7 +27,7 @@
"clean": "rimraf node_modules dist coverage \"*.tsbuildinfo\""
},
"dependencies": {
"@expo/eas-build-job": "24.3.0",
"@expo/eas-build-job": "24.5.0",
"@expo/logger": "24.0.0",
"@expo/spawn-async": "^1.7.2",
"arg": "^5.0.2",
+4 -4
View File
@@ -1,6 +1,6 @@
{
"name": "@expo/worker",
"version": "24.4.0",
"version": "24.5.0",
"private": true,
"scripts": {
"start": "yarn watch",
@@ -19,15 +19,15 @@
},
"dependencies": {
"@babel/core": "7.24.7",
"@expo/build-tools": "24.4.0",
"@expo/build-tools": "24.5.0",
"@expo/config-plugins": "55.0.7",
"@expo/config-types": "55.0.5",
"@expo/eas-build-job": "24.3.0",
"@expo/eas-build-job": "24.5.0",
"@expo/logger": "24.0.0",
"@expo/results": "1.0.0",
"@expo/rudder-sdk-node": "1.1.1",
"@expo/spawn-async": "1.7.2",
"@expo/steps": "24.3.0",
"@expo/steps": "24.5.0",
"@expo/template-file": "24.0.0",
"@expo/turtle-spawn": "24.0.0",
"@hapi/boom": "10.0.1",
Generated
+37 -17
View File
@@ -2291,14 +2291,14 @@ __metadata:
languageName: node
linkType: hard
"@expo/build-tools@npm:24.4.0, @expo/build-tools@workspace:packages/build-tools":
"@expo/build-tools@npm:24.5.0, @expo/build-tools@workspace:packages/build-tools":
version: 0.0.0-use.local
resolution: "@expo/build-tools@workspace:packages/build-tools"
dependencies:
"@expo/config": "npm:55.0.10"
"@expo/config-plugins": "npm:55.0.7"
"@expo/downloader": "npm:24.0.0"
"@expo/eas-build-job": "npm:24.3.0"
"@expo/eas-build-job": "npm:24.5.0"
"@expo/env": "npm:^0.4.0"
"@expo/logger": "npm:24.0.0"
"@expo/package-manager": "npm:1.9.10"
@@ -2306,7 +2306,7 @@ __metadata:
"@expo/repack-app": "npm:~0.9.0"
"@expo/results": "npm:^1.0.0"
"@expo/spawn-async": "npm:1.7.2"
"@expo/steps": "npm:24.3.0"
"@expo/steps": "npm:24.5.0"
"@expo/template-file": "npm:24.0.0"
"@expo/turtle-spawn": "npm:24.0.0"
"@expo/xcpretty": "npm:^4.3.1"
@@ -2340,6 +2340,7 @@ __metadata:
nock: "npm:13.4.0"
node-fetch: "npm:^2.7.0"
node-forge: "npm:^1.3.1"
node-pty: "npm:1.2.0-beta.15"
node-stream-zip: "npm:1.15.0"
nullthrows: "npm:^1.1.1"
plist: "npm:^3.1.0"
@@ -2559,7 +2560,7 @@ __metadata:
languageName: unknown
linkType: soft
"@expo/eas-build-job@npm:24.3.0, @expo/eas-build-job@workspace:packages/eas-build-job":
"@expo/eas-build-job@npm:24.5.0, @expo/eas-build-job@workspace:packages/eas-build-job":
version: 0.0.0-use.local
resolution: "@expo/eas-build-job@workspace:packages/eas-build-job"
dependencies:
@@ -2578,12 +2579,12 @@ __metadata:
languageName: unknown
linkType: soft
"@expo/eas-json@npm:24.3.0, @expo/eas-json@workspace:packages/eas-json":
"@expo/eas-json@npm:24.5.0, @expo/eas-json@workspace:packages/eas-json":
version: 0.0.0-use.local
resolution: "@expo/eas-json@workspace:packages/eas-json"
dependencies:
"@babel/code-frame": "npm:7.23.5"
"@expo/eas-build-job": "npm:24.3.0"
"@expo/eas-build-job": "npm:24.5.0"
"@tsconfig/node20": "npm:20.1.8"
"@types/babel__code-frame": "npm:7.0.3"
"@types/fs-extra": "npm:11.0.4"
@@ -2897,11 +2898,11 @@ __metadata:
languageName: node
linkType: hard
"@expo/steps@npm:24.3.0, @expo/steps@workspace:packages/steps":
"@expo/steps@npm:24.5.0, @expo/steps@workspace:packages/steps":
version: 0.0.0-use.local
resolution: "@expo/steps@workspace:packages/steps"
dependencies:
"@expo/eas-build-job": "npm:24.3.0"
"@expo/eas-build-job": "npm:24.5.0"
"@expo/logger": "npm:24.0.0"
"@expo/spawn-async": "npm:^1.7.2"
"@jest/globals": "npm:^29.7.0"
@@ -2971,15 +2972,15 @@ __metadata:
resolution: "@expo/worker@workspace:packages/worker"
dependencies:
"@babel/core": "npm:7.24.7"
"@expo/build-tools": "npm:24.4.0"
"@expo/build-tools": "npm:24.5.0"
"@expo/config-plugins": "npm:55.0.7"
"@expo/config-types": "npm:55.0.5"
"@expo/eas-build-job": "npm:24.3.0"
"@expo/eas-build-job": "npm:24.5.0"
"@expo/logger": "npm:24.0.0"
"@expo/results": "npm:1.0.0"
"@expo/rudder-sdk-node": "npm:1.1.1"
"@expo/spawn-async": "npm:1.7.2"
"@expo/steps": "npm:24.3.0"
"@expo/steps": "npm:24.5.0"
"@expo/template-file": "npm:24.0.0"
"@expo/turtle-spawn": "npm:24.0.0"
"@hapi/boom": "npm:10.0.1"
@@ -9830,7 +9831,7 @@ __metadata:
resolution: "create-eas-build-function@workspace:packages/create-eas-build-function"
dependencies:
"@expo/package-manager": "npm:1.9.10"
"@expo/steps": "npm:24.3.0"
"@expo/steps": "npm:24.5.0"
"@jest/globals": "npm:^29.7.0"
"@types/jest": "npm:^29.5.12"
"@types/node": "npm:20.14.2"
@@ -10414,9 +10415,9 @@ __metadata:
version: 0.0.0-use.local
resolution: "eas-cli-local-build-plugin@workspace:packages/local-build-plugin"
dependencies:
"@expo/build-tools": "npm:24.4.0"
"@expo/build-tools": "npm:24.5.0"
"@expo/bunyan": "npm:^4.0.1"
"@expo/eas-build-job": "npm:24.3.0"
"@expo/eas-build-job": "npm:24.5.0"
"@expo/logger": "npm:24.0.0"
"@expo/spawn-async": "npm:^1.7.2"
"@expo/turtle-spawn": "npm:24.0.0"
@@ -10471,8 +10472,8 @@ __metadata:
"@expo/code-signing-certificates": "npm:0.0.5"
"@expo/config": "npm:55.0.10"
"@expo/config-plugins": "npm:55.0.7"
"@expo/eas-build-job": "npm:24.3.0"
"@expo/eas-json": "npm:24.3.0"
"@expo/eas-build-job": "npm:24.5.0"
"@expo/eas-json": "npm:24.5.0"
"@expo/env": "npm:^1.0.0"
"@expo/json-file": "npm:8.3.3"
"@expo/logger": "npm:24.0.0"
@@ -10487,7 +10488,7 @@ __metadata:
"@expo/results": "npm:1.0.0"
"@expo/rudder-sdk-node": "npm:1.1.1"
"@expo/spawn-async": "npm:1.7.2"
"@expo/steps": "npm:24.3.0"
"@expo/steps": "npm:24.5.0"
"@expo/timeago.js": "npm:1.0.0"
"@graphql-codegen/cli": "npm:6.3.1"
"@graphql-codegen/introspection": "npm:5.0.2"
@@ -15369,6 +15370,15 @@ __metadata:
languageName: node
linkType: hard
"node-addon-api@npm:^7.1.0":
version: 7.1.1
resolution: "node-addon-api@npm:7.1.1"
dependencies:
node-gyp: "npm:latest"
checksum: 10c0/fb32a206276d608037fa1bcd7e9921e177fe992fc610d098aa3128baca3c0050fc1e014fa007e9b3874cf865ddb4f5bd9f43ccb7cbbbe4efaff6a83e920b17e9
languageName: node
linkType: hard
"node-domexception@npm:^1.0.0":
version: 1.0.0
resolution: "node-domexception@npm:1.0.0"
@@ -15528,6 +15538,16 @@ __metadata:
languageName: node
linkType: hard
"node-pty@npm:1.2.0-beta.15":
version: 1.2.0-beta.15
resolution: "node-pty@npm:1.2.0-beta.15"
dependencies:
node-addon-api: "npm:^7.1.0"
node-gyp: "npm:latest"
checksum: 10c0/2f2bcca18efe94f56fd5c9fc9611164f63af912b96286ea4d858f4886e3b25aa9c529e3d7680d47e5ddefc6c3e799786ed11dc4b80947e40438e00a67dbc2fbf
languageName: node
linkType: hard
"node-releases@npm:^2.0.1":
version: 2.0.1
resolution: "node-releases@npm:2.0.1"