Compare commits

..
Author SHA1 Message Date
Brent Vatne 2cac6b3812 [eas-cli] Fix multi-account review findings
- Make --account per-command: use an in-memory session override instead of
  persistently switching the active account
- Skip --account interception for commands that define their own account flag
  (project:init, chat, billing:manage, billing:subscribe)
- Support --account=value, reject flag-shaped values, stop at the -- terminator
- Error when --account is combined with EXPO_TOKEN
- Invalidate session secrets on the server in all multi-account logout paths
- Preserve unrelated top-level keys in ~/.expo/state.json on account writes
- Keep the v1 accounts map in sync when logging in or out with the flag off,
  so toggling the flag cannot resurrect a stale identity or drop accounts
- Guard v0->v1 migration against a legacy session with no userId
- Pass the browser flag through the add-account flows; error when logout
  username/--all are used without the feature flag
2026-08-26 23:02:36 -07:00
Brent Vatne 2d694aa02e [eas-cli] Update account command args to oclif object-style API 2026-08-26 22:03:05 -07:00
Brent Vatne a79750f9a5 Add changelog entry and fix prettier formatting 2026-08-26 22:00:07 -07:00
Brent Vatne b9cbf27808 Fix --account flag parsing to work with oclif 2026-08-26 21:58:41 -07:00
Brent Vatne 3033559fdf [eas-cli] Add global --account flag for per-command account selection
Allow specifying which account to use for any command via --account flag
when multi-account is enabled. Switches to the specified account before
executing the command.
2026-08-26 21:58:41 -07:00
Brent Vatne d001580c02 [eas-cli] Update account commands for multi-account support
- login: Add menu to add account or switch when already logged in
- logout: Add username arg and --all flag for selective logout
- view: Show other logged-in accounts when multi-account enabled
2026-08-26 21:58:22 -07:00
Brent Vatne bb51f9b9c1 [eas-cli] Add account:list and account:switch commands
New commands for multi-account support (hidden when feature flag disabled):
- account:list: Show all logged-in accounts with active indicator
- account:switch: Switch between accounts interactively or by username
2026-08-26 21:57:53 -07:00
Brent Vatne de095bc521 [eas-cli] Add multi-account session management support
Add experimental multi-account support behind EAS_EXPERIMENTAL_ACCOUNT_SWITCHER=1 flag.

