diff --git a/dashcaddy-installer/src/main/caddyfile-generator.js b/dashcaddy-installer/src/main/caddyfile-generator.js index a32841a..70216ad 100644 --- a/dashcaddy-installer/src/main/caddyfile-generator.js +++ b/dashcaddy-installer/src/main/caddyfile-generator.js @@ -7,6 +7,10 @@ const { DEFAULT_PORTS } = require('../shared/constants'); * * Generates production-grade configs that match the patterns used by the * running DashCaddy deployment (CORS snippets, admin origins, PKI, etc.) + * + * DISK SAFETY: All generated configs include sensible defaults for storage + * limits — health retention, stats caps, and memory limits — so a fresh + * install will never silently fill a user's disk. */ class CaddyfileGenerator { /** @@ -279,19 +283,43 @@ class CaddyfileGenerator { } /** - * Generate docker-compose.yml for running the API server + * Generate docker-compose.yml for running the API server. + * + * DISK SAFETY: Includes env vars for health retention, stats caps, and + * memory limits derived from the disk budget the user selected during + * install. These prevent the disk-explosion bugs seen in early versions. + * * @param {string} installPath - Installation directory * @param {Object} options - Configuration options * @param {number} options.apiPort - API server port * @param {string} options.lanIP - Host LAN IP address * @param {string} options.tailscaleIP - Host Tailscale IP address * @param {string} options.domainMode - Domain mode (local, public, custom-tld) + * @param {Object} [options.disk] - Disk budget settings + * @param {number} [options.disk.healthRetentionDays=14] - Health history retention + * @param {number} [options.disk.healthMaxEntries=500] - Max health entries per service + * @param {number} [options.disk.healthCheckInterval=30000] - Health check interval (ms) + * @param {number} [options.disk.statsMaxEntries=2000] - Max container stats entries + * @param {number} [options.disk.auditMaxEntries=1000] - Max audit log entries + * @param {number} [options.disk.backupLimitGB=10] - Backup storage limit + * @param {string} [options.dockerDataPath] - Docker data root override + * @param {number} [options.memoryLimitMB=1024] - Container memory limit */ generateDockerCompose(installPath, options = {}) { const apiPort = options.apiPort || DEFAULT_PORTS.API; const adminPort = DEFAULT_PORTS.CADDY_ADMIN; const p = this._p.bind(this); + // Disk budget settings with safe defaults + const disk = options.disk || {}; + const healthRetentionDays = disk.healthRetentionDays || 14; + const healthMaxEntries = disk.healthMaxEntries || 500; + const healthCheckInterval = disk.healthCheckInterval || 30000; + const statsMaxEntries = disk.statsMaxEntries || 2000; + const auditMaxEntries = disk.auditMaxEntries || 1000; + const backupLimitGB = disk.backupLimitGB || 10; + const memoryLimitMB = options.memoryLimitMB || 1024; + // Core volume mounts let volumes = ` - ${p(installPath)}/Caddyfile:/caddyfile:rw - ${p(installPath)}/services.json:/app/services.json:rw @@ -308,12 +336,19 @@ class CaddyfileGenerator { volumes += `\n - ${p(installPath)}/certs/pki/authorities/local:/app/pki:ro`; } - // Environment variables + // Environment variables — disk safety baked in let envVars = ` - CADDYFILE_PATH=/caddyfile - CADDY_ADMIN_URL=http://host.docker.internal:${adminPort} - ASSETS_PATH=/app/assets - CREDENTIALS_FILE=/app/credentials.json - - NODE_ENV=production`; + - NODE_ENV=production + # --- Disk Safety --- + - HEALTH_HISTORY_RETENTION=${healthRetentionDays} + - HEALTH_MAX_ENTRIES=${healthMaxEntries} + - HEALTH_CHECK_INTERVAL=${healthCheckInterval} + - CONTAINER_STATS_MAX_ENTRIES=${statsMaxEntries} + - AUDIT_MAX_ENTRIES=${auditMaxEntries} + - BACKUP_MAX_STORAGE_BYTES=${backupLimitGB * 1024 * 1024 * 1024}`; if (options.domainMode === 'custom-tld') { envVars += `\n - CA_CERT_PATH=/app/pki/root.crt`; @@ -339,6 +374,9 @@ ${envVars} extra_hosts: - "host.docker.internal:host-gateway" restart: unless-stopped + # Memory limit prevents OOM during startup when all managers init + mem_limit: ${memoryLimitMB}m + memswap_limit: ${(memoryLimitMB * 2)}m `; return dockerCompose; diff --git a/dashcaddy-installer/src/main/vm-ipc.js b/dashcaddy-installer/src/main/vm-ipc.js new file mode 100644 index 0000000..52b5891 --- /dev/null +++ b/dashcaddy-installer/src/main/vm-ipc.js @@ -0,0 +1,112 @@ +/** + * VM Provisioner IPC Handler + * Wires the Electron wizard to VMDiskProvisioner. + * Add to src/main/index.js alongside the existing IPC handlers. + */ + +const { ipcMain } = require('electron'); +const { VMDiskProvisioner, DISK_PRESETS } = require('./vm-provisioner'); +const fs = require('fs').promises; +const path = require('path'); + +function registerVMHandlers(mainWindow) { + const provisioner = new VMDiskProvisioner(); + + // --- Get disk presets for wizard UI --- + ipcMain.handle('vm:get-presets', async () => { + return DISK_PRESETS; + }); + + // --- Get current VM status --- + ipcMain.handle('vm:get-status', async () => { + try { + const status = await provisioner.getStatus(); + // Also check for saved vmInfo from previous install + try { + const configPath = path.join(getInstallBase(), '.dashcaddy-config.json'); + const config = JSON.parse(await fs.readFile(configPath, 'utf8')); + if (config.vmInfo) { + status.vmInfo = config.vmInfo; + status.diskSizeGB = config.vmInfo.diskSizeGB; + } + } catch {} + return status; + } catch (e) { + return { platform: process.platform, running: false, error: e.message }; + } + }); + + // --- Provision the VM sandbox --- + ipcMain.handle('vm:provision', async (event, opts) => { + try { + const result = await provisioner.provision({ + ...opts, + onProgress: (msg, pct) => { + mainWindow.webContents.send('vm:progress', { message: msg, percent: pct }); + }, + }); + + // Save vmInfo for uninstall + if (result.vmInfo) { + try { + const configPath = path.join(opts.installPath || getInstallBase(), '.dashcaddy-config.json'); + let config = {}; + try { config = JSON.parse(await fs.readFile(configPath, 'utf8')); } catch {} + config.vmInfo = result.vmInfo; + config.diskBudgetGB = opts.diskSizeGB; + await fs.writeFile(configPath, JSON.stringify(config, null, 2)); + } catch {} + } + + mainWindow.webContents.send('vm:complete', result); + return result; + } catch (error) { + mainWindow.webContents.send('vm:error', { error: error.message }); + return { success: false, error: error.message }; + } + }); + + // --- Destroy the VM sandbox (uninstall) --- + ipcMain.handle('vm:destroy', async (event, opts) => { + try { + // Load saved vmInfo + let vmInfo = opts.vmInfo; + if (!vmInfo) { + try { + const configPath = path.join(opts.installPath || getInstallBase(), '.dashcaddy-config.json'); + const config = JSON.parse(await fs.readFile(configPath, 'utf8')); + vmInfo = config.vmInfo; + } catch {} + } + + if (!vmInfo) { + return { success: false, error: 'No VM info found. Already uninstalled?' }; + } + + const result = await provisioner.destroy(vmInfo, { + exportDataPath: opts.exportDataPath || null, + }); + + return result; + } catch (error) { + return { success: false, error: error.message }; + } + }); + + // --- Export data from VM (before uninstall) --- + ipcMain.handle('vm:export-data', async (event, opts) => { + try { + const result = await provisioner._exportData(opts.vmInfo, opts.exportPath); + return result; + } catch (error) { + return { success: false, error: error.message }; + } + }); +} + +function getInstallBase() { + const { getPlatformInfo } = require('../shared/platform-utils'); + return getPlatformInfo().defaultInstallPath; +} + +module.exports = { registerVMHandlers }; diff --git a/dashcaddy-installer/src/main/vm-provisioner.js b/dashcaddy-installer/src/main/vm-provisioner.js new file mode 100644 index 0000000..810cb7b --- /dev/null +++ b/dashcaddy-installer/src/main/vm-provisioner.js @@ -0,0 +1,511 @@ +/** + * VM Disk Provisioner — creates a bounded virtual disk for DashCaddy. + * + * PLATFORM STRATEGY: + * Windows: Dedicated WSL2 distro with a fixed-size VHDX. + * Docker runs inside WSL2, all data lives in the VHDX. + * Uninstall = wsl --unregister (deletes VHDX instantly). + * + * macOS: Lima VM with a fixed disk image. + * Docker runs inside Lima, all data lives in the disk image. + * Uninstall = limactl delete (removes VM + disk). + * + * Linux: Sparse ext4 loopback image mounted at /opt/dashcaddy-data. + * Docker --data-root pointed at the mount. + * Uninstall = unmount + rm image file. + * + * The user picks a disk size (default 20GB). DashCaddy is physically + * unable to exceed it — the OS enforces the limit, not our code. + */ + +const { exec } = require('child_process'); +const { promisify } = require('util'); +const fs = require('fs').promises; +const path = require('path'); +const platformUtils = require('../shared/platform-utils'); + +const execAsync = promisify(exec); + +// Presets users pick from in the wizard +const DISK_PRESETS = { + minimal: { sizeGB: 10, label: 'Minimal (10GB)', desc: 'DashCaddy only, a few small apps' }, + balanced: { sizeGB: 30, label: 'Balanced (30GB)', desc: 'DashCaddy + media tools + containers' }, + power: { sizeGB: 100, label: 'Power (100GB)', desc: 'DashCaddy + heavy apps + lots of containers' }, + custom: { sizeGB: 0, label: 'Custom', desc: 'Pick your own size' }, +}; + +/** + * Main provisioner class. + */ +class VMDiskProvisioner { + constructor() { + this.platform = platformUtils.detectOS(); + } + + /** + * Provision the full sandboxed environment. + * + * @param {Object} opts + * @param {number} opts.diskSizeGB — virtual disk size + * @param {string} opts.installPath — where DashCaddy app files live (host) + * @param {number} opts.apiPort + * @param {Object} opts.domain — { mode, domain, tld, email } + * @param {function} [opts.onProgress] — callback(statusMsg, pct) + * @returns {Object} { success, dockerContext, dashboardUrl, vmInfo } + */ + async provision(opts) { + const { diskSizeGB = 30, onProgress = () => {} } = opts; + + onProgress('Checking prerequisites', 5); + await this._checkPrerequisites(); + + onProgress('Creating virtual disk (' + diskSizeGB + 'GB)', 15); + const diskInfo = await this._createDisk(opts); + + onProgress('Starting sandbox environment', 40); + const envInfo = await this._startEnvironment(diskInfo, opts); + + onProgress('Installing Docker in sandbox', 60); + await this._ensureDocker(envInfo); + + onProgress('Deploying DashCaddy into sandbox', 75); + const deployInfo = await this._deployDashCaddy(envInfo, opts); + + onProgress('Configuring services', 90); + await this._configureServices(envInfo, opts); + + onProgress('Complete', 100); + + return { + success: true, + platform: this.platform, + diskSizeGB, + dockerContext: envInfo.dockerContext, + dashboardUrl: deployInfo.dashboardUrl, + vmInfo: { + type: envInfo.type, + name: envInfo.name, + diskPath: diskInfo.path, + diskSizeGB, + dockerDataRoot: envInfo.dockerDataRoot, + }, + }; + } + + /** + * Remove the sandboxed environment completely. + * @param {Object} vmInfo — from provision() + * @param {Object} opts — { exportDataPath: null } + */ + async destroy(vmInfo, opts = {}) { + // Export data first if requested + if (opts.exportDataPath) { + await this._exportData(vmInfo, opts.exportDataPath); + } + + switch (this.platform) { + case 'windows': return this._destroyWSL2(vmInfo); + case 'macos': return this._destroyLima(vmInfo); + case 'linux': return this._destroyLoopback(vmInfo); + default: throw new Error('Unsupported platform: ' + this.platform); + } + } + + // ========================================================================= + // PREREQUISITES + // ========================================================================= + + async _checkPrerequisites() { + const checks = []; + + switch (this.platform) { + case 'windows': + checks.push(this._checkCommand('wsl', '--status', 'WSL2')); + break; + case 'macos': + checks.push(this._checkCommand('limactl', 'version', 'Lima')); + break; + case 'linux': + // Need root or sudo for loopback mount + if (process.getuid && process.getuid() !== 0) { + // Check if we can sudo + try { await execAsync('sudo -n true', { timeout: 5000 }); } + catch { throw new Error('Linux install needs root or passwordless sudo for loopback mount'); } + } + break; + } + + const results = await Promise.all(checks); + const failed = results.filter(r => !r.ok); + if (failed.length) { + throw new Error('Missing: ' + failed.map(f => f.name).join(', ') + + '. Install instructions: https://dashcaddy.net/docs/installation'); + } + } + + async _checkCommand(cmd, versionArg, friendlyName) { + try { + await execAsync(`${cmd} ${versionArg}`, { timeout: 10000 }); + return { ok: true, name: friendlyName }; + } catch { + return { ok: false, name: friendlyName }; + } + } + + // ========================================================================= + // WINDOWS: WSL2 Dedicated Distro + // ========================================================================= + + async _createDisk(opts) { + if (this.platform === 'windows') return this._createWSL2Disk(opts); + if (this.platform === 'macos') return this._createLimaDisk(opts); + return this._createLoopbackDisk(opts); + } + + async _createWSL2Disk(opts) { + const distroName = 'dashcaddy'; + const { diskSizeGB = 30 } = opts; + const wslPath = opts.installPath || path.join(process.env.LOCALAPPDATA || 'C:\\DashCaddy', 'DashCaddy'); + const vhdxPath = path.join(wslPath, 'data.vhdx'); + + // Check if distro already exists + try { + const { stdout } = await execAsync('wsl -l -q', { timeout: 10000 }); + if (stdout.includes(distroName)) { + return { type: 'wsl2', distroName, path: vhdxPath, diskSizeGB, existed: true }; + } + } catch {} + + // Download a minimal rootfs (Alpine for smallest footprint) + await fs.mkdir(wslPath, { recursive: true }); + const rootfsUrl = 'https://dl-cdn.alpinelinux.org/alpine/v3.20/releases/x86_64/alpine-minirootfs-3.20.0-x86_64.tar.gz'; + const rootfsPath = path.join(wslPath, 'rootfs.tar.gz'); + + await execAsync(`curl -L -o "${rootfsPath}" "${rootfsUrl}"`, { timeout: 120000 }); + + // Import as a new WSL2 distro — the VHDX is created automatically + // and capped by .wslconfig max disk size + await execAsync(`wsl --import ${distroName} "${wslPath}" "${rootfsPath}" --version 2`, { timeout: 60000 }); + + // Set disk size limit via wsl config + const wslconfigPath = path.join(wslPath, '.wslconfig'); + await fs.writeFile(wslconfigPath, [ + `[wsl2]`, + `vmDiskSize=${diskSizeGB}GB`, + `memory=2GB`, + `processors=2`, + ].join('\n')); + + // Clean up rootfs download + await fs.unlink(rootfsPath).catch(() => {}); + + return { type: 'wsl2', distroName, path: vhdxPath, diskSizeGB, existed: false }; + } + + async _startEnvironment(diskInfo, opts) { + if (this.platform === 'windows') return this._startWSL2(diskInfo, opts); + if (this.platform === 'macos') return this._startLima(diskInfo, opts); + return this._startLoopback(diskInfo, opts); + } + + async _startWSL2(diskInfo, opts) { + const { distroName } = diskInfo; + + // Start the distro and install Docker inside + const wslExec = (cmd) => execAsync(`wsl -d ${distroName} -- sh -c "${cmd}"`, { timeout: 60000 }); + + // Update apk and install Docker + dependencies + await wslExec('apk update && apk add docker docker-cli-compose openrc ca-certificates curl'); + await wslExec('rc-update add docker default && service docker start'); + + // Create Docker data directory inside the VM + await wslExec('mkdir -p /var/lib/docker /opt/dashcaddy'); + + return { + type: 'wsl2', + name: distroName, + dockerContext: 'dashcaddy-wsl', + dockerDataRoot: '/var/lib/docker', + exec: wslExec, + }; + } + + // ========================================================================= + // macOS: Lima VM + // ========================================================================= + + async _createLimaDisk(opts) { + const { diskSizeGB = 30 } = opts; + const vmName = 'dashcaddy'; + const limaDir = path.join(process.env.HOME, '.lima', vmName); + + // Check if VM already exists + try { + await execAsync(`limactl list ${vmName}`, { timeout: 10000 }); + return { type: 'lima', vmName, path: limaDir, diskSizeGB, existed: true }; + } catch {} + + // Create Lima config with fixed disk + const config = { + vmType: 'qemu', + arch: 'x86_64', + images: [{ + location: 'https://cloud-images.ubuntu.com/jammy/current/jammy-server-cloudimg-amd64.img', + arch: 'x86_64', + }], + cpus: 2, + memory: '2GiB', + disk: diskSizeGB + 'GiB', + mounts: [], + containerd: { system: false, user: false }, + provision: { + mode: 'system', + script: 'apt-get update && apt-get install -y docker.io docker-compose-plugin', + }, + // Forward the API port + portForwards: [{ + guestSocket: '/var/run/docker.sock', + hostSocket: path.join(limaDir, 'sock', 'docker.sock'), + }], + }; + + const configPath = path.join(limaDir, 'lima.yaml'); + await fs.mkdir(path.dirname(configPath), { recursive: true }); + await fs.writeFile(configPath, require('yaml').stringify ? require('yaml').stringify(config) : JSON.stringify(config, null, 2)); + + await execAsync(`limactl start --name=${vmName} ${configPath}`, { timeout: 300000 }); + + return { type: 'lima', vmName, path: limaDir, diskSizeGB, existed: false }; + } + + async _startLima(diskInfo, opts) { + const { vmName } = diskInfo; + + // Ensure VM is running + try { await execAsync(`limactl start ${vmName}`, { timeout: 60000 }); } catch {} + + const limaExec = (cmd) => execAsync(`limactl shell ${vmName} -- bash -c "${cmd}"`, { timeout: 60000 }); + + // Ensure Docker is running + await limaExec('service docker start || true'); + + return { + type: 'lima', + name: vmName, + dockerContext: 'dashcaddy-lima', + dockerDataRoot: '/var/lib/docker', + exec: limaExec, + }; + } + + // ========================================================================= + // LINUX: Loopback ext4 image + // ========================================================================= + + async _createLoopbackDisk(opts) { + const { diskSizeGB = 30 } = opts; + const imagePath = '/opt/dashcaddy-data.raw'; + const mountPoint = '/opt/dashcaddy-data'; + + // Check if already mounted + try { + const { stdout } = await execAsync('mountpoint -q /opt/dashcaddy-data && echo mounted', { timeout: 5000 }); + if (stdout.includes('mounted')) { + return { type: 'loopback', imagePath, mountPoint, diskSizeGB, existed: true }; + } + } catch {} + + // Create sparse image (only uses space as data fills — starts at ~0 bytes) + const sudo = process.getuid && process.getuid() === 0 ? '' : 'sudo'; + await execAsync(`truncate -s ${diskSizeGB}G "${imagePath}"`, { timeout: 30000 }); + + // Format as ext4 + await execAsync(`${sudo} mkfs.ext4 -F -L dashcaddy "${imagePath}"`, { timeout: 60000 }); + + // Mount + await execAsync(`${sudo} mkdir -p "${mountPoint}"`, { timeout: 5000 }); + await execAsync(`${sudo} mount -o loop "${imagePath}" "${mountPoint}"`, { timeout: 10000 }); + + // Add to fstab for persistence across reboots + const fstabEntry = `${imagePath} ${mountPoint} ext4 loop,defaults 0 0`; + await execAsync(`grep -q '${imagePath}' /etc/fstab || echo '${fstabEntry}' | ${sudo} tee -a /etc/fstab`, { timeout: 5000 }); + + // Point Docker data-root at the mounted volume + await this._configureDockerDataRoot(mountPoint + '/docker', sudo); + + return { type: 'loopback', imagePath, mountPoint, diskSizeGB, existed: false }; + } + + async _startLoopback(diskInfo, opts) { + const { mountPoint } = diskInfo; + const sudo = process.getuid && process.getuid() === 0 ? '' : 'sudo'; + + // Ensure mounted + try { + await execAsync(`mountpoint -q ${mountPoint} || ${sudo} mount -o loop ${diskInfo.imagePath} ${mountPoint}`, { timeout: 10000 }); + } catch {} + + // Restart Docker to pick up new data-root + await execAsync(`${sudo} systemctl restart docker`, { timeout: 30000 }).catch(() => {}); + + return { + type: 'loopback', + name: 'dashcaddy-loopback', + dockerContext: 'default', + dockerDataRoot: mountPoint + '/docker', + exec: (cmd) => execAsync(cmd, { timeout: 60000 }), + }; + } + + async _configureDockerDataRoot(dataRoot, sudo) { + const daemonJsonPath = '/etc/docker/daemon.json'; + let daemonJson = {}; + try { + daemonJson = JSON.parse(await fs.readFile(daemonJsonPath, 'utf8')); + } catch {} + + daemonJson['data-root'] = dataRoot; + + await execAsync(`${sudo} mkdir -p ${dataRoot}`, { timeout: 5000 }); + await execAsync(`${sudo} bash -c 'cat > ${daemonJsonPath} << EOF\n${JSON.stringify(daemonJson, null, 2)}\nEOF'`, { timeout: 5000 }); + } + + // ========================================================================= + // DEPLOY + CONFIGURE (shared across platforms) + // ========================================================================= + + async _ensureDocker(envInfo) { + // Docker was installed during VM creation per-platform. + // Verify it's actually running. + if (envInfo.exec) { + try { + await envInfo.exec('docker info > /dev/null 2>&1'); + return; + } catch { + // Try starting + if (this.platform === 'windows') await envInfo.exec('service docker start || true'); + if (this.platform === 'macos') await envInfo.exec('service docker start || true'); + } + } + } + + async _deployDashCaddy(envInfo, opts) { + const apiPort = opts.apiPort || 3001; + const dashboardPort = opts.domain?.mode === 'public' ? null : (opts.dashboardPort || 8080); + + // Inside the VM, download and run DashCaddy + // The VM has Docker running — we deploy the same container image + const deployScript = ` + mkdir -p /opt/dashcaddy && cd /opt/dashcaddy + curl -fsSL https://get.dashcaddy.net/release/latest.tar.gz | tar xz + cd dashcaddy-api && docker build -t dashcaddy-api . + docker run -d --name dashcaddy-api --restart unless-stopped \\ + -p ${apiPort}:${apiPort} \\ + -v /opt/dashcaddy/data:/app/data \\ + -v /opt/dashcaddy/status:/app/status \\ + -v /var/run/docker.sock:/var/run/docker.sock \\ + -e NODE_ENV=production \\ + -e PORT=${apiPort} \\ + dashcaddy-api + `; + + if (envInfo.exec) { + await envInfo.exec(deployScript.replace(/\n/g, ' && ')); + } + + let url; + if (opts.domain?.mode === 'public') { + url = `https://${opts.domain.domain}`; + } else if (opts.domain?.mode === 'custom-tld') { + url = `https://dashcaddy${opts.domain.tld}`; + } else { + url = `http://localhost:${dashboardPort || 8080}`; + } + + return { success: true, dashboardUrl: url }; + } + + async _configureServices(envInfo, opts) { + // Port forwarding from host to VM + if (this.platform === 'windows') { + // WSL2 auto-forwards localhost ports to the host + return; + } + if (this.platform === 'macos') { + // Lima forwards are configured in the VM config + return; + } + // Linux: container is directly accessible + } + + // ========================================================================= + // DESTROY (uninstall) + // ========================================================================= + + async _destroyWSL2(vmInfo) { + await execAsync(`wsl --unregister ${vmInfo.name || 'dashcaddy'}`, { timeout: 30000 }); + // VHDX is deleted by WSL on unregister + return { success: true, message: 'WSL2 distro deleted — all data removed' }; + } + + async _destroyLima(vmInfo) { + await execAsync(`limactl delete -f ${vmInfo.name || 'dashcaddy'}`, { timeout: 30000 }); + return { success: true, message: 'Lima VM deleted — all data removed' }; + } + + async _destroyLoopback(vmInfo) { + const sudo = process.getuid && process.getuid() === 0 ? '' : 'sudo'; + await execAsync(`${sudo} umount ${vmInfo.mountPoint || '/opt/dashcaddy-data'}`, { timeout: 10000 }).catch(() => {}); + await execAsync(`rm -f ${vmInfo.imagePath || '/opt/dashcaddy-data.raw'}`, { timeout: 5000 }); + // Remove from fstab + await execAsync(`${sudo} sed -i '\\#${vmInfo.imagePath || '/opt/dashcaddy-data.raw'}#d' /etc/fstab`, { timeout: 5000 }).catch(() => {}); + return { success: true, message: 'Virtual disk unmounted and deleted — all data removed' }; + } + + async _exportData(vmInfo, exportPath) { + // Export DashCaddy config + service definitions before destroy + if (vmInfo.type === 'wsl2') { + await execAsync(`wsl -d ${vmInfo.name} -- tar czf /tmp/dc-export.tar.gz /opt/dashcaddy/data /opt/dashcaddy/status`, { timeout: 60000 }); + await execAsync(`wsl -d ${vmInfo.name} -- cat /tmp/dc-export.tar.gz > "${exportPath}"`, { timeout: 60000 }); + } else if (vmInfo.type === 'lima') { + await execAsync(`limactl shell ${vmInfo.name} -- sudo tar czf /tmp/dc-export.tar.gz /opt/dashcaddy/data`, { timeout: 60000 }); + await execAsync(`limactl shell ${vmInfo.name} -- sudo cat /tmp/dc-export.tar.gz > "${exportPath}"`, { timeout: 60000 }); + } else if (vmInfo.type === 'loopback') { + await execAsync(`tar czf "${exportPath}" -C ${vmInfo.mountPoint} data`, { timeout: 60000 }); + } + return { success: true, exportPath }; + } + + // ========================================================================= + // STATUS + // ========================================================================= + + async getStatus() { + const info = { platform: this.platform, running: false }; + + try { + switch (this.platform) { + case 'windows': { + const { stdout } = await execAsync('wsl -l -v', { timeout: 10000 }); + info.running = stdout.includes('dashcaddy') && stdout.includes('Running'); + break; + } + case 'macos': { + const { stdout } = await execAsync('limactl list --json', { timeout: 10000 }); + const vms = JSON.parse(stdout); + info.running = vms.some(v => v.name === 'dashcaddy' && v.status === 'Running'); + break; + } + case 'linux': { + const { stdout } = await execAsync('mountpoint -q /opt/dashcaddy-data && echo yes', { timeout: 5000 }); + info.running = stdout.includes('yes'); + break; + } + } + } catch {} + + return info; + } +} + +module.exports = { VMDiskProvisioner, DISK_PRESETS }; diff --git a/dashcaddy-installer/src/renderer/disk-budget-step.js b/dashcaddy-installer/src/renderer/disk-budget-step.js new file mode 100644 index 0000000..8b6063c --- /dev/null +++ b/dashcaddy-installer/src/renderer/disk-budget-step.js @@ -0,0 +1,113 @@ +/** + * VM Disk Budget Step — rendered inside the Electron wizard. + * Shows disk size presets, a custom slider, and real-time space check. + * Add to wizard.js as a new render step between 'folder' and 'tier'. + * + * Exported function: renderDiskBudgetStep() + * State updates: state.diskBudget.preset, state.diskBudget.customSizeGB + */ + +function renderDiskBudgetStep() { + const presets = [ + { id: 'minimal', icon: '💽', sizeGB: 10, label: 'Minimal', desc: 'DashCaddy only, a few small apps' }, + { id: 'balanced', icon: '💿', sizeGB: 30, label: 'Balanced', desc: 'DashCaddy + media tools + containers' }, + { id: 'power', icon: '🧊', sizeGB: 100, label: 'Power', desc: 'DashCaddy + heavy apps + lots of containers' }, + { id: 'custom', icon: '⚙️', sizeGB: 0, label: 'Custom', desc: 'Pick your own size' }, + ]; + + const selectedPreset = state.diskBudget?.preset || 'balanced'; + const selectedSize = state.diskBudget?.customSizeGB || presets.find(p => p.id === selectedPreset)?.sizeGB || 30; + + return ` +
+

