1import chalk from 'chalk';
2import semver from 'semver';
3import set from 'lodash/set';
4import inquirer from 'inquirer';
5import unset from 'lodash/unset';
6import cloneDeep from 'lodash/cloneDeep';
7import { Config, Versions } from '@expo/xdl';
8import * as jsondiffpatch from 'jsondiffpatch';
9import { Command } from '@expo/commander';
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(
119      newVersions,
120      options.key,
121      options.delete ? undefined : options.value
122    );
123  }
124
125  const delta = jsondiffpatch.diff(versions, newVersions);
126
127  await applyChangesToStagingAsync(delta, versions, newVersions);
128}
129
130async function applyChangesToSDKVersionAsync(options: ActionOptions, versions: any) {
131  const sdkVersions = Object.keys(versions.sdkVersions).sort(semver.rcompare);
132  const sdkVersion = options.sdkVersion || (await chooseSdkVersionAsync(sdkVersions));
133  const containsSdk = sdkVersions.includes(sdkVersion);
134
135  if (!semver.valid(sdkVersion)) {
136    console.error(chalk.red(`Provided SDK version ${chalk.cyan(sdkVersion)} is invalid.`));
137    return;
138  }
139  if (!containsSdk) {
140    const { addNewSdk } = await inquirer.prompt<{ addNewSdk: boolean }>([
141      {
142        type: 'confirm',
143        name: 'addNewSdk',
144        message: `Configuration for SDK ${chalk.cyan(
145          sdkVersion
146        )} doesn't exist. Do you want to initialize it?`,
147        default: true,
148      },
149    ]);
150    if (!addNewSdk) {
151      console.log(chalk.yellow('Canceled'));
152      return;
153    }
154  }
155
156  // If SDK is already there, make a deep clone of the sdkVersion config so we can calculate a diff later.
157  const sdkVersionConfig = containsSdk ? cloneDeep(versions.sdkVersions[sdkVersion]) : {};
158
159  console.log(`\nUsing ${chalk.blue(STAGING_API_HOST)} host ...`);
160  console.log(`Using SDK ${chalk.cyan(sdkVersion)} ...`);
161
162  if ('deprecated' in options) {
163    setConfigValueForKey(sdkVersionConfig, 'isDeprecated', !!options.deprecated);
164  }
165  if ('releaseNoteUrl' in options && typeof options.releaseNoteUrl === 'string') {
166    setConfigValueForKey(sdkVersionConfig, 'releaseNoteUrl', options.releaseNoteUrl);
167  }
168  if (options.key) {
169    if (!('value' in options) && !options.delete) {
170      console.log(chalk.red('`--key` flag requires `--value` or `--delete` flag.'));
171      return;
172    }
173    setConfigValueForKey(
174      sdkVersionConfig,
175      options.key,
176      options.delete ? undefined : options.value
177    );
178  }
179
180  const newVersions = {
181    ...versions,
182    sdkVersions: {
183      ...versions.sdkVersions,
184      [sdkVersion]: sdkVersionConfig,
185    },
186  };
187
188  if (options.deleteSdk) {
189    delete newVersions.sdkVersions[sdkVersion];
190  }
191
192  const delta = jsondiffpatch.diff(
193    versions.sdkVersions[sdkVersion],
194    newVersions.sdkVersions[sdkVersion]
195  );
196
197  await applyChangesToStagingAsync(delta, versions.sdkVersions[sdkVersion], newVersions);
198}
199
200async function action(options: ActionOptions) {
201  if (options.reset) {
202    await resetStagingConfigurationAsync();
203    return;
204  }
205
206  Config.api.host = STAGING_API_HOST;
207  const versions = await Versions.versionsAsync();
208
209  if (options.root) {
210    await applyChangesToRootAsync(options, versions);
211  } else {
212    await applyChangesToSDKVersionAsync(options, versions);
213  }
214}
215
216export default (program: Command) => {
217  program
218    .command('update-versions-endpoint')
219    .alias('update-versions')
220    .description(
221      `Updates SDK configuration under ${chalk.blue('https://staging.exp.host/--/api/v2/versions')}`
222    )
223    .option(
224      '-s, --sdkVersion [string]',
225      'SDK version to update. Can be chosen from the list if not provided.'
226    )
227    .option(
228      '--root',
229      'Modify a key at the root of the versions config rather than a specific SDK version.',
230      false
231    )
232    .option('-d, --deprecated [boolean]', 'Sets chosen SDK version as deprecated.')
233    .option('-r, --release-note-url [string]', 'URL pointing to the release blog post.')
234    .option('-k, --key [string]', 'A custom, dotted key that you want to set in the configuration.')
235    .option('-v, --value [any]', 'Value for the custom key to be set in the configuration.')
236    .option('--delete', 'Deletes config entry under key specified by `--key` flag.', false)
237    .option(
238      '--delete-sdk',
239      'Deletes configuration for SDK specified by `--sdkVersion` flag.',
240      false
241    )
242    .option('--reset', 'Resets changes on staging to the state from production.', false)
243    .asyncAction(action);
244};
245