refactor: remove old mobile project
@@ -1,20 +1,20 @@
|
||||
BASANGO_API_HOST=localhost
|
||||
# Committed development defaults. Override them in an ignored .env.dev,
|
||||
# .env.test, .env.prod, or .env.local file.
|
||||
NODE_ENV=dev
|
||||
|
||||
# API
|
||||
BASANGO_API_HOST=127.0.0.1
|
||||
BASANGO_API_PORT=3080
|
||||
BASANGO_API_ALLOWED_ORIGINS=http://localhost:3000,http://127.0.0.1:3000
|
||||
|
||||
BASANGO_API_KEY=some_secret_key
|
||||
BASANGO_API_CRAWLER_TOKEN=some_secret_key
|
||||
BASANGO_API_JWT_SECRET=some_secret_key
|
||||
|
||||
BASANGO_ADMIN_EMAIL="ngandubernard@gmail.com"
|
||||
BASANGO_ADMIN_NAME="Bernard Ngandu"
|
||||
BASANGO_ADMIN_PASSWORD=some_secret_key
|
||||
|
||||
BETTER_AUTH_SECRET=some_secret_key
|
||||
BETTER_AUTH_COOKIE_DOMAIN=".basango.ngandu.dev"
|
||||
BETTER_AUTH_URL="https://localhost:3080"
|
||||
|
||||
BASANGO_API_CRAWLER_TOKEN=development-crawler-token
|
||||
BASANGO_DATABASE_URL=postgresql://postgres:postgres@127.0.0.1:5432/app
|
||||
BASANGO_ENCRYPTION_KEY=0000000000000000000000000000000000000000000000000000000000000000
|
||||
BASANGO_LOGGER_LEVEL=debug
|
||||
BASANGO_ENCRYPTION_KEY=some_secret_key
|
||||
BASANGO_LOGGER_PRETTY=true
|
||||
|
||||
BASANGO_DATABASE_URL="postgresql://postgres:postgres@localhost:5432/app?serverVersion=16&charset=utf8"
|
||||
# Authentication
|
||||
BETTER_AUTH_SECRET=development-only-better-auth-secret
|
||||
BETTER_AUTH_URL=http://localhost:3080
|
||||
|
||||
# Dashboard (only VITE_ variables are exposed to browser code)
|
||||
VITE_PUBLIC_API_URL=http://localhost:3080
|
||||
VITE_PUBLIC_URL=http://localhost:3000
|
||||
|
||||
@@ -15,9 +15,7 @@ yarn-debug.log*
|
||||
yarn-error.log*
|
||||
.pnpm-debug.log*
|
||||
|
||||
.env.local
|
||||
.env.*.local
|
||||
.env.*.local.*
|
||||
.env.*
|
||||
.vercel
|
||||
*.tsbuildinfo
|
||||
.turbo
|
||||
|
||||
@@ -1,8 +0,0 @@
|
||||
{
|
||||
"recommendations": [
|
||||
"expo.vscode-expo-tools",
|
||||
"bradlc.vscode-tailwindcss",
|
||||
"expo.vscode-expo-tools",
|
||||
"yoavbls.pretty-ts-errors"
|
||||
]
|
||||
}
|
||||
@@ -1,28 +0,0 @@
|
||||
{
|
||||
"[javascript]": {
|
||||
"editor.defaultFormatter": "biomejs.biome"
|
||||
},
|
||||
"[typescript]": {
|
||||
"editor.defaultFormatter": "biomejs.biome"
|
||||
},
|
||||
"[typescriptreact]": {
|
||||
"editor.defaultFormatter": "biomejs.biome"
|
||||
},
|
||||
"editor.codeActionsOnSave": {
|
||||
"source.fixAll.biome": "explicit",
|
||||
"source.organizeImports.biome": "explicit"
|
||||
},
|
||||
"editor.defaultFormatter": "biomejs.biome",
|
||||
"editor.formatOnSave": true,
|
||||
"files.autoSave": "onFocusChange",
|
||||
"search.exclude": {
|
||||
"**/node_modules": true
|
||||
},
|
||||
"terminal.integrated.localEchoStyle": "dim",
|
||||
"typescript.enablePromptUseWorkspaceTsdk": true,
|
||||
"typescript.preferences.autoImportFileExcludePatterns": [
|
||||
"next/router.d.ts",
|
||||
"next/dist/client/router.d.ts"
|
||||
],
|
||||
"typescript.tsdk": "node_modules/typescript/lib"
|
||||
}
|
||||
@@ -30,6 +30,8 @@
|
||||
| Scope | Link |
|
||||
|-------------------|-----------------------------------------------|
|
||||
| Architecture | [ARCHITECTURE.md](./docs/architecture.md) |
|
||||
| Environment | [ENVIRONMENT.md](./docs/environment.md) |
|
||||
| Deployment | [DEPLOYMENT.md](./docs/deployment.md) |
|
||||
| TypeScript apps | [README.md](./docs/web/README.md) |
|
||||
| TypeScript style | [CODE-STYLE.md](./docs/web/code-style.md) |
|
||||
| Contributing | [CONTRIBUTING.md](./CONTRIBUTING.md) |
|
||||
|
||||
@@ -28,8 +28,8 @@
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"auth:create-admin": "bun run src/scripts/create-admin.ts",
|
||||
"dev": "bun run --hot src/index.ts",
|
||||
"start": "NODE_ENV=production bun run src/index.ts",
|
||||
"dev": "NODE_ENV=dev bun run --hot src/index.ts",
|
||||
"start": "NODE_ENV=prod bun run src/index.ts",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"type": "module"
|
||||
|
||||
@@ -9,7 +9,7 @@ import { v7 as uuidv7 } from "uuid";
|
||||
|
||||
import { sendPasswordResetEmail } from "#api/utils/password-reset-email";
|
||||
|
||||
const isProduction = env.NODE_ENV === "production";
|
||||
const isProduction = env.NODE_ENV === "prod";
|
||||
const baseURL = env.BETTER_AUTH_URL?.trim() ?? `http://localhost:${config.api.server.port}`;
|
||||
const cookieDomain = env.BETTER_AUTH_COOKIE_DOMAIN?.trim();
|
||||
const secret =
|
||||
|
||||
@@ -25,7 +25,7 @@ export async function sendPasswordResetEmail(input: PasswordResetEmail): Promise
|
||||
const apiKey = env.BASANGO_RESEND_API_KEY?.trim();
|
||||
|
||||
if (!apiKey) {
|
||||
if (env.NODE_ENV === "production") {
|
||||
if (env.NODE_ENV === "prod") {
|
||||
throw new Error("BASANGO_RESEND_API_KEY is required to send password reset emails.");
|
||||
}
|
||||
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
VITE_PUBLIC_API_URL=https://api.basango.ngandu.dev
|
||||
VITE_PUBLIC_URL=https://dashboard.basango.ngandu.dev
|
||||
|
||||
@@ -33,9 +33,8 @@ yarn-debug.log*
|
||||
yarn-error.log*
|
||||
.pnpm-debug.log*
|
||||
|
||||
# env files (can opt-in for committing if needed)
|
||||
# env files are defined at the repository root
|
||||
.env*
|
||||
!.env.production
|
||||
|
||||
# vercel
|
||||
.vercel
|
||||
|
||||
@@ -49,10 +49,10 @@
|
||||
"name": "@basango/dashboard",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"build": "vite build",
|
||||
"build": "vite build --mode prod",
|
||||
"clean": "rm -rf .output .tanstack node_modules",
|
||||
"dev": "vite dev",
|
||||
"start": "NODE_ENV=production node .output/server/index.mjs",
|
||||
"dev": "vite dev --mode dev",
|
||||
"start": "NODE_ENV=prod node .output/server/index.mjs",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"type": "module"
|
||||
|
||||
@@ -1,22 +1,40 @@
|
||||
import { normalizeNodeEnvironment, readEnvFiles } from "@basango/domain/config/environment";
|
||||
import tailwindcss from "@tailwindcss/vite";
|
||||
import { tanstackStart } from "@tanstack/react-start/plugin/vite";
|
||||
import viteReact from "@vitejs/plugin-react";
|
||||
import { nitro } from "nitro/vite";
|
||||
import { defineConfig } from "vite";
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [
|
||||
tanstackStart({
|
||||
router: {
|
||||
generatedRouteTree: "./routeTree.gen.ts",
|
||||
routesDirectory: "./routes",
|
||||
},
|
||||
}),
|
||||
nitro(),
|
||||
tailwindcss(),
|
||||
viteReact(),
|
||||
],
|
||||
resolve: {
|
||||
tsconfigPaths: true,
|
||||
},
|
||||
export default defineConfig(({ mode }) => {
|
||||
const nodeEnvironment = normalizeNodeEnvironment(mode);
|
||||
applyPublicEnvironment(nodeEnvironment);
|
||||
|
||||
return {
|
||||
define: {
|
||||
"process.env.NODE_ENV": JSON.stringify(nodeEnvironment),
|
||||
},
|
||||
envDir: false,
|
||||
plugins: [
|
||||
tanstackStart({
|
||||
router: {
|
||||
generatedRouteTree: "./routeTree.gen.ts",
|
||||
routesDirectory: "./routes",
|
||||
},
|
||||
}),
|
||||
nitro(),
|
||||
tailwindcss(),
|
||||
viteReact(),
|
||||
],
|
||||
resolve: {
|
||||
tsconfigPaths: true,
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
function applyPublicEnvironment(mode: string): void {
|
||||
for (const [key, value] of Object.entries(readEnvFiles({ nodeEnvironment: mode }))) {
|
||||
if (key.startsWith("VITE_") && value !== undefined && process.env[key] === undefined) {
|
||||
process.env[key] = value;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,43 +0,0 @@
|
||||
# Learn more https://docs.github.com/en/get-started/getting-started-with-git/ignoring-files
|
||||
|
||||
# dependencies
|
||||
node_modules/
|
||||
|
||||
# Expo
|
||||
.expo/
|
||||
dist/
|
||||
web-build/
|
||||
expo-env.d.ts
|
||||
|
||||
# Native
|
||||
.kotlin/
|
||||
*.orig.*
|
||||
*.jks
|
||||
*.p8
|
||||
*.p12
|
||||
*.key
|
||||
*.mobileprovision
|
||||
|
||||
# Metro
|
||||
.metro-health-check*
|
||||
|
||||
# debug
|
||||
npm-debug.*
|
||||
yarn-debug.*
|
||||
yarn-error.*
|
||||
|
||||
# macOS
|
||||
.DS_Store
|
||||
*.pem
|
||||
|
||||
# local env files
|
||||
.env*.local
|
||||
|
||||
# typescript
|
||||
*.tsbuildinfo
|
||||
|
||||
app-example
|
||||
|
||||
# generated native folders
|
||||
/ios
|
||||
/android
|
||||
@@ -1 +0,0 @@
|
||||
# Basango Mobile
|
||||
@@ -1,48 +0,0 @@
|
||||
{
|
||||
"expo": {
|
||||
"android": {
|
||||
"adaptiveIcon": {
|
||||
"backgroundColor": "#E6F4FE",
|
||||
"backgroundImage": "./assets/images/android-icon-background.png",
|
||||
"foregroundImage": "./assets/images/android-icon-foreground.png",
|
||||
"monochromeImage": "./assets/images/android-icon-monochrome.png"
|
||||
},
|
||||
"edgeToEdgeEnabled": true,
|
||||
"predictiveBackGestureEnabled": false
|
||||
},
|
||||
"experiments": {
|
||||
"reactCompiler": true,
|
||||
"typedRoutes": true
|
||||
},
|
||||
"icon": "./assets/images/icon.png",
|
||||
"ios": {
|
||||
"supportsTablet": true
|
||||
},
|
||||
"name": "basango",
|
||||
"newArchEnabled": true,
|
||||
"orientation": "portrait",
|
||||
"plugins": [
|
||||
"expo-router",
|
||||
[
|
||||
"expo-splash-screen",
|
||||
{
|
||||
"backgroundColor": "#ffffff",
|
||||
"dark": {
|
||||
"backgroundColor": "#000000"
|
||||
},
|
||||
"image": "./assets/images/splash-icon.png",
|
||||
"imageWidth": 200,
|
||||
"resizeMode": "contain"
|
||||
}
|
||||
]
|
||||
],
|
||||
"scheme": "basango",
|
||||
"slug": "basango",
|
||||
"userInterfaceStyle": "automatic",
|
||||
"version": "1.0.0",
|
||||
"web": {
|
||||
"favicon": "./assets/images/favicon.png",
|
||||
"output": "static"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
Before Width: | Height: | Size: 17 KiB |
|
Before Width: | Height: | Size: 77 KiB |
|
Before Width: | Height: | Size: 4.0 KiB |
|
Before Width: | Height: | Size: 1.1 KiB |
|
Before Width: | Height: | Size: 384 KiB |
|
Before Width: | Height: | Size: 5.0 KiB |
|
Before Width: | Height: | Size: 6.2 KiB |
|
Before Width: | Height: | Size: 14 KiB |
|
Before Width: | Height: | Size: 21 KiB |
|
Before Width: | Height: | Size: 17 KiB |
@@ -1,46 +0,0 @@
|
||||
{
|
||||
"dependencies": {
|
||||
"@expo/vector-icons": "^15.0.3",
|
||||
"@react-navigation/bottom-tabs": "^7.4.0",
|
||||
"@react-navigation/elements": "^2.6.3",
|
||||
"@react-navigation/native": "^7.1.8",
|
||||
"expo": "~54.0.22",
|
||||
"expo-constants": "~18.0.10",
|
||||
"expo-font": "~14.0.9",
|
||||
"expo-haptics": "~15.0.7",
|
||||
"expo-image": "~3.0.10",
|
||||
"expo-linking": "~8.0.8",
|
||||
"expo-router": "~6.0.14",
|
||||
"expo-splash-screen": "~31.0.10",
|
||||
"expo-status-bar": "~3.0.8",
|
||||
"expo-symbols": "~1.0.7",
|
||||
"expo-system-ui": "~6.0.8",
|
||||
"expo-web-browser": "~15.0.9",
|
||||
"react": "catalog:",
|
||||
"react-dom": "catalog:",
|
||||
"react-native": "0.81.5",
|
||||
"react-native-gesture-handler": "~2.28.0",
|
||||
"react-native-reanimated": "~4.1.1",
|
||||
"react-native-safe-area-context": "~5.6.0",
|
||||
"react-native-screens": "~4.16.0",
|
||||
"react-native-web": "~0.21.0",
|
||||
"react-native-worklets": "0.5.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "catalog:",
|
||||
"typescript": "catalog:"
|
||||
},
|
||||
"main": "expo-router/entry",
|
||||
"name": "@basango/mobile",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"android": "expo start --android",
|
||||
"clean": "rm -rf .expo node_modules",
|
||||
"ios": "expo start --ios",
|
||||
"lint": "expo lint",
|
||||
"start": "expo start",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"web": "expo start --web"
|
||||
},
|
||||
"version": "1.0.0"
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
import { Stack } from "expo-router";
|
||||
|
||||
export default function RootLayout() {
|
||||
return <Stack />;
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
import { Text, View } from "react-native";
|
||||
|
||||
export default function Index() {
|
||||
return (
|
||||
<View
|
||||
style={{
|
||||
alignItems: "center",
|
||||
flex: 1,
|
||||
justifyContent: "center",
|
||||
}}
|
||||
>
|
||||
<Text>Edit app/index.tsx to edit this screen.</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
@@ -1,10 +0,0 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"paths": {
|
||||
"#mobile/*": ["./src/*"]
|
||||
},
|
||||
"strict": true
|
||||
},
|
||||
"extends": "expo/tsconfig.base",
|
||||
"include": ["**/*.ts", "**/*.tsx", ".expo/types/**/*.ts", "expo-env.d.ts"]
|
||||
}
|
||||
@@ -37,7 +37,7 @@ BASANGO_API_CRAWLER_TOKEN=replace-with-the-same-secret-as-the-api
|
||||
|
||||
The endpoint is the API origin without `/ingest`; the crawler appends `/ingest/articles` and the
|
||||
other ingestion paths itself. The token must exactly match `BASANGO_API_CRAWLER_TOKEN` in the
|
||||
TypeScript API's root `.env`. Keep both values secret. Before Certbot enables HTTPS, use
|
||||
TypeScript API's root `.env.local`. Keep both values secret. Before Certbot enables HTTPS, use
|
||||
`http://api.basango.ngandu.dev` only for an initial connectivity check, then switch the crawler to
|
||||
the HTTPS endpoint.
|
||||
|
||||
@@ -59,10 +59,17 @@ pm2 startup
|
||||
pm2 save
|
||||
```
|
||||
|
||||
The root `.env` must provide the API database, encryption, crawler token, and Better Auth secrets.
|
||||
Use these public authentication values:
|
||||
The committed root `.env` only contains safe development examples. On the server, create an ignored
|
||||
root `.env.prod` for production-wide values:
|
||||
|
||||
```dotenv
|
||||
VITE_PUBLIC_API_URL=https://api.basango.ngandu.dev
|
||||
VITE_PUBLIC_URL=https://dashboard.basango.ngandu.dev
|
||||
BETTER_AUTH_URL=https://api.basango.ngandu.dev
|
||||
BETTER_AUTH_COOKIE_DOMAIN=.basango.ngandu.dev
|
||||
```
|
||||
|
||||
Put the API database, encryption key, crawler token, and Better Auth secret in root `.env.local`.
|
||||
That file is loaded after `.env.prod`, so it is the final file-based override and stays outside Git.
|
||||
Variables injected directly by PM2 or the host still take precedence. See the
|
||||
[environment configuration guide](environment.md) for the complete convention.
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
# Environment configuration
|
||||
|
||||
All TypeScript applications read environment files from the repository root. Do not create
|
||||
application-specific environment files under `apps/` or `packages/`.
|
||||
|
||||
## Files and precedence
|
||||
|
||||
Environment files are loaded from lowest to highest priority:
|
||||
|
||||
1. `.env` contains committed, safe development defaults and documents the available variables.
|
||||
2. `.env.dev`, `.env.test`, or `.env.prod` overrides `.env` for the selected environment.
|
||||
3. `.env.local` overrides every environment file and is reserved for machine-local values and
|
||||
secrets.
|
||||
4. Variables already supplied by the process, CI system, or process manager override all files.
|
||||
|
||||
Only `.env` is committed. Every `.env.*` file is ignored by Git. Never put production credentials
|
||||
or personal secrets in `.env`.
|
||||
|
||||
`NODE_ENV` accepts exactly `dev`, `test`, or `prod`, selecting `.env.dev`, `.env.test`, or
|
||||
`.env.prod` respectively. The longer `development` and `production` values are intentionally
|
||||
rejected so every TypeScript application uses the same vocabulary.
|
||||
|
||||
The API, Drizzle commands, and dashboard build share this ordering. The dashboard exposes only
|
||||
variables prefixed with `VITE_` to browser code. If the mobile application gains public runtime
|
||||
configuration, use `EXPO_PUBLIC_` variables and keep them in these same root files.
|
||||
|
||||
## Local development
|
||||
|
||||
The committed `.env` is enough for the default local Docker services. Put personal overrides in
|
||||
`.env.local`:
|
||||
|
||||
```dotenv
|
||||
BASANGO_API_CRAWLER_TOKEN=my-local-token
|
||||
```
|
||||
|
||||
Use `.env.dev` when an override should apply to every developer on one machine but should still be
|
||||
superseded by `.env.local`.
|
||||
|
||||
## Production
|
||||
|
||||
On the server, set `NODE_ENV=prod` and create `.env.prod` for production-wide values. Put
|
||||
host-specific credentials in `.env.local`. For example:
|
||||
|
||||
```dotenv
|
||||
# .env.prod
|
||||
VITE_PUBLIC_API_URL=https://api.basango.ngandu.dev
|
||||
VITE_PUBLIC_URL=https://dashboard.basango.ngandu.dev
|
||||
BETTER_AUTH_URL=https://api.basango.ngandu.dev
|
||||
BETTER_AUTH_COOKIE_DOMAIN=.basango.ngandu.dev
|
||||
```
|
||||
|
||||
```dotenv
|
||||
# .env.local
|
||||
BASANGO_API_CRAWLER_TOKEN=replace-with-a-long-random-secret
|
||||
BASANGO_DATABASE_URL=postgresql://...
|
||||
BASANGO_ENCRYPTION_KEY=replace-with-a-64-character-key
|
||||
BETTER_AUTH_SECRET=replace-with-a-long-random-secret
|
||||
```
|
||||
|
||||
The crawler uses the same ingestion token as the API but is configured in the crawler deployment,
|
||||
not in this TypeScript repository.
|
||||
@@ -19,6 +19,10 @@ substantive Basango-owned behavior.
|
||||
Keep route composition, navigation, environment access, and platform-specific behavior in the owning application.
|
||||
Start new product behavior there. Extract it only after a stable package responsibility is visible.
|
||||
|
||||
Environment files are an exception to application ownership: all TypeScript applications use the repository-root
|
||||
precedence documented in the [environment configuration guide](../environment.md). Application-specific `.env` files
|
||||
must not be added under `apps/` or `packages/`.
|
||||
|
||||
## Shared packages
|
||||
|
||||
- `@basango/domain` owns runtime schemas and inferred TypeScript contracts shared across process boundaries. It must
|
||||
|
||||
@@ -1,31 +1,16 @@
|
||||
const path = require("node:path");
|
||||
|
||||
const sharedEnv = {
|
||||
NODE_ENV: "production",
|
||||
};
|
||||
|
||||
const apiEnv = {
|
||||
...sharedEnv,
|
||||
BASANGO_API_HOST: "127.0.0.1",
|
||||
BASANGO_API_PORT: "3080",
|
||||
};
|
||||
|
||||
const dashboardEnv = {
|
||||
...sharedEnv,
|
||||
HOST: "127.0.0.1",
|
||||
PORT: "3000",
|
||||
VITE_PUBLIC_API_URL: "https://api.basango.ngandu.dev",
|
||||
VITE_PUBLIC_URL: "https://dashboard.basango.ngandu.dev",
|
||||
};
|
||||
|
||||
module.exports = {
|
||||
apps: [
|
||||
{
|
||||
args: "run start",
|
||||
autorestart: true,
|
||||
cwd: path.join(__dirname, "apps", "api"),
|
||||
env: apiEnv,
|
||||
env_production: apiEnv,
|
||||
env: {
|
||||
BASANGO_API_HOST: "127.0.0.1",
|
||||
BASANGO_API_PORT: "3080",
|
||||
NODE_ENV: "prod",
|
||||
},
|
||||
max_restarts: 5,
|
||||
name: "api.basango.ngandu.dev",
|
||||
script: "bun",
|
||||
@@ -35,8 +20,13 @@ module.exports = {
|
||||
args: "run start",
|
||||
autorestart: true,
|
||||
cwd: path.join(__dirname, "apps", "dashboard"),
|
||||
env: dashboardEnv,
|
||||
env_production: dashboardEnv,
|
||||
env: {
|
||||
HOST: "127.0.0.1",
|
||||
NODE_ENV: "prod",
|
||||
PORT: "3000",
|
||||
VITE_PUBLIC_API_URL: "https://api.basango.ngandu.dev",
|
||||
VITE_PUBLIC_URL: "https://dashboard.basango.ngandu.dev",
|
||||
},
|
||||
max_restarts: 5,
|
||||
name: "dashboard.basango.ngandu.dev",
|
||||
script: "bun",
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
{ "dialect": "postgresql", "entries": [], "version": "7" }
|
||||
@@ -1,8 +1,8 @@
|
||||
import { readEnvFile } from "@basango/domain/config/environment";
|
||||
import { readEnvFiles } from "@basango/domain/config/environment";
|
||||
import { defineConfig } from "drizzle-kit";
|
||||
|
||||
const databaseUrl = (
|
||||
process.env.BASANGO_DATABASE_URL ?? readEnvFile().BASANGO_DATABASE_URL
|
||||
process.env.BASANGO_DATABASE_URL ?? readEnvFiles().BASANGO_DATABASE_URL
|
||||
)?.trim();
|
||||
|
||||
if (!databaseUrl) {
|
||||
|
||||
@@ -4,7 +4,7 @@ import { Pool } from "pg";
|
||||
|
||||
import * as schema from "#db/schema";
|
||||
|
||||
const isDevelopment = env.NODE_ENV === "development";
|
||||
const isDevelopment = env.NODE_ENV === "dev";
|
||||
|
||||
const pool = new Pool({
|
||||
allowExitOnIdle: true,
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
import { afterEach, describe, expect, test } from "bun:test";
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
|
||||
import { normalizeNodeEnvironment, readEnvFiles, resolveEnvFiles } from "./environment";
|
||||
|
||||
const temporaryDirectories: string[] = [];
|
||||
|
||||
afterEach(() => {
|
||||
for (const directory of temporaryDirectories.splice(0)) {
|
||||
fs.rmSync(directory, { force: true, recursive: true });
|
||||
}
|
||||
});
|
||||
|
||||
describe("normalizeNodeEnvironment", () => {
|
||||
test.each([
|
||||
["dev", "dev"],
|
||||
["test", "test"],
|
||||
["prod", "prod"],
|
||||
] as const)("normalizes %s to %s", (value, expected) => {
|
||||
expect(normalizeNodeEnvironment(value)).toBe(expected);
|
||||
});
|
||||
|
||||
test.each(["development", "production"])("rejects unsupported value %s", (value) => {
|
||||
expect(() => normalizeNodeEnvironment(value)).toThrow(`Unsupported NODE_ENV: ${value}`);
|
||||
});
|
||||
});
|
||||
|
||||
describe("environment files", () => {
|
||||
test("loads base, mode, then local values", () => {
|
||||
const directory = createEnvironmentDirectory({
|
||||
".env": "VALUE=base\nBASE_ONLY=yes\n",
|
||||
".env.local": "VALUE=local\nLOCAL_ONLY=yes\n",
|
||||
".env.prod": "VALUE=production\nMODE_ONLY=yes\n",
|
||||
});
|
||||
|
||||
expect(readEnvFiles({ cwd: directory, nodeEnvironment: "prod" })).toEqual({
|
||||
BASE_ONLY: "yes",
|
||||
LOCAL_ONLY: "yes",
|
||||
MODE_ONLY: "yes",
|
||||
VALUE: "local",
|
||||
});
|
||||
});
|
||||
|
||||
test.each([
|
||||
["dev", ".env.dev"],
|
||||
["test", ".env.test"],
|
||||
["prod", ".env.prod"],
|
||||
] as const)("selects %s mode file", (mode, expectedFile) => {
|
||||
const directory = createEnvironmentDirectory({ ".env": "VALUE=base\n" });
|
||||
const files = resolveEnvFiles({ cwd: directory, nodeEnvironment: mode });
|
||||
|
||||
expect(files.map((file) => path.basename(file.path))).toEqual([
|
||||
".env",
|
||||
expectedFile,
|
||||
".env.local",
|
||||
]);
|
||||
});
|
||||
|
||||
test("uses NODE_ENV from the base file when no mode is provided", () => {
|
||||
const directory = createEnvironmentDirectory({
|
||||
".env": "NODE_ENV=prod\nVALUE=base\n",
|
||||
".env.prod": "VALUE=production\n",
|
||||
});
|
||||
const previousNodeEnvironment = process.env.NODE_ENV;
|
||||
delete process.env.NODE_ENV;
|
||||
|
||||
try {
|
||||
expect(readEnvFiles({ cwd: directory }).VALUE).toBe("production");
|
||||
} finally {
|
||||
if (previousNodeEnvironment === undefined) {
|
||||
delete process.env.NODE_ENV;
|
||||
} else {
|
||||
process.env.NODE_ENV = previousNodeEnvironment;
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
function createEnvironmentDirectory(files: Record<string, string>): string {
|
||||
const directory = fs.mkdtempSync(path.join(os.tmpdir(), "basango-env-"));
|
||||
temporaryDirectories.push(directory);
|
||||
|
||||
for (const [fileName, content] of Object.entries(files)) {
|
||||
fs.writeFileSync(path.join(directory, fileName), content);
|
||||
}
|
||||
|
||||
return directory;
|
||||
}
|
||||
@@ -2,13 +2,41 @@ import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { parseEnv } from "node:util";
|
||||
|
||||
export const findEnvPath = (): string => {
|
||||
export type NodeEnvironment = "dev" | "test" | "prod";
|
||||
|
||||
export interface EnvironmentFileOptions {
|
||||
cwd?: string;
|
||||
envPath?: string;
|
||||
nodeEnvironment?: string;
|
||||
}
|
||||
|
||||
interface EnvironmentFile {
|
||||
optional: boolean;
|
||||
path: string;
|
||||
}
|
||||
|
||||
export function normalizeNodeEnvironment(value?: string): NodeEnvironment {
|
||||
switch (value?.trim().toLowerCase()) {
|
||||
case undefined:
|
||||
case "":
|
||||
case "dev":
|
||||
return "dev";
|
||||
case "test":
|
||||
return "test";
|
||||
case "prod":
|
||||
return "prod";
|
||||
default:
|
||||
throw new Error(`Unsupported NODE_ENV: ${value}`);
|
||||
}
|
||||
}
|
||||
|
||||
export function findEnvPath(cwd = process.cwd()): string {
|
||||
const configured = process.env.BASANGO_ENV_PATH?.trim();
|
||||
if (configured) {
|
||||
return path.resolve(configured);
|
||||
}
|
||||
|
||||
let current = process.cwd();
|
||||
let current = path.resolve(cwd);
|
||||
while (true) {
|
||||
const candidate = path.join(current, ".env");
|
||||
if (fs.existsSync(candidate)) {
|
||||
@@ -17,16 +45,55 @@ export const findEnvPath = (): string => {
|
||||
|
||||
const parent = path.dirname(current);
|
||||
if (parent === current) {
|
||||
return path.join(process.cwd(), ".env");
|
||||
return path.join(path.resolve(cwd), ".env");
|
||||
}
|
||||
current = parent;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export const readEnvFile = (envPath = findEnvPath()): Record<string, string | undefined> => {
|
||||
if (!fs.existsSync(envPath)) {
|
||||
export function resolveEnvFiles(options: EnvironmentFileOptions = {}): EnvironmentFile[] {
|
||||
const envPath = path.resolve(options.envPath ?? findEnvPath(options.cwd));
|
||||
const baseEnvironment = readOptionalEnvFile(envPath);
|
||||
const nodeEnvironment = normalizeNodeEnvironment(
|
||||
options.nodeEnvironment ?? process.env.NODE_ENV ?? baseEnvironment.NODE_ENV,
|
||||
);
|
||||
const directory = path.dirname(envPath);
|
||||
const paths = [
|
||||
envPath,
|
||||
path.join(directory, `.env.${nodeEnvironment}`),
|
||||
path.join(directory, ".env.local"),
|
||||
];
|
||||
|
||||
return [...new Set(paths)].map((filePath, index) => ({
|
||||
optional: index > 0,
|
||||
path: filePath,
|
||||
}));
|
||||
}
|
||||
|
||||
export function readEnvFiles(
|
||||
options: EnvironmentFileOptions = {},
|
||||
): Record<string, string | undefined> {
|
||||
const environment: Record<string, string | undefined> = {};
|
||||
|
||||
for (const file of resolveEnvFiles(options)) {
|
||||
if (!fs.existsSync(file.path)) {
|
||||
if (file.optional) {
|
||||
continue;
|
||||
}
|
||||
|
||||
throw new Error(`Environment file not found: ${file.path}`);
|
||||
}
|
||||
|
||||
Object.assign(environment, parseEnv(fs.readFileSync(file.path, "utf8")));
|
||||
}
|
||||
|
||||
return environment;
|
||||
}
|
||||
|
||||
function readOptionalEnvFile(filePath: string): Record<string, string | undefined> {
|
||||
if (!fs.existsSync(filePath)) {
|
||||
return {};
|
||||
}
|
||||
|
||||
return parseEnv(fs.readFileSync(envPath, "utf8"));
|
||||
};
|
||||
return parseEnv(fs.readFileSync(filePath, "utf8"));
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@ import z from "zod";
|
||||
import { ApiConfigurationSchema } from "./api";
|
||||
import { DatabaseConfigurationSchema } from "./database";
|
||||
import { EncryptionConfigurationSchema } from "./encryption";
|
||||
import { findEnvPath } from "./environment";
|
||||
import { resolveEnvFiles } from "./environment";
|
||||
import { LoggerConfigurationSchema } from "./logger";
|
||||
import { SharedConfigurationSchema } from "./shared";
|
||||
|
||||
@@ -16,10 +16,12 @@ export * from "./encryption";
|
||||
export * from "./logger";
|
||||
export * from "./shared";
|
||||
|
||||
const nodeEnvironmentSchema = z.enum(["dev", "test", "prod"]).default("dev");
|
||||
|
||||
export const { env, config } = await defineConfig({
|
||||
cwd: fileURLToPath(new URL("../..", import.meta.url)),
|
||||
environment: {
|
||||
files: [{ optional: true, path: findEnvPath() }],
|
||||
files: resolveEnvFiles(),
|
||||
redact: [
|
||||
"BASANGO_ADMIN_PASSWORD",
|
||||
"BASANGO_API_CRAWLER_TOKEN",
|
||||
@@ -64,7 +66,7 @@ export const { env, config } = await defineConfig({
|
||||
BETTER_AUTH_COOKIE_DOMAIN: z.string().optional(),
|
||||
BETTER_AUTH_SECRET: z.string().optional(),
|
||||
BETTER_AUTH_URL: z.string().optional(),
|
||||
NODE_ENV: z.enum(["development", "test", "production"]).optional(),
|
||||
NODE_ENV: nodeEnvironmentSchema,
|
||||
}),
|
||||
},
|
||||
schema: z.object({
|
||||
|
||||
@@ -98,7 +98,7 @@ const createPinoLogger = (): LoggerLike => {
|
||||
|
||||
return createLogger({
|
||||
level: config.logger.level,
|
||||
...(env.NODE_ENV !== "production" &&
|
||||
...(env.NODE_ENV !== "prod" &&
|
||||
env.BASANGO_LOGGER_PRETTY === "true" && {
|
||||
transport: {
|
||||
options: {
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
{
|
||||
"$schema": "https://turborepo.com/schema.json",
|
||||
"globalDependencies": ["**/.env"],
|
||||
"globalDependencies": [".env", ".env.*"],
|
||||
"globalEnv": ["NODE_ENV", "VITE_*", "EXPO_PUBLIC_*", "VERCEL_TARGET_ENV", "VERCEL_URL"],
|
||||
"globalPassThroughEnv": ["BASANGO_*", "BETTER_AUTH_*"],
|
||||
"tasks": {
|
||||
"build": {
|
||||
"dependsOn": ["^build"],
|
||||
@@ -14,7 +16,7 @@
|
||||
},
|
||||
"dev": {
|
||||
"cache": false,
|
||||
"inputs": ["$TURBO_DEFAULT$", ".env"],
|
||||
"inputs": ["$TURBO_DEFAULT$", ".env*"],
|
||||
"persistent": true
|
||||
},
|
||||
"start": {
|
||||
|
||||