Skip to content

Commit

Permalink
Merge pull request #1144 from bugsnag/feature/rn-cli-configure-cmd
Browse files Browse the repository at this point in the history
[PLAT-4934] Add configure command to RN CLI
  • Loading branch information
bengourley authored Nov 27, 2020
2 parents 23e96aa + cc73e4c commit 010b4ed
Show file tree
Hide file tree
Showing 12 changed files with 482 additions and 1 deletion.
1 change: 1 addition & 0 deletions packages/react-native-cli/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down
5 changes: 4 additions & 1 deletion packages/react-native-cli/src/bin/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = [
{
Expand Down Expand Up @@ -37,9 +38,11 @@ export default async function run (argv: string[]): Promise<void> {
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
Expand Down
30 changes: 30 additions & 0 deletions packages/react-native-cli/src/commands/ConfigureCommand.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown>): Promise<void> {
const projectRoot = process.cwd()

try {
const { apiKey } = await prompts({
type: 'text',
name: 'apiKey',
message: 'What is your Bugsnag API key?',
validate: value => {
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'
}
}, { 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)
}
}
44 changes: 44 additions & 0 deletions packages/react-native-cli/src/lib/AndroidManifest.ts
Original file line number Diff line number Diff line change
@@ -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<void> {
const manifestPath = path.join(projectRoot, 'android', 'app', 'src', 'main', 'AndroidManifest.xml')
try {
const manifest = await fs.readFile(manifestPath, 'utf8')
const activityStartMatch = /(\s*)<activity/.exec(manifest)
const appEndMatch = APP_END_REGEX.exec(manifest)
if (manifest.includes('com.bugsnag.android.API_KEY')) {
logger.warn('API key is already present, skipping')
return
}
if (!activityStartMatch || !appEndMatch) {
logger.warn(MATCH_FAIL_MSG)
return
}
const activityStartIndent = activityStartMatch[1]
const updatedManifest = manifest.replace(
APP_END_REGEX,
`${activityStartIndent}<meta-data android:name="com.bugsnag.android.API_KEY" android:value="${apiKey}" />${appEndMatch}`
)
await fs.writeFile(manifestPath, updatedManifest, 'utf8')
logger.success('Updated AndroidManifest.xml')
} catch (e) {
logger.warn(UNLOCATED_PROJ_MSG)
}
}
41 changes: 41 additions & 0 deletions packages/react-native-cli/src/lib/InfoPlist.ts
Original file line number Diff line number Diff line change
@@ -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<void> {
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)
}
}
90 changes: 90 additions & 0 deletions packages/react-native-cli/src/lib/__test__/AndroidManifest.test.ts
Original file line number Diff line number Diff line change
@@ -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<typeof fs.readFile>
readFileMock.mockResolvedValue(androidManifest)

const writeFileMock = fs.writeFile as jest.MockedFunction<typeof fs.writeFile>
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<typeof fs.readFile>
readFileMock.mockResolvedValue(androidManifest)

const writeFileMock = fs.writeFile as jest.MockedFunction<typeof fs.writeFile>
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 =
`<manifest xmlns:android="http://schemas.android.com/apk/res/android" package="com.bugsnagreactnativeclitest">
<application/>
</manifest>`

const readFileMock = fs.readFile as jest.MockedFunction<typeof fs.readFile>
readFileMock.mockResolvedValue(androidManifest)

const writeFileMock = fs.writeFile as jest.MockedFunction<typeof fs.writeFile>

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<typeof fs.readFile>
readFileMock.mockRejectedValue(await generateNotFoundError())

const writeFileMock = fs.writeFile as jest.MockedFunction<typeof fs.writeFile>

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')
)
})
91 changes: 91 additions & 0 deletions packages/react-native-cli/src/lib/__test__/InfoPlist.test.ts
Original file line number Diff line number Diff line change
@@ -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<string[]>
const readdirMock = fs.readdir as unknown as jest.MockedFunction<readdir>
readdirMock.mockResolvedValue(['BugsnagReactNativeCliTest.xcodeproj'])

const infoPlist = await loadFixture(path.join(__dirname, 'fixtures', 'Info-before.plist'))
const readFileMock = fs.readFile as jest.MockedFunction<typeof fs.readFile>
readFileMock.mockResolvedValue(infoPlist)

const writeFileMock = fs.writeFile as jest.MockedFunction<typeof fs.writeFile>
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<string[]>
const readdirMock = fs.readdir as unknown as jest.MockedFunction<readdir>
readdirMock.mockResolvedValue(['floop'])

const readFileMock = fs.readFile as jest.MockedFunction<typeof fs.readFile>
const writeFileMock = fs.writeFile as jest.MockedFunction<typeof fs.writeFile>

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<string[]>
const readdirMock = fs.readdir as unknown as jest.MockedFunction<readdir>
readdirMock.mockRejectedValue(await generateNotFoundError())

const readFileMock = fs.readFile as jest.MockedFunction<typeof fs.readFile>
const writeFileMock = fs.writeFile as jest.MockedFunction<typeof fs.writeFile>

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<string[]>
const readdirMock = fs.readdir as unknown as jest.MockedFunction<readdir>
readdirMock.mockResolvedValue(['BugsnagReactNativeCliTest.xcodeproj'])

const infoPlist = 'not xml'
const readFileMock = fs.readFile as jest.MockedFunction<typeof fs.readFile>
readFileMock.mockResolvedValue(infoPlist)

const writeFileMock = fs.writeFile as jest.MockedFunction<typeof fs.writeFile>

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.'))
})
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
package="com.bugsnagreactnativeclitest">

<uses-permission android:name="android.permission.INTERNET" />

<application
android:name=".MainApplication"
android:label="@string/app_name"
android:icon="@mipmap/ic_launcher"
android:roundIcon="@mipmap/ic_launcher_round"
android:allowBackup="false"
android:theme="@style/AppTheme">
<activity
android:name=".MainActivity"
android:label="@string/app_name"
android:configChanges="keyboard|keyboardHidden|orientation|screenSize|uiMode"
android:launchMode="singleTask"
android:windowSoftInputMode="adjustResize">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
<activity android:name="com.facebook.react.devsupport.DevSettingsActivity" />
<meta-data android:name="com.bugsnag.android.API_KEY" android:value="API_KEY_GOES_HERE" />
</application>

</manifest>
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
package="com.bugsnagreactnativeclitest">

<uses-permission android:name="android.permission.INTERNET" />

<application
android:name=".MainApplication"
android:label="@string/app_name"
android:icon="@mipmap/ic_launcher"
android:roundIcon="@mipmap/ic_launcher_round"
android:allowBackup="false"
android:theme="@style/AppTheme">
<activity
android:name=".MainActivity"
android:label="@string/app_name"
android:configChanges="keyboard|keyboardHidden|orientation|screenSize|uiMode"
android:launchMode="singleTask"
android:windowSoftInputMode="adjustResize">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
<activity android:name="com.facebook.react.devsupport.DevSettingsActivity" />
</application>

</manifest>
Loading

0 comments on commit 010b4ed

Please sign in to comment.