Compare commits

...
Author SHA1 Message Date
szdziedzic c3a5c99899 [eas-cli] Support --type serve-sim in eas simulator:start
# Why

Now that the API server exposes DeviceRunSessionType.ServeSim and ServeSimRunSessionRemoteConfig (previewUrl, streamUrl), wire up the CLI to actually launch a serve-sim session and tell the user how to use it. Companion to the build-tools step function in the previous PR.

# How

- Add ... on ServeSimRunSessionRemoteConfig { previewUrl streamUrl } to DeviceRunSessionByIdQuery and regenerate types.
- Register DeviceRunSessionType.ServeSim → 'serve-sim' in the flag-value map in eas simulator:start. Because the map is typed Record<DeviceRunSessionType, string>, the build was already failing on the missing entry (this is what was breaking verify-graphql-code in CI).
- Replace formatRemoteConfigShellSnippet with formatRemoteSessionInstructions in simulator/utils.ts. The switch over remoteConfig.__typename is now exhaustive: agent-device returns the export … shell snippet (unchanged); serve-sim returns "🌐 Open the following URL in your browser to access the simulator:" followed by previewUrl.
- start.ts and get.ts call the new function, so the headline now lives inside the formatter and stays variant-appropriate.
- Rename the user-facing "${type} daemon" copy to "${type} session" so it reads naturally for both agent-device and serve-sim.
- Tweak the command description to drop the agent-device-specific phrasing.

# Test Plan

- yarn typecheck, yarn lint, yarn fmt:check, yarn verify-graphql-code all pass locally.
- Smoke test by running eas simulator:start --platform ios --type serve-sim once the server side has rolled out and the build-tools step from the parent PR is published. Verify the CLI prints the preview URL and that opening it in a browser loads the simulator.
2026-05-12 16:35:10 +02:00
szdziedzic 7bcbe366e8 [build-tools] Add eas/start_serve_sim_remote_session function
# Why

Adds a new EAS build step function that starts a serve-sim server with a Cloudflare tunnel and reports the preview/stream tunnel URLs back to the API server as the remote config for a DeviceRunSession. Mirrors the existing eas/start_agent_device_remote_session flow.

# How

- Adds eas/start_serve_sim_remote_session in packages/build-tools/src/steps/functions/startServeSimRemoteSession.ts. It:
  - Selects Xcode and ensures cloudflared is installed via Homebrew.
  - Launches npx serve-sim-szdziedzic@latest --tunnel detached, with stdout/stderr captured to /tmp/serve-sim/serve-sim.log.
  - Polls the log for the Tunnel: and Stream: URLs (both *.trycloudflare.com), with a 60s timeout.
  - Calls StartDeviceRunSession with remoteConfig: { previewUrl, streamUrl }, then blocks so the tunnel stays alive until the session is stopped.
  - Declares supportedRuntimePlatforms: [BuildRuntimePlatform.DARWIN] so it only runs on iOS/macOS runners.
- Extracts the duplication shared with start_agent_device_remote_session into a new packages/build-tools/src/steps/utils/remoteDeviceRunSession.ts module (StartDeviceRunSession mutation, getDeviceRunSessionIdOrThrow, reportRemoteSessionConfigAndBlockAsync, spawnDetachedAsync, ensureBrewPackageInstalledAsync, readFileOrEmptyAsync). Both step functions now consume the shared helpers.
- Registers the new function in packages/build-tools/src/steps/easFunctions.ts.

# Test Plan

- yarn typecheck, yarn lint, yarn fmt:check all pass for the changed files.
- Not yet exercised end-to-end — requires a DEVICE_RUN_SESSION_ID injected by the API server. Once the server side is ready, smoke-test by triggering a device run session against an iOS/macOS runner and verifying that previewUrl/streamUrl show up on the session and the tunnel URLs are reachable.
2026-05-12 16:25:57 +02:00
10 changed files with 338 additions and 151 deletions

No files matched your search

