commit 341700ddacdc19eea50dfde1c8d9e748dfff3343 Author: Paul Bailey Date: Mon Aug 3 23:28:15 2026 +0100 Initial commit: StackPilot - Docker stack builder with 186 apps, compose analyser, analytics diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..a547bf3 --- /dev/null +++ b/.gitignore @@ -0,0 +1,24 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +node_modules +dist +dist-ssr +*.local + +# Editor directories and files +.vscode/* +!.vscode/extensions.json +.idea +.DS_Store +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? diff --git a/README.md b/README.md new file mode 100644 index 0000000..453c99e --- /dev/null +++ b/README.md @@ -0,0 +1,119 @@ +# ๐Ÿš€ StackPilot + +**The ultimate self-hosted Docker stack builder.** A web-based wizard that lets you select from 186+ apps, configure paths and credentials, preview your generated Docker Compose setup live, and download a ready-to-deploy bundle โ€” all client-side, no server required. + +## Features + +- **186+ self-hosted apps** across 22 categories (media, automation, AI, games, security, monitoring, and more) +- **Visual app selection** with categories, descriptions, and GitHub links +- **OS-aware defaults** โ€” paths adjust for Linux, macOS, or Windows +- **Live compose preview** โ€” see docker-compose.yml, .env, and setup.sh update as you select apps +- **Auto-linking setup script** โ€” post-deploy script wires services together via APIs (Prowlarrโ†’Sonarr/Radarr, download clients, root folders) +- **Tailscale integration** โ€” optional remote access with sidecar container, subnet routing, and Funnel support +- **VPN routing** โ€” Gluetun routes download client traffic through your VPN automatically +- **Compose Analyser** โ€” paste an existing docker-compose.yml to get security, performance, and best-practice recommendations +- **Download bundle** โ€” zip containing compose + .env + setup.sh + README +- **Dark theme, responsive** โ€” works on desktop and mobile +- **Zero backend** โ€” everything runs in your browser via vanilla JS + Vite + +## Quick Start + +```bash +# Clone and install +git clone https://github.com/yourusername/stackpilot.git +cd stackpilot +npm install + +# Development +npm run dev + +# Build for production +npm run build +# Output in dist/ โ€” deploy anywhere (Netlify, Vercel, GitHub Pages, nginx, etc.) +``` + +## How It Works + +1. **Select your OS** โ€” paths auto-adjust (Linux/macOS/Windows) +2. **Pick your apps** โ€” browse 22 categories, click to select, use "Select all" per category +3. **Configure** โ€” paths, timezone, Tailscale options +4. **Preview** โ€” live tabs show docker-compose.yml, .env, and setup.sh +5. **Download** โ€” get a zip bundle ready to deploy +6. **Deploy** โ€” `docker compose up -d && ./setup.sh` + +## The Compose Analyser + +Switch to "Compose Analyser" mode to paste any existing docker-compose.yml and get instant feedback: + +- **Errors** โ€” port conflicts, missing images, invalid structure +- **Warnings** โ€” :latest tags, no restart policy, exposed database ports, privileged containers, docker socket access +- **Suggestions** โ€” healthchecks, resource limits, log rotation, secret management, network isolation, depends_on conditions + +## App Categories + +| Category | Apps | Examples | +|----------|------|----------| +| ๐ŸŽฌ Media Servers | 16 | Jellyfin, Plex, Navidrome, Audiobookshelf, Kavita | +| ๐Ÿ“ก Arr Suite | 10 | Sonarr, Radarr, Prowlarr, Bazarr, Lidarr, Readarr | +| โฌ‡๏ธ Downloads | 7 | qBittorrent, Transmission, Deluge, SABnzbd | +| ๐Ÿ” Requests | 2 | Jellyseerr, Overseerr | +| ๐ŸŒ Networking | 12 | Tailscale, Gluetun, Traefik, Caddy, WireGuard, WG-Easy | +| ๐Ÿ”’ Security | 5 | Authelia, Vaultwarden, CrowdSec, Keycloak, Fail2ban | +| ๐Ÿ›ก๏ธ DNS | 4 | Pi-hole, AdGuard Home, Technitium, Unbound | +| ๐Ÿ”” Notifications | 1 | ntfy | +| ๐Ÿ’พ Backups | 4 | Duplicati, Syncthing, Restic, MinIO | +| ๐Ÿ“„ Documents & Photos | 7 | Paperless-ngx, Immich, PhotoPrism, Stirling-PDF | +| ๐Ÿ  Automation | 8 | Home Assistant, Node-RED, n8n, Frigate, ESPHome | +| ๐Ÿ’ป Development | 11 | Gitea, GitLab, Code-Server, Drone CI, Woodpecker, PocketBase | +| ๐Ÿ“Š Monitoring | 17 | Grafana, Prometheus, Portainer, Uptime Kuma, Netdata | +| ๐ŸŽฎ Games | 8 | Minecraft, Valheim, Palworld, Satisfactory, RomM, Pterodactyl | +| ๐Ÿ’ฌ Communication | 7 | Matrix, Element, Revolt, Rocket.Chat, Listmonk | +| ๐Ÿ—„๏ธ Databases | 5 | PostgreSQL, MariaDB, Redis, MongoDB, InfluxDB | +| ๐Ÿก Dashboards | 4 | Homepage, Homarr, Dashy, Organizr | +| ๐Ÿค– AI & ML | 9 | Ollama, Open WebUI, ComfyUI, Stable Diffusion, Flowise | +| ๐Ÿ’ฐ Finance | 5 | Firefly III, Actual Budget, Ghostfolio, Invoice Ninja | +| ๐Ÿ“ Knowledge | 8 | Trilium, Wiki.js, Outline, HedgeDoc, Memos, Answer | +| ๐Ÿ•ต๏ธ Privacy | 5 | SearXNG, Whoogle, Nitter, Redlib, LibreTranslate | +| ๐Ÿ”ง Utilities | 27 | Nextcloud, Mealie, CyberChef, RustDesk, Guacamole, Vikunja | + +## Auto-Linking + +The generated `setup.sh` script handles post-deploy wiring: + +- **Prowlarr โ†’ Sonarr/Radarr/Lidarr/Readarr** โ€” syncs indexers automatically +- **qBittorrent/Transmission/Deluge โ†’ Arr apps** โ€” adds as download client +- **Root folders** โ€” configures media paths in each Arr app +- **Health checks** โ€” waits for all services before attempting links +- **VPN routing** โ€” download clients automatically route through Gluetun when selected + +## Tech Stack + +- **Vite** โ€” fast dev/build +- **Vanilla JS** โ€” no framework, minimal bundle size +- **js-yaml** โ€” compose generation and parsing +- **JSZip** โ€” client-side zip creation + +## Contributing + +To add a new app, edit `src/catalog/apps.js` and add an entry with: + +```js +{ + id: 'your-app', + name: 'Your App', + category: 'utilities', // must match a category id + description: 'What it does', + image: 'dockerhub/image:tag', + ports: [{ container: 8080, host: 8080, protocol: 'tcp' }], + volumes: [{ host: '${CONFIG_PATH}/your-app', container: '/config' }], + env: { OPTIONAL_VAR: 'value' }, + healthcheck: 'curl -f http://localhost:8080 || exit 1', + api: { port: 8080 }, // or null + links: {}, // dependency/linking metadata + url: 'https://github.com/org/repo' +} +``` + +## License + +MIT diff --git a/api/package-lock.json b/api/package-lock.json new file mode 100644 index 0000000..c536b11 --- /dev/null +++ b/api/package-lock.json @@ -0,0 +1,855 @@ +{ + "name": "stackpilot-api", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "stackpilot-api", + "version": "1.0.0", + "dependencies": { + "cors": "2.8.5", + "express": "4.21.2" + } + }, + "node_modules/accepts": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", + "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==", + "license": "MIT", + "dependencies": { + "mime-types": "~2.1.34", + "negotiator": "0.6.3" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/array-flatten": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", + "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==", + "license": "MIT" + }, + "node_modules/body-parser": { + "version": "1.20.3", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.3.tgz", + "integrity": "sha512-7rAxByjUMqQ3/bHJy7D6OGXvx/MMc4IqBn/X0fcM1QUcAItpZrBEYhWGem+tzXH90c+G01ypMcYJBO9Y30203g==", + "license": "MIT", + "dependencies": { + "bytes": "3.1.2", + "content-type": "~1.0.5", + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "1.2.0", + "http-errors": "2.0.0", + "iconv-lite": "0.4.24", + "on-finished": "2.4.1", + "qs": "6.13.0", + "raw-body": "2.5.2", + "type-is": "~1.6.18", + "unpipe": "1.0.0" + }, + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/content-disposition": { + "version": "0.5.4", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz", + "integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==", + "license": "MIT", + "dependencies": { + "safe-buffer": "5.2.1" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/content-type": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie": { + "version": "0.7.1", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.1.tgz", + "integrity": "sha512-6DnInpx7SJ2AK3+CTUE/ZM0vWTUboZCegxhC2xiIydHR9jNuTAASBrfEpHhiGOZw/nX51bHt6YQl8jsGo4y/0w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie-signature": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz", + "integrity": "sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==", + "license": "MIT" + }, + "node_modules/cors": { + "version": "2.8.5", + "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.5.tgz", + "integrity": "sha512-KIHbLJqu73RGr/hnbrO9uBeixNGuvSQjul/jdFvS/KFSIH1hWVd1ng7zOHx+YrEfInLG7q4n6GHQ9cDtxv/P6g==", + "license": "MIT", + "dependencies": { + "object-assign": "^4", + "vary": "^1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/destroy": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz", + "integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==", + "license": "MIT", + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", + "license": "MIT" + }, + "node_modules/encodeurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", + "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "license": "MIT" + }, + "node_modules/etag": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/express": { + "version": "4.21.2", + "resolved": "https://registry.npmjs.org/express/-/express-4.21.2.tgz", + "integrity": "sha512-28HqgMZAmih1Czt9ny7qr6ek2qddF4FclbMzwhCREB6OFfH+rXAnuNCwo1/wFvrtbgsQDb4kSbX9de9lFbrXnA==", + "license": "MIT", + "dependencies": { + "accepts": "~1.3.8", + "array-flatten": "1.1.1", + "body-parser": "1.20.3", + "content-disposition": "0.5.4", + "content-type": "~1.0.4", + "cookie": "0.7.1", + "cookie-signature": "1.0.6", + "debug": "2.6.9", + "depd": "2.0.0", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "finalhandler": "1.3.1", + "fresh": "0.5.2", + "http-errors": "2.0.0", + "merge-descriptors": "1.0.3", + "methods": "~1.1.2", + "on-finished": "2.4.1", + "parseurl": "~1.3.3", + "path-to-regexp": "0.1.12", + "proxy-addr": "~2.0.7", + "qs": "6.13.0", + "range-parser": "~1.2.1", + "safe-buffer": "5.2.1", + "send": "0.19.0", + "serve-static": "1.16.2", + "setprototypeof": "1.2.0", + "statuses": "2.0.1", + "type-is": "~1.6.18", + "utils-merge": "1.0.1", + "vary": "~1.1.2" + }, + "engines": { + "node": ">= 0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/finalhandler": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.1.tgz", + "integrity": "sha512-6BN9trH7bp3qvnrRyzsBz+g3lZxTNZTbVO2EV1CS0WIcDbawYVdYvGflME/9QP0h0pYlCDBCTjYa9nZzMDpyxQ==", + "license": "MIT", + "dependencies": { + "debug": "2.6.9", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "on-finished": "2.4.1", + "parseurl": "~1.3.3", + "statuses": "2.0.1", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/forwarded": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fresh": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz", + "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/http-errors": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.0.tgz", + "integrity": "sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ==", + "license": "MIT", + "dependencies": { + "depd": "2.0.0", + "inherits": "2.0.4", + "setprototypeof": "1.2.0", + "statuses": "2.0.1", + "toidentifier": "1.0.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/iconv-lite": { + "version": "0.4.24", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", + "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "node_modules/ipaddr.js": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/media-typer": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", + "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/merge-descriptors": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz", + "integrity": "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/methods": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz", + "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz", + "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==", + "license": "MIT", + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "license": "MIT" + }, + "node_modules/negotiator": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", + "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/on-finished": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/path-to-regexp": { + "version": "0.1.12", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.12.tgz", + "integrity": "sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ==", + "license": "MIT" + }, + "node_modules/proxy-addr": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", + "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", + "license": "MIT", + "dependencies": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/qs": { + "version": "6.13.0", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.13.0.tgz", + "integrity": "sha512-+38qI9SOr8tfZ4QmJNplMUxqjbe7LKvvZgWdExBOmd+egZTtjLB67Gu0HRX3u/XOq7UU2Nx6nsjvS16Z9uwfpg==", + "license": "BSD-3-Clause", + "dependencies": { + "side-channel": "^1.0.6" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/range-parser": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", + "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/raw-body": { + "version": "2.5.2", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.2.tgz", + "integrity": "sha512-8zGqypfENjCIqGhgXToC8aB2r7YrBX+AQAfIPs/Mlk+BtPTztOvTS01NRW/3Eh60J+a48lt8qsCzirQ6loCVfA==", + "license": "MIT", + "dependencies": { + "bytes": "3.1.2", + "http-errors": "2.0.0", + "iconv-lite": "0.4.24", + "unpipe": "1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT" + }, + "node_modules/send": { + "version": "0.19.0", + "resolved": "https://registry.npmjs.org/send/-/send-0.19.0.tgz", + "integrity": "sha512-dW41u5VfLXu8SJh5bwRmyYUbAoSB3c9uQh6L8h/KtsFREPWpbX1lrljJo186Jc4nmci/sGUZ9a0a0J2zgfq2hw==", + "license": "MIT", + "dependencies": { + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "1.2.0", + "encodeurl": "~1.0.2", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "fresh": "0.5.2", + "http-errors": "2.0.0", + "mime": "1.6.0", + "ms": "2.1.3", + "on-finished": "2.4.1", + "range-parser": "~1.2.1", + "statuses": "2.0.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/send/node_modules/encodeurl": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-1.0.2.tgz", + "integrity": "sha512-TPJXq8JqFaVYm2CWmPvnP2Iyo4ZSM7/QKcSmuMLDObfpH5fi7RUGmd/rTDf+rut/saiDiQEeVTNgAmJEdAOx0w==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/send/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/serve-static": { + "version": "1.16.2", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.2.tgz", + "integrity": "sha512-VqpjJZKadQB/PEbEwvFdO43Ax5dFBZ2UECszz8bQ7pi7wt//PWe1P6MN7eCnjsatYtBT6EuiClbjSWP2WrIoTw==", + "license": "MIT", + "dependencies": { + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "parseurl": "~1.3.3", + "send": "0.19.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", + "license": "ISC" + }, + "node_modules/side-channel": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz", + "integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4", + "side-channel-list": "^1.0.1", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz", + "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/statuses": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz", + "integrity": "sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "license": "MIT", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/type-is": { + "version": "1.6.18", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", + "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==", + "license": "MIT", + "dependencies": { + "media-typer": "0.3.0", + "mime-types": "~2.1.24" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/utils-merge": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", + "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==", + "license": "MIT", + "engines": { + "node": ">= 0.4.0" + } + }, + "node_modules/vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + } + } +} diff --git a/api/package.json b/api/package.json new file mode 100644 index 0000000..89122fe --- /dev/null +++ b/api/package.json @@ -0,0 +1,12 @@ +{ + "name": "stackpilot-api", + "version": "1.0.0", + "type": "module", + "scripts": { + "start": "node server.js" + }, + "dependencies": { + "express": "4.21.2", + "cors": "2.8.5" + } +} diff --git a/api/server.js b/api/server.js new file mode 100644 index 0000000..35bed9e --- /dev/null +++ b/api/server.js @@ -0,0 +1,115 @@ +import express from 'express'; +import cors from 'cors'; +import { readFileSync, writeFileSync, existsSync } from 'fs'; +import { join, dirname } from 'path'; +import { fileURLToPath } from 'url'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const DATA_FILE = join(__dirname, 'data', 'popularity.json'); +const DOWNLOADS_LOG = join(__dirname, 'data', 'downloads.json'); + +const app = express(); +app.use(cors()); +app.use(express.json()); + +// Ensure data dir exists +import { mkdirSync } from 'fs'; +mkdirSync(join(__dirname, 'data'), { recursive: true }); + +// Load or init popularity data +function loadPopularity() { + if (existsSync(DATA_FILE)) { + return JSON.parse(readFileSync(DATA_FILE, 'utf8')); + } + return {}; +} + +function savePopularity(data) { + writeFileSync(DATA_FILE, JSON.stringify(data, null, 2)); +} + +function loadDownloads() { + if (existsSync(DOWNLOADS_LOG)) { + return JSON.parse(readFileSync(DOWNLOADS_LOG, 'utf8')); + } + return { total: 0, history: [] }; +} + +function saveDownloads(data) { + writeFileSync(DOWNLOADS_LOG, JSON.stringify(data, null, 2)); +} + +// POST /api/analytics/download โ€” record a bundle download +app.post('/api/analytics/download', (req, res) => { + const { apps, timestamp } = req.body; + if (!apps || !Array.isArray(apps)) { + return res.status(400).json({ error: 'apps array required' }); + } + + // Update popularity counts + const popularity = loadPopularity(); + for (const appId of apps) { + popularity[appId] = (popularity[appId] || 0) + 1; + } + savePopularity(popularity); + + // Log the download + const downloads = loadDownloads(); + downloads.total += 1; + downloads.history.push({ + timestamp: timestamp || new Date().toISOString(), + apps, + count: apps.length, + }); + // Keep last 10,000 entries to avoid unbounded growth + if (downloads.history.length > 10000) { + downloads.history = downloads.history.slice(-10000); + } + saveDownloads(downloads); + + res.json({ ok: true, total_downloads: downloads.total }); +}); + +// GET /api/analytics/popularity โ€” get app download counts +app.get('/api/analytics/popularity', (req, res) => { + const popularity = loadPopularity(); + res.json(popularity); +}); + +// GET /api/analytics/stats โ€” overview stats +app.get('/api/analytics/stats', (req, res) => { + const popularity = loadPopularity(); + const downloads = loadDownloads(); + + // Top apps + const sorted = Object.entries(popularity) + .sort((a, b) => b[1] - a[1]); + + res.json({ + total_downloads: downloads.total, + unique_apps_selected: sorted.length, + top_20: sorted.slice(0, 20).map(([id, count]) => ({ id, count })), + recent: downloads.history.slice(-10).reverse(), + }); +}); + +// GET /api/analytics/app/:id โ€” stats for a specific app +app.get('/api/analytics/app/:id', (req, res) => { + const popularity = loadPopularity(); + const count = popularity[req.params.id] || 0; + const downloads = loadDownloads(); + const total = downloads.total || 1; + const percentage = ((count / total) * 100).toFixed(1); + + res.json({ + id: req.params.id, + download_count: count, + percentage: `${percentage}%`, + total_bundles: total, + }); +}); + +const PORT = process.env.PORT || 3052; +app.listen(PORT, () => { + console.log(`StackPilot Analytics API running on port ${PORT}`); +}); diff --git a/index.html b/index.html new file mode 100644 index 0000000..3acc7a4 --- /dev/null +++ b/index.html @@ -0,0 +1,137 @@ + + + + + + StackPilot โ€” Docker Stack Builder + + + + + + + +
+

๐Ÿš€ StackPilot

+

Build your self-hosted Docker stack in minutes

+ +
+ +
+ +
+

1. Configure Paths

+
+ + + +
+
+ + + + +
+
+ + +
+

2. Select Apps

+
+
+ + +
+

3. Remote Access (Optional)

+
+ + +
+
+ + +
+

4. Preview & Download

