@expo/build-tools 22.5.0 → 22.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.d.ts CHANGED
@@ -6,6 +6,8 @@ export { uploadWithSignedUrl } from './storage/uploadWithSignedUrl';
6
6
  export type { SignedUrl, UploadWithSignedUrlParams } from './storage/uploadWithSignedUrl';
7
7
  export { ArtifactToUpload, Artifacts, BuildContext, BuildContextOptions, CacheManager, LogBuffer, SkipNativeBuildError, } from './context';
8
8
  export { PackageManager } from './utils/packageManager';
9
+ export * as TurtleSshSession from './utils/turtleSshSession';
10
+ export { formatSecondsForLog } from './utils/formatDuration';
9
11
  export { findAndUploadXcodeBuildLogsAsync } from './ios/xcodeBuildLogs';
10
12
  export { Hook, runHookIfPresent } from './utils/hooks';
11
13
  export { parseGradleProfile, formatGradleProfileReport } from './android/gradleProfile';
package/dist/index.js CHANGED
@@ -39,7 +39,7 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
39
39
  return (mod && mod.__esModule) ? mod : { "default": mod };
40
40
  };
41
41
  Object.defineProperty(exports, "__esModule", { value: true });
42
- exports.Sentry = exports.Datadog = exports.formatGradleProfileReport = exports.parseGradleProfile = exports.runHookIfPresent = exports.Hook = exports.findAndUploadXcodeBuildLogsAsync = exports.PackageManager = exports.SkipNativeBuildError = exports.BuildContext = exports.uploadWithSignedUrl = exports.RemoteLoggerStream = exports.HttpLogStream = exports.Builders = void 0;
42
+ exports.Sentry = exports.Datadog = exports.formatGradleProfileReport = exports.parseGradleProfile = exports.runHookIfPresent = exports.Hook = exports.findAndUploadXcodeBuildLogsAsync = exports.formatSecondsForLog = exports.TurtleSshSession = exports.PackageManager = exports.SkipNativeBuildError = exports.BuildContext = exports.uploadWithSignedUrl = exports.RemoteLoggerStream = exports.HttpLogStream = exports.Builders = void 0;
43
43
  const Builders = __importStar(require("./builders"));
44
44
  exports.Builders = Builders;
45
45
  const HttpLogStream_1 = __importDefault(require("./logging/HttpLogStream"));