@@ -43,6 +43,7 @@ import { createStartAgentDeviceRemoteSessionBuildFunction } from './functions/st
import { createStartAndroidEmulatorBuildFunction } from './functions/startAndroidEmulator';
import { createStartCuttlefishDeviceBuildFunction } from './functions/startCuttlefishDevice';
import { createStartIosSimulatorBuildFunction } from './functions/startIosSimulator';
import { createStartServeSimRemoteSessionBuildFunction } from './functions/startServeSimRemoteSession';
import { createUploadArtifactBuildFunction } from './functions/uploadArtifact';
import { createUploadToAscBuildFunction } from './functions/uploadToAsc';
import { createSetUpNpmrcBuildFunction } from './functions/useNpmToken';
@@ -83,6 +84,7 @@ export function getEasFunctions(ctx: CustomBuildContext): BuildFunction[] {
createStartAndroidEmulatorBuildFunction(),
createStartCuttlefishDeviceBuildFunction(),
createStartIosSimulatorBuildFunction(),
createStartServeSimRemoteSessionBuildFunction(ctx),
createInstallMaestroBuildFunction(),
createInstallPodsBuildFunction(),
@@ -1,45 +1,34 @@
import { SystemError } from '@expo/eas-build-job';
import { bunyan } from '@expo/logger';
import {
BuildFunction,
BuildRuntimePlatform,
BuildStepEnv,
BuildStepInput,
BuildStepInputValueTypeName,
} from '@expo/steps';
import spawn from '@expo/turtle-spawn';
import { graphql } from 'gql.tada';
import childProcess from 'node:child_process';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { CustomBuildContext } from '../../customBuildContext';
import { sleepAsync } from '../../utils/retry';
import { SystemError } from '@expo/eas-build-job';
import {
DetachedProcessHandle,
ensureBrewPackageInstalledAsync,
getDeviceRunSessionIdOrThrow,
spawnDetached,
uploadRemoteSessionConfigAsync,
} from '../utils/remoteDeviceRunSession';
const AGENT_DEVICE_REPO_URL = 'https://github.com/callstackincubator/agent-device.git';
const SRC_DIR = '/tmp/agent-device-src';
const RUN_DIR = '/tmp/agent-device';
const DAEMON_LOG = path.join(RUN_DIR, 'daemon.log');
const TUNNEL_LOG = path.join(RUN_DIR, 'cloudflared.log');
const DAEMON_JSON_PATH = path.join(os.homedir(), '.agent-device', 'daemon.json');
const XCODE_DEVELOPER_DIR = '/Applications/Xcode.app/Contents/Developer';
const CLOUDFLARED_LINUX_INSTALL_PATH = '/usr/local/bin/cloudflared';
const STARTUP_TIMEOUT_MS = 60_000;
const START_DEVICE_RUN_SESSION_MUTATION = graphql(`
mutation StartDeviceRunSession($deviceRunSessionId: ID!, $remoteConfig: JSONObject!) {
deviceRunSession {
startDeviceRunSession(
deviceRunSessionId: $deviceRunSessionId
remoteConfig: $remoteConfig
) {
id
status
}
}
}
`);
export function createStartAgentDeviceRemoteSessionBuildFunction(
ctx: CustomBuildContext
): BuildFunction {
@@ -59,14 +48,7 @@ export function createStartAgentDeviceRemoteSessionBuildFunction(
// Fail fast before any expensive setup if the orchestrator-injected
// DEVICE_RUN_SESSION_ID env var is missing — without it we cannot
// report the remote config back to the API server.
const deviceRunSessionId = env.DEVICE_RUN_SESSION_ID;
if (!deviceRunSessionId) {
throw new SystemError(
'DEVICE_RUN_SESSION_ID is not set. ' +
'This step must run as part of a device run session created by the API server, ' +
'which injects DEVICE_RUN_SESSION_ID into the job environment.'
);
}
const deviceRunSessionId = getDeviceRunSessionIdOrThrow(env);
const packageVersion = inputs.package_version.value as string | undefined;
const { runtimePlatform } = global;
@@ -74,9 +56,6 @@ export function createStartAgentDeviceRemoteSessionBuildFunction(
`Starting agent-device remote session (version: ${packageVersion ?? 'latest'}, runtime: ${runtimePlatform}).`
);
logger.info(`Preparing runtime directory at ${RUN_DIR}.`);
await fs.promises.mkdir(RUN_DIR, { recursive: true });
if (runtimePlatform === BuildRuntimePlatform.DARWIN) {
logger.info(`Selecting Xcode developer directory: ${XCODE_DEVELOPER_DIR}.`);
await spawn('sudo', ['xcode-select', '-s', XCODE_DEVELOPER_DIR], { env, logger });
@@ -103,13 +82,12 @@ export function createStartAgentDeviceRemoteSessionBuildFunction(
logger,
});
logger.info(`Launching agent-device daemon (log file: ${DAEMON_LOG}).`);
await spawnDetachedAsync({
command: 'bun run src/daemon.ts',
logger.info('Launching agent-device daemon.');
spawnDetached({
command: 'bun',
args: ['run', 'src/daemon.ts'],
cwd: SRC_DIR,
logFile: DAEMON_LOG,
env: { ...env, AGENT_DEVICE_DAEMON_SERVER_MODE: 'http' },
logger,
});
logger.info(`Waiting for daemon credentials at ${DAEMON_JSON_PATH}.`);
@@ -121,39 +99,28 @@ export function createStartAgentDeviceRemoteSessionBuildFunction(
const { port: daemonPort, token: daemonToken } = readDaemonInfo(DAEMON_JSON_PATH);
logger.info(`Daemon is listening on port ${daemonPort}; loaded auth token.`);
logger.info(
`Starting cloudflared tunnel to http://localhost:${daemonPort} (log file: ${TUNNEL_LOG}).`
);
await spawnDetachedAsync({
command: `${cloudflaredCommand} tunnel --url "http://localhost:${daemonPort}"`,
logFile: TUNNEL_LOG,
logger.info(`Starting cloudflared tunnel to http://localhost:${daemonPort}.`);
const cloudflared = spawnDetached({
command: cloudflaredCommand,
args: ['tunnel', '--url', `http://localhost:${daemonPort}`],
env,
logger,
});
logger.info('Waiting for a public tunnel URL.');
const tunnelUrl = await waitForMatchInLogAsync({
logFile: TUNNEL_LOG,
const tunnelUrl = await waitForMatchInOutputAsync({
process: cloudflared,
pattern: /https:\/\/[a-z0-9-]+\.trycloudflare\.com/,
timeoutMs: STARTUP_TIMEOUT_MS,
description: 'cloudflared tunnel',
});
logger.info(`Tunnel is ready at ${tunnelUrl}.`);
logger.info(
`Reporting agent-device remote config to the API server (device run session: ${deviceRunSessionId}).`
);
const result = await ctx.graphqlClient
.mutation(START_DEVICE_RUN_SESSION_MUTATION, {
deviceRunSessionId,
remoteConfig: { url: tunnelUrl, token: daemonToken },
})
.toPromise();
if (result.error) {
throw new SystemError(
`Failed to start device run session ${deviceRunSessionId}: ${result.error.message}`
);
}
await uploadRemoteSessionConfigAsync({
ctx,
deviceRunSessionId,
remoteConfig: { url: tunnelUrl, token: daemonToken },
logger,
});
logger.info('Remote session is live. Keeping the job alive until the session is stopped.');
// Keep the turtle job alive so the daemon and tunnel stay reachable
@@ -169,7 +136,7 @@ async function ensureCloudflaredInstalledAsync({
logger,
}: {
runtimePlatform: BuildRuntimePlatform;
env: NodeJS.ProcessEnv;
env: BuildStepEnv;
logger: bunyan;
}): Promise<string> {
if (runtimePlatform === BuildRuntimePlatform.DARWIN) {
@@ -199,33 +166,17 @@ function cloudflaredLinuxArchForNodeArch(arch: string): 'amd64' | 'arm64' {
if (arch === 'arm64') {
return 'arm64';
}
throw new Error(
throw new SystemError(
`Unsupported architecture for cloudflared on Linux: "${arch}". Expected "x64" or "arm64".`
);
}
async function ensureBrewPackageInstalledAsync({
name,
env,
logger,
}: {
name: string;
env: NodeJS.ProcessEnv;
logger: bunyan;
}): Promise<void> {
await spawn(
'bash',
['-c', `command -v ${name} >/dev/null 2>&1 || HOMEBREW_NO_AUTO_UPDATE=1 brew install ${name}`],
{ env, logger }
);
}
async function isCommandAvailableAsync({
command,
env,
}: {
command: string;
env: NodeJS.ProcessEnv;
env: BuildStepEnv;
}): Promise<boolean> {
try {
await spawn('bash', ['-c', `command -v ${command}`], { env, ignoreStdio: true });
@@ -241,7 +192,7 @@ async function cloneAgentDeviceAsync({
logger,
}: {
packageVersion: string | undefined;
env: NodeJS.ProcessEnv;
env: BuildStepEnv;
logger: bunyan;
}): Promise<void> {
const branchArgs = packageVersion ? ['--branch', `v${packageVersion}`] : [];
@@ -251,63 +202,27 @@ async function cloneAgentDeviceAsync({
});
}
async function spawnDetachedAsync({
command,
cwd,
logFile,
env,
logger,
}: {
command: string;
cwd?: string;
logFile: string;
env: NodeJS.ProcessEnv;
logger: bunyan;
}): Promise<void> {
// Launch the process fully detached so this function returns immediately and
// the grandchild survives the step. Stdio goes to a log file so the daemon
// output can be polled, and we unref so Node doesn't wait on it.
const fd = fs.openSync(logFile, 'a');
try {
const child = childProcess.spawn('bash', ['-c', command], {
cwd,
env,
detached: true,
stdio: ['ignore', fd, fd],
});
if (!child.pid) {
throw new Error(`Failed to spawn detached process: ${command}`);
}
child.unref();
logger.info(`Started detached process (pid ${child.pid}).`);
} finally {
fs.closeSync(fd);
}
}
async function waitForMatchInLogAsync({
logFile,
async function waitForMatchInOutputAsync({
process,
pattern,
timeoutMs,
description,
}: {
logFile: string;
process: DetachedProcessHandle;
pattern: RegExp;
timeoutMs: number;
description: string;
}): Promise<string> {
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
const content = await readFileOrEmptyAsync(logFile);
const match = pattern.exec(content);
const match = pattern.exec(process.getOutput());
if (match) {
return match[1] ?? match[0];
}
await sleepAsync(1_000);
}
const tail = await readFileOrEmptyAsync(logFile);
throw new Error(
`Timed out waiting for ${description} to start. Last log contents:\n${tail || '<empty>'}`
throw new SystemError(
`Timed out waiting for ${description} to start. Last output:\n${process.getOutput() || '<empty>'}`
);
}
@@ -330,15 +245,7 @@ async function waitForFileAsync({
}
await sleepAsync(1_000);
}
throw new Error(`Timed out waiting for ${description} to write ${filePath}.`);
}
async function readFileOrEmptyAsync(filePath: string): Promise<string> {
try {
return await fs.promises.readFile(filePath, 'utf8');
} catch {
return '';
}
throw new SystemError(`Timed out waiting for ${description} to write ${filePath}.`);
}
function readDaemonInfo(filePath: string): { port: number; token: string } {
@@ -350,7 +257,9 @@ function readDaemonInfo(filePath: string): { port: number; token: string } {
typeof (parsed as { httpPort: unknown }).httpPort !== 'number' ||
typeof (parsed as { token: unknown }).token !== 'string'
) {
throw new Error(`Expected ${filePath} to contain { "httpPort": <number>, "token": "..." }.`);
throw new SystemError(
`Expected ${filePath} to contain { "httpPort": <number>, "token": "..." }.`
);
}
const { httpPort, token } = parsed as { httpPort: number; token: string };
return { port: httpPort, token };
@@ -0,0 +1,95 @@
import { SystemError } from '@expo/eas-build-job';
import { BuildFunction, BuildRuntimePlatform } from '@expo/steps';
import spawn from '@expo/turtle-spawn';
import { CustomBuildContext } from '../../customBuildContext';
import { sleepAsync } from '../../utils/retry';
import {
DetachedProcessHandle,
ensureBrewPackageInstalledAsync,
getDeviceRunSessionIdOrThrow,
spawnDetached,
uploadRemoteSessionConfigAsync,
} from '../utils/remoteDeviceRunSession';
const XCODE_DEVELOPER_DIR = '/Applications/Xcode.app/Contents/Developer';
const STARTUP_TIMEOUT_MS = 60_000;
const TRYCLOUDFLARE_URL_PATTERN = /https:\/\/[a-z0-9-]+\.trycloudflare\.com/;
export function createStartServeSimRemoteSessionBuildFunction(
ctx: CustomBuildContext
): BuildFunction {
return new BuildFunction({
namespace: 'eas',
id: 'start_serve_sim_remote_session',
name: 'Start serve-sim remote session',
__metricsId: 'eas/start_serve_sim_remote_session',
supportedRuntimePlatforms: [BuildRuntimePlatform.DARWIN],
fn: async ({ logger }, { env }) => {
const deviceRunSessionId = getDeviceRunSessionIdOrThrow(env);
logger.info('Starting serve-sim remote session.');
logger.info(`Selecting Xcode developer directory: ${XCODE_DEVELOPER_DIR}.`);
await spawn('sudo', ['xcode-select', '-s', XCODE_DEVELOPER_DIR], { env, logger });
logger.info('Ensuring cloudflared is installed.');
await ensureBrewPackageInstalledAsync({ name: 'cloudflared', env, logger });
logger.info('Launching serve-sim with tunnel.');
const serveSim = spawnDetached({
command: 'npx',
args: ['serve-sim-szdziedzic@latest', '--tunnel'],
env,
});
logger.info('Waiting for serve-sim to report tunnel and stream URLs.');
const { previewUrl, streamUrl } = await waitForServeSimUrlsAsync({
serveSim,
timeoutMs: STARTUP_TIMEOUT_MS,
});
logger.info(`Preview URL: ${previewUrl}`);
logger.info(`Stream URL: ${streamUrl}`);
await uploadRemoteSessionConfigAsync({
ctx,
deviceRunSessionId,
remoteConfig: { previewUrl, streamUrl },
logger,
});
logger.info('Remote session is live. Keeping the job alive until the session is stopped.');
// Keep the turtle job alive so the serve-sim tunnel stays reachable
// until stopDeviceRunSession cancels the run.
await new Promise<never>(() => {});
},
});
}
async function waitForServeSimUrlsAsync({
serveSim,
timeoutMs,
}: {
serveSim: DetachedProcessHandle;
timeoutMs: number;
}): Promise<{ previewUrl: string; streamUrl: string }> {
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
const output = serveSim.getOutput();
const previewUrl = matchLabeledUrl(output, 'Tunnel');
const streamUrl = matchLabeledUrl(output, 'Stream');
if (previewUrl && streamUrl) {
return { previewUrl, streamUrl };
}
await sleepAsync(1_000);
}
throw new SystemError(
`Timed out waiting for serve-sim to report Tunnel and Stream URLs. Last output:\n${serveSim.getOutput() || '<empty>'}`
);
}
function matchLabeledUrl(content: string, label: string): string | null {
const labelPattern = new RegExp(`${label}:\\s*(${TRYCLOUDFLARE_URL_PATTERN.source})`);
const match = labelPattern.exec(content);
return match ? match[1] : null;
}
@@ -0,0 +1,109 @@
import { SystemError } from '@expo/eas-build-job';
import { bunyan } from '@expo/logger';
import { BuildStepEnv } from '@expo/steps';
import spawn from '@expo/turtle-spawn';
import { graphql } from 'gql.tada';
import { CustomBuildContext } from '../../customBuildContext';
const START_DEVICE_RUN_SESSION_MUTATION = graphql(`
mutation StartDeviceRunSession($deviceRunSessionId: ID!, $remoteConfig: JSONObject!) {
deviceRunSession {
startDeviceRunSession(
deviceRunSessionId: $deviceRunSessionId
remoteConfig: $remoteConfig
) {
id
status
}
}
}
`);
export function getDeviceRunSessionIdOrThrow(env: BuildStepEnv): string {
const deviceRunSessionId = env.DEVICE_RUN_SESSION_ID;
if (!deviceRunSessionId) {
throw new SystemError(
'DEVICE_RUN_SESSION_ID is not set. ' +
'This step must run as part of a device run session created by the API server, ' +
'which injects DEVICE_RUN_SESSION_ID into the job environment.'
);
}
return deviceRunSessionId;
}
export async function uploadRemoteSessionConfigAsync({
ctx,
deviceRunSessionId,
remoteConfig,
logger,
}: {
ctx: CustomBuildContext;
deviceRunSessionId: string;
remoteConfig: Record<string, unknown>;
logger: bunyan;
}): Promise<void> {
logger.info(
`Reporting remote config to the API server (device run session: ${deviceRunSessionId}).`
);
const result = await ctx.graphqlClient
.mutation(START_DEVICE_RUN_SESSION_MUTATION, { deviceRunSessionId, remoteConfig })
.toPromise();
if (result.error) {
throw new SystemError(
`Failed to start device run session ${deviceRunSessionId}: ${result.error.message}`
);
}
}
export async function ensureBrewPackageInstalledAsync({
name,
env,
logger,
}: {
name: string;
env: BuildStepEnv;
logger: bunyan;
}): Promise<void> {
await spawn(
'bash',
['-c', `command -v ${name} >/dev/null 2>&1 || HOMEBREW_NO_AUTO_UPDATE=1 brew install ${name}`],
{ env, logger }
);
}
export type DetachedProcessHandle = {
getOutput: () => string;
};
export function spawnDetached({
command,
args,
cwd,
env,
}: {
command: string;
args: string[];
cwd?: string;
env: BuildStepEnv;
}): DetachedProcessHandle {
const promise = spawn(command, args, {
cwd,
env,
stdio: ['ignore', 'pipe', 'pipe'],
detached: true,
});
// We don't await the process — it should outlive this step. Failures show
// up in the captured output; suppress unhandled rejections here.
promise.catch(() => {});
promise.child.unref();
let output = '';
const appendChunk = (chunk: Buffer | string): void => {
output += chunk.toString();
};
promise.child.stdout?.on('data', appendChunk);
promise.child.stderr?.on('data', appendChunk);
return { getOutput: () => output };
}
+54
View File
@@ -36660,6 +36660,11 @@
"kind": "OBJECT",
"name": "AgentDeviceRunSessionRemoteConfig",
"ofType": null
},
{
"kind": "OBJECT",
"name": "ServeSimRunSessionRemoteConfig",
"ofType": null
}
]
},
@@ -36711,6 +36716,12 @@
"description": null,
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "SERVE_SIM",
"description": null,
"isDeprecated": false,
"deprecationReason": null
}
],
"possibleTypes": null
@@ -61586,6 +61597,49 @@
"enumValues": null,
"possibleTypes": null
},
{
"kind": "OBJECT",
"name": "ServeSimRunSessionRemoteConfig",
"description": null,
"fields": [
{
"name": "previewUrl",
"description": null,
"args": [],
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "SCALAR",
"name": "String",
"ofType": null
}
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "streamUrl",
"description": null,
"args": [],
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "SCALAR",
"name": "String",
"ofType": null
}
},
"isDeprecated": false,
"deprecationReason": null
}
],
"inputFields": null,
"interfaces": [],
"enumValues": null,
"possibleTypes": null
},
{
"kind": "INPUT_OBJECT",
"name": "SetupConvexProjectInput",
@@ -7,7 +7,7 @@ import { DeviceRunSessionStatus } from '../../graphql/generated';
import { DeviceRunSessionQuery } from '../../graphql/queries/DeviceRunSessionQuery';
import Log, { link } from '../../log';
import { ora } from '../../ora';
import { formatRemoteConfigShellSnippet } from '../../simulator/utils';
import { formatRemoteSessionInstructions } from '../../simulator/utils';
export default class SimulatorGet extends EasCommand {
static override hidden = true;
@@ -58,9 +58,7 @@ export default class SimulatorGet extends EasCommand {
if (session.status === DeviceRunSessionStatus.InProgress) {
Log.newLine();
if (session.remoteConfig) {
Log.log('🔑 Run the following in your shell to attach to the session:');
Log.newLine();
Log.log(formatRemoteConfigShellSnippet(session.remoteConfig));
Log.log(formatRemoteSessionInstructions(session.remoteConfig));
} else {
Log.log(
'⏳ Session is starting up — remote config is not available yet. Re-run this command in a moment.'
@@ -16,7 +16,7 @@ import Log, { link } from '../../log';
import { ora } from '../../ora';
import {
DeviceRunSessionRemoteConfig,
formatRemoteConfigShellSnippet,
formatRemoteSessionInstructions,
} from '../../simulator/utils';
import { sleepAsync } from '../../utils/promise';
import nullthrows from 'nullthrows';
@@ -28,6 +28,7 @@ const POLL_TIMEOUT_MS = 15 * 60 * 1_000; // 15 minutes
// so adding a new enum value in codegen fails the build until it is wired up here.
const DEVICE_RUN_SESSION_TYPE_FLAG_VALUES: Record<DeviceRunSessionType, string> = {
[DeviceRunSessionType.AgentDevice]: 'agent-device',
[DeviceRunSessionType.ServeSim]: 'serve-sim',
};
const DEVICE_RUN_SESSION_TYPE_BY_FLAG_VALUE = Object.fromEntries(
@@ -39,7 +40,7 @@ const DEVICE_RUN_SESSION_TYPE_BY_FLAG_VALUE = Object.fromEntries(
export default class SimulatorStart extends EasCommand {
static override hidden = true;
static override description =
'[EXPERIMENTAL] start a remote simulator session on EAS and get the credentials to connect to it with the CLI tool of your choice';
'[EXPERIMENTAL] start a remote simulator session on EAS and get instructions to connect to it';
static override flags = {
platform: Flags.option({
@@ -97,7 +98,7 @@ export default class SimulatorStart extends EasCommand {
throw err;
}
const pollSpinner = ora(`⏳ Waiting for ${flags.type} daemon to start`).start();
const pollSpinner = ora(`⏳ Waiting for ${flags.type} session to be ready`).start();
const deadline = Date.now() + POLL_TIMEOUT_MS;
let remoteConfig: DeviceRunSessionRemoteConfig | undefined;
@@ -110,7 +111,7 @@ export default class SimulatorStart extends EasCommand {
session.status === DeviceRunSessionStatus.Stopped
) {
throw new Error(
`Device run session ${deviceRunSessionId} ${session.status.toLowerCase()} before the ${flags.type} daemon was ready. ${link(jobRunUrl)}`
`Device run session ${deviceRunSessionId} ${session.status.toLowerCase()} before the ${flags.type} session was ready. ${link(jobRunUrl)}`
);
}
@@ -121,36 +122,34 @@ export default class SimulatorStart extends EasCommand {
jobRunStatus === JobRunStatus.Finished
) {
throw new Error(
`Turtle job run for device run session ${deviceRunSessionId} ${jobRunStatus.toLowerCase()} before the ${flags.type} daemon was ready. ${link(jobRunUrl)}`
`Turtle job run for device run session ${deviceRunSessionId} ${jobRunStatus.toLowerCase()} before the ${flags.type} session was ready. ${link(jobRunUrl)}`
);
}
if (session.remoteConfig) {
remoteConfig = session.remoteConfig;
pollSpinner.succeed(`🎉 ${flags.type} daemon is ready`);
pollSpinner.succeed(`🎉 ${flags.type} session is ready`);
break;
}
await sleepAsync(POLL_INTERVAL_MS);
}
} catch (err) {
pollSpinner.fail(`Failed while polling for ${flags.type} daemon to start`);
pollSpinner.fail(`Failed while polling for ${flags.type} session to be ready`);
await ensureDeviceRunSessionStoppedSafelyAsync(graphqlClient, deviceRunSessionId);
throw err;
}
if (!remoteConfig) {
pollSpinner.fail(`Timed out waiting for ${flags.type} daemon to start`);
pollSpinner.fail(`Timed out waiting for ${flags.type} session to be ready`);
await ensureDeviceRunSessionStoppedSafelyAsync(graphqlClient, deviceRunSessionId);
throw new Error(
`Timed out after ${Math.round(POLL_TIMEOUT_MS / 1000)}s waiting for ${flags.type} daemon to start. ${link(jobRunUrl)}`
`Timed out after ${Math.round(POLL_TIMEOUT_MS / 1000)}s waiting for ${flags.type} session to be ready. ${link(jobRunUrl)}`
);
}
Log.newLine();
Log.log(`🔑 Run the following in your shell to attach to ${flags.type}:`);
Log.newLine();
Log.log(formatRemoteConfigShellSnippet(remoteConfig));
Log.log(formatRemoteSessionInstructions(remoteConfig));
Log.newLine();
if (flags['non-interactive']) {
+10 -3
View File
@@ -5151,7 +5151,7 @@ export type DeviceRunSessionQueryByIdArgs = {
deviceRunSessionId: Scalars['ID']['input'];
};
export type DeviceRunSessionRemoteConfig = AgentDeviceRunSessionRemoteConfig;
export type DeviceRunSessionRemoteConfig = AgentDeviceRunSessionRemoteConfig | ServeSimRunSessionRemoteConfig;
export enum DeviceRunSessionStatus {
Errored = 'ERRORED',
@@ -5161,7 +5161,8 @@ export enum DeviceRunSessionStatus {
}
export enum DeviceRunSessionType {
AgentDevice = 'AGENT_DEVICE'
AgentDevice = 'AGENT_DEVICE',
ServeSim = 'SERVE_SIM'
}
export type DiscordUser = {
@@ -8651,6 +8652,12 @@ export type SentryProjectMutationDeleteSentryProjectArgs = {
sentryProjectId: Scalars['ID']['input'];
};
export type ServeSimRunSessionRemoteConfig = {
__typename?: 'ServeSimRunSessionRemoteConfig';
previewUrl: Scalars['String']['output'];
streamUrl: Scalars['String']['output'];
};
export type SetupConvexProjectInput = {
appId: Scalars['ID']['input'];
convexTeamConnectionId: Scalars['ID']['input'];
@@ -12694,7 +12701,7 @@ export type DeviceRunSessionByIdQueryVariables = Exact<{
}>;
export type DeviceRunSessionByIdQuery = { __typename?: 'RootQuery', deviceRunSessions: { __typename?: 'DeviceRunSessionQuery', byId: { __typename?: 'DeviceRunSession', id: string, status: DeviceRunSessionStatus, type: DeviceRunSessionType, app: { __typename?: 'App', id: string, slug: string, ownerAccount: { __typename?: 'Account', id: string, name: string } }, remoteConfig?: { __typename: 'AgentDeviceRunSessionRemoteConfig', url: string, token: string } | null, turtleJobRun?: { __typename?: 'JobRun', id: string, status: JobRunStatus } | null } } };
export type DeviceRunSessionByIdQuery = { __typename?: 'RootQuery', deviceRunSessions: { __typename?: 'DeviceRunSessionQuery', byId: { __typename?: 'DeviceRunSession', id: string, status: DeviceRunSessionStatus, type: DeviceRunSessionType, app: { __typename?: 'App', id: string, slug: string, ownerAccount: { __typename?: 'Account', id: string, name: string } }, remoteConfig?: { __typename: 'AgentDeviceRunSessionRemoteConfig', url: string, token: string } | { __typename: 'ServeSimRunSessionRemoteConfig', previewUrl: string, streamUrl: string } | null, turtleJobRun?: { __typename?: 'JobRun', id: string, status: JobRunStatus } | null } } };
export type EnvironmentSecretsByAppIdQueryVariables = Exact<{
appId: Scalars['String']['input'];
@@ -33,6 +33,10 @@ export const DeviceRunSessionQuery = {
url
token
}
... on ServeSimRunSessionRemoteConfig {
previewUrl
streamUrl
}
}
turtleJobRun {
id
+11 -1
View File
@@ -3,12 +3,22 @@ import { DeviceRunSessionByIdQuery } from '../graphql/generated';
type DeviceRunSessionByIdResult = DeviceRunSessionByIdQuery['deviceRunSessions']['byId'];
export type DeviceRunSessionRemoteConfig = NonNullable<DeviceRunSessionByIdResult['remoteConfig']>;
export function formatRemoteConfigShellSnippet(remoteConfig: DeviceRunSessionRemoteConfig): string {
export function formatRemoteSessionInstructions(
remoteConfig: DeviceRunSessionRemoteConfig
): string {
switch (remoteConfig.__typename) {
case 'AgentDeviceRunSessionRemoteConfig':
return [
'🔑 Run the following in your shell to attach to the agent-device daemon:',
'',
`export AGENT_DEVICE_DAEMON_BASE_URL='${remoteConfig.url}'`,
`export AGENT_DEVICE_DAEMON_AUTH_TOKEN='${remoteConfig.token}'`,
].join('\n');
case 'ServeSimRunSessionRemoteConfig':
return [
'🌐 Open the following URL in your browser to access the simulator:',
'',
remoteConfig.previewUrl,
].join('\n');
}
}