Add system for managing env vars (#5902)

# Description of Changes
Previously, `VITE_*` environment variables were scattered across the
codebase with hardcoded fallback values inline (e.g.
`import.meta.env.VITE_STRIPE_KEY || 'pk_live_...'`). This made it
unclear which variables
were required, what they were for, and caused real keys to be silently
used in builds where they hadn't been explicitly configured.

## What's changed

I've added `frontend/.env.example` and `frontend/.env.desktop.example`,
which declare every `VITE_*` variable the app uses, with comments
explaining each one and sensible defaults where applicable. These
are the source of truth for what's required.

I've added a setup script which runs before `npm run dev`, `build`,
`tauri-dev`, and all `tauri-build*` commands. It:
- Creates your local `.env` / `.env.desktop` from the example files on
first run, so you don't need to do anything manually
- Errors if you're missing keys that the example defines (e.g. after
pulling changes that added a new variable). These can either be
manually-set env vars, or in your `.env` file (env vars take precedence
over `.env` file vars when running)
- Warns if you have `VITE_*` variables set in your environment that
aren't listed in any example file

I've removed all `|| 'hardcoded-value'` defaults from source files
because they are not necessary in this system, as all variables must be
explicitly set (they can be set to `VITE_ENV_VAR=`, just as long as the
variable actually exists). I think this system will make it really
obvious exactly what you need to set and what's actually running in the
code.

I've added a test that checks that every `import.meta.env.VITE_*`
reference found in source is present in at least one example file, so
new variables can't be added without being documented.

## For contributors

New contributors shouldn't need to do anything - `npm run dev` will
create your `.env` automatically.

If you already have a `.env` file in the `frontend/` folder, you may
well need to update it to make the system happy. Here's an example
output from running `npm run dev` with an old `.env` file:

```
$ npm run dev

> [email protected] dev
> npm run prep && vite


> [email protected] prep
> tsx scripts/setup-env.ts && npm run generate-icons

setup-env: see frontend/README.md#environment-variables for documentation
setup-env: .env is missing keys from config/.env.example:
  VITE_GOOGLE_DRIVE_CLIENT_ID
  VITE_GOOGLE_DRIVE_API_KEY
  VITE_GOOGLE_DRIVE_APP_ID
  VITE_PUBLIC_POSTHOG_KEY
  VITE_PUBLIC_POSTHOG_HOST
  Add them manually or delete your local file to re-copy from the example.
setup-env: the following VITE_ vars are set but not listed in any example file:
  VITE_DEV_BYPASS_AUTH
  Add them to config/.env.example or config/.env.desktop.example if they are required.
```

