🔀 merge: integrate till-dev into main

Merge till-dev branch containing:
- Planta plant care tracking application
- Clock backend with alarms, timers, world clocks
- Zitare backend with favorites and lists
- Various app improvements and fixes
- Auth system updates
- Infrastructure improvements

Note: Some type-check issues may need resolution after merge.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Till-JS 2026-01-18 15:40:43 +01:00
commit 49a8c652da
475 changed files with 28008 additions and 22742 deletions

View file

@ -1,102 +0,0 @@
#!/usr/bin/env bash
# Build web apps that have changes compared to the remote branch
# This catches build failures (like npm package incompatibilities) before push
set -e
# Get the remote branch we're pushing to (default to origin/dev)
REMOTE_BRANCH="${1:-origin/dev}"
echo "🔍 Checking for changed web apps compared to $REMOTE_BRANCH..."
# Get list of changed files
CHANGED_FILES=$(git diff --name-only "$REMOTE_BRANCH" HEAD 2>/dev/null || git diff --name-only HEAD~1 HEAD)
if [ -z "$CHANGED_FILES" ]; then
echo "No changes detected, skipping build check"
exit 0
fi
# Find unique web app directories that have changes
WEB_APPS=""
for file in $CHANGED_FILES; do
app_path=""
if [[ $file =~ ^(apps/[^/]+/apps/web)/ ]]; then
app_path="${BASH_REMATCH[1]}"
elif [[ $file =~ ^(games/[^/]+/apps/web)/ ]]; then
app_path="${BASH_REMATCH[1]}"
elif [[ $file =~ ^(packages/[^/]+)/ ]]; then
pkg_name="${BASH_REMATCH[1]}"
# Shared packages affect multiple apps - check which ones depend on them
echo "⚠️ Changes in shared package: $pkg_name"
# For critical shared packages, build the main apps
case "$pkg_name" in
packages/shared-ui|packages/shared-auth|packages/shared-theme)
# These affect most web apps - build the main ones
for main_app in "apps/manacore/apps/web" "apps/todo/apps/web" "apps/chat/apps/web"; do
if [ -d "$main_app" ] && [[ ! " $WEB_APPS " =~ " $main_app " ]]; then
WEB_APPS="$WEB_APPS $main_app"
fi
done
;;
esac
fi
# Add to list if not already present
if [ -n "$app_path" ] && [ -d "$app_path" ]; then
if [[ ! " $WEB_APPS " =~ " $app_path " ]]; then
WEB_APPS="$WEB_APPS $app_path"
fi
fi
done
# Trim leading space
WEB_APPS=$(echo "$WEB_APPS" | xargs)
if [ -z "$WEB_APPS" ]; then
echo "✅ No web app changes detected, skipping build"
exit 0
fi
echo ""
echo "📦 Building changed web apps..."
echo " Apps to build: $WEB_APPS"
echo ""
FAILED=0
for app in $WEB_APPS; do
if [ -f "$app/package.json" ]; then
# Get the package name for pnpm filter
PKG_NAME=$(node -p "require('./$app/package.json').name" 2>/dev/null || echo "")
if [ -n "$PKG_NAME" ]; then
echo "━━━ Building $PKG_NAME ━━━"
if pnpm --filter "$PKG_NAME" build 2>&1; then
echo "✅ Build passed for $PKG_NAME"
else
echo "❌ Build failed for $PKG_NAME"
FAILED=1
fi
echo ""
fi
fi
done
if [ $FAILED -eq 1 ]; then
echo ""
echo "❌ Build failed! Fix the issues above before pushing."
echo ""
echo "Common issues:"
echo " - npm package incompatibility (check node_modules versions)"
echo " - Missing workspace dependencies in Dockerfile"
echo " - TypeScript errors in production build"
echo ""
echo "To skip this check (emergency only): git push --no-verify"
exit 1
fi
echo "✅ All builds passed!"

View file

