diff --git a/.gitignore b/.gitignore index d6dc4826ca..2bff657491 100644 --- a/.gitignore +++ b/.gitignore @@ -163,9 +163,6 @@ __pycache__/ *.pyo # Virtual environments -.env* -!.env*.example -!engine/.env .venv* env*/ venv*/ @@ -173,6 +170,9 @@ ENV/ env.bak/ venv.bak/ +# Env files (secrets / local overrides). Subproject .gitignore files whitelist any committed defaults. +.env* + # VS Code /.vscode/**/* !/.vscode/settings.json diff --git a/.taskfiles/frontend.yml b/.taskfiles/frontend.yml index 4156a34f1c..984ae4b322 100644 --- a/.taskfiles/frontend.yml +++ b/.taskfiles/frontend.yml @@ -22,9 +22,8 @@ tasks: - npx tsx scripts/setup-env.ts sources: - scripts/setup-env.ts - - config/.env.example generates: - - .env + - .env.local prepare:env:saas: desc: "Generate .env and .env.saas from examples if missing" @@ -34,11 +33,9 @@ tasks: - npx tsx scripts/setup-env.ts --saas sources: - scripts/setup-env.ts - - config/.env.example - - config/.env.saas.example generates: - - .env - - .env.saas + - .env.local + - .env.saas.local prepare:env:desktop: desc: "Generate .env and .env.desktop from examples if missing" @@ -48,11 +45,9 @@ tasks: - npx tsx scripts/setup-env.ts --desktop sources: - scripts/setup-env.ts - - config/.env.example - - config/.env.desktop.example generates: - - .env - - .env.desktop + - .env.local + - .env.desktop.local prepare:icons: desc: "Generate icon bundle from source references" diff --git a/AGENTS.md b/AGENTS.md index 5ed391821e..49e456eb71 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -136,12 +136,14 @@ The project structure is defined in `engine/pyproject.toml`. Any new dependencie - **Development**: `task desktop:dev` for desktop dev mode #### Environment Variables -- All `VITE_*` variables must be declared in the appropriate example file: - - `frontend/config/.env.example` — core, proprietary, and shared vars - - `frontend/config/.env.saas.example` — SaaS-only vars - - `frontend/config/.env.desktop.example` — desktop (Tauri)-only vars -- Never use `|| 'hardcoded-fallback'` inline — put defaults in the example files -- `task frontend:prepare` / `prepare:saas` / `prepare:desktop` auto-create the env files from examples on first run, and error if any required keys are missing +- All `VITE_*` variables must be declared in the appropriate committed env file: + - `frontend/.env` — core, proprietary, and shared vars + - `frontend/.env.saas` — SaaS-only vars (layered on top of `.env` in SaaS mode) + - `frontend/.env.desktop` — desktop (Tauri)-only vars (layered on top of `.env` in desktop mode) +- These files are committed to Git and must not contain private keys +- Local overrides (API keys, machine-specific settings) go in uncommitted sibling `.env.local` / `.env.saas.local` / `.env.desktop.local` files — Vite automatically layers them on top +- Never use `|| 'hardcoded-fallback'` inline — put defaults in the committed env files +- `task frontend:prepare` / `prepare:saas` / `prepare:desktop` create empty `.local` override files on first run - Prepare runs automatically as a dependency of all `dev*`, `build*`, and `desktop*` tasks - See `frontend/README.md#environment-variables` for full documentation diff --git a/engine/.gitignore b/engine/.gitignore index 890e5247ba..0602cd44db 100644 --- a/engine/.gitignore +++ b/engine/.gitignore @@ -21,6 +21,9 @@ yarn-error.log* # Environment .env.local +# Root .gitignore ignores all .env* - whitelist our committed .env here +!.env + # LaTeX outputs *.aux *.log diff --git a/frontend/config/.env.example b/frontend/.env similarity index 70% rename from frontend/config/.env.example rename to frontend/.env index 1df035f4d5..f8f3c2e308 100644 --- a/frontend/config/.env.example +++ b/frontend/.env @@ -1,4 +1,8 @@ +############################################################################### # Frontend environment variables for core and proprietary builds. +# Values can be overridden in the uncommitted sibling `.env.local` file. +# Note: This file is committed to Git, so should not contain any private keys. +############################################################################### # API base URL — use / for same-origin (default for web builds) VITE_API_BASE_URL=/ diff --git a/frontend/config/.env.desktop.example b/frontend/.env.desktop similarity index 62% rename from frontend/config/.env.desktop.example rename to frontend/.env.desktop index a83666a4e1..1cddad78e9 100644 --- a/frontend/config/.env.desktop.example +++ b/frontend/.env.desktop @@ -1,5 +1,9 @@ +############################################################################### # Frontend environment variables for desktop (Tauri) builds. # Layered on top of .env when running in desktop mode. +# Values can be overridden in the uncommitted sibling `.env.desktop.local` file. +# Note: This file is committed to Git, so should not contain any private keys. +############################################################################### # Desktop backend endpoint — leave blank to use VITE_API_BASE_URL from .env VITE_DESKTOP_BACKEND_URL= diff --git a/frontend/config/.env.saas.example b/frontend/.env.saas similarity index 56% rename from frontend/config/.env.saas.example rename to frontend/.env.saas index cc14a11dc7..2de6a435fc 100644 --- a/frontend/config/.env.saas.example +++ b/frontend/.env.saas @@ -1,5 +1,9 @@ +############################################################################### # Frontend environment variables for SaaS builds. # Layered on top of .env when running in SaaS mode. +# Values can be overridden in the uncommitted sibling `.env.saas.local` file. +# Note: This file is committed to Git, so should not contain any private keys. +############################################################################### # Userback feedback widget — leave blank to disable VITE_USERBACK_TOKEN= diff --git a/frontend/.gitignore b/frontend/.gitignore index 3755d7fa70..cf6ed7061e 100644 --- a/frontend/.gitignore +++ b/frontend/.gitignore @@ -14,14 +14,13 @@ # misc .DS_Store -.env -.env.saas -.env.desktop .env.local -.env.development.local -.env.test.local -.env.production.local -!.env*.example +.env.*.local + +# Root .gitignore ignores all .env* - whitelist our committed ones here +!.env +!.env.desktop +!.env.saas npm-debug.log* yarn-debug.log* @@ -32,4 +31,4 @@ test-results # auto-generated files /src/assets/material-symbols-icons.json -/src/assets/material-symbols-icons.d.ts \ No newline at end of file +/src/assets/material-symbols-icons.d.ts diff --git a/frontend/README.md b/frontend/README.md index a8802b24ef..0028c07c26 100644 --- a/frontend/README.md +++ b/frontend/README.md @@ -15,11 +15,15 @@ For desktop app development, see the [Tauri](#tauri) section below. ## Environment Variables -The frontend requires environment variables to be set before running. `task frontend:dev` will create a `.env` file for you automatically on first run using the defaults from `config/.env.example` - for most development work this is all you need. +Environment variables live in committed `.env` files at the frontend root: -If you need to configure specific services (Google Drive, Supabase, Stripe, PostHog), edit your local `.env` file. The values in `config/.env.example` show what each variable does and provides sensible defaults where applicable. +- `.env` — used by all builds (core, proprietary, and as the base for desktop/SaaS) +- `.env.desktop` — additional vars loaded in desktop (Tauri) mode +- `.env.saas` — additional vars loaded in SaaS mode -For desktop (Tauri) development, `task desktop:dev` will additionally create a `.env.desktop` file from `config/.env.desktop.example`. +These files contain non-secret defaults and are checked into Git, so most dev work needs no further setup. + +To override values locally (API keys, machine-specific settings), create an uncommitted sibling `.env.local` / `.env.desktop.local` / `.env.saas.local`. Vite automatically layers these on top of the committed files. ## Docker Setup @@ -72,8 +76,3 @@ task desktop:clean ``` Removes all desktop build artifacts including JLink runtime, bundled JARs, Cargo build, and dist/build directories. - -> [!NOTE] -> -> Desktop builds require additional environment variables. See [Environment Variables](#environment-variables) -> above - `task desktop:dev` will set these up automatically from `config/.env.desktop.example` on first run. diff --git a/frontend/scripts/setup-env.ts b/frontend/scripts/setup-env.ts index 14c6aef82d..475ce2a0ae 100644 --- a/frontend/scripts/setup-env.ts +++ b/frontend/scripts/setup-env.ts @@ -1,18 +1,18 @@ /** - * Copies missing env files from their .example templates, and warns about - * any keys present in the example but not set in the environment. - * Also warns about any VITE_ vars set in the environment that aren't listed - * in any example file. + * Ensures `.env.local` (and mode-specific `.env.desktop.local` / `.env.saas.local`) + * files exist so developers have a place to put overrides (API keys, machine-specific + * settings) without touching the committed `.env` / `.env.desktop` / `.env.saas` files. + * + * Vite automatically layers these `.local` files on top of the committed ones. * * Usage: - * tsx scripts/setup-env.ts # checks .env - * tsx scripts/setup-env.ts --desktop # also checks .env.desktop - * tsx scripts/setup-env.ts --saas # also checks .env.saas + * tsx scripts/setup-env.ts # ensures .env.local + * tsx scripts/setup-env.ts --desktop # also ensures .env.desktop.local + * tsx scripts/setup-env.ts --saas # also ensures .env.saas.local */ -import { existsSync, copyFileSync, readFileSync } from "fs"; +import { existsSync, writeFileSync } from "fs"; import { join } from "path"; -import { config, parse } from "dotenv"; // npm scripts run from the directory containing package.json (frontend/) const root = process.cwd(); @@ -20,75 +20,25 @@ const args = process.argv.slice(2); const isDesktop = args.includes("--desktop"); const isSaas = args.includes("--saas"); -console.log( - "setup-env: see frontend/README.md#environment-variables for documentation", -); - -function getExampleKeys(exampleFile: string): string[] { - const examplePath = join(root, exampleFile); - if (!existsSync(examplePath)) return []; - return Object.keys(parse(readFileSync(examplePath, "utf-8"))); +function template(parent: string): string { + return [ + "###############################################################################", + `# Local overrides for \`frontend/${parent}\``, + "# Put API keys and machine-specific settings here. Any variable defined here", + `# takes precedence over the committed \`${parent}\``, + "###############################################################################", + "", + ].join("\n"); } -function ensureEnvFile(envFile: string, exampleFile: string): boolean { - const envPath = join(root, envFile); - const examplePath = join(root, exampleFile); - - if (!existsSync(examplePath)) { - console.warn(`setup-env: ${exampleFile} not found, skipping ${envFile}`); - return false; +function ensureLocalFile(localFile: string, parentFile: string): void { + const localPath = join(root, localFile); + if (!existsSync(localPath)) { + writeFileSync(localPath, template(parentFile)); + console.log(`setup-env: created empty ${localFile} for local overrides`); } - - if (!existsSync(envPath)) { - copyFileSync(examplePath, envPath); - console.log(`setup-env: created ${envFile} from ${exampleFile}`); - } - - config({ path: envPath }); - - const missing = getExampleKeys(exampleFile).filter( - (k) => !(k in process.env), - ); - - if (missing.length > 0) { - console.error( - `setup-env: ${envFile} is missing keys from ${exampleFile}:\n` + - missing.map((k) => ` ${k}`).join("\n") + - "\n Add them manually or delete your local file to re-copy from the example.", - ); - return true; - } - - return false; } -let failed = false; -failed = ensureEnvFile(".env", "config/.env.example") || failed; - -if (isDesktop) { - failed = - ensureEnvFile(".env.desktop", "config/.env.desktop.example") || failed; -} - -if (isSaas) { - failed = ensureEnvFile(".env.saas", "config/.env.saas.example") || failed; -} - -// Warn about any VITE_ vars set in the environment that aren't listed in any example file. -const allExampleKeys = new Set([ - ...getExampleKeys("config/.env.example"), - ...getExampleKeys("config/.env.desktop.example"), - ...getExampleKeys("config/.env.saas.example"), -]); -const unknownViteVars = Object.keys(process.env).filter( - (k) => k.startsWith("VITE_") && !allExampleKeys.has(k), -); -if (unknownViteVars.length > 0) { - console.warn( - "setup-env: the following VITE_ vars are set but not listed in any example file:\n" + - unknownViteVars.map((k) => ` ${k}`).join("\n") + - "\n Add them to the appropriate config/.env.*.example file if they are required.", - ); -} - -if (failed) process.exit(1); +ensureLocalFile(".env.local", ".env"); +if (isDesktop) ensureLocalFile(".env.desktop.local", ".env.desktop"); +if (isSaas) ensureLocalFile(".env.saas.local", ".env.saas"); diff --git a/frontend/src/core/env.test.ts b/frontend/src/core/env.test.ts index 00a8b9a0f8..af218623ec 100644 --- a/frontend/src/core/env.test.ts +++ b/frontend/src/core/env.test.ts @@ -49,30 +49,24 @@ function findViteEnvVars(srcDir: string): Set { describe("env vars", () => { it("every VITE_ var used in source is present in an example env file", () => { - const baseEnv = readFileSync( - join(frontendRoot, "config/.env.example"), - "utf-8", - ); + const baseEnv = readFileSync(join(frontendRoot, ".env"), "utf-8"); const desktopEnv = readFileSync( - join(frontendRoot, "config/.env.desktop.example"), - "utf-8", - ); - const saasEnv = readFileSync( - join(frontendRoot, "config/.env.saas.example"), + join(frontendRoot, ".env.desktop"), "utf-8", ); + const saasEnv = readFileSync(join(frontendRoot, ".env.saas"), "utf-8"); - const exampleKeys = new Set([ + const declaredKeys = new Set([ ...parseEnvKeys(baseEnv), ...parseEnvKeys(desktopEnv), ...parseEnvKeys(saasEnv), ]); const sourceVars = findViteEnvVars(join(frontendRoot, "src")); - const missing = [...sourceVars].filter((v) => !exampleKeys.has(v)); + const missing = [...sourceVars].filter((v) => !declaredKeys.has(v)); expect( missing, - `Missing from 'frontend/config/.env.example' files: ${missing.join(", ")}`, + `Missing from 'frontend/.env*' files: ${missing.join(", ")}`, ).toHaveLength(0); }); });