+
+
+ + + +
+
Select apps above to see preview...
+
+ +
+
+ + + + + + + + + diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..7353e54 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,931 @@ +{ + "name": "stackpilot", + "version": "0.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "stackpilot", + "version": "0.0.0", + "dependencies": { + "js-yaml": "^4.1.0", + "jszip": "^3.10.1" + }, + "devDependencies": { + "vite": "^8.2.0" + } + }, + "node_modules/@oxc-project/types": { + "version": "0.142.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.142.0.tgz", + "integrity": "sha512-7W+2q5AKQVU36fkaryontrHn3YDt1RyUYXatw9i5H8ocYe2sPKSFB6eS8WNPeRKiN1qAWWZUPm7gwFzJGrccqQ==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/Boshen" + } + }, + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.2.tgz", + "integrity": "sha512-l7x215OGvo1s52JWmR8U/DAVzEDWBCIbTm28aeJV/WDTSHgcKXaZTuBT0hJMs5NggilfJTW3clZVvd24yfKJxA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.2.tgz", + "integrity": "sha512-9u9Xv6c1AJZT0FfwH5vrMG5Jjcwhc1MlyrPu0XfTqkzsmqfks2M6W/o5XwAJgVVN/jHpqqngC1WevHKKTIUtIA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.2.tgz", + "integrity": "sha512-9W1mbGZAfW3oqd85bhBkmpyHCCzL1TeG/zFFP3vg7b0rlly8cxOcre5nXwz+LHazCwac2MNWgPdPCHndABjpWQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.2.tgz", + "integrity": "sha512-0p1lhiCSCyaerFwtrdZQUx7NqGk6LQnaRKWX7tFQqwQgvX0rjM15cIkm3pax1UpEakK14C4mOxx/jSqCBdBRqQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.2.tgz", + "integrity": "sha512-e+cOJXrJ2L3zx6YzqPg+f6Wbk3V1cKB8bOhbaYdVYN3DdquzNdRAmrbETz1qnt5yp/c7JNlNjmITiA2cVneQ7w==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.2.tgz", + "integrity": "sha512-JsSMsj6sNat/MuhG5fnBD7QgbtpHKVe30x5/bAVirDHdhoQRXJkF6xc0Jqk8O4fiCUQAzMOoH9wZi3m60c8wtg==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.2.tgz", + "integrity": "sha512-B5G/zJdHaoJn9vD50eGHWkiWfmq8Uhi3IiLPJTzmZTrAalk1bztUikSXo0qga18ibE0IXboyeMUnhPjhAJ45wQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.2.tgz", + "integrity": "sha512-6mC/awzKka8W6EoekjegpfGkjz8jXWDX63pqu/HYVpyKtZfu65Jsh4QAH3Kej3CAv/c1oGX7psTmFEbr0mDxLA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.2.tgz", + "integrity": "sha512-412MX9fJLdA1IK28EZnc8jYv2HRTleOZgfLQumJ5zy7OeJLZlg/CETwFaXjNmGVxG51cFHpKLqb5LKvBC+HsHA==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.2.tgz", + "integrity": "sha512-Q/+HI/ToJafZ1iCqGgVQXUEkIjufHCTF0gBQ2a5o3cg7GJ2h0qyq3nvvSmU+bGda2/7ygXpTY4TM6gO9OhQ0ZA==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.2.tgz", + "integrity": "sha512-ZKp/w41n6wCvxzxQHtQSbuphfX3Y4cCvbjkKHusrLx4lh+JWLTU7StSltO/DKARISzbj368d+qUaCjI8K2wzXw==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.2.tgz", + "integrity": "sha512-pxE6xD4KS3eAROkKK5yrhB9/3+vhlhVGMvlQLbdpzrBGDbKrnzx3RLwPaHvasLo6jgaiBLn7e04Df9C4tYhjmA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.2.tgz", + "integrity": "sha512-4MqEue5re+xIZzAWsB8sj0P1kqZySWqIuN4t6QaIO/YA6SFwySOLruvWQFzfmqk8LBK2P30KCSJwf6mJCZZ5/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.2.tgz", + "integrity": "sha512-NweNxxD0Nf9t8v7kodun45Ijp3EIwYY+uydPP6qBEYvfBqhIjN6dZMzlQja3tqX/aLs3F3Uz+AxDpKgRhpOZQg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", + "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", + "dev": true, + "license": "MIT" + }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "license": "Python-2.0" + }, + "node_modules/core-util-is": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz", + "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==", + "license": "MIT" + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/immediate": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/immediate/-/immediate-3.0.6.tgz", + "integrity": "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==", + "license": "MIT" + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "node_modules/isarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", + "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==", + "license": "MIT" + }, + "node_modules/js-yaml": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz", + "integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==", + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/jszip": { + "version": "3.10.1", + "resolved": "https://registry.npmjs.org/jszip/-/jszip-3.10.1.tgz", + "integrity": "sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g==", + "license": "(MIT OR GPL-3.0-or-later)", + "dependencies": { + "lie": "~3.3.0", + "pako": "~1.0.2", + "readable-stream": "~2.3.6", + "setimmediate": "^1.0.5" + } + }, + "node_modules/lie": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/lie/-/lie-3.3.0.tgz", + "integrity": "sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==", + "license": "MIT", + "dependencies": { + "immediate": "~3.0.5" + } + }, + "node_modules/lightningcss": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.33.0.tgz", + "integrity": "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.33.0", + "lightningcss-darwin-arm64": "1.33.0", + "lightningcss-darwin-x64": "1.33.0", + "lightningcss-freebsd-x64": "1.33.0", + "lightningcss-linux-arm-gnueabihf": "1.33.0", + "lightningcss-linux-arm64-gnu": "1.33.0", + "lightningcss-linux-arm64-musl": "1.33.0", + "lightningcss-linux-x64-gnu": "1.33.0", + "lightningcss-linux-x64-musl": "1.33.0", + "lightningcss-win32-arm64-msvc": "1.33.0", + "lightningcss-win32-x64-msvc": "1.33.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.33.0.tgz", + "integrity": "sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.33.0.tgz", + "integrity": "sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.33.0.tgz", + "integrity": "sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.33.0.tgz", + "integrity": "sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.33.0.tgz", + "integrity": "sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.33.0.tgz", + "integrity": "sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.33.0.tgz", + "integrity": "sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.33.0.tgz", + "integrity": "sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.33.0.tgz", + "integrity": "sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.33.0.tgz", + "integrity": "sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.33.0.tgz", + "integrity": "sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/nanoid": { + "version": "3.3.17", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.17.tgz", + "integrity": "sha512-xQLf0A3HOMlgHq0n247/LRuAOYmB7dXJ/DvAxGvsSBij45XtBSmQycu+F8ODbHwns/XyFZagyL1+J0Offw1E0g==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/pako": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz", + "integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==", + "license": "(MIT AND Zlib)" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", + "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.25", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.25.tgz", + "integrity": "sha512-DTPx3RWSSnWyzLxQnlH0rJP+EW5ekl16ZU4/psbIhA0e53kJfdgaN5vKM+xP7yJtXVu+nfdVFmlgFDEKAe4Pyw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.16", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/process-nextick-args": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", + "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==", + "license": "MIT" + }, + "node_modules/readable-stream": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", + "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", + "license": "MIT", + "dependencies": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, + "node_modules/rolldown": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.2.tgz", + "integrity": "sha512-opwpo1tQBAcpSUJDt94B7hhLNGOKjCdE//XXjeLrnx9b83bjnw45tXdg1b09yEw/VLFBJGZpwRULMmOZo7ol+A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@oxc-project/types": "=0.142.0", + "@rolldown/pluginutils": "^1.0.0" + }, + "bin": { + "rolldown": "bin/cli.mjs" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@rolldown/binding-android-arm64": "1.2.2", + "@rolldown/binding-darwin-arm64": "1.2.2", + "@rolldown/binding-darwin-x64": "1.2.2", + "@rolldown/binding-freebsd-x64": "1.2.2", + "@rolldown/binding-linux-arm-gnueabihf": "1.2.2", + "@rolldown/binding-linux-arm64-gnu": "1.2.2", + "@rolldown/binding-linux-arm64-musl": "1.2.2", + "@rolldown/binding-linux-ppc64-gnu": "1.2.2", + "@rolldown/binding-linux-s390x-gnu": "1.2.2", + "@rolldown/binding-linux-x64-gnu": "1.2.2", + "@rolldown/binding-linux-x64-musl": "1.2.2", + "@rolldown/binding-openharmony-arm64": "1.2.2", + "@rolldown/binding-win32-arm64-msvc": "1.2.2", + "@rolldown/binding-win32-x64-msvc": "1.2.2" + } + }, + "node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "license": "MIT" + }, + "node_modules/setimmediate": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/setimmediate/-/setimmediate-1.0.5.tgz", + "integrity": "sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==", + "license": "MIT" + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/string_decoder": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.1.0" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "license": "MIT" + }, + "node_modules/vite": { + "version": "8.2.0", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.2.0.tgz", + "integrity": "sha512-pn+CFpM0lwDeKwmOq1ZaBK/9sjorZcgqxki6MbY/jPEVd9vichIlmlD4HmQ5wdP5EgqQCFRaACBxMC7uEGc6lQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "lightningcss": "^1.33.0", + "picomatch": "^4.0.5", + "postcss": "^8.5.23", + "rolldown": "~1.2.0", + "tinyglobby": "^0.2.17" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "@vitejs/devtools": "^0.4.0", + "esbuild": "^0.27.0 || ^0.28.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "@vitejs/devtools": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..af67480 --- /dev/null +++ b/package.json @@ -0,0 +1,18 @@ +{ + "name": "stackpilot", + "version": "0.0.0", + "private": true, + "type": "module", + "scripts": { + "dev": "vite", + "build": "vite build", + "preview": "vite preview" + }, + "devDependencies": { + "vite": "^8.2.0" + }, + "dependencies": { + "js-yaml": "^4.1.0", + "jszip": "^3.10.1" + } +} diff --git a/public/favicon.svg b/public/favicon.svg new file mode 100644 index 0000000..0906f9c --- /dev/null +++ b/public/favicon.svg @@ -0,0 +1 @@ + diff --git a/public/icons.svg b/public/icons.svg new file mode 100644 index 0000000..e952219 --- /dev/null +++ b/public/icons.svg @@ -0,0 +1,24 @@ + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/catalog/apps.js b/src/catalog/apps.js new file mode 100644 index 0000000..6ece98b --- /dev/null +++ b/src/catalog/apps.js @@ -0,0 +1,1193 @@ +export const categories = [ + { id: 'media', name: 'Media Servers', icon: '๐ŸŽฌ' }, + { id: 'arr', name: 'Arr Suite', icon: '๐Ÿ“ก' }, + { id: 'downloads', name: 'Download Clients', icon: 'โฌ‡๏ธ' }, + { id: 'requests', name: 'Requests & Discovery', icon: '๐Ÿ”' }, + { id: 'networking', name: 'Networking & Access', icon: '๐ŸŒ' }, + { id: 'security', name: 'Security & Auth', icon: '๐Ÿ”’' }, + { id: 'dns', name: 'DNS & Ad Blocking', icon: '๐Ÿ›ก๏ธ' }, + { id: 'notifications', name: 'Notifications', icon: '๐Ÿ””' }, + { id: 'backups', name: 'Backups & Storage', icon: '๐Ÿ’พ' }, + { id: 'documents', name: 'Documents & Photos', icon: '๐Ÿ“„' }, + { id: 'automation', name: 'Home Automation', icon: '๐Ÿ ' }, + { id: 'development', name: 'Development', icon: '๐Ÿ’ป' }, + { id: 'monitoring', name: 'Monitoring & Management', icon: '๐Ÿ“Š' }, + { id: 'games', name: 'Games & Emulation', icon: '๐ŸŽฎ' }, + { id: 'communication', name: 'Communication', icon: '๐Ÿ’ฌ' }, + { id: 'databases', name: 'Databases & Supporting', icon: '๐Ÿ—„๏ธ' }, + { id: 'dashboards', name: 'Dashboards & Start Pages', icon: '๐Ÿก' }, + { id: 'ai', name: 'AI & Machine Learning', icon: '๐Ÿค–' }, + { id: 'finance', name: 'Finance', icon: '๐Ÿ’ฐ' }, + { id: 'knowledge', name: 'Knowledge & Notes', icon: '๐Ÿ“' }, + { id: 'privacy', name: 'Privacy & Search', icon: '๐Ÿ•ต๏ธ' }, + { id: 'utilities', name: 'Utilities', icon: '๐Ÿ”ง' }, +]; + +export const apps = [ + // Media Servers + { + id: 'jellyfin', + name: 'Jellyfin', + category: 'media', + description: 'Free media server for movies, TV, music & photos', + image: 'jellyfin/jellyfin:latest', + ports: [{ container: 8096, host: 8096, protocol: 'tcp' }], + volumes: [ + { host: '${CONFIG_PATH}/jellyfin', container: '/config' }, + { host: '${MEDIA_PATH}', container: '/media', readonly: true }, + ], + env: {}, + healthcheck: 'curl -f http://localhost:8096/health || exit 1', + api: { port: 8096, keyPath: '/System/Info', authHeader: 'X-Emby-Token' }, + links: { consumesMedia: true }, + url: 'https://github.com/jellyfin/jellyfin', + }, + { + id: 'plex', + name: 'Plex', + category: 'media', + description: 'Popular media server with broad device support', + image: 'lscr.io/linuxserver/plex:latest', + ports: [{ container: 32400, host: 32400, protocol: 'tcp' }], + volumes: [ + { host: '${CONFIG_PATH}/plex', container: '/config' }, + { host: '${MEDIA_PATH}', container: '/media', readonly: true }, + ], + env: { PLEX_CLAIM: '' }, + healthcheck: 'curl -f http://localhost:32400/identity || exit 1', + api: { port: 32400 }, + links: { consumesMedia: true }, + url: 'https://github.com/plexinc/pms-docker', + }, + { + id: 'emby', + name: 'Emby', + category: 'media', + description: 'Media server with live TV and DVR support', + image: 'emby/embyserver:latest', + ports: [{ container: 8096, host: 8097, protocol: 'tcp' }], + volumes: [ + { host: '${CONFIG_PATH}/emby', container: '/config' }, + { host: '${MEDIA_PATH}', container: '/media', readonly: true }, + ], + env: {}, + healthcheck: 'curl -f http://localhost:8096/health || exit 1', + api: { port: 8097 }, + links: { consumesMedia: true }, + url: 'https://github.com/MediaBrowser/Emby.Releases', + }, + // Arr Suite + { + id: 'sonarr', + name: 'Sonarr', + category: 'arr', + description: 'TV series management and automation', + image: 'lscr.io/linuxserver/sonarr:latest', + ports: [{ container: 8989, host: 8989, protocol: 'tcp' }], + volumes: [ + { host: '${CONFIG_PATH}/sonarr', container: '/config' }, + { host: '${MEDIA_PATH}/tv', container: '/tv' }, + { host: '${DOWNLOADS_PATH}', container: '/downloads' }, + ], + env: {}, + healthcheck: 'curl -f http://localhost:8989/ping || exit 1', + api: { port: 8989, keyPath: '/api/v3/system/status', keyLocation: 'config.xml' }, + links: { needsDownloadClient: true, needsIndexer: true, providesMedia: 'tv' }, + url: 'https://github.com/Sonarr/Sonarr', + }, + { + id: 'radarr', + name: 'Radarr', + category: 'arr', + description: 'Movie management and automation', + image: 'lscr.io/linuxserver/radarr:latest', + ports: [{ container: 7878, host: 7878, protocol: 'tcp' }], + volumes: [ + { host: '${CONFIG_PATH}/radarr', container: '/config' }, + { host: '${MEDIA_PATH}/movies', container: '/movies' }, + { host: '${DOWNLOADS_PATH}', container: '/downloads' }, + ], + env: {}, + healthcheck: 'curl -f http://localhost:7878/ping || exit 1', + api: { port: 7878, keyPath: '/api/v3/system/status', keyLocation: 'config.xml' }, + links: { needsDownloadClient: true, needsIndexer: true, providesMedia: 'movies' }, + url: 'https://github.com/Radarr/Radarr', + }, + { + id: 'lidarr', + name: 'Lidarr', + category: 'arr', + description: 'Music management and automation', + image: 'lscr.io/linuxserver/lidarr:latest', + ports: [{ container: 8686, host: 8686, protocol: 'tcp' }], + volumes: [ + { host: '${CONFIG_PATH}/lidarr', container: '/config' }, + { host: '${MEDIA_PATH}/music', container: '/music' }, + { host: '${DOWNLOADS_PATH}', container: '/downloads' }, + ], + env: {}, + healthcheck: 'curl -f http://localhost:8686/ping || exit 1', + api: { port: 8686, keyPath: '/api/v1/system/status', keyLocation: 'config.xml' }, + links: { needsDownloadClient: true, needsIndexer: true, providesMedia: 'music' }, + url: 'https://github.com/Lidarr/Lidarr', + }, + { + id: 'readarr', + name: 'Readarr', + category: 'arr', + description: 'Book and audiobook management', + image: 'lscr.io/linuxserver/readarr:develop', + ports: [{ container: 8787, host: 8787, protocol: 'tcp' }], + volumes: [ + { host: '${CONFIG_PATH}/readarr', container: '/config' }, + { host: '${MEDIA_PATH}/books', container: '/books' }, + { host: '${DOWNLOADS_PATH}', container: '/downloads' }, + ], + env: {}, + healthcheck: 'curl -f http://localhost:8787/ping || exit 1', + api: { port: 8787, keyPath: '/api/v1/system/status', keyLocation: 'config.xml' }, + links: { needsDownloadClient: true, needsIndexer: true, providesMedia: 'books' }, + url: 'https://github.com/Readarr/Readarr', + }, + { + id: 'prowlarr', + name: 'Prowlarr', + category: 'arr', + description: 'Indexer manager โ€” syncs to all Arr apps automatically', + image: 'lscr.io/linuxserver/prowlarr:latest', + ports: [{ container: 9696, host: 9696, protocol: 'tcp' }], + volumes: [ + { host: '${CONFIG_PATH}/prowlarr', container: '/config' }, + ], + env: {}, + healthcheck: 'curl -f http://localhost:9696/ping || exit 1', + api: { port: 9696, keyPath: '/api/v1/system/status', keyLocation: 'config.xml' }, + links: { providesIndexer: true }, + url: 'https://github.com/Prowlarr/Prowlarr', + }, + { + id: 'bazarr', + name: 'Bazarr', + category: 'arr', + description: 'Subtitle management for Sonarr & Radarr', + image: 'lscr.io/linuxserver/bazarr:latest', + ports: [{ container: 6767, host: 6767, protocol: 'tcp' }], + volumes: [ + { host: '${CONFIG_PATH}/bazarr', container: '/config' }, + { host: '${MEDIA_PATH}/movies', container: '/movies' }, + { host: '${MEDIA_PATH}/tv', container: '/tv' }, + ], + env: {}, + healthcheck: 'curl -f http://localhost:6767/ping || exit 1', + api: { port: 6767 }, + links: { needsSonarr: true, needsRadarr: true }, + url: 'https://github.com/morpheus65535/bazarr', + }, + // Download Clients + { + id: 'qbittorrent', + name: 'qBittorrent', + category: 'downloads', + description: 'Lightweight torrent client with web UI', + image: 'lscr.io/linuxserver/qbittorrent:latest', + ports: [ + { container: 8080, host: 8080, protocol: 'tcp' }, + { container: 6881, host: 6881, protocol: 'tcp' }, + { container: 6881, host: 6881, protocol: 'udp' }, + ], + volumes: [ + { host: '${CONFIG_PATH}/qbittorrent', container: '/config' }, + { host: '${DOWNLOADS_PATH}', container: '/downloads' }, + ], + env: { WEBUI_PORT: '8080' }, + healthcheck: 'curl -f http://localhost:8080 || exit 1', + api: { port: 8080, type: 'qbittorrent' }, + links: { providesDownloadClient: true }, + url: 'https://github.com/qbittorrent/qBittorrent', + }, + { + id: 'transmission', + name: 'Transmission', + category: 'downloads', + description: 'Simple, lightweight torrent client', + image: 'lscr.io/linuxserver/transmission:latest', + ports: [ + { container: 9091, host: 9091, protocol: 'tcp' }, + { container: 51413, host: 51413, protocol: 'tcp' }, + ], + volumes: [ + { host: '${CONFIG_PATH}/transmission', container: '/config' }, + { host: '${DOWNLOADS_PATH}', container: '/downloads' }, + ], + env: {}, + healthcheck: 'curl -f http://localhost:9091 || exit 1', + api: { port: 9091, type: 'transmission' }, + links: { providesDownloadClient: true }, + url: 'https://github.com/transmission/transmission', + }, + { + id: 'sabnzbd', + name: 'SABnzbd', + category: 'downloads', + description: 'Usenet download client', + image: 'lscr.io/linuxserver/sabnzbd:latest', + ports: [{ container: 8080, host: 8081, protocol: 'tcp' }], + volumes: [ + { host: '${CONFIG_PATH}/sabnzbd', container: '/config' }, + { host: '${DOWNLOADS_PATH}', container: '/downloads' }, + ], + env: {}, + healthcheck: 'curl -f http://localhost:8080 || exit 1', + api: { port: 8081, type: 'sabnzbd' }, + links: { providesDownloadClient: true }, + url: 'https://github.com/sabnzbd/sabnzbd', + }, + { + id: 'nzbget', + name: 'NZBGet', + category: 'downloads', + description: 'Efficient Usenet downloader', + image: 'lscr.io/linuxserver/nzbget:latest', + ports: [{ container: 6789, host: 6789, protocol: 'tcp' }], + volumes: [ + { host: '${CONFIG_PATH}/nzbget', container: '/config' }, + { host: '${DOWNLOADS_PATH}', container: '/downloads' }, + ], + env: {}, + healthcheck: 'curl -f http://localhost:6789 || exit 1', + api: { port: 6789, type: 'nzbget' }, + links: { providesDownloadClient: true }, + url: 'https://github.com/nzbgetcom/nzbget', + }, + // Requests & Discovery + { + id: 'jellyseerr', + name: 'Jellyseerr', + category: 'requests', + description: 'Media request management for Jellyfin', + image: 'fallenbagel/jellyseerr:latest', + ports: [{ container: 5055, host: 5055, protocol: 'tcp' }], + volumes: [ + { host: '${CONFIG_PATH}/jellyseerr', container: '/app/config' }, + ], + env: {}, + healthcheck: 'curl -f http://localhost:5055/api/v1/status || exit 1', + api: { port: 5055 }, + links: { needsJellyfin: true, needsSonarr: true, needsRadarr: true }, + url: 'https://github.com/Fallenbagel/jellyseerr', + }, + { + id: 'overseerr', + name: 'Overseerr', + category: 'requests', + description: 'Media request management for Plex', + image: 'lscr.io/linuxserver/overseerr:latest', + ports: [{ container: 5055, host: 5056, protocol: 'tcp' }], + volumes: [ + { host: '${CONFIG_PATH}/overseerr', container: '/config' }, + ], + env: {}, + healthcheck: 'curl -f http://localhost:5055/api/v1/status || exit 1', + api: { port: 5056 }, + links: { needsPlex: true, needsSonarr: true, needsRadarr: true }, + url: 'https://github.com/sct/overseerr', + }, + // Networking + { + id: 'tailscale', + name: 'Tailscale', + category: 'networking', + description: 'Zero-config VPN for secure remote access', + image: 'tailscale/tailscale:latest', + ports: [], + volumes: [ + { host: '${CONFIG_PATH}/tailscale', container: '/var/lib/tailscale' }, + ], + env: { TS_AUTHKEY: '', TS_EXTRA_ARGS: '--advertise-exit-node' }, + capAdd: ['NET_ADMIN', 'SYS_MODULE'], + healthcheck: 'tailscale status || exit 1', + api: null, + links: {}, + url: 'https://github.com/tailscale/tailscale', + }, + { + id: 'gluetun', + name: 'Gluetun', + category: 'networking', + description: 'VPN container โ€” route download traffic through VPN', + image: 'qmcgaw/gluetun:latest', + ports: [], + volumes: [ + { host: '${CONFIG_PATH}/gluetun', container: '/gluetun' }, + ], + env: { VPN_SERVICE_PROVIDER: '', VPN_TYPE: 'openvpn', OPENVPN_USER: '', OPENVPN_PASSWORD: '' }, + capAdd: ['NET_ADMIN'], + healthcheck: 'curl -f https://ipinfo.io || exit 1', + api: null, + links: { providesVPN: true }, + url: 'https://github.com/qdm12/gluetun', + }, + { + id: 'nginx-proxy-manager', + name: 'Nginx Proxy Manager', + category: 'networking', + description: 'Reverse proxy with SSL and web UI', + image: 'jc21/nginx-proxy-manager:latest', + ports: [ + { container: 80, host: 80, protocol: 'tcp' }, + { container: 443, host: 443, protocol: 'tcp' }, + { container: 81, host: 81, protocol: 'tcp' }, + ], + volumes: [ + { host: '${CONFIG_PATH}/npm/data', container: '/data' }, + { host: '${CONFIG_PATH}/npm/letsencrypt', container: '/etc/letsencrypt' }, + ], + env: {}, + healthcheck: 'curl -f http://localhost:81 || exit 1', + api: { port: 81 }, + links: {}, + url: 'https://github.com/NginxProxyManager/nginx-proxy-manager', + }, + // Monitoring + { + id: 'portainer', + name: 'Portainer', + category: 'monitoring', + description: 'Docker management UI', + image: 'portainer/portainer-ce:latest', + ports: [{ container: 9000, host: 9000, protocol: 'tcp' }], + volumes: [ + { host: '${CONFIG_PATH}/portainer', container: '/data' }, + { host: '/var/run/docker.sock', container: '/var/run/docker.sock' }, + ], + env: {}, + healthcheck: 'curl -f http://localhost:9000 || exit 1', + api: { port: 9000 }, + links: {}, + url: 'https://github.com/portainer/portainer', + }, + { + id: 'homepage', + name: 'Homepage', + category: 'monitoring', + description: 'Customizable homelab dashboard', + image: 'ghcr.io/gethomepage/homepage:latest', + ports: [{ container: 3000, host: 3000, protocol: 'tcp' }], + volumes: [ + { host: '${CONFIG_PATH}/homepage', container: '/app/config' }, + { host: '/var/run/docker.sock', container: '/var/run/docker.sock', readonly: true }, + ], + env: {}, + healthcheck: 'curl -f http://localhost:3000 || exit 1', + api: null, + links: {}, + url: 'https://github.com/gethomepage/homepage', + }, + { + id: 'uptime-kuma', + name: 'Uptime Kuma', + category: 'monitoring', + description: 'Self-hosted uptime monitoring', + image: 'louislam/uptime-kuma:latest', + ports: [{ container: 3001, host: 3001, protocol: 'tcp' }], + volumes: [ + { host: '${CONFIG_PATH}/uptime-kuma', container: '/app/data' }, + ], + env: {}, + healthcheck: 'curl -f http://localhost:3001 || exit 1', + api: { port: 3001 }, + links: {}, + url: 'https://github.com/louislam/uptime-kuma', + }, + { + id: 'watchtower', + name: 'Watchtower', + category: 'monitoring', + description: 'Automatic Docker container updates', + image: 'containrrr/watchtower:latest', + ports: [], + volumes: [ + { host: '/var/run/docker.sock', container: '/var/run/docker.sock' }, + ], + env: { WATCHTOWER_CLEANUP: 'true', WATCHTOWER_SCHEDULE: '0 4 * * *' }, + healthcheck: null, + api: null, + links: {}, + url: 'https://github.com/containrrr/watchtower', + }, + // Utilities + { + id: 'filebrowser', + name: 'File Browser', + category: 'utilities', + description: 'Web-based file manager', + image: 'filebrowser/filebrowser:latest', + ports: [{ container: 80, host: 8082, protocol: 'tcp' }], + volumes: [ + { host: '${CONFIG_PATH}/filebrowser', container: '/config' }, + { host: '${MEDIA_PATH}', container: '/srv' }, + ], + env: {}, + healthcheck: 'curl -f http://localhost:80 || exit 1', + api: null, + links: {}, + url: 'https://github.com/filebrowser/filebrowser', + }, + { + id: 'navidrome', + name: 'Navidrome', + category: 'media', + description: 'Music server compatible with Subsonic apps', + image: 'deluan/navidrome:latest', + ports: [{ container: 4533, host: 4533, protocol: 'tcp' }], + volumes: [ + { host: '${CONFIG_PATH}/navidrome', container: '/data' }, + { host: '${MEDIA_PATH}/music', container: '/music', readonly: true }, + ], + env: {}, + healthcheck: 'curl -f http://localhost:4533/ping || exit 1', + api: { port: 4533 }, + links: { consumesMedia: true }, + url: 'https://github.com/navidrome/navidrome', + }, + { + id: 'flaresolverr', + name: 'FlareSolverr', + category: 'utilities', + description: 'Bypass Cloudflare protection for indexers', + image: 'ghcr.io/flaresolverr/flaresolverr:latest', + ports: [{ container: 8191, host: 8191, protocol: 'tcp' }], + volumes: [], + env: { LOG_LEVEL: 'info' }, + healthcheck: 'curl -f http://localhost:8191 || exit 1', + api: { port: 8191 }, + links: { providesFlaresolverr: true }, + url: 'https://github.com/FlareSolverr/FlareSolverr', + }, + { + id: 'tdarr', + name: 'Tdarr', + category: 'utilities', + description: 'Distributed media transcoding', + image: 'ghcr.io/haveagitgat/tdarr:latest', + ports: [ + { container: 8265, host: 8265, protocol: 'tcp' }, + { container: 8266, host: 8266, protocol: 'tcp' }, + ], + volumes: [ + { host: '${CONFIG_PATH}/tdarr/server', container: '/app/server' }, + { host: '${CONFIG_PATH}/tdarr/configs', container: '/app/configs' }, + { host: '${CONFIG_PATH}/tdarr/logs', container: '/app/logs' }, + { host: '${MEDIA_PATH}', container: '/media' }, + { host: '${CONFIG_PATH}/tdarr/transcode_cache', container: '/temp' }, + ], + env: { serverIP: '0.0.0.0', serverPort: '8266', webUIPort: '8265', internalNode: 'true' }, + healthcheck: 'curl -f http://localhost:8265 || exit 1', + api: { port: 8265 }, + links: { consumesMedia: true }, + url: 'https://github.com/HaveAGitGat/Tdarr', + }, + // Notifications + { + id: 'ntfy', + name: 'ntfy', + category: 'notifications', + description: 'Self-hosted push notifications โ€” HTTP-based pub/sub', + image: 'binwiederhier/ntfy:latest', + ports: [{ container: 80, host: 8090, protocol: 'tcp' }], + volumes: [ + { host: '${CONFIG_PATH}/ntfy/cache', container: '/var/cache/ntfy' }, + { host: '${CONFIG_PATH}/ntfy/etc', container: '/etc/ntfy' }, + ], + env: {}, + healthcheck: 'curl -f http://localhost:80/v1/health || exit 1', + api: { port: 8090 }, + links: { providesNotifications: true }, + url: 'https://github.com/binwiederhier/ntfy', + }, + // Security & Auth + { + id: 'authelia', + name: 'Authelia', + category: 'security', + description: 'SSO & 2FA authentication gateway', + image: 'authelia/authelia:latest', + ports: [{ container: 9091, host: 9092, protocol: 'tcp' }], + volumes: [ + { host: '${CONFIG_PATH}/authelia', container: '/config' }, + ], + env: {}, + healthcheck: 'curl -f http://localhost:9091/api/health || exit 1', + api: { port: 9092 }, + links: {}, + url: 'https://github.com/authelia/authelia', + }, + { + id: 'vaultwarden', + name: 'Vaultwarden', + category: 'security', + description: 'Lightweight Bitwarden-compatible password manager', + image: 'vaultwarden/server:latest', + ports: [{ container: 80, host: 8083, protocol: 'tcp' }], + volumes: [ + { host: '${CONFIG_PATH}/vaultwarden', container: '/data' }, + ], + env: {}, + healthcheck: 'curl -f http://localhost:80/alive || exit 1', + api: { port: 8083 }, + links: {}, + url: 'https://github.com/dani-garcia/vaultwarden', + }, + // DNS & Ad Blocking + { + id: 'pihole', + name: 'Pi-hole', + category: 'dns', + description: 'Network-wide ad blocking & local DNS', + image: 'pihole/pihole:latest', + ports: [ + { container: 80, host: 8084, protocol: 'tcp' }, + { container: 53, host: 53, protocol: 'tcp' }, + { container: 53, host: 53, protocol: 'udp' }, + ], + volumes: [ + { host: '${CONFIG_PATH}/pihole/etc', container: '/etc/pihole' }, + { host: '${CONFIG_PATH}/pihole/dnsmasq', container: '/etc/dnsmasq.d' }, + ], + env: { WEBPASSWORD: 'changeme' }, + healthcheck: 'curl -f http://localhost:80/admin || exit 1', + api: { port: 8084 }, + links: {}, + url: 'https://github.com/pi-hole/pi-hole', + }, + { + id: 'adguard-home', + name: 'AdGuard Home', + category: 'dns', + description: 'Modern ad blocker & DNS with parental controls', + image: 'adguard/adguardhome:latest', + ports: [ + { container: 3000, host: 3002, protocol: 'tcp' }, + { container: 53, host: 53, protocol: 'tcp' }, + { container: 53, host: 53, protocol: 'udp' }, + ], + volumes: [ + { host: '${CONFIG_PATH}/adguard/work', container: '/opt/adguardhome/work' }, + { host: '${CONFIG_PATH}/adguard/conf', container: '/opt/adguardhome/conf' }, + ], + env: {}, + healthcheck: 'curl -f http://localhost:3000 || exit 1', + api: { port: 3002 }, + links: {}, + url: 'https://github.com/AdguardTeam/AdGuardHome', + }, + // Backups & Storage + { + id: 'duplicati', + name: 'Duplicati', + category: 'backups', + description: 'Scheduled encrypted backups to cloud or local', + image: 'lscr.io/linuxserver/duplicati:latest', + ports: [{ container: 8200, host: 8200, protocol: 'tcp' }], + volumes: [ + { host: '${CONFIG_PATH}/duplicati', container: '/config' }, + { host: '${CONFIG_PATH}', container: '/source', readonly: true }, + ], + env: {}, + healthcheck: 'curl -f http://localhost:8200 || exit 1', + api: null, + links: {}, + url: 'https://github.com/duplicati/duplicati', + }, + { + id: 'syncthing', + name: 'Syncthing', + category: 'backups', + description: 'Peer-to-peer file sync between devices', + image: 'lscr.io/linuxserver/syncthing:latest', + ports: [ + { container: 8384, host: 8384, protocol: 'tcp' }, + { container: 22000, host: 22000, protocol: 'tcp' }, + { container: 21027, host: 21027, protocol: 'udp' }, + ], + volumes: [ + { host: '${CONFIG_PATH}/syncthing', container: '/config' }, + { host: '${MEDIA_PATH}', container: '/data' }, + ], + env: {}, + healthcheck: 'curl -f http://localhost:8384 || exit 1', + api: { port: 8384 }, + links: {}, + url: 'https://github.com/syncthing/syncthing', + }, + // Documents & Photos + { + id: 'paperless-ngx', + name: 'Paperless-ngx', + category: 'documents', + description: 'Document scanning, OCR & organisation', + image: 'ghcr.io/paperless-ngx/paperless-ngx:latest', + ports: [{ container: 8000, host: 8000, protocol: 'tcp' }], + volumes: [ + { host: '${CONFIG_PATH}/paperless/data', container: '/usr/src/paperless/data' }, + { host: '${CONFIG_PATH}/paperless/media', container: '/usr/src/paperless/media' }, + { host: '${CONFIG_PATH}/paperless/consume', container: '/usr/src/paperless/consume' }, + ], + env: { PAPERLESS_OCR_LANGUAGE: 'eng' }, + healthcheck: 'curl -f http://localhost:8000 || exit 1', + api: { port: 8000 }, + links: {}, + url: 'https://github.com/paperless-ngx/paperless-ngx', + }, + { + id: 'stirling-pdf', + name: 'Stirling-PDF', + category: 'documents', + description: 'Self-hosted PDF manipulation tools', + image: 'frooodle/s-pdf:latest', + ports: [{ container: 8080, host: 8085, protocol: 'tcp' }], + volumes: [ + { host: '${CONFIG_PATH}/stirling-pdf', container: '/configs' }, + ], + env: {}, + healthcheck: 'curl -f http://localhost:8080 || exit 1', + api: null, + links: {}, + url: 'https://github.com/Stirling-Tools/Stirling-PDF', + }, + { + id: 'immich', + name: 'Immich', + category: 'documents', + description: 'Self-hosted Google Photos alternative', + image: 'ghcr.io/immich-app/immich-server:release', + ports: [{ container: 2283, host: 2283, protocol: 'tcp' }], + volumes: [ + { host: '${CONFIG_PATH}/immich', container: '/usr/src/app/upload' }, + { host: '${MEDIA_PATH}/photos', container: '/usr/src/app/external', readonly: true }, + ], + env: { DB_PASSWORD: 'changeme', REDIS_HOSTNAME: 'immich-redis' }, + healthcheck: 'curl -f http://localhost:2283/api/server-info/ping || exit 1', + api: { port: 2283 }, + links: {}, + url: 'https://github.com/immich-app/immich', + }, + // Home Automation + { + id: 'homeassistant', + name: 'Home Assistant', + category: 'automation', + description: 'Smart home hub โ€” integrates 2000+ devices', + image: 'ghcr.io/home-assistant/home-assistant:stable', + ports: [{ container: 8123, host: 8123, protocol: 'tcp' }], + volumes: [ + { host: '${CONFIG_PATH}/homeassistant', container: '/config' }, + ], + env: {}, + healthcheck: 'curl -f http://localhost:8123 || exit 1', + api: { port: 8123 }, + links: {}, + url: 'https://github.com/home-assistant/core', + }, + { + id: 'nodered', + name: 'Node-RED', + category: 'automation', + description: 'Visual flow-based automation', + image: 'nodered/node-red:latest', + ports: [{ container: 1880, host: 1880, protocol: 'tcp' }], + volumes: [ + { host: '${CONFIG_PATH}/nodered', container: '/data' }, + ], + env: {}, + healthcheck: 'curl -f http://localhost:1880 || exit 1', + api: { port: 1880 }, + links: {}, + url: 'https://github.com/node-red/node-red', + }, + // Development + { + id: 'gitea', + name: 'Gitea', + category: 'development', + description: 'Lightweight self-hosted Git service', + image: 'gitea/gitea:latest', + ports: [ + { container: 3000, host: 3003, protocol: 'tcp' }, + { container: 22, host: 2222, protocol: 'tcp' }, + ], + volumes: [ + { host: '${CONFIG_PATH}/gitea', container: '/data' }, + ], + env: {}, + healthcheck: 'curl -f http://localhost:3000 || exit 1', + api: { port: 3003 }, + links: {}, + url: 'https://github.com/go-gitea/gitea', + }, + { + id: 'code-server', + name: 'Code-Server', + category: 'development', + description: 'VS Code in the browser', + image: 'lscr.io/linuxserver/code-server:latest', + ports: [{ container: 8443, host: 8443, protocol: 'tcp' }], + volumes: [ + { host: '${CONFIG_PATH}/code-server', container: '/config' }, + ], + env: { PASSWORD: 'changeme' }, + healthcheck: 'curl -f http://localhost:8443 || exit 1', + api: null, + links: {}, + url: 'https://github.com/coder/code-server', + }, + // Networking extras + { + id: 'wireguard', + name: 'WireGuard', + category: 'networking', + description: 'Fast, modern VPN tunnel', + image: 'lscr.io/linuxserver/wireguard:latest', + ports: [{ container: 51820, host: 51820, protocol: 'udp' }], + volumes: [ + { host: '${CONFIG_PATH}/wireguard', container: '/config' }, + ], + env: { SERVERURL: 'auto', PEERS: '3' }, + capAdd: ['NET_ADMIN', 'SYS_MODULE'], + healthcheck: null, + api: null, + links: {}, + url: 'https://github.com/WireGuard/wireguard-linux', + }, + { + id: 'traefik', + name: 'Traefik', + category: 'networking', + description: 'Auto-discovering reverse proxy with Let\'s Encrypt', + image: 'traefik:latest', + ports: [ + { container: 80, host: 80, protocol: 'tcp' }, + { container: 443, host: 443, protocol: 'tcp' }, + { container: 8080, host: 8086, protocol: 'tcp' }, + ], + volumes: [ + { host: '${CONFIG_PATH}/traefik', container: '/etc/traefik' }, + { host: '/var/run/docker.sock', container: '/var/run/docker.sock', readonly: true }, + ], + env: {}, + healthcheck: 'curl -f http://localhost:8080/ping || exit 1', + api: { port: 8086 }, + links: {}, + url: 'https://github.com/traefik/traefik', + }, + { + id: 'cloudflared', + name: 'Cloudflare Tunnel', + category: 'networking', + description: 'Expose services without port forwarding', + image: 'cloudflare/cloudflared:latest', + ports: [], + volumes: [ + { host: '${CONFIG_PATH}/cloudflared', container: '/etc/cloudflared' }, + ], + env: { TUNNEL_TOKEN: '' }, + healthcheck: null, + api: null, + links: {}, + url: 'https://github.com/cloudflare/cloudflared', + }, + // Media extras + { + id: 'kavita', + name: 'Kavita', + category: 'media', + description: 'Comics, manga & ebook reader', + image: 'jvmilazz0/kavita:latest', + ports: [{ container: 5000, host: 5000, protocol: 'tcp' }], + volumes: [ + { host: '${CONFIG_PATH}/kavita', container: '/kavita/config' }, + { host: '${MEDIA_PATH}/books', container: '/manga' }, + ], + env: {}, + healthcheck: 'curl -f http://localhost:5000 || exit 1', + api: { port: 5000 }, + links: { consumesMedia: true }, + url: 'https://github.com/Kareadita/Kavita', + }, + { + id: 'audiobookshelf', + name: 'Audiobookshelf', + category: 'media', + description: 'Audiobook & podcast server', + image: 'ghcr.io/advplyr/audiobookshelf:latest', + ports: [{ container: 80, host: 13378, protocol: 'tcp' }], + volumes: [ + { host: '${CONFIG_PATH}/audiobookshelf', container: '/config' }, + { host: '${MEDIA_PATH}/audiobooks', container: '/audiobooks' }, + { host: '${MEDIA_PATH}/podcasts', container: '/podcasts' }, + ], + env: {}, + healthcheck: 'curl -f http://localhost:80/healthcheck || exit 1', + api: { port: 13378 }, + links: { consumesMedia: true }, + url: 'https://github.com/advplyr/audiobookshelf', + }, + // Games + { + id: 'romm', + name: 'RomM', + category: 'games', + description: 'ROM library manager with web UI', + image: 'zurdi15/romm:latest', + ports: [{ container: 8080, host: 8087, protocol: 'tcp' }], + volumes: [ + { host: '${CONFIG_PATH}/romm', container: '/romm' }, + { host: '${MEDIA_PATH}/roms', container: '/romm/library' }, + ], + env: { DB_DRIVER: 'sqlite' }, + healthcheck: 'curl -f http://localhost:8080 || exit 1', + api: { port: 8087 }, + links: {}, + url: 'https://github.com/zurdi15/romm', + }, + // Communication + { + id: 'matrix-synapse', + name: 'Matrix (Synapse)', + category: 'communication', + description: 'Decentralised chat server (Slack/Discord alternative)', + image: 'matrixdotorg/synapse:latest', + ports: [{ container: 8008, host: 8008, protocol: 'tcp' }], + volumes: [ + { host: '${CONFIG_PATH}/synapse', container: '/data' }, + ], + env: {}, + healthcheck: 'curl -f http://localhost:8008/health || exit 1', + api: { port: 8008 }, + links: {}, + url: 'https://github.com/element-hq/synapse', + }, + { + id: 'mealie', + name: 'Mealie', + category: 'utilities', + description: 'Recipe manager & meal planner', + image: 'ghcr.io/mealie-recipes/mealie:latest', + ports: [{ container: 9000, host: 9001, protocol: 'tcp' }], + volumes: [ + { host: '${CONFIG_PATH}/mealie', container: '/app/data' }, + ], + env: {}, + healthcheck: 'curl -f http://localhost:9000 || exit 1', + api: { port: 9001 }, + links: {}, + url: 'https://github.com/mealie-recipes/mealie', + }, + { + id: 'changedetection', + name: 'Changedetection.io', + category: 'utilities', + description: 'Website change monitoring & alerts', + image: 'ghcr.io/dgtlmoon/changedetection.io:latest', + ports: [{ container: 5000, host: 5001, protocol: 'tcp' }], + volumes: [ + { host: '${CONFIG_PATH}/changedetection', container: '/datastore' }, + ], + env: {}, + healthcheck: 'curl -f http://localhost:5000 || exit 1', + api: { port: 5001 }, + links: {}, + url: 'https://github.com/dgtlmoon/changedetection.io', + }, + { + id: 'speedtest-tracker', + name: 'Speedtest Tracker', + category: 'monitoring', + description: 'Automated internet speed tests with history', + image: 'lscr.io/linuxserver/speedtest-tracker:latest', + ports: [{ container: 80, host: 8088, protocol: 'tcp' }], + volumes: [ + { host: '${CONFIG_PATH}/speedtest-tracker', container: '/config' }, + ], + env: { DB_CONNECTION: 'sqlite' }, + healthcheck: 'curl -f http://localhost:80 || exit 1', + api: null, + links: {}, + url: 'https://github.com/alexjustesen/speedtest-tracker', + }, + { + id: 'dozzle', + name: 'Dozzle', + category: 'monitoring', + description: 'Real-time Docker container log viewer', + image: 'amir20/dozzle:latest', + ports: [{ container: 8080, host: 8089, protocol: 'tcp' }], + volumes: [ + { host: '/var/run/docker.sock', container: '/var/run/docker.sock', readonly: true }, + ], + env: {}, + healthcheck: 'curl -f http://localhost:8080 || exit 1', + api: null, + links: {}, + url: 'https://github.com/amir20/dozzle', + }, + { + id: 'scrutiny', + name: 'Scrutiny', + category: 'monitoring', + description: 'Hard drive S.M.A.R.T. monitoring dashboard', + image: 'ghcr.io/analogj/scrutiny:master-omnibus', + ports: [{ container: 8080, host: 8091, protocol: 'tcp' }], + volumes: [ + { host: '${CONFIG_PATH}/scrutiny', container: '/opt/scrutiny/config' }, + { host: '/run/udev', container: '/run/udev', readonly: true }, + ], + capAdd: ['SYS_RAWIO'], + env: {}, + healthcheck: 'curl -f http://localhost:8080/api/health || exit 1', + api: { port: 8091 }, + links: {}, + url: 'https://github.com/AnalogJ/scrutiny', + }, + { + id: 'actual-budget', + name: 'Actual Budget', + category: 'utilities', + description: 'Privacy-focused budgeting app', + image: 'actualbudget/actual-server:latest', + ports: [{ container: 5006, host: 5006, protocol: 'tcp' }], + volumes: [ + { host: '${CONFIG_PATH}/actual-budget', container: '/data' }, + ], + env: {}, + healthcheck: 'curl -f http://localhost:5006 || exit 1', + api: null, + links: {}, + url: 'https://github.com/actualbudget/actual', + }, + { + id: 'freshrss', + name: 'FreshRSS', + category: 'utilities', + description: 'Self-hosted RSS feed reader', + image: 'freshrss/freshrss:latest', + ports: [{ container: 80, host: 8092, protocol: 'tcp' }], + volumes: [ + { host: '${CONFIG_PATH}/freshrss/data', container: '/var/www/FreshRSS/data' }, + { host: '${CONFIG_PATH}/freshrss/extensions', container: '/var/www/FreshRSS/extensions' }, + ], + env: {}, + healthcheck: 'curl -f http://localhost:80 || exit 1', + api: null, + links: {}, + url: 'https://github.com/FreshRSS/FreshRSS', + }, + { + id: 'bookstack', + name: 'BookStack', + category: 'documents', + description: 'Wiki & documentation platform', + image: 'lscr.io/linuxserver/bookstack:latest', + ports: [{ container: 80, host: 6875, protocol: 'tcp' }], + volumes: [ + { host: '${CONFIG_PATH}/bookstack', container: '/config' }, + ], + env: { DB_HOST: 'bookstack-db', DB_DATABASE: 'bookstack', DB_USERNAME: 'bookstack', DB_PASSWORD: 'changeme' }, + healthcheck: 'curl -f http://localhost:80 || exit 1', + api: null, + links: {}, + url: 'https://github.com/BookStackApp/BookStack', + }, + { + id: 'n8n', + name: 'n8n', + category: 'automation', + description: 'Workflow automation (Zapier/IFTTT alternative)', + image: 'n8nio/n8n:latest', + ports: [{ container: 5678, host: 5678, protocol: 'tcp' }], + volumes: [ + { host: '${CONFIG_PATH}/n8n', container: '/home/node/.n8n' }, + ], + env: {}, + healthcheck: 'curl -f http://localhost:5678/healthz || exit 1', + api: { port: 5678 }, + links: {}, + url: 'https://github.com/n8n-io/n8n', + }, + { + id: 'it-tools', + name: 'IT-Tools', + category: 'utilities', + description: 'Collection of developer utilities (hashing, encoding, etc.)', + image: 'corentinth/it-tools:latest', + ports: [{ container: 80, host: 8093, protocol: 'tcp' }], + volumes: [], + env: {}, + healthcheck: 'curl -f http://localhost:80 || exit 1', + api: null, + links: {}, + url: 'https://github.com/CorentinTh/it-tools', + }, + { + id: 'excalidraw', + name: 'Excalidraw', + category: 'utilities', + description: 'Self-hosted collaborative whiteboard', + image: 'excalidraw/excalidraw:latest', + ports: [{ container: 80, host: 8094, protocol: 'tcp' }], + volumes: [], + env: {}, + healthcheck: 'curl -f http://localhost:80 || exit 1', + api: null, + links: {}, + url: 'https://github.com/excalidraw/excalidraw', + }, + // --- MEDIA EXTRAS --- + { id: 'stash', name: 'Stash', category: 'media', description: 'Media organiser with metadata scraping & tagging', image: 'stashapp/stash:latest', ports: [{ container: 9999, host: 9999, protocol: 'tcp' }], volumes: [{ host: '${CONFIG_PATH}/stash', container: '/root/.stash' }, { host: '${MEDIA_PATH}', container: '/data' }], env: {}, healthcheck: 'curl -f http://localhost:9999 || exit 1', api: { port: 9999 }, links: { consumesMedia: true }, url: 'https://github.com/stashapp/stash' }, + { id: 'komga', name: 'Komga', category: 'media', description: 'Comics & manga server with OPDS support', image: 'gotson/komga:latest', ports: [{ container: 25600, host: 25600, protocol: 'tcp' }], volumes: [{ host: '${CONFIG_PATH}/komga', container: '/config' }, { host: '${MEDIA_PATH}/comics', container: '/data' }], env: {}, healthcheck: 'curl -f http://localhost:25600 || exit 1', api: { port: 25600 }, links: { consumesMedia: true }, url: 'https://github.com/gotson/komga' }, + { id: 'photoprism', name: 'PhotoPrism', category: 'documents', description: 'AI-powered photo management & search', image: 'photoprism/photoprism:latest', ports: [{ container: 2342, host: 2342, protocol: 'tcp' }], volumes: [{ host: '${CONFIG_PATH}/photoprism', container: '/photoprism/storage' }, { host: '${MEDIA_PATH}/photos', container: '/photoprism/originals' }], env: { PHOTOPRISM_ADMIN_PASSWORD: 'changeme' }, healthcheck: 'curl -f http://localhost:2342/api/v1/status || exit 1', api: { port: 2342 }, links: {}, url: 'https://github.com/photoprism/photoprism' }, + { id: 'tube-archivist', name: 'Tube Archivist', category: 'media', description: 'YouTube archiver & personal media server', image: 'bbilly1/tubearchivist:latest', ports: [{ container: 8000, host: 8095, protocol: 'tcp' }], volumes: [{ host: '${CONFIG_PATH}/tubearchivist', container: '/cache' }, { host: '${MEDIA_PATH}/youtube', container: '/youtube' }], env: { TA_USERNAME: 'admin', TA_PASSWORD: 'changeme', ES_URL: 'http://tubearchivist-es:9200', REDIS_HOST: 'tubearchivist-redis' }, healthcheck: 'curl -f http://localhost:8000/health || exit 1', api: { port: 8095 }, links: {}, url: 'https://github.com/tubearchivist/tubearchivist' }, + { id: 'invidious', name: 'Invidious', category: 'media', description: 'Private YouTube frontend โ€” no tracking', image: 'quay.io/invidious/invidious:latest', ports: [{ container: 3000, host: 3004, protocol: 'tcp' }], volumes: [], env: { INVIDIOUS_CONFIG: 'db:\\n dbname: invidious\\n user: kemal\\n password: kemal' }, healthcheck: 'curl -f http://localhost:3000 || exit 1', api: null, links: {}, url: 'https://github.com/iv-org/invidious' }, + { id: 'dim', name: 'Dim', category: 'media', description: 'Minimal movie & TV media manager', image: 'ghcr.io/dim-database/dim:latest', ports: [{ container: 8000, host: 8096, protocol: 'tcp' }], volumes: [{ host: '${CONFIG_PATH}/dim', container: '/opt/dim/config' }, { host: '${MEDIA_PATH}', container: '/media' }], env: {}, healthcheck: 'curl -f http://localhost:8000 || exit 1', api: null, links: { consumesMedia: true }, url: 'https://github.com/Dusk-Labs/dim' }, + // --- ARR EXTRAS --- + { id: 'mylar3', name: 'Mylar3', category: 'arr', description: 'Comic book automation (Arr-style)', image: 'lscr.io/linuxserver/mylar3:latest', ports: [{ container: 8090, host: 8100, protocol: 'tcp' }], volumes: [{ host: '${CONFIG_PATH}/mylar3', container: '/config' }, { host: '${MEDIA_PATH}/comics', container: '/comics' }, { host: '${DOWNLOADS_PATH}', container: '/downloads' }], env: {}, healthcheck: 'curl -f http://localhost:8090 || exit 1', api: { port: 8100 }, links: { needsDownloadClient: true, needsIndexer: true, providesMedia: 'comics' }, url: 'https://github.com/mylar3/mylar3' }, + { id: 'whisparr', name: 'Whisparr', category: 'arr', description: 'Adult content automation (Arr-style)', image: 'hotio/whisparr:latest', ports: [{ container: 6969, host: 6969, protocol: 'tcp' }], volumes: [{ host: '${CONFIG_PATH}/whisparr', container: '/config' }, { host: '${MEDIA_PATH}/adult', container: '/adult' }, { host: '${DOWNLOADS_PATH}', container: '/downloads' }], env: {}, healthcheck: 'curl -f http://localhost:6969/ping || exit 1', api: { port: 6969, keyLocation: 'config.xml' }, links: { needsDownloadClient: true, needsIndexer: true, providesMedia: 'adult' }, url: 'https://github.com/Whisparr/Whisparr' }, + { id: 'recyclarr', name: 'Recyclarr', category: 'arr', description: 'Auto-sync TRaSH Guides quality profiles to Sonarr/Radarr', image: 'ghcr.io/recyclarr/recyclarr:latest', ports: [], volumes: [{ host: '${CONFIG_PATH}/recyclarr', container: '/config' }], env: {}, healthcheck: null, api: null, links: {}, url: 'https://github.com/recyclarr/recyclarr' }, + // --- DOWNLOADS EXTRAS --- + { id: 'deluge', name: 'Deluge', category: 'downloads', description: 'Feature-rich torrent client', image: 'lscr.io/linuxserver/deluge:latest', ports: [{ container: 8112, host: 8112, protocol: 'tcp' }, { container: 6881, host: 6882, protocol: 'tcp' }], volumes: [{ host: '${CONFIG_PATH}/deluge', container: '/config' }, { host: '${DOWNLOADS_PATH}', container: '/downloads' }], env: {}, healthcheck: 'curl -f http://localhost:8112 || exit 1', api: { port: 8112, type: 'deluge' }, links: { providesDownloadClient: true }, url: 'https://github.com/deluge-torrent/deluge' }, + { id: 'jdownloader2', name: 'JDownloader 2', category: 'downloads', description: 'General-purpose download manager', image: 'jlesage/jdownloader-2:latest', ports: [{ container: 5800, host: 5800, protocol: 'tcp' }], volumes: [{ host: '${CONFIG_PATH}/jdownloader', container: '/config' }, { host: '${DOWNLOADS_PATH}', container: '/output' }], env: {}, healthcheck: 'curl -f http://localhost:5800 || exit 1', api: null, links: {}, url: 'https://github.com/jlesage/docker-jdownloader-2' }, + { id: 'pyload', name: 'pyLoad', category: 'downloads', description: 'Lightweight download manager', image: 'lscr.io/linuxserver/pyload-ng:latest', ports: [{ container: 8000, host: 8101, protocol: 'tcp' }], volumes: [{ host: '${CONFIG_PATH}/pyload', container: '/config' }, { host: '${DOWNLOADS_PATH}', container: '/downloads' }], env: {}, healthcheck: 'curl -f http://localhost:8000 || exit 1', api: null, links: {}, url: 'https://github.com/pyload/pyload' }, + // --- DATABASES --- + { id: 'mariadb', name: 'MariaDB', category: 'databases', description: 'MySQL-compatible database (for BookStack, etc.)', image: 'mariadb:latest', ports: [{ container: 3306, host: 3306, protocol: 'tcp' }], volumes: [{ host: '${CONFIG_PATH}/mariadb', container: '/var/lib/mysql' }], env: { MYSQL_ROOT_PASSWORD: 'changeme', MYSQL_DATABASE: 'stackpilot', MYSQL_USER: 'stackpilot', MYSQL_PASSWORD: 'changeme' }, healthcheck: 'mysqladmin ping -h localhost || exit 1', api: null, links: {}, url: 'https://github.com/MariaDB/server' }, + { id: 'postgres', name: 'PostgreSQL', category: 'databases', description: 'Advanced relational database (for Immich, Authelia, etc.)', image: 'postgres:16-alpine', ports: [{ container: 5432, host: 5432, protocol: 'tcp' }], volumes: [{ host: '${CONFIG_PATH}/postgres', container: '/var/lib/postgresql/data' }], env: { POSTGRES_PASSWORD: 'changeme', POSTGRES_USER: 'stackpilot', POSTGRES_DB: 'stackpilot' }, healthcheck: 'pg_isready -U stackpilot || exit 1', api: null, links: {}, url: 'https://github.com/postgres/postgres' }, + { id: 'redis', name: 'Redis', category: 'databases', description: 'In-memory cache & message broker', image: 'redis:alpine', ports: [{ container: 6379, host: 6379, protocol: 'tcp' }], volumes: [{ host: '${CONFIG_PATH}/redis', container: '/data' }], env: {}, healthcheck: 'redis-cli ping || exit 1', api: null, links: {}, url: 'https://github.com/redis/redis' }, + { id: 'mongodb', name: 'MongoDB', category: 'databases', description: 'Document database (for Rocket.Chat, etc.)', image: 'mongo:7', ports: [{ container: 27017, host: 27017, protocol: 'tcp' }], volumes: [{ host: '${CONFIG_PATH}/mongodb', container: '/data/db' }], env: {}, healthcheck: 'mongosh --eval "db.adminCommand({ping:1})" || exit 1', api: null, links: {}, url: 'https://github.com/mongodb/mongo' }, + { id: 'influxdb', name: 'InfluxDB', category: 'databases', description: 'Time-series database for metrics & IoT', image: 'influxdb:2', ports: [{ container: 8086, host: 8186, protocol: 'tcp' }], volumes: [{ host: '${CONFIG_PATH}/influxdb', container: '/var/lib/influxdb2' }], env: {}, healthcheck: 'curl -f http://localhost:8086/health || exit 1', api: { port: 8186 }, links: {}, url: 'https://github.com/influxdata/influxdb' }, + // --- DASHBOARDS --- + { id: 'homarr', name: 'Homarr', category: 'dashboards', description: 'Homelab dashboard with Docker integrations', image: 'ghcr.io/ajnart/homarr:latest', ports: [{ container: 7575, host: 7575, protocol: 'tcp' }], volumes: [{ host: '${CONFIG_PATH}/homarr/configs', container: '/app/data/configs' }, { host: '${CONFIG_PATH}/homarr/icons', container: '/app/public/icons' }, { host: '/var/run/docker.sock', container: '/var/run/docker.sock', readonly: true }], env: {}, healthcheck: 'curl -f http://localhost:7575 || exit 1', api: null, links: {}, url: 'https://github.com/ajnart/homarr' }, + { id: 'dashy', name: 'Dashy', category: 'dashboards', description: 'Highly customizable start page & dashboard', image: 'lissy93/dashy:latest', ports: [{ container: 8080, host: 8102, protocol: 'tcp' }], volumes: [{ host: '${CONFIG_PATH}/dashy', container: '/app/user-data' }], env: {}, healthcheck: 'curl -f http://localhost:8080 || exit 1', api: null, links: {}, url: 'https://github.com/Lissy93/dashy' }, + { id: 'flame', name: 'Flame', category: 'dashboards', description: 'Minimal start page with bookmarks & weather', image: 'pawelmalak/flame:latest', ports: [{ container: 5005, host: 5005, protocol: 'tcp' }], volumes: [{ host: '${CONFIG_PATH}/flame', container: '/app/data' }], env: {}, healthcheck: 'curl -f http://localhost:5005 || exit 1', api: null, links: {}, url: 'https://github.com/pawelmalak/flame' }, + // --- AI & ML --- + { id: 'ollama', name: 'Ollama', category: 'ai', description: 'Run local LLMs (Llama, Mistral, Gemma, etc.)', image: 'ollama/ollama:latest', ports: [{ container: 11434, host: 11434, protocol: 'tcp' }], volumes: [{ host: '${CONFIG_PATH}/ollama', container: '/root/.ollama' }], env: {}, healthcheck: 'curl -f http://localhost:11434 || exit 1', api: { port: 11434 }, links: {}, url: 'https://github.com/ollama/ollama' }, + { id: 'open-webui', name: 'Open WebUI', category: 'ai', description: 'ChatGPT-style interface for Ollama & OpenAI', image: 'ghcr.io/open-webui/open-webui:main', ports: [{ container: 8080, host: 8103, protocol: 'tcp' }], volumes: [{ host: '${CONFIG_PATH}/open-webui', container: '/app/backend/data' }], env: { OLLAMA_BASE_URL: 'http://ollama:11434' }, healthcheck: 'curl -f http://localhost:8080 || exit 1', api: null, links: {}, url: 'https://github.com/open-webui/open-webui' }, + { id: 'stable-diffusion', name: 'Stable Diffusion WebUI', category: 'ai', description: 'AI image generation (AUTOMATIC1111)', image: 'ghcr.io/absolutereality/sd-auto:latest', ports: [{ container: 7860, host: 7860, protocol: 'tcp' }], volumes: [{ host: '${CONFIG_PATH}/stable-diffusion', container: '/data' }, { host: '${CONFIG_PATH}/stable-diffusion/output', container: '/output' }], env: {}, healthcheck: 'curl -f http://localhost:7860 || exit 1', api: { port: 7860 }, links: {}, url: 'https://github.com/AUTOMATIC1111/stable-diffusion-webui' }, + { id: 'comfyui', name: 'ComfyUI', category: 'ai', description: 'Node-based AI image generation workflow', image: 'yanwk/comfyui-boot:latest', ports: [{ container: 8188, host: 8188, protocol: 'tcp' }], volumes: [{ host: '${CONFIG_PATH}/comfyui', container: '/home/runner' }, { host: '${CONFIG_PATH}/comfyui/models', container: '/home/runner/ComfyUI/models' }], env: {}, healthcheck: 'curl -f http://localhost:8188 || exit 1', api: { port: 8188 }, links: {}, url: 'https://github.com/comfyanonymous/ComfyUI' }, + { id: 'whisper', name: 'Whisper', category: 'ai', description: 'Speech-to-text transcription (OpenAI Whisper)', image: 'onerahmet/openai-whisper-asr-webservice:latest', ports: [{ container: 9000, host: 9003, protocol: 'tcp' }], volumes: [], env: { ASR_MODEL: 'base' }, healthcheck: 'curl -f http://localhost:9000/health || exit 1', api: { port: 9003 }, links: {}, url: 'https://github.com/openai/whisper' }, + { id: 'localai', name: 'LocalAI', category: 'ai', description: 'OpenAI-compatible local inference API', image: 'localai/localai:latest-aio-cpu', ports: [{ container: 8080, host: 8104, protocol: 'tcp' }], volumes: [{ host: '${CONFIG_PATH}/localai/models', container: '/build/models' }], env: {}, healthcheck: 'curl -f http://localhost:8080/readyz || exit 1', api: { port: 8104 }, links: {}, url: 'https://github.com/mudler/LocalAI' }, + // --- FINANCE --- + { id: 'firefly-iii', name: 'Firefly III', category: 'finance', description: 'Personal finance manager with budgeting', image: 'fireflyiii/core:latest', ports: [{ container: 8080, host: 8105, protocol: 'tcp' }], volumes: [{ host: '${CONFIG_PATH}/firefly/upload', container: '/var/www/html/storage/upload' }], env: { APP_KEY: 'CHANGEME_32_CHARS_EXACTLY_NEEDED', DB_CONNECTION: 'sqlite' }, healthcheck: 'curl -f http://localhost:8080/health || exit 1', api: { port: 8105 }, links: {}, url: 'https://github.com/firefly-iii/firefly-iii' }, + { id: 'maybe', name: 'Maybe', category: 'finance', description: 'Modern open-source financial tracker', image: 'ghcr.io/maybe-finance/maybe:latest', ports: [{ container: 3000, host: 3005, protocol: 'tcp' }], volumes: [{ host: '${CONFIG_PATH}/maybe', container: '/rails/storage' }], env: { SECRET_KEY_BASE: 'changeme' }, healthcheck: 'curl -f http://localhost:3000/health || exit 1', api: null, links: {}, url: 'https://github.com/maybe-finance/maybe' }, + // --- KNOWLEDGE & NOTES --- + { id: 'outline', name: 'Outline', category: 'knowledge', description: 'Team wiki & knowledge base (Notion-like)', image: 'outlinewiki/outline:latest', ports: [{ container: 3000, host: 3006, protocol: 'tcp' }], volumes: [{ host: '${CONFIG_PATH}/outline', container: '/var/lib/outline/data' }], env: { SECRET_KEY: 'changeme', UTILS_SECRET: 'changeme', DATABASE_URL: 'postgres://outline:changeme@postgres:5432/outline', REDIS_URL: 'redis://redis:6379' }, healthcheck: 'curl -f http://localhost:3000/_health || exit 1', api: null, links: {}, url: 'https://github.com/outline/outline' }, + { id: 'trilium', name: 'Trilium', category: 'knowledge', description: 'Hierarchical note-taking with relations', image: 'zadam/trilium:latest', ports: [{ container: 8080, host: 8106, protocol: 'tcp' }], volumes: [{ host: '${CONFIG_PATH}/trilium', container: '/home/node/trilium-data' }], env: {}, healthcheck: 'curl -f http://localhost:8080 || exit 1', api: { port: 8106 }, links: {}, url: 'https://github.com/zadam/trilium' }, + { id: 'wikijs', name: 'Wiki.js', category: 'knowledge', description: 'Modern wiki with Git-backed storage', image: 'ghcr.io/requarks/wiki:2', ports: [{ container: 3000, host: 3007, protocol: 'tcp' }], volumes: [{ host: '${CONFIG_PATH}/wikijs', container: '/wiki/data' }], env: { DB_TYPE: 'sqlite', DB_FILEPATH: '/wiki/data/wiki.sqlite' }, healthcheck: 'curl -f http://localhost:3000/healthz || exit 1', api: null, links: {}, url: 'https://github.com/requarks/wiki' }, + { id: 'memos', name: 'Memos', category: 'knowledge', description: 'Lightweight self-hosted memo hub (Twitter-style notes)', image: 'neosmemo/memos:stable', ports: [{ container: 5230, host: 5230, protocol: 'tcp' }], volumes: [{ host: '${CONFIG_PATH}/memos', container: '/var/opt/memos' }], env: {}, healthcheck: 'curl -f http://localhost:5230/healthz || exit 1', api: null, links: {}, url: 'https://github.com/usememos/memos' }, + // --- PRIVACY & SEARCH --- + { id: 'searxng', name: 'SearXNG', category: 'privacy', description: 'Privacy-respecting metasearch engine', image: 'searxng/searxng:latest', ports: [{ container: 8080, host: 8107, protocol: 'tcp' }], volumes: [{ host: '${CONFIG_PATH}/searxng', container: '/etc/searxng' }], env: {}, healthcheck: 'curl -f http://localhost:8080 || exit 1', api: null, links: {}, url: 'https://github.com/searxng/searxng' }, + { id: 'libretranslate', name: 'LibreTranslate', category: 'privacy', description: 'Self-hosted translation API', image: 'libretranslate/libretranslate:latest', ports: [{ container: 5000, host: 5002, protocol: 'tcp' }], volumes: [{ host: '${CONFIG_PATH}/libretranslate', container: '/home/libretranslate/.local' }], env: {}, healthcheck: 'curl -f http://localhost:5000/languages || exit 1', api: { port: 5002 }, links: {}, url: 'https://github.com/LibreTranslate/LibreTranslate' }, + { id: 'languagetool', name: 'LanguageTool', category: 'privacy', description: 'Grammar & spell checker API', image: 'erikvl87/languagetool:latest', ports: [{ container: 8010, host: 8010, protocol: 'tcp' }], volumes: [{ host: '${CONFIG_PATH}/languagetool', container: '/LanguageTool/data' }], env: {}, healthcheck: 'curl -f http://localhost:8010/v2/languages || exit 1', api: { port: 8010 }, links: {}, url: 'https://github.com/languagetool-org/languagetool' }, + // --- COMMUNICATION EXTRAS --- + { id: 'gotify', name: 'Gotify', category: 'communication', description: 'Self-hosted push notification server', image: 'gotify/server:latest', ports: [{ container: 80, host: 8108, protocol: 'tcp' }], volumes: [{ host: '${CONFIG_PATH}/gotify', container: '/app/data' }], env: {}, healthcheck: 'curl -f http://localhost:80/health || exit 1', api: { port: 8108 }, links: {}, url: 'https://github.com/gotify/server' }, + { id: 'rocketchat', name: 'Rocket.Chat', category: 'communication', description: 'Team chat platform (Slack alternative)', image: 'rocket.chat:latest', ports: [{ container: 3000, host: 3008, protocol: 'tcp' }], volumes: [{ host: '${CONFIG_PATH}/rocketchat/uploads', container: '/app/uploads' }], env: { MONGO_URL: 'mongodb://mongodb:27017/rocketchat', ROOT_URL: 'http://localhost:3008' }, healthcheck: 'curl -f http://localhost:3000/health || exit 1', api: { port: 3008 }, links: {}, url: 'https://github.com/RocketChat/Rocket.Chat' }, + { id: 'mattermost', name: 'Mattermost', category: 'communication', description: 'Open-source team collaboration', image: 'mattermost/mattermost-team-edition:latest', ports: [{ container: 8065, host: 8065, protocol: 'tcp' }], volumes: [{ host: '${CONFIG_PATH}/mattermost/data', container: '/mattermost/data' }, { host: '${CONFIG_PATH}/mattermost/config', container: '/mattermost/config' }], env: {}, healthcheck: 'curl -f http://localhost:8065/api/v4/system/ping || exit 1', api: { port: 8065 }, links: {}, url: 'https://github.com/mattermost/mattermost' }, + // --- NETWORKING EXTRAS --- + { id: 'caddy', name: 'Caddy', category: 'networking', description: 'Automatic HTTPS reverse proxy (simple config)', image: 'caddy:latest', ports: [{ container: 80, host: 80, protocol: 'tcp' }, { container: 443, host: 443, protocol: 'tcp' }], volumes: [{ host: '${CONFIG_PATH}/caddy/data', container: '/data' }, { host: '${CONFIG_PATH}/caddy/config', container: '/config' }, { host: '${CONFIG_PATH}/caddy/Caddyfile', container: '/etc/caddy/Caddyfile' }], env: {}, healthcheck: 'curl -f http://localhost:80 || exit 1', api: null, links: {}, url: 'https://github.com/caddyserver/caddy' }, + { id: 'ddns-updater', name: 'DDNS Updater', category: 'networking', description: 'Dynamic DNS updater for 30+ providers', image: 'qmcgaw/ddns-updater:latest', ports: [{ container: 8000, host: 8109, protocol: 'tcp' }], volumes: [{ host: '${CONFIG_PATH}/ddns-updater', container: '/updater/data' }], env: {}, healthcheck: 'curl -f http://localhost:8000 || exit 1', api: null, links: {}, url: 'https://github.com/qdm12/ddns-updater' }, + // --- GAMES EXTRAS --- + { id: 'crafty', name: 'Crafty', category: 'games', description: 'Minecraft server manager with web UI', image: 'registry.gitlab.com/crafty-controller/crafty-4:latest', ports: [{ container: 8443, host: 8444, protocol: 'tcp' }, { container: 25565, host: 25565, protocol: 'tcp' }], volumes: [{ host: '${CONFIG_PATH}/crafty', container: '/crafty/crafty-4' }, { host: '${CONFIG_PATH}/crafty/servers', container: '/crafty/servers' }], env: {}, healthcheck: null, api: null, links: {}, url: 'https://gitlab.com/crafty-controller/crafty-4' }, + { id: 'pterodactyl', name: 'Pterodactyl', category: 'games', description: 'Game server management panel (MC, ARK, CS, etc.)', image: 'ghcr.io/pterodactyl/panel:latest', ports: [{ container: 80, host: 8110, protocol: 'tcp' }], volumes: [{ host: '${CONFIG_PATH}/pterodactyl', container: '/app/var' }], env: { APP_URL: 'http://localhost:8110', DB_CONNECTION: 'sqlite' }, healthcheck: 'curl -f http://localhost:80 || exit 1', api: { port: 8110 }, links: {}, url: 'https://github.com/pterodactyl/panel' }, + { id: 'retroarch-web', name: 'RetroArch Web', category: 'games', description: 'Retro gaming in the browser', image: 'inglebard/retroarch-web:latest', ports: [{ container: 80, host: 8111, protocol: 'tcp' }], volumes: [{ host: '${MEDIA_PATH}/roms', container: '/var/www/html/assets/cores' }], env: {}, healthcheck: 'curl -f http://localhost:80 || exit 1', api: null, links: {}, url: 'https://github.com/nicholasgasior/docker-retroarch-web' }, + // --- MORE UTILITIES --- + { id: 'linkwarden', name: 'Linkwarden', category: 'utilities', description: 'Collaborative bookmark manager with archiving', image: 'ghcr.io/linkwarden/linkwarden:latest', ports: [{ container: 3000, host: 3009, protocol: 'tcp' }], volumes: [{ host: '${CONFIG_PATH}/linkwarden', container: '/data' }], env: { DATABASE_URL: 'postgresql://postgres:changeme@postgres:5432/linkwarden', NEXTAUTH_SECRET: 'changeme', NEXTAUTH_URL: 'http://localhost:3009' }, healthcheck: 'curl -f http://localhost:3000 || exit 1', api: null, links: {}, url: 'https://github.com/linkwarden/linkwarden' }, + { id: 'miniflux', name: 'Miniflux', category: 'utilities', description: 'Minimalist RSS reader (fast & opinionated)', image: 'miniflux/miniflux:latest', ports: [{ container: 8080, host: 8112, protocol: 'tcp' }], volumes: [], env: { DATABASE_URL: 'postgres://miniflux:changeme@postgres:5432/miniflux?sslmode=disable', CREATE_ADMIN: '1', ADMIN_USERNAME: 'admin', ADMIN_PASSWORD: 'changeme' }, healthcheck: 'curl -f http://localhost:8080/healthcheck || exit 1', api: { port: 8112 }, links: {}, url: 'https://github.com/miniflux/v2' }, + { id: 'planka', name: 'Planka', category: 'utilities', description: 'Kanban board (Trello alternative)', image: 'ghcr.io/plankanban/planka:latest', ports: [{ container: 1337, host: 1337, protocol: 'tcp' }], volumes: [{ host: '${CONFIG_PATH}/planka', container: '/app/public/user-avatars' }], env: { SECRET_KEY: 'changeme', DATABASE_URL: 'postgresql://postgres:changeme@postgres:5432/planka', DEFAULT_ADMIN_EMAIL: 'admin@example.com', DEFAULT_ADMIN_PASSWORD: 'changeme' }, healthcheck: 'curl -f http://localhost:1337 || exit 1', api: null, links: {}, url: 'https://github.com/plankanban/planka' }, + { id: 'docmost', name: 'Docmost', category: 'knowledge', description: 'Collaborative documentation (Confluence alternative)', image: 'docmost/docmost:latest', ports: [{ container: 3000, host: 3010, protocol: 'tcp' }], volumes: [{ host: '${CONFIG_PATH}/docmost', container: '/app/data/storage' }], env: { APP_SECRET: 'changeme', DATABASE_URL: 'postgresql://postgres:changeme@postgres:5432/docmost' }, healthcheck: 'curl -f http://localhost:3000 || exit 1', api: null, links: {}, url: 'https://github.com/docmost/docmost' }, + { id: 'netdata', name: 'Netdata', category: 'monitoring', description: 'Real-time server performance monitoring', image: 'netdata/netdata:latest', ports: [{ container: 19999, host: 19999, protocol: 'tcp' }], volumes: [{ host: '/proc', container: '/host/proc', readonly: true }, { host: '/sys', container: '/host/sys', readonly: true }, { host: '/var/run/docker.sock', container: '/var/run/docker.sock', readonly: true }], env: {}, healthcheck: 'curl -f http://localhost:19999/api/v1/info || exit 1', capAdd: ['SYS_PTRACE'], api: { port: 19999 }, links: {}, url: 'https://github.com/netdata/netdata' }, + { id: 'grafana', name: 'Grafana', category: 'monitoring', description: 'Dashboards & visualisation for metrics', image: 'grafana/grafana:latest', ports: [{ container: 3000, host: 3011, protocol: 'tcp' }], volumes: [{ host: '${CONFIG_PATH}/grafana', container: '/var/lib/grafana' }], env: {}, healthcheck: 'curl -f http://localhost:3000/api/health || exit 1', api: { port: 3011 }, links: {}, url: 'https://github.com/grafana/grafana' }, + { id: 'prometheus', name: 'Prometheus', category: 'monitoring', description: 'Metrics collection & alerting', image: 'prom/prometheus:latest', ports: [{ container: 9090, host: 9090, protocol: 'tcp' }], volumes: [{ host: '${CONFIG_PATH}/prometheus', container: '/prometheus' }], env: {}, healthcheck: 'curl -f http://localhost:9090/-/healthy || exit 1', api: { port: 9090 }, links: {}, url: 'https://github.com/prometheus/prometheus' }, + { id: 'nextcloud', name: 'Nextcloud', category: 'utilities', description: 'Self-hosted cloud storage & collaboration', image: 'nextcloud:latest', ports: [{ container: 80, host: 8113, protocol: 'tcp' }], volumes: [{ host: '${CONFIG_PATH}/nextcloud', container: '/var/www/html' }], env: { SQLITE_DATABASE: 'nextcloud' }, healthcheck: 'curl -f http://localhost:80/status.php || exit 1', api: { port: 8113 }, links: {}, url: 'https://github.com/nextcloud/server' }, + { id: 'gitea-actions', name: 'Gitea Actions Runner', category: 'development', description: 'CI/CD runner for Gitea (GitHub Actions compatible)', image: 'gitea/act_runner:latest', ports: [], volumes: [{ host: '/var/run/docker.sock', container: '/var/run/docker.sock' }], env: { GITEA_INSTANCE_URL: 'http://gitea:3000', GITEA_RUNNER_REGISTRATION_TOKEN: 'CHANGE_ME' }, healthcheck: null, api: null, links: {}, url: 'https://github.com/go-gitea/gitea' }, + { id: 'drone', name: 'Drone CI', category: 'development', description: 'Container-native CI/CD platform', image: 'drone/drone:latest', ports: [{ container: 80, host: 8114, protocol: 'tcp' }], volumes: [{ host: '${CONFIG_PATH}/drone', container: '/data' }], env: { DRONE_GITEA_SERVER: 'http://gitea:3000', DRONE_SERVER_HOST: 'localhost:8114', DRONE_SERVER_PROTO: 'http' }, healthcheck: 'curl -f http://localhost:80/healthz || exit 1', api: { port: 8114 }, links: {}, url: 'https://github.com/harness/drone' }, + { id: 'semaphore', name: 'Semaphore', category: 'development', description: 'Ansible UI โ€” run playbooks from a web interface', image: 'semaphoreui/semaphore:latest', ports: [{ container: 3000, host: 3012, protocol: 'tcp' }], volumes: [{ host: '${CONFIG_PATH}/semaphore', container: '/home/semaphore' }], env: { SEMAPHORE_DB_DIALECT: 'bolt', SEMAPHORE_ADMIN: 'admin', SEMAPHORE_ADMIN_PASSWORD: 'changeme', SEMAPHORE_ADMIN_EMAIL: 'admin@localhost' }, healthcheck: 'curl -f http://localhost:3000/api/ping || exit 1', api: { port: 3012 }, links: {}, url: 'https://github.com/semaphoreui/semaphore' }, + // --- MORE APPS --- + { id: 'tandoor', name: 'Tandoor Recipes', category: 'utilities', description: 'Recipe manager with meal planning & shopping lists', image: 'vabene1111/recipes:latest', ports: [{ container: 8080, host: 8115, protocol: 'tcp' }], volumes: [{ host: '${CONFIG_PATH}/tandoor/staticfiles', container: '/opt/recipes/staticfiles' }, { host: '${CONFIG_PATH}/tandoor/mediafiles', container: '/opt/recipes/mediafiles' }], env: { SECRET_KEY: 'changeme', DB_ENGINE: 'django.db.backends.sqlite3' }, healthcheck: 'curl -f http://localhost:8080 || exit 1', api: null, links: {}, url: 'https://github.com/TandoorRecipes/recipes' }, + { id: 'calibre-web', name: 'Calibre-Web', category: 'media', description: 'Ebook library with web reader', image: 'lscr.io/linuxserver/calibre-web:latest', ports: [{ container: 8083, host: 8116, protocol: 'tcp' }], volumes: [{ host: '${CONFIG_PATH}/calibre-web', container: '/config' }, { host: '${MEDIA_PATH}/books', container: '/books' }], env: {}, healthcheck: 'curl -f http://localhost:8083 || exit 1', api: null, links: { consumesMedia: true }, url: 'https://github.com/janeczku/calibre-web' }, + { id: 'frigate', name: 'Frigate', category: 'automation', description: 'NVR with AI object detection for security cameras', image: 'ghcr.io/blakeblackshear/frigate:stable', ports: [{ container: 5000, host: 5003, protocol: 'tcp' }, { container: 8554, host: 8554, protocol: 'tcp' }], volumes: [{ host: '${CONFIG_PATH}/frigate', container: '/config' }, { host: '${MEDIA_PATH}/frigate', container: '/media/frigate' }], env: {}, healthcheck: 'curl -f http://localhost:5000 || exit 1', api: { port: 5003 }, links: {}, url: 'https://github.com/blakeblackshear/frigate' }, + { id: 'zigbee2mqtt', name: 'Zigbee2MQTT', category: 'automation', description: 'Bridge Zigbee devices to MQTT', image: 'koenkk/zigbee2mqtt:latest', ports: [{ container: 8080, host: 8117, protocol: 'tcp' }], volumes: [{ host: '${CONFIG_PATH}/zigbee2mqtt', container: '/app/data' }], env: {}, healthcheck: 'curl -f http://localhost:8080 || exit 1', api: null, links: {}, url: 'https://github.com/Koenkk/zigbee2mqtt' }, + { id: 'mosquitto', name: 'Mosquitto', category: 'automation', description: 'Lightweight MQTT broker for IoT', image: 'eclipse-mosquitto:latest', ports: [{ container: 1883, host: 1883, protocol: 'tcp' }, { container: 9001, host: 9004, protocol: 'tcp' }], volumes: [{ host: '${CONFIG_PATH}/mosquitto', container: '/mosquitto/config' }, { host: '${CONFIG_PATH}/mosquitto/data', container: '/mosquitto/data' }], env: {}, healthcheck: null, api: null, links: {}, url: 'https://github.com/eclipse/mosquitto' }, + { id: 'invoice-ninja', name: 'Invoice Ninja', category: 'finance', description: 'Self-hosted invoicing & payments', image: 'invoiceninja/invoiceninja:latest', ports: [{ container: 9000, host: 9005, protocol: 'tcp' }], volumes: [{ host: '${CONFIG_PATH}/invoiceninja/public', container: '/var/www/app/public' }, { host: '${CONFIG_PATH}/invoiceninja/storage', container: '/var/www/app/storage' }], env: { APP_KEY: 'changeme', DB_HOST: 'mariadb', DB_DATABASE: 'invoiceninja', DB_USERNAME: 'ninja', DB_PASSWORD: 'changeme' }, healthcheck: null, api: null, links: {}, url: 'https://github.com/invoiceninja/invoiceninja' }, + { id: 'forgejo', name: 'Forgejo', category: 'development', description: 'Community-owned Git forge (Gitea fork)', image: 'codeberg.org/forgejo/forgejo:latest', ports: [{ container: 3000, host: 3013, protocol: 'tcp' }, { container: 22, host: 2223, protocol: 'tcp' }], volumes: [{ host: '${CONFIG_PATH}/forgejo', container: '/data' }], env: {}, healthcheck: 'curl -f http://localhost:3000 || exit 1', api: { port: 3013 }, links: {}, url: 'https://codeberg.org/forgejo/forgejo' }, + { id: 'vikunja', name: 'Vikunja', category: 'utilities', description: 'Task management & to-do lists (Todoist alternative)', image: 'vikunja/vikunja:latest', ports: [{ container: 3456, host: 3456, protocol: 'tcp' }], volumes: [{ host: '${CONFIG_PATH}/vikunja', container: '/app/vikunja/files' }], env: { VIKUNJA_SERVICE_JWTSECRET: 'changeme' }, healthcheck: 'curl -f http://localhost:3456/health || exit 1', api: { port: 3456 }, links: {}, url: 'https://kolaente.dev/vikunja/vikunja' }, + { id: 'crowdsec', name: 'CrowdSec', category: 'security', description: 'Collaborative intrusion detection & prevention', image: 'crowdsecurity/crowdsec:latest', ports: [{ container: 8080, host: 8118, protocol: 'tcp' }], volumes: [{ host: '${CONFIG_PATH}/crowdsec', container: '/etc/crowdsec' }, { host: '${CONFIG_PATH}/crowdsec/data', container: '/var/lib/crowdsec/data' }], env: {}, healthcheck: 'curl -f http://localhost:8080/health || exit 1', api: { port: 8118 }, links: {}, url: 'https://github.com/crowdsecurity/crowdsec' }, + { id: 'loki', name: 'Loki', category: 'monitoring', description: 'Log aggregation for Grafana', image: 'grafana/loki:latest', ports: [{ container: 3100, host: 3100, protocol: 'tcp' }], volumes: [{ host: '${CONFIG_PATH}/loki', container: '/loki' }], env: {}, healthcheck: 'curl -f http://localhost:3100/ready || exit 1', api: { port: 3100 }, links: {}, url: 'https://github.com/grafana/loki' }, + { id: 'yacht', name: 'Yacht', category: 'monitoring', description: 'Simple Docker management UI', image: 'selfhostedpro/yacht:latest', ports: [{ container: 8000, host: 8119, protocol: 'tcp' }], volumes: [{ host: '${CONFIG_PATH}/yacht', container: '/config' }, { host: '/var/run/docker.sock', container: '/var/run/docker.sock' }], env: {}, healthcheck: 'curl -f http://localhost:8000 || exit 1', api: null, links: {}, url: 'https://github.com/SelfhostedPro/Yacht' }, + { id: 'healthchecks', name: 'Healthchecks', category: 'monitoring', description: 'Cron job & scheduled task monitoring', image: 'lscr.io/linuxserver/healthchecks:latest', ports: [{ container: 8000, host: 8120, protocol: 'tcp' }], volumes: [{ host: '${CONFIG_PATH}/healthchecks', container: '/config' }], env: { SITE_ROOT: 'http://localhost:8120', SECRET_KEY: 'changeme' }, healthcheck: 'curl -f http://localhost:8000 || exit 1', api: { port: 8120 }, links: {}, url: 'https://github.com/healthchecks/healthchecks' }, + { id: 'woodpecker', name: 'Woodpecker CI', category: 'development', description: 'Lightweight container-native CI/CD', image: 'woodpeckerci/woodpecker-server:latest', ports: [{ container: 8000, host: 8121, protocol: 'tcp' }], volumes: [{ host: '${CONFIG_PATH}/woodpecker', container: '/var/lib/woodpecker' }], env: { WOODPECKER_OPEN: 'true', WOODPECKER_HOST: 'http://localhost:8121', WOODPECKER_SECRET: 'changeme' }, healthcheck: 'curl -f http://localhost:8000/healthz || exit 1', api: { port: 8121 }, links: {}, url: 'https://github.com/woodpecker-ci/woodpecker' }, + { id: 'umami', name: 'Umami', category: 'monitoring', description: 'Privacy-focused web analytics (GA alternative)', image: 'ghcr.io/umami-software/umami:postgresql-latest', ports: [{ container: 3000, host: 3014, protocol: 'tcp' }], volumes: [], env: { DATABASE_URL: 'postgresql://postgres:changeme@postgres:5432/umami', HASH_SALT: 'changeme' }, healthcheck: 'curl -f http://localhost:3000/api/heartbeat || exit 1', api: null, links: {}, url: 'https://github.com/umami-software/umami' }, + { id: 'plausible', name: 'Plausible Analytics', category: 'monitoring', description: 'Lightweight privacy-friendly analytics', image: 'ghcr.io/plausible/community-edition:latest', ports: [{ container: 8000, host: 8122, protocol: 'tcp' }], volumes: [{ host: '${CONFIG_PATH}/plausible', container: '/var/lib/plausible' }], env: { SECRET_KEY_BASE: 'changeme', BASE_URL: 'http://localhost:8122' }, healthcheck: 'curl -f http://localhost:8000 || exit 1', api: null, links: {}, url: 'https://github.com/plausible/analytics' }, + // --- BATCH 5: MORE MEDIA --- + { id: 'jellyplex-watched', name: 'JellyPlex Watched', category: 'media', description: 'Sync watched status between Jellyfin & Plex', image: 'luigi311/jellyplex-watched:latest', ports: [], volumes: [{ host: '${CONFIG_PATH}/jellyplex-watched', container: '/app/config' }], env: {}, healthcheck: null, api: null, links: {}, url: 'https://github.com/luigi311/JellyPlex-Watched' }, + { id: 'podgrab', name: 'Podgrab', category: 'media', description: 'Podcast manager & downloader', image: 'akhilrex/podgrab:latest', ports: [{ container: 8080, host: 8123, protocol: 'tcp' }], volumes: [{ host: '${CONFIG_PATH}/podgrab', container: '/config' }, { host: '${MEDIA_PATH}/podcasts', container: '/assets' }], env: {}, healthcheck: 'curl -f http://localhost:8080 || exit 1', api: null, links: {}, url: 'https://github.com/akhilrex/podgrab' }, + { id: 'funkwhale', name: 'Funkwhale', category: 'media', description: 'Federated music streaming platform', image: 'funkwhale/all-in-one:latest', ports: [{ container: 80, host: 8124, protocol: 'tcp' }], volumes: [{ host: '${CONFIG_PATH}/funkwhale', container: '/data' }, { host: '${MEDIA_PATH}/music', container: '/music', readonly: true }], env: {}, healthcheck: 'curl -f http://localhost:80 || exit 1', api: null, links: {}, url: 'https://github.com/funkwhale/funkwhale' }, + { id: 'subsonic', name: 'Airsonic-Advanced', category: 'media', description: 'Music streaming server (Subsonic-compatible)', image: 'lscr.io/linuxserver/airsonic-advanced:latest', ports: [{ container: 4040, host: 4040, protocol: 'tcp' }], volumes: [{ host: '${CONFIG_PATH}/airsonic', container: '/config' }, { host: '${MEDIA_PATH}/music', container: '/music' }, { host: '${MEDIA_PATH}/podcasts', container: '/podcasts' }], env: {}, healthcheck: 'curl -f http://localhost:4040 || exit 1', api: null, links: { consumesMedia: true }, url: 'https://github.com/airsonic-advanced/airsonic-advanced' }, + // --- BATCH 6: SECURITY, PRODUCTIVITY, MISC --- + { id: 'fail2ban', name: 'Fail2ban', category: 'security', description: 'Intrusion prevention โ€” bans repeated login failures', image: 'lscr.io/linuxserver/fail2ban:latest', ports: [], volumes: [{ host: '${CONFIG_PATH}/fail2ban', container: '/config' }, { host: '/var/log', container: '/var/log', readonly: true }], env: {}, capAdd: ['NET_ADMIN', 'NET_RAW'], healthcheck: null, api: null, links: {}, url: 'https://github.com/fail2ban/fail2ban' }, + { id: 'keycloak', name: 'Keycloak', category: 'security', description: 'Enterprise-grade identity & access management', image: 'quay.io/keycloak/keycloak:latest', ports: [{ container: 8080, host: 8125, protocol: 'tcp' }], volumes: [], env: { KEYCLOAK_ADMIN: 'admin', KEYCLOAK_ADMIN_PASSWORD: 'changeme', KC_HTTP_ENABLED: 'true' }, healthcheck: 'curl -f http://localhost:8080/health || exit 1', api: { port: 8125 }, links: {}, url: 'https://github.com/keycloak/keycloak' }, + { id: 'rustdesk', name: 'RustDesk', category: 'utilities', description: 'Self-hosted remote desktop (TeamViewer alternative)', image: 'rustdesk/rustdesk-server:latest', ports: [{ container: 21115, host: 21115, protocol: 'tcp' }, { container: 21116, host: 21116, protocol: 'tcp' }, { container: 21116, host: 21116, protocol: 'udp' }, { container: 21117, host: 21117, protocol: 'tcp' }], volumes: [{ host: '${CONFIG_PATH}/rustdesk', container: '/data' }], env: {}, healthcheck: null, api: null, links: {}, url: 'https://github.com/rustdesk/rustdesk-server' }, + { id: 'guacamole', name: 'Apache Guacamole', category: 'utilities', description: 'Clientless remote desktop gateway (RDP/VNC/SSH in browser)', image: 'guacamole/guacamole:latest', ports: [{ container: 8080, host: 8126, protocol: 'tcp' }], volumes: [], env: { GUACD_HOSTNAME: 'guacd', MYSQL_HOSTNAME: 'mariadb' }, healthcheck: 'curl -f http://localhost:8080 || exit 1', api: null, links: {}, url: 'https://github.com/apache/guacamole-server' }, + { id: 'cyberchef', name: 'CyberChef', category: 'utilities', description: 'Data transformation & encoding swiss-army knife', image: 'ghcr.io/gchq/cyberchef:latest', ports: [{ container: 80, host: 8127, protocol: 'tcp' }], volumes: [], env: {}, healthcheck: 'curl -f http://localhost:80 || exit 1', api: null, links: {}, url: 'https://github.com/gchq/CyberChef' }, + { id: 'hoarder', name: 'Hoarder', category: 'utilities', description: 'AI-powered bookmark manager with full-text search', image: 'ghcr.io/hoarder-app/hoarder:latest', ports: [{ container: 3000, host: 3015, protocol: 'tcp' }], volumes: [{ host: '${CONFIG_PATH}/hoarder', container: '/data' }], env: { NEXTAUTH_SECRET: 'changeme', DATA_DIR: '/data' }, healthcheck: 'curl -f http://localhost:3000 || exit 1', api: null, links: {}, url: 'https://github.com/hoarder-app/hoarder' }, + { id: 'wallabag', name: 'Wallabag', category: 'utilities', description: 'Read-it-later app (Pocket alternative)', image: 'wallabag/wallabag:latest', ports: [{ container: 80, host: 8128, protocol: 'tcp' }], volumes: [{ host: '${CONFIG_PATH}/wallabag', container: '/var/www/wallabag/data' }], env: { SYMFONY__ENV__DOMAIN_NAME: 'http://localhost:8128' }, healthcheck: 'curl -f http://localhost:80 || exit 1', api: null, links: {}, url: 'https://github.com/wallabag/wallabag' }, + { id: 'privatebin', name: 'PrivateBin', category: 'utilities', description: 'Encrypted pastebin โ€” zero-knowledge', image: 'privatebin/nginx-fpm-alpine:latest', ports: [{ container: 8080, host: 8129, protocol: 'tcp' }], volumes: [{ host: '${CONFIG_PATH}/privatebin', container: '/srv/data' }], env: {}, healthcheck: 'curl -f http://localhost:8080 || exit 1', api: null, links: {}, url: 'https://github.com/PrivateBin/PrivateBin' }, + { id: 'send', name: 'Send', category: 'utilities', description: 'Encrypted file sharing with auto-expiry (Firefox Send fork)', image: 'registry.gitlab.com/timvisee/send:latest', ports: [{ container: 1234, host: 1234, protocol: 'tcp' }], volumes: [{ host: '${CONFIG_PATH}/send', container: '/uploads' }], env: { BASE_URL: 'http://localhost:1234' }, healthcheck: 'curl -f http://localhost:1234 || exit 1', api: null, links: {}, url: 'https://github.com/timvisee/send' }, + { id: 'zipline', name: 'Zipline', category: 'utilities', description: 'ShareX-compatible image/file host with dashboard', image: 'ghcr.io/diced/zipline:latest', ports: [{ container: 3000, host: 3016, protocol: 'tcp' }], volumes: [{ host: '${CONFIG_PATH}/zipline', container: '/zipline' }], env: { CORE_SECRET: 'changeme', DATASOURCE_TYPE: 'local' }, healthcheck: 'curl -f http://localhost:3000 || exit 1', api: null, links: {}, url: 'https://github.com/diced/zipline' }, + // --- BATCH 7: DEV, AI, NETWORKING, MONITORING --- + { id: 'gitlab', name: 'GitLab CE', category: 'development', description: 'Complete DevOps platform โ€” Git, CI/CD, registry', image: 'gitlab/gitlab-ce:latest', ports: [{ container: 80, host: 8130, protocol: 'tcp' }, { container: 443, host: 8131, protocol: 'tcp' }, { container: 22, host: 2224, protocol: 'tcp' }], volumes: [{ host: '${CONFIG_PATH}/gitlab/config', container: '/etc/gitlab' }, { host: '${CONFIG_PATH}/gitlab/logs', container: '/var/log/gitlab' }, { host: '${CONFIG_PATH}/gitlab/data', container: '/var/opt/gitlab' }], env: {}, healthcheck: 'curl -f http://localhost:80/-/health || exit 1', api: { port: 8130 }, links: {}, url: 'https://github.com/gitlabhq/gitlabhq' }, + { id: 'registry', name: 'Docker Registry', category: 'development', description: 'Private container image registry', image: 'registry:2', ports: [{ container: 5000, host: 5004, protocol: 'tcp' }], volumes: [{ host: '${CONFIG_PATH}/registry', container: '/var/lib/registry' }], env: {}, healthcheck: 'curl -f http://localhost:5000/v2/ || exit 1', api: { port: 5004 }, links: {}, url: 'https://github.com/distribution/distribution' }, + { id: 'jupyter', name: 'Jupyter Notebook', category: 'ai', description: 'Interactive Python notebooks for data science', image: 'jupyter/scipy-notebook:latest', ports: [{ container: 8888, host: 8888, protocol: 'tcp' }], volumes: [{ host: '${CONFIG_PATH}/jupyter', container: '/home/jovyan/work' }], env: { JUPYTER_TOKEN: 'changeme' }, healthcheck: 'curl -f http://localhost:8888/api || exit 1', api: null, links: {}, url: 'https://github.com/jupyter/docker-stacks' }, + { id: 'flowise', name: 'Flowise', category: 'ai', description: 'Drag & drop LLM app builder (LangChain UI)', image: 'flowiseai/flowise:latest', ports: [{ container: 3000, host: 3017, protocol: 'tcp' }], volumes: [{ host: '${CONFIG_PATH}/flowise', container: '/root/.flowise' }], env: {}, healthcheck: 'curl -f http://localhost:3000 || exit 1', api: { port: 3017 }, links: {}, url: 'https://github.com/FlowiseAI/Flowise' }, + { id: 'anything-llm', name: 'AnythingLLM', category: 'ai', description: 'All-in-one AI desktop app with RAG', image: 'mintplexlabs/anythingllm:latest', ports: [{ container: 3001, host: 3018, protocol: 'tcp' }], volumes: [{ host: '${CONFIG_PATH}/anythingllm', container: '/app/server/storage' }], env: {}, healthcheck: 'curl -f http://localhost:3001/api/ping || exit 1', api: { port: 3018 }, links: {}, url: 'https://github.com/Mintplex-Labs/anything-llm' }, + { id: 'zerotier', name: 'ZeroTier', category: 'networking', description: 'Peer-to-peer virtual network (VPN alternative)', image: 'zerotier/zerotier:latest', ports: [{ container: 9993, host: 9993, protocol: 'udp' }], volumes: [{ host: '${CONFIG_PATH}/zerotier', container: '/var/lib/zerotier-one' }], env: {}, capAdd: ['NET_ADMIN', 'SYS_ADMIN'], healthcheck: null, api: null, links: {}, url: 'https://github.com/zerotier/ZeroTierOne' }, + { id: 'netbird', name: 'NetBird', category: 'networking', description: 'WireGuard-based mesh VPN with SSO', image: 'netbirdio/netbird:latest', ports: [{ container: 33073, host: 33073, protocol: 'tcp' }], volumes: [{ host: '${CONFIG_PATH}/netbird', container: '/etc/netbird' }], env: {}, healthcheck: null, api: null, links: {}, url: 'https://github.com/netbirdio/netbird' }, + { id: 'beszel', name: 'Beszel', category: 'monitoring', description: 'Lightweight server monitoring with Docker stats', image: 'henrygd/beszel:latest', ports: [{ container: 8090, host: 8132, protocol: 'tcp' }], volumes: [{ host: '${CONFIG_PATH}/beszel', container: '/beszel_data' }], env: {}, healthcheck: 'curl -f http://localhost:8090 || exit 1', api: null, links: {}, url: 'https://github.com/henrygd/beszel' }, + { id: 'glances', name: 'Glances', category: 'monitoring', description: 'System monitoring tool with web UI', image: 'nicolargo/glances:latest-full', ports: [{ container: 61208, host: 61208, protocol: 'tcp' }], volumes: [{ host: '/var/run/docker.sock', container: '/var/run/docker.sock', readonly: true }], env: { GLANCES_OPT: '-w' }, healthcheck: 'curl -f http://localhost:61208/api/4/status || exit 1', api: { port: 61208 }, links: {}, url: 'https://github.com/nicolargo/glances' }, + // --- BATCH 7 CONT: BACKUP, DNS, MISC --- + { id: 'restic', name: 'Restic (rest-server)', category: 'backups', description: 'Fast, encrypted backup repository server', image: 'restic/rest-server:latest', ports: [{ container: 8000, host: 8133, protocol: 'tcp' }], volumes: [{ host: '${CONFIG_PATH}/restic', container: '/data' }], env: {}, healthcheck: 'curl -f http://localhost:8000 || exit 1', api: null, links: {}, url: 'https://github.com/restic/rest-server' }, + { id: 'minio', name: 'MinIO', category: 'backups', description: 'S3-compatible object storage', image: 'minio/minio:latest', ports: [{ container: 9000, host: 9006, protocol: 'tcp' }, { container: 9001, host: 9007, protocol: 'tcp' }], volumes: [{ host: '${CONFIG_PATH}/minio', container: '/data' }], env: { MINIO_ROOT_USER: 'admin', MINIO_ROOT_PASSWORD: 'changeme123' }, healthcheck: 'curl -f http://localhost:9000/minio/health/live || exit 1', api: { port: 9006 }, links: {}, url: 'https://github.com/minio/minio' }, + { id: 'technitium', name: 'Technitium DNS', category: 'dns', description: 'Advanced self-hosted DNS server with blocking', image: 'technitium/dns-server:latest', ports: [{ container: 5380, host: 5380, protocol: 'tcp' }, { container: 53, host: 53, protocol: 'udp' }], volumes: [{ host: '${CONFIG_PATH}/technitium', container: '/etc/dns' }], env: {}, healthcheck: 'curl -f http://localhost:5380 || exit 1', api: { port: 5380 }, links: {}, url: 'https://github.com/TechnitiumSoftware/DnsServer' }, + { id: 'unbound', name: 'Unbound', category: 'dns', description: 'Recursive DNS resolver for privacy', image: 'mvance/unbound:latest', ports: [{ container: 53, host: 5353, protocol: 'tcp' }, { container: 53, host: 5353, protocol: 'udp' }], volumes: [{ host: '${CONFIG_PATH}/unbound', container: '/opt/unbound/etc/unbound' }], env: {}, healthcheck: null, api: null, links: {}, url: 'https://github.com/NLnetLabs/unbound' }, + { id: 'docuseal', name: 'DocuSeal', category: 'documents', description: 'Document signing & form filling (DocuSign alternative)', image: 'docuseal/docuseal:latest', ports: [{ container: 3000, host: 3019, protocol: 'tcp' }], volumes: [{ host: '${CONFIG_PATH}/docuseal', container: '/data' }], env: {}, healthcheck: 'curl -f http://localhost:3000 || exit 1', api: null, links: {}, url: 'https://github.com/docusealco/docuseal' }, + { id: 'ocrmypdf', name: 'OCRmyPDF', category: 'documents', description: 'Add OCR text layer to scanned PDFs (web API)', image: 'jbarlow83/ocrmypdf:latest', ports: [], volumes: [{ host: '${CONFIG_PATH}/ocrmypdf', container: '/output' }], env: {}, healthcheck: null, api: null, links: {}, url: 'https://github.com/ocrmypdf/OCRmyPDF' }, + // --- BATCH 8: FINAL --- + { id: 'esphome', name: 'ESPHome', category: 'automation', description: 'IoT firmware builder for ESP32/ESP8266', image: 'ghcr.io/esphome/esphome:latest', ports: [{ container: 6052, host: 6052, protocol: 'tcp' }], volumes: [{ host: '${CONFIG_PATH}/esphome', container: '/config' }], env: {}, healthcheck: 'curl -f http://localhost:6052 || exit 1', api: null, links: {}, url: 'https://github.com/esphome/esphome' }, + { id: 'scrypted', name: 'Scrypted', category: 'automation', description: 'Smart home camera hub with HomeKit/Alexa/Google', image: 'ghcr.io/koush/scrypted:latest', ports: [{ container: 11080, host: 11080, protocol: 'tcp' }], volumes: [{ host: '${CONFIG_PATH}/scrypted', container: '/server/volume' }], env: {}, healthcheck: 'curl -f http://localhost:11080 || exit 1', api: { port: 11080 }, links: {}, url: 'https://github.com/koush/scrypted' }, + { id: 'grocy', name: 'Grocy', category: 'utilities', description: 'Household & grocery management (ERP for your home)', image: 'lscr.io/linuxserver/grocy:latest', ports: [{ container: 80, host: 8134, protocol: 'tcp' }], volumes: [{ host: '${CONFIG_PATH}/grocy', container: '/config' }], env: {}, healthcheck: 'curl -f http://localhost:80 || exit 1', api: { port: 8134 }, links: {}, url: 'https://github.com/grocy/grocy' }, + { id: 'habitica', name: 'Habitica', category: 'utilities', description: 'Gamified habit & task tracker', image: 'habitica/habitica:latest', ports: [{ container: 3000, host: 3020, protocol: 'tcp' }], volumes: [{ host: '${CONFIG_PATH}/habitica', container: '/data' }], env: {}, healthcheck: 'curl -f http://localhost:3000 || exit 1', api: null, links: {}, url: 'https://github.com/HabitRPG/habitica' }, + { id: 'element', name: 'Element Web', category: 'communication', description: 'Matrix chat client (web app)', image: 'vectorim/element-web:latest', ports: [{ container: 80, host: 8135, protocol: 'tcp' }], volumes: [{ host: '${CONFIG_PATH}/element/config.json', container: '/app/config.json' }], env: {}, healthcheck: 'curl -f http://localhost:80 || exit 1', api: null, links: {}, url: 'https://github.com/element-hq/element-web' }, + { id: 'revolt', name: 'Revolt', category: 'communication', description: 'Open-source Discord alternative', image: 'ghcr.io/revoltchat/server:latest', ports: [{ container: 14702, host: 14702, protocol: 'tcp' }], volumes: [{ host: '${CONFIG_PATH}/revolt', container: '/data' }], env: {}, healthcheck: null, api: null, links: {}, url: 'https://github.com/revoltchat/self-hosted' }, + { id: 'hedgedoc', name: 'HedgeDoc', category: 'knowledge', description: 'Collaborative Markdown editor (HackMD fork)', image: 'quay.io/hedgedoc/hedgedoc:latest', ports: [{ container: 3000, host: 3021, protocol: 'tcp' }], volumes: [{ host: '${CONFIG_PATH}/hedgedoc', container: '/hedgedoc/public/uploads' }], env: { CMD_DB_URL: 'sqlite:///hedgedoc/db.sqlite', CMD_DOMAIN: 'localhost', CMD_URL_ADDPORT: 'true' }, healthcheck: 'curl -f http://localhost:3000 || exit 1', api: null, links: {}, url: 'https://github.com/hedgedoc/hedgedoc' }, + { id: 'logseq', name: 'Logseq (sync)', category: 'knowledge', description: 'Outliner knowledge base with graph view (sync server)', image: 'ghcr.io/logseq/logseq:latest', ports: [{ container: 80, host: 8136, protocol: 'tcp' }], volumes: [{ host: '${CONFIG_PATH}/logseq', container: '/data' }], env: {}, healthcheck: 'curl -f http://localhost:80 || exit 1', api: null, links: {}, url: 'https://github.com/logseq/logseq' }, + { id: 'minecraft', name: 'Minecraft Server', category: 'games', description: 'Vanilla/modded Minecraft server with easy config', image: 'itzg/minecraft-server:latest', ports: [{ container: 25565, host: 25565, protocol: 'tcp' }], volumes: [{ host: '${CONFIG_PATH}/minecraft', container: '/data' }], env: { EULA: 'TRUE', TYPE: 'VANILLA', MEMORY: '2G' }, healthcheck: null, api: null, links: {}, url: 'https://github.com/itzg/docker-minecraft-server' }, + { id: 'valheim', name: 'Valheim Server', category: 'games', description: 'Dedicated Valheim game server', image: 'ghcr.io/lloesche/valheim-server:latest', ports: [{ container: 2456, host: 2456, protocol: 'udp' }, { container: 2457, host: 2457, protocol: 'udp' }], volumes: [{ host: '${CONFIG_PATH}/valheim/config', container: '/config' }, { host: '${CONFIG_PATH}/valheim/data', container: '/opt/valheim' }], env: { SERVER_NAME: 'StackPilot Valheim', WORLD_NAME: 'stackpilot', SERVER_PASS: 'changeme' }, healthcheck: null, api: null, links: {}, url: 'https://github.com/lloesche/valheim-server-docker' }, + { id: 'palworld', name: 'Palworld Server', category: 'games', description: 'Dedicated Palworld game server', image: 'thijsvanloef/palworld-server-docker:latest', ports: [{ container: 8211, host: 8211, protocol: 'udp' }], volumes: [{ host: '${CONFIG_PATH}/palworld', container: '/palworld' }], env: { SERVER_NAME: 'StackPilot', ADMIN_PASSWORD: 'changeme' }, healthcheck: null, api: null, links: {}, url: 'https://github.com/thijsvanloef/palworld-server-docker' }, + { id: 'satisfactory', name: 'Satisfactory Server', category: 'games', description: 'Dedicated Satisfactory game server', image: 'wolveix/satisfactory-server:latest', ports: [{ container: 7777, host: 7777, protocol: 'udp' }, { container: 15000, host: 15000, protocol: 'udp' }, { container: 15777, host: 15777, protocol: 'udp' }], volumes: [{ host: '${CONFIG_PATH}/satisfactory', container: '/config' }], env: { MAXPLAYERS: '4' }, healthcheck: null, api: null, links: {}, url: 'https://github.com/wolveix/satisfactory-server' }, + { id: 'arr-tools-recyclarr', name: 'TRaSH Guides (Notifiarr)', category: 'arr', description: 'Arr notification & integration agent', image: 'golift/notifiarr:latest', ports: [{ container: 5454, host: 5454, protocol: 'tcp' }], volumes: [{ host: '${CONFIG_PATH}/notifiarr', container: '/config' }], env: {}, healthcheck: 'curl -f http://localhost:5454 || exit 1', api: { port: 5454 }, links: {}, url: 'https://github.com/Notifiarr/notifiarr' }, + // --- BATCH 9: FINAL ROUND --- + { id: 'homepage-mirror', name: 'Organizr', category: 'dashboards', description: 'HTPC/homelab organiser & tab dashboard', image: 'organizr/organizr:latest', ports: [{ container: 80, host: 8137, protocol: 'tcp' }], volumes: [{ host: '${CONFIG_PATH}/organizr', container: '/config' }], env: {}, healthcheck: 'curl -f http://localhost:80 || exit 1', api: null, links: {}, url: 'https://github.com/causefx/Organizr' }, + { id: 'kasm', name: 'Kasm Workspaces', category: 'utilities', description: 'Browser-based virtual desktops & apps', image: 'kasmweb/core:latest', ports: [{ container: 6901, host: 6901, protocol: 'tcp' }], volumes: [{ host: '${CONFIG_PATH}/kasm', container: '/opt/kasm' }], env: {}, healthcheck: null, api: null, links: {}, url: 'https://github.com/kasmtech/KasmVNC' }, + { id: 'webtop', name: 'Webtop', category: 'utilities', description: 'Full Linux desktop in your browser', image: 'lscr.io/linuxserver/webtop:ubuntu-kde', ports: [{ container: 3000, host: 3022, protocol: 'tcp' }], volumes: [{ host: '${CONFIG_PATH}/webtop', container: '/config' }], env: {}, healthcheck: 'curl -f http://localhost:3000 || exit 1', api: null, links: {}, url: 'https://github.com/linuxserver/docker-webtop' }, + { id: 'windmill', name: 'Windmill', category: 'development', description: 'Developer platform for scripts, workflows & UIs', image: 'ghcr.io/windmill-labs/windmill:main', ports: [{ container: 8000, host: 8138, protocol: 'tcp' }], volumes: [{ host: '${CONFIG_PATH}/windmill', container: '/tmp/windmill' }], env: { DATABASE_URL: 'postgres://postgres:changeme@postgres:5432/windmill', MODE: 'standalone' }, healthcheck: 'curl -f http://localhost:8000 || exit 1', api: { port: 8138 }, links: {}, url: 'https://github.com/windmill-labs/windmill' }, + { id: 'pocketbase', name: 'PocketBase', category: 'development', description: 'Backend-in-a-file โ€” auth, DB, realtime, file storage', image: 'ghcr.io/muchobien/pocketbase:latest', ports: [{ container: 8090, host: 8139, protocol: 'tcp' }], volumes: [{ host: '${CONFIG_PATH}/pocketbase', container: '/pb/pb_data' }], env: {}, healthcheck: 'curl -f http://localhost:8090/api/health || exit 1', api: { port: 8139 }, links: {}, url: 'https://github.com/pocketbase/pocketbase' }, + { id: 'supabase', name: 'Supabase', category: 'development', description: 'Open-source Firebase alternative (Postgres + Auth + Storage)', image: 'supabase/studio:latest', ports: [{ container: 3000, host: 3023, protocol: 'tcp' }], volumes: [{ host: '${CONFIG_PATH}/supabase', container: '/var/lib/supabase' }], env: {}, healthcheck: 'curl -f http://localhost:3000 || exit 1', api: null, links: {}, url: 'https://github.com/supabase/supabase' }, + { id: 'ghostfolio', name: 'Ghostfolio', category: 'finance', description: 'Portfolio & investment tracker', image: 'ghostfolio/ghostfolio:latest', ports: [{ container: 3333, host: 3333, protocol: 'tcp' }], volumes: [], env: { DATABASE_URL: 'postgresql://postgres:changeme@postgres:5432/ghostfolio', REDIS_HOST: 'redis', ACCESS_TOKEN_SALT: 'changeme', JWT_SECRET_KEY: 'changeme' }, healthcheck: 'curl -f http://localhost:3333 || exit 1', api: null, links: {}, url: 'https://github.com/ghostfolio/ghostfolio' }, + { id: 'traggo', name: 'Traggo', category: 'utilities', description: 'Tag-based time tracking', image: 'traggo/server:latest', ports: [{ container: 3030, host: 3030, protocol: 'tcp' }], volumes: [{ host: '${CONFIG_PATH}/traggo', container: '/opt/traggo/data' }], env: { TRAGGO_DEFAULT_USER_NAME: 'admin', TRAGGO_DEFAULT_USER_PASS: 'changeme' }, healthcheck: 'curl -f http://localhost:3030 || exit 1', api: null, links: {}, url: 'https://github.com/traggo/server' }, + { id: 'listmonk', name: 'Listmonk', category: 'communication', description: 'Newsletter & mailing list manager', image: 'listmonk/listmonk:latest', ports: [{ container: 9000, host: 9008, protocol: 'tcp' }], volumes: [], env: {}, healthcheck: 'curl -f http://localhost:9000 || exit 1', api: { port: 9008 }, links: {}, url: 'https://github.com/knadh/listmonk' }, + { id: 'answer', name: 'Answer', category: 'knowledge', description: 'Q&A platform (Stack Overflow alternative)', image: 'answerdev/answer:latest', ports: [{ container: 80, host: 8140, protocol: 'tcp' }], volumes: [{ host: '${CONFIG_PATH}/answer', container: '/data' }], env: {}, healthcheck: 'curl -f http://localhost:80 || exit 1', api: null, links: {}, url: 'https://github.com/answerdev/answer' }, + { id: 'focalboard', name: 'Focalboard', category: 'utilities', description: 'Project management (Trello/Asana/Notion boards)', image: 'mattermost/focalboard:latest', ports: [{ container: 8000, host: 8141, protocol: 'tcp' }], volumes: [{ host: '${CONFIG_PATH}/focalboard', container: '/opt/focalboard/data' }], env: {}, healthcheck: 'curl -f http://localhost:8000 || exit 1', api: null, links: {}, url: 'https://github.com/mattermost/focalboard' }, + { id: 'invoice-shelf', name: 'InvoiceShelf', category: 'finance', description: 'Invoicing for freelancers (Crater fork)', image: 'invoiceshelf/invoiceshelf:latest', ports: [{ container: 80, host: 8142, protocol: 'tcp' }], volumes: [{ host: '${CONFIG_PATH}/invoiceshelf', container: '/data' }], env: {}, healthcheck: 'curl -f http://localhost:80 || exit 1', api: null, links: {}, url: 'https://github.com/InvoiceShelf/InvoiceShelf' }, + { id: 'openspeedtest', name: 'OpenSpeedTest', category: 'networking', description: 'Self-hosted internet speed test server', image: 'openspeedtest/latest:latest', ports: [{ container: 3000, host: 3024, protocol: 'tcp' }], volumes: [], env: {}, healthcheck: 'curl -f http://localhost:3000 || exit 1', api: null, links: {}, url: 'https://github.com/openspeedtest/Speed-Test' }, + { id: 'whoogle', name: 'Whoogle', category: 'privacy', description: 'Self-hosted Google search proxy (no ads/tracking)', image: 'benbusby/whoogle-search:latest', ports: [{ container: 5000, host: 5007, protocol: 'tcp' }], volumes: [], env: {}, healthcheck: 'curl -f http://localhost:5000 || exit 1', api: null, links: {}, url: 'https://github.com/benbusby/whoogle-search' }, + { id: 'nitter', name: 'Nitter', category: 'privacy', description: 'Privacy-focused Twitter/X frontend', image: 'zedeus/nitter:latest', ports: [{ container: 8080, host: 8143, protocol: 'tcp' }], volumes: [], env: {}, healthcheck: 'curl -f http://localhost:8080 || exit 1', api: null, links: {}, url: 'https://github.com/zedeus/nitter' }, + { id: 'libreddit', name: 'Redlib', category: 'privacy', description: 'Privacy-focused Reddit frontend', image: 'quay.io/redlib/redlib:latest', ports: [{ container: 8080, host: 8144, protocol: 'tcp' }], volumes: [], env: {}, healthcheck: 'curl -f http://localhost:8080 || exit 1', api: null, links: {}, url: 'https://github.com/redlib-org/redlib' }, + { id: 'wg-easy', name: 'WG-Easy', category: 'networking', description: 'WireGuard VPN with simple web UI', image: 'ghcr.io/wg-easy/wg-easy:latest', ports: [{ container: 51820, host: 51821, protocol: 'udp' }, { container: 51821, host: 51822, protocol: 'tcp' }], volumes: [{ host: '${CONFIG_PATH}/wg-easy', container: '/etc/wireguard' }], env: { WG_HOST: 'your-server-ip', PASSWORD_HASH: 'changeme' }, capAdd: ['NET_ADMIN', 'SYS_MODULE'], healthcheck: null, api: null, links: {}, url: 'https://github.com/wg-easy/wg-easy' }, +]; diff --git a/src/generators/analyser.js b/src/generators/analyser.js new file mode 100644 index 0000000..8ca9447 --- /dev/null +++ b/src/generators/analyser.js @@ -0,0 +1,316 @@ +import jsYaml from 'js-yaml'; + +/** + * Analyse a docker-compose.yml string and return issues + suggestions. + */ +export function analyseCompose(yamlStr) { + const results = { errors: [], warnings: [], suggestions: [], info: [] }; + + let compose; + try { + compose = jsYaml.load(yamlStr); + } catch (e) { + results.errors.push({ title: 'Invalid YAML', detail: e.message }); + return results; + } + + if (!compose || typeof compose !== 'object') { + results.errors.push({ title: 'Empty or invalid compose file', detail: 'The file does not contain a valid Docker Compose structure.' }); + return results; + } + + const services = compose.services || {}; + const serviceNames = Object.keys(services); + + if (serviceNames.length === 0) { + results.errors.push({ title: 'No services defined', detail: 'The compose file has no services section.' }); + return results; + } + + results.info.push({ title: 'Services found', detail: `${serviceNames.length} services: ${serviceNames.join(', ')}` }); + + // Check compose version + if (compose.version) { + results.suggestions.push({ title: 'Remove "version" field', detail: 'The "version" key is obsolete in modern Docker Compose (v2+). It\'s ignored and can be safely removed.' }); + } + + for (const [name, svc] of Object.entries(services)) { + // No image specified + if (!svc.image && !svc.build) { + results.errors.push({ title: `${name}: No image or build`, detail: 'Service has neither an "image" nor a "build" directive.' }); + } + + // Using :latest tag + if (svc.image && svc.image.endsWith(':latest')) { + results.warnings.push({ title: `${name}: Using :latest tag`, detail: `Image "${svc.image}" uses :latest which is unpredictable. Pin to a specific version for reproducibility.` }); + } + + // No image tag at all + if (svc.image && !svc.image.includes(':')) { + results.warnings.push({ title: `${name}: No image tag`, detail: `Image "${svc.image}" has no tag โ€” Docker will pull :latest implicitly. Specify a version tag.` }); + } + + // No restart policy + if (!svc.restart) { + results.warnings.push({ title: `${name}: No restart policy`, detail: 'Add "restart: unless-stopped" so the container recovers from crashes and host reboots.' }); + } + + // restart: always vs unless-stopped + if (svc.restart === 'always') { + results.suggestions.push({ title: `${name}: Consider "unless-stopped"`, detail: '"restart: unless-stopped" is usually better than "always" โ€” it respects manual docker stop commands.' }); + } + + // Privileged mode + if (svc.privileged) { + results.warnings.push({ title: `${name}: Privileged mode`, detail: 'Running privileged gives the container full host access. Use specific cap_add instead if possible.' }); + } + + // No healthcheck + if (!svc.healthcheck) { + results.suggestions.push({ title: `${name}: No healthcheck`, detail: 'Adding a healthcheck helps Docker (and depends_on) know when the service is actually ready.' }); + } + + // Volumes โ€” host path binds without absolute paths + if (svc.volumes) { + for (const vol of svc.volumes) { + const volStr = typeof vol === 'string' ? vol : vol.source || ''; + if (volStr && !volStr.startsWith('/') && !volStr.startsWith('$') && !volStr.startsWith('.') && volStr.includes(':')) { + const hostPart = volStr.split(':')[0]; + if (!hostPart.match(/^[a-zA-Z_][a-zA-Z0-9_-]*$/)) { + // Not a named volume, not absolute โ€” suspicious + results.warnings.push({ title: `${name}: Ambiguous volume path`, detail: `"${volStr}" โ€” use absolute paths or named volumes for clarity.` }); + } + } + } + } + + // Docker socket mount + if (svc.volumes) { + const hasSock = svc.volumes.some(v => { + const s = typeof v === 'string' ? v : `${v.source}:${v.target}`; + return s.includes('/var/run/docker.sock'); + }); + if (hasSock && !svc.read_only) { + results.warnings.push({ title: `${name}: Docker socket mounted`, detail: 'Mounting docker.sock gives the container full control over Docker. Consider ":ro" (read-only) if the service only needs read access.' }); + } + } + + // Port conflicts + if (svc.ports) { + for (const port of svc.ports) { + const portStr = typeof port === 'string' ? port : `${port.published}:${port.target}`; + if (portStr.startsWith('0.0.0.0:') || (!portStr.includes('127.0.0.1') && !portStr.includes('localhost'))) { + // Binding to all interfaces is fine but worth noting for security-sensitive ports + const hostPort = portStr.split(':')[0] === '0.0.0.0' ? portStr.split(':')[1] : portStr.split(':')[0]; + if (['3306', '5432', '6379', '27017'].includes(hostPort)) { + results.warnings.push({ title: `${name}: Database port exposed`, detail: `Port ${hostPort} is exposed to all interfaces. Bind to 127.0.0.1:${hostPort} to restrict to local access only.` }); + } + } + } + } + + // Environment with plaintext secrets + if (svc.environment) { + const envEntries = Array.isArray(svc.environment) ? svc.environment : Object.entries(svc.environment).map(([k, v]) => `${k}=${v}`); + for (const entry of envEntries) { + const key = typeof entry === 'string' ? entry.split('=')[0] : entry; + const lower = key.toLowerCase(); + if ((lower.includes('password') || lower.includes('secret') || lower.includes('token') || lower.includes('api_key')) && !entry.includes('${')) { + results.suggestions.push({ title: `${name}: Hardcoded secret`, detail: `"${key}" appears to contain a secret. Move it to a .env file or Docker secrets for better security.` }); + break; // One warning per service is enough + } + } + } + + // network_mode: host + if (svc.network_mode === 'host') { + results.warnings.push({ title: `${name}: Host networking`, detail: 'network_mode: host bypasses Docker network isolation. Only use this when absolutely necessary.' }); + } + + // No resource limits + if (!svc.deploy?.resources && !svc.mem_limit && !svc.cpus) { + results.suggestions.push({ title: `${name}: No resource limits`, detail: 'Consider adding memory/CPU limits (deploy.resources or mem_limit) to prevent one container from starving others.' }); + } + + // Logging + if (!svc.logging) { + results.suggestions.push({ title: `${name}: No log rotation`, detail: 'Without logging config, container logs grow unbounded. Add "logging: { driver: json-file, options: { max-size: 10m, max-file: 3 } }".' }); + } + } + + // Network analysis + if (!compose.networks) { + results.suggestions.push({ title: 'No custom networks defined', detail: 'All services share the default bridge network. Define custom networks to isolate groups of services (e.g., frontend, backend, database).' }); + } + + // Port conflict detection across services + const portMap = {}; + for (const [name, svc] of Object.entries(services)) { + if (svc.ports) { + for (const port of svc.ports) { + const portStr = typeof port === 'string' ? port : `${port.published}`; + const hostPort = portStr.split(':')[0] === '0.0.0.0' ? portStr.split(':')[1] : portStr.split(':')[0]; + if (portMap[hostPort]) { + results.errors.push({ title: `Port conflict: ${hostPort}`, detail: `Both "${portMap[hostPort]}" and "${name}" bind to host port ${hostPort}.` }); + } else { + portMap[hostPort] = name; + } + } + } + } + + // depends_on without condition + for (const [name, svc] of Object.entries(services)) { + if (svc.depends_on) { + if (Array.isArray(svc.depends_on)) { + results.suggestions.push({ title: `${name}: depends_on without condition`, detail: 'Use the long form with "condition: service_healthy" for more reliable startup ordering.' }); + } + } + } + + return results; +} + +/** + * Apply automatic fixes to a docker-compose.yml based on analysis results. + * Returns the improved YAML string. + */ +export function fixCompose(yamlStr) { + let compose; + try { + compose = jsYaml.load(yamlStr); + } catch (e) { + return yamlStr; // Can't fix invalid YAML + } + + if (!compose || typeof compose !== 'object') return yamlStr; + + // Remove obsolete version field + delete compose.version; + + const services = compose.services || {}; + + for (const [name, svc] of Object.entries(services)) { + // Add restart policy + if (!svc.restart) { + svc.restart = 'unless-stopped'; + } + + // Fix restart: always โ†’ unless-stopped + if (svc.restart === 'always') { + svc.restart = 'unless-stopped'; + } + + // Add healthcheck if missing and service has ports + if (!svc.healthcheck && svc.ports && svc.ports.length > 0) { + const portStr = typeof svc.ports[0] === 'string' ? svc.ports[0] : `${svc.ports[0].target || svc.ports[0]}`; + const containerPort = portStr.includes(':') ? portStr.split(':').pop().replace(/\/.*/, '') : portStr.replace(/\/.*/, ''); + svc.healthcheck = { + test: ['CMD-SHELL', `curl -f http://localhost:${containerPort} || exit 1`], + interval: '30s', + timeout: '10s', + retries: 3, + }; + } + + // Add logging config + if (!svc.logging) { + svc.logging = { + driver: 'json-file', + options: { 'max-size': '10m', 'max-file': '3' }, + }; + } + + // Add resource limits + if (!svc.deploy) { + svc.deploy = {}; + } + if (!svc.deploy.resources) { + svc.deploy.resources = { + limits: { memory: '512M' }, + }; + } + + // Fix docker socket to read-only if not already + if (svc.volumes) { + svc.volumes = svc.volumes.map(v => { + const volStr = typeof v === 'string' ? v : `${v.source}:${v.target}`; + if (volStr.includes('/var/run/docker.sock') && !volStr.includes(':ro')) { + return typeof v === 'string' ? v + ':ro' : { ...v, read_only: true }; + } + return v; + }); + } + + // Bind database ports to localhost only + if (svc.ports) { + svc.ports = svc.ports.map(p => { + const portStr = typeof p === 'string' ? p : `${p.published}:${p.target}`; + const parts = portStr.split(':'); + const hostPort = parts.length === 2 ? parts[0] : parts[1]; + const containerPort = parts.length === 2 ? parts[1] : parts[2]; + if (['3306', '5432', '6379', '27017'].includes(hostPort)) { + return `127.0.0.1:${hostPort}:${containerPort}`; + } + return p; + }); + } + + // Convert depends_on array to long form with condition + if (svc.depends_on && Array.isArray(svc.depends_on)) { + const deps = {}; + for (const dep of svc.depends_on) { + // Only use service_healthy if the dependency has a healthcheck + const depSvc = services[dep]; + if (depSvc && (depSvc.healthcheck || svc.healthcheck)) { + deps[dep] = { condition: 'service_healthy' }; + } else { + deps[dep] = { condition: 'service_started' }; + } + } + svc.depends_on = deps; + } + + // Move hardcoded secrets to env_file reference pattern (add comment) + // We can't actually create a .env file from here, but we restructure + if (svc.environment) { + const envEntries = Array.isArray(svc.environment) ? svc.environment : Object.entries(svc.environment).map(([k, v]) => `${k}=${v}`); + const newEnv = {}; + for (const entry of envEntries) { + const [key, ...valueParts] = (typeof entry === 'string' ? entry : `${entry}`).split('='); + const value = valueParts.join('='); + const lower = key.toLowerCase(); + if (lower.includes('password') || lower.includes('secret') || lower.includes('token') || lower.includes('api_key')) { + newEnv[key] = '${' + key + '}'; // Reference .env variable + } else { + newEnv[key] = value || ''; + } + } + svc.environment = newEnv; + // Add env_file if secrets were found + if (!svc.env_file) { + const hasSecrets = Object.values(newEnv).some(v => v.startsWith('${')); + if (hasSecrets) { + svc.env_file = ['.env']; + } + } + } + + services[name] = svc; + } + + compose.services = services; + + // Add custom network if none exists + if (!compose.networks) { + compose.networks = { stackpilot: { driver: 'bridge' } }; + // Add network to all services + for (const svc of Object.values(compose.services)) { + if (!svc.network_mode) { + svc.networks = ['stackpilot']; + } + } + } + + return jsYaml.dump(compose, { lineWidth: 120, noRefs: true }); +} diff --git a/src/generators/compose.js b/src/generators/compose.js new file mode 100644 index 0000000..bd58f6b --- /dev/null +++ b/src/generators/compose.js @@ -0,0 +1,156 @@ +import jsYaml from 'js-yaml'; + +export function generateCompose(selectedApps, config) { + const compose = { + version: '3.8', + services: {}, + networks: { stackpilot: { driver: 'bridge' } }, + }; + + for (const app of selectedApps) { + const service = { + image: app.image, + container_name: app.id, + restart: 'unless-stopped', + networks: ['stackpilot'], + environment: { + PUID: '1000', + PGID: '1000', + TZ: config.timezone, + ...app.env, + }, + }; + + // Ports + if (app.ports && app.ports.length > 0) { + service.ports = app.ports.map(p => + p.protocol === 'udp' + ? `${p.host}:${p.container}/udp` + : `${p.host}:${p.container}` + ); + } + + // Volumes + if (app.volumes && app.volumes.length > 0) { + service.volumes = app.volumes.map(v => { + const hostPath = v.host + .replace('${CONFIG_PATH}', config.configPath) + .replace('${MEDIA_PATH}', config.mediaPath) + .replace('${DOWNLOADS_PATH}', config.downloadsPath); + return v.readonly ? `${hostPath}:${v.container}:ro` : `${hostPath}:${v.container}`; + }); + } + + // Cap add + if (app.capAdd) { + service.cap_add = app.capAdd; + } + + // Healthcheck + if (app.healthcheck) { + service.healthcheck = { + test: ['CMD-SHELL', app.healthcheck], + interval: '30s', + timeout: '10s', + retries: 3, + }; + } + + // VPN routing โ€” if gluetun is selected and this is a download client + const gluetunSelected = selectedApps.some(a => a.id === 'gluetun'); + if (gluetunSelected && app.links?.providesDownloadClient) { + service.network_mode = `service:gluetun`; + delete service.networks; + delete service.ports; + } + + // If this IS gluetun, expose download client ports through it + if (app.id === 'gluetun' && gluetunSelected) { + const dlClients = selectedApps.filter(a => a.links?.providesDownloadClient); + const extraPorts = dlClients.flatMap(a => a.ports || []); + if (extraPorts.length > 0) { + service.ports = [ + ...(service.ports || []), + ...extraPorts.map(p => + p.protocol === 'udp' ? `${p.host}:${p.container}/udp` : `${p.host}:${p.container}` + ), + ]; + } + } + + compose.services[app.id] = service; + } + + // Tailscale sidecar + if (config.tailscale?.enabled) { + const tsArgs = ['--hostname=' + (config.tailscale.hostname || 'stackpilot')]; + if (config.tailscale.subnetRouter) { + tsArgs.push('--advertise-routes=172.20.0.0/16'); + } + if (config.tailscale.funnel) { + tsArgs.push('--advertise-exit-node'); + } + + compose.services['tailscale-sidecar'] = { + image: 'tailscale/tailscale:latest', + container_name: 'tailscale-sidecar', + restart: 'unless-stopped', + hostname: config.tailscale.hostname || 'stackpilot', + networks: ['stackpilot'], + cap_add: ['NET_ADMIN', 'SYS_MODULE'], + environment: { + TS_AUTHKEY: config.tailscale.authKey || '${TS_AUTHKEY}', + TS_EXTRA_ARGS: tsArgs.join(' '), + TS_STATE_DIR: '/var/lib/tailscale', + }, + volumes: [ + `${config.configPath}/tailscale:/var/lib/tailscale`, + '/dev/net/tun:/dev/net/tun', + ], + }; + } + + return jsYaml.dump(compose, { lineWidth: 120, noRefs: true }); +} + +export function generateEnv(selectedApps, config) { + const lines = [ + '# StackPilot โ€” Generated Environment File', + `# Generated: ${new Date().toISOString()}`, + '', + '# Paths', + `CONFIG_PATH=${config.configPath}`, + `MEDIA_PATH=${config.mediaPath}`, + `DOWNLOADS_PATH=${config.downloadsPath}`, + `TZ=${config.timezone}`, + '', + '# User/Group IDs', + 'PUID=1000', + 'PGID=1000', + '', + ]; + + for (const app of selectedApps) { + if (app.env && Object.keys(app.env).length > 0) { + const envVarsWithValues = Object.entries(app.env).filter(([, v]) => v !== ''); + if (envVarsWithValues.length > 0) { + lines.push(`# ${app.name}`); + for (const [key, value] of envVarsWithValues) { + lines.push(`${app.id.toUpperCase()}_${key}=${value}`); + } + lines.push(''); + } + // Prompt-style placeholders for empty values + const envVarsEmpty = Object.entries(app.env).filter(([, v]) => v === ''); + if (envVarsEmpty.length > 0) { + lines.push(`# ${app.name} โ€” FILL THESE IN`); + for (const [key] of envVarsEmpty) { + lines.push(`${app.id.toUpperCase()}_${key}=CHANGE_ME`); + } + lines.push(''); + } + } + } + + return lines.join('\n'); +} diff --git a/src/generators/setup.js b/src/generators/setup.js new file mode 100644 index 0000000..3c57928 --- /dev/null +++ b/src/generators/setup.js @@ -0,0 +1,217 @@ +export function generateSetupScript(selectedApps, config) { + const ids = selectedApps.map(a => a.id); + + // Determine if we need linking functionality + const hasArrApps = selectedApps.some(a => a.links?.needsIndexer || a.links?.needsDownloadClient); + const hasProwlarr = ids.includes('prowlarr'); + const hasDlClient = selectedApps.some(a => a.links?.providesDownloadClient); + const hasMediaProviders = selectedApps.some(a => a.links?.providesMedia); + const needsLinking = (hasProwlarr && hasArrApps) || (hasDlClient && hasArrApps) || hasMediaProviders; + + const lines = [ + '#!/usr/bin/env bash', + '# StackPilot โ€” Post-Deploy Auto-Linker', + '# This script waits for services to be healthy, then wires them together via their APIs.', + `# Generated: ${new Date().toISOString()}`, + '', + 'set -euo pipefail', + '', + 'GREEN="\\033[0;32m"', + 'YELLOW="\\033[1;33m"', + 'RED="\\033[0;31m"', + 'NC="\\033[0m"', + '', + 'log() { echo -e "${GREEN}[StackPilot]${NC} $1"; }', + 'warn() { echo -e "${YELLOW}[StackPilot]${NC} $1"; }', + 'err() { echo -e "${RED}[StackPilot]${NC} $1"; }', + '', + '# Wait for a service to respond', + 'wait_for() {', + ' local name="$1" url="$2" max_attempts="${3:-30}"', + ' log "Waiting for $name..."', + ' for i in $(seq 1 $max_attempts); do', + ' if curl -sf "$url" > /dev/null 2>&1; then', + ' log "$name is ready!"', + ' return 0', + ' fi', + ' sleep 2', + ' done', + ' err "$name did not start within $((max_attempts * 2))s"', + ' return 1', + '}', + '', + ]; + + // Only include get_api_key if Arr apps are selected + if (needsLinking) { + lines.push('# Get API key from Arr app config.xml'); + lines.push('get_api_key() {'); + lines.push(' local app="$1"'); + lines.push(` local config_file="${config.configPath}/\${app}/config.xml"`); + lines.push(' if [ -f "$config_file" ]; then'); + lines.push(' grep -oP \'\\K[^<]+\' "$config_file"'); + lines.push(' else'); + lines.push(' err "Config file not found for $app: $config_file"'); + lines.push(' return 1'); + lines.push(' fi'); + lines.push('}'); + lines.push(''); + } + + lines.push('log "Starting StackPilot auto-linking..."'); + lines.push('echo ""'); + lines.push(''); + + // Wait for all services + lines.push('# === Wait for services ==='); + for (const app of selectedApps) { + if (app.healthcheck && app.ports?.length > 0) { + const port = app.ports[0].host; + lines.push(`wait_for "${app.name}" "http://localhost:${port}"`); + } + } + lines.push('', 'echo ""', ''); + + // Link Prowlarr to Sonarr/Radarr/Lidarr/Readarr + if (ids.includes('prowlarr')) { + const arrApps = selectedApps.filter(a => + a.links?.needsIndexer && ids.includes(a.id) + ); + + if (arrApps.length > 0) { + lines.push('# === Link Prowlarr โ†’ Arr Apps ==='); + lines.push('PROWLARR_KEY=$(get_api_key prowlarr)'); + lines.push(''); + + for (const arr of arrApps) { + const port = arr.ports[0].host; + lines.push(`log "Linking Prowlarr โ†’ ${arr.name}..."`); + lines.push(`${arr.id.toUpperCase()}_KEY=$(get_api_key ${arr.id})`); + lines.push(`curl -sf -X POST "http://localhost:9696/api/v1/applications" \\`); + lines.push(` -H "X-Api-Key: $PROWLARR_KEY" \\`); + lines.push(` -H "Content-Type: application/json" \\`); + lines.push(` -d '{`); + lines.push(` "syncLevel": "fullSync",`); + lines.push(` "name": "${arr.name}",`); + lines.push(` "fields": [`); + lines.push(` {"name": "prowlarrUrl", "value": "http://prowlarr:9696"},`); + lines.push(` {"name": "baseUrl", "value": "http://${arr.id}:${arr.ports[0].container}"},`); + lines.push(` {"name": "apiKey", "value": "'"\$${arr.id.toUpperCase()}_KEY"'"}`); + lines.push(` ],`); + lines.push(` "implementationName": "${arr.name}",`); + lines.push(` "implementation": "${capitalize(arr.id)}",`); + lines.push(` "configContract": "${capitalize(arr.id)}Settings"`); + lines.push(` }' && log " โœ“ ${arr.name} linked" || warn " โš  ${arr.name} link failed (may need manual setup)"`); + lines.push(''); + } + } + } + + // Link download client to Sonarr/Radarr/Lidarr + const dlClient = selectedApps.find(a => a.links?.providesDownloadClient); + if (dlClient) { + const arrApps = selectedApps.filter(a => a.links?.needsDownloadClient); + if (arrApps.length > 0) { + lines.push('# === Add Download Client to Arr Apps ==='); + const dlPort = dlClient.ports[0].container; + const dlHost = ids.includes('gluetun') ? 'gluetun' : dlClient.id; + + for (const arr of arrApps) { + const arrPort = arr.ports[0].host; + lines.push(`log "Adding ${dlClient.name} as download client in ${arr.name}..."`); + lines.push(`${arr.id.toUpperCase()}_KEY=$(get_api_key ${arr.id})`); + + if (dlClient.id === 'qbittorrent') { + lines.push(`curl -sf -X POST "http://localhost:${arrPort}/api/v3/downloadclient" \\`); + lines.push(` -H "X-Api-Key: \$${arr.id.toUpperCase()}_KEY" \\`); + lines.push(` -H "Content-Type: application/json" \\`); + lines.push(` -d '{`); + lines.push(` "name": "qBittorrent",`); + lines.push(` "implementation": "QBittorrent",`); + lines.push(` "configContract": "QBittorrentSettings",`); + lines.push(` "protocol": "torrent",`); + lines.push(` "fields": [`); + lines.push(` {"name": "host", "value": "${dlHost}"},`); + lines.push(` {"name": "port", "value": ${dlPort}},`); + lines.push(` {"name": "username", "value": "admin"},`); + lines.push(` {"name": "password", "value": "adminadmin"}`); + lines.push(` ]`); + lines.push(` }' && log " โœ“ ${dlClient.name} added to ${arr.name}" || warn " โš  Failed (may need manual setup)"`); + } else if (dlClient.id === 'transmission') { + lines.push(`curl -sf -X POST "http://localhost:${arrPort}/api/v3/downloadclient" \\`); + lines.push(` -H "X-Api-Key: \$${arr.id.toUpperCase()}_KEY" \\`); + lines.push(` -H "Content-Type: application/json" \\`); + lines.push(` -d '{`); + lines.push(` "name": "Transmission",`); + lines.push(` "implementation": "Transmission",`); + lines.push(` "configContract": "TransmissionSettings",`); + lines.push(` "protocol": "torrent",`); + lines.push(` "fields": [`); + lines.push(` {"name": "host", "value": "${dlHost}"},`); + lines.push(` {"name": "port", "value": ${dlPort}}`); + lines.push(` ]`); + lines.push(` }' && log " โœ“ ${dlClient.name} added to ${arr.name}" || warn " โš  Failed (may need manual setup)"`); + } else if (dlClient.id === 'sabnzbd' || dlClient.id === 'nzbget') { + lines.push(`warn " โš  ${dlClient.name} linking requires API key โ€” add manually in ${arr.name} UI"`); + } + lines.push(''); + } + } + } + + // Root folders for Sonarr/Radarr + const arrWithMedia = selectedApps.filter(a => a.links?.providesMedia); + if (arrWithMedia.length > 0) { + lines.push('# === Set Root Folders ==='); + for (const arr of arrWithMedia) { + const arrPort = arr.ports[0].host; + const mediaType = arr.links.providesMedia; + const containerPath = `/${mediaType}`; + lines.push(`log "Setting root folder for ${arr.name}..."`); + lines.push(`${arr.id.toUpperCase()}_KEY=$(get_api_key ${arr.id})`); + const apiVersion = arr.id === 'lidarr' || arr.id === 'readarr' ? 'v1' : 'v3'; + lines.push(`curl -sf -X POST "http://localhost:${arrPort}/api/${apiVersion}/rootfolder" \\`); + lines.push(` -H "X-Api-Key: \$${arr.id.toUpperCase()}_KEY" \\`); + lines.push(` -H "Content-Type: application/json" \\`); + lines.push(` -d '{"path": "${containerPath}"}' && log " โœ“ Root folder set" || warn " โš  Root folder may already exist"`); + lines.push(''); + } + } + + // Summary + lines.push('# === Summary ==='); + lines.push('echo ""'); + lines.push('log "========================================="'); + lines.push('log " StackPilot Setup Complete!"'); + lines.push('log "========================================="'); + lines.push('echo ""'); + lines.push('log "Your services:"'); + for (const app of selectedApps) { + if (app.ports?.length > 0) { + lines.push(`log " ${app.name}: http://localhost:${app.ports[0].host}"`); + } + } + lines.push('echo ""'); + + // Manual steps + const manualSteps = []; + if (ids.includes('tailscale')) manualSteps.push('Set your Tailscale auth key in .env (TS_AUTHKEY)'); + if (ids.includes('gluetun')) manualSteps.push('Configure your VPN provider details in .env'); + if (ids.includes('plex')) manualSteps.push('Get a Plex claim token from https://plex.tv/claim'); + if (ids.includes('jellyfin')) manualSteps.push('Complete Jellyfin setup wizard at http://localhost:8096'); + if (ids.includes('prowlarr')) manualSteps.push('Add indexers in Prowlarr at http://localhost:9696'); + + if (manualSteps.length > 0) { + lines.push('warn "Manual steps remaining:"'); + for (const step of manualSteps) { + lines.push(`warn " โ†’ ${step}"`); + } + lines.push('echo ""'); + } + + return lines.join('\n'); +} + +function capitalize(str) { + return str.charAt(0).toUpperCase() + str.slice(1); +} diff --git a/src/main.js b/src/main.js new file mode 100644 index 0000000..63bdd62 --- /dev/null +++ b/src/main.js @@ -0,0 +1,370 @@ +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 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, +}; + +// 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); + + for (const cat of categories) { + const catApps = apps.filter(a => a.category === cat.id); + 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(); + 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(); + updatePreview(); +} + +// 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 +renderAppGrid(); +initTabs(); +initConfigListeners(); +initTailscale(); +initOsSelector(); +initModeNav(); +initAnalyser(); +document.getElementById('download-btn').addEventListener('click', downloadBundle); + +// Load popularity data (async, non-blocking) +fetchPopularity().then(data => { + if (data) { + state.popularity = data; + renderAppGrid(); // Re-render with badges + } +}); + +// Mode switching (builder vs analyser) +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'; + }); + }); +} + +// Analyser +function initAnalyser() { + document.getElementById('analyse-btn').addEventListener('click', () => { + const input = document.getElementById('analyser-input').value.trim(); + if (!input) return; + const results = analyseCompose(input); + renderAnalyserResults(results); + + // Show "Show Fixed Compose" button if there are fixable issues + const fixBtn = document.getElementById('show-fixed-btn'); + const hasFixable = results.warnings.length > 0 || results.suggestions.length > 0; + fixBtn.classList.toggle('hidden', !hasFixable); + }); + + document.getElementById('show-fixed-btn').addEventListener('click', () => { + const input = document.getElementById('analyser-input').value.trim(); + if (!input) return; + const fixed = fixCompose(input); + document.getElementById('fixed-content').textContent = fixed; + document.getElementById('fixed-preview').classList.remove('hidden'); + }); + + 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); + }); +} + +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); + } +} diff --git a/src/style.css b/src/style.css new file mode 100644 index 0000000..c3284d6 --- /dev/null +++ b/src/style.css @@ -0,0 +1,505 @@ +:root { + --bg: #09090b; + --surface: #131318; + --surface-hover: #1c1c24; + --surface-raised: #18181f; + --border: #23232d; + --border-light: #2d2d3a; + --text: #f4f4f5; + --text-dim: #a1a1aa; + --text-muted: #71717a; + --accent: #6366f1; + --accent-hover: #818cf8; + --accent-glow: rgba(99, 102, 241, 0.15); + --success: #22c55e; + --warning: #f59e0b; + --danger: #ef4444; + --radius: 12px; + --radius-sm: 8px; + --radius-lg: 16px; + --shadow-sm: 0 1px 2px rgba(0,0,0,0.3); + --shadow-md: 0 4px 12px rgba(0,0,0,0.4); + --shadow-lg: 0 8px 32px rgba(0,0,0,0.5); + --gradient-accent: linear-gradient(135deg, #6366f1, #8b5cf6); + --gradient-surface: linear-gradient(180deg, rgba(99,102,241,0.03), transparent); +} + +* { box-sizing: border-box; margin: 0; padding: 0; } + +body { + font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif; + background: var(--bg); + color: var(--text); + line-height: 1.6; + min-height: 100vh; + -webkit-font-smoothing: antialiased; +} + +/* Header */ +.header { + text-align: center; + padding: 4rem 1rem 2.5rem; + position: relative; + overflow: hidden; +} +.header::before { + content: ''; + position: absolute; + top: -50%; + left: 50%; + transform: translateX(-50%); + width: 800px; + height: 600px; + background: radial-gradient(ellipse, rgba(99,102,241,0.08) 0%, transparent 70%); + pointer-events: none; +} +.header h1 { + font-size: 3rem; + font-weight: 800; + letter-spacing: -0.02em; + background: var(--gradient-accent); + -webkit-background-clip: text; + -webkit-text-fill-color: transparent; + background-clip: text; + margin-bottom: 0.5rem; +} +.subtitle { color: var(--text-dim); font-size: 1.15rem; font-weight: 400; } + +/* Mode Nav */ +.mode-nav { + display: flex; + justify-content: center; + gap: 0.25rem; + margin-top: 1.75rem; + background: var(--surface); + border: 1px solid var(--border); + border-radius: 999px; + padding: 0.25rem; + display: inline-flex; +} +.mode-btn { + background: transparent; + border: none; + color: var(--text-dim); + padding: 0.6rem 1.5rem; + border-radius: 999px; + font-size: 0.85rem; + font-weight: 500; + cursor: pointer; + transition: all 0.2s ease; +} +.mode-btn:hover { color: var(--text); } +.mode-btn.active { + background: var(--gradient-accent); + color: white; + box-shadow: 0 2px 8px rgba(99,102,241,0.3); +} + +/* Container */ +.container { max-width: 1280px; margin: 0 auto; padding: 2.5rem 1.5rem; } + +/* Steps */ +.step { margin-bottom: 3.5rem; } +.step h2 { + font-size: 1.3rem; + font-weight: 700; + margin-bottom: 1.25rem; + padding-bottom: 0.75rem; + border-bottom: 1px solid var(--border); + color: var(--text); + letter-spacing: -0.01em; +} + +/* OS Selector */ +.os-selector { + display: flex; + gap: 0.5rem; + margin-bottom: 1.5rem; +} +.os-option { + display: flex; + align-items: center; + cursor: pointer; +} +.os-option input[type="radio"] { display: none; } +.os-label { + background: var(--surface); + border: 1px solid var(--border); + border-radius: var(--radius-sm); + padding: 0.6rem 1.25rem; + font-size: 0.85rem; + font-weight: 500; + transition: all 0.2s ease; + user-select: none; +} +.os-label:hover { border-color: var(--accent); color: var(--accent-hover); } +.os-option input[type="radio"]:checked + .os-label { + border-color: var(--accent); + background: var(--accent-glow); + color: var(--accent-hover); + box-shadow: 0 0 0 1px var(--accent); +} + +/* Config Grid */ +.config-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(260px, 1fr)); + gap: 1rem; +} +.config-grid label { display: flex; flex-direction: column; gap: 0.4rem; } +.config-grid label span { + font-size: 0.8rem; + font-weight: 500; + color: var(--text-dim); + text-transform: uppercase; + letter-spacing: 0.04em; +} +.config-grid input { + background: var(--surface); + border: 1px solid var(--border); + color: var(--text); + padding: 0.7rem 1rem; + border-radius: var(--radius-sm); + font-size: 0.9rem; + transition: all 0.2s ease; +} +.config-grid input:focus { + outline: none; + border-color: var(--accent); + box-shadow: 0 0 0 3px var(--accent-glow); +} + +/* App Grid */ +.category-group { margin-bottom: 2.5rem; } +.category-header { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 1rem; +} +.category-title { + font-size: 1rem; + font-weight: 600; + color: var(--text-dim); + margin: 0; +} +.category-select-all { + background: transparent; + border: 1px solid var(--border); + color: var(--text-muted); + padding: 0.3rem 0.75rem; + border-radius: 999px; + font-size: 0.7rem; + font-weight: 500; + cursor: pointer; + transition: all 0.2s ease; + text-transform: uppercase; + letter-spacing: 0.03em; +} +.category-select-all:hover { border-color: var(--accent); color: var(--accent); } +.selection-badge { + display: inline-flex; + align-items: center; + gap: 0.5rem; + background: var(--gradient-accent); + color: white; + padding: 0.5rem 1.25rem; + border-radius: 999px; + font-size: 0.85rem; + font-weight: 600; + margin-bottom: 1.5rem; + box-shadow: 0 2px 8px rgba(99,102,241,0.3); + transition: all 0.25s ease; +} +.selection-badge.hidden { opacity: 0; transform: scale(0.9); pointer-events: none; } +.category-apps { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(240px, 1fr)); + gap: 0.75rem; +} + +/* App Card */ +.app-card { + background: var(--surface); + border: 1px solid var(--border); + border-radius: var(--radius); + padding: 1rem 1.1rem; + cursor: pointer; + transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1); + user-select: none; + position: relative; + overflow: hidden; +} +.app-card::before { + content: ''; + position: absolute; + top: 0; left: 0; right: 0; + height: 2px; + background: var(--gradient-accent); + opacity: 0; + transition: opacity 0.2s ease; +} +.app-card:hover { + border-color: var(--border-light); + background: var(--surface-hover); + transform: translateY(-2px); + box-shadow: var(--shadow-md); +} +.app-card:hover::before { opacity: 0.5; } +.app-card.selected { + border-color: var(--accent); + background: var(--accent-glow); + transform: translateY(-2px); + box-shadow: 0 4px 16px rgba(99,102,241,0.15); +} +.app-card.selected::before { opacity: 1; } +.app-card .app-name { font-weight: 600; font-size: 0.9rem; margin-bottom: 0.3rem; } +.app-card .app-desc { font-size: 0.78rem; color: var(--text-muted); line-height: 1.4; } +.app-card .app-link { + display: inline-block; + margin-top: 0.5rem; + font-size: 0.7rem; + font-weight: 500; + color: var(--accent); + text-decoration: none; + opacity: 0.6; + transition: opacity 0.15s ease; +} +.app-card .app-link:hover { opacity: 1; text-decoration: underline; } +.app-card .app-check { + float: right; + width: 22px; height: 22px; + border: 2px solid var(--border-light); + border-radius: 6px; + display: flex; align-items: center; justify-content: center; + font-size: 0.7rem; + transition: all 0.2s ease; +} +.app-card.selected .app-check { + background: var(--gradient-accent); + border-color: var(--accent); + color: white; + box-shadow: 0 2px 4px rgba(99,102,241,0.3); +} +.pop-badge { + display: inline-block; + font-size: 0.6rem; + font-weight: 600; + background: rgba(245, 158, 11, 0.12); + color: var(--warning); + padding: 0.15rem 0.45rem; + border-radius: 999px; + margin-left: 0.4rem; + vertical-align: middle; +} + +/* Tailscale Config */ +.tailscale-config { + background: var(--surface); + border: 1px solid var(--border); + border-radius: var(--radius); + padding: 1.5rem; +} +.toggle-row { + display: flex; + align-items: center; + gap: 0.75rem; + cursor: pointer; + margin-bottom: 0.75rem; + font-size: 0.9rem; +} +.toggle-row.sub { margin-left: 1.5rem; font-size: 0.85rem; color: var(--text-dim); } +.toggle-row input[type="checkbox"] { + width: 18px; height: 18px; + accent-color: var(--accent); + cursor: pointer; +} +.tailscale-options { margin-top: 1.25rem; padding-top: 1.25rem; border-top: 1px solid var(--border); } +.tailscale-options.hidden { display: none; } +.ts-warning { + margin-top: 1rem; + padding: 0.85rem 1rem; + background: rgba(245, 158, 11, 0.06); + border: 1px solid rgba(245, 158, 11, 0.2); + border-radius: var(--radius-sm); + font-size: 0.8rem; + color: var(--warning); + line-height: 1.5; +} + +/* Preview */ +.preview-area { + background: var(--surface); + border: 1px solid var(--border); + border-radius: var(--radius); + overflow: hidden; + margin-bottom: 1.25rem; + box-shadow: var(--shadow-sm); +} +.preview-tabs { + display: flex; + border-bottom: 1px solid var(--border); + background: rgba(0,0,0,0.3); + padding: 0 0.5rem; +} +.tab { + background: none; + border: none; + color: var(--text-muted); + padding: 0.85rem 1.25rem; + cursor: pointer; + font-size: 0.8rem; + font-weight: 500; + border-bottom: 2px solid transparent; + transition: all 0.2s ease; +} +.tab:hover { color: var(--text-dim); } +.tab.active { color: var(--accent-hover); border-bottom-color: var(--accent); } +.preview-code { + padding: 1.5rem; + overflow-x: auto; + font-family: 'JetBrains Mono', 'SF Mono', 'Fira Code', monospace; + font-size: 0.78rem; + line-height: 1.6; + max-height: 500px; + overflow-y: auto; + white-space: pre; + color: var(--text-dim); +} + +/* Download Button */ +.download-btn { + background: var(--gradient-accent); + color: white; + border: none; + padding: 1rem 2.5rem; + border-radius: var(--radius); + font-size: 1.05rem; + font-weight: 700; + cursor: pointer; + width: 100%; + transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1); + box-shadow: 0 4px 16px rgba(99,102,241,0.25); + letter-spacing: -0.01em; +} +.download-btn:hover:not(:disabled) { + transform: translateY(-2px); + box-shadow: 0 8px 24px rgba(99,102,241,0.35); +} +.download-btn:active:not(:disabled) { transform: translateY(0); } +.download-btn:disabled { opacity: 0.35; cursor: not-allowed; box-shadow: none; } + +/* Analyser */ +.analyser-desc { color: var(--text-dim); margin-bottom: 1.25rem; font-size: 0.9rem; } +.analyser-textarea { + width: 100%; + min-height: 280px; + background: var(--surface); + border: 1px solid var(--border); + border-radius: var(--radius); + color: var(--text); + font-family: 'JetBrains Mono', 'SF Mono', 'Fira Code', monospace; + font-size: 0.78rem; + padding: 1.25rem; + resize: vertical; + margin-bottom: 1.25rem; + line-height: 1.6; + transition: border-color 0.2s ease; +} +.analyser-textarea:focus { outline: none; border-color: var(--accent); box-shadow: 0 0 0 3px var(--accent-glow); } +.analyse-btn { + background: var(--gradient-accent); + color: white; + border: none; + padding: 0.8rem 2.25rem; + border-radius: var(--radius-sm); + font-size: 0.95rem; + font-weight: 600; + cursor: pointer; + transition: all 0.2s ease; + margin-bottom: 1.5rem; + box-shadow: 0 2px 8px rgba(99,102,241,0.2); +} +.analyse-btn:hover { transform: translateY(-1px); box-shadow: 0 4px 12px rgba(99,102,241,0.3); } +.analyse-btn.fixed-btn { + background: linear-gradient(135deg, #22c55e, #16a34a); + box-shadow: 0 2px 8px rgba(34,197,94,0.2); + margin-top: 1.5rem; +} +.analyse-btn.fixed-btn:hover { box-shadow: 0 4px 12px rgba(34,197,94,0.3); } +.hidden { display: none !important; } +.fixed-preview { + margin-top: 1.5rem; + background: var(--surface); + border: 1px solid var(--border); + border-radius: var(--radius); + overflow: hidden; + box-shadow: var(--shadow-sm); +} +.fixed-preview h3 { + padding: 1.25rem 1.5rem 0; + color: var(--success); + font-size: 0.95rem; + font-weight: 600; +} +.analyser-results { display: flex; flex-direction: column; gap: 0.6rem; } +.result-card { + background: var(--surface); + border: 1px solid var(--border); + border-left: 4px solid var(--border); + border-radius: var(--radius-sm); + padding: 0.85rem 1.1rem; + transition: all 0.15s ease; +} +.result-card:hover { background: var(--surface-hover); } +.result-card.error { border-left-color: var(--danger); } +.result-card.warning { border-left-color: var(--warning); } +.result-card.suggestion { border-left-color: var(--accent); } +.result-card.info { border-left-color: var(--success); } +.result-card .result-title { font-weight: 600; font-size: 0.85rem; margin-bottom: 0.2rem; } +.result-card.error .result-title { color: var(--danger); } +.result-card.warning .result-title { color: var(--warning); } +.result-card.suggestion .result-title { color: var(--accent-hover); } +.result-card.info .result-title { color: var(--success); } +.result-card .result-detail { font-size: 0.78rem; color: var(--text-muted); line-height: 1.5; } +.result-summary { display: flex; gap: 0.6rem; margin-bottom: 1.25rem; flex-wrap: wrap; } +.result-badge { + padding: 0.35rem 0.9rem; + border-radius: 999px; + font-size: 0.75rem; + font-weight: 600; + letter-spacing: 0.01em; +} +.result-badge.error { background: rgba(239,68,68,0.1); color: var(--danger); border: 1px solid rgba(239,68,68,0.2); } +.result-badge.warning { background: rgba(245,158,11,0.1); color: var(--warning); border: 1px solid rgba(245,158,11,0.2); } +.result-badge.suggestion { background: rgba(99,102,241,0.1); color: var(--accent-hover); border: 1px solid rgba(99,102,241,0.2); } +.result-badge.info { background: rgba(34,197,94,0.1); color: var(--success); border: 1px solid rgba(34,197,94,0.2); } + +/* Footer */ +.footer { + text-align: center; + padding: 3rem 1rem; + color: var(--text-muted); + font-size: 0.8rem; + border-top: 1px solid var(--border); +} + +/* Scrollbar */ +::-webkit-scrollbar { width: 8px; height: 8px; } +::-webkit-scrollbar-track { background: var(--bg); } +::-webkit-scrollbar-thumb { background: var(--border-light); border-radius: 4px; } +::-webkit-scrollbar-thumb:hover { background: var(--text-muted); } + +/* Responsive */ +@media (max-width: 768px) { + .header h1 { font-size: 2.2rem; } + .header { padding: 3rem 1rem 2rem; } + .category-apps { grid-template-columns: repeat(auto-fill, minmax(180px, 1fr)); } + .config-grid { grid-template-columns: 1fr; } + .mode-nav { flex-direction: column; align-items: center; } +} +@media (max-width: 480px) { + .header h1 { font-size: 1.8rem; } + .category-apps { grid-template-columns: 1fr; } + .os-selector { flex-wrap: wrap; } +} diff --git a/src/utils/analytics.js b/src/utils/analytics.js new file mode 100644 index 0000000..8a3c3a7 --- /dev/null +++ b/src/utils/analytics.js @@ -0,0 +1,37 @@ +// Lightweight analytics โ€” tracks app selection popularity +// Uses a simple JSON API. Replace the URL with your own backend. + +const ANALYTICS_URL = '/api/analytics'; + +/** + * Record which apps were included in a downloaded bundle. + * Fires on download โ€” not on selection (avoids noise). + */ +export async function trackDownload(selectedAppIds) { + try { + await fetch(`${ANALYTICS_URL}/download`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + apps: selectedAppIds, + timestamp: new Date().toISOString(), + }), + }); + } catch (e) { + // Silent fail โ€” analytics should never break the app + } +} + +/** + * Fetch popularity stats (app download counts). + * Returns: { [appId]: count } + */ +export async function fetchPopularity() { + try { + const res = await fetch(`${ANALYTICS_URL}/popularity`); + if (res.ok) return await res.json(); + } catch (e) { + // Silent fail + } + return null; +} diff --git a/vite.config.js b/vite.config.js new file mode 100644 index 0000000..99c3ae3 --- /dev/null +++ b/vite.config.js @@ -0,0 +1,12 @@ +import { defineConfig } from 'vite'; + +export default defineConfig({ + server: { + proxy: { + '/api': { + target: 'http://localhost:3052', + changeOrigin: true, + }, + }, + }, +});