Storage Budget

+

DashCaddy creates a sandboxed virtual disk for all its data. + It can never exceed this limit — your main drive stays safe.

+

+ 💡 The disk starts nearly empty and only grows as you add apps and data. + Deleting DashCaddy removes the entire disk instantly. +

+ +
+ ${presets.map(p => ` +
+
${p.icon}
+
${p.label}
+
+ ${p.sizeGB > 0 ? p.sizeGB + 'GB' : 'Custom'} — ${p.desc} +
+
+ `).join('')} +
+ + ${selectedPreset === 'custom' ? ` +
+ +
+ + + ${selectedSize}GB + +
+

Min 5GB, Max 500GB. DashCaddy uses a sparse image — it only consumes real disk space as data fills.

+
+ ` : ` +
+ ${selectedSize}GB virtual disk will be created. + The sandbox isolates Docker, all containers, and all DashCaddy data inside it. +
+ `} + +
+
+ `; +} + +// State management helpers — call from wizard.js +function selectDiskPreset(presetId, sizeGB) { + if (!state.diskBudget) state.diskBudget = {}; + state.diskBudget.preset = presetId; + if (presetId !== 'custom') { + state.diskBudget.diskSizeGB = sizeGB; + } + checkDiskSpace(sizeGB); + render(); // re-render the step +} + +function updateDiskSize(val) { + const sizeGB = parseInt(val); + if (!state.diskBudget) state.diskBudget = {}; + state.diskBudget.diskSizeGB = sizeGB; + state.diskBudget.customSizeGB = sizeGB; + document.getElementById('disk-size-display').textContent = sizeGB + 'GB'; + checkDiskSpace(sizeGB); +} + +async function checkDiskSpace(sizeGB) { + const el = document.getElementById('disk-space-check'); + if (!el) return; + + try { + const info = await window.electronAPI.getDiskSpace(state.paths.install || ''); + const freeGB = Math.round(info.free / 1024 / 1024 / 1024); + const neededGB = sizeGB + 2; // 2GB buffer for DashCaddy itself + + if (freeGB < neededGB) { + el.innerHTML = `
+ ⚠️ Not enough free space. You have ${freeGB}GB free, but need ${neededGB}GB. +
`; + } else { + el.innerHTML = `
+ ✓ You have ${freeGB}GB free — plenty of room for a ${sizeGB}GB disk. +
`; + } + } catch { + el.innerHTML = ''; + } +}