import { apps, categories } from './catalog/apps.js'; import { generateCompose, generateEnv } from './generators/compose.js'; import { generateSetupScript } from './generators/setup.js'; import { analyseCompose, fixCompose } from './generators/analyser.js'; import { trackDownload, fetchPopularity } from './utils/analytics.js'; import { initAuth } from './utils/auth.js'; import { initGallery } from './utils/gallery.js'; import JSZip from 'jszip'; import './style.css'; // OS-specific default paths const osDefaults = { linux: { configPath: '/opt/stackpilot/config', mediaPath: '/mnt/media', downloadsPath: '/mnt/downloads', timezone: 'Europe/Dublin', }, macos: { configPath: '~/docker/stackpilot/config', mediaPath: '~/Media', downloadsPath: '~/Downloads/stackpilot', timezone: 'Europe/Dublin', }, windows: { configPath: 'C:\\stackpilot\\config', mediaPath: 'D:\\Media', downloadsPath: 'D:\\Downloads', timezone: 'Europe/Dublin', }, }; // State const state = { selected: new Set(), activeTab: 'compose', os: 'linux', popularity: null, searchQuery: '', }; // Presets const presets = { media: ['jellyfin', 'sonarr', 'radarr', 'prowlarr', 'qbittorrent', 'bazarr', 'jellyseerr', 'flaresolverr'], arr: ['sonarr', 'radarr', 'lidarr', 'readarr', 'prowlarr', 'bazarr', 'qbittorrent', 'flaresolverr', 'recyclarr'], ai: ['ollama', 'open-webui', 'comfyui', 'stable-diffusion', 'flowise', 'anything-llm', 'whisper'], dev: ['gitea', 'code-server', 'drone', 'registry', 'portainer', 'postgres', 'redis'], homelab: ['homepage', 'portainer', 'uptime-kuma', 'watchtower', 'tailscale', 'vaultwarden', 'ntfy', 'adguard-home'], gaming: ['minecraft', 'valheim', 'palworld', 'satisfactory', 'crafty', 'pterodactyl'], }; // Get config from inputs function getConfig() { return { configPath: document.getElementById('config-path').value || '/opt/stackpilot/config', mediaPath: document.getElementById('media-path').value || '/mnt/media', downloadsPath: document.getElementById('downloads-path').value || '/mnt/downloads', timezone: document.getElementById('timezone').value || 'Europe/Dublin', tailscale: { enabled: document.getElementById('tailscale-enable').checked, authKey: document.getElementById('ts-authkey').value, hostname: document.getElementById('ts-hostname').value || 'stackpilot', funnel: document.getElementById('ts-funnel').checked, subnetRouter: document.getElementById('ts-subnet').checked, }, }; } // Render app grid function renderAppGrid() { const grid = document.getElementById('app-grid'); grid.innerHTML = ''; // Selection badge const badge = document.createElement('div'); badge.className = `selection-badge${state.selected.size === 0 ? ' hidden' : ''}`; badge.textContent = `${state.selected.size} app${state.selected.size !== 1 ? 's' : ''} selected`; grid.appendChild(badge); const query = state.searchQuery.toLowerCase(); for (const cat of categories) { let catApps = apps.filter(a => a.category === cat.id); // Filter by search if (query) { catApps = catApps.filter(a => a.name.toLowerCase().includes(query) || a.description.toLowerCase().includes(query) || a.id.includes(query) ); } if (catApps.length === 0) continue; const group = document.createElement('div'); group.className = 'category-group'; const header = document.createElement('div'); header.className = 'category-header'; const title = document.createElement('h3'); title.className = 'category-title'; title.textContent = `${cat.icon} ${cat.name}`; const selectAllBtn = document.createElement('button'); const allSelected = catApps.every(a => state.selected.has(a.id)); selectAllBtn.className = 'category-select-all'; selectAllBtn.textContent = allSelected ? 'Deselect all' : 'Select all'; selectAllBtn.addEventListener('click', () => { if (allSelected) { catApps.forEach(a => state.selected.delete(a.id)); } else { catApps.forEach(a => state.selected.add(a.id)); } renderAppGrid(); checkDependencies(); checkPortConflicts(); updatePreview(); }); header.appendChild(title); header.appendChild(selectAllBtn); group.appendChild(header); const appsDiv = document.createElement('div'); appsDiv.className = 'category-apps'; for (const app of catApps) { const card = document.createElement('div'); card.className = `app-card${state.selected.has(app.id) ? ' selected' : ''}`; const popCount = state.popularity?.[app.id] || 0; const popBadge = popCount > 0 ? `đŸ”Ĩ ${popCount}` : ''; card.innerHTML = `
${state.selected.has(app.id) ? '✓' : ''}
${app.name} ${popBadge}
${app.description}
${app.url ? `GitHub →` : ''} `; card.addEventListener('click', () => toggleApp(app.id)); appsDiv.appendChild(card); } group.appendChild(appsDiv); grid.appendChild(group); } } // Toggle app selection function toggleApp(id) { if (state.selected.has(id)) { state.selected.delete(id); } else { state.selected.add(id); } renderAppGrid(); checkDependencies(); checkPortConflicts(); updatePreview(); updateUrlState(); } // Dependency warnings function checkDependencies() { const warnings = []; const ids = state.selected; if (ids.has('jellyseerr') && !ids.has('jellyfin')) warnings.push('Jellyseerr requires Jellyfin to function'); if (ids.has('overseerr') && !ids.has('plex')) warnings.push('Overseerr requires Plex to function'); if (ids.has('bazarr') && !ids.has('sonarr') && !ids.has('radarr')) warnings.push('Bazarr needs Sonarr or Radarr to manage subtitles for'); if (ids.has('prowlarr') && !ids.has('sonarr') && !ids.has('radarr') && !ids.has('lidarr') && !ids.has('readarr')) warnings.push('Prowlarr has no Arr apps to sync indexers to'); if ((ids.has('sonarr') || ids.has('radarr')) && !ids.has('qbittorrent') && !ids.has('transmission') && !ids.has('deluge') && !ids.has('sabnzbd') && !ids.has('nzbget')) warnings.push('Sonarr/Radarr need a download client (qBittorrent, Transmission, etc.)'); if (ids.has('open-webui') && !ids.has('ollama')) warnings.push('Open WebUI needs Ollama for local LLM inference'); if (ids.has('immich') && !ids.has('postgres') && !ids.has('redis')) warnings.push('Immich requires PostgreSQL and Redis'); if (ids.has('outline') && !ids.has('postgres') && !ids.has('redis')) warnings.push('Outline requires PostgreSQL and Redis'); if (ids.has('bookstack') && !ids.has('mariadb')) warnings.push('BookStack requires MariaDB'); if (ids.has('rocketchat') && !ids.has('mongodb')) warnings.push('Rocket.Chat requires MongoDB'); const el = document.getElementById('dependency-warnings'); el.innerHTML = warnings.map(w => `
âš ī¸ ${w}
`).join(''); } // Port conflict detection function checkPortConflicts() { const selectedApps = apps.filter(a => state.selected.has(a.id)); const portMap = {}; const conflicts = []; for (const app of selectedApps) { if (!app.ports) continue; for (const port of app.ports) { const hostPort = `${port.host}/${port.protocol}`; if (portMap[hostPort]) { conflicts.push(`Port ${port.host} conflict: ${portMap[hostPort]} and ${app.name}`); } else { portMap[hostPort] = app.name; } } } const el = document.getElementById('port-conflicts'); el.innerHTML = conflicts.map(c => `
❌ ${c}
`).join(''); } // URL state (shareable selections) function updateUrlState() { const selected = [...state.selected]; if (selected.length > 0) { const url = new URL(window.location); url.searchParams.set('apps', selected.join(',')); window.history.replaceState({}, '', url); } else { const url = new URL(window.location); url.searchParams.delete('apps'); window.history.replaceState({}, '', url); } } function loadUrlState() { const url = new URL(window.location); const appsParam = url.searchParams.get('apps'); if (appsParam) { const appIds = appsParam.split(','); for (const id of appIds) { if (apps.some(a => a.id === id)) { state.selected.add(id); } } } } // Update preview function updatePreview() { const selectedApps = apps.filter(a => state.selected.has(a.id)); const config = getConfig(); const content = document.getElementById('preview-content'); const downloadBtn = document.getElementById('download-btn'); if (selectedApps.length === 0) { content.textContent = 'Select apps above to see preview...'; downloadBtn.disabled = true; return; } downloadBtn.disabled = false; switch (state.activeTab) { case 'compose': content.textContent = generateCompose(selectedApps, config); break; case 'env': content.textContent = generateEnv(selectedApps, config); break; case 'setup': content.textContent = generateSetupScript(selectedApps, config); break; } } // Tab switching function initTabs() { document.querySelectorAll('.tab').forEach(tab => { tab.addEventListener('click', () => { document.querySelectorAll('.tab').forEach(t => t.classList.remove('active')); tab.classList.add('active'); state.activeTab = tab.dataset.tab; updatePreview(); }); }); } // Download bundle async function downloadBundle() { const selectedApps = apps.filter(a => state.selected.has(a.id)); const config = getConfig(); const zip = new JSZip(); zip.file('docker-compose.yml', generateCompose(selectedApps, config)); zip.file('.env', generateEnv(selectedApps, config)); zip.file('setup.sh', generateSetupScript(selectedApps, config)); zip.file('README.md', generateReadme(selectedApps, config)); const blob = await zip.generateAsync({ type: 'blob' }); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = 'stackpilot-bundle.zip'; a.click(); URL.revokeObjectURL(url); // Track popularity trackDownload([...state.selected]); } function generateReadme(selectedApps, config) { const lines = [ '# StackPilot Bundle', '', `Generated: ${new Date().toISOString()}`, '', '## Quick Start', '', '```bash', '# 1. Review and edit .env file', 'nano .env', '', '# 2. Create directories', `mkdir -p ${config.configPath} ${config.mediaPath} ${config.downloadsPath}`, '', '# 3. Start the stack', 'docker compose up -d', '', '# 4. Run auto-linker (after all services are healthy)', 'chmod +x setup.sh', './setup.sh', '```', '', '## Services', '', '| Service | URL | Port |', '|---------|-----|------|', ]; for (const app of selectedApps) { if (app.ports?.length > 0) { lines.push(`| ${app.name} | http://localhost:${app.ports[0].host} | ${app.ports[0].host} |`); } else { lines.push(`| ${app.name} | (no web UI) | — |`); } } lines.push('', '## Notes', ''); lines.push('- Default qBittorrent credentials: admin / adminadmin (change immediately)'); lines.push('- Arr apps generate their API keys on first run — setup.sh reads them from config.xml'); lines.push('- Run `docker compose logs -f` to monitor startup'); lines.push('- Run `docker compose down` to stop all services'); return lines.join('\n'); } // Config change listeners function initConfigListeners() { document.querySelectorAll('.config-grid input').forEach(input => { input.addEventListener('input', updatePreview); }); } // Tailscale toggle function initTailscale() { const enable = document.getElementById('tailscale-enable'); const options = document.getElementById('tailscale-options'); enable.addEventListener('change', () => { options.classList.toggle('hidden', !enable.checked); updatePreview(); }); document.getElementById('ts-funnel').addEventListener('change', updatePreview); document.getElementById('ts-subnet').addEventListener('change', updatePreview); document.getElementById('ts-authkey').addEventListener('input', updatePreview); document.getElementById('ts-hostname').addEventListener('input', updatePreview); } // OS selector function initOsSelector() { document.querySelectorAll('input[name="os"]').forEach(radio => { radio.addEventListener('change', (e) => { state.os = e.target.value; const defaults = osDefaults[state.os]; document.getElementById('config-path').value = defaults.configPath; document.getElementById('media-path').value = defaults.mediaPath; document.getElementById('downloads-path').value = defaults.downloadsPath; document.getElementById('timezone').value = defaults.timezone; updatePreview(); }); }); } // Init loadUrlState(); renderAppGrid(); initTabs(); initConfigListeners(); initTailscale(); initOsSelector(); initModeNav(); initAnalyser(); initSearch(); initPresets(); initAuth(); initGallery(() => [...state.selected], getConfig); document.getElementById('download-btn').addEventListener('click', downloadBundle); // Listen for "Use This Stack" from gallery window.addEventListener('use-stack', (e) => { const { apps: stackApps } = e.detail; state.selected = new Set(stackApps); renderAppGrid(); checkDependencies(); checkPortConflicts(); updatePreview(); updateUrlState(); }); // Load popularity data (async, non-blocking) fetchPopularity().then(data => { if (data) { state.popularity = data; renderAppGrid(); } }); // Search function initSearch() { const input = document.getElementById('app-search'); input.addEventListener('input', (e) => { state.searchQuery = e.target.value; renderAppGrid(); }); } // Presets function initPresets() { document.querySelectorAll('.preset-btn').forEach(btn => { btn.addEventListener('click', () => { const preset = presets[btn.dataset.preset]; if (!preset) return; for (const id of preset) { state.selected.add(id); } renderAppGrid(); checkDependencies(); checkPortConflicts(); updatePreview(); updateUrlState(); }); }); } // Mode switching (builder vs analyser vs gallery) function initModeNav() { document.querySelectorAll('.mode-btn').forEach(btn => { btn.addEventListener('click', () => { document.querySelectorAll('.mode-btn').forEach(b => b.classList.remove('active')); btn.classList.add('active'); const mode = btn.dataset.mode; document.querySelector('.container').style.display = mode === 'builder' ? 'block' : 'none'; document.getElementById('step-analyser').style.display = mode === 'analyser' ? 'block' : 'none'; document.getElementById('step-gallery').style.display = mode === 'gallery' ? 'block' : 'none'; }); }); } // Analyser function initAnalyser() { const dropZone = document.getElementById('drop-zone'); const textarea = document.getElementById('analyser-input'); // Drag and drop dropZone.addEventListener('dragover', (e) => { e.preventDefault(); dropZone.classList.add('dragging'); }); dropZone.addEventListener('dragleave', () => { dropZone.classList.remove('dragging'); }); dropZone.addEventListener('drop', (e) => { e.preventDefault(); dropZone.classList.remove('dragging'); const file = e.dataTransfer.files[0]; if (file) { const reader = new FileReader(); reader.onload = (ev) => { textarea.value = ev.target.result; }; reader.readAsText(file); } }); // Analyse button document.getElementById('analyse-btn').addEventListener('click', () => { const input = textarea.value.trim(); if (!input) return; const results = analyseCompose(input); renderAnalyserResults(results); const fixBtn = document.getElementById('show-fixed-btn'); const hasFixable = results.warnings.length > 0 || results.suggestions.length > 0; fixBtn.classList.toggle('hidden', !hasFixable); // Hide previous fixed/diff views document.getElementById('fixed-preview').classList.add('hidden'); document.getElementById('diff-preview').classList.add('hidden'); document.getElementById('view-toggle').classList.add('hidden'); }); // Show fixed button document.getElementById('show-fixed-btn').addEventListener('click', () => { const input = textarea.value.trim(); if (!input) return; const fixed = fixCompose(input); document.getElementById('fixed-content').textContent = fixed; document.getElementById('fixed-preview').classList.remove('hidden'); document.getElementById('diff-preview').classList.add('hidden'); document.getElementById('view-toggle').classList.remove('hidden'); // Generate diff const diffHtml = generateDiff(input, fixed); document.getElementById('diff-content').innerHTML = diffHtml; // Set active view document.querySelectorAll('.view-btn').forEach(b => b.classList.remove('active')); document.querySelector('.view-btn[data-view="fixed"]').classList.add('active'); }); // View toggle (fixed vs diff) document.querySelectorAll('.view-btn').forEach(btn => { btn.addEventListener('click', () => { document.querySelectorAll('.view-btn').forEach(b => b.classList.remove('active')); btn.classList.add('active'); const view = btn.dataset.view; document.getElementById('fixed-preview').classList.toggle('hidden', view !== 'fixed'); document.getElementById('diff-preview').classList.toggle('hidden', view !== 'diff'); }); }); // Download fixed document.getElementById('download-fixed-btn').addEventListener('click', () => { const fixed = document.getElementById('fixed-content').textContent; const blob = new Blob([fixed], { type: 'text/yaml' }); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = 'docker-compose-fixed.yml'; a.click(); URL.revokeObjectURL(url); }); } // Simple line-by-line diff function generateDiff(before, after) { const beforeLines = before.split('\n'); const afterLines = after.split('\n'); const output = []; // Simple LCS-based approach for readable diff const beforeSet = new Set(beforeLines.map((l, i) => `${i}:${l}`)); const afterSet = new Set(afterLines); let bi = 0, ai = 0; while (bi < beforeLines.length || ai < afterLines.length) { if (bi < beforeLines.length && ai < afterLines.length && beforeLines[bi] === afterLines[ai]) { output.push(` ${escHtml(beforeLines[bi])}`); bi++; ai++; } else if (ai < afterLines.length && !beforeLines.includes(afterLines[ai])) { output.push(`+ ${escHtml(afterLines[ai])}`); ai++; } else if (bi < beforeLines.length && !afterLines.includes(beforeLines[bi])) { output.push(`- ${escHtml(beforeLines[bi])}`); bi++; } else { // Changed line if (bi < beforeLines.length) { output.push(`- ${escHtml(beforeLines[bi])}`); bi++; } if (ai < afterLines.length) { output.push(`+ ${escHtml(afterLines[ai])}`); ai++; } } } return output.join('\n'); } function escHtml(str) { return str.replace(/&/g, '&').replace(//g, '>'); } function renderAnalyserResults(results) { const container = document.getElementById('analyser-results'); container.innerHTML = ''; // Summary badges const summary = document.createElement('div'); summary.className = 'result-summary'; if (results.errors.length) summary.innerHTML += `❌ ${results.errors.length} Error${results.errors.length > 1 ? 's' : ''}`; if (results.warnings.length) summary.innerHTML += `âš ī¸ ${results.warnings.length} Warning${results.warnings.length > 1 ? 's' : ''}`; if (results.suggestions.length) summary.innerHTML += `💡 ${results.suggestions.length} Suggestion${results.suggestions.length > 1 ? 's' : ''}`; if (results.info.length) summary.innerHTML += `â„šī¸ ${results.info.length} Info`; container.appendChild(summary); // Cards const allResults = [ ...results.errors.map(r => ({ ...r, type: 'error' })), ...results.warnings.map(r => ({ ...r, type: 'warning' })), ...results.suggestions.map(r => ({ ...r, type: 'suggestion' })), ...results.info.map(r => ({ ...r, type: 'info' })), ]; for (const item of allResults) { const card = document.createElement('div'); card.className = `result-card ${item.type}`; card.innerHTML = `
${item.title}
${item.detail}
`; container.appendChild(card); } }