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