fix(cli): remix detection (#4972)

# What

Some remix templates doesn't package a `vite.config.*` file at their root.
It's the case for the recommended starter "stack" templates: blues-stack, indie-stack and grunge-stack.
As recommended in a TODO comment, it's more suitable to check for a `@remix-run/*` dependency in the package dependencies.

# How

- decouple vite and remix checks
- retrieve the `package.json`
- allow passing a `cwd` to the retrieval method
- remove the "empty config file list" that can be empty for a remix stack
- check that the `package.json` contains a `@remix-run/*` dependency

# Test

Added a fixture by running `npx create-remix@latest --template remix-run/indie-stack` in the [frameworks](/Fluf22/shadcn-ui/tree/fix/cli-remix-detection/packages/cli/test/fixtures/frameworks) folder and named it `remix-indie-stack`, if ever we want another stack as a fixture later

---

Fixes shadcn-ui/ui#4967
This commit is contained in:
Thomas Raffray
2024-09-27 19:58:46 +02:00
committed by GitHub
parent 5fc9ade413
commit 4b546bfb13
74 changed files with 3113 additions and 14 deletions

View File

@@ -0,0 +1,5 @@
---
"shadcn": patch
---
update remix detection

7
.gitignore vendored
View File

@@ -33,4 +33,9 @@ yarn-error.log*
.turbo
.contentlayer
tsconfig.tsbuildinfo
tsconfig.tsbuildinfo
# ide
.idea
.fleet
.vscode

View File

@@ -2,8 +2,13 @@ import path from "path"
import fs from "fs-extra"
import { type PackageJson } from "type-fest"
export function getPackageInfo() {
const packageJsonPath = path.join("package.json")
export function getPackageInfo(
cwd: string = "",
shouldThrow: boolean = true
): PackageJson | null {
const packageJsonPath = path.join(cwd, "package.json")
return fs.readJSONSync(packageJsonPath) as PackageJson
return fs.readJSONSync(packageJsonPath, {
throws: shouldThrow,
}) as PackageJson
}

View File

@@ -6,6 +6,7 @@ import {
getConfig,
resolveConfigPaths,
} from "@/src/utils/get-config"
import { getPackageInfo } from "@/src/utils/get-package-info"
import fg from "fast-glob"
import fs from "fs-extra"
import { loadConfig } from "tsconfig-paths"
@@ -36,6 +37,7 @@ export async function getProjectInfo(cwd: string): Promise<ProjectInfo | null> {
tailwindConfigFile,
tailwindCssFile,
aliasPrefix,
packageJson,
] = await Promise.all([
fg.glob("**/{next,vite,astro}.config.*|gatsby-config.*|composer.json", {
cwd,
@@ -47,6 +49,7 @@ export async function getProjectInfo(cwd: string): Promise<ProjectInfo | null> {
getTailwindConfigFile(cwd),
getTailwindCssFile(cwd),
getTsConfigAliasPrefix(cwd),
getPackageInfo(cwd, false),
])
const isUsingAppDir = await fs.pathExists(
@@ -63,10 +66,6 @@ export async function getProjectInfo(cwd: string): Promise<ProjectInfo | null> {
aliasPrefix,
}
if (!configFiles.length) {
return type
}
// Next.js.
if (configFiles.find((file) => file.startsWith("next.config."))?.length) {
type.framework = isUsingAppDir
@@ -94,13 +93,21 @@ export async function getProjectInfo(cwd: string): Promise<ProjectInfo | null> {
return type
}
// Vite and Remix.
// They both have a vite.config.* file.
// Remix.
if (
Object.keys(packageJson?.dependencies ?? {}).find((dep) =>
dep.startsWith("@remix-run/")
)
) {
type.framework = FRAMEWORKS["remix"]
return type
}
// Vite.
// Some Remix templates also have a vite.config.* file.
// We'll assume that it got caught by the Remix check above.
if (configFiles.find((file) => file.startsWith("vite.config."))?.length) {
// We'll assume that if the project has an app dir, it's a Remix project.
// Otherwise, it's a Vite project.
// TODO: Maybe check for `@remix-run/react` in package.json?
type.framework = isUsingAppDir ? FRAMEWORKS["remix"] : FRAMEWORKS["vite"]
type.framework = FRAMEWORKS["vite"]
return type
}

View File

@@ -0,0 +1,7 @@
/node_modules
*.log
.DS_Store
.env
/.cache
/public/build
/build

View File

@@ -0,0 +1,2 @@
DATABASE_URL="file:./data.db?connection_limit=1"
SESSION_SECRET="super-duper-s3cret"

View File

@@ -0,0 +1,136 @@
/**
* This is intended to be a basic starting point for linting in the Indie Stack.
* It relies on recommended configs out of the box for simplicity, but you can
* and should modify this configuration to best suit your team's needs.
*/
/** @type {import('eslint').Linter.Config} */
module.exports = {
root: true,
parserOptions: {
ecmaVersion: "latest",
sourceType: "module",
ecmaFeatures: {
jsx: true,
},
},
env: {
browser: true,
commonjs: true,
es6: true,
},
// Base config
extends: ["eslint:recommended"],
overrides: [
// React
{
files: ["**/*.{js,jsx,ts,tsx}"],
plugins: ["react", "jsx-a11y"],
extends: [
"plugin:react/recommended",
"plugin:react/jsx-runtime",
"plugin:react-hooks/recommended",
"plugin:jsx-a11y/recommended",
"prettier",
],
settings: {
react: {
version: "detect",
},
formComponents: ["Form"],
linkComponents: [
{ name: "Link", linkAttribute: "to" },
{ name: "NavLink", linkAttribute: "to" },
],
},
rules: {
"react/jsx-no-leaked-render": [
"warn",
{ validStrategies: ["ternary"] },
],
},
},
// Typescript
{
files: ["**/*.{ts,tsx}"],
plugins: ["@typescript-eslint", "import"],
parser: "@typescript-eslint/parser",
settings: {
"import/internal-regex": "^~/",
"import/resolver": {
node: {
extensions: [".ts", ".tsx"],
},
typescript: {
alwaysTryTypes: true,
},
},
},
extends: [
"plugin:@typescript-eslint/recommended",
"plugin:@typescript-eslint/stylistic",
"plugin:import/recommended",
"plugin:import/typescript",
"prettier",
],
rules: {
"import/order": [
"error",
{
alphabetize: { caseInsensitive: true, order: "asc" },
groups: ["builtin", "external", "internal", "parent", "sibling"],
"newlines-between": "always",
},
],
},
},
// Markdown
{
files: ["**/*.md"],
plugins: ["markdown"],
extends: ["plugin:markdown/recommended-legacy", "prettier"],
},
// Jest/Vitest
{
files: ["**/*.test.{js,jsx,ts,tsx}"],
plugins: ["jest", "jest-dom", "testing-library"],
extends: [
"plugin:jest/recommended",
"plugin:jest-dom/recommended",
"plugin:testing-library/react",
"prettier",
],
env: {
"jest/globals": true,
},
settings: {
jest: {
// we're using vitest which has a very similar API to jest
// (so the linting plugins work nicely), but it means we have to explicitly
// set the jest version.
version: 28,
},
},
},
// Cypress
{
files: ["cypress/**/*.ts"],
plugins: ["cypress"],
extends: ["plugin:cypress/recommended", "prettier"],
},
// Node
{
files: [".eslintrc.js", "mocks/**/*.js"],
env: {
node: true,
},
},
],
};

View File

@@ -0,0 +1,41 @@
name: 🐛 Bug Report
description: Something is wrong with the Stack.
body:
- type: markdown
attributes:
value: >-
Thank you for helping to improve Remix!
Our bandwidth on maintaining these stacks is limited. As a team, we're
currently focusing our efforts on Remix itself. The good news is you can
fork and adjust this stack however you'd like and start using it today
as a custom stack. Learn more from
[the Remix Stacks docs](https://remix.run/stacks).
If you'd still like to report a bug, please fill out this form. We can't
promise a timely response, but hopefully when we have the bandwidth to
work on these stacks again we can take a look. Thanks!
- type: input
attributes:
label: Have you experienced this bug with the latest version of the template?
validations:
required: true
- type: textarea
attributes:
label: Steps to Reproduce
description: Steps to reproduce the behavior.
validations:
required: true
- type: textarea
attributes:
label: Expected Behavior
description: A concise description of what you expected to happen.
validations:
required: true
- type: textarea
attributes:
label: Actual Behavior
description: A concise description of what you're experiencing.
validations:
required: true

View File

@@ -0,0 +1,21 @@
blank_issues_enabled: false
contact_links:
- name: Get Help
url: https://github.com/remix-run/remix/discussions/new?category=q-a
about:
If you can't get something to work the way you expect, open a question in
the Remix discussions.
- name: Feature Request
url: https://github.com/remix-run/remix/discussions/new?category=ideas
about:
We appreciate you taking the time to improve Remix with your ideas, but we
use the Remix Discussions for this instead of the issues tab 🙂.
- name: 💬 Remix Discord Channel
url: https://rmx.as/discord
about: Interact with other people using Remix 💿
- name: 💬 New Updates (Twitter)
url: https://twitter.com/remix_run
about: Stay up to date with Remix news on twitter
- name: 🍿 Remix YouTube Channel
url: https://rmx.as/youtube
about: Are you a tech lead or wanting to learn more about Remix in depth? Checkout the Remix YouTube Channel

View File

@@ -0,0 +1,14 @@
<!--
👋 Hey, thanks for your interest in contributing to Remix!
Our bandwidth on maintaining these stacks is limited. As a team, we're currently
focusing our efforts on Remix itself. The good news is you can fork and adjust
this stack however you'd like and start using it today as a custom stack. Learn
more from [the Remix Stacks docs](https://remix.run/stacks).
You're still welcome to make a PR. We can't promise a timely response, but
hopefully when we have the bandwidth to work on these stacks again we can take
a look. Thanks!
-->

View File

@@ -0,0 +1,6 @@
version: 2
updates:
- package-ecosystem: github-actions
directory: /
schedule:
interval: daily

View File

@@ -0,0 +1,144 @@
name: 🚀 Deploy
on:
push:
branches:
- main
- dev
pull_request:
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
permissions:
actions: write
contents: read
jobs:
lint:
name: ⬣ ESLint
runs-on: ubuntu-latest
steps:
- name: ⬇️ Checkout repo
uses: actions/checkout@v4
- name: ⎔ Setup node
uses: actions/setup-node@v4
with:
cache: npm
cache-dependency-path: ./package.json
node-version: 18
- name: 📥 Install deps
run: npm install
- name: 🔬 Lint
run: npm run lint
typecheck:
name: ʦ TypeScript
runs-on: ubuntu-latest
steps:
- name: ⬇️ Checkout repo
uses: actions/checkout@v4
- name: ⎔ Setup node
uses: actions/setup-node@v4
with:
cache: npm
cache-dependency-path: ./package.json
node-version: 18
- name: 📥 Install deps
run: npm install
- name: 🔎 Type check
run: npm run typecheck --if-present
vitest:
name: ⚡ Vitest
runs-on: ubuntu-latest
steps:
- name: ⬇️ Checkout repo
uses: actions/checkout@v4
- name: ⎔ Setup node
uses: actions/setup-node@v4
with:
cache: npm
cache-dependency-path: ./package.json
node-version: 18
- name: 📥 Install deps
run: npm install
- name: ⚡ Run vitest
run: npm run test -- --coverage
cypress:
name: ⚫️ Cypress
runs-on: ubuntu-latest
steps:
- name: ⬇️ Checkout repo
uses: actions/checkout@v4
- name: 🏄 Copy test env vars
run: cp .env.example .env
- name: ⎔ Setup node
uses: actions/setup-node@v4
with:
cache: npm
cache-dependency-path: ./package.json
node-version: 18
- name: 📥 Install deps
run: npm install
- name: 🛠 Setup Database
run: npx prisma migrate reset --force
- name: ⚙️ Build
run: npm run build
- name: 🌳 Cypress run
uses: cypress-io/github-action@v6
with:
start: npm run start:mocks
wait-on: http://localhost:8811
env:
PORT: 8811
deploy:
name: 🚀 Deploy
runs-on: ubuntu-latest
needs: [lint, typecheck, vitest, cypress]
# only deploy main/dev branch on pushes
if: ${{ (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/dev') && github.event_name == 'push' }}
steps:
- name: ⬇️ Checkout repo
uses: actions/checkout@v4
- name: 👀 Read app name
uses: SebRollen/toml-action@v1.2.0
id: app_name
with:
file: fly.toml
field: app
- name: 🎈 Setup Fly
uses: superfly/flyctl-actions/setup-flyctl@v1
- name: 🚀 Deploy Staging
if: ${{ github.ref == 'refs/heads/dev' }}
run: flyctl deploy --remote-only --build-arg COMMIT_SHA=${{ github.sha }} --app ${{ steps.app_name.outputs.value }}-staging
env:
FLY_API_TOKEN: ${{ secrets.FLY_API_TOKEN }}
- name: 🚀 Deploy Production
if: ${{ github.ref == 'refs/heads/main' }}
run: flyctl deploy --remote-only --build-arg COMMIT_SHA=${{ github.sha }} --app ${{ steps.app_name.outputs.value }}
env:
FLY_API_TOKEN: ${{ secrets.FLY_API_TOKEN }}

View File

@@ -0,0 +1,46 @@
name: 👔 Format
on:
push:
branches:
- main
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
format:
if: github.repository == 'remix-run/indie-stack'
runs-on: ubuntu-latest
steps:
- name: ⬇️ Checkout repo
uses: actions/checkout@v4
- name: ⎔ Setup node
uses: actions/setup-node@v4
with:
cache: npm
cache-dependency-path: ./package.json
node-version: 18
- name: 📥 Install deps
run: npm install
- name: 👔 Format
run: npm run format:repo
- name: 💪 Commit
run: |
git config --local user.email "github-actions[bot]@users.noreply.github.com"
git config --local user.name "github-actions[bot]"
git add .
if [ -z "$(git status --porcelain)" ]; then
echo "💿 no formatting changed"
exit 0
fi
git commit -m "chore: format"
git push
echo "💿 pushed formatting changes https://github.com/$GITHUB_REPOSITORY/commit/$(git rev-parse HEAD)"

View File

@@ -0,0 +1,33 @@
name: ⬣ Lint repository
on:
push:
branches:
- main
- dev
pull_request:
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
lint:
name: ⬣ Lint repo
runs-on: ubuntu-latest
steps:
- name: ⬇️ Checkout repo
uses: actions/checkout@v4
- name: ⎔ Setup node
uses: actions/setup-node@v4
with:
cache: npm
cache-dependency-path: ./package.json
node-version: 18
- name: 📥 Install deps
run: npm install
- name: 🔬 Lint
run: npm run lint

View File

@@ -0,0 +1,34 @@
name: 🥺 No Response
on:
schedule:
# Schedule for five minutes after the hour, every hour
- cron: "5 * * * *"
permissions:
issues: write
pull-requests: write
jobs:
stale:
if: github.repository == 'remix-run/indie-stack'
runs-on: ubuntu-latest
steps:
- name: 🥺 Handle Ghosting
uses: actions/stale@v9
with:
days-before-close: 10
close-issue-message: >
This issue has been automatically closed because we haven't received a
response from the original author 🙈. This automation helps keep the issue
tracker clean from issues that are unactionable. Please reach out if you
have more information for us! 🙂
close-pr-message: >
This PR has been automatically closed because we haven't received a
response from the original author 🙈. This automation helps keep the issue
tracker clean from PRs that are unactionable. Please reach out if you
have more information for us! 🙂
# don't automatically mark issues/PRs as stale
days-before-stale: -1
stale-issue-label: needs-response
stale-pr-label: needs-response

View File

@@ -0,0 +1,18 @@
# We don't want lockfiles in stacks, as people could use a different package manager
# This part will be removed by `remix.init`
bun.lockb
package-lock.json
pnpm-lock.yaml
pnpm-lock.yml
yarn.lock
node_modules
/build
/public/build
.env
/cypress/screenshots
/cypress/videos
/prisma/data.db
/prisma/data.db-journal

View File

@@ -0,0 +1,9 @@
FROM gitpod/workspace-full
# Install Fly
RUN curl -L https://fly.io/install.sh | sh
ENV FLYCTL_INSTALL="/home/gitpod/.fly"
ENV PATH="$FLYCTL_INSTALL/bin:$PATH"
# Install GitHub CLI
RUN brew install gh

View File

@@ -0,0 +1,48 @@
# https://www.gitpod.io/docs/config-gitpod-file
image:
file: .gitpod.Dockerfile
ports:
- port: 3000
onOpen: notify
tasks:
- name: Restore .env file
command: |
if [ -f .env ]; then
# If this workspace already has a .env, don't override it
# Local changes survive a workspace being opened and closed
# but they will not persist between separate workspaces for the same repo
echo "Found .env in workspace"
else
# There is no .env
if [ ! -n "${ENV}" ]; then
# There is no $ENV from a previous workspace
# Default to the example .env
echo "Setting example .env"
cp .env.example .env
else
# After making changes to .env, run this line to persist it to $ENV
# eval $(gp env -e ENV="$(base64 .env | tr -d '\n')")
#
# Environment variables set this way are shared between all your workspaces for this repo
# The lines below will read $ENV and print a .env file
echo "Restoring .env from Gitpod"
echo "${ENV}" | base64 -d | tee .env > /dev/null
fi
fi
- init: npm install
command: npm run setup && npm run dev
vscode:
extensions:
- ms-azuretools.vscode-docker
- esbenp.prettier-vscode
- dbaeumer.vscode-eslint
- bradlc.vscode-tailwindcss

View File

@@ -0,0 +1 @@
legacy-peer-deps=true

View File

@@ -0,0 +1,7 @@
node_modules
/build
/public/build
.env
/app/styles/tailwind.css

View File

@@ -0,0 +1,61 @@
# base node image
FROM node:18-bullseye-slim as base
# set for base and all layer that inherit from it
ENV NODE_ENV production
# Install openssl for Prisma
RUN apt-get update && apt-get install -y openssl sqlite3
# Install all node_modules, including dev dependencies
FROM base as deps
WORKDIR /myapp
ADD package.json .npmrc ./
RUN npm install --include=dev
# Setup production node_modules
FROM base as production-deps
WORKDIR /myapp
COPY --from=deps /myapp/node_modules /myapp/node_modules
ADD package.json .npmrc ./
RUN npm prune --omit=dev
# Build the app
FROM base as build
WORKDIR /myapp
COPY --from=deps /myapp/node_modules /myapp/node_modules
ADD prisma .
RUN npx prisma generate
ADD . .
RUN npm run build
# Finally, build the production image with minimal footprint
FROM base
ENV DATABASE_URL=file:/data/sqlite.db
ENV PORT="8080"
ENV NODE_ENV="production"
# add shortcut for connecting to database CLI
RUN echo "#!/bin/sh\nset -x\nsqlite3 \$DATABASE_URL" > /usr/local/bin/database-cli && chmod +x /usr/local/bin/database-cli
WORKDIR /myapp
COPY --from=production-deps /myapp/node_modules /myapp/node_modules
COPY --from=build /myapp/node_modules/.prisma /myapp/node_modules/.prisma
COPY --from=build /myapp/build /myapp/build
COPY --from=build /myapp/public /myapp/public
COPY --from=build /myapp/package.json /myapp/package.json
COPY --from=build /myapp/start.sh /myapp/start.sh
COPY --from=build /myapp/prisma /myapp/prisma
ENTRYPOINT [ "./start.sh" ]

View File

@@ -0,0 +1,22 @@
MIT License
Copyright (c) Remix Software Inc. 2021
Copyright (c) Shopify Inc. 2022-2023
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.

View File

@@ -0,0 +1,183 @@
# Remix Indie Stack
![The Remix Indie Stack](https://repository-images.githubusercontent.com/465928257/a241fa49-bd4d-485a-a2a5-5cb8e4ee0abf)
Learn more about [Remix Stacks](https://remix.run/stacks).
```sh
npx create-remix@latest --template remix-run/indie-stack
```
## What's in the stack
- [Fly app deployment](https://fly.io) with [Docker](https://www.docker.com/)
- Production-ready [SQLite Database](https://sqlite.org)
- Healthcheck endpoint for [Fly backups region fallbacks](https://fly.io/docs/reference/configuration/#services-http_checks)
- [GitHub Actions](https://github.com/features/actions) for deploy on merge to production and staging environments
- Email/Password Authentication with [cookie-based sessions](https://remix.run/utils/sessions#md-createcookiesessionstorage)
- Database ORM with [Prisma](https://prisma.io)
- Styling with [Tailwind](https://tailwindcss.com/)
- End-to-end testing with [Cypress](https://cypress.io)
- Local third party request mocking with [MSW](https://mswjs.io)
- Unit testing with [Vitest](https://vitest.dev) and [Testing Library](https://testing-library.com)
- Code formatting with [Prettier](https://prettier.io)
- Linting with [ESLint](https://eslint.org)
- Static Types with [TypeScript](https://typescriptlang.org)
Not a fan of bits of the stack? Fork it, change it, and use `npx create-remix --template your/repo`! Make it your own.
## Quickstart
Click this button to create a [Gitpod](https://gitpod.io) workspace with the project set up and Fly pre-installed
[![Gitpod Ready-to-Code](https://img.shields.io/badge/Gitpod-Ready--to--Code-blue?logo=gitpod)](https://gitpod.io/#https://github.com/remix-run/indie-stack/tree/main)
## Development
- First run this stack's `remix.init` script and commit the changes it makes to your project.
```sh
npx remix init
git init # if you haven't already
git add .
git commit -m "Initialize project"
```
- Initial setup:
```sh
npm run setup
```
- Start dev server:
```sh
npm run dev
```
This starts your app in development mode, rebuilding assets on file changes.
The database seed script creates a new user with some data you can use to get started:
- Email: `rachel@remix.run`
- Password: `racheliscool`
### Relevant code:
This is a pretty simple note-taking app, but it's a good example of how you can build a full stack app with Prisma and Remix. The main functionality is creating users, logging in and out, and creating and deleting notes.
- creating users, and logging in and out [./app/models/user.server.ts](app/models/user.server.ts)
- user sessions, and verifying them [./app/session.server.ts](app/session.server.ts)
- creating, and deleting notes [./app/models/note.server.ts](app/models/note.server.ts)
## Deployment
This Remix Stack comes with two GitHub Actions that handle automatically deploying your app to production and staging environments.
Prior to your first deployment, you'll need to do a few things:
- [Install Fly](https://fly.io/docs/getting-started/installing-flyctl/)
- Sign up and log in to Fly
```sh
fly auth signup
```
> **Note:** If you have more than one Fly account, ensure that you are signed into the same account in the Fly CLI as you are in the browser. In your terminal, run `fly auth whoami` and ensure the email matches the Fly account signed into the browser.
- Create two apps on Fly, one for staging and one for production:
```sh
fly apps create indie-stack-template
fly apps create indie-stack-template-staging
```
> **Note:** Make sure this name matches the `app` set in your `fly.toml` file. Otherwise, you will not be able to deploy.
- Initialize Git.
```sh
git init
```
- Create a new [GitHub Repository](https://repo.new), and then add it as the remote for your project. **Do not push your app yet!**
```sh
git remote add origin <ORIGIN_URL>
```
- Add a `FLY_API_TOKEN` to your GitHub repo. To do this, go to your user settings on Fly and create a new [token](https://web.fly.io/user/personal_access_tokens/new), then add it to [your repo secrets](https://docs.github.com/en/actions/security-guides/encrypted-secrets) with the name `FLY_API_TOKEN`.
- Add a `SESSION_SECRET` to your fly app secrets, to do this you can run the following commands:
```sh
fly secrets set SESSION_SECRET=$(openssl rand -hex 32) --app indie-stack-template
fly secrets set SESSION_SECRET=$(openssl rand -hex 32) --app indie-stack-template-staging
```
If you don't have openssl installed, you can also use [1Password](https://1password.com/password-generator) to generate a random secret, just replace `$(openssl rand -hex 32)` with the generated secret.
- Create a persistent volume for the sqlite database for both your staging and production environments. Run the following:
```sh
fly volumes create data --size 1 --app indie-stack-template
fly volumes create data --size 1 --app indie-stack-template-staging
```
Now that everything is set up you can commit and push your changes to your repo. Every commit to your `main` branch will trigger a deployment to your production environment, and every commit to your `dev` branch will trigger a deployment to your staging environment.
### Connecting to your database
The sqlite database lives at `/data/sqlite.db` in your deployed application. You can connect to the live database by running `fly ssh console -C database-cli`.
### Getting Help with Deployment
If you run into any issues deploying to Fly, make sure you've followed all of the steps above and if you have, then post as many details about your deployment (including your app name) to [the Fly support community](https://community.fly.io). They're normally pretty responsive over there and hopefully can help resolve any of your deployment issues and questions.
## GitHub Actions
We use GitHub Actions for continuous integration and deployment. Anything that gets into the `main` branch will be deployed to production after running tests/build/etc. Anything in the `dev` branch will be deployed to staging.
## Testing
### Cypress
We use Cypress for our End-to-End tests in this project. You'll find those in the `cypress` directory. As you make changes, add to an existing file or create a new file in the `cypress/e2e` directory to test your changes.
We use [`@testing-library/cypress`](https://testing-library.com/cypress) for selecting elements on the page semantically.
To run these tests in development, run `npm run test:e2e:dev` which will start the dev server for the app as well as the Cypress client. Make sure the database is running in docker as described above.
We have a utility for testing authenticated features without having to go through the login flow:
```ts
cy.login();
// you are now logged in as a new user
```
We also have a utility to auto-delete the user at the end of your test. Just make sure to add this in each test file:
```ts
afterEach(() => {
cy.cleanupUser();
});
```
That way, we can keep your local db clean and keep your tests isolated from one another.
### Vitest
For lower level tests of utilities and individual components, we use `vitest`. We have DOM-specific assertion helpers via [`@testing-library/jest-dom`](https://testing-library.com/jest-dom).
### Type Checking
This project uses TypeScript. It's recommended to get TypeScript set up for your editor to get a really great in-editor experience with type checking and auto-complete. To run type checking across the whole project, run `npm run typecheck`.
### Linting
This project uses ESLint for linting. That is configured in `.eslintrc.js`.
### Formatting
We use [Prettier](https://prettier.io/) for auto-formatting in this project. It's recommended to install an editor plugin (like the [VSCode Prettier plugin](https://marketplace.visualstudio.com/items?itemName=esbenp.prettier-vscode)) to get auto-formatting on save. There's also a `npm run format` script you can run to format all files in the project.

View File

@@ -0,0 +1,9 @@
import { PrismaClient } from "@prisma/client";
import { singleton } from "./singleton.server";
// Hard-code a unique key, so we can look up the client when this module gets re-imported
const prisma = singleton("prisma", () => new PrismaClient());
prisma.$connect();
export { prisma };

View File

@@ -0,0 +1,18 @@
/**
* By default, Remix will handle hydrating your app on the client for you.
* You are free to delete this file if you'd like to, but if you ever want it revealed again, you can run `npx remix reveal` ✨
* For more information, see https://remix.run/docs/en/main/file-conventions/entry.client
*/
import { RemixBrowser } from "@remix-run/react";
import { startTransition, StrictMode } from "react";
import { hydrateRoot } from "react-dom/client";
startTransition(() => {
hydrateRoot(
document,
<StrictMode>
<RemixBrowser />
</StrictMode>,
);
});

View File

@@ -0,0 +1,120 @@
/**
* By default, Remix will handle generating the HTTP Response for you.
* You are free to delete this file if you'd like to, but if you ever want it revealed again, you can run `npx remix reveal` ✨
* For more information, see https://remix.run/docs/en/main/file-conventions/entry.server
*/
import { PassThrough } from "node:stream";
import type { EntryContext } from "@remix-run/node";
import { createReadableStreamFromReadable } from "@remix-run/node";
import { RemixServer } from "@remix-run/react";
import { isbot } from "isbot";
import { renderToPipeableStream } from "react-dom/server";
const ABORT_DELAY = 5_000;
export default function handleRequest(
request: Request,
responseStatusCode: number,
responseHeaders: Headers,
remixContext: EntryContext,
) {
return isbot(request.headers.get("user-agent"))
? handleBotRequest(
request,
responseStatusCode,
responseHeaders,
remixContext,
)
: handleBrowserRequest(
request,
responseStatusCode,
responseHeaders,
remixContext,
);
}
function handleBotRequest(
request: Request,
responseStatusCode: number,
responseHeaders: Headers,
remixContext: EntryContext,
) {
return new Promise((resolve, reject) => {
const { abort, pipe } = renderToPipeableStream(
<RemixServer
context={remixContext}
url={request.url}
abortDelay={ABORT_DELAY}
/>,
{
onAllReady() {
const body = new PassThrough();
responseHeaders.set("Content-Type", "text/html");
resolve(
new Response(createReadableStreamFromReadable(body), {
headers: responseHeaders,
status: responseStatusCode,
}),
);
pipe(body);
},
onShellError(error: unknown) {
reject(error);
},
onError(error: unknown) {
responseStatusCode = 500;
console.error(error);
},
},
);
setTimeout(abort, ABORT_DELAY);
});
}
function handleBrowserRequest(
request: Request,
responseStatusCode: number,
responseHeaders: Headers,
remixContext: EntryContext,
) {
return new Promise((resolve, reject) => {
const { abort, pipe } = renderToPipeableStream(
<RemixServer
context={remixContext}
url={request.url}
abortDelay={ABORT_DELAY}
/>,
{
onShellReady() {
const body = new PassThrough();
responseHeaders.set("Content-Type", "text/html");
resolve(
new Response(createReadableStreamFromReadable(body), {
headers: responseHeaders,
status: responseStatusCode,
}),
);
pipe(body);
},
onShellError(error: unknown) {
reject(error);
},
onError(error: unknown) {
console.error(error);
responseStatusCode = 500;
},
},
);
setTimeout(abort, ABORT_DELAY);
});
}

View File

@@ -0,0 +1,52 @@
import type { User, Note } from "@prisma/client";
import { prisma } from "~/db.server";
export function getNote({
id,
userId,
}: Pick<Note, "id"> & {
userId: User["id"];
}) {
return prisma.note.findFirst({
select: { id: true, body: true, title: true },
where: { id, userId },
});
}
export function getNoteListItems({ userId }: { userId: User["id"] }) {
return prisma.note.findMany({
where: { userId },
select: { id: true, title: true },
orderBy: { updatedAt: "desc" },
});
}
export function createNote({
body,
title,
userId,
}: Pick<Note, "body" | "title"> & {
userId: User["id"];
}) {
return prisma.note.create({
data: {
title,
body,
user: {
connect: {
id: userId,
},
},
},
});
}
export function deleteNote({
id,
userId,
}: Pick<Note, "id"> & { userId: User["id"] }) {
return prisma.note.deleteMany({
where: { id, userId },
});
}

View File

@@ -0,0 +1,63 @@
import type { Password, User } from "@prisma/client";
import bcrypt from "bcryptjs";
import { prisma } from "~/db.server";
export type { User } from "@prisma/client";
export async function getUserById(id: User["id"]) {
return prisma.user.findUnique({ where: { id } });
}
export async function getUserByEmail(email: User["email"]) {
return prisma.user.findUnique({ where: { email } });
}
export async function createUser(email: User["email"], password: string) {
const hashedPassword = await bcrypt.hash(password, 10);
return prisma.user.create({
data: {
email,
password: {
create: {
hash: hashedPassword,
},
},
},
});
}
export async function deleteUserByEmail(email: User["email"]) {
return prisma.user.delete({ where: { email } });
}
export async function verifyLogin(
email: User["email"],
password: Password["hash"],
) {
const userWithPassword = await prisma.user.findUnique({
where: { email },
include: {
password: true,
},
});
if (!userWithPassword || !userWithPassword.password) {
return null;
}
const isValid = await bcrypt.compare(
password,
userWithPassword.password.hash,
);
if (!isValid) {
return null;
}
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const { password: _password, ...userWithoutPassword } = userWithPassword;
return userWithoutPassword;
}

View File

@@ -0,0 +1,42 @@
import { cssBundleHref } from "@remix-run/css-bundle";
import type { LinksFunction, LoaderFunctionArgs } from "@remix-run/node";
import { json } from "@remix-run/node";
import {
Links,
LiveReload,
Meta,
Outlet,
Scripts,
ScrollRestoration,
} from "@remix-run/react";
import { getUser } from "~/session.server";
import stylesheet from "~/tailwind.css";
export const links: LinksFunction = () => [
{ rel: "stylesheet", href: stylesheet },
...(cssBundleHref ? [{ rel: "stylesheet", href: cssBundleHref }] : []),
];
export const loader = async ({ request }: LoaderFunctionArgs) => {
return json({ user: await getUser(request) });
};
export default function App() {
return (
<html lang="en" className="h-full">
<head>
<meta charSet="utf-8" />
<meta name="viewport" content="width=device-width,initial-scale=1" />
<Meta />
<Links />
</head>
<body className="h-full">
<Outlet />
<ScrollRestoration />
<Scripts />
<LiveReload />
</body>
</html>
);
}

View File

@@ -0,0 +1,141 @@
import type { MetaFunction } from "@remix-run/node";
import { Link } from "@remix-run/react";
import { useOptionalUser } from "~/utils";
export const meta: MetaFunction = () => [{ title: "Remix Notes" }];
export default function Index() {
const user = useOptionalUser();
return (
<main className="relative min-h-screen bg-white sm:flex sm:items-center sm:justify-center">
<div className="relative sm:pb-16 sm:pt-8">
<div className="mx-auto max-w-7xl sm:px-6 lg:px-8">
<div className="relative shadow-xl sm:overflow-hidden sm:rounded-2xl">
<div className="absolute inset-0">
<img
className="h-full w-full object-cover"
src="https://user-images.githubusercontent.com/1500684/157774694-99820c51-8165-4908-a031-34fc371ac0d6.jpg"
alt="Sonic Youth On Stage"
/>
<div className="absolute inset-0 bg-[color:rgba(254,204,27,0.5)] mix-blend-multiply" />
</div>
<div className="relative px-4 pb-8 pt-16 sm:px-6 sm:pb-14 sm:pt-24 lg:px-8 lg:pb-20 lg:pt-32">
<h1 className="text-center text-6xl font-extrabold tracking-tight sm:text-8xl lg:text-9xl">
<span className="block uppercase text-yellow-500 drop-shadow-md">
Indie Stack
</span>
</h1>
<p className="mx-auto mt-6 max-w-lg text-center text-xl text-white sm:max-w-3xl">
Check the README.md file for instructions on how to get this
project deployed.
</p>
<div className="mx-auto mt-10 max-w-sm sm:flex sm:max-w-none sm:justify-center">
{user ? (
<Link
to="/notes"
className="flex items-center justify-center rounded-md border border-transparent bg-white px-4 py-3 text-base font-medium text-yellow-700 shadow-sm hover:bg-yellow-50 sm:px-8"
>
View Notes for {user.email}
</Link>
) : (
<div className="space-y-4 sm:mx-auto sm:inline-grid sm:grid-cols-2 sm:gap-5 sm:space-y-0">
<Link
to="/join"
className="flex items-center justify-center rounded-md border border-transparent bg-white px-4 py-3 text-base font-medium text-yellow-700 shadow-sm hover:bg-yellow-50 sm:px-8"
>
Sign up
</Link>
<Link
to="/login"
className="flex items-center justify-center rounded-md bg-yellow-500 px-4 py-3 font-medium text-white hover:bg-yellow-600"
>
Log In
</Link>
</div>
)}
</div>
<a href="https://remix.run">
<img
src="https://user-images.githubusercontent.com/1500684/158298926-e45dafff-3544-4b69-96d6-d3bcc33fc76a.svg"
alt="Remix"
className="mx-auto mt-16 w-full max-w-[12rem] md:max-w-[16rem]"
/>
</a>
</div>
</div>
</div>
<div className="mx-auto max-w-7xl px-4 py-2 sm:px-6 lg:px-8">
<div className="mt-6 flex flex-wrap justify-center gap-8">
{[
{
src: "https://user-images.githubusercontent.com/1500684/157764397-ccd8ea10-b8aa-4772-a99b-35de937319e1.svg",
alt: "Fly.io",
href: "https://fly.io",
},
{
src: "https://user-images.githubusercontent.com/1500684/157764395-137ec949-382c-43bd-a3c0-0cb8cb22e22d.svg",
alt: "SQLite",
href: "https://sqlite.org",
},
{
src: "https://user-images.githubusercontent.com/1500684/157764484-ad64a21a-d7fb-47e3-8669-ec046da20c1f.svg",
alt: "Prisma",
href: "https://prisma.io",
},
{
src: "https://user-images.githubusercontent.com/1500684/157764276-a516a239-e377-4a20-b44a-0ac7b65c8c14.svg",
alt: "Tailwind",
href: "https://tailwindcss.com",
},
{
src: "https://user-images.githubusercontent.com/1500684/157764454-48ac8c71-a2a9-4b5e-b19c-edef8b8953d6.svg",
alt: "Cypress",
href: "https://www.cypress.io",
},
{
src: "https://user-images.githubusercontent.com/1500684/157772386-75444196-0604-4340-af28-53b236faa182.svg",
alt: "MSW",
href: "https://mswjs.io",
},
{
src: "https://user-images.githubusercontent.com/1500684/157772447-00fccdce-9d12-46a3-8bb4-fac612cdc949.svg",
alt: "Vitest",
href: "https://vitest.dev",
},
{
src: "https://user-images.githubusercontent.com/1500684/157772662-92b0dd3a-453f-4d18-b8be-9fa6efde52cf.png",
alt: "Testing Library",
href: "https://testing-library.com",
},
{
src: "https://user-images.githubusercontent.com/1500684/157772934-ce0a943d-e9d0-40f8-97f3-f464c0811643.svg",
alt: "Prettier",
href: "https://prettier.io",
},
{
src: "https://user-images.githubusercontent.com/1500684/157772990-3968ff7c-b551-4c55-a25c-046a32709a8e.svg",
alt: "ESLint",
href: "https://eslint.org",
},
{
src: "https://user-images.githubusercontent.com/1500684/157773063-20a0ed64-b9f8-4e0b-9d1e-0b65a3d4a6db.svg",
alt: "TypeScript",
href: "https://typescriptlang.org",
},
].map((img) => (
<a
key={img.href}
href={img.href}
className="flex h-16 w-32 justify-center p-1 grayscale transition hover:grayscale-0 focus:grayscale-0"
>
<img alt={img.alt} src={img.src} className="object-contain" />
</a>
))}
</div>
</div>
</div>
</main>
);
}

View File

@@ -0,0 +1,25 @@
// learn more: https://fly.io/docs/reference/configuration/#services-http_checks
import type { LoaderFunctionArgs } from "@remix-run/node";
import { prisma } from "~/db.server";
export const loader = async ({ request }: LoaderFunctionArgs) => {
const host =
request.headers.get("X-Forwarded-Host") ?? request.headers.get("host");
try {
const url = new URL("/", `http://${host}`);
// if we can connect to the database and make a simple query
// and make a HEAD request to ourselves, then we're good.
await Promise.all([
prisma.user.count(),
fetch(url.toString(), { method: "HEAD" }).then((r) => {
if (!r.ok) return Promise.reject(r);
}),
]);
return new Response("OK");
} catch (error: unknown) {
console.log("healthcheck ❌", { error });
return new Response("ERROR", { status: 500 });
}
};

View File

@@ -0,0 +1,171 @@
import type {
ActionFunctionArgs,
LoaderFunctionArgs,
MetaFunction,
} from "@remix-run/node";
import { json, redirect } from "@remix-run/node";
import { Form, Link, useActionData, useSearchParams } from "@remix-run/react";
import { useEffect, useRef } from "react";
import { createUser, getUserByEmail } from "~/models/user.server";
import { createUserSession, getUserId } from "~/session.server";
import { safeRedirect, validateEmail } from "~/utils";
export const loader = async ({ request }: LoaderFunctionArgs) => {
const userId = await getUserId(request);
if (userId) return redirect("/");
return json({});
};
export const action = async ({ request }: ActionFunctionArgs) => {
const formData = await request.formData();
const email = formData.get("email");
const password = formData.get("password");
const redirectTo = safeRedirect(formData.get("redirectTo"), "/");
if (!validateEmail(email)) {
return json(
{ errors: { email: "Email is invalid", password: null } },
{ status: 400 },
);
}
if (typeof password !== "string" || password.length === 0) {
return json(
{ errors: { email: null, password: "Password is required" } },
{ status: 400 },
);
}
if (password.length < 8) {
return json(
{ errors: { email: null, password: "Password is too short" } },
{ status: 400 },
);
}
const existingUser = await getUserByEmail(email);
if (existingUser) {
return json(
{
errors: {
email: "A user already exists with this email",
password: null,
},
},
{ status: 400 },
);
}
const user = await createUser(email, password);
return createUserSession({
redirectTo,
remember: false,
request,
userId: user.id,
});
};
export const meta: MetaFunction = () => [{ title: "Sign Up" }];
export default function Join() {
const [searchParams] = useSearchParams();
const redirectTo = searchParams.get("redirectTo") ?? undefined;
const actionData = useActionData<typeof action>();
const emailRef = useRef<HTMLInputElement>(null);
const passwordRef = useRef<HTMLInputElement>(null);
useEffect(() => {
if (actionData?.errors?.email) {
emailRef.current?.focus();
} else if (actionData?.errors?.password) {
passwordRef.current?.focus();
}
}, [actionData]);
return (
<div className="flex min-h-full flex-col justify-center">
<div className="mx-auto w-full max-w-md px-8">
<Form method="post" className="space-y-6">
<div>
<label
htmlFor="email"
className="block text-sm font-medium text-gray-700"
>
Email address
</label>
<div className="mt-1">
<input
ref={emailRef}
id="email"
required
// eslint-disable-next-line jsx-a11y/no-autofocus
autoFocus={true}
name="email"
type="email"
autoComplete="email"
aria-invalid={actionData?.errors?.email ? true : undefined}
aria-describedby="email-error"
className="w-full rounded border border-gray-500 px-2 py-1 text-lg"
/>
{actionData?.errors?.email ? (
<div className="pt-1 text-red-700" id="email-error">
{actionData.errors.email}
</div>
) : null}
</div>
</div>
<div>
<label
htmlFor="password"
className="block text-sm font-medium text-gray-700"
>
Password
</label>
<div className="mt-1">
<input
id="password"
ref={passwordRef}
name="password"
type="password"
autoComplete="new-password"
aria-invalid={actionData?.errors?.password ? true : undefined}
aria-describedby="password-error"
className="w-full rounded border border-gray-500 px-2 py-1 text-lg"
/>
{actionData?.errors?.password ? (
<div className="pt-1 text-red-700" id="password-error">
{actionData.errors.password}
</div>
) : null}
</div>
</div>
<input type="hidden" name="redirectTo" value={redirectTo} />
<button
type="submit"
className="w-full rounded bg-blue-500 px-4 py-2 text-white hover:bg-blue-600 focus:bg-blue-400"
>
Create Account
</button>
<div className="flex items-center justify-center">
<div className="text-center text-sm text-gray-500">
Already have an account?{" "}
<Link
className="text-blue-500 underline"
to={{
pathname: "/login",
search: searchParams.toString(),
}}
>
Log in
</Link>
</div>
</div>
</Form>
</div>
</div>
);
}

View File

@@ -0,0 +1,180 @@
import type {
ActionFunctionArgs,
LoaderFunctionArgs,
MetaFunction,
} from "@remix-run/node";
import { json, redirect } from "@remix-run/node";
import { Form, Link, useActionData, useSearchParams } from "@remix-run/react";
import { useEffect, useRef } from "react";
import { verifyLogin } from "~/models/user.server";
import { createUserSession, getUserId } from "~/session.server";
import { safeRedirect, validateEmail } from "~/utils";
export const loader = async ({ request }: LoaderFunctionArgs) => {
const userId = await getUserId(request);
if (userId) return redirect("/");
return json({});
};
export const action = async ({ request }: ActionFunctionArgs) => {
const formData = await request.formData();
const email = formData.get("email");
const password = formData.get("password");
const redirectTo = safeRedirect(formData.get("redirectTo"), "/");
const remember = formData.get("remember");
if (!validateEmail(email)) {
return json(
{ errors: { email: "Email is invalid", password: null } },
{ status: 400 },
);
}
if (typeof password !== "string" || password.length === 0) {
return json(
{ errors: { email: null, password: "Password is required" } },
{ status: 400 },
);
}
if (password.length < 8) {
return json(
{ errors: { email: null, password: "Password is too short" } },
{ status: 400 },
);
}
const user = await verifyLogin(email, password);
if (!user) {
return json(
{ errors: { email: "Invalid email or password", password: null } },
{ status: 400 },
);
}
return createUserSession({
redirectTo,
remember: remember === "on" ? true : false,
request,
userId: user.id,
});
};
export const meta: MetaFunction = () => [{ title: "Login" }];
export default function LoginPage() {
const [searchParams] = useSearchParams();
const redirectTo = searchParams.get("redirectTo") || "/notes";
const actionData = useActionData<typeof action>();
const emailRef = useRef<HTMLInputElement>(null);
const passwordRef = useRef<HTMLInputElement>(null);
useEffect(() => {
if (actionData?.errors?.email) {
emailRef.current?.focus();
} else if (actionData?.errors?.password) {
passwordRef.current?.focus();
}
}, [actionData]);
return (
<div className="flex min-h-full flex-col justify-center">
<div className="mx-auto w-full max-w-md px-8">
<Form method="post" className="space-y-6">
<div>
<label
htmlFor="email"
className="block text-sm font-medium text-gray-700"
>
Email address
</label>
<div className="mt-1">
<input
ref={emailRef}
id="email"
required
// eslint-disable-next-line jsx-a11y/no-autofocus
autoFocus={true}
name="email"
type="email"
autoComplete="email"
aria-invalid={actionData?.errors?.email ? true : undefined}
aria-describedby="email-error"
className="w-full rounded border border-gray-500 px-2 py-1 text-lg"
/>
{actionData?.errors?.email ? (
<div className="pt-1 text-red-700" id="email-error">
{actionData.errors.email}
</div>
) : null}
</div>
</div>
<div>
<label
htmlFor="password"
className="block text-sm font-medium text-gray-700"
>
Password
</label>
<div className="mt-1">
<input
id="password"
ref={passwordRef}
name="password"
type="password"
autoComplete="current-password"
aria-invalid={actionData?.errors?.password ? true : undefined}
aria-describedby="password-error"
className="w-full rounded border border-gray-500 px-2 py-1 text-lg"
/>
{actionData?.errors?.password ? (
<div className="pt-1 text-red-700" id="password-error">
{actionData.errors.password}
</div>
) : null}
</div>
</div>
<input type="hidden" name="redirectTo" value={redirectTo} />
<button
type="submit"
className="w-full rounded bg-blue-500 px-4 py-2 text-white hover:bg-blue-600 focus:bg-blue-400"
>
Log in
</button>
<div className="flex items-center justify-between">
<div className="flex items-center">
<input
id="remember"
name="remember"
type="checkbox"
className="h-4 w-4 rounded border-gray-300 text-blue-600 focus:ring-blue-500"
/>
<label
htmlFor="remember"
className="ml-2 block text-sm text-gray-900"
>
Remember me
</label>
</div>
<div className="text-center text-sm text-gray-500">
Don&apos;t have an account?{" "}
<Link
className="text-blue-500 underline"
to={{
pathname: "/join",
search: searchParams.toString(),
}}
>
Sign up
</Link>
</div>
</div>
</Form>
</div>
</div>
);
}

View File

@@ -0,0 +1,9 @@
import type { ActionFunctionArgs } from "@remix-run/node";
import { redirect } from "@remix-run/node";
import { logout } from "~/session.server";
export const action = async ({ request }: ActionFunctionArgs) =>
logout(request);
export const loader = async () => redirect("/");

View File

@@ -0,0 +1,70 @@
import type { ActionFunctionArgs, LoaderFunctionArgs } from "@remix-run/node";
import { json, redirect } from "@remix-run/node";
import {
Form,
isRouteErrorResponse,
useLoaderData,
useRouteError,
} from "@remix-run/react";
import invariant from "tiny-invariant";
import { deleteNote, getNote } from "~/models/note.server";
import { requireUserId } from "~/session.server";
export const loader = async ({ params, request }: LoaderFunctionArgs) => {
const userId = await requireUserId(request);
invariant(params.noteId, "noteId not found");
const note = await getNote({ id: params.noteId, userId });
if (!note) {
throw new Response("Not Found", { status: 404 });
}
return json({ note });
};
export const action = async ({ params, request }: ActionFunctionArgs) => {
const userId = await requireUserId(request);
invariant(params.noteId, "noteId not found");
await deleteNote({ id: params.noteId, userId });
return redirect("/notes");
};
export default function NoteDetailsPage() {
const data = useLoaderData<typeof loader>();
return (
<div>
<h3 className="text-2xl font-bold">{data.note.title}</h3>
<p className="py-6">{data.note.body}</p>
<hr className="my-4" />
<Form method="post">
<button
type="submit"
className="rounded bg-blue-500 px-4 py-2 text-white hover:bg-blue-600 focus:bg-blue-400"
>
Delete
</button>
</Form>
</div>
);
}
export function ErrorBoundary() {
const error = useRouteError();
if (error instanceof Error) {
return <div>An unexpected error occurred: {error.message}</div>;
}
if (!isRouteErrorResponse(error)) {
return <h1>Unknown Error</h1>;
}
if (error.status === 404) {
return <div>Note not found</div>;
}
return <div>An unexpected error occurred: {error.statusText}</div>;
}

View File

@@ -0,0 +1,12 @@
import { Link } from "@remix-run/react";
export default function NoteIndexPage() {
return (
<p>
No note selected. Select a note on the left, or{" "}
<Link to="new" className="text-blue-500 underline">
create a new note.
</Link>
</p>
);
}

View File

@@ -0,0 +1,109 @@
import type { ActionFunctionArgs } from "@remix-run/node";
import { json, redirect } from "@remix-run/node";
import { Form, useActionData } from "@remix-run/react";
import { useEffect, useRef } from "react";
import { createNote } from "~/models/note.server";
import { requireUserId } from "~/session.server";
export const action = async ({ request }: ActionFunctionArgs) => {
const userId = await requireUserId(request);
const formData = await request.formData();
const title = formData.get("title");
const body = formData.get("body");
if (typeof title !== "string" || title.length === 0) {
return json(
{ errors: { body: null, title: "Title is required" } },
{ status: 400 },
);
}
if (typeof body !== "string" || body.length === 0) {
return json(
{ errors: { body: "Body is required", title: null } },
{ status: 400 },
);
}
const note = await createNote({ body, title, userId });
return redirect(`/notes/${note.id}`);
};
export default function NewNotePage() {
const actionData = useActionData<typeof action>();
const titleRef = useRef<HTMLInputElement>(null);
const bodyRef = useRef<HTMLTextAreaElement>(null);
useEffect(() => {
if (actionData?.errors?.title) {
titleRef.current?.focus();
} else if (actionData?.errors?.body) {
bodyRef.current?.focus();
}
}, [actionData]);
return (
<Form
method="post"
style={{
display: "flex",
flexDirection: "column",
gap: 8,
width: "100%",
}}
>
<div>
<label className="flex w-full flex-col gap-1">
<span>Title: </span>
<input
ref={titleRef}
name="title"
className="flex-1 rounded-md border-2 border-blue-500 px-3 text-lg leading-loose"
aria-invalid={actionData?.errors?.title ? true : undefined}
aria-errormessage={
actionData?.errors?.title ? "title-error" : undefined
}
/>
</label>
{actionData?.errors?.title ? (
<div className="pt-1 text-red-700" id="title-error">
{actionData.errors.title}
</div>
) : null}
</div>
<div>
<label className="flex w-full flex-col gap-1">
<span>Body: </span>
<textarea
ref={bodyRef}
name="body"
rows={8}
className="w-full flex-1 rounded-md border-2 border-blue-500 px-3 py-2 text-lg leading-6"
aria-invalid={actionData?.errors?.body ? true : undefined}
aria-errormessage={
actionData?.errors?.body ? "body-error" : undefined
}
/>
</label>
{actionData?.errors?.body ? (
<div className="pt-1 text-red-700" id="body-error">
{actionData.errors.body}
</div>
) : null}
</div>
<div className="text-right">
<button
type="submit"
className="rounded bg-blue-500 px-4 py-2 text-white hover:bg-blue-600 focus:bg-blue-400"
>
Save
</button>
</div>
</Form>
);
}

View File

@@ -0,0 +1,70 @@
import type { LoaderFunctionArgs } from "@remix-run/node";
import { json } from "@remix-run/node";
import { Form, Link, NavLink, Outlet, useLoaderData } from "@remix-run/react";
import { getNoteListItems } from "~/models/note.server";
import { requireUserId } from "~/session.server";
import { useUser } from "~/utils";
export const loader = async ({ request }: LoaderFunctionArgs) => {
const userId = await requireUserId(request);
const noteListItems = await getNoteListItems({ userId });
return json({ noteListItems });
};
export default function NotesPage() {
const data = useLoaderData<typeof loader>();
const user = useUser();
return (
<div className="flex h-full min-h-screen flex-col">
<header className="flex items-center justify-between bg-slate-800 p-4 text-white">
<h1 className="text-3xl font-bold">
<Link to="">Notes</Link>
</h1>
<p>{user.email}</p>
<Form action="/logout" method="post">
<button
type="submit"
className="rounded bg-slate-600 px-4 py-2 text-blue-100 hover:bg-blue-500 active:bg-blue-600"
>
Logout
</button>
</Form>
</header>
<main className="flex h-full bg-white">
<div className="h-full w-80 border-r bg-gray-50">
<Link to="new" className="block p-4 text-xl text-blue-500">
+ New Note
</Link>
<hr />
{data.noteListItems.length === 0 ? (
<p className="p-4">No notes yet</p>
) : (
<ol>
{data.noteListItems.map((note) => (
<li key={note.id}>
<NavLink
className={({ isActive }) =>
`block border-b p-4 text-xl ${isActive ? "bg-white" : ""}`
}
to={note.id}
>
📝 {note.title}
</NavLink>
</li>
))}
</ol>
)}
</div>
<div className="flex-1 p-6">
<Outlet />
</div>
</main>
</div>
);
}

View File

@@ -0,0 +1,97 @@
import { createCookieSessionStorage, redirect } from "@remix-run/node";
import invariant from "tiny-invariant";
import type { User } from "~/models/user.server";
import { getUserById } from "~/models/user.server";
invariant(process.env.SESSION_SECRET, "SESSION_SECRET must be set");
export const sessionStorage = createCookieSessionStorage({
cookie: {
name: "__session",
httpOnly: true,
path: "/",
sameSite: "lax",
secrets: [process.env.SESSION_SECRET],
secure: process.env.NODE_ENV === "production",
},
});
const USER_SESSION_KEY = "userId";
export async function getSession(request: Request) {
const cookie = request.headers.get("Cookie");
return sessionStorage.getSession(cookie);
}
export async function getUserId(
request: Request,
): Promise<User["id"] | undefined> {
const session = await getSession(request);
const userId = session.get(USER_SESSION_KEY);
return userId;
}
export async function getUser(request: Request) {
const userId = await getUserId(request);
if (userId === undefined) return null;
const user = await getUserById(userId);
if (user) return user;
throw await logout(request);
}
export async function requireUserId(
request: Request,
redirectTo: string = new URL(request.url).pathname,
) {
const userId = await getUserId(request);
if (!userId) {
const searchParams = new URLSearchParams([["redirectTo", redirectTo]]);
throw redirect(`/login?${searchParams}`);
}
return userId;
}
export async function requireUser(request: Request) {
const userId = await requireUserId(request);
const user = await getUserById(userId);
if (user) return user;
throw await logout(request);
}
export async function createUserSession({
request,
userId,
remember,
redirectTo,
}: {
request: Request;
userId: string;
remember: boolean;
redirectTo: string;
}) {
const session = await getSession(request);
session.set(USER_SESSION_KEY, userId);
return redirect(redirectTo, {
headers: {
"Set-Cookie": await sessionStorage.commitSession(session, {
maxAge: remember
? 60 * 60 * 24 * 7 // 7 days
: undefined,
}),
},
});
}
export async function logout(request: Request) {
const session = await getSession(request);
return redirect("/", {
headers: {
"Set-Cookie": await sessionStorage.destroySession(session),
},
});
}

View File

@@ -0,0 +1,13 @@
// Since the dev server re-requires the bundle, do some shenanigans to make
// certain things persist across that 😆
// Borrowed/modified from https://github.com/jenseng/abuse-the-platform/blob/2993a7e846c95ace693ce61626fa072174c8d9c7/app/utils/singleton.ts
export const singleton = <Value>(
name: string,
valueFactory: () => Value,
): Value => {
const g = global as unknown as { __singletons: Record<string, unknown> };
g.__singletons ??= {};
g.__singletons[name] ??= valueFactory();
return g.__singletons[name] as Value;
};

View File

@@ -0,0 +1,3 @@
@tailwind base;
@tailwind components;
@tailwind utilities;

View File

@@ -0,0 +1,13 @@
import { validateEmail } from "./utils";
test("validateEmail returns false for non-emails", () => {
expect(validateEmail(undefined)).toBe(false);
expect(validateEmail(null)).toBe(false);
expect(validateEmail("")).toBe(false);
expect(validateEmail("not-an-email")).toBe(false);
expect(validateEmail("n@")).toBe(false);
});
test("validateEmail returns true for emails", () => {
expect(validateEmail("kody@example.com")).toBe(true);
});

View File

@@ -0,0 +1,76 @@
import { useMatches } from "@remix-run/react";
import { useMemo } from "react";
import type { User } from "~/models/user.server";
const DEFAULT_REDIRECT = "/";
/**
* This should be used any time the redirect path is user-provided
* (Like the query string on our login/signup pages). This avoids
* open-redirect vulnerabilities.
* @param {string} to The redirect destination
* @param {string} defaultRedirect The redirect to use if the to is unsafe.
*/
export function safeRedirect(
to: FormDataEntryValue | string | null | undefined,
defaultRedirect: string = DEFAULT_REDIRECT,
) {
if (!to || typeof to !== "string") {
return defaultRedirect;
}
if (!to.startsWith("/") || to.startsWith("//")) {
return defaultRedirect;
}
return to;
}
/**
* This base hook is used in other hooks to quickly search for specific data
* across all loader data using useMatches.
* @param {string} id The route id
* @returns {JSON|undefined} The router data or undefined if not found
*/
export function useMatchesData(
id: string,
): Record<string, unknown> | undefined {
const matchingRoutes = useMatches();
const route = useMemo(
() => matchingRoutes.find((route) => route.id === id),
[matchingRoutes, id],
);
return route?.data as Record<string, unknown>;
}
function isUser(user: unknown): user is User {
return (
user != null &&
typeof user === "object" &&
"email" in user &&
typeof user.email === "string"
);
}
export function useOptionalUser(): User | undefined {
const data = useMatchesData("root");
if (!data || !isUser(data.user)) {
return undefined;
}
return data.user;
}
export function useUser(): User {
const maybeUser = useOptionalUser();
if (!maybeUser) {
throw new Error(
"No user found in root loader, but user is required by useUser. If user is optional, try useOptionalUser instead.",
);
}
return maybeUser;
}
export function validateEmail(email: unknown): email is string {
return typeof email === "string" && email.length > 3 && email.includes("@");
}

View File

@@ -0,0 +1,26 @@
import { defineConfig } from "cypress";
export default defineConfig({
e2e: {
setupNodeEvents: (on, config) => {
const isDev = config.watchForFileChanges;
const port = process.env.PORT ?? (isDev ? "3000" : "8811");
const configOverrides: Partial<Cypress.PluginConfigOptions> = {
baseUrl: `http://localhost:${port}`,
screenshotOnRunFailure: !process.env.CI,
};
// To use this:
// cy.task('log', whateverYouWantInTheTerminal)
on("task", {
log: (message) => {
console.log(message);
return null;
},
});
return { ...config, ...configOverrides };
},
},
});

View File

@@ -0,0 +1,6 @@
module.exports = {
parserOptions: {
tsconfigRootDir: __dirname,
project: "./tsconfig.json",
},
};

View File

@@ -0,0 +1,51 @@
import { faker } from "@faker-js/faker";
describe("smoke tests", () => {
afterEach(() => {
cy.cleanupUser();
});
it("should allow you to register and login", () => {
const loginForm = {
email: `${faker.internet.userName()}@example.com`,
password: faker.internet.password(),
};
cy.then(() => ({ email: loginForm.email })).as("user");
cy.visitAndCheck("/");
cy.findByRole("link", { name: /sign up/i }).click();
cy.findByRole("textbox", { name: /email/i }).type(loginForm.email);
cy.findByLabelText(/password/i).type(loginForm.password);
cy.findByRole("button", { name: /create account/i }).click();
cy.findByRole("link", { name: /notes/i }).click();
cy.findByRole("button", { name: /logout/i }).click();
cy.findByRole("link", { name: /log in/i });
});
it("should allow you to make a note", () => {
const testNote = {
title: faker.lorem.words(1),
body: faker.lorem.sentences(1),
};
cy.login();
cy.visitAndCheck("/");
cy.findByRole("link", { name: /notes/i }).click();
cy.findByText("No notes yet");
cy.findByRole("link", { name: /\+ new note/i }).click();
cy.findByRole("textbox", { name: /title/i }).type(testNote.title);
cy.findByRole("textbox", { name: /body/i }).type(testNote.body);
cy.findByRole("button", { name: /save/i }).click();
cy.findByRole("button", { name: /delete/i }).click();
cy.findByText("No notes yet");
});
});

View File

@@ -0,0 +1,5 @@
{
"name": "Using fixtures to represent data",
"email": "hello@cypress.io",
"body": "Fixtures are a great way to mock data for responses to routes"
}

View File

@@ -0,0 +1,98 @@
import { faker } from "@faker-js/faker";
declare global {
// eslint-disable-next-line @typescript-eslint/no-namespace
namespace Cypress {
interface Chainable {
/**
* Logs in with a random user. Yields the user and adds an alias to the user
*
* @returns {typeof login}
* @memberof Chainable
* @example
* cy.login()
* @example
* cy.login({ email: 'whatever@example.com' })
*/
login: typeof login;
/**
* Deletes the current @user
*
* @returns {typeof cleanupUser}
* @memberof Chainable
* @example
* cy.cleanupUser()
* @example
* cy.cleanupUser({ email: 'whatever@example.com' })
*/
cleanupUser: typeof cleanupUser;
/**
* Extends the standard visit command to wait for the page to load
*
* @returns {typeof visitAndCheck}
* @memberof Chainable
* @example
* cy.visitAndCheck('/')
* @example
* cy.visitAndCheck('/', 500)
*/
visitAndCheck: typeof visitAndCheck;
}
}
}
function login({
email = faker.internet.email({ provider: "example.com" }),
}: {
email?: string;
} = {}) {
cy.then(() => ({ email })).as("user");
cy.exec(
`npx ts-node -r tsconfig-paths/register ./cypress/support/create-user.ts "${email}"`,
).then(({ stdout }) => {
const cookieValue = stdout
.replace(/.*<cookie>(?<cookieValue>.*)<\/cookie>.*/s, "$<cookieValue>")
.trim();
cy.setCookie("__session", cookieValue);
});
return cy.get("@user");
}
function cleanupUser({ email }: { email?: string } = {}) {
if (email) {
deleteUserByEmail(email);
} else {
cy.get("@user").then((user) => {
const email = (user as { email?: string }).email;
if (email) {
deleteUserByEmail(email);
}
});
}
cy.clearCookie("__session");
}
function deleteUserByEmail(email: string) {
cy.exec(
`npx ts-node -r tsconfig-paths/register ./cypress/support/delete-user.ts "${email}"`,
);
cy.clearCookie("__session");
}
// We're waiting a second because of this issue happen randomly
// https://github.com/cypress-io/cypress/issues/7306
// Also added custom types to avoid getting detached
// https://github.com/cypress-io/cypress/issues/7306#issuecomment-1152752612
// ===========================================================
function visitAndCheck(url: string, waitTime = 1000) {
cy.visit(url);
cy.location("pathname").should("contain", url).wait(waitTime);
}
export const registerCommands = () => {
Cypress.Commands.add("login", login);
Cypress.Commands.add("cleanupUser", cleanupUser);
Cypress.Commands.add("visitAndCheck", visitAndCheck);
};

View File

@@ -0,0 +1,48 @@
// Use this to create a new user and login with that user
// Simply call this with:
// npx ts-node -r tsconfig-paths/register ./cypress/support/create-user.ts username@example.com,
// and it will log out the cookie value you can use to interact with the server
// as that new user.
import { installGlobals } from "@remix-run/node";
import { parse } from "cookie";
import { createUser } from "~/models/user.server";
import { createUserSession } from "~/session.server";
installGlobals();
async function createAndLogin(email: string) {
if (!email) {
throw new Error("email required for login");
}
if (!email.endsWith("@example.com")) {
throw new Error("All test emails must end in @example.com");
}
const user = await createUser(email, "myreallystrongpassword");
const response = await createUserSession({
request: new Request("test://test"),
userId: user.id,
remember: false,
redirectTo: "/",
});
const cookieValue = response.headers.get("Set-Cookie");
if (!cookieValue) {
throw new Error("Cookie missing from createUserSession response");
}
const parsedCookie = parse(cookieValue);
// we log it like this so our cypress command can parse it out and set it as
// the cookie value.
console.log(
`
<cookie>
${parsedCookie.__session}
</cookie>
`.trim(),
);
}
createAndLogin(process.argv[2]);

View File

@@ -0,0 +1,37 @@
// Use this to delete a user by their email
// Simply call this with:
// npx ts-node -r tsconfig-paths/register ./cypress/support/delete-user.ts username@example.com,
// and that user will get deleted
import { PrismaClientKnownRequestError } from "@prisma/client/runtime/library";
import { installGlobals } from "@remix-run/node";
import { prisma } from "~/db.server";
installGlobals();
async function deleteUser(email: string) {
if (!email) {
throw new Error("email required for login");
}
if (!email.endsWith("@example.com")) {
throw new Error("All test emails must end in @example.com");
}
try {
await prisma.user.delete({ where: { email } });
} catch (error) {
if (
error instanceof PrismaClientKnownRequestError &&
error.code === "P2025"
) {
console.log("User not found, so no need to delete");
} else {
throw error;
}
} finally {
await prisma.$disconnect();
}
}
deleteUser(process.argv[2]);

View File

@@ -0,0 +1,17 @@
import "@testing-library/cypress/add-commands";
import { registerCommands } from "./commands";
registerCommands();
Cypress.on("uncaught:exception", (err) => {
// Cypress and React Hydrating the document don't get along
// for some unknown reason. Hopefully we figure out why eventually
// so we can remove this.
if (
/hydrat/i.test(err.message) ||
/Minified React error #418/.test(err.message) ||
/Minified React error #423/.test(err.message)
) {
return false;
}
});

View File

@@ -0,0 +1,28 @@
{
"exclude": [
"../node_modules/@types/jest",
"../node_modules/@testing-library/jest-dom"
],
"include": [
"e2e/**/*",
"support/**/*",
"../node_modules/cypress",
"../node_modules/@testing-library/cypress"
],
"compilerOptions": {
"baseUrl": "./",
"noEmit": true,
"types": ["node", "cypress", "@testing-library/cypress"],
"esModuleInterop": true,
"jsx": "react-jsx",
"moduleResolution": "Bundler",
"target": "ES2020",
"strict": true,
"skipLibCheck": true,
"resolveJsonModule": true,
"paths": {
"~/*": ["../app/*"]
}
}
}

View File

@@ -0,0 +1,52 @@
app = "indie-stack-template"
kill_signal = "SIGINT"
kill_timeout = 5
processes = []
swap_size_mb = 512
[experimental]
allowed_public_ports = []
auto_rollback = true
cmd = "start.sh"
entrypoint = "sh"
[mounts]
source = "data"
destination = "/data"
[[services]]
internal_port = 8080
processes = ["app"]
protocol = "tcp"
script_checks = []
[services.concurrency]
hard_limit = 25
soft_limit = 20
type = "connections"
[[services.ports]]
handlers = ["http"]
port = 80
force_https = true
[[services.ports]]
handlers = ["tls", "http"]
port = 443
[[services.tcp_checks]]
grace_period = "1s"
interval = "15s"
restart_limit = 0
timeout = "2s"
[[services.http_checks]]
interval = "10s"
grace_period = "5s"
method = "get"
path = "/healthcheck"
protocol = "http"
timeout = "2s"
tls_skip_verify = false
[services.http_checks.headers]

View File

@@ -0,0 +1,7 @@
# Mocks
Use this to mock any third party HTTP resources that you don't have running locally and want to have mocked for local development as well as tests.
Learn more about how to use this at [mswjs.io](https://mswjs.io/)
For an extensive example, see the [source code for kentcdodds.com](https://github.com/kentcdodds/kentcdodds.com/blob/main/mocks/index.ts)

View File

@@ -0,0 +1,15 @@
const { http, passthrough } = require("msw");
const { setupServer } = require("msw/node");
// put one-off handlers that don't really need an entire file to themselves here
const miscHandlers = [
http.post(`${process.env.REMIX_DEV_HTTP_ORIGIN}/ping`, () => passthrough()),
];
const server = setupServer(...miscHandlers);
server.listen({ onUnhandledRequest: "bypass" });
console.info("🔶 Mock server running");
process.once("SIGINT", () => server.close());
process.once("SIGTERM", () => server.close());

View File

@@ -0,0 +1,93 @@
{
"name": "remix-indie-stack",
"private": true,
"sideEffects": false,
"scripts": {
"build": "remix build",
"dev": "remix dev -c \"npm run dev:serve\"",
"dev:serve": "binode --require mocks -- @remix-run/serve:remix-serve ./build/index.js",
"format": "prettier --write ",
"format:repo": "npm run format && npm run lint -- --fix",
"lint": "eslint --cache --cache-location ./node_modules/.cache/eslint ",
"setup": "prisma generate && prisma migrate deploy && prisma db seed",
"start": "remix-serve ./build/index.js",
"start:mocks": "binode --require mocks -- @remix-run/serve:remix-serve ./build/index.js",
"test": "vitest",
"test:e2e:dev": "start-server-and-test dev http://localhost:3000 \"npx cypress open\"",
"pretest:e2e:run": "npm run build",
"test:e2e:run": "cross-env PORT=8811 start-server-and-test start:mocks http://localhost:8811 \"npx cypress run\"",
"typecheck": "tsc && tsc -p cypress",
"validate": "npm-run-all --parallel \"test -- --run\" lint typecheck test:e2e:run"
},
"eslintIgnore": [
"/node_modules",
"/build",
"/public/build"
],
"dependencies": {
"@prisma/client": "^5.19.1",
"@remix-run/css-bundle": "^2.12.1",
"@remix-run/node": "^2.12.1",
"@remix-run/react": "^2.12.1",
"@remix-run/serve": "^2.12.1",
"bcryptjs": "^2.4.3",
"isbot": "^5.1.17",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"tiny-invariant": "^1.3.3"
},
"devDependencies": {
"@faker-js/faker": "^9.0.0",
"@remix-run/dev": "^2.12.1",
"@testing-library/cypress": "^10.0.2",
"@testing-library/jest-dom": "^6.5.0",
"@types/bcryptjs": "^2.4.6",
"@types/cookie": "^0.6.0",
"@types/eslint": "^8.56.12",
"@types/node": "^20.16.5",
"@types/react": "^18.3.5",
"@types/react-dom": "^18.3.0",
"@typescript-eslint/eslint-plugin": "^7.18.0",
"@typescript-eslint/parser": "^7.18.0",
"@vitejs/plugin-react": "^4.3.1",
"@vitest/coverage-v8": "^2.0.5",
"autoprefixer": "^10.4.20",
"binode": "^1.0.5",
"cookie": "^0.6.0",
"cross-env": "^7.0.3",
"cypress": "^13.14.2",
"eslint": "^8.57.0",
"eslint-config-prettier": "^9.1.0",
"eslint-import-resolver-typescript": "^3.6.3",
"eslint-plugin-cypress": "^3.5.0",
"eslint-plugin-import": "^2.30.0",
"eslint-plugin-jest": "^28.8.3",
"eslint-plugin-jest-dom": "^5.4.0",
"eslint-plugin-jsx-a11y": "^6.10.0",
"eslint-plugin-markdown": "^5.1.0",
"eslint-plugin-react": "^7.35.2",
"eslint-plugin-react-hooks": "^4.6.2",
"eslint-plugin-testing-library": "^6.3.0",
"happy-dom": "^15.7.3",
"msw": "^2.4.4",
"npm-run-all2": "^6.2.2",
"postcss": "^8.4.45",
"prettier": "3.3.3",
"prettier-plugin-tailwindcss": "^0.6.6",
"prisma": "^5.19.1",
"start-server-and-test": "^2.0.7",
"tailwindcss": "^3.4.10",
"ts-node": "^10.9.2",
"tsconfig-paths": "^4.2.0",
"typescript": "^5.6.2",
"vite": "^5.4.3",
"vite-tsconfig-paths": "^4.3.2",
"vitest": "^2.0.5"
},
"engines": {
"node": ">=18.0.0"
},
"prisma": {
"seed": "ts-node -r tsconfig-paths/register prisma/seed.ts"
}
}

View File

@@ -0,0 +1,6 @@
module.exports = {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};

View File

@@ -0,0 +1,4 @@
/** @type {import("prettier").Config} */
module.exports = {
plugins: ["prettier-plugin-tailwindcss"],
};

View File

@@ -0,0 +1,31 @@
-- CreateTable
CREATE TABLE "User" (
"id" TEXT NOT NULL PRIMARY KEY,
"email" TEXT NOT NULL,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL
);
-- CreateTable
CREATE TABLE "Password" (
"hash" TEXT NOT NULL,
"userId" TEXT NOT NULL,
CONSTRAINT "Password_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User" ("id") ON DELETE CASCADE ON UPDATE CASCADE
);
-- CreateTable
CREATE TABLE "Note" (
"id" TEXT NOT NULL PRIMARY KEY,
"title" TEXT NOT NULL,
"body" TEXT NOT NULL,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL,
"userId" TEXT NOT NULL,
CONSTRAINT "Note_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User" ("id") ON DELETE CASCADE ON UPDATE CASCADE
);
-- CreateIndex
CREATE UNIQUE INDEX "User_email_key" ON "User"("email");
-- CreateIndex
CREATE UNIQUE INDEX "Password_userId_key" ON "Password"("userId");

View File

@@ -0,0 +1,3 @@
# Please do not edit this file manually
# It should be added in your version-control system (i.e. Git)
provider = "sqlite"

View File

@@ -0,0 +1,38 @@
datasource db {
provider = "sqlite"
url = env("DATABASE_URL")
}
generator client {
provider = "prisma-client-js"
}
model User {
id String @id @default(cuid())
email String @unique
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
password Password?
notes Note[]
}
model Password {
hash String
user User @relation(fields: [userId], references: [id], onDelete: Cascade, onUpdate: Cascade)
userId String @unique
}
model Note {
id String @id @default(cuid())
title String
body String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
user User @relation(fields: [userId], references: [id], onDelete: Cascade, onUpdate: Cascade)
userId String
}

View File

@@ -0,0 +1,53 @@
import { PrismaClient } from "@prisma/client";
import bcrypt from "bcryptjs";
const prisma = new PrismaClient();
async function seed() {
const email = "rachel@remix.run";
// cleanup the existing database
await prisma.user.delete({ where: { email } }).catch(() => {
// no worries if it doesn't exist yet
});
const hashedPassword = await bcrypt.hash("racheliscool", 10);
const user = await prisma.user.create({
data: {
email,
password: {
create: {
hash: hashedPassword,
},
},
},
});
await prisma.note.create({
data: {
title: "My first note",
body: "Hello, world!",
userId: user.id,
},
});
await prisma.note.create({
data: {
title: "My second note",
body: "Hello, world!",
userId: user.id,
},
});
console.log(`Database has been seeded. 🌱`);
}
seed()
.catch((e) => {
console.error(e);
process.exit(1);
})
.finally(async () => {
await prisma.$disconnect();
});

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

View File

@@ -0,0 +1,6 @@
/** @type {import('@remix-run/dev').AppConfig} */
module.exports = {
cacheDirectory: "./node_modules/.cache/remix",
ignoredRouteFiles: ["**/.*", "**/*.test.{ts,tsx}"],
serverModuleFormat: "cjs",
};

View File

@@ -0,0 +1,2 @@
/// <reference types="@remix-run/dev" />
/// <reference types="@remix-run/node" />

View File

@@ -0,0 +1,10 @@
node_modules
/build
/public/build
.env
/cypress/screenshots
/cypress/videos
/prisma/data.db
/prisma/data.db-journal

View File

@@ -0,0 +1,227 @@
const { execSync } = require("node:child_process");
const crypto = require("node:crypto");
const fs = require("node:fs/promises");
const path = require("node:path");
const toml = require("@iarna/toml");
const PackageJson = require("@npmcli/package-json");
const semver = require("semver");
const cleanupCypressFiles = ({ fileEntries, packageManager }) =>
fileEntries.flatMap(([filePath, content]) => {
const newContent = content.replace(
new RegExp("npx ts-node", "g"),
packageManager.name === "bun" ? "bun" : `${packageManager.exec} ts-node`,
);
return [fs.writeFile(filePath, newContent)];
});
const escapeRegExp = (string) =>
// $& means the whole matched string
string.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
const getPackageManagerCommand = (packageManager) =>
// Inspired by https://github.com/nrwl/nx/blob/bd9b33eaef0393d01f747ea9a2ac5d2ca1fb87c6/packages/nx/src/utils/package-manager.ts#L38-L103
({
bun: () => ({
exec: "bunx",
lockfile: "bun.lockb",
name: "bun",
run: (script, args) => `bun run ${script} ${args || ""}`,
}),
npm: () => ({
exec: "npx",
lockfile: "package-lock.json",
name: "npm",
run: (script, args) => `npm run ${script} ${args ? `-- ${args}` : ""}`,
}),
pnpm: () => {
const pnpmVersion = getPackageManagerVersion("pnpm");
const includeDoubleDashBeforeArgs = semver.lt(pnpmVersion, "7.0.0");
const useExec = semver.gte(pnpmVersion, "6.13.0");
return {
exec: useExec ? "pnpm exec" : "pnpx",
lockfile: "pnpm-lock.yaml",
name: "pnpm",
run: (script, args) =>
includeDoubleDashBeforeArgs
? `pnpm run ${script} ${args ? `-- ${args}` : ""}`
: `pnpm run ${script} ${args || ""}`,
};
},
yarn: () => ({
exec: "yarn",
lockfile: "yarn.lock",
name: "yarn",
run: (script, args) => `yarn ${script} ${args || ""}`,
}),
})[packageManager]();
const getPackageManagerVersion = (packageManager) =>
// Copied over from https://github.com/nrwl/nx/blob/bd9b33eaef0393d01f747ea9a2ac5d2ca1fb87c6/packages/nx/src/utils/package-manager.ts#L105-L114
execSync(`${packageManager} --version`).toString("utf-8").trim();
const getRandomString = (length) => crypto.randomBytes(length).toString("hex");
const removeUnusedDependencies = (dependencies, unusedDependencies) =>
Object.fromEntries(
Object.entries(dependencies).filter(
([key]) => !unusedDependencies.includes(key),
),
);
const updatePackageJson = ({ APP_NAME, packageJson, packageManager }) => {
const {
devDependencies,
prisma: { seed: prismaSeed, ...prisma },
scripts: {
// eslint-disable-next-line no-unused-vars
"format:repo": _repoFormatScript,
...scripts
},
} = packageJson.content;
packageJson.update({
name: APP_NAME,
devDependencies:
packageManager.name === "bun"
? removeUnusedDependencies(devDependencies, ["ts-node"])
: devDependencies,
prisma: {
...prisma,
seed:
packageManager.name === "bun"
? prismaSeed.replace("ts-node", "bun")
: prismaSeed,
},
scripts,
});
};
const main = async ({ packageManager, rootDirectory }) => {
const pm = getPackageManagerCommand(packageManager);
const README_PATH = path.join(rootDirectory, "README.md");
const FLY_TOML_PATH = path.join(rootDirectory, "fly.toml");
const EXAMPLE_ENV_PATH = path.join(rootDirectory, ".env.example");
const ENV_PATH = path.join(rootDirectory, ".env");
const DOCKERFILE_PATH = path.join(rootDirectory, "Dockerfile");
const CYPRESS_SUPPORT_PATH = path.join(rootDirectory, "cypress", "support");
const CYPRESS_COMMANDS_PATH = path.join(CYPRESS_SUPPORT_PATH, "commands.ts");
const CREATE_USER_COMMAND_PATH = path.join(
CYPRESS_SUPPORT_PATH,
"create-user.ts",
);
const DELETE_USER_COMMAND_PATH = path.join(
CYPRESS_SUPPORT_PATH,
"delete-user.ts",
);
const REPLACER = "indie-stack-template";
const DIR_NAME = path.basename(rootDirectory);
const SUFFIX = getRandomString(2);
const APP_NAME = (DIR_NAME + "-" + SUFFIX)
// get rid of anything that's not allowed in an app name
.replace(/[^a-zA-Z0-9-_]/g, "-");
const [
prodContent,
readme,
env,
dockerfile,
cypressCommands,
createUserCommand,
deleteUserCommand,
packageJson,
] = await Promise.all([
fs.readFile(FLY_TOML_PATH, "utf-8"),
fs.readFile(README_PATH, "utf-8"),
fs.readFile(EXAMPLE_ENV_PATH, "utf-8"),
fs.readFile(DOCKERFILE_PATH, "utf-8"),
fs.readFile(CYPRESS_COMMANDS_PATH, "utf-8"),
fs.readFile(CREATE_USER_COMMAND_PATH, "utf-8"),
fs.readFile(DELETE_USER_COMMAND_PATH, "utf-8"),
PackageJson.load(rootDirectory),
]);
const newEnv = env.replace(
/^SESSION_SECRET=.*$/m,
`SESSION_SECRET="${getRandomString(16)}"`,
);
const prodToml = toml.parse(prodContent);
prodToml.app = prodToml.app.replace(REPLACER, APP_NAME);
const initInstructions = `
- First run this stack's \`remix.init\` script and commit the changes it makes to your project.
\`\`\`sh
npx remix init
git init # if you haven't already
git add .
git commit -m "Initialize project"
\`\`\`
`;
const newReadme = readme
.replace(new RegExp(escapeRegExp(REPLACER), "g"), APP_NAME)
.replace(initInstructions, "");
const newDockerfile = pm.lockfile
? dockerfile.replace(
new RegExp(escapeRegExp("ADD package.json"), "g"),
`ADD package.json ${pm.lockfile}`,
)
: dockerfile;
updatePackageJson({ APP_NAME, packageJson, packageManager: pm });
await Promise.all([
fs.writeFile(FLY_TOML_PATH, toml.stringify(prodToml)),
fs.writeFile(README_PATH, newReadme),
fs.writeFile(ENV_PATH, newEnv),
fs.writeFile(DOCKERFILE_PATH, newDockerfile),
...cleanupCypressFiles({
fileEntries: [
[CYPRESS_COMMANDS_PATH, cypressCommands],
[CREATE_USER_COMMAND_PATH, createUserCommand],
[DELETE_USER_COMMAND_PATH, deleteUserCommand],
],
packageManager: pm,
}),
packageJson.save(),
fs.copyFile(
path.join(rootDirectory, "remix.init", "gitignore"),
path.join(rootDirectory, ".gitignore"),
),
fs.rm(path.join(rootDirectory, ".github", "ISSUE_TEMPLATE"), {
recursive: true,
}),
fs.rm(path.join(rootDirectory, ".github", "workflows", "format-repo.yml")),
fs.rm(path.join(rootDirectory, ".github", "workflows", "lint-repo.yml")),
fs.rm(path.join(rootDirectory, ".github", "workflows", "no-response.yml")),
fs.rm(path.join(rootDirectory, ".github", "dependabot.yml")),
fs.rm(path.join(rootDirectory, ".github", "PULL_REQUEST_TEMPLATE.md")),
fs.rm(path.join(rootDirectory, "LICENSE.md")),
]);
execSync(pm.run("setup"), { cwd: rootDirectory, stdio: "inherit" });
execSync(pm.run("format", "--log-level warn"), {
cwd: rootDirectory,
stdio: "inherit",
});
console.log(
`Setup is complete. You're now ready to rock and roll 🤘
Start development with \`${pm.run("dev")}\`
`.trim(),
);
};
module.exports = main;

View File

@@ -0,0 +1,11 @@
{
"name": "remix.init",
"private": true,
"main": "index.js",
"license": "MIT",
"dependencies": {
"@iarna/toml": "^2.2.5",
"@npmcli/package-json": "^5.1.0",
"semver": "^7.6.0"
}
}

View File

@@ -0,0 +1,9 @@
#!/bin/sh -ex
# This file is how Fly starts the server (configured in fly.toml). Before starting
# the server though, we need to run any prisma migrations that haven't yet been
# run, which is why this file exists in the first place.
# Learn more: https://community.fly.io/t/sqlite-not-getting-setup-properly/4386
npx prisma migrate deploy
npm run start

View File

@@ -0,0 +1,9 @@
import type { Config } from "tailwindcss";
export default {
content: ["./app/**/*.{js,jsx,ts,tsx}"],
theme: {
extend: {},
},
plugins: [],
} satisfies Config;

View File

@@ -0,0 +1,4 @@
import { installGlobals } from "@remix-run/node";
import "@testing-library/jest-dom/vitest";
installGlobals();

View File

@@ -0,0 +1,28 @@
{
"exclude": [
"./cypress", "./cypress.config.ts"],
"include": [
"remix.env.d.ts", "**/*.ts", "**/*.tsx"],
"compilerOptions": {
"lib": ["DOM", "DOM.Iterable", "ES2020"],
"types": ["vitest/globals"],
"isolatedModules": true,
"esModuleInterop": true,
"jsx": "react-jsx",
"module": "CommonJS",
"moduleResolution": "node",
"resolveJsonModule": true,
"target": "ES2020",
"strict": true,
"allowJs": true,
"forceConsistentCasingInFileNames": true,
"baseUrl": "./",
"paths": {
"~/*": ["./app/*"]
},
"skipLibCheck": true,
// Remix takes care of building everything in `remix build`.
"noEmit": true
}
}

View File

@@ -0,0 +1,15 @@
/// <reference types="vitest" />
/// <reference types="vite/client" />
import react from "@vitejs/plugin-react";
import tsconfigPaths from "vite-tsconfig-paths";
import { defineConfig } from "vitest/config";
export default defineConfig({
plugins: [react(), tsconfigPaths()],
test: {
globals: true,
environment: "happy-dom",
setupFiles: ["./test/setup-test-env.ts"],
},
});

View File

@@ -90,6 +90,18 @@ describe("get project info", async () => {
aliasPrefix: "~",
},
},
{
name: "remix-indie-stack",
type: {
framework: FRAMEWORKS["remix"],
isSrcDir: false,
isRSC: false,
isTsx: true,
tailwindConfigFile: "tailwind.config.ts",
tailwindCssFile: "app/tailwind.css",
aliasPrefix: "~",
},
},
{
name: "vite",
type: {