/** * Default Workspace Management * * Ensures a default workspace exists for desktop mode and * legacy single-user (admin password) mode. Called on first * access when no workspace context exists. * * Handles migration: if data/osws.sqlite has existing projects, * copies it to the new workspace so data isn't lost on upgrade. */ import 'server-only'; import Database from 'better-sqlite3'; import path from 'path'; import fs from 'fs'; import { getSystemDatabase, createWorkspace, setDefaultWorkspace, getUserDefaultWorkspace, getUserById, updateWorkspace, getWorkspaceById, getDeploymentBySlug, } from './system-database'; import { generateUniqueSlug } from '@/lib/publishing/slug-generator'; import { logger } from '@/lib/utils'; function openReadonlyDb(dbPath: string): Database.Database { const db = new Database(dbPath, { readonly: true }); const key = process.env.DB_ENCRYPTION_KEY; if (key) db.pragma(`key='${key}'`); return db; } const DEFAULT_WORKSPACE_NAME = 'Local Workspace'; function getDataDir(): string { return process.env.DATA_DIR || path.join(process.cwd(), 'data'); } /** * Ensure a default workspace exists for the given user ID. * Creates the user (if synthetic like 'admin'/'desktop') and workspace on first call. * If upgrading from single-user mode, migrates existing data to the workspace. * Returns the default workspace ID. */ export async function ensureDefaultWorkspace(userId: string): Promise { // When in managed mode (WEBHOOK_URL set), workspaces start clean — no legacy migration. // On standalone instances, migrate legacy data/osws.sqlite into the workspace for all users. const isBalancerManaged = !!process.env.WEBHOOK_URL; // Check if user already has a default workspace — and that it still exists. // A stale default (e.g. database partially lost during a desktop update) // must fall through to recreation instead of returning a dead workspace id. const existing = getUserDefaultWorkspace(userId); if (existing && getWorkspaceById(existing)) { if (!isBalancerManaged) { migrateLegacyData(existing); } return existing; } // For synthetic users (admin, desktop), create a user record if it doesn't exist const user = getUserById(userId); if (!user) { const { randomBytes } = await import('crypto'); // Synthetic local users (admin/desktop) never log in by this hash, so store // a non-bcrypt placeholder — avoids loading bcrypt's native addon during // desktop boot, where a load failure would block workspace init. const hash = `nologin:${randomBytes(32).toString('hex')}`; const db = getSystemDatabase(); db.prepare(` INSERT OR IGNORE INTO users (id, email, password_hash, display_name, is_admin, active) VALUES (?, ?, ?, ?, 1, 1) `).run(userId, `${userId}@localhost`, hash, userId === 'desktop' ? 'Desktop' : 'Admin'); } // Create default workspace with high limits for admin/desktop const workspaceId = createWorkspace(DEFAULT_WORKSPACE_NAME, userId); updateWorkspace(workspaceId, { max_projects: 9999, max_deployments: 9999, max_storage_mb: 99999, }); setDefaultWorkspace(userId, workspaceId); // Migrate legacy data on standalone instances if (!isBalancerManaged) { migrateLegacyData(workspaceId); } return workspaceId; } /** * If upgrading from single-user mode, copy the existing data/osws.sqlite * and project databases into the new workspace directory. * * Checks whether the legacy DB has data AND the workspace DB is empty * (not just whether files exist, since the adapter may have already * created an empty workspace DB with schema migrations). */ function migrateLegacyData(workspaceId: string): void { const dataDir = getDataDir(); const legacyDbPath = path.join(dataDir, 'osws.sqlite'); const workspaceDir = path.join(dataDir, 'workspaces', workspaceId); const workspaceDbPath = path.join(workspaceDir, 'osws.sqlite'); if (!fs.existsSync(legacyDbPath)) return; // Check if legacy DB actually has projects let legacyProjectCount = 0; try { const db = openReadonlyDb(legacyDbPath); const row = db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='projects'").get(); if (row) { legacyProjectCount = (db.prepare('SELECT COUNT(*) as c FROM projects').get() as { c: number }).c; } db.close(); } catch { return; } if (legacyProjectCount === 0) return; // Check if workspace DB already has data (don't overwrite) if (fs.existsSync(workspaceDbPath)) { try { const db = openReadonlyDb(workspaceDbPath); const row = db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='projects'").get(); if (row) { const count = (db.prepare('SELECT COUNT(*) as c FROM projects').get() as { c: number }).c; db.close(); if (count > 0) return; // Workspace already has data, skip } else { db.close(); } } catch { /* workspace DB doesn't exist or is corrupt — proceed with copy */ } } try { fs.mkdirSync(workspaceDir, { recursive: true }); // Copy the legacy database (overwrites the empty one if it exists) fs.copyFileSync(legacyDbPath, workspaceDbPath); // Copy WAL/SHM files if they exist for (const ext of ['-wal', '-shm']) { const walPath = legacyDbPath + ext; if (fs.existsSync(walPath)) { fs.copyFileSync(walPath, workspaceDbPath + ext); } } // Copy project databases (data/projects/ -> data/workspaces/{id}/projects/) const legacyProjectsDir = path.join(dataDir, 'projects'); if (fs.existsSync(legacyProjectsDir)) { const workspaceProjectsDir = path.join(workspaceDir, 'projects'); copyDirRecursive(legacyProjectsDir, workspaceProjectsDir); } } catch (err) { logger.error('[DefaultWorkspace] Failed to migrate legacy data:', err); } } function copyDirRecursive(src: string, dest: string): void { if (!fs.existsSync(src)) return; fs.mkdirSync(dest, { recursive: true }); const entries = fs.readdirSync(src, { withFileTypes: true }); for (const entry of entries) { const srcPath = path.join(src, entry.name); const destPath = path.join(dest, entry.name); if (entry.isDirectory()) { copyDirRecursive(srcPath, destPath); } else { fs.copyFileSync(srcPath, destPath); } } } // --------------------------------------------------------------------------- // Repair / Heal // --------------------------------------------------------------------------- export interface RepairResult { legacyDbMigrated: boolean; legacyProjectsMigrated: number; deploymentRoutesCreated: number; errors: string[]; } /** * Repair a workspace by detecting and fixing common issues: * 1. Legacy data/osws.sqlite not migrated to workspace * 2. Project databases in data/projects/ not copied to workspace * 3. Deployments in workspace DB but missing from deployment_routing * * Safe to run multiple times — skips already-fixed items. */ export function repairWorkspace(workspaceId: string): RepairResult { const dataDir = getDataDir(); const workspaceDir = path.join(dataDir, 'workspaces', workspaceId); const workspaceDbPath = path.join(workspaceDir, 'osws.sqlite'); const legacyDbPath = path.join(dataDir, 'osws.sqlite'); const result: RepairResult = { legacyDbMigrated: false, legacyProjectsMigrated: 0, deploymentRoutesCreated: 0, errors: [], }; // 1. If workspace DB is empty/missing but legacy DB has data, copy it if (fs.existsSync(legacyDbPath)) { const legacyHasData = (() => { try { const db = openReadonlyDb(legacyDbPath); const tableExists = db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='projects'").get(); if (!tableExists) { db.close(); return false; } const count = (db.prepare('SELECT COUNT(*) as count FROM projects').get() as { count: number }).count; db.close(); return count > 0; } catch { return false; } })(); if (legacyHasData) { const workspaceHasData = (() => { if (!fs.existsSync(workspaceDbPath)) return false; try { const db = openReadonlyDb(workspaceDbPath); const tableExists = db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='projects'").get(); if (!tableExists) { db.close(); return false; } const count = (db.prepare('SELECT COUNT(*) as count FROM projects').get() as { count: number }).count; db.close(); return count > 0; } catch { return false; } })(); if (!workspaceHasData) { try { fs.mkdirSync(workspaceDir, { recursive: true }); fs.copyFileSync(legacyDbPath, workspaceDbPath); for (const ext of ['-wal', '-shm']) { const walPath = legacyDbPath + ext; if (fs.existsSync(walPath)) { fs.copyFileSync(walPath, workspaceDbPath + ext); } } result.legacyDbMigrated = true; } catch (err) { result.errors.push(`Failed to copy legacy DB: ${err}`); } } } } // 2. Copy orphaned project databases from data/projects/ to workspace const legacyProjectsDir = path.join(dataDir, 'projects'); const workspaceProjectsDir = path.join(workspaceDir, 'projects'); if (fs.existsSync(legacyProjectsDir) && fs.existsSync(workspaceDbPath)) { try { // Get project IDs from workspace DB const db = openReadonlyDb(workspaceDbPath); const tableExists = db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='projects'").get(); const projectIds: string[] = []; if (tableExists) { const rows = db.prepare('SELECT id FROM projects').all() as { id: string }[]; projectIds.push(...rows.map(r => r.id)); } db.close(); // For each project in workspace DB, check if its database is in legacy dir but not workspace dir for (const projectId of projectIds) { const legacyProjectDir = path.join(legacyProjectsDir, projectId); const workspaceProjectDir = path.join(workspaceProjectsDir, projectId); if (fs.existsSync(legacyProjectDir) && !fs.existsSync(workspaceProjectDir)) { copyDirRecursive(legacyProjectDir, workspaceProjectDir); result.legacyProjectsMigrated++; } } } catch (err) { result.errors.push(`Failed to migrate project databases: ${err}`); } } // 3. Ensure all deployments in workspace DB are registered in deployment_routing if (fs.existsSync(workspaceDbPath)) { try { const db = openReadonlyDb(workspaceDbPath); const tableExists = db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='deployments'").get(); if (tableExists) { const deployments = db.prepare('SELECT id, slug FROM deployments').all() as { id: string; slug: string | null }[]; db.close(); const sysDb = getSystemDatabase(); for (const deployment of deployments) { const existing = sysDb.prepare('SELECT deployment_id FROM deployment_routing WHERE deployment_id = ?') .get(deployment.id); if (!existing) { // Assign a slug so the deployment gets a subdomain route — Caddy // generation skips routing rows without one. Prefer the deployment's // own slug; generate a unique one only if it lacks it. const slug = deployment.slug || generateUniqueSlug(s => !!getDeploymentBySlug(s)); sysDb.prepare(` INSERT OR IGNORE INTO deployment_routing (deployment_id, workspace_id, slug) VALUES (?, ?, ?) `).run(deployment.id, workspaceId, slug); result.deploymentRoutesCreated++; } } } else { db.close(); } } catch (err) { result.errors.push(`Failed to repair deployment routes: ${err}`); } } return result; }