mirror of
https://github.com/expo/eas-cli.git
synced 2026-11-14 17:50:32 -10:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
43068db22a | ||
|
|
2b7f1954fc | ||
|
|
3b64599aea | ||
|
|
96fe9a239e |
No files matched your search
+8
-2
@@ -8,12 +8,18 @@ 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))
|
||||
|
||||
### 🧹 Chores
|
||||
|
||||
## [24.4.1](https://github.com/expo/eas-cli/releases/tag/v24.4.1) - 2026-09-15
|
||||
|
||||
### 🧹 Chores
|
||||
|
||||
+2
-2
@@ -1,4 +1,4 @@
|
||||
{
|
||||
"STAGING": "24.4.1",
|
||||
"PRODUCTION": "24.4.1"
|
||||
"STAGING": "24.4.2",
|
||||
"PRODUCTION": "24.4.2"
|
||||
}
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"npmClient": "yarn",
|
||||
"version": "24.4.2",
|
||||
"version": "24.5.0",
|
||||
"$schema": "node_modules/lerna/schemas/lerna-schema.json"
|
||||
}
|
||||
@@ -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,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,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]>",
|
||||
|
||||
@@ -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
File diff suppressed because it is too large.
Load diff
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "eas-cli",
|
||||
"version": "24.4.2",
|
||||
"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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in new issue
Block a user