├── .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 |

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 |
--------------------------------------------------------------------------------