From 65741acde3bb5bc111e177c5bbc006541cef8d9b Mon Sep 17 00:00:00 2001 From: Ben Gourley Date: Wed, 25 Nov 2020 15:03:50 +0000 Subject: [PATCH 1/2] feat(react-native-cli): Add configure command --- packages/react-native-cli/package.json | 1 + packages/react-native-cli/src/bin/cli.ts | 5 +- .../src/commands/ConfigureCommand.ts | 30 ++++++ .../src/lib/AndroidManifest.ts | 44 +++++++++ .../react-native-cli/src/lib/InfoPlist.ts | 41 +++++++++ .../src/lib/__test__/AndroidManifest.test.ts | 90 ++++++++++++++++++ .../src/lib/__test__/InfoPlist.test.ts | 91 +++++++++++++++++++ .../fixtures/AndroidManifest-after.xml | 28 ++++++ .../fixtures/AndroidManifest-before.xml | 27 ++++++ .../lib/__test__/fixtures/Info-after.plist | 62 +++++++++++++ .../lib/__test__/fixtures/Info-before.plist | 57 ++++++++++++ .../react-native-cli/src/lib/lib-plist.d.ts | 7 ++ 12 files changed, 482 insertions(+), 1 deletion(-) create mode 100644 packages/react-native-cli/src/commands/ConfigureCommand.ts create mode 100644 packages/react-native-cli/src/lib/AndroidManifest.ts create mode 100644 packages/react-native-cli/src/lib/InfoPlist.ts create mode 100644 packages/react-native-cli/src/lib/__test__/AndroidManifest.test.ts create mode 100644 packages/react-native-cli/src/lib/__test__/InfoPlist.test.ts create mode 100644 packages/react-native-cli/src/lib/__test__/fixtures/AndroidManifest-after.xml create mode 100644 packages/react-native-cli/src/lib/__test__/fixtures/AndroidManifest-before.xml create mode 100644 packages/react-native-cli/src/lib/__test__/fixtures/Info-after.plist create mode 100644 packages/react-native-cli/src/lib/__test__/fixtures/Info-before.plist create mode 100644 packages/react-native-cli/src/lib/lib-plist.d.ts diff --git a/packages/react-native-cli/package.json b/packages/react-native-cli/package.json index 08b7d67c70..4027d3eee5 100644 --- a/packages/react-native-cli/package.json +++ b/packages/react-native-cli/package.json @@ -20,6 +20,7 @@ "command-line-usage": "^6.1.0", "consola": "^2.15.0", "prompts": "^2.4.0", + "plist": "^3.0.1", "xcode": "^3.0.1" }, "devDependencies": { diff --git a/packages/react-native-cli/src/bin/cli.ts b/packages/react-native-cli/src/bin/cli.ts index 7d79ed78ca..93264e25e1 100644 --- a/packages/react-native-cli/src/bin/cli.ts +++ b/packages/react-native-cli/src/bin/cli.ts @@ -4,6 +4,7 @@ import logger from '../Logger' import automateSymbolication from '../commands/AutomateSymbolicationCommand' import install from '../commands/InstallCommand' +import configure from '../commands/ConfigureCommand' const topLevelDefs = [ { @@ -37,9 +38,11 @@ export default async function run (argv: string[]): Promise { switch (opts.command) { case 'init': case 'insert': - case 'configure': logger.info(`TODO ${opts.command}`) break + case 'configure': + await configure(remainingOpts, opts) + break case 'install': await install(remainingOpts, opts) break diff --git a/packages/react-native-cli/src/commands/ConfigureCommand.ts b/packages/react-native-cli/src/commands/ConfigureCommand.ts new file mode 100644 index 0000000000..7b4883cb2d --- /dev/null +++ b/packages/react-native-cli/src/commands/ConfigureCommand.ts @@ -0,0 +1,30 @@ +import prompts from 'prompts' +import logger from '../Logger' +import onCancel from '../lib/OnCancel' +import { addApiKey as addApiKeyAndroid } from '../lib/AndroidManifest' +import { addApiKey as addApiKeyIos } from '../lib/InfoPlist' + +export default async function run (argv: string[], opts: Record): Promise { + const projectRoot = process.cwd() + + try { + const { apiKey } = await prompts({ + type: 'text', + name: 'apiKey', + message: 'What is your Bugsnag API key?', + validate: value => { + return value.length > 1 + ? true + : 'API key is required. You can find it by going to https://app.bugsnag.com/settings/ > Projects' + } + }, { onCancel }) + + logger.info('Adding API key to AndroidManifest.xml') + await addApiKeyAndroid(projectRoot, apiKey, logger) + + logger.info('Adding API key to Info.plist') + await addApiKeyIos(projectRoot, apiKey, logger) + } catch (e) { + logger.error(e) + } +} diff --git a/packages/react-native-cli/src/lib/AndroidManifest.ts b/packages/react-native-cli/src/lib/AndroidManifest.ts new file mode 100644 index 0000000000..0b26a2d822 --- /dev/null +++ b/packages/react-native-cli/src/lib/AndroidManifest.ts @@ -0,0 +1,44 @@ +import path from 'path' +import { Logger } from '../Logger' +import { promises as fs } from 'fs' + +const DOCS_LINK = 'https://docs.bugsnag.com/platforms/react-native/react-native/#android' +const UNLOCATED_PROJ_MSG = `The Android configuration was not in the expected location and so couldn't be updated automatically. + +Add your API key to the AndroidManifest.xml in your project. + +See ${DOCS_LINK} for more information` + +const MATCH_FAIL_MSG = `The project's AndroidManifest.xml couldn't be updated automatically as it was in an unexpected format. + +Add your API key to the AndroidManifest.xml in your project. + +See ${DOCS_LINK} for more information` + +const APP_END_REGEX = /\n\s*<\/application>/ + +export async function addApiKey (projectRoot: string, apiKey: string, logger: Logger): Promise { + const manifestPath = path.join(projectRoot, 'android', 'app', 'src', 'main', 'AndroidManifest.xml') + try { + const manifest = await fs.readFile(manifestPath, 'utf8') + const activityStartMatch = /(\s*)${appEndMatch}` + ) + await fs.writeFile(manifestPath, updatedManifest, 'utf8') + logger.success('Updated AndroidManifest.xml') + } catch (e) { + logger.warn(UNLOCATED_PROJ_MSG) + } +} diff --git a/packages/react-native-cli/src/lib/InfoPlist.ts b/packages/react-native-cli/src/lib/InfoPlist.ts new file mode 100644 index 0000000000..81f4d27279 --- /dev/null +++ b/packages/react-native-cli/src/lib/InfoPlist.ts @@ -0,0 +1,41 @@ +import { Logger } from '../Logger' +import plist from 'plist' +import path from 'path' +import { promises as fs } from 'fs' + +const DOCS_LINK = 'https://docs.bugsnag.com/platforms/react-native/react-native/#ios' +const UNLOCATED_PROJ_MSG = `The Xcode configuration was not in the expected location and so couldn't be updated automatically. + +Add your API key to the Info.plist in your project. + +See ${DOCS_LINK} for more information` + +const PLIST_FAIL_MSG = `The project's Info.plist couldn't be updated automatically. The plist file may not be valid XML. + +Add your API key to the Info.plist in your project manually. + +See ${DOCS_LINK} for more information` + +export async function addApiKey (projectRoot: string, apiKey: string, logger: Logger): Promise { + const iosDir = path.join(projectRoot, 'ios') + let xcodeprojDir + try { + xcodeprojDir = (await fs.readdir(iosDir)).find(p => p.endsWith('.xcodeproj')) + if (!xcodeprojDir) { + logger.warn(UNLOCATED_PROJ_MSG) + return + } + } catch (e) { + logger.warn(UNLOCATED_PROJ_MSG) + return + } + const plistPath = path.join(iosDir, xcodeprojDir.replace(/\.xcodeproj$/, ''), 'Info.plist') + try { + const infoPlist = plist.parse(await fs.readFile(plistPath, 'utf8')) + infoPlist.bugsnag = { apiKey } + await fs.writeFile(plistPath, `${plist.build(infoPlist, { indent: '\t', indentSize: 1, offset: -1 })}\n`, 'utf8') + logger.success('Updating Info.plist') + } catch (e) { + logger.warn(PLIST_FAIL_MSG) + } +} diff --git a/packages/react-native-cli/src/lib/__test__/AndroidManifest.test.ts b/packages/react-native-cli/src/lib/__test__/AndroidManifest.test.ts new file mode 100644 index 0000000000..0c7483b61d --- /dev/null +++ b/packages/react-native-cli/src/lib/__test__/AndroidManifest.test.ts @@ -0,0 +1,90 @@ +import { addApiKey } from '../AndroidManifest' +import logger from '../../Logger' +import path from 'path' +import { promises as fs } from 'fs' + +async function loadFixture (fixture: string) { + return jest.requireActual('fs').promises.readFile(fixture, 'utf8') +} + +async function generateNotFoundError () { + try { + await jest.requireActual('fs').promises.readFile(path.join(__dirname, 'does-not-exist.txt')) + } catch (e) { + return e + } +} + +jest.mock('../../Logger') +jest.mock('fs', () => { + return { promises: { readFile: jest.fn(), writeFile: jest.fn() } } +}) + +afterEach(() => jest.resetAllMocks()) + +test('addApiKey(): success', async () => { + const androidManifest = await loadFixture(path.join(__dirname, 'fixtures', 'AndroidManifest-before.xml')) + const readFileMock = fs.readFile as jest.MockedFunction + readFileMock.mockResolvedValue(androidManifest) + + const writeFileMock = fs.writeFile as jest.MockedFunction + writeFileMock.mockResolvedValue() + + await addApiKey('/random/path', 'API_KEY_GOES_HERE', logger) + expect(readFileMock).toHaveBeenCalledWith('/random/path/android/app/src/main/AndroidManifest.xml', 'utf8') + expect(writeFileMock).toHaveBeenCalledWith( + '/random/path/android/app/src/main/AndroidManifest.xml', + await loadFixture(path.join(__dirname, 'fixtures', 'AndroidManifest-after.xml')), + 'utf8' + ) +}) + +test('addApiKey(): already present', async () => { + const androidManifest = await loadFixture(path.join(__dirname, 'fixtures', 'AndroidManifest-after.xml')) + const readFileMock = fs.readFile as jest.MockedFunction + readFileMock.mockResolvedValue(androidManifest) + + const writeFileMock = fs.writeFile as jest.MockedFunction + writeFileMock.mockResolvedValue() + + await addApiKey('/random/path', 'API_KEY_GOES_HERE', logger) + expect(readFileMock).toHaveBeenCalledWith('/random/path/android/app/src/main/AndroidManifest.xml', 'utf8') + expect(writeFileMock).not.toHaveBeenCalled() + + expect(logger.warn).toHaveBeenCalledWith('API key is already present, skipping') +}) + +test('addApiKey(): self closing application tag', async () => { + const androidManifest = +` + +` + + const readFileMock = fs.readFile as jest.MockedFunction + readFileMock.mockResolvedValue(androidManifest) + + const writeFileMock = fs.writeFile as jest.MockedFunction + + await addApiKey('/random/path', 'API_KEY_GOES_HERE', logger) + expect(readFileMock).toHaveBeenCalledWith('/random/path/android/app/src/main/AndroidManifest.xml', 'utf8') + expect(writeFileMock).not.toHaveBeenCalled() + + expect(logger.warn).toHaveBeenCalledWith( + expect.stringContaining('The project\'s AndroidManifest.xml couldn\'t be updated automatically as it was in an unexpected format.') + ) +}) + +test('addApiKey(): missing file', async () => { + const readFileMock = fs.readFile as jest.MockedFunction + readFileMock.mockRejectedValue(await generateNotFoundError()) + + const writeFileMock = fs.writeFile as jest.MockedFunction + + await addApiKey('/random/path', 'API_KEY_GOES_HERE', logger) + expect(readFileMock).toHaveBeenCalledWith('/random/path/android/app/src/main/AndroidManifest.xml', 'utf8') + expect(writeFileMock).not.toHaveBeenCalled() + + expect(logger.warn).toHaveBeenCalledWith( + expect.stringContaining('The Android configuration was not in the expected location') + ) +}) diff --git a/packages/react-native-cli/src/lib/__test__/InfoPlist.test.ts b/packages/react-native-cli/src/lib/__test__/InfoPlist.test.ts new file mode 100644 index 0000000000..7f9bc4cd5f --- /dev/null +++ b/packages/react-native-cli/src/lib/__test__/InfoPlist.test.ts @@ -0,0 +1,91 @@ +import { addApiKey } from '../InfoPlist' +import logger from '../../Logger' +import path from 'path' +import { promises as fs } from 'fs' + +async function loadFixture (fixture: string) { + return jest.requireActual('fs').promises.readFile(fixture, 'utf8') +} + +async function generateNotFoundError () { + try { + await jest.requireActual('fs').promises.readFile(path.join(__dirname, 'does-not-exist.txt')) + } catch (e) { + return e + } +} + +jest.mock('../../Logger') +jest.mock('fs', () => { + return { promises: { readFile: jest.fn(), writeFile: jest.fn(), readdir: jest.fn() } } +}) + +afterEach(() => jest.resetAllMocks()) + +test('addApiKey(): success', async () => { + type readdir = (path: string) => Promise + const readdirMock = fs.readdir as unknown as jest.MockedFunction + readdirMock.mockResolvedValue(['BugsnagReactNativeCliTest.xcodeproj']) + + const infoPlist = await loadFixture(path.join(__dirname, 'fixtures', 'Info-before.plist')) + const readFileMock = fs.readFile as jest.MockedFunction + readFileMock.mockResolvedValue(infoPlist) + + const writeFileMock = fs.writeFile as jest.MockedFunction + writeFileMock.mockResolvedValue() + + await addApiKey('/random/path', 'API_KEY_GOES_HERE', logger) + expect(readFileMock).toHaveBeenCalledWith('/random/path/ios/BugsnagReactNativeCliTest/Info.plist', 'utf8') + expect(writeFileMock).toHaveBeenCalledWith( + '/random/path/ios/BugsnagReactNativeCliTest/Info.plist', + await loadFixture(path.join(__dirname, 'fixtures', 'Info-after.plist')), + 'utf8' + ) +}) + +test('addApiKey(): unlocated project', async () => { + type readdir = (path: string) => Promise + const readdirMock = fs.readdir as unknown as jest.MockedFunction + readdirMock.mockResolvedValue(['floop']) + + const readFileMock = fs.readFile as jest.MockedFunction + const writeFileMock = fs.writeFile as jest.MockedFunction + + await addApiKey('/random/path', 'API_KEY_GOES_HERE', logger) + expect(readFileMock).not.toHaveBeenCalled() + expect(writeFileMock).not.toHaveBeenCalled() + + expect(logger.warn).toHaveBeenCalledWith(expect.stringContaining('The Xcode configuration was not in the expected location')) +}) + +test('addApiKey(): unlocated project #2', async () => { + type readdir = (path: string) => Promise + const readdirMock = fs.readdir as unknown as jest.MockedFunction + readdirMock.mockRejectedValue(await generateNotFoundError()) + + const readFileMock = fs.readFile as jest.MockedFunction + const writeFileMock = fs.writeFile as jest.MockedFunction + + await addApiKey('/random/path', 'API_KEY_GOES_HERE', logger) + expect(readFileMock).not.toHaveBeenCalled() + expect(writeFileMock).not.toHaveBeenCalled() + + expect(logger.warn).toHaveBeenCalledWith(expect.stringContaining('The Xcode configuration was not in the expected location')) +}) + +test('addApiKey(): bad xml', async () => { + type readdir = (path: string) => Promise + const readdirMock = fs.readdir as unknown as jest.MockedFunction + readdirMock.mockResolvedValue(['BugsnagReactNativeCliTest.xcodeproj']) + + const infoPlist = 'not xml' + const readFileMock = fs.readFile as jest.MockedFunction + readFileMock.mockResolvedValue(infoPlist) + + const writeFileMock = fs.writeFile as jest.MockedFunction + + await addApiKey('/random/path', 'API_KEY_GOES_HERE', logger) + expect(writeFileMock).not.toHaveBeenCalled() + + expect(logger.warn).toHaveBeenCalledWith(expect.stringContaining('The project\'s Info.plist couldn\'t be updated automatically.')) +}) diff --git a/packages/react-native-cli/src/lib/__test__/fixtures/AndroidManifest-after.xml b/packages/react-native-cli/src/lib/__test__/fixtures/AndroidManifest-after.xml new file mode 100644 index 0000000000..e9d7120013 --- /dev/null +++ b/packages/react-native-cli/src/lib/__test__/fixtures/AndroidManifest-after.xml @@ -0,0 +1,28 @@ + + + + + + + + + + + + + + + + diff --git a/packages/react-native-cli/src/lib/__test__/fixtures/AndroidManifest-before.xml b/packages/react-native-cli/src/lib/__test__/fixtures/AndroidManifest-before.xml new file mode 100644 index 0000000000..9fcc8a9db3 --- /dev/null +++ b/packages/react-native-cli/src/lib/__test__/fixtures/AndroidManifest-before.xml @@ -0,0 +1,27 @@ + + + + + + + + + + + + + + + diff --git a/packages/react-native-cli/src/lib/__test__/fixtures/Info-after.plist b/packages/react-native-cli/src/lib/__test__/fixtures/Info-after.plist new file mode 100644 index 0000000000..0f387c5ea9 --- /dev/null +++ b/packages/react-native-cli/src/lib/__test__/fixtures/Info-after.plist @@ -0,0 +1,62 @@ + + + + + CFBundleDevelopmentRegion + en + CFBundleDisplayName + BugsnagReactNativeCliTest + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + APPL + CFBundleShortVersionString + 1.0 + CFBundleSignature + ???? + CFBundleVersion + 1 + LSRequiresIPhoneOS + + NSAppTransportSecurity + + NSAllowsArbitraryLoads + + NSExceptionDomains + + localhost + + NSExceptionAllowsInsecureHTTPLoads + + + + + NSLocationWhenInUseUsageDescription + + UILaunchStoryboardName + LaunchScreen + UIRequiredDeviceCapabilities + + armv7 + + UISupportedInterfaceOrientations + + UIInterfaceOrientationPortrait + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + UIViewControllerBasedStatusBarAppearance + + bugsnag + + apiKey + API_KEY_GOES_HERE + + + diff --git a/packages/react-native-cli/src/lib/__test__/fixtures/Info-before.plist b/packages/react-native-cli/src/lib/__test__/fixtures/Info-before.plist new file mode 100644 index 0000000000..2300fb96fc --- /dev/null +++ b/packages/react-native-cli/src/lib/__test__/fixtures/Info-before.plist @@ -0,0 +1,57 @@ + + + + + CFBundleDevelopmentRegion + en + CFBundleDisplayName + BugsnagReactNativeCliTest + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + APPL + CFBundleShortVersionString + 1.0 + CFBundleSignature + ???? + CFBundleVersion + 1 + LSRequiresIPhoneOS + + NSAppTransportSecurity + + NSAllowsArbitraryLoads + + NSExceptionDomains + + localhost + + NSExceptionAllowsInsecureHTTPLoads + + + + + NSLocationWhenInUseUsageDescription + + UILaunchStoryboardName + LaunchScreen + UIRequiredDeviceCapabilities + + armv7 + + UISupportedInterfaceOrientations + + UIInterfaceOrientationPortrait + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + UIViewControllerBasedStatusBarAppearance + + + diff --git a/packages/react-native-cli/src/lib/lib-plist.d.ts b/packages/react-native-cli/src/lib/lib-plist.d.ts new file mode 100644 index 0000000000..2ec273eed5 --- /dev/null +++ b/packages/react-native-cli/src/lib/lib-plist.d.ts @@ -0,0 +1,7 @@ +// Types for the module "plist", since it doesn't ship with them, nor are there any available on Definitely Typed. +// These definitions are not complete, and only specify the parts of the library we interact with. + +declare module 'plist' { + export function parse (str: string): Record + export function build (obj: Record, opts?: { indent?: string, indentSize?: number, offset?: number }): string +} From cc73e4c470bc7f01df3111f155402eb75b6089ee Mon Sep 17 00:00:00 2001 From: Ben Gourley Date: Wed, 25 Nov 2020 15:44:28 +0000 Subject: [PATCH 2/2] refactor(react-native-cli): Be strict about API key format --- packages/react-native-cli/src/commands/ConfigureCommand.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/react-native-cli/src/commands/ConfigureCommand.ts b/packages/react-native-cli/src/commands/ConfigureCommand.ts index 7b4883cb2d..6abaadebce 100644 --- a/packages/react-native-cli/src/commands/ConfigureCommand.ts +++ b/packages/react-native-cli/src/commands/ConfigureCommand.ts @@ -13,7 +13,7 @@ export default async function run (argv: string[], opts: Record name: 'apiKey', message: 'What is your Bugsnag API key?', validate: value => { - return value.length > 1 + return /[A-Fa-f0-9]{32}/.test(value) ? true : 'API key is required. You can find it by going to https://app.bugsnag.com/settings/ > Projects' }