1import { Command } from '@expo/commander';
2import JsonFile from '@expo/json-file';
3import chalk from 'chalk';
4import inquirer from 'inquirer';
5import fetch from 'node-fetch';
6import path from 'path';
7import semver from 'semver';
8
9import { EXPO_DIR, LOCAL_API_HOST, STAGING_API_HOST, PRODUCTION_API_HOST } from '../Constants';
10import logger from '../Logger';
11
12type ActionOptions = {
13  env: string;
14};
15
16type Env = 'local' | 'staging' | 'production';
17type BundledNativeModules = Record<string, string>;
18interface NativeModule {
19  npmPackage: string;
20  versionRange: string;
21}
22type BundledNativeModulesList = NativeModule[];
23interface SyncPayload {
24  nativeModules: BundledNativeModulesList;
25}
26interface GetBundledNativeModulesResult {
27  data: BundledNativeModulesList;
28}
29
30const EXPO_PACKAGE_PATH = path.join(EXPO_DIR, 'packages/expo');
31
32async function main(options: ActionOptions) {
33  logger.info('\nSyncing bundledNativeModules.json with www...');
34
35  const env = resolveEnv(options);
36  await confirmEnvAsync(env);
37  const secret = await resolveSecretAsync();
38
39  const sdkVersion = await resolveTargetSdkVersionAsync();
40  const bundledNativeModules = await readBundledNativeModulesAsync();
41  const syncPayload = prepareSyncPayload(bundledNativeModules);
42
43  const currentBundledNativeModules = await getCurrentBundledNativeModules(env, sdkVersion);
44  await compareAndConfirmAsync(currentBundledNativeModules, syncPayload.nativeModules);
45
46  await syncModulesAsync({ env, secret }, sdkVersion, syncPayload);
47  logger.success(`Successfully synced the modules for SDK ${sdkVersion}!`);
48}
49
50function resolveEnv({ env }: ActionOptions): Env {
51  if (env === 'staging' || env === 'production' || env === 'local') {
52    return env;
53  } else {
54    throw new Error(`Unknown env name: ${env}`);
55  }
56}
57
58async function confirmEnvAsync(env: Env): Promise<void> {
59  const { confirmed } = await inquirer.prompt<{ confirmed: boolean }>([
60    {
61      type: 'confirm',
62      name: 'confirmed',
63      message: `Are you sure to run this script against the ${chalk.green(env)} environment?`,
64      default: true,
65    },
66  ]);
67  if (!confirmed) {
68    logger.info('No worries, come back soon!');
69    process.exit(1);
70  }
71}
72
73async function resolveSecretAsync(): Promise<string> {
74  if (process.env.EXPO_SDK_NATIVE_MODULES_SECRET) {
75    return process.env.EXPO_SDK_NATIVE_MODULES_SECRET;
76  }
77
78  logger.info(
79    `We need the secret to authenticate you with Expo servers.\nPlease set the ${chalk.green(
80      'EXPO_SDK_NATIVE_MODULES_SECRET'
81    )} env var if you want to skip the prompt in the future.`
82  );
83
84  const { secret } = await inquirer.prompt<{ secret: string }>([
85    {
86      type: 'password',
87      name: 'secret',
88      message: 'Secret:',
89      validate: (val) => (val ? true : 'The secret cannot be empty'),
90    },
91  ]);
92  return secret;
93}
94
95async function resolveTargetSdkVersionAsync(): Promise<string> {
96  const expoPackageJsonPath = path.join(EXPO_PACKAGE_PATH, 'package.json');
97  const contents = await JsonFile.readAsync<Record<string, string>>(expoPackageJsonPath);
98  const majorVersion = semver.major(contents.version);
99
100  const sdkVersion = `${majorVersion}.0.0`;
101
102  const { confirmed } = await inquirer.prompt<{ confirmed: boolean }>([
103    {
104      type: 'confirm',
105      name: 'confirmed',
106      message: `Do you want to sync bundledNativeModules.json for ${chalk.green(
107        `SDK ${sdkVersion}`
108      )}?`,
109      default: true,
110    },
111  ]);
112
113  if (!confirmed) {
114    logger.info('No worries, come back soon!');
115    process.exit(1);
116  } else {
117    return sdkVersion;
118  }
119}
120
121async function readBundledNativeModulesAsync(): Promise<BundledNativeModules> {
122  const bundledNativeModulesPath = path.join(EXPO_PACKAGE_PATH, 'bundledNativeModules.json');
123  return await JsonFile.readAsync<BundledNativeModules>(bundledNativeModulesPath);
124}
125
126async function getCurrentBundledNativeModules(
127  env: Env,
128  sdkVersion: string
129): Promise<BundledNativeModulesList> {
130  const baseApiUrl = resolveBaseApiUrl(env);
131  const result = await fetch(`${baseApiUrl}/--/api/v2/sdks/${sdkVersion}/native-modules`);
132  const resultJson: GetBundledNativeModulesResult = await result.json();
133  return resultJson.data;
134}
135
136async function compareAndConfirmAsync(
137  current: BundledNativeModulesList,
138  next: BundledNativeModulesList
139): Promise<void> {
140  const currentMap = current.reduce((acc, i) => {
141    acc[i.npmPackage] = i;
142    return acc;
143  }, {} as Record<string, NativeModule>);
144
145  logger.info('Changes:');
146  let hasChanges = false;
147  for (const { npmPackage, versionRange } of next) {
148    if (versionRange !== currentMap[npmPackage]?.versionRange) {
149      hasChanges = true;
150      logger.info(
151        ` - ${npmPackage}: ${chalk.red(
152          currentMap[npmPackage]?.versionRange ?? '(none)'
153        )} -> ${chalk.green(versionRange)}`
154      );
155    }
156  }
157  if (!hasChanges) {
158    logger.info(chalk.gray('(no changes found)'));
159    // there's no need to proceed with the script
160    process.exit(0);
161  }
162
163  const { confirmed } = await inquirer.prompt<{ confirmed: boolean }>([
164    {
165      type: 'confirm',
166      name: 'confirmed',
167      message: `Are you sure to make these changes?`,
168      default: true,
169    },
170  ]);
171  if (!confirmed) {
172    logger.info('No worries, come back soon!');
173    process.exit(1);
174  }
175}
176
177async function syncModulesAsync(
178  { env, secret }: { env: Env; secret: string },
179  sdkVersion: string,
180  payload: SyncPayload
181): Promise<void> {
182  const baseApiUrl = resolveBaseApiUrl(env);
183  const result = await fetch(`${baseApiUrl}/--/api/v2/sdks/${sdkVersion}/native-modules/sync`, {
184    method: 'put',
185    body: JSON.stringify(payload),
186    headers: {
187      'Content-Type': 'application/json',
188      'expo-sdk-native-modules-secret': secret,
189    },
190  });
191
192  if (result.status !== 200) {
193    throw new Error(`Failed to sync the modules: ${await result.text()}`);
194  }
195}
196
197function resolveBaseApiUrl(env: Env): string {
198  if (env === 'production') {
199    return `https://${PRODUCTION_API_HOST}`;
200  } else if (env === 'staging') {
201    return `https://${STAGING_API_HOST}`;
202  } else {
203    return `http://${LOCAL_API_HOST}`;
204  }
205}
206
207/**
208 * converts
209 * {
210 *   "expo-ads-admob": "~10.0.4",
211 *   "expo-ads-facebook": "~12.0.4"
212 * }
213 * to
214 * {
215 *   "nativeModules": [
216 *     { "npmPackage": "expo-ads-admob", "versionRange": "~10.0.4" },
217 *     { "npmPackage": "expo-ads-facebook", "versionRange": "~12.0.4" }
218 *   ]
219 * }
220 */
221function prepareSyncPayload(bundledNativeModules: BundledNativeModules): SyncPayload {
222  return {
223    nativeModules: Object.entries(bundledNativeModules).map(([npmPackage, versionRange]) => ({
224      npmPackage,
225      versionRange,
226    })),
227  };
228}
229
230export default (program: Command) => {
231  program
232    .command('sync-bundled-native-modules')
233    .description(
234      'Sync configuration from bundledNativeModules.json to the corresponding API endpoint.'
235    )
236    .alias('sbnm')
237    .option('-e, --env <local|staging|production>', 'www environment', 'staging')
238    .asyncAction(main);
239};
240