1import { Command } from '@expo/commander';
2import { Config, Versions } from '@expo/xdl';
3import chalk from 'chalk';
4import inquirer from 'inquirer';
5import * as jsondiffpatch from 'jsondiffpatch';
6import cloneDeep from 'lodash/cloneDeep';
7import set from 'lodash/set';
8import unset from 'lodash/unset';
9import semver from 'semver';
10
11import { STAGING_API_HOST, PRODUCTION_API_HOST } from '../Constants';
12import { sleepAsync } from '../Utils';
13
14type ActionOptions = {
15  sdkVersion: string;
16  root: boolean;
17  deprecated?: boolean;
18  releaseNoteUrl?: string;
19  key?: string;
20  value?: any;
21  delete: boolean;
22  deleteSdk: boolean;
23  reset: boolean;
24};
25
26async function chooseSdkVersionAsync(sdkVersions: string[]): Promise<string> {
27  const { sdkVersion } = await inquirer.prompt<{ sdkVersion: string }>([
28    {
29      type: 'list',
30      name: 'sdkVersion',
31      default: sdkVersions[0],
32      choices: sdkVersions,
33    },
34  ]);
35  return sdkVersion;
36}
37
38async function askForCorrectnessAsync(): Promise<boolean> {
39  const { isCorrect } = await inquirer.prompt<{ isCorrect: boolean }>([
40    {
41      type: 'confirm',
42      name: 'isCorrect',
43      message: `Does this look correct? Type \`y\` or press enter to update ${chalk.green(
44        'staging'
45      )} config.`,
46      default: true,
47    },
48  ]);
49  return isCorrect;
50}
51
52function setConfigValueForKey(config: object, key: string, value: any): void {
53  if (value === undefined) {
54    console.log(`Deleting ${chalk.yellow(key)} config key ...`);
55    unset(config, key);
56  } else {
57    console.log(`Changing ${chalk.yellow(key)} config key ...`);
58    set(config, key, value);
59  }
60}
61
62async function applyChangesToStagingAsync(delta: any, previousVersions: any, newVersions: any) {
63  if (!delta) {
64    console.log(chalk.yellow('There are no changes to apply in the configuration.'));
65    return;
66  }
67
68  console.log(
69    `\nHere is the diff of changes to apply on ${chalk.green('staging')} version config:`
70  );
71  console.log(jsondiffpatch.formatters.console.format(delta!, previousVersions));
72
73  const isCorrect = await askForCorrectnessAsync();
74
75  if (isCorrect) {
76    // Save new configuration.
77    try {
78      await Versions.setVersionsAsync(newVersions);
79    } catch (error) {
80      console.error(error);
81    }
82
83    console.log(
84      chalk.green('\nSuccessfully updated staging config. You can check it out on'),
85      chalk.blue(`https://${STAGING_API_HOST}/--/api/v2/versions`)
86    );
87  } else {
88    console.log(chalk.yellow('Canceled'));
89  }
90}
91
92async function resetStagingConfigurationAsync() {
93  // Get current production config.
94  Config.api.host = PRODUCTION_API_HOST;
95  const productionVersions = await Versions.versionsAsync();
96
97  // Wait for the cache to invalidate.
98  await sleepAsync(10);
99
100  // Get current staging config.
101  Config.api.host = STAGING_API_HOST;
102  const stagingVersions = await Versions.versionsAsync();
103
104  // Calculate the diff between them.
105  const delta = jsondiffpatch.diff(stagingVersions, productionVersions);
106
107  // Reset changes (if any) on staging.
108  await applyChangesToStagingAsync(delta, stagingVersions, productionVersions);
109}
110
111async function applyChangesToRootAsync(options: ActionOptions, versions: any) {
112  const newVersions = cloneDeep(versions);
113  if (options.key) {
114    if (!('value' in options) && !options.delete) {
115      console.log(chalk.red('`--key` flag requires `--value` or `--delete` flag.'));
116      return;
117    }
118    setConfigValueForKey(newVersions, options.key, options.delete ? undefined : options.value);
119  }
120
121  const delta = jsondiffpatch.diff(versions, newVersions);
122
123  await applyChangesToStagingAsync(delta, versions, newVersions);
124}
125
126async function applyChangesToSDKVersionAsync(options: ActionOptions, versions: any) {
127  const sdkVersions = Object.keys(versions.sdkVersions).sort(semver.rcompare);
128  const sdkVersion = options.sdkVersion || (await chooseSdkVersionAsync(sdkVersions));
129  const containsSdk = sdkVersions.includes(sdkVersion);
130
131  if (!semver.valid(sdkVersion)) {
132    console.error(chalk.red(`Provided SDK version ${chalk.cyan(sdkVersion)} is invalid.`));
133    return;
134  }
135  if (!containsSdk) {
136    const { addNewSdk } = await inquirer.prompt<{ addNewSdk: boolean }>([
137      {
138        type: 'confirm',
139        name: 'addNewSdk',
140        message: `Configuration for SDK ${chalk.cyan(
141          sdkVersion
142        )} doesn't exist. Do you want to initialize it?`,
143        default: true,
144      },
145    ]);
146    if (!addNewSdk) {
147      console.log(chalk.yellow('Canceled'));
148      return;
149    }
150  }
151
152  // If SDK is already there, make a deep clone of the sdkVersion config so we can calculate a diff later.
153  const sdkVersionConfig = containsSdk ? cloneDeep(versions.sdkVersions[sdkVersion]) : {};
154
155  console.log(`\nUsing ${chalk.blue(STAGING_API_HOST)} host ...`);
156  console.log(`Using SDK ${chalk.cyan(sdkVersion)} ...`);
157
158  if ('deprecated' in options) {
159    setConfigValueForKey(sdkVersionConfig, 'isDeprecated', !!options.deprecated);
160  }
161  if ('releaseNoteUrl' in options && typeof options.releaseNoteUrl === 'string') {
162    setConfigValueForKey(sdkVersionConfig, 'releaseNoteUrl', options.releaseNoteUrl);
163  }
164  if (options.key) {
165    if (!('value' in options) && !options.delete) {
166      console.log(chalk.red('`--key` flag requires `--value` or `--delete` flag.'));
167      return;
168    }
169    setConfigValueForKey(sdkVersionConfig, options.key, options.delete ? undefined : options.value);
170  }
171
172  const newVersions = {
173    ...versions,
174    sdkVersions: {
175      ...versions.sdkVersions,
176      [sdkVersion]: sdkVersionConfig,
177    },
178  };
179
180  if (options.deleteSdk) {
181    delete newVersions.sdkVersions[sdkVersion];
182  }
183
184  const delta = jsondiffpatch.diff(
185    versions.sdkVersions[sdkVersion],
186    newVersions.sdkVersions[sdkVersion]
187  );
188
189  await applyChangesToStagingAsync(delta, versions.sdkVersions[sdkVersion], newVersions);
190}
191
192async function action(options: ActionOptions) {
193  if (options.reset) {
194    await resetStagingConfigurationAsync();
195    return;
196  }
197
198  Config.api.host = STAGING_API_HOST;
199  const versions = await Versions.versionsAsync();
200
201  if (options.root) {
202    await applyChangesToRootAsync(options, versions);
203  } else {
204    await applyChangesToSDKVersionAsync(options, versions);
205  }
206}
207
208export default (program: Command) => {
209  program
210    .command('update-versions-endpoint')
211    .alias('update-versions')
212    .description(
213      `Updates SDK configuration under ${chalk.blue('https://staging.exp.host/--/api/v2/versions')}`
214    )
215    .option(
216      '-s, --sdkVersion [string]',
217      'SDK version to update. Can be chosen from the list if not provided.'
218    )
219    .option(
220      '--root',
221      'Modify a key at the root of the versions config rather than a specific SDK version.',
222      false
223    )
224    .option('-d, --deprecated [boolean]', 'Sets chosen SDK version as deprecated.')
225    .option('-r, --release-note-url [string]', 'URL pointing to the release blog post.')
226    .option('-k, --key [string]', 'A custom, dotted key that you want to set in the configuration.')
227    .option('-v, --value [any]', 'Value for the custom key to be set in the configuration.')
228    .option('--delete', 'Deletes config entry under key specified by `--key` flag.', false)
229    .option(
230      '--delete-sdk',
231      'Deletes configuration for SDK specified by `--sdkVersion` flag.',
232      false
233    )
234    .option('--reset', 'Resets changes on staging to the state from production.', false)
235    .asyncAction(action);
236};
237