mirror of
https://github.com/expo/eas-cli.git
synced 2026-11-14 17:50:32 -10:00
Compare commits
14
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2cac6b3812 | ||
|
|
2d694aa02e | ||
|
|
a79750f9a5 | ||
|
|
b9cbf27808 | ||
|
|
3033559fdf | ||
|
|
d001580c02 | ||
|
|
bb51f9b9c1 | ||
|
|
de095bc521 | ||
|
|
a7436edb50 | ||
|
|
89fe2cb3e1 | ||
|
|
0e8e8cc0aa | ||
|
|
6d7cef80a3 | ||
|
|
5a744b73f5 | ||
|
|
b8b53e262a |
No files matched your search
+15
-3
@@ -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
@@ -1,4 +1,4 @@
|
||||
{
|
||||
"STAGING": "22.4.0",
|
||||
"STAGING": "22.6.0",
|
||||
"PRODUCTION": "22.4.0"
|
||||
}
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"npmClient": "yarn",
|
||||
"version": "22.5.0",
|
||||
"version": "22.6.0",
|
||||
"$schema": "node_modules/lerna/schemas/lerna-schema.json"
|
||||
}
|
||||
@@ -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]>",
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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
File diff suppressed because it is too large.
Load diff
Generated
+551
-23
@@ -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,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.`);
|
||||
}
|
||||
}
|
||||
@@ -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)}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)}`);
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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> {
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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';
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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)],
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
Reference in new issue
Block a user