If you add a new `VITE_*` variable to the codebase, add it to the
appropriate `frontend/config/.env.example` file or the test will fail.
This commit is contained in:
James Brunton
2026-03-12 13:03:44 +00:00
committed by GitHub
parent d5d03b9ada
commit 8674765528
27 changed files with 366 additions and 70 deletions
+1 -1
View File
@@ -202,7 +202,7 @@ jobs:
- name: Install frontend dependencies - name: Install frontend dependencies
run: cd frontend && npm ci run: cd frontend && npm ci
- name: Type-check frontend - name: Type-check frontend
run: cd frontend && npm run prebuild && npm run typecheck:all run: cd frontend && npm run prep && npm run typecheck:all
- name: Lint frontend - name: Lint frontend
run: cd frontend && npm run lint run: cd frontend && npm run lint
- name: Build frontend - name: Build frontend
+1
View File
@@ -158,6 +158,7 @@ __pycache__/
# Virtual environments # Virtual environments
.env* .env*
!.env*.example
.venv* .venv*
env*/ env*/
venv*/ venv*/
+10
View File
@@ -30,6 +30,16 @@ Set `DOCKER_ENABLE_SECURITY=true` environment variable to enable security featur
- **Web Server**: `npm run build` then serve dist/ folder - **Web Server**: `npm run build` then serve dist/ folder
- **Development**: `npm run tauri-dev` for desktop dev mode - **Development**: `npm run tauri-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
- `npm run prep` / `prep:saas` / `prep:desktop` auto-create the env files from examples on first run, and error if any required keys are missing
- These prep scripts run automatically at the start of all `dev*`, `build*`, and `tauri*` commands
- See `frontend/README.md#environment-variables` for full documentation
#### Import Paths - CRITICAL #### Import Paths - CRITICAL
**ALWAYS use `@app/*` for imports.** Do not use `@core/*` or `@proprietary/*` unless explicitly wrapping/extending a lower layer implementation. **ALWAYS use `@app/*` for imports.** Do not use `@core/*` or `@proprietary/*` unless explicitly wrapping/extending a lower layer implementation.
+4
View File
@@ -14,10 +14,14 @@
# misc # misc
.DS_Store .DS_Store
.env
.env.saas
.env.desktop
.env.local .env.local
.env.development.local .env.development.local
.env.test.local .env.test.local
.env.production.local .env.production.local
!.env*.example
npm-debug.log* npm-debug.log*
yarn-debug.log* yarn-debug.log*
+17 -2
View File
@@ -1,6 +1,11 @@
# Getting Started with Create React App # Frontend
## Environment Variables
This project was bootstrapped with [Create React App](https://github.com/facebook/create-react-app). The frontend requires environment variables to be set before running. `npm run 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.
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.
For desktop (Tauri) development, `npm run tauri-dev` will additionally create a `.env.desktop` file from `config/.env.desktop.example`.
## Docker Setup ## Docker Setup
@@ -120,6 +125,11 @@ npm run tauri-dev
This will run the gradle runboot command and the tauri dev command concurrently, starting the app once both are stable. This will run the gradle runboot command and the tauri dev command concurrently, starting the app once both are stable.
> [!NOTE]
>
> Desktop builds require additional environment variables. See [Environment Variables](#environment-variables)
> above - `npm run tauri-dev` will set these up automatically from `config/.env.desktop.example` on first run.
### Build ### Build
To build a deployment of the Tauri app. Use this command in the `frontend` folder: To build a deployment of the Tauri app. Use this command in the `frontend` folder:
@@ -128,3 +138,8 @@ npm run tauri-build
``` ```
This will bundle the backend and frontend into one executable for each target. Targets can be set within the `tauri.conf.json` file. This will bundle the backend and frontend into one executable for each target. Targets can be set within the `tauri.conf.json` file.
> [!NOTE]
>
> Desktop builds require additional environment variables. See [Environment Variables](#environment-variables)
> above - `npm run tauri-build` will set these up automatically from `config/.env.desktop.example` on first run.
+11
View File
@@ -0,0 +1,11 @@
# Frontend environment variables for desktop (Tauri) builds.
# Layered on top of .env when running in desktop mode.
# Desktop backend endpoint — leave blank to use VITE_API_BASE_URL from .env
VITE_DESKTOP_BACKEND_URL=
# Desktop auth integration
VITE_SAAS_SERVER_URL=https://auth.stirling.com
VITE_SAAS_BACKEND_API_URL=https://api2.stirling.com
VITE_SUPABASE_PUBLISHABLE_DEFAULT_KEY=sb_publishable_UHz2SVRF5mvdrPHWkRteyA_yNlZTkYb
VITE_SUPABASE_URL=https://rficokptxxxxtyzcvgmx.supabase.co
+20
View File
@@ -0,0 +1,20 @@
# Frontend environment variables for core and proprietary builds.
# API base URL — use / for same-origin (default for web builds)
VITE_API_BASE_URL=/
# Google Drive integration
VITE_GOOGLE_DRIVE_CLIENT_ID=
VITE_GOOGLE_DRIVE_API_KEY=
VITE_GOOGLE_DRIVE_APP_ID=
# Supabase configuration
VITE_SUPABASE_URL=https://rficokptxxxxtyzcvgmx.supabase.co
VITE_SUPABASE_PUBLISHABLE_DEFAULT_KEY=sb_publishable_UHz2SVRF5mvdrPHWkRteyA_yNlZTkYb
# Stripe checkout
VITE_STRIPE_PUBLISHABLE_KEY=pk_live_51Q56W2P9mY5IAnSnp3kcxG50uyFMLuhM4fFs774DAP3t88KmlwUrUo31CecpnAZ9FHsNp8xJyOnYNYNVVP6z4oi500q5sFYPEp
# PostHog analytics
VITE_PUBLIC_POSTHOG_KEY=phc_VOdeYnlevc2T63m3myFGjeBlRcIusRgmhfx6XL5a1iz
VITE_PUBLIC_POSTHOG_HOST=https://eu.i.posthog.com
+11
View File
@@ -0,0 +1,11 @@
# Frontend environment variables for SaaS builds.
# Layered on top of .env when running in SaaS mode.
# Userback feedback widget — leave blank to disable
VITE_USERBACK_TOKEN=
# URL subpath prefix for SaaS deployments (e.g. "app" if serving at /app/) — leave blank for root
VITE_RUN_SUBPATH=
# Development-only auth bypass — allows unauthenticated access on localhost in dev mode
VITE_DEV_BYPASS_AUTH=false
+73
View File
@@ -104,6 +104,7 @@
"@typescript-eslint/parser": "^8.44.1", "@typescript-eslint/parser": "^8.44.1",
"@vitejs/plugin-react-swc": "^4.1.0", "@vitejs/plugin-react-swc": "^4.1.0",
"@vitest/coverage-v8": "^3.2.4", "@vitest/coverage-v8": "^3.2.4",
"dotenv": "^16.4.7",
"dpdm": "^3.14.0", "dpdm": "^3.14.0",
"eslint": "^10.0.2", "eslint": "^10.0.2",
"jsdom": "^27.0.0", "jsdom": "^27.0.0",
@@ -114,6 +115,7 @@
"postcss-preset-mantine": "^1.18.0", "postcss-preset-mantine": "^1.18.0",
"postcss-simple-vars": "^7.0.1", "postcss-simple-vars": "^7.0.1",
"puppeteer": "^24.25.0", "puppeteer": "^24.25.0",
"tsx": "^4.19.4",
"typescript": "^5.9.2", "typescript": "^5.9.2",
"typescript-eslint": "^8.44.1", "typescript-eslint": "^8.44.1",
"vite": "^7.1.7", "vite": "^7.1.7",
@@ -7518,6 +7520,19 @@
"@types/trusted-types": "^2.0.7" "@types/trusted-types": "^2.0.7"
} }
}, },
"node_modules/dotenv": {
"version": "16.6.1",
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz",
"integrity": "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==",
"dev": true,
"license": "BSD-2-Clause",
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://dotenvx.com"
}
},
"node_modules/dpdm": { "node_modules/dpdm": {
"version": "3.15.1", "version": "3.15.1",
"resolved": "https://registry.npmjs.org/dpdm/-/dpdm-3.15.1.tgz", "resolved": "https://registry.npmjs.org/dpdm/-/dpdm-3.15.1.tgz",
@@ -8540,6 +8555,19 @@
"url": "https://github.com/sponsors/sindresorhus" "url": "https://github.com/sponsors/sindresorhus"
} }
}, },
"node_modules/get-tsconfig": {
"version": "4.13.6",
"resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.13.6.tgz",
"integrity": "sha512-shZT/QMiSHc/YBLxxOkMtgSid5HFoauqCE3/exfsEcwg1WkeqjG+V40yBbBrsD+jW2HDXcs28xOfcbm2jI8Ddw==",
"dev": true,
"license": "MIT",
"dependencies": {
"resolve-pkg-maps": "^1.0.0"
},
"funding": {
"url": "https://github.com/privatenumber/get-tsconfig?sponsor=1"
}
},
"node_modules/get-uri": { "node_modules/get-uri": {
"version": "6.0.5", "version": "6.0.5",
"resolved": "https://registry.npmjs.org/get-uri/-/get-uri-6.0.5.tgz", "resolved": "https://registry.npmjs.org/get-uri/-/get-uri-6.0.5.tgz",
@@ -12145,6 +12173,16 @@
"node": ">=4" "node": ">=4"
} }
}, },
"node_modules/resolve-pkg-maps": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz",
"integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==",
"dev": true,
"license": "MIT",
"funding": {
"url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1"
}
},
"node_modules/responselike": { "node_modules/responselike": {
"version": "4.0.2", "version": "4.0.2",
"resolved": "https://registry.npmjs.org/responselike/-/responselike-4.0.2.tgz", "resolved": "https://registry.npmjs.org/responselike/-/responselike-4.0.2.tgz",
@@ -13384,6 +13422,41 @@
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
"license": "0BSD" "license": "0BSD"
}, },
"node_modules/tsx": {
"version": "4.21.0",
"resolved": "https://registry.npmjs.org/tsx/-/tsx-4.21.0.tgz",
"integrity": "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==",
"dev": true,
"license": "MIT",
"dependencies": {
"esbuild": "~0.27.0",
"get-tsconfig": "^4.7.5"
},
"bin": {
"tsx": "dist/cli.mjs"
},
"engines": {
"node": ">=18.0.0"
},
"optionalDependencies": {
"fsevents": "~2.3.3"
}
},
"node_modules/tsx/node_modules/fsevents": {
"version": "2.3.3",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
"integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
"node_modules/type-check": { "node_modules/type-check": {
"version": "0.4.0", "version": "0.4.0",
"resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz",
+25 -20
View File
@@ -79,36 +79,39 @@
"web-vitals": "^5.1.0" "web-vitals": "^5.1.0"
}, },
"scripts": { "scripts": {
"pretauri-build": "node scripts/build-provisioner.mjs", "prep": "tsx scripts/setup-env.ts && npm run generate-icons",
"predev": "npm run generate-icons", "prep:saas": "tsx scripts/setup-env.ts --saas && npm run generate-icons",
"dev": "vite", "prep:desktop": "tsx scripts/setup-env.ts --desktop && npm run generate-icons",
"dev:core": "vite --mode core", "prep:desktop-build": "node scripts/build-provisioner.mjs && npm run prep:desktop",
"dev:proprietary": "vite --mode proprietary", "dev": "npm run prep && vite",
"dev:saas": "vite --mode saas", "dev:core": "npm run prep && vite --mode core",
"dev:desktop": "vite --mode desktop", "dev:proprietary": "npm run prep && vite --mode proprietary",
"prebuild": "npm run generate-icons", "dev:saas": "npm run prep:saas && vite --mode saas",
"dev:desktop": "npm run prep:desktop && vite --mode desktop",
"lint": "npm run lint:eslint && npm run lint:cycles", "lint": "npm run lint:eslint && npm run lint:cycles",
"lint:eslint": "eslint --max-warnings=0", "lint:eslint": "eslint --max-warnings=0",
"lint:cycles": "dpdm src --circular --no-warning --no-tree --exit-code circular:1", "lint:cycles": "dpdm src --circular --no-warning --no-tree --exit-code circular:1",
"build": "vite build", "build": "npm run prep && vite build",
"build:core": "vite build --mode core", "build:core": "npm run prep && vite build --mode core",
"build:proprietary": "vite build --mode proprietary", "build:proprietary": "npm run prep && vite build --mode proprietary",
"build:saas": "vite build --mode saas", "build:saas": "npm run prep:saas && vite build --mode saas",
"build:desktop": "vite build --mode desktop", "build:desktop": "npm run prep:desktop && vite build --mode desktop",
"preview": "vite preview", "preview": "vite preview",
"tauri-dev": "tauri dev --no-watch", "tauri-dev": "npm run prep:desktop && tauri dev --no-watch",
"tauri-build": "tauri build", "tauri-build": "npm run prep:desktop-build && tauri build",
"tauri-build-dev": "tauri build --no-bundle", "_tauri-build-dev": "npm run prep:desktop && tauri build",
"tauri-build-dev-mac": "tauri build --bundles app", "tauri-build-dev": "npm run _tauri-build-dev -- --no-bundle",
"tauri-build-dev-windows": "tauri build --bundles nsis", "tauri-build-dev-mac": "npm run _tauri-build-dev -- --bundles app",
"tauri-build-dev-linux": "tauri build --bundles appimage", "tauri-build-dev-windows": "npm run _tauri-build-dev -- --bundles nsis",
"tauri-build-dev-linux": "npm run _tauri-build-dev -- --bundles appimage",
"tauri-clean": "cd src-tauri && cargo clean && cd .. && rm -rf dist build", "tauri-clean": "cd src-tauri && cargo clean && cd .. && rm -rf dist build",
"typecheck": "npm run typecheck:proprietary", "typecheck": "npm run typecheck:proprietary",
"typecheck:core": "tsc --noEmit --project src/core/tsconfig.json", "typecheck:core": "tsc --noEmit --project src/core/tsconfig.json",
"typecheck:proprietary": "tsc --noEmit --project src/proprietary/tsconfig.json", "typecheck:proprietary": "tsc --noEmit --project src/proprietary/tsconfig.json",
"typecheck:saas": "tsc --noEmit --project src/saas/tsconfig.json", "typecheck:saas": "tsc --noEmit --project src/saas/tsconfig.json",
"typecheck:desktop": "tsc --noEmit --project src/desktop/tsconfig.json", "typecheck:desktop": "tsc --noEmit --project src/desktop/tsconfig.json",
"typecheck:all": "npm run typecheck:core && npm run typecheck:proprietary && npm run typecheck:saas && npm run typecheck:desktop", "typecheck:scripts": "tsc --noEmit --project scripts/tsconfig.json",
"typecheck:all": "npm run typecheck:core && npm run typecheck:proprietary && npm run typecheck:saas && npm run typecheck:desktop && npm run typecheck:scripts",
"check": "npm run typecheck && npm run lint && npm run test:run", "check": "npm run typecheck && npm run lint && npm run test:run",
"generate-licenses": "node scripts/generate-licenses.js", "generate-licenses": "node scripts/generate-licenses.js",
"generate-icons": "node scripts/generate-icons.js", "generate-icons": "node scripts/generate-icons.js",
@@ -160,6 +163,7 @@
"@typescript-eslint/parser": "^8.44.1", "@typescript-eslint/parser": "^8.44.1",
"@vitejs/plugin-react-swc": "^4.1.0", "@vitejs/plugin-react-swc": "^4.1.0",
"@vitest/coverage-v8": "^3.2.4", "@vitest/coverage-v8": "^3.2.4",
"dotenv": "^16.4.7",
"dpdm": "^3.14.0", "dpdm": "^3.14.0",
"eslint": "^10.0.2", "eslint": "^10.0.2",
"jsdom": "^27.0.0", "jsdom": "^27.0.0",
@@ -171,6 +175,7 @@
"postcss-simple-vars": "^7.0.1", "postcss-simple-vars": "^7.0.1",
"puppeteer": "^24.25.0", "puppeteer": "^24.25.0",
"typescript": "^5.9.2", "typescript": "^5.9.2",
"tsx": "^4.19.4",
"typescript-eslint": "^8.44.1", "typescript-eslint": "^8.44.1",
"vite": "^7.1.7", "vite": "^7.1.7",
"vite-plugin-static-copy": "^3.1.4", "vite-plugin-static-copy": "^3.1.4",
+88
View File
@@ -0,0 +1,88 @@
/**
* 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.
*
* 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
*/
import { existsSync, copyFileSync, readFileSync } 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();
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 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;
}
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);
+10
View File
@@ -0,0 +1,10 @@
{
"extends": "../tsconfig.json",
"compilerOptions": {
"module": "node16",
"moduleResolution": "node16",
"noEmit": true
},
"include": ["./**/*.ts"],
"exclude": []
}
+58
View File
@@ -0,0 +1,58 @@
import { readFileSync, readdirSync, statSync } from 'fs';
import { join, extname } from 'path';
import { fileURLToPath } from 'url';
import { describe, it, expect } from 'vitest';
// frontend/ root — this file lives at src/core/env.test.ts
const frontendRoot = join(fileURLToPath(import.meta.url), '../../..');
function parseEnvKeys(content: string): Set<string> {
const keys = new Set<string>();
for (const line of content.split('\n')) {
const trimmed = line.trim();
if (trimmed && !trimmed.startsWith('#')) {
const match = trimmed.match(/^([A-Z_][A-Z0-9_]*)=/);
if (match) keys.add(match[1]);
}
}
return keys;
}
function collectSourceFiles(dir: string): string[] {
const files: string[] = [];
for (const entry of readdirSync(dir)) {
const fullPath = join(dir, entry);
const stat = statSync(fullPath);
if (stat.isDirectory() && entry !== 'node_modules' && entry !== 'assets') {
files.push(...collectSourceFiles(fullPath));
} else if (stat.isFile() && (extname(entry) === '.ts' || extname(entry) === '.tsx') && !entry.endsWith('.d.ts')) {
files.push(fullPath);
}
}
return files;
}
function findViteEnvVars(srcDir: string): Set<string> {
const vars = new Set<string>();
for (const file of collectSourceFiles(srcDir)) {
const content = readFileSync(file, 'utf-8');
for (const match of content.matchAll(/import\.meta\.env\.(VITE_\w+)/g)) {
vars.add(match[1]);
}
}
return vars;
}
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 desktopEnv = readFileSync(join(frontendRoot, 'config/.env.desktop.example'), 'utf-8');
const saasEnv = readFileSync(join(frontendRoot, 'config/.env.saas.example'), 'utf-8');
const exampleKeys = new Set([...parseEnvKeys(baseEnv), ...parseEnvKeys(desktopEnv), ...parseEnvKeys(saasEnv)]);
const sourceVars = findViteEnvVars(join(frontendRoot, 'src'));
const missing = [...sourceVars].filter(v => !exampleKeys.has(v));
expect(missing, `Missing from 'frontend/config/.env.example' files: ${missing.join(', ')}`).toHaveLength(0);
});
});
@@ -15,5 +15,5 @@ export function getApiBaseUrl(): string {
return (window as any).STIRLING_PDF_API_BASE_URL; return (window as any).STIRLING_PDF_API_BASE_URL;
} }
return import.meta.env.VITE_API_BASE_URL || '/'; return import.meta.env.VITE_API_BASE_URL;
} }
+2 -2
View File
@@ -1,7 +1,7 @@
import { createClient, SupabaseClient } from '@supabase/supabase-js'; import { createClient, SupabaseClient } from '@supabase/supabase-js';
const supabaseUrl = import.meta.env.VITE_SUPABASE_URL || 'https://rficokptxxxxtyzcvgmx.supabase.co'; const supabaseUrl = import.meta.env.VITE_SUPABASE_URL;
const supabaseAnonKey = import.meta.env.VITE_SUPABASE_PUBLISHABLE_DEFAULT_KEY || 'sb_publishable_UHz2SVRF5mvdrPHWkRteyA_yNlZTkYb'; const supabaseAnonKey = import.meta.env.VITE_SUPABASE_PUBLISHABLE_DEFAULT_KEY;
// Check if Supabase is configured // Check if Supabase is configured
export const isSupabaseConfigured = !!(supabaseUrl && supabaseAnonKey); export const isSupabaseConfigured = !!(supabaseUrl && supabaseAnonKey);
+6 -14
View File
@@ -2,22 +2,14 @@
* Connection-related constants for desktop app * Connection-related constants for desktop app
*/ */
// SaaS server URL from environment variable // SaaS authentication server URL
// The SaaS authentication server (Supabase) export const STIRLING_SAAS_URL: string = import.meta.env.VITE_SAAS_SERVER_URL;
export const STIRLING_SAAS_URL: string = import.meta.env.VITE_SAAS_SERVER_URL || '';
// SaaS backend API URL from environment variable // Stirling SaaS backend API server (for team endpoints, etc.)
// The Stirling SaaS backend API server (for team endpoints, etc.) export const STIRLING_SAAS_BACKEND_API_URL: string = import.meta.env.VITE_SAAS_BACKEND_API_URL;
export const STIRLING_SAAS_BACKEND_API_URL: string = import.meta.env.VITE_SAAS_BACKEND_API_URL || '';
// Supabase publishable key from environment variable // Supabase publishable key — used for SaaS authentication
// Used for SaaS authentication export const SUPABASE_KEY: string = import.meta.env.VITE_SUPABASE_PUBLISHABLE_DEFAULT_KEY;
export const SUPABASE_KEY: string = import.meta.env.VITE_SUPABASE_PUBLISHABLE_DEFAULT_KEY || 'sb_publishable_UHz2SVRF5mvdrPHWkRteyA_yNlZTkYb';
// Desktop deep link callback for Supabase email confirmations // Desktop deep link callback for Supabase email confirmations
export const DESKTOP_DEEP_LINK_CALLBACK = 'stirlingpdf://auth/callback'; export const DESKTOP_DEEP_LINK_CALLBACK = 'stirlingpdf://auth/callback';
// Validation warnings
if (!STIRLING_SAAS_BACKEND_API_URL) {
console.warn('[Desktop Connection] VITE_SAAS_BACKEND_API_URL not configured - SaaS backend APIs (teams, etc.) will not work');
}
@@ -401,8 +401,7 @@ export function useMultipleEndpointsEnabled(endpoints: string[]): {
// Default backend URL from environment variables // Default backend URL from environment variables
const DEFAULT_BACKEND_URL = const DEFAULT_BACKEND_URL =
import.meta.env.VITE_DESKTOP_BACKEND_URL import.meta.env.VITE_DESKTOP_BACKEND_URL
|| import.meta.env.VITE_API_BASE_URL || import.meta.env.VITE_API_BASE_URL;
|| '';
/** /**
* Desktop override exposing the backend URL based on connection mode. * Desktop override exposing the backend URL based on connection mode.
@@ -19,7 +19,7 @@ export function getApiBaseUrl(): string {
return (window as any).STIRLING_PDF_API_BASE_URL; return (window as any).STIRLING_PDF_API_BASE_URL;
} }
return import.meta.env.VITE_API_BASE_URL || '/'; return import.meta.env.VITE_API_BASE_URL;
} }
// In Tauri mode, return empty string as placeholder // In Tauri mode, return empty string as placeholder
+2 -2
View File
@@ -12,8 +12,8 @@ import posthog from 'posthog-js';
import { PostHogProvider } from '@posthog/react'; import { PostHogProvider } from '@posthog/react';
import { BASE_PATH } from '@app/constants/app'; import { BASE_PATH } from '@app/constants/app';
posthog.init('phc_VOdeYnlevc2T63m3myFGjeBlRcIusRgmhfx6XL5a1iz', { posthog.init(import.meta.env.VITE_PUBLIC_POSTHOG_KEY, {
api_host: 'https://eu.i.posthog.com', api_host: import.meta.env.VITE_PUBLIC_POSTHOG_HOST,
defaults: '2025-05-24', defaults: '2025-05-24',
capture_exceptions: true, // This enables capturing exceptions using Error Tracking, set to false if you don't want this capture_exceptions: true, // This enables capturing exceptions using Error Tracking, set to false if you don't want this
debug: false, debug: false,
@@ -19,7 +19,7 @@ import { SuccessStage } from '@app/components/shared/stripeCheckout/stages/Succe
import { ErrorStage } from '@app/components/shared/stripeCheckout/stages/ErrorStage'; import { ErrorStage } from '@app/components/shared/stripeCheckout/stages/ErrorStage';
// Validate Stripe key (static validation, no dynamic imports) // Validate Stripe key (static validation, no dynamic imports)
const STRIPE_KEY = import.meta.env.VITE_STRIPE_PUBLISHABLE_KEY || 'pk_live_51Q56W2P9mY5IAnSnp3kcxG50uyFMLuhM4fFs774DAP3t88KmlwUrUo31CecpnAZ9FHsNp8xJyOnYNYNVVP6z4oi500q5sFYPEp'; const STRIPE_KEY = import.meta.env.VITE_STRIPE_PUBLISHABLE_KEY;
if (!STRIPE_KEY) { if (!STRIPE_KEY) {
console.error( console.error(
@@ -6,7 +6,7 @@ import { EmbeddedCheckoutProvider, EmbeddedCheckout } from '@stripe/react-stripe
import { PlanTier } from '@app/services/licenseService'; import { PlanTier } from '@app/services/licenseService';
// Load Stripe once // Load Stripe once
const STRIPE_KEY = import.meta.env.VITE_STRIPE_PUBLISHABLE_KEY || 'pk_live_51Q56W2P9mY5IAnSnp3kcxG50uyFMLuhM4fFs774DAP3t88KmlwUrUo31CecpnAZ9FHsNp8xJyOnYNYNVVP6z4oi500q5sFYPEp'; const STRIPE_KEY = import.meta.env.VITE_STRIPE_PUBLISHABLE_KEY;
const stripePromise = STRIPE_KEY ? loadStripe(STRIPE_KEY) : null; const stripePromise = STRIPE_KEY ? loadStripe(STRIPE_KEY) : null;
interface PaymentStageProps { interface PaymentStageProps {
@@ -9,7 +9,7 @@
* @returns true if key exists and has valid format * @returns true if key exists and has valid format
*/ */
export function isStripeConfigured(): boolean { export function isStripeConfigured(): boolean {
const stripeKey = import.meta.env.VITE_STRIPE_PUBLISHABLE_KEY || 'pk_live_51Q56W2P9mY5IAnSnp3kcxG50uyFMLuhM4fFs774DAP3t88KmlwUrUo31CecpnAZ9FHsNp8xJyOnYNYNVVP6z4oi500q5sFYPEp'; const stripeKey = import.meta.env.VITE_STRIPE_PUBLISHABLE_KEY;
return !!stripeKey && stripeKey.startsWith('pk_'); return !!stripeKey && stripeKey.startsWith('pk_');
} }
@@ -6,7 +6,7 @@ import { EmbeddedCheckoutProvider, EmbeddedCheckout } from '@stripe/react-stripe
import { supabase } from '@app/auth/supabase'; import { supabase } from '@app/auth/supabase';
import { Z_INDEX_OVER_SETTINGS_MODAL } from '@app/styles/zIndex'; import { Z_INDEX_OVER_SETTINGS_MODAL } from '@app/styles/zIndex';
const stripePromise = loadStripe(import.meta.env.VITE_STRIPE_PUBLISHABLE_DEFAULT_KEY); const stripePromise = loadStripe(import.meta.env.VITE_STRIPE_PUBLISHABLE_KEY);
export type PurchaseType = 'subscription' | 'credits'; export type PurchaseType = 'subscription' | 'credits';
export type CreditsPack = 'xsmall' | 'small' | 'medium' | 'large' | null; export type CreditsPack = 'xsmall' | 'small' | 'medium' | 'large' | null;
+1 -1
View File
@@ -31,7 +31,7 @@ function decodeJwtPayload(token: string): Record<string, unknown> | null {
// Create axios instance with default config // Create axios instance with default config
const apiClient = axios.create({ const apiClient = axios.create({
baseURL: import.meta.env.VITE_API_BASE_URL || '/', // Use env var or relative path (proxied by Vite in dev) baseURL: import.meta.env.VITE_API_BASE_URL,
responseType: 'json', responseType: 'json',
}); });
+1 -1
View File
@@ -1,6 +1,6 @@
import { URL_TO_TOOL_MAP } from '@app/utils/urlMapping' import { URL_TO_TOOL_MAP } from '@app/utils/urlMapping'
const SUBPATH = (import.meta.env.VITE_RUN_SUBPATH || '').replace(/^\/|\/$/g, '') // "app" or "" const SUBPATH = import.meta.env.VITE_RUN_SUBPATH.replace(/^\/|\/$/g, '') // "app" or ""
/** /**
* Normalize pathname by stripping subpath prefix and trailing slashes * Normalize pathname by stripping subpath prefix and trailing slashes
+18 -2
View File
@@ -1,10 +1,26 @@
/// <reference types="vite/client" /> /// <reference types="vite/client" />
interface ImportMetaEnv { interface ImportMetaEnv {
readonly VITE_PUBLIC_POSTHOG_KEY: string; // Used by all builds (.env)
readonly VITE_PUBLIC_POSTHOG_HOST: string; readonly VITE_API_BASE_URL: string;
readonly VITE_GOOGLE_DRIVE_CLIENT_ID: string;
readonly VITE_GOOGLE_DRIVE_API_KEY: string;
readonly VITE_GOOGLE_DRIVE_APP_ID: string;
readonly VITE_SUPABASE_URL: string; readonly VITE_SUPABASE_URL: string;
readonly VITE_SUPABASE_PUBLISHABLE_DEFAULT_KEY: string; readonly VITE_SUPABASE_PUBLISHABLE_DEFAULT_KEY: string;
readonly VITE_STRIPE_PUBLISHABLE_KEY: string;
readonly VITE_PUBLIC_POSTHOG_KEY: string;
readonly VITE_PUBLIC_POSTHOG_HOST: string;
// SaaS only (.env.saas)
readonly VITE_USERBACK_TOKEN: string;
readonly VITE_RUN_SUBPATH: string;
readonly VITE_DEV_BYPASS_AUTH: string;
// Desktop only (.env.desktop)
readonly VITE_DESKTOP_BACKEND_URL: string;
readonly VITE_SAAS_SERVER_URL: string;
readonly VITE_SAAS_BACKEND_API_URL: string;
} }
interface ImportMeta { interface ImportMeta {
-17
View File
@@ -27,23 +27,6 @@ export default defineConfig(({ mode }) => {
? (mode as BuildMode) ? (mode as BuildMode)
: process.env.DISABLE_ADDITIONAL_FEATURES === 'true' ? 'core' : 'proprietary'; : process.env.DISABLE_ADDITIONAL_FEATURES === 'true' ? 'core' : 'proprietary';
// Validate required environment variables for desktop builds
if (effectiveMode === 'desktop') {
const requiredEnvVars = [
'VITE_SAAS_SERVER_URL',
'VITE_SUPABASE_PUBLISHABLE_DEFAULT_KEY',
'VITE_SAAS_BACKEND_API_URL',
];
const missingVars = requiredEnvVars.filter(varName => !env[varName]);
if (missingVars.length > 0) {
throw new Error(
`Desktop build failed: Missing required environment variables:\n${missingVars.map(v => ` - ${v}`).join('\n')}\n\nPlease set these variables before building the desktop app.`
);
}
}
const tsconfigProject = TSCONFIG_MAP[effectiveMode]; const tsconfigProject = TSCONFIG_MAP[effectiveMode];
return { return {