@ -1,107 +0,0 @@
#!/bin/bash
# Run production builds on web apps that have changes since main/dev
# This catches issues that only appear in production builds
#
# Usage:
# ./scripts/build-check-staged.sh # Check changes vs origin/dev
# ./scripts/build-check-staged.sh main # Check changes vs origin/main
# ./scripts/build-check-staged.sh --all # Check all web apps
set -e
BASE_BRANCH="${1:-origin/dev}"
# Handle --all flag
if [ "$1" = "--all" ]; then
echo "🔨 Building ALL web apps..."
APPS_TO_BUILD=(
"apps/todo/apps/web"
"apps/chat/apps/web"
"apps/calendar/apps/web"
"apps/clock/apps/web"
"apps/manacore/apps/web"
"apps/contacts/apps/web"
"apps/zitare/apps/web"
"apps/picture/apps/web"
"apps/manadeck/apps/web"
)
else
echo "🔍 Finding changed files since $BASE_BRANCH..."
# Get list of changed files
CHANGED_FILES=$(git diff --name-only "$BASE_BRANCH"...HEAD 2>/dev/null || git diff --name-only HEAD~10...HEAD)
if [ -z "$CHANGED_FILES" ]; then
echo "No changes detected"
exit 0
fi
# Find unique web app directories that have changes
declare -A WEB_APPS
for file in $CHANGED_FILES; do
# Direct changes in web app
if [[ $file =~ ^(apps/[^/]+/apps/web)/ ]]; then
WEB_APPS["${BASH_REMATCH[1]}"]=1
elif [[ $file =~ ^(games/[^/]+/apps/web)/ ]]; then
WEB_APPS["${BASH_REMATCH[1]}"]=1
# Changes in shared packages affect all web apps using them
elif [[ $file =~ ^packages/shared- ]]; then
echo "⚠️ Shared package changed: $file"
echo " All web apps may be affected"
# Add major web apps
WEB_APPS["apps/todo/apps/web"]=1
WEB_APPS["apps/chat/apps/web"]=1
WEB_APPS["apps/calendar/apps/web"]=1
WEB_APPS["apps/manacore/apps/web"]=1
fi
done
APPS_TO_BUILD=("${!WEB_APPS[@]}")
fi
if [ ${#APPS_TO_BUILD[@]} -eq 0 ]; then
echo "No web app changes detected"
exit 0
fi
echo ""
echo "🔨 Building affected web apps..."
echo " Apps: ${APPS_TO_BUILD[*]}"
echo ""
# First build shared packages (needed for web apps)
echo "━━━ Building shared packages ━━━"
pnpm run build:packages || {
echo "❌ Failed to build shared packages"
exit 1
}
FAILED=0
for app in "${APPS_TO_BUILD[@]}"; do
if [ -f "$app/package.json" ]; then
echo ""
echo "━━━ Building $app ━━━"
PKG_NAME=$(node -p "require('./$app/package.json').name" 2>/dev/null || echo "")
if [ -n "$PKG_NAME" ]; then
if ! pnpm --filter "$PKG_NAME" build 2>&1; then
echo "❌ Build failed for $app"
FAILED=1
else
echo "✅ Build passed for $app"
fi
fi
fi
done
if [ $FAILED -eq 1 ]; then
echo ""
echo "❌ Build check failed! Fix the issues above before pushing."
exit 1
fi
echo ""
echo "✅ All builds passed!"

View file

@ -66,7 +66,8 @@ const APP_CONFIGS = [
REDIS_HOST: (env) => env.REDIS_HOST,
REDIS_PORT: (env) => env.REDIS_PORT,
REDIS_PASSWORD: (env) => env.REDIS_PASSWORD || '',
// JWT keys managed by Better Auth (EdDSA) - stored in auth.jwks table
JWT_PRIVATE_KEY: (env) => env.JWT_PRIVATE_KEY,
JWT_PUBLIC_KEY: (env) => env.JWT_PUBLIC_KEY,
JWT_ACCESS_TOKEN_EXPIRY: (env) => env.JWT_ACCESS_TOKEN_EXPIRY,
JWT_REFRESH_TOKEN_EXPIRY: (env) => env.JWT_REFRESH_TOKEN_EXPIRY,
JWT_ISSUER: (env) => env.JWT_ISSUER,
@ -172,7 +173,7 @@ const APP_CONFIGS = [
vars: {
PUBLIC_SUPABASE_URL: (env) => env.MANACORE_SUPABASE_URL,
PUBLIC_SUPABASE_ANON_KEY: (env) => env.MANACORE_SUPABASE_ANON_KEY,
PUBLIC_MANA_CORE_AUTH_URL: (env) => env.MANA_CORE_AUTH_URL,
MIDDLEWARE_URL: (env) => env.MANA_CORE_AUTH_URL,
},
},
@ -340,7 +341,7 @@ const APP_CONFIGS = [
MANA_CORE_AUTH_URL: (env) => env.MANA_CORE_AUTH_URL,
DEV_BYPASS_AUTH: () => 'true',
DEV_USER_ID: (env) => env.DEV_USER_ID || '00000000-0000-0000-0000-000000000000',
// JWT keys fetched via JWKS from MANA_CORE_AUTH_URL/api/v1/auth/jwks
JWT_PUBLIC_KEY: (env) => env.JWT_PUBLIC_KEY,
CORS_ORIGINS: (env) => env.CORS_ORIGINS,
},
},

View file

@ -0,0 +1,124 @@
#!/bin/bash
# Generate Staging Secrets for GitHub
# Run this script and copy the output to GitHub Secrets
set -e
echo "================================================"
echo " STAGING SECRETS GENERATOR"
echo "================================================"
echo ""
echo "Copy each value below to GitHub Settings → Secrets and variables → Actions"
echo ""
echo "Note: Configuration values (host, ports, etc.) are now hardcoded in the workflow"
echo "Only sensitive values (passwords, keys) need to be added as secrets"
echo ""
echo "================================================"
echo ""
# Generate secure random passwords
POSTGRES_PASSWORD=$(openssl rand -base64 32 | tr -d "=+/" | cut -c1-32)
REDIS_PASSWORD=$(openssl rand -base64 32 | tr -d "=+/" | cut -c1-32)
JWT_SECRET=$(openssl rand -base64 64 | tr -d "=+/" | cut -c1-64)
# Generate Ed25519 key pair for JWT
TEMP_KEY_DIR=$(mktemp -d)
ssh-keygen -t ed25519 -f "$TEMP_KEY_DIR/jwt_key" -N "" -C "manacore-staging-jwt" > /dev/null 2>&1
# Convert SSH keys to raw format for JWT
PRIVATE_KEY=$(cat "$TEMP_KEY_DIR/jwt_key" | grep -v "BEGIN" | grep -v "END" | tr -d '\n')
PUBLIC_KEY=$(ssh-keygen -e -m PKCS8 -f "$TEMP_KEY_DIR/jwt_key.pub" 2>/dev/null | grep -v "BEGIN" | grep -v "END" | tr -d '\n' || cat "$TEMP_KEY_DIR/jwt_key.pub" | awk '{print $2}')
# Clean up temp files
rm -rf "$TEMP_KEY_DIR"
# Output all secrets in GitHub format
echo "# ============================================"
echo "# DATABASE SECRETS (2 secrets)"
echo "# ============================================"
echo ""
echo "STAGING_POSTGRES_PASSWORD"
echo "$POSTGRES_PASSWORD"
echo ""
echo "# ============================================"
echo "# REDIS SECRETS (1 secret)"
echo "# ============================================"
echo ""
echo "STAGING_REDIS_PASSWORD"
echo "$REDIS_PASSWORD"
echo ""
echo "# ============================================"
echo "# MANA CORE AUTH SECRETS (3 secrets)"
echo "# ============================================"
echo ""
echo "STAGING_JWT_SECRET"
echo "$JWT_SECRET"
echo ""
echo "STAGING_JWT_PUBLIC_KEY"
echo "$PUBLIC_KEY"
echo ""
echo "STAGING_JWT_PRIVATE_KEY"
echo "$PRIVATE_KEY"
echo ""
echo "# ============================================"
echo "# SUPABASE SECRETS (Fill these manually - 3 secrets)"
echo "# ============================================"
echo ""
echo "STAGING_SUPABASE_URL"
echo "https://YOUR_PROJECT.supabase.co"
echo ""
echo "STAGING_SUPABASE_ANON_KEY"
echo "YOUR_SUPABASE_ANON_KEY_HERE"
echo ""
echo "STAGING_SUPABASE_SERVICE_ROLE_KEY"
echo "YOUR_SUPABASE_SERVICE_ROLE_KEY_HERE"
echo ""
echo "# ============================================"
echo "# AZURE OPENAI SECRETS (Fill these manually - 2 secrets)"
echo "# ============================================"
echo ""
echo "STAGING_AZURE_OPENAI_ENDPOINT"
echo "https://YOUR_RESOURCE.openai.azure.com/"
echo ""
echo "STAGING_AZURE_OPENAI_API_KEY"
echo "YOUR_AZURE_OPENAI_API_KEY_HERE"
echo ""
echo "# ============================================"
echo "# SSH DEPLOYMENT SECRETS (Fill these manually - 1 secret)"
echo "# ============================================"
echo ""
echo "STAGING_SSH_KEY"
echo "Run: cat ~/.ssh/hetzner_deploy_key"
echo "(Copy the ENTIRE output including -----BEGIN and -----END lines)"
echo ""
echo "================================================"
echo " SUMMARY"
echo "================================================"
echo ""
echo "Total secrets to add: 12"
echo " - Auto-generated: 6 (passwords, JWT keys)"
echo " - Manual: 6 (Supabase, Azure, SSH key)"
echo ""
echo "The following are now HARDCODED in the workflow:"
echo " - POSTGRES_HOST, POSTGRES_PORT, POSTGRES_DB, POSTGRES_USER"
echo " - REDIS_HOST, REDIS_PORT"
echo " - MANA_SERVICE_URL"
echo " - STAGING_HOST (46.224.108.214)"
echo " - STAGING_USER (deploy)"
echo ""
echo "================================================"
echo ""
echo "Next steps:"
echo "1. Go to: https://github.com/YOUR_ORG/manacore-monorepo/settings/secrets/actions"
echo "2. Click 'New repository secret' for each value above"
echo "3. Copy the secret name (e.g., STAGING_POSTGRES_PASSWORD)"
echo "4. Copy the secret value (the line below the name)"
echo "5. Fill in Supabase, Azure, and SSH key values manually"
echo ""

View file

@ -71,6 +71,7 @@ ALL_DATABASES=(
"techbase"
"voxel_lava"
"figgos"
"planta"
)
# Check if specific service requested
@ -136,9 +137,13 @@ setup_service() {
create_db_if_not_exists "figgos"
push_schema "@figgos/backend" "figgos"
;;
planta)
create_db_if_not_exists "planta"
push_schema "@planta/backend" "planta"
;;
*)
echo -e "${RED}Unknown service: $service${NC}"
echo "Available services: auth, chat, zitare, contacts, calendar, clock, todo, manadeck, mail, moodlit, finance, voxel-lava, figgos"
echo "Available services: auth, chat, zitare, contacts, calendar, clock, todo, manadeck, mail, moodlit, finance, voxel-lava, figgos, planta"
exit 1
;;
esac
@ -162,7 +167,7 @@ echo -e "\n${GREEN}Step 2: Pushing schemas${NC}"
echo "--------------------------------------"
# Push schemas for all known services
for service in auth chat zitare contacts calendar clock todo manadeck picture mail moodlit finance voxel-lava figgos; do
for service in auth chat zitare contacts calendar clock todo manadeck picture mail moodlit finance voxel-lava figgos planta; do
setup_service "$service" 2>/dev/null || true
done

View file

@ -1,84 +0,0 @@
#!/usr/bin/env bash
# Run svelte-check on web apps that have staged .svelte files
# This catches a11y warnings, Svelte 5 issues, and import errors before CI
set -e
# Get list of staged svelte files
STAGED_SVELTE=$(git diff --cached --name-only --diff-filter=ACM | grep '\.svelte$' || true)
if [ -z "$STAGED_SVELTE" ]; then
echo "No staged .svelte files, skipping svelte-check"
exit 0
fi
# Find unique web app directories that have changes
WEB_APPS=""
for file in $STAGED_SVELTE; do
# Extract the web app path (e.g., apps/todo/apps/web)
app_path=""
if [[ $file =~ ^(apps/[^/]+/apps/web)/ ]]; then
app_path="${BASH_REMATCH[1]}"
elif [[ $file =~ ^(games/[^/]+/apps/web)/ ]]; then
app_path="${BASH_REMATCH[1]}"
elif [[ $file =~ ^(packages/[^/]+)/ ]]; then
# For shared packages, check all web apps that might use them
echo "⚠️ Changes in shared package: $file"
echo " Consider running: pnpm run build:check to verify all web apps"
fi
# Add to list if not already present
if [ -n "$app_path" ]; then
if [[ ! " $WEB_APPS " =~ " $app_path " ]]; then
WEB_APPS="$WEB_APPS $app_path"
fi
fi
done
# Trim leading space
WEB_APPS=$(echo "$WEB_APPS" | xargs)
if [ -z "$WEB_APPS" ]; then
echo "No web app changes detected"
exit 0
fi
echo "🔍 Running svelte-check on affected web apps..."
FAILED=0
for app in $WEB_APPS; do
if [ -f "$app/package.json" ]; then
echo ""
echo "━━━ Checking $app ━━━"
# Get the package name for pnpm filter
PKG_NAME=$(node -p "require('./$app/package.json').name" 2>/dev/null || echo "")
if [ -n "$PKG_NAME" ]; then
# Run svelte-check - fails on both errors AND warnings
# This ensures no a11y issues or Svelte problems slip through
if ! pnpm --filter "$PKG_NAME" exec svelte-check --tsconfig ./tsconfig.json --threshold warning 2>&1; then
echo "❌ svelte-check failed for $app"
FAILED=1
else
echo "✅ svelte-check passed for $app"
fi
fi
fi
done
if [ $FAILED -eq 1 ]; then
echo ""
echo "❌ svelte-check failed! Fix the issues above before committing."
echo ""
echo "Common fixes:"
echo " - Add role and tabindex to interactive divs"
echo " - Add onkeydown handler alongside onclick"
echo " - Use \$state() for reactive variables in Svelte 5"
echo " - Check that all imports resolve correctly"
exit 1
fi
echo ""
echo "✅ All svelte-checks passed!"

View file

@ -1,204 +0,0 @@
#!/usr/bin/env node
/**
* Migration Validation Script
*
* Scans migration files for destructive SQL patterns and fails CI if found.
* This prevents accidental data loss in production deployments.
*
* Usage:
* node scripts/validate-migrations.mjs [--allow-destructive]
*
* Destructive patterns detected:
* - DROP TABLE
* - DROP COLUMN
* - DROP INDEX (without CONCURRENTLY)
* - DROP SCHEMA
* - TRUNCATE
* - DELETE FROM (without WHERE)
*
* Safe patterns (allowed):
* - DROP ... IF EXISTS (only creates if not exists)
* - CREATE ... IF NOT EXISTS
* - ALTER TABLE ... ADD COLUMN
* - CREATE INDEX CONCURRENTLY
*/
import { readFileSync, readdirSync, existsSync, statSync } from 'fs';
import { join } from 'path';
// Configuration
const MIGRATION_DIRS = [
'services/mana-core-auth/src/db/migrations',
// Add other services as needed
];
// Destructive patterns to detect
const DESTRUCTIVE_PATTERNS = [
{
pattern: /DROP\s+TABLE(?!\s+IF\s+EXISTS)/gi,
message: 'DROP TABLE without IF EXISTS - will fail if table does not exist and is destructive',
severity: 'error',
},
{
pattern: /DROP\s+TABLE\s+IF\s+EXISTS\s+(?!.*CASCADE\s*;)/gi,
message: 'DROP TABLE IF EXISTS - THIS WILL DELETE DATA',
severity: 'error',
},
{
pattern: /DROP\s+TABLE.*CASCADE/gi,
message: 'DROP TABLE CASCADE - THIS WILL DELETE DATA AND DEPENDENT OBJECTS',
severity: 'error',
},
{
pattern: /ALTER\s+TABLE\s+\S+\s+DROP\s+COLUMN/gi,
message: 'DROP COLUMN - THIS WILL DELETE DATA',
severity: 'error',
},
{
pattern: /DROP\s+SCHEMA(?!\s+IF\s+EXISTS)/gi,
message: 'DROP SCHEMA without IF EXISTS',
severity: 'error',
},
{
pattern: /DROP\s+SCHEMA\s+IF\s+EXISTS.*CASCADE/gi,
message: 'DROP SCHEMA CASCADE - THIS WILL DELETE ALL TABLES IN SCHEMA',
severity: 'error',
},
{
pattern: /TRUNCATE\s+(?:TABLE\s+)?/gi,
message: 'TRUNCATE - THIS WILL DELETE ALL DATA IN TABLE',
severity: 'error',
},
{
pattern: /DELETE\s+FROM\s+\S+\s*(?:;|$)/gim,
message: 'DELETE FROM without WHERE clause - THIS WILL DELETE ALL DATA',
severity: 'error',
},
{
pattern: /DROP\s+INDEX(?!\s+CONCURRENTLY)/gi,
message: 'DROP INDEX without CONCURRENTLY - may cause table locks',
severity: 'warning',
},
];
// Safe patterns that override destructive checks
const SAFE_PATTERNS = [
// These patterns indicate safe, idempotent operations
/CREATE\s+(?:TABLE|INDEX|SCHEMA|TYPE)\s+IF\s+NOT\s+EXISTS/gi,
/DO\s+\$\$.*EXCEPTION.*WHEN\s+duplicate_object/gis, // Safe enum creation pattern
];
function findMigrationFiles(dir) {
const files = [];
if (!existsSync(dir)) {
return files;
}
const entries = readdirSync(dir);
for (const entry of entries) {
const fullPath = join(dir, entry);
const stat = statSync(fullPath);
if (stat.isDirectory() && entry !== 'meta') {
// Check subdirectories for .sql files
files.push(...findMigrationFiles(fullPath));
} else if (entry.endsWith('.sql')) {
files.push(fullPath);
}
}
return files;
}
function validateMigration(filePath) {
const content = readFileSync(filePath, 'utf-8');
const issues = [];
// Check if file uses safe patterns throughout (reserved for future use)
// const isSafeFile = SAFE_PATTERNS.some((pattern) => pattern.test(content));
void SAFE_PATTERNS; // Silence unused warning - patterns reserved for future enhancements
for (const { pattern, message, severity } of DESTRUCTIVE_PATTERNS) {
// Reset regex lastIndex
pattern.lastIndex = 0;
let match;
while ((match = pattern.exec(content)) !== null) {
// Find line number
const beforeMatch = content.substring(0, match.index);
const lineNumber = (beforeMatch.match(/\n/g) || []).length + 1;
issues.push({
file: filePath,
line: lineNumber,
message,
severity,
match: match[0].trim(),
});
}
}
return issues;
}
function main() {
const args = process.argv.slice(2);
const allowDestructive = args.includes('--allow-destructive');
console.log('🔍 Validating migration files for destructive patterns...\n');
const allIssues = [];
let filesChecked = 0;
for (const dir of MIGRATION_DIRS) {
const files = findMigrationFiles(dir);
filesChecked += files.length;
for (const file of files) {
const issues = validateMigration(file);
allIssues.push(...issues);
}
}
// Separate errors and warnings
const errors = allIssues.filter((i) => i.severity === 'error');
const warnings = allIssues.filter((i) => i.severity === 'warning');
console.log(`📁 Checked ${filesChecked} migration files\n`);
if (warnings.length > 0) {
console.log('⚠️ WARNINGS:\n');
for (const issue of warnings) {
console.log(` ${issue.file}:${issue.line}`);
console.log(` ${issue.message}`);
console.log(` Found: ${issue.match}\n`);
}
}
if (errors.length > 0) {
console.log('❌ DESTRUCTIVE PATTERNS DETECTED:\n');
for (const issue of errors) {
console.log(` ${issue.file}:${issue.line}`);
console.log(` ${issue.message}`);
console.log(` Found: ${issue.match}\n`);
}
if (allowDestructive) {
console.log('⚠️ --allow-destructive flag set, continuing despite errors\n');
console.log('🟡 Migration validation passed with warnings');
process.exit(0);
} else {
console.log('💡 To proceed with destructive migrations, use --allow-destructive flag');
console.log(' Or review and update the migration to use safe patterns.\n');
console.log('❌ Migration validation FAILED');
process.exit(1);
}
}
console.log('✅ Migration validation passed - no destructive patterns found');
process.exit(0);
}
main();

View file

@ -1,451 +0,0 @@
#!/usr/bin/env node
/**
* Runtime Configuration Validator
*
* Validates that all web apps follow the runtime configuration pattern correctly.
* This prevents bugs where apps use build-time env vars or window injection instead
* of the proper runtime config loader.
*
* Usage:
* node scripts/validate-runtime-config.mjs
* pnpm validate:runtime-config
*
* What it checks:
* 1. Required files exist (runtime.ts, docker-entrypoint.sh, Dockerfile)
* 2. No window injection patterns (window.__PUBLIC_*)
* 3. No direct build-time env imports in stores/api (import.meta.env.PUBLIC_*)
* 4. Correct async patterns (no missing await on getAuthUrl(), etc.)
* 5. Docker entrypoint generates config.json correctly
*/
import { readdir, readFile, stat } from 'node:fs/promises';
import { join, relative } from 'node:path';
import { fileURLToPath } from 'node:url';
const __dirname = fileURLToPath(new URL('.', import.meta.url));
const monorepoRoot = join(__dirname, '..');
// Colors for terminal output
const colors = {
reset: '\x1b[0m',
red: '\x1b[31m',
green: '\x1b[32m',
yellow: '\x1b[33m',
blue: '\x1b[34m',
bold: '\x1b[1m',
};
const { reset, red, green, yellow, blue, bold } = colors;
// Validation results
const results = {
passed: [],
failed: [],
warnings: [],
};
/**
* Find all web apps in the monorepo
*/
async function findWebApps() {
const webApps = [];
const appsDirs = [join(monorepoRoot, 'apps'), join(monorepoRoot, 'games')];
for (const appsDir of appsDirs) {
try {
const projects = await readdir(appsDir);
for (const project of projects) {
const webAppPath = join(appsDir, project, 'apps', 'web');
try {
const stats = await stat(webAppPath);
if (stats.isDirectory()) {
webApps.push({
path: webAppPath,
name: `${relative(monorepoRoot, appsDir)}/${project}/apps/web`,
});
}
} catch {
// No web app in this project, skip
}
}
} catch {
// Apps directory doesn't exist, skip
}
}
return webApps;
}
/**
* Check if a file exists
*/
async function fileExists(filePath) {
try {
await stat(filePath);
return true;
} catch {
return false;
}
}
/**
* Read file content safely
*/
async function readFileSafe(filePath) {
try {
return await readFile(filePath, 'utf-8');
} catch {
return null;
}
}
/**
* Validate required files exist
*/
async function validateRequiredFiles(webApp) {
const errors = [];
const warnings = [];
// Check for runtime.ts
const runtimeTsPath = join(webApp.path, 'src/lib/config/runtime.ts');
if (!(await fileExists(runtimeTsPath))) {
errors.push('Missing src/lib/config/runtime.ts');
}
// Check for docker-entrypoint.sh
const entrypointPath = join(webApp.path, 'docker-entrypoint.sh');
if (!(await fileExists(entrypointPath))) {
warnings.push('Missing docker-entrypoint.sh (required for Docker deployment)');
}
// Check for Dockerfile
const dockerfilePath = join(webApp.path, 'Dockerfile');
if (!(await fileExists(dockerfilePath))) {
warnings.push('Missing Dockerfile (required for Docker deployment)');
}
// Check for static/config.json (dev fallback)
const configJsonPath = join(webApp.path, 'static/config.json');
if (!(await fileExists(configJsonPath))) {
warnings.push('Missing static/config.json (dev fallback)');
}
return { errors, warnings };
}
/**
* Check for window injection anti-pattern
*/
async function validateNoWindowInjection(webApp) {
const errors = [];
const srcPath = join(webApp.path, 'src');
async function scanDirectory(dir) {
try {
const entries = await readdir(dir, { withFileTypes: true });
for (const entry of entries) {
const fullPath = join(dir, entry.name);
if (entry.isDirectory()) {
// Skip node_modules, build artifacts
if (!['node_modules', 'build', '.svelte-kit', 'dist'].includes(entry.name)) {
await scanDirectory(fullPath);
}
} else if (entry.name.match(/\.(ts|js|svelte)$/)) {
const content = await readFileSafe(fullPath);
if (!content) continue;
// Check for window.__PUBLIC_* pattern
const windowInjectionPattern = /window\.__PUBLIC_[A-Z_]+__/g;
const matches = content.match(windowInjectionPattern);
if (matches) {
const relativePath = relative(webApp.path, fullPath);
errors.push(
`${relativePath}: Found window injection pattern (${matches.join(', ')}). Use runtime config instead.`
);
}
// Check for (window as any).__PUBLIC_* pattern
const windowAsAnyPattern = /\(window as (?:any|unknown)\)\.__PUBLIC_[A-Z_]+__/g;
const asAnyMatches = content.match(windowAsAnyPattern);
if (asAnyMatches) {
const relativePath = relative(webApp.path, fullPath);
errors.push(
`${relativePath}: Found window injection with type assertion (${asAnyMatches.join(', ')}). Use runtime config instead.`
);
}
}
}
} catch {
// Directory doesn't exist or can't be read
}
}
await scanDirectory(srcPath);
return { errors, warnings: [] };
}
/**
* Check for direct build-time env usage in stores/api files
*/
async function validateNoBuildTimeEnv(webApp) {
const errors = [];
const warnings = [];
const criticalPaths = [
join(webApp.path, 'src/lib/stores'),
join(webApp.path, 'src/lib/api'),
join(webApp.path, 'src/lib/config'),
];
for (const criticalPath of criticalPaths) {
if (!(await fileExists(criticalPath))) continue;
async function scanDirectory(dir) {
try {
const entries = await readdir(dir, { withFileTypes: true });
for (const entry of entries) {
const fullPath = join(dir, entry.name);
if (entry.isDirectory()) {
await scanDirectory(fullPath);
} else if (entry.name.match(/\.(ts|js)$/)) {
const content = await readFileSafe(fullPath);
if (!content) continue;
// Check for import.meta.env.PUBLIC_* in stores/api
const envPattern = /import\.meta\.env\.PUBLIC_[A-Z_]+/g;
const matches = content.match(envPattern);
if (matches) {
const relativePath = relative(webApp.path, fullPath);
// Allow in config files if they have backward compat exports
if (
relativePath.includes('config/api.ts') ||
relativePath.includes('config/env.ts')
) {
warnings.push(
`${relativePath}: Uses build-time env vars (${matches.join(', ')}). Consider migrating to runtime config.`
);
} else {
errors.push(
`${relativePath}: Uses build-time env vars (${matches.join(', ')}). Use runtime config instead.`
);
}
}
}
}
} catch {
// Directory doesn't exist or can't be read
}
}
await scanDirectory(criticalPath);
}
return { errors, warnings };
}
/**
* Check for missing await on async config functions
*/
async function validateAsyncUsage(webApp) {
const errors = [];
const srcPath = join(webApp.path, 'src');
async function scanDirectory(dir) {
try {
const entries = await readdir(dir, { withFileTypes: true });
for (const entry of entries) {
const fullPath = join(dir, entry.name);
if (entry.isDirectory()) {
if (!['node_modules', 'build', '.svelte-kit', 'dist'].includes(entry.name)) {
await scanDirectory(fullPath);
}
} else if (entry.name.match(/\.(ts|js|svelte)$/)) {
const content = await readFileSafe(fullPath);
if (!content) continue;
// Check for common async config functions called without await
const asyncFunctions = [
'getAuthUrl',
'getBackendUrl',
'getApiBaseUrl',
'getTodoApiUrl',
'getCalendarApiUrl',
'getClockApiUrl',
'getContactsApiUrl',
'getConfig',
];
for (const funcName of asyncFunctions) {
// Pattern: using function in template literal without await
// e.g., `${getAuthUrl()}/api` instead of `${await getAuthUrl()}/api`
const templateLiteralPattern = new RegExp(`\\$\\{\\s*${funcName}\\(\\)\\s*\\}`, 'g');
const matches = content.match(templateLiteralPattern);
if (matches) {
// Check if there's await before it
const fullPattern = new RegExp(`await\\s+${funcName}\\(\\)`, 'g');
const awaitMatches = content.match(fullPattern);
// If we found calls without await
if (!awaitMatches || matches.length > awaitMatches.length) {
const relativePath = relative(webApp.path, fullPath);
errors.push(
`${relativePath}: Missing 'await' on async function ${funcName}(). This causes "[object Promise]" in URLs.`
);
}
}
}
}
}
} catch {
// Directory doesn't exist or can't be read
}
}
await scanDirectory(srcPath);
return { errors, warnings: [] };
}
/**
* Validate Docker entrypoint generates config.json correctly
*/
async function validateDockerEntrypoint(webApp) {
const errors = [];
const warnings = [];
const entrypointPath = join(webApp.path, 'docker-entrypoint.sh');
const content = await readFileSafe(entrypointPath);
if (!content) {
// Already caught in validateRequiredFiles
return { errors, warnings };
}
// Check that it generates config.json
if (!content.includes('config.json')) {
errors.push('docker-entrypoint.sh does not generate config.json');
}
// Check that it uses relative paths (not absolute like /app/build/client/config.json)
if (content.includes('> /app/build/client/config.json')) {
errors.push(
'docker-entrypoint.sh uses absolute path for config.json. Use relative path (build/client/config.json) instead.'
);
}
// Check that it has mkdir -p for config directory
if (!content.includes('mkdir -p')) {
warnings.push('docker-entrypoint.sh should include "mkdir -p build/client" for safety');
}
// Check that it executes the CMD with exec "$@"
if (!content.includes('exec "$@"')) {
warnings.push('docker-entrypoint.sh should end with: exec "$@"');
}
return { errors, warnings };
}
/**
* Validate a single web app
*/
async function validateWebApp(webApp) {
console.log(`\n${blue}${bold}Checking:${reset} ${webApp.name}`);
const checks = [
{ name: 'Required files', fn: validateRequiredFiles },
{ name: 'No window injection', fn: validateNoWindowInjection },
{ name: 'No build-time env in stores/api', fn: validateNoBuildTimeEnv },
{ name: 'Async function usage', fn: validateAsyncUsage },
{ name: 'Docker entrypoint', fn: validateDockerEntrypoint },
];
const allErrors = [];
const allWarnings = [];
for (const check of checks) {
const { errors, warnings } = await check.fn(webApp);
if (errors.length > 0) {
allErrors.push(...errors);
}
if (warnings.length > 0) {
allWarnings.push(...warnings);
}
}
// Print results
if (allErrors.length === 0 && allWarnings.length === 0) {
console.log(`${green}${reset} All checks passed`);
results.passed.push(webApp.name);
} else {
if (allErrors.length > 0) {
console.log(`${red}${reset} ${allErrors.length} error(s):`);
allErrors.forEach((error) => console.log(` ${red}${reset} ${error}`));
results.failed.push({ name: webApp.name, errors: allErrors });
}
if (allWarnings.length > 0) {
console.log(`${yellow}${reset} ${allWarnings.length} warning(s):`);
allWarnings.forEach((warning) => console.log(` ${yellow}${reset} ${warning}`));
results.warnings.push({ name: webApp.name, warnings: allWarnings });
}
}
}
/**
* Main validation function
*/
async function main() {
console.log(`${bold}Runtime Configuration Validator${reset}\n`);
console.log('Scanning for web apps...\n');
const webApps = await findWebApps();
if (webApps.length === 0) {
console.log(`${yellow}No web apps found${reset}`);
return;
}
console.log(`Found ${webApps.length} web app(s)\n`);
// Validate each web app
for (const webApp of webApps) {
await validateWebApp(webApp);
}
// Print summary
console.log(`\n${bold}═══════════════════════════════════════${reset}`);
console.log(`${bold}Summary${reset}\n`);
console.log(`${green}${reset} Passed: ${results.passed.length}`);
console.log(`${yellow}${reset} Warnings: ${results.warnings.length}`);
console.log(`${red}${reset} Failed: ${results.failed.length}`);
if (results.failed.length > 0) {
console.log(`\n${red}${bold}Failed apps:${reset}`);
results.failed.forEach(({ name }) => console.log(` ${red}${reset} ${name}`));
}
// Exit with error if any validations failed
if (results.failed.length > 0) {
console.log(`\n${red}${bold}Validation failed!${reset}`);
console.log('\nFix the errors above to ensure runtime configuration is implemented correctly.');
console.log('See docs/RUNTIME_CONFIG.md for implementation guide.\n');
process.exit(1);
}
console.log(`\n${green}${bold}All validations passed!${reset}\n`);
}
main().catch((error) => {
console.error(`${red}${bold}Validation script error:${reset}`, error);
process.exit(1);
});