UNPKG

@sentry/wizard

Version:

Sentry wizard helping you to configure your project

573 lines (505 loc) 17.7 kB
/* eslint-disable max-lines */ import { exec } from 'child_process'; import * as fs from 'fs'; import type { Answers} from 'inquirer'; import { prompt } from 'inquirer'; import * as _ from 'lodash'; import * as path from 'path'; import { promisify } from 'util'; import type { Args } from '../../Constants'; import { exists, matchesContent, matchFiles, patchMatchingFile, } from '../../Helper/File'; import { dim, green, l, nl, red } from '../../Helper/Logging'; import { checkPackageVersion } from '../../Helper/Package'; import { getPackageManagerChoice } from '../../Helper/PackageManager'; import { SentryCli } from '../../Helper/SentryCli'; import { MobileProject } from './MobileProject'; import { BottomBar } from '../../Helper/BottomBar'; import { URL } from 'url'; const xcode = require('xcode'); export const COMPATIBLE_REACT_NATIVE_VERSIONS = '>=0.69.0'; export const COMPATIBLE_SDK_VERSION = '>= 5.0.0'; export const SENTRY_REACT_NATIVE_PACKAGE = '@sentry/react-native'; export const REACT_NATIVE_PACKAGE = 'react-native'; export const DOCS_MANUAL_STEPS = 'https://docs.sentry.io/platforms/react-native/manual-setup/manual-setup/'; export class ReactNative extends MobileProject { /** * All React Native versions have app/build.gradle with android section. */ private static _buildGradleAndroidSectionBeginning = /^android {/m; private url: string | undefined; protected _answers: Answers; protected _sentryCli: SentryCli; public constructor(protected _argv: Args) { super(_argv); this.url = _argv.url; this._sentryCli = new SentryCli(this._argv); } public async emit(answers: Answers): Promise<Answers> { if (this._argv.uninstall) { return this.uninstall(answers); } if (!(await this.shouldEmit(answers))) { return {}; } nl(); let userAnswers: Answers = { continue: true }; const packageManager = getPackageManagerChoice(); const hasCompatibleReactNativeVersion = checkPackageVersion( this._readAppPackage(), REACT_NATIVE_PACKAGE, COMPATIBLE_REACT_NATIVE_VERSIONS, true, ); if (!hasCompatibleReactNativeVersion && !this._argv.quiet) { userAnswers = await prompt({ message: 'Your version of React Native is not compatible with Sentry\'s React Native SDK. Do you want to continue?', name: 'continue', default: false, type: 'confirm', }); nl(); } if (!userAnswers.continue) { throw new Error( `Please upgrade to a version that is compatible with ${COMPATIBLE_REACT_NATIVE_VERSIONS}. Or use ${DOCS_MANUAL_STEPS}`, ); } if (packageManager) { BottomBar.show(`Adding ${SENTRY_REACT_NATIVE_PACKAGE}...`); await packageManager.installPackage(SENTRY_REACT_NATIVE_PACKAGE); BottomBar.hide(); green(`✓ Added \`${SENTRY_REACT_NATIVE_PACKAGE}\``); } const hasCompatibleSentryReactNativeVersion = checkPackageVersion( this._readAppPackage(), SENTRY_REACT_NATIVE_PACKAGE, COMPATIBLE_SDK_VERSION, true, ); if (!hasCompatibleSentryReactNativeVersion && !this._argv.quiet) { userAnswers = await prompt({ message: `Your version of ${SENTRY_REACT_NATIVE_PACKAGE} is not compatible with this wizard. Do you want to continue?`, name: 'continue', default: false, type: 'confirm', }); nl(); } if (!userAnswers.continue) { throw new Error( `Please upgrade to a version that is compatible with ${COMPATIBLE_SDK_VERSION}.`, ); } const sentryCliProperties = this._sentryCli.convertAnswersToProperties( answers, ); const promises = this.getPlatforms(answers).map( async (platform: string) => { try { if (platform === 'ios') { await patchMatchingFile( 'ios/*.xcodeproj/project.pbxproj', this._patchXcodeProj.bind(this), ); green('✓ Patched build script in Xcode project.'); BottomBar.show('Adding Sentry pods...'); await this._podInstall(); BottomBar.hide(); green('✓ Pods installed.'); } else { await patchMatchingFile( '**/app/build.gradle', this._patchBuildGradle.bind(this), ); green('✓ Patched build.gradle file.'); } await this._patchJsSentryInit(platform, answers); await this._addSentryProperties(platform, sentryCliProperties); green(`✓ Added sentry.properties file to ${platform}`); } catch (e) { red(e); } }, ); await Promise.all(promises); let host: string | null = null try { host = (new URL(this.url || '')).host; } catch (_error) { // ignore } const orgSlug = _.get(answers, 'config.organization.slug', null); const projectId = _.get(answers, 'config.project.id', null); const projectIssuesUrl = host && orgSlug && projectId ? `https://${orgSlug}.${host}/issues/?project=${projectId}` : null; l(` To make sure everything is set up correctly, put the following code snippet into your application. The snippet will create a button that, when tapped, sends a test event to Sentry. `); if (projectIssuesUrl) { l(`After that check your project issues:`); l(projectIssuesUrl); nl(); } l(`<Button title='Try!' onPress={ () => { Sentry.captureException(new Error('First error')) }}/>`); nl(); if (!this._argv.quiet) { await prompt({ message: 'Have you successfully sent a test event?', name: 'snippet', default: true, type: 'confirm', }); } return answers; } public async uninstall(_answers: Answers): Promise<Answers> { await patchMatchingFile( '**/*.xcodeproj/project.pbxproj', this._unpatchXcodeProj.bind(this), ); await patchMatchingFile( '**/app/build.gradle', this._unpatchBuildGradle.bind(this), ); return {}; } // eslint-disable-next-line @typescript-eslint/require-await protected async _shouldConfigurePlatform(platform: string): Promise<boolean> { let result = false; if (!exists(`${platform}/sentry.properties`)) { result = true; this.debug(`${platform}/sentry.properties not exists`); } if (!matchesContent('**/*.xcodeproj/project.pbxproj', /sentry-cli/gi)) { result = true; this.debug('**/*.xcodeproj/project.pbxproj not matched'); } if (!matchesContent('**/app/build.gradle', /sentry\.gradle/gi)) { result = true; this.debug('**/app/build.gradle not matched'); } const regex = /Sentry/gi; if ( exists(`index.${platform}.js`) && !matchesContent(`index.${platform}.js`, regex) ) { result = true; this.debug(`index.${platform}.js not matched`); } if (exists('App.js') && !matchesContent('App.js', regex)) { result = true; this.debug('index.js or App.js not matched'); } if (this._argv.uninstall) { // if we uninstall we need to invert the result so we remove already patched // but leave untouched platforms as they are return !result; } return result; } private _readAppPackage(): Record<string, unknown> { let appPackage: Record<string, unknown> = {}; try { appPackage = JSON.parse( fs.readFileSync(path.join(process.cwd(), 'package.json'), 'utf8'), ); } catch { // We don't need to have this } return appPackage; } private async _podInstall(): Promise<void> { await promisify(exec)('npx --yes pod-install --non-interactive --quiet'); } private async _patchJsSentryInit( platform: string, answers: Answers, ): Promise<void> { const prefixGlob = '{.,./src}'; const suffixGlob = '@(j|t|cj|mj)s?(x)'; const platformGlob = `index.${platform}.${suffixGlob}`; // rm 0.49 introduced an App.js for both platforms const universalGlob = `App.${suffixGlob}`; const jsFileGlob = `${prefixGlob}/+(${platformGlob}|${universalGlob})`; const jsFileToPatch = matchFiles(jsFileGlob); if (jsFileToPatch.length !== 0) { await patchMatchingFile( jsFileGlob, this._patchJs.bind(this), answers, platform, ); green(`✓ Patched ${jsFileToPatch.join(', ')} file(s).`); } else { red(`✗ Could not find ${platformGlob} nor ${universalGlob} files.`); red('✗ Please, visit https://docs.sentry.io/platforms/react-native'); } } private _addSentryProperties( platform: string, properties: any, ): Promise<void> { let rv = Promise.resolve(); // This will create the ios/android folder before trying to write // sentry.properties in it which would fail otherwise if (!fs.existsSync(platform)) { dim(`${platform} folder did not exist, creating it.`); fs.mkdirSync(platform); } const fn = path.join(platform, 'sentry.properties'); if (platform === 'android' && properties['cli/executable']) { // We don't need to write the sentry-cli path in the properties file // since our gradle plugins already pick it up on the correct spot delete properties['cli/executable']; } rv = rv.then(() => fs.writeFileSync(fn, this._sentryCli.dumpProperties(properties)), ); return rv; } private _patchJs( contents: string, _filename: string, answers: Answers, platform?: string, ): Promise<string | null> { // since the init call could live in other places too, we really only // want to do this if we managed to patch any of the other files as well. if (contents.match(/Sentry.config\(/)) { return Promise.resolve(null); } // if we match @sentry\/react-native somewhere, we already patched the file // and no longer need to if (contents.match('@sentry/react-native')) { return Promise.resolve(contents); } let dsn = '__DSN__'; this.getPlatforms(answers).forEach((selectedPlatform: string) => { if (platform && selectedPlatform === platform) { dsn = _.get(answers, 'config.dsn.public', null); } else if (platform === undefined) { dsn = _.get(answers, 'config.dsn.public', null); } }); return Promise.resolve( contents.replace( /^([^]*)(import\s+[^;]*?;$)/m, match => // eslint-disable-next-line prefer-template match + "\n\nimport * as Sentry from '@sentry/react-native';\n\n" + 'Sentry.init({ \n' + ` dsn: '${dsn}', \n` + '});\n', ), ); } // ANDROID ----------------------------------------- private _patchBuildGradle(contents: string): Promise<string | null> { const applyFrom = 'apply from: "../../node_modules/@sentry/react-native/sentry.gradle"'; if (contents.indexOf(applyFrom) >= 0) { return Promise.resolve(null); } return Promise.resolve( contents.replace( ReactNative._buildGradleAndroidSectionBeginning, // eslint-disable-next-line prefer-template match => applyFrom + '\n' + match, ), ); } private _unpatchBuildGradle(contents: string): Promise<string> { return Promise.resolve( contents.replace( /^\s*apply from: ["']..\/..\/node_modules\/@sentry\/react-native\/sentry.gradle["'];?\s*?\r?\n/m, '', ), ); } // IOS ----------------------------------------- private _patchExistingXcodeBuildScripts(buildScripts: any): void { for (const script of buildScripts) { if ( !script.shellScript.match(/\/scripts\/react-native-xcode\.sh/i) || script.shellScript.match(/sentry-cli\s+react-native\s+xcode/i) ) { continue; } let code = JSON.parse(script.shellScript); code = // eslint-disable-next-line prefer-template, @typescript-eslint/restrict-plus-operands 'export SENTRY_PROPERTIES=sentry.properties\n' + 'export EXTRA_PACKAGER_ARGS="--sourcemap-output $DERIVED_FILE_DIR/main.jsbundle.map"\n' + code.replace( '$REACT_NATIVE_XCODE', () => // eslint-disable-next-line no-useless-escape '\\"../node_modules/@sentry/cli/bin/sentry-cli react-native xcode $REACT_NATIVE_XCODE\\"', ) + '\n/bin/sh ../node_modules/@sentry/react-native/scripts/collect-modules.sh\n'; script.shellScript = JSON.stringify(code); } } private _addNewXcodeBuildPhaseForSymbols(buildScripts: any, proj: any): void { for (const script of buildScripts) { if ( script.shellScript.match( /sentry-cli\s+(upload-dsym|debug-files upload)/, ) ) { return; } } proj.addBuildPhase( [], 'PBXShellScriptBuildPhase', 'Upload Debug Symbols to Sentry', null, { shellPath: '/bin/sh', shellScript: ` export SENTRY_PROPERTIES=sentry.properties [[ $SENTRY_INCLUDE_NATIVE_SOURCES == "true" ]] && INCLUDE_SOURCES_FLAG="--include-sources" || INCLUDE_SOURCES_FLAG="" ../node_modules/@sentry/cli/bin/sentry-cli debug-files upload "$INCLUDE_SOURCES_FLAG" "$DWARF_DSYM_FOLDER_PATH" `, }, ); } private _patchXcodeProj( contents: string, filename: string, ): Promise<string | undefined> { const proj = xcode.project(filename); return new Promise((resolve, reject) => { proj.parse((err: any) => { if (err) { reject(err); return; } const buildScripts = []; for (const key in proj.hash.project.objects.PBXShellScriptBuildPhase || {}) { if ( // eslint-disable-next-line no-prototype-builtins proj.hash.project.objects.PBXShellScriptBuildPhase.hasOwnProperty( key, ) ) { const val = proj.hash.project.objects.PBXShellScriptBuildPhase[key]; if (val.isa) { buildScripts.push(val); } } } try { this._patchExistingXcodeBuildScripts(buildScripts); } catch (e) { red(e); } try { this._addNewXcodeBuildPhaseForSymbols(buildScripts, proj); } catch (e) { red(e); } // we always modify the xcode file in memory but we only want to save it // in case the user wants configuration for ios. This is why we check // here first if changes are made before we might prompt the platform // continue prompt. const newContents = proj.writeSync(); if (newContents === contents) { resolve(undefined); } else { resolve(newContents); } }); }); } private _unpatchXcodeBuildScripts(proj: any): void { const scripts = proj.hash.project.objects.PBXShellScriptBuildPhase || {}; const firstTarget = proj.getFirstTarget().uuid; const nativeTargets = proj.hash.project.objects.PBXNativeTarget; // scripts to patch partially. Run this first so that we don't // accidentally delete some scripts later entirely that we only want to // rewrite. for (const key of Object.keys(scripts)) { const script = scripts[key]; // ignore comments if (typeof script === 'string') { continue; } // ignore scripts that do not invoke the react-native-xcode command. if (!script.shellScript.match(/sentry-cli\s+react-native\s+xcode/i)) { continue; } script.shellScript = JSON.stringify( JSON.parse(script.shellScript) // remove sentry properties export .replace(/^export SENTRY_PROPERTIES=sentry.properties\r?\n/m, '') .replace( /^\/bin\/sh ..\/node_modules\/@sentry\/react-native\/scripts\/collect-modules.sh\r?\n/m, '', ) // unwrap react-native-xcode.sh command. In case someone replaced it // entirely with the sentry-cli command we need to put the original // version back in. .replace( /\.\.\/node_modules\/@sentry\/cli\/bin\/sentry-cli\s+react-native\s+xcode\s+\$REACT_NATIVE_XCODE/i, '$REACT_NATIVE_XCODE', ), ); } // scripts to kill entirely. for (const key of Object.keys(scripts)) { const script = scripts[key]; // ignore comments and keys that got deleted if (typeof script === 'string' || script === undefined) { continue; } if ( script.shellScript.match( /@sentry\/cli\/bin\/sentry-cli\s+(upload-dsym|debug-files upload)\b/, ) ) { // eslint-disable-next-line @typescript-eslint/no-dynamic-delete delete scripts[key]; // eslint-disable-next-line @typescript-eslint/no-dynamic-delete delete scripts[`${key}_comment`]; const phases = nativeTargets[firstTarget].buildPhases; if (phases) { for (let i = 0; i < phases.length; i++) { if (phases[i].value === key) { phases.splice(i, 1); break; } } } continue; } } } private _unpatchXcodeProj( _contents: string, filename: string, ): Promise<string> { const proj = xcode.project(filename); return new Promise((resolve, reject) => { proj.parse((err: any) => { if (err) { reject(err); return; } this._unpatchXcodeBuildScripts(proj); resolve(proj.writeSync()); }); }); } }