@expo/build-tools 22.5.0 → 23.0.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.
@@ -101,6 +101,12 @@ async function buildInnerAsync(ctx, jobHooksRef) {
101
101
  env: ctx.env,
102
102
  secrets: ctx.job.secrets,
103
103
  });
104
+ await (0, restoreBuildCache_1.restoreCocoapodsCacheAsync)({
105
+ logger: ctx.logger,
106
+ workingDirectory,
107
+ env: ctx.env,
108
+ secrets: ctx.job.secrets,
109
+ });
104
110
  });
105
111
  await ctx.runBuildPhase(eas_build_job_1.BuildPhase.INSTALL_PODS, async () => {
106
112
  await runInstallPodsAsync(ctx);
@@ -229,6 +235,12 @@ async function buildInnerAsync(ctx, jobHooksRef) {
229
235
  env: ctx.env,
230
236
  secrets: ctx.job.secrets,
231
237
  });
238
+ await (0, saveBuildCache_1.saveCocoapodsCacheAsync)({
239
+ logger: ctx.logger,
240
+ workingDirectory,
241
+ env: ctx.env,
242
+ secrets: ctx.job.secrets,
243
+ });
232
244
  });
233
245
  await ctx.runBuildPhase(eas_build_job_1.BuildPhase.CACHE_STATS, async () => {
234
246
  await (0, restoreBuildCache_1.cacheStatsAsync)({
@@ -49,7 +49,11 @@ async function installDependenciesAsync({ packageManager, env, logger, infoCallb
49
49
  break;
50
50
  }
51
51
  case packageManager_1.PackageManager.BUN:
52
- args = ['install', ...(useFrozenLockfile ? ['--frozen-lockfile'] : [])];
52
+ args = [
53
+ 'install',
54
+ ...(env['EAS_BUN_FILTER_WORKSPACE'] ? ['--filter', env['EAS_BUN_FILTER_WORKSPACE']] : []),
55
+ ...(useFrozenLockfile ? ['--frozen-lockfile'] : []),
56
+ ];
53
57
  break;
54
58
  default:
55
59
  throw new Error(`Unsupported package manager: ${packageManager}`);
package/dist/index.d.ts CHANGED
@@ -4,8 +4,12 @@ import RemoteLoggerStream from './logging/RemoteLoggerStream';
4
4
  export { Builders, HttpLogStream, RemoteLoggerStream };
5
5
  export { uploadWithSignedUrl } from './storage/uploadWithSignedUrl';
6
6
  export type { SignedUrl, UploadWithSignedUrlParams } from './storage/uploadWithSignedUrl';
7
+ export { downloadCacheAsync } from './steps/functions/restoreCache';
8
+ export { uploadCacheAsync } from './steps/functions/saveCache';
7
9
  export { ArtifactToUpload, Artifacts, BuildContext, BuildContextOptions, CacheManager, LogBuffer, SkipNativeBuildError, } from './context';
8
10
  export { PackageManager } from './utils/packageManager';
11
+ export * as TurtleSshSession from './utils/turtleSshSession';
12
+ export { formatSecondsForLog } from './utils/formatDuration';
9
13
  export { findAndUploadXcodeBuildLogsAsync } from './ios/xcodeBuildLogs';
10
14
  export { Hook, runHookIfPresent } from './utils/hooks';
11
15
  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.uploadCacheAsync = exports.downloadCacheAsync = 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"));
@@ -48,11 +48,18 @@ const RemoteLoggerStream_1 = __importDefault(require("./logging/RemoteLoggerStre
48
48
  exports.RemoteLoggerStream = RemoteLoggerStream_1.default;
49
49
  var uploadWithSignedUrl_1 = require("./storage/uploadWithSignedUrl");
50
50
  Object.defineProperty(exports, "uploadWithSignedUrl", { enumerable: true, get: function () { return uploadWithSignedUrl_1.uploadWithSignedUrl; } });
51
+ var restoreCache_1 = require("./steps/functions/restoreCache");
52
+ Object.defineProperty(exports, "downloadCacheAsync", { enumerable: true, get: function () { return restoreCache_1.downloadCacheAsync; } });
53
+ var saveCache_1 = require("./steps/functions/saveCache");
54
+ Object.defineProperty(exports, "uploadCacheAsync", { enumerable: true, get: function () { return saveCache_1.uploadCacheAsync; } });
51
55
  var context_1 = require("./context");
52
56
  Object.defineProperty(exports, "BuildContext", { enumerable: true, get: function () { return context_1.BuildContext; } });
53
57
  Object.defineProperty(exports, "SkipNativeBuildError", { enumerable: true, get: function () { return context_1.SkipNativeBuildError; } });
54
58
  var packageManager_1 = require("./utils/packageManager");
55
59
  Object.defineProperty(exports, "PackageManager", { enumerable: true, get: function () { return packageManager_1.PackageManager; } });
60
+ exports.TurtleSshSession = __importStar(require("./utils/turtleSshSession"));
61
+ var formatDuration_1 = require("./utils/formatDuration");
62
+ Object.defineProperty(exports, "formatSecondsForLog", { enumerable: true, get: function () { return formatDuration_1.formatSecondsForLog; } });
56
63
  var xcodeBuildLogs_1 = require("./ios/xcodeBuildLogs");
57
64
  Object.defineProperty(exports, "findAndUploadXcodeBuildLogsAsync", { enumerable: true, get: function () { return xcodeBuildLogs_1.findAndUploadXcodeBuildLogsAsync; } });
58
65
  var hooks_1 = require("./utils/hooks");
@@ -80,6 +80,7 @@ function createEasBuildBuildFunctionGroup(buildToolsContext) {
80
80
  });
81
81
  }
82
82
  function createStepsForIosSimulatorBuild({ globalCtx, buildToolsContext, workingDirectory, }) {
83
+ const evictUsedBefore = new Date();
83
84
  const calculateEASUpdateRuntimeVersion = (0, calculateEASUpdateRuntimeVersion_1.calculateEASUpdateRuntimeVersionFunction)().createBuildStepFromFunctionCall(globalCtx, {
84
85
  id: 'calculate_eas_update_runtime_version',
85
86
  workingDirectory,
@@ -87,6 +88,13 @@ function createStepsForIosSimulatorBuild({ globalCtx, buildToolsContext, working
87
88
  const installPods = (0, installPods_1.createInstallPodsBuildFunction)().createBuildStepFromFunctionCall(globalCtx, {
88
89
  workingDirectory: workingDirectory ? path_1.default.join(workingDirectory, './ios') : './ios',
89
90
  });
91
+ const restoreCache = (0, restoreBuildCache_1.createRestoreBuildCacheFunction)().createBuildStepFromFunctionCall(globalCtx, {
92
+ workingDirectory,
93
+ callInputs: {
94
+ platform: eas_build_job_1.Platform.IOS,
95
+ simulator: true,
96
+ },
97
+ });
90
98
  const configureEASUpdate = (0, configureEASUpdateIfInstalled_1.configureEASUpdateIfInstalledFunction)().createBuildStepFromFunctionCall(globalCtx, {
91
99
  workingDirectory,
92
100
  callInputs: {
@@ -101,6 +109,13 @@ function createStepsForIosSimulatorBuild({ globalCtx, buildToolsContext, working
101
109
  resolved_eas_update_runtime_version: '${ steps.calculate_eas_update_runtime_version.resolved_eas_update_runtime_version }',
102
110
  },
103
111
  });
112
+ const saveCache = (0, saveBuildCache_1.createSaveBuildCacheFunction)(evictUsedBefore).createBuildStepFromFunctionCall(globalCtx, {
113
+ workingDirectory,
114
+ callInputs: {
115
+ platform: eas_build_job_1.Platform.IOS,
116
+ simulator: true,
117
+ },
118
+ });
104
119
  return [
105
120
  (0, checkout_1.createCheckoutBuildFunction)().createBuildStepFromFunctionCall(globalCtx),
106
121
  (0, useNpmToken_1.createSetUpNpmrcBuildFunction)().createBuildStepFromFunctionCall(globalCtx, {
@@ -113,6 +128,7 @@ function createStepsForIosSimulatorBuild({ globalCtx, buildToolsContext, working
113
128
  (0, prebuild_1.createPrebuildBuildFunction)().createBuildStepFromFunctionCall(globalCtx, {
114
129
  workingDirectory,
115
130
  }),
131
+ restoreCache,
116
132
  calculateEASUpdateRuntimeVersion,
117
133
  installPods,
118
134
  configureEASUpdate,
@@ -131,6 +147,10 @@ function createStepsForIosSimulatorBuild({ globalCtx, buildToolsContext, working
131
147
  }),
132
148
  runFastlane,
133
149
  (0, findAndUploadBuildArtifacts_1.createFindAndUploadBuildArtifactsBuildFunction)(buildToolsContext).createBuildStepFromFunctionCall(globalCtx, { workingDirectory }),
150
+ saveCache,
151
+ (0, restoreBuildCache_1.createCacheStatsBuildFunction)().createBuildStepFromFunctionCall(globalCtx, {
152
+ workingDirectory,
153
+ }),
134
154
  ];
135
155
  }
136
156
  function createStepsForIosBuildWithCredentials({ globalCtx, buildToolsContext, workingDirectory, }) {
@@ -12,6 +12,14 @@ export declare function restoreCcacheAsync({ logger, workingDirectory, target, e
12
12
  robotAccessToken?: string;
13
13
  };
14
14
  }): Promise<void>;
15
+ export declare function restoreCocoapodsCacheAsync({ logger, workingDirectory, env, secrets, }: {
16
+ logger: bunyan;
17
+ workingDirectory: string;
18
+ env: Record<string, string | undefined>;
19
+ secrets?: {
20
+ robotAccessToken?: string;
21
+ };
22
+ }): Promise<void>;
15
23
  export declare function restoreGradleCacheAsync({ logger, workingDirectory, env, secrets, }: {
16
24
  logger: bunyan;
17
25
  workingDirectory: string;
@@ -6,6 +6,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
6
6
  exports.createRestoreBuildCacheFunction = createRestoreBuildCacheFunction;
7
7
  exports.createCacheStatsBuildFunction = createCacheStatsBuildFunction;
8
8
  exports.restoreCcacheAsync = restoreCcacheAsync;
9
+ exports.restoreCocoapodsCacheAsync = restoreCocoapodsCacheAsync;
9
10
  exports.restoreGradleCacheAsync = restoreGradleCacheAsync;
10
11
  exports.cacheStatsAsync = cacheStatsAsync;
11
12
  const eas_build_job_1 = require("@expo/eas-build-job");
@@ -18,6 +19,7 @@ const path_1 = __importDefault(require("path"));
18
19
  const ccacheStats_1 = require("./ccacheStats");
19
20
  const restoreCache_1 = require("./restoreCache");
20
21
  const cacheKey_1 = require("../../utils/cacheKey");
22
+ const cocoapodsCache_1 = require("../../utils/cocoapodsCache");
21
23
  const datadog_1 = require("../../datadog");
22
24
  const gradleCacheKey_1 = require("../../utils/gradleCacheKey");
23
25
  const turtleFetch_1 = require("../../utils/turtleFetch");
@@ -70,6 +72,14 @@ function createRestoreBuildCacheFunction() {
70
72
  secrets: stepCtx.global.staticContext.job.secrets,
71
73
  });
72
74
  }
75
+ else {
76
+ await restoreCocoapodsCacheAsync({
77
+ logger,
78
+ workingDirectory,
79
+ env,
80
+ secrets: stepCtx.global.staticContext.job.secrets,
81
+ });
82
+ }
73
83
  },
74
84
  });
75
85
  }
@@ -167,6 +177,48 @@ async function restoreCcacheAsync({ logger, workingDirectory, target, env, secre
167
177
  }
168
178
  }
169
179
  }
180
+ async function restoreCocoapodsCacheAsync({ logger, workingDirectory, env, secrets, }) {
181
+ if (env.EAS_PODS_CACHE !== '1') {
182
+ return;
183
+ }
184
+ try {
185
+ const { stdout } = await (0, steps_1.spawnAsync)('pod', ['--version'], {
186
+ env,
187
+ stdio: 'pipe',
188
+ });
189
+ const { key, keyPrefix } = await (0, cocoapodsCache_1.resolveCocoapodsCacheKeyAsync)(workingDirectory, stdout);
190
+ logger.info(`Restoring CocoaPods cache key: ${key}`);
191
+ const jobId = (0, nullthrows_1.default)(env.EAS_BUILD_ID, 'EAS_BUILD_ID is not set');
192
+ const robotAccessToken = (0, nullthrows_1.default)(secrets?.robotAccessToken, 'Robot access token is required for cache operations');
193
+ const expoApiServerURL = (0, nullthrows_1.default)(env.__API_SERVER_URL, '__API_SERVER_URL is not set');
194
+ const { podsDirectory } = (0, cocoapodsCache_1.getCocoapodsCachePaths)(workingDirectory);
195
+ const { archivePath, matchedKey } = await (0, restoreCache_1.downloadCacheAsync)({
196
+ logger,
197
+ jobId,
198
+ expoApiServerURL,
199
+ robotAccessToken,
200
+ paths: [podsDirectory],
201
+ key,
202
+ keyPrefixes: [keyPrefix],
203
+ platform: eas_build_job_1.Platform.IOS,
204
+ });
205
+ await (0, cocoapodsCache_1.restoreCocoapodsCacheArchiveAsync)({ archivePath, workingDirectory });
206
+ const hitType = matchedKey === key ? 'direct_hit' : 'prefix_match';
207
+ logger.info(`CocoaPods cache restored to ${podsDirectory} (${hitType === 'direct_hit' ? 'direct hit' : 'prefix match'})`);
208
+ datadog_1.Datadog.log(`CocoaPods cache restored (${hitType})`, {
209
+ event: 'cocoapods_cache_restored',
210
+ cache_hit_type: hitType,
211
+ });
212
+ }
213
+ catch (err) {
214
+ if (err instanceof turtleFetch_1.TurtleFetchError && err.response?.status === 404) {
215
+ logger.info('No CocoaPods cache found for this key');
216
+ }
217
+ else {
218
+ logger.warn('Failed to restore CocoaPods cache: ', err);
219
+ }
220
+ }
221
+ }
170
222
  async function restoreGradleCacheAsync({ logger, workingDirectory, env, secrets, }) {
171
223
  if (env.EAS_GRADLE_CACHE !== '1') {
172
224
  return;
@@ -2,6 +2,14 @@ import { bunyan } from '@expo/logger';
2
2
  import { BuildFunction } from '@expo/steps';
3
3
  import { CcacheBuildTarget } from '../../utils/cacheKey';
4
4
  export declare function createSaveBuildCacheFunction(evictUsedBefore: Date): BuildFunction;
5
+ export declare function saveCocoapodsCacheAsync({ logger, workingDirectory, env, secrets, }: {
6
+ logger: bunyan;
7
+ workingDirectory: string;
8
+ env: Record<string, string | undefined>;
9
+ secrets?: {
10
+ robotAccessToken?: string;
11
+ };
12
+ }): Promise<void>;
5
13
  export declare function saveCcacheAsync({ logger, workingDirectory, target, evictUsedBefore, env, secrets, }: {
6
14
  logger: bunyan;
7
15
  workingDirectory: string;
@@ -4,6 +4,7 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
4
4
  };
5
5
  Object.defineProperty(exports, "__esModule", { value: true });
6
6
  exports.createSaveBuildCacheFunction = createSaveBuildCacheFunction;
7
+ exports.saveCocoapodsCacheAsync = saveCocoapodsCacheAsync;
7
8
  exports.saveCcacheAsync = saveCcacheAsync;
8
9
  exports.saveGradleCacheAsync = saveGradleCacheAsync;
9
10
  const eas_build_job_1 = require("@expo/eas-build-job");
@@ -16,6 +17,7 @@ const path_1 = __importDefault(require("path"));
16
17
  const saveCache_1 = require("./saveCache");
17
18
  const artifacts_1 = require("../../utils/artifacts");
18
19
  const cacheKey_1 = require("../../utils/cacheKey");
20
+ const cocoapodsCache_1 = require("../../utils/cocoapodsCache");
19
21
  const gradleCacheKey_1 = require("../../utils/gradleCacheKey");
20
22
  function createSaveBuildCacheFunction(evictUsedBefore) {
21
23
  return new steps_1.BuildFunction({
@@ -67,9 +69,59 @@ function createSaveBuildCacheFunction(evictUsedBefore) {
67
69
  secrets: stepCtx.global.staticContext.job.secrets,
68
70
  });
69
71
  }
72
+ else {
73
+ await saveCocoapodsCacheAsync({
74
+ logger,
75
+ workingDirectory,
76
+ env,
77
+ secrets: stepCtx.global.staticContext.job.secrets,
78
+ });
79
+ }
70
80
  },
71
81
  });
72
82
  }
83
+ async function saveCocoapodsCacheAsync({ logger, workingDirectory, env, secrets, }) {
84
+ if (env.EAS_PODS_CACHE !== '1') {
85
+ return;
86
+ }
87
+ const { podsDirectory, podfileLockPath } = (0, cocoapodsCache_1.getCocoapodsCachePaths)(workingDirectory);
88
+ try {
89
+ await Promise.all([fs_1.default.promises.access(podsDirectory), fs_1.default.promises.access(podfileLockPath)]);
90
+ }
91
+ catch {
92
+ logger.warn('No CocoaPods installation found, skipping cache save');
93
+ return;
94
+ }
95
+ try {
96
+ const { stdout } = await (0, steps_1.spawnAsync)('pod', ['--version'], {
97
+ env,
98
+ stdio: 'pipe',
99
+ });
100
+ const { key } = await (0, cocoapodsCache_1.resolveCocoapodsCacheKeyAsync)(workingDirectory, stdout);
101
+ logger.info(`Saving CocoaPods cache key: ${key}`);
102
+ const jobId = (0, nullthrows_1.default)(env.EAS_BUILD_ID, 'EAS_BUILD_ID is not set');
103
+ const robotAccessToken = (0, nullthrows_1.default)(secrets?.robotAccessToken, 'Robot access token is required for cache operations');
104
+ const expoApiServerURL = (0, nullthrows_1.default)(env.__API_SERVER_URL, '__API_SERVER_URL is not set');
105
+ logger.info('Compressing CocoaPods cache...');
106
+ const { archivePath } = await (0, cocoapodsCache_1.compressCocoapodsCacheAsync)({ workingDirectory });
107
+ const { size } = await fs_1.default.promises.stat(archivePath);
108
+ logger.info(`CocoaPods cache archive size: ${(0, artifacts_1.formatBytes)(size)}`);
109
+ await (0, saveCache_1.uploadCacheAsync)({
110
+ logger,
111
+ jobId,
112
+ expoApiServerURL,
113
+ robotAccessToken,
114
+ archivePath,
115
+ key,
116
+ paths: [podsDirectory],
117
+ size,
118
+ platform: eas_build_job_1.Platform.IOS,
119
+ });
120
+ }
121
+ catch (err) {
122
+ logger.error({ err }, 'Failed to save CocoaPods cache');
123
+ }
124
+ }
73
125
  async function saveCcacheAsync({ logger, workingDirectory, target, evictUsedBefore, env, secrets, }) {
74
126
  const enabled = env.EAS_SAVE_CACHE === '1' || (env.EAS_USE_CACHE === '1' && env.EAS_SAVE_CACHE !== '0');
75
127
  if (!enabled) {
@@ -2,7 +2,7 @@ import { Platform } from '@expo/eas-build-job';
2
2
  import { bunyan } from '@expo/logger';
3
3
  import { BuildFunction } from '@expo/steps';
4
4
  export declare function createSaveCacheFunction(): BuildFunction;
5
- export declare function uploadCacheAsync({ logger, jobId, expoApiServerURL, robotAccessToken, paths, key, archivePath, size, platform, }: {
5
+ export declare function uploadCacheAsync({ logger, jobId, expoApiServerURL, robotAccessToken, paths, key, archivePath, size, platform, force, }: {
6
6
  logger: bunyan;
7
7
  jobId: string;
8
8
  expoApiServerURL: string;
@@ -12,6 +12,7 @@ export declare function uploadCacheAsync({ logger, jobId, expoApiServerURL, robo
12
12
  archivePath: string;
13
13
  size: number;
14
14
  platform: Platform | undefined;
15
+ force?: boolean;
15
16
  }): Promise<void>;
16
17
  export declare function uploadPublicCacheAsync({ logger, jobId, expoApiServerURL, robotAccessToken, paths, key, archivePath, size, }: {
17
18
  logger: bunyan;
@@ -122,7 +122,7 @@ function createSaveCacheFunction() {
122
122
  },
123
123
  });
124
124
  }
125
- async function uploadCacheAsync({ logger, jobId, expoApiServerURL, robotAccessToken, paths, key, archivePath, size, platform, }) {
125
+ async function uploadCacheAsync({ logger, jobId, expoApiServerURL, robotAccessToken, paths, key, archivePath, size, platform, force = false, }) {
126
126
  const routerURL = platform
127
127
  ? 'v2/turtle-builds/caches/upload-sessions'
128
128
  : 'v2/turtle-caches/upload-sessions';
@@ -135,12 +135,14 @@ async function uploadCacheAsync({ logger, jobId, expoApiServerURL, robotAccessTo
135
135
  key,
136
136
  version: (0, cache_1.getCacheVersion)(paths),
137
137
  size,
138
+ force,
138
139
  })
139
140
  : JSON.stringify({
140
141
  jobRunId: jobId,
141
142
  key,
142
143
  version: (0, cache_1.getCacheVersion)(paths),
143
144
  size,
145
+ force,
144
146
  }),
145
147
  headers: {
146
148
  Authorization: `Bearer ${robotAccessToken}`,
@@ -71,9 +71,9 @@ const turtleFetch_1 = require("../../utils/turtleFetch");
71
71
  const XCODE_DEVELOPER_DIR = '/Applications/Xcode.app/Contents/Developer';
72
72
  const SERVE_SIM_PACKAGE_NAME = '@expo/serve-sim';
73
73
  const SERVE_SIM_HOST = '127.0.0.1';
74
- const SERVE_SIM_MAX_DIMENSION = '1280';
74
+ const SERVE_SIM_MAX_DIMENSION = '960';
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,19 @@
1
+ export declare const COCOAPODS_CACHE_KEY_PREFIX = "ios-pods-";
2
+ export declare function getCocoapodsCachePaths(workingDirectory: string): {
3
+ iosDirectory: string;
4
+ podsDirectory: string;
5
+ podfileLockPath: string;
6
+ };
7
+ export declare function resolveCocoapodsCacheKeyAsync(workingDirectory: string, cocoapodsVersion: string): Promise<{
8
+ key: string;
9
+ keyPrefix: string;
10
+ }>;
11
+ export declare function compressCocoapodsCacheAsync({ workingDirectory, }: {
12
+ workingDirectory: string;
13
+ }): Promise<{
14
+ archivePath: string;
15
+ }>;
16
+ export declare function restoreCocoapodsCacheArchiveAsync({ archivePath, workingDirectory, }: {
17
+ archivePath: string;
18
+ workingDirectory: string;
19
+ }): Promise<void>;
@@ -0,0 +1,111 @@
1
+ "use strict";
2
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
+ if (k2 === undefined) k2 = k;
4
+ var desc = Object.getOwnPropertyDescriptor(m, k);
5
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
+ desc = { enumerable: true, get: function() { return m[k]; } };
7
+ }
8
+ Object.defineProperty(o, k2, desc);
9
+ }) : (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ o[k2] = m[k];
12
+ }));
13
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
14
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
15
+ }) : function(o, v) {
16
+ o["default"] = v;
17
+ });
18
+ var __importStar = (this && this.__importStar) || (function () {
19
+ var ownKeys = function(o) {
20
+ ownKeys = Object.getOwnPropertyNames || function (o) {
21
+ var ar = [];
22
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
23
+ return ar;
24
+ };
25
+ return ownKeys(o);
26
+ };
27
+ return function (mod) {
28
+ if (mod && mod.__esModule) return mod;
29
+ var result = {};
30
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
31
+ __setModuleDefault(result, mod);
32
+ return result;
33
+ };
34
+ })();
35
+ var __importDefault = (this && this.__importDefault) || function (mod) {
36
+ return (mod && mod.__esModule) ? mod : { "default": mod };
37
+ };
38
+ Object.defineProperty(exports, "__esModule", { value: true });
39
+ exports.COCOAPODS_CACHE_KEY_PREFIX = void 0;
40
+ exports.getCocoapodsCachePaths = getCocoapodsCachePaths;
41
+ exports.resolveCocoapodsCacheKeyAsync = resolveCocoapodsCacheKeyAsync;
42
+ exports.compressCocoapodsCacheAsync = compressCocoapodsCacheAsync;
43
+ exports.restoreCocoapodsCacheArchiveAsync = restoreCocoapodsCacheArchiveAsync;
44
+ const steps_1 = require("@expo/steps");
45
+ const fs_1 = __importDefault(require("fs"));
46
+ const os_1 = __importDefault(require("os"));
47
+ const path_1 = __importDefault(require("path"));
48
+ const tar = __importStar(require("tar"));
49
+ exports.COCOAPODS_CACHE_KEY_PREFIX = 'ios-pods-';
50
+ const PODS_DIRECTORY_NAME = 'Pods';
51
+ const PODFILE_LOCK_NAME = 'Podfile.lock';
52
+ function getCocoapodsCachePaths(workingDirectory) {
53
+ const iosDirectory = path_1.default.join(workingDirectory, 'ios');
54
+ return {
55
+ iosDirectory,
56
+ podsDirectory: path_1.default.join(iosDirectory, PODS_DIRECTORY_NAME),
57
+ podfileLockPath: path_1.default.join(iosDirectory, PODFILE_LOCK_NAME),
58
+ };
59
+ }
60
+ async function resolveCocoapodsCacheKeyAsync(workingDirectory, cocoapodsVersion) {
61
+ const normalizedVersion = cocoapodsVersion.trim();
62
+ if (!normalizedVersion) {
63
+ throw new Error('Failed to determine CocoaPods version');
64
+ }
65
+ const keyPrefix = `${exports.COCOAPODS_CACHE_KEY_PREFIX}${normalizedVersion}-`;
66
+ const { podfileLockPath } = getCocoapodsCachePaths(workingDirectory);
67
+ try {
68
+ await fs_1.default.promises.access(podfileLockPath);
69
+ }
70
+ catch {
71
+ return { key: keyPrefix, keyPrefix };
72
+ }
73
+ return {
74
+ key: `${keyPrefix}${(0, steps_1.hashFiles)([podfileLockPath])}`,
75
+ keyPrefix,
76
+ };
77
+ }
78
+ async function compressCocoapodsCacheAsync({ workingDirectory, }) {
79
+ const { iosDirectory, podsDirectory } = getCocoapodsCachePaths(workingDirectory);
80
+ await fs_1.default.promises.access(podsDirectory);
81
+ const archiveDestinationDirectory = await fs_1.default.promises.mkdtemp(path_1.default.join(os_1.default.tmpdir(), 'save-cocoapods-cache-'));
82
+ const archivePath = path_1.default.join(archiveDestinationDirectory, 'cache.tar.gz');
83
+ await tar.create({
84
+ file: archivePath,
85
+ cwd: iosDirectory,
86
+ gzip: true,
87
+ }, [PODS_DIRECTORY_NAME]);
88
+ return { archivePath };
89
+ }
90
+ async function restoreCocoapodsCacheArchiveAsync({ archivePath, workingDirectory, }) {
91
+ const { iosDirectory, podsDirectory } = getCocoapodsCachePaths(workingDirectory);
92
+ await fs_1.default.promises.mkdir(iosDirectory, { recursive: true });
93
+ const temporaryRestoreDirectory = await fs_1.default.promises.mkdtemp(path_1.default.join(iosDirectory, '.eas-pods-cache-'));
94
+ try {
95
+ await tar.extract({
96
+ file: archivePath,
97
+ cwd: temporaryRestoreDirectory,
98
+ filter: entryPath => entryPath === PODS_DIRECTORY_NAME || entryPath.startsWith(`${PODS_DIRECTORY_NAME}/`),
99
+ });
100
+ const restoredPodsDirectory = path_1.default.join(temporaryRestoreDirectory, PODS_DIRECTORY_NAME);
101
+ const restoredPodsStat = await fs_1.default.promises.stat(restoredPodsDirectory);
102
+ if (!restoredPodsStat.isDirectory()) {
103
+ throw new Error('CocoaPods cache archive does not contain a Pods directory');
104
+ }
105
+ await fs_1.default.promises.rm(podsDirectory, { recursive: true, force: true });
106
+ await fs_1.default.promises.rename(restoredPodsDirectory, podsDirectory);
107
+ }
108
+ finally {
109
+ await fs_1.default.promises.rm(temporaryRestoreDirectory, { recursive: true, force: true });
110
+ }
111
+ }
@@ -11,6 +11,7 @@ const eas_build_job_1 = require("@expo/eas-build-job");
11
11
  const packageManager_1 = require("./packageManager");
12
12
  const sentry_1 = require("../sentry");
13
13
  let cachedEasCliVersionsPromise;
14
+ const NPX_ENV = { NPM_CONFIG_MIN_RELEASE_AGE: '0' };
14
15
  /**
15
16
  * Resolves the `eas-cli` versions to install for staging/production. Prefers the
16
17
  * versions committed to `cli-versions.json` (fetched from expo/eas-cli); on any
@@ -66,7 +67,7 @@ async function resolveEasCommandPrefixAndEnvAsync() {
66
67
  return {
67
68
  cmd: 'npx',
68
69
  args: [...npxArgsPrefix, `eas-cli@${versions.STAGING}`],
69
- extraEnv: {},
70
+ extraEnv: NPX_ENV,
70
71
  };
71
72
  }
72
73
  else if (process.env.ENVIRONMENT === 'staging') {
@@ -74,7 +75,7 @@ async function resolveEasCommandPrefixAndEnvAsync() {
74
75
  return {
75
76
  cmd: 'npx',
76
77
  args: [...npxArgsPrefix, `eas-cli@${versions.STAGING}`],
77
- extraEnv: { EXPO_STAGING: '1' },
78
+ extraEnv: { ...NPX_ENV, EXPO_STAGING: '1' },
78
79
  };
79
80
  }
80
81
  else {
@@ -82,7 +83,7 @@ async function resolveEasCommandPrefixAndEnvAsync() {
82
83
  return {
83
84
  cmd: 'npx',
84
85
  args: [...npxArgsPrefix, `eas-cli@${versions.PRODUCTION}`],
85
- extraEnv: {},
86
+ extraEnv: NPX_ENV,
86
87
  };
87
88
  }
88
89
  }
@@ -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": "23.0.0",
4
4
  "bugs": "https://github.com/expo/eas-cli/issues",
5
5
  "license": "BUSL-1.1",
6
6
  "author": "Expo <support@expo.io>",
@@ -39,17 +39,17 @@
39
39
  "dependencies": {
40
40
  "@expo/config": "55.0.10",
41
41
  "@expo/config-plugins": "55.0.7",
42
- "@expo/downloader": "22.0.0",
43
- "@expo/eas-build-job": "22.0.0",
42
+ "@expo/downloader": "23.0.0",
43
+ "@expo/eas-build-job": "23.0.0",
44
44
  "@expo/env": "^0.4.0",
45
- "@expo/logger": "22.0.0",
45
+ "@expo/logger": "23.0.0",
46
46
  "@expo/package-manager": "1.9.10",
47
47
  "@expo/plist": "^0.3.5",
48
48
  "@expo/results": "^1.0.0",
49
49
  "@expo/spawn-async": "1.7.2",
50
- "@expo/steps": "22.4.0",
51
- "@expo/template-file": "22.0.0",
52
- "@expo/turtle-spawn": "22.0.0",
50
+ "@expo/steps": "23.0.0",
51
+ "@expo/template-file": "23.0.0",
52
+ "@expo/turtle-spawn": "23.0.0",
53
53
  "@expo/xcpretty": "^4.3.1",
54
54
  "@ngrok/ngrok": "1.7.0",
55
55
  "@sentry/node": "7.77.0",
@@ -103,5 +103,5 @@
103
103
  "typescript": "^5.5.4",
104
104
  "uuid": "^9.0.1"
105
105
  },
106
- "gitHead": "3f74ea369d329a363fe16c9f90c8be74cb598777"
106
+ "gitHead": "2e2aaa4ac902b653934d7dccdc0c9b72cb7b9cac"
107
107
  }