eoas 1.0.1

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.
@@ -0,0 +1,277 @@
1
+ import { Platform } from '@expo/eas-build-job';
2
+ import spawnAsync from '@expo/spawn-async';
3
+ import { Command, Flags } from '@oclif/core';
4
+ import { Config } from '@oclif/core/lib/config';
5
+ import FormData from 'form-data';
6
+ import fs from 'fs-extra';
7
+ import mime from 'mime';
8
+ import fetch from 'node-fetch';
9
+ import path from 'path';
10
+
11
+ import { computeFilesRequests, requestUploadUrls } from '../lib/assets';
12
+ import { getAuthExpoHeaders, retrieveExpoCredentials } from '../lib/auth';
13
+ import {
14
+ RequestedPlatform,
15
+ getExpoConfigUpdateUrl,
16
+ getPrivateExpoConfigAsync,
17
+ getPublicExpoConfigAsync,
18
+ } from '../lib/expoConfig';
19
+ import Log from '../lib/log';
20
+ import { ora } from '../lib/ora';
21
+ import { isExpoInstalled } from '../lib/package';
22
+ import { confirmAsync } from '../lib/prompts';
23
+ import { ensureRepoIsCleanAsync } from '../lib/repo';
24
+ import { resolveRuntimeVersionAsync } from '../lib/runtimeVersion';
25
+ import { resolveVcsClient } from '../lib/vcs';
26
+ import { Client } from '../lib/vcs/vcs';
27
+ import { resolveWorkflowAsync } from '../lib/workflow';
28
+
29
+ export default class Publish extends Command {
30
+ vcsClient: Client;
31
+ constructor(argv: string[], config: Config) {
32
+ super(argv, config);
33
+ this.vcsClient = resolveVcsClient(false);
34
+ }
35
+ static override args = {};
36
+ static override description = 'Publish a new update to the self-hosted update server';
37
+ static override examples = ['<%= config.bin %> <%= command.id %>'];
38
+ static override flags = {
39
+ platform: Flags.string({
40
+ type: 'option',
41
+ options: Object.values(RequestedPlatform),
42
+ default: RequestedPlatform.All,
43
+ required: false,
44
+ }),
45
+ channel: Flags.string({
46
+ description: 'Name of the channel to publish the update to',
47
+ required: true,
48
+ }),
49
+ branch: Flags.string({
50
+ description: 'Name of the branch to point to',
51
+ required: true,
52
+ }),
53
+ nonInteractive: Flags.boolean({
54
+ description: 'Run command in non-interactive mode',
55
+ default: false,
56
+ }),
57
+ };
58
+ private sanitizeFlags(flags: any): {
59
+ platform: RequestedPlatform;
60
+ branch: string;
61
+ nonInteractive: boolean;
62
+ channel: string;
63
+ } {
64
+ return {
65
+ platform: flags.platform,
66
+ branch: flags.branch,
67
+ nonInteractive: flags.nonInteractive,
68
+ channel: flags.channel,
69
+ };
70
+ }
71
+ public async run(): Promise<void> {
72
+ const credentials = retrieveExpoCredentials();
73
+
74
+ if (!credentials.token && !credentials.sessionSecret) {
75
+ Log.error('You are not logged to eas, please run `eas login`');
76
+ return;
77
+ }
78
+ const { flags } = await this.parse(Publish);
79
+ const { platform, nonInteractive, branch, channel } = this.sanitizeFlags(flags);
80
+ if (!branch) {
81
+ Log.error('Branch name is required');
82
+ return;
83
+ }
84
+ if (!channel) {
85
+ Log.error('Channel name is required');
86
+ return;
87
+ }
88
+ await this.vcsClient.ensureRepoExistsAsync();
89
+ await ensureRepoIsCleanAsync(this.vcsClient, nonInteractive);
90
+ const projectDir = process.cwd();
91
+ const hasExpo = isExpoInstalled(projectDir);
92
+ if (!hasExpo) {
93
+ Log.error('Expo is not installed in this project. Please install Expo first.');
94
+ return;
95
+ }
96
+
97
+ const privateConfig = await getPrivateExpoConfigAsync(projectDir, {
98
+ env: {
99
+ RELEASE_CHANNEL: channel,
100
+ },
101
+ });
102
+ const updateUrl = getExpoConfigUpdateUrl(privateConfig);
103
+ if (!updateUrl) {
104
+ Log.error(
105
+ "Update url is not setup in your config. Please run 'eoas init' to setup the update url"
106
+ );
107
+ return;
108
+ }
109
+ let baseUrl: string;
110
+ try {
111
+ const parsedUrl = new URL(updateUrl);
112
+ baseUrl = parsedUrl.origin;
113
+ } catch (e) {
114
+ Log.error('Invalid URL', e);
115
+ return;
116
+ }
117
+ if (!nonInteractive) {
118
+ const confirmed = await confirmAsync({
119
+ message: `Is this the correct URL of your self-hosted update server? ${baseUrl}`,
120
+ name: 'export',
121
+ type: 'confirm',
122
+ });
123
+ if (!confirmed) {
124
+ Log.error('Please run `eoas init` to setup the correct update url');
125
+ }
126
+ }
127
+ const runtimeSpinner = ora('Resolving runtime version').start();
128
+ const runtimeVersions = [
129
+ ...(!platform || platform === RequestedPlatform.All || platform === RequestedPlatform.Ios
130
+ ? [
131
+ (
132
+ await resolveRuntimeVersionAsync({
133
+ exp: privateConfig,
134
+ platform: 'ios',
135
+ workflow: await resolveWorkflowAsync(projectDir, Platform.IOS, this.vcsClient),
136
+ projectDir,
137
+ env: {
138
+ RELEASE_CHANNEL: channel,
139
+ },
140
+ })
141
+ )?.runtimeVersion,
142
+ ]
143
+ : []),
144
+ ...(!platform || platform === RequestedPlatform.All || platform === RequestedPlatform.Android
145
+ ? [
146
+ (
147
+ await resolveRuntimeVersionAsync({
148
+ exp: privateConfig,
149
+ platform: 'android',
150
+ workflow: await resolveWorkflowAsync(projectDir, Platform.ANDROID, this.vcsClient),
151
+ projectDir,
152
+ env: {
153
+ RELEASE_CHANNEL: channel,
154
+ },
155
+ })
156
+ )?.runtimeVersion,
157
+ ]
158
+ : []),
159
+ ].filter(Boolean);
160
+ if (!runtimeVersions.length) {
161
+ runtimeSpinner.fail('Could not resolve runtime versions for the requested platforms');
162
+ Log.error('Could not resolve runtime versions for the requested platforms');
163
+ return;
164
+ }
165
+ runtimeSpinner.succeed('Runtime versions resolved');
166
+
167
+ const exportSpinner = ora("Exporting project's static files").start();
168
+ try {
169
+ await spawnAsync('rm', ['-rf', 'dist'], { cwd: projectDir });
170
+ const { stdout } = await spawnAsync('npx', ['expo', 'export', '--output-dir', 'dist'], {
171
+ cwd: projectDir,
172
+ env: {
173
+ ...process.env,
174
+ EXPO_NO_DOTENV: '1',
175
+ },
176
+ });
177
+ exportSpinner.succeed('Project exported successfully');
178
+ Log.withInfo(stdout);
179
+ } catch {
180
+ exportSpinner.fail('Failed to export the project');
181
+ }
182
+ const publicConfig = await getPublicExpoConfigAsync(projectDir, {
183
+ skipSDKVersionRequirement: true,
184
+ });
185
+ if (!publicConfig) {
186
+ Log.error(
187
+ 'Could not find Expo config in this project. Please make sure you have an Expo config.'
188
+ );
189
+ return;
190
+ }
191
+ // eslint-disable-next-line
192
+ fs.writeJsonSync(path.join(projectDir, 'dist', 'expoConfig.json'), publicConfig, {
193
+ spaces: 2,
194
+ });
195
+ Log.withInfo('expoConfig.json file created in dist directory');
196
+ const uploadFilesSpinner = ora('Uploading files to the server').start();
197
+ const files = computeFilesRequests(projectDir, platform || RequestedPlatform.All);
198
+ if (!files.length) {
199
+ uploadFilesSpinner.fail('No files to upload');
200
+ }
201
+ try {
202
+ const uploadUrls = await Promise.all(
203
+ runtimeVersions.map(runtimeVersion => {
204
+ if (!runtimeVersion) {
205
+ throw new Error('Runtime version is not resolved');
206
+ }
207
+ return requestUploadUrls(
208
+ {
209
+ fileNames: files.map(file => file.path),
210
+ },
211
+ `${baseUrl}/requestUploadUrl/${branch}`,
212
+ credentials,
213
+ runtimeVersion
214
+ );
215
+ })
216
+ );
217
+ const allItems = uploadUrls.flat();
218
+ await Promise.all(
219
+ allItems.map(async itm => {
220
+ const isLocalBucketFileUpload = itm.requestUploadUrl.startsWith(
221
+ `${baseUrl}/uploadLocalFile`
222
+ );
223
+ const formData = new FormData();
224
+ let file: fs.ReadStream;
225
+ try {
226
+ file = fs.createReadStream(path.join(projectDir, 'dist', itm.filePath));
227
+ } catch {
228
+ throw new Error(`Failed to read file ${itm.filePath}`);
229
+ }
230
+ formData.append(itm.fileName, file);
231
+ if (isLocalBucketFileUpload) {
232
+ const response = await fetch(itm.requestUploadUrl, {
233
+ method: 'PUT',
234
+ headers: {
235
+ ...formData.getHeaders(),
236
+ ...getAuthExpoHeaders(credentials),
237
+ },
238
+ body: formData,
239
+ });
240
+ if (!response.ok) {
241
+ Log.error('Failed to upload file', await response.text());
242
+ throw new Error('Failed to upload file');
243
+ }
244
+ file.close();
245
+ return;
246
+ }
247
+ const findFile = files.find(f => f.path === itm.filePath || f.name === itm.fileName);
248
+ if (!findFile) {
249
+ Log.error(`File ${itm.filePath} not found`);
250
+ throw new Error(`File ${itm.filePath} not found`);
251
+ }
252
+ let contentType = mime.getType(findFile.ext);
253
+ if (!contentType) {
254
+ contentType = 'application/octet-stream';
255
+ }
256
+ const buffer = await fs.readFile(path.join(projectDir, 'dist', itm.filePath));
257
+ const response = await fetch(itm.requestUploadUrl, {
258
+ method: 'PUT',
259
+ headers: {
260
+ 'Content-Type': contentType,
261
+ 'Cache-Control': 'max-age=31556926',
262
+ },
263
+ body: buffer,
264
+ });
265
+ if (!response.ok) {
266
+ Log.error('Failed to upload file', await response.text());
267
+ throw new Error('Failed to upload file');
268
+ }
269
+ file.close();
270
+ })
271
+ );
272
+ uploadFilesSpinner.succeed('Files uploaded successfully');
273
+ } catch {
274
+ uploadFilesSpinner.fail('Failed to upload static files');
275
+ }
276
+ }
277
+ }
package/src/index.d.ts ADDED
@@ -0,0 +1,7 @@
1
+ declare module 'better-opn' {
2
+ function open(
3
+ target: string,
4
+ options?: any
5
+ ): Promise<import('child_process').ChildProcess | false>;
6
+ export = open;
7
+ }
@@ -0,0 +1,118 @@
1
+ // This file is partially copied from eas-cli[https://github.com/expo/eas-cli] to ensure consistent user experience across the CLI.
2
+ import { Platform } from '@expo/config';
3
+ import fs from 'fs-extra';
4
+ import Joi from 'joi';
5
+ import fetch from 'node-fetch';
6
+ import path from 'path';
7
+
8
+ import { ExpoCredentials, getAuthExpoHeaders } from './auth';
9
+ import { RequestedPlatform } from './expoConfig';
10
+ import Log from './log';
11
+
12
+ const fileMetadataJoi = Joi.object({
13
+ assets: Joi.array()
14
+ .required()
15
+ .items(Joi.object({ path: Joi.string().required(), ext: Joi.string().required() })),
16
+ bundle: Joi.string().required(),
17
+ }).optional();
18
+ export const MetadataJoi = Joi.object({
19
+ version: Joi.number().required(),
20
+ bundler: Joi.string().required(),
21
+ fileMetadata: Joi.object({
22
+ android: fileMetadataJoi,
23
+ ios: fileMetadataJoi,
24
+ web: fileMetadataJoi,
25
+ }).required(),
26
+ }).required();
27
+
28
+ type Metadata = {
29
+ version: number;
30
+ bundler: 'metro';
31
+ fileMetadata: {
32
+ [key in Platform]: { assets: { path: string; ext: string }[]; bundle: string };
33
+ };
34
+ };
35
+
36
+ interface AssetToUpload {
37
+ path: string;
38
+ name: string;
39
+ ext: string;
40
+ }
41
+
42
+ function loadMetadata(distRoot: string): Metadata {
43
+ // eslint-disable-next-line
44
+ const fileContent = fs.readFileSync(path.join(distRoot, 'metadata.json'), 'utf8');
45
+ let metadata: Metadata;
46
+ try {
47
+ metadata = JSON.parse(fileContent);
48
+ } catch (e: any) {
49
+ Log.error(`Failed to read metadata.json: ${e.message}`);
50
+ throw e;
51
+ }
52
+ const { error } = MetadataJoi.validate(metadata);
53
+ if (error) {
54
+ throw error;
55
+ }
56
+ // Check version and bundler by hand (instead of with Joi) so
57
+ // more informative error messages can be returned.
58
+ if (metadata.version !== 0) {
59
+ throw new Error('Only bundles with metadata version 0 are supported');
60
+ }
61
+ if (metadata.bundler !== 'metro') {
62
+ throw new Error('Only bundles created with Metro are currently supported');
63
+ }
64
+ const platforms = Object.keys(metadata.fileMetadata);
65
+ if (platforms.length === 0) {
66
+ Log.warn('No updates were exported for any platform');
67
+ }
68
+ Log.debug(`Loaded ${platforms.length} platform(s): ${platforms.join(', ')}`);
69
+ return metadata;
70
+ }
71
+
72
+ export function computeFilesRequests(
73
+ projectDir: string,
74
+ requestedPlatform: RequestedPlatform
75
+ ): AssetToUpload[] {
76
+ const metadata = loadMetadata(path.join(projectDir, 'dist'));
77
+ const assets: AssetToUpload[] = [
78
+ { path: 'metadata.json', name: 'metadata.json', ext: 'json' },
79
+ { path: 'expoConfig.json', name: 'expoConfig.json', ext: 'json' },
80
+ ];
81
+ for (const platform of Object.keys(metadata.fileMetadata) as Platform[]) {
82
+ if (requestedPlatform !== RequestedPlatform.All && requestedPlatform !== platform) {
83
+ continue;
84
+ }
85
+ const bundle = metadata.fileMetadata[platform].bundle;
86
+ assets.push({ path: bundle, name: path.basename(bundle), ext: 'hbc' });
87
+ for (const asset of metadata.fileMetadata[platform].assets) {
88
+ assets.push({ path: asset.path, name: path.basename(asset.path), ext: asset.ext });
89
+ }
90
+ }
91
+ return assets;
92
+ }
93
+
94
+ export interface RequestUploadUrlItem {
95
+ requestUploadUrl: string;
96
+ fileName: string;
97
+ filePath: string;
98
+ }
99
+
100
+ export async function requestUploadUrls(
101
+ body: { fileNames: string[] },
102
+ requestUploadUrl: string,
103
+ auth: ExpoCredentials,
104
+ runtimeVersion: string
105
+ ): Promise<RequestUploadUrlItem[]> {
106
+ const response = await fetch(`${requestUploadUrl}?runtimeVersion=${runtimeVersion}`, {
107
+ method: 'POST',
108
+ headers: {
109
+ ...getAuthExpoHeaders(auth),
110
+ 'Content-Type': 'application/json',
111
+ },
112
+ body: JSON.stringify(body),
113
+ });
114
+ if (!response.ok) {
115
+ throw new Error(`Failed to request upload URL`);
116
+ }
117
+ return await response.json();
118
+ }
@@ -0,0 +1,67 @@
1
+ import { homedir } from 'os';
2
+ import path from 'path';
3
+
4
+ export interface ExpoCredentials {
5
+ token?: string;
6
+ sessionSecret?: string;
7
+ }
8
+ type SessionData = {
9
+ sessionSecret: string;
10
+ userId: string;
11
+ username: string;
12
+ currentConnection: 'Username-Password-Authentication' | 'Browser-Flow-Authentication';
13
+ };
14
+
15
+ function dotExpoHomeDirectory(): string {
16
+ const home = homedir();
17
+ if (!home) {
18
+ throw new Error(
19
+ "Can't determine your home directory; make sure your $HOME environment variable is set."
20
+ );
21
+ }
22
+
23
+ let dirPath;
24
+ if (process.env.EXPO_STAGING) {
25
+ dirPath = path.join(home, '.expo-staging');
26
+ } else if (process.env.EXPO_LOCAL) {
27
+ dirPath = path.join(home, '.expo-local');
28
+ } else {
29
+ dirPath = path.join(home, '.expo');
30
+ }
31
+ return dirPath;
32
+ }
33
+
34
+ function getStateJsonPath(): string {
35
+ return path.join(dotExpoHomeDirectory(), 'state.json');
36
+ }
37
+
38
+ function getExpoSessionData(): SessionData | null {
39
+ try {
40
+ const stateJsonPath = getStateJsonPath();
41
+ const stateJson = require(stateJsonPath);
42
+ return stateJson['auth'] || null;
43
+ } catch {
44
+ return null;
45
+ }
46
+ }
47
+
48
+ export function retrieveExpoCredentials(): ExpoCredentials {
49
+ const token = process.env.EXPO_TOKEN;
50
+ const sessionData = getExpoSessionData();
51
+ const sessionSecret = sessionData?.sessionSecret;
52
+ return { token, sessionSecret };
53
+ }
54
+
55
+ export function getAuthExpoHeaders(credentials: ExpoCredentials): Record<string, string> {
56
+ if (credentials.token) {
57
+ return {
58
+ Authorization: `Bearer ${credentials.token}`,
59
+ };
60
+ }
61
+ if (credentials.sessionSecret) {
62
+ return {
63
+ 'expo-session': credentials.sessionSecret,
64
+ };
65
+ }
66
+ return {};
67
+ }