├── .eslintrc.cjs ├── .gitignore ├── .prettierignore ├── .vscode └── settings.json ├── LICENSE ├── README.md ├── package-lock.json ├── package.json ├── src ├── api │ ├── github │ │ ├── download-and-extract-tarball.ts │ │ └── extract-repo-info.ts │ └── supabase │ │ ├── organizations.ts │ │ ├── project.ts │ │ └── utils.ts ├── app.tsx ├── components │ ├── error.tsx │ ├── loading.tsx │ ├── outlet.tsx │ ├── pr-owl.tsx │ ├── select-input │ │ ├── indicator.tsx │ │ └── option.tsx │ ├── spinner.tsx │ ├── text-input.tsx │ └── utils │ │ └── error-fallback.tsx ├── config │ └── frameworks.ts ├── entry.tsx ├── internal │ ├── auth │ │ ├── access-token.ts │ │ └── auth-context.tsx │ └── init-project.ts ├── router │ ├── not-found-screen.tsx │ ├── router-context.tsx │ ├── router.tsx │ └── types.ts ├── screens │ ├── auth.tsx │ ├── create-app │ │ ├── organization │ │ │ ├── create-organization.tsx │ │ │ └── select-organization.tsx │ │ ├── project │ │ │ ├── api-keys.tsx │ │ │ ├── create-project.tsx │ │ │ ├── db-password.tsx │ │ │ ├── select-plan.tsx │ │ │ ├── select-region.tsx │ │ │ └── supabase-project-name.tsx │ │ ├── run-init-project.tsx │ │ ├── select-framework.tsx │ │ ├── select-project-dir.tsx │ │ └── select-template.tsx │ ├── layouts │ │ ├── app-layout.tsx │ │ └── create-app-layout.tsx │ └── welcome.tsx ├── utils │ ├── check-new-project-path.ts │ ├── create-choices.ts │ ├── fetch.ts │ ├── get-default-project-name.ts │ ├── handle-error.ts │ ├── is-empty.ts │ ├── print.ts │ └── user-name.ts └── version.json ├── templates └── remix │ ├── basic-v2 │ ├── .env.example │ ├── .eslintrc.js │ ├── .gitignore │ ├── .prettierignore │ ├── README.md │ ├── app │ │ ├── database │ │ │ ├── DatabaseDefinitions.ts │ │ │ ├── index.ts │ │ │ └── types.ts │ │ ├── entry.client.tsx │ │ ├── entry.server.tsx │ │ ├── hooks │ │ │ ├── index.ts │ │ │ └── use-fetcher.ts │ │ ├── modules │ │ │ ├── auth │ │ │ │ ├── components │ │ │ │ │ ├── continue-with-email-form.tsx │ │ │ │ │ ├── index.ts │ │ │ │ │ └── logout-button.tsx │ │ │ │ ├── const.ts │ │ │ │ ├── guards │ │ │ │ │ ├── assert-auth-session.server.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ └── require-auth-session.server.ts │ │ │ │ ├── mutations │ │ │ │ │ ├── create-auth-account.server.ts │ │ │ │ │ ├── delete-auth-account.server.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── refresh-auth-session.server.ts │ │ │ │ │ └── sign-in.server.ts │ │ │ │ ├── queries │ │ │ │ │ └── get-auth-account.server.ts │ │ │ │ ├── session.server.ts │ │ │ │ └── utils │ │ │ │ │ └── map-auth-session.ts │ │ │ └── user │ │ │ │ ├── mutations │ │ │ │ ├── create-user-account.server.ts │ │ │ │ └── index.ts │ │ │ │ └── queries │ │ │ │ ├── get-user.server.ts │ │ │ │ └── index.ts │ │ ├── root.tsx │ │ ├── routes │ │ │ ├── index.tsx │ │ │ ├── join.tsx │ │ │ ├── login.tsx │ │ │ ├── logout.tsx │ │ │ ├── oauth.callback.tsx │ │ │ ├── profile.tsx │ │ │ └── send-magic-link.tsx │ │ └── utils │ │ │ ├── env.ts │ │ │ ├── http.server.ts │ │ │ └── is-browser.ts │ ├── package.json │ ├── public │ │ └── favicon.ico │ ├── remix.config.js │ ├── remix.env.d.ts │ ├── supabase.init │ │ ├── gitignore │ │ └── schema.sql │ ├── tailwind.config.js │ └── tsconfig.json │ └── basic │ ├── .env.example │ ├── .eslintrc.js │ ├── .gitignore │ ├── .prettierignore │ ├── README.md │ ├── app │ ├── database │ │ ├── index.ts │ │ └── types.ts │ ├── entry.client.tsx │ ├── entry.server.tsx │ ├── hooks │ │ ├── index.ts │ │ └── use-fetcher.ts │ ├── modules │ │ ├── auth │ │ │ ├── components │ │ │ │ ├── continue-with-email-form.tsx │ │ │ │ ├── index.ts │ │ │ │ └── logout-button.tsx │ │ │ ├── const.ts │ │ │ ├── guards │ │ │ │ ├── assert-auth-session.server.ts │ │ │ │ ├── index.ts │ │ │ │ └── require-auth-session.server.ts │ │ │ ├── mutations │ │ │ │ ├── create-auth-account.server.ts │ │ │ │ ├── delete-auth-account.server.ts │ │ │ │ ├── index.ts │ │ │ │ ├── refresh-auth-session.server.ts │ │ │ │ └── sign-in.server.ts │ │ │ ├── queries │ │ │ │ └── get-auth-account.server.ts │ │ │ ├── session.server.ts │ │ │ └── utils │ │ │ │ └── map-auth-session.ts │ │ └── user │ │ │ ├── mutations │ │ │ ├── create-user-account.server.ts │ │ │ └── index.ts │ │ │ └── queries │ │ │ ├── get-user.server.ts │ │ │ └── index.ts │ ├── root.tsx │ ├── routes │ │ ├── index.tsx │ │ ├── join.tsx │ │ ├── login.tsx │ │ ├── logout.tsx │ │ ├── oauth.callback.tsx │ │ ├── profile.tsx │ │ └── send-magic-link.tsx │ └── utils │ │ ├── env.ts │ │ ├── http.server.ts │ │ └── is-browser.ts │ ├── package.json │ ├── public │ └── favicon.ico │ ├── remix.config.js │ ├── remix.env.d.ts │ ├── supabase.init │ ├── gitignore │ └── schema.sql │ ├── tailwind.config.js │ └── tsconfig.json └── tsconfig.json /.eslintrc.cjs: -------------------------------------------------------------------------------- 1 | /** 2 | * @type {import('@types/eslint').Linter.BaseConfig} 3 | */ 4 | module.exports = { 5 | root: true, 6 | plugins: ["@typescript-eslint", "import"], 7 | parser: "@typescript-eslint/parser", 8 | parserOptions: { 9 | project: ["./tsconfig.json"], 10 | }, 11 | extends: [ 12 | "eslint:recommended", 13 | "plugin:import/recommended", 14 | "plugin:import/typescript", 15 | "plugin:@typescript-eslint/eslint-recommended", 16 | "plugin:@typescript-eslint/recommended", 17 | "plugin:react/recommended", 18 | "plugin:react-hooks/recommended", 19 | "prettier", 20 | ], 21 | env: { 22 | node: true, 23 | es6: true, 24 | }, 25 | ignorePatterns: ["node_modules", ".eslintrc.cjs", "dist", "templates"], 26 | settings: { 27 | "import/extensions": [".ts", ".js", ".tsx"], 28 | "import/parsers": { 29 | "@typescript-eslint/parser": [".ts", ".js", ".tsx"], 30 | }, 31 | "import/resolver": { 32 | typescript: { 33 | alwaysTryTypes: true, 34 | project: "./tsconfig.json", 35 | }, 36 | }, 37 | }, 38 | rules: { 39 | "no-console": "warn", 40 | "arrow-body-style": ["warn", "as-needed"], 41 | // React 42 | "react/prop-types": "off", 43 | "react/no-unescaped-entities": "off", 44 | // @typescript-eslint 45 | "@typescript-eslint/consistent-type-imports": "error", 46 | "@typescript-eslint/no-non-null-assertion": "off", 47 | "@typescript-eslint/sort-type-union-intersection-members": "off", 48 | "@typescript-eslint/no-namespace": "off", 49 | "@typescript-eslint/no-unsafe-call": "off", 50 | "@typescript-eslint/no-unsafe-assignment": "off", 51 | "@typescript-eslint/no-unsafe-member-access": "off", 52 | "@typescript-eslint/no-unsafe-argument": "off", 53 | // //import 54 | "import/no-default-export": "error", 55 | "import/order": [ 56 | "error", 57 | { 58 | groups: ["builtin", "external", "internal"], 59 | pathGroups: [ 60 | { 61 | pattern: "react", 62 | group: "external", 63 | position: "before", 64 | }, 65 | { 66 | pattern: "@api/**", 67 | group: "internal", 68 | position: "before", 69 | }, 70 | { 71 | pattern: "@modules/**", 72 | group: "internal", 73 | position: "before", 74 | }, 75 | { 76 | pattern: "@plugins/**", 77 | group: "internal", 78 | position: "before", 79 | }, 80 | { 81 | pattern: "@utils/**", 82 | group: "internal", 83 | position: "before", 84 | }, 85 | ], 86 | pathGroupsExcludedImportTypes: ["react"], 87 | "newlines-between": "always", 88 | alphabetize: { 89 | order: "asc", 90 | caseInsensitive: true, 91 | }, 92 | }, 93 | ], 94 | }, 95 | overrides: [ 96 | { 97 | files: [ 98 | "./src/modules/**/*.ts", 99 | "./src/internal/**/api/*.ts", 100 | "./src/screens/**/*.tsx", 101 | ], 102 | rules: { 103 | "import/no-default-export": "off", 104 | "import/prefer-default-export": "error", 105 | }, 106 | }, 107 | ], 108 | }; 109 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | 3 | .env 4 | 5 | .idea 6 | 7 | dist 8 | 9 | # project location placeholder 10 | my-supabase-app 11 | 12 | .DS_Store -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | 3 | /build 4 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "eslint.workingDirectories": [ 3 | ".", 4 | { 5 | "pattern": "templates/**/**" 6 | } 7 | ] 8 | } 9 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 Raphaël Moreau 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Welcome to Create Supabase App 👁⚡️👁 2 | 3 | ![create-supabase-app](https://user-images.githubusercontent.com/20722140/186226036-c1a91274-1e2d-4a40-9164-f41d430942ee.png) 4 | 5 | [Supabase](https://supabase.com/) is an open-source Firebase alternative. 6 | 7 | This terminal application aims to help new developers quickly create ready-to-play applications, powered by Supabase. 8 | 9 | > It doesn't replace [Supabase CLI](https://supabase.com/docs/guides/cli) 10 | 11 | ## Pick a starter project & start playing with Supabase! 12 | 13 | > 🚨 This is still an **alpha** and could be **unstable** 14 | 15 | ```sh 16 | npx create-supabase-app@latest 17 | ``` 18 | 19 | ## Future 20 | 21 | Known issues: 22 | 23 | - [x] UI glitches on macOS & Linux (solved by changing title font and removing gradients 🥲) 24 | - [ ] Fix color theme (solarized dark doesn't work well) 25 | - [ ] Windows with WSL 2, glitches with some Terminal, glitches when screen rerender (Ink know issue) 26 | - [ ] Not responsive 😂 (and probably never) 27 | 28 | I plan to add more features and improve the experience (order is subjective) : 29 | 30 | - [ ] Add Next.js templates 31 | - [ ] Add more examples with Supabase (edge functions, realtime, ...) 32 | - [ ] Add a no UI mode and support args 33 | - [ ] Add an option to create a local project with Supabase CLI (a more advanced use case) 34 | - [ ] Add tooling to generate base templates (like replicating Auth module without copy/paste) 35 | - [ ] Add support to run an init script located in `supabase.init` 36 | - [ ] Add a way to test a template locally (to help people create new templates) 37 | - [ ] Add documentation on how to create a template 38 | - [ ] Why not Expo examples? 39 | 40 | ## Make your own CLI app! 41 | 42 | To make what I wanted to do, I created a naive implementation of React Router / React Navigation. 43 | You can reuse it and improve it to quickly create your own CLI app. 44 | 45 | ## Credits 46 | 47 | The workflow comes from retro engineering of [Supabase CLI](https://github.com/supabase/cli) 48 | 49 | The template system/idea comes from [Remix](https://github.com/remix-run/remix) `create-remix` (credits in the source code) 50 | 51 | The whole project relies on [Ink](https://github.com/vadimdemedes/ink) and : 52 | 53 | - [ink-big-text](https://github.com/sindresorhus/ink-big-text) 54 | - [ink-gradient](https://github.com/sindresorhus/ink-gradient) 55 | - [ink-select-input](https://github.com/vadimdemedes/ink-select-input) 56 | - [ink-spinner](https://github.com/vadimdemedes/ink-spinner) 57 | - [ink-text-input](https://github.com/vadimdemedes/ink-text-input) 58 | 59 | Thanks to [@vadimdemedes](https://github.com/vadimdemedes) and [@sindresorhus](https://github.com/sindresorhus) for the great work! 60 | 61 | [React Router](https://reactrouter.com/) for the routing system that inspired me. 62 | 63 | [React Navigation](https://reactnavigation.org/) for the navigation system that I try to mimic. 64 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "create-supabase-app", 3 | "version": "0.1.0-alpha.14", 4 | "description": "A CLI to create apps powered by Supabase", 5 | "author": "Raphaël Moreau (https://twitter.com/rphlmr)", 6 | "repository": "https://github.com/rphlmr/create-supabase-app", 7 | "bugs": { 8 | "url": "https://github.com/rphlmr/create-supabase-app/issues" 9 | }, 10 | "main": "dist/entry.js", 11 | "bin": { 12 | "create-supabase-app": "dist/entry.js" 13 | }, 14 | "files": [ 15 | "dist", 16 | "README.md", 17 | "LICENSE.md", 18 | "CHANGELOG.md" 19 | ], 20 | "scripts": { 21 | "clean": "rimraf dist", 22 | "prebuild": "npm run clean", 23 | "build": "tsc && tsc-alias", 24 | "prepublish": "npm run build", 25 | "prestart": "npm run build", 26 | "test": "echo \"Error: no test specified\" && exit 1", 27 | "start": "node dist/entry.js", 28 | "dev": "node dist/entry.js", 29 | "lint": "eslint --cache --cache-location ./node_modules/.cache/eslint .", 30 | "typecheck": "tsc -b", 31 | "format": "prettier --write ." 32 | }, 33 | "license": "MIT", 34 | "dependencies": { 35 | "figures": "^3.2.0", 36 | "fs-extra": "^10.1.0", 37 | "gunzip-maybe": "^1.4.2", 38 | "ink": "^3.2.0", 39 | "ink-big-text": "^1.2.0", 40 | "ink-gradient": "^2.0.0", 41 | "ink-select-input": "^4.2.1", 42 | "ink-spinner": "^4.0.3", 43 | "ink-text-input": "^4.0.3", 44 | "node-fetch": "^3.2.10", 45 | "pg": "^8.7.3", 46 | "react": "^17.0.2", 47 | "react-error-boundary": "^3.1.4", 48 | "sort-package-json": "^1.57.0", 49 | "tar-fs": "^2.1.1" 50 | }, 51 | "devDependencies": { 52 | "@types/fs-extra": "^9.0.13", 53 | "@types/gunzip-maybe": "^1.4.0", 54 | "@types/ink-big-text": "^1.2.1", 55 | "@types/ink-gradient": "^2.0.1", 56 | "@types/inquirer": "^9.0.0", 57 | "@types/node": "^18.6.5", 58 | "@types/pg": "^8.6.5", 59 | "@types/react": "^17.0.48", 60 | "@types/tar-fs": "^2.0.1", 61 | "@typescript-eslint/eslint-plugin": "^5.33.0", 62 | "@typescript-eslint/parser": "^5.33.0", 63 | "eslint": "^8.21.0", 64 | "eslint-config-prettier": "^8.5.0", 65 | "eslint-import-resolver-typescript": "^3.4.0", 66 | "eslint-plugin-import": "^2.26.0", 67 | "eslint-plugin-react": "^7.30.1", 68 | "eslint-plugin-react-hooks": "^4.6.0", 69 | "prettier": "^2.7.1", 70 | "rimraf": "^3.0.2", 71 | "tsc-alias": "^1.7.0", 72 | "typescript": "^4.7.4" 73 | }, 74 | "engines": { 75 | "node": ">=16" 76 | }, 77 | "prettier": { 78 | "tabWidth": 2, 79 | "useTabs": false, 80 | "semi": true, 81 | "singleQuote": false 82 | } 83 | } 84 | -------------------------------------------------------------------------------- /src/api/github/download-and-extract-tarball.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Credit to Remix CLI https://github.com/remix-run/remix/blob/main/packages/remix-dev/cli/create.ts 3 | */ 4 | 5 | import path from "node:path"; 6 | import stream from "node:stream/promises"; 7 | 8 | import gunzip from "gunzip-maybe"; 9 | import { extract } from "tar-fs"; 10 | 11 | import { nfetch } from "@utils/fetch"; 12 | import { createError } from "@utils/handle-error"; 13 | import { NEW_LINE } from "@utils/print"; 14 | 15 | import type { RepoInfo } from "./extract-repo-info"; 16 | 17 | // TODO: add more use cases like downloading from a local path to test before making pull request, etc 18 | export async function downloadAndExtractTarball( 19 | projectDir: string, 20 | { branch, filePath, name, owner }: RepoInfo 21 | ) { 22 | const resourceUrl = `https://codeload.github.com/${owner}/${name}/tar.gz/${branch}`; 23 | 24 | if (filePath) { 25 | filePath = filePath.split(path.sep).join(path.posix.sep); 26 | } 27 | 28 | const response = await nfetch(resourceUrl); 29 | 30 | if (response.status !== 200 || !response.body) { 31 | throw createError(`Unable to download repository template ${resourceUrl}`); 32 | } 33 | 34 | try { 35 | await stream.pipeline( 36 | response.body.pipe(gunzip()), 37 | extract(projectDir, { 38 | map(header) { 39 | const originalDirName = header.name.split("/")[0]; 40 | header.name = header.name.replace(`${originalDirName}/`, ""); 41 | 42 | if (filePath) { 43 | if (header.name.startsWith(filePath)) { 44 | header.name = header.name.replace(filePath, ""); 45 | } else { 46 | header.name = "__IGNORE__"; 47 | } 48 | } 49 | 50 | return header; 51 | }, 52 | ignore(_filename, header) { 53 | if (!header) { 54 | throw createError(`Header is undefined : template ${resourceUrl}`); 55 | } 56 | 57 | return header.name === "__IGNORE__"; 58 | }, 59 | }) 60 | ); 61 | } catch (e) { 62 | throw createError( 63 | `There was a problem extracting the file from the provided template.${NEW_LINE}Template URL: ${resourceUrl}${NEW_LINE}Destination directory: ${projectDir}`, 64 | e 65 | ); 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /src/api/github/extract-repo-info.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Credit to Remix CLI https://github.com/remix-run/remix/blob/main/packages/remix-dev/cli/create.ts 3 | */ 4 | 5 | // support only url with a tree for now 6 | export function extractRepoInfo(url: string) { 7 | const [, owner, name, , branch = "main", ...file] = new URL( 8 | url 9 | ).pathname.split("/"); 10 | 11 | const filePath = file.join("/"); 12 | 13 | return { 14 | owner, 15 | name, 16 | branch, 17 | filePath: filePath === "" || filePath === "/" ? null : filePath, 18 | }; 19 | } 20 | 21 | export type RepoInfo = ReturnType; 22 | -------------------------------------------------------------------------------- /src/api/supabase/organizations.ts: -------------------------------------------------------------------------------- 1 | import { nfetch } from "@utils/fetch"; 2 | import { createError, mayBeSupabaseAPIError } from "@utils/handle-error"; 3 | import { NEW_LINE } from "@utils/print"; 4 | 5 | import { supabaseAPI } from "./utils"; 6 | 7 | type OrganizationResponse = { 8 | id: string; 9 | name: string; 10 | }; 11 | 12 | export async function getOrganizations({ 13 | accessToken, 14 | signal, 15 | }: { 16 | accessToken?: string | null; 17 | signal?: AbortSignal; 18 | }) { 19 | if (!accessToken) throw createError("Access token is required"); 20 | 21 | const response = await nfetch(`${supabaseAPI}/organizations`, { 22 | signal, 23 | headers: { Authorization: `Bearer ${accessToken}` }, 24 | }); 25 | 26 | if (response.status !== 200) { 27 | const maybeApiError = await mayBeSupabaseAPIError(response); 28 | 29 | throw createError( 30 | `There was a problem fetching your organizations.${NEW_LINE}Status: ${response.status} ${response.statusText}.${NEW_LINE}Try to Logout and be sure your access token is valid.`, 31 | maybeApiError 32 | ); 33 | } 34 | 35 | return (await response.json()) as OrganizationResponse[]; 36 | } 37 | 38 | export async function createOrganization( 39 | name: string, 40 | { 41 | accessToken, 42 | signal, 43 | }: { 44 | accessToken?: string | null; 45 | signal?: AbortSignal; 46 | } 47 | ) { 48 | if (!accessToken) throw createError("Access token is required"); 49 | 50 | const response = await nfetch(`${supabaseAPI}/organizations`, { 51 | signal, 52 | headers: { 53 | Authorization: `Bearer ${accessToken}`, 54 | "Content-Type": "application/json", 55 | }, 56 | method: "post", 57 | body: JSON.stringify({ name }), 58 | }); 59 | 60 | if (response.status !== 201) { 61 | const maybeApiError = await mayBeSupabaseAPIError(response); 62 | 63 | throw createError( 64 | `There was a problem creating your organization "${name}".${NEW_LINE}Status: ${response.status} ${response.statusText}.${NEW_LINE}Try to Logout and be sure your access token is valid.`, 65 | maybeApiError 66 | ); 67 | } 68 | 69 | return (await response.json()) as OrganizationResponse; 70 | } 71 | -------------------------------------------------------------------------------- /src/api/supabase/project.ts: -------------------------------------------------------------------------------- 1 | import { nfetch } from "@utils/fetch"; 2 | import { createError, mayBeSupabaseAPIError } from "@utils/handle-error"; 3 | 4 | import { supabaseAPI } from "./utils"; 5 | 6 | const region = { 7 | "ap-northeast-1": "Northeast Asia (Tokyo)", 8 | "ap-northeast-2": "Northeast Asia (Seoul)", 9 | "ap-south-1": "South Asia (Mumbai)", 10 | "ap-southeast-1": "Southeast Asia (Singapore)", 11 | "ap-southeast-2": "Oceania (Sydney)", 12 | "ca-central-1": "Canada (Central)", 13 | "eu-central-1": "Central EU (Frankfurt)", 14 | "eu-west-1": "West EU (Ireland)", 15 | "eu-west-2": "West EU (London)", 16 | "sa-east-1": "South America (São Paulo)", 17 | "us-east-1": "East US (North Virginia)", 18 | "us-west-1": "West US (North California)", 19 | }; 20 | 21 | export type Region = keyof typeof region; 22 | 23 | type ProjectResponse = { 24 | id: string; 25 | name: string; 26 | }; 27 | 28 | const plan = { 29 | free: { value: "free", label: "Free" }, 30 | pro: { value: "pro", label: "Pro" }, 31 | }; 32 | export type Plan = keyof typeof plan; 33 | 34 | export type CreateProjectBody = { 35 | db_pass: string; 36 | name: string; 37 | organization_id: string; 38 | plan: Plan; 39 | region: Region; 40 | }; 41 | 42 | export function getRegions() { 43 | return Object.entries(region).map(([value, label]) => ({ label, value })); 44 | } 45 | 46 | export function getPlans() { 47 | return Object.values(plan); 48 | } 49 | 50 | export function generatePassword() { 51 | const chars = 52 | "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"; 53 | let password = ""; 54 | 55 | for (let i = 0; i < 20; i++) { 56 | password += chars[Math.floor(Math.random() * chars.length)]; 57 | } 58 | return password; 59 | } 60 | 61 | export async function createProject( 62 | body: CreateProjectBody, 63 | { 64 | accessToken, 65 | }: { 66 | accessToken?: string | null; 67 | signal?: AbortSignal; 68 | } 69 | ) { 70 | if (!accessToken) throw createError("Access token is required"); 71 | 72 | const response = await nfetch(`${supabaseAPI}/projects`, { 73 | method: "post", 74 | body: JSON.stringify(body), 75 | headers: { 76 | Authorization: `Bearer ${accessToken}`, 77 | "Content-Type": "application/json", 78 | }, 79 | }); 80 | 81 | if (response.status !== 201) { 82 | const maybeApiError = await mayBeSupabaseAPIError(response); 83 | 84 | throw createError( 85 | `There was a problem creating your project "${body.name}"`, 86 | maybeApiError 87 | ); 88 | } 89 | 90 | return (await response.json()) as ProjectResponse; 91 | } 92 | -------------------------------------------------------------------------------- /src/api/supabase/utils.ts: -------------------------------------------------------------------------------- 1 | export const supabaseAPI = "https://api.supabase.com/v1"; 2 | -------------------------------------------------------------------------------- /src/app.tsx: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | import React from "react"; 3 | 4 | import { AuthProvider } from "@auth/auth-context"; 5 | import AppLayout from "@layouts/app-layout"; 6 | import CreateAppLayout from "@layouts/create-app-layout"; 7 | import { Router, Route } from "@router/router"; 8 | import { RouterProvider } from "@router/router-context"; 9 | import AuthScreen from "@screens/auth"; 10 | import CreateOrganizationScreen from "@screens/create-app/organization/create-organization"; 11 | import SelectOrganizationScreen from "@screens/create-app/organization/select-organization"; 12 | import APIKeyScreen from "@screens/create-app/project/api-keys"; 13 | import CreateProjectScreen from "@screens/create-app/project/create-project"; 14 | import DbPasswordScreen from "@screens/create-app/project/db-password"; 15 | import SelectPlanScreen from "@screens/create-app/project/select-plan"; 16 | import SelectRegionScreen from "@screens/create-app/project/select-region"; 17 | import SupabaseProjectNameScreen from "@screens/create-app/project/supabase-project-name"; 18 | import RunProjectInit from "@screens/create-app/run-init-project"; 19 | import SelectFrameworkScreen from "@screens/create-app/select-framework"; 20 | import SelectProjectDirScreen from "@screens/create-app/select-project-dir"; 21 | import SelectTemplateScreen from "@screens/create-app/select-template"; 22 | import WelcomeScreen from "@screens/welcome"; 23 | 24 | export const App = () => ( 25 | 26 | 27 | }> 28 | } /> 29 | }> 30 | } 33 | layout={} 34 | > 35 | } /> 36 | } 39 | /> 40 | }> 41 | } /> 42 | 43 | }> 44 | } /> 45 | } /> 46 | } /> 47 | } /> 48 | } /> 49 | 50 | } /> 51 | 52 | 53 | 54 | 55 | 56 | ); 57 | -------------------------------------------------------------------------------- /src/components/error.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | 3 | import { Box, Text } from "ink"; 4 | 5 | export const Error = ({ children }: { children: React.ReactNode }) => ( 6 | 7 | 8 | {children} 9 | 10 | 11 | ); 12 | -------------------------------------------------------------------------------- /src/components/loading.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | 3 | import { Box } from "ink"; 4 | 5 | import { Spinner } from "./spinner"; 6 | 7 | export const Loading = ({ children }: { children: React.ReactNode }) => ( 8 | 9 | 10 | 11 | 12 | {children} 13 | 14 | 15 | ); 16 | -------------------------------------------------------------------------------- /src/components/outlet.tsx: -------------------------------------------------------------------------------- 1 | import React, { createContext, useContext, useMemo } from "react"; 2 | 3 | type OutletProps = { data: Data; children: React.ReactNode }; 4 | 5 | const context = createContext(null); 6 | 7 | /** 8 | * Use it in Route Layout to pass down data to children. 9 | * 10 | * example: 11 | * ```tsx 12 | const MyLayout = ({ children }: { children?: React.ReactNode }) => { 13 | const data = //any thing you want, even from a hook that fetches from an API (with isLoading state etc...); 14 | return ( 15 | 16 | {children} 17 | Right aside 18 | 19 | ); 20 | }; 21 | ``` 22 | */ 23 | export function Outlet({ 24 | data, 25 | children, 26 | }: OutletProps) { 27 | return ( 28 | data, [data])}> 29 | {children} 30 | 31 | ); 32 | } 33 | 34 | /** 35 | * Get data passed down from the **last** `` rendered in the Route tree. 36 | */ 37 | export function useParentData() { 38 | const parentData = useContext(context); 39 | 40 | if (parentData === null) 41 | throw new Error( 42 | "useParentData must be used within an Outlet. Did you forget to wrap your layout's children with ?" 43 | ); 44 | 45 | return parentData; 46 | } 47 | -------------------------------------------------------------------------------- /src/components/pr-owl.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | 3 | import { Box, Text } from "ink"; 4 | import Gradient from "ink-gradient"; 5 | 6 | const owl = ` 7 | ___ 8 | (o o) 9 | ( V ) 10 | /--m-m- 11 | `; 12 | 13 | export const Owl = () => ( 14 | 15 | {owl} 16 | 17 | ); 18 | 19 | export const PrOwl = ({ children }: { children: React.ReactNode }) => ( 20 | 26 | 33 | 34 | {children} 35 | 36 | 37 | 38 | 39 | 40 | 41 | ); 42 | -------------------------------------------------------------------------------- /src/components/select-input/indicator.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | 3 | import figures from "figures"; 4 | import { Box, Text } from "ink"; 5 | 6 | export const Indicator = ({ isSelected }: { isSelected?: boolean }) => ( 7 | 8 | {figures.pointer} 9 | 10 | ); 11 | -------------------------------------------------------------------------------- /src/components/select-input/option.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | 3 | import { Box, Text } from "ink"; 4 | import type { ItemProps } from "ink-select-input"; 5 | 6 | export const Option = ({ 7 | label, 8 | isSelected, 9 | }: ItemProps & { disabled?: boolean }) => ( 10 | 16 | 17 | {label} 18 | 19 | 20 | ); 21 | -------------------------------------------------------------------------------- /src/components/spinner.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | 3 | import { Text } from "ink"; 4 | import InkSpinner from "ink-spinner"; 5 | 6 | export const Spinner = () => ( 7 | 8 | 9 | 10 | ); 11 | -------------------------------------------------------------------------------- /src/components/text-input.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | 3 | import { Box } from "ink"; 4 | import InkTextInput from "ink-text-input"; 5 | 6 | export function TextInput(inputProps: Parameters[0]) { 7 | return ( 8 | 9 | 10 | 11 | ); 12 | } 13 | -------------------------------------------------------------------------------- /src/components/utils/error-fallback.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | 3 | import { Box, Newline, Text } from "ink"; 4 | import BigText from "ink-big-text"; 5 | import Gradient from "ink-gradient"; 6 | 7 | const ErrorFallback = ({ error }: { error: Error }) => ( 8 | 15 | 16 | 17 | 18 | 19 | 20 | Stay calm and complaint here :{" "} 21 | 22 | https://github.com/rphlmr/create-supabase-app 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | {error.message} 32 | 33 | 34 | 35 | 36 | {error.stack} 37 | 38 | 39 | 40 | ); 41 | 42 | export { ErrorFallback }; 43 | -------------------------------------------------------------------------------- /src/config/frameworks.ts: -------------------------------------------------------------------------------- 1 | import type { Region } from "@api/supabase/project"; 2 | 3 | const supportedFrameworks = { 4 | remix: { 5 | label: "Remix", 6 | value: "remix", 7 | url: "https://remix.run/", 8 | enabled: true, 9 | templates: [ 10 | { 11 | value: "basic", 12 | label: "Basic", 13 | description: 14 | "Just the basics, authentication included. Start coding your awesome project right away", 15 | url: "https://github.com/rphlmr/create-supabase-app/tree/main/templates/remix/basic", 16 | }, 17 | { 18 | value: "basic-v2", 19 | label: "Basic with supabase.js v2", 20 | description: 21 | "Just the basics, authentication included. Start coding your awesome project right away", 22 | url: "https://github.com/rphlmr/create-supabase-app/tree/main/templates/remix/basic-v2", 23 | }, 24 | ], 25 | }, 26 | nextjs: { 27 | label: "Next.js", 28 | value: "nextjs", 29 | url: "https://nextjs.org/", 30 | enabled: false, 31 | templates: [ 32 | { 33 | value: "basic", 34 | label: "Basic", 35 | description: "", 36 | url: "https://nextjs.org/", 37 | }, 38 | ], 39 | }, 40 | } as const; 41 | 42 | export const availableFw = Object.values(supportedFrameworks) 43 | .filter(({ enabled }) => enabled) 44 | // eslint-disable-next-line @typescript-eslint/no-unused-vars -- lodash ? 45 | .map(({ templates, ...rest }) => rest); 46 | 47 | export const comingSoonFw = Object.values(supportedFrameworks) 48 | .filter(({ enabled }) => !enabled) 49 | // eslint-disable-next-line @typescript-eslint/no-unused-vars -- lodash ? 50 | .map(({ templates, ...rest }) => rest); 51 | 52 | export const getTemplates = (framework: Framework) => [ 53 | ...supportedFrameworks[framework].templates, 54 | ]; 55 | 56 | export const getTemplate = (framework: Framework, template: Template) => 57 | getTemplates(framework).filter(({ value }) => value === template)[0]; 58 | 59 | export const getFrameworkName = (framework: Framework) => 60 | supportedFrameworks[framework].label; 61 | 62 | export const getTemplateName = (framework: Framework, template: Template) => 63 | getTemplate(framework, template).label; 64 | 65 | export type Framework = keyof typeof supportedFrameworks; 66 | export type Template = ReturnType[number]["value"]; 67 | 68 | export type CreateAppConfig = { 69 | framework: Framework; 70 | template: Template; 71 | projectDir: string; 72 | organizationId: string; 73 | projectName: string; 74 | projectId: string; 75 | supabaseProjectName: string; 76 | dbPassword: string; 77 | plan: "free" | "pro"; 78 | region: Region; 79 | anonKey: string; 80 | serviceRoleKey: string; 81 | }; 82 | -------------------------------------------------------------------------------- /src/entry.tsx: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | import React from "react"; 4 | 5 | import { render } from "ink"; 6 | import { ErrorBoundary } from "react-error-boundary"; 7 | 8 | import { ErrorFallback } from "@components/utils/error-fallback"; 9 | 10 | import { App } from "./app"; 11 | 12 | render( 13 | 14 | 15 | 16 | ); 17 | 18 | process.on("uncaughtExceptionMonitor", (reason: string) => { 19 | render(); 20 | process.exit(1); 21 | }); 22 | -------------------------------------------------------------------------------- /src/internal/auth/access-token.ts: -------------------------------------------------------------------------------- 1 | import os from "node:os"; 2 | import path from "node:path"; 3 | 4 | import { readFile, pathExists, mkdir, stat, writeFile } from "fs-extra"; 5 | 6 | import { createError } from "@utils/handle-error"; 7 | 8 | const configPath = path.join(os.homedir(), ".supabase"); 9 | const accessTokenPath = path.join(os.homedir(), ".supabase", "access-token"); 10 | 11 | const accessTokenPattern = new RegExp(/^sbp_[a-f0-9]{40}$/); 12 | 13 | export function isToken(accessToken: string | undefined) { 14 | return accessTokenPattern.test(accessToken ?? ""); 15 | } 16 | 17 | export async function loadAccessToken() { 18 | let accessToken: string | undefined; 19 | 20 | // check if exists in env 21 | accessToken = process.env.SUPABASE_ACCESS_TOKEN; 22 | 23 | // check if exists in config file 24 | if (!accessToken) { 25 | accessToken = await readFile(accessTokenPath, "utf8") 26 | .then((token) => token.trim()) 27 | .catch(() => undefined); 28 | } 29 | 30 | return accessToken; 31 | } 32 | 33 | export async function persistAccessToken(accessToken: string) { 34 | const configPathExists = 35 | (await pathExists(configPath)) && (await stat(configPath)).isDirectory(); 36 | 37 | if (!configPathExists) { 38 | await mkdir(configPath).catch((e) => { 39 | throw createError(`Unable to create config directory ${configPath}`, e); 40 | }); 41 | } 42 | 43 | await writeFile(accessTokenPath, accessToken, "utf-8").catch((e) => { 44 | throw createError(`Unable to save access token in ${configPath}`, e); 45 | }); 46 | } 47 | -------------------------------------------------------------------------------- /src/internal/auth/auth-context.tsx: -------------------------------------------------------------------------------- 1 | /** 2 | * A naive implementation of a router that can be used to render screens. 3 | * Inspired by https://reactrouter.com/ 4 | * Maybe this should be a separate package? 5 | * Maybe not 6 | * Maybe it'll just not work 7 | * This are Copilot's thoughts ... 😂 8 | */ 9 | 10 | import React, { 11 | createContext, 12 | useCallback, 13 | useContext, 14 | useEffect, 15 | useMemo, 16 | useState, 17 | } from "react"; 18 | 19 | import { isToken, loadAccessToken, persistAccessToken } from "./access-token"; 20 | 21 | const AuthContext = createContext< 22 | | { 23 | accessToken?: string; 24 | saveAccessToken: ( 25 | accessToken: string 26 | ) => ReturnType; 27 | isLoggedIn: boolean; 28 | } 29 | | undefined 30 | >(undefined); 31 | 32 | function AuthProvider({ children }: { children: React.ReactNode }) { 33 | const [accessToken, setAccessToken] = useState(); 34 | 35 | useEffect(() => { 36 | loadAccessToken().then((token) => { 37 | if (isToken(token)) { 38 | setAccessToken(token); 39 | } 40 | }); 41 | }, []); 42 | 43 | const saveAccessToken = useCallback(async (accessToken: string) => { 44 | await persistAccessToken(accessToken); 45 | 46 | setAccessToken(isToken(accessToken) ? accessToken : undefined); 47 | }, []); 48 | 49 | const value = useMemo( 50 | () => ({ 51 | accessToken, 52 | saveAccessToken, 53 | isLoggedIn: isToken(accessToken), 54 | }), 55 | [accessToken, saveAccessToken] 56 | ); 57 | 58 | return {children}; 59 | } 60 | 61 | function useAuth() { 62 | const context = useContext(AuthContext); 63 | 64 | if (context === undefined) { 65 | throw new Error("useAuth must be used within a AuthProvider"); 66 | } 67 | 68 | return context; 69 | } 70 | 71 | export { AuthProvider, useAuth }; 72 | -------------------------------------------------------------------------------- /src/internal/init-project.ts: -------------------------------------------------------------------------------- 1 | // Credit to Remix Indie stack : https://github.com/remix-run/indie-stack 2 | 3 | import crypto from "node:crypto"; 4 | import path from "node:path"; 5 | 6 | import { readFile, readJSON, writeFile, copyFile, rm } from "fs-extra"; 7 | import { Client } from "pg"; 8 | import sort from "sort-package-json"; 9 | 10 | import type { CreateAppConfig } from "@config/frameworks"; 11 | 12 | function getRandomString(length: number) { 13 | return crypto.randomBytes(length).toString("hex"); 14 | } 15 | 16 | export async function initProject({ 17 | projectDir, 18 | dbPassword, 19 | projectId, 20 | anonKey, 21 | serviceRoleKey, 22 | projectName, 23 | }: CreateAppConfig) { 24 | const EXAMPLE_ENV_PATH = path.join(projectDir, ".env.example"); 25 | const ENV_PATH = path.join(projectDir, ".env"); 26 | const PACKAGE_JSON_PATH = path.join(projectDir, "package.json"); 27 | const SQL_SCHEMA_PATH = path.join(projectDir, "supabase.init", "schema.sql"); 28 | 29 | const [env, packageJson, SQLSchema] = await Promise.all([ 30 | readFile(EXAMPLE_ENV_PATH, "utf-8"), 31 | readJSON(PACKAGE_JSON_PATH, "utf-8"), 32 | readFile(SQL_SCHEMA_PATH, "utf-8"), 33 | ]); 34 | 35 | const newEnv = env 36 | .replace(/^SESSION_SECRET=.*$/m, `SESSION_SECRET="${getRandomString(16)}"`) 37 | .replace(/^SUPABASE_ANON_PUBLIC=.*$/m, `SUPABASE_ANON_PUBLIC="${anonKey}"`) 38 | .replace( 39 | /^SUPABASE_SERVICE_ROLE=.*$/m, 40 | `SUPABASE_SERVICE_ROLE="${serviceRoleKey}"` 41 | ) 42 | .replace( 43 | /^SUPABASE_URL=.*$/m, 44 | `SUPABASE_URL="https://${projectId}.supabase.co"` 45 | ); 46 | 47 | const newPackageJson = 48 | JSON.stringify(sort({ ...packageJson, name: projectName }), null, 2) + "\n"; 49 | 50 | await Promise.all([ 51 | writeFile(ENV_PATH, newEnv), 52 | writeFile(PACKAGE_JSON_PATH, newPackageJson), 53 | copyFile( 54 | path.join(projectDir, "supabase.init", "gitignore"), 55 | path.join(projectDir, ".gitignore") 56 | ), 57 | ]); 58 | 59 | // run sql schema init 60 | await runSQLSchemaInit(SQLSchema, dbPassword, projectId); 61 | 62 | // at the end, remove supabase.init folder 63 | await rm(path.join(projectDir, "supabase.init"), { 64 | recursive: true, 65 | force: true, 66 | }); 67 | } 68 | 69 | async function runSQLSchemaInit( 70 | SQLSchema: string, 71 | dbPassword: string, 72 | projectId: string 73 | ) { 74 | // wait db to be ready 75 | const MAX_ATTEMPTS = 60; 76 | const TICK = 3_000; 77 | let attempts = 0; 78 | 79 | let client; 80 | do { 81 | try { 82 | attempts++; 83 | 84 | client = new Client({ 85 | database: "postgres", 86 | user: "postgres", 87 | host: `db.${projectId}.supabase.co`, 88 | password: dbPassword, 89 | port: 5432, 90 | }); 91 | await client.connect(); 92 | await client.query(SQLSchema); 93 | await client?.end(); 94 | break; 95 | } catch (e) { 96 | await client?.end(); 97 | await new Promise((resolve) => setTimeout(resolve, TICK)); 98 | } 99 | } while (attempts < MAX_ATTEMPTS); 100 | } 101 | -------------------------------------------------------------------------------- /src/router/not-found-screen.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | 3 | import { Box, Text } from "ink"; 4 | import BigText from "ink-big-text"; 5 | import Gradient from "ink-gradient"; 6 | 7 | import { useRoute } from "@router/router-context"; 8 | 9 | export const NotFoundScreen = () => { 10 | const { path } = useRoute(); 11 | return ( 12 | 13 | 14 | 15 | 16 | 17 | Not found {path} 18 | 19 | ); 20 | }; 21 | -------------------------------------------------------------------------------- /src/router/router.tsx: -------------------------------------------------------------------------------- 1 | import React, { cloneElement } from "react"; 2 | 3 | import { NotFoundScreen as DefaultNotFoundScreen } from "./not-found-screen"; 4 | import { useRouter } from "./router-context"; 5 | import type { RouteComponent, RouterComponent } from "./types"; 6 | 7 | export const Router: RouterComponent = ({ 8 | layout = <>, 9 | notFoundScreen = , 10 | }) => { 11 | const { outlet } = useRouter(); 12 | 13 | return cloneElement(layout, {}, outlet ?? notFoundScreen); 14 | }; 15 | 16 | export const Route: RouteComponent = ({ screen }) => cloneElement(screen); 17 | -------------------------------------------------------------------------------- /src/router/types.ts: -------------------------------------------------------------------------------- 1 | import type React from "react"; 2 | 3 | // FIXME: sync this with context value 4 | export interface RouterContext { 5 | navigation: { 6 | navigateTo: ( 7 | path: string, 8 | params?: { [key: string]: unknown }, 9 | options?: { replaceParams: boolean } 10 | ) => void; 11 | restart: () => void; 12 | }; 13 | outlet: React.ReactNode; 14 | route: { 15 | path: string; 16 | params: { [key: string]: unknown }; 17 | }; 18 | } 19 | 20 | export interface RouterProps { 21 | children: React.ReactNode; 22 | startingPath?: string; 23 | layout?: ScreenLayout; 24 | notFoundScreen?: Screen; 25 | } 26 | 27 | export type RouterComponent = React.FC; 28 | 29 | export interface RouteProps { 30 | screen: Screen; 31 | path: string; 32 | children?: React.ReactNode; 33 | layout?: ScreenLayout; 34 | } 35 | 36 | export type RouteComponent = React.FC; 37 | 38 | export type Screen = React.ReactElement; 39 | export type ScreenLayout = React.ReactElement; 40 | -------------------------------------------------------------------------------- /src/screens/auth.tsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect, useState } from "react"; 2 | 3 | import { Box, Text } from "ink"; 4 | import TextInput from "ink-text-input"; 5 | 6 | import { isToken } from "@auth/access-token"; 7 | import { useAuth } from "@auth/auth-context"; 8 | import { useNavigation } from "@router/router-context"; 9 | 10 | // TODO: maybe refactor that? 11 | const AuthScreen = ({ successTo }: { successTo: string }) => { 12 | const { navigateTo } = useNavigation(); 13 | const { saveAccessToken, isLoggedIn } = useAuth(); 14 | const [userInput, setUserInput] = useState(""); 15 | const [isSubmitOnError, setIsSubmitOnError] = useState(false); 16 | const isValidToken = isToken(userInput); 17 | 18 | useEffect(() => { 19 | if (isLoggedIn) navigateTo(successTo); 20 | }, [isLoggedIn, navigateTo, successTo]); 21 | 22 | const handleOnChange = (value: string) => { 23 | setIsSubmitOnError(false); 24 | setUserInput(value); 25 | }; 26 | 27 | const handleSubmit = async (input: string) => { 28 | if (!isToken(input)) { 29 | setIsSubmitOnError(true); 30 | return; 31 | } 32 | 33 | saveAccessToken(input); 34 | }; 35 | 36 | if (isLoggedIn) return null; 37 | 38 | return ( 39 | 40 | 46 | {isSubmitOnError ? ( 47 | 48 | ❌ Invalid access token format. Must be like `sbp_0102...1920` 49 | 50 | ) : isValidToken ? ( 51 | 52 | ✅ Your access token is valid 53 | 54 | ) : ( 55 | 56 | 🔑 Enter your access token to continue 57 | 58 | )} 59 | 68 | 74 | 75 | 76 | 77 | 78 | You can generate an access token here 79 | 👇 80 | https://app.supabase.io/account/tokens 81 | 82 | 83 | ); 84 | }; 85 | 86 | export default AuthScreen; 87 | -------------------------------------------------------------------------------- /src/screens/create-app/organization/create-organization.tsx: -------------------------------------------------------------------------------- 1 | import React, { useCallback, useEffect, useState } from "react"; 2 | 3 | import { Box, Text } from "ink"; 4 | 5 | import { createOrganization } from "@api/supabase/organizations"; 6 | 7 | import { getUserName } from "@utils/user-name"; 8 | 9 | import { useAuth } from "@auth/auth-context"; 10 | import { Error } from "@components/error"; 11 | import { Loading } from "@components/loading"; 12 | import { PrOwl } from "@components/pr-owl"; 13 | import { TextInput } from "@components/text-input"; 14 | import { useNavigation } from "@router/router-context"; 15 | 16 | const useCreateOrganization = () => { 17 | const { accessToken } = useAuth(); 18 | const [isLoading, setIsLoading] = useState(false); 19 | const [error, setError] = useState(); 20 | const [isSuccess, setIsSuccess] = useState(false); 21 | 22 | const create = useCallback( 23 | async (name: string) => { 24 | setIsLoading(true); 25 | createOrganization(name, { accessToken }) 26 | .then(() => { 27 | setIsSuccess(true); 28 | }) 29 | .catch((e) => { 30 | setIsSuccess(false); 31 | setError((e as Error).message); 32 | }) 33 | .finally(() => { 34 | setIsLoading(false); 35 | }); 36 | }, 37 | [accessToken] 38 | ); 39 | 40 | return { isLoading, error, isSuccess, create }; 41 | }; 42 | 43 | const CreateOrganizationScreen = () => { 44 | const { navigateTo } = useNavigation(); 45 | const { create, isSuccess, error, isLoading } = useCreateOrganization(); 46 | const [choice, setChoice] = useState(""); 47 | 48 | useEffect(() => { 49 | if (!isLoading && isSuccess) { 50 | return navigateTo("/create-app/organization"); 51 | } 52 | }, [isLoading, isSuccess, navigateTo]); 53 | 54 | const handleSubmit = useCallback( 55 | async (input: string) => { 56 | const orgName = input || getUserName(); 57 | 58 | create(orgName); 59 | }, 60 | [create] 61 | ); 62 | 63 | if (isLoading) { 64 | return ( 65 | 66 | 67 | Creating your Supabase organization{" "} 68 | {choice || getUserName()} 69 | 70 | 71 | ); 72 | } 73 | 74 | if (error) { 75 | return ( 76 | 77 | {error} 78 | 79 | ); 80 | } 81 | 82 | return ( 83 | 84 | 85 | 86 | Create a Supabase organization 87 | 88 | Press Enter to continue 89 | 90 | 91 | 92 | You choose 93 | 94 | {choice || getUserName()} 95 | 96 | 97 | 98 | 104 | 105 | {`An organization is like a folder in your Supabase Dashboard. 106 | It's a logical grouping of your Supabase projects.`} 107 | 108 | 109 | 110 | 111 | 112 | 113 | 119 | 120 | 121 | ); 122 | }; 123 | 124 | export default CreateOrganizationScreen; 125 | -------------------------------------------------------------------------------- /src/screens/create-app/organization/select-organization.tsx: -------------------------------------------------------------------------------- 1 | import React, { useCallback, useEffect, useMemo, useState } from "react"; 2 | 3 | import { Box, Text } from "ink"; 4 | import SelectInput from "ink-select-input"; 5 | 6 | import { getOrganizations } from "@api/supabase/organizations"; 7 | 8 | import { createChoices } from "@utils/create-choices"; 9 | import { isEmpty } from "@utils/is-empty"; 10 | 11 | import { useAuth } from "@auth/auth-context"; 12 | import { Error } from "@components/error"; 13 | import { Loading } from "@components/loading"; 14 | import { PrOwl } from "@components/pr-owl"; 15 | import { Indicator } from "@components/select-input/indicator"; 16 | import { Option } from "@components/select-input/option"; 17 | import { useNavigation } from "@router/router-context"; 18 | 19 | const useFetchOrganizations = () => { 20 | const { accessToken } = useAuth(); 21 | const [isLoading, setIsLoading] = useState(true); 22 | const [error, setError] = useState(); 23 | const [hasOrganizations, setHasOrganizations] = useState(false); 24 | const [organizations, setOrganizations] = useState< 25 | Awaited> 26 | >([]); 27 | 28 | useEffect(() => { 29 | const { abort, signal } = new AbortController(); 30 | 31 | getOrganizations({ signal, accessToken }) 32 | .then((result) => { 33 | setOrganizations(result); 34 | setHasOrganizations(!isEmpty(result)); 35 | }) 36 | .catch((e) => { 37 | setError((e as Error).message); 38 | }) 39 | .finally(() => { 40 | setIsLoading(false); 41 | }); 42 | 43 | return () => { 44 | abort(); 45 | }; 46 | }, [accessToken]); 47 | 48 | return { isLoading, error, organizations, hasOrganizations }; 49 | }; 50 | 51 | const SelectOrganizationScreen = () => { 52 | const { navigateTo } = useNavigation(); 53 | const { organizations, hasOrganizations, error, isLoading } = 54 | useFetchOrganizations(); 55 | const choices = useMemo(() => createChoices(organizations), [organizations]); 56 | const [choice, setChoice] = useState(); 57 | 58 | useEffect(() => { 59 | if (!isLoading && !hasOrganizations) 60 | return navigateTo("/create-app/organization/create"); 61 | }, [hasOrganizations, isLoading, navigateTo]); 62 | 63 | const handleSelect = useCallback( 64 | ({ value }: { value: string }) => { 65 | navigateTo(`/create-app/project`, { organizationId: value }); 66 | }, 67 | [navigateTo] 68 | ); 69 | 70 | if (isLoading) { 71 | return ( 72 | 73 | Loading your Supabase organizations 74 | 75 | ); 76 | } 77 | 78 | if (error) { 79 | return ( 80 | 81 | {error} 82 | 83 | ); 84 | } 85 | 86 | return ( 87 | 88 | 89 | 90 | Pick an organization 91 | 92 | Press Enter to continue 93 | 94 | 95 | 96 | You choose 97 | 98 | {(choice || choices[0])?.label} 99 | 100 | 101 | 102 | 103 | 104 | 105 | setChoice(item as typeof choices[number])} 111 | indicatorComponent={Indicator} 112 | /> 113 | 114 | 115 | ); 116 | }; 117 | 118 | export default SelectOrganizationScreen; 119 | -------------------------------------------------------------------------------- /src/screens/create-app/project/api-keys.tsx: -------------------------------------------------------------------------------- 1 | import React, { useCallback, useState } from "react"; 2 | 3 | import { Box, Text } from "ink"; 4 | 5 | import { isEmpty } from "@utils/is-empty"; 6 | import { NEW_LINE } from "@utils/print"; 7 | 8 | import { PrOwl } from "@components/pr-owl"; 9 | import { TextInput } from "@components/text-input"; 10 | import type { CreateAppConfig } from "@config/frameworks"; 11 | import { useNavigation, useRouteParams } from "@router/router-context"; 12 | 13 | const APIKeyScreen = () => { 14 | const { navigateTo } = useNavigation(); 15 | const { projectId } = useRouteParams() as Pick; 16 | const [anonKey, setAnonKey] = useState(""); 17 | const [serviceRoleKey, setServiceRoleKey] = useState(""); 18 | const [currentKey, setCurrentKey] = useState< 19 | "anon public" | "service_role secret" 20 | >("anon public"); 21 | 22 | const handleSubmit = useCallback( 23 | async (key: string) => { 24 | if (isEmpty(key)) return; 25 | 26 | navigateTo("/create-app/run-init-project", { anonKey, serviceRoleKey }); 27 | }, 28 | [anonKey, navigateTo, serviceRoleKey] 29 | ); 30 | 31 | const requestServiceRoleKey = useCallback((key: string) => { 32 | if (isEmpty(key)) return; 33 | 34 | setCurrentKey("service_role secret"); 35 | }, []); 36 | 37 | return ( 38 | 39 | 40 | 41 | 42 | Set your {currentKey} API Key 43 | 44 | 45 | 46 | Press Enter to confirm 47 | 48 | 49 | 55 | You set 56 | 57 | {currentKey === "anon public" ? anonKey : serviceRoleKey} 58 | 59 | 60 | 61 | 62 | Make a mistake? You can always change it later in the project .env 63 | file. 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | You can find your{" "} 73 | 74 | {currentKey} key 75 | {" "} 76 | here 👇 77 | {NEW_LINE} 78 | 79 | {`https://app.supabase.com/project/${projectId}/settings/api`} 80 | 81 | 82 | 83 | 84 | {currentKey === "anon public" ? ( 85 | 91 | ) : ( 92 | 98 | )} 99 | 100 | 101 | ); 102 | }; 103 | 104 | export default APIKeyScreen; 105 | -------------------------------------------------------------------------------- /src/screens/create-app/project/create-project.tsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect, useState } from "react"; 2 | 3 | import { Text } from "ink"; 4 | 5 | import { createProject } from "@api/supabase/project"; 6 | 7 | import { useAuth } from "@auth/auth-context"; 8 | import { Error } from "@components/error"; 9 | import { Loading } from "@components/loading"; 10 | import type { CreateAppConfig } from "@config/frameworks"; 11 | import { useNavigation, useRouteParams } from "@router/router-context"; 12 | 13 | const useCreateProject = () => { 14 | const { accessToken } = useAuth(); 15 | const { supabaseProjectName, dbPassword, organizationId, plan, region } = 16 | useRouteParams() as CreateAppConfig; 17 | const [isLoading, setIsLoading] = useState(true); 18 | const [error, setError] = useState(); 19 | const [projectId, setProjectId] = useState(); 20 | 21 | useEffect(() => { 22 | const { abort, signal } = new AbortController(); 23 | 24 | createProject( 25 | { 26 | name: supabaseProjectName, 27 | db_pass: dbPassword, 28 | organization_id: organizationId, 29 | plan, 30 | region, 31 | }, 32 | { accessToken, signal } 33 | ) 34 | .then(({ id }) => setProjectId(id)) 35 | .catch((e) => { 36 | setError((e as Error).message); 37 | }) 38 | .finally(() => { 39 | setIsLoading(false); 40 | }); 41 | 42 | return () => { 43 | abort(); 44 | }; 45 | }, [ 46 | accessToken, 47 | dbPassword, 48 | organizationId, 49 | plan, 50 | region, 51 | supabaseProjectName, 52 | ]); 53 | 54 | return { isLoading, error, projectId }; 55 | }; 56 | 57 | const CreateProjectScreen = () => { 58 | const { navigateTo } = useNavigation(); 59 | const { projectName, projectDir } = useRouteParams() as CreateAppConfig; 60 | const { error, isLoading, projectId } = useCreateProject(); 61 | 62 | useEffect(() => { 63 | if (!isLoading && projectId) { 64 | navigateTo("/create-app/project/api-keys", { projectId }); 65 | } 66 | }, [isLoading, navigateTo, projectDir, projectId]); 67 | 68 | if (isLoading) { 69 | return ( 70 | 71 | 72 | Creating your Supabase project {projectName} 73 | 74 | 75 | ); 76 | } 77 | 78 | if (error) { 79 | return ( 80 | 81 | {error} 82 | 83 | ); 84 | } 85 | 86 | return null; 87 | }; 88 | 89 | export default CreateProjectScreen; 90 | -------------------------------------------------------------------------------- /src/screens/create-app/project/db-password.tsx: -------------------------------------------------------------------------------- 1 | import React, { useMemo } from "react"; 2 | 3 | import { Box, Text, useInput } from "ink"; 4 | 5 | import { generatePassword } from "@api/supabase/project"; 6 | 7 | import { PrOwl } from "@components/pr-owl"; 8 | import { useNavigation } from "@router/router-context"; 9 | 10 | const DbPasswordScreen = () => { 11 | const { navigateTo } = useNavigation(); 12 | const dbPassword = useMemo(() => generatePassword(), []); 13 | 14 | useInput((_, key) => { 15 | if (key.return) { 16 | navigateTo("/create-app/project/region", { dbPassword }); 17 | } 18 | }); 19 | 20 | return ( 21 | 22 | 23 | 24 | Your Supabase database password 25 | 26 | Press Enter to continue 27 | 28 | 29 | 30 | I've generated one for you 31 | 32 | 33 | 34 | 35 | {dbPassword} 36 | 37 | 38 | 39 | 45 | 46 | Copy and save this password somewhere safe. If you lose it, don't 47 | worry, you can generate a new one on your Supabase dashboard. 48 | 49 | 50 | 51 | 52 | 53 | ); 54 | }; 55 | 56 | export default DbPasswordScreen; 57 | -------------------------------------------------------------------------------- /src/screens/create-app/project/select-plan.tsx: -------------------------------------------------------------------------------- 1 | import React, { useCallback, useState } from "react"; 2 | 3 | import { Box, Text } from "ink"; 4 | import SelectInput from "ink-select-input"; 5 | 6 | import { getPlans } from "@api/supabase/project"; 7 | 8 | import { PrOwl } from "@components/pr-owl"; 9 | import { Indicator } from "@components/select-input/indicator"; 10 | import { Option } from "@components/select-input/option"; 11 | import { useNavigation } from "@router/router-context"; 12 | 13 | const SelectPlanScreen = () => { 14 | const [choice, setChoice] = useState(getPlans()[0]); 15 | const { navigateTo } = useNavigation(); 16 | 17 | const handleSelect = useCallback( 18 | ({ value }: { value: string }) => { 19 | navigateTo(`/create-app/project/create`, { plan: value }); 20 | }, 21 | [navigateTo] 22 | ); 23 | 24 | return ( 25 | 26 | 27 | 28 | Select a plan that suits your needs 29 | 30 | Press Enter to continue 31 | 32 | 33 | 34 | You choose 35 | 36 | {choice.label} 37 | 38 | 39 | 45 | 46 | See https://supabase.com/pricing for more details 47 | 48 | 49 | 50 | 51 | 52 | 53 | setChoice(item as typeof choice)} 58 | indicatorComponent={Indicator} 59 | limit={3} 60 | /> 61 | 62 | 63 | ); 64 | }; 65 | 66 | export default SelectPlanScreen; 67 | -------------------------------------------------------------------------------- /src/screens/create-app/project/select-region.tsx: -------------------------------------------------------------------------------- 1 | import React, { useCallback, useState } from "react"; 2 | 3 | import { Box, Text } from "ink"; 4 | import SelectInput from "ink-select-input"; 5 | 6 | import { getRegions } from "@api/supabase/project"; 7 | 8 | import { PrOwl } from "@components/pr-owl"; 9 | import { Indicator } from "@components/select-input/indicator"; 10 | import { Option } from "@components/select-input/option"; 11 | import { useNavigation } from "@router/router-context"; 12 | 13 | const SelectRegionScreen = () => { 14 | const [choice, setChoice] = useState(getRegions()[0]); 15 | const { navigateTo } = useNavigation(); 16 | 17 | const handleSelect = useCallback( 18 | ({ value }: { value: string }) => { 19 | navigateTo(`/create-app/project/plan`, { region: value }); 20 | }, 21 | [navigateTo] 22 | ); 23 | 24 | return ( 25 | 26 | 27 | 28 | Tell me where to deploy your database 29 | 30 | Press Enter to continue 31 | 32 | 33 | 34 | You choose 35 | 36 | {choice.label} 37 | 38 | 39 | 40 | 41 | 42 | 43 | setChoice(item as typeof choice)} 48 | indicatorComponent={Indicator} 49 | limit={3} 50 | /> 51 | 52 | 53 | ); 54 | }; 55 | 56 | export default SelectRegionScreen; 57 | -------------------------------------------------------------------------------- /src/screens/create-app/project/supabase-project-name.tsx: -------------------------------------------------------------------------------- 1 | import React, { useCallback, useState } from "react"; 2 | 3 | import { Box, Text } from "ink"; 4 | 5 | import { PrOwl } from "@components/pr-owl"; 6 | import { TextInput } from "@components/text-input"; 7 | import type { CreateAppConfig } from "@config/frameworks"; 8 | import { useNavigation, useRouteParams } from "@router/router-context"; 9 | 10 | const SupabaseProjectNameScreen = () => { 11 | const { navigateTo } = useNavigation(); 12 | const { projectName } = useRouteParams() as Pick< 13 | CreateAppConfig, 14 | "projectName" 15 | >; 16 | const [choice, setChoice] = useState(""); 17 | 18 | const handleSubmit = useCallback( 19 | async (input: string) => { 20 | const supabaseProjectName = input || projectName; 21 | 22 | navigateTo("/create-app/project/db-password", { supabaseProjectName }); 23 | }, 24 | [navigateTo, projectName] 25 | ); 26 | 27 | return ( 28 | 29 | 30 | 31 | Choose a Supabase project name 32 | 33 | Press Enter to continue 34 | 35 | 36 | 37 | You choose 38 | 39 | {choice || projectName} 40 | 41 | 42 | 43 | 44 | 45 | 46 | 52 | 53 | 54 | ); 55 | }; 56 | 57 | export default SupabaseProjectNameScreen; 58 | -------------------------------------------------------------------------------- /src/screens/create-app/run-init-project.tsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect, useState } from "react"; 2 | 3 | import figures from "figures"; 4 | import { Box, Text, useApp } from "ink"; 5 | 6 | import { Error } from "@components/error"; 7 | import { Loading } from "@components/loading"; 8 | import { PrOwl } from "@components/pr-owl"; 9 | import type { CreateAppConfig } from "@config/frameworks"; 10 | import { initProject } from "@internal/init-project"; 11 | import { useRouteParams } from "@router/router-context"; 12 | 13 | const useRunProjectInit = () => { 14 | const [isLoading, setIsLoading] = useState(true); 15 | const [error, setError] = useState(); 16 | const config = useRouteParams() as CreateAppConfig; 17 | 18 | useEffect(() => { 19 | initProject(config) 20 | .catch((e) => { 21 | setError((e as Error).message); 22 | }) 23 | .finally(() => { 24 | setIsLoading(false); 25 | }); 26 | }, [config]); 27 | 28 | return { isLoading, error }; 29 | }; 30 | 31 | export default function RunProjectInit() { 32 | const { projectName, projectDir } = useRouteParams() as CreateAppConfig; 33 | const { isLoading, error } = useRunProjectInit(); 34 | const { exit } = useApp(); 35 | 36 | useEffect(() => { 37 | let id: NodeJS.Timeout; 38 | 39 | if (!isLoading) { 40 | id = setTimeout(() => exit(), 1_000); 41 | } 42 | 43 | return () => clearTimeout(id); 44 | }, [exit, isLoading]); 45 | 46 | if (isLoading) { 47 | return ( 48 | 49 | 50 | Preparing your Supabase project {projectName} 51 | 52 | 53 | It can take a few minutes to Supabase to finish initializing your 54 | database. 55 | 56 | 57 | ); 58 | } 59 | 60 | if (error) { 61 | return ( 62 | 63 | {error} 64 | 65 | ); 66 | } 67 | 68 | return ( 69 | 70 | 71 | 72 | 73 | That's all folks 🥳 74 | 75 | 76 | 77 | 78 | {figures.tick} Your project {projectName} has been created 79 | 80 | 81 | 82 | 83 | Now,{" "} 84 | 85 | `cd` 86 | {" "} 87 | into " 88 | 89 | {projectDir} 90 | 91 | " 92 | 93 | 94 | 95 | 96 | Run{" "} 97 | 98 | `npm install` 99 | {" "} 100 | & when it's done, run{" "} 101 | 102 | `npm run dev` 103 | 104 | 105 | 106 | 107 | 108 | Take a look at the{" "} 109 | 110 | README 111 | 112 | 113 | 114 | 115 | 116 | ); 117 | } 118 | -------------------------------------------------------------------------------- /src/screens/create-app/select-framework.tsx: -------------------------------------------------------------------------------- 1 | import React, { useCallback, useState } from "react"; 2 | 3 | import { Box, Text } from "ink"; 4 | import SelectInput from "ink-select-input"; 5 | 6 | import { PrOwl } from "@components/pr-owl"; 7 | import { Indicator } from "@components/select-input/indicator"; 8 | import { Option } from "@components/select-input/option"; 9 | import { availableFw, comingSoonFw } from "@config/frameworks"; 10 | import { useNavigation } from "@router/router-context"; 11 | 12 | const SelectFrameworkScreen = () => { 13 | const [choice, setChoice] = useState(availableFw[0]); 14 | const { navigateTo } = useNavigation(); 15 | 16 | const handleSelect = useCallback( 17 | ({ value }: { value: string }) => { 18 | navigateTo(`/create-app/select-template`, { framework: value }); 19 | }, 20 | [navigateTo] 21 | ); 22 | 23 | return ( 24 | 25 | 26 | 27 | Pick a framework 28 | 29 | Press Enter to continue 30 | 31 | 32 | 33 | You choose 34 | 35 | {choice.label} 36 | 37 | 38 | 44 | Bookmark the documentation! 45 | 46 | {choice.url} 47 | 48 | 49 | 50 | 51 | 52 | 53 | setChoice(item as typeof choice)} 58 | indicatorComponent={Indicator} 59 | /> 60 | {comingSoonFw.map(({ label }) => ( 61 | 64 | 65 | ); 66 | }; 67 | 68 | export default SelectFrameworkScreen; 69 | -------------------------------------------------------------------------------- /src/screens/create-app/select-project-dir.tsx: -------------------------------------------------------------------------------- 1 | import React, { useCallback, useState } from "react"; 2 | 3 | import { Box, Text } from "ink"; 4 | 5 | import { checkNewProjectPath } from "@utils/check-new-project-path"; 6 | import { getDefaultProjectName } from "@utils/get-default-project-name"; 7 | 8 | import { PrOwl } from "@components/pr-owl"; 9 | import { TextInput } from "@components/text-input"; 10 | import { useNavigation } from "@router/router-context"; 11 | 12 | const defaultFolder = "./my-supabase-app"; 13 | 14 | const SelectProjectDirScreen = () => { 15 | const { navigateTo } = useNavigation(); 16 | const [choice, setChoice] = useState(""); 17 | const [isInvalidChoice, setIsInvalidChoice] = useState(false); 18 | 19 | const handleSubmit = useCallback( 20 | async (input: string) => { 21 | const projectDir = await checkNewProjectPath( 22 | `${process.cwd()}/${input || defaultFolder}` 23 | ); 24 | 25 | if (projectDir) { 26 | const projectName = getDefaultProjectName(projectDir); 27 | return navigateTo(`/create-app/organization`, { 28 | projectDir, 29 | projectName, 30 | }); 31 | } 32 | 33 | setIsInvalidChoice(true); 34 | }, 35 | [navigateTo] 36 | ); 37 | 38 | const handleChange = useCallback(async (choice: string) => { 39 | setIsInvalidChoice(false); 40 | setChoice(choice); 41 | }, []); 42 | 43 | return ( 44 | 45 | 46 | 47 | Tell me where to create your app 48 | 49 | Press Enter to continue 50 | 51 | 52 | 53 | You choose 54 | 55 | {choice || defaultFolder} 56 | 57 | 58 | 59 | {isInvalidChoice ? ( 60 | 66 | The project directory must be empty 67 | 68 | ) : null} 69 | 70 | 71 | 72 | 73 | 79 | 80 | 81 | ); 82 | }; 83 | 84 | export default SelectProjectDirScreen; 85 | -------------------------------------------------------------------------------- /src/screens/create-app/select-template.tsx: -------------------------------------------------------------------------------- 1 | import React, { useCallback, useMemo, useState } from "react"; 2 | 3 | import { Box, Text } from "ink"; 4 | import SelectInput from "ink-select-input"; 5 | 6 | import { PrOwl } from "@components/pr-owl"; 7 | import { Indicator } from "@components/select-input/indicator"; 8 | import { Option } from "@components/select-input/option"; 9 | import type { CreateAppConfig } from "@config/frameworks"; 10 | import { getTemplates } from "@config/frameworks"; 11 | import { useNavigation, useRouteParams } from "@router/router-context"; 12 | 13 | const SelectTemplateScreen = () => { 14 | const { framework } = useRouteParams() as Pick; 15 | const { navigateTo } = useNavigation(); 16 | const templates = useMemo(() => getTemplates(framework), [framework]); 17 | const [choice, setChoice] = useState(templates[0]); 18 | 19 | const handleSelect = useCallback( 20 | ({ value }: { value: string }) => { 21 | navigateTo(`/create-app/select-project-dir`, { 22 | template: value, 23 | }); 24 | }, 25 | [navigateTo] 26 | ); 27 | 28 | return ( 29 | 30 | 31 | 32 | Pick a template 33 | 34 | Press Enter to continue 35 | 36 | 37 | 38 | You choose 39 | 40 | {choice.label} 41 | 42 | 43 | 49 | {choice.description} 50 | 51 | 52 | 53 | 54 | 55 | setChoice(item as typeof choice)} 60 | indicatorComponent={Indicator} 61 | limit={3} 62 | /> 63 | 64 | 65 | ); 66 | }; 67 | 68 | export default SelectTemplateScreen; 69 | -------------------------------------------------------------------------------- /src/screens/layouts/app-layout.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | 3 | import { Box, Text, useInput, Spacer, useApp } from "ink"; 4 | import BigText from "ink-big-text"; 5 | 6 | import { useAuth } from "@auth/auth-context"; 7 | import { useNavigation } from "@router/router-context"; 8 | 9 | import { version } from "../../version.json"; 10 | 11 | const WindowButtons = () => { 12 | const app = useApp(); 13 | const { saveAccessToken, accessToken } = useAuth(); 14 | const { restart } = useNavigation(); 15 | 16 | useInput((_, key) => { 17 | if (key.escape) app.exit(); 18 | 19 | if (accessToken && key.meta && _ === "[1;2P") { 20 | saveAccessToken(""); 21 | restart(); 22 | } 23 | }); 24 | 25 | return ( 26 | 32 | 33 | Exit (ESC) 34 | 35 | 36 | {accessToken ? ( 37 | 38 | Logout (shift + F1) 39 | 40 | ) : null} 41 | 42 | ); 43 | }; 44 | 45 | const AppLayout = ({ children }: { children?: React.ReactNode }) => ( 46 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 66 | {/* Hack Emoji */} 67 | 68 | 69 | 👁 70 | 71 | 72 | ⚡️ 73 | 74 | 75 | 👁 76 | 77 | 78 | 79 | {`v${version} `} 80 | 81 | Alpha 82 | 83 | 84 | 85 | 86 | 87 | 95 | {children} 96 | 97 | 98 | ); 99 | 100 | export default AppLayout; 101 | -------------------------------------------------------------------------------- /src/screens/layouts/create-app-layout.tsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect, useMemo, useState } from "react"; 2 | 3 | import figures from "figures"; 4 | import { Box, Text } from "ink"; 5 | import Spinner from "ink-spinner"; 6 | 7 | import { downloadAndExtractTarball } from "@api/github/download-and-extract-tarball"; 8 | import { extractRepoInfo } from "@api/github/extract-repo-info"; 9 | 10 | import type { CreateAppConfig } from "@config/frameworks"; 11 | import { getTemplate, getFrameworkName } from "@config/frameworks"; 12 | import { useRouteParams } from "@router/router-context"; 13 | 14 | const useFetchRepository = () => { 15 | const { template, framework, projectDir } = useRouteParams() as Pick< 16 | CreateAppConfig, 17 | "framework" | "template" | "projectDir" 18 | >; 19 | const [isLoading, setIsLoading] = useState(false); 20 | const [loadingMessage, setLoadingMessage] = useState(); 21 | 22 | useEffect(() => { 23 | let id: NodeJS.Timeout; 24 | 25 | if (template && framework && projectDir) { 26 | const fwName = getFrameworkName(framework); 27 | const { label: templateName, url } = getTemplate(framework, template); 28 | setIsLoading(true); 29 | setLoadingMessage( 30 | `I'm downloading ${fwName}'s template ${templateName} in the background` 31 | ); 32 | 33 | downloadAndExtractTarball(projectDir, extractRepoInfo(url)).then(() => { 34 | setIsLoading(false); 35 | setLoadingMessage(`${fwName}'s template ${templateName} downloaded`); 36 | id = setTimeout(() => { 37 | setLoadingMessage(undefined); 38 | }, 5000); 39 | }); 40 | } 41 | 42 | return () => { 43 | clearTimeout(id); 44 | }; 45 | }, [framework, template, projectDir]); 46 | 47 | return useMemo( 48 | () => ({ isLoading, loadingMessage }), 49 | [isLoading, loadingMessage] 50 | ); 51 | }; 52 | 53 | const CreateAppLayout = ({ children }: { children?: React.ReactNode }) => { 54 | const { isLoading, loadingMessage } = useFetchRepository(); 55 | 56 | return ( 57 | <> 58 | {children} 59 | 60 | {loadingMessage ? ( 61 | 62 | {isLoading ? : figures.tick}{" "} 63 | {loadingMessage} 64 | 65 | ) : null} 66 | 67 | 68 | ); 69 | }; 70 | 71 | export default CreateAppLayout; 72 | -------------------------------------------------------------------------------- /src/screens/welcome.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | 3 | import { Box, Text, useInput } from "ink"; 4 | 5 | import { useNavigation } from "@router/router-context"; 6 | 7 | const WelcomeScreen = () => { 8 | const { navigateTo } = useNavigation(); 9 | 10 | useInput((_, key) => { 11 | if (key.return) { 12 | navigateTo("/", {}, { replaceParams: true }); 13 | } 14 | }); 15 | 16 | return ( 17 | 18 | Pick a starter project & start playing with Supabase! 19 | 20 | {/* on press, /auth */} 21 | 22 | 23 | Press Enter to continue 24 | 25 | 26 | 27 | ); 28 | }; 29 | export default WelcomeScreen; 30 | -------------------------------------------------------------------------------- /src/utils/check-new-project-path.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Credit to Remix CLI https://github.com/remix-run/remix/blob/ba5c84861b578abf7dc750b2b3018ccda5c45b67/packages/remix-dev/cli/create.ts#L506 3 | */ 4 | 5 | import path from "node:path"; 6 | 7 | import { pathExists, readdir, stat } from "fs-extra"; 8 | 9 | export async function checkNewProjectPath(location: string) { 10 | const projectDir = path.resolve(process.cwd(), location); 11 | const isDirectoryExists = 12 | (await pathExists(projectDir)) && (await stat(projectDir)).isDirectory(); 13 | 14 | // if directory exists, check if it is empty 15 | if (isDirectoryExists) { 16 | // empty directory? OK 17 | return (await readdir(projectDir)).length === 0 ? projectDir : null; 18 | } 19 | 20 | return projectDir; 21 | } 22 | -------------------------------------------------------------------------------- /src/utils/create-choices.ts: -------------------------------------------------------------------------------- 1 | export function createChoices( 2 | values: T[] 3 | ) { 4 | return values.map((choice) => ({ 5 | ...choice, 6 | label: choice.name, 7 | value: choice.id, 8 | })); 9 | } 10 | -------------------------------------------------------------------------------- /src/utils/fetch.ts: -------------------------------------------------------------------------------- 1 | const fetchPromise = import("node-fetch").then((mod) => mod.default); 2 | // We can't build this app in ESM for now :/ 3 | 4 | export const nfetch: Awaited = (...args) => 5 | fetchPromise.then((fetch) => fetch(...args)); 6 | 7 | export type Response = Awaited>; 8 | -------------------------------------------------------------------------------- /src/utils/get-default-project-name.ts: -------------------------------------------------------------------------------- 1 | export function getDefaultProjectName(projectDir: string) { 2 | const separator = process.platform === "win32" ? `\\` : `/`; 3 | 4 | return projectDir.split(separator).pop(); 5 | } 6 | -------------------------------------------------------------------------------- /src/utils/handle-error.ts: -------------------------------------------------------------------------------- 1 | import type { Response } from "./fetch"; 2 | import { isEmpty } from "./is-empty"; 3 | import { NEW_LINE } from "./print"; 4 | 5 | export function getErrorMessage(error: unknown) { 6 | if (error instanceof Error) return error.message; 7 | 8 | return String(error || ""); 9 | } 10 | 11 | export function createError(message: string, error?: unknown) { 12 | const reason = getErrorMessage(error); 13 | 14 | return new Error( 15 | `${message}${!isEmpty(reason) ? `${NEW_LINE}Reason => ${reason}` : ""}` 16 | ); 17 | } 18 | 19 | export async function mayBeSupabaseAPIError(response: Response) { 20 | return ( 21 | (await response 22 | .json() 23 | .then((maybeError) => (maybeError as { message: string })?.message) 24 | .catch((error) => error)) || "" 25 | ); 26 | } 27 | -------------------------------------------------------------------------------- /src/utils/is-empty.ts: -------------------------------------------------------------------------------- 1 | import { createError } from "./handle-error"; 2 | 3 | export function isEmpty(value: string | null | undefined | Array) { 4 | if (!value) return true; 5 | 6 | if (typeof value === "string") { 7 | return value.trim().length === 0; 8 | } 9 | 10 | if (Array.isArray(value)) { 11 | return value.length === 0; 12 | } 13 | 14 | throw createError(`Should implement type ${typeof value}`); 15 | } 16 | -------------------------------------------------------------------------------- /src/utils/print.ts: -------------------------------------------------------------------------------- 1 | import os from "node:os"; 2 | 3 | export const NEW_LINE = os.EOL; 4 | -------------------------------------------------------------------------------- /src/utils/user-name.ts: -------------------------------------------------------------------------------- 1 | export function getUserName() { 2 | // this is GitHub copilot code 😂 3 | return ( 4 | process.env.SUDO_USER || 5 | process.env.C9_USER || 6 | process.env.LOGNAME || 7 | process.env.USER || 8 | process.env.LNAME || 9 | process.env.USERNAME || 10 | "John Doe" 11 | ); 12 | } 13 | -------------------------------------------------------------------------------- /src/version.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "0.1.0-alpha.14" 3 | } 4 | -------------------------------------------------------------------------------- /templates/remix/basic-v2/.env.example: -------------------------------------------------------------------------------- 1 | SESSION_SECRET="super-duper-s3cret" 2 | SUPABASE_ANON_PUBLIC="{ANON_PUBLIC}" 3 | SUPABASE_SERVICE_ROLE="{SERVICE_ROLE}" 4 | SUPABASE_URL="https://{YOUR_INSTANCE_NAME}.supabase.co" 5 | SERVER_URL="http://localhost:3000" -------------------------------------------------------------------------------- /templates/remix/basic-v2/.eslintrc.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @type {import('@types/eslint').Linter.BaseConfig} 3 | */ 4 | module.exports = { 5 | root: true, 6 | extends: [ 7 | "@remix-run/eslint-config", 8 | "@remix-run/eslint-config/node", 9 | "@remix-run/eslint-config/jest-testing-library", 10 | "plugin:import/recommended", 11 | "plugin:import/typescript", 12 | "plugin:@typescript-eslint/eslint-recommended", 13 | "plugin:@typescript-eslint/recommended", 14 | "prettier", 15 | ], 16 | plugins: ["@typescript-eslint", "import"], 17 | parser: "@typescript-eslint/parser", 18 | parserOptions: { 19 | project: ["./tsconfig.json"], 20 | }, 21 | ignorePatterns: [ 22 | "node_modules", 23 | "server-build", 24 | "build", 25 | "public/build", 26 | "*.ignored/", 27 | "*.ignored.*", 28 | "remix.config.js", 29 | ".cache", 30 | "tailwind.config.js", 31 | ".eslintrc.js", 32 | ], 33 | // we're using vitest which has a very similar API to jest 34 | // (so the linting plugins work nicely), but it we have to explicitly 35 | // set the jest version. 36 | settings: { 37 | "import/extensions": [".ts", ".tsx"], 38 | "import/parsers": { 39 | "@typescript-eslint/parser": [".ts", ".tsx"], 40 | }, 41 | "import/resolver": { 42 | typescript: { 43 | alwaysTryTypes: true, 44 | project: "./tsconfig.json", 45 | }, 46 | }, 47 | jest: { 48 | version: 27, 49 | }, 50 | }, 51 | rules: { 52 | "no-console": "warn", 53 | "arrow-body-style": ["warn", "as-needed"], 54 | "react/jsx-filename-extension": "off", 55 | // @typescript-eslint 56 | "@typescript-eslint/consistent-type-imports": "error", 57 | "@typescript-eslint/no-non-null-assertion": "off", 58 | "@typescript-eslint/sort-type-union-intersection-members": "off", 59 | "@typescript-eslint/no-namespace": "off", 60 | "@typescript-eslint/no-unsafe-call": "off", 61 | "@typescript-eslint/no-unsafe-assignment": "off", 62 | "@typescript-eslint/no-unsafe-member-access": "off", 63 | "@typescript-eslint/no-unsafe-argument": "off", 64 | "@typescript-eslint/no-throw-literal": "off", // for CatchBoundaries 65 | //import 66 | "import/no-default-export": "error", 67 | "import/order": [ 68 | "error", 69 | { 70 | groups: ["builtin", "external", "internal"], 71 | pathGroups: [ 72 | { 73 | pattern: "react", 74 | group: "external", 75 | position: "before", 76 | }, 77 | ], 78 | pathGroupsExcludedImportTypes: ["react"], 79 | "newlines-between": "always", 80 | alphabetize: { 81 | order: "asc", 82 | caseInsensitive: true, 83 | }, 84 | }, 85 | ], 86 | }, 87 | overrides: [ 88 | { 89 | files: [ 90 | "./app/root.tsx", 91 | "./app/entry.client.tsx", 92 | "./app/entry.server.tsx", 93 | "./app/routes/**/*.tsx", 94 | ], 95 | rules: { 96 | "import/no-default-export": "off", 97 | }, 98 | }, 99 | ], 100 | }; 101 | -------------------------------------------------------------------------------- /templates/remix/basic-v2/.gitignore: -------------------------------------------------------------------------------- 1 | # We don't want lockfiles in stacks, as people could use a different package manager 2 | # This part will be removed by `remix.init` 3 | package-lock.json 4 | yarn.lock 5 | pnpm-lock.yaml 6 | pnpm-lock.yml 7 | 8 | node_modules 9 | 10 | /build 11 | /public/build 12 | .env 13 | 14 | 15 | /app/styles/tailwind.css 16 | 17 | .vscode 18 | .idea 19 | 20 | # Supabase 21 | **/supabase/.branches 22 | **/supabase/.temp 23 | **/supabase/.env 24 | -------------------------------------------------------------------------------- /templates/remix/basic-v2/.prettierignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | 3 | /build 4 | /public/build 5 | .env 6 | 7 | /app/styles/tailwind.css 8 | -------------------------------------------------------------------------------- /templates/remix/basic-v2/README.md: -------------------------------------------------------------------------------- 1 | # Create Supabase App - Remix Basic V2 Template 2 | 3 | Learn more about [Remix](https://remix.run/). 4 | 5 | > This template uses [Supabase.js v2](https://supabase.com/docs/reference/javascript/next/release-notes) 6 | 7 | ## What's in this template 8 | 9 | - Production-ready [Supabase Database](https://supabase.com/) 10 | - Email/Password Authentication + Magic Link with [cookie-based sessions](https://remix.run/docs/en/v1/api/remix#createcookiesessionstorage) 11 | - Forms Schema (client and server sides !) validation with [Remix Params Helper](https://github.com/kiliman/remix-params-helper) 12 | - Styling with [Tailwind](https://tailwindcss.com/) 13 | - Code formatting with [Prettier](https://prettier.io) 14 | - Linting with [ESLint](https://eslint.org) 15 | - Static Types with [TypeScript](https://typescriptlang.org) 16 | 17 | ## Development 18 | 19 | - Start dev server: 20 | 21 | ```sh 22 | npm run dev 23 | ``` 24 | 25 | This starts your app in development mode, rebuilding assets on file changes. 26 | 27 | ### Relevant code: 28 | 29 | This is a blank app with Supabase and Remix. The main functionality is creating users, logging in and out (handling access and refresh tokens + refresh on expire). 30 | 31 | - auth / session [./app/modules/auth](./app/modules/auth) 32 | -------------------------------------------------------------------------------- /templates/remix/basic-v2/app/database/DatabaseDefinitions.ts: -------------------------------------------------------------------------------- 1 | export type Json = 2 | | string 3 | | number 4 | | boolean 5 | | null 6 | | { [key: string]: Json } 7 | | Json[]; 8 | 9 | export interface Database { 10 | public: { 11 | Tables: { 12 | profiles: { 13 | Row: { 14 | id: string; 15 | updatedAt: string | null; 16 | email: string | null; 17 | createdAt: string | null; 18 | }; 19 | Insert: { 20 | id: string; 21 | updatedAt?: string | null; 22 | email?: string | null; 23 | createdAt?: string | null; 24 | }; 25 | Update: { 26 | id?: string; 27 | updatedAt?: string | null; 28 | email?: string | null; 29 | createdAt?: string | null; 30 | }; 31 | }; 32 | }; 33 | Functions: Record; 34 | }; 35 | } 36 | -------------------------------------------------------------------------------- /templates/remix/basic-v2/app/database/index.ts: -------------------------------------------------------------------------------- 1 | import { createClient } from "@supabase/supabase-js"; 2 | 3 | import { 4 | SUPABASE_SERVICE_ROLE, 5 | SUPABASE_URL, 6 | SUPABASE_ANON_PUBLIC, 7 | } from "~/utils/env"; 8 | import { isBrowser } from "~/utils/is-browser"; 9 | 10 | import type { Database } from "./DatabaseDefinitions"; 11 | 12 | // ⚠️ cloudflare needs you define fetch option : https://github.com/supabase/supabase-js#custom-fetch-implementation 13 | // Use Remix fetch polyfill for node (See https://remix.run/docs/en/v1/other-api/node) 14 | function getSupabaseClient(supabaseKey: string, accessToken?: string) { 15 | const global = accessToken 16 | ? { 17 | global: { 18 | headers: { 19 | Authorization: `Bearer ${accessToken}`, 20 | }, 21 | }, 22 | } 23 | : {}; 24 | 25 | return createClient(SUPABASE_URL, supabaseKey, { 26 | auth: { 27 | autoRefreshToken: false, 28 | persistSession: false, 29 | }, 30 | ...global, 31 | }); 32 | } 33 | 34 | /** 35 | * Provides a Supabase Client for the logged in user or get back a public and safe client without admin privileges 36 | * 37 | * It's a per request scoped client to prevent access token leaking over multiple concurrent requests and from different users. 38 | * 39 | * Reason : https://github.com/rphlmr/supa-fly-stack/pull/43#issue-1336412790 40 | */ 41 | function getSupabase(accessToken?: string) { 42 | return getSupabaseClient(SUPABASE_ANON_PUBLIC, accessToken); 43 | } 44 | 45 | /** 46 | * Provides a Supabase Admin Client with full admin privileges 47 | * 48 | * It's a per request scoped client, to prevent access token leaking if you don't use it like `getSupabaseAdmin().auth.api`. 49 | * 50 | * Reason : https://github.com/rphlmr/supa-fly-stack/pull/43#issue-1336412790 51 | */ 52 | function getSupabaseAdmin() { 53 | if (isBrowser) 54 | throw new Error( 55 | "getSupabaseAdmin is not available in browser and should NOT be used in insecure environments" 56 | ); 57 | 58 | return getSupabaseClient(SUPABASE_SERVICE_ROLE); 59 | } 60 | 61 | export { getSupabaseAdmin, getSupabase }; 62 | -------------------------------------------------------------------------------- /templates/remix/basic-v2/app/database/types.ts: -------------------------------------------------------------------------------- 1 | export type { 2 | AuthSession as SupabaseAuthSession, 3 | SupabaseClient, 4 | } from "@supabase/supabase-js"; 5 | -------------------------------------------------------------------------------- /templates/remix/basic-v2/app/entry.client.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | 3 | import { RemixBrowser } from "@remix-run/react"; 4 | import { hydrateRoot } from "react-dom/client"; 5 | 6 | function hydrate() { 7 | React.startTransition(() => { 8 | hydrateRoot( 9 | document, 10 | 11 | 12 | 13 | ); 14 | }); 15 | } 16 | 17 | if (window.requestIdleCallback) { 18 | window.requestIdleCallback(hydrate); 19 | } else { 20 | window.setTimeout(hydrate, 1); 21 | } 22 | -------------------------------------------------------------------------------- /templates/remix/basic-v2/app/entry.server.tsx: -------------------------------------------------------------------------------- 1 | import { PassThrough } from "stream"; 2 | 3 | import { Response } from "@remix-run/node"; 4 | import type { EntryContext, Headers } from "@remix-run/node"; 5 | import { RemixServer } from "@remix-run/react"; 6 | import isbot from "isbot"; 7 | import { renderToPipeableStream } from "react-dom/server"; 8 | 9 | const ABORT_DELAY = 5000; 10 | 11 | export default function handleRequest( 12 | request: Request, 13 | responseStatusCode: number, 14 | responseHeaders: Headers, 15 | remixContext: EntryContext 16 | ) { 17 | const callbackName = isbot(request.headers.get("user-agent")) 18 | ? "onAllReady" 19 | : "onShellReady"; 20 | 21 | return new Promise((resolve, reject) => { 22 | let didError = false; 23 | 24 | const { pipe, abort } = renderToPipeableStream( 25 | , 29 | { 30 | [callbackName]() { 31 | const body = new PassThrough(); 32 | 33 | responseHeaders.set("Content-Type", "text/html"); 34 | 35 | resolve( 36 | new Response(body, { 37 | status: didError ? 500 : responseStatusCode, 38 | headers: responseHeaders, 39 | }) 40 | ); 41 | pipe(body); 42 | }, 43 | onShellError(err: unknown) { 44 | reject(err); 45 | }, 46 | onError(error: unknown) { 47 | didError = true; 48 | console.error(error); 49 | }, 50 | } 51 | ); 52 | setTimeout(abort, ABORT_DELAY); 53 | }); 54 | } 55 | -------------------------------------------------------------------------------- /templates/remix/basic-v2/app/hooks/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./use-fetcher"; 2 | -------------------------------------------------------------------------------- /templates/remix/basic-v2/app/hooks/use-fetcher.ts: -------------------------------------------------------------------------------- 1 | import { useFetcher } from "@remix-run/react"; 2 | import type { FetcherWithComponents } from "@remix-run/react"; 3 | import type { UseDataFunctionReturn } from "@remix-run/react/dist/components"; 4 | 5 | type TypedFetcherWithComponents = Omit, "data"> & { 6 | data: UseDataFunctionReturn | null; 7 | }; 8 | export function useTypedFetcher(): TypedFetcherWithComponents { 9 | return useFetcher() as TypedFetcherWithComponents; 10 | } 11 | -------------------------------------------------------------------------------- /templates/remix/basic-v2/app/modules/auth/components/continue-with-email-form.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | 3 | import { useTypedFetcher } from "~/hooks"; 4 | import type { action } from "~/routes/send-magic-link"; 5 | 6 | export function ContinueWithEmailForm() { 7 | const ref = React.useRef(null); 8 | const sendMagicLink = useTypedFetcher(); 9 | const { data, state, type } = sendMagicLink; 10 | const isSuccessFull = type === "done" && !data?.error; 11 | const isLoading = state === "submitting" || state === "loading"; 12 | const buttonLabel = isLoading 13 | ? "Sending you a link..." 14 | : "Continue with email"; 15 | 16 | React.useEffect(() => { 17 | if (isSuccessFull) { 18 | ref.current?.reset(); 19 | } 20 | }, [isSuccessFull]); 21 | 22 | return ( 23 | 29 | 36 |
41 | {!isSuccessFull ? data?.error : "Check your emails ✌️"} 42 |
43 | 50 |
51 | ); 52 | } 53 | -------------------------------------------------------------------------------- /templates/remix/basic-v2/app/modules/auth/components/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./logout-button"; 2 | -------------------------------------------------------------------------------- /templates/remix/basic-v2/app/modules/auth/components/logout-button.tsx: -------------------------------------------------------------------------------- 1 | import { Form } from "@remix-run/react"; 2 | 3 | export function LogoutButton() { 4 | return ( 5 |
9 | 15 |
16 | ); 17 | } 18 | -------------------------------------------------------------------------------- /templates/remix/basic-v2/app/modules/auth/const.ts: -------------------------------------------------------------------------------- 1 | export const SESSION_KEY = "authenticated"; 2 | export const SESSION_ERROR_KEY = "error"; 3 | export const SESSION_MAX_AGE = 60 * 60 * 24 * 7; // 7 days; 4 | export const LOGIN_URL = "/login"; 5 | export const REFRESH_THRESHOLD = 8; // 10 minutes left before token expires 6 | -------------------------------------------------------------------------------- /templates/remix/basic-v2/app/modules/auth/guards/assert-auth-session.server.ts: -------------------------------------------------------------------------------- 1 | import { redirect } from "@remix-run/node"; 2 | 3 | import { makeRedirectToFromHere } from "~/utils/http.server"; 4 | 5 | import { LOGIN_URL } from "../const"; 6 | import { commitAuthSession, getAuthSession } from "../session.server"; 7 | 8 | export async function assertAuthSession( 9 | request: Request, 10 | { onFailRedirectTo }: { onFailRedirectTo?: string } = {} 11 | ) { 12 | const authSession = await getAuthSession(request); 13 | 14 | // If there is no user session, Fly, You Fools! 🧙‍♂️ 15 | if (!authSession?.accessToken || !authSession?.refreshToken) { 16 | throw redirect( 17 | `${onFailRedirectTo || LOGIN_URL}?${makeRedirectToFromHere(request)}`, 18 | { 19 | headers: { 20 | "Set-Cookie": await commitAuthSession(request, { 21 | authSession: null, 22 | flashErrorMessage: "no-user-session", 23 | }), 24 | }, 25 | } 26 | ); 27 | } 28 | 29 | return authSession; 30 | } 31 | -------------------------------------------------------------------------------- /templates/remix/basic-v2/app/modules/auth/guards/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./require-auth-session.server"; 2 | -------------------------------------------------------------------------------- /templates/remix/basic-v2/app/modules/auth/guards/require-auth-session.server.ts: -------------------------------------------------------------------------------- 1 | import { REFRESH_THRESHOLD } from "../const"; 2 | import { refreshAuthSession } from "../mutations/refresh-auth-session.server"; 3 | import { getAuthAccountByAccessToken } from "../queries/get-auth-account.server"; 4 | import type { AuthSession } from "../session.server"; 5 | import { assertAuthSession } from "./assert-auth-session.server"; 6 | 7 | async function verifyAuthSession(authSession: AuthSession) { 8 | const authAccount = await getAuthAccountByAccessToken( 9 | authSession.accessToken 10 | ); 11 | 12 | return Boolean(authAccount); 13 | } 14 | 15 | function isExpiringSoon(expiresAt: number) { 16 | return (expiresAt - REFRESH_THRESHOLD) * 1000 < Date.now(); 17 | } 18 | 19 | /** 20 | * Assert auth session is present and verified from supabase auth api 21 | * 22 | * If used in loader (GET method) 23 | * - Refresh tokens if session is expired 24 | * - Return auth session if not expired 25 | * - Destroy session if refresh token is expired 26 | * 27 | * If used in action (POST method) 28 | * - Try to refresh session if expired and return this new session (it's your job to handle session commit) 29 | * - Return auth session if not expired 30 | * - Destroy session if refresh token is expired 31 | */ 32 | export async function requireAuthSession( 33 | request: Request, 34 | { onFailRedirectTo }: { onFailRedirectTo?: string } = {} 35 | ): Promise { 36 | // hello there 37 | const authSession = await assertAuthSession(request, { 38 | onFailRedirectTo, 39 | }); 40 | 41 | // ok, let's challenge its access token 42 | const isValidSession = await verifyAuthSession(authSession); 43 | 44 | // damn, access token is not valid or expires soon 45 | // let's try to refresh, in case of 🧐 46 | if (!isValidSession || isExpiringSoon(authSession.expiresAt)) { 47 | return refreshAuthSession(request); 48 | } 49 | 50 | // finally, we have a valid session, let's return it 51 | return authSession; 52 | } 53 | -------------------------------------------------------------------------------- /templates/remix/basic-v2/app/modules/auth/mutations/create-auth-account.server.ts: -------------------------------------------------------------------------------- 1 | import { getSupabaseAdmin } from "~/database"; 2 | 3 | export async function createAuthAccount(email: string, password: string) { 4 | const { data, error } = await getSupabaseAdmin().auth.admin.createUser({ 5 | email, 6 | password, 7 | email_confirm: true, // demo purpose, assert that email is confirmed. For production, check email confirmation 8 | }); 9 | 10 | if (!data || error) return null; 11 | 12 | return data; 13 | } 14 | -------------------------------------------------------------------------------- /templates/remix/basic-v2/app/modules/auth/mutations/delete-auth-account.server.ts: -------------------------------------------------------------------------------- 1 | import { getSupabaseAdmin } from "~/database"; 2 | 3 | export async function deleteAuthAccount(userId: string) { 4 | return getSupabaseAdmin().auth.admin.deleteUser(userId); 5 | } 6 | -------------------------------------------------------------------------------- /templates/remix/basic-v2/app/modules/auth/mutations/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./create-auth-account.server"; 2 | export * from "./delete-auth-account.server"; 3 | export * from "./sign-in.server"; 4 | export * from "./refresh-auth-session.server"; 5 | -------------------------------------------------------------------------------- /templates/remix/basic-v2/app/modules/auth/mutations/refresh-auth-session.server.ts: -------------------------------------------------------------------------------- 1 | import { redirect } from "@remix-run/node"; 2 | 3 | import { getSupabaseAdmin } from "~/database"; 4 | import { 5 | getCurrentPath, 6 | isGet, 7 | makeRedirectToFromHere, 8 | } from "~/utils/http.server"; 9 | 10 | import { LOGIN_URL } from "../const"; 11 | import type { AuthSession } from "../session.server"; 12 | import { getAuthSession, commitAuthSession } from "../session.server"; 13 | import { mapAuthSession } from "../utils/map-auth-session"; 14 | 15 | async function refreshAccessToken(refreshToken?: string) { 16 | if (!refreshToken) return null; 17 | 18 | const { data, error } = await getSupabaseAdmin().auth.setSession( 19 | refreshToken 20 | ); 21 | 22 | if (!data.session || error) return null; 23 | 24 | return mapAuthSession(data.session); 25 | } 26 | 27 | export async function refreshAuthSession( 28 | request: Request 29 | ): Promise { 30 | const authSession = await getAuthSession(request); 31 | 32 | const refreshedAuthSession = await refreshAccessToken( 33 | authSession?.refreshToken 34 | ); 35 | 36 | // 👾 game over, log in again 37 | // yes, arbitrary, but it's a good way to don't let an illegal user here with an expired token 38 | if (!refreshedAuthSession) { 39 | const redirectUrl = `${LOGIN_URL}?${makeRedirectToFromHere(request)}`; 40 | 41 | // here we throw instead of return because this function promise a AuthSession and not a response object 42 | // https://remix.run/docs/en/v1/guides/constraints#higher-order-functions 43 | throw redirect(redirectUrl, { 44 | headers: { 45 | "Set-Cookie": await commitAuthSession(request, { 46 | authSession: null, 47 | flashErrorMessage: "fail-refresh-auth-session", 48 | }), 49 | }, 50 | }); 51 | } 52 | 53 | // refresh is ok and we can redirect 54 | if (isGet(request)) { 55 | // here we throw instead of return because this function promise a UserSession and not a response object 56 | // https://remix.run/docs/en/v1/guides/constraints#higher-order-functions 57 | throw redirect(getCurrentPath(request), { 58 | headers: { 59 | "Set-Cookie": await commitAuthSession(request, { 60 | authSession: refreshedAuthSession, 61 | }), 62 | }, 63 | }); 64 | } 65 | 66 | // we can't redirect because we are in an action, so, deal with it and don't forget to handle session commit 👮‍♀️ 67 | return refreshedAuthSession; 68 | } 69 | -------------------------------------------------------------------------------- /templates/remix/basic-v2/app/modules/auth/mutations/sign-in.server.ts: -------------------------------------------------------------------------------- 1 | import { getSupabaseAdmin } from "~/database"; 2 | import { SERVER_URL } from "~/utils/env"; 3 | 4 | import { mapAuthSession } from "../utils/map-auth-session"; 5 | 6 | export async function signInWithEmail(email: string, password: string) { 7 | const { data, error } = await getSupabaseAdmin().auth.signInWithPassword({ 8 | email, 9 | password, 10 | }); 11 | 12 | if (!data.session || error) return null; 13 | 14 | return mapAuthSession(data.session); 15 | } 16 | 17 | export async function sendMagicLink(email: string) { 18 | return getSupabaseAdmin().auth.signInWithOtp({ 19 | email, 20 | options: { emailRedirectTo: `${SERVER_URL}/oauth/callback` }, 21 | }); 22 | } 23 | -------------------------------------------------------------------------------- /templates/remix/basic-v2/app/modules/auth/queries/get-auth-account.server.ts: -------------------------------------------------------------------------------- 1 | import { getSupabaseAdmin } from "~/database"; 2 | import type { SupabaseAuthSession } from "~/database/types"; 3 | 4 | export async function getAuthAccountByAccessToken( 5 | accessToken: SupabaseAuthSession["access_token"] 6 | ) { 7 | const { data, error } = await getSupabaseAdmin().auth.getUser(accessToken); 8 | 9 | if (!data || error) return null; 10 | 11 | return data; 12 | } 13 | -------------------------------------------------------------------------------- /templates/remix/basic-v2/app/modules/auth/session.server.ts: -------------------------------------------------------------------------------- 1 | import { createCookieSessionStorage, redirect } from "@remix-run/node"; 2 | 3 | import { NODE_ENV, SESSION_SECRET } from "../../utils/env"; 4 | import { safeRedirect } from "../../utils/http.server"; 5 | import { SESSION_ERROR_KEY, SESSION_KEY, SESSION_MAX_AGE } from "./const"; 6 | 7 | export interface AuthSession { 8 | accessToken: string; 9 | refreshToken: string; 10 | userId: string; 11 | email: string; 12 | expiresIn: number; 13 | expiresAt: number; 14 | providerToken?: string | null; 15 | } 16 | 17 | export type RealtimeAuthSession = Pick< 18 | AuthSession, 19 | "accessToken" | "expiresIn" | "expiresAt" 20 | >; 21 | 22 | /** 23 | * Session storage CRUD 24 | */ 25 | 26 | const sessionStorage = createCookieSessionStorage({ 27 | cookie: { 28 | name: "__session", 29 | httpOnly: true, 30 | path: "/", 31 | sameSite: "lax", 32 | secrets: [SESSION_SECRET], 33 | secure: NODE_ENV === "production", 34 | }, 35 | }); 36 | 37 | export async function createAuthSession({ 38 | request, 39 | authSession, 40 | redirectTo, 41 | }: { 42 | request: Request; 43 | authSession: AuthSession; 44 | redirectTo: string; 45 | }) { 46 | return redirect(safeRedirect(redirectTo), { 47 | headers: { 48 | "Set-Cookie": await commitAuthSession(request, { 49 | authSession, 50 | flashErrorMessage: null, 51 | }), 52 | }, 53 | }); 54 | } 55 | 56 | export async function getSession(request: Request) { 57 | const cookie = request.headers.get("Cookie"); 58 | return sessionStorage.getSession(cookie); 59 | } 60 | 61 | export async function getAuthSession( 62 | request: Request 63 | ): Promise { 64 | const session = await getSession(request); 65 | return session.get(SESSION_KEY); 66 | } 67 | 68 | export async function commitAuthSession( 69 | request: Request, 70 | { 71 | authSession, 72 | flashErrorMessage, 73 | }: { 74 | authSession?: AuthSession | null; 75 | flashErrorMessage?: string | null; 76 | } = {} 77 | ) { 78 | const session = await getSession(request); 79 | 80 | // allow user session to be null. 81 | // useful if you want to clear session and display a message explaining why 82 | if (authSession !== undefined) { 83 | session.set(SESSION_KEY, authSession); 84 | } 85 | 86 | session.flash(SESSION_ERROR_KEY, flashErrorMessage); 87 | 88 | return sessionStorage.commitSession(session, { maxAge: SESSION_MAX_AGE }); 89 | } 90 | 91 | export async function destroyAuthSession(request: Request) { 92 | const session = await getSession(request); 93 | 94 | return redirect("/", { 95 | headers: { 96 | "Set-Cookie": await sessionStorage.destroySession(session), 97 | }, 98 | }); 99 | } 100 | -------------------------------------------------------------------------------- /templates/remix/basic-v2/app/modules/auth/utils/map-auth-session.ts: -------------------------------------------------------------------------------- 1 | import type { SupabaseAuthSession } from "~/database/types"; 2 | 3 | import type { AuthSession } from "../session.server"; 4 | 5 | export function mapAuthSession( 6 | supabaseAuthSession: SupabaseAuthSession | null 7 | ): AuthSession | null { 8 | if (!supabaseAuthSession) return null; 9 | 10 | return { 11 | accessToken: supabaseAuthSession.access_token, 12 | refreshToken: supabaseAuthSession.refresh_token ?? "", 13 | userId: supabaseAuthSession.user.id ?? "", 14 | email: supabaseAuthSession.user.email ?? "", 15 | expiresIn: supabaseAuthSession.expires_in ?? -1, 16 | expiresAt: supabaseAuthSession.expires_at ?? -1, 17 | providerToken: supabaseAuthSession.provider_token, 18 | }; 19 | } 20 | -------------------------------------------------------------------------------- /templates/remix/basic-v2/app/modules/user/mutations/create-user-account.server.ts: -------------------------------------------------------------------------------- 1 | import { getSupabaseAdmin } from "~/database"; 2 | import { 3 | createAuthAccount, 4 | deleteAuthAccount, 5 | signInWithEmail, 6 | } from "~/modules/auth/mutations"; 7 | import type { AuthSession } from "~/modules/auth/session.server"; 8 | 9 | async function createUser({ 10 | email, 11 | userId, 12 | }: Pick) { 13 | const { error } = await getSupabaseAdmin() 14 | .from("profiles") 15 | .insert({ id: userId, email }); 16 | 17 | return error; 18 | } 19 | 20 | export async function tryCreateUser({ 21 | email, 22 | userId, 23 | }: Pick) { 24 | const error = await createUser({ 25 | userId, 26 | email, 27 | }); 28 | 29 | // user account created and have a session but unable to store in User table 30 | // we should delete the user account to allow retry create account again 31 | if (error) { 32 | await deleteAuthAccount(userId); 33 | return false; 34 | } 35 | 36 | return true; 37 | } 38 | 39 | export async function createUserAccount( 40 | email: string, 41 | password: string 42 | ): Promise { 43 | const authAccount = await createAuthAccount(email, password); 44 | 45 | // ok, no user account created 46 | if (!authAccount) return null; 47 | 48 | const authSession = await signInWithEmail(email, password); 49 | 50 | // user account created but no session 😱 51 | // we should delete the user account to allow retry create account again 52 | if (!authSession) { 53 | await deleteAuthAccount(authAccount.user.id); 54 | return null; 55 | } 56 | 57 | const success = await tryCreateUser(authSession); 58 | 59 | if (!success) return null; 60 | 61 | return authSession; 62 | } 63 | -------------------------------------------------------------------------------- /templates/remix/basic-v2/app/modules/user/mutations/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./create-user-account.server"; 2 | -------------------------------------------------------------------------------- /templates/remix/basic-v2/app/modules/user/queries/get-user.server.ts: -------------------------------------------------------------------------------- 1 | import { getSupabaseAdmin } from "~/database"; 2 | 3 | export async function getUserByEmail(email: string) { 4 | const { data, error } = await getSupabaseAdmin() 5 | .from("profiles") 6 | .select() 7 | .eq("email", email) 8 | .limit(1) 9 | .single(); 10 | 11 | if (!data || error) return null; 12 | 13 | return data; 14 | } 15 | -------------------------------------------------------------------------------- /templates/remix/basic-v2/app/modules/user/queries/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./get-user.server"; 2 | -------------------------------------------------------------------------------- /templates/remix/basic-v2/app/root.tsx: -------------------------------------------------------------------------------- 1 | import type { LinksFunction, MetaFunction } from "@remix-run/node"; 2 | import { json } from "@remix-run/node"; 3 | import { 4 | Links, 5 | LiveReload, 6 | Meta, 7 | Outlet, 8 | Scripts, 9 | ScrollRestoration, 10 | useLoaderData, 11 | } from "@remix-run/react"; 12 | 13 | import tailwindStylesheetUrl from "./styles/tailwind.css"; 14 | import { getBrowserEnv } from "./utils/env"; 15 | 16 | export const links: LinksFunction = () => [ 17 | { rel: "stylesheet", href: tailwindStylesheetUrl }, 18 | ]; 19 | 20 | export const meta: MetaFunction = () => ({ 21 | charset: "utf-8", 22 | title: "Remix Notes", 23 | viewport: "width=device-width,initial-scale=1", 24 | }); 25 | 26 | export async function loader() { 27 | return json({ 28 | env: getBrowserEnv(), 29 | }); 30 | } 31 | 32 | export default function App() { 33 | const { env } = useLoaderData(); 34 | 35 | return ( 36 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 |