diff --git a/packages/build-tools/src/steps/easFunctions.ts b/packages/build-tools/src/steps/easFunctions.ts index b92ea54ba1..f36676be76 100644 --- a/packages/build-tools/src/steps/easFunctions.ts +++ b/packages/build-tools/src/steps/easFunctions.ts @@ -38,6 +38,7 @@ import { runGradleFunction } from './functions/runGradle'; import { createSaveBuildCacheFunction } from './functions/saveBuildCache'; import { createSaveCacheFunction } from './functions/saveCache'; import { createSendSlackMessageFunction } from './functions/sendSlackMessage'; +import { createStartAgentDeviceRemoteSessionBuildFunction } from './functions/startAgentDeviceRemoteSession'; import { createStartAndroidEmulatorBuildFunction } from './functions/startAndroidEmulator'; import { createStartCuttlefishDeviceBuildFunction } from './functions/startCuttlefishDevice'; import { createStartIosSimulatorBuildFunction } from './functions/startIosSimulator'; @@ -77,6 +78,7 @@ export function getEasFunctions(ctx: CustomBuildContext): BuildFunction[] { generateGymfileFromTemplateFunction(), runFastlaneFunction(), parseXcactivitylogFunction(), + createStartAgentDeviceRemoteSessionBuildFunction(), createStartAndroidEmulatorBuildFunction(), createStartCuttlefishDeviceBuildFunction(), createStartIosSimulatorBuildFunction(), diff --git a/packages/build-tools/src/steps/functions/startAgentDeviceRemoteSession.ts b/packages/build-tools/src/steps/functions/startAgentDeviceRemoteSession.ts new file mode 100644 index 0000000000..ceaff7d84b --- /dev/null +++ b/packages/build-tools/src/steps/functions/startAgentDeviceRemoteSession.ts @@ -0,0 +1,267 @@ +import { bunyan } from '@expo/logger'; +import { BuildFunction, BuildStepInput, BuildStepInputValueTypeName } from '@expo/steps'; +import spawn from '@expo/turtle-spawn'; +import childProcess from 'node:child_process'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; + +import { sleepAsync } from '../../utils/retry'; + +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 STARTUP_TIMEOUT_MS = 60_000; + +export function createStartAgentDeviceRemoteSessionBuildFunction(): BuildFunction { + return new BuildFunction({ + namespace: 'eas', + id: 'start_agent_device_remote_session', + name: 'Start agent device remote session', + __metricsId: 'eas/start_agent_device_remote_session', + inputProviders: [ + BuildStepInput.createProvider({ + id: 'package_version', + required: false, + allowedValueTypeName: BuildStepInputValueTypeName.STRING, + }), + ], + fn: async ({ logger }, { inputs, env }) => { + const packageVersion = inputs.package_version.value as string | undefined; + logger.info(`Starting agent-device remote session (version: ${packageVersion ?? 'latest'}).`); + + logger.info(`Preparing runtime directory at ${RUN_DIR}.`); + await fs.promises.mkdir(RUN_DIR, { recursive: true }); + + 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 ensureCloudflaredInstalledAsync({ env, logger }); + + logger.info('Ensuring bun is installed.'); + await ensureBunInstalledAsync({ env, logger }); + + logger.info( + packageVersion + ? `Cloning agent-device @ v${packageVersion} into ${SRC_DIR}.` + : `Cloning agent-device (latest) into ${SRC_DIR}.` + ); + await cloneAgentDeviceAsync({ packageVersion, env, logger }); + + logger.info('Installing agent-device dependencies.'); + await spawn('bun', ['install', '--production'], { + cwd: SRC_DIR, + env, + logger, + }); + + logger.info(`Launching agent-device daemon (log file: ${DAEMON_LOG}).`); + await spawnDetachedAsync({ + command: 'bun 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}.`); + await waitForFileAsync({ + filePath: DAEMON_JSON_PATH, + timeoutMs: STARTUP_TIMEOUT_MS, + description: 'agent-device daemon', + }); + 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: `cloudflared tunnel --url "http://localhost:${daemonPort}"`, + logFile: TUNNEL_LOG, + env, + logger, + }); + + logger.info('Waiting for a public tunnel URL.'); + const tunnelUrl = await waitForMatchInLogAsync({ + logFile: TUNNEL_LOG, + pattern: /https:\/\/[a-z0-9-]+\.trycloudflare\.com/, + timeoutMs: STARTUP_TIMEOUT_MS, + description: 'cloudflared tunnel', + }); + logger.info(`Tunnel is ready at ${tunnelUrl}.`); + + logger.info('Emitting agent-device credentials for the CLI to pick up:'); + logger.info(`export AGENT_DEVICE_DAEMON_BASE_URL="${tunnelUrl}"`); + logger.info(`export AGENT_DEVICE_DAEMON_AUTH_TOKEN="${daemonToken}"`); + + 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 + // until stopDeviceRunSession cancels the run. + await new Promise(() => {}); + }, + }); +} + +async function ensureCloudflaredInstalledAsync({ + env, + logger, +}: { + env: NodeJS.ProcessEnv; + logger: bunyan; +}): Promise { + await ensureBrewPackageInstalledAsync({ name: 'cloudflared', env, logger }); +} + +async function ensureBunInstalledAsync({ + env, + logger, +}: { + env: NodeJS.ProcessEnv; + logger: bunyan; +}): Promise { + await ensureBrewPackageInstalledAsync({ name: 'bun', env, logger }); +} + +async function ensureBrewPackageInstalledAsync({ + name, + env, + logger, +}: { + name: string; + env: NodeJS.ProcessEnv; + logger: bunyan; +}): Promise { + await spawn( + 'bash', + ['-c', `command -v ${name} >/dev/null 2>&1 || HOMEBREW_NO_AUTO_UPDATE=1 brew install ${name}`], + { env, logger } + ); +} + +async function cloneAgentDeviceAsync({ + packageVersion, + env, + logger, +}: { + packageVersion: string | undefined; + env: NodeJS.ProcessEnv; + logger: bunyan; +}): Promise { + const branchArgs = packageVersion ? ['--branch', `v${packageVersion}`] : []; + await spawn('git', ['clone', '--depth', '1', ...branchArgs, AGENT_DEVICE_REPO_URL, SRC_DIR], { + env, + logger, + }); +} + +async function spawnDetachedAsync({ + command, + cwd, + logFile, + env, + logger, +}: { + command: string; + cwd?: string; + logFile: string; + env: NodeJS.ProcessEnv; + logger: bunyan; +}): Promise { + // 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, + pattern, + timeoutMs, + description, +}: { + logFile: string; + pattern: RegExp; + timeoutMs: number; + description: string; +}): Promise { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + const content = await readFileOrEmptyAsync(logFile); + const match = pattern.exec(content); + 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 || ''}` + ); +} + +async function waitForFileAsync({ + filePath, + timeoutMs, + description, +}: { + filePath: string; + timeoutMs: number; + description: string; +}): Promise { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + try { + await fs.promises.access(filePath); + return; + } catch { + // not yet; keep polling + } + await sleepAsync(1_000); + } + throw new Error(`Timed out waiting for ${description} to write ${filePath}.`); +} + +async function readFileOrEmptyAsync(filePath: string): Promise { + try { + return await fs.promises.readFile(filePath, 'utf8'); + } catch { + return ''; + } +} + +function readDaemonInfo(filePath: string): { port: number; token: string } { + const raw = fs.readFileSync(filePath, 'utf8'); + const parsed = JSON.parse(raw) as unknown; + if ( + !parsed || + typeof parsed !== 'object' || + typeof (parsed as { httpPort: unknown }).httpPort !== 'number' || + typeof (parsed as { token: unknown }).token !== 'string' + ) { + throw new Error(`Expected ${filePath} to contain { "httpPort": , "token": "..." }.`); + } + const { httpPort, token } = parsed as { httpPort: number; token: string }; + return { port: httpPort, token }; +} diff --git a/packages/eas-cli/graphql.schema.json b/packages/eas-cli/graphql.schema.json index 1e5fbbd28d..f4d649cbb9 100644 --- a/packages/eas-cli/graphql.schema.json +++ b/packages/eas-cli/graphql.schema.json @@ -4534,6 +4534,18 @@ "isDeprecated": false, "deprecationReason": null }, + { + "name": "firstProjectCreatedAt", + "description": null, + "args": [], + "type": { + "kind": "SCALAR", + "name": "DateTime", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, { "name": "firstSubmissionCompletedAt", "description": null, @@ -4546,6 +4558,18 @@ "isDeprecated": false, "deprecationReason": null }, + { + "name": "firstUpdateCreatedAt", + "description": null, + "args": [], + "type": { + "kind": "SCALAR", + "name": "DateTime", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, { "name": "hasConfiguredUpdate", "description": null, @@ -4562,6 +4586,22 @@ "isDeprecated": false, "deprecationReason": null }, + { + "name": "hasConfiguredWorkflow", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, { "name": "hasTeamMembers", "description": null, @@ -19450,6 +19490,73 @@ "enumValues": null, "possibleTypes": null }, + { + "kind": "OBJECT", + "name": "AppleDeviceRegistrationRequestPublicData", + "description": "Publicly visible data for an AppleDeviceRegistrationRequest.", + "fields": [ + { + "name": "id", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "ID", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + } + ], + "inputFields": null, + "interfaces": [], + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "OBJECT", + "name": "AppleDeviceRegistrationRequestPublicDataQuery", + "description": null, + "fields": [ + { + "name": "byId", + "description": "Get AppleDeviceRegistrationRequest public data by ID", + "args": [ + { + "name": "id", + "description": null, + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "ID", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "type": { + "kind": "OBJECT", + "name": "AppleDeviceRegistrationRequestPublicData", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + } + ], + "inputFields": null, + "interfaces": [], + "enumValues": null, + "possibleTypes": null + }, { "kind": "OBJECT", "name": "AppleDeviceRegistrationRequestQuery", @@ -28553,6 +28660,39 @@ }, "isDeprecated": false, "deprecationReason": null + }, + { + "name": "sendConvexTeamInviteToVerifiedEmail", + "description": null, + "args": [ + { + "name": "input", + "description": null, + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "INPUT_OBJECT", + "name": "SendConvexTeamInviteToVerifiedEmailInput", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null } ], "inputFields": null, @@ -29098,6 +29238,77 @@ "enumValues": null, "possibleTypes": null }, + { + "kind": "INPUT_OBJECT", + "name": "CreateDeviceRunSessionInput", + "description": null, + "fields": null, + "inputFields": [ + { + "name": "appId", + "description": null, + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "ID", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "packageVersion", + "description": "The version of the package backing the device run session (e.g. \"0.1.3-alpha.3\").\nIf omitted, consumers treat the session as pinned to \"latest\".", + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "platform", + "description": null, + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "ENUM", + "name": "AppPlatform", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "type", + "description": null, + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "ENUM", + "name": "DeviceRunSessionType", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "interfaces": null, + "enumValues": null, + "possibleTypes": null + }, { "kind": "INPUT_OBJECT", "name": "CreateEchoChatInput", @@ -33599,6 +33810,362 @@ "enumValues": null, "possibleTypes": null }, + { + "kind": "OBJECT", + "name": "DeviceRunSession", + "description": null, + "fields": [ + { + "name": "app", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "App", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "createdAt", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "DateTime", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "finishedAt", + "description": null, + "args": [], + "type": { + "kind": "SCALAR", + "name": "DateTime", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "id", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "ID", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "initiatingActor", + "description": null, + "args": [], + "type": { + "kind": "INTERFACE", + "name": "Actor", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "packageVersion", + "description": "The version of the package backing the device run session. Null means the session is\npinned to \"latest\" at the consumer side.", + "args": [], + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "platform", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "ENUM", + "name": "AppPlatform", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "startedAt", + "description": null, + "args": [], + "type": { + "kind": "SCALAR", + "name": "DateTime", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "status", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "ENUM", + "name": "DeviceRunSessionStatus", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "turtleJobRun", + "description": null, + "args": [], + "type": { + "kind": "OBJECT", + "name": "JobRun", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "type", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "ENUM", + "name": "DeviceRunSessionType", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "updatedAt", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "DateTime", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + } + ], + "inputFields": null, + "interfaces": [], + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "OBJECT", + "name": "DeviceRunSessionMutation", + "description": null, + "fields": [ + { + "name": "createDeviceRunSession", + "description": "Create a device run session", + "args": [ + { + "name": "deviceRunSessionInput", + "description": null, + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "INPUT_OBJECT", + "name": "CreateDeviceRunSessionInput", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "DeviceRunSession", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "stopDeviceRunSession", + "description": "Stop a device run session", + "args": [ + { + "name": "deviceRunSessionId", + "description": null, + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "ID", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "DeviceRunSession", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + } + ], + "inputFields": null, + "interfaces": [], + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "OBJECT", + "name": "DeviceRunSessionQuery", + "description": null, + "fields": [ + { + "name": "byId", + "description": null, + "args": [ + { + "name": "deviceRunSessionId", + "description": null, + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "ID", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "DeviceRunSession", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + } + ], + "inputFields": null, + "interfaces": [], + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "ENUM", + "name": "DeviceRunSessionStatus", + "description": null, + "fields": null, + "inputFields": null, + "interfaces": null, + "enumValues": [ + { + "name": "ERRORED", + "description": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "IN_PROGRESS", + "description": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "NEW", + "description": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "STOPPED", + "description": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "possibleTypes": null + }, + { + "kind": "ENUM", + "name": "DeviceRunSessionType", + "description": null, + "fields": null, + "inputFields": null, + "interfaces": null, + "enumValues": [ + { + "name": "AGENT_DEVICE", + "description": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "possibleTypes": null + }, { "kind": "OBJECT", "name": "DiscordUser", @@ -38546,6 +39113,12 @@ "isDeprecated": false, "deprecationReason": null }, + { + "name": "ConvexTeamConnectionEntity", + "description": null, + "isDeprecated": false, + "deprecationReason": null + }, { "name": "CustomerEntity", "description": null, @@ -53861,6 +54434,22 @@ "isDeprecated": false, "deprecationReason": null }, + { + "name": "deviceRunSession", + "description": "Mutations that create and stop device run sessions", + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "DeviceRunSessionMutation", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, { "name": "discordUser", "description": "Mutations for Discord users", @@ -54882,6 +55471,22 @@ "isDeprecated": false, "deprecationReason": null }, + { + "name": "appleDeviceRegistrationRequestPublicData", + "description": "Top-level query object for querying AppleDeviceRegistrationRequest publicly.", + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "AppleDeviceRegistrationRequestPublicDataQuery", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, { "name": "appleDistributionCertificate", "description": "Top-level query object for querying Apple distribution certificates.", @@ -55082,6 +55687,22 @@ "isDeprecated": false, "deprecationReason": null }, + { + "name": "deviceRunSessions", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "DeviceRunSessionQuery", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, { "name": "echoChat", "description": "Top-level query object for querying Echo chats.", @@ -57587,6 +58208,33 @@ "enumValues": null, "possibleTypes": null }, + { + "kind": "INPUT_OBJECT", + "name": "SendConvexTeamInviteToVerifiedEmailInput", + "description": null, + "fields": null, + "inputFields": [ + { + "name": "convexTeamConnectionId", + "description": null, + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "ID", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "interfaces": null, + "enumValues": null, + "possibleTypes": null + }, { "kind": "OBJECT", "name": "SentryInstallation", diff --git a/packages/eas-cli/src/build/utils/url.ts b/packages/eas-cli/src/build/utils/url.ts index bc63c0d5bf..deca4665df 100644 --- a/packages/eas-cli/src/build/utils/url.ts +++ b/packages/eas-cli/src/build/utils/url.ts @@ -64,6 +64,24 @@ export function getWorkflowRunUrl( ).toString(); } +/** + * @deprecated Links to the raw job-run page; prefer a higher-level URL (e.g. the workflow run + * or the feature-specific dashboard) that gives users more context. Use this only for internal + * tooling where no richer URL exists. + */ +export function getBareJobRunUrl( + accountName: string, + projectName: string, + jobRunId: string +): string { + return new URL( + `/accounts/${encodeURIComponent(accountName)}/projects/${encodeURIComponent( + projectName + )}/job-runs/${encodeURIComponent(jobRunId)}`, + getExpoWebsiteBaseUrl() + ).toString(); +} + export function getProjectGitHubSettingsUrl(accountName: string, projectName: string): string { return new URL( `/accounts/${encodeURIComponent(accountName)}/projects/${encodeURIComponent( diff --git a/packages/eas-cli/src/commands/simulator/start.ts b/packages/eas-cli/src/commands/simulator/start.ts new file mode 100644 index 0000000000..d06b2bf3df --- /dev/null +++ b/packages/eas-cli/src/commands/simulator/start.ts @@ -0,0 +1,252 @@ +import { Flags } from '@oclif/core'; + +import { getBareJobRunUrl } from '../../build/utils/url'; +import EasCommand from '../../commandUtils/EasCommand'; +import { EASNonInteractiveFlag } from '../../commandUtils/flags'; +import { + AppPlatform, + DeviceRunSessionStatus, + DeviceRunSessionType, + JobRunStatus, +} from '../../graphql/generated'; +import { DeviceRunSessionMutation } from '../../graphql/mutations/DeviceRunSessionMutation'; +import { DeviceRunSessionQuery } from '../../graphql/queries/DeviceRunSessionQuery'; +import Log, { link } from '../../log'; +import { ora } from '../../ora'; +import { sleepAsync } from '../../utils/promise'; +import nullthrows from 'nullthrows'; + +const POLL_INTERVAL_MS = 5_000; // 5 seconds +const POLL_TIMEOUT_MS = 15 * 60 * 1_000; // 15 minutes + +// Mapping enum → CLI flag value. Declared as Record +// 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.AgentDevice]: 'agent-device', +}; + +const DEVICE_RUN_SESSION_TYPE_BY_FLAG_VALUE = Object.fromEntries( + (Object.entries(DEVICE_RUN_SESSION_TYPE_FLAG_VALUES) as [DeviceRunSessionType, string][]).map( + ([type, value]) => [value, type] + ) +) as Record; + +type ReadinessResult = { ready: true; message: string } | { ready: false }; +type ReadinessChecker = (logMessages: readonly string[]) => ReadinessResult; + +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'; + + static override flags = { + platform: Flags.option({ + description: 'Device platform', + options: ['android', 'ios'] as const, + required: true, + })(), + type: Flags.option({ + description: 'Type of device run session to create', + options: Object.values(DEVICE_RUN_SESSION_TYPE_FLAG_VALUES), + default: DEVICE_RUN_SESSION_TYPE_FLAG_VALUES[DeviceRunSessionType.AgentDevice], + })(), + 'package-version': Flags.string({ + description: + 'Version of the package backing the device run session (e.g. "0.1.3-alpha.3"). Defaults to "latest" when omitted.', + }), + ...EASNonInteractiveFlag, + }; + + static override contextDefinition = { + ...this.ContextOptions.ProjectId, + ...this.ContextOptions.LoggedIn, + }; + + async runAsync(): Promise { + const { flags } = await this.parse(SimulatorStart); + + const { + projectId, + loggedIn: { graphqlClient }, + } = await this.getContextAsync(SimulatorStart, { + nonInteractive: flags['non-interactive'], + }); + + const platform = flags.platform === 'android' ? AppPlatform.Android : AppPlatform.Ios; + + const createSpinner = ora('🚀 Creating device run session').start(); + let deviceRunSessionId: string; + try { + const session = await DeviceRunSessionMutation.createDeviceRunSessionAsync(graphqlClient, { + appId: projectId, + platform, + type: DEVICE_RUN_SESSION_TYPE_BY_FLAG_VALUE[flags.type], + packageVersion: flags['package-version'], + }); + deviceRunSessionId = session.id; + const jobRunId = nullthrows(session.turtleJobRun?.id, 'Expected device run session to start'); + const jobRunUrl = getBareJobRunUrl(session.app.ownerAccount.name, session.app.slug, jobRunId); + createSpinner.succeed( + `Device run session created (id: ${deviceRunSessionId}) ${link(jobRunUrl)}` + ); + } catch (err) { + createSpinner.fail('Failed to create device run session'); + throw err; + } + + const checkReadiness = getReadinessCheckerForType(flags.type); + + const pollSpinner = ora(`⏳ Waiting for ${flags.type} daemon to start`).start(); + const deadline = Date.now() + POLL_TIMEOUT_MS; + let result: ReadinessResult = { ready: false }; + + try { + while (Date.now() < deadline) { + const session = await DeviceRunSessionQuery.byIdAsync(graphqlClient, deviceRunSessionId); + + if ( + session.status === DeviceRunSessionStatus.Errored || + session.status === DeviceRunSessionStatus.Stopped + ) { + throw new Error( + `Device run session ${deviceRunSessionId} ${session.status.toLowerCase()} before the ${flags.type} daemon was ready.` + ); + } + + const jobRunStatus = session.turtleJobRun?.status; + if ( + jobRunStatus === JobRunStatus.Errored || + jobRunStatus === JobRunStatus.Canceled || + jobRunStatus === JobRunStatus.Finished + ) { + throw new Error( + `Turtle job run for device run session ${deviceRunSessionId} ${jobRunStatus.toLowerCase()} before the ${flags.type} daemon was ready.` + ); + } + + const logMessages = await fetchLogMessagesAsync(session.turtleJobRun?.logFileUrls ?? []); + result = checkReadiness(logMessages); + if (result.ready) { + pollSpinner.succeed(`🎉 ${flags.type} daemon is ready`); + break; + } + + await sleepAsync(POLL_INTERVAL_MS); + } + } catch (err) { + pollSpinner.fail(`Failed while polling for ${flags.type} daemon logs`); + throw err; + } + + if (!result.ready) { + pollSpinner.fail(`Timed out waiting for ${flags.type} daemon to start`); + throw new Error( + `Timed out after ${Math.round(POLL_TIMEOUT_MS / 1000)}s waiting for ${flags.type} daemon to start.` + ); + } + + Log.newLine(); + Log.log(`🔑 Run the following in your shell to attach to ${flags.type}:`); + Log.newLine(); + Log.log(result.message); + } +} + +function getReadinessCheckerForType(type: string): ReadinessChecker { + switch (type) { + case DEVICE_RUN_SESSION_TYPE_FLAG_VALUES[DeviceRunSessionType.AgentDevice]: + return checkAgentDeviceReadiness; + default: + throw new Error(`Unsupported device run session type: ${type}`); + } +} + +const AGENT_DEVICE_BASE_URL_ENV_VAR = 'AGENT_DEVICE_DAEMON_BASE_URL'; +const AGENT_DEVICE_AUTH_TOKEN_ENV_VAR = 'AGENT_DEVICE_DAEMON_AUTH_TOKEN'; + +function checkAgentDeviceReadiness(logMessages: readonly string[]): ReadinessResult { + let baseUrl: string | undefined; + let authToken: string | undefined; + for (const msg of logMessages) { + baseUrl = baseUrl ?? extractExportedEnvValue(msg, AGENT_DEVICE_BASE_URL_ENV_VAR); + authToken = authToken ?? extractExportedEnvValue(msg, AGENT_DEVICE_AUTH_TOKEN_ENV_VAR); + if (baseUrl && authToken) { + break; + } + } + if (baseUrl && authToken) { + return { + ready: true, + message: [ + `export ${AGENT_DEVICE_BASE_URL_ENV_VAR}='${baseUrl}'`, + `export ${AGENT_DEVICE_AUTH_TOKEN_ENV_VAR}='${authToken}'`, + ].join('\n'), + }; + } + return { ready: false }; +} + +async function fetchLogMessagesAsync(logUrls: readonly string[]): Promise { + const messages: string[] = []; + for (const url of logUrls) { + const text = await fetchLogTextAsync(url); + if (!text) { + continue; + } + for (const line of text.split('\n')) { + if (!line.trim()) { + continue; + } + messages.push(extractLogMessage(line)); + } + } + return messages; +} + +async function fetchLogTextAsync(url: string): Promise { + try { + const response = await fetch(url); + if (!response.ok) { + return undefined; + } + return await response.text(); + } catch { + return undefined; + } +} + +function extractLogMessage(line: string): string { + // Turtle job run logs are JSONL (bunyan-shaped), e.g. + // {"msg":"export FOO=\"bar\"","time":"...","logId":"..."} + // Fall back to the raw line if it's not JSON or doesn't have a string msg. + const trimmed = line.trim(); + if (!trimmed.startsWith('{')) { + return line; + } + try { + const parsed = JSON.parse(trimmed) as unknown; + if (parsed && typeof parsed === 'object' && 'msg' in parsed) { + const msg = (parsed as { msg: unknown }).msg; + if (typeof msg === 'string') { + return msg; + } + } + } catch { + // not JSON, fall through + } + return line; +} + +function extractExportedEnvValue(text: string, varName: string): string | undefined { + // Matches: export NAME=value | export NAME="value" | export NAME='value' + const pattern = new RegExp(`export\\s+${escapeRegExp(varName)}=(?:"([^"]*)"|'([^']*)'|(\\S+))`); + const match = pattern.exec(text); + if (!match) { + return undefined; + } + return match[1] ?? match[2] ?? match[3]; +} + +function escapeRegExp(value: string): string { + return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +} diff --git a/packages/eas-cli/src/graphql/generated.ts b/packages/eas-cli/src/graphql/generated.ts index a89b057557..320ada9579 100644 --- a/packages/eas-cli/src/graphql/generated.ts +++ b/packages/eas-cli/src/graphql/generated.ts @@ -2850,6 +2850,23 @@ export type AppleDeviceRegistrationRequestMutationCreateAppleDeviceRegistrationR appleTeamId: Scalars['ID']['input']; }; +/** Publicly visible data for an AppleDeviceRegistrationRequest. */ +export type AppleDeviceRegistrationRequestPublicData = { + __typename?: 'AppleDeviceRegistrationRequestPublicData'; + id: Scalars['ID']['output']; +}; + +export type AppleDeviceRegistrationRequestPublicDataQuery = { + __typename?: 'AppleDeviceRegistrationRequestPublicDataQuery'; + /** Get AppleDeviceRegistrationRequest public data by ID */ + byId?: Maybe; +}; + + +export type AppleDeviceRegistrationRequestPublicDataQueryByIdArgs = { + id: Scalars['ID']['input']; +}; + export type AppleDeviceRegistrationRequestQuery = { __typename?: 'AppleDeviceRegistrationRequestQuery'; byId: AppleDeviceRegistrationRequest; @@ -4173,6 +4190,17 @@ export type CreateConvexTeamConnectionInput = { deploymentRegion: Scalars['String']['input']; }; +export type CreateDeviceRunSessionInput = { + appId: Scalars['ID']['input']; + /** + * The version of the package backing the device run session (e.g. "0.1.3-alpha.3"). + * If omitted, consumers treat the session as pinned to "latest". + */ + packageVersion?: InputMaybe; + platform: AppPlatform; + type: DeviceRunSessionType; +}; + export type CreateEchoChatInput = { agentMetadata?: InputMaybe; agentType?: InputMaybe; @@ -4770,6 +4798,65 @@ export type DeploymentsMutationDeleteWorkerDeploymentByIdentifierArgs = { deploymentIdentifier: Scalars['ID']['input']; }; +export type DeviceRunSession = { + __typename?: 'DeviceRunSession'; + app: App; + createdAt: Scalars['DateTime']['output']; + finishedAt?: Maybe; + id: Scalars['ID']['output']; + initiatingActor?: Maybe; + /** + * The version of the package backing the device run session. Null means the session is + * pinned to "latest" at the consumer side. + */ + packageVersion?: Maybe; + platform: AppPlatform; + startedAt?: Maybe; + status: DeviceRunSessionStatus; + turtleJobRun?: Maybe; + type: DeviceRunSessionType; + updatedAt: Scalars['DateTime']['output']; +}; + +export type DeviceRunSessionMutation = { + __typename?: 'DeviceRunSessionMutation'; + /** Create a device run session */ + createDeviceRunSession: DeviceRunSession; + /** Stop a device run session */ + stopDeviceRunSession: DeviceRunSession; +}; + + +export type DeviceRunSessionMutationCreateDeviceRunSessionArgs = { + deviceRunSessionInput: CreateDeviceRunSessionInput; +}; + + +export type DeviceRunSessionMutationStopDeviceRunSessionArgs = { + deviceRunSessionId: Scalars['ID']['input']; +}; + +export type DeviceRunSessionQuery = { + __typename?: 'DeviceRunSessionQuery'; + byId: DeviceRunSession; +}; + + +export type DeviceRunSessionQueryByIdArgs = { + deviceRunSessionId: Scalars['ID']['input']; +}; + +export enum DeviceRunSessionStatus { + Errored = 'ERRORED', + InProgress = 'IN_PROGRESS', + New = 'NEW', + Stopped = 'STOPPED' +} + +export enum DeviceRunSessionType { + AgentDevice = 'AGENT_DEVICE' +} + export type DiscordUser = { __typename?: 'DiscordUser'; discordIdentifier: Scalars['String']['output']; @@ -5523,6 +5610,7 @@ export enum EntityTypeName { BillingContractEntity = 'BillingContractEntity', BranchEntity = 'BranchEntity', ChannelEntity = 'ChannelEntity', + ConvexTeamConnectionEntity = 'ConvexTeamConnectionEntity', CustomerEntity = 'CustomerEntity', EchoProjectEntity = 'EchoProjectEntity', EchoVersionEntity = 'EchoVersionEntity', @@ -7610,6 +7698,8 @@ export type RootMutation = { deployments: DeploymentsMutation; /** Mutations that assign or modify DevDomainNames for apps */ devDomainName: AppDevDomainNameMutation; + /** Mutations that create and stop device run sessions */ + deviceRunSession: DeviceRunSessionMutation; /** Mutations for Discord users */ discordUser: DiscordUserMutation; /** Mutations for Echo chats */ @@ -7745,6 +7835,8 @@ export type RootQuery = { appStoreConnectApiKey: AppStoreConnectApiKeyQuery; /** Top-level query object for querying Apple Device registration requests. */ appleDeviceRegistrationRequest: AppleDeviceRegistrationRequestQuery; + /** Top-level query object for querying AppleDeviceRegistrationRequest publicly. */ + appleDeviceRegistrationRequestPublicData: AppleDeviceRegistrationRequestPublicDataQuery; /** Top-level query object for querying Apple distribution certificates. */ appleDistributionCertificate?: Maybe; /** Top-level query object for querying Apple provisioning profiles. */ @@ -7768,6 +7860,7 @@ export type RootQuery = { convexIntegration: ConvexIntegrationQuery; /** Top-level query object for querying Deployments. */ deployments: DeploymentQuery; + deviceRunSessions: DeviceRunSessionQuery; /** Top-level query object for querying Echo chats. */ echoChat: EchoChatQuery; /** Top-level query object for querying Echo messages. */ @@ -11654,6 +11747,13 @@ export type RetryIosBuildMutationVariables = Exact<{ export type RetryIosBuildMutation = { __typename?: 'RootMutation', build: { __typename?: 'BuildMutation', retryIosBuild: { __typename?: 'Build', id: string, status: BuildStatus, platform: AppPlatform, logFiles: Array, channel?: string | null, distribution?: DistributionType | null, iosEnterpriseProvisioning?: BuildIosEnterpriseProvisioning | null, buildProfile?: string | null, sdkVersion?: string | null, appVersion?: string | null, appBuildVersion?: string | null, runtimeVersion?: string | null, gitCommitHash?: string | null, gitCommitMessage?: string | null, initialQueuePosition?: number | null, queuePosition?: number | null, estimatedWaitTimeLeftSeconds?: number | null, priority: BuildPriority, createdAt: any, updatedAt: any, message?: string | null, completedAt?: any | null, expirationDate?: any | null, isForIosSimulator: boolean, error?: { __typename?: 'BuildError', errorCode: string, message: string, docsUrl?: string | null } | null, artifacts?: { __typename?: 'BuildArtifacts', buildUrl?: string | null, xcodeBuildLogsUrl?: string | null, applicationArchiveUrl?: string | null, buildArtifactsUrl?: string | null } | null, fingerprint?: { __typename?: 'Fingerprint', id: string, hash: string } | null, initiatingActor?: { __typename: 'PartnerActor', id: string, displayName: string } | { __typename: 'Robot', id: string, displayName: string } | { __typename: 'SSOUser', id: string, displayName: string } | { __typename: 'User', id: string, displayName: string } | null, project: { __typename: 'App', id: string, name: string, slug: string, ownerAccount: { __typename?: 'Account', id: string, name: string } } | { __typename: 'Snack', id: string, name: string, slug: string }, metrics?: { __typename?: 'BuildMetrics', buildWaitTime?: number | null, buildQueueTime?: number | null, buildDuration?: number | null } | null } } }; +export type CreateDeviceRunSessionMutationVariables = Exact<{ + deviceRunSessionInput: CreateDeviceRunSessionInput; +}>; + + +export type CreateDeviceRunSessionMutation = { __typename?: 'RootMutation', deviceRunSession: { __typename?: 'DeviceRunSessionMutation', createDeviceRunSession: { __typename?: 'DeviceRunSession', id: string, status: DeviceRunSessionStatus, app: { __typename?: 'App', id: string, slug: string, ownerAccount: { __typename?: 'Account', id: string, name: string } }, turtleJobRun?: { __typename?: 'JobRun', id: string } | null } } }; + export type CreateEnvironmentSecretForAccountMutationVariables = Exact<{ input: CreateEnvironmentSecretInput; accountId: Scalars['String']['input']; @@ -12106,6 +12206,13 @@ export type ViewUpdateChannelsPaginatedOnAppQueryVariables = Exact<{ export type ViewUpdateChannelsPaginatedOnAppQuery = { __typename?: 'RootQuery', app: { __typename?: 'AppQuery', byId: { __typename?: 'App', id: string, channelsPaginated: { __typename?: 'AppChannelsConnection', edges: Array<{ __typename?: 'AppChannelEdge', cursor: string, node: { __typename?: 'UpdateChannel', id: string, name: string, branchMapping: string } }>, pageInfo: { __typename?: 'PageInfo', hasNextPage: boolean, hasPreviousPage: boolean, startCursor?: string | null, endCursor?: string | null } } } } }; +export type DeviceRunSessionByIdQueryVariables = Exact<{ + deviceRunSessionId: Scalars['ID']['input']; +}>; + + +export type DeviceRunSessionByIdQuery = { __typename?: 'RootQuery', deviceRunSessions: { __typename?: 'DeviceRunSessionQuery', byId: { __typename?: 'DeviceRunSession', id: string, status: DeviceRunSessionStatus, turtleJobRun?: { __typename?: 'JobRun', id: string, status: JobRunStatus, logFileUrls: Array } | null } } }; + export type EnvironmentSecretsByAppIdQueryVariables = Exact<{ appId: Scalars['String']['input']; }>; diff --git a/packages/eas-cli/src/graphql/mutations/DeviceRunSessionMutation.ts b/packages/eas-cli/src/graphql/mutations/DeviceRunSessionMutation.ts new file mode 100644 index 0000000000..7de3db2018 --- /dev/null +++ b/packages/eas-cli/src/graphql/mutations/DeviceRunSessionMutation.ts @@ -0,0 +1,47 @@ +import gql from 'graphql-tag'; + +import { ExpoGraphqlClient } from '../../commandUtils/context/contextUtils/createGraphqlClient'; +import { withErrorHandlingAsync } from '../client'; +import { + CreateDeviceRunSessionInput, + CreateDeviceRunSessionMutation, + CreateDeviceRunSessionMutationVariables, +} from '../generated'; + +export const DeviceRunSessionMutation = { + async createDeviceRunSessionAsync( + graphqlClient: ExpoGraphqlClient, + deviceRunSessionInput: CreateDeviceRunSessionInput + ): Promise { + const data = await withErrorHandlingAsync( + graphqlClient + .mutation( + gql` + mutation CreateDeviceRunSessionMutation($deviceRunSessionInput: CreateDeviceRunSessionInput!) { + deviceRunSession { + createDeviceRunSession(deviceRunSessionInput: $deviceRunSessionInput) { + id + status + app { + id + slug + ownerAccount { + id + name + } + } + turtleJobRun { + id + } + } + } + } + `, + { deviceRunSessionInput }, + { noRetry: true } + ) + .toPromise() + ); + return data.deviceRunSession.createDeviceRunSession; + }, +}; diff --git a/packages/eas-cli/src/graphql/queries/DeviceRunSessionQuery.ts b/packages/eas-cli/src/graphql/queries/DeviceRunSessionQuery.ts new file mode 100644 index 0000000000..2bff6ce696 --- /dev/null +++ b/packages/eas-cli/src/graphql/queries/DeviceRunSessionQuery.ts @@ -0,0 +1,37 @@ +import gql from 'graphql-tag'; + +import { ExpoGraphqlClient } from '../../commandUtils/context/contextUtils/createGraphqlClient'; +import { withErrorHandlingAsync } from '../client'; +import { DeviceRunSessionByIdQuery, DeviceRunSessionByIdQueryVariables } from '../generated'; + +export const DeviceRunSessionQuery = { + async byIdAsync( + graphqlClient: ExpoGraphqlClient, + deviceRunSessionId: string + ): Promise { + const data = await withErrorHandlingAsync( + graphqlClient + .query( + gql` + query DeviceRunSessionByIdQuery($deviceRunSessionId: ID!) { + deviceRunSessions { + byId(deviceRunSessionId: $deviceRunSessionId) { + id + status + turtleJobRun { + id + status + logFileUrls + } + } + } + } + `, + { deviceRunSessionId }, + { requestPolicy: 'network-only' } + ) + .toPromise() + ); + return data.deviceRunSessions.byId; + }, +};