diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md index df336e9a..39a9b2e6 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.md +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -1,22 +1,35 @@ --- name: Bug report about: Create a report to help us improve -title: "[ BUG ]" +title: '[ BUG ]' labels: bug assignees: '' - --- + + +**Reproduction link**: `` + **Describe the bug** A clear and concise description of what the bug is. -**To Reproduce** -Steps to reproduce the behavior: +**Steps to reproduce the behavior:** + 1. Start the '...' app with '...' 2. Go to '...' -2. Click on '....' -3. Scroll down to '....' -4. See error +3. Click on '....' +4. Scroll down to '....' +5. See error **Expected behavior** A clear and concise description of what you expected to happen. @@ -25,9 +38,21 @@ A clear and concise description of what you expected to happen. If applicable, add screenshots to help explain your problem. **Platform (please complete the following information):** - - Type: [eg: Browser, Simulator, Emulator, Device] - - OS: [e.g. iOS] - - Browser (if applies) [e.g. chrome, safari] + +- Type: [eg: Browser, Simulator, Emulator, Device] +- OS: [e.g. iOS] +- Browser (if applies) [e.g. chrome, safari] + +**CLI output (paste the full command output)** + +If applicable, paste the full command output by running it with the `--log-level all` flag. + +```bash +npx @react-native-reusables/cli@latest --log-level all [command] [args] [options] + +// example: +// npx @react-native-reusables/cli@latest --log-level all init -t minimal +``` **Additional context** Add any other context about the problem here. diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md deleted file mode 100644 index c7f50e99..00000000 --- a/.github/ISSUE_TEMPLATE/feature_request.md +++ /dev/null @@ -1,20 +0,0 @@ ---- -name: Feature request -about: Suggest an idea for this project -title: "[ FEATURE ]" -labels: enhancement -assignees: '' - ---- - -**Is your feature request related to a problem? Please describe.** -A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] - -**Describe the solution you'd like** -A clear and concise description of what you want to happen. - -**Describe alternatives you've considered** -A clear and concise description of any alternative solutions or features you've considered. - -**Additional context** -Add any other context or screenshots about the feature request here. diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index 9487f88a..3d9843dc 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -2,9 +2,12 @@ @@ -18,7 +21,6 @@ Fixes issue # -- [ ] Docs - [ ] Web - [ ] iOS - [ ] Android @@ -27,8 +29,10 @@ Fixes issue # -- [apps/app_x] -- [packages/package_y] +- [ ] apps/docs +- [ ] apps/showcase +- [ ] apps/cli +- [ ] packages/registry ### Screenshots: diff --git a/.prettierrc b/.prettierrc new file mode 100644 index 00000000..45433040 --- /dev/null +++ b/.prettierrc @@ -0,0 +1,9 @@ +{ + "printWidth": 100, + "tabWidth": 2, + "singleQuote": true, + "bracketSameLine": true, + "trailingComma": "es5", + "plugins": ["prettier-plugin-tailwindcss"], + "tailwindFunctions": ["cva"] +} diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index c1f625c5..dd01f983 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,15 +1,15 @@ -# Contributing to [react-native-reusables](https://github.com/mrzachnugent/react-native-reusables) +# Contributing to React Native Reusables Thank you for your interest in contributing to `react-native-reusables`! We welcome contributions from the community to improve and enhance this project. Before getting started, please take a moment to review the following guidelines. ## How to Contribute -> **IMPORTANT** +> ⚠️ **Important** > -> **If you want to propose a new feature:** +> If you want to propose a new feature: > -> 1. Make sure to read the [project scope](https://github.com/mrzachnugent/react-native-reusables/discussions/229) to confirm your proposal fits within the vision and purpose of `react-native-reusables`. -> 2. Please open a [new discussion](https://github.com/mrzachnugent/react-native-reusables/discussions) before taking any action. This allows us to collaborate, gather feedback, and ensure alignment with the project's goals. +> 1. Make sure to read the [project scope](https://github.com/founded-labs/react-native-reusables/discussions/229) to confirm your proposal fits within the vision and purpose of `react-native-reusables`. +> 2. Before taking any action, please open a [new discussion](https://github.com/founded-labs/react-native-reusables/discussions). This allows us to collaborate, gather feedback, and ensure alignment with the project's goals.
@@ -19,10 +19,10 @@ Thank you for your interest in contributing to `react-native-reusables`! We welc git clone https://github.com/your-username/react-native-reusables.git cd react-native-reusables ``` -3. Create a new branch for your feature or bug fix: +3. Create a new branch: ```bash - git checkout -b feature/your-feature-name + git checkout -b your-username/your-feature-name ``` 4. Make your changes and ensure that your code adheres to the existing coding standards. @@ -35,7 +35,7 @@ git commit -m "Add your commit message here" 6. Push your changes to your forked repository: ```bash - git push origin feature/your-feature-name + git push origin your-username/your-feature-name ``` 7. Open a pull request (PR) against the main branch of the original repository. @@ -48,15 +48,15 @@ Please follow the coding style and guidelines used in the project. If there are ## Issue Tracker -Check the [issue tracker](https://github.com/mrzachnugent/react-native-reusables/issues) for existing issues or open a new issue to discuss and coordinate your contribution with the maintainers. +Check the [issue tracker](https://github.com/founded-labs/react-native-reusables/issues) for existing issues or open a new issue to discuss and coordinate your contribution with the maintainers. ## Code of Conduct -Please review and adhere to our [Code of Conduct](https://github.com/mrzachnugent/react-native-reusables/blob/main/CODE_OF_CONDUCT.md). Be respectful and considerate towards others. +Please review and adhere to our [Code of Conduct](https://github.com/founded-labs/react-native-reusables/blob/main/CODE_OF_CONDUCT.md). Be respectful and considerate towards others. ## License -By contributing to this project, you agree that your contributions will be licensed under the [LICENSE](https://github.com/mrzachnugent/react-native-reusables/blob/main/LICENSE) file of this repository. +By contributing to this project, you agree that your contributions will be licensed under the [LICENSE](https://github.com/founded-labs/react-native-reusables/blob/main/LICENSE) file of this repository. ## Contact diff --git a/LICENSE b/LICENSE index fe17af13..8e3a9ed1 100644 --- a/LICENSE +++ b/LICENSE @@ -1,6 +1,6 @@ MIT License -Copyright (c) 2023 Zach Nugent +Copyright (c) 2025 Founded Labs Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/README.md b/README.md index 2a491c55..472e47b5 100644 --- a/README.md +++ b/README.md @@ -1,141 +1,23 @@ # React Native Reusables -![banner](https://github.com/mrzachnugent/react-native-reusables/assets/63797719/0eef0a6d-d8eb-4b52-a97d-fa3b1e534215) +Bringing [shadcn/ui](https://ui.shadcn.com) to React Native. Beautifully crafted components with [Nativewind](https://www.nativewind.dev/), open source, and almost as easy to use. -## Universal [shadcn/ui](https://ui.shadcn.com) for React Native featuring a focused collection of components +![hero](apps/docs/public/og.png) -Crafted with [NativeWind v4](https://www.nativewind.dev/) and accessibility in mind, `react-native-reusables` is open source, offering a foundation for developing your own high-quality component library. +## Documentation -https://github.com/mrzachnugent/react-native-reusables/assets/63797719/ae7e074f-05a4-4568-b71a-f1e0be13650d +Visit https://reactnativereusables.com/docs to view the documentation. -[📖 Docs](https://rnr-docs.vercel.app/) -
-[🌐 Web demo](https://rnr-showcase.vercel.app/) +## Contributing -### How to use +Please read the [contributing guide](/CONTRIBUTING.md). -**Init** +## License -Quickly create a **new project** using the React Native Reusables CLI. +Licensed under the [MIT license](/LICENSE). -```bash -npx @react-native-reusables/cli@latest init -``` - -**Add** - -Add components to an existing project using the React Native Reusables CLI. - -```bash -npx @react-native-reusables/cli@latest add -``` - -#### Upcoming components - -- [Alert](https://ui.shadcn.com/docs/components/alert) -- [Breadcrumb](https://ui.shadcn.com/docs/components/breadcrumb) -- [Pagination](https://ui.shadcn.com/docs/components/pagination) -- [Slider](https://ui.shadcn.com/docs/components/slider) -- [Toast](https://ui.shadcn.com/docs/components/toast) - -## Project Scope - -This project includes only components built without third-party libraries or those that use [@rn-primitives](https://rnprimitives.com) _(universal radix-ui/primitives)_. - -**Excluded components** - -Only **15 out of the 51** shadcn/ui components are excluded from this library. However, you can use the following packages or repositories to build your own - -#### Calendar - -- [React Native Flash Calendar](https://github.com/MarceloPrado/flash-calendar): An incredibly fast and flexible library to build calendars in React Native. - -#### Carousel - -- [Animated.ScrollView](https://medium.com/timeless/building-a-gallery-carousel-in-react-native-using-reanimated-i-19b19e6b6b10): An article explaining how to create a carousel using the ScrollView component. - -#### Chart - -- [Victory Native](https://github.com/FormidableLabs/victory-native-xl): A charting library for React Native with a focus on performance and customization. - -#### Combobox - -_TBD_ - -#### Command - -_TBD_ - -#### Data Table - -- [Tanstack Table](https://tanstack.com/table/latest): Headless UI for building powerful tables & datagrids - -#### Date Picker - -- [React Native DateTimePicker](https://github.com/react-native-datetimepicker/datetimepicker): React Native date & time picker component for iOS, Android and Windows - -#### Drawer - -- [Universal Bottom Sheet](https://github.com/adebayoileri/universal-bottom-sheet) by [adebayoileri](https://github.com/adebayoileri): A bottom sheet component that combines Gorhom Bottom Sheet and Vaul for seamless and responsive experience across both mobile and web. - -#### Form - -- [React Hook Form](https://react-hook-form.com/get-started#ReactNative): Performant, flexible and extensible forms with easy-to-use validation. - -#### Input OTP - -- [input-otp-input](https://github.com/yjose/input-otp-native): 🔐 OTP input for React Native/Expo App: Unstyled, copy-paste examples that are fully tested and compatible with NativeWind. - -#### Resizable - -_TBD_ - -#### Scroll Area - -- [React Native ScrollView](https://reactnative.dev/docs/scrollview): A generic scrolling container that can host multiple components and views. - -#### Sheet (Drawer navigation) - -- [Drawer navigation](https://reactnavigation.org/docs/drawer-based-navigation/): A drawer navigation component that slides in from the side. - -#### Sonner - -- [Sonner Native](https://github.com/gunnartorfis/sonner-native) by [gunnartorfis](https://github.com/gunnartorfis): An opinionated toast component for React Native. A port of @emilkowalski's sonner. - -- [Burnt](https://www.npmjs.com/package/burnt): Cross-platform toasts for React Native, powered by native elements. On Web, it wraps [Sonner](https://github.com/emilkowalski/sonner). - -### Community Templates - -Explore community-created components and templates that extend the core library and fill in missing shadcn/ui elements. Contributions are welcome! - -- [RNR Base Bare](https://github.com/a0m0rajab/rnr-base-bare) by [a0m0rajab](https://github.com/a0m0rajab): _A simple app using Supabase as the backend, featuring sign-in/sign-up and profile functionality._ - -## How to contribute - -1. Fork this repo, then clone your fork on your machine. - -2. Change directory into the cloned repo: `cd react-native-reusables` - -3. Install the dependencies (**IMPORTANT:** Must use pnpm): `pnpm i` - -4. From the root directory, start up desired app with the following commands: - -- Showcase - - iOS: `pnpm dev:showcase` - - Android: `pnpm dev:showcase:android` - - Web: `pnpm dev:showcase:web` -- Starter-base - - iOS: `pnpm dev:starter-base` - - Android: `pnpm dev:starter-base:android` - - Web: `pnpm dev:starter-base:web` -- Docs: `pnpm dev:docs` - -5. Add and commit your changes - -6. Make a pull request - -### Deprecated-UI - -> These components are still available for use but are no longer recommended or actively supported by the developers. They can be used as inspiration or as a starting point for your own components. - -[View deprecated components](/packages/reusables/src/components/deprecated-ui/README.md) +
+
+ + Vercel OSS Program + diff --git a/apps/README.md b/apps/README.md deleted file mode 100644 index 581b76de..00000000 --- a/apps/README.md +++ /dev/null @@ -1,5 +0,0 @@ -# Apps - -- **Docs:** A Starlight App for Documentation -- **Showcase:** A Universal Expo App that features all components, primitives, hooks, and utilities -- **Starter Base:** A base starting point to help you set up your project quickly \ No newline at end of file diff --git a/apps/cli/.changeset/config.json b/apps/cli/.changeset/config.json new file mode 100644 index 00000000..d83cc449 --- /dev/null +++ b/apps/cli/.changeset/config.json @@ -0,0 +1,16 @@ +{ + "$schema": "https://unpkg.com/@changesets/config@3.0.2/schema.json", + "changelog": [ + "@changesets/changelog-github", + { + "repo": "founded-labs/react-native-reusables" + } + ], + "commit": false, + "fixed": [], + "linked": [], + "access": "restricted", + "baseBranch": "main", + "updateInternalDependencies": "patch", + "ignore": [] +} diff --git a/apps/cli/.github/actions/setup/action.yml b/apps/cli/.github/actions/setup/action.yml new file mode 100644 index 00000000..50cd2ca9 --- /dev/null +++ b/apps/cli/.github/actions/setup/action.yml @@ -0,0 +1,21 @@ +name: Setup +description: Perform standard setup and install dependencies using pnpm. +inputs: + node-version: + description: The version of Node.js to install + required: true + default: 20.16.0 + +runs: + using: composite + steps: + - name: Install pnpm + uses: pnpm/action-setup@v3 + - name: Install node + uses: actions/setup-node@v4 + with: + cache: pnpm + node-version: ${{ inputs.node-version }} + - name: Install dependencies + shell: bash + run: pnpm install diff --git a/apps/cli/.github/workflows/check.yml b/apps/cli/.github/workflows/check.yml new file mode 100644 index 00000000..e505cb8c --- /dev/null +++ b/apps/cli/.github/workflows/check.yml @@ -0,0 +1,54 @@ +name: Check + +on: + workflow_dispatch: + pull_request: + branches: [main] + push: + branches: [main] + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +permissions: {} + +jobs: + build: + name: Build + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - uses: actions/checkout@v4 + - name: Install dependencies + uses: ./.github/actions/setup + + types: + name: Types + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - uses: actions/checkout@v4 + - name: Install dependencies + uses: ./.github/actions/setup + - run: pnpm check + + lint: + name: Lint + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - uses: actions/checkout@v4 + - name: Install dependencies + uses: ./.github/actions/setup + - run: pnpm lint + + test: + name: Test + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - uses: actions/checkout@v4 + - name: Install dependencies + uses: ./.github/actions/setup + - run: pnpm test diff --git a/apps/cli/.github/workflows/release.yml b/apps/cli/.github/workflows/release.yml new file mode 100644 index 00000000..e69de29b diff --git a/apps/cli/.github/workflows/snapshot.yml b/apps/cli/.github/workflows/snapshot.yml new file mode 100644 index 00000000..ca2dfc73 --- /dev/null +++ b/apps/cli/.github/workflows/snapshot.yml @@ -0,0 +1,24 @@ +name: Snapshot + +on: + pull_request: + branches: [main, next-minor, next-major] + workflow_dispatch: + +permissions: {} + +jobs: + snapshot: + name: Snapshot + if: github.repository_owner == 'Effect-Ts' + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - uses: actions/checkout@v4 + - name: Install dependencies + uses: ./.github/actions/setup + - name: Build package + run: pnpm build + - name: Create snapshot + id: snapshot + run: pnpx pkg-pr-new@0.0.24 publish --pnpm --comment=off diff --git a/apps/cli/.gitignore b/apps/cli/.gitignore index d028f333..01885e35 100644 --- a/apps/cli/.gitignore +++ b/apps/cli/.gitignore @@ -1,5 +1,15 @@ -__generated -dist -testing -components.json -.turbo \ No newline at end of file +coverage/ +*.tsbuildinfo +node_modules/ +yarn-error.log +.ultra.cache.json +.DS_Store +tmp/ +build/ +dist/ +.direnv/ +.env +.env.local +.env.development.local +.env.test.local +.env.production.local diff --git a/apps/cli/.prettierrc b/apps/cli/.prettierrc new file mode 100644 index 00000000..67bab101 --- /dev/null +++ b/apps/cli/.prettierrc @@ -0,0 +1,8 @@ +{ + "tabWidth": 2, + "printWidth": 120, + "semi": false, + "singleQuote": false, + "trailingComma": "none", + "arrowParens": "always" +} diff --git a/apps/cli/.vscode/extensions.json b/apps/cli/.vscode/extensions.json new file mode 100644 index 00000000..049dfc67 --- /dev/null +++ b/apps/cli/.vscode/extensions.json @@ -0,0 +1,6 @@ +{ + "recommendations": [ + "effectful-tech.effect-vscode", + "dbaeumer.vscode-eslint" + ] +} \ No newline at end of file diff --git a/apps/cli/.vscode/settings.json b/apps/cli/.vscode/settings.json new file mode 100644 index 00000000..393a3c82 --- /dev/null +++ b/apps/cli/.vscode/settings.json @@ -0,0 +1,46 @@ +{ + "typescript.tsdk": "node_modules/typescript/lib", + "typescript.preferences.importModuleSpecifier": "relative", + "typescript.enablePromptUseWorkspaceTsdk": true, + "editor.formatOnSave": true, + "eslint.format.enable": true, + "[json]": { + "editor.defaultFormatter": "vscode.json-language-features" + }, + "[markdown]": { + "editor.defaultFormatter": "esbenp.prettier-vscode", + "prettier.semi": false, + "prettier.trailingComma": "none" + }, + "[javascript]": { + "editor.defaultFormatter": "dbaeumer.vscode-eslint" + }, + "[javascriptreact]": { + "editor.defaultFormatter": "dbaeumer.vscode-eslint" + }, + "[typescript]": { + "editor.defaultFormatter": "dbaeumer.vscode-eslint" + }, + "[typescriptreact]": { + "editor.defaultFormatter": "dbaeumer.vscode-eslint" + }, + "eslint.validate": ["markdown", "javascript", "typescript"], + "editor.codeActionsOnSave": { + "source.fixAll.eslint": "explicit" + }, + "editor.quickSuggestions": { + "other": true, + "comments": false, + "strings": false + }, + "editor.acceptSuggestionOnCommitCharacter": true, + "editor.acceptSuggestionOnEnter": "on", + "editor.quickSuggestionsDelay": 10, + "editor.suggestOnTriggerCharacters": true, + "editor.tabCompletion": "off", + "editor.suggest.localityBonus": true, + "editor.suggestSelection": "recentlyUsed", + "editor.wordBasedSuggestions": "matchingDocuments", + "editor.parameterHints.enabled": true, + "files.insertFinalNewline": true +} diff --git a/apps/cli/LICENSE b/apps/cli/LICENSE new file mode 100644 index 00000000..e6b87de1 --- /dev/null +++ b/apps/cli/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2024-present Founded Labs + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/apps/cli/README.md b/apps/cli/README.md index 3a6f75cc..e4b549b3 100644 --- a/apps/cli/README.md +++ b/apps/cli/README.md @@ -1,63 +1,96 @@ # React Native Reusables CLI -> Please follow [the initial setup steps](https://rnr-docs.vercel.app/getting-started/initial-setup/) before using +A command-line toolkit to streamline the integration, setup, and maintenance of reusable React Native components in your projects. -A CLI to add [react-native-reusables](https://rnr-docs.vercel.app/getting-started/introduction/) components to your project. When components depend on other components, they will also be added to your project. +## Features -## How to use +- **Add**: Quickly add reusable React Native components to your project, with style and path options. +- **Doctor**: Diagnose your project for missing files, misconfigurations, and required dependencies. Optionally auto-install missing dependencies. +- **Init**: Bootstrap a new React Native project pre-configured for reusables, or inspect/repair an existing setup. -Use the following command _(with optional arugments and flags)_: +## Getting Started -```bash -npx @react-native-reusables/cli@latest add -``` +### Installation + +You can run the CLI directly with your favorite package manager: -### Arguments - -If you do not add arguments, you will be prompted to select the `ui` components you would like to add to your project. - -#### UI Components - -- `accordion` -- `alert` -- `alert-dialog` -- `aspect-ratio` -- `avatar` -- `badge` -- `button` -- `card` -- `checkbox` -- `collapsible` -- `context-menu` -- `dialog` -- `dropdown-menu` -- `hover-card` -- `input` -- `label` -- `menubar` -- `navigation-menu` -- `popover` -- `radio-group` -- `select` -- `separator` -- `skeleton` -- `switch` -- `table` -- `tabs` -- `text` -- `textarea` -- `toggle` -- `toggle-group` -- `tooltip` -- `typography` - -### Flags - -- `-o` or `--overwrite`: Overwrite existing files. Default to `false` -- `-c ` or `--cwd `: The working directory. Defaults to the current directory. - -```mdx -This project uses code from shadcn. -The code is licensed under the MIT License. -https://github.com/shadcn-ui/ui +```sh +npx @react-native-reusables/cli@latest +pnpm dlx @react-native-reusables/cli@latest +yarn dlx @react-native-reusables/cli@latest +bunx --bun @react-native-reusables/cli@latest ``` + +## Commands + +--- + +### `@react-native-reusables/cli@latest add [options] [components...]` + +Add one or more React Native components to your project. + +| Argument | Description | +| ---------- | ----------------------------------------------------------------------------- | +| components | Name of component(s) to add. (e.g. `button`, `input`, `card`, `avatar`, etc.) | + +| Option | Description | Default | +| ------------------- | ------------------------------------ | --------------- | +| `-y, --yes` | Skip confirmation prompts. | false | +| `-o, --overwrite` | Overwrite existing files. | false | +| `-c, --cwd ` | The working directory. | . (current dir) | +| `-a, --all` | Add all available components. | false | +| `-p, --path ` | The path to add the component(s) to. | | +| `-h, --help` | Display help for command. | | + +--- + +### `rnr doctor [options]` + +Check your project setup and diagnose issues. + +| Option | Description | Default | +| ----------------- | ------------------------------------------------------ | --------------- | +| `-y, --yes` | Skip confirmation prompts for installing dependencies. | false | +| `-c, --cwd ` | The working directory. | . (current dir) | +| `--summary` | Output a summary only. | false | +| `-h, --help` | Display help for command. | | + +--- + +### `rnr init [options]` + +Initialize a new React Native project with reusables. + +| Option | Description | Default | +| ----------------- | ------------------------- | --------------- | +| `-c, --cwd ` | The working directory. | . (current dir) | +| `-h, --help` | Display help for command. | | + +--- + +## Development + +### Scripts + +- `pnpm build` – Build the CLI for production. +- `pnpm dev` – Run the CLI in development mode. + +> **Note:** If you are developing locally and want to use the `add` command in development mode, you must have the `apps/docs` app running. Start it from the root with: +> +> ```sh +> pnpm dev:docs +> ``` +> +> This serves the component registry required for local development. + +### Structure + +- `src/cli.ts` – Main CLI entrypoint and command definitions. +- `src/bin.ts` – Node boot-strapper. +- `src/services/commands/` – Command implementations (`add`, `doctor`, `init`). +- `src/contexts/` – CLI option/context definitions. +- `src/utils/` – Utility functions. + +## Contributing + +See the [main repo README](../../README.md) for guidelines. diff --git a/apps/cli/eslint.config.mjs b/apps/cli/eslint.config.mjs new file mode 100644 index 00000000..03cb381c --- /dev/null +++ b/apps/cli/eslint.config.mjs @@ -0,0 +1,118 @@ +import { fixupPluginRules } from "@eslint/compat" +import { FlatCompat } from "@eslint/eslintrc" +import js from "@eslint/js" +import tsParser from "@typescript-eslint/parser" +import codegen from "eslint-plugin-codegen" +import _import from "eslint-plugin-import" +import simpleImportSort from "eslint-plugin-simple-import-sort" +import sortDestructureKeys from "eslint-plugin-sort-destructure-keys" +import path from "node:path" +import { fileURLToPath } from "node:url" + +const __filename = fileURLToPath(import.meta.url) +const __dirname = path.dirname(__filename) +const compat = new FlatCompat({ + baseDirectory: __dirname, + recommendedConfig: js.configs.recommended, + allConfig: js.configs.all +}) + +export default [ + { + ignores: ["**/dist", "**/build", "**/docs", "**/*.md"] + }, + ...compat.extends( + "eslint:recommended", + "plugin:@typescript-eslint/eslint-recommended", + "plugin:@typescript-eslint/recommended", + "plugin:@effect/recommended" + ), + { + plugins: { + import: fixupPluginRules(_import), + "sort-destructure-keys": sortDestructureKeys, + "simple-import-sort": simpleImportSort, + codegen + }, + + languageOptions: { + parser: tsParser, + ecmaVersion: 2018, + sourceType: "module" + }, + + settings: { + "import/parsers": { + "@typescript-eslint/parser": [".ts", ".tsx"] + }, + + "import/resolver": { + typescript: { + alwaysTryTypes: true + } + } + }, + + rules: { + "codegen/codegen": "error", + "no-fallthrough": "off", + "no-irregular-whitespace": "off", + "object-shorthand": "error", + "prefer-destructuring": "off", + "sort-imports": "off", + + "no-restricted-syntax": [ + "error", + { + selector: "CallExpression[callee.property.name='push'] > SpreadElement.arguments", + message: "Do not use spread arguments in Array.push" + } + ], + + "no-unused-vars": "off", + "prefer-rest-params": "off", + "prefer-spread": "off", + "import/first": "error", + "import/newline-after-import": "error", + "import/no-duplicates": "error", + "import/no-unresolved": "off", + "import/order": "off", + "simple-import-sort/imports": "off", + "sort-destructure-keys/sort-destructure-keys": "error", + "deprecation/deprecation": "off", + + "@typescript-eslint/array-type": [ + "warn", + { + default: "generic", + readonly: "generic" + } + ], + + "@typescript-eslint/member-delimiter-style": 0, + "@typescript-eslint/no-non-null-assertion": "off", + "@typescript-eslint/ban-types": "off", + "@typescript-eslint/no-explicit-any": "off", + "@typescript-eslint/no-empty-interface": "off", + "@typescript-eslint/consistent-type-imports": "warn", + + "@typescript-eslint/no-unused-vars": [ + "error", + { + argsIgnorePattern: "^_", + varsIgnorePattern: "^_" + } + ], + + "@typescript-eslint/ban-ts-comment": "off", + "@typescript-eslint/camelcase": "off", + "@typescript-eslint/explicit-function-return-type": "off", + "@typescript-eslint/explicit-module-boundary-types": "off", + "@typescript-eslint/interface-name-prefix": "off", + "@typescript-eslint/no-array-constructor": "off", + "@typescript-eslint/no-use-before-define": "off", + "@typescript-eslint/no-namespace": "off", + "@effect/dprint": "off" + } + } +] diff --git a/apps/cli/package.json b/apps/cli/package.json index b3662755..c4af3fd9 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -1,87 +1,72 @@ { "name": "@react-native-reusables/cli", - "version": "0.4.1", - "description": "Add react-native-reusables to your project.", - "publishConfig": { - "access": "public" - }, + "version": "0.5.0-beta.3", + "type": "module", "license": "MIT", - "author": { - "name": "mrzachnugent", - "url": "https://twitter.com/mrzachnugent" - }, + "description": "A CLI for React Native Reusables", "repository": { "type": "git", - "url": "https://github.com/mrzachnugent/react-native-reusables.git", - "directory": "packages/cli" + "url": "https://github.com/founded-labs/react-native-reusables.git", + "directory": "apps/cli" + }, + "publishConfig": { + "access": "public", + "directory": "dist" }, - "files": [ - "dist", - "__generated" - ], - "keywords": [ - "expo", - "react-native", - "universal-components", - "components", - "tailwind", - "nativewind", - "radix-ui", - "shadcn" - ], - "type": "module", - "exports": "./dist/index.js", - "bin": "./dist/index.js", "scripts": { - "gen": "rm -rf __generated && tsx scripts/generate-source-files.ts", - "dev": "pnpm gen && tsup --watch", - "build": "pnpm gen && tsup", - "typecheck": "tsc --noEmit", - "clean": "rm -rf node_modules && rm -rf dist && rm -rf __generated", - "start:dev": "node dist/index.js", - "start": "node dist/index.js", - "format:write": "prettier --write \"**/*.{ts,tsx,mdx}\" --cache", - "format:check": "prettier --check \"**/*.{ts,tsx,mdx}\" --cache", - "release": "changeset version", + "build": "tsup && pnpm copy-package-json", + "build:ts": "tsup", + "clean": "rimraf dist/*", + "check": "tsc -b tsconfig.json", + "dev": "NODE_ENV=development tsx src/bin.ts", + "lint": "eslint \"**/{src,test,examples,scripts,dtslint}/**/*.{ts,mjs}\"", + "lint-fix": "pnpm lint --fix", + "test": "vitest run", + "coverage": "vitest run --coverage", + "copy-package-json": "tsx scripts/copy-package-json.ts", + "changeset-version": "changeset version && node scripts/version.mjs", + "changeset-publish": "pnpm build && TEST_DIST= pnpm vitest && changeset publish", "pub:beta": "pnpm publish --no-git-checks --access public --tag beta", "pub:next": "pnpm publish --no-git-checks --access public --tag next", - "pub:release": "pnpm publish --access public", - "test": "vitest run" + "pub:release": "pnpm publish --access public" }, "dependencies": { - "@antfu/ni": "^24.3.0", - "@babel/core": "^7.26.0", - "@babel/parser": "^7.22.6", - "@babel/plugin-transform-typescript": "^7.22.5", - "chalk": "5.2.0", - "commander": "^10.0.0", - "cosmiconfig": "^8.1.3", - "diff": "^5.1.0", - "execa": "^7.0.0", - "fast-glob": "^3.3.2", - "fs-extra": "^11.1.0", - "https-proxy-agent": "^6.2.0", - "lodash.template": "^4.5.0", - "node-fetch": "^3.3.0", - "ora": "^6.1.2", - "prompts": "^2.4.2", - "recast": "^0.23.2", - "ts-morph": "^18.0.0", - "tsconfig-paths": "^4.2.0", - "zod": "^3.20.2" + "tsconfig-paths": "^4.2.0" }, "devDependencies": { - "@rnr/reusables": "workspace:*", - "@rnr/starter-base": "workspace:*", - "@types/babel__core": "^7.20.1", - "@types/diff": "^5.0.3", - "@types/fs-extra": "^11.0.1", - "@types/lodash.template": "^4.5.1", - "@types/prompts": "^2.4.2", - "rimraf": "^4.1.3", - "tsup": "^6.6.3", - "tsx": "^4.7.1", - "type-fest": "^3.8.0", - "typescript": "^5.8.3" + "@changesets/changelog-github": "^0.5.0", + "@changesets/cli": "^2.27.8", + "@effect/cli": "latest", + "@effect/eslint-plugin": "^0.2.0", + "@effect/language-service": "^0.1.0", + "@effect/platform": "latest", + "@effect/platform-node": "latest", + "@effect/vitest": "latest", + "@eslint/compat": "1.1.1", + "@eslint/eslintrc": "3.1.0", + "@eslint/js": "9.10.0", + "@types/node": "22.10.5", + "@typescript-eslint/eslint-plugin": "^8.4.0", + "@typescript-eslint/parser": "^8.4.0", + "effect": "latest", + "eslint": "^9.10.0", + "eslint-import-resolver-typescript": "^3.6.3", + "eslint-plugin-codegen": "0.28.0", + "eslint-plugin-deprecation": "^3.0.0", + "eslint-plugin-import": "^2.30.0", + "eslint-plugin-simple-import-sort": "^12.1.1", + "eslint-plugin-sort-destructure-keys": "^2.0.0", + "execa": "^7.2.0", + "log-symbols": "^7.0.1", + "ora": "^6.1.2", + "tsup": "^8.2.4", + "tsx": "^4.19.1", + "typescript": "^5.8.3", + "vitest": "^2.0.5" + }, + "pnpm": { + "patchedDependencies": { + "@changesets/get-github-info@0.6.0": "patches/@changesets__get-github-info@0.6.0.patch" + } } } diff --git a/apps/cli/patches/@changesets__get-github-info@0.6.0.patch b/apps/cli/patches/@changesets__get-github-info@0.6.0.patch new file mode 100644 index 00000000..911d6510 --- /dev/null +++ b/apps/cli/patches/@changesets__get-github-info@0.6.0.patch @@ -0,0 +1,48 @@ +diff --git a/dist/changesets-get-github-info.cjs.js b/dist/changesets-get-github-info.cjs.js +index a74df59f8a5988f458a3476087399f5e6dfe4818..ce5e60ef9916eb0cb76ab1e9dd422abcad752bf6 100644 +--- a/dist/changesets-get-github-info.cjs.js ++++ b/dist/changesets-get-github-info.cjs.js +@@ -251,18 +251,13 @@ async function getInfo(request) { + b = new Date(b.mergedAt); + return a > b ? 1 : a < b ? -1 : 0; + })[0] : null; +- +- if (associatedPullRequest) { +- user = associatedPullRequest.author; +- } +- + return { + user: user ? user.login : null, + pull: associatedPullRequest ? associatedPullRequest.number : null, + links: { + commit: `[\`${request.commit.slice(0, 7)}\`](${data.commitUrl})`, + pull: associatedPullRequest ? `[#${associatedPullRequest.number}](${associatedPullRequest.url})` : null, +- user: user ? `[@${user.login}](${user.url})` : null ++ user: user ? `@${user.login}` : null + } + }; + } +diff --git a/dist/changesets-get-github-info.esm.js b/dist/changesets-get-github-info.esm.js +index 27e5c972ab1202ff16f5124b471f4bbcc46be2b5..3940a8fe86e10cb46d8ff6436dea1103b1839927 100644 +--- a/dist/changesets-get-github-info.esm.js ++++ b/dist/changesets-get-github-info.esm.js +@@ -242,18 +242,13 @@ async function getInfo(request) { + b = new Date(b.mergedAt); + return a > b ? 1 : a < b ? -1 : 0; + })[0] : null; +- +- if (associatedPullRequest) { +- user = associatedPullRequest.author; +- } +- + return { + user: user ? user.login : null, + pull: associatedPullRequest ? associatedPullRequest.number : null, + links: { + commit: `[\`${request.commit.slice(0, 7)}\`](${data.commitUrl})`, + pull: associatedPullRequest ? `[#${associatedPullRequest.number}](${associatedPullRequest.url})` : null, +- user: user ? `[@${user.login}](${user.url})` : null ++ user: user ? `@${user.login}` : null + } + }; + } diff --git a/apps/cli/scripts/copy-package-json.ts b/apps/cli/scripts/copy-package-json.ts new file mode 100644 index 00000000..ed201ede --- /dev/null +++ b/apps/cli/scripts/copy-package-json.ts @@ -0,0 +1,32 @@ +import { FileSystem, Path } from "@effect/platform" +import { NodeContext } from "@effect/platform-node" +import { Effect } from "effect" + +const program = Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem + const path = yield* Path.Path + yield* Effect.log("[Build] Copying package.json ...") + const json: any = yield* fs.readFileString("package.json").pipe(Effect.map(JSON.parse)) + const pkg = { + name: json.name, + version: json.version, + type: json.type, + description: json.description, + main: "bin.cjs", + bin: "bin.cjs", + engines: json.engines, + dependencies: json.dependencies, + peerDependencies: json.peerDependencies, + repository: json.repository, + author: json.author, + license: json.license, + bugs: json.bugs, + homepage: json.homepage, + tags: json.tags, + keywords: json.keywords + } + yield* fs.writeFileString(path.join("dist", "package.json"), JSON.stringify(pkg, null, 2)) + yield* Effect.log("[Build] Build completed.") +}).pipe(Effect.provide(NodeContext.layer)) + +Effect.runPromise(program).catch(console.error) diff --git a/apps/cli/scripts/generate-source-files.ts b/apps/cli/scripts/generate-source-files.ts deleted file mode 100644 index c3d7f707..00000000 --- a/apps/cli/scripts/generate-source-files.ts +++ /dev/null @@ -1,40 +0,0 @@ -#!/usr/bin/env node - -import { existsSync, promises as fs } from 'fs'; -import path from 'path'; -import { COMPONENTS } from '../src/items/components'; -import { TEMPLATES } from '../src/items/templates'; -import { copyFolder } from '../src/utils/copy-folder'; - -async function main() { - for (const template of TEMPLATES) { - await copyFolder(template.path, path.join('__generated', template.name), { - ignore: ['.expo', 'node_modules'], - renameTemplateFiles: false, - }); - } - for (const comp of COMPONENTS) { - if (Array.isArray(comp.paths)) { - await writeFiles(comp.paths); - } else { - await writeFiles(comp.paths['universal']); - } - } -} - -main(); - -async function writeFiles(paths: Array<{ from: string; to: { folder: string; file: string } }>) { - for (const compPath of paths) { - const targetDir = path.join('__generated/components', compPath.to.folder); - if (!existsSync(targetDir)) { - await fs.mkdir(targetDir, { recursive: true }); - } - try { - const content = await fs.readFile(path.resolve(compPath.from), 'utf8'); - await fs.writeFile(path.join(targetDir, compPath.to.file), content); - } catch (error) { - console.error(error); - } - } -} diff --git a/apps/cli/src/bin.ts b/apps/cli/src/bin.ts new file mode 100644 index 00000000..838e5d52 --- /dev/null +++ b/apps/cli/src/bin.ts @@ -0,0 +1,18 @@ +#!/usr/bin/env node + +import * as NodeContext from "@effect/platform-node/NodeContext" +import * as NodeRuntime from "@effect/platform-node/NodeRuntime" +import * as Effect from "effect/Effect" +import * as Cli from "./cli.js" + +Effect.suspend(Cli.run).pipe( + Effect.provide(NodeContext.layer), + Effect.catchAll((error) => { + if (error instanceof Error) { + Effect.logDebug(error) + return Effect.logError(error.message) + } + return Effect.logError(error) + }), + NodeRuntime.runMain({ disableErrorReporting: true }) +) diff --git a/apps/cli/src/cli.ts b/apps/cli/src/cli.ts new file mode 100644 index 00000000..d73c0ac2 --- /dev/null +++ b/apps/cli/src/cli.ts @@ -0,0 +1,65 @@ +import { all, cwd, overwrite, path, summary, template, yes } from "@cli/contexts/cli-options.js" +import * as Add from "@cli/services/commands/add.js" +import * as Doctor from "@cli/services/commands/doctor.js" +import * as Init from "@cli/services/commands/init.js" +import { Args, Command, Prompt } from "@effect/cli" +import { Effect, pipe } from "effect" + +const addArgs = Args.all({ + components: Args.text({ name: "components" }).pipe(Args.repeated) +}) + +const AddCommand = Command.make("add", { args: addArgs, cwd, yes, overwrite, all, path }) + .pipe(Command.withDescription("Add React Native components to your project")) + .pipe(Command.withHandler(Add.make)) + +const DoctorCommand = Command.make("doctor", { cwd, summary, yes }) + .pipe(Command.withDescription("Check your project setup and diagnose issues")) + .pipe(Command.withHandler(Doctor.make)) + +const InitCommand = Command.make("init", { cwd, template }) + .pipe(Command.withDescription("Initialize a new React Native project with reusables")) + .pipe(Command.withHandler(Init.make)) + +const Cli = Command.make("react-native-reusables/cli", { cwd }) + .pipe(Command.withDescription("React Native Reusables CLI - A powerful toolkit for React Native development")) + .pipe( + Command.withHandler((options) => + Effect.gen(function* () { + yield* Effect.log("React Native Reusables CLI - A powerful toolkit for React Native development") + const choice = yield* Prompt.select({ + message: "What would you like to do?", + choices: [ + { title: "Add a component", value: "add" }, + { title: "Inspect project configuration", value: "doctor" }, + { title: "Initialize a new project", value: "init" } + ] + }) + + if (choice === "add") { + yield* Add.make({ + cwd: options.cwd, + yes: true, + overwrite: false, + all: false, + path: "", + args: { components: [] } + }) + } else if (choice === "doctor") { + yield* Doctor.make({ cwd: options.cwd, summary: false, yes: false }) + } else if (choice === "init") { + yield* Init.make({ cwd: options.cwd, template: "" }) + } + }) + ) + ) + .pipe(Command.withSubcommands([AddCommand, DoctorCommand, InitCommand])) + +export const run = () => + pipe( + process.argv, + Command.run(Cli, { + name: "@react-native-reusables/cli", + version: "1.0.0" + }) + ) diff --git a/apps/cli/src/commands/add.ts b/apps/cli/src/commands/add.ts deleted file mode 100644 index 2dae1216..00000000 --- a/apps/cli/src/commands/add.ts +++ /dev/null @@ -1,238 +0,0 @@ -import { Config, getConfig } from '@/src/utils/get-config'; -import { getPackageManager } from '@/src/utils/get-package-manager'; -import { handleError } from '@/src/utils/handle-error'; -import { logger } from '@/src/utils/logger'; -import { promptForConfig } from '@/src/utils/prompt-for-config'; -import chalk from 'chalk'; -import { Command } from 'commander'; -import { execa } from 'execa'; -import { existsSync, promises as fs } from 'fs'; -import ora, { Ora } from 'ora'; -import path from 'path'; -import prompts from 'prompts'; -import { fileURLToPath } from 'url'; -import { z } from 'zod'; -import { Component, INVALID_COMPONENT_ERROR, getAllComponentsToWrite } from '../items'; -import { COMPONENTS } from '../items/components'; - -const filePath = fileURLToPath(import.meta.url); -const fileDir = path.dirname(filePath); - -const addOptionsSchema = z.object({ - components: z.array(z.string()).optional(), - overwrite: z.boolean(), - cwd: z.string(), - path: z.string().optional(), -}); - -export const add = new Command() - .name('add') - .description('add components to your project') - .argument('[components...]', 'the components to add') - .option('-o, --overwrite', 'overwrite existing files.', false) - .option( - '-c, --cwd ', - 'the working directory. defaults to the current directory.', - process.cwd() - ) - .action(async (components, opts) => { - try { - const options = addOptionsSchema.parse({ - components, - ...opts, - }); - - const cwd = path.resolve(options.cwd); - - if (!existsSync(cwd)) { - logger.error(`The path ${cwd} does not exist. Please try again.`); - process.exit(1); - } - - let config = await getConfig(cwd); - - if (!config) { - config = await promptForConfig(cwd); - } - - let selectedComponents: Array = options.components ?? []; - if (!selectedComponents?.length) { - const { components } = await prompts({ - type: 'multiselect', - name: 'components', - message: 'Which components would you like to add?', - hint: 'Space to select. A to toggle all. Enter to submit.', - instructions: false, - choices: COMPONENTS.map((entry) => ({ - title: entry.name, - value: entry.name, - selected: false, - })), - }); - selectedComponents = components; - } - - if (!selectedComponents?.length) { - logger.warn('No components selected. Exiting.'); - process.exit(0); - } - - const spinner = ora(`Installing components...`).start(); - - let componentsToWrite: Array = []; - try { - componentsToWrite = getAllComponentsToWrite(selectedComponents); - } catch (err) { - if (err instanceof Error && err.message === INVALID_COMPONENT_ERROR) { - logger.error( - `Invalid component(s): ${selectedComponents - .filter((component) => !COMPONENTS.find((entry) => entry.name === component)) - .join(', ')}` - ); - process.exit(1); - } - logger.error(err); - } - - const npmPackages: Array = []; - - for (const comp of componentsToWrite) { - spinner.text = `Installing ${comp.name}...`; - - await writeFiles(comp, comp.paths, config, spinner, options.overwrite); - - npmPackages.push(...comp.npmPackages); - } - - const packageManager = await getPackageManager(cwd); - - const uniqueNpmPackages = Array.from(new Set(npmPackages)); - - if (uniqueNpmPackages.length) { - spinner.text = `Installing ${uniqueNpmPackages.join(', ')}...`; - await execa( - packageManager, - [packageManager === 'npm' ? 'install' : 'add', ...uniqueNpmPackages], - { - cwd, - } - ); - } - spinner.succeed(`Done.`); - } catch (error) { - handleError(error); - } - }); - -async function writeFiles( - comp: Component, - paths: Array<{ from: string; distFrom?: string; to: { folder: string; file: string } }>, - config: Config, - spinner: Ora, - overwriteFlag: boolean -) { - for (const compPath of paths) { - const targetDir = path.join(config.resolvedPaths.components, compPath.to.folder); - if (!existsSync(targetDir)) { - await fs.mkdir(targetDir, { recursive: true }); - } - - spinner.stop(); - - if (existsSync(path.join(targetDir, compPath.to.file))) { - const filePath = [compPath.to.folder, compPath.to.file].join('/'); - if (!overwriteFlag) { - logger.info( - `File already exists: ${chalk.bgCyan( - filePath - )} was skipped. To overwrite, run with the ${chalk.green('--overwrite')} flag.` - ); - continue; - } - - const { overwrite } = await prompts({ - type: 'confirm', - name: 'overwrite', - message: `File already exists: ${chalk.yellow(filePath)}. Would you like to overwrite?`, - initial: false, - }); - - if (!overwrite) { - logger.info(`Skipped`); - continue; - } - } - - spinner.start(`Installing ${comp.name}...`); - const readFromPath = compPath.distFrom - ? path.join(fileDir, '../__generated/components', compPath.distFrom) - : path.join(fileDir, '../__generated/components', compPath.to.folder, compPath.to.file); - try { - const content = await fs.readFile(path.resolve(readFromPath), 'utf8'); - await fs.writeFile( - path.join(targetDir, compPath.to.file), - fixImports(content, config.aliases.components, config.aliases.lib) - ); - } catch (error) { - handleError(error); - } - } - - for (const icon of comp.icons ?? []) { - const targetDir = path.resolve(config.resolvedPaths.lib, 'icons'); - if (!existsSync(targetDir)) { - await fs.mkdir(targetDir, { recursive: true }); - try { - await fs.writeFile( - path.join(targetDir, `iconWithClassName.ts`), - `import type { LucideIcon } from 'lucide-react-native';\nimport { cssInterop } from 'nativewind';\n\nexport function iconWithClassName(icon: LucideIcon) {\ncssInterop(icon, {\n className: {\n target: 'style',\n nativeStyleToProp: {\n color: true,\n opacity: true,\n },\n },\n});\n}` - ); - } catch (error) { - handleError(error); - } - } - - if (existsSync(path.join(targetDir, `${icon}.tsx`))) { - const filePath = path.join(targetDir, `${icon}.tsx`); - if (!overwriteFlag) { - logger.info( - `File already exists: ${chalk.bgCyan( - `${icon}.tsx` - )} was skipped. To overwrite, run with the ${chalk.green('--overwrite')} flag.` - ); - continue; - } - - const { overwrite } = await prompts({ - type: 'confirm', - name: 'overwrite', - message: `File already exists: ${chalk.yellow(filePath)}. Would you like to overwrite?`, - initial: false, - }); - - if (!overwrite) { - logger.info(`Skipped ${icon}.tsx`); - continue; - } - } - - spinner.start(`Adding the ${icon} icon...`); - - try { - await fs.writeFile( - path.join(targetDir, `${icon}.tsx`), - `import { ${icon} } from 'lucide-react-native';\nimport { iconWithClassName } from './iconWithClassName';\niconWithClassName(${icon});\nexport { ${icon} };` - ); - } catch (error) { - handleError(error); - } - } -} - -function fixImports(rawfile: string, componentsAlias: string, libAlias: string) { - return rawfile - .replace('./typography', `${componentsAlias}/ui/typography`) - .replace('./text', `${componentsAlias}/ui/text`) - .replaceAll('../../components', componentsAlias) - .replaceAll('../../lib', libAlias); -} diff --git a/apps/cli/src/commands/init.ts b/apps/cli/src/commands/init.ts deleted file mode 100644 index 17930cbf..00000000 --- a/apps/cli/src/commands/init.ts +++ /dev/null @@ -1,167 +0,0 @@ -import { copyFolder } from '@/src/utils/copy-folder'; -import { handleError } from '@/src/utils/handle-error'; -import { logger } from '@/src/utils/logger'; -import chalk from 'chalk'; -import { execSync } from 'child_process'; -import { Command } from 'commander'; -import { execa } from 'execa'; -import { existsSync, promises as fs } from 'fs'; -import ora from 'ora'; -import path from 'path'; -import prompts from 'prompts'; -import { fileURLToPath } from 'url'; - -const filePath = fileURLToPath(import.meta.url); -const fileDir = path.dirname(filePath); - -export const init = new Command() - .name('init') - .description('Initialize a new React Native Reusables project') - .action(async () => { - try { - const cwd = process.cwd(); - - if (existsSync(cwd) && existsSync(path.join(cwd, 'package.json'))) { - const { option } = await prompts({ - type: 'select', - name: 'option', - message: 'Package.json found. How would you like to proceed?', - choices: [ - { title: 'Initialize a new project', value: 'new-project' }, - { title: 'Cancel', value: 'cancel' }, - ], - initial: false, - }); - - if (option === 'cancel') { - logger.info('Installation cancelled.'); - process.exit(0); - } - } - - const { projectPath } = await prompts({ - type: 'text', - name: 'projectPath', - message: `Enter the project name or relative path (e.g., 'my-app' or './apps/my-app'):`, - initial: './my-app', - }); - - const { packageManager } = await prompts({ - type: 'select', - name: 'packageManager', - message: 'Which package manager would you like the CLI to use?', - choices: [ - { title: 'npm', value: 'npm' }, - { title: 'yarn', value: 'yarn' }, - { title: 'pnpm', value: 'pnpm' }, - { title: 'bun', value: 'bun' }, - { title: 'None.', value: 'none' }, - ], - }); - - const projectName = path.basename(projectPath); - - const spinner = ora(`Initializing ${projectName}...`).start(); - - const fullProjectPath = path.join(cwd, projectPath); - if (!existsSync(fullProjectPath)) { - await fs.mkdir(fullProjectPath, { recursive: true }); - } - - const filesToIgnore = []; - - if (packageManager !== 'pnpm') { - filesToIgnore.push('npmrc-template'); - } - - await copyFolder(path.join(fileDir, '../__generated/starter-base'), fullProjectPath, { - ignore: filesToIgnore, - renameTemplateFiles: true, - }); - - await Promise.all([ - replaceAllInJsonFile(path.join(fullProjectPath, 'app.json'), 'starter-base', projectName), - replaceAllInJsonFile( - path.join(fullProjectPath, 'package.json'), - '@rnr/starter-base', - projectName - ), - ]); - - if (packageManager !== 'none') { - spinner.start( - `Installing dependencies using ${packageManager} (this may take a few minutes)...` - ); - await execa(packageManager, ['install'], { - cwd: fullProjectPath, - }); - spinner.text = 'Running expo doctor to ensure package compatibility...'; - await execa('npx', ['expo', 'install', '--fix'], { - cwd: fullProjectPath, - }); - } - - spinner.stop(); - const { gitInit } = await prompts({ - type: 'confirm', - name: 'gitInit', - message: 'Would you like to initialize a Git repository?', - }); - - if (gitInit) { - spinner.start('Initializing Git repository...'); - try { - execSync('git init', { stdio: 'inherit', cwd: fullProjectPath }); - - execSync('git add -A', { stdio: 'inherit', cwd: fullProjectPath }); - execSync('git commit -m "initialize project with @react-native-reusables/cli"', { - stdio: 'inherit', - cwd: fullProjectPath, - }); - - spinner.succeed('Git repository initialized successfully.'); - } catch (error) { - logger.error('Failed to initialize Git repository:', error); - } - } - - spinner.succeed('New project initialized successfully!'); - console.log(`\nTo get started, run the following commands:\n`); - console.log(chalk.cyan(`cd ${projectPath}`)); - if (packageManager !== 'none') { - console.log( - chalk.cyan( - `${packageManager} ${ - packageManager === 'npm' || packageManager === 'bun' ? 'run ' : '' - }dev` - ) - ); - } - if (packageManager === 'none') { - console.log('Install the dependencies manually using your package manager of choice.'); - console.log('Then run the dev script.'); - } - console.log('\nAdditional resources:'); - console.log('- Documentation: https://rnr-docs.vercel.app'); - console.log('- Report issues: https://github.com/mrzachnugent/react-native-reusables/issues'); - process.exit(0); - } catch (error) { - handleError(error); - } - }); - -async function replaceAllInJsonFile(path: string, searchValue: string, replaceValue: string) { - try { - if (!existsSync(path)) { - logger.error(`The path ${path} does not exist.`); - process.exit(1); - } - - const jsonValue = await fs.readFile(path, 'utf8'); - const replacedValue = jsonValue.replaceAll(searchValue, replaceValue); - - await fs.writeFile(path, replacedValue); - } catch (error) { - handleError(error); - } -} diff --git a/apps/cli/src/contexts/cli-options.ts b/apps/cli/src/contexts/cli-options.ts new file mode 100644 index 00000000..b8cea21c --- /dev/null +++ b/apps/cli/src/contexts/cli-options.ts @@ -0,0 +1,20 @@ +import { Options } from "@effect/cli" +import { Context } from "effect" + +class CliOptions extends Context.Tag("CommandOptions")< + CliOptions, + Readonly<{ + cwd: string + yes: boolean + }> +>() {} + +const cwd = Options.directory("cwd", { exists: "yes" }).pipe(Options.withDefault("."), Options.withAlias("c")) +const yes = Options.boolean("yes", { aliases: ["y"] }) +const summary = Options.boolean("summary").pipe(Options.withAlias("s")) +const overwrite = Options.boolean("overwrite", { aliases: ["o"] }) +const all = Options.boolean("all", { aliases: ["a"] }) +const path = Options.text("path").pipe(Options.withDefault(""), Options.withAlias("p")) +const template = Options.text("template").pipe(Options.withAlias("t"), Options.withDefault("")) + +export { CliOptions, cwd, summary, yes, overwrite, all, path, template } diff --git a/apps/cli/src/index.ts b/apps/cli/src/index.ts deleted file mode 100644 index 16b43167..00000000 --- a/apps/cli/src/index.ts +++ /dev/null @@ -1,21 +0,0 @@ -#!/usr/bin/env node -import { add } from '@/src/commands/add'; -import { init } from '@/src/commands/init'; - -import { Command } from 'commander'; - -process.on('SIGINT', () => process.exit(0)); -process.on('SIGTERM', () => process.exit(0)); - -async function main() { - const program = new Command() - .name('@react-native-reusables/cli') - .description('add components and dependencies to your project'); - - program.addCommand(add); - program.addCommand(init); - - program.parse(); -} - -main(); diff --git a/apps/cli/src/items/components.ts b/apps/cli/src/items/components.ts deleted file mode 100644 index e2d7d8cc..00000000 --- a/apps/cli/src/items/components.ts +++ /dev/null @@ -1,479 +0,0 @@ -export const COMPONENTS = [ - { - name: 'accordion', - dependencies: ['text'], - icons: ['ChevronDown'], - npmPackages: ['@rn-primitives/accordion'], - paths: [ - { - from: './node_modules/@rnr/reusables/src/components/ui/accordion.tsx', - to: { - folder: 'ui', - file: 'accordion.tsx', - }, - }, - ], - }, - { - name: 'alert', - dependencies: ['text'], - icons: [], - npmPackages: [], - paths: [ - { - from: './node_modules/@rnr/reusables/src/components/ui/alert.tsx', - to: { - folder: 'ui', - file: 'alert.tsx', - }, - }, - ], - }, - { - name: 'alert-dialog', - dependencies: ['button', 'text'], - icons: [], - npmPackages: ['@rn-primitives/alert-dialog'], - paths: [ - { - from: './node_modules/@rnr/reusables/src/components/ui/alert-dialog.tsx', - to: { - folder: 'ui', - file: 'alert-dialog.tsx', - }, - }, - ], - }, - { - name: 'aspect-ratio', - dependencies: [], - icons: [], - npmPackages: ['@rn-primitives/aspect-ratio'], - paths: [ - { - from: './node_modules/@rnr/reusables/src/components/ui/aspect-ratio.tsx', - to: { - folder: 'ui', - file: 'aspect-ratio.tsx', - }, - }, - ], - }, - { - name: 'avatar', - dependencies: [], - icons: [], - npmPackages: ['@rn-primitives/avatar'], - paths: [ - { - from: './node_modules/@rnr/reusables/src/components/ui/avatar.tsx', - to: { - folder: 'ui', - file: 'avatar.tsx', - }, - }, - ], - }, - { - name: 'badge', - dependencies: [], - icons: [], - npmPackages: ['@rn-primitives/slot'], - paths: [ - { - from: './node_modules/@rnr/reusables/src/components/ui/badge.tsx', - to: { - folder: 'ui', - file: 'badge.tsx', - }, - }, - ], - }, - { - name: 'button', - dependencies: ['text'], - icons: [], - npmPackages: [], - paths: [ - { - from: './node_modules/@rnr/reusables/src/components/ui/button.tsx', - to: { - folder: 'ui', - file: 'button.tsx', - }, - }, - ], - }, - { - name: 'card', - dependencies: ['text'], - icons: [], - npmPackages: [], - paths: [ - { - from: './node_modules/@rnr/reusables/src/components/ui/card.tsx', - to: { - folder: 'ui', - file: 'card.tsx', - }, - }, - ], - }, - { - name: 'checkbox', - dependencies: [], - icons: ['Check'], - npmPackages: ['@rn-primitives/checkbox'], - paths: [ - { - from: './node_modules/@rnr/reusables/src/components/ui/checkbox.tsx', - to: { - folder: 'ui', - file: 'checkbox.tsx', - }, - }, - ], - }, - { - name: 'collapsible', - dependencies: [], - icons: [], - npmPackages: ['@rn-primitives/collapsible'], - paths: [ - { - from: './node_modules/@rnr/reusables/src/components/ui/collapsible.tsx', - to: { - folder: 'ui', - file: 'collapsible.tsx', - }, - }, - ], - }, - { - name: 'context-menu', - dependencies: ['text'], - icons: ['Check', 'ChevronDown', 'ChevronRight', 'ChevronUp'], - npmPackages: ['@rn-primitives/context-menu'], - paths: [ - { - from: './node_modules/@rnr/reusables/src/components/ui/context-menu.tsx', - to: { - folder: 'ui', - file: 'context-menu.tsx', - }, - }, - ], - }, - { - name: 'dialog', - dependencies: [], - icons: ['X'], - npmPackages: ['@rn-primitives/dialog'], - paths: [ - { - from: './node_modules/@rnr/reusables/src/components/ui/dialog.tsx', - to: { - folder: 'ui', - file: 'dialog.tsx', - }, - }, - ], - }, - { - name: 'dropdown-menu', - dependencies: ['text'], - icons: ['Check', 'ChevronDown', 'ChevronRight', 'ChevronUp'], - npmPackages: ['@rn-primitives/dropdown-menu'], - paths: [ - { - from: './node_modules/@rnr/reusables/src/components/ui/dropdown-menu.tsx', - to: { - folder: 'ui', - file: 'dropdown-menu.tsx', - }, - }, - ], - }, - { - name: 'hover-card', - dependencies: [], - icons: [], - npmPackages: ['@rn-primitives/hover-card'], - paths: [ - { - from: './node_modules/@rnr/reusables/src/components/ui/hover-card.tsx', - to: { - folder: 'ui', - file: 'hover-card.tsx', - }, - }, - ], - }, - { - name: 'input', - dependencies: [], - icons: [], - npmPackages: [], - paths: [ - { - from: './node_modules/@rnr/reusables/src/components/ui/input.tsx', - to: { - folder: 'ui', - file: 'input.tsx', - }, - }, - ], - }, - { - name: 'label', - dependencies: [], - icons: [], - npmPackages: ['@rn-primitives/label'], - paths: [ - { - from: './node_modules/@rnr/reusables/src/components/ui/label.tsx', - to: { - folder: 'ui', - file: 'label.tsx', - }, - }, - ], - }, - { - name: 'menubar', - dependencies: ['text'], - icons: ['Check', 'ChevronDown', 'ChevronRight', 'ChevronUp'], - npmPackages: ['@rn-primitives/menubar'], - paths: [ - { - from: './node_modules/@rnr/reusables/src/components/ui/menubar.tsx', - to: { - folder: 'ui', - file: 'menubar.tsx', - }, - }, - ], - }, - { - name: 'navigation-menu', - dependencies: [], - icons: ['ChevronDown'], - npmPackages: ['@rn-primitives/navigation-menu'], - paths: [ - { - from: './node_modules/@rnr/reusables/src/components/ui/navigation-menu.tsx', - to: { - folder: 'ui', - file: 'navigation-menu.tsx', - }, - }, - ], - }, - { - name: 'popover', - dependencies: ['text'], - icons: [], - npmPackages: ['@rn-primitives/popover'], - paths: [ - { - from: './node_modules/@rnr/reusables/src/components/ui/popover.tsx', - to: { - folder: 'ui', - file: 'popover.tsx', - }, - }, - ], - }, - { - name: 'progress', - dependencies: [], - icons: [], - npmPackages: ['@rn-primitives/progress'], - paths: [ - { - from: './node_modules/@rnr/reusables/src/components/ui/progress.tsx', - to: { - folder: 'ui', - file: 'progress.tsx', - }, - }, - ], - }, - { - name: 'radio-group', - dependencies: [], - icons: [], - npmPackages: ['@rn-primitives/radio-group'], - paths: [ - { - from: './node_modules/@rnr/reusables/src/components/ui/radio-group.tsx', - to: { - folder: 'ui', - file: 'radio-group.tsx', - }, - }, - ], - }, - { - name: 'select', - dependencies: [], - icons: ['Check', 'ChevronDown', 'ChevronUp'], - npmPackages: ['@rn-primitives/select'], - paths: [ - { - from: './node_modules/@rnr/reusables/src/components/ui/select.tsx', - to: { - folder: 'ui', - file: 'select.tsx', - }, - }, - ], - }, - { - name: 'separator', - dependencies: [], - icons: [], - npmPackages: ['@rn-primitives/separator'], - paths: [ - { - from: './node_modules/@rnr/reusables/src/components/ui/separator.tsx', - to: { - folder: 'ui', - file: 'separator.tsx', - }, - }, - ], - }, - { - name: 'skeleton', - dependencies: [], - icons: [], - npmPackages: [], - paths: [ - { - from: './node_modules/@rnr/reusables/src/components/ui/skeleton.tsx', - to: { - folder: 'ui', - file: 'skeleton.tsx', - }, - }, - ], - }, - { - name: 'switch', - dependencies: [], - icons: [], - npmPackages: ['@rn-primitives/switch'], - paths: [ - { - from: './node_modules/@rnr/reusables/src/components/ui/switch.tsx', - to: { - folder: 'ui', - file: 'switch.tsx', - }, - }, - ], - }, - { - name: 'table', - dependencies: ['text'], - icons: [], - npmPackages: ['@rn-primitives/table'], - paths: [ - { - from: './node_modules/@rnr/reusables/src/components/ui/table.tsx', - to: { - folder: 'ui', - file: 'table.tsx', - }, - }, - ], - }, - { - name: 'tabs', - dependencies: ['text'], - icons: [], - npmPackages: ['@rn-primitives/tabs'], - paths: [ - { - from: './node_modules/@rnr/reusables/src/components/ui/tabs.tsx', - to: { - folder: 'ui', - file: 'tabs.tsx', - }, - }, - ], - }, - { - name: 'text', - dependencies: [], - icons: [], - npmPackages: ['@rn-primitives/slot'], - paths: [ - { - from: './node_modules/@rnr/reusables/src/components/ui/text.tsx', - to: { folder: 'ui', file: 'text.tsx' }, - }, - ], - }, - { - name: 'textarea', - dependencies: [], - icons: [], - npmPackages: [], - paths: [ - { - from: './node_modules/@rnr/reusables/src/components/ui/textarea.tsx', - to: { folder: 'ui', file: 'textarea.tsx' }, - }, - ], - }, - { - name: 'toggle', - dependencies: ['text'], - icons: [], - npmPackages: ['@rn-primitives/toggle'], - paths: [ - { - from: './node_modules/@rnr/reusables/src/components/ui/toggle.tsx', - to: { folder: 'ui', file: 'toggle.tsx' }, - }, - ], - }, - { - name: 'toggle-group', - dependencies: ['text'], - icons: [], - npmPackages: ['@rn-primitives/toggle-group'], - paths: [ - { - from: './node_modules/@rnr/reusables/src/components/ui/toggle-group.tsx', - to: { folder: 'ui', file: 'toggle-group.tsx' }, - }, - ], - }, - { - name: 'tooltip', - dependencies: ['text'], - icons: [], - npmPackages: ['@rn-primitives/tooltip'], - paths: [ - { - from: './node_modules/@rnr/reusables/src/components/ui/tooltip.tsx', - to: { folder: 'ui', file: 'tooltip.tsx' }, - }, - ], - }, - { - name: 'typography', - dependencies: [], - icons: [], - npmPackages: ['@rn-primitives/slot'], - paths: [ - { - from: './node_modules/@rnr/reusables/src/components/ui/typography.tsx', - to: { folder: 'ui', file: 'typography.tsx' }, - }, - ], - }, -]; diff --git a/apps/cli/src/items/index.ts b/apps/cli/src/items/index.ts deleted file mode 100644 index 1e7249aa..00000000 --- a/apps/cli/src/items/index.ts +++ /dev/null @@ -1,53 +0,0 @@ -import { COMPONENTS } from './components'; - -export type Component = (typeof COMPONENTS)[number] & { icons?: string[] }; -type ComponentName = (typeof COMPONENTS)[number]['name']; - -function getComponentDependencies( - componentName: ComponentName, - visited = new Set() -) { - const component = COMPONENTS.find((comp) => comp.name === componentName); - if (!component) return []; - - visited.add(componentName); - - let dependencies: ComponentName[] = component.dependencies.slice(); - - component.dependencies.forEach((dependency) => { - if (!visited.has(dependency)) { - const childDependencies = getComponentDependencies(dependency, visited); - dependencies = dependencies.concat(childDependencies); - } - }); - - return dependencies; -} - -export const INVALID_COMPONENT_ERROR = 'invalid component'; - -export function getAllComponentsToWrite(componentNames: string[]): Component[] { - const uniqueComponents = new Set(); - - if ( - componentNames.some((componentName) => !COMPONENTS.find((comp) => comp.name === componentName)) - ) { - throw new Error(INVALID_COMPONENT_ERROR); - } - - componentNames.forEach((componentName) => { - const allDependencies = getComponentDependencies(componentName as ComponentName); - allDependencies.unshift(componentName as never); // Add the component itself to the list - allDependencies.forEach((dep) => { - uniqueComponents.add(dep); - }); - }); - - return Array.from(uniqueComponents).map((dep) => { - const comp = COMPONENTS.find((comp) => comp.name === dep); - if (!comp) { - throw new Error(INVALID_COMPONENT_ERROR); - } - return comp; - }); -} diff --git a/apps/cli/src/items/templates.ts b/apps/cli/src/items/templates.ts deleted file mode 100644 index 98af1c6b..00000000 --- a/apps/cli/src/items/templates.ts +++ /dev/null @@ -1,6 +0,0 @@ -export const TEMPLATES = [ - { - name: 'starter-base', - path: './node_modules/@rnr/starter-base', - }, -]; diff --git a/apps/cli/src/project-manifest.ts b/apps/cli/src/project-manifest.ts new file mode 100644 index 00000000..e14f0def --- /dev/null +++ b/apps/cli/src/project-manifest.ts @@ -0,0 +1,305 @@ +interface FileCheck { + name: string + fileNames: Array + docs: string + includes: Array<{ + content: Array + message: string + docs: string + }> +} + +type CustomFileCheck = Omit & { defaultFileNames?: ReadonlyArray } + +interface FileWithContent extends FileCheck { + content: string +} + +interface MissingInclude { + fileName: string + content: ReadonlyArray + message: string + docs: string +} + +const DEPENDENCIES = [ + "expo", + "nativewind", + "react-native-reanimated", + "react-native-safe-area-context", + "tailwindcss-animate", + "class-variance-authority", + "clsx", + "tailwind-merge" +] + +const DEV_DEPENDENCIES = ["tailwindcss@^3.4.17"] + +const FILE_CHECKS: Array = [ + { + name: "Babel Config", + fileNames: ["babel.config.js", "babel.config.ts"], + docs: "https://www.nativewind.dev/docs/getting-started/installation#3-add-the-babel-preset", + includes: [ + { + content: ["nativewind/babel", "jsxImportSource"], + message: "jsxImportSource or nativewind/babel is missing", + docs: "https://www.nativewind.dev/docs/getting-started/installation#3-add-the-babel-preset" + } + ] + }, + { + name: "Metro Config", + fileNames: ["metro.config.js", "metro.config.ts"], + docs: "https://www.nativewind.dev/docs/getting-started/installation#4-create-or-modify-your-metroconfigjs", + includes: [ + { + content: ["withNativeWind("], + message: "The withNativeWind function is missing", + docs: "https://www.nativewind.dev/docs/getting-started/installation#4-create-or-modify-your-metroconfigjs" + }, + { + content: ["inlineRem", "16"], + message: "The inlineRem is missing", + docs: "https://reactnativereusables.com/docs/installation/manual#update-the-default-inlined-rem-value" + } + ] + }, + { + name: "Root Layout", + fileNames: ["app/_layout.tsx", "src/app/_layout.tsx"], + docs: "hhttps://reactnativereusables.com/docs/installation/manual#add-the-portal-host-to-your-root-layout", // + includes: [ + { + content: [".css"], + message: "The css file import is missing", + docs: "https://www.nativewind.dev/docs/getting-started/installation#5-import-your-css-file" + }, + { + content: ["Title)", + docs: "https://reactnativereusables.com/docs/components/text#typography" + } + ] + } +] + +// Excludes foreground colors since it is formatted differently in all 3 styling files (tailwind config, global.css, theme.ts) +const CSS_VARIABLE_NAMES = [ + "background", + "foreground", + "card", + "popover", + "primary", + "secondary", + "muted", + "accent", + "destructive", + "border", + "input", + "ring", + "radius" +] + +const CUSTOM_FILE_CHECKS = { + tailwindConfig: { + name: "Tailwind Config", + defaultFileNames: ["tailwind.config.js", "tailwind.config.ts"], + docs: "https://reactnativereusables.com/docs/installation/manual#configure-your-styles", + includes: [ + { + content: ["nativewind/preset"], + message: "The nativewind preset is missing", + docs: "https://www.nativewind.dev/docs/getting-started/installation#2-setup-tailwind-css" + }, + { + content: CSS_VARIABLE_NAMES, + message: "At least one of the color css variables is missing", + docs: "https://reactnativereusables.com/docs/installation/manual#configure-your-styles" + } + ] + }, + theme: { + name: "Theme", + defaultFileNames: ["lib/theme.ts"], + docs: "https://reactnativereusables.com/docs/installation/manual#configure-your-styles", + includes: [ + { + content: CSS_VARIABLE_NAMES, + message: "At least one of the color variables is missing", + docs: "https://reactnativereusables.com/docs/installation/manual#configure-your-styles" + }, + { + content: ["NAV_THEME"], + message: "The NAV_THEME is missing", + docs: "https://reactnativereusables.com/docs/installation/manual#configure-your-styles" + } + ] + }, + nativewindEnv: { + name: "Nativewind Env", + docs: "https://www.nativewind.dev/docs/getting-started/installation#7-typescript-setup-optional", + includes: [ + { + content: ["nativewind/types"], + message: "The nativewind types are missing", + docs: "https://www.nativewind.dev/docs/getting-started/installation#7-typescript-setup-optional" + } + ] + }, + utils: { + name: "Utils", + defaultFileNames: ["lib/utils.ts"], + docs: "https://reactnativereusables.com/docs/installation/manual#add-a-cn-helper", + includes: [ + { + content: ["function cn("], + message: "The cn function is missing", + docs: "https://reactnativereusables.com/docs/installation/manual#add-a-cn-helper" + } + ] + }, + css: { + name: "CSS", + defaultFileNames: ["globals.css", "src/global.css"], + docs: "https://reactnativereusables.com/docs/installation/manual#configure-your-styles", + includes: [ + { + content: ["@tailwind base", "@tailwind components", "@tailwind utilities"], + message: "The tailwind layer directives are missing", + docs: "https://reactnativereusables.com/docs/installation/manual#configure-your-styles" + }, + { + content: CSS_VARIABLE_NAMES, + message: "At least one of the color css variables is missing", + docs: "https://reactnativereusables.com/docs/installation/manual#configure-your-styles" + } + ] + } +} + +const NATIVEWIND_ENV_FILE = "nativewind-env.d.ts" + +const COMPONENTS = [ + "accordion", + "alert-dialog", + "alert", + "aspect-ratio", + "avatar", + "badge", + "button", + "card", + "checkbox", + "collapsible", + "context-menu", + "dialog", + "dropdown-menu", + "hover-card", + "input", + "label", + "menubar", + "popover", + "progress", + "radio-group", + "select", + "separator", + "skeleton", + "switch", + "tabs", + "text", + "textarea", + "toggle-group", + "toggle", + "tooltip" +] + +const TEMPLATES = [ + { + name: "Minimal", + url: "https://github.com/founded-labs/react-native-reusables-templates.git", + subPath: "minimal" + }, + { + name: "Clerk auth", + url: "https://github.com/founded-labs/react-native-reusables-templates.git", + subPath: "clerk-auth" + } +] + +const PROJECT_MANIFEST = { + dependencies: DEPENDENCIES, + devDependencies: DEV_DEPENDENCIES, + fileChecks: FILE_CHECKS, + deprecatedFromLib: DEPRECATED_FROM_LIB, + deprecatedFromUi: DEPRECATED_FROM_UI, + customFileChecks: CUSTOM_FILE_CHECKS, + nativewindEnvFile: NATIVEWIND_ENV_FILE, + components: COMPONENTS, + templates: TEMPLATES +} + +export { PROJECT_MANIFEST } +export type { FileCheck, CustomFileCheck, FileWithContent, MissingInclude } diff --git a/apps/cli/src/services/commands/add.ts b/apps/cli/src/services/commands/add.ts new file mode 100644 index 00000000..0ee1da9d --- /dev/null +++ b/apps/cli/src/services/commands/add.ts @@ -0,0 +1,112 @@ +import { Effect, Layer, pipe, Schema } from "effect" +import { CliOptions } from "@cli/contexts/cli-options.js" +import { Doctor } from "@cli/services/commands/doctor.js" +import { ProjectConfig } from "../project-config.js" +import { Prompt } from "@effect/cli" +import { PROJECT_MANIFEST } from "@cli/project-manifest.js" +import { runCommand } from "@cli/utils/run-command.js" + +type AddOptions = { + cwd: string + args: { components: Array } + yes: boolean + overwrite: boolean + all: boolean + path: string +} + +class Add extends Effect.Service()("Add", { + effect: Effect.gen(function* () { + const doctor = yield* Doctor + const projectConfig = yield* ProjectConfig + + return { + run: (options: AddOptions) => + Effect.gen(function* () { + yield* Effect.logDebug(`Add options: ${JSON.stringify(options, null, 2)}`) + + const componentJson = yield* projectConfig.getComponentJson() + const style = yield* pipe( + componentJson.style, + Schema.decodeUnknown(Schema.Union(Schema.Literal("default"), Schema.Literal("new-york"))) + ) + + const components = options.all ? PROJECT_MANIFEST.components : (options.args?.components ?? []) + + if (components.length === 0) { + const selectedComponents = yield* Prompt.multiSelect({ + message: "Select components to add", + choices: PROJECT_MANIFEST.components.map((component) => ({ + title: component, + value: component + })) + }) + for (const component of selectedComponents) { + components.push(component) + } + } + + if (components.length === 0) { + yield* Effect.fail(new Error("No components selected.")) + } + + yield* Effect.logDebug(`Selected components: ${components.join(", ")}`) + + const baseUrl = + process.env.NODE_ENV === "development" + ? "http://localhost:3000/local/r" + : "https://reactnativereusables.com/r" + + const componentUrls = components.map((component) => `${baseUrl}/${style}/${component}.json`) + + const shadcnOptions = toShadcnOptions(options) + + const commandArgs = ["shadcn@latest", "add", ...shadcnOptions, ...componentUrls] + + yield* Effect.logDebug(`Running command: npx ${commandArgs.join(" ")}`) + + yield* runCommand("npx", commandArgs, { + cwd: options.cwd, + stdio: "inherit" + }) + + yield* doctor.run({ ...options, summary: true }) + }) + } + }) +}) {} + +function make(options: AddOptions) { + const optionsLayer = Layer.succeed(CliOptions, { ...options, yes: true }) // For the project config + return Effect.gen(function* () { + const add = yield* Add + + return yield* add.run(options) + }).pipe( + Effect.provide(Add.Default), + Effect.provide(Doctor.Default), + Effect.provide(ProjectConfig.Default), + Effect.provide(optionsLayer) + ) +} + +export { make } + +function toShadcnOptions(options: AddOptions) { + const shadcnOptions = [] + + if (options.overwrite) { + shadcnOptions.push("--overwrite") + } + + if (options.yes) { + shadcnOptions.push("--yes") + } + + if (options.path) { + shadcnOptions.push("--path") + shadcnOptions.push(options.path) + } + + return shadcnOptions +} diff --git a/apps/cli/src/services/commands/doctor.ts b/apps/cli/src/services/commands/doctor.ts new file mode 100644 index 00000000..9ae5f5fc --- /dev/null +++ b/apps/cli/src/services/commands/doctor.ts @@ -0,0 +1,274 @@ +import { CliOptions } from "@cli/contexts/cli-options.js" +import { type CustomFileCheck, type FileCheck, type MissingInclude, PROJECT_MANIFEST } from "@cli/project-manifest.js" +import { ProjectConfig } from "@cli/services/project-config.js" +import { RequiredFilesChecker } from "@cli/services/required-files-checker.js" +import { Spinner } from "@cli/services/spinner.js" +import { runCommand } from "@cli/utils/run-command.js" +import { Prompt } from "@effect/cli" +import { FileSystem, Path } from "@effect/platform" +import { Data, Effect, Layer, Schema } from "effect" +import logSymbols from "log-symbols" + +const packageJsonSchema = Schema.Struct({ + dependencies: Schema.optional(Schema.Record({ key: Schema.String, value: Schema.String })), + devDependencies: Schema.optional(Schema.Record({ key: Schema.String, value: Schema.String })) +}) + +class PackageJsonError extends Data.TaggedError("PackageJsonError")<{ + cause?: unknown + message?: string +}> {} + +type DoctorOptions = { + cwd: string + summary: boolean + yes: boolean +} + +class Doctor extends Effect.Service()("Doctor", { + dependencies: [RequiredFilesChecker.Default, Spinner.Default], + effect: Effect.gen(function* () { + const options = yield* CliOptions + const fs = yield* FileSystem.FileSystem + const path = yield* Path.Path + const requiredFileChecker = yield* RequiredFilesChecker + const spinner = yield* Spinner + + const checkRequiredDependencies = ({ + dependencies, + devDependencies + }: { + dependencies: Array + devDependencies: Array + }) => + Effect.gen(function* () { + const packageJsonExists = yield* fs.exists(path.join(options.cwd, "package.json")) + if (!packageJsonExists) { + return yield* Effect.fail(new PackageJsonError({ message: "A package.json was not found and is required." })) + } + + const packageJson = yield* fs.readFileString(path.join(options.cwd, "package.json")).pipe( + Effect.flatMap(Schema.decodeUnknown(Schema.parseJson())), + Effect.flatMap(Schema.decodeUnknown(packageJsonSchema)), + Effect.catchTags({ + ParseError: () => Effect.fail(new PackageJsonError({ message: "Failed to parse package.json" })) + }) + ) + + const uninstalledDependencies: Array = [] + const uninstalledDevDependencies: Array = [] + + for (const dependency of dependencies) { + if ( + !packageJson.dependencies?.[dependency.split("@")[0]] && + !packageJson.devDependencies?.[dependency.split("@")[0]] + ) { + uninstalledDependencies.push(dependency) + continue + } + yield* Effect.logDebug( + `${logSymbols.success} ${dependency}@${packageJson.dependencies?.[dependency.split("@")[0]]} is installed` + ) + } + + for (const devDependency of devDependencies) { + if ( + !packageJson.devDependencies?.[devDependency.split("@")[0]] && + !packageJson.dependencies?.[devDependency.split("@")[0]] + ) { + uninstalledDevDependencies.push(devDependency) + continue + } + yield* Effect.logDebug( + `${logSymbols.success} ${devDependency}@${packageJson.devDependencies?.[devDependency]} is installed` + ) + } + + return { uninstalledDependencies, uninstalledDevDependencies } + }) + + return { + run: (options: DoctorOptions) => + Effect.gen(function* () { + yield* Effect.logDebug(`Doctor options: ${JSON.stringify(options, null, 2)}`) + const { uninstalledDependencies, uninstalledDevDependencies } = yield* checkRequiredDependencies({ + dependencies: PROJECT_MANIFEST.dependencies, + devDependencies: PROJECT_MANIFEST.devDependencies + }) + + const { customFileResults, deprecatedFileResults, fileResults } = yield* requiredFileChecker.run({ + customFileChecks: PROJECT_MANIFEST.customFileChecks, + deprecatedFromLib: PROJECT_MANIFEST.deprecatedFromLib, + deprecatedFromUi: PROJECT_MANIFEST.deprecatedFromUi, + fileChecks: PROJECT_MANIFEST.fileChecks + }) + + const result = { + missingFiles: [...fileResults.missingFiles, ...customFileResults.missingFiles], + uninstalledDependencies, + uninstalledDevDependencies, + missingIncludes: [...fileResults.missingIncludes, ...customFileResults.missingIncludes], + deprecatedFileResults + } + + let total = Object.values(result).reduce((sum, cat) => sum + cat.length, 0) + if (!options.summary) { + const dependenciesToInstall: Array = [] + for (const dep of result.uninstalledDependencies) { + const confirmsInstall = options.yes + ? true + : yield* Prompt.confirm({ + message: `The ${dep} dependency is missing. Do you want to install it?`, + initial: true + }) + if (confirmsInstall) { + if (uninstalledDependencies.includes("expo")) { + continue + } + total-- + yield* Effect.logDebug(`Adding ${dep} to dependencies to install`) + dependenciesToInstall.push(dep) + result.uninstalledDependencies = result.uninstalledDependencies.filter((d) => d !== dep) + } + } + + if (dependenciesToInstall.length > 0) { + yield* Effect.logDebug(`Installing ${dependenciesToInstall.join(", ")}`) + if (process.env.NODE_ENV !== "development") { + spinner.start("Installing dependencies") + yield* runCommand("npx", ["expo", "install", ...dependenciesToInstall], { + cwd: options.cwd + }) + spinner.stop() + } + } + + const devDependenciesToInstall: Array = [] + for (const dep of result.uninstalledDevDependencies) { + const confirmsInstall = options.yes + ? true + : yield* Prompt.confirm({ + message: `The ${dep} dependency is missing. Do you want to install it?`, + initial: true + }) + if (confirmsInstall) { + if (uninstalledDependencies.includes("expo")) { + continue + } + total-- + yield* Effect.logDebug(`Adding ${dep} to devDependencies to install`) + devDependenciesToInstall.push(dep) + result.uninstalledDevDependencies = result.uninstalledDevDependencies.filter((d) => d !== dep) + } + } + + if (devDependenciesToInstall.length > 0) { + yield* Effect.logDebug(`Installing ${devDependenciesToInstall.join(", ")}`) + if (process.env.NODE_ENV !== "development") { + spinner.start("Installing dev dependencies") + yield* runCommand("npx", ["expo", "install", ...devDependenciesToInstall], { + cwd: options.cwd + }) + spinner.stop() + } + } + } + + if (total === 0) { + console.log(`\x1b[2m${logSymbols.success} All checks passed.\x1b[0m\n`) + return yield* Effect.succeed(true) + } + + const analysis = analyzeResult(result) + if (options.summary) { + console.log( + `\x1b[2m${logSymbols.warning} ${total} Potential issue${ + total > 1 ? "s" : "" + } found. For more info, run: 'npx @react-native-reusables/cli doctor${ + options.cwd !== "." ? ` -c ${options.cwd}` : "" + }'\x1b[0m\n` + ) + } else { + yield* Effect.log("\n\n🩺 Diagnosis") + for (const item of analysis) { + console.group(`\n${item.title}`) + item.logs.forEach((line) => console.log(line)) + console.groupEnd() + } + console.log(`\n`) + } + }) + } + }) +}) {} + +function make(options: DoctorOptions) { + const optionsLayer = Layer.succeed(CliOptions, options) + return Effect.gen(function* () { + const doctor = yield* Doctor + return yield* doctor.run(options) + }).pipe(Effect.provide(Doctor.Default), Effect.provide(ProjectConfig.Default), Effect.provide(optionsLayer)) +} + +export { Doctor, make } + +interface Result { + missingFiles: Array + missingIncludes: Array + uninstalledDependencies: Array + uninstalledDevDependencies: Array + deprecatedFileResults: Array> +} + +function analyzeResult(result: Result) { + const categories: Array<{ title: string; logs: Array; count: number }> = [] + + if (result.missingFiles.length > 0) { + categories.push({ + title: `${logSymbols.error} Missing Files (${result.missingFiles.length})`, + count: result.missingFiles.length, + logs: result.missingFiles.flatMap((f) => [`• ${f.name}`, ` 📘 Docs: ${f.docs}`]) + }) + } + + if (result.missingIncludes.length > 0) { + categories.push({ + title: `${logSymbols.error} Potentially Misconfigured Files (${result.missingIncludes.length})`, + count: result.missingIncludes.length, + logs: result.missingIncludes.flatMap((inc) => [ + `• ${inc.fileName}`, + ` ↪ ${inc.message}`, + ` 📘 Docs: ${inc.docs}` + ]) + }) + } + + if (result.uninstalledDependencies.length > 0) { + categories.push({ + title: `${logSymbols.error} Missing Dependencies (${result.uninstalledDependencies.length})`, + count: result.uninstalledDependencies.length, + logs: ["• Install with:", ` ↪ npx expo install ${result.uninstalledDependencies.join(" ")}`] + }) + } + + if (result.uninstalledDevDependencies.length > 0) { + categories.push({ + title: `${logSymbols.error} Missing Dev Dependencies (${result.uninstalledDevDependencies.length})`, + count: result.uninstalledDevDependencies.length, + logs: ["• Install with:", ` ↪ npx expo install -- -D ${result.uninstalledDevDependencies.join(" ")}`] + }) + } + + if (result.deprecatedFileResults.length > 0) { + categories.push({ + title: `${logSymbols.warning} Deprecated (${result.deprecatedFileResults.length})`, + count: result.deprecatedFileResults.length, + logs: result.deprecatedFileResults.flatMap((deprecatedFile) => [ + `• ${deprecatedFile.name}`, + ...deprecatedFile.includes.map((item) => ` ↪ ${item.message}\n 📘 Docs: ${item.docs}`) + ]) + }) + } + + return categories +} diff --git a/apps/cli/src/services/commands/init.ts b/apps/cli/src/services/commands/init.ts new file mode 100644 index 00000000..44afee31 --- /dev/null +++ b/apps/cli/src/services/commands/init.ts @@ -0,0 +1,94 @@ +import { CliOptions } from "@cli/contexts/cli-options.js" +import { PROJECT_MANIFEST } from "@cli/project-manifest.js" +import { Doctor } from "@cli/services/commands/doctor.js" +import { ProjectConfig } from "@cli/services/project-config.js" +import { Template } from "@cli/services/template.js" +import { Prompt } from "@effect/cli" +import { FileSystem, Path } from "@effect/platform" +import { Effect, Layer } from "effect" +import logSymbols from "log-symbols" + +type InitOptions = { + cwd: string + template: string +} + +class Init extends Effect.Service()("Init", { + dependencies: [Template.Default], + effect: Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem + const path = yield* Path.Path + const doctor = yield* Doctor + const template = yield* Template + + return { + run: (options: InitOptions) => + Effect.gen(function* () { + yield* Effect.logDebug(`Init options: ${JSON.stringify(options, null, 2)}`) + + const packageJsonExists = yield* fs.exists(path.join(options.cwd, "package.json")) + + yield* Effect.logDebug(`Does package.json exist: ${packageJsonExists ? "yes" : "no"}`) + + if (packageJsonExists) { + yield* Effect.logWarning(`${logSymbols.warning} A project already exists in this directory.`) + const choice = yield* Prompt.select({ + message: "How would you like to proceed?", + choices: [ + { title: "Initialize a new project here anyway", value: "init-new" }, + { title: "Inspect project configuration", value: "doctor" }, + { title: "Cancel and exit", value: "cancel" } + ] + }) + yield* Effect.logDebug(`Init choice: ${choice}`) + if (choice === "cancel") { + return yield* Effect.succeed(true) + } + if (choice === "doctor") { + console.log("") + return yield* doctor.run({ ...options, summary: false, yes: false }) + } + } + + const projectName = yield* Prompt.text({ + message: "What is the name of your project? (e.g. my-app)", + default: "my-app" + }) + + const templateFromFlag = PROJECT_MANIFEST.templates.find((t) => t.subPath === options.template) + + const selectedTemplate = templateFromFlag + ? templateFromFlag + : yield* Prompt.select({ + message: "Select a template", + choices: PROJECT_MANIFEST.templates.map((template) => ({ + title: template.name, + value: template + })) + }) + + yield* template.clone({ + cwd: options.cwd, + name: projectName, + repo: selectedTemplate + }) + }) + } + }) +}) {} + +function make(options: InitOptions) { + const optionsLayer = Layer.succeed(CliOptions, { ...options, yes: true }) + return Effect.gen(function* () { + const init = yield* Init + + return yield* init.run(options) + }).pipe( + Effect.provide(Init.Default), + Effect.provide(Doctor.Default), + Effect.provide(ProjectConfig.Default), + Effect.provide(optionsLayer) + ) +} + +export { make } diff --git a/apps/cli/src/services/git.ts b/apps/cli/src/services/git.ts new file mode 100644 index 00000000..6214cfcf --- /dev/null +++ b/apps/cli/src/services/git.ts @@ -0,0 +1,39 @@ +import { Data, Effect } from "effect" +import { Command } from "@effect/platform" +import { Prompt } from "@effect/cli" +import logSymbols from "log-symbols" + +export class GitError extends Data.TaggedError("GitError")<{ + cause?: unknown + message?: string +}> {} + +const COMMANDS = { + status: Command.make("git", "status", "--porcelain") +} as const + +export class Git extends Effect.Service()("Git", { + succeed: { + promptIfDirty: () => + Effect.gen(function* () { + const gitStatus = yield* COMMANDS.status.pipe( + Command.string, + Effect.catchAll(() => Effect.succeed("")) // Not a git repository + ) + const isDirty = gitStatus.trim().length > 0 + if (!isDirty) { + return false + } + const result = yield* Prompt.confirm({ + message: `${logSymbols.warning} The Git repository is dirty (uncommitted changes). Continue anyway?`, + initial: true + }) + + if (!result) { + return yield* Effect.fail(new GitError({ message: "Aborted due to uncommitted changes." })) + } + + return result + }) + } +}) {} diff --git a/apps/cli/src/services/project-config.ts b/apps/cli/src/services/project-config.ts new file mode 100644 index 00000000..9c1a6161 --- /dev/null +++ b/apps/cli/src/services/project-config.ts @@ -0,0 +1,256 @@ +import { CliOptions } from "@cli/contexts/cli-options.js" +import { Prompt } from "@effect/cli" +import { FileSystem, Path } from "@effect/platform" +import { Effect, Schema } from "effect" +import { Git } from "./git.js" +import { type ConfigLoaderSuccessResult, createMatchPath, loadConfig as loadTypescriptConfig } from "tsconfig-paths" + +const componentJsonSchema = Schema.Struct({ + $schema: Schema.optional(Schema.String), + style: Schema.String, + rsc: Schema.Boolean, + tsx: Schema.Boolean, + tailwind: Schema.Struct({ + config: Schema.optional(Schema.String), + css: Schema.String, + baseColor: Schema.String, + cssVariables: Schema.Boolean, + prefix: Schema.optional(Schema.String) + }), + aliases: Schema.Struct({ + components: Schema.String, + utils: Schema.String, + ui: Schema.optional(Schema.String), + lib: Schema.optional(Schema.String), + hooks: Schema.optional(Schema.String) + }), + iconLibrary: Schema.optional(Schema.String) +}) + +const supportedExtensions = [".ts", ".tsx", ".jsx", ".js", ".css"] + +class ProjectConfig extends Effect.Service()("ProjectConfig", { + dependencies: [Git.Default], + effect: Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem + const path = yield* Path.Path + const options = yield* CliOptions + const git = yield* Git + + let componentJsonConfig: typeof componentJsonSchema.Type | null = null + let tsConfig: ConfigLoaderSuccessResult | null = null + + const getComponentJson = () => + Effect.gen(function* () { + if (componentJsonConfig) { + return componentJsonConfig + } + + const componentJsonExists = yield* fs.exists(path.join(options.cwd, "components.json")) + if (!componentJsonExists) { + return yield* handleInvalidComponentJson(false) + } + const config = yield* fs.readFileString(path.join(options.cwd, "components.json")).pipe( + Effect.flatMap(Schema.decodeUnknown(Schema.parseJson())), + Effect.flatMap(Schema.decodeUnknown(componentJsonSchema)), + Effect.catchTags({ + ParseError: () => handleInvalidComponentJson(true) + }) + ) + + componentJsonConfig = config + + yield* Effect.logDebug(`componentJsonConfig: ${JSON.stringify(componentJsonConfig, null, 2)}`) + return config + }) + + const handleInvalidComponentJson = (exists: boolean) => + Effect.gen(function* () { + yield* Effect.logWarning( + `${exists ? "Invalid components.json" : "Missing components.json"}${" (required to continue)"}` + ) + const agreeToWrite = options.yes + ? true + : yield* Prompt.confirm({ + message: `Would you like to ${exists ? "update the" : "write a"} components.json file?`, + label: { confirm: "y", deny: "n" }, + initial: true, + placeholder: { defaultConfirm: "y/n" } + }) + if (!agreeToWrite) { + return yield* Effect.fail(new Error("Unable to continue without a valid components.json file.")) + } + + const style = options.yes + ? exists + ? "default" + : "new-york" + : yield* Prompt.select({ + message: "Which style would you like to use?", + choices: exists + ? [ + { title: "default", value: "default" }, + { title: "new-york", value: "new-york" } + ] + : ([ + { title: "new-york", value: "new-york" }, + { title: "default", value: "default" } + ] as const) + }) + + const baseColor = options.yes + ? "neutral" + : yield* Prompt.select({ + message: "Which color would you like to use as the base color?", + choices: [ + { title: "neutral", value: "neutral" }, + { title: "stone", value: "stone" }, + { title: "zinc", value: "zinc" }, + { title: "gray", value: "gray" }, + { title: "slate", value: "slate" } + ] as const + }) + + const hasRootGlobalCss = yield* fs.exists(path.join(options.cwd, "global.css")) + + const hasSrcGlobalCss = hasRootGlobalCss ? false : yield* fs.exists(path.join(options.cwd, "src/global.css")) + + const detectedCss = hasRootGlobalCss ? "global.css" : hasSrcGlobalCss ? "src/global.css" : "" + + const css = + options.yes && detectedCss + ? detectedCss + : yield* Prompt.text({ + message: "What is the name of the CSS file and path to it? (e.g. global.css or src/global.css)", + default: detectedCss + }) + + const hasTailwindConfig = yield* fs.exists(path.join(options.cwd, "tailwind.config.js")) + const tailwindConfig = + options.yes && hasTailwindConfig + ? "tailwind.config.js" + : yield* Prompt.text({ + message: + "What is the name of the Tailwind config file and path to it? (e.g. tailwind.config.js or src/tailwind.config.js)", + default: "tailwind.config.js" + }) + + const tsConfig = yield* getTsConfig() + + const aliasSymbol = `${(Object.keys(tsConfig.paths ?? {})[0] ?? "@/*").split("/*")[0]}` + + const detectedAliases = { + components: `${aliasSymbol}/components`, + utils: `${aliasSymbol}/lib/utils`, + ui: `${aliasSymbol}/components/ui`, + lib: `${aliasSymbol}/lib`, + hooks: `${aliasSymbol}/hooks` + } + + let aliases = detectedAliases + + if (!options.yes) { + const useDetectedAliases = yield* Prompt.confirm({ + message: `Use detected alias (${aliasSymbol}/*) in your setup?`, + initial: true + }) + + if (!useDetectedAliases) { + const [componentsAlias, utilsAlias, uiAlias, libAlias, hooksAlias] = yield* Prompt.all([ + Prompt.text({ + message: "What is the name of the components alias?", + default: detectedAliases.components + }), + Prompt.text({ + message: "What is the name of the utils alias?", + default: detectedAliases.utils + }), + Prompt.text({ + message: "What is the name of the ui alias?", + default: detectedAliases.ui + }), + Prompt.text({ + message: "What is the name of the lib alias?", + default: detectedAliases.lib + }), + Prompt.text({ + message: "What is the name of the hooks alias?", + default: detectedAliases.hooks + }) + ]) + + aliases = { + components: componentsAlias, + utils: utilsAlias, + ui: uiAlias, + lib: libAlias, + hooks: hooksAlias + } + } + } + + const newComponentJson = yield* Schema.encode(componentJsonSchema)({ + $schema: "https://ui.shadcn.com/schema.json", + style, + aliases, + rsc: false, + tsx: true, + tailwind: { + css, + baseColor, + cssVariables: true, + config: tailwindConfig + } + }) + + yield* git.promptIfDirty() + yield* fs.writeFileString(path.join(options.cwd, "components.json"), JSON.stringify(newComponentJson, null, 2)) + yield* Effect.logDebug(`newComponentJson: ${JSON.stringify(newComponentJson, null, 2)}`) + return newComponentJson + }) + + const getTsConfig = () => + Effect.try({ + try: () => { + if (tsConfig) { + return tsConfig + } + const configResult = loadTypescriptConfig(options.cwd) + if (configResult.resultType === "failed") { + throw new Error("Error loading tsconfig.json", { cause: configResult.message }) + } + tsConfig = configResult + return configResult + }, + catch: (error) => new Error("Error loading {ts,js}config.json", { cause: String(error) }) + }) + + const resolvePathFromAlias = (aliasPath: string) => + Effect.gen(function* () { + const config = yield* getTsConfig() + return yield* Effect.try({ + try: () => { + const matchPath = createMatchPath(config.absoluteBaseUrl, config.paths)( + aliasPath, + undefined, + () => true, + supportedExtensions + ) + if (!matchPath) { + throw new Error("Path not found", { cause: aliasPath }) + } + return matchPath + }, + catch: (error) => new Error("Path not found", { cause: String(error) }) + }) + }) + + return { + getComponentJson, + getTsConfig, + resolvePathFromAlias + } + }) +}) {} + +export { ProjectConfig } diff --git a/apps/cli/src/services/required-files-checker.ts b/apps/cli/src/services/required-files-checker.ts new file mode 100644 index 00000000..be28e856 --- /dev/null +++ b/apps/cli/src/services/required-files-checker.ts @@ -0,0 +1,333 @@ +import { CliOptions } from "@cli/contexts/cli-options.js" +import type { CustomFileCheck, FileCheck, FileWithContent, MissingInclude } from "@cli/project-manifest.js" +import { PROJECT_MANIFEST } from "@cli/project-manifest.js" +import { ProjectConfig } from "@cli/services/project-config.js" +import { retryWith } from "@cli/utils/retry-with.js" +import { FileSystem, Path } from "@effect/platform" +import { Data, Effect } from "effect" +import logSymbols from "log-symbols" + +class RequiredFileError extends Data.TaggedError("RequiredFileError")<{ + file: string + message?: string +}> {} + +class RequiredFilesChecker extends Effect.Service()("RequiredFilesChecker", { + effect: Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem + const path = yield* Path.Path + const options = yield* CliOptions + const projectConfig = yield* ProjectConfig + + const checkFiles = (fileChecks: Array) => + Effect.gen(function* () { + const missingFiles: Array = [] + const missingIncludes: Array = [] + + const filesWithContent = yield* Effect.forEach( + fileChecks, + (file) => + retryWith( + (filePath: string) => + Effect.gen(function* () { + const fileContents = yield* fs.readFileString(filePath) + yield* Effect.logDebug(`${logSymbols.success} ${file.name} found`) + return { ...file, content: fileContents } as FileWithContent + }), + file.fileNames.map((p) => path.join(options.cwd, p)) as [string, ...Array] + ).pipe( + Effect.catchAll(() => { + missingFiles.push(file) + return Effect.logDebug(`${logSymbols.error} ${file.name} not found`).pipe(() => Effect.succeed(null)) + }) + ), + { concurrency: "unbounded" } + ).pipe(Effect.map((files) => files.filter((file): file is FileWithContent => file !== null))) + + yield* Effect.forEach(filesWithContent, (file) => + Effect.gen(function* () { + const { content, includes, name } = file + for (const include of includes) { + if (include.content.every((str) => content.includes(str))) { + yield* Effect.logDebug(`${logSymbols.success} ${name} has ${include.content.join(", ")}`) + continue + } + yield* Effect.logDebug(`${logSymbols.error} ${name} missing ${include.content.join(", ")}`) + missingIncludes.push({ ...include, fileName: name }) + } + }) + ) + + return { missingFiles, missingIncludes } + }) + + const checkDeprecatedFiles = ( + deprecatedFromLib: Array>, + deprecatedFromUi: Array> + ) => + Effect.gen(function* () { + const componentJson = yield* projectConfig.getComponentJson() + const aliasForLib = componentJson.aliases.lib ?? `${componentJson.aliases.utils}/lib` + + const existingDeprecatedFromLibs = yield* Effect.forEach( + deprecatedFromLib, + (file) => + projectConfig.resolvePathFromAlias(`${aliasForLib}/${file.fileNames[0]}`).pipe( + Effect.flatMap((fullPath) => + Effect.gen(function* () { + const exists = yield* fs.exists(fullPath) + if (!exists) { + yield* Effect.logDebug( + `${logSymbols.success} Deprecated ${aliasForLib}/${file.fileNames[0]} not found` + ) + return { ...file, hasIncludes: false } + } + + yield* Effect.logDebug(`${logSymbols.error} Deprecated ${aliasForLib}/${file.fileNames[0]} found`) + + const fileContent = yield* fs.readFileString(fullPath) + + return { + ...file, + hasIncludes: file.includes.some((include) => + include.content.some((content) => fileContent.includes(content)) + ) + } + }) + ) + ), + { concurrency: "unbounded" } + ).pipe( + Effect.map((results) => + results.filter((result) => result.hasIncludes).map(({ hasIncludes: _hasIncludes, ...result }) => result) + ) + ) + + const aliasForUi = componentJson.aliases.ui ?? `${componentJson.aliases.components}/ui` + + const existingDeprecatedFromUi = yield* Effect.forEach( + deprecatedFromUi, + (file) => + projectConfig.resolvePathFromAlias(`${aliasForUi}/${file.fileNames[0]}`).pipe( + Effect.flatMap((fullPath) => + Effect.gen(function* () { + const exists = yield* fs.exists(fullPath) + if (!exists) { + yield* Effect.logDebug( + `${logSymbols.success} Deprecated ${aliasForUi}/${file.fileNames[0]} not found` + ) + return { ...file, hasIncludes: false } + } + + yield* Effect.logDebug(`${logSymbols.error} Deprecated ${aliasForUi}/${file.fileNames[0]} found`) + + const fileContent = yield* fs.readFileString(fullPath) + + return { + ...file, + hasIncludes: file.includes.some((include) => + include.content.some((content) => fileContent.includes(content)) + ) + } + }) + ) + ), + { concurrency: "unbounded" } + ).pipe( + Effect.map((results) => + results.filter((result) => result.hasIncludes).map(({ hasIncludes: _hasIncludes, ...result }) => result) + ) + ) + + return [...existingDeprecatedFromLibs, ...existingDeprecatedFromUi] + }) + + const checkCustomFiles = (customFileChecks: Record) => + Effect.gen(function* () { + const componentJson = yield* projectConfig.getComponentJson() + const aliasForLib = componentJson.aliases.lib ?? `${componentJson.aliases.utils}/lib` + const missingFiles: Array = [] + const missingIncludes: Array = [] + + // Check CSS files + const cssPaths = [componentJson.tailwind.css, "global.css", "src/global.css"].filter((p) => p != null) + const cssContent = yield* retryWith( + (filePath: string) => + Effect.gen(function* () { + const content = yield* fs.readFileString(filePath) + yield* Effect.logDebug(`${logSymbols.success} ${customFileChecks.css.name} found`) + return content + }), + cssPaths.map((p) => path.join(options.cwd, p)) as [string, ...Array] + ).pipe( + Effect.catchAll(() => + Effect.fail( + new RequiredFileError({ + file: "CSS", + message: + "CSS file not found. Please follow the instructions at https://www.nativewind.dev/docs/getting-started/installation#installation-with-expo" + }) + ) + ) + ) + + for (const include of customFileChecks.css.includes) { + if (include.content.every((str) => cssContent.includes(str))) { + yield* Effect.logDebug( + `${logSymbols.success} ${customFileChecks.css.name} has ${include.content.join(", ")}` + ) + continue + } + yield* Effect.logDebug( + `${logSymbols.error} ${customFileChecks.css.name} missing ${include.content.join(", ")}` + ) + missingIncludes.push({ ...include, fileName: customFileChecks.css.name }) + } + + // Check NativeWind env file + if (componentJson.tsx !== false) { + const nativewindEnvContent = yield* fs + .readFileString(path.join(options.cwd, PROJECT_MANIFEST.nativewindEnvFile)) + .pipe( + Effect.catchAll(() => { + missingFiles.push(customFileChecks.nativewindEnv) + return Effect.succeed(null) + }) + ) + + if (nativewindEnvContent) { + for (const include of customFileChecks.nativewindEnv.includes) { + if (include.content.every((str) => nativewindEnvContent.includes(str))) { + yield* Effect.logDebug( + `${logSymbols.success} ${customFileChecks.nativewindEnv.name} has ${include.content.join(", ")}` + ) + continue + } + yield* Effect.logDebug( + `${logSymbols.error} ${customFileChecks.nativewindEnv.name} missing ${include.content.join(", ")}` + ) + missingIncludes.push({ ...include, fileName: customFileChecks.nativewindEnv.name }) + } + } else { + yield* Effect.logDebug(`${logSymbols.error} ${customFileChecks.nativewindEnv.name} not found`) + } + } + + // Check Tailwind config + const tailwindConfigPaths = [componentJson.tailwind.config, "tailwind.config.js", "tailwind.config.ts"].filter( + (p) => p != null + ) + const tailwindConfigContent = yield* retryWith( + (filePath: string) => + Effect.gen(function* () { + const content = yield* fs.readFileString(filePath) + yield* Effect.logDebug(`${logSymbols.success} ${customFileChecks.tailwindConfig.name} found`) + return content + }), + tailwindConfigPaths.map((p) => path.join(options.cwd, p)) as [string, ...Array] + ).pipe( + Effect.catchAll(() => + Effect.fail( + new RequiredFileError({ + file: "Tailwind config", + message: + "Tailwind config not found, Please follow the instructions at https://www.nativewind.dev/docs/getting-started/installation#installation-with-expo" + }) + ) + ) + ) + + for (const include of customFileChecks.tailwindConfig.includes) { + if (include.content.every((str) => tailwindConfigContent.includes(str))) { + yield* Effect.logDebug( + `${logSymbols.success} ${customFileChecks.tailwindConfig.name} has ${include.content.join(", ")}` + ) + continue + } + yield* Effect.logDebug( + `${logSymbols.error} ${customFileChecks.tailwindConfig.name} missing ${include.content.join(", ")}` + ) + missingIncludes.push({ ...include, fileName: customFileChecks.tailwindConfig.name }) + } + + // Check theme file + const themeAliasPath = yield* projectConfig.resolvePathFromAlias(`${aliasForLib}/theme.ts`) + const themeContent = yield* fs.readFileString(themeAliasPath).pipe( + Effect.catchAll(() => { + missingFiles.push(customFileChecks.theme) + return Effect.succeed(null) + }) + ) + + if (themeContent) { + for (const include of customFileChecks.theme.includes) { + if (include.content.every((str) => themeContent.includes(str))) { + yield* Effect.logDebug( + `${logSymbols.success} ${customFileChecks.theme.name} has ${include.content.join(", ")}` + ) + continue + } + yield* Effect.logDebug( + `${logSymbols.error} ${customFileChecks.theme.name} missing ${include.content.join(", ")}` + ) + missingIncludes.push({ ...include, fileName: customFileChecks.theme.name }) + } + } else { + yield* Effect.logDebug(`${logSymbols.error} ${customFileChecks.theme.name} not found`) + } + + // Check utils file + const utilsPath = yield* projectConfig.resolvePathFromAlias(`${aliasForLib}/utils.ts`) + const utilsContent = yield* fs.readFileString(utilsPath).pipe( + Effect.catchAll(() => { + missingFiles.push(customFileChecks.utils) + return Effect.succeed(null) + }) + ) + + if (utilsContent) { + for (const include of customFileChecks.utils.includes) { + if (include.content.every((str) => utilsContent.includes(str))) { + yield* Effect.logDebug( + `${logSymbols.success} ${customFileChecks.utils.name} has ${include.content.join(", ")}` + ) + continue + } + yield* Effect.logDebug( + `${logSymbols.error} ${customFileChecks.utils.name} missing ${include.content.join(", ")}` + ) + missingIncludes.push({ ...include, fileName: customFileChecks.utils.name }) + } + } else { + yield* Effect.logDebug(`${logSymbols.error} ${customFileChecks.utils.name} not found`) + } + + return { missingFiles, missingIncludes } + }) + + return { + run: ({ + customFileChecks, + deprecatedFromLib, + deprecatedFromUi, + fileChecks + }: { + fileChecks: Array + customFileChecks: Record + deprecatedFromLib: Array> + deprecatedFromUi: Array> + }) => + Effect.gen(function* () { + const [fileResults, customFileResults, deprecatedFileResults] = yield* Effect.all([ + checkFiles(fileChecks), + checkCustomFiles(customFileChecks), + checkDeprecatedFiles(deprecatedFromLib, deprecatedFromUi) + ]) + + return { fileResults, customFileResults, deprecatedFileResults } + }) + } + }) +}) {} + +export { RequiredFilesChecker } diff --git a/apps/cli/src/services/spinner.ts b/apps/cli/src/services/spinner.ts new file mode 100644 index 00000000..84e52270 --- /dev/null +++ b/apps/cli/src/services/spinner.ts @@ -0,0 +1,15 @@ +import { Effect } from "effect" +import ora from "ora" + +class Spinner extends Effect.Service()("Spinner", { + effect: Effect.gen(function* () { + const spinner = yield* Effect.try({ + try: () => ora(), + catch: () => new Error("Failed to create spinner") + }) + + return spinner + }) +}) {} + +export { Spinner } diff --git a/apps/cli/src/services/template.ts b/apps/cli/src/services/template.ts new file mode 100644 index 00000000..f9bf3569 --- /dev/null +++ b/apps/cli/src/services/template.ts @@ -0,0 +1,222 @@ +import { runCommand } from "@cli/utils/run-command.js" +import { Prompt } from "@effect/cli" +import { FileSystem, Path } from "@effect/platform" +import { Effect } from "effect" +import { Spinner } from "@cli/services/spinner.js" +import logSymbols from "log-symbols" + +class Template extends Effect.Service