SessionManager changes:
- Add v1 schema with accounts object and activeAccountId
- Support storing multiple account sessions
- Add switchAccountAsync/switchAccountByUsernameAsync methods
- Add getAllAccounts, removeAccountAsync, removeAllAccountsAsync
- Maintain backward compatibility with Expo CLI via legacy fields
- Read credentials from active account when multi-account enabled
2026-08-26 21:57:53 -07:00
Expo CI a7436edb50 update CHANGELOG.md and bump STAGING in cli-versions.json 2026-08-26 23:54:31 +00:00
Expo CI 89fe2cb3e1 v22.6.0 2026-08-26 23:49:36 +00:00
Szymon Dziedzic 0e8e8cc0aa [build-tools] Increase serve-sim bitrate to 6 Mbps (#4291)
* [build-tools] Increase serve-sim bitrate to 16 Mbps

* [build-tools] Add serve-sim bitrate changelog entry

* [build-tools] Set serve-sim bitrate to 6 Mbps
2026-08-27 01:45:23 +02:00
Gabe Debes 6d7cef80a3 [eas-cli] add experimental support to resource class on simulator sessions (#4268)
* [eas-cli] let simulator sessions request a large resource class

* [eas-cli] add changelog for hidden --resource-class

* [eas-cli] call --resource-class experimental in the changelog

* [eas-cli] oxfmt simulator resource-class test

* [eas-cli] regenerate graphql for resource class
2026-08-26 23:34:56 +00:00
Gabe Debes 5a744b73f5 [worker] Wire SSH session into build (#4031)
* [worker] wire ssh session into build

* Rename SSH phase sessionPromise to done.

Same anti-flatten object wrapper; clearer that callers await tunnel teardown.

* Cover SSH phase supervise callbacks and teardown failure paths.

* [worker] Stop baking upterm into the worker package

build-tools resolves upterm from PATH or downloads darwin-arm64 /
linux-amd64 from gs://turtle-v2/upterm at runtime.

* [worker] align ssh phase with build-tools api

* [worker] build the ssh target from the job type
2026-08-26 16:25:12 -07:00
Gabe Debes b8b53e262a [build-tools] Add worker-side SSH session helper (#4030)
* [build-tools] add worker-side ssh session helper

* Keep host.connectionConfig current and simplify SSH reconnect.

Update connectionConfig on every redial, linearize ensureConnectedAsync, and
parse upterm session objects without a JSON stringify round-trip.

* Reuse SshSettings instead of inlining idleTimeout/relay fields.

* Cover remaining SSH helper branches in unit tests.

Invalid upterm host URLs, missing process pid, stdout chunks, unknown
client counts during the job, and turtleBuild targets.

* Format unknownGraceMs ternary for oxfmt.

* [build-tools] Drop dup changelog entry; redact upterm exit logs

* [build-tools] gitignore baked upterm binaries

* [build-tools] Cover SSH helpers; drop upterm gitignore; restore review fixes

Zod-parse upterm session JSON and move isChildProcessAlive into processes.ts
(claimed in review but never landed). Expand unit coverage and stop gitignoring
baked upterm binaries now that the worker fetches from GCS.

* [build-tools] Resolve upterm from PATH or GCS instead of baking

Restore the orphaned review-fix tip: prefer an image/PATH upterm, else
download darwin-arm64 / linux-amd64 from gs://turtle-v2/upterm. Keeps the
stricter zod session parse, process-group/admin-socket comments, and GCS
cache/path unit coverage.

* [build-tools] Redact upterm tokens from ws:// relay URLs too

* [build-tools] Align upterm PATH probe with job env; scrub controls in logs

* [build-tools] address SSH review feedback

* [build-tools] address second round of SSH review feedback

* [build-tools] send a discriminated target on the ssh session mutation

* [build-tools] sort ssh test imports
2026-08-26 08:09:27 -07:00
38 changed files with 4050 additions and 231 deletions

No files matched your search

+15 -3
View File
@@ -8,14 +8,26 @@ This is the log of notable changes to EAS CLI and related packages.
### 🎉 New features
- [eas-cli] Add an opt-in `--max-idle-time-minutes` flag to `eas simulator`; the session stops automatically after that many minutes without activity. ([#4156](https://github.com/expo/eas-cli/pull/4156) by [@szdziedzic](https://github.com/szdziedzic))
- [eas-cli] Add experimental multi-account support behind `EAS_EXPERIMENTAL_ACCOUNT_SWITCHER=1`: `account:list`, `account:switch`, a global `--account` flag, and multi-account `account:login`/`account:logout`. ([#3340](https://github.com/expo/eas-cli/pull/3340) by [@brentvatne](https://github.com/brentvatne))
### 🐛 Bug fixes
- [build-tools] Preapprove custom URL schemes before opening them in iOS Simulator sessions to avoid the first-use confirmation prompt. ([#4274](https://github.com/expo/eas-cli/pull/4274) by [@szdziedzic](https://github.com/szdziedzic))
### 🧹 Chores
## [22.6.0](https://github.com/expo/eas-cli/releases/tag/v22.6.0) - 2026-08-26
### 🎉 New features
- [eas-cli] Add an opt-in `--max-idle-time-minutes` flag to `eas simulator`; the session stops automatically after that many minutes without activity. ([#4156](https://github.com/expo/eas-cli/pull/4156) by [@szdziedzic](https://github.com/szdziedzic))
- [eas-cli] Add experimental `--resource-class` flag to `eas simulator`. ([#4268](https://github.com/expo/eas-cli/pull/4268) by [@gwdp](https://github.com/gwdp))
- [build-tools] Add worker-side SSH session helpers (upterm relay + session create/report/close). ([#4030](https://github.com/expo/eas-cli/pull/4030) by [@gwdp](https://github.com/gwdp))
- [worker] Wire an SSH session into the build lifecycle behind the workflow ssh flag. ([#4031](https://github.com/expo/eas-cli/pull/4031) by [@gwdp](https://github.com/gwdp))
### 🐛 Bug fixes
- [build-tools] Increase the serve-sim video bitrate to 6 Mbps for higher-quality simulator previews. ([#4291](https://github.com/expo/eas-cli/pull/4291) by [@szdziedzic](https://github.com/szdziedzic))
- [build-tools] Preapprove custom URL schemes before opening them in iOS Simulator sessions to avoid the first-use confirmation prompt. ([#4274](https://github.com/expo/eas-cli/pull/4274) by [@szdziedzic](https://github.com/szdziedzic))
## [22.4.0](https://github.com/expo/eas-cli/releases/tag/v22.4.0) - 2026-08-24
### 🎉 New features
+1 -1
View File
@@ -1,4 +1,4 @@
{
"STAGING": "22.4.0",
"STAGING": "22.6.0",
"PRODUCTION": "22.4.0"
}
+1 -1
View File
@@ -1,5 +1,5 @@
{
"npmClient": "yarn",
"version": "22.5.0",
"version": "22.6.0",
"$schema": "node_modules/lerna/schemas/lerna-schema.json"
}
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@expo/build-tools",
"version": "22.5.0",
"version": "22.6.0",
"bugs": "https://github.com/expo/eas-cli/issues",
"license": "BUSL-1.1",
"author": "Expo <[email protected]>",
+3
View File
@@ -18,6 +18,9 @@ export {
export { PackageManager } from './utils/packageManager';
export * as TurtleSshSession from './utils/turtleSshSession';
export { formatSecondsForLog } from './utils/formatDuration';
export { findAndUploadXcodeBuildLogsAsync } from './ios/xcodeBuildLogs';
export { Hook, runHookIfPresent } from './utils/hooks';
@@ -136,7 +136,7 @@ describe(createServeSimArgs, () => {
'--mjpeg-quality',
'0.55',
'--video-bitrate',
'3000000',
'6000000',
'--video-fps',
'60',
'--turn-url',
@@ -23,7 +23,7 @@ const SERVE_SIM_PACKAGE_NAME = '@expo/serve-sim';
const SERVE_SIM_HOST = '127.0.0.1';
const SERVE_SIM_MAX_DIMENSION = '1280';
const SERVE_SIM_MJPEG_QUALITY = '0.55';
const SERVE_SIM_VIDEO_BITRATE = '3000000';
const SERVE_SIM_VIDEO_BITRATE = '6000000';
const SERVE_SIM_VIDEO_FPS = '60';
const START_DEVICE_RUN_SESSION_MUTATION = graphql(`
@@ -0,0 +1,17 @@
import { formatSecondsForLog } from '../formatDuration';
describe(formatSecondsForLog, () => {
it.each([
[0, '0 seconds'],
[1, '1 second'],
[45, '45 seconds'],
[60, '1 minute'],
[90, '1 minute 30 seconds'],
[120, '2 minutes'],
[3600, '1 hour'],
[3661, '1 hour 1 minute 1 second'],
[7200, '2 hours'],
] as const)('formats %i as %s', (seconds, expected) => {
expect(formatSecondsForLog(seconds)).toBe(expected);
});
});
@@ -0,0 +1,43 @@
import { ChildProcess } from 'node:child_process';
import { isChildProcessAlive } from '../processes';
function child(partial: {
exitCode: number | null;
signalCode: NodeJS.Signals | null;
killed: boolean;
}): ChildProcess {
return partial as ChildProcess;
}
describe(isChildProcessAlive, () => {
it('is alive while the process is running', () => {
expect(isChildProcessAlive(child({ exitCode: null, signalCode: null, killed: false }))).toBe(
true
);
});
it('is dead after a normal exit', () => {
expect(isChildProcessAlive(child({ exitCode: 0, signalCode: null, killed: false }))).toBe(
false
);
expect(isChildProcessAlive(child({ exitCode: 1, signalCode: null, killed: false }))).toBe(
false
);
});
it('is dead after an external signal termination (the regression case)', () => {
expect(
isChildProcessAlive(child({ exitCode: null, signalCode: 'SIGTERM', killed: false }))
).toBe(false);
expect(
isChildProcessAlive(child({ exitCode: null, signalCode: 'SIGKILL', killed: false }))
).toBe(false);
});
it('is dead once we have killed it', () => {
expect(isChildProcessAlive(child({ exitCode: null, signalCode: null, killed: true }))).toBe(
false
);
});
});
@@ -0,0 +1,503 @@
import { SshSettings, SystemError } from '@expo/eas-build-job';
import { createMockLogger } from '../../__tests__/utils/logger';
import { BuildContext } from '../../context';
import { sleepAsync } from '../retry';
import {
SshSessionHandle,
getSshIdleTimeoutSeconds,
getSshRelayServerUrl,
isSshEnabled,
startSshSessionAsync,
superviseSshSessionAsync,
} from '../turtleSshSession';
import { SshConnectionConfig, UptermHost, startUptermHostAsync } from '../upterm';
jest.mock('../retry', () => ({
sleepAsync: jest.fn().mockResolvedValue(undefined),
}));
jest.mock('../upterm', () => ({
...jest.requireActual('../upterm'),
startUptermHostAsync: jest.fn(),
}));
const mockedStartUptermHost = jest.mocked(startUptermHostAsync);
function sshJob(idleTimeoutSeconds: number): { ssh: SshSettings } {
return { ssh: { idleTimeoutSeconds, relayServerUrl: 'wss://relay.expo.dev' } };
}
function superviseHandle(
overrides: Partial<SshSessionHandle> &
Pick<SshSessionHandle, 'getConnectedClientCountAsync' | 'ensureConnectedAsync'>
): SshSessionHandle {
return {
stopAsync: async () => {},
...overrides,
};
}
describe(isSshEnabled, () => {
it('is true when job.ssh is present', () => {
expect(isSshEnabled(sshJob(0))).toBe(true);
});
it('is false otherwise', () => {
expect(isSshEnabled({})).toBe(false);
});
});
describe(getSshIdleTimeoutSeconds, () => {
it('returns idleTimeoutSeconds from job.ssh', () => {
expect(getSshIdleTimeoutSeconds(sshJob(900))).toBe(900);
expect(getSshIdleTimeoutSeconds(sshJob(0))).toBe(0);
expect(getSshIdleTimeoutSeconds(sshJob(3600))).toBe(3600);
});
it('defaults to 0 when job.ssh is missing', () => {
expect(getSshIdleTimeoutSeconds({})).toBe(0);
});
it('throws when the value is not an integer in range', () => {
for (const idleTimeoutSeconds of [300.5, -1, 3601]) {
expect(() => getSshIdleTimeoutSeconds(sshJob(idleTimeoutSeconds))).toThrow(SystemError);
}
});
});
describe(getSshRelayServerUrl, () => {
it('returns the relay url from the job ssh settings', () => {
expect(getSshRelayServerUrl(sshJob(0))).toBe('wss://relay.expo.dev');
});
it('throws when the job carries no relay url', () => {
expect(() => getSshRelayServerUrl({})).toThrow(/no relay server URL/);
});
});
describe(startSshSessionAsync, () => {
const config1: SshConnectionConfig = {
type: 'upterm-v1',
host: 'relay.expo.dev',
secret: 'secret-1',
};
const config2: SshConnectionConfig = { ...config1, secret: 'secret-2' };
const target = { type: 'JOB_RUN', id: 'jr-1' } as const;
let createOrUpdateResult: { data?: unknown; error?: { message: string } };
let mutation: jest.Mock;
let ctx: BuildContext;
function makeHost(overrides: Partial<UptermHost> = {}): UptermHost {
const host: UptermHost = {
connectionConfig: config1,
getConnectedClientCountAsync: jest.fn().mockResolvedValue(3),
isAlive: jest.fn().mockReturnValue(true),
redialAsync: jest.fn(async () => {
host.connectionConfig = config2;
return config2;
}),
stopAsync: jest.fn().mockResolvedValue(undefined),
...overrides,
};
return host;
}
beforeEach(() => {
createOrUpdateResult = {
data: {
turtleSshSession: {
createOrUpdateTurtleSshSession: {
id: 'ts-1',
sessionSettings: { idleTimeoutSeconds: 900 },
},
},
},
};
mutation = jest.fn().mockReturnValue({ toPromise: async () => createOrUpdateResult });
ctx = {
logger: createMockLogger(),
graphqlClient: { mutation },
} as unknown as BuildContext;
});
it('creates or updates the session and returns a handle plus idle timeout that delegates to the host', async () => {
const host = makeHost();
mockedStartUptermHost.mockResolvedValue(host);
const { handle, idleTimeoutSeconds } = await startSshSessionAsync(ctx, {
target,
relayServerUrl: 'wss://relay.expo.dev',
idleTimeoutSeconds: 900,
});
expect(mutation).toHaveBeenCalledWith(expect.anything(), {
target: { type: 'JOB_RUN', id: 'jr-1' },
connectionConfig: { ...config1, reconnecting: false, type: 'UPTERM_V1' },
sessionSettings: { idleTimeoutSeconds: 900 },
});
expect(idleTimeoutSeconds).toBe(900);
await handle.getConnectedClientCountAsync();
expect(host.getConnectedClientCountAsync).toHaveBeenCalled();
await handle.stopAsync();
expect(host.stopAsync).toHaveBeenCalled();
});
it('reports a build target', async () => {
const host = makeHost();
mockedStartUptermHost.mockResolvedValue(host);
await startSshSessionAsync(ctx, {
target: { type: 'BUILD', id: 'b-1' },
relayServerUrl: 'wss://relay.expo.dev',
idleTimeoutSeconds: 900,
});
expect(mutation).toHaveBeenCalledWith(expect.anything(), {
target: { type: 'BUILD', id: 'b-1' },
connectionConfig: { ...config1, reconnecting: false, type: 'UPTERM_V1' },
sessionSettings: { idleTimeoutSeconds: 900 },
});
});
it('throws and tears down the dialed host when creating or updating the session fails', async () => {
const host = makeHost();
mockedStartUptermHost.mockResolvedValue(host);
createOrUpdateResult = { error: { message: 'boom' } };
await expect(
startSshSessionAsync(ctx, {
target,
relayServerUrl: 'wss://r',
idleTimeoutSeconds: 300,
})
).rejects.toThrow('boom');
expect(host.stopAsync).toHaveBeenCalled();
});
it('still rethrows when teardown after a create/update failure also fails', async () => {
const host = makeHost({
stopAsync: jest.fn().mockRejectedValue(new Error('already gone')),
});
mockedStartUptermHost.mockResolvedValue(host);
createOrUpdateResult = { error: { message: 'boom' } };
await expect(
startSshSessionAsync(ctx, {
target,
relayServerUrl: 'wss://r',
idleTimeoutSeconds: 300,
})
).rejects.toThrow('boom');
expect(host.stopAsync).toHaveBeenCalled();
});
it('throws "no data returned" when create or update succeeds without a payload', async () => {
const host = makeHost();
mockedStartUptermHost.mockResolvedValue(host);
createOrUpdateResult = {};
await expect(
startSshSessionAsync(ctx, {
target,
relayServerUrl: 'wss://r',
idleTimeoutSeconds: 300,
})
).rejects.toThrow(/no data returned/);
expect(host.stopAsync).toHaveBeenCalled();
});
it('ensureConnected is a no-op while the host is alive', async () => {
const host = makeHost({ isAlive: jest.fn().mockReturnValue(true) });
mockedStartUptermHost.mockResolvedValue(host);
const { handle } = await startSshSessionAsync(ctx, {
target,
relayServerUrl: 'wss://r',
idleTimeoutSeconds: 300,
});
await handle.ensureConnectedAsync();
expect(host.redialAsync).not.toHaveBeenCalled();
expect(mutation).toHaveBeenCalledTimes(1);
});
it('marks reconnecting, redials, then reports the fresh config', async () => {
const host = makeHost({ isAlive: jest.fn().mockReturnValue(false) });
mockedStartUptermHost.mockResolvedValue(host);
const { handle } = await startSshSessionAsync(ctx, {
target,
relayServerUrl: 'wss://r',
idleTimeoutSeconds: 300,
});
await handle.ensureConnectedAsync();
expect(host.redialAsync).toHaveBeenCalledTimes(1);
expect(mutation).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({
connectionConfig: expect.objectContaining({ reconnecting: true }),
})
);
expect(mutation).toHaveBeenLastCalledWith(expect.anything(), {
target: { type: 'JOB_RUN', id: 'jr-1' },
connectionConfig: { ...config2, reconnecting: false, type: 'UPTERM_V1' },
sessionSettings: { idleTimeoutSeconds: 300 },
});
});
it('continues redial when the reconnecting status update fails', async () => {
const host = makeHost({ isAlive: jest.fn().mockReturnValue(false) });
mockedStartUptermHost.mockResolvedValue(host);
mutation
.mockReturnValueOnce({ toPromise: async () => createOrUpdateResult }) // initial create
.mockReturnValueOnce({
toPromise: async () => {
throw new Error('www unreachable');
},
}) // reconnecting=true best-effort
.mockReturnValue({ toPromise: async () => createOrUpdateResult }); // post-redial
const { handle } = await startSshSessionAsync(ctx, {
target,
relayServerUrl: 'wss://r',
idleTimeoutSeconds: 300,
});
await handle.ensureConnectedAsync();
expect(host.redialAsync).toHaveBeenCalledTimes(1);
});
it('retries only the report (no second redial) when reporting the fresh config fails', async () => {
let alive = false;
const host = makeHost({
isAlive: jest.fn(() => alive),
});
host.redialAsync = jest.fn(async () => {
alive = true;
host.connectionConfig = config2;
return config2;
});
mockedStartUptermHost.mockResolvedValue(host);
mutation = jest
.fn()
.mockReturnValueOnce({ toPromise: async () => createOrUpdateResult })
.mockReturnValueOnce({ toPromise: async () => createOrUpdateResult })
.mockReturnValueOnce({ toPromise: async () => ({ error: { message: 'flaky' } }) })
.mockReturnValue({ toPromise: async () => createOrUpdateResult });
ctx = { logger: createMockLogger(), graphqlClient: { mutation } } as unknown as BuildContext;
const { handle } = await startSshSessionAsync(ctx, {
target,
relayServerUrl: 'wss://r',
idleTimeoutSeconds: 300,
});
await handle.ensureConnectedAsync();
expect(host.redialAsync).toHaveBeenCalledTimes(1);
expect(mutation).toHaveBeenLastCalledWith(expect.anything(), {
target: { type: 'JOB_RUN', id: 'jr-1' },
connectionConfig: { ...config2, reconnecting: false, type: 'UPTERM_V1' },
sessionSettings: { idleTimeoutSeconds: 300 },
});
});
it('throws once the redial budget is exhausted', async () => {
const host = makeHost({
isAlive: jest.fn().mockReturnValue(false),
redialAsync: jest.fn().mockRejectedValue(new Error('relay down')),
});
mockedStartUptermHost.mockResolvedValue(host);
const { handle } = await startSshSessionAsync(ctx, {
target,
relayServerUrl: 'wss://r',
idleTimeoutSeconds: 300,
});
await expect(handle.ensureConnectedAsync()).rejects.toThrow(/after 10 attempts/);
expect(host.redialAsync).toHaveBeenCalledTimes(10);
});
});
describe(superviseSshSessionAsync, () => {
const logger = createMockLogger();
const mockedSleep = jest.mocked(sleepAsync);
beforeEach(() => {
jest.clearAllMocks();
mockedSleep.mockResolvedValue(undefined);
});
it('keeps the session open while the job is running, even with a 0 idle timeout', async () => {
let polls = 0;
await superviseSshSessionAsync({
handle: superviseHandle({
ensureConnectedAsync: async () => {
if (polls >= 5) {
throw new SystemError('stop the loop');
}
},
getConnectedClientCountAsync: async () => {
polls += 1;
return 0;
},
}),
idleTimeoutSeconds: 0,
hasJobFinished: () => false,
logger,
});
expect(polls).toBe(5);
});
it('closes as soon as the job finishes when the idle timeout is 0', async () => {
let polls = 0;
await superviseSshSessionAsync({
handle: superviseHandle({
ensureConnectedAsync: async () => {},
getConnectedClientCountAsync: async () => {
polls += 1;
return 0;
},
}),
idleTimeoutSeconds: 0,
hasJobFinished: () => true,
logger,
});
expect(polls).toBe(1);
expect(logger.info).toHaveBeenCalledWith(
'The job finished and no SSH client is connected. Closing the session.'
);
});
it('waits for a connected client to disconnect before closing with a 0 idle timeout', async () => {
const clientCounts = [1, 1, 0];
let polls = 0;
await superviseSshSessionAsync({
handle: superviseHandle({
ensureConnectedAsync: async () => {},
getConnectedClientCountAsync: async () => {
polls += 1;
return clientCounts.shift() ?? 0;
},
}),
idleTimeoutSeconds: 0,
hasJobFinished: () => true,
logger,
});
expect(polls).toBe(3);
});
it('starts the idle countdown when the job finishes, not when the session opens', async () => {
jest.useFakeTimers();
jest.setSystemTime(0);
try {
let jobHasFinished = false;
let polls = 0;
mockedSleep.mockImplementation(async () => {
jest.setSystemTime(Date.now() + 60_000);
});
await superviseSshSessionAsync({
handle: superviseHandle({
ensureConnectedAsync: async () => {},
getConnectedClientCountAsync: async () => {
polls += 1;
if (polls >= 5) {
jobHasFinished = true;
}
return 0;
},
}),
idleTimeoutSeconds: 300,
hasJobFinished: () => jobHasFinished,
logger,
});
// Nobody connects, so the 4 idle minutes spent during the job must not count towards the
// 5 minute timeout: it closes 5 minutes after the job finished, on the 10th poll.
expect(polls).toBe(10);
} finally {
jest.useRealTimers();
}
});
it('logs the connected client count whenever it changes', async () => {
const clientCounts = [1, 2, 1, 0];
await superviseSshSessionAsync({
handle: superviseHandle({
ensureConnectedAsync: async () => {},
getConnectedClientCountAsync: async () => clientCounts.shift() ?? 0,
}),
idleTimeoutSeconds: 0,
hasJobFinished: () => true,
logger,
});
expect(logger.info).toHaveBeenCalledWith('SSH clients connected: 1');
expect(logger.info).toHaveBeenCalledWith('SSH clients connected: 2');
expect(logger.info).toHaveBeenCalledWith('SSH clients connected: 1');
expect(logger.info).toHaveBeenCalledWith('SSH clients connected: 0');
});
it('closes the session when the relay connection cannot be restored', async () => {
let clientCountCalls = 0;
await superviseSshSessionAsync({
handle: superviseHandle({
ensureConnectedAsync: async () => {
throw new SystemError('relay down');
},
getConnectedClientCountAsync: async () => {
clientCountCalls += 1;
return 1;
},
}),
idleTimeoutSeconds: 300,
hasJobFinished: () => false,
logger,
});
expect(clientCountCalls).toBe(0);
});
it('does not close while a client stays connected, even past the idle timeout', async () => {
let polls = 0;
await superviseSshSessionAsync({
handle: superviseHandle({
ensureConnectedAsync: async () => {
if (polls > 3) {
throw new SystemError('stop the loop');
}
},
getConnectedClientCountAsync: async () => {
polls += 1;
return 1;
},
}),
idleTimeoutSeconds: 0,
hasJobFinished: () => true,
logger,
});
expect(polls).toBeGreaterThan(3);
});
it('closes when the client count cannot be read', async () => {
let polls = 0;
await superviseSshSessionAsync({
handle: superviseHandle({
ensureConnectedAsync: async () => {},
getConnectedClientCountAsync: async () => {
polls += 1;
throw new SystemError(
'Could not read the SSH client count from the upterm admin socket.'
);
},
}),
idleTimeoutSeconds: 300,
hasJobFinished: () => false,
logger,
});
expect(polls).toBe(1);
expect(logger.warn).toHaveBeenCalledWith(
expect.anything(),
'Could not read the SSH client count. Closing the session.'
);
});
});
@@ -0,0 +1,466 @@
import { SystemError } from '@expo/eas-build-job';
import downloadFile from '@expo/downloader';
import spawn from '@expo/turtle-spawn';
import fs from 'node:fs/promises';
import { createMockLogger } from '../../__tests__/utils/logger';
import { BuildContext } from '../../context';
import { sleepAsync } from '../retry';
import {
connectionConfigFromUptermSession,
redactConnectionSecrets,
redactSpawnErrorForLog,
resolveUptermGcsObjectName,
startUptermHostAsync,
} from '../upterm';
jest.mock('@expo/turtle-spawn', () => ({ __esModule: true, default: jest.fn() }));
jest.mock('@expo/downloader', () => ({ __esModule: true, default: jest.fn() }));
jest.mock('../retry', () => ({ sleepAsync: jest.fn() }));
jest.mock('node:fs/promises', () => ({
__esModule: true,
default: {
access: jest.fn(),
mkdir: jest.fn(),
chmod: jest.fn(),
mkdtemp: jest.fn(),
writeFile: jest.fn(),
rm: jest.fn(),
readdir: jest.fn(),
},
}));
describe(resolveUptermGcsObjectName, () => {
it('maps worker platforms to GCS object names', () => {
expect(resolveUptermGcsObjectName('darwin', 'arm64')).toBe('upterm-darwin-arm64');
expect(resolveUptermGcsObjectName('linux', 'x64')).toBe('upterm-linux-amd64');
});
it('rejects unsupported platforms', () => {
expect(() => resolveUptermGcsObjectName('darwin', 'x64')).toThrow(/only available/);
expect(() => resolveUptermGcsObjectName('linux', 'arm64')).toThrow(/only available/);
});
});
describe(connectionConfigFromUptermSession, () => {
it('parses sessionId and host from session current fields', () => {
expect(
connectionConfigFromUptermSession({
sessionId: 'TOKENabc123',
host: 'ssh://relay.expo.dev:22',
})
).toEqual({
type: 'upterm-v1',
host: 'relay.expo.dev',
secret: 'TOKENabc123',
});
});
it('accepts a bare hostname', () => {
expect(connectionConfigFromUptermSession({ sessionId: 'tok', host: 'relay.expo.dev' })).toEqual(
{
type: 'upterm-v1',
host: 'relay.expo.dev',
secret: 'tok',
}
);
});
it('keeps a non-default port so the CLI can pass it to ssh -p', () => {
expect(
connectionConfigFromUptermSession({
sessionId: 'tok',
host: 'ssh://relay.expo.dev:2222',
})
).toEqual({
type: 'upterm-v1',
host: 'relay.expo.dev:2222',
secret: 'tok',
});
});
it('returns null when the host is not a parseable url', () => {
expect(connectionConfigFromUptermSession({ sessionId: 'tok', host: 'ssh://' })).toBeNull();
expect(connectionConfigFromUptermSession({ sessionId: 'tok', host: 'ssh://[' })).toBeNull();
});
});
describe(redactConnectionSecrets, () => {
it('strips the userinfo secret from a connection string', () => {
expect(
redactConnectionSecrets('Connect: upterm proxy wss://[email protected] now')
).toBe('Connect: upterm proxy wss://<redacted>@relay.expo.dev now');
});
it('strips the bare token that trails the ssh ProxyCommand line', () => {
const line =
"ssh -o ProxyCommand='upterm proxy wss://[email protected]' [email protected]";
const redacted = redactConnectionSecrets(line);
expect(redacted).not.toContain('TOKENabc123');
expect(redacted).toBe(
"ssh -o ProxyCommand='upterm proxy wss://<redacted>@uptermd.upterm.dev' <redacted>@uptermd.upterm.dev"
);
});
it('strips the bare token for ws:// relay URLs too', () => {
const line =
"ssh -o ProxyCommand='upterm proxy ws://[email protected]' [email protected]";
const redacted = redactConnectionSecrets(line);
expect(redacted).not.toContain('TOKENabc123');
expect(redacted).toBe(
"ssh -o ProxyCommand='upterm proxy ws://<redacted>@relay.local' <redacted>@relay.local"
);
});
it('leaves output without userinfo untouched', () => {
expect(redactConnectionSecrets('dialing wss://relay.expo.dev')).toBe(
'dialing wss://relay.expo.dev'
);
});
it('strips control characters (incl. ESC) while keeping newlines', () => {
// ESC alone is enough to neutralize CSI; leftover printable "[31m" is harmless in logs.
expect(redactConnectionSecrets('ok\n\u001b[31mfail\u001b[0m\r\nbad\u0007')).toBe(
'ok\n[31mfail[0m\nbad'
);
});
});
describe(redactSpawnErrorForLog, () => {
it('redacts secrets in message, stdout, and stderr', () => {
expect(
redactSpawnErrorForLog({
message: 'failed wss://[email protected]',
stdout: 'upterm proxy wss://[email protected]',
stderr: 'ssh dial error: wss://[email protected]',
code: 1,
})
).toEqual({
message: 'failed wss://<redacted>@relay.expo.dev',
stdout: 'upterm proxy wss://<redacted>@relay.expo.dev',
stderr: 'ssh dial error: wss://<redacted>@relay.expo.dev',
code: 1,
});
});
it('passes through non-objects', () => {
expect(redactSpawnErrorForLog('boom')).toBe('boom');
expect(redactSpawnErrorForLog(null)).toBeNull();
});
it('leaves non-string message/stdout/stderr fields alone', () => {
expect(
redactSpawnErrorForLog({
message: 42,
stdout: Buffer.from('x'),
code: 1,
})
).toEqual({
message: 42,
stdout: Buffer.from('x'),
code: 1,
});
});
});
describe(startUptermHostAsync, () => {
const mockedSpawn = jest.mocked(spawn);
const mockedSleep = jest.mocked(sleepAsync);
const mockedFs = jest.mocked(fs);
const mockedDownloadFile = jest.mocked(downloadFile);
let hostProcess: {
child: {
stdout: { on: (event: string, cb: (chunk: Buffer) => void) => void };
stderr: { on: jest.Mock };
pid: number;
kill: jest.Mock;
unref: jest.Mock;
exitCode: number | null;
signalCode: NodeJS.Signals | null;
killed: boolean;
};
catch: (cb: (err: unknown) => void) => Promise<void>;
};
let sessionStdout: string;
let uptermOnPath = true;
function makeHostProcess(): typeof hostProcess {
return {
child: {
stdout: {
on: jest.fn((_event: string, cb: (chunk: Buffer) => void) => {
cb(Buffer.from('host output'));
}),
},
stderr: { on: jest.fn() },
pid: 4242,
kill: jest.fn(),
unref: jest.fn(),
exitCode: null,
signalCode: null,
killed: false,
},
catch: cb => {
cb(new Error('exited'));
return Promise.resolve();
},
};
}
function makeCtx(env: Record<string, string> = {}): BuildContext {
return {
logger: createMockLogger(),
env,
} as unknown as BuildContext;
}
beforeEach(() => {
jest.clearAllMocks();
jest.spyOn(process, 'kill').mockReturnValue(true);
hostProcess = makeHostProcess();
uptermOnPath = true;
sessionStdout = JSON.stringify({
sessionId: 'TOKENx',
host: 'ssh://relay.expo.dev:22',
clientCount: 0,
});
mockedSleep.mockResolvedValue(undefined);
mockedFs.mkdir.mockResolvedValue(undefined as never);
mockedFs.chmod.mockResolvedValue(undefined as never);
mockedFs.mkdtemp.mockImplementation((async (prefix: string) =>
String(prefix).includes('eas-upterm') ? '/tmp/eas-upterm-1' : '/tmp/eas-ssh-1') as never);
mockedFs.writeFile.mockResolvedValue(undefined as never);
mockedFs.rm.mockResolvedValue(undefined as never);
mockedFs.readdir.mockResolvedValue(['default.sock'] as never);
mockedDownloadFile.mockResolvedValue(undefined as never);
mockedSpawn.mockImplementation(((cmd: string, args: string[]) => {
if (cmd === 'upterm' && Array.isArray(args) && args[0] === 'version') {
if (!uptermOnPath) {
return Promise.reject(new Error('not found')) as never;
}
return Promise.resolve({ stdout: 'upterm version 0.24.0\n', stderr: '' }) as never;
}
if (Array.isArray(args) && args[0] === 'host') {
return hostProcess as never;
}
if (Array.isArray(args) && args[0] === 'session') {
return Promise.resolve({ stdout: sessionStdout, stderr: '' }) as never;
}
return Promise.resolve({ stdout: '', stderr: '' }) as never;
}) as never);
});
it('uses upterm on PATH, dials, and returns a handle with the parsed config', async () => {
const env = { PATH: '/job/bin', CUSTOM: '1' };
const host = await startUptermHostAsync(makeCtx(env), {
relayServerUrl: 'wss://relay.expo.dev',
});
expect(host.connectionConfig).toEqual({
type: 'upterm-v1',
host: 'relay.expo.dev',
secret: 'TOKENx',
});
expect(host.isAlive()).toBe(true);
expect(mockedDownloadFile).not.toHaveBeenCalled();
expect(mockedSpawn).toHaveBeenCalledWith('upterm', ['version'], {
stdio: 'pipe',
env,
});
expect(mockedSpawn).toHaveBeenCalledWith(
'upterm',
expect.arrayContaining(['host']),
expect.objectContaining({ env: expect.objectContaining({ CUSTOM: '1', PATH: '/job/bin' }) })
);
});
it('downloads from GCS into a fresh temp dir when upterm is not on PATH', async () => {
uptermOnPath = false;
const host = await startUptermHostAsync(makeCtx(), { relayServerUrl: 'wss://relay.expo.dev' });
expect(host.connectionConfig.secret).toBe('TOKENx');
expect(mockedDownloadFile).toHaveBeenCalledWith(
expect.stringMatching(/storage\.googleapis\.com\/turtle-v2\/upterm\/upterm-/),
expect.stringContaining('/tmp/eas-upterm-1'),
expect.objectContaining({ retry: 3 })
);
expect(mockedSpawn).toHaveBeenCalledWith(
expect.stringContaining('/tmp/eas-upterm-1'),
expect.arrayContaining(['host']),
expect.anything()
);
});
it('throws when upterm is missing from PATH and the GCS download fails', async () => {
uptermOnPath = false;
mockedDownloadFile.mockRejectedValue(new Error('403 Forbidden') as never);
await expect(startUptermHostAsync(makeCtx(), { relayServerUrl: 'wss://r' })).rejects.toThrow(
/could not be downloaded/
);
});
it('still throws when download fails with a non-Error and temp cleanup also fails', async () => {
uptermOnPath = false;
mockedDownloadFile.mockRejectedValue('network down' as never);
mockedFs.rm.mockRejectedValue(new Error('EPERM') as never);
await expect(startUptermHostAsync(makeCtx(), { relayServerUrl: 'wss://r' })).rejects.toThrow(
/network down/
);
});
it('tears down and rethrows when the host never registers with the relay', async () => {
jest.useFakeTimers();
jest.setSystemTime(0);
try {
mockedFs.readdir.mockResolvedValue([] as never);
mockedSleep.mockImplementation(async () => {
jest.setSystemTime(Date.now() + 61_000);
});
await expect(startUptermHostAsync(makeCtx(), { relayServerUrl: 'wss://r' })).rejects.toThrow(
/did not register/
);
expect(process.kill).toHaveBeenCalledWith(-4242, 'SIGTERM');
expect(mockedFs.rm).toHaveBeenCalledWith('/tmp/eas-ssh-1', expect.anything());
} finally {
jest.useRealTimers();
}
});
it('stopAsync kills the process and removes the state dir', async () => {
const host = await startUptermHostAsync(makeCtx(), { relayServerUrl: 'wss://r' });
await host.stopAsync();
expect(process.kill).toHaveBeenCalledWith(-4242, 'SIGTERM');
expect(mockedFs.rm).toHaveBeenCalledWith('/tmp/eas-ssh-1', expect.anything());
});
it('falls back to child.kill when process.kill on the process group fails', async () => {
jest.spyOn(process, 'kill').mockImplementation(() => {
throw new Error('ESRCH');
});
const host = await startUptermHostAsync(makeCtx(), { relayServerUrl: 'wss://r' });
await host.stopAsync();
expect(hostProcess.child.kill).toHaveBeenCalled();
});
it('skips process.kill when the host process has no pid', async () => {
const host = await startUptermHostAsync(makeCtx(), { relayServerUrl: 'wss://r' });
hostProcess.child.pid = undefined as unknown as number;
await host.stopAsync();
expect(process.kill).not.toHaveBeenCalled();
expect(hostProcess.child.kill).not.toHaveBeenCalled();
});
it('redialAsync re-establishes the connection', async () => {
const host = await startUptermHostAsync(makeCtx(), { relayServerUrl: 'wss://r' });
const config = await host.redialAsync();
expect(config).toEqual({
type: 'upterm-v1',
host: 'relay.expo.dev',
secret: 'TOKENx',
});
expect(host.connectionConfig).toEqual(config);
});
it('redialAsync waits for the old process to exit before clearing its socket dir', async () => {
const order: string[] = [];
const host = await startUptermHostAsync(makeCtx(), { relayServerUrl: 'wss://r' });
// Keep the give-up timer pending so only the process exit can unblock the redial.
mockedSleep.mockImplementation(() => new Promise<void>(() => {}));
let resolveExit: (() => void) | undefined;
hostProcess.catch = () =>
new Promise<void>(resolve => {
resolveExit = () => {
order.push('exited');
resolve();
};
});
mockedFs.rm.mockImplementation((() => {
order.push('rm');
return Promise.resolve(undefined);
}) as never);
jest.spyOn(process, 'kill').mockImplementation(() => {
order.push('kill');
// A real SIGTERM is asynchronous, so the exit lands after the kill returns.
setImmediate(() => resolveExit?.());
return true;
});
await host.redialAsync();
expect(order).toEqual(['kill', 'exited', 'rm']);
});
it('redialAsync gives up waiting when the old process does not exit', async () => {
const host = await startUptermHostAsync(makeCtx(), { relayServerUrl: 'wss://r' });
hostProcess.catch = () => new Promise<void>(() => {});
await expect(host.redialAsync()).resolves.toEqual({
type: 'upterm-v1',
host: 'relay.expo.dev',
secret: 'TOKENx',
});
expect(mockedSleep).toHaveBeenCalledWith(5_000);
});
it('continues when the previous socket dir cannot be cleared', async () => {
mockedFs.rm.mockImplementation(((dir: string) =>
dir === '/tmp/eas-ssh-1/upterm'
? Promise.reject(new Error('busy'))
: Promise.resolve(undefined)) as never);
const host = await startUptermHostAsync(makeCtx(), { relayServerUrl: 'wss://r' });
expect(host.connectionConfig.secret).toBe('TOKENx');
});
describe('getConnectedClientCountAsync', () => {
it('throws when no admin socket exists yet (after retries)', async () => {
const host = await startUptermHostAsync(makeCtx(), { relayServerUrl: 'wss://r' });
mockedFs.readdir.mockResolvedValue([] as never);
await expect(host.getConnectedClientCountAsync()).rejects.toThrow(/client count/i);
});
it('returns the client count reported by the admin socket', async () => {
const host = await startUptermHostAsync(makeCtx(), { relayServerUrl: 'wss://r' });
sessionStdout = JSON.stringify({
sessionId: 'TOKENx',
host: 'relay.expo.dev',
clientCount: 4,
});
expect(await host.getConnectedClientCountAsync()).toBe(4);
});
it('throws when the count query fails (after retries)', async () => {
const host = await startUptermHostAsync(makeCtx(), { relayServerUrl: 'wss://r' });
mockedSpawn.mockImplementation((() => Promise.reject(new Error('socket gone'))) as never);
await expect(host.getConnectedClientCountAsync()).rejects.toThrow(/client count/i);
});
it('throws when the reported count is not a number (after retries)', async () => {
const host = await startUptermHostAsync(makeCtx(), { relayServerUrl: 'wss://r' });
sessionStdout = JSON.stringify({
sessionId: 'TOKENx',
host: 'relay.expo.dev',
clientCount: 'nope',
});
await expect(host.getConnectedClientCountAsync()).rejects.toThrow(/client count/i);
});
it('throws when the socket dir cannot be read (after retries)', async () => {
const host = await startUptermHostAsync(makeCtx(), { relayServerUrl: 'wss://r' });
mockedFs.readdir.mockRejectedValue(new Error('no dir') as never);
await expect(host.getConnectedClientCountAsync()).rejects.toThrow(/client count/i);
});
});
});
@@ -0,0 +1,16 @@
export function formatSecondsForLog(totalSeconds: number): string {
const hours = Math.floor(totalSeconds / 3600);
const minutes = Math.floor((totalSeconds % 3600) / 60);
const seconds = totalSeconds % 60;
const parts: string[] = [];
if (hours > 0) {
parts.push(hours === 1 ? '1 hour' : `${hours} hours`);
}
if (minutes > 0) {
parts.push(minutes === 1 ? '1 minute' : `${minutes} minutes`);
}
if (seconds > 0 || parts.length === 0) {
parts.push(seconds === 1 ? '1 second' : `${seconds} seconds`);
}
return parts.join(' ');
}
@@ -1,5 +1,26 @@
import { ChildProcess } from 'node:child_process';
import spawn from '@expo/turtle-spawn';
export function isChildProcessAlive(child: ChildProcess): boolean {
return child.exitCode === null && child.signalCode === null && !child.killed;
}
/**
* 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 {
if (child.pid == null) {
return;
}
try {
process.kill(-child.pid, 'SIGTERM');
} catch {
child.kill();
}
}
async function getChildrenPidsAsync(parentPids: number[]): Promise<number[]> {
try {
const result = await spawn('pgrep', ['-P', parentPids.join(',')], {
@@ -0,0 +1,250 @@
import { Job, SshSettings, SystemError } from '@expo/eas-build-job';
import { bunyan } from '@expo/logger';
import { graphql } from 'gql.tada';
import { formatSecondsForLog } from './formatDuration';
import { sleepAsync } from './retry';
import { SshConnectionConfig, startUptermHostAsync } from './upterm';
import { BuildContext } from '../context';
import { Sentry } from '../sentry';
const MAX_SSH_REDIALS = 10;
const REDIAL_BACKOFF_MS = 6_000;
const CLIENT_COUNT_POLL_INTERVAL_MS = 5_000;
const MAX_SSH_IDLE_TIMEOUT_SECONDS = 3600;
const DEFAULT_SSH_IDLE_TIMEOUT_SECONDS = 0;
const CREATE_OR_UPDATE_TURTLE_SSH_SESSION_MUTATION = graphql(`
mutation CreateOrUpdateTurtleSshSession(
$target: TurtleSshTargetInput!
$connectionConfig: TurtleSshConnectionConfigInput!
$sessionSettings: TurtleSshSessionSettingsInput!
) {
turtleSshSession {
createOrUpdateTurtleSshSession(
target: $target
connectionConfig: $connectionConfig
sessionSettings: $sessionSettings
) {
id
sessionSettings {
idleTimeoutSeconds
}
}
}
}
`);
export type TurtleSshTarget = { type: 'BUILD' | 'JOB_RUN'; id: string };
export function isSshEnabled(job: Pick<Job, 'ssh'>): boolean {
return job.ssh != null;
}
export function getSshIdleTimeoutSeconds(job: Pick<Job, 'ssh'>): number {
const idleTimeoutSeconds = job.ssh?.idleTimeoutSeconds ?? DEFAULT_SSH_IDLE_TIMEOUT_SECONDS;
if (
!Number.isInteger(idleTimeoutSeconds) ||
idleTimeoutSeconds < 0 ||
idleTimeoutSeconds > MAX_SSH_IDLE_TIMEOUT_SECONDS
) {
throw new SystemError(
`SSH idle timeout must be an integer between 0 and ${MAX_SSH_IDLE_TIMEOUT_SECONDS} seconds, got ${idleTimeoutSeconds}.`,
{ trackingCode: 'SSH_IDLE_TIMEOUT_INVALID' }
);
}
return idleTimeoutSeconds;
}
export function getSshRelayServerUrl(job: Pick<Job, 'ssh'>): string {
const relayServerUrl = job.ssh?.relayServerUrl;
if (!relayServerUrl) {
throw new SystemError('SSH is enabled but no relay server URL was configured on the job.', {
trackingCode: 'SSH_RELAY_SERVER_URL_MISSING',
});
}
return relayServerUrl;
}
export type SshSessionHandle = {
getConnectedClientCountAsync: () => Promise<number>;
ensureConnectedAsync: () => Promise<void>;
stopAsync: () => Promise<void>;
};
export type StartedSshSession = {
handle: SshSessionHandle;
idleTimeoutSeconds: number;
};
async function createOrUpdateSessionAsync(
ctx: BuildContext,
{
target,
connectionConfig,
idleTimeoutSeconds,
}: {
target: TurtleSshTarget;
connectionConfig: SshConnectionConfig & { reconnecting?: boolean };
idleTimeoutSeconds: number;
}
): Promise<{ idleTimeoutSeconds: number }> {
const result = await ctx.graphqlClient
.mutation(CREATE_OR_UPDATE_TURTLE_SSH_SESSION_MUTATION, {
target,
connectionConfig: {
...connectionConfig,
type: 'UPTERM_V1',
},
sessionSettings: { idleTimeoutSeconds },
})
.toPromise();
if (result.error || !result.data) {
throw new SystemError(
`Failed to create or update the SSH session: ${result.error?.message ?? 'no data returned'}`,
{ cause: result.error }
);
}
const session = result.data.turtleSshSession.createOrUpdateTurtleSshSession;
return { idleTimeoutSeconds: session.sessionSettings.idleTimeoutSeconds };
}
export async function startSshSessionAsync(
ctx: BuildContext,
{
target,
relayServerUrl,
idleTimeoutSeconds: requestedIdleTimeoutSeconds,
}: {
target: TurtleSshTarget;
} & SshSettings
): Promise<StartedSshSession> {
const logger = ctx.logger;
const host = await startUptermHostAsync(ctx, { relayServerUrl });
let idleTimeoutSeconds: number;
try {
({ idleTimeoutSeconds } = await createOrUpdateSessionAsync(ctx, {
target,
connectionConfig: { ...host.connectionConfig, reconnecting: false },
idleTimeoutSeconds: requestedIdleTimeoutSeconds,
}));
} catch (err) {
await host.stopAsync().catch(() => {});
throw err;
}
const ensureConnectedAsync = async (): Promise<void> => {
if (host.isAlive()) {
return;
}
for (let attempt = 1; attempt <= MAX_SSH_REDIALS; attempt++) {
try {
if (!host.isAlive()) {
logger.warn('The SSH relay connection dropped. Reconnecting...');
await createOrUpdateSessionAsync(ctx, {
target,
connectionConfig: { ...host.connectionConfig, reconnecting: true },
idleTimeoutSeconds: requestedIdleTimeoutSeconds,
}).catch(() => {});
await host.redialAsync();
}
await createOrUpdateSessionAsync(ctx, {
target,
connectionConfig: { ...host.connectionConfig, reconnecting: false },
idleTimeoutSeconds: requestedIdleTimeoutSeconds,
});
logger.info('The SSH relay connection was restored.');
return;
} catch (err) {
logger.warn({ err }, `SSH reconnect attempt ${attempt} of ${MAX_SSH_REDIALS} failed.`);
if (attempt < MAX_SSH_REDIALS) {
await sleepAsync(REDIAL_BACKOFF_MS);
}
}
}
throw new SystemError(
`The SSH relay connection dropped and could not be restored after ${MAX_SSH_REDIALS} attempts.`
);
};
return {
handle: {
getConnectedClientCountAsync: () => host.getConnectedClientCountAsync(),
ensureConnectedAsync,
stopAsync: () => host.stopAsync(),
},
idleTimeoutSeconds,
};
}
export async function superviseSshSessionAsync({
handle,
idleTimeoutSeconds,
hasJobFinished,
logger,
}: {
handle: SshSessionHandle;
idleTimeoutSeconds: number;
hasJobFinished: () => boolean;
logger: bunyan;
}): Promise<void> {
const idleTimeoutMs = idleTimeoutSeconds * 1_000;
let idleSince: number | null = null;
let previousClientCount = 0;
for (;;) {
try {
await handle.ensureConnectedAsync();
} catch (err) {
logger.warn({ err }, 'Could not restore the SSH relay connection. Closing the session.');
Sentry.capture(
'Could not restore the SSH relay connection',
err instanceof Error ? err : undefined,
{
tags: { trackingCode: 'SSH_RELAY_RECONNECT_FAILED' },
}
);
return;
}
let connectedClientCount: number;
try {
connectedClientCount = await handle.getConnectedClientCountAsync();
} catch (err) {
logger.warn({ err }, 'Could not read the SSH client count. Closing the session.');
Sentry.capture(
'Could not read the SSH client count',
err instanceof Error ? err : undefined,
{
tags: { trackingCode: 'SSH_CLIENT_COUNT_UNREADABLE' },
}
);
return;
}
if (connectedClientCount !== previousClientCount) {
logger.info(`SSH clients connected: ${connectedClientCount}`);
previousClientCount = connectedClientCount;
}
const jobHasFinished = hasJobFinished();
if (connectedClientCount > 0 || !jobHasFinished) {
idleSince = null;
} else if (idleSince === null) {
idleSince = Date.now();
}
if (jobHasFinished && connectedClientCount === 0) {
if (idleTimeoutSeconds === 0) {
logger.info('The job finished and no SSH client is connected. Closing the session.');
return;
}
if (idleSince !== null && Date.now() - idleSince >= idleTimeoutMs) {
logger.info(
`No SSH client connected for ${formatSecondsForLog(idleTimeoutSeconds)} after the job finished. Closing the session.`
);
return;
}
}
await sleepAsync(CLIENT_COUNT_POLL_INTERVAL_MS);
}
}
+318
View File
@@ -0,0 +1,318 @@
import { Env, SystemError } from '@expo/eas-build-job';
import downloadFile from '@expo/downloader';
import spawn, { SpawnPromise, SpawnResult } from '@expo/turtle-spawn';
import fs from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import { z } from 'zod';
import { isChildProcessAlive, killProcessGroup } from './processes';
import { sleepAsync } from './retry';
import { BuildContext } from '../context';
const CONTROL_CHARACTERS = /[\u0000-\u0008\u000B-\u001F\u007F-\u009F]/g;
export function resolveUptermGcsObjectName(
platform: NodeJS.Platform = process.platform,
arch: string = process.arch
): string {
if (platform === 'darwin' && arch === 'arm64') {
return 'upterm-darwin-arm64';
}
if (platform === 'linux' && arch === 'x64') {
return 'upterm-linux-amd64';
}
throw new SystemError(
`SSH upterm is only available on darwin/arm64 and linux/x64 (got ${platform}/${arch}).`
);
}
const UPTERM_GCS_BASE_URL = 'https://storage.googleapis.com/turtle-v2/upterm';
const UPTERM_DOWNLOAD_TIMEOUT_MS = 60_000;
const UPTERM_KEEPALIVE_SLEEP_SECONDS = 6 * 60 * 60;
const CONNECTION_POLL_INTERVAL_MS = 500;
const CONNECTION_STARTUP_TIMEOUT_MS = 60_000;
const PROCESS_EXIT_TIMEOUT_MS = 5_000;
const CLIENT_COUNT_READ_ATTEMPTS = 4;
const CLIENT_COUNT_READ_RETRY_MS = 500;
const DEFAULT_SSH_PORT = '22';
export type SshConnectionConfig = {
type: 'upterm-v1';
host: string;
secret: string;
};
export type UptermHost = {
connectionConfig: SshConnectionConfig;
getConnectedClientCountAsync: () => Promise<number>;
isAlive: () => boolean;
redialAsync: () => Promise<SshConnectionConfig>;
stopAsync: () => Promise<void>;
};
const UptermSessionJsonZ = z.object({
sessionId: z.string().min(1),
host: z.string().min(1),
clientCount: z.number().optional(),
});
type UptermSessionJson = z.infer<typeof UptermSessionJsonZ>;
export function connectionConfigFromUptermSession(
parsed: Pick<UptermSessionJson, 'sessionId' | 'host'>
): SshConnectionConfig | null {
let host = parsed.host;
if (host.includes('://')) {
let url: URL;
try {
url = new URL(host);
} catch {
return null;
}
host = url.port && url.port !== DEFAULT_SSH_PORT ? `${url.hostname}:${url.port}` : url.hostname;
}
if (!host) {
return null;
}
return { type: 'upterm-v1', host, secret: parsed.sessionId };
}
/**
* Strip upterm session secrets out of text before it reaches a log or an error message. upterm
* prints the session id as the userinfo of the connect line it advertises
* (`upterm proxy ws(s)://<sessionId>@host`) and repeats it bare as the ssh destination on the
* same line. Control characters are dropped first so they cannot split a token mid-match. The
* loop lifts the id out of the proxy URL and removes every occurrence of it; the final replace
* blanks userinfo in any other URL as a catch-all.
*/
export function redactConnectionSecrets(text: string): string {
let redacted = text.replace(CONTROL_CHARACTERS, '');
for (const [, token] of redacted.matchAll(/upterm proxy wss?:\/\/([^@\s]+)@/g)) {
redacted = redacted.split(token).join('<redacted>');
}
return redacted.replace(/([a-z][a-z0-9+.-]*:\/\/)[^@\s/]+@/gi, '$1<redacted>@');
}
export function redactSpawnErrorForLog(err: unknown): unknown {
if (!err || typeof err !== 'object') {
return err;
}
const spawnErr = err as { message?: unknown; stdout?: unknown; stderr?: unknown };
return {
...spawnErr,
...(typeof spawnErr.message === 'string'
? { message: redactConnectionSecrets(spawnErr.message) }
: {}),
...(typeof spawnErr.stdout === 'string'
? { stdout: redactConnectionSecrets(spawnErr.stdout) }
: {}),
...(typeof spawnErr.stderr === 'string'
? { stderr: redactConnectionSecrets(spawnErr.stderr) }
: {}),
};
}
export async function resolveUptermPathAsync(env: Env): Promise<string> {
try {
await spawn('upterm', ['version'], { stdio: 'pipe', env });
return 'upterm';
} catch {}
const objectName = resolveUptermGcsObjectName();
const downloadDir = await fs.mkdtemp(path.join(os.tmpdir(), 'eas-upterm-'));
const uptermPath = path.join(downloadDir, objectName);
const url = `${UPTERM_GCS_BASE_URL}/${objectName}`;
try {
await downloadFile(url, uptermPath, { retry: 3, timeout: UPTERM_DOWNLOAD_TIMEOUT_MS });
await fs.chmod(uptermPath, 0o755);
} catch (err) {
await fs.rm(downloadDir, { recursive: true, force: true }).catch(() => {});
throw new SystemError(
`The upterm SSH client was not on PATH and could not be downloaded from ${url}. ${
err instanceof Error ? err.message : String(err)
}`
);
}
return uptermPath;
}
async function findAdminSocketPathAsync(uptermSocketDir: string): Promise<string | null> {
// Use this dial's own admin socket, not upterm's default, which can still point at a previous
// dial's session after a redial and break client-count reads.
const entries = await fs.readdir(uptermSocketDir).catch(() => [] as string[]);
const socketName = entries.find(entry => entry.endsWith('.sock'));
return socketName ? path.join(uptermSocketDir, socketName) : null;
}
async function readCurrentSessionJsonAsync(
uptermPath: string,
adminSocketPath: string
): Promise<UptermSessionJson | null> {
try {
const result = await spawn(
uptermPath,
['session', 'current', '--admin-socket', adminSocketPath, '--output', 'json'],
{ stdio: 'pipe' }
);
const parsed = UptermSessionJsonZ.safeParse(JSON.parse(result.stdout));
return parsed.success ? parsed.data : null;
} catch {
return null;
}
}
async function waitForConnectionConfigAsync(
uptermPath: string,
uptermSocketDir: string,
getHostOutput: () => string
): Promise<SshConnectionConfig> {
const deadline = Date.now() + CONNECTION_STARTUP_TIMEOUT_MS;
for (;;) {
const adminSocketPath = await findAdminSocketPathAsync(uptermSocketDir);
if (adminSocketPath) {
const session = await readCurrentSessionJsonAsync(uptermPath, adminSocketPath);
if (session) {
const connectionConfig = connectionConfigFromUptermSession(session);
if (connectionConfig) {
return connectionConfig;
}
}
}
if (Date.now() >= deadline) {
throw new SystemError(
`The upterm client did not register with the relay within ${
CONNECTION_STARTUP_TIMEOUT_MS / 1_000
}s. Output:\n${redactConnectionSecrets(getHostOutput())}`
);
}
await sleepAsync(CONNECTION_POLL_INTERVAL_MS);
}
}
export async function startUptermHostAsync(
ctx: BuildContext,
{ relayServerUrl }: { relayServerUrl: string }
): Promise<UptermHost> {
const uptermPath = await resolveUptermPathAsync(ctx.env);
const stateDir = await fs.mkdtemp(path.join(os.tmpdir(), 'eas-ssh-'));
const hostKeyPath = path.join(stateDir, 'id_host');
const forceCommandPath = path.join(stateDir, 'join.sh');
const uptermSocketDir = path.join(stateDir, 'upterm');
await spawn('ssh-keygen', ['-t', 'ed25519', '-N', '', '-f', hostKeyPath, '-q'], {
logger: ctx.logger,
});
await fs.writeFile(forceCommandPath, '#!/usr/bin/env bash\nexec bash -l\n', { mode: 0o755 });
let currentProcess: SpawnPromise<SpawnResult> | null = null;
const stopCurrentProcessAsync = async (): Promise<void> => {
const previousProcess = currentProcess;
currentProcess = null;
if (!previousProcess) {
return;
}
killProcessGroup(previousProcess.child);
await Promise.race([
previousProcess.catch(() => {}),
sleepAsync(PROCESS_EXIT_TIMEOUT_MS).then(() => {
ctx.logger.debug('The previous upterm host process did not exit in time.');
}),
]);
};
const getConnectedClientCountAsync = async (): Promise<number> => {
for (let attempt = 1; attempt <= CLIENT_COUNT_READ_ATTEMPTS; attempt++) {
const adminSocketPath = await findAdminSocketPathAsync(uptermSocketDir);
const session = adminSocketPath
? await readCurrentSessionJsonAsync(uptermPath, adminSocketPath)
: null;
if (session && typeof session.clientCount === 'number') {
return session.clientCount;
}
if (attempt < CLIENT_COUNT_READ_ATTEMPTS) {
await sleepAsync(CLIENT_COUNT_READ_RETRY_MS);
}
}
throw new SystemError('Could not read the SSH client count from the upterm admin socket.', {
trackingCode: 'SSH_CLIENT_COUNT_UNREADABLE',
});
};
const dialAsync = async (): Promise<SshConnectionConfig> => {
await stopCurrentProcessAsync();
await fs.rm(uptermSocketDir, { recursive: true, force: true }).catch(err => {
ctx.logger.debug({ err }, 'Failed to clear the previous SSH socket directory.');
});
ctx.logger.debug('Connecting to the SSH relay.');
// --force-command is what each connecting SSH client runs (a login shell). The `sleep` after
// `--` is the host-side process that keeps `upterm host` up while nobody is connected.
const uptermProcess = spawn(
uptermPath,
[
'host',
'--server',
relayServerUrl,
'--accept',
'--skip-host-key-check',
'-i',
hostKeyPath,
'--force-command',
forceCommandPath,
'--',
'bash',
'-lc',
`sleep ${UPTERM_KEEPALIVE_SLEEP_SECONDS}`,
],
{
// upterm puts its admin socket under XDG_RUNTIME_DIR; point it at our state dir so we can
// find it for `session current` and clean it up on stop/redial.
env: { ...ctx.env, XDG_RUNTIME_DIR: stateDir },
stdio: ['ignore', 'pipe', 'pipe'],
detached: true,
}
);
uptermProcess.catch(err =>
ctx.logger.debug({ err: redactSpawnErrorForLog(err) }, 'The upterm host process exited.')
);
uptermProcess.child.unref();
currentProcess = uptermProcess;
let output = '';
const appendChunk = (chunk: Buffer | string): void => {
output += chunk.toString();
};
uptermProcess.child.stdout?.on('data', appendChunk);
uptermProcess.child.stderr?.on('data', appendChunk);
return await waitForConnectionConfigAsync(uptermPath, uptermSocketDir, () => output);
};
const stopAsync = async (): Promise<void> => {
await stopCurrentProcessAsync();
await fs.rm(stateDir, { recursive: true, force: true });
};
let connectionConfig: SshConnectionConfig;
try {
connectionConfig = await dialAsync();
} catch (err) {
await stopAsync();
throw err;
}
return {
get connectionConfig() {
return connectionConfig;
},
getConnectedClientCountAsync,
isAlive: () => currentProcess != null && isChildProcessAlive(currentProcess.child),
redialAsync: async () => {
connectionConfig = await dialAsync();
return connectionConfig;
},
stopAsync,
};
}
+129 -129
View File
File diff suppressed because it is too large. Load diff
+551 -23
View File
@@ -14158,6 +14158,35 @@
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "customEvent",
"description": "A single custom event by the `id` of an `AppObserveCustomEvent`. Null when the app has no\nsuch event, including when it has aged out of retention.",
"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": "AppObserveCustomEvent",
"ofType": null
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "customEventCounts",
"description": null,
@@ -14396,6 +14425,26 @@
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "platforms",
"description": "Filter to these platforms. Cannot be set together with platform.",
"type": {
"kind": "LIST",
"name": null,
"ofType": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "ENUM",
"name": "AppObservePlatform",
"ofType": null
}
}
},
"defaultValue": null,
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "startTime",
"description": null,
@@ -14453,6 +14502,26 @@
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "platforms",
"description": "Filter to these platforms. Cannot be set together with platform.",
"type": {
"kind": "LIST",
"name": null,
"ofType": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "ENUM",
"name": "AppObservePlatform",
"ofType": null
}
}
},
"defaultValue": null,
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "startTime",
"description": null,
@@ -14618,6 +14687,35 @@
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "event",
"description": "A single metric event by the `id` of an `AppObserveEvent`. Null when the app has no such\nevent, including when it has aged out of retention or the id is not one this API issued.\n\n`sessionEventCount` and `userEventCount` are always null here: they are aggregates over a\ntime range, and a single event does not supply one.",
"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": "AppObserveEvent",
"ofType": null
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "events",
"description": null,
@@ -15028,7 +15126,7 @@
},
{
"name": "updates",
"description": null,
"description": "Update download activity (the Recent updates list). Not available on the free tier or legacy plans.",
"args": [
{
"name": "input",
@@ -16394,12 +16492,28 @@
"name": "platform",
"description": null,
"type": {
"kind": "NON_NULL",
"kind": "ENUM",
"name": "AppObservePlatform",
"ofType": null
},
"defaultValue": null,
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "platforms",
"description": "Filter to these platforms. Cannot be set together with platform. One of platform or platforms is required.",
"type": {
"kind": "LIST",
"name": null,
"ofType": {
"kind": "ENUM",
"name": "AppObservePlatform",
"ofType": null
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "ENUM",
"name": "AppObservePlatform",
"ofType": null
}
}
},
"defaultValue": null,
@@ -16659,6 +16773,26 @@
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "platforms",
"description": "Filter to these platforms. Cannot be set together with platform.",
"type": {
"kind": "LIST",
"name": null,
"ofType": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "ENUM",
"name": "AppObservePlatform",
"ofType": null
}
}
},
"defaultValue": null,
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "propertyFilters",
"description": null,
@@ -17524,6 +17658,26 @@
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "platforms",
"description": "Filter to these platforms. Cannot be set together with platform.",
"type": {
"kind": "LIST",
"name": null,
"ofType": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "ENUM",
"name": "AppObservePlatform",
"ofType": null
}
}
},
"defaultValue": null,
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "startTime",
"description": null,
@@ -17797,6 +17951,26 @@
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "platforms",
"description": "Filter to these platforms. Cannot be set together with platform.",
"type": {
"kind": "LIST",
"name": null,
"ofType": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "ENUM",
"name": "AppObservePlatform",
"ofType": null
}
}
},
"defaultValue": null,
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "severity",
"description": "Restrict to fatal-only or non-fatal-only errors.",
@@ -18268,6 +18442,26 @@
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "platforms",
"description": "Filter to these platforms. Cannot be set together with platform.",
"type": {
"kind": "LIST",
"name": null,
"ofType": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "ENUM",
"name": "AppObservePlatform",
"ofType": null
}
}
},
"defaultValue": null,
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "startTime",
"description": null,
@@ -18561,6 +18755,26 @@
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "platforms",
"description": "Filter to these platforms. Cannot be set together with platform.",
"type": {
"kind": "LIST",
"name": null,
"ofType": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "ENUM",
"name": "AppObservePlatform",
"ofType": null
}
}
},
"defaultValue": null,
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "startTime",
"description": null,
@@ -19312,6 +19526,26 @@
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "platforms",
"description": "Filter to these platforms. Cannot be set together with platform.",
"type": {
"kind": "LIST",
"name": null,
"ofType": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "ENUM",
"name": "AppObservePlatform",
"ofType": null
}
}
},
"defaultValue": null,
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "routeName",
"description": null,
@@ -19705,6 +19939,26 @@
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "platforms",
"description": "Filter to these platforms. Cannot be set together with platform.",
"type": {
"kind": "LIST",
"name": null,
"ofType": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "ENUM",
"name": "AppObservePlatform",
"ofType": null
}
}
},
"defaultValue": null,
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "routeNames",
"description": null,
@@ -20094,6 +20348,26 @@
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "platforms",
"description": "Filter to these platforms. Cannot be set together with platform.",
"type": {
"kind": "LIST",
"name": null,
"ofType": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "ENUM",
"name": "AppObservePlatform",
"ofType": null
}
}
},
"defaultValue": null,
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "startTime",
"description": null,
@@ -20470,6 +20744,26 @@
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "platforms",
"description": "Filter to these platforms. Cannot be set together with platform.",
"type": {
"kind": "LIST",
"name": null,
"ofType": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "ENUM",
"name": "AppObservePlatform",
"ofType": null
}
}
},
"defaultValue": null,
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "startTime",
"description": null,
@@ -20510,7 +20804,7 @@
},
{
"name": "downloadCount",
"description": "Downloads recorded in range across platforms; null when none were recorded (and always for the embedded bundle).",
"description": "Downloads recorded in range across platforms; null when none were recorded\n(and always for the embedded bundle). Download data is paid-only: on the\nfree tier and legacy plans this is always null.",
"args": [],
"type": {
"kind": "SCALAR",
@@ -20522,7 +20816,7 @@
},
{
"name": "downloads",
"description": "One entry per platform with downloads in range; empty for the embedded bundle.",
"description": "One entry per platform with downloads in range; empty for the embedded bundle and on the free tier and legacy plans.",
"args": [],
"type": {
"kind": "NON_NULL",
@@ -21499,6 +21793,26 @@
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "platforms",
"description": "Filter to these platforms. Cannot be set together with platform.",
"type": {
"kind": "LIST",
"name": null,
"ofType": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "ENUM",
"name": "AppObservePlatform",
"ofType": null
}
}
},
"defaultValue": null,
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "startTime",
"description": null,
@@ -21896,6 +22210,26 @@
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "platforms",
"description": "Filter to these platforms. Cannot be set together with platform.",
"type": {
"kind": "LIST",
"name": null,
"ofType": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "ENUM",
"name": "AppObservePlatform",
"ofType": null
}
}
},
"defaultValue": null,
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "routeName",
"description": null,
@@ -22403,6 +22737,26 @@
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "platforms",
"description": "Filter to these platforms. Cannot be set together with platform.",
"type": {
"kind": "LIST",
"name": null,
"ofType": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "ENUM",
"name": "AppObservePlatform",
"ofType": null
}
}
},
"defaultValue": null,
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "startTime",
"description": null,
@@ -37355,6 +37709,18 @@
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "resourceClass",
"description": null,
"type": {
"kind": "ENUM",
"name": "DeviceRunSessionResourceClass",
"ofType": null
},
"defaultValue": null,
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "sdkVersion",
"description": "Expo SDK version used to select an Expo Go application archive. Only supported when expoGo is\ntrue. If omitted, the current Expo Go archive for the platform is used.",
@@ -42847,6 +43213,18 @@
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "applicationArchiveUrl",
"description": "Direct application archive URL installed and launched for this session. Null when\nthe session uses an EAS Build or starts without an app.",
"args": [],
"type": {
"kind": "SCALAR",
"name": "String",
"ofType": null
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "artifacts",
"description": null,
@@ -42871,6 +43249,18 @@
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "build",
"description": "EAS Build installed and launched for this session. Null when the session uses\na direct application archive URL or starts without an app.",
"args": [],
"type": {
"kind": "OBJECT",
"name": "Build",
"ofType": null
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "createdAt",
"description": null,
@@ -43724,6 +44114,29 @@
}
]
},
{
"kind": "ENUM",
"name": "DeviceRunSessionResourceClass",
"description": null,
"fields": null,
"inputFields": null,
"interfaces": null,
"enumValues": [
{
"name": "LARGE",
"description": null,
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "MEDIUM",
"description": null,
"isDeprecated": false,
"deprecationReason": null
}
],
"possibleTypes": null
},
{
"kind": "ENUM",
"name": "DeviceRunSessionStatus",
@@ -75023,24 +75436,16 @@
"deprecationReason": null
},
{
"name": "turtleBuildId",
"name": "target",
"description": null,
"type": {
"kind": "SCALAR",
"name": "ID",
"ofType": null
},
"defaultValue": null,
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "turtleJobRunId",
"description": null,
"type": {
"kind": "SCALAR",
"name": "ID",
"ofType": null
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "INPUT_OBJECT",
"name": "TurtleSshTargetInput",
"ofType": null
}
},
"defaultValue": null,
"isDeprecated": false,
@@ -75119,6 +75524,72 @@
"enumValues": null,
"possibleTypes": null
},
{
"kind": "INPUT_OBJECT",
"name": "TurtleSshTargetInput",
"description": null,
"fields": null,
"inputFields": [
{
"name": "id",
"description": null,
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "SCALAR",
"name": "ID",
"ofType": null
}
},
"defaultValue": null,
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "type",
"description": null,
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "ENUM",
"name": "TurtleSshTargetType",
"ofType": null
}
},
"defaultValue": null,
"isDeprecated": false,
"deprecationReason": null
}
],
"interfaces": null,
"enumValues": null,
"possibleTypes": null
},
{
"kind": "ENUM",
"name": "TurtleSshTargetType",
"description": null,
"fields": null,
"inputFields": null,
"interfaces": null,
"enumValues": [
{
"name": "BUILD",
"description": null,
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "JOB_RUN",
"description": null,
"isDeprecated": false,
"deprecationReason": null
}
],
"possibleTypes": null
},
{
"kind": "ENUM",
"name": "TurtleSshTransportType",
@@ -94676,6 +95147,18 @@
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "cancelReason",
"description": "Why the server canceled this run. Null for manually canceled runs and for\nruns canceled before the reason was recorded.",
"args": [],
"type": {
"kind": "UNION",
"name": "WorkflowRunCancelReason",
"ofType": null
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "createdAt",
"description": null,
@@ -95040,6 +95523,45 @@
"enumValues": null,
"possibleTypes": null
},
{
"kind": "UNION",
"name": "WorkflowRunCancelReason",
"description": null,
"fields": null,
"inputFields": null,
"interfaces": null,
"enumValues": null,
"possibleTypes": [
{
"kind": "OBJECT",
"name": "WorkflowRunCancelReasonConcurrencyGroup",
"ofType": null
}
]
},
{
"kind": "OBJECT",
"name": "WorkflowRunCancelReasonConcurrencyGroup",
"description": null,
"fields": [
{
"name": "cancelingWorkflowRun",
"description": "The newer run in the same concurrency group whose creation canceled this run.\nNull if that run no longer exists.",
"args": [],
"type": {
"kind": "OBJECT",
"name": "WorkflowRun",
"ofType": null
},
"isDeprecated": false,
"deprecationReason": null
}
],
"inputFields": null,
"interfaces": [],
"enumValues": null,
"possibleTypes": null
},
{
"kind": "OBJECT",
"name": "WorkflowRunEdge",
@@ -95621,6 +96143,12 @@
"description": null,
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "WAITING",
"description": null,
"isDeprecated": false,
"deprecationReason": null
}
],
"possibleTypes": null
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "eas-cli",
"version": "22.5.0",
"version": "22.6.0",
"description": "EAS command line tool",
"keywords": [
"cli",
@@ -30,6 +30,7 @@ import Log, { link } from '../log';
import Sentry from '../sentry';
import SessionManager from '../user/SessionManager';
import { getActorDisplayName } from '../user/User';
import { isMultiAccountEnabled } from '../utils/easCli';
import { Client } from '../vcs/vcs';
export type ContextInput<
@@ -224,11 +225,75 @@ export default abstract class EasCommand extends Command {
protected abstract runAsync(): Promise<any>;
/**
* Parse and remove the --account flag from command line arguments.
* This is a global flag available on all commands when multi-account is enabled.
* We remove it from this.argv so child commands don't see it during their parse().
* Commands that declare their own `account` flag (with different semantics,
* e.g. `project:init`) are skipped so their flag keeps working.
*/
private parseAndRemoveAccountFlag(): string | undefined {
if (this.ctor.flags && 'account' in this.ctor.flags) {
return undefined;
}
const terminatorIndex = this.argv.indexOf('--');
const searchEnd = terminatorIndex === -1 ? this.argv.length : terminatorIndex;
for (let i = 0; i < searchEnd; i++) {
const arg = this.argv[i];
if (arg === '--account') {
const value = i + 1 < searchEnd ? this.argv[i + 1] : undefined;
if (!value || value.startsWith('-')) {
throw new Error('The --account flag requires a username value.');
}
this.argv.splice(i, 2);
return value;
}
if (arg.startsWith('--account=')) {
const value = arg.slice('--account='.length);
if (!value) {
throw new Error('The --account flag requires a username value.');
}
this.argv.splice(i, 1);
return value;
}
}
return undefined;
}
// eslint-disable-next-line async-protect/async-suffix
async run(): Promise<any> {
this.analyticsInternal = await createAnalyticsAsync();
this.sessionManagerInternal = new SessionManager(this.analytics);
// Handle --account flag for per-command account selection
if (isMultiAccountEnabled()) {
const accountFlag = this.parseAndRemoveAccountFlag();
if (accountFlag) {
if (this.sessionManager.getAccessToken()) {
throw new Error(
'EXPO_TOKEN is set in your environment and is used for all EAS authentication, so --account has no effect. Unset EXPO_TOKEN to select an account with --account.'
);
}
const accounts = this.sessionManager.getAllAccounts();
const targetAccount = accounts.find(a => a.username === accountFlag);
if (!targetAccount) {
const availableAccounts = accounts.map(a => a.username).join(', ') || 'none';
throw new Error(
`Account '${accountFlag}' not found. Available accounts: ${availableAccounts}`
);
}
if (
!targetAccount.isActive &&
!this.sessionManager.useAccountForProcessByUsername(accountFlag)
) {
throw new Error(`Account '${accountFlag}' not found.`);
}
Log.log(chalk.dim(`Using account: ${accountFlag}`));
}
}
// this is needed for logEvent call below as it identifies the user in the analytics system
// if possible
const actor = await this.sessionManager.getUserAsync();
@@ -0,0 +1,63 @@
import chalk from 'chalk';
import EasCommand from '../../commandUtils/EasCommand';
import Log from '../../log';
import { fromNow } from '../../utils/date';
import { isMultiAccountEnabled } from '../../utils/easCli';
export default class AccountList extends EasCommand {
static override description = 'list all logged-in Expo accounts';
static override contextDefinition = {
...this.ContextOptions.SessionManagment,
};
// Hide this command when the feature flag is disabled
static override get hidden(): boolean {
return !isMultiAccountEnabled();
}
async runAsync(): Promise<void> {
if (!isMultiAccountEnabled()) {
Log.error(
'Multi-account listing is not enabled. Set EAS_EXPERIMENTAL_ACCOUNT_SWITCHER=1 to enable.'
);
process.exit(1);
}
const { sessionManager } = await this.getContextAsync(AccountList, { nonInteractive: true });
if (sessionManager.getAccessToken()) {
Log.log(chalk.yellow('Using EXPO_TOKEN from environment for authentication.'));
Log.log('Account switching is not available when using EXPO_TOKEN.');
return;
}
const accounts = sessionManager.getAllAccounts();
if (accounts.length === 0) {
Log.log('No accounts logged in.');
Log.log(`Run ${chalk.bold('eas login')} to log in.`);
return;
}
Log.log('Logged-in accounts:');
Log.newLine();
for (const account of accounts) {
const marker = account.isActive ? chalk.green('●') : chalk.dim('○');
const activeLabel = account.isActive ? chalk.dim(' (active)') : '';
const username = account.isActive ? chalk.bold(account.username) : account.username;
Log.log(`${marker} ${username}${activeLabel}`);
if (account.lastUsedAt) {
const lastUsed = fromNow(new Date(account.lastUsedAt));
Log.log(chalk.dim(` └─ Last used: ${lastUsed} ago`));
}
}
Log.newLine();
Log.log(`Use ${chalk.bold('eas account:switch')} to change accounts.`);
}
}
+65 -2
View File
@@ -3,8 +3,10 @@ import chalk from 'chalk';
import EasCommand from '../../commandUtils/EasCommand';
import Log from '../../log';
import { confirmAsync } from '../../prompts';
import { getActorDisplayName } from '../../user/User';
import { confirmAsync, selectAsync } from '../../prompts';
import SessionManager from '../../user/SessionManager';
import { Actor, getActorDisplayName } from '../../user/User';
import { isMultiAccountEnabled } from '../../utils/easCli';
export default class AccountLogin extends EasCommand {
static override description = 'log in with your Expo account';
@@ -47,6 +49,13 @@ export default class AccountLogin extends EasCommand {
}
if (actor) {
if (isMultiAccountEnabled()) {
// Multi-account mode: offer options
await this.handleMultiAccountLoginAsync(sessionManager, actor, { sso, browser });
return;
}
// Legacy mode: simple confirmation
Log.warn(`You are already logged in as ${chalk.bold(getActorDisplayName(actor))}.`);
const shouldContinue = await confirmAsync({
@@ -60,4 +69,58 @@ export default class AccountLogin extends EasCommand {
await sessionManager.showLoginPromptAsync({ sso, browser });
Log.log('Logged in');
}
private async handleMultiAccountLoginAsync(
sessionManager: SessionManager,
actor: Actor,
{ sso, browser }: { sso: boolean; browser: boolean }
): Promise<void> {
const accounts = sessionManager.getAllAccounts();
const currentUsername = getActorDisplayName(actor);
Log.log(`You're logged in as ${chalk.bold(currentUsername)}.`);
const choices: { title: string; value: string }[] = [
{
title: 'Add another account',
value: 'add',
},
];
// Add switch options for other accounts
const otherAccounts = accounts.filter(a => !a.isActive);
if (otherAccounts.length > 0) {
for (const account of otherAccounts) {
choices.push({
title: `Switch to ${account.username}`,
value: `switch:${account.username}`,
});
}
}
choices.push({
title: 'Cancel',
value: 'cancel',
});
const action = await selectAsync('What would you like to do?', choices);
if (action === 'cancel') {
Errors.error('Aborted', { exit: 1 });
}
if (action === 'add') {
await sessionManager.showLoginPromptAsync({ sso, browser });
const newAccounts = sessionManager.getAllAccounts();
Log.log('Logged in');
Log.log(`You now have ${newAccounts.length} account${newAccounts.length > 1 ? 's' : ''}.`);
return;
}
if (action.startsWith('switch:')) {
const username = action.slice('switch:'.length);
await sessionManager.switchAccountByUsernameAsync(username);
Log.log(`Switched to ${chalk.bold(username)}`);
}
}
}
+105 -2
View File
@@ -1,17 +1,120 @@
import { Args, Flags } from '@oclif/core';
import chalk from 'chalk';
import EasCommand from '../../commandUtils/EasCommand';
import Log from '../../log';
import { confirmAsync } from '../../prompts';
import { isMultiAccountEnabled } from '../../utils/easCli';
export default class AccountLogout extends EasCommand {
static override description = 'log out';
static override aliases = ['logout'];
static override args = {
username: Args.string({
description: 'Username of the account to log out (multi-account mode only)',
required: false,
}),
};
static override flags = {
all: Flags.boolean({
description: 'Log out of all accounts (multi-account mode only)',
default: false,
}),
};
static override contextDefinition = {
...this.ContextOptions.SessionManagment,
};
async runAsync(): Promise<void> {
const { args, flags } = await this.parse(AccountLogout);
const { sessionManager } = await this.getContextAsync(AccountLogout, { nonInteractive: false });
await sessionManager.logoutAsync();
Log.log('Logged out');
if (!isMultiAccountEnabled()) {
if (args.username || flags.all) {
Log.error(
'Logging out a specific account requires the experimental account switcher. Set EAS_EXPERIMENTAL_ACCOUNT_SWITCHER=1 to enable it.'
);
process.exit(1);
}
// Legacy behavior
await sessionManager.logoutAsync();
Log.log('Logged out');
return;
}
// Multi-account mode
const accounts = sessionManager.getAllAccounts();
if (accounts.length === 0) {
Log.log('Not logged in to any accounts.');
return;
}
// Handle --all flag
if (flags.all) {
const confirmed = await confirmAsync({
message: `Log out of all ${accounts.length} account${accounts.length > 1 ? 's' : ''}?`,
});
if (!confirmed) {
Log.log('Aborted');
return;
}
await sessionManager.removeAllAccountsAsync();
Log.log('Logged out of all accounts');
return;
}
// Handle specific username argument
if (args.username) {
const account = accounts.find(a => a.username === args.username);
if (!account) {
Log.error(`Account '${args.username}' not found.`);
Log.log('Logged-in accounts:');
accounts.forEach(a => {
Log.log(` • ${a.username}${a.isActive ? ' (active)' : ''}`);
});
process.exit(1);
}
await sessionManager.removeAccountAsync(account.userId);
Log.log(`Logged out of ${chalk.bold(args.username)}`);
// Show remaining accounts info
const remainingAccounts = sessionManager.getAllAccounts();
if (remainingAccounts.length > 0) {
const activeAccount = remainingAccounts.find(a => a.isActive);
if (activeAccount) {
Log.log(`Active account is now: ${chalk.bold(activeAccount.username)}`);
}
}
return;
}
// Default: log out of active account
const activeAccount = accounts.find(a => a.isActive);
if (!activeAccount) {
Log.log('No active account to log out of.');
return;
}
await sessionManager.removeAccountAsync(activeAccount.userId);
Log.log(`Logged out of ${chalk.bold(activeAccount.username)}`);
// Show remaining accounts info
const remainingAccounts = sessionManager.getAllAccounts();
if (remainingAccounts.length > 0) {
const newActiveAccount = remainingAccounts.find(a => a.isActive);
if (newActiveAccount) {
Log.log(`Active account is now: ${chalk.bold(newActiveAccount.username)}`);
}
} else {
Log.log('No accounts remaining. Run `eas login` to log in.');
}
}
}
@@ -0,0 +1,98 @@
import { Args } from '@oclif/core';
import chalk from 'chalk';
import EasCommand from '../../commandUtils/EasCommand';
import Log from '../../log';
import { selectAsync } from '../../prompts';
import { isMultiAccountEnabled } from '../../utils/easCli';
export default class AccountSwitch extends EasCommand {
static override description = 'switch to a different Expo account';
static override args = {
username: Args.string({
description: 'Username of the account to switch to',
required: false,
}),
};
static override contextDefinition = {
...this.ContextOptions.SessionManagment,
};
// Hide this command when the feature flag is disabled
static override get hidden(): boolean {
return !isMultiAccountEnabled();
}
async runAsync(): Promise<void> {
if (!isMultiAccountEnabled()) {
Log.error(
'Multi-account switching is not enabled. Set EAS_EXPERIMENTAL_ACCOUNT_SWITCHER=1 to enable.'
);
process.exit(1);
}
const { args } = await this.parse(AccountSwitch);
const { sessionManager } = await this.getContextAsync(AccountSwitch, { nonInteractive: false });
const accounts = sessionManager.getAllAccounts();
if (accounts.length === 0) {
Log.warn('No accounts logged in. Run `eas login` to log in.');
process.exit(1);
}
if (accounts.length === 1) {
Log.log(`Only one account is logged in: ${chalk.bold(accounts[0].username)}`);
Log.log('Run `eas login` to add another account.');
return;
}
let targetUsername = args.username;
if (!targetUsername) {
// Interactive mode: show account picker
const choices = accounts.map(account => {
const title = account.isActive
? `${account.username} ${chalk.dim('(active)')}`
: account.username;
return { title, value: account.username };
});
choices.push({
title: chalk.dim('Add new account...'),
value: '__add_new__',
});
targetUsername = await selectAsync('Select an account:', choices);
if (targetUsername === '__add_new__') {
// Match the default `eas login` behavior (browser-based login)
await sessionManager.showLoginPromptAsync({ browser: true });
Log.log('Logged in');
return;
}
}
// Check if already active
const targetAccount = accounts.find(a => a.username === targetUsername);
if (!targetAccount) {
Log.error(`Account '${targetUsername}' not found.`);
Log.log('Available accounts:');
accounts.forEach(a => {
Log.log(` • ${a.username}${a.isActive ? ' (active)' : ''}`);
});
process.exit(1);
}
if (targetAccount.isActive) {
Log.log(`Already using account ${chalk.bold(targetUsername)}`);
return;
}
// Switch to the account
await sessionManager.switchAccountByUsernameAsync(targetUsername);
Log.log(`Switched to ${chalk.bold(targetUsername)}`);
}
}
+38 -23
View File
@@ -4,6 +4,7 @@ import EasCommand from '../../commandUtils/EasCommand';
import { Role } from '../../graphql/generated';
import Log from '../../log';
import { Actor, getActorDisplayName } from '../../user/User';
import { isMultiAccountEnabled } from '../../utils/easCli';
export default class AccountView extends EasCommand {
static override description = 'show the username you are logged in as';
@@ -11,39 +12,53 @@ export default class AccountView extends EasCommand {
static override contextDefinition = {
...this.ContextOptions.MaybeLoggedIn,
...this.ContextOptions.SessionManagment,
};
async runAsync(): Promise<void> {
const {
maybeLoggedIn: { actor, authenticationInfo },
sessionManager,
} = await this.getContextAsync(AccountView, { nonInteractive: true });
if (actor) {
const displayName = getActorDisplayName(actor);
const loggedInAs = authenticationInfo.accessToken
? `${displayName} (authenticated using EXPO_TOKEN)`
: displayName;
Log.log(chalk.green(loggedInAs));
if ('email' in actor) {
Log.log(actor.email);
}
// personal account is included, only show if more accounts that personal account
// but do show personal account in list if there are more
const accountExcludingPersonalAccount = actor.accounts.filter(
account => !('username' in actor) || account.name !== actor.username
);
if (accountExcludingPersonalAccount.length > 0) {
Log.newLine();
Log.log('Accounts:');
actor.accounts.forEach(account => {
const roleOnAccount = AccountView.getRoleOnAccount(actor, account);
Log.log(`• ${account.name} (Role: ${AccountView.getLabelForRole(roleOnAccount)})`);
});
}
} else {
if (!actor) {
Log.warn('Not logged in');
process.exit(1);
}
const displayName = getActorDisplayName(actor);
const loggedInAs = authenticationInfo.accessToken
? `${displayName} (authenticated using EXPO_TOKEN)`
: displayName;
Log.log(chalk.green(loggedInAs));
if ('email' in actor) {
Log.log(actor.email);
}
// Show other logged-in accounts if multi-account is enabled
if (isMultiAccountEnabled() && !authenticationInfo.accessToken) {
const accounts = sessionManager.getAllAccounts();
const otherAccounts = accounts.filter(a => !a.isActive);
if (otherAccounts.length > 0) {
const otherUsernames = otherAccounts.map(a => a.username).join(', ');
Log.log(chalk.dim(`Also logged in: ${otherUsernames}`));
}
}
// personal account is included, only show if more accounts that personal account
// but do show personal account in list if there are more
const accountExcludingPersonalAccount = actor.accounts.filter(
account => !('username' in actor) || account.name !== actor.username
);
if (accountExcludingPersonalAccount.length > 0) {
Log.newLine();
Log.log('Accounts:');
actor.accounts.forEach(account => {
const roleOnAccount = AccountView.getRoleOnAccount(actor, account);
Log.log(`• ${account.name} (Role: ${AccountView.getLabelForRole(roleOnAccount)})`);
});
}
}
private static getRoleOnAccount(actor: Actor, account: Actor['accounts'][0]): Role {
@@ -6,6 +6,7 @@ import {
AppPlatform,
CreateDeviceRunSessionMutation,
DeviceRunSessionByIdQuery,
DeviceRunSessionResourceClass,
DeviceRunSessionStatus,
DeviceRunSessionType,
JobRunStatus,
@@ -522,6 +523,35 @@ describe(Simulator, () => {
);
});
it('omits resourceClass when --resource-class is not set', async () => {
const { command } = createCommand(['--platform', 'ios', '--non-interactive']);
await command.runAsync();
expect(mockCreateDeviceRunSessionAsync.mock.calls[0][1]).not.toHaveProperty('resourceClass');
});
it.each([
['large', DeviceRunSessionResourceClass.Large],
['medium', DeviceRunSessionResourceClass.Medium],
] as const)(
'forwards --resource-class %s to the create mutation',
async (flag, resourceClass) => {
const { command } = createCommand([
'--platform',
'ios',
'--non-interactive',
'--resource-class',
flag,
]);
await command.runAsync();
expect(mockCreateDeviceRunSessionAsync).toHaveBeenCalledWith(
graphqlClient,
expect.objectContaining({ resourceClass })
);
}
);
it('forwards --build-id to the create mutation', async () => {
const { command } = createCommand([
'--platform',
@@ -30,6 +30,8 @@ import {
} from '../../simulator/env';
import { resolveExpoGoSdkVersionAsync } from '../../simulator/expoGo';
import {
DEVICE_RUN_SESSION_RESOURCE_CLASS_BY_FLAG_VALUE,
DEVICE_RUN_SESSION_RESOURCE_CLASS_FLAG_VALUES,
DEVICE_RUN_SESSION_TYPE_BY_FLAG_VALUE,
DEVICE_RUN_SESSION_TYPE_FLAG_VALUES,
DeviceRunSessionRemoteConfig,
@@ -119,6 +121,11 @@ export default class Simulator extends EasCommand {
'Stop the simulator session automatically after this many minutes without session activity. When omitted, the session has no idle timeout and runs until its maximum duration.',
min: 0,
}),
'resource-class': Flags.option({
description: 'The instance type that will be used to run this simulator session',
hidden: true,
options: Object.values(DEVICE_RUN_SESSION_RESOURCE_CLASS_FLAG_VALUES),
})(),
force: Flags.boolean({
description:
'[default: true] Create a new simulator session even when an existing simulator session is present in the environment.',
@@ -177,6 +184,9 @@ export default class Simulator extends EasCommand {
const sdkVersionFromFlag = flags['sdk-version']?.trim() || undefined;
const launchArgs = flags['launch-arg'];
const openUrl = flags['open-url']?.trim() || undefined;
const resourceClass = flags['resource-class']
? DEVICE_RUN_SESSION_RESOURCE_CLASS_BY_FLAG_VALUE[flags['resource-class']]
: undefined;
if (sdkVersionFromFlag && !flags['expo-go']) {
throw new EasCommandError('The --sdk-version flag can only be used with --expo-go.');
@@ -233,6 +243,7 @@ export default class Simulator extends EasCommand {
...(expoGoSdkVersion ? { expoGo: true, sdkVersion: expoGoSdkVersion } : {}),
...(launchArgs?.length ? { launchArgs } : {}),
...(openUrl ? { openUrl } : {}),
...(resourceClass ? { resourceClass } : {}),
maxRunTimeMinutes: flags['max-duration-minutes'],
maxIdleTimeMinutes: flags['max-idle-time-minutes'],
});
+104 -6
View File
@@ -2173,6 +2173,11 @@ export type AppNotificationPreferenceInput = {
export type AppObserve = {
__typename?: 'AppObserve';
appVersions: Array<AppObserveAppVersion>;
/**
* A single custom event by the `id` of an `AppObserveCustomEvent`. Null when the app has no
* such event, including when it has aged out of retention.
*/
customEvent?: Maybe<AppObserveCustomEvent>;
customEventCounts: AppObserveCustomEventCounts;
customEventList: AppObserveCustomEventListConnection;
customEventNames: AppObserveCustomEventNames;
@@ -2185,6 +2190,14 @@ export type AppObserve = {
errorStats: AppObserveErrorStats;
/** Time-bucketed exception counts split into fatal vs non-fatal, for the stacked-bar chart. */
errorTimeSeries: AppObserveErrorTimeSeries;
/**
* A single metric event by the `id` of an `AppObserveEvent`. Null when the app has no such
* event, including when it has aged out of retention or the id is not one this API issued.
*
* `sessionEventCount` and `userEventCount` are always null here: they are aggregates over a
* time range, and a single event does not supply one.
*/
event?: Maybe<AppObserveEvent>;
events: AppObserveEventsConnection;
/** Highest Expo SDK version seen in telemetry over the trailing 30 days; null when none was reported. */
latestExpoSdkVersion?: Maybe<Scalars['String']['output']>;
@@ -2208,6 +2221,7 @@ export type AppObserve = {
* universe used by `appVersions` headline counts.
*/
uniqueActiveUserCount: Scalars['Int']['output'];
/** Update download activity (the Recent updates list). Not available on the free tier or legacy plans. */
updates: AppObserveUpdatesConnection;
};
@@ -2217,6 +2231,11 @@ export type AppObserveAppVersionsArgs = {
};
export type AppObserveCustomEventArgs = {
id: Scalars['ID']['input'];
};
export type AppObserveCustomEventCountsArgs = {
input: AppObserveCustomEventCountsInput;
};
@@ -2242,6 +2261,7 @@ export type AppObserveCustomEventNamesArgs = {
isEmbeddedUpdate?: InputMaybe<Scalars['Boolean']['input']>;
orderBy?: InputMaybe<AppObserveCustomEventNamesOrderBy>;
platform?: InputMaybe<AppObservePlatform>;
platforms?: InputMaybe<Array<AppObservePlatform>>;
startTime: Scalars['DateTime']['input'];
};
@@ -2249,6 +2269,7 @@ export type AppObserveCustomEventNamesArgs = {
export type AppObserveEnvironmentsArgs = {
endTime?: InputMaybe<Scalars['DateTime']['input']>;
platform?: InputMaybe<AppObservePlatform>;
platforms?: InputMaybe<Array<AppObservePlatform>>;
startTime?: InputMaybe<Scalars['DateTime']['input']>;
};
@@ -2273,6 +2294,11 @@ export type AppObserveErrorTimeSeriesArgs = {
};
export type AppObserveEventArgs = {
id: Scalars['ID']['input'];
};
export type AppObserveEventsArgs = {
after?: InputMaybe<Scalars['String']['input']>;
before?: InputMaybe<Scalars['String']['input']>;
@@ -2479,7 +2505,9 @@ export type AppObserveCustomEventCountsInput = {
environment?: InputMaybe<Scalars['String']['input']>;
eventName: Scalars['String']['input'];
isEmbeddedUpdate?: InputMaybe<Scalars['Boolean']['input']>;
platform: AppObservePlatform;
platform?: InputMaybe<AppObservePlatform>;
/** Filter to these platforms. Cannot be set together with platform. One of platform or platforms is required. */
platforms?: InputMaybe<Array<AppObservePlatform>>;
startTime: Scalars['DateTime']['input'];
};
@@ -2508,6 +2536,8 @@ export type AppObserveCustomEventListFilter = {
eventName?: InputMaybe<Scalars['String']['input']>;
isEmbeddedUpdate?: InputMaybe<Scalars['Boolean']['input']>;
platform?: InputMaybe<AppObservePlatform>;
/** Filter to these platforms. Cannot be set together with platform. */
platforms?: InputMaybe<Array<AppObservePlatform>>;
propertyFilters?: InputMaybe<Array<AppObserveCustomEventPropertyFilter>>;
sessionId?: InputMaybe<Scalars['String']['input']>;
startTime?: InputMaybe<Scalars['DateTime']['input']>;
@@ -2615,6 +2645,8 @@ export type AppObserveErrorGroupBreakdownInput = {
fingerprint: Scalars['String']['input'];
isEmbeddedUpdate?: InputMaybe<Scalars['Boolean']['input']>;
platform?: InputMaybe<AppObservePlatform>;
/** Filter to these platforms. Cannot be set together with platform. */
platforms?: InputMaybe<Array<AppObservePlatform>>;
startTime: Scalars['DateTime']['input'];
};
@@ -2647,6 +2679,8 @@ export type AppObserveErrorGroupsInput = {
isEmbeddedUpdate?: InputMaybe<Scalars['Boolean']['input']>;
orderBy?: InputMaybe<AppObserveErrorGroupsOrderBy>;
platform?: InputMaybe<AppObservePlatform>;
/** Filter to these platforms. Cannot be set together with platform. */
platforms?: InputMaybe<Array<AppObservePlatform>>;
/** Restrict to fatal-only or non-fatal-only errors. */
severity?: InputMaybe<AppObserveErrorSeverity>;
/** Restrict to a capture source (e.g. global). */
@@ -2710,6 +2744,8 @@ export type AppObserveErrorStatsInput = {
environment?: InputMaybe<Scalars['String']['input']>;
isEmbeddedUpdate?: InputMaybe<Scalars['Boolean']['input']>;
platform?: InputMaybe<AppObservePlatform>;
/** Filter to these platforms. Cannot be set together with platform. */
platforms?: InputMaybe<Array<AppObservePlatform>>;
startTime: Scalars['DateTime']['input'];
};
@@ -2746,6 +2782,8 @@ export type AppObserveErrorTimeSeriesInput = {
fingerprint?: InputMaybe<Scalars['String']['input']>;
isEmbeddedUpdate?: InputMaybe<Scalars['Boolean']['input']>;
platform?: InputMaybe<AppObservePlatform>;
/** Filter to these platforms. Cannot be set together with platform. */
platforms?: InputMaybe<Array<AppObservePlatform>>;
startTime: Scalars['DateTime']['input'];
};
@@ -2817,6 +2855,8 @@ export type AppObserveEventsFilter = {
isEmbeddedUpdate?: InputMaybe<Scalars['Boolean']['input']>;
metricName?: InputMaybe<Scalars['String']['input']>;
platform?: InputMaybe<AppObservePlatform>;
/** Filter to these platforms. Cannot be set together with platform. */
platforms?: InputMaybe<Array<AppObservePlatform>>;
routeName?: InputMaybe<Scalars['String']['input']>;
sessionId?: InputMaybe<Scalars['String']['input']>;
startTime?: InputMaybe<Scalars['DateTime']['input']>;
@@ -2871,6 +2911,8 @@ export type AppObserveNavigationRoutesFilter = {
endTime: Scalars['DateTime']['input'];
environment?: InputMaybe<Scalars['String']['input']>;
platform?: InputMaybe<AppObservePlatform>;
/** Filter to these platforms. Cannot be set together with platform. */
platforms?: InputMaybe<Array<AppObservePlatform>>;
routeNames?: InputMaybe<Array<Scalars['String']['input']>>;
startTime: Scalars['DateTime']['input'];
};
@@ -2924,6 +2966,8 @@ export type AppObserveOverviewEngagementInput = {
endTime: Scalars['DateTime']['input'];
environment?: InputMaybe<Scalars['String']['input']>;
platform?: InputMaybe<AppObservePlatform>;
/** Filter to these platforms. Cannot be set together with platform. */
platforms?: InputMaybe<Array<AppObservePlatform>>;
startTime: Scalars['DateTime']['input'];
};
@@ -2971,6 +3015,8 @@ export type AppObserveOverviewStabilityInput = {
endTime: Scalars['DateTime']['input'];
environment?: InputMaybe<Scalars['String']['input']>;
platform?: InputMaybe<AppObservePlatform>;
/** Filter to these platforms. Cannot be set together with platform. */
platforms?: InputMaybe<Array<AppObservePlatform>>;
startTime: Scalars['DateTime']['input'];
};
@@ -2978,9 +3024,13 @@ export type AppObserveOverviewUpdate = {
__typename?: 'AppObserveOverviewUpdate';
/** Null for the embedded bundle. */
appUpdateId?: Maybe<Scalars['ID']['output']>;
/** Downloads recorded in range across platforms; null when none were recorded (and always for the embedded bundle). */
/**
* Downloads recorded in range across platforms; null when none were recorded
* (and always for the embedded bundle). Download data is paid-only: on the
* free tier and legacy plans this is always null.
*/
downloadCount?: Maybe<Scalars['Int']['output']>;
/** One entry per platform with downloads in range; empty for the embedded bundle. */
/** One entry per platform with downloads in range; empty for the embedded bundle and on the free tier and legacy plans. */
downloads: Array<AppObserveOverviewUpdateDownload>;
eventCount: Scalars['Int']['output'];
firstSeenAt: Scalars['DateTime']['output'];
@@ -3092,6 +3142,8 @@ export type AppObserveReleasesInput = {
environment?: InputMaybe<Scalars['String']['input']>;
metricNames?: InputMaybe<Array<Scalars['String']['input']>>;
platform?: InputMaybe<AppObservePlatform>;
/** Filter to these platforms. Cannot be set together with platform. */
platforms?: InputMaybe<Array<AppObservePlatform>>;
startTime: Scalars['DateTime']['input'];
};
@@ -3129,6 +3181,8 @@ export type AppObserveTimeSeriesInput = {
isEmbeddedUpdate?: InputMaybe<Scalars['Boolean']['input']>;
metricName: Scalars['String']['input'];
platform?: InputMaybe<AppObservePlatform>;
/** Filter to these platforms. Cannot be set together with platform. */
platforms?: InputMaybe<Array<AppObservePlatform>>;
routeName?: InputMaybe<Scalars['String']['input']>;
startTime: Scalars['DateTime']['input'];
};
@@ -3180,6 +3234,8 @@ export type AppObserveUpdatesInput = {
first?: InputMaybe<Scalars['Int']['input']>;
orderBy?: InputMaybe<AppObserveUpdatesOrderBy>;
platform?: InputMaybe<AppObservePlatform>;
/** Filter to these platforms. Cannot be set together with platform. */
platforms?: InputMaybe<Array<AppObservePlatform>>;
startTime: Scalars['DateTime']['input'];
};
@@ -5293,6 +5349,7 @@ export type CreateDeviceRunSessionInput = {
*/
packageVersion?: InputMaybe<Scalars['String']['input']>;
platform: AppPlatform;
resourceClass?: InputMaybe<DeviceRunSessionResourceClass>;
/**
* Expo SDK version used to select an Expo Go application archive. Only supported when expoGo is
* true. If omitted, the current Expo Go archive for the platform is used.
@@ -6043,7 +6100,17 @@ export type DeploymentsMutationDeleteWorkerDeploymentByIdentifierArgs = {
export type DeviceRunSession = {
__typename?: 'DeviceRunSession';
app: App;
/**
* Direct application archive URL installed and launched for this session. Null when
* the session uses an EAS Build or starts without an app.
*/
applicationArchiveUrl?: Maybe<Scalars['String']['output']>;
artifacts: Array<DeviceRunSessionArtifact>;
/**
* EAS Build installed and launched for this session. Null when the session uses
* a direct application archive URL or starts without an app.
*/
build?: Maybe<Build>;
createdAt: Scalars['DateTime']['output'];
finishedAt?: Maybe<Scalars['DateTime']['output']>;
id: Scalars['ID']['output'];
@@ -6176,6 +6243,11 @@ export type DeviceRunSessionQueryByIdArgs = {
export type DeviceRunSessionRemoteConfig = AgentDeviceRunSessionRemoteConfig | AppiumRunSessionRemoteConfig | ArgentRunSessionRemoteConfig | ServeSimRunSessionRemoteConfig;
export enum DeviceRunSessionResourceClass {
Large = 'LARGE',
Medium = 'MEDIUM'
}
export enum DeviceRunSessionStatus {
Errored = 'ERRORED',
InProgress = 'IN_PROGRESS',
@@ -10644,8 +10716,7 @@ export type TurtleSshSessionMutation = {
export type TurtleSshSessionMutationCreateOrUpdateTurtleSshSessionArgs = {
connectionConfig: TurtleSshConnectionConfigInput;
sessionSettings: TurtleSshSessionSettingsInput;
turtleBuildId?: InputMaybe<Scalars['ID']['input']>;
turtleJobRunId?: InputMaybe<Scalars['ID']['input']>;
target: TurtleSshTargetInput;
};
export type TurtleSshSessionSettings = {
@@ -10657,6 +10728,16 @@ export type TurtleSshSessionSettingsInput = {
idleTimeoutSeconds: Scalars['Int']['input'];
};
export type TurtleSshTargetInput = {
id: Scalars['ID']['input'];
type: TurtleSshTargetType;
};
export enum TurtleSshTargetType {
Build = 'BUILD',
JobRun = 'JOB_RUN'
}
export enum TurtleSshTransportType {
UptermV1 = 'UPTERM_V1'
}
@@ -13277,6 +13358,11 @@ export type WorkflowRun = ActivityTimelineProjectActivity & {
__typename?: 'WorkflowRun';
activityTimestamp: Scalars['DateTime']['output'];
actor?: Maybe<Actor>;
/**
* Why the server canceled this run. Null for manually canceled runs and for
* runs canceled before the reason was recorded.
*/
cancelReason?: Maybe<WorkflowRunCancelReason>;
createdAt: Scalars['DateTime']['output'];
durationSeconds?: Maybe<Scalars['Int']['output']>;
errors: Array<WorkflowRunError>;
@@ -13303,6 +13389,17 @@ export type WorkflowRun = ActivityTimelineProjectActivity & {
workflowRevision?: Maybe<WorkflowRevision>;
};
export type WorkflowRunCancelReason = WorkflowRunCancelReasonConcurrencyGroup;
export type WorkflowRunCancelReasonConcurrencyGroup = {
__typename?: 'WorkflowRunCancelReasonConcurrencyGroup';
/**
* The newer run in the same concurrency group whose creation canceled this run.
* Null if that run no longer exists.
*/
cancelingWorkflowRun?: Maybe<WorkflowRun>;
};
export type WorkflowRunEdge = {
__typename?: 'WorkflowRunEdge';
cursor: Scalars['String']['output'];
@@ -13392,7 +13489,8 @@ export enum WorkflowRunStatus {
Failure = 'FAILURE',
InProgress = 'IN_PROGRESS',
New = 'NEW',
Success = 'SUCCESS'
Success = 'SUCCESS',
Waiting = 'WAITING'
}
export type WorkflowRunTimeRangeInput = {
@@ -1,5 +1,6 @@
import { DeviceRunSessionType } from '../../graphql/generated';
import { DeviceRunSessionResourceClass, DeviceRunSessionType } from '../../graphql/generated';
import {
DEVICE_RUN_SESSION_RESOURCE_CLASS_BY_FLAG_VALUE,
DEVICE_RUN_SESSION_TYPE_BY_FLAG_VALUE,
formatRemoteSessionInstructions,
getRemoteSessionEnvironmentVariables,
@@ -37,3 +38,14 @@ describe('Appium simulator configuration', () => {
expect(instructions).not.toContain('https://appium.example.test');
});
});
describe('simulator resource class flags', () => {
it('maps CLI values to the GraphQL enum', () => {
expect(DEVICE_RUN_SESSION_RESOURCE_CLASS_BY_FLAG_VALUE.large).toBe(
DeviceRunSessionResourceClass.Large
);
expect(DEVICE_RUN_SESSION_RESOURCE_CLASS_BY_FLAG_VALUE.medium).toBe(
DeviceRunSessionResourceClass.Medium
);
});
});
+22 -1
View File
@@ -1,4 +1,8 @@
import { DeviceRunSessionByIdQuery, DeviceRunSessionType } from '../graphql/generated';
import {
DeviceRunSessionByIdQuery,
DeviceRunSessionResourceClass,
DeviceRunSessionType,
} from '../graphql/generated';
import { link } from '../log';
type DeviceRunSessionByIdResult = DeviceRunSessionByIdQuery['deviceRunSessions']['byId'];
@@ -37,6 +41,23 @@ export function deviceRunSessionTypeToFlagValue(type: DeviceRunSessionType): str
return DEVICE_RUN_SESSION_TYPE_FLAG_VALUES[type];
}
export const DEVICE_RUN_SESSION_RESOURCE_CLASS_FLAG_VALUES: Record<
DeviceRunSessionResourceClass,
string
> = {
[DeviceRunSessionResourceClass.Large]: 'large',
[DeviceRunSessionResourceClass.Medium]: 'medium',
};
export const DEVICE_RUN_SESSION_RESOURCE_CLASS_BY_FLAG_VALUE = Object.fromEntries(
(
Object.entries(DEVICE_RUN_SESSION_RESOURCE_CLASS_FLAG_VALUES) as [
DeviceRunSessionResourceClass,
string,
][]
).map(([resourceClass, value]) => [value, resourceClass])
) as Record<string, DeviceRunSessionResourceClass>;
export function getRemoteSessionEnvironmentVariables(
remoteConfig: DeviceRunSessionRemoteConfig
): Record<string, string> {
+612 -24
View File
@@ -1,7 +1,9 @@
import JsonFile from '@expo/json-file';
import { Errors } from '@oclif/core';
import chalk from 'chalk';
import fs from 'fs-extra';
import nullthrows from 'nullthrows';
import path from 'path';
import { fetchSessionSecretAndUserAsync } from './fetchSessionSecretAndUser';
import { fetchSessionSecretAndUserFromBrowserAuthFlowAsync } from './fetchSessionSecretAndUserFromBrowserAuthFlow';
@@ -13,21 +15,61 @@ import { CurrentUserQuery } from '../graphql/generated';
import { UserQuery } from '../graphql/queries/UserQuery';
import Log, { learnMore } from '../log';
import { promptAsync } from '../prompts';
import { isMultiAccountEnabled } from '../utils/easCli';
import { getStateJsonPath } from '../utils/paths';
type UserSettingsData = {
auth?: SessionData;
};
type ConnectionType = 'Username-Password-Authentication' | 'Browser-Flow-Authentication';
type SessionData = {
/**
* Legacy session data format (v0 schema).
* These fields are also used for Expo CLI backward compatibility.
*/
type LegacySessionData = {
sessionSecret: string;
// These fields are potentially used by Expo CLI.
userId: string;
username: string;
currentConnection: 'Username-Password-Authentication' | 'Browser-Flow-Authentication';
currentConnection: ConnectionType;
};
/**
* Account data with timestamps for multi-account support (v1 schema).
*/
type AccountData = {
sessionSecret: string;
userId: string;
username: string;
currentConnection: ConnectionType;
addedAt: string;
lastUsedAt: string;
};
/**
* State file format for v0 (legacy single-account).
*/
type StateDataV0 = {
auth?: LegacySessionData;
};
/**
* State file format for v1 (multi-account).
*/
type StateDataV1 = {
version: 1;
auth: {
activeAccountId: string | null;
accounts: Record<string, AccountData>;
// Legacy fields for Expo CLI backward compatibility
sessionSecret?: string;
userId?: string;
username?: string;
currentConnection?: ConnectionType;
};
};
type StateData = StateDataV0 | StateDataV1;
type SessionData = LegacySessionData;
export type LoggedInAuthenticationInfo =
| {
accessToken: string;
@@ -40,9 +82,26 @@ export type LoggedInAuthenticationInfo =
type Actor = NonNullable<CurrentUserQuery['meActor']>;
/**
* Publicly exposed account info for multi-account features.
*/
export type StoredAccount = {
userId: string;
username: string;
isActive: boolean;
addedAt?: string;
lastUsedAt?: string;
};
export default class SessionManager {
private currentActor: Actor | undefined;
/**
* When set, this stored account is used for the rest of the process instead of the
* active account from the state file. Never persisted (used by the global --account flag).
*/
private perProcessAccountOverrideUserId: string | undefined;
constructor(private readonly analytics: AnalyticsWithOrchestration) {}
public getAccessToken(): string | null {
@@ -50,12 +109,16 @@ export default class SessionManager {
}
public getSessionSecret(): string | null {
return this.getSession()?.sessionSecret ?? null;
return this.getActiveSession()?.sessionSecret ?? null;
}
private getSession(): SessionData | null {
// ============================================
// State File Reading
// ============================================
private readStateFile(): StateData | null {
try {
return JsonFile.read<UserSettingsData>(getStateJsonPath())?.auth ?? null;
return JsonFile.read<StateData>(getStateJsonPath()) ?? null;
} catch (error: any) {
if (error.code === 'ENOENT') {
return null;
@@ -64,25 +127,550 @@ export default class SessionManager {
}
}
private isV1Schema(state: StateData | null): state is StateDataV1 {
return (
state !== null &&
'version' in state &&
state.version === 1 &&
state.auth?.accounts !== undefined
);
}
/**
* Get the active session data, reading from appropriate schema.
* For v1 schema, reads from the active account in the accounts object.
* For v0 schema, reads from legacy fields.
*/
private getActiveSession(): SessionData | null {
const state = this.readStateFile();
if (!state) {
return null;
}
// For v1 schema with multi-account enabled, read from the active account
if (isMultiAccountEnabled() && this.isV1Schema(state)) {
const overrideId = this.perProcessAccountOverrideUserId;
const activeId =
overrideId && state.auth.accounts[overrideId] ? overrideId : state.auth.activeAccountId;
if (activeId && state.auth.accounts[activeId]) {
const account = state.auth.accounts[activeId];
return {
sessionSecret: account.sessionSecret,
userId: account.userId,
username: account.username,
currentConnection: account.currentConnection,
};
}
}
// Read from legacy fields (v0 schema or fallback)
const auth = state.auth;
if (!auth?.sessionSecret) {
return null;
}
return {
sessionSecret: auth.sessionSecret,
userId: auth.userId!,
username: auth.username!,
currentConnection: auth.currentConnection!,
};
}
// ============================================
// State File Writing
// ============================================
/**
* Write session data, preserving existing schema structure.
* - If multi-account disabled: only update legacy fields
* - If multi-account enabled: update accounts object + legacy fields
*/
private async setSessionAsync(sessionData?: SessionData): Promise<void> {
await JsonFile.setAsync(getStateJsonPath(), 'auth', sessionData, {
default: {},
ensureDir: true,
});
const statePath = getStateJsonPath();
if (!sessionData) {
// Logout: clear session data
if (isMultiAccountEnabled()) {
await this.clearActiveAccountAsync();
return;
}
const state = this.readStateFile();
if (this.isV1Schema(state)) {
// Preserve other stored accounts: drop only the logged-out account and legacy fields
const loggedOutUserId = state.auth.userId ?? state.auth.activeAccountId;
const { [loggedOutUserId ?? '']: _removed, ...remainingAccounts } = state.auth.accounts;
await this.writeStateFileAsync({
...state,
auth: {
activeAccountId: null,
accounts: remainingAccounts,
},
});
} else {
await JsonFile.setAsync(statePath, 'auth', undefined, {
default: {},
ensureDir: true,
});
}
return;
}
const state = this.readStateFile();
if (!isMultiAccountEnabled() && !this.isV1Schema(state)) {
// Feature disabled and no v1 structure: plain legacy write
const existingAuth = state?.auth ?? {};
const updatedAuth = {
...existingAuth,
sessionSecret: sessionData.sessionSecret,
userId: sessionData.userId,
username: sessionData.username,
currentConnection: sessionData.currentConnection,
};
await this.writeStateFileAsync({ ...state, auth: updatedAuth });
return;
}
// Feature enabled, or a v1 state file already exists: keep the accounts
// object and the legacy fields in sync so that toggling the feature flag
// never resurrects a stale identity.
await this.addOrUpdateAccountAsync(sessionData);
}
/**
* Write state file atomically with proper permissions.
*/
private async writeStateFileAsync(state: StateData): Promise<void> {
const statePath = getStateJsonPath();
const dir = path.dirname(statePath);
await fs.ensureDir(dir);
// Write atomically: write to temp file, then rename
const tempPath = `${statePath}.tmp.${process.pid}`;
await fs.writeJson(tempPath, state, { spaces: 2 });
await fs.rename(tempPath, statePath);
// Ensure restrictive permissions (owner read/write only)
try {
await fs.chmod(statePath, 0o600);
} catch {
// Ignore chmod errors on platforms that don't support it
}
}
// ============================================
// Multi-Account Methods (Feature Flag Gated)
// ============================================
/**
* Get all stored accounts.
* Returns single account array when multi-account is disabled.
*/
public getAllAccounts(): StoredAccount[] {
const state = this.readStateFile();
if (!isMultiAccountEnabled() || !this.isV1Schema(state)) {
// Return single account from legacy fields
const session = this.getActiveSession();
if (!session) {
return [];
}
return [
{
userId: session.userId,
username: session.username,
isActive: true,
},
];
}
// Multi-account enabled with v1 schema
const accounts = Object.values(state.auth.accounts);
const activeId = state.auth.activeAccountId;
return accounts.map(account => ({
userId: account.userId,
username: account.username,
isActive: account.userId === activeId,
addedAt: account.addedAt,
lastUsedAt: account.lastUsedAt,
}));
}
/**
* Check if a user is already logged in (by userId).
*/
public isAccountLoggedIn(userId: string): boolean {
const accounts = this.getAllAccounts();
return accounts.some(a => a.userId === userId);
}
/**
* Check if a user is already logged in (by username).
*/
public isAccountLoggedInByUsername(username: string): boolean {
const accounts = this.getAllAccounts();
return accounts.some(a => a.username === username);
}
/**
* Get the active account, or null if not logged in.
*/
public getActiveAccount(): StoredAccount | null {
const accounts = this.getAllAccounts();
return accounts.find(a => a.isActive) ?? null;
}
/**
* Add or update an account in multi-account mode.
* If the account already exists (by userId), update its session.
* Always sets the new/updated account as active.
*/
private async addOrUpdateAccountAsync(sessionData: SessionData): Promise<void> {
const state = this.readStateFile();
const now = new Date().toISOString();
let newState: StateDataV1;
if (this.isV1Schema(state)) {
// Update existing v1 schema
const existingAccount = state.auth.accounts[sessionData.userId];
newState = {
...state,
version: 1,
auth: {
...state.auth,
activeAccountId: sessionData.userId,
accounts: {
...state.auth.accounts,
[sessionData.userId]: {
sessionSecret: sessionData.sessionSecret,
userId: sessionData.userId,
username: sessionData.username,
currentConnection: sessionData.currentConnection,
addedAt: existingAccount?.addedAt ?? now,
lastUsedAt: now,
},
},
// Sync legacy fields
sessionSecret: sessionData.sessionSecret,
userId: sessionData.userId,
username: sessionData.username,
currentConnection: sessionData.currentConnection,
},
};
} else {
// Migrate from v0 to v1
newState = {
...state,
version: 1,
auth: {
activeAccountId: sessionData.userId,
accounts: {
[sessionData.userId]: {
sessionSecret: sessionData.sessionSecret,
userId: sessionData.userId,
username: sessionData.username,
currentConnection: sessionData.currentConnection,
addedAt: now,
lastUsedAt: now,
},
},
// Sync legacy fields
sessionSecret: sessionData.sessionSecret,
userId: sessionData.userId,
username: sessionData.username,
currentConnection: sessionData.currentConnection,
},
};
// If there was an existing session in v0, preserve it as a separate account
if (
state?.auth?.sessionSecret &&
state.auth.userId &&
state.auth.userId !== sessionData.userId
) {
newState.auth.accounts[state.auth.userId] = {
sessionSecret: state.auth.sessionSecret,
userId: state.auth.userId,
username: state.auth.username,
currentConnection: state.auth.currentConnection,
addedAt: now,
lastUsedAt: now,
};
}
}
await this.writeStateFileAsync(newState);
}
/**
* Switch to a different account by userId.
* Only available when multi-account is enabled.
*/
public async switchAccountAsync(userId: string): Promise<void> {
if (!isMultiAccountEnabled()) {
throw new Error('Multi-account switching is not enabled');
}
const state = this.readStateFile();
if (!this.isV1Schema(state)) {
throw new Error('No accounts to switch to');
}
const account = state.auth.accounts[userId];
if (!account) {
throw new Error(`Account not found: ${userId}`);
}
const now = new Date().toISOString();
const newState: StateDataV1 = {
...state,
auth: {
...state.auth,
activeAccountId: userId,
accounts: {
...state.auth.accounts,
[userId]: {
...account,
lastUsedAt: now,
},
},
// Sync legacy fields
sessionSecret: account.sessionSecret,
userId: account.userId,
username: account.username,
currentConnection: account.currentConnection,
},
};
await this.writeStateFileAsync(newState);
// Clear cached actor since we switched
this.currentActor = undefined;
}
/**
* Use a stored account for the rest of this process without persisting a switch
* to the state file. Used by the global --account flag for per-command account
* selection. Returns false if no such account is stored.
*/
public useAccountForProcessByUsername(username: string): boolean {
if (!isMultiAccountEnabled()) {
return false;
}
const state = this.readStateFile();
if (!this.isV1Schema(state)) {
return false;
}
const account = Object.values(state.auth.accounts).find(a => a.username === username);
if (!account) {
return false;
}
this.perProcessAccountOverrideUserId = account.userId;
this.currentActor = undefined;
return true;
}
/**
* Switch to a different account by username.
* Only available when multi-account is enabled.
*/
public async switchAccountByUsernameAsync(username: string): Promise<void> {
if (!isMultiAccountEnabled()) {
throw new Error('Multi-account switching is not enabled');
}
const state = this.readStateFile();
if (!this.isV1Schema(state)) {
throw new Error('No accounts to switch to');
}
const account = Object.values(state.auth.accounts).find(a => a.username === username);
if (!account) {
throw new Error(`Account not found: ${username}`);
}
await this.switchAccountAsync(account.userId);
}
/**
* Remove an account by userId. Invalidates its session secret on the server (best-effort).
* If removing the active account, switches to the most recently used remaining account.
*/
public async removeAccountAsync(userId: string): Promise<void> {
if (!isMultiAccountEnabled()) {
// When disabled, just clear everything
await this.invalidateCurrentSessionOnServerAsync();
await JsonFile.setAsync(getStateJsonPath(), 'auth', undefined, {
default: {},
ensureDir: true,
});
this.currentActor = undefined;
return;
}
const state = this.readStateFile();
if (!this.isV1Schema(state)) {
// V0 schema: just clear
await this.invalidateCurrentSessionOnServerAsync();
await JsonFile.setAsync(getStateJsonPath(), 'auth', undefined, {
default: {},
ensureDir: true,
});
this.currentActor = undefined;
return;
}
const { [userId]: removed, ...remainingAccounts } = state.auth.accounts;
if (!removed) {
return; // Account not found, nothing to do
}
await this.invalidateSessionSecretOnServerAsync(removed.sessionSecret);
const wasActive = state.auth.activeAccountId === userId;
let newActiveId: string | null = state.auth.activeAccountId;
if (wasActive) {
// Find the most recently used remaining account
const remaining = Object.values(remainingAccounts);
if (remaining.length > 0) {
remaining.sort(
(a, b) => new Date(b.lastUsedAt).getTime() - new Date(a.lastUsedAt).getTime()
);
newActiveId = remaining[0].userId;
} else {
newActiveId = null;
}
}
const newActiveAccount = newActiveId ? remainingAccounts[newActiveId] : null;
const newState: StateDataV1 = {
...state,
version: 1,
auth: {
activeAccountId: newActiveId,
accounts: remainingAccounts,
// Sync legacy fields to new active account (or clear if none)
sessionSecret: newActiveAccount?.sessionSecret,
userId: newActiveAccount?.userId,
username: newActiveAccount?.username,
currentConnection: newActiveAccount?.currentConnection,
},
};
await this.writeStateFileAsync(newState);
this.currentActor = undefined;
}
/**
* Remove all accounts (logout all). Invalidates their session secrets on the
* server (best-effort).
*/
public async removeAllAccountsAsync(): Promise<void> {
if (!isMultiAccountEnabled()) {
await this.invalidateCurrentSessionOnServerAsync();
await JsonFile.setAsync(getStateJsonPath(), 'auth', undefined, {
default: {},
ensureDir: true,
});
this.currentActor = undefined;
return;
}
const state = this.readStateFile();
if (!this.isV1Schema(state)) {
await this.invalidateCurrentSessionOnServerAsync();
await JsonFile.setAsync(getStateJsonPath(), 'auth', undefined, {
default: {},
ensureDir: true,
});
this.currentActor = undefined;
return;
}
for (const account of Object.values(state.auth.accounts)) {
await this.invalidateSessionSecretOnServerAsync(account.sessionSecret);
}
const newState: StateDataV1 = {
...state,
version: 1,
auth: {
activeAccountId: null,
accounts: {},
// Clear legacy fields
sessionSecret: undefined,
userId: undefined,
username: undefined,
currentConnection: undefined,
},
};
await this.writeStateFileAsync(newState);
this.currentActor = undefined;
}
/**
* Clear the active account (used by logout when multi-account enabled).
*/
private async clearActiveAccountAsync(): Promise<void> {
const state = this.readStateFile();
if (!this.isV1Schema(state)) {
// V0 schema: just clear
await this.invalidateCurrentSessionOnServerAsync();
await JsonFile.setAsync(getStateJsonPath(), 'auth', undefined, {
default: {},
ensureDir: true,
});
return;
}
const activeId = state.auth.activeAccountId;
if (!activeId) {
return; // Already no active account
}
await this.removeAccountAsync(activeId);
}
/**
* Best-effort invalidation of a session secret on the server.
*/
private async invalidateSessionSecretOnServerAsync(sessionSecret: string): Promise<void> {
const apiV2Client = new ApiV2Client({ accessToken: null, sessionSecret });
try {
await apiV2Client.postAsync('auth/logout', { body: {} });
} catch (e) {
// Best-effort: clear the local session even if the server request fails
Log.debug('Failed to invalidate session secret on server:', e);
}
}
private async invalidateCurrentSessionOnServerAsync(): Promise<void> {
const sessionSecret = this.getSessionSecret();
if (sessionSecret) {
await this.invalidateSessionSecretOnServerAsync(sessionSecret);
}
}
public async logoutAsync(): Promise<void> {
const sessionSecret = this.getSessionSecret();
if (sessionSecret) {
const apiV2Client = new ApiV2Client({ accessToken: null, sessionSecret });
try {
await apiV2Client.postAsync('auth/logout', { body: {} });
} catch (e) {
// Best-effort: clear the local session even if the server request fails
Log.debug('Failed to invalidate session secret on server:', e);
}
}
this.currentActor = undefined;
if (isMultiAccountEnabled()) {
// Clearing the active account invalidates its session secret on the server.
await this.setSessionAsync(undefined);
return;
}
await this.invalidateCurrentSessionOnServerAsync();
await this.setSessionAsync(undefined);
}
@@ -48,6 +48,8 @@ beforeEach(() => {
jest.resetModules();
jest.clearAllMocks();
process.env = { ...OLD_ENV }; // Make a copy
// Tests must not depend on the experimental flag being set in the ambient shell
delete process.env.EAS_EXPERIMENTAL_ACCOUNT_SWITCHER;
});
afterEach(() => {
@@ -55,17 +57,17 @@ afterEach(() => {
});
describe(SessionManager, () => {
describe('getSession', () => {
describe('getActiveSession', () => {
it('returns null when session is not stored', () => {
const sessionManager = new SessionManager(analytics);
expect(sessionManager['getSession']()).toBeNull();
expect(sessionManager['getActiveSession']()).toBeNull();
});
it('returns stored session data', async () => {
await fs.mkdirp(path.dirname(getStateJsonPath()));
await fs.writeJSON(getStateJsonPath(), { auth: authStub });
const sessionManager = new SessionManager(analytics);
expect(sessionManager['getSession']()).toMatchObject(authStub);
expect(sessionManager['getActiveSession']()).toMatchObject(authStub);
});
});
@@ -392,4 +394,123 @@ describe(SessionManager, () => {
).rejects.toThrowError('Cancelled login');
});
});
describe('multi-account (EAS_EXPERIMENTAL_ACCOUNT_SWITCHER=1)', () => {
const loginAsUserAsync = async (
sessionManager: SessionManager,
{ id, username }: { id: string; username: string }
): Promise<void> => {
jest.mocked(fetchSessionSecretAndUserAsync).mockResolvedValueOnce({
sessionSecret: `SECRET_${id}`,
id,
username,
});
await sessionManager['loginAsync']({ username, password: 'PASSWORD' });
};
beforeEach(() => {
process.env.EAS_EXPERIMENTAL_ACCOUNT_SWITCHER = '1';
});
it('migrates v0 state to v1 on login, preserving the old session and unrelated top-level keys', async () => {
await fs.mkdirp(path.dirname(getStateJsonPath()));
await fs.writeJSON(getStateJsonPath(), {
uuid: 'SOME_UUID',
auth: {
sessionSecret: 'OLD_SECRET',
userId: 'OLD_USER_ID',
username: 'OLD_USERNAME',
currentConnection: 'Username-Password-Authentication',
},
});
const sessionManager = new SessionManager(analytics);
await loginAsUserAsync(sessionManager, { id: 'USER_ID', username: 'USERNAME' });
const state = await fs.readJSON(getStateJsonPath());
expect(state.uuid).toBe('SOME_UUID');
expect(state.version).toBe(1);
expect(state.auth.activeAccountId).toBe('USER_ID');
expect(Object.keys(state.auth.accounts).sort()).toEqual(['OLD_USER_ID', 'USER_ID']);
expect(state.auth.sessionSecret).toBe('SECRET_USER_ID');
expect(state.auth.username).toBe('USERNAME');
});
it('uses an account for the process without persisting a switch', async () => {
const sessionManager = new SessionManager(analytics);
await loginAsUserAsync(sessionManager, { id: 'ID_A', username: 'user-a' });
await loginAsUserAsync(sessionManager, { id: 'ID_B', username: 'user-b' });
expect(sessionManager['getSessionSecret']()).toBe('SECRET_ID_B');
expect(sessionManager.useAccountForProcessByUsername('user-a')).toBe(true);
expect(sessionManager['getSessionSecret']()).toBe('SECRET_ID_A');
const state = await fs.readJSON(getStateJsonPath());
expect(state.auth.activeAccountId).toBe('ID_B');
expect(state.auth.sessionSecret).toBe('SECRET_ID_B');
expect(sessionManager.useAccountForProcessByUsername('nonexistent')).toBe(false);
});
it('invalidates the session secret on the server when removing an account', async () => {
const apiV2PostSpy = jest.spyOn(ApiV2Client.prototype, 'postAsync');
const sessionManager = new SessionManager(analytics);
await loginAsUserAsync(sessionManager, { id: 'ID_A', username: 'user-a' });
await loginAsUserAsync(sessionManager, { id: 'ID_B', username: 'user-b' });
await sessionManager.removeAccountAsync('ID_A');
expect(apiV2PostSpy).toHaveBeenCalledWith('auth/logout', { body: {} });
const state = await fs.readJSON(getStateJsonPath());
expect(Object.keys(state.auth.accounts)).toEqual(['ID_B']);
expect(state.auth.activeAccountId).toBe('ID_B');
});
it('invalidates all session secrets on the server when removing all accounts', async () => {
const apiV2PostSpy = jest.spyOn(ApiV2Client.prototype, 'postAsync');
const sessionManager = new SessionManager(analytics);
await loginAsUserAsync(sessionManager, { id: 'ID_A', username: 'user-a' });
await loginAsUserAsync(sessionManager, { id: 'ID_B', username: 'user-b' });
await sessionManager.removeAllAccountsAsync();
expect(apiV2PostSpy).toHaveBeenCalledTimes(2);
const state = await fs.readJSON(getStateJsonPath());
expect(state.auth.accounts).toEqual({});
expect(state.auth.activeAccountId).toBeNull();
expect(sessionManager['getSessionSecret']()).toBeNull();
});
it('keeps the v1 accounts map in sync when logging in with the flag off', async () => {
const sessionManager = new SessionManager(analytics);
await loginAsUserAsync(sessionManager, { id: 'ID_A', username: 'user-a' });
delete process.env.EAS_EXPERIMENTAL_ACCOUNT_SWITCHER;
await loginAsUserAsync(sessionManager, { id: 'ID_B', username: 'user-b' });
const state = await fs.readJSON(getStateJsonPath());
expect(state.auth.activeAccountId).toBe('ID_B');
expect(Object.keys(state.auth.accounts).sort()).toEqual(['ID_A', 'ID_B']);
// Re-enabling the flag must not resurrect the stale identity
process.env.EAS_EXPERIMENTAL_ACCOUNT_SWITCHER = '1';
expect(sessionManager['getActiveSession']()).toMatchObject({ userId: 'ID_B' });
});
it('preserves other stored accounts when logging out with the flag off', async () => {
const sessionManager = new SessionManager(analytics);
await loginAsUserAsync(sessionManager, { id: 'ID_A', username: 'user-a' });
await loginAsUserAsync(sessionManager, { id: 'ID_B', username: 'user-b' });
delete process.env.EAS_EXPERIMENTAL_ACCOUNT_SWITCHER;
await sessionManager.logoutAsync();
expect(sessionManager['getSessionSecret']()).toBeNull();
const state = await fs.readJSON(getStateJsonPath());
expect(state.auth.activeAccountId).toBeNull();
expect(Object.keys(state.auth.accounts)).toEqual(['ID_A']);
expect(state.auth.sessionSecret).toBeUndefined();
});
});
});
+10
View File
@@ -1,3 +1,13 @@
const packageJSON = require('../../package.json');
export const easCliVersion: string = packageJSON.version;
/**
* Check if the experimental account switcher feature is enabled.
* This allows users to log in to multiple accounts and switch between them.
*
* Enable with: EAS_EXPERIMENTAL_ACCOUNT_SWITCHER=1
*/
export function isMultiAccountEnabled(): boolean {
return process.env.EAS_EXPERIMENTAL_ACCOUNT_SWITCHER === '1';
}
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "eas-cli-local-build-plugin",
"version": "22.5.0",
"version": "22.6.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,7 +26,7 @@
"clean": "rimraf node_modules dist coverage \"*.tsbuildinfo\""
},
"dependencies": {
"@expo/build-tools": "22.5.0",
"@expo/build-tools": "22.6.0",
"@expo/eas-build-job": "22.0.0",
"@expo/logger": "22.0.0",
"@expo/spawn-async": "^1.7.2",
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@expo/worker",
"version": "22.5.0",
"version": "22.6.0",
"private": true,
"scripts": {
"start": "yarn watch",
@@ -19,7 +19,7 @@
},
"dependencies": {
"@babel/core": "7.24.7",
"@expo/build-tools": "22.5.0",
"@expo/build-tools": "22.6.0",
"@expo/config-plugins": "55.0.7",
"@expo/config-types": "55.0.5",
"@expo/downloader": "22.0.0",
@@ -0,0 +1,235 @@
import { TurtleSshSession } from '@expo/build-tools';
import { BuildPhase, BuildPhaseResult, LogMarker } from '@expo/eas-build-job';
import { startSshSessionPhaseAsync } from '../sshSession';
jest.mock('@expo/build-tools', () => ({
TurtleSshSession: {
getSshRelayServerUrl: jest.fn(() => 'wss://relay.expo.dev'),
getSshIdleTimeoutSeconds: jest.fn(() => 0),
startSshSessionAsync: jest.fn(),
superviseSshSessionAsync: jest.fn(),
},
formatSecondsForLog: jest.fn(() => '15 minutes'),
}));
const mocked = jest.mocked(TurtleSshSession);
function createLogger(): any {
const logger: any = {
info: jest.fn(),
warn: jest.fn(),
error: jest.fn(),
};
logger.child = jest.fn(() => logger);
return logger;
}
function createContext({ logger }: { logger: any }): any {
return {
env: { __WORKFLOW_JOB_ID: 'wj-1' },
job: { ssh: { idleTimeoutSeconds: 0, relayServerUrl: 'wss://relay.expo.dev' } },
logger,
markBuildPhaseHasWarnings: jest.fn(),
// The real implementation runs the phase body and honors doNotMarkEnd; for these tests we only
// need the body to run.
runBuildPhase: jest.fn(async (_phase: BuildPhase, body: () => Promise<void>) => {
await body();
}),
};
}
function endPhaseCalls(logger: any): any[] {
return logger.info.mock.calls.filter(
([meta]: any[]) =>
meta != null && typeof meta === 'object' && meta.marker === LogMarker.END_PHASE
);
}
describe(startSshSessionPhaseAsync, () => {
let handle: {
getConnectedClientCountAsync: jest.Mock;
ensureConnectedAsync: jest.Mock;
stopAsync: jest.Mock;
};
beforeEach(() => {
handle = {
getConnectedClientCountAsync: jest.fn(async () => 0),
ensureConnectedAsync: jest.fn(async () => {}),
stopAsync: jest.fn(async () => {}),
};
mocked.startSshSessionAsync.mockResolvedValue({ handle, idleTimeoutSeconds: 0 } as any);
mocked.superviseSshSessionAsync.mockResolvedValue(undefined as any);
});
it('opens the session inside SSH_SESSION and leaves the phase open', async () => {
const logger = createLogger();
const ctx = createContext({ logger });
const { done } = await startSshSessionPhaseAsync({
ctx,
buildId: 'jr-1',
logger,
hasJobFinished: () => false,
});
expect(ctx.runBuildPhase).toHaveBeenCalledWith(BuildPhase.SSH_SESSION, expect.any(Function), {
doNotMarkEnd: true,
});
expect(mocked.startSshSessionAsync).toHaveBeenCalledWith(ctx, {
target: { type: 'JOB_RUN', id: 'jr-1' },
relayServerUrl: 'wss://relay.expo.dev',
idleTimeoutSeconds: 0,
});
expect(logger.info).toHaveBeenCalledWith(
'SSH session ready. Connect with: eas workflow:ssh jr-1'
);
expect(done).toBeInstanceOf(Promise);
await done;
});
it('mentions the idle timeout when it is non-zero', async () => {
const logger = createLogger();
const ctx = createContext({ logger });
mocked.getSshIdleTimeoutSeconds.mockReturnValue(900);
mocked.startSshSessionAsync.mockResolvedValue({ handle, idleTimeoutSeconds: 900 } as any);
const { done } = await startSshSessionPhaseAsync({
ctx,
buildId: 'jr-1',
logger,
hasJobFinished: () => true,
});
await done;
expect(logger.info).toHaveBeenCalledWith(
expect.stringContaining('then closes after 15 minutes with no client connected')
);
});
it('returns before the session ends so the job is not blocked', async () => {
const logger = createLogger();
const ctx = createContext({ logger });
let releaseSupervision = (): void => {};
mocked.superviseSshSessionAsync.mockImplementation(
() =>
new Promise<void>(resolve => {
releaseSupervision = resolve;
})
);
const { done } = await startSshSessionPhaseAsync({
ctx,
buildId: 'jr-1',
logger,
hasJobFinished: () => false,
});
expect(done).toBeDefined();
expect(handle.stopAsync).not.toHaveBeenCalled();
releaseSupervision();
await done;
expect(handle.stopAsync).toHaveBeenCalled();
});
it('tears the tunnel down and closes the phase once supervision returns', async () => {
const logger = createLogger();
const ctx = createContext({ logger });
const { done } = await startSshSessionPhaseAsync({
ctx,
buildId: 'jr-1',
logger,
hasJobFinished: () => true,
});
await done;
expect(handle.stopAsync).toHaveBeenCalled();
expect(endPhaseCalls(logger)).toEqual([
[expect.objectContaining({ result: BuildPhaseResult.SUCCESS }), expect.any(String)],
]);
});
it('passes the job-finished signal through to the supervisor', async () => {
const logger = createLogger();
const ctx = createContext({ logger });
let jobFinished = false;
const { done } = await startSshSessionPhaseAsync({
ctx,
buildId: 'jr-1',
logger,
hasJobFinished: () => jobFinished,
});
await done;
const { hasJobFinished, handle: passedHandle } =
mocked.superviseSshSessionAsync.mock.calls[0][0];
expect(hasJobFinished()).toBe(false);
jobFinished = true;
expect(hasJobFinished()).toBe(true);
expect(passedHandle).toBe(handle);
});
it('closes the phase with a warning when supervision throws', async () => {
const logger = createLogger();
const ctx = createContext({ logger });
mocked.superviseSshSessionAsync.mockRejectedValue(new Error('relay went away'));
const { done } = await startSshSessionPhaseAsync({
ctx,
buildId: 'jr-1',
logger,
hasJobFinished: () => true,
});
await done;
expect(handle.stopAsync).toHaveBeenCalled();
expect(endPhaseCalls(logger)).toEqual([
[expect.objectContaining({ result: BuildPhaseResult.WARNING }), expect.any(String)],
]);
});
it('warns when tearing down the tunnel fails after supervision', async () => {
const logger = createLogger();
const ctx = createContext({ logger });
handle.stopAsync.mockRejectedValue(new Error('already gone'));
const { done } = await startSshSessionPhaseAsync({
ctx,
buildId: 'jr-1',
logger,
hasJobFinished: () => true,
});
await done;
expect(logger.warn).toHaveBeenCalledWith(
{ err: expect.any(Error) },
'Failed to tear down the SSH tunnel.'
);
expect(endPhaseCalls(logger)).toEqual([
[expect.objectContaining({ result: BuildPhaseResult.SUCCESS }), expect.any(String)],
]);
});
it('lets the job continue and closes the phase when the session cannot be opened', async () => {
const logger = createLogger();
const ctx = createContext({ logger });
mocked.startSshSessionAsync.mockRejectedValue(new Error('no relay'));
const { done } = await startSshSessionPhaseAsync({
ctx,
buildId: 'jr-1',
logger,
hasJobFinished: () => false,
});
expect(done).toBeUndefined();
expect(ctx.markBuildPhaseHasWarnings).toHaveBeenCalled();
expect(mocked.superviseSshSessionAsync).not.toHaveBeenCalled();
expect(endPhaseCalls(logger)).toEqual([
[expect.objectContaining({ result: BuildPhaseResult.WARNING }), expect.any(String)],
]);
});
});
+22 -1
View File
@@ -1,4 +1,10 @@
import { Artifacts, BuildContext, Builders, runGenericJobAsync } from '@expo/build-tools';
import {
Artifacts,
BuildContext,
Builders,
TurtleSshSession,
runGenericJobAsync,
} from '@expo/build-tools';
import {
Android,
BuildJob,
@@ -17,6 +23,7 @@ import config from './config';
import { displayWorkerRuntimeInfo } from './displayRuntimeInfo';
import { Analytics, Event, logProjectDependenciesAsync } from './external/analytics';
import { prepareRuntimeEnvironment } from './runtimeEnvironment';
import { startSshSessionPhaseAsync } from './sshSession';
import { cleanUpWorkingdir } from './workingdir';
export async function build({
@@ -29,6 +36,8 @@ export async function build({
analytics: Analytics;
}): Promise<Artifacts> {
const { job, logger } = ctx;
let sshSessionDone: Promise<void> | undefined;
let hasJobFinished = false;
try {
analytics.logEvent(Event.WORKER_BUILD_START, {});
@@ -49,6 +58,15 @@ export async function build({
}
});
if (TurtleSshSession.isSshEnabled(ctx.job)) {
({ done: sshSessionDone } = await startSshSessionPhaseAsync({
ctx,
buildId,
logger,
hasJobFinished: () => hasJobFinished,
}));
}
let artifacts: Artifacts;
switch (job.platform) {
@@ -89,6 +107,9 @@ export async function build({
}
throw err;
} finally {
// Lets the SSH supervisor start its idle countdown and tear the tunnel down.
hasJobFinished = true;
await sshSessionDone;
if (config.env === 'development') {
await cleanUpWorkingdir();
}
+88
View File
@@ -0,0 +1,88 @@
import { BuildContext, TurtleSshSession, formatSecondsForLog } from '@expo/build-tools';
import { BuildPhase, BuildPhaseResult, LogMarker } from '@expo/eas-build-job';
import { bunyan } from '@expo/logger';
import { performance } from 'node:perf_hooks';
export async function startSshSessionPhaseAsync({
ctx,
buildId,
logger,
hasJobFinished,
}: {
ctx: BuildContext;
buildId: string;
logger: bunyan;
hasJobFinished: () => boolean;
}): Promise<{ done?: Promise<void> }> {
let done: Promise<void> | undefined;
await ctx.runBuildPhase(
BuildPhase.SSH_SESSION,
async () => {
const phaseStartedAt = performance.now();
try {
ctx.logger.info('Opening an SSH session for this job.');
const target = {
type: ctx.job.platform ? ('BUILD' as const) : ('JOB_RUN' as const),
id: buildId,
};
const { handle, idleTimeoutSeconds } = await TurtleSshSession.startSshSessionAsync(ctx, {
target,
relayServerUrl: TurtleSshSession.getSshRelayServerUrl(ctx.job),
idleTimeoutSeconds: TurtleSshSession.getSshIdleTimeoutSeconds(ctx.job),
});
ctx.logger.info(`SSH session ready. Connect with: eas workflow:ssh ${buildId}`);
ctx.logger.info(
idleTimeoutSeconds === 0
? 'It stays open for the whole job and closes once the job finishes and no client is connected.'
: `It stays open for the whole job, then closes after ${formatSecondsForLog(idleTimeoutSeconds)} with no client connected. The worker stays up until then.`
);
const sshLogger = logger.child({ phase: BuildPhase.SSH_SESSION });
done = (async () => {
let result = BuildPhaseResult.SUCCESS;
try {
await TurtleSshSession.superviseSshSessionAsync({
handle,
idleTimeoutSeconds,
hasJobFinished,
logger: sshLogger,
});
} catch (err) {
result = BuildPhaseResult.WARNING;
sshLogger.warn({ err }, 'The SSH session ended unexpectedly.');
} finally {
await handle.stopAsync().catch(err => {
sshLogger.warn({ err }, 'Failed to tear down the SSH tunnel.');
});
sshLogger.info(
{
marker: LogMarker.END_PHASE,
result,
durationMs: Math.round(performance.now() - phaseStartedAt),
},
`End phase: ${BuildPhase.SSH_SESSION}`
);
}
})();
} catch (err) {
ctx.logger.warn(
{ err },
'Failed to open the SSH session. The job will continue without it.'
);
ctx.markBuildPhaseHasWarnings();
ctx.logger.info(
{
marker: LogMarker.END_PHASE,
result: BuildPhaseResult.WARNING,
durationMs: Math.round(performance.now() - phaseStartedAt),
},
`End phase: ${BuildPhase.SSH_SESSION}`
);
}
},
{ doNotMarkEnd: true }
);
return { done };
}
Generated
+3 -3
View File
@@ -2291,7 +2291,7 @@ __metadata:
languageName: node
linkType: hard
"@expo/build-tools@npm:22.5.0, @expo/build-tools@workspace:packages/build-tools":
"@expo/build-tools@npm:22.6.0, @expo/build-tools@workspace:packages/build-tools":
version: 0.0.0-use.local
resolution: "@expo/build-tools@workspace:packages/build-tools"
dependencies:
@@ -2962,7 +2962,7 @@ __metadata:
resolution: "@expo/worker@workspace:packages/worker"
dependencies:
"@babel/core": "npm:7.24.7"
"@expo/build-tools": "npm:22.5.0"
"@expo/build-tools": "npm:22.6.0"
"@expo/config-plugins": "npm:55.0.7"
"@expo/config-types": "npm:55.0.5"
"@expo/downloader": "npm:22.0.0"
@@ -10300,7 +10300,7 @@ __metadata:
version: 0.0.0-use.local
resolution: "eas-cli-local-build-plugin@workspace:packages/local-build-plugin"
dependencies:
"@expo/build-tools": "npm:22.5.0"
"@expo/build-tools": "npm:22.6.0"
"@expo/eas-build-job": "npm:22.0.0"
"@expo/logger": "npm:22.0.0"
"@expo/spawn-async": "npm:^1.7.2"