1import { Command } from '@expo/commander';
2import JsonFile from '@expo/json-file';
3import {
4  isMultipartPartWithName,
5  parseMultipartMixedResponseAsync,
6} from '@expo/multipart-body-parser';
7import chalk from 'chalk';
8import fs from 'fs/promises';
9import fetch, { Response } from 'node-fetch';
10import nullthrows from 'nullthrows';
11import os from 'os';
12import path from 'path';
13
14import { ANDROID_DIR, IOS_DIR } from '../Constants';
15import { deepCloneObject } from '../Utils';
16import { Directories, EASUpdate } from '../expotools';
17import AppConfig from '../typings/AppConfig';
18
19type ExpoCliStateObject = {
20  auth?: {
21    username?: string;
22  };
23};
24
25const EXPO_HOME_PATH = Directories.getExpoHomeJSDir();
26
27const iosPublishBundlePath = path.join(IOS_DIR, 'Exponent', 'Supporting', 'kernel.ios.bundle');
28const androidPublishBundlePath = path.join(
29  ANDROID_DIR,
30  'app',
31  'src',
32  'main',
33  'assets',
34  'kernel.android.bundle'
35);
36const iosManifestPath = path.join(IOS_DIR, 'Exponent', 'Supporting', 'kernel-manifest.json');
37const androidManifestPath = path.join(
38  ANDROID_DIR,
39  'app',
40  'src',
41  'main',
42  'assets',
43  'kernel-manifest.json'
44);
45
46/**
47 * Returns path to production's expo-cli state file.
48 */
49function getExpoCliStatePath(): string {
50  return path.join(os.homedir(), '.expo/state.json');
51}
52
53/**
54 * Reads expo-cli state file which contains, among other things, session credentials to the account that you're logged in.
55 */
56async function getExpoCliStateAsync(): Promise<ExpoCliStateObject> {
57  return JsonFile.readAsync<ExpoCliStateObject>(getExpoCliStatePath());
58}
59
60/**
61 * Publishes @exponent/home app on EAS Update.
62 */
63async function publishAppAsync({
64  slug,
65  message,
66}: {
67  slug: string;
68  message: string;
69}): Promise<{ createdUpdateGroupId: string }> {
70  console.log(`Publishing ${chalk.green(slug)}...`);
71
72  const result = await EASUpdate.publishProjectWithEasCliAsync(EXPO_HOME_PATH, {
73    branch: 'production',
74    message,
75  });
76
77  console.log(
78    `Done publishing ${chalk.green(slug)}. Update Group ID is: ${chalk.blue(
79      result.createdUpdateGroupId
80    )}`
81  );
82
83  return result;
84}
85
86interface Manifest {
87  id: string;
88  launchAsset: {
89    key: string;
90    url: string;
91  };
92}
93
94type AssetRequestHeaders = { authorization: string };
95type Extensions = { assetRequestHeaders: { [key: string]: AssetRequestHeaders } };
96
97async function getManifestAndExtensionsAsync(response: Response): Promise<{
98  manifest: Manifest;
99  extensions: Extensions;
100}> {
101  const contentType = response.headers.get('content-type');
102  if (!contentType) {
103    throw new Error('The multipart manifest response is missing the content-type header');
104  }
105
106  const bodyBuffer = await response.arrayBuffer();
107  const multipartParts = await parseMultipartMixedResponseAsync(
108    contentType,
109    Buffer.from(bodyBuffer)
110  );
111
112  const manifestPart = multipartParts.find((part) => isMultipartPartWithName(part, 'manifest'));
113  if (!manifestPart) {
114    throw new Error('The multipart manifest response is missing the manifest part');
115  }
116  const manifest: Manifest = JSON.parse(manifestPart.body);
117
118  const extensionsPart = multipartParts.find((part) => isMultipartPartWithName(part, 'extensions'));
119  if (!extensionsPart) {
120    throw new Error('The multipart manifest response is missing the extensions part');
121  }
122  const extensions: Extensions = JSON.parse(extensionsPart.body);
123
124  return { manifest, extensions };
125}
126
127async function fetchManifestAndBundleAsync(
128  projectId: string,
129  groupId: string,
130  platform: 'ios' | 'android'
131): Promise<void> {
132  const manifestUrl = `https://staging-u.expo.dev/${projectId}/group/${groupId}`;
133  const manifestResponse = await fetch(manifestUrl, {
134    method: 'GET',
135    headers: {
136      accept: 'multipart/mixed',
137      'expo-platform': platform,
138    },
139  });
140  const { manifest, extensions } = await getManifestAndExtensionsAsync(manifestResponse);
141
142  const bundleUrl = manifest.launchAsset.url;
143  const bundleRequestHeaders = nullthrows(
144    extensions?.assetRequestHeaders[manifest.launchAsset.key]
145  );
146
147  const bundleResponse = await fetch(bundleUrl, {
148    method: 'GET',
149    headers: {
150      ...bundleRequestHeaders,
151    },
152  });
153
154  const manifestPath = platform === 'ios' ? iosManifestPath : androidManifestPath;
155  await fs.writeFile(path.resolve(manifestPath), JSON.stringify(manifest));
156
157  const bundlePath = platform === 'ios' ? iosPublishBundlePath : androidPublishBundlePath;
158  await fs.writeFile(path.resolve(bundlePath), await bundleResponse.buffer());
159}
160
161/**
162 * Main action that runs once the command is invoked.
163 */
164async function action(): Promise<void> {
165  console.log('Getting expo-cli state of the current session...');
166  const cliState = await getExpoCliStateAsync();
167  const cliUsername = cliState?.auth?.username;
168  if (cliUsername !== 'exponent') {
169    throw new Error('Must be logged in as `exponent` account to publish');
170  }
171
172  const appJsonFilePath = path.join(EXPO_HOME_PATH, 'app.json');
173
174  const slug = 'home';
175  const owner = 'exponent';
176  const easProjectId = '6b6c6660-df76-11e6-b9b4-59d1587e6774';
177  const easUpdateURL = `https://u.expo.dev/${easProjectId}`;
178
179  const appJsonFile = new JsonFile<AppConfig>(appJsonFilePath);
180  const appJson = await appJsonFile.readAsync();
181
182  if (!appJson.expo.owner) {
183    throw new Error('app.json missing owner');
184  }
185  if (!appJson.expo.extra || !appJson.expo.extra.eas || !appJson.expo.extra.eas.projectId) {
186    throw new Error('app.json missing extra.eas.projectId');
187  }
188  if (!appJson.expo.updates || !appJson.expo.updates.url) {
189    throw new Error('app.json missing updates.url');
190  }
191
192  console.log(`Creating backup of ${chalk.magenta('app.json')} file...`);
193  const appJsonBackup = deepCloneObject<AppConfig>(appJson);
194
195  console.log(`Modifying home's slug to ${chalk.green(slug)}...`);
196  appJson.expo.slug = slug;
197
198  console.log(`Modifying home's owner to ${chalk.green(owner)}...`);
199  appJson.expo.owner = owner;
200
201  console.log(`Modifying home's EAS project ID to ${chalk.green(easProjectId)}...`);
202  appJson.expo.extra.eas.projectId = easProjectId;
203
204  console.log(`Modifying home's update URL to ${chalk.green(easUpdateURL)}...`);
205  appJson.expo.updates.url = easUpdateURL;
206
207  // Save the modified `appJson` to the file so it'll be used as a manifest.
208  await appJsonFile.writeAsync(appJson);
209
210  const createdUpdateGroupId = (
211    await publishAppAsync({ slug, message: `Publish ${appJson.expo.sdkVersion}` })
212  ).createdUpdateGroupId;
213
214  console.log(`Restoring ${chalk.magenta('app.json')} file...`);
215  await appJsonFile.writeAsync(appJsonBackup);
216
217  console.log(`Downloading published manifests and bundles...`);
218  await Promise.all([
219    fetchManifestAndBundleAsync(easProjectId, createdUpdateGroupId, 'ios'),
220    fetchManifestAndBundleAsync(easProjectId, createdUpdateGroupId, 'android'),
221  ]);
222
223  console.log(
224    chalk.yellow(
225      `Finished publishing. Remember to commit changes of the embedded manifests and bundles.`
226    )
227  );
228}
229
230export default (program: Command) => {
231  program
232    .command('publish-prod-home')
233    .alias('pph')
234    .description('Publishes home app for production on EAS Update.')
235    .asyncAction(action);
236};
237