Leon4gr45/builder
0
1/**2 * Default Workspace Management3 *4 * Ensures a default workspace exists for desktop mode and5 * legacy single-user (admin password) mode. Called on first6 * access when no workspace context exists.7 *8 * Handles migration: if data/osws.sqlite has existing projects,9 * copies it to the new workspace so data isn't lost on upgrade.10 */11 12import 'server-only';13 14import Database from 'better-sqlite3';15import path from 'path';16import fs from 'fs';17import {18 getSystemDatabase,19 createWorkspace,20 setDefaultWorkspace,21 getUserDefaultWorkspace,22 getUserById,23 updateWorkspace,24 getWorkspaceById,25 getDeploymentBySlug,26} from './system-database';27import { generateUniqueSlug } from '@/lib/publishing/slug-generator';28import { logger } from '@/lib/utils';29 30function openReadonlyDb(dbPath: string): Database.Database {31 const db = new Database(dbPath, { readonly: true });32 const key = process.env.DB_ENCRYPTION_KEY;33 if (key) db.pragma(`key='${key}'`);34 return db;35}36 37const DEFAULT_WORKSPACE_NAME = 'Local Workspace';38 39function getDataDir(): string {40 return process.env.DATA_DIR || path.join(process.cwd(), 'data');41}42 43/**44 * Ensure a default workspace exists for the given user ID.45 * Creates the user (if synthetic like 'admin'/'desktop') and workspace on first call.46 * If upgrading from single-user mode, migrates existing data to the workspace.47 * Returns the default workspace ID.48 */49export async function ensureDefaultWorkspace(userId: string): Promise<string> {50 // When in managed mode (WEBHOOK_URL set), workspaces start clean — no legacy migration.51 // On standalone instances, migrate legacy data/osws.sqlite into the workspace for all users.52 const isBalancerManaged = !!process.env.WEBHOOK_URL;53 54 // Check if user already has a default workspace — and that it still exists.55 // A stale default (e.g. database partially lost during a desktop update)56 // must fall through to recreation instead of returning a dead workspace id.57 const existing = getUserDefaultWorkspace(userId);58 if (existing && getWorkspaceById(existing)) {59 if (!isBalancerManaged) {60 migrateLegacyData(existing);61 }62 return existing;63 }64 65 // For synthetic users (admin, desktop), create a user record if it doesn't exist66 const user = getUserById(userId);67 if (!user) {68 const { randomBytes } = await import('crypto');69 // Synthetic local users (admin/desktop) never log in by this hash, so store70 // a non-bcrypt placeholder — avoids loading bcrypt's native addon during71 // desktop boot, where a load failure would block workspace init.72 const hash = `nologin:${randomBytes(32).toString('hex')}`;73 const db = getSystemDatabase();74 db.prepare(`75 INSERT OR IGNORE INTO users (id, email, password_hash, display_name, is_admin, active)76 VALUES (?, ?, ?, ?, 1, 1)77 `).run(userId, `${userId}@localhost`, hash, userId === 'desktop' ? 'Desktop' : 'Admin');78 }79 80 // Create default workspace with high limits for admin/desktop81 const workspaceId = createWorkspace(DEFAULT_WORKSPACE_NAME, userId);82 updateWorkspace(workspaceId, {83 max_projects: 9999,84 max_deployments: 9999,85 max_storage_mb: 99999,86 });87 setDefaultWorkspace(userId, workspaceId);88 89 // Migrate legacy data on standalone instances90 if (!isBalancerManaged) {91 migrateLegacyData(workspaceId);92 }93 94 return workspaceId;95}96 97/**98 * If upgrading from single-user mode, copy the existing data/osws.sqlite99 * and project databases into the new workspace directory.100 *101 * Checks whether the legacy DB has data AND the workspace DB is empty102 * (not just whether files exist, since the adapter may have already103 * created an empty workspace DB with schema migrations).104 */105function migrateLegacyData(workspaceId: string): void {106 const dataDir = getDataDir();107 const legacyDbPath = path.join(dataDir, 'osws.sqlite');108 const workspaceDir = path.join(dataDir, 'workspaces', workspaceId);109 const workspaceDbPath = path.join(workspaceDir, 'osws.sqlite');110 111 if (!fs.existsSync(legacyDbPath)) return;112 113 // Check if legacy DB actually has projects114 let legacyProjectCount = 0;115 try {116 const db = openReadonlyDb(legacyDbPath);117 const row = db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='projects'").get();118 if (row) {119 legacyProjectCount = (db.prepare('SELECT COUNT(*) as c FROM projects').get() as { c: number }).c;120 }121 db.close();122 } catch { return; }123 124 if (legacyProjectCount === 0) return;125 126 // Check if workspace DB already has data (don't overwrite)127 if (fs.existsSync(workspaceDbPath)) {128 try {129 const db = openReadonlyDb(workspaceDbPath);130 const row = db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='projects'").get();131 if (row) {132 const count = (db.prepare('SELECT COUNT(*) as c FROM projects').get() as { c: number }).c;133 db.close();134 if (count > 0) return; // Workspace already has data, skip135 } else {136 db.close();137 }138 } catch { /* workspace DB doesn't exist or is corrupt — proceed with copy */ }139 }140 141 try {142 fs.mkdirSync(workspaceDir, { recursive: true });143 144 // Copy the legacy database (overwrites the empty one if it exists)145 fs.copyFileSync(legacyDbPath, workspaceDbPath);146 147 // Copy WAL/SHM files if they exist148 for (const ext of ['-wal', '-shm']) {149 const walPath = legacyDbPath + ext;150 if (fs.existsSync(walPath)) {151 fs.copyFileSync(walPath, workspaceDbPath + ext);152 }153 }154 155 // Copy project databases (data/projects/ -> data/workspaces/{id}/projects/)156 const legacyProjectsDir = path.join(dataDir, 'projects');157 if (fs.existsSync(legacyProjectsDir)) {158 const workspaceProjectsDir = path.join(workspaceDir, 'projects');159 copyDirRecursive(legacyProjectsDir, workspaceProjectsDir);160 }161 } catch (err) {162 logger.error('[DefaultWorkspace] Failed to migrate legacy data:', err);163 }164}165 166function copyDirRecursive(src: string, dest: string): void {167 if (!fs.existsSync(src)) return;168 fs.mkdirSync(dest, { recursive: true });169 170 const entries = fs.readdirSync(src, { withFileTypes: true });171 for (const entry of entries) {172 const srcPath = path.join(src, entry.name);173 const destPath = path.join(dest, entry.name);174 if (entry.isDirectory()) {175 copyDirRecursive(srcPath, destPath);176 } else {177 fs.copyFileSync(srcPath, destPath);178 }179 }180}181 182// ---------------------------------------------------------------------------183// Repair / Heal184// ---------------------------------------------------------------------------185 186export interface RepairResult {187 legacyDbMigrated: boolean;188 legacyProjectsMigrated: number;189 deploymentRoutesCreated: number;190 errors: string[];191}192 193/**194 * Repair a workspace by detecting and fixing common issues:195 * 1. Legacy data/osws.sqlite not migrated to workspace196 * 2. Project databases in data/projects/ not copied to workspace197 * 3. Deployments in workspace DB but missing from deployment_routing198 *199 * Safe to run multiple times — skips already-fixed items.200 */201export function repairWorkspace(workspaceId: string): RepairResult {202 const dataDir = getDataDir();203 const workspaceDir = path.join(dataDir, 'workspaces', workspaceId);204 const workspaceDbPath = path.join(workspaceDir, 'osws.sqlite');205 const legacyDbPath = path.join(dataDir, 'osws.sqlite');206 const result: RepairResult = {207 legacyDbMigrated: false,208 legacyProjectsMigrated: 0,209 deploymentRoutesCreated: 0,210 errors: [],211 };212 213 // 1. If workspace DB is empty/missing but legacy DB has data, copy it214 if (fs.existsSync(legacyDbPath)) {215 const legacyHasData = (() => {216 try {217 const db = openReadonlyDb(legacyDbPath);218 const tableExists = db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='projects'").get();219 if (!tableExists) { db.close(); return false; }220 const count = (db.prepare('SELECT COUNT(*) as count FROM projects').get() as { count: number }).count;221 db.close();222 return count > 0;223 } catch { return false; }224 })();225 226 if (legacyHasData) {227 const workspaceHasData = (() => {228 if (!fs.existsSync(workspaceDbPath)) return false;229 try {230 const db = openReadonlyDb(workspaceDbPath);231 const tableExists = db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='projects'").get();232 if (!tableExists) { db.close(); return false; }233 const count = (db.prepare('SELECT COUNT(*) as count FROM projects').get() as { count: number }).count;234 db.close();235 return count > 0;236 } catch { return false; }237 })();238 239 if (!workspaceHasData) {240 try {241 fs.mkdirSync(workspaceDir, { recursive: true });242 fs.copyFileSync(legacyDbPath, workspaceDbPath);243 for (const ext of ['-wal', '-shm']) {244 const walPath = legacyDbPath + ext;245 if (fs.existsSync(walPath)) {246 fs.copyFileSync(walPath, workspaceDbPath + ext);247 }248 }249 result.legacyDbMigrated = true;250 } catch (err) {251 result.errors.push(`Failed to copy legacy DB: ${err}`);252 }253 }254 }255 }256 257 // 2. Copy orphaned project databases from data/projects/ to workspace258 const legacyProjectsDir = path.join(dataDir, 'projects');259 const workspaceProjectsDir = path.join(workspaceDir, 'projects');260 if (fs.existsSync(legacyProjectsDir) && fs.existsSync(workspaceDbPath)) {261 try {262 // Get project IDs from workspace DB263 const db = openReadonlyDb(workspaceDbPath);264 const tableExists = db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='projects'").get();265 const projectIds: string[] = [];266 if (tableExists) {267 const rows = db.prepare('SELECT id FROM projects').all() as { id: string }[];268 projectIds.push(...rows.map(r => r.id));269 }270 db.close();271 272 // For each project in workspace DB, check if its database is in legacy dir but not workspace dir273 for (const projectId of projectIds) {274 const legacyProjectDir = path.join(legacyProjectsDir, projectId);275 const workspaceProjectDir = path.join(workspaceProjectsDir, projectId);276 if (fs.existsSync(legacyProjectDir) && !fs.existsSync(workspaceProjectDir)) {277 copyDirRecursive(legacyProjectDir, workspaceProjectDir);278 result.legacyProjectsMigrated++;279 }280 }281 } catch (err) {282 result.errors.push(`Failed to migrate project databases: ${err}`);283 }284 }285 286 // 3. Ensure all deployments in workspace DB are registered in deployment_routing287 if (fs.existsSync(workspaceDbPath)) {288 try {289 const db = openReadonlyDb(workspaceDbPath);290 const tableExists = db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='deployments'").get();291 if (tableExists) {292 const deployments = db.prepare('SELECT id, slug FROM deployments').all() as { id: string; slug: string | null }[];293 db.close();294 295 const sysDb = getSystemDatabase();296 for (const deployment of deployments) {297 const existing = sysDb.prepare('SELECT deployment_id FROM deployment_routing WHERE deployment_id = ?')298 .get(deployment.id);299 if (!existing) {300 // Assign a slug so the deployment gets a subdomain route — Caddy301 // generation skips routing rows without one. Prefer the deployment's302 // own slug; generate a unique one only if it lacks it.303 const slug = deployment.slug || generateUniqueSlug(s => !!getDeploymentBySlug(s));304 sysDb.prepare(`305 INSERT OR IGNORE INTO deployment_routing (deployment_id, workspace_id, slug)306 VALUES (?, ?, ?)307 `).run(deployment.id, workspaceId, slug);308 result.deploymentRoutesCreated++;309 }310 }311 } else {312 db.close();313 }314 } catch (err) {315 result.errors.push(`Failed to repair deployment routes: ${err}`);316 }317 }318 319 return result;320}321 