@@ -53,6 +53,9 @@ Object.defineProperty(exports, "BuildContext", { enumerable: true, get: function
53
53
  Object.defineProperty(exports, "SkipNativeBuildError", { enumerable: true, get: function () { return context_1.SkipNativeBuildError; } });
54
54
  var packageManager_1 = require("./utils/packageManager");
55
55
  Object.defineProperty(exports, "PackageManager", { enumerable: true, get: function () { return packageManager_1.PackageManager; } });
56
+ exports.TurtleSshSession = __importStar(require("./utils/turtleSshSession"));
57
+ var formatDuration_1 = require("./utils/formatDuration");
58
+ Object.defineProperty(exports, "formatSecondsForLog", { enumerable: true, get: function () { return formatDuration_1.formatSecondsForLog; } });
56
59
  var xcodeBuildLogs_1 = require("./ios/xcodeBuildLogs");
57
60
  Object.defineProperty(exports, "findAndUploadXcodeBuildLogsAsync", { enumerable: true, get: function () { return xcodeBuildLogs_1.findAndUploadXcodeBuildLogsAsync; } });
58
61
  var hooks_1 = require("./utils/hooks");
@@ -73,7 +73,7 @@ const SERVE_SIM_PACKAGE_NAME = '@expo/serve-sim';
73
73
  const SERVE_SIM_HOST = '127.0.0.1';
74
74
  const SERVE_SIM_MAX_DIMENSION = '1280';
75
75
  const SERVE_SIM_MJPEG_QUALITY = '0.55';
76
- const SERVE_SIM_VIDEO_BITRATE = '3000000';
76
+ const SERVE_SIM_VIDEO_BITRATE = '6000000';
77
77
  const SERVE_SIM_VIDEO_FPS = '60';
78
78
  const START_DEVICE_RUN_SESSION_MUTATION = (0, gql_tada_1.graphql)(`
79
79
  mutation StartDeviceRunSession($deviceRunSessionId: ID!, $remoteConfig: JSONObject!) {
@@ -0,0 +1 @@
1
+ export declare function formatSecondsForLog(totalSeconds: number): string;
@@ -0,0 +1,19 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.formatSecondsForLog = formatSecondsForLog;
4
+ function formatSecondsForLog(totalSeconds) {
5
+ const hours = Math.floor(totalSeconds / 3600);
6
+ const minutes = Math.floor((totalSeconds % 3600) / 60);
7
+ const seconds = totalSeconds % 60;
8
+ const parts = [];
9
+ if (hours > 0) {
10
+ parts.push(hours === 1 ? '1 hour' : `${hours} hours`);
11
+ }
12
+ if (minutes > 0) {
13
+ parts.push(minutes === 1 ? '1 minute' : `${minutes} minutes`);
14
+ }
15
+ if (seconds > 0 || parts.length === 0) {
16
+ parts.push(seconds === 1 ? '1 second' : `${seconds} seconds`);
17
+ }
18
+ return parts.join(' ');
19
+ }
@@ -1,2 +1,9 @@
1
+ import { ChildProcess } from 'node:child_process';
2
+ export declare function isChildProcessAlive(child: ChildProcess): boolean;
3
+ /**
4
+ * Kill a detached spawn's process group. Negated pid targets the group so bash/sleep
5
+ * children cannot survive after the parent is gone (e.g. across upterm redial).
6
+ */
7
+ export declare function killProcessGroup(child: ChildProcess): void;
1
8
  export declare function getParentAndDescendantProcessPidsAsync(ppid: number): Promise<number[]>;
2
9
  export declare function isProcessDescendantOfAsync(pid: number, ancestorPid: number): Promise<boolean>;
@@ -3,9 +3,29 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
3
3
  return (mod && mod.__esModule) ? mod : { "default": mod };
4
4
  };
5
5
  Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.isChildProcessAlive = isChildProcessAlive;
7
+ exports.killProcessGroup = killProcessGroup;
6
8
  exports.getParentAndDescendantProcessPidsAsync = getParentAndDescendantProcessPidsAsync;
7
9
  exports.isProcessDescendantOfAsync = isProcessDescendantOfAsync;
8
10
  const turtle_spawn_1 = __importDefault(require("@expo/turtle-spawn"));
11
+ function isChildProcessAlive(child) {
12
+ return child.exitCode === null && child.signalCode === null && !child.killed;
13
+ }
14
+ /**
15
+ * Kill a detached spawn's process group. Negated pid targets the group so bash/sleep
16
+ * children cannot survive after the parent is gone (e.g. across upterm redial).
17
+ */
18
+ function killProcessGroup(child) {
19
+ if (child.pid == null) {
20
+ return;
21
+ }
22
+ try {
23
+ process.kill(-child.pid, 'SIGTERM');
24
+ }
25
+ catch {
26
+ child.kill();
27
+ }
28
+ }
9
29
  async function getChildrenPidsAsync(parentPids) {
10
30
  try {
11
31
  const result = await (0, turtle_spawn_1.default)('pgrep', ['-P', parentPids.join(',')], {
@@ -0,0 +1,28 @@
1
+ import { Job, SshSettings } from '@expo/eas-build-job';
2
+ import { bunyan } from '@expo/logger';
3
+ import { BuildContext } from '../context';
4
+ export type TurtleSshTarget = {
5
+ type: 'BUILD' | 'JOB_RUN';
6
+ id: string;
7
+ };
8
+ export declare function isSshEnabled(job: Pick<Job, 'ssh'>): boolean;
9
+ export declare function getSshIdleTimeoutSeconds(job: Pick<Job, 'ssh'>): number;
10
+ export declare function getSshRelayServerUrl(job: Pick<Job, 'ssh'>): string;
11
+ export type SshSessionHandle = {
12
+ getConnectedClientCountAsync: () => Promise<number>;
13
+ ensureConnectedAsync: () => Promise<void>;
14
+ stopAsync: () => Promise<void>;
15
+ };
16
+ export type StartedSshSession = {
17
+ handle: SshSessionHandle;
18
+ idleTimeoutSeconds: number;
19
+ };
20
+ export declare function startSshSessionAsync(ctx: BuildContext, { target, relayServerUrl, idleTimeoutSeconds: requestedIdleTimeoutSeconds, }: {
21
+ target: TurtleSshTarget;
22
+ } & SshSettings): Promise<StartedSshSession>;
23
+ export declare function superviseSshSessionAsync({ handle, idleTimeoutSeconds, hasJobFinished, logger, }: {
24
+ handle: SshSessionHandle;
25
+ idleTimeoutSeconds: number;
26
+ hasJobFinished: () => boolean;
27
+ logger: bunyan;
28
+ }): Promise<void>;
@@ -0,0 +1,182 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.isSshEnabled = isSshEnabled;
4
+ exports.getSshIdleTimeoutSeconds = getSshIdleTimeoutSeconds;
5
+ exports.getSshRelayServerUrl = getSshRelayServerUrl;
6
+ exports.startSshSessionAsync = startSshSessionAsync;
7
+ exports.superviseSshSessionAsync = superviseSshSessionAsync;
8
+ const eas_build_job_1 = require("@expo/eas-build-job");
9
+ const gql_tada_1 = require("gql.tada");
10
+ const formatDuration_1 = require("./formatDuration");
11
+ const retry_1 = require("./retry");
12
+ const upterm_1 = require("./upterm");
13
+ const sentry_1 = require("../sentry");
14
+ const MAX_SSH_REDIALS = 10;
15
+ const REDIAL_BACKOFF_MS = 6_000;
16
+ const CLIENT_COUNT_POLL_INTERVAL_MS = 5_000;
17
+ const MAX_SSH_IDLE_TIMEOUT_SECONDS = 3600;
18
+ const DEFAULT_SSH_IDLE_TIMEOUT_SECONDS = 0;
19
+ const CREATE_OR_UPDATE_TURTLE_SSH_SESSION_MUTATION = (0, gql_tada_1.graphql)(`
20
+ mutation CreateOrUpdateTurtleSshSession(
21
+ $target: TurtleSshTargetInput!
22
+ $connectionConfig: TurtleSshConnectionConfigInput!
23
+ $sessionSettings: TurtleSshSessionSettingsInput!
24
+ ) {
25
+ turtleSshSession {
26
+ createOrUpdateTurtleSshSession(
27
+ target: $target
28
+ connectionConfig: $connectionConfig
29
+ sessionSettings: $sessionSettings
30
+ ) {
31
+ id
32
+ sessionSettings {
33
+ idleTimeoutSeconds
34
+ }
35
+ }
36
+ }
37
+ }
38
+ `);
39
+ function isSshEnabled(job) {
40
+ return job.ssh != null;
41
+ }
42
+ function getSshIdleTimeoutSeconds(job) {
43
+ const idleTimeoutSeconds = job.ssh?.idleTimeoutSeconds ?? DEFAULT_SSH_IDLE_TIMEOUT_SECONDS;
44
+ if (!Number.isInteger(idleTimeoutSeconds) ||
45
+ idleTimeoutSeconds < 0 ||
46
+ idleTimeoutSeconds > MAX_SSH_IDLE_TIMEOUT_SECONDS) {
47
+ throw new eas_build_job_1.SystemError(`SSH idle timeout must be an integer between 0 and ${MAX_SSH_IDLE_TIMEOUT_SECONDS} seconds, got ${idleTimeoutSeconds}.`, { trackingCode: 'SSH_IDLE_TIMEOUT_INVALID' });
48
+ }
49
+ return idleTimeoutSeconds;
50
+ }
51
+ function getSshRelayServerUrl(job) {
52
+ const relayServerUrl = job.ssh?.relayServerUrl;
53
+ if (!relayServerUrl) {
54
+ throw new eas_build_job_1.SystemError('SSH is enabled but no relay server URL was configured on the job.', {
55
+ trackingCode: 'SSH_RELAY_SERVER_URL_MISSING',
56
+ });
57
+ }
58
+ return relayServerUrl;
59
+ }
60
+ async function createOrUpdateSessionAsync(ctx, { target, connectionConfig, idleTimeoutSeconds, }) {
61
+ const result = await ctx.graphqlClient
62
+ .mutation(CREATE_OR_UPDATE_TURTLE_SSH_SESSION_MUTATION, {
63
+ target,
64
+ connectionConfig: {
65
+ ...connectionConfig,
66
+ type: 'UPTERM_V1',
67
+ },
68
+ sessionSettings: { idleTimeoutSeconds },
69
+ })
70
+ .toPromise();
71
+ if (result.error || !result.data) {
72
+ throw new eas_build_job_1.SystemError(`Failed to create or update the SSH session: ${result.error?.message ?? 'no data returned'}`, { cause: result.error });
73
+ }
74
+ const session = result.data.turtleSshSession.createOrUpdateTurtleSshSession;
75
+ return { idleTimeoutSeconds: session.sessionSettings.idleTimeoutSeconds };
76
+ }
77
+ async function startSshSessionAsync(ctx, { target, relayServerUrl, idleTimeoutSeconds: requestedIdleTimeoutSeconds, }) {
78
+ const logger = ctx.logger;
79
+ const host = await (0, upterm_1.startUptermHostAsync)(ctx, { relayServerUrl });
80
+ let idleTimeoutSeconds;
81
+ try {
82
+ ({ idleTimeoutSeconds } = await createOrUpdateSessionAsync(ctx, {
83
+ target,
84
+ connectionConfig: { ...host.connectionConfig, reconnecting: false },
85
+ idleTimeoutSeconds: requestedIdleTimeoutSeconds,
86
+ }));
87
+ }
88
+ catch (err) {
89
+ await host.stopAsync().catch(() => { });
90
+ throw err;
91
+ }
92
+ const ensureConnectedAsync = async () => {
93
+ if (host.isAlive()) {
94
+ return;
95
+ }
96
+ for (let attempt = 1; attempt <= MAX_SSH_REDIALS; attempt++) {
97
+ try {
98
+ if (!host.isAlive()) {
99
+ logger.warn('The SSH relay connection dropped. Reconnecting...');
100
+ await createOrUpdateSessionAsync(ctx, {
101
+ target,
102
+ connectionConfig: { ...host.connectionConfig, reconnecting: true },
103
+ idleTimeoutSeconds: requestedIdleTimeoutSeconds,
104
+ }).catch(() => { });
105
+ await host.redialAsync();
106
+ }
107
+ await createOrUpdateSessionAsync(ctx, {
108
+ target,
109
+ connectionConfig: { ...host.connectionConfig, reconnecting: false },
110
+ idleTimeoutSeconds: requestedIdleTimeoutSeconds,
111
+ });
112
+ logger.info('The SSH relay connection was restored.');
113
+ return;
114
+ }
115
+ catch (err) {
116
+ logger.warn({ err }, `SSH reconnect attempt ${attempt} of ${MAX_SSH_REDIALS} failed.`);
117
+ if (attempt < MAX_SSH_REDIALS) {
118
+ await (0, retry_1.sleepAsync)(REDIAL_BACKOFF_MS);
119
+ }
120
+ }
121
+ }
122
+ throw new eas_build_job_1.SystemError(`The SSH relay connection dropped and could not be restored after ${MAX_SSH_REDIALS} attempts.`);
123
+ };
124
+ return {
125
+ handle: {
126
+ getConnectedClientCountAsync: () => host.getConnectedClientCountAsync(),
127
+ ensureConnectedAsync,
128
+ stopAsync: () => host.stopAsync(),
129
+ },
130
+ idleTimeoutSeconds,
131
+ };
132
+ }
133
+ async function superviseSshSessionAsync({ handle, idleTimeoutSeconds, hasJobFinished, logger, }) {
134
+ const idleTimeoutMs = idleTimeoutSeconds * 1_000;
135
+ let idleSince = null;
136
+ let previousClientCount = 0;
137
+ for (;;) {
138
+ try {
139
+ await handle.ensureConnectedAsync();
140
+ }
141
+ catch (err) {
142
+ logger.warn({ err }, 'Could not restore the SSH relay connection. Closing the session.');
143
+ sentry_1.Sentry.capture('Could not restore the SSH relay connection', err instanceof Error ? err : undefined, {
144
+ tags: { trackingCode: 'SSH_RELAY_RECONNECT_FAILED' },
145
+ });
146
+ return;
147
+ }
148
+ let connectedClientCount;
149
+ try {
150
+ connectedClientCount = await handle.getConnectedClientCountAsync();
151
+ }
152
+ catch (err) {
153
+ logger.warn({ err }, 'Could not read the SSH client count. Closing the session.');
154
+ sentry_1.Sentry.capture('Could not read the SSH client count', err instanceof Error ? err : undefined, {
155
+ tags: { trackingCode: 'SSH_CLIENT_COUNT_UNREADABLE' },
156
+ });
157
+ return;
158
+ }
159
+ if (connectedClientCount !== previousClientCount) {
160
+ logger.info(`SSH clients connected: ${connectedClientCount}`);
161
+ previousClientCount = connectedClientCount;
162
+ }
163
+ const jobHasFinished = hasJobFinished();
164
+ if (connectedClientCount > 0 || !jobHasFinished) {
165
+ idleSince = null;
166
+ }
167
+ else if (idleSince === null) {
168
+ idleSince = Date.now();
169
+ }
170
+ if (jobHasFinished && connectedClientCount === 0) {
171
+ if (idleTimeoutSeconds === 0) {
172
+ logger.info('The job finished and no SSH client is connected. Closing the session.');
173
+ return;
174
+ }
175
+ if (idleSince !== null && Date.now() - idleSince >= idleTimeoutMs) {
176
+ logger.info(`No SSH client connected for ${(0, formatDuration_1.formatSecondsForLog)(idleTimeoutSeconds)} after the job finished. Closing the session.`);
177
+ return;
178
+ }
179
+ }
180
+ await (0, retry_1.sleepAsync)(CLIENT_COUNT_POLL_INTERVAL_MS);
181
+ }
182
+ }
@@ -0,0 +1,38 @@
1
+ import { Env } from '@expo/eas-build-job';
2
+ import { z } from 'zod';
3
+ import { BuildContext } from '../context';
4
+ export declare function resolveUptermGcsObjectName(platform?: NodeJS.Platform, arch?: string): string;
5
+ export type SshConnectionConfig = {
6
+ type: 'upterm-v1';
7
+ host: string;
8
+ secret: string;
9
+ };
10
+ export type UptermHost = {
11
+ connectionConfig: SshConnectionConfig;
12
+ getConnectedClientCountAsync: () => Promise<number>;
13
+ isAlive: () => boolean;
14
+ redialAsync: () => Promise<SshConnectionConfig>;
15
+ stopAsync: () => Promise<void>;
16
+ };
17
+ declare const UptermSessionJsonZ: z.ZodObject<{
18
+ sessionId: z.ZodString;
19
+ host: z.ZodString;
20
+ clientCount: z.ZodOptional<z.ZodNumber>;
21
+ }, z.core.$strip>;
22
+ type UptermSessionJson = z.infer<typeof UptermSessionJsonZ>;
23
+ export declare function connectionConfigFromUptermSession(parsed: Pick<UptermSessionJson, 'sessionId' | 'host'>): SshConnectionConfig | null;
24
+ /**
25
+ * Strip upterm session secrets out of text before it reaches a log or an error message. upterm
26
+ * prints the session id as the userinfo of the connect line it advertises
27
+ * (`upterm proxy ws(s)://<sessionId>@host`) and repeats it bare as the ssh destination on the
28
+ * same line. Control characters are dropped first so they cannot split a token mid-match. The
29
+ * loop lifts the id out of the proxy URL and removes every occurrence of it; the final replace
30
+ * blanks userinfo in any other URL as a catch-all.
31
+ */
32
+ export declare function redactConnectionSecrets(text: string): string;
33
+ export declare function redactSpawnErrorForLog(err: unknown): unknown;
34
+ export declare function resolveUptermPathAsync(env: Env): Promise<string>;
35
+ export declare function startUptermHostAsync(ctx: BuildContext, { relayServerUrl }: {
36
+ relayServerUrl: string;
37
+ }): Promise<UptermHost>;
38
+ export {};
@@ -0,0 +1,257 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.resolveUptermGcsObjectName = resolveUptermGcsObjectName;
7
+ exports.connectionConfigFromUptermSession = connectionConfigFromUptermSession;
8
+ exports.redactConnectionSecrets = redactConnectionSecrets;
9
+ exports.redactSpawnErrorForLog = redactSpawnErrorForLog;
10
+ exports.resolveUptermPathAsync = resolveUptermPathAsync;
11
+ exports.startUptermHostAsync = startUptermHostAsync;
12
+ const eas_build_job_1 = require("@expo/eas-build-job");
13
+ const downloader_1 = __importDefault(require("@expo/downloader"));
14
+ const turtle_spawn_1 = __importDefault(require("@expo/turtle-spawn"));
15
+ const promises_1 = __importDefault(require("node:fs/promises"));
16
+ const node_os_1 = __importDefault(require("node:os"));
17
+ const node_path_1 = __importDefault(require("node:path"));
18
+ const zod_1 = require("zod");
19
+ const processes_1 = require("./processes");
20
+ const retry_1 = require("./retry");
21
+ const CONTROL_CHARACTERS = /[\u0000-\u0008\u000B-\u001F\u007F-\u009F]/g;
22
+ function resolveUptermGcsObjectName(platform = process.platform, arch = process.arch) {
23
+ if (platform === 'darwin' && arch === 'arm64') {
24
+ return 'upterm-darwin-arm64';
25
+ }
26
+ if (platform === 'linux' && arch === 'x64') {
27
+ return 'upterm-linux-amd64';
28
+ }
29
+ throw new eas_build_job_1.SystemError(`SSH upterm is only available on darwin/arm64 and linux/x64 (got ${platform}/${arch}).`);
30
+ }
31
+ const UPTERM_GCS_BASE_URL = 'https://storage.googleapis.com/turtle-v2/upterm';
32
+ const UPTERM_DOWNLOAD_TIMEOUT_MS = 60_000;
33
+ const UPTERM_KEEPALIVE_SLEEP_SECONDS = 6 * 60 * 60;
34
+ const CONNECTION_POLL_INTERVAL_MS = 500;
35
+ const CONNECTION_STARTUP_TIMEOUT_MS = 60_000;
36
+ const PROCESS_EXIT_TIMEOUT_MS = 5_000;
37
+ const CLIENT_COUNT_READ_ATTEMPTS = 4;
38
+ const CLIENT_COUNT_READ_RETRY_MS = 500;
39
+ const DEFAULT_SSH_PORT = '22';
40
+ const UptermSessionJsonZ = zod_1.z.object({
41
+ sessionId: zod_1.z.string().min(1),
42
+ host: zod_1.z.string().min(1),
43
+ clientCount: zod_1.z.number().optional(),
44
+ });
45
+ function connectionConfigFromUptermSession(parsed) {
46
+ let host = parsed.host;
47
+ if (host.includes('://')) {
48
+ let url;
49
+ try {
50
+ url = new URL(host);
51
+ }
52
+ catch {
53
+ return null;
54
+ }
55
+ host = url.port && url.port !== DEFAULT_SSH_PORT ? `${url.hostname}:${url.port}` : url.hostname;
56
+ }
57
+ if (!host) {
58
+ return null;
59
+ }
60
+ return { type: 'upterm-v1', host, secret: parsed.sessionId };
61
+ }
62
+ /**
63
+ * Strip upterm session secrets out of text before it reaches a log or an error message. upterm
64
+ * prints the session id as the userinfo of the connect line it advertises
65
+ * (`upterm proxy ws(s)://<sessionId>@host`) and repeats it bare as the ssh destination on the
66
+ * same line. Control characters are dropped first so they cannot split a token mid-match. The
67
+ * loop lifts the id out of the proxy URL and removes every occurrence of it; the final replace
68
+ * blanks userinfo in any other URL as a catch-all.
69
+ */
70
+ function redactConnectionSecrets(text) {
71
+ let redacted = text.replace(CONTROL_CHARACTERS, '');
72
+ for (const [, token] of redacted.matchAll(/upterm proxy wss?:\/\/([^@\s]+)@/g)) {
73
+ redacted = redacted.split(token).join('<redacted>');
74
+ }
75
+ return redacted.replace(/([a-z][a-z0-9+.-]*:\/\/)[^@\s/]+@/gi, '$1<redacted>@');
76
+ }
77
+ function redactSpawnErrorForLog(err) {
78
+ if (!err || typeof err !== 'object') {
79
+ return err;
80
+ }
81
+ const spawnErr = err;
82
+ return {
83
+ ...spawnErr,
84
+ ...(typeof spawnErr.message === 'string'
85
+ ? { message: redactConnectionSecrets(spawnErr.message) }
86
+ : {}),
87
+ ...(typeof spawnErr.stdout === 'string'
88
+ ? { stdout: redactConnectionSecrets(spawnErr.stdout) }
89
+ : {}),
90
+ ...(typeof spawnErr.stderr === 'string'
91
+ ? { stderr: redactConnectionSecrets(spawnErr.stderr) }
92
+ : {}),
93
+ };
94
+ }
95
+ async function resolveUptermPathAsync(env) {
96
+ try {
97
+ await (0, turtle_spawn_1.default)('upterm', ['version'], { stdio: 'pipe', env });
98
+ return 'upterm';
99
+ }
100
+ catch { }
101
+ const objectName = resolveUptermGcsObjectName();
102
+ const downloadDir = await promises_1.default.mkdtemp(node_path_1.default.join(node_os_1.default.tmpdir(), 'eas-upterm-'));
103
+ const uptermPath = node_path_1.default.join(downloadDir, objectName);
104
+ const url = `${UPTERM_GCS_BASE_URL}/${objectName}`;
105
+ try {
106
+ await (0, downloader_1.default)(url, uptermPath, { retry: 3, timeout: UPTERM_DOWNLOAD_TIMEOUT_MS });
107
+ await promises_1.default.chmod(uptermPath, 0o755);
108
+ }
109
+ catch (err) {
110
+ await promises_1.default.rm(downloadDir, { recursive: true, force: true }).catch(() => { });
111
+ throw new eas_build_job_1.SystemError(`The upterm SSH client was not on PATH and could not be downloaded from ${url}. ${err instanceof Error ? err.message : String(err)}`);
112
+ }
113
+ return uptermPath;
114
+ }
115
+ async function findAdminSocketPathAsync(uptermSocketDir) {
116
+ // Use this dial's own admin socket, not upterm's default, which can still point at a previous
117
+ // dial's session after a redial and break client-count reads.
118
+ const entries = await promises_1.default.readdir(uptermSocketDir).catch(() => []);
119
+ const socketName = entries.find(entry => entry.endsWith('.sock'));
120
+ return socketName ? node_path_1.default.join(uptermSocketDir, socketName) : null;
121
+ }
122
+ async function readCurrentSessionJsonAsync(uptermPath, adminSocketPath) {
123
+ try {
124
+ const result = await (0, turtle_spawn_1.default)(uptermPath, ['session', 'current', '--admin-socket', adminSocketPath, '--output', 'json'], { stdio: 'pipe' });
125
+ const parsed = UptermSessionJsonZ.safeParse(JSON.parse(result.stdout));
126
+ return parsed.success ? parsed.data : null;
127
+ }
128
+ catch {
129
+ return null;
130
+ }
131
+ }
132
+ async function waitForConnectionConfigAsync(uptermPath, uptermSocketDir, getHostOutput) {
133
+ const deadline = Date.now() + CONNECTION_STARTUP_TIMEOUT_MS;
134
+ for (;;) {
135
+ const adminSocketPath = await findAdminSocketPathAsync(uptermSocketDir);
136
+ if (adminSocketPath) {
137
+ const session = await readCurrentSessionJsonAsync(uptermPath, adminSocketPath);
138
+ if (session) {
139
+ const connectionConfig = connectionConfigFromUptermSession(session);
140
+ if (connectionConfig) {
141
+ return connectionConfig;
142
+ }
143
+ }
144
+ }
145
+ if (Date.now() >= deadline) {
146
+ throw new eas_build_job_1.SystemError(`The upterm client did not register with the relay within ${CONNECTION_STARTUP_TIMEOUT_MS / 1_000}s. Output:\n${redactConnectionSecrets(getHostOutput())}`);
147
+ }
148
+ await (0, retry_1.sleepAsync)(CONNECTION_POLL_INTERVAL_MS);
149
+ }
150
+ }
151
+ async function startUptermHostAsync(ctx, { relayServerUrl }) {
152
+ const uptermPath = await resolveUptermPathAsync(ctx.env);
153
+ const stateDir = await promises_1.default.mkdtemp(node_path_1.default.join(node_os_1.default.tmpdir(), 'eas-ssh-'));
154
+ const hostKeyPath = node_path_1.default.join(stateDir, 'id_host');
155
+ const forceCommandPath = node_path_1.default.join(stateDir, 'join.sh');
156
+ const uptermSocketDir = node_path_1.default.join(stateDir, 'upterm');
157
+ await (0, turtle_spawn_1.default)('ssh-keygen', ['-t', 'ed25519', '-N', '', '-f', hostKeyPath, '-q'], {
158
+ logger: ctx.logger,
159
+ });
160
+ await promises_1.default.writeFile(forceCommandPath, '#!/usr/bin/env bash\nexec bash -l\n', { mode: 0o755 });
161
+ let currentProcess = null;
162
+ const stopCurrentProcessAsync = async () => {
163
+ const previousProcess = currentProcess;
164
+ currentProcess = null;
165
+ if (!previousProcess) {
166
+ return;
167
+ }
168
+ (0, processes_1.killProcessGroup)(previousProcess.child);
169
+ await Promise.race([
170
+ previousProcess.catch(() => { }),
171
+ (0, retry_1.sleepAsync)(PROCESS_EXIT_TIMEOUT_MS).then(() => {
172
+ ctx.logger.debug('The previous upterm host process did not exit in time.');
173
+ }),
174
+ ]);
175
+ };
176
+ const getConnectedClientCountAsync = async () => {
177
+ for (let attempt = 1; attempt <= CLIENT_COUNT_READ_ATTEMPTS; attempt++) {
178
+ const adminSocketPath = await findAdminSocketPathAsync(uptermSocketDir);
179
+ const session = adminSocketPath
180
+ ? await readCurrentSessionJsonAsync(uptermPath, adminSocketPath)
181
+ : null;
182
+ if (session && typeof session.clientCount === 'number') {
183
+ return session.clientCount;
184
+ }
185
+ if (attempt < CLIENT_COUNT_READ_ATTEMPTS) {
186
+ await (0, retry_1.sleepAsync)(CLIENT_COUNT_READ_RETRY_MS);
187
+ }
188
+ }
189
+ throw new eas_build_job_1.SystemError('Could not read the SSH client count from the upterm admin socket.', {
190
+ trackingCode: 'SSH_CLIENT_COUNT_UNREADABLE',
191
+ });
192
+ };
193
+ const dialAsync = async () => {
194
+ await stopCurrentProcessAsync();
195
+ await promises_1.default.rm(uptermSocketDir, { recursive: true, force: true }).catch(err => {
196
+ ctx.logger.debug({ err }, 'Failed to clear the previous SSH socket directory.');
197
+ });
198
+ ctx.logger.debug('Connecting to the SSH relay.');
199
+ // --force-command is what each connecting SSH client runs (a login shell). The `sleep` after
200
+ // `--` is the host-side process that keeps `upterm host` up while nobody is connected.
201
+ const uptermProcess = (0, turtle_spawn_1.default)(uptermPath, [
202
+ 'host',
203
+ '--server',
204
+ relayServerUrl,
205
+ '--accept',
206
+ '--skip-host-key-check',
207
+ '-i',
208
+ hostKeyPath,
209
+ '--force-command',
210
+ forceCommandPath,
211
+ '--',
212
+ 'bash',
213
+ '-lc',
214
+ `sleep ${UPTERM_KEEPALIVE_SLEEP_SECONDS}`,
215
+ ], {
216
+ // upterm puts its admin socket under XDG_RUNTIME_DIR; point it at our state dir so we can
217
+ // find it for `session current` and clean it up on stop/redial.
218
+ env: { ...ctx.env, XDG_RUNTIME_DIR: stateDir },
219
+ stdio: ['ignore', 'pipe', 'pipe'],
220
+ detached: true,
221
+ });
222
+ uptermProcess.catch(err => ctx.logger.debug({ err: redactSpawnErrorForLog(err) }, 'The upterm host process exited.'));
223
+ uptermProcess.child.unref();
224
+ currentProcess = uptermProcess;
225
+ let output = '';
226
+ const appendChunk = (chunk) => {
227
+ output += chunk.toString();
228
+ };
229
+ uptermProcess.child.stdout?.on('data', appendChunk);
230
+ uptermProcess.child.stderr?.on('data', appendChunk);
231
+ return await waitForConnectionConfigAsync(uptermPath, uptermSocketDir, () => output);
232
+ };
233
+ const stopAsync = async () => {
234
+ await stopCurrentProcessAsync();
235
+ await promises_1.default.rm(stateDir, { recursive: true, force: true });
236
+ };
237
+ let connectionConfig;
238
+ try {
239
+ connectionConfig = await dialAsync();
240
+ }
241
+ catch (err) {
242
+ await stopAsync();
243
+ throw err;
244
+ }
245
+ return {
246
+ get connectionConfig() {
247
+ return connectionConfig;
248
+ },
249
+ getConnectedClientCountAsync,
250
+ isAlive: () => currentProcess != null && (0, processes_1.isChildProcessAlive)(currentProcess.child),
251
+ redialAsync: async () => {
252
+ connectionConfig = await dialAsync();
253
+ return connectionConfig;
254
+ },
255
+ stopAsync,
256
+ };
257
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@expo/build-tools",
3
- "version": "22.5.0",
3
+ "version": "22.6.0",
4
4
  "bugs": "https://github.com/expo/eas-cli/issues",
5
5
  "license": "BUSL-1.1",
6
6
  "author": "Expo <support@expo.io>",
@@ -103,5 +103,5 @@
103
103
  "typescript": "^5.5.4",
104
104
  "uuid": "^9.0.1"
105
105
  },
106
- "gitHead": "3f74ea369d329a363fe16c9f90c8be74cb598777"
106
+ "gitHead": "89fe2cb3e1bba6e153752f1acf26b7817974b4a4"
107
107
  }