├── .github └── workflows │ ├── ci.yml │ ├── dynamic-security.yml │ └── scheduled_expo_build.yml ├── .gitignore ├── .husky └── pre-commit ├── .node-version ├── .prettierignore ├── .prettierrc ├── .tool-versions ├── .yarnrc ├── CODEOWNERS ├── CODE_OF_CONDUCT.md ├── CONTRIBUTING.md ├── LICENSE ├── README.md ├── SECURITY.md ├── __mocks__ ├── child_process.js ├── fs-extra.js └── process.js ├── bin ├── belt.js ├── check-gitignore.js ├── checks │ └── build-with-latest-expo.check.js ├── generate-gitignore.js ├── sync-from-app.js └── util │ └── gitignoreUtil.js ├── package.json ├── src ├── __tests__ │ └── testUtils.ts ├── cli.ts ├── commands │ ├── __tests__ │ │ ├── createApp.test.ts │ │ ├── notifications.test.ts │ │ ├── testingLibrary.test.ts │ │ └── typescript.test.ts │ ├── createApp.ts │ ├── eslint.ts │ ├── notifications.ts │ ├── prettier.ts │ ├── testingLibrary.ts │ └── typescript.ts ├── constants.ts ├── index.ts ├── types.ts ├── types │ ├── Global.d.ts │ └── fs-extra.d.ts └── util │ ├── __tests__ │ ├── getPackageManager.test.ts │ └── validateAndSanitizeAppName.test.ts │ ├── addDependency.ts │ ├── addExpoConfig.ts │ ├── addPackageJsonScripts.ts │ ├── addToGitignore.ts │ ├── appendToFile.ts │ ├── buildAction.ts │ ├── commit.ts │ ├── copyTemplate.ts │ ├── copyTemplateDirectory.ts │ ├── exec.ts │ ├── formatFile.ts │ ├── getPackageManager.ts │ ├── getProjectDir.ts │ ├── getProjectType.ts │ ├── getUserPackageManager.ts │ ├── injectHooks.ts │ ├── isEslintConfigured.ts │ ├── isExpo.ts │ ├── isPackageInstalled.ts │ ├── isPrettierConfigured.ts │ ├── print.ts │ ├── print │ ├── __tests__ │ │ ├── copyTemplateDirectory.test.ts │ │ └── printWelcome.test.ts │ └── printWelcome.ts │ ├── readAppJson.ts │ ├── readPackageJson.ts │ ├── validateAndSanitizeAppName.ts │ └── writeFile.ts ├── templates ├── boilerplate │ ├── .eslintignore │ ├── .eslintrc.js │ ├── .gitignore │ ├── .gitignore.eta │ ├── .prettierignore │ ├── .prettierrc │ ├── App.tsx │ ├── __mocks__ │ │ └── react-native-keyboard-aware-scroll-view.tsx │ ├── app.json │ ├── assets │ │ ├── adaptive-icon.png │ │ ├── favicon.png │ │ ├── icon.png │ │ └── splash.png │ ├── babel.config.js │ ├── jest.config.js │ ├── jest.setup.js │ ├── package.json │ ├── src │ │ ├── __tests__ │ │ │ └── App.integration.test.tsx │ │ ├── components │ │ │ ├── Providers.tsx │ │ │ ├── Screen.tsx │ │ │ ├── Text.tsx │ │ │ └── buttons │ │ │ │ └── PrimaryButton.tsx │ │ ├── navigators │ │ │ ├── AboutStack.tsx │ │ │ ├── DashboardStack.tsx │ │ │ ├── RootNavigator.tsx │ │ │ ├── TabNavigator.tsx │ │ │ └── navigatorTypes.tsx │ │ ├── screens │ │ │ ├── AboutScreen │ │ │ │ └── AboutScreen.tsx │ │ │ ├── HomeScreen │ │ │ │ ├── HomeScreen.tsx │ │ │ │ └── HomeScreenContent.tsx │ │ │ ├── InformationScreen │ │ │ │ └── InformationScreen.tsx │ │ │ └── SettingsScreen │ │ │ │ └── SettingsScreen.tsx │ │ ├── test │ │ │ ├── fileMock.js │ │ │ ├── mock.ts │ │ │ ├── render.tsx │ │ │ ├── server.ts │ │ │ ├── sleep.ts │ │ │ └── waitForUpdates.ts │ │ ├── theme │ │ │ ├── colors.ts │ │ │ └── useTheme.ts │ │ ├── types │ │ │ └── global.d.ts │ │ └── util │ │ │ └── api │ │ │ ├── api.ts │ │ │ └── queryClient.ts │ └── tsconfig.json ├── eslint │ ├── .eslintignore │ └── .eslintrc.js.eta ├── notifications │ └── src │ │ ├── hooks │ │ └── useNotifications.ts │ │ └── util │ │ └── requestNotificationPermission.ts ├── prettier │ ├── .prettierignore.eta │ └── .prettierrc ├── testingLibrary │ ├── jest.config.js │ ├── jest.setup.js.eta │ └── src │ │ ├── __tests__ │ │ └── App.test.tsx │ │ └── test │ │ ├── fileMock.js │ │ └── render.tsx └── typescript │ └── tsconfig.json ├── tsconfig.json ├── tsup.config.ts ├── vitest.config.ts ├── vitest.setup.js └── yarn.lock /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: Belt CI 2 | 3 | on: 4 | push: 5 | branches: [main] 6 | pull_request: 7 | 8 | jobs: 9 | build: 10 | runs-on: ubuntu-latest 11 | 12 | strategy: 13 | matrix: 14 | node-version: [18.x, 20.x] 15 | 16 | steps: 17 | - uses: actions/checkout@v3 18 | - name: Use Node.js ${{ matrix.node-version }} 19 | uses: actions/setup-node@v3 20 | with: 21 | node-version: ${{ matrix.node-version }} 22 | - name: Install dependencies 23 | run: yarn install --frozen-lockfile 24 | - name: Run ESLint, Prettier, tsc 25 | run: yarn lint 26 | - name: Run tests 27 | run: yarn test:run 28 | - name: Build with latest Expo 29 | run: node bin/checks/build-with-latest-expo.check.js 30 | -------------------------------------------------------------------------------- /.github/workflows/dynamic-security.yml: -------------------------------------------------------------------------------- 1 | name: update-security 2 | 3 | on: 4 | push: 5 | paths: 6 | - SECURITY.md 7 | branches: 8 | - main 9 | workflow_dispatch: 10 | 11 | jobs: 12 | update-security: 13 | permissions: 14 | contents: write 15 | pull-requests: write 16 | pages: write 17 | uses: thoughtbot/templates/.github/workflows/dynamic-security.yaml@main 18 | secrets: 19 | token: ${{ secrets.GITHUB_TOKEN }} 20 | -------------------------------------------------------------------------------- /.github/workflows/scheduled_expo_build.yml: -------------------------------------------------------------------------------- 1 | name: Scheduled Expo Build 2 | 3 | on: 4 | schedule: 5 | - cron: '0 0 * * *' 6 | 7 | jobs: 8 | build: 9 | runs-on: ubuntu-latest 10 | 11 | strategy: 12 | matrix: 13 | node-version: [18.x, 20.x] 14 | 15 | steps: 16 | - uses: actions/checkout@v4 17 | - name: Use Node.js ${{ matrix.node-version }} 18 | uses: actions/setup-node@v4 19 | with: 20 | node-version: ${{ matrix.node-version }} 21 | - name: Install dependencies 22 | run: yarn --frozen-lockfile 23 | - name: Build with latest Expo 24 | run: node bin/checks/build-with-latest-expo.check.js 25 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | package-lock.json 3 | yarn-error.log* 4 | yarn-debug.log* 5 | npm-debug.log 6 | .idea/* 7 | .vscode/* 8 | .DS_Store 9 | /dist 10 | /builds 11 | ios/ 12 | android/ 13 | -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | node bin/check-gitignore.js 2 | -------------------------------------------------------------------------------- /.node-version: -------------------------------------------------------------------------------- 1 | 20.10.0 2 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | SECURITY.md 2 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "singleQuote": true 3 | } 4 | -------------------------------------------------------------------------------- /.tool-versions: -------------------------------------------------------------------------------- 1 | nodejs 20.10.0 2 | -------------------------------------------------------------------------------- /.yarnrc: -------------------------------------------------------------------------------- 1 | registry "https://registry.npmjs.org/" 2 | -------------------------------------------------------------------------------- /CODEOWNERS: -------------------------------------------------------------------------------- 1 | # Lines starting with '#' are comments. 2 | # Each line is a file pattern followed by one or more owners. 3 | 4 | # More details are here: https://help.github.com/articles/about-codeowners/ 5 | 6 | # The '*' pattern is global owners. 7 | 8 | # Order is important. The last matching pattern has the most precedence. 9 | # The folders are ordered as follows: 10 | 11 | # In each subsection folders are ordered first by depth, then alphabetically. 12 | # This should make it easy to add new rules without breaking existing ones. 13 | 14 | # Global rule: 15 | * @stevehanson @codeofdiego 16 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Code of conduct 2 | 3 | By participating in this project, you agree to abide by the 4 | [thoughtbot code of conduct][1]. 5 | 6 | [1]: https://thoughtbot.com/open-source-code-of-conduct 7 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | We love contributions from everyone. By participating in this project, you agree 4 | to abide by the [code of conduct](./CODE_OF_CONDUCT.md). 5 | 6 | We expect everyone to follow the code of conduct anywhere in thoughtbot's 7 | project codebases, issue trackers, chatrooms, and mailing lists. 8 | 9 | ## Contributing Code 10 | 11 | Fork the repo. 12 | 13 | Install dependencies: 14 | 15 | ```bash 16 | yarn install 17 | ``` 18 | 19 | Run the dev server during development: 20 | 21 | ```bash 22 | yarn dev 23 | ``` 24 | 25 | ## Test changes locally 26 | 27 | You can run Belt locally with: 28 | 29 | ```bash 30 | # build app to /dist (not necessary if already running yarn dev) 31 | yarn build 32 | 33 | node bin/belt.js MyApp 34 | ``` 35 | 36 | Or, with Bun (faster): 37 | 38 | ```bash 39 | # build app to /dist (not necessary if already running yarn dev) 40 | yarn build 41 | 42 | bun bin/belt.js MyApp 43 | ``` 44 | 45 | This generates a new Belt app in builds/MyApp, `cd`s to the directory, runs tests, and then `cd`s back. You can then run that app by `cd`ing to the directory and running `yarn ios` or the desired command. 46 | 47 | ## Common development techniques 48 | 49 | One way to build new features is to generate a new Belt app locally using the command outlined above. You can then build the new feature in the generated app and then copy the changed files back over to Belt. Example: 50 | 51 | ``` 52 | > bun bin/belt.js MyApp 53 | > cd builds/MyApp 54 | # now make some changes 55 | 56 | # now copy changes back into Belt. Go back to Belt project: 57 | > cd ../.. 58 | 59 | # run sync script 60 | > node bin/sync-from-app.js MyApp --dry-run 61 | 62 | # now run without the dry-run flag: 63 | > node bin/sync-from-app.js MyApp 64 | ``` 65 | 66 | ## Creating a pull request 67 | 68 | Make sure the tests pass: 69 | 70 | ```bash 71 | yarn test 72 | ``` 73 | 74 | Before committing your changes, make sure the linter, formatter, and vitest tests all pass: 75 | 76 | ```bash 77 | yarn test:all 78 | ``` 79 | 80 | Additionally, CI runs a check that uses the CLI to build an app and then ensures that the test suite of the new app passes. You can run this locally with: 81 | 82 | ```bash 83 | node bin/checks/build-with-latest-expo.check.js 84 | ``` 85 | 86 | Follow the [style guide][style]. 87 | 88 | [style]: https://github.com/thoughtbot/guides 89 | 90 | Push to your fork. Write a [good commit message][commit]. Submit a pull request. 91 | 92 | [commit]: http://tbaggery.com/2008/04/19/a-note-about-git-commit-messages.html 93 | 94 | Others will give constructive feedback. This is a time for discussion and 95 | improvements, and making the necessary changes will be required before we can 96 | merge the contribution. 97 | 98 | ## Updating Belt dependencies 99 | 100 | It's imperative that we keep dependencies up-to-date. We should monitor Expo and other dependency releases and update Belt when they come available. 101 | 102 | ### Updating Expo 103 | 104 | Updating Expo is straightforward. When a new Expo version is released, open the Belt codebase, then: 105 | 106 | ```bash 107 | cd templates/boilerplate 108 | yarn install 109 | 110 | # install latest Expo 111 | yarn add expo@latest 112 | 113 | # update dependencies to match versions required by latest Expo 114 | npx expo install --fix 115 | ``` 116 | 117 | Now, run the test suite with `yarn test:all` and then generate a new app and verify that it works with: 118 | 119 | ```bash 120 | node bin/belt.js MyApp 121 | ``` 122 | 123 | Manually verify other commands also work (eg. push notifications). Todo: get better tests around these (i.e. tests that build an app and run the new app's test suite). 124 | 125 | ### Updating other dependencies 126 | 127 | We can periodically update all dependencies to the latest versions with: 128 | 129 | ```bash 130 | cd templates/boilerplate 131 | 132 | yarn install 133 | 134 | # with yarn 1 135 | yarn upgrade-interactive --latest 136 | 137 | # or, with yarn 2+ 138 | yarn up --interactive 139 | 140 | # audit and fix any packages that aren't compatible with the installed Expo 141 | npx expo install --fix 142 | ``` 143 | 144 | ### Cutting releases 145 | 146 | To cut a new release: 147 | 148 | ```bash 149 | # increment version (either major/minor/patch), following semver 150 | npm version minor 151 | 152 | # publish with the "beta" tag, good for testing out a release before it's fully ready 153 | # or if there is less certainty about its stability 154 | yarn pub:beta 155 | 156 | # publish as a regular release (the "latest" tag) 157 | yarn pub:release 158 | 159 | # push up the version changes 160 | git push 161 | git push --tags 162 | ``` 163 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2023 Stephen Hanson and thoughtbot, inc. 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy 4 | of this software and associated documentation files (the "Software"), to deal 5 | in the Software without restriction, including without limitation the rights 6 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | copies of the Software, and to permit persons to whom the Software is 8 | furnished to do so, subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in 11 | all copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 19 | THE SOFTWARE. 20 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |
2 | Logo 3 |
4 | 5 | # Belt 6 | 7 | _While we actively use Belt internally at thoughtbot, this project is still in early phases of development, so its API might still change frequently._ 8 | 9 | Belt is an opionated CLI tool for starting a new React Native app. It makes the mundane decisions for you using tooling and conventions that we at thoughtbot have battle-tested and found to work well for the many successful apps we have built for clients. 10 | 11 | Here are some of what gets configured when you start a new Belt app: 12 | 13 | - Expo 14 | - ESLint 15 | - Prettier 16 | - TypeScript 17 | - Jest 18 | - React Native Testing Library 19 | - MSW for mocking 20 | - React Navigation with bottom tabs 21 | - Tanstack Query for REST APIs. Apollo Client for GraphQL coming soon! 22 | - Redux Toolkit for global state (coming soon!) 23 | 24 | ## Usage 25 | 26 | Create a new React Native Expo app using Belt with: 27 | 28 | ```sh 29 | # With NPM 30 | npx create-belt-app MyApp 31 | 32 | # With Yarn 33 | npx create-belt-app MyApp --yarn 34 | 35 | # With pnpm (experimental) 36 | npx create-belt-app MyApp --pnpm 37 | 38 | # With Bun (experimental) 39 | npx create-belt-app MyApp --bun 40 | ``` 41 | 42 | then run the command you'd like to perform: 43 | 44 | ```sh 45 | # eg. add TypeScript to the project 46 | yarn belt add notifications 47 | 48 | # or, with NPM 49 | npx belt add notifications 50 | 51 | # or, with PNPM 52 | pnpm belt add notifications 53 | ``` 54 | 55 | ## Contributing 56 | 57 | See the [CONTRIBUTING](./CONTRIBUTING.md) document. Thank you, [contributors](https://github.com/thoughtbot/belt/graphs/contributors)! 58 | 59 | ## License 60 | 61 | Belt is Copyright © 2024 thoughtbot. It is free software, and may be 62 | redistributed under the terms specified in the [LICENSE](/LICENSE) file. 63 | 64 | ### About thoughtbot 65 | 66 | 67 | 68 | Belt is maintained and funded by thoughtbot, inc. 69 | The names and logos for thoughtbot are trademarks of thoughtbot, inc. 70 | 71 | We love open source software! See [our other projects][community] or 72 | [hire us][hire] to design, develop, and grow your product. 73 | 74 | [community]: https://thoughtbot.com/community?utm_source=github 75 | [hire]: https://thoughtbot.com/hire-us?utm_source=github 76 | -------------------------------------------------------------------------------- /SECURITY.md: -------------------------------------------------------------------------------- 1 | 2 | # Security Policy 3 | 4 | ## Supported Versions 5 | 6 | Only the the latest version of this project is supported at a given time. If 7 | you find a security issue with an older version, please try updating to the 8 | latest version first. 9 | 10 | If for some reason you can't update to the latest version, please let us know 11 | your reasons so that we can have a better understanding of your situation. 12 | 13 | ## Reporting a Vulnerability 14 | 15 | For security inquiries or vulnerability reports, visit 16 | . 17 | 18 | If you have any suggestions to improve this policy, visit . 19 | 20 | 21 | -------------------------------------------------------------------------------- /__mocks__/child_process.js: -------------------------------------------------------------------------------- 1 | import { vi } from 'vitest'; 2 | 3 | export const spawnSync = vi.fn(); 4 | export const execSync = vi.fn(); 5 | export const exec = vi.fn(); 6 | -------------------------------------------------------------------------------- /__mocks__/fs-extra.js: -------------------------------------------------------------------------------- 1 | import fse from 'fs-extra'; 2 | import { fs as memfs } from 'memfs'; 3 | import path from 'path'; 4 | 5 | let DONT_MOCK_PATTERNS = ['templates/']; 6 | 7 | // Most of this CLI uses fs-extra for file-system operations 8 | // This mock mocks this module to instead use memfs, while also allowing 9 | // read operations to read from 'templates' so our tests do not need to 10 | // mock templates 11 | export default { 12 | // allow mocking templates with memfs (normally tests read these from regular FS) 13 | mockTemplates() { 14 | DONT_MOCK_PATTERNS = []; 15 | }, 16 | ...memfs.promises, 17 | exists(path) { 18 | if (dontMock(path)) { 19 | return fse.exists(path); 20 | } else { 21 | return new Promise((resolve) => { 22 | memfs.exists(path, (exists) => resolve(exists)); 23 | }); 24 | } 25 | }, 26 | isDirectory(src) { 27 | return memfs.statSync(src).isDirectory(src); 28 | }, 29 | appendFile: memfs.promises.appendFile, 30 | // currently having to manually copy the sync methods over, there's prob a better way 31 | rmSync: memfs.rmSync, 32 | readFileSync(file, options) { 33 | if (dontMock(file)) { 34 | return fse.readFileSync(file, options); 35 | } 36 | 37 | return memfs.readFileSync(file, options); 38 | }, 39 | outputFile: async (file, data, options) => { 40 | const dirname = path.dirname(file); 41 | const exists = memfs.existsSync(dirname); 42 | if (!exists) { 43 | memfs.mkdirSync(dirname, { recursive: true }); 44 | } 45 | 46 | return Promise.resolve(memfs.writeFileSync(file, data, options)); 47 | }, 48 | writeFileSync: memfs.writeFileSync, 49 | existsSync: memfs.existsSync, 50 | appendFileSync: memfs.appendFileSync, 51 | readdir(path, options) { 52 | return Promise.resolve(this.readdirSync(path, options)); 53 | }, 54 | readdirSync(path, options) { 55 | if (dontMock(path)) { 56 | return fse.readdirSync(path, options); 57 | } 58 | 59 | return memfs.readdirSync(path, options); 60 | }, 61 | copy(src, dest) { 62 | return Promise.resolve(this.copySync(src, dest)); 63 | }, 64 | copySync(src, dest) { 65 | // read templates from actual fs 66 | const sourceFS = dontMock(src) ? fse : memfs; 67 | 68 | memfs.mkdirSync(path.dirname(dest), { recursive: true }); 69 | if (sourceFS.existsSync(src) && sourceFS.statSync(src).isDirectory(src)) { 70 | sourceFS.readdirSync(src).forEach((childItemName) => { 71 | this.copySync( 72 | path.join(src, childItemName), 73 | path.join(dest, childItemName), 74 | ); 75 | }); 76 | } else { 77 | memfs.writeFileSync(dest, sourceFS.readFileSync(src, 'utf-8')); 78 | } 79 | }, 80 | readFile: (path, ...args) => { 81 | if (dontMock(path)) { 82 | return fse.readFile(path, ...args); 83 | } 84 | 85 | return memfs.promises.readFile(path, ...args); 86 | }, 87 | }; 88 | 89 | function dontMock(src) { 90 | return DONT_MOCK_PATTERNS.some((pattern) => src.includes(pattern)); 91 | } 92 | -------------------------------------------------------------------------------- /__mocks__/process.js: -------------------------------------------------------------------------------- 1 | export default { 2 | chdir: vi.fn(), 3 | }; 4 | -------------------------------------------------------------------------------- /bin/belt.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | import { execSync } from 'child_process'; 3 | import fs from 'fs-extra'; 4 | import _ from 'lodash'; 5 | import path from 'path'; 6 | 7 | // executes the CLI locally from the `builds` directory 8 | // this can be useful for troubleshooting 9 | // eg. node bin/belt.js MyApp, or bun bin/belt.js 10 | async function run() { 11 | const dir = './builds'; 12 | 13 | console.log( 14 | "Ensure you've built the app with 'yarn dev' or 'yarn build' first", 15 | ); 16 | 17 | // clean /builds, cd into it 18 | fs.mkdirSync(dir, { recursive: true }); 19 | const rawAppName = process.argv[2]; 20 | const appName = _.upperFirst(_.camelCase((rawAppName || '').trim())); 21 | if (appName && fs.existsSync(path.join(dir, appName))) { 22 | fs.rmSync(path.join(dir, appName), { recursive: true }); 23 | } 24 | process.chdir(dir); 25 | 26 | // run CLI 27 | execSync( 28 | `${getNodeRunner()} ../dist/index.js ${process.argv.slice(2).join(' ')}`, 29 | { 30 | stdio: 'inherit', 31 | }, 32 | ); 33 | 34 | if (appName) { 35 | process.chdir(appName); 36 | execSync(`npm run test:all`, { stdio: 'inherit' }); 37 | 38 | process.chdir('../..'); 39 | console.log(`some commands you might want to run now: 40 | 41 | cd builds/${appName} 42 | cd builds/${appName} && npm run test:all 43 | cd builds/${appName} && npm run ios 44 | code builds/${appName} 45 | `); 46 | } 47 | } 48 | 49 | function getNodeRunner() { 50 | return typeof Bun !== 'undefined' && Bun.env ? 'bun' : 'node'; 51 | } 52 | 53 | void run(); 54 | -------------------------------------------------------------------------------- /bin/check-gitignore.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | import { checkGitIgnoreTemplate } from './util/gitignoreUtil.js'; 3 | 4 | // Check if templates/boilerplate/.gitignore exists and matches the 5 | // .gitignore.eta template. Exits with error if it does not match. 6 | // 7 | // Run 'node bin/generate-gitignore.js' to update the .gitignore file if 8 | // template changed. 9 | checkGitIgnoreTemplate(); 10 | -------------------------------------------------------------------------------- /bin/checks/build-with-latest-expo.check.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | import { execSync } from 'child_process'; 3 | import fs from 'fs-extra'; 4 | import path from 'path'; 5 | 6 | async function runCheck() { 7 | const dir = './builds'; 8 | 9 | if (process.env.CI) { 10 | execSync('git config --global user.email "ci@example.com"', { 11 | stdio: 'inherit', 12 | }); 13 | execSync('git config --global user.name "CI User"', { stdio: 'inherit' }); 14 | 15 | // build to /dist 16 | execSync('yarn build', { stdio: 'inherit' }); 17 | } else { 18 | console.log( 19 | "Ensure you've built the app with 'yarn dev' or 'yarn build' first", 20 | ); 21 | } 22 | 23 | // clean /builds, cd into it 24 | fs.rmSync(path.join(dir, 'ExpoSample'), { recursive: true, force: true }); 25 | fs.mkdirSync(dir, { recursive: true }); 26 | process.chdir(dir); 27 | 28 | const opts = process.env.CI ? '--no-interactive' : ''; 29 | 30 | // run CLI 31 | execSync(`${getNodeRunner()} ../dist/index.js ExpoSample ${opts}`, { 32 | stdio: 'inherit', 33 | }); 34 | 35 | process.chdir('./ExpoSample'); 36 | 37 | console.log(` 38 | ------------------------------------------------ 39 | 40 | New app created. Running tests and linters on the app to verify that it is 41 | working as expected. If the following fails, note that this does not mean the 42 | Belt test suite is broken but rather that the test suite in the app that it 43 | builds is broken. 44 | `); 45 | 46 | // verify linter and tests all pass in new project 47 | const pkgMgr = fs.existsSync('package-lock.json') ? 'npm run' : 'yarn'; 48 | execSync(`${pkgMgr} test:all`, { stdio: 'inherit' }); 49 | 50 | console.log(`--------------------------------------- 51 | Checking that working directory is clean in new app 52 | `); 53 | execSync(`git diff --exit-code`, { stdio: 'inherit' }); 54 | console.log('✅ working directory clean'); 55 | 56 | console.log('All checks have passed!'); 57 | } 58 | 59 | function getNodeRunner() { 60 | return typeof Bun !== 'undefined' && Bun.env ? 'bun' : 'node'; 61 | } 62 | 63 | void runCheck(); 64 | -------------------------------------------------------------------------------- /bin/generate-gitignore.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | import { copyGitIgnoreTemplate } from './util/gitignoreUtil.js'; 3 | 4 | copyGitIgnoreTemplate(); 5 | -------------------------------------------------------------------------------- /bin/sync-from-app.js: -------------------------------------------------------------------------------- 1 | import { execSync } from 'child_process'; 2 | 3 | // copies files over from a sample app generated with `bin/belt.js` 4 | // See CONTRIBUTING.md for more info 5 | function run() { 6 | const appDir = process.argv[2]; 7 | 8 | if (!appDir || appDir.includes('builds')) { 9 | console.error('Please provide an app directory, relative to `builds`'); 10 | console.error('Usage: node bin/sync-from-app.js MyApp --dry-run'); 11 | process.exit(1); 12 | } 13 | 14 | const excludes = [ 15 | 'node_modules', 16 | '.cache', 17 | '.expo', 18 | '.vscode', 19 | 'assets', 20 | '.git', 21 | '.gitignore', 22 | ]; 23 | 24 | const excludesStr = excludes.map((e) => `--exclude ${e}`).join(' '); 25 | 26 | // provide additional flags, eg. --dry-run 27 | const flags = `-avp ${process.argv[3] || ''}`; 28 | 29 | const command = `rsync ${flags} ${excludesStr} builds/${appDir}/ templates/boilerplate/`; 30 | console.log(command); 31 | execSync(command, { 32 | stdio: 'inherit', 33 | }); 34 | 35 | console.log( 36 | "\n\n🎉 Success! Ensure that all files have copied over correctly, remove any unwanted modifications (eg. app.json, package.json, etc), and manually remove any files that need to be deleted (these don't sync)", 37 | ); 38 | } 39 | 40 | run(); 41 | -------------------------------------------------------------------------------- /bin/util/gitignoreUtil.js: -------------------------------------------------------------------------------- 1 | import fs from 'fs-extra'; 2 | 3 | const gitignoreEta = './templates/boilerplate/.gitignore.eta'; 4 | const gitignore = './templates/boilerplate/.gitignore'; 5 | 6 | /** 7 | * There is no way for npm to package the .gitignore file in 8 | * templates/boilerplate/.gitignore. The solution is to instead include a 9 | * differently named file with the package, which we name .gitignore.eta to take 10 | * advantage of the fact that we already parse .eta template files. We then 11 | * verify the .gitignore file and the .gitignore.eta file stay in sync via a 12 | * pre-commit hook. Any time .gitignore.eta is updated, we need to re-generate 13 | * .gitignore. 14 | */ 15 | export function copyGitIgnoreTemplate() { 16 | fs.writeFileSync(gitignore, generateGitignoreContents()); 17 | } 18 | 19 | /** 20 | * check if templates/boilerplate/.gitignore exists and matches the .gitignore.eta template 21 | * @returns exits with error if it does not match 22 | */ 23 | export function checkGitIgnoreTemplate() { 24 | const gitIgnoreContents = fs.readFileSync(gitignore, 'utf-8'); 25 | 26 | if (gitIgnoreContents !== generateGitignoreContents()) { 27 | console.error(` 28 | ---------------------------------------------------------------------------- 29 | 🔔 ERROR: Unable to commit changes 30 | ---------------------------------------------------------------------------- 31 | 32 | Auto-generated file, templates/boilerplate/.gitignore, does not match 33 | .gitignore.eta template. Run: 34 | 35 | node bin/generate-gitignore.js 36 | 37 | to update the .gitignore file. See the comment at the top of 38 | templates/boilerplate/.gitignore for more info. 39 | `); 40 | process.exit(1); 41 | } 42 | } 43 | 44 | /** 45 | * generate templates/boilerplate/.gitignore contents from .gitignore.eta template 46 | */ 47 | export function generateGitignoreContents() { 48 | const gitIgnoreContents = fs.readFileSync(gitignoreEta, 'utf-8'); 49 | 50 | return `# ----------------------------------------------------------------------------- 51 | # This file is generated from .gitignore.eta in bin/util/gitignoreUtil.js, do 52 | # not modify directly 53 | # ----------------------------------------------------------------------------- 54 | 55 | # this file is not packaged to npm, because npm does not support packaging 56 | # .gitignore files. we package .gitignore.eta instead. We still want to have a 57 | # .gitignore file present though for two reasons: 58 | # 59 | # - The belt repo uses this file to ignore files that should not be committed 60 | # - NPM uses this file to exclude files from the NPM package 61 | # 62 | # This file is auto-generated in a pre-commit hook. See bin/generate-gitignore.js 63 | 64 | # ----------------------------------------------------------------------------- 65 | # The following are not in .gitignore.eta because we don't want them to be 66 | # ignored in generated Belt apps. But we do want to ignore them in the Belt repo 67 | # and the npm package, so we add them. 68 | # 69 | # This file is auto-generated. To make changes here, update it in 70 | # bin/util/gitignoreUtil.js 71 | # ----------------------------------------------------------------------------- 72 | 73 | bun.lockb 74 | yarn.lock 75 | package-lock.json 76 | pnpm-lock.yaml 77 | 78 | # ----------------------------------------------------------------------------- 79 | # begin .gitignore contents from .gitignore.eta 80 | # ----------------------------------------------------------------------------- 81 | 82 | ${gitIgnoreContents}`; 83 | } 84 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "create-belt-app", 3 | "author": "thoughtbot, Inc.", 4 | "license": "MIT", 5 | "version": "0.7.4", 6 | "description": "React Native Expo project starter and generator CLI", 7 | "repository": { 8 | "type": "git", 9 | "url": "git+https://github.com/thoughtbot/belt.git" 10 | }, 11 | "homepage": "https://github.com/thoughtbot/belt#readme", 12 | "type": "module", 13 | "exports": "./dist/index.js", 14 | "bin": { 15 | "create-belt-app": "./dist/index.js", 16 | "belt": "./dist/index.js" 17 | }, 18 | "files": [ 19 | "dist", 20 | "templates", 21 | "README.md", 22 | "LICENSE", 23 | "CONTRIBUTING.md", 24 | "package.json" 25 | ], 26 | "scripts": { 27 | "build": "tsup", 28 | "dev": "tsup --watch", 29 | "clean": "rm -rf dist node_modules", 30 | "start": "node dist/index.js", 31 | "lint": "run-p lint:eslint lint:types lint:prettier", 32 | "lint:eslint": "eslint --max-warnings=0 --ext js,jsx,ts,tsx .", 33 | "lint:prettier": "prettier --check '**/*' --ignore-unknown", 34 | "lint:types": "tsc", 35 | "fix:prettier": "prettier --write '**/*' --ignore-unknown", 36 | "test": "vitest", 37 | "test:run": "vitest run", 38 | "test:all": "yarn lint && yarn test:run", 39 | "pub:beta": "yarn build && npm publish --tag beta", 40 | "pub:release": "yarn build && npm publish", 41 | "prepare": "husky" 42 | }, 43 | "dependencies": { 44 | "@expo/config": "^8.5.4", 45 | "@inquirer/prompts": "^3.2.0", 46 | "@thoughtbot/eslint-config": "^1.0.2", 47 | "chalk": "^5.2.0", 48 | "commander": "^10.0.1", 49 | "eslint": "^8.45.0", 50 | "eta": "^2.1.1", 51 | "fs-extra": "^11.1.1", 52 | "ignore": "^5.3.1", 53 | "lodash": "^4.17.21", 54 | "ora": "^6.3.0", 55 | "prettier": "^3.0.1", 56 | "ts-node": "^10.9.1" 57 | }, 58 | "devDependencies": { 59 | "@types/fs-extra": "^11.0.3", 60 | "@types/lodash": "^4.17.0", 61 | "@types/node": "^18.16.3", 62 | "@types/react": "^18.2.6", 63 | "husky": "^9.0.11", 64 | "memfs": "^4.5.1", 65 | "npm-run-all": "^4.1.5", 66 | "tsup": "^7.2.0", 67 | "typescript": "^5.0.4", 68 | "vitest": "^2.0.5" 69 | }, 70 | "eslintConfig": { 71 | "extends": [ 72 | "@thoughtbot/eslint-config/base", 73 | "@thoughtbot/eslint-config/prettier", 74 | "@thoughtbot/eslint-config/typescript" 75 | ], 76 | "ignorePatterns": [ 77 | "templates", 78 | "__mocks__/**/*.js", 79 | "/bin", 80 | "/dist", 81 | "/builds", 82 | "vitest.setup.js" 83 | ], 84 | "rules": { 85 | "no-console": "off", 86 | "import/order": "off", 87 | "no-continue": "off" 88 | } 89 | } 90 | } 91 | -------------------------------------------------------------------------------- /src/__tests__/testUtils.ts: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/thoughtbot/belt/77a9408e2bdcbbebafe4b06f8ecbd242d15b1e8f/src/__tests__/testUtils.ts -------------------------------------------------------------------------------- /src/cli.ts: -------------------------------------------------------------------------------- 1 | import { program } from 'commander'; 2 | import buildAction from './util/buildAction'; 3 | import printWelcome from './util/print/printWelcome'; 4 | 5 | export default function runCli() { 6 | program 7 | .name('Belt') 8 | .description( 9 | 'Perform React Native and Expo setup and redundant tasks without your pants falling down!', 10 | ) 11 | .showHelpAfterError(); 12 | 13 | program 14 | .command('create', { isDefault: true }) 15 | .description('Create new app') 16 | .argument( 17 | '[appName]', 18 | 'The name of the app and directory it will be created in', 19 | '', 20 | ) 21 | .option('--bun', 'Use Bun package manager') 22 | .option('--yarn', 'Use Yarn package manager') 23 | .option('--pnpm', 'Use PNPM package manager') 24 | .option('--npm', 'Use NPM package manager') 25 | .option('--no-interactive', 'Pass true to skip all prompts') 26 | .action(buildAction(import('./commands/createApp'))); 27 | 28 | program 29 | .command('eslint') 30 | .description('Configure ESLint') 31 | .action(buildAction(import('./commands/eslint'))); 32 | 33 | program 34 | .command('prettier') 35 | .description('Configure Prettier') 36 | .action(buildAction(import('./commands/prettier'))); 37 | 38 | program 39 | .command('typescript') 40 | .description('Install and configure TypeScript') 41 | .action(buildAction(import('./commands/typescript'))); 42 | 43 | program 44 | .command('testing') 45 | .description('Install and configure Jest and Testing Library') 46 | .action(buildAction(import('./commands/testingLibrary'))); 47 | 48 | program 49 | .command('add') 50 | .description('Add a new feature to your project') 51 | .command('notifications') 52 | .description( 53 | 'Install and configure React Native Firebase with Notifications', 54 | ) 55 | .option( 56 | '--bundleId', 57 | 'The bundle identifier for your app to be used on both iOS and Android', 58 | ) 59 | .option( 60 | '--no-interactive', 61 | 'Pass true to skip all prompts and use default values', 62 | ) 63 | .action(buildAction(import('./commands/notifications'))); 64 | 65 | printWelcome(); 66 | program.parse(); 67 | } 68 | -------------------------------------------------------------------------------- /src/commands/__tests__/createApp.test.ts: -------------------------------------------------------------------------------- 1 | import { confirm, input } from '@inquirer/prompts'; 2 | import { fs, vol } from 'memfs'; 3 | import { Mock, afterEach, describe, expect, test, vi } from 'vitest'; 4 | import exec from '../../util/exec'; 5 | import print from '../../util/print'; 6 | import { createApp } from '../createApp'; 7 | 8 | vi.mock('@inquirer/prompts', () => ({ 9 | input: vi.fn(), 10 | confirm: vi.fn(), 11 | })); 12 | vi.mock('../../util/addDependency'); 13 | vi.mock('../../util/print', () => ({ default: vi.fn() })); 14 | 15 | afterEach(() => { 16 | vol.reset(); 17 | (print as Mock).mockReset(); 18 | }); 19 | 20 | test('creates app, substituting the app name where appropriate', async () => { 21 | (confirm as Mock).mockResolvedValueOnce(true); 22 | vol.fromJSON({ 'file.txt': '{}' }, './'); 23 | await createApp('MyApp'); 24 | 25 | expectFileContents('MyApp/package.json', '"name": "my-app"'); 26 | expectFileContents('MyApp/app.json', '"name": "MyApp"'); 27 | expectFileContents('MyApp/app.json', '"slug": "MyApp"'); 28 | expect(exec).toHaveBeenCalledWith('yarn install'); 29 | }); 30 | 31 | test('prompts for app name if not supplied', async () => { 32 | (confirm as Mock).mockResolvedValueOnce(true); 33 | (input as Mock).mockReturnValue('MyApp'); 34 | await createApp(undefined); 35 | 36 | expectFileContents('MyApp/package.json', '"name": "my-app"'); 37 | expectFileContents('MyApp/app.json', '"name": "MyApp"'); 38 | expectFileContents('MyApp/app.json', '"slug": "MyApp"'); 39 | expect(exec).toHaveBeenCalledWith('yarn install'); 40 | }); 41 | 42 | test('exits if directory already exists', async () => { 43 | (print as Mock).mockReset(); 44 | vi.spyOn(process, 'exit'); 45 | process.exit = vi.fn(); 46 | 47 | vol.fromJSON({ 'MyApp/package.json': '{}' }, './'); 48 | 49 | await createApp('my-app'); // gets sanitized to MyApp 50 | 51 | expect(print).toHaveBeenCalledWith(expect.stringMatching(/already exists/)); 52 | // eslint-disable-next-line @typescript-eslint/unbound-method 53 | expect(process.exit).toHaveBeenCalledWith(0); 54 | }); 55 | 56 | test('converts directory to camel case and strips special characters', async () => { 57 | (confirm as Mock).mockResolvedValueOnce(true); 58 | vol.fromJSON({ 'file.txt': '{}' }, './'); 59 | await createApp('my-$%-app'); 60 | 61 | expectFileContents('MyApp/package.json', '"name": "my-app"'); 62 | expectFileContents('MyApp/app.json', '"name": "MyApp"'); 63 | expectFileContents('MyApp/app.json', '"slug": "MyApp"'); 64 | expect(exec).toHaveBeenCalledWith('yarn install'); 65 | }); 66 | 67 | test('exits if app name does not start with a letter', async () => { 68 | (print as Mock).mockReset(); 69 | vi.spyOn(process, 'exit'); 70 | process.exit = vi.fn(); 71 | vol.fromJSON({ 'MyApp/package.json': '{}' }, './'); 72 | 73 | await createApp('555MyApp'); 74 | 75 | expect(print).toHaveBeenCalledWith( 76 | expect.stringMatching('App name must start with a letter.'), 77 | ); 78 | // eslint-disable-next-line @typescript-eslint/unbound-method 79 | expect(process.exit).toHaveBeenCalledWith(0); 80 | }); 81 | 82 | describe('package manager options', () => { 83 | test('creates with NPM', async () => { 84 | (confirm as Mock).mockResolvedValueOnce(true); 85 | await createApp('MyApp', { npm: true }); 86 | expect(exec).toHaveBeenCalledWith('npm install'); 87 | }); 88 | 89 | test('creates with bun', async () => { 90 | (confirm as Mock).mockResolvedValueOnce(true); 91 | await createApp('MyApp', { bun: true }); 92 | expect(exec).toHaveBeenCalledWith('bun install'); 93 | }); 94 | 95 | test('creates with pnpm', async () => { 96 | (confirm as Mock).mockResolvedValueOnce(true); 97 | await createApp('MyApp', { pnpm: true }); 98 | expect(exec).toHaveBeenCalledWith('pnpm install'); 99 | }); 100 | }); 101 | 102 | function expectFileContents(file: string, expected: string) { 103 | try { 104 | const contents = fs.readFileSync(file, 'utf8'); 105 | expect(contents).toMatch(expected); 106 | } catch (error) { 107 | Error.captureStackTrace(error as Error, expectFileContents); // remove this function from stack trace 108 | throw error; 109 | } 110 | } 111 | -------------------------------------------------------------------------------- /src/commands/__tests__/notifications.test.ts: -------------------------------------------------------------------------------- 1 | import { confirm, input } from '@inquirer/prompts'; 2 | import { fs, vol } from 'memfs'; 3 | import { Mock, expect, test, vi } from 'vitest'; 4 | import exec from '../../util/exec'; 5 | import { addNotifications } from '../notifications'; 6 | 7 | vi.mock('../../util/print', () => ({ default: vi.fn() })); 8 | 9 | vi.mock('@inquirer/prompts', () => ({ 10 | input: vi.fn(), 11 | confirm: vi.fn(), 12 | })); 13 | vi.mock('../../util/exec'); 14 | 15 | test('install React Native Firebase and dependencies', async () => { 16 | (input as Mock).mockResolvedValueOnce('com.myapp'); 17 | (input as Mock).mockResolvedValueOnce('com.myapp'); 18 | (confirm as Mock).mockResolvedValueOnce(true); 19 | const json = { 20 | 'package.json': JSON.stringify({ 21 | scripts: {}, 22 | dependencies: {}, 23 | devDependencies: {}, 24 | }), 25 | 'yarn.lock': '', 26 | 'App.tsx': '// CODEGEN:BELT:HOOKS - do not remove', 27 | 'app.json': JSON.stringify({}), 28 | }; 29 | vol.fromJSON(json, './'); 30 | 31 | await addNotifications(); 32 | 33 | expect(exec).toHaveBeenCalledWith( 34 | 'npx expo install @react-native-firebase/app @react-native-firebase/messaging expo-build-properties', 35 | ); 36 | 37 | expect(fs.existsSync('./src/hooks/useNotifications.ts')).toBe(true); 38 | const app = fs.readFileSync('App.tsx', 'utf8'); 39 | expect(app).toMatch( 40 | "import useNotifications from 'src/hooks/useNotifications';", 41 | ); 42 | expect(app).toMatch( 43 | '// CODEGEN:BELT:HOOKS - do not remove\nuseNotifications();', 44 | ); 45 | 46 | const config = fs.readFileSync('app.json', 'utf8'); 47 | expect(config).toMatch( 48 | '"googleServicesFile":"./config/google-services.json"', 49 | ); 50 | expect(config).toMatch( 51 | '"googleServicesFile":"./config/GoogleService-Info.plist"', 52 | ); 53 | expect(config).toMatch('"package":"com.myapp"'); 54 | expect(config).toMatch('plugins'); 55 | }); 56 | 57 | test('add plugins to app.json expo config preserves existing ones', async () => { 58 | (confirm as Mock).mockResolvedValueOnce(true); 59 | const json = { 60 | 'package.json': JSON.stringify({ 61 | scripts: {}, 62 | dependencies: {}, 63 | devDependencies: {}, 64 | }), 65 | 'yarn.lock': '', 66 | 'app.json': JSON.stringify({ 67 | expo: { 68 | plugins: [ 69 | '@react-native-firebase/auth', 70 | ['expo-build-properties', { config: 'test' }], 71 | ], 72 | }, 73 | }), 74 | }; 75 | vol.fromJSON(json, './'); 76 | 77 | await addNotifications(); 78 | 79 | const config = fs.readFileSync('app.json', 'utf8'); 80 | expect(config).toMatch('"@react-native-firebase/auth"'); 81 | expect(config).toMatch('"@react-native-firebase/app"'); 82 | expect(config).toMatch('"@react-native-firebase/messaging"'); 83 | expect(config).toMatch('"expo-build-properties"'); 84 | }); 85 | 86 | test('adds package name and bundle identifier from bundleId option', async () => { 87 | (confirm as Mock).mockResolvedValueOnce(true); 88 | const json = { 89 | 'package.json': JSON.stringify({ 90 | scripts: {}, 91 | dependencies: {}, 92 | devDependencies: {}, 93 | }), 94 | 'yarn.lock': '', 95 | 'app.json': JSON.stringify({}), 96 | }; 97 | vol.fromJSON(json, './'); 98 | 99 | await addNotifications({ bundleId: 'com.myapp' }); 100 | 101 | const config = fs.readFileSync('app.json', 'utf8'); 102 | expect(config).toMatch('"package":"com.myapp"'); 103 | expect(config).toMatch('"bundleIdentifier":"com.myapp"'); 104 | }); 105 | 106 | test('preserves existing package name and bundle identifier when bundleId is passed', async () => { 107 | (confirm as Mock).mockResolvedValueOnce(true); 108 | const json = { 109 | 'package.json': JSON.stringify({ 110 | scripts: {}, 111 | dependencies: {}, 112 | devDependencies: {}, 113 | }), 114 | 'yarn.lock': '', 115 | 'app.json': JSON.stringify({ 116 | expo: { 117 | android: { 118 | package: 'com.myexistingapp', 119 | }, 120 | ios: { 121 | bundleIdentifier: 'com.myexistingapp', 122 | }, 123 | }, 124 | }), 125 | }; 126 | vol.fromJSON(json, './'); 127 | 128 | await addNotifications({ bundleId: 'com.myapp' }); 129 | 130 | const config = fs.readFileSync('app.json', 'utf8'); 131 | expect(config).toMatch('"package":"com.myexistingapp"'); 132 | expect(config).toMatch('"bundleIdentifier":"com.myexistingapp"'); 133 | }); 134 | -------------------------------------------------------------------------------- /src/commands/__tests__/testingLibrary.test.ts: -------------------------------------------------------------------------------- 1 | import { vol } from 'memfs'; 2 | import { expect, test, vi } from 'vitest'; 3 | import addDependency from '../../util/addDependency'; 4 | import addTestingLibrary from '../testingLibrary'; 5 | 6 | vi.mock('../../util/addDependency'); 7 | vi.mock('../../util/print', () => ({ 8 | default: vi.fn(), 9 | })); 10 | 11 | test('installs Testing Library', async () => { 12 | vol.fromJSON({ 13 | 'package.json': JSON.stringify({ 14 | scripts: {}, 15 | dependencies: { 16 | expo: '1.0.0', 17 | }, 18 | devDependencies: {}, 19 | }), 20 | 'yarn.lock': '', 21 | }); 22 | 23 | await addTestingLibrary(); 24 | 25 | expect(addDependency).toHaveBeenCalled(); 26 | }); 27 | -------------------------------------------------------------------------------- /src/commands/__tests__/typescript.test.ts: -------------------------------------------------------------------------------- 1 | import { fs, vol } from 'memfs'; 2 | import { Mock, afterEach, expect, test, vi } from 'vitest'; 3 | import addDependency from '../../util/addDependency'; 4 | import print from '../../util/print'; 5 | import addTypescript from '../typescript'; 6 | 7 | vi.mock('../../util/addDependency'); 8 | vi.mock('../../util/print', () => ({ default: vi.fn() })); 9 | 10 | afterEach(() => { 11 | vol.reset(); 12 | (print as Mock).mockReset(); 13 | }); 14 | 15 | test('exits with message if tsconfig.json already exists', async () => { 16 | const json = { 17 | 'package.json': JSON.stringify({ 18 | scripts: {}, 19 | dependencies: {}, 20 | }), 21 | 'tsconfig.json': '1', 22 | }; 23 | vol.fromJSON(json, './'); 24 | 25 | await addTypescript(); 26 | expect(print).toHaveBeenCalledWith( 27 | expect.stringMatching(/tsconfig\.json already exists/), 28 | ); 29 | 30 | // doesn't modify 31 | expect(fs.readFileSync('tsconfig.json', 'utf8')).toEqual('1'); 32 | }); 33 | 34 | test('writes new tsconfig.json, adds dependencies', async () => { 35 | vol.fromJSON({ 36 | 'package.json': JSON.stringify({ 37 | scripts: {}, 38 | dependencies: { 39 | expo: '1.0.0', 40 | }, 41 | }), 42 | }); 43 | 44 | await addTypescript(); 45 | 46 | expect(addDependency).toHaveBeenCalledWith('typescript @types/react', { 47 | dev: true, 48 | }); 49 | 50 | expect(fs.readFileSync('tsconfig.json', 'utf8')).toMatch( 51 | '"extends": "expo/tsconfig.base"', 52 | ); 53 | 54 | expect(print).not.toHaveBeenCalledWith( 55 | expect.stringMatching(/already exists/), 56 | ); 57 | }); 58 | -------------------------------------------------------------------------------- /src/commands/createApp.ts: -------------------------------------------------------------------------------- 1 | import { confirm } from '@inquirer/prompts'; 2 | import chalk from 'chalk'; 3 | import fs from 'fs-extra'; 4 | import _ from 'lodash'; 5 | import ora from 'ora'; 6 | import path from 'path'; 7 | import { PACKAGE_ROOT, globals } from '../constants'; 8 | import commit from '../util/commit'; 9 | import copyTemplateDirectory from '../util/copyTemplateDirectory'; 10 | import exec from '../util/exec'; 11 | import { lockFileNames } from '../util/getPackageManager'; 12 | import getUserPackageManager from '../util/getUserPackageManager'; 13 | import print from '../util/print'; 14 | import validateAndSanitizeAppName from '../util/validateAndSanitizeAppName'; 15 | 16 | type PackageManagerOptions = { 17 | bun?: boolean; 18 | npm?: boolean; 19 | yarn?: boolean; 20 | pnpm?: boolean; 21 | }; 22 | type Options = { 23 | interactive?: boolean; 24 | } & PackageManagerOptions; 25 | 26 | export async function createApp( 27 | name: string | undefined, 28 | options: Options = {}, 29 | ) { 30 | const { interactive = true } = options; 31 | globals.interactive = interactive; 32 | const appName = await validateAndSanitizeAppName(name); 33 | 34 | await ensureDirectoryDoesNotExist(appName); 35 | await printIntro(appName); 36 | const spinner = ora('Creating app with Belt').start(); 37 | 38 | try { 39 | await exec(`mkdir ${appName}`); 40 | 41 | await copyTemplateDirectory({ 42 | templateDir: 'boilerplate', 43 | destinationDir: appName, 44 | gitignore: await boilerplateIgnoreFiles(), 45 | stringSubstitutions: { 46 | 'app.json': { 47 | BELT_APP_NAME: appName, 48 | }, 49 | 'package.json': { 50 | belt_app_name: _.kebabCase(appName), 51 | }, 52 | }, 53 | }); 54 | 55 | spinner.succeed('Created new Belt app with Expo'); 56 | 57 | process.chdir(`./${appName}`); 58 | 59 | spinner.start('Installing dependencies'); 60 | const packageManager = getPackageManager(options); 61 | await exec(`${packageManager} install`); 62 | await exec('git init'); 63 | await commit('Initial commit'); 64 | spinner.succeed('Installed dependencies'); 65 | 66 | print(chalk.green(`\n\n👖 ${appName} successfully configured!`)); 67 | 68 | print(` 69 | Your pants are now secure! To get started with your new app: 70 | 71 | cd ${appName} 72 | ${packageManager} run ios 73 | ${packageManager} run android 74 | 75 | For more information about Belt, visit https://github.com/thoughtbot/belt. 76 | `); 77 | } catch (e) { 78 | spinner.fail('An error occurred creating the app\n'); 79 | if (e instanceof Error) { 80 | print(chalk.red(e.message)); 81 | } 82 | process.exit(1); 83 | } 84 | } 85 | 86 | /** 87 | * Commander requires this signature to be ...args: unknown[] 88 | * Actual args are: 89 | * ([, , ]) 90 | * or ([, ]) if not passed) 91 | */ 92 | export default function createAppAction(...args: unknown[]) { 93 | // if argument ommitted, args[0] is options 94 | const appNameArg = (args[0] as string[])[0]; 95 | const options = (args[0] as unknown[])[1] as Options; 96 | return createApp(appNameArg, options); 97 | } 98 | 99 | async function printIntro(appName: string) { 100 | print('Let’s get started!'); 101 | print(`\nWe will now create a new app in ./${chalk.bold( 102 | appName, 103 | )} for you with all of the following goodies: 104 | 105 | - Expo 106 | - TypeScript 107 | - Prettier 108 | - ESLint 109 | - Jest, React Native Testing Library 110 | - React Navigation 111 | - TanStack Query (formerly known as React Query) 112 | `); 113 | 114 | if (!globals.interactive) { 115 | return; 116 | } 117 | 118 | const proceed = await confirm({ message: 'Ready to proceed?' }); 119 | if (!proceed) { 120 | process.exit(0); 121 | } 122 | 123 | print(''); // add new line 124 | } 125 | 126 | function getPackageManager(options: Options) { 127 | return options.bun 128 | ? 'bun' 129 | : options.yarn 130 | ? 'yarn' 131 | : options.pnpm 132 | ? 'pnpm' 133 | : options.npm 134 | ? 'npm' 135 | : getUserPackageManager(); 136 | } 137 | 138 | async function ensureDirectoryDoesNotExist(appName: string) { 139 | if (await fs.exists(appName)) { 140 | print( 141 | chalk.yellow( 142 | `Whoopsy. The directory ${process.cwd()}/${appName} already exists. Please choose a different name or delete the existing directory.\n`, 143 | ), 144 | ); 145 | process.exit(0); 146 | } 147 | } 148 | 149 | /** 150 | * Don't copy any files over that are in the boilerplate gitignore. 151 | * Additionally, don't copy any package manager lockfiles over. This is 152 | * primarily helpful for development in the case that the developer has run the 153 | * app directly from the `boilerplate` directory and might have a node_modules 154 | * directory and lockfile 155 | */ 156 | async function boilerplateIgnoreFiles() { 157 | const gitignorePath = path.join( 158 | PACKAGE_ROOT, 159 | 'templates/boilerplate/.gitignore.eta', 160 | ); 161 | return ` 162 | ${(await fs.readFile(gitignorePath, 'utf8')).toString()} 163 | ${lockFileNames.join('\n')} 164 | `; 165 | } 166 | -------------------------------------------------------------------------------- /src/commands/eslint.ts: -------------------------------------------------------------------------------- 1 | import ora from 'ora'; 2 | import addDependency from '../util/addDependency'; 3 | import addPackageJsonScripts from '../util/addPackageJsonScripts'; 4 | import copyTemplateDirectory from '../util/copyTemplateDirectory'; 5 | import isEslintConfigured from '../util/isEslintConfigured'; 6 | import isPackageInstalled from '../util/isPackageInstalled'; 7 | 8 | export default async function addEslint() { 9 | const spinner = ora().start('Installing and configuring ESLint'); 10 | 11 | if (await isEslintConfigured()) { 12 | spinner.warn('ESLint config already exists, skipping.'); 13 | return; 14 | } 15 | 16 | const hasTypeScript = await isPackageInstalled('typescript'); 17 | 18 | await addDependency('eslint @thoughtbot/eslint-config', { dev: true }); 19 | 20 | await copyTemplateDirectory({ 21 | templateDir: 'eslint', 22 | variables: { typescript: hasTypeScript }, 23 | }); 24 | 25 | await addPackageJsonScripts({ 26 | 'lint:eslint': 'eslint --max-warnings=0 --ext js,jsx,ts,tsx .', 27 | 'fix:eslint': 'eslint --fix --ext js,jsx,ts,tsx .', 28 | }); 29 | 30 | spinner.succeed('ESLint successfully configured'); 31 | } 32 | -------------------------------------------------------------------------------- /src/commands/notifications.ts: -------------------------------------------------------------------------------- 1 | import { confirm, input } from '@inquirer/prompts'; 2 | import ora from 'ora'; 3 | import { globals } from '../constants'; 4 | import addExpoConfig from '../util/addExpoConfig'; 5 | import commit from '../util/commit'; 6 | import copyTemplateDirectory from '../util/copyTemplateDirectory'; 7 | import exec from '../util/exec'; 8 | import injectHooks from '../util/injectHooks'; 9 | import print from '../util/print'; 10 | import readAppJson from '../util/readAppJson'; 11 | 12 | type Options = { 13 | bundleId?: string; 14 | interactive?: boolean; 15 | }; 16 | 17 | const handleCommitError = (error: { stdout: string }) => { 18 | if (!error.stdout.includes('nothing to commit')) { 19 | throw error; 20 | } 21 | }; 22 | 23 | export async function addNotifications(options: Options = {}) { 24 | const { interactive = true } = options; 25 | 26 | globals.interactive = interactive; 27 | 28 | const { bundleId = !interactive ? 'com.myapp' : undefined } = options; 29 | 30 | await printIntro(); 31 | 32 | const spinner = ora().start('Adding React Native Firebase and dependencies'); 33 | 34 | // Install dependencies 35 | await exec( 36 | 'npx expo install @react-native-firebase/app @react-native-firebase/messaging expo-build-properties', 37 | ); 38 | 39 | spinner.succeed('Added React Native Firebase and dependencies'); 40 | 41 | spinner.start('Adding notification handlers'); 42 | 43 | await copyTemplateDirectory({ 44 | templateDir: 'notifications', 45 | }); 46 | 47 | await injectHooks( 48 | 'App.tsx', 49 | 'useNotifications();', 50 | "import useNotifications from 'src/hooks/useNotifications';\n", 51 | ); 52 | 53 | spinner.succeed('Added notification handlers'); 54 | 55 | // Verify for bundle identifier and package name in the AppJsonConfig 56 | const appJson = await readAppJson(); 57 | 58 | const packageName = 59 | appJson.expo?.android?.package ?? 60 | bundleId ?? 61 | (await input({ 62 | message: 'Define your Android package name:', 63 | default: 'com.myapp', 64 | })); 65 | 66 | const bundleIdentifier = 67 | appJson.expo?.ios?.bundleIdentifier ?? 68 | bundleId ?? 69 | (await input({ 70 | message: 'Define your iOS bundle identifier:', 71 | default: packageName, 72 | })); 73 | 74 | spinner.start('Configuring app.json'); 75 | 76 | await addExpoConfig({ 77 | android: { 78 | googleServicesFile: './config/google-services.json', 79 | package: packageName, 80 | }, 81 | ios: { 82 | googleServicesFile: './config/GoogleService-Info.plist', 83 | bundleIdentifier, 84 | }, 85 | plugins: [ 86 | '@react-native-firebase/app', 87 | '@react-native-firebase/messaging', 88 | [ 89 | 'expo-build-properties', 90 | { 91 | ios: { 92 | useFrameworks: 'static', 93 | }, 94 | }, 95 | ], 96 | ], 97 | }); 98 | 99 | await commit('Add push notifications support.').catch(handleCommitError); 100 | 101 | spinner.succeed( 102 | `Successfully added notifications support to project. 103 | 104 | In order to finish the setup please complete the following steps: 105 | - Add your google-service.json and GoogleService-Info.plist files to your project's config folder 106 | - Run the command "npx expo prebuild --clean" to rebuild the app 107 | - Add the ios/ and android/ folders to your .gitignore file if you don't need to track them 108 | 109 | For more details please refer to the official documentation: https://rnfirebase.io/#configure-react-native-firebase-modules. 110 | `, 111 | ); 112 | } 113 | 114 | async function printIntro() { 115 | print('Let’s get started!'); 116 | print(`\nWe will now add notifications support to your app. This will include the following changes: 117 | 118 | - Add React Native Firebase, Messaging and Expo Build Properties dependencies 119 | - Add notification handlers to your app 120 | - Configure your app.json file with the necessary information 121 | 122 | NOTE: React Native Firebase cannot be used in the pre-compiled Expo Go app because React Native Firebase uses native code that is not compiled into Expo Go. This will switch the app build process to use Continuos Native Generation (CGN), for more details please refer to the documentation (https://docs.expo.dev/workflow/continuous-native-generation). 123 | `); 124 | 125 | if (!globals.interactive) { 126 | return; 127 | } 128 | 129 | const proceed = await confirm({ message: 'Ready to proceed?' }); 130 | if (!proceed) { 131 | process.exit(0); 132 | } 133 | 134 | print(''); // add new line 135 | } 136 | 137 | /** 138 | * Commander requires this signature to be ...args: unknown[] 139 | * Actual args are: 140 | * ([, ]) 141 | */ 142 | export default function addNotificationsAction(...args: unknown[]) { 143 | // if argument ommitted, args[0] is options 144 | const options = (args[0] as unknown[])[0] as Options; 145 | return addNotifications(options); 146 | } 147 | -------------------------------------------------------------------------------- /src/commands/prettier.ts: -------------------------------------------------------------------------------- 1 | import fs from 'fs-extra'; 2 | import ora from 'ora'; 3 | import path from 'path'; 4 | import addDependency from '../util/addDependency'; 5 | import addPackageJsonScripts from '../util/addPackageJsonScripts'; 6 | import copyTemplate from '../util/copyTemplate'; 7 | import getProjectDir from '../util/getProjectDir'; 8 | import isPrettierConfigured from '../util/isPrettierConfigured'; 9 | import print from '../util/print'; 10 | 11 | export default async function addPrettier() { 12 | const spinner = ora().start('Installing and configuring Prettier'); 13 | const projectDir = await getProjectDir(); 14 | const eslintJsFile = path.join(projectDir, '.eslintrc.js'); 15 | const eslintJSONFile = path.join(projectDir, '.eslintrc.json'); 16 | let hasEslint = false; 17 | 18 | if (await isPrettierConfigured()) { 19 | spinner.warn('prettier config file already exists, exiting'); 20 | return; 21 | } 22 | 23 | if ((await fs.exists(eslintJsFile)) || (await fs.exists(eslintJSONFile))) { 24 | hasEslint = true; 25 | } 26 | 27 | if (await fs.exists(path.join(projectDir, '.prettierignore'))) { 28 | print('.prettierignore config file already exists, will not overwrite'); 29 | } else { 30 | await copyTemplate({ 31 | templateDir: 'prettier', 32 | templateFile: '.prettierignore.eta', 33 | }); 34 | } 35 | 36 | await addDependency('prettier', { dev: true }); 37 | 38 | await copyTemplate({ 39 | templateDir: 'prettier', 40 | templateFile: '.prettierrc', 41 | }); 42 | 43 | await addPackageJsonScripts({ 44 | 'lint:prettier': "prettier --check '**/*' --ignore-unknown", 45 | 'fix:prettier': "prettier --write '**/*' --ignore-unknown", 46 | }); 47 | 48 | spinner.succeed('Prettier successfully configured'); 49 | 50 | if (hasEslint) { 51 | print(` 52 | 'We noticed ESLint is already set up, you might consider updating your ESLint 53 | configuration to use eslint-config-prettier: 54 | https://github.com/prettier/eslint-config-prettier. This turns off all ESLint 55 | rule that are unnecessary or might conflict with Prettier. 56 | `); 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /src/commands/testingLibrary.ts: -------------------------------------------------------------------------------- 1 | import ora from 'ora'; 2 | import addDependency from '../util/addDependency'; 3 | import addPackageJsonScripts from '../util/addPackageJsonScripts'; 4 | import addToGitignore from '../util/addToGitignore'; 5 | import copyTemplateDirectory from '../util/copyTemplateDirectory'; 6 | import exec from '../util/exec'; 7 | import getPackageManager from '../util/getPackageManager'; 8 | 9 | export default async function addTestingLibrary() { 10 | const spinner = ora().start('Installing Jest and Testing Library'); 11 | 12 | await exec('npx expo install jest jest-expo'); 13 | 14 | await addDependency( 15 | `@testing-library/react-native @testing-library/jest-native @types/jest babel-jest`, 16 | { dev: true }, 17 | ); 18 | 19 | await copyTemplateDirectory({ 20 | templateDir: 'testingLibrary', 21 | }); 22 | 23 | const mgr = await getPackageManager(); 24 | const cmd = mgr === 'npm' ? 'npm run' : mgr; 25 | await addPackageJsonScripts( 26 | { 27 | test: 'jest', 28 | 'test:ci': `jest --maxWorkers=2 --silent --ci`, 29 | 'test:cov': `jest --coverage --coverageDirectory ./.cache/coverage`, 30 | 'test:all': `${cmd} lint && ${cmd} test:cov`, 31 | }, 32 | { overwrite: true }, 33 | ); 34 | 35 | await addToGitignore('/.cache'); 36 | 37 | spinner.succeed( 38 | 'Successfully installed and configured Jest and Testing Library', 39 | ); 40 | } 41 | -------------------------------------------------------------------------------- /src/commands/typescript.ts: -------------------------------------------------------------------------------- 1 | import chalk from 'chalk'; 2 | import fs from 'fs-extra'; 3 | import ora from 'ora'; 4 | import path from 'path'; 5 | import addDependency from '../util/addDependency'; 6 | import addPackageJsonScripts from '../util/addPackageJsonScripts'; 7 | import copyTemplateDirectory from '../util/copyTemplateDirectory'; 8 | import getProjectDir from '../util/getProjectDir'; 9 | import print from '../util/print'; 10 | 11 | export default async function addTypescript() { 12 | const projectDir = await getProjectDir(); 13 | 14 | if (fs.existsSync(path.join(projectDir, 'tsconfig.json'))) { 15 | print( 16 | chalk.yellow( 17 | 'tsconfig.json already exists, exiting.\nIf you would like to perform a fresh TypeScript install, delete this file and re-run the script.\n', 18 | ), 19 | ); 20 | return; 21 | } 22 | 23 | const spinner = ora().start('Installing and configuring TypeScript'); 24 | await addDependency('typescript @types/react', { dev: true }); 25 | 26 | await copyTemplateDirectory({ 27 | templateDir: 'typescript', 28 | }); 29 | 30 | if (await fs.exists(path.join(projectDir, 'App.js'))) { 31 | await fs.move( 32 | path.join(projectDir, 'App.js'), 33 | path.join(projectDir, 'App.tsx'), 34 | ); 35 | } 36 | 37 | await addPackageJsonScripts({ 'lint:types': 'tsc' }); 38 | 39 | spinner.succeed('TypeScript successfully configured'); 40 | } 41 | -------------------------------------------------------------------------------- /src/constants.ts: -------------------------------------------------------------------------------- 1 | import path from 'path'; 2 | import { fileURLToPath } from 'url'; 3 | 4 | export const globals = { 5 | interactive: true, 6 | }; 7 | 8 | // TSUP builds files without hierarchy to dist/, so the path is ultimately 9 | // in relation to the dist/ directory. NPM package structure is: 10 | // 11 | // dist/ 12 | // index.js // file executed here 13 | // templates/ 14 | const filename = fileURLToPath(import.meta.url); 15 | const distPath = path.dirname(filename); 16 | // eslint-disable-next-line import/prefer-default-export 17 | export const PACKAGE_ROOT = path.join(distPath, '../'); 18 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | import runCli from './cli'; 3 | 4 | runCli(); 5 | -------------------------------------------------------------------------------- /src/types.ts: -------------------------------------------------------------------------------- 1 | import { ExpoConfig } from '@expo/config'; 2 | 3 | export type PackageJson = { 4 | [k: string]: unknown; 5 | scripts: Record; 6 | dependencies?: { 7 | [k: string]: unknown; 8 | }; 9 | devDependencies?: { 10 | [k: string]: unknown; 11 | }; 12 | }; 13 | 14 | export type AppJson = { 15 | expo: ExpoConfig; 16 | }; 17 | -------------------------------------------------------------------------------- /src/types/Global.d.ts: -------------------------------------------------------------------------------- 1 | // this is available as a global only if run in Bun environment 2 | declare namespace Bun { 3 | const Env = {}; 4 | } 5 | -------------------------------------------------------------------------------- /src/types/fs-extra.d.ts: -------------------------------------------------------------------------------- 1 | import 'fs-extra'; 2 | 3 | /* eslint-disable import/prefer-default-export */ 4 | declare module 'fs-extra' { 5 | export const mockTemplates: () => void; 6 | } 7 | -------------------------------------------------------------------------------- /src/util/__tests__/getPackageManager.test.ts: -------------------------------------------------------------------------------- 1 | import { vol } from 'memfs'; 2 | import { beforeEach, expect, test, vi } from 'vitest'; 3 | import getPackageManager from '../getPackageManager'; 4 | 5 | vi.mock('../../print', () => ({ default: vi.fn() })); 6 | 7 | beforeEach(() => { 8 | vi.clearAllMocks(); 9 | vol.reset(); 10 | }); 11 | 12 | test('returns yarn when yarn.lock present', async () => { 13 | mockFileExists('yarn.lock'); 14 | expect(await getPackageManager()).toEqual('yarn'); 15 | }); 16 | 17 | test('returns yarn when .yarn present', async () => { 18 | mockFileExists('.yarn'); 19 | expect(await getPackageManager()).toEqual('yarn'); 20 | }); 21 | 22 | test('returns npm when package-lock.json present', async () => { 23 | mockFileExists('package-lock.json'); 24 | expect(await getPackageManager()).toEqual('npm'); 25 | }); 26 | 27 | test('returns pnpm when pnpm-lock.yaml present', async () => { 28 | mockFileExists('pnpm-lock.yaml'); 29 | expect(await getPackageManager()).toEqual('pnpm'); 30 | }); 31 | 32 | test('returns bun when bun.lockb present', async () => { 33 | mockFileExists('bun.lockb'); 34 | expect(await getPackageManager()).toEqual('bun'); 35 | }); 36 | 37 | test('throws an error if no recognized lockfile', async () => { 38 | mockFileExists('foobar.lock'); 39 | void expect(async () => getPackageManager()).rejects.toThrowError( 40 | 'Unable to determine package manager.', 41 | ); 42 | }); 43 | 44 | function mockFileExists(filename: string) { 45 | vol.fromJSON( 46 | { 47 | 'package.json': '{}', 48 | [filename]: '', 49 | }, 50 | './', 51 | ); 52 | } 53 | -------------------------------------------------------------------------------- /src/util/__tests__/validateAndSanitizeAppName.test.ts: -------------------------------------------------------------------------------- 1 | import { input } from '@inquirer/prompts'; 2 | import { vol } from 'memfs'; 3 | import { Mock, afterEach, describe, expect, test, vi } from 'vitest'; 4 | import print from '../print'; 5 | import validateAndSanitizeAppName from '../validateAndSanitizeAppName'; 6 | 7 | vi.mock('@inquirer/prompts', () => ({ 8 | input: vi.fn(), 9 | confirm: vi.fn(), 10 | })); 11 | vi.mock('../../util/print', () => ({ default: vi.fn() })); 12 | 13 | afterEach(() => { 14 | vol.reset(); 15 | (print as Mock).mockReset(); 16 | }); 17 | 18 | describe('validateAndSanitizeAppName', () => { 19 | test('returns the correct camelized application name', async () => { 20 | (input as Mock).mockReturnValue('my_app'); 21 | const appName = await validateAndSanitizeAppName(undefined); 22 | 23 | expect(appName).toBe('MyApp'); 24 | }); 25 | 26 | test('returns the correct camelized app name when the application name includes letters and numbers', async () => { 27 | (input as Mock).mockReturnValue('my321App '); 28 | const appName = await validateAndSanitizeAppName(undefined); 29 | 30 | expect(appName).toBe('My321App'); 31 | }); 32 | 33 | test('returns the correct camelized app name when the application name includes spaces', async () => { 34 | (input as Mock).mockReturnValue('my '); 35 | const appName = await validateAndSanitizeAppName(undefined); 36 | 37 | expect(appName).toBe('My'); 38 | }); 39 | 40 | test('returns the correct camelized app name when the application name includes illegal characters', async () => { 41 | (input as Mock).mockReturnValue('my***App '); 42 | const appName = await validateAndSanitizeAppName(undefined); 43 | 44 | expect(appName).toBe('MyApp'); 45 | }); 46 | 47 | test('returns a warning when the application name does not start with a letter', async () => { 48 | (print as Mock).mockReset(); 49 | vi.spyOn(process, 'exit'); 50 | process.exit = vi.fn(); 51 | (input as Mock).mockReturnValue('123MyApp'); 52 | await validateAndSanitizeAppName(undefined); 53 | 54 | expect(print).toHaveBeenCalledWith( 55 | expect.stringMatching('App name must start with a letter.'), 56 | ); 57 | // eslint-disable-next-line @typescript-eslint/unbound-method 58 | expect(process.exit).toHaveBeenCalledWith(0); 59 | }); 60 | }); 61 | -------------------------------------------------------------------------------- /src/util/addDependency.ts: -------------------------------------------------------------------------------- 1 | import exec from './exec'; 2 | import getPackageManager from './getPackageManager'; 3 | 4 | export default async function addDependency( 5 | deps: string, 6 | { dev = false } = {}, 7 | ) { 8 | const mgr = await getPackageManager(); 9 | 10 | if (['yarn', 'bun'].includes(mgr)) { 11 | await exec(`${mgr} add ${dev ? '--dev' : ''} ${deps}`); 12 | } else { 13 | await exec(`${mgr} install ${dev ? '--save-dev' : '--save'} ${deps}`); 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /src/util/addExpoConfig.ts: -------------------------------------------------------------------------------- 1 | import { ExpoConfig } from '@expo/config'; 2 | import mergeWith from 'lodash/mergeWith.js'; 3 | import isArray from 'lodash/isArray.js'; 4 | import readAppJson from './readAppJson'; 5 | import writeFile from './writeFile'; 6 | 7 | /** 8 | * add entries to the expo configuration property in app.json 9 | * @param config - object where key is name, value is command 10 | */ 11 | export default async function addExpoConfig(config: Partial) { 12 | const appJson = await readAppJson(); 13 | 14 | // Merge the config into the appJson 15 | const updatedAppJson = mergeWith( 16 | appJson, 17 | { expo: config }, 18 | (objValue, srcValue) => { 19 | if (isArray(objValue)) { 20 | // eslint-disable-next-line @typescript-eslint/no-unsafe-return, @typescript-eslint/no-unsafe-assignment 21 | return [...new Set([...objValue, ...srcValue])]; 22 | } 23 | 24 | return undefined; 25 | }, 26 | ); 27 | 28 | return writeFile('app.json', JSON.stringify(updatedAppJson), { 29 | format: true, 30 | }); 31 | } 32 | -------------------------------------------------------------------------------- /src/util/addPackageJsonScripts.ts: -------------------------------------------------------------------------------- 1 | import print from './print'; 2 | import readPackageJson from './readPackageJson'; 3 | import writeFile from './writeFile'; 4 | 5 | type Params = { 6 | overwrite?: boolean; 7 | }; 8 | /** 9 | * add entries to the "scripts" property in Package.json 10 | * @param values - object where key is name, value is command 11 | */ 12 | export default async function addPackageJsonScripts( 13 | values: Record, 14 | { overwrite = false }: Params = {}, 15 | ) { 16 | const packageJson = await readPackageJson(); 17 | 18 | Object.entries(values).forEach(([name, command]) => { 19 | if (packageJson.scripts[name]) { 20 | if (overwrite) { 21 | print(`Overwriting package.json script "${name}".`); 22 | } else { 23 | print( 24 | `package.json already has script "${name}", skipping adding script with value: ${command}`, 25 | ); 26 | } 27 | } 28 | // eslint-disable-next-line no-param-reassign 29 | packageJson.scripts[name] = command; 30 | }); 31 | 32 | return writeFile('package.json', JSON.stringify(packageJson), { 33 | format: true, 34 | }); 35 | } 36 | -------------------------------------------------------------------------------- /src/util/addToGitignore.ts: -------------------------------------------------------------------------------- 1 | import appendToFile from './appendToFile'; 2 | 3 | /** 4 | * lines should be separated by newlines 5 | */ 6 | export default async function addToGitignore(lines: string) { 7 | return appendToFile('.gitignore', lines); 8 | } 9 | -------------------------------------------------------------------------------- /src/util/appendToFile.ts: -------------------------------------------------------------------------------- 1 | import fs from 'fs-extra'; 2 | import path from 'path'; 3 | import getProjectDir from './getProjectDir'; 4 | /** 5 | * lines should be separated by newlines 6 | */ 7 | export default async function appendToFile(filename: string, lines: string) { 8 | return fs.appendFile( 9 | path.join(await getProjectDir(), filename), 10 | `\n${lines}`, 11 | ); 12 | } 13 | -------------------------------------------------------------------------------- /src/util/buildAction.ts: -------------------------------------------------------------------------------- 1 | type AsyncModule = Promise<{ 2 | default: (...args: unknown[]) => void; 3 | }>; 4 | 5 | /** 6 | * builds the action function that is passed to Commander's 7 | * program.action. 8 | * Eg: program.action( 9 | * buildAction(import('./commands/prettier)) 10 | * ) 11 | */ 12 | export default function buildAction(asyncModule: AsyncModule) { 13 | return async (...args: unknown[]) => { 14 | const module = await asyncModule; 15 | module.default(args); 16 | }; 17 | } 18 | -------------------------------------------------------------------------------- /src/util/commit.ts: -------------------------------------------------------------------------------- 1 | import exec from './exec'; 2 | 3 | export default async function commit(message: string) { 4 | await exec('git add .'); 5 | await exec(`git commit -m "${message}"`); 6 | } 7 | -------------------------------------------------------------------------------- /src/util/copyTemplate.ts: -------------------------------------------------------------------------------- 1 | import * as eta from 'eta'; 2 | import fs from 'fs-extra'; 3 | import path from 'path'; 4 | import { PACKAGE_ROOT } from '../constants'; 5 | import getProjectDir from './getProjectDir'; 6 | import writeFile from './writeFile'; 7 | 8 | type Params = { 9 | templateDir: string; 10 | templateFile: string; 11 | /** relative to project root */ 12 | destination?: string; 13 | variables?: object; 14 | format?: boolean; 15 | }; 16 | 17 | export default async function copyTemplate({ 18 | templateDir, 19 | templateFile, 20 | variables, 21 | destination = '.', 22 | format = false, 23 | }: Params) { 24 | const projectDir = await getProjectDir(); 25 | let template: string = ( 26 | await fs.readFile( 27 | path.join(PACKAGE_ROOT, 'templates', templateDir, templateFile), 28 | ) 29 | ).toString(); 30 | 31 | if (templateFile.endsWith('eta')) { 32 | template = eta.render(template.toString(), variables ?? {}); 33 | } 34 | 35 | const fullDestination = destination.endsWith('/') 36 | ? `${destination}${templateFile}` 37 | : destination === '.' 38 | ? templateFile 39 | : destination; 40 | 41 | const fullDestinationFilename = fullDestination.replace(/\.eta$/, ''); 42 | 43 | await writeFile(path.join(projectDir, fullDestinationFilename), template, { 44 | format, 45 | }); 46 | } 47 | -------------------------------------------------------------------------------- /src/util/copyTemplateDirectory.ts: -------------------------------------------------------------------------------- 1 | import * as eta from 'eta'; 2 | import fs from 'fs-extra'; 3 | import ignore from 'ignore'; 4 | import path from 'path'; 5 | import { PACKAGE_ROOT } from '../constants'; 6 | import writeFile from './writeFile'; 7 | 8 | const UNSUPPORTED_EXTENSIONS = [ 9 | '.png', 10 | '.jpg', 11 | '.jpeg', 12 | '.gif', 13 | '.svg', 14 | '.pdf', 15 | ]; 16 | 17 | type Substitutions = { 18 | [fileNamePattern: string]: Record; 19 | }; 20 | 21 | type Params = { 22 | /** relative to templates/, eg "boilerplate" */ 23 | templateDir: string; 24 | /** relative to project root, eg ".", "src". Default "." */ 25 | destinationDir?: string; 26 | /** variables to use when rendering .eta template files */ 27 | variables?: object; 28 | /** 29 | * strings to replace in copied files, specific to files that match the 30 | * supplied filename. Filename and replacement strings are regex patterns. 31 | * eg: { 32 | * 'package\.json': { 'REPLACE_ME': 'NEW CONTENT' } 33 | * 'components/*.tsx': { 'My.*Component': 'YourComponent' } 34 | * } 35 | * */ 36 | stringSubstitutions?: Substitutions; 37 | /** 38 | * string representation of a gitignore file, notating files to ignore in src 39 | * directory, eg: 40 | * "package-lock.json 41 | * node_modules" 42 | */ 43 | gitignore?: string; 44 | }; 45 | 46 | /** 47 | * copies the template directory to the destination directory, optionally performing 48 | * string substitutions and rendering .eta template files. 49 | */ 50 | export default async function copyTemplateDirectory({ 51 | templateDir, 52 | destinationDir = '.', 53 | gitignore, 54 | variables = {}, 55 | stringSubstitutions = {}, 56 | }: Params) { 57 | const srcDir = path.join(PACKAGE_ROOT, `templates`, templateDir); 58 | const filenames = await getFiles(srcDir, gitignore); 59 | await Promise.all( 60 | filenames.map(async (filename) => { 61 | const relativeFilename = path.relative(srcDir, filename); 62 | const destinationFilename = path.join(destinationDir, relativeFilename); 63 | 64 | if (substitutionsSupported(filename)) { 65 | const contents = await makeFileSubstitutions( 66 | filename, 67 | stringSubstitutions, 68 | variables, 69 | ); 70 | 71 | return writeFile(destinationFilename.replace(/\.eta$/, ''), contents); 72 | } 73 | 74 | try { 75 | // file is binary or otherwise doesn't support substitutions, just copy 76 | // it over instead of reading it to a string 77 | return await fs.copy(filename, destinationFilename); 78 | } catch (e) { 79 | if (e instanceof Error) { 80 | throw new Error( 81 | `An error occurred copying file ${relativeFilename} to ${destinationFilename}: ${e.message}`, 82 | ); 83 | } 84 | } 85 | 86 | return null; 87 | }), 88 | ); 89 | } 90 | 91 | async function makeFileSubstitutions( 92 | filename: string, 93 | stringSubstitutions: Substitutions, 94 | variables: object, 95 | ): Promise { 96 | let contents = (await fs.readFile(filename)).toString(); 97 | const substitutions = substitutionsForFile(filename, stringSubstitutions); 98 | if (Object.keys(substitutions).length > 0) { 99 | contents = Object.entries(substitutions).reduce((acc, [key, value]) => { 100 | return acc.replaceAll(new RegExp(key, 'g'), value); 101 | }, contents); 102 | } 103 | 104 | if (filename.endsWith('.eta')) { 105 | contents = eta.render(contents, variables); 106 | } 107 | 108 | return contents; 109 | } 110 | 111 | function substitutionsSupported(filename: string) { 112 | const extension = path.extname(filename).toLocaleLowerCase(); 113 | return !extension || !UNSUPPORTED_EXTENSIONS.includes(extension); 114 | } 115 | 116 | function substitutionsForFile( 117 | filename: string, 118 | stringSubstitutions: Substitutions, 119 | ): Record { 120 | if (!substitutionsSupported(filename)) return {}; 121 | 122 | return Object.entries(stringSubstitutions).reduce((acc, [pattern, value]) => { 123 | if (filename.match(pattern)) { 124 | return { ...acc, ...value }; 125 | } 126 | return acc; 127 | }, {}); 128 | } 129 | 130 | /** 131 | * returns array of all filenames within 'dir', recursively 132 | * respecting .gitignore 133 | */ 134 | async function getFiles( 135 | dir: string, 136 | gitignore: string | undefined, 137 | ): Promise { 138 | const ig = ignore().add(gitignore || ''); 139 | const shouldIgnore = (fullPath: string) => 140 | ig.ignores(path.relative(dir, fullPath)); 141 | 142 | return getFilesInternal(dir, shouldIgnore); 143 | } 144 | 145 | // get all files inside dir, recursively 146 | // filtering out any from gitignore 147 | async function getFilesInternal( 148 | dir: string, 149 | shouldIgnore: (path: string) => boolean, 150 | ): Promise { 151 | const dirents = await fs.readdir(dir, { withFileTypes: true }); 152 | const files = await Promise.all( 153 | dirents 154 | .filter((dirent) => !shouldIgnore(path.join(dir, dirent.name))) 155 | .map((dirent) => { 156 | const fullPath = path.resolve(dir, dirent.name); 157 | return dirent.isDirectory() 158 | ? getFilesInternal(fullPath, shouldIgnore) 159 | : fullPath; 160 | }), 161 | ); 162 | 163 | return files.flat(); 164 | } 165 | -------------------------------------------------------------------------------- /src/util/exec.ts: -------------------------------------------------------------------------------- 1 | import { exec as execCb } from 'child_process'; 2 | import util from 'util'; 3 | 4 | const exec = util.promisify(execCb); 5 | export default exec; 6 | -------------------------------------------------------------------------------- /src/util/formatFile.ts: -------------------------------------------------------------------------------- 1 | import exec from './exec'; 2 | 3 | export default async function formatFile(filePath: string) { 4 | return exec(`npx prettier --write '${filePath}'`); 5 | } 6 | -------------------------------------------------------------------------------- /src/util/getPackageManager.ts: -------------------------------------------------------------------------------- 1 | import fs from 'fs-extra'; 2 | import path from 'path'; 3 | import getProjectDir from './getProjectDir'; 4 | import { PackageManager } from './getUserPackageManager'; 5 | 6 | const lockFiles: Record = { 7 | 'yarn.lock': 'yarn', 8 | '.yarn': 'yarn', 9 | 'package-lock.json': 'npm', 10 | 'pnpm-lock.yaml': 'pnpm', 11 | 'bun.lockb': 'bun', 12 | }; 13 | 14 | export const lockFileNames = Object.keys(lockFiles); 15 | 16 | export default async function getPackageManager(): Promise { 17 | const projectDir = await getProjectDir(); 18 | 19 | async function fileExists(name: string) { 20 | return fs.exists(path.join(projectDir, name)); 21 | } 22 | 23 | // eslint-disable-next-line no-restricted-syntax 24 | for await (const [lockFile, packageManager] of Object.entries(lockFiles)) { 25 | if (await fileExists(lockFile)) { 26 | return packageManager; 27 | } 28 | } 29 | 30 | throw new Error('Unable to determine package manager.'); 31 | } 32 | -------------------------------------------------------------------------------- /src/util/getProjectDir.ts: -------------------------------------------------------------------------------- 1 | import fs from 'fs-extra'; 2 | import path from 'path'; 3 | 4 | export default async function getProjectDir( 5 | base: string = process.cwd(), 6 | ): Promise { 7 | let previous: string | null = null; 8 | let dir = base; 9 | 10 | do { 11 | try { 12 | // This will throw if there is no package.json in the directory 13 | // eslint-disable-next-line no-await-in-loop 14 | await fs.readFile(path.join(dir, 'package.json')); 15 | 16 | // if didn't throw, package.json exists, return dir 17 | return dir; 18 | } catch { 19 | // Expected to throw if no package.json is present 20 | } finally { 21 | previous = dir; 22 | dir = path.dirname(dir); 23 | } 24 | } while (dir !== previous); 25 | 26 | throw new Error( 27 | 'No project found. Ensure you are inside of a project directory with a package.json file.', 28 | ); 29 | } 30 | -------------------------------------------------------------------------------- /src/util/getProjectType.ts: -------------------------------------------------------------------------------- 1 | import readPackageJson from './readPackageJson'; 2 | 3 | type ProjectType = 'expo-bare' | 'expo-managed' | 'react-native'; 4 | 5 | export default async function getProjectType(): Promise { 6 | const packageJson = await readPackageJson(); 7 | const hasExpo = hasProperty(packageJson.dependencies, 'expo'); 8 | const hasReactNativeUnimodules = hasProperty( 9 | packageJson.dependencies, 10 | 'react-native-unimodules', 11 | ); 12 | if (hasExpo) { 13 | return hasReactNativeUnimodules ? 'expo-bare' : 'expo-managed'; 14 | } 15 | 16 | return 'react-native'; 17 | } 18 | 19 | function hasProperty( 20 | object: Record | undefined, 21 | property: string, 22 | ) { 23 | return Object.prototype.hasOwnProperty.call(object, property); 24 | } 25 | -------------------------------------------------------------------------------- /src/util/getUserPackageManager.ts: -------------------------------------------------------------------------------- 1 | export type PackageManager = 'npm' | 'pnpm' | 'yarn' | 'bun'; 2 | 3 | /** 4 | * attempts to detect the runtime / package manager that was used to 5 | * run the current process 6 | */ 7 | export default function getUserPackageManager(): PackageManager { 8 | // This environment variable is set by npm and yarn but pnpm seems less consistent 9 | const userAgent = process.env.npm_config_user_agent; 10 | 11 | if (userAgent?.startsWith('yarn')) { 12 | return 'yarn'; 13 | } 14 | if (userAgent?.startsWith('pnpm')) { 15 | return 'pnpm'; 16 | } 17 | // bun sets Bun.env if running in Bun process. userAgent bit doesn't seem to work 18 | if (userAgent?.startsWith('bun') || typeof Bun !== 'undefined') { 19 | return 'bun'; 20 | } 21 | 22 | // If no user agent is set, assume npm 23 | return 'npm'; 24 | } 25 | -------------------------------------------------------------------------------- /src/util/injectHooks.ts: -------------------------------------------------------------------------------- 1 | import fs from 'fs-extra'; 2 | import path from 'path'; 3 | import getProjectDir from './getProjectDir'; 4 | import exec from './exec'; 5 | import writeFile from './writeFile'; 6 | 7 | export default async function injectHooks( 8 | filepath: string, // Path to the file to inject the hooks into, relative to the project root 9 | hooks: string, // The hooks to inject under the target line 10 | imports: string, // The import lines to the injected roots 11 | ) { 12 | const rootDir = await getProjectDir(); 13 | const filePath = path.join(rootDir, filepath); 14 | const data = await fs.readFile(filePath, 'utf8'); 15 | const lines = data.split('\n'); 16 | 17 | const targetLineIndex = lines.findIndex((line) => 18 | line.includes('CODEGEN:BELT:HOOKS'), 19 | ); 20 | 21 | if (targetLineIndex === -1) { 22 | throw new Error('Target line not found in file'); 23 | } 24 | 25 | lines.splice(targetLineIndex + 1, 0, hooks); 26 | lines.splice(0, 0, imports); 27 | 28 | const updatedData = lines.join('\n'); 29 | 30 | await writeFile(filePath, updatedData, { format: true }); 31 | 32 | // Format the file to make sure it's consistent 33 | await exec(`npx eslint --max-warnings=0 --fix ${filepath}`); 34 | await exec(`npx prettier --write ${filepath}`); 35 | } 36 | -------------------------------------------------------------------------------- /src/util/isEslintConfigured.ts: -------------------------------------------------------------------------------- 1 | import path from 'path'; 2 | import fsExtra from 'fs-extra'; 3 | import getProjectDir from './getProjectDir'; 4 | import readPackageJson from './readPackageJson'; 5 | 6 | export default async function isEslintConfigured() { 7 | const packageJson = await readPackageJson(); 8 | 9 | const projectDir = await getProjectDir(); 10 | 11 | const hasEslintConfigInPackageJson = Object.prototype.hasOwnProperty.call( 12 | packageJson, 13 | 'eslint', 14 | ); 15 | const hasEslintrcJsFile = await fsExtra.exists( 16 | path.join(projectDir, '.eslintrc.js'), 17 | ); 18 | const hasEslintJsonFile = await fsExtra.exists( 19 | path.join(projectDir, '.eslintrc.json'), 20 | ); 21 | 22 | return hasEslintConfigInPackageJson || hasEslintrcJsFile || hasEslintJsonFile; 23 | } 24 | -------------------------------------------------------------------------------- /src/util/isExpo.ts: -------------------------------------------------------------------------------- 1 | import getProjectType from './getProjectType'; 2 | 3 | export default async function isExpo() { 4 | const projectType = await getProjectType(); 5 | return projectType === 'expo-bare' || projectType === 'expo-managed'; 6 | } 7 | -------------------------------------------------------------------------------- /src/util/isPackageInstalled.ts: -------------------------------------------------------------------------------- 1 | import readPackageJson from './readPackageJson'; 2 | 3 | export default async function isPackageInstalled(packageName: string) { 4 | const packageJson = await readPackageJson(); 5 | 6 | const isPackageInDevdependencies = Object.prototype.hasOwnProperty.call( 7 | packageJson.dependencies, 8 | packageName, 9 | ); 10 | const isPackageInDependencies = Object.prototype.hasOwnProperty.call( 11 | packageJson.devDependencies, 12 | packageName, 13 | ); 14 | return isPackageInDevdependencies || isPackageInDependencies; 15 | } 16 | -------------------------------------------------------------------------------- /src/util/isPrettierConfigured.ts: -------------------------------------------------------------------------------- 1 | import path from 'path'; 2 | import fsExtra from 'fs-extra'; 3 | import getProjectDir from './getProjectDir'; 4 | import readPackageJson from './readPackageJson'; 5 | 6 | export default async function isPrettierConfigured() { 7 | const packageJson = await readPackageJson(); 8 | 9 | const projectDir = await getProjectDir(); 10 | 11 | const hasPrettierConfigInPackageJson = Object.prototype.hasOwnProperty.call( 12 | packageJson, 13 | 'prettier', 14 | ); 15 | const hasPrettierrcJsFile = await fsExtra.exists( 16 | path.join(projectDir, 'prettierrc.js'), 17 | ); 18 | const hasPrettierrcFile = await fsExtra.exists( 19 | path.join(projectDir, '.prettierrc'), 20 | ); 21 | const hasPrettierrcJsonFile = await fsExtra.exists( 22 | path.join(projectDir, '.prettierrc.json'), 23 | ); 24 | const hasPrettierConfigFile = await fsExtra.exists( 25 | path.join(projectDir, '.prettier.config.js'), 26 | ); 27 | 28 | return ( 29 | hasPrettierConfigInPackageJson || 30 | hasPrettierrcJsFile || 31 | hasPrettierrcFile || 32 | hasPrettierrcJsonFile || 33 | hasPrettierConfigFile 34 | ); 35 | } 36 | -------------------------------------------------------------------------------- /src/util/print.ts: -------------------------------------------------------------------------------- 1 | export default function print(...args: unknown[]) { 2 | return console.log(...args); 3 | } 4 | -------------------------------------------------------------------------------- /src/util/print/__tests__/copyTemplateDirectory.test.ts: -------------------------------------------------------------------------------- 1 | import fse from 'fs-extra'; 2 | import { fs, vol } from 'memfs'; 3 | import { describe, expect, test, vi } from 'vitest'; 4 | import copyTemplateDirectory from '../../copyTemplateDirectory'; 5 | 6 | vi.mock('../../print', () => ({ default: vi.fn() })); 7 | 8 | test('copies directory structure to destination, including binary files (based on the extension)', async () => { 9 | fse.mockTemplates(); 10 | const json = { 11 | 'templates/testing/jest.config.js': '1', 12 | 'templates/testing/src/test/render.ts': '2', 13 | 'templates/testing/assets/splash.png': '3', 14 | }; 15 | vol.fromJSON(json, './'); 16 | 17 | await copyTemplateDirectory({ templateDir: 'testing', destinationDir: '.' }); 18 | 19 | expect(fs.readFileSync('jest.config.js', 'utf8')).toEqual('1'); 20 | expect(fs.readFileSync('src/test/render.ts', 'utf8')).toEqual('2'); 21 | expect(fs.readFileSync('assets/splash.png', 'utf8')).toEqual('3'); 22 | }); 23 | 24 | test('compiles files with .eta file extensions', async () => { 25 | fse.mockTemplates(); 26 | const json = { 27 | 'package.json': '{}', 28 | 'templates/testing/jest.config.js.eta': 29 | '<%= it.expo ? "is expo" : "not expo" %>', 30 | 'templates/testing/src/test/render.ts.eta': 31 | '<%= it.foo ? "is foo" : "not foo" %>', 32 | }; 33 | vol.fromJSON(json, './'); 34 | 35 | await copyTemplateDirectory({ 36 | templateDir: 'testing', 37 | variables: { expo: true }, 38 | }); 39 | 40 | expect(fs.readFileSync('jest.config.js', 'utf8')).toEqual('is expo'); 41 | expect(fs.readFileSync('src/test/render.ts', 'utf8')).toEqual('not foo'); 42 | 43 | expect(fs.existsSync('src/test/render.ts.eta')).toBe(false); 44 | }); 45 | 46 | describe('string substitutions', () => { 47 | test('performs string substitutions', async () => { 48 | fse.mockTemplates(); 49 | const json = { 50 | 'templates/boilerplate/app.json': '{ "appName": "BELT_APP_NAME" }', 51 | }; 52 | vol.fromJSON(json, './'); 53 | 54 | await copyTemplateDirectory({ 55 | templateDir: 'boilerplate', 56 | stringSubstitutions: { 57 | 'app.json': { 58 | BELT_APP_NAME: 'MyApp', 59 | }, 60 | }, 61 | }); 62 | 63 | expect(fs.readFileSync('app.json', 'utf8')).toEqual( 64 | '{ "appName": "MyApp" }', 65 | ); 66 | }); 67 | 68 | test('matches file name using regex', async () => { 69 | fse.mockTemplates(); 70 | const json = { 71 | 'templates/boilerplate/src/app1.json': '{ "appName": "BELT_APP_NAME" }', 72 | }; 73 | vol.fromJSON(json, './'); 74 | 75 | await copyTemplateDirectory({ 76 | templateDir: 'boilerplate', 77 | stringSubstitutions: { 78 | 'src/app.*.json': { 79 | BELT_APP_NAME: 'MyApp', 80 | }, 81 | }, 82 | }); 83 | 84 | expect(fs.readFileSync('src/app1.json', 'utf8')).toEqual( 85 | '{ "appName": "MyApp" }', 86 | ); 87 | }); 88 | 89 | test('matches string to replace using regex', async () => { 90 | fse.mockTemplates(); 91 | const json = { 92 | 'templates/boilerplate/app.json': '{ "appName": "BELT_APP_NAME" }', 93 | }; 94 | vol.fromJSON(json, './'); 95 | 96 | await copyTemplateDirectory({ 97 | templateDir: 'boilerplate', 98 | stringSubstitutions: { 'app.json': { 'BELT_.*_NAME': 'MyApp' } }, 99 | }); 100 | 101 | expect(fs.readFileSync('app.json', 'utf8')).toEqual( 102 | '{ "appName": "MyApp" }', 103 | ); 104 | }); 105 | }); 106 | -------------------------------------------------------------------------------- /src/util/print/__tests__/printWelcome.test.ts: -------------------------------------------------------------------------------- 1 | import { test, vi } from 'vitest'; 2 | import printWelcome from '../printWelcome'; 3 | 4 | vi.mock('../../print', () => ({ default: vi.fn() })); 5 | 6 | test('doesnt error', () => { 7 | printWelcome(); 8 | }); 9 | -------------------------------------------------------------------------------- /src/util/print/printWelcome.ts: -------------------------------------------------------------------------------- 1 | import chalk from 'chalk'; 2 | import print from '../print'; 3 | 4 | export default function printWelcome() { 5 | print(chalk.bold('\n\n\t👖 Belt 👖\n')); 6 | print( 7 | 'Perform project setup and redundant tasks\n without your pants falling down!\n\n', 8 | ); 9 | } 10 | -------------------------------------------------------------------------------- /src/util/readAppJson.ts: -------------------------------------------------------------------------------- 1 | import fs from 'fs-extra'; 2 | import path from 'path'; 3 | import { AppJson } from '../types'; 4 | import getProjectDir from './getProjectDir'; 5 | 6 | export default async function readAppJson() { 7 | const rootDir = await getProjectDir(); 8 | const pkg = await fs.readFile(path.join(rootDir, 'app.json')); 9 | return JSON.parse(pkg.toString()) as AppJson; 10 | } 11 | -------------------------------------------------------------------------------- /src/util/readPackageJson.ts: -------------------------------------------------------------------------------- 1 | import fs from 'fs-extra'; 2 | import path from 'path'; 3 | import { PackageJson } from '../types'; 4 | import getProjectDir from './getProjectDir'; 5 | 6 | export default async function readPackageJson() { 7 | const rootDir = await getProjectDir(); 8 | const pkg = await fs.readFile(path.join(rootDir, 'package.json')); 9 | return JSON.parse(pkg.toString()) as PackageJson; 10 | } 11 | -------------------------------------------------------------------------------- /src/util/validateAndSanitizeAppName.ts: -------------------------------------------------------------------------------- 1 | import { input } from '@inquirer/prompts'; 2 | import chalk from 'chalk'; 3 | import _ from 'lodash'; 4 | import { globals } from '../constants'; 5 | import print from './print'; 6 | 7 | const startWithLetter = /^[a-zA-Z].*$/i; 8 | 9 | export default async function validateAndSanitizeAppName( 10 | name: string | undefined, 11 | ) { 12 | const appName = camelize(name || (await getAppName())); 13 | 14 | if (!appName) { 15 | printWarning("\nPants required! App name can't be blank."); 16 | process.exit(0); 17 | } 18 | 19 | if (!startWithLetter.test(appName)) { 20 | printWarning('\nApp name must start with a letter.'); 21 | process.exit(0); 22 | } 23 | 24 | return appName; 25 | } 26 | 27 | async function getAppName() { 28 | if (!globals.interactive) { 29 | throw new Error( 30 | 'App name not provided and running in non-interactive mode, aborting..', 31 | ); 32 | } 33 | 34 | return input({ message: 'What is the name of your app?' }); 35 | } 36 | 37 | function camelize(appName: string) { 38 | return _.upperFirst(_.camelCase(appName.trim())); 39 | } 40 | 41 | function printWarning(message: string) { 42 | return print(chalk.yellow(message)); 43 | } 44 | -------------------------------------------------------------------------------- /src/util/writeFile.ts: -------------------------------------------------------------------------------- 1 | import fs from 'fs-extra'; 2 | import formatFile from './formatFile'; 3 | 4 | type Options = { 5 | format?: boolean; 6 | }; 7 | 8 | export default async function writeFile( 9 | filePath: string, 10 | contents: string, 11 | { format = false }: Options = {}, 12 | ) { 13 | // outputFile is the same as writeFile, but it creates directories that don't exist 14 | await fs.outputFile(filePath, contents); 15 | 16 | if (format) { 17 | await formatFile(filePath); 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /templates/boilerplate/.eslintignore: -------------------------------------------------------------------------------- 1 | babel.config.js 2 | metro.config.js 3 | jest.config.js 4 | jest.setup.js 5 | /.cache 6 | /android 7 | /ios 8 | -------------------------------------------------------------------------------- /templates/boilerplate/.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | root: true, 3 | extends: [ 4 | '@thoughtbot/eslint-config/native', 5 | '@thoughtbot/eslint-config/typescript', 6 | ], 7 | }; 8 | -------------------------------------------------------------------------------- /templates/boilerplate/.gitignore: -------------------------------------------------------------------------------- 1 | # ----------------------------------------------------------------------------- 2 | # This file is generated from .gitignore.eta in bin/util/gitignoreUtil.js, do 3 | # not modify directly 4 | # ----------------------------------------------------------------------------- 5 | 6 | # this file is not packaged to npm, because npm does not support packaging 7 | # .gitignore files. we package .gitignore.eta instead. We still want to have a 8 | # .gitignore file present though for two reasons: 9 | # 10 | # - The belt repo uses this file to ignore files that should not be committed 11 | # - NPM uses this file to exclude files from the NPM package 12 | # 13 | # This file is auto-generated in a pre-commit hook. See bin/generate-gitignore.js 14 | 15 | # ----------------------------------------------------------------------------- 16 | # The following are not in .gitignore.eta because we don't want them to be 17 | # ignored in generated Belt apps. But we do want to ignore them in the Belt repo 18 | # and the npm package, so we add them. 19 | # 20 | # This file is auto-generated. To make changes here, update it in 21 | # bin/util/gitignoreUtil.js 22 | # ----------------------------------------------------------------------------- 23 | 24 | bun.lockb 25 | yarn.lock 26 | package-lock.json 27 | pnpm-lock.yaml 28 | 29 | # ----------------------------------------------------------------------------- 30 | # begin .gitignore contents from .gitignore.eta 31 | # ----------------------------------------------------------------------------- 32 | 33 | # Learn more https://docs.github.com/en/get-started/getting-started-with-git/ignoring-files 34 | 35 | # dependencies 36 | node_modules/ 37 | 38 | # Prefer using Expo Continuous Native Generation (CNG), even if using prebuild 39 | # https://docs.expo.dev/workflow/continuous-native-generation/ 40 | /ios 41 | /android 42 | 43 | # Expo 44 | .expo/ 45 | dist/ 46 | web-build/ 47 | 48 | # Native 49 | *.orig.* 50 | *.jks 51 | *.p8 52 | *.p12 53 | *.key 54 | *.mobileprovision 55 | 56 | # Metro 57 | .metro-health-check* 58 | 59 | # debug 60 | npm-debug.* 61 | yarn-debug.* 62 | yarn-error.* 63 | 64 | # macOS 65 | .DS_Store 66 | *.pem 67 | 68 | # local env files 69 | .env*.local 70 | 71 | # typescript 72 | *.tsbuildinfo 73 | 74 | /.cache 75 | -------------------------------------------------------------------------------- /templates/boilerplate/.gitignore.eta: -------------------------------------------------------------------------------- 1 | # Learn more https://docs.github.com/en/get-started/getting-started-with-git/ignoring-files 2 | 3 | # dependencies 4 | node_modules/ 5 | 6 | # Prefer using Expo Continuous Native Generation (CNG), even if using prebuild 7 | # https://docs.expo.dev/workflow/continuous-native-generation/ 8 | /ios 9 | /android 10 | 11 | # Expo 12 | .expo/ 13 | dist/ 14 | web-build/ 15 | 16 | # Native 17 | *.orig.* 18 | *.jks 19 | *.p8 20 | *.p12 21 | *.key 22 | *.mobileprovision 23 | 24 | # Metro 25 | .metro-health-check* 26 | 27 | # debug 28 | npm-debug.* 29 | yarn-debug.* 30 | yarn-error.* 31 | 32 | # macOS 33 | .DS_Store 34 | *.pem 35 | 36 | # local env files 37 | .env*.local 38 | 39 | # typescript 40 | *.tsbuildinfo 41 | 42 | /.cache 43 | -------------------------------------------------------------------------------- /templates/boilerplate/.prettierignore: -------------------------------------------------------------------------------- 1 | /android 2 | /ios 3 | /.yarn 4 | /.cache 5 | /vendor 6 | -------------------------------------------------------------------------------- /templates/boilerplate/.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "singleQuote": true 3 | } 4 | -------------------------------------------------------------------------------- /templates/boilerplate/App.tsx: -------------------------------------------------------------------------------- 1 | import { useColorScheme } from 'react-native'; 2 | import { NavigationContainer } from '@react-navigation/native'; 3 | import { QueryClientProvider } from '@tanstack/react-query'; 4 | import Providers, { Provider } from 'src/components/Providers'; 5 | import RootNavigator from 'src/navigators/RootNavigator'; 6 | import queryClient from 'src/util/api/queryClient'; 7 | import { lightThemeColors, darkThemeColors } from 'src/theme/colors'; 8 | 9 | // Add providers to this array. They will be wrapped around the app, with the 10 | // first items in the array wrapping the last items in the array. 11 | const providers: Provider[] = [ 12 | (children) => { 13 | const colorScheme = useColorScheme(); 14 | const colors = colorScheme === 'dark' ? darkThemeColors : lightThemeColors; 15 | 16 | return ( 17 | 23 | {children} 24 | 25 | ); 26 | }, 27 | (children) => ( 28 | {children} 29 | ), 30 | // CODEGEN:BELT:PROVIDERS - do not remove 31 | ]; 32 | 33 | export default function App() { 34 | // CODEGEN:BELT:HOOKS - do not remove 35 | return ( 36 | 37 | 38 | 39 | ); 40 | } 41 | -------------------------------------------------------------------------------- /templates/boilerplate/__mocks__/react-native-keyboard-aware-scroll-view.tsx: -------------------------------------------------------------------------------- 1 | /* eslint-disable */ 2 | const { ScrollView } = require('react-native'); 3 | 4 | module.exports = { 5 | KeyboardAwareScrollView: ScrollView, 6 | }; 7 | 8 | export {}; 9 | -------------------------------------------------------------------------------- /templates/boilerplate/app.json: -------------------------------------------------------------------------------- 1 | { 2 | "expo": { 3 | "name": "BELT_APP_NAME", 4 | "slug": "BELT_APP_NAME", 5 | "version": "1.0.0", 6 | "orientation": "portrait", 7 | "icon": "./assets/icon.png", 8 | "userInterfaceStyle": "automatic", 9 | "splash": { 10 | "image": "./assets/splash.png", 11 | "resizeMode": "contain", 12 | "backgroundColor": "#ffffff" 13 | }, 14 | "assetBundlePatterns": ["**/*"], 15 | "ios": { 16 | "supportsTablet": true 17 | }, 18 | "android": { 19 | "adaptiveIcon": { 20 | "foregroundImage": "./assets/adaptive-icon.png", 21 | "backgroundColor": "#ffffff" 22 | } 23 | }, 24 | "web": { 25 | "favicon": "./assets/favicon.png" 26 | } 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /templates/boilerplate/assets/adaptive-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/thoughtbot/belt/77a9408e2bdcbbebafe4b06f8ecbd242d15b1e8f/templates/boilerplate/assets/adaptive-icon.png -------------------------------------------------------------------------------- /templates/boilerplate/assets/favicon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/thoughtbot/belt/77a9408e2bdcbbebafe4b06f8ecbd242d15b1e8f/templates/boilerplate/assets/favicon.png -------------------------------------------------------------------------------- /templates/boilerplate/assets/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/thoughtbot/belt/77a9408e2bdcbbebafe4b06f8ecbd242d15b1e8f/templates/boilerplate/assets/icon.png -------------------------------------------------------------------------------- /templates/boilerplate/assets/splash.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/thoughtbot/belt/77a9408e2bdcbbebafe4b06f8ecbd242d15b1e8f/templates/boilerplate/assets/splash.png -------------------------------------------------------------------------------- /templates/boilerplate/babel.config.js: -------------------------------------------------------------------------------- 1 | module.exports = function (api) { 2 | api.cache(true); 3 | 4 | return { 5 | presets: ['babel-preset-expo'], 6 | }; 7 | }; 8 | -------------------------------------------------------------------------------- /templates/boilerplate/jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | preset: 'jest-expo', 3 | collectCoverageFrom: ['src/**/*.{js,jsx,ts,tsx}'], 4 | coveragePathIgnorePatterns: ['/node_modules', 'src/test'], 5 | transformIgnorePatterns: [ 6 | 'node_modules/(?!((jest-)?react-native|@react-native-community|@react-native|react-native|@react-navigation)|expo(nent)?|@expo(nent)?/.*|@expo-google-fonts/.*|react-navigation|@react-navigation/.*|@unimodules/.*|unimodules|sentry-expo|native-base|react-native-svg)', 7 | ], 8 | moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx', 'json', 'node'], 9 | moduleNameMapper: { 10 | '.+\\.(png|jpg|ttf|woff|woff2)$': '/src/test/fileMock.js', 11 | }, 12 | setupFilesAfterEnv: [ 13 | '@testing-library/jest-native/extend-expect', 14 | './jest.setup.js', 15 | ], 16 | }; 17 | -------------------------------------------------------------------------------- /templates/boilerplate/jest.setup.js: -------------------------------------------------------------------------------- 1 | import '@testing-library/jest-native/extend-expect'; 2 | import { configure } from '@testing-library/react-native'; 3 | import mockSafeAreaContext from 'react-native-safe-area-context/jest/mock'; 4 | import mockBackHandler from 'react-native/Libraries/Utilities/__mocks__/BackHandler.js'; 5 | import server from 'src/test/server'; 6 | import queryClient from 'src/util/api/queryClient'; 7 | 8 | beforeEach(() => { 9 | jest.clearAllMocks(); 10 | }); 11 | 12 | jest.mock('expo-font'); 13 | jest.mock('expo-asset'); 14 | 15 | jest.mock('react-native-safe-area-context', () => mockSafeAreaContext); 16 | 17 | jest.mock('react-native/Libraries/Alert/Alert', () => ({ 18 | alert: jest.fn(), 19 | })); 20 | 21 | jest.mock('react-native/Libraries/LogBox/LogBox', () => ({ 22 | __esModule: true, 23 | default: { 24 | ignoreLogs: jest.fn(), 25 | ignoreAllLogs: jest.fn(), 26 | }, 27 | })); 28 | 29 | jest.mock( 30 | 'react-native/Libraries/Utilities/BackHandler', 31 | () => mockBackHandler, 32 | ); 33 | 34 | jest.mock('@react-native-async-storage/async-storage', () => 35 | require('@react-native-async-storage/async-storage/jest/async-storage-mock'), 36 | ); 37 | 38 | jest.mock('react-native-keyboard-aware-scroll-view'); 39 | 40 | // listen with MSW server. Individual tests can pass mocks to 'render' function 41 | beforeAll(() => server.listen({ onUnhandledRequest: 'error' })); 42 | afterAll(() => server.close()); 43 | 44 | beforeEach(() => { 45 | server.resetHandlers(); 46 | }); 47 | 48 | afterEach(() => { 49 | queryClient.clear(); 50 | }); 51 | 52 | // configure debug output for RN Testing Library 53 | // is way too verbose by default. Only include common 54 | // props that might affect test failure. 55 | configure({ 56 | defaultDebugOptions: { 57 | mapProps({ 58 | accessibilityLabel, 59 | accessibilityRole, 60 | accessibilityElementsHidden, 61 | testID, 62 | accessibilityViewIsModal, 63 | }) { 64 | return { 65 | accessibilityLabel, 66 | accessibilityRole, 67 | accessibilityElementsHidden, 68 | testID, 69 | accessibilityViewIsModal, 70 | }; 71 | }, 72 | }, 73 | }); 74 | -------------------------------------------------------------------------------- /templates/boilerplate/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "belt_app_name", 3 | "version": "1.0.0", 4 | "main": "node_modules/expo/AppEntry.js", 5 | "scripts": { 6 | "start": "expo start", 7 | "android": "expo start --android", 8 | "ios": "expo start --ios", 9 | "web": "expo start --web", 10 | "lint:types": "tsc", 11 | "lint:prettier": "prettier --check '**/*' --ignore-unknown", 12 | "fix:prettier": "prettier --write '**/*' --ignore-unknown", 13 | "lint:eslint": "eslint --max-warnings=0 --ext js,jsx,ts,tsx .", 14 | "lint": "run-p lint:eslint lint:types lint:prettier", 15 | "test": "jest", 16 | "test:ci": "jest --maxWorkers=2 --silent --ci", 17 | "test:cov": "jest --coverage --coverageDirectory ./.cache/coverage", 18 | "test:all": "npm run lint && npm run test:cov" 19 | }, 20 | "dependencies": { 21 | "@expo/vector-icons": "^14.0.2", 22 | "@react-native-async-storage/async-storage": "1.23.1", 23 | "@react-navigation/bottom-tabs": "^6.5.20", 24 | "@react-navigation/native": "^6.1.10", 25 | "@react-navigation/native-stack": "^6.9.18", 26 | "@tanstack/react-query": "^5.32.1", 27 | "axios": "^1.6.8", 28 | "expo": "~51.0.18", 29 | "expo-status-bar": "~1.12.1", 30 | "msw": "^2.2.14", 31 | "react": "18.2.0", 32 | "react-native": "0.74.5", 33 | "react-native-keyboard-aware-scroll-view": "^0.9.5", 34 | "react-native-safe-area-context": "4.10.5", 35 | "react-native-screens": "3.31.1" 36 | }, 37 | "devDependencies": { 38 | "@babel/core": "^7.20.0", 39 | "@testing-library/jest-native": "^5.4.3", 40 | "@testing-library/react-native": "^12.4.5", 41 | "@thoughtbot/eslint-config": "^1.0.2", 42 | "@types/jest": "^29.5.12", 43 | "@types/react": "~18.2.73", 44 | "@types/react-test-renderer": "^18.0.7", 45 | "babel-jest": "^29.7.0", 46 | "create-belt-app": "^0.7.3", 47 | "eslint": "^8.56.0", 48 | "jest": "^29.3.1", 49 | "jest-expo": "~51.0.3", 50 | "npm-run-all": "^4.1.5", 51 | "prettier": "^3.2.5", 52 | "react-test-renderer": "18.2.0", 53 | "typescript": "~5.3.3" 54 | }, 55 | "private": true 56 | } 57 | -------------------------------------------------------------------------------- /templates/boilerplate/src/__tests__/App.integration.test.tsx: -------------------------------------------------------------------------------- 1 | import { screen, userEvent } from '@testing-library/react-native'; 2 | 3 | import mock from 'src/test/mock'; 4 | import { renderApplication } from 'src/test/render'; 5 | import { GithubProjectsResponse } from 'src/util/api/api'; 6 | 7 | // Testing philosophy: 8 | // - Tests that render the entire application with `renderApplication` go in the 9 | // top level `src/__tests__` directory and are named with `mytest.integration.test.tsx`. 10 | // These are ideal for when you need to test flows that include navigation between screens 11 | // - Tests that render a single screen or component are colocated in 12 | // `__tests__/MyComponent.test.tsx`. These call `render` and are not able to 13 | // navigate between screens, since the Navigator is not mounted 14 | test('renders app, can navigate between screens', async () => { 15 | jest.useFakeTimers(); 16 | 17 | const mocks = [mockGitHubProjects()]; 18 | 19 | // load the app on the Home screen 20 | renderApplication({ mocks }); 21 | expect( 22 | await screen.findByRole('header', { name: /Welcome to your new app/ }), 23 | ).toBeDefined(); 24 | 25 | // go to About tab 26 | await userEvent.press(screen.getByRole('button', { name: /About/ })); 27 | expect( 28 | await screen.findByRole('header', { name: 'Open Source' }), 29 | ).toBeDefined(); 30 | 31 | // expect GitHub project loaded via API 32 | expect(await screen.findByText(/Belt is a CLI/)).toBeDefined(); 33 | }); 34 | 35 | // TODO: sample data, remove 36 | // creates a mock for a GET request to the GitHub projects API. 37 | // Pass this mock to `render` or `renderApplication` to register it with MSW. 38 | // Recommended to place these mocks in a central location like `src/test/mocks` 39 | function mockGitHubProjects() { 40 | return mock.get( 41 | 'https://thoughtbot-projects-api-68b03dc59059.herokuapp.com/api/projects', 42 | { 43 | response: { 44 | projects: [ 45 | { 46 | id: 635980144, 47 | name: 'belt', 48 | description: 49 | 'Belt is a CLI for starting a new React Native Expo app and will even keep your pants secure as you continue development.', 50 | url: 'https://github.com/thoughtbot/belt', 51 | stars: 8, 52 | forks: 0, 53 | }, 54 | ], 55 | }, 56 | }, 57 | ); 58 | } 59 | -------------------------------------------------------------------------------- /templates/boilerplate/src/components/Providers.tsx: -------------------------------------------------------------------------------- 1 | import { ReactNode } from 'react'; 2 | 3 | export type Provider = (children: ReactNode) => ReactNode; 4 | 5 | type ProvidersProps = { 6 | children: ReactNode; 7 | providers: Provider[]; 8 | }; 9 | 10 | export default function Providers({ children, providers }: ProvidersProps) { 11 | return providers.reverse().reduce((acc, current) => { 12 | return current(acc); 13 | }, children); 14 | } 15 | -------------------------------------------------------------------------------- /templates/boilerplate/src/components/Screen.tsx: -------------------------------------------------------------------------------- 1 | import { useNavigation } from '@react-navigation/native'; 2 | import { ReactNode } from 'react'; 3 | import { StyleSheet, View } from 'react-native'; 4 | import { 5 | KeyboardAwareScrollView, 6 | KeyboardAwareScrollViewProps, 7 | } from 'react-native-keyboard-aware-scroll-view'; 8 | import { 9 | SafeAreaView, 10 | useSafeAreaInsets, 11 | } from 'react-native-safe-area-context'; 12 | 13 | type Props = KeyboardAwareScrollViewProps & { 14 | /** 15 | * If true (default), horizontal padding is added to the screen content 16 | */ 17 | padHorizontal?: boolean; 18 | /** 19 | * If true, the screen will be scrollable. If false, the screen will not scroll. 20 | * Set to false if screen includes a scrollable component like a FlatList 21 | */ 22 | scroll?: boolean; 23 | /** 24 | * If true, a safe area view is not added for the top of the screen, since it is 25 | * handled instead by React Navigation 26 | */ 27 | hasHeader?: boolean; 28 | /** A React component to render fixed to the bottom of the screen. It is not 29 | * positioned absolutely and would show above a tab bar. If your screen does 30 | * not have a tab bar, set fixedBottomAddSafeArea to ensure a safe area view 31 | * is used on the bottom */ 32 | FixedBottomComponent?: ReactNode; 33 | fixedBottomAddSafeArea?: boolean; 34 | }; 35 | 36 | export default function Screen({ 37 | style, 38 | padHorizontal = true, 39 | scroll = true, 40 | testID, 41 | hasHeader = false, 42 | children, 43 | FixedBottomComponent, 44 | fixedBottomAddSafeArea = false, 45 | ...props 46 | }: Props) { 47 | const navigation = useNavigation(); 48 | const insets = useSafeAreaInsets(); 49 | 50 | return ( 51 | 58 | 66 | {scroll ? ( 67 | navigation.goBack()} 72 | testID={`${testID || 'Screen'}ScrollView`} 73 | showsVerticalScrollIndicator={false} 74 | {...props} 75 | > 76 | {children} 77 | 78 | ) : ( 79 | children 80 | )} 81 | 82 | {!!FixedBottomComponent && ( 83 | 90 | {FixedBottomComponent} 91 | 92 | )} 93 | 94 | ); 95 | } 96 | 97 | const styles = StyleSheet.create({ 98 | wrapper: { 99 | flex: 1, 100 | }, 101 | contentContainer: { 102 | flex: 1, 103 | }, 104 | horizontalPadding: { 105 | paddingHorizontal: 20, 106 | }, 107 | }); 108 | -------------------------------------------------------------------------------- /templates/boilerplate/src/components/Text.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Text as RNText, TextProps as RNTextProps } from 'react-native'; 3 | import useTheme from 'src/theme/useTheme'; 4 | 5 | type TextProps = RNTextProps; 6 | 7 | export default function Text({ style, ...props }: TextProps) { 8 | const { colors } = useTheme(); 9 | 10 | return ; 11 | } 12 | -------------------------------------------------------------------------------- /templates/boilerplate/src/components/buttons/PrimaryButton.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | StyleSheet, 3 | TouchableOpacity, 4 | TouchableOpacityProps, 5 | } from 'react-native'; 6 | import useTheme from 'src/theme/useTheme'; 7 | 8 | type ButtonProps = TouchableOpacityProps; 9 | 10 | export default function PrimaryButton({ style, ...props }: ButtonProps) { 11 | const { colors } = useTheme(); 12 | 13 | return ( 14 | 19 | ); 20 | } 21 | 22 | const styles = StyleSheet.create({ 23 | button: { 24 | paddingVertical: 16, 25 | paddingHorizontal: 14, 26 | borderRadius: 12, 27 | justifyContent: 'center', 28 | }, 29 | }); 30 | -------------------------------------------------------------------------------- /templates/boilerplate/src/navigators/AboutStack.tsx: -------------------------------------------------------------------------------- 1 | import { createNativeStackNavigator } from '@react-navigation/native-stack'; 2 | import React from 'react'; 3 | import AboutScreen from 'src/screens/AboutScreen/AboutScreen'; 4 | import { AboutTabParamList } from './navigatorTypes'; 5 | 6 | const About = createNativeStackNavigator(); 7 | 8 | export default function AboutStack() { 9 | return ( 10 | 11 | 12 | 13 | ); 14 | } 15 | -------------------------------------------------------------------------------- /templates/boilerplate/src/navigators/DashboardStack.tsx: -------------------------------------------------------------------------------- 1 | import { createNativeStackNavigator } from '@react-navigation/native-stack'; 2 | import React from 'react'; 3 | import HomeScreen from '../screens/HomeScreen/HomeScreen'; 4 | import InformationScreen from '../screens/InformationScreen/InformationScreen'; 5 | import { DashboardTabParamList } from './navigatorTypes'; 6 | 7 | const Dashboard = createNativeStackNavigator(); 8 | 9 | export default function DashboardStack() { 10 | return ( 11 | 12 | 17 | 18 | 19 | ); 20 | } 21 | -------------------------------------------------------------------------------- /templates/boilerplate/src/navigators/RootNavigator.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | NativeStackNavigationOptions, 3 | createNativeStackNavigator, 4 | } from '@react-navigation/native-stack'; 5 | import TabNavigator from './TabNavigator'; 6 | 7 | const navigatorScreenOptions: NativeStackNavigationOptions = { 8 | headerShadowVisible: false, 9 | }; 10 | 11 | const Stack = createNativeStackNavigator(); 12 | 13 | export default function RootNavigator() { 14 | return ( 15 | 16 | 21 | 22 | {/* 23 | screens that are navigable outside of tabs go here. This can include: 24 | - authentication screens (only render tab navigator conditionally) 25 | - screens that should not display bottom tab bar and that might be 26 | navigated to from a screen from multiple tabs 27 | */} 28 | 29 | ); 30 | } 31 | -------------------------------------------------------------------------------- /templates/boilerplate/src/navigators/TabNavigator.tsx: -------------------------------------------------------------------------------- 1 | import Feather from '@expo/vector-icons/Feather'; 2 | import { createBottomTabNavigator } from '@react-navigation/bottom-tabs'; 3 | import SettingsScreen from '../screens/SettingsScreen/SettingsScreen'; 4 | import AboutStack from './AboutStack'; 5 | import DashboardStack from './DashboardStack'; 6 | import { TabsParamList } from './navigatorTypes'; 7 | 8 | const Tab = createBottomTabNavigator(); 9 | 10 | function HomeIcon({ focused = false, color = 'gray' }) { 11 | return ; 12 | } 13 | 14 | function AboutIcon({ focused = false, color = 'gray' }) { 15 | return ; 16 | } 17 | 18 | function AccountIcon({ focused = false, color = 'gray' }) { 19 | return ; 20 | } 21 | 22 | // To add a new bottom tab: 23 | // 1. Create a new stack navigator for the tab's screens 24 | // 2. Add a new screen to the stack navigator 25 | // 3. Add a new Tab.Screen to the TabNavigator 26 | // 4. Update navigatorTypes with the TypeScript types for the tab 27 | export default function TabNavigator() { 28 | return ( 29 | 40 | 49 | 58 | 66 | 67 | ); 68 | } 69 | -------------------------------------------------------------------------------- /templates/boilerplate/src/navigators/navigatorTypes.tsx: -------------------------------------------------------------------------------- 1 | import { NavigatorScreenParams } from '@react-navigation/native'; 2 | import { NativeStackScreenProps } from '@react-navigation/native-stack'; 3 | 4 | export type RootStackParamList = { 5 | Tabs: NavigatorScreenParams; 6 | }; 7 | 8 | // Each tab goes here 9 | export type TabsParamList = { 10 | DashboardTab: NavigatorScreenParams; 11 | SettingsTab: NavigatorScreenParams; 12 | AboutTab: NavigatorScreenParams; 13 | }; 14 | 15 | /* ---------------------------------------------------------------- 16 | For each tab, define all of the screens that can be navigated to 17 | -------------------------------------------------------------*/ 18 | 19 | export type DashboardTabParamList = { 20 | Home: undefined; 21 | Information: { greeting: string } | undefined; 22 | }; 23 | 24 | export type AboutTabParamList = { 25 | About: undefined; 26 | }; 27 | 28 | export type SettingsTabParamList = { 29 | Settings: undefined; 30 | }; 31 | 32 | /* ---------------------------------------------------------------- 33 | Derived types -- these should not need to be frequently modified 34 | -------------------------------------------------------------*/ 35 | export type TabName = keyof TabsParamList; 36 | export type RootRouteName = keyof RootStackParamList; 37 | export type AppRouteName = 38 | | keyof RootStackParamList 39 | | keyof DashboardTabParamList 40 | | keyof SettingsTabParamList; 41 | 42 | /* ---------------------------------------------------------------- 43 | Define ScreenProp type for each screen that might need to access 44 | navigation or route props 45 | Usage eg: 46 | const navigation = useNavigation(); 47 | const params = useRoute(); 48 | -------------------------------------------------------------*/ 49 | 50 | export type HomeScreenProp = NativeStackScreenProps< 51 | DashboardTabParamList, 52 | 'Home' 53 | >; 54 | 55 | export type AboutScreenProp = NativeStackScreenProps< 56 | AboutTabParamList, 57 | 'About' 58 | >; 59 | 60 | export type InformationScreenProp = NativeStackScreenProps< 61 | DashboardTabParamList, 62 | 'Information' 63 | >; 64 | -------------------------------------------------------------------------------- /templates/boilerplate/src/screens/AboutScreen/AboutScreen.tsx: -------------------------------------------------------------------------------- 1 | import Feather from '@expo/vector-icons/Feather'; 2 | import { useQuery } from '@tanstack/react-query'; 3 | import { ActivityIndicator, FlatList, StyleSheet, View } from 'react-native'; 4 | import Screen from 'src/components/Screen'; 5 | import Text from 'src/components/Text'; 6 | import api, { GithubProject } from 'src/util/api/api'; 7 | 8 | export default function AboutScreen() { 9 | return ; 10 | } 11 | 12 | function Header() { 13 | return ( 14 | <> 15 | 16 | Open Source 17 | 18 | 19 | Here are a few projects that we maintain. 20 | 21 | 22 | ); 23 | } 24 | 25 | // TODO: sample data, remove 26 | function GitHubProjects() { 27 | const { data, isLoading } = useQuery({ 28 | queryKey: ['githubRepos'], 29 | queryFn: api.githubRepos, 30 | }); 31 | 32 | const projects = data?.projects 33 | ? data.projects.sort((a, b) => (b.stars || 0) - (a.stars || 0)).slice(0, 30) 34 | : []; 35 | 36 | return ( 37 | 38 | } 41 | keyExtractor={(item) => item.id.toString()} 42 | ListHeaderComponent={Header} 43 | stickyHeaderHiddenOnScroll 44 | ListEmptyComponent={ 45 | isLoading ? : No results found 46 | } 47 | style={{ flexGrow: 0 }} 48 | /> 49 | 50 | ); 51 | } 52 | 53 | // TODO: sample data, remove 54 | function Project({ project }: { project: GithubProject }) { 55 | const { name, description, stars } = project; 56 | const formatStars = () => { 57 | if (!stars || stars < 1000) { 58 | return stars; 59 | } 60 | 61 | return `${(stars / 1000).toFixed(1)}k`; 62 | }; 63 | const formattedStars = formatStars(); 64 | 65 | return ( 66 | 67 | 68 | 69 | {name} 70 | 71 | 72 | {formattedStars != null && ( 73 | 74 | 75 | {formatStars()} 76 | 77 | )} 78 | 79 | 80 | {description} 81 | 82 | ); 83 | } 84 | 85 | const styles = StyleSheet.create({ 86 | projectHeader: { 87 | flexDirection: 'row', 88 | justifyContent: 'space-between', 89 | alignItems: 'center', 90 | marginBottom: 8, 91 | }, 92 | starContainer: { 93 | flexDirection: 'row', 94 | gap: 6, 95 | }, 96 | heading: { 97 | fontWeight: '800', 98 | fontSize: 24, 99 | marginTop: 48, 100 | marginBottom: 12, 101 | paddingHorizontal: 16, 102 | }, 103 | paragraph: { 104 | fontSize: 16, 105 | marginBottom: 16, 106 | paddingHorizontal: 16, 107 | }, 108 | project: { 109 | paddingHorizontal: 16, 110 | paddingVertical: 20, 111 | borderBottomWidth: StyleSheet.hairlineWidth, 112 | borderBottomColor: 'rgba(0, 0, 0, 0.2)', 113 | }, 114 | projectName: { 115 | fontWeight: '500', 116 | fontSize: 18, 117 | marginBottom: 4, 118 | }, 119 | projectDescription: { 120 | fontSize: 15, 121 | lineHeight: 22, 122 | }, 123 | projectStars: { 124 | // 125 | }, 126 | }); 127 | -------------------------------------------------------------------------------- /templates/boilerplate/src/screens/HomeScreen/HomeScreen.tsx: -------------------------------------------------------------------------------- 1 | import { StatusBar } from 'expo-status-bar'; 2 | import Screen from 'src/components/Screen'; 3 | import HomeScreenContent from './HomeScreenContent'; 4 | 5 | export default function HomeScreen() { 6 | return ( 7 | 8 | 9 | 10 | 11 | ); 12 | } 13 | -------------------------------------------------------------------------------- /templates/boilerplate/src/screens/HomeScreen/HomeScreenContent.tsx: -------------------------------------------------------------------------------- 1 | import { ReactNode } from 'react'; 2 | import { Image, Platform, StyleSheet, TextProps, View } from 'react-native'; 3 | import UnStyledText from 'src/components/Text'; 4 | import useTheme from 'src/theme/useTheme'; 5 | 6 | // TODO: sample, remove 7 | export default function HomeScreenContent() { 8 | return ( 9 | <> 10 | 22 | 26 | Welcome to your new app! Here are some notes and tips to get you 27 | started. 28 | 29 | 30 | 31 | 32 | We set some things up for you: Expo, 33 | TanStack Query, Testing Library, React Navigation, and more! 34 | 35 | 36 | 37 | 38 | Some notes about organization 39 | 40 | 41 | Reusable components go in{' '} 42 | 43 | src/components 44 | 45 | 46 | 47 | 48 | Screens go in{' '} 49 | 50 | {'src/screens/{MyScreen}/{MyScreen}.tsx'} 51 | 52 | 53 | 54 | 55 | Screen-specific code goes in{' '} 56 | 57 | {'src/screens/{MyScreen}'} 58 | 59 | 60 | 61 | 62 | Navigation code goes in{' '} 63 | 64 | src/navigators 65 | 66 | 67 | 68 | 69 | Tests are co-located with code. Eg.{' '} 70 | 71 | {'__tests__/{MyComponent}.test.tsx'} 72 | 73 | 74 | 75 | 76 | 77 | 78 | 79 | To add a new bottom tab head to{' '} 80 | TabNavigator.tsx and follow the 81 | instructions commented there. 82 | 83 | 84 | 85 | 86 | 87 | To add a new screen head to the 88 | screens directory and mount it in the appropriate stack. 89 | 90 | 91 | 92 | 93 | 94 | Check out an example API call using 95 | TanStack Query in{' '} 96 | AboutScreen.tsx. 97 | 98 | 99 | 100 | 101 | 102 | Check out a sample test in{' '} 103 | 104 | src/__tests__/App.integration.test.tsx 105 | 106 | . 107 | 108 | 109 | 110 | 111 | 112 | Sample code is marked with a{' '} 113 | “TODO” comment.{' '} 114 | Search the codebase for{' '} 115 | “TODO” and remove any desired 116 | sample code. 117 | 118 | 119 | 120 | ); 121 | } 122 | 123 | function Text({ style, ...props }: TextProps) { 124 | return ; 125 | } 126 | 127 | function BulletListItem({ children }: { children: ReactNode }) { 128 | return ( 129 | 130 | {'\u2022'} 131 | {children} 132 | 133 | ); 134 | } 135 | 136 | function Card({ children }: { children: ReactNode }) { 137 | const { colors } = useTheme(); 138 | return ( 139 | 140 | {children} 141 | 142 | ); 143 | } 144 | 145 | const styles = StyleSheet.create({ 146 | text: { 147 | fontSize: 18, 148 | lineHeight: 24, 149 | }, 150 | paragraph: { 151 | fontSize: 19, 152 | lineHeight: 24, 153 | marginBottom: 24, 154 | marginTop: 12, 155 | }, 156 | card: { 157 | marginBottom: 18, 158 | paddingVertical: 18, 159 | paddingHorizontal: 12, 160 | borderRadius: 10, 161 | }, 162 | centered: { 163 | textAlign: 'center', 164 | }, 165 | bulletItem: { 166 | flexDirection: 'row', 167 | alignItems: 'flex-start', 168 | marginBottom: 5, 169 | }, 170 | bullet: { 171 | fontSize: 20, 172 | lineHeight: 22, 173 | marginRight: 8, 174 | }, 175 | itemText: { 176 | flex: 1, 177 | fontSize: 17, 178 | lineHeight: 22, 179 | }, 180 | 181 | orgBullets: { 182 | marginTop: 12, 183 | }, 184 | inlineCode: { 185 | fontFamily: Platform.OS === 'ios' ? 'Courier New' : 'monospace', 186 | fontWeight: '500', 187 | fontSize: 15, 188 | }, 189 | bold: { 190 | fontWeight: 'bold', 191 | }, 192 | }); 193 | -------------------------------------------------------------------------------- /templates/boilerplate/src/screens/InformationScreen/InformationScreen.tsx: -------------------------------------------------------------------------------- 1 | import { useRoute } from '@react-navigation/native'; 2 | import { StatusBar } from 'expo-status-bar'; 3 | import { StyleSheet, View } from 'react-native'; 4 | import { InformationScreenProp } from 'src/navigators/navigatorTypes'; 5 | import Text from 'src/components/Text'; 6 | 7 | export default function InformationScreen() { 8 | const { params } = useRoute(); 9 | 10 | return ( 11 | 12 | {params && {params.greeting}} 13 | 14 | 15 | ); 16 | } 17 | 18 | const styles = StyleSheet.create({ 19 | container: { 20 | flex: 1, 21 | backgroundColor: '#fff', 22 | alignItems: 'center', 23 | justifyContent: 'center', 24 | }, 25 | }); 26 | -------------------------------------------------------------------------------- /templates/boilerplate/src/screens/SettingsScreen/SettingsScreen.tsx: -------------------------------------------------------------------------------- 1 | import { StatusBar } from 'expo-status-bar'; 2 | import { StyleSheet, View } from 'react-native'; 3 | import Text from 'src/components/Text'; 4 | 5 | export default function SettingsScreen() { 6 | return ( 7 | 8 | Settings Screen 9 | 10 | 11 | ); 12 | } 13 | 14 | const styles = StyleSheet.create({ 15 | container: { 16 | flex: 1, 17 | alignItems: 'center', 18 | justifyContent: 'center', 19 | }, 20 | }); 21 | -------------------------------------------------------------------------------- /templates/boilerplate/src/test/fileMock.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | process() { 3 | return 'module.exports = ""'; 4 | }, 5 | }; 6 | -------------------------------------------------------------------------------- /templates/boilerplate/src/test/mock.ts: -------------------------------------------------------------------------------- 1 | import { 2 | DefaultBodyType, 3 | HttpResponse, 4 | PathParams, 5 | StrictRequest, 6 | http, 7 | } from 'msw'; 8 | import { RequestMethod } from 'src/util/api/api'; 9 | 10 | type RequestParams = Record< 11 | string, 12 | string | number | undefined | (string | number)[] 13 | >; 14 | type MockRequestParams = { 15 | method: RequestMethod; 16 | response?: TData; 17 | headers?: Record; 18 | params?: Partial; 19 | status?: number; 20 | delay?: number; 21 | baseUrl?: string; 22 | }; 23 | 24 | function mockRequest( 25 | url: string, 26 | { 27 | method, 28 | status = 200, 29 | response, 30 | headers, 31 | params, 32 | }: MockRequestParams, 33 | ) { 34 | const methodName = method.toLowerCase() as Lowercase; 35 | return http[methodName]( 36 | url, 37 | async (info) => { 38 | const { request, params: actualParams } = info; 39 | validateHeaders(headers, request); 40 | await validateParams(params, actualParams, request); 41 | 42 | const responseString = 43 | typeof response === 'string' ? response : JSON.stringify(response); 44 | return new HttpResponse(responseString, { status }); 45 | }, 46 | { once: true }, 47 | ); 48 | } 49 | 50 | function validateHeaders( 51 | expectedHeaders: Record | undefined, 52 | req: StrictRequest, 53 | ) { 54 | if (!expectedHeaders) { 55 | return; 56 | } 57 | 58 | Object.entries(expectedHeaders).forEach(([key, value]) => { 59 | try { 60 | expect(req.headers.get(key)).toEqual(value); 61 | } catch (e) { 62 | handleAndThrowError(req, e, 'the headers did not match expectation'); 63 | } 64 | }); 65 | } 66 | 67 | async function validateParams( 68 | expectedParams: TParams | undefined, 69 | actualParams: PathParams, 70 | req: StrictRequest, 71 | ) { 72 | if (!expectedParams) { 73 | return; 74 | } 75 | 76 | const searchParams = Object.fromEntries(new URL(req.url).searchParams); 77 | const params = Object.keys(searchParams).length ? searchParams : actualParams; 78 | 79 | try { 80 | expect(params).toMatchObject(expectedParams); 81 | } catch (e) { 82 | handleAndThrowError(req, e, 'the params did not match expectation'); 83 | } 84 | } 85 | 86 | function handleAndThrowError( 87 | request: StrictRequest, 88 | e: unknown, 89 | message: string, 90 | ) { 91 | const error = e as Error; 92 | if (error.message) { 93 | error.message = `Mock for ${request.method} ${ 94 | request.url 95 | } was called, but ${message}. Verify that the mocks provided to the test are correct.\n\n${ 96 | error.message 97 | }.\n\nThis error occurred in test: ${ 98 | expect.getState().testPath || '' 99 | }. Test name: '${expect.getState().currentTestName || 'unknown'}'`; 100 | } 101 | // eslint-disable-next-line no-console 102 | console.error(error.stack); 103 | throw error; 104 | } 105 | 106 | export type MockParams = Omit< 107 | MockRequestParams, 108 | 'method' 109 | >; 110 | 111 | /** 112 | * mock requests for tests 113 | * Eg. usage: 114 | * const mocks = [ 115 | * mock.post('/user/login', { response: { firstName: 'Debra' }}) 116 | * ]) 117 | * 118 | * render(\, { mocks }) 119 | */ 120 | const mock = { 121 | /** 122 | * mock a GET request to the specified url 123 | * If params are passed, will throw an error if request params do not match 124 | * */ 125 | get: >( 126 | url: string, 127 | params: MockParams = {}, 128 | ) => mockRequest(url, { ...params, method: 'GET' }), 129 | /** 130 | * mock a POST request to the specified url 131 | * if params are passed, will throw an error if request params do not match 132 | * */ 133 | post: >( 134 | url: string, 135 | params: MockParams = {}, 136 | ) => mockRequest(url, { ...params, method: 'POST' }), 137 | /** 138 | * mock a DELETE request to the specified url 139 | * if params are passed, will throw an error if request params do not match 140 | * */ 141 | delete: >( 142 | url: string, 143 | params: MockParams = {}, 144 | ) => mockRequest(url, { ...params, method: 'DELETE' }), 145 | }; 146 | 147 | export default mock; 148 | -------------------------------------------------------------------------------- /templates/boilerplate/src/test/render.tsx: -------------------------------------------------------------------------------- 1 | import { NavigationContainer } from '@react-navigation/native'; 2 | import { QueryClientProvider } from '@tanstack/react-query'; 3 | import { 4 | RenderAPI, 5 | // eslint-disable-next-line no-restricted-imports 6 | render as TestingLibraryRender, 7 | } from '@testing-library/react-native'; 8 | import { RequestHandler } from 'msw'; 9 | import { ReactElement } from 'react'; 10 | import Providers, { Provider } from 'src/components/Providers'; 11 | import RootNavigator from 'src/navigators/RootNavigator'; 12 | import queryClient from 'src/util/api/queryClient'; 13 | import server from './server'; 14 | 15 | export type RenderOptions = { 16 | mocks?: Array; 17 | }; 18 | 19 | // TODO: this will become customized as the codebase progresses, so our 20 | // tests can be wrapped with appropriate providers, mocks can be supplied, etc 21 | export default function render( 22 | element: ReactElement, 23 | { mocks }: RenderOptions = {}, 24 | ): RenderAPI { 25 | if (mocks) { 26 | server.use(...mocks); 27 | } 28 | 29 | const providers: Provider[] = [ 30 | (children) => ( 31 | {children} 32 | ), 33 | (children) => {children}, 34 | // CODEGEN:BELT:PROVIDERS - do not remove 35 | ]; 36 | 37 | return TestingLibraryRender( 38 | {element}, 39 | ); 40 | } 41 | 42 | export function renderApplication(options: RenderOptions = {}) { 43 | return render(, options); 44 | } 45 | -------------------------------------------------------------------------------- /templates/boilerplate/src/test/server.ts: -------------------------------------------------------------------------------- 1 | import { setupServer } from 'msw/node'; 2 | 3 | /** 4 | * MSW server for mocking network requests 5 | * server is started in jest.setup.js 6 | * individual tests can pass mocks to 'render' function 7 | */ 8 | const server = setupServer(); 9 | export default server; 10 | -------------------------------------------------------------------------------- /templates/boilerplate/src/test/sleep.ts: -------------------------------------------------------------------------------- 1 | export default function sleep(ms: number) { 2 | return new Promise((resolve) => { 3 | setTimeout(resolve, ms); 4 | }); 5 | } 6 | -------------------------------------------------------------------------------- /templates/boilerplate/src/test/waitForUpdates.ts: -------------------------------------------------------------------------------- 1 | import { act } from '@testing-library/react-native'; 2 | import sleep from './sleep'; 3 | 4 | /** 5 | * Wait a specified time, wrapped in act 6 | * Usually, it is better to use waitFor or a findBy* matcher, 7 | * but this is sometimes required 8 | * @param time 9 | */ 10 | export default async function waitForUpdates(time = 2) { 11 | return act(() => sleep(time)); 12 | } 13 | -------------------------------------------------------------------------------- /templates/boilerplate/src/theme/colors.ts: -------------------------------------------------------------------------------- 1 | import { DefaultTheme, DarkTheme } from '@react-navigation/native'; 2 | import type { Theme } from '@react-navigation/native'; 3 | 4 | export type CustomThemeColors = Theme['colors'] & { 5 | button: string; 6 | }; 7 | 8 | export const lightThemeColors: CustomThemeColors = { 9 | ...DefaultTheme.colors, 10 | button: '#08f', 11 | }; 12 | 13 | export const darkThemeColors: CustomThemeColors = { 14 | ...DarkTheme.colors, 15 | button: '#08f', 16 | }; 17 | -------------------------------------------------------------------------------- /templates/boilerplate/src/theme/useTheme.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Theme, 3 | useTheme as useNavigationTheme, 4 | } from '@react-navigation/native'; 5 | import type { CustomThemeColors } from './colors'; 6 | 7 | export type CustomTheme = Theme & { 8 | colors: CustomThemeColors; 9 | }; 10 | 11 | const useTheme = () => { 12 | const theme = useNavigationTheme(); 13 | return theme as CustomTheme; 14 | }; 15 | 16 | export default useTheme; 17 | -------------------------------------------------------------------------------- /templates/boilerplate/src/types/global.d.ts: -------------------------------------------------------------------------------- 1 | // eslint-disable-next-line @typescript-eslint/no-unused-vars 2 | import { RootStackParamList } from '../navigators/navigatorTypes'; 3 | 4 | declare global { 5 | namespace ReactNavigation { 6 | // eslint-disable-next-line @typescript-eslint/no-empty-interface 7 | interface RootParamList extends RootStackParamList {} 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /templates/boilerplate/src/util/api/api.ts: -------------------------------------------------------------------------------- 1 | import axios from 'axios'; 2 | 3 | export type RequestMethod = 'GET' | 'POST' | 'DELETE'; 4 | 5 | type Params = { 6 | url: string; 7 | method?: RequestMethod; 8 | params?: unknown; 9 | parseJson?: boolean; 10 | }; 11 | 12 | async function makeRequest(options: Params): Promise { 13 | const { url, method = 'GET', params } = options; 14 | 15 | const response = await axios(url, { 16 | method, 17 | headers: { 18 | 'Content-Type': 'application/json', 19 | }, 20 | [method === 'GET' ? 'params' : 'data']: params, 21 | }); 22 | 23 | return response.data; 24 | } 25 | 26 | const api = { 27 | // TODO: sample, remove 28 | githubRepos: () => 29 | makeRequest({ 30 | url: 'https://thoughtbot-projects-api-68b03dc59059.herokuapp.com/api/projects', 31 | }), 32 | }; 33 | 34 | // TODO: sample data, remove 35 | export type GithubProjectsResponse = { 36 | projects: GithubProject[]; 37 | }; 38 | 39 | export type GithubProject = { 40 | id: number; 41 | name: string; 42 | description: string | null; 43 | url: string; 44 | stars?: number; 45 | forks?: number; 46 | }; 47 | 48 | export default api; 49 | -------------------------------------------------------------------------------- /templates/boilerplate/src/util/api/queryClient.ts: -------------------------------------------------------------------------------- 1 | import { QueryClient } from '@tanstack/react-query'; 2 | 3 | const queryClient = new QueryClient({ 4 | defaultOptions: { 5 | queries: { 6 | retry: false, 7 | }, 8 | }, 9 | }); 10 | 11 | export default queryClient; 12 | -------------------------------------------------------------------------------- /templates/boilerplate/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ESNext", 4 | "lib": ["DOM", "ESNext"], 5 | "allowJs": true, 6 | "skipLibCheck": true, 7 | "esModuleInterop": true, 8 | "allowSyntheticDefaultImports": true, 9 | "strict": true, 10 | "forceConsistentCasingInFileNames": true, 11 | "module": "commonjs", 12 | "moduleResolution": "node", 13 | "resolveJsonModule": true, 14 | "isolatedModules": true, 15 | "noEmit": true, 16 | "baseUrl": ".", 17 | "jsx": "react-native", 18 | "noFallthroughCasesInSwitch": true, 19 | "paths": { 20 | "src/*": ["src/*"], 21 | "assets/*": ["assets/*"] 22 | } 23 | }, 24 | "include": ["src/**/*", "*.js", ".*.js", "*.ts", "*.tsx", "__mocks__"], 25 | "exclude": [ 26 | "node_modules", 27 | "babel.config.js", 28 | "metro.config.js", 29 | "jest.config.js" 30 | ], 31 | "extends": "expo/tsconfig.base" 32 | } 33 | -------------------------------------------------------------------------------- /templates/eslint/.eslintignore: -------------------------------------------------------------------------------- 1 | babel.config.js 2 | metro.config.js 3 | jest.config.js 4 | jest.setup.js 5 | /.cache 6 | /android 7 | /ios 8 | -------------------------------------------------------------------------------- /templates/eslint/.eslintrc.js.eta: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | root: true, 3 | extends: [ 4 | "@thoughtbot/eslint-config/native", 5 | <% if(it.typescript) { %> 6 | "@thoughtbot/eslint-config/typescript" 7 | <% } %> 8 | ], 9 | }; 10 | -------------------------------------------------------------------------------- /templates/notifications/src/hooks/useNotifications.ts: -------------------------------------------------------------------------------- 1 | import messaging from '@react-native-firebase/messaging'; 2 | import { useEffect } from 'react'; 3 | 4 | import requestNotificationPermission from 'src/util/requestNotificationPermission'; 5 | 6 | messaging().setBackgroundMessageHandler(async (remoteMessage) => { 7 | // Handle background message 8 | }); 9 | 10 | function useNotifications() { 11 | useEffect(() => { 12 | void requestNotificationPermission(); 13 | 14 | const unsubscribe = messaging().onMessage(async (remoteMessage) => { 15 | // Handle foreground message 16 | }); 17 | 18 | return unsubscribe; 19 | }, []); 20 | } 21 | 22 | export default useNotifications; 23 | -------------------------------------------------------------------------------- /templates/notifications/src/util/requestNotificationPermission.ts: -------------------------------------------------------------------------------- 1 | import messaging, { 2 | FirebaseMessagingTypes, 3 | } from '@react-native-firebase/messaging'; 4 | import { PermissionStatus, PermissionsAndroid, Platform } from 'react-native'; 5 | 6 | export default async function requestNotificationsPermission(): Promise< 7 | FirebaseMessagingTypes.AuthorizationStatus | PermissionStatus 8 | > { 9 | if (Platform.OS === 'ios') { 10 | return messaging().requestPermission(); 11 | } 12 | 13 | return PermissionsAndroid.request( 14 | PermissionsAndroid.PERMISSIONS.POST_NOTIFICATIONS, 15 | ); 16 | } 17 | -------------------------------------------------------------------------------- /templates/prettier/.prettierignore.eta: -------------------------------------------------------------------------------- 1 | /android 2 | /ios 3 | /.yarn 4 | /.cache 5 | /vendor 6 | -------------------------------------------------------------------------------- /templates/prettier/.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "singleQuote": true 3 | } 4 | -------------------------------------------------------------------------------- /templates/testingLibrary/jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | preset: 'jest-expo', 3 | collectCoverageFrom: ['src/**/*.{js,jsx,ts,tsx}'], 4 | coveragePathIgnorePatterns: ['/node_modules', 'src/test'], 5 | transformIgnorePatterns: [ 6 | 'node_modules/(?!((jest-)?react-native|@react-native-community|@react-native|react-native|@react-navigation)|expo(nent)?|@expo(nent)?/.*|@expo-google-fonts/.*|react-navigation|@react-navigation/.*|@unimodules/.*|unimodules|sentry-expo|native-base|react-native-svg)', 7 | ], 8 | moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx', 'json', 'node'], 9 | moduleNameMapper: { 10 | '.+\\.(png|jpg|ttf|woff|woff2)$': '/src/test/fileMock.js', 11 | }, 12 | setupFilesAfterEnv: [ 13 | '@testing-library/jest-native/extend-expect', 14 | './jest.setup.js', 15 | ], 16 | }; 17 | -------------------------------------------------------------------------------- /templates/testingLibrary/jest.setup.js.eta: -------------------------------------------------------------------------------- 1 | import '@testing-library/jest-native/extend-expect'; 2 | import { configure } from '@testing-library/react-native'; 3 | import mockSafeAreaContext from 'react-native-safe-area-context/jest/mock'; 4 | import mockBackHandler from 'react-native/Libraries/Utilities/__mocks__/BackHandler.js'; 5 | 6 | beforeEach(() => { 7 | jest.clearAllMocks(); 8 | }); 9 | 10 | jest.mock('react-native-safe-area-context', () => mockSafeAreaContext); 11 | 12 | jest.mock('react-native/Libraries/Alert/Alert', () => ({ 13 | alert: jest.fn(), 14 | })); 15 | 16 | jest.mock('react-native/Libraries/LogBox/LogBox', () => ({ 17 | __esModule: true, 18 | default: { 19 | ignoreLogs: jest.fn(), 20 | ignoreAllLogs: jest.fn(), 21 | }, 22 | })); 23 | 24 | jest.mock( 25 | 'react-native/Libraries/Utilities/BackHandler', 26 | () => mockBackHandler, 27 | ); 28 | 29 | jest.mock('@react-native-async-storage/async-storage', () => 30 | require('@react-native-async-storage/async-storage/jest/async-storage-mock'), 31 | ); 32 | 33 | jest.mock('react-native-keyboard-aware-scroll-view'); 34 | 35 | // configure debug output for RN Testing Library 36 | // is way too verbose by default. Only include common 37 | // props that might affect test failure. 38 | configure({ 39 | defaultDebugOptions: { 40 | mapProps({ 41 | accessibilityLabel, 42 | accessibilityRole, 43 | accessibilityElementsHidden, 44 | testID, 45 | accessibilityViewIsModal, 46 | }) { 47 | return { 48 | accessibilityLabel, 49 | accessibilityRole, 50 | accessibilityElementsHidden, 51 | testID, 52 | accessibilityViewIsModal, 53 | }; 54 | }, 55 | }, 56 | }); 57 | -------------------------------------------------------------------------------- /templates/testingLibrary/src/__tests__/App.test.tsx: -------------------------------------------------------------------------------- 1 | import render from 'src/test/render'; 2 | import App from '../../App'; 3 | 4 | test('renders', () => { 5 | expect(true).toBe(true); 6 | expect(() => render()).not.toThrow(); 7 | }); 8 | -------------------------------------------------------------------------------- /templates/testingLibrary/src/test/fileMock.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | process() { 3 | return 'module.exports = ""'; 4 | }, 5 | }; 6 | -------------------------------------------------------------------------------- /templates/testingLibrary/src/test/render.tsx: -------------------------------------------------------------------------------- 1 | import { NavigationContainer } from '@react-navigation/native'; 2 | import { 3 | RenderAPI, 4 | // eslint-disable-next-line no-restricted-imports 5 | render as TestingLibraryRender, 6 | } from '@testing-library/react-native'; 7 | import { ReactElement } from 'react'; 8 | 9 | // TODO: this will become customized as the codebase progresses, so our 10 | // tests can be wrapped with appropriate providers, mocks can be supplied, etc 11 | export default function render(element: ReactElement): RenderAPI { 12 | return TestingLibraryRender(element); 13 | } 14 | -------------------------------------------------------------------------------- /templates/typescript/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ESNext", 4 | "lib": ["DOM", "ESNext"], 5 | "allowJs": true, 6 | "skipLibCheck": true, 7 | "esModuleInterop": true, 8 | "allowSyntheticDefaultImports": true, 9 | "strict": true, 10 | "forceConsistentCasingInFileNames": true, 11 | "module": "commonjs", 12 | "moduleResolution": "node", 13 | "resolveJsonModule": true, 14 | "isolatedModules": true, 15 | "noEmit": true, 16 | "baseUrl": ".", 17 | "jsx": "react-native", 18 | "noFallthroughCasesInSwitch": true, 19 | "paths": { 20 | "src/*": ["src/*"], 21 | "assets/*": ["assets/*"] 22 | } 23 | }, 24 | "include": ["src/**/*", "*.js", ".*.js", "*.ts", "*.tsx", "__mocks__"], 25 | "exclude": [ 26 | "node_modules", 27 | "babel.config.js", 28 | "metro.config.js", 29 | "jest.config.js" 30 | ], 31 | "extends": "expo/tsconfig.base" 32 | } 33 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "allowSyntheticDefaultImports": true, 4 | "target": "es2021", 5 | "module": "ES2020", 6 | "moduleResolution": "node", 7 | "strict": true, 8 | "noEmit": true, 9 | "sourceMap": true, 10 | "outDir": "./build", 11 | "skipLibCheck": true, 12 | "types": ["node"], 13 | "typeRoots": ["./src/types", "./node_modules/@types"] 14 | }, 15 | "ts-node": { 16 | "esm": true, 17 | "experimentalSpecifierResolution": "node" 18 | }, 19 | "include": ["./**/*.ts"], 20 | "exclude": ["templates", "dist", "builds"] 21 | } 22 | -------------------------------------------------------------------------------- /tsup.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'tsup'; 2 | 3 | export default defineConfig({ 4 | clean: true, 5 | entry: ['src/index.ts'], 6 | format: ['esm'], 7 | minify: false, 8 | target: 'esnext', 9 | outDir: 'dist', 10 | }); 11 | -------------------------------------------------------------------------------- /vitest.config.ts: -------------------------------------------------------------------------------- 1 | import { configDefaults, defineConfig } from 'vitest/config'; 2 | 3 | export default defineConfig({ 4 | test: { 5 | exclude: [ 6 | ...configDefaults.exclude, 7 | 'build/**/*', 8 | 'templates/**/*', 9 | 'builds/**/*', 10 | ], 11 | setupFiles: ['./vitest.setup.js'], 12 | }, 13 | }); 14 | -------------------------------------------------------------------------------- /vitest.setup.js: -------------------------------------------------------------------------------- 1 | import { vi } from 'vitest'; 2 | 3 | vi.mock('child_process'); 4 | vi.mock('fs-extra'); 5 | 6 | vi.mock('ora', () => ({ 7 | default: () => ({ 8 | start: vi.fn().mockReturnThis(), 9 | succeed: vi.fn().mockReturnThis(), 10 | warn: vi.fn().mockReturnThis(), 11 | }), 12 | })); 13 | 14 | vi.mock('./src/util/exec', () => ({ 15 | default: vi.fn().mockResolvedValue(true), 16 | })); 17 | 18 | vi.spyOn(process, 'chdir').mockReturnValue(undefined); 19 | --------------------------------------------------------------------------------