UNPKG

@sentry/wizard

Version:

Sentry wizard helping you to configure your project

600 lines (525 loc) 16.6 kB
// @ts-ignore - clack is ESM and TS complains about that. It works though import * as clack from '@clack/prompts'; // @ts-ignore - magicast is ESM and TS complains about that. It works though import { ProxifiedModule, parseModule, writeFile } from 'magicast'; import * as fs from 'fs'; import * as Sentry from '@sentry/node'; import { getLastRequireIndex, hasSentryContent, removeRequire, } from '../utils/ast-utils'; import { abortIfCancelled, makeCodeSnippet, showCopyPasteInstructions, } from '../utils/clack-utils'; import * as recast from 'recast'; import x = recast.types; import t = x.namedTypes; import chalk from 'chalk'; const b = recast.types.builders; export const metroConfigPath = 'metro.config.js'; export async function patchMetroWithSentryConfig() { const mod = await parseMetroConfig(); const showInstructions = () => showCopyPasteInstructions( metroConfigPath, getMetroWithSentryConfigSnippet(true), ); const success = await patchMetroWithSentryConfigInMemory( mod, showInstructions, ); if (!success) { return; } const saved = await writeMetroConfig(mod); if (saved) { clack.log.success( chalk.green(`${chalk.cyan(metroConfigPath)} changes saved.`), ); } else { clack.log.warn( `Could not save changes to ${chalk.cyan( metroConfigPath, )}, please follow the manual steps.`, ); return await showInstructions(); } } export async function patchMetroWithSentryConfigInMemory( mod: ProxifiedModule, showInstructions: () => Promise<void>, ): Promise<boolean> { if (hasSentryContent(mod.$ast as t.Program)) { const shouldContinue = await confirmPathMetroConfig(); if (!shouldContinue) { await showInstructions(); return false; } } const configExpression = getModuleExportsAssignmentRight( mod.$ast as t.Program, ); if (!configExpression) { clack.log.warn( 'Could not find Metro config, please follow the manual steps.', ); await showInstructions(); return false; } const wrappedConfig = wrapWithSentryConfig(configExpression); const replacedModuleExportsRight = replaceModuleExportsRight( mod.$ast as t.Program, wrappedConfig, ); if (!replacedModuleExportsRight) { clack.log.warn( 'Could not automatically wrap the config export, please follow the manual steps.', ); await showInstructions(); return false; } const addedSentryMetroImport = addSentryMetroRequireToMetroConfig( mod.$ast as t.Program, ); if (!addedSentryMetroImport) { clack.log.warn( 'Could not add `@sentry/react-native/metro` import to Metro config, please follow the manual steps.', ); await showInstructions(); return false; } clack.log.success( `Added Sentry Metro plugin to ${chalk.cyan(metroConfigPath)}.`, ); return true; } export async function patchMetroConfigWithSentrySerializer() { const mod = await parseMetroConfig(); const showInstructions = () => showCopyPasteInstructions( metroConfigPath, getMetroSentrySerializerSnippet(true), ); if (hasSentryContent(mod.$ast as t.Program)) { const shouldContinue = await confirmPathMetroConfig(); if (!shouldContinue) { return await showInstructions(); } } const configObj = getMetroConfigObject(mod.$ast as t.Program); if (!configObj) { clack.log.warn( 'Could not find Metro config object, please follow the manual steps.', ); return showInstructions(); } const addedSentrySerializer = addSentrySerializerToMetroConfig(configObj); if (!addedSentrySerializer) { clack.log.warn( 'Could not add Sentry serializer to Metro config, please follow the manual steps.', ); return await showInstructions(); } const addedSentrySerializerImport = addSentrySerializerRequireToMetroConfig( mod.$ast as t.Program, ); if (!addedSentrySerializerImport) { clack.log.warn( 'Could not add Sentry serializer import to Metro config, please follow the manual steps.', ); return await showInstructions(); } clack.log.success( `Added Sentry Metro plugin to ${chalk.cyan(metroConfigPath)}.`, ); const saved = await writeMetroConfig(mod); if (saved) { clack.log.success( chalk.green(`${chalk.cyan(metroConfigPath)} changes saved.`), ); } else { clack.log.warn( `Could not save changes to ${chalk.cyan( metroConfigPath, )}, please follow the manual steps.`, ); return await showInstructions(); } } export async function unPatchMetroConfig() { const mod = await parseMetroConfig(); const removedAtLeastOneRequire = removeSentryRequire(mod.$ast as t.Program); const removedSerializerConfig = removeSentrySerializerFromMetroConfig( mod.$ast as t.Program, ); if (removedAtLeastOneRequire || removedSerializerConfig) { const saved = await writeMetroConfig(mod); if (saved) { clack.log.success( `Removed Sentry Metro plugin from ${chalk.cyan(metroConfigPath)}.`, ); } } else { clack.log.warn( `No Sentry Metro plugin found in ${chalk.cyan(metroConfigPath)}.`, ); } } export function removeSentrySerializerFromMetroConfig( program: t.Program, ): boolean { const configObject = getMetroConfigObject(program); if (!configObject) { return false; } const serializerProp = getSerializerProp(configObject); if ('invalid' === serializerProp || 'undefined' === serializerProp) { return false; } const customSerializerProp = getCustomSerializerProp(serializerProp); if ( 'invalid' === customSerializerProp || 'undefined' === customSerializerProp ) { return false; } if ( serializerProp.value.type === 'ObjectExpression' && customSerializerProp.value.type === 'CallExpression' && customSerializerProp.value.callee.type === 'Identifier' && customSerializerProp.value.callee.name === 'createSentryMetroSerializer' ) { if (customSerializerProp.value.arguments.length === 0) { // FROM serializer: { customSerializer: createSentryMetroSerializer() } // TO serializer: {} let removed = false; serializerProp.value.properties = serializerProp.value.properties.filter( (p) => { if ( p.type === 'ObjectProperty' && p.key.type === 'Identifier' && p.key.name === 'customSerializer' ) { removed = true; return false; } return true; }, ); if (removed) { return true; } } else { if (customSerializerProp.value.arguments[0].type !== 'SpreadElement') { // FROM serializer: { customSerializer: createSentryMetroSerializer(wrapperSerializer) } // TO serializer: { customSerializer: wrapperSerializer } customSerializerProp.value = customSerializerProp.value.arguments[0]; return true; } } } return false; } export function removeSentryRequire(program: t.Program): boolean { return removeRequire(program, '@sentry'); } export async function parseMetroConfig(): Promise<ProxifiedModule> { const metroConfigContent = ( await fs.promises.readFile(metroConfigPath) ).toString(); return parseModule(metroConfigContent); } export async function writeMetroConfig(mod: ProxifiedModule): Promise<boolean> { try { await writeFile(mod.$ast, metroConfigPath); } catch (e) { clack.log.error( `Failed to write to ${chalk.cyan(metroConfigPath)}: ${JSON.stringify(e)}`, ); return false; } return true; } export function addSentrySerializerToMetroConfig( configObj: t.ObjectExpression, ): boolean { const serializerProp = getSerializerProp(configObj); if ('invalid' === serializerProp) { return false; } // case 1: serializer property doesn't exist yet, so we can just add it if ('undefined' === serializerProp) { configObj.properties.push( b.objectProperty( b.identifier('serializer'), b.objectExpression([ b.objectProperty( b.identifier('customSerializer'), b.callExpression(b.identifier('createSentryMetroSerializer'), []), ), ]), ), ); return true; } const customSerializerProp = getCustomSerializerProp(serializerProp); // case 2: serializer.customSerializer property doesn't exist yet, so we just add it if ( 'undefined' === customSerializerProp && serializerProp.value.type === 'ObjectExpression' ) { serializerProp.value.properties.push( b.objectProperty( b.identifier('customSerializer'), b.callExpression(b.identifier('createSentryMetroSerializer'), []), ), ); return true; } return false; } function getCustomSerializerProp( prop: t.ObjectProperty, ): t.ObjectProperty | 'undefined' | 'invalid' { const customSerializerProp = prop.value.type === 'ObjectExpression' && prop.value.properties.find( (p: t.ObjectProperty) => p.key.type === 'Identifier' && p.key.name === 'customSerializer', ); if (!customSerializerProp) { return 'undefined'; } if (customSerializerProp.type === 'ObjectProperty') { return customSerializerProp; } return 'invalid'; } function getSerializerProp( obj: t.ObjectExpression, ): t.ObjectProperty | 'undefined' | 'invalid' { const serializerProp = obj.properties.find( (p: t.ObjectProperty) => p.key.type === 'Identifier' && p.key.name === 'serializer', ); if (!serializerProp) { return 'undefined'; } if (serializerProp.type === 'ObjectProperty') { return serializerProp; } return 'invalid'; } export function addSentrySerializerRequireToMetroConfig( program: t.Program, ): boolean { const lastRequireIndex = getLastRequireIndex(program); const sentrySerializerRequire = createSentrySerializerRequire(); const sentryImportIndex = lastRequireIndex + 1; if (sentryImportIndex < program.body.length) { // insert after last require program.body.splice(lastRequireIndex + 1, 0, sentrySerializerRequire); } else { // insert at the beginning program.body.unshift(sentrySerializerRequire); } return true; } export function addSentryMetroRequireToMetroConfig( program: t.Program, ): boolean { const lastRequireIndex = getLastRequireIndex(program); const sentryMetroRequire = createSentryMetroRequire(); const sentryImportIndex = lastRequireIndex + 1; if (sentryImportIndex < program.body.length) { // insert after last require program.body.splice(lastRequireIndex + 1, 0, sentryMetroRequire); } else { // insert at the beginning program.body.unshift(sentryMetroRequire); } return true; } function wrapWithSentryConfig( configObj: t.Identifier | t.CallExpression | t.ObjectExpression, ): t.CallExpression { return b.callExpression(b.identifier('withSentryConfig'), [configObj]); } function replaceModuleExportsRight( program: t.Program, wrappedConfig: t.CallExpression, ): boolean { const moduleExports = getModuleExports(program); if (!moduleExports) { return false; } if (moduleExports.expression.type === 'AssignmentExpression') { moduleExports.expression.right = wrappedConfig; return true; } return false; } /** * Creates const {createSentryMetroSerializer} = require('@sentry/react-native/dist/js/tools/sentryMetroSerializer'); */ function createSentrySerializerRequire() { return b.variableDeclaration('const', [ b.variableDeclarator( b.objectPattern([ b.objectProperty.from({ key: b.identifier('createSentryMetroSerializer'), value: b.identifier('createSentryMetroSerializer'), shorthand: true, }), ]), b.callExpression(b.identifier('require'), [ b.literal('@sentry/react-native/dist/js/tools/sentryMetroSerializer'), ]), ), ]); } /** * Creates const {withSentryConfig} = require('@sentry/react-native/metro'); */ function createSentryMetroRequire() { return b.variableDeclaration('const', [ b.variableDeclarator( b.objectPattern([ b.objectProperty.from({ key: b.identifier('withSentryConfig'), value: b.identifier('withSentryConfig'), shorthand: true, }), ]), b.callExpression(b.identifier('require'), [ b.literal('@sentry/react-native/metro'), ]), ), ]); } async function confirmPathMetroConfig() { const shouldContinue = await abortIfCancelled( clack.select({ message: `Metro Config already contains Sentry-related code. Should the wizard modify it anyway?`, options: [ { label: 'Yes, add the Sentry Metro plugin', value: true, }, { label: 'No, show me instructions to manually add the plugin', value: false, }, ], initialValue: true, }), ); if (!shouldContinue) { Sentry.setTag('ast-mod-fail-reason', 'has-sentry-content'); } return shouldContinue; } /** * Returns value from `module.exports = value` or `const config = value` */ export function getMetroConfigObject( program: t.Program, ): t.ObjectExpression | undefined { // check config variable const configVariable = program.body.find((s) => { if ( s.type === 'VariableDeclaration' && s.declarations.length === 1 && s.declarations[0].type === 'VariableDeclarator' && s.declarations[0].id.type === 'Identifier' && s.declarations[0].id.name === 'config' ) { return true; } return false; }) as t.VariableDeclaration | undefined; if ( configVariable?.declarations[0].type === 'VariableDeclarator' && configVariable?.declarations[0].init?.type === 'ObjectExpression' ) { Sentry.setTag('metro-config', 'config-variable'); return configVariable.declarations[0].init; } return getModuleExportsObject(program); } function getModuleExportsObject( program: t.Program, ): t.ObjectExpression | undefined { // check module.exports const moduleExports = getModuleExportsAssignmentRight(program); if (moduleExports?.type === 'ObjectExpression') { return moduleExports; } Sentry.setTag('metro-config', 'not-found'); return undefined; } export function getModuleExportsAssignmentRight( program: t.Program, ): t.Identifier | t.CallExpression | t.ObjectExpression | undefined { // check module.exports const moduleExports = getModuleExports(program); if ( moduleExports?.expression.type === 'AssignmentExpression' && (moduleExports.expression.right.type === 'ObjectExpression' || moduleExports.expression.right.type === 'CallExpression' || moduleExports.expression.right.type === 'Identifier') ) { Sentry.setTag('metro-config', 'module-exports'); return moduleExports?.expression.right; } Sentry.setTag('metro-config', 'not-found'); return undefined; } function getModuleExports( program: t.Program, ): t.ExpressionStatement | undefined { // find module.exports return program.body.find((s) => { if ( s.type === 'ExpressionStatement' && s.expression.type === 'AssignmentExpression' && s.expression.left.type === 'MemberExpression' && s.expression.left.object.type === 'Identifier' && s.expression.left.object.name === 'module' && s.expression.left.property.type === 'Identifier' && s.expression.left.property.name === 'exports' ) { return true; } return false; }) as t.ExpressionStatement | undefined; } function getMetroSentrySerializerSnippet(colors: boolean) { return makeCodeSnippet(colors, (unchanged, plus, _) => unchanged(`const {getDefaultConfig, mergeConfig} = require('@react-native/metro-config');"; ${plus( "const {createSentryMetroSerializer} = require('@sentry/react-native/dist/js/tools/sentryMetroSerializer');", )} const config = { ${plus(`serializer: { customSerializer: createSentryMetroSerializer(), },`)} }; module.exports = mergeConfig(getDefaultConfig(__dirname), config); `), ); } function getMetroWithSentryConfigSnippet(colors: boolean) { return makeCodeSnippet(colors, (unchanged, plus, _) => unchanged(`const {getDefaultConfig, mergeConfig} = require('@react-native/metro-config');"; ${plus("const {withSentryConfig} = require('@sentry/react-native/metro');")} const config = {}; module.exports = ${plus( 'withSentryConfig(', )}mergeConfig(getDefaultConfig(__dirname), config)${plus(')')}; `), ); }