Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[PLAT-4934] Add configure command to RN CLI #1144

Merged
merged 2 commits into from
Nov 27, 2020
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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')
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This works fine with react native's project template but may break if developers reorganise their project structure. Determining the path 100% correctly would involve parsing the Xcode project and finding the INFOPLIST_FILE build setting for the app target... tricky to implement, so this current solution is probably good enough!

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