Initial commit: StackPilot - Docker stack builder with 186 apps, compose analyser, analytics
This commit is contained in:
24
.gitignore
vendored
Normal file
24
.gitignore
vendored
Normal file
@@ -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?
|
||||
119
README.md
Normal file
119
README.md
Normal file
@@ -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
|
||||
855
api/package-lock.json
generated
Normal file
855
api/package-lock.json
generated
Normal file
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
12
api/package.json
Normal file
12
api/package.json
Normal file
@@ -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"
|
||||
}
|
||||
}
|
||||
115
api/server.js
Normal file
115
api/server.js
Normal file
@@ -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}`);
|
||||
});
|
||||
137
index.html
Normal file
137
index.html
Normal file
@@ -0,0 +1,137 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>StackPilot — Docker Stack Builder</title>
|
||||
<link rel="icon" href="/favicon.svg" />
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet" />
|
||||
<link rel="stylesheet" href="/src/style.css" />
|
||||
</head>
|
||||
<body>
|
||||
<header class="header">
|
||||
<h1>🚀 StackPilot</h1>
|
||||
<p class="subtitle">Build your self-hosted Docker stack in minutes</p>
|
||||
<nav class="mode-nav">
|
||||
<button class="mode-btn active" data-mode="builder">Stack Builder</button>
|
||||
<button class="mode-btn" data-mode="analyser">Compose Analyser</button>
|
||||
</nav>
|
||||
</header>
|
||||
|
||||
<main class="container">
|
||||
<!-- Step 1: Global Config -->
|
||||
<section class="step" id="step-config">
|
||||
<h2>1. Configure Paths</h2>
|
||||
<div class="os-selector">
|
||||
<label class="os-option">
|
||||
<input type="radio" name="os" value="linux" checked />
|
||||
<span class="os-label">🐧 Linux</span>
|
||||
</label>
|
||||
<label class="os-option">
|
||||
<input type="radio" name="os" value="macos" />
|
||||
<span class="os-label">🍎 macOS</span>
|
||||
</label>
|
||||
<label class="os-option">
|
||||
<input type="radio" name="os" value="windows" />
|
||||
<span class="os-label">🪟 Windows</span>
|
||||
</label>
|
||||
</div>
|
||||
<div class="config-grid">
|
||||
<label>
|
||||
<span>Config path</span>
|
||||
<input type="text" id="config-path" value="/opt/stackpilot/config" placeholder="/opt/stackpilot/config" />
|
||||
</label>
|
||||
<label>
|
||||
<span>Media path</span>
|
||||
<input type="text" id="media-path" value="/mnt/media" placeholder="/mnt/media" />
|
||||
</label>
|
||||
<label>
|
||||
<span>Downloads path</span>
|
||||
<input type="text" id="downloads-path" value="/mnt/downloads" placeholder="/mnt/downloads" />
|
||||
</label>
|
||||
<label>
|
||||
<span>Timezone</span>
|
||||
<input type="text" id="timezone" value="Europe/Dublin" placeholder="Europe/London" />
|
||||
</label>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Step 2: App Selection -->
|
||||
<section class="step" id="step-apps">
|
||||
<h2>2. Select Apps</h2>
|
||||
<div id="app-grid"></div>
|
||||
</section>
|
||||
|
||||
<!-- Step 3: Tailscale Remote Access -->
|
||||
<section class="step" id="step-tailscale">
|
||||
<h2>3. Remote Access (Optional)</h2>
|
||||
<div class="tailscale-config">
|
||||
<label class="toggle-row">
|
||||
<input type="checkbox" id="tailscale-enable" />
|
||||
<span>Enable Tailscale — access your services from anywhere</span>
|
||||
</label>
|
||||
<div class="tailscale-options hidden" id="tailscale-options">
|
||||
<div class="config-grid">
|
||||
<label>
|
||||
<span>Tailscale Auth Key</span>
|
||||
<input type="text" id="ts-authkey" placeholder="tskey-auth-..." />
|
||||
</label>
|
||||
<label>
|
||||
<span>Hostname</span>
|
||||
<input type="text" id="ts-hostname" value="stackpilot" placeholder="stackpilot" />
|
||||
</label>
|
||||
</div>
|
||||
<label class="toggle-row sub">
|
||||
<input type="checkbox" id="ts-funnel" />
|
||||
<span>Enable Tailscale Funnel (expose selected services to the internet)</span>
|
||||
</label>
|
||||
<label class="toggle-row sub">
|
||||
<input type="checkbox" id="ts-subnet" checked />
|
||||
<span>Advertise as subnet router (access all services via Tailscale IP)</span>
|
||||
</label>
|
||||
<div class="ts-warning">
|
||||
⚠️ Security note: Tailscale Funnel exposes services publicly. Subnet routing only shares with your Tailnet (your devices).
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Step 4: Preview & Download -->
|
||||
<section class="step" id="step-preview">
|
||||
<h2>4. Preview & Download</h2>
|
||||
<div class="preview-area">
|
||||
<div class="preview-tabs">
|
||||
<button class="tab active" data-tab="compose">docker-compose.yml</button>
|
||||
<button class="tab" data-tab="env">.env</button>
|
||||
<button class="tab" data-tab="setup">setup.sh</button>
|
||||
</div>
|
||||
<pre class="preview-code"><code id="preview-content">Select apps above to see preview...</code></pre>
|
||||
</div>
|
||||
<button class="download-btn" id="download-btn" disabled>📦 Download Stack Bundle (.zip)</button>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<footer class="footer">
|
||||
<p>StackPilot — No server required. Everything runs in your browser.</p>
|
||||
</footer>
|
||||
|
||||
<!-- Compose Analyser Modal/Section -->
|
||||
<section class="step" id="step-analyser" style="display:none;">
|
||||
<h2>🔍 Analyse Existing Docker Compose</h2>
|
||||
<p class="analyser-desc">Paste your existing docker-compose.yml below to get security, performance, and best-practice recommendations.</p>
|
||||
<textarea id="analyser-input" class="analyser-textarea" placeholder="Paste your docker-compose.yml here..."></textarea>
|
||||
<button class="analyse-btn" id="analyse-btn">Analyse</button>
|
||||
<div id="analyser-results" class="analyser-results"></div>
|
||||
<button class="analyse-btn fixed-btn hidden" id="show-fixed-btn">✨ Show Fixed Compose</button>
|
||||
<div id="fixed-preview" class="fixed-preview hidden">
|
||||
<h3>Improved docker-compose.yml</h3>
|
||||
<pre class="preview-code"><code id="fixed-content"></code></pre>
|
||||
<button class="download-btn" id="download-fixed-btn" style="margin-top:1rem;">📦 Download Fixed Compose</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<script type="module" src="/src/main.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
931
package-lock.json
generated
Normal file
931
package-lock.json
generated
Normal file
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
18
package.json
Normal file
18
package.json
Normal file
@@ -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"
|
||||
}
|
||||
}
|
||||
1
public/favicon.svg
Normal file
1
public/favicon.svg
Normal file
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 9.3 KiB |
24
public/icons.svg
Normal file
24
public/icons.svg
Normal file
@@ -0,0 +1,24 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg">
|
||||
<symbol id="bluesky-icon" viewBox="0 0 16 17">
|
||||
<g clip-path="url(#bluesky-clip)"><path fill="#08060d" d="M7.75 7.735c-.693-1.348-2.58-3.86-4.334-5.097-1.68-1.187-2.32-.981-2.74-.79C.188 2.065.1 2.812.1 3.251s.241 3.602.398 4.13c.52 1.744 2.367 2.333 4.07 2.145-2.495.37-4.71 1.278-1.805 4.512 3.196 3.309 4.38-.71 4.987-2.746.608 2.036 1.307 5.91 4.93 2.746 2.72-2.746.747-4.143-1.747-4.512 1.702.189 3.55-.4 4.07-2.145.156-.528.397-3.691.397-4.13s-.088-1.186-.575-1.406c-.42-.19-1.06-.395-2.741.79-1.755 1.24-3.64 3.752-4.334 5.099"/></g>
|
||||
<defs><clipPath id="bluesky-clip"><path fill="#fff" d="M.1.85h15.3v15.3H.1z"/></clipPath></defs>
|
||||
</symbol>
|
||||
<symbol id="discord-icon" viewBox="0 0 20 19">
|
||||
<path fill="#08060d" d="M16.224 3.768a14.5 14.5 0 0 0-3.67-1.153c-.158.286-.343.67-.47.976a13.5 13.5 0 0 0-4.067 0c-.128-.306-.317-.69-.476-.976A14.4 14.4 0 0 0 3.868 3.77C1.546 7.28.916 10.703 1.231 14.077a14.7 14.7 0 0 0 4.5 2.306q.545-.748.965-1.587a9.5 9.5 0 0 1-1.518-.74q.191-.14.372-.293c2.927 1.369 6.107 1.369 8.999 0q.183.152.372.294-.723.437-1.52.74.418.838.963 1.588a14.6 14.6 0 0 0 4.504-2.308c.37-3.911-.63-7.302-2.644-10.309m-9.13 8.234c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.894 0 1.614.82 1.599 1.82.001 1-.705 1.82-1.6 1.82m5.91 0c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.893 0 1.614.82 1.599 1.82 0 1-.706 1.82-1.6 1.82"/>
|
||||
</symbol>
|
||||
<symbol id="documentation-icon" viewBox="0 0 21 20">
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="m15.5 13.333 1.533 1.322c.645.555.967.833.967 1.178s-.322.623-.967 1.179L15.5 18.333m-3.333-5-1.534 1.322c-.644.555-.966.833-.966 1.178s.322.623.966 1.179l1.534 1.321"/>
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M17.167 10.836v-4.32c0-1.41 0-2.117-.224-2.68-.359-.906-1.118-1.621-2.08-1.96-.599-.21-1.349-.21-2.848-.21-2.623 0-3.935 0-4.983.369-1.684.591-3.013 1.842-3.641 3.428C3 6.449 3 7.684 3 10.154v2.122c0 2.558 0 3.838.706 4.726q.306.383.713.671c.76.536 1.79.64 3.581.66"/>
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M3 10a2.78 2.78 0 0 1 2.778-2.778c.555 0 1.209.097 1.748-.047.48-.129.854-.503.982-.982.145-.54.048-1.194.048-1.749a2.78 2.78 0 0 1 2.777-2.777"/>
|
||||
</symbol>
|
||||
<symbol id="github-icon" viewBox="0 0 19 19">
|
||||
<path fill="#08060d" fill-rule="evenodd" d="M9.356 1.85C5.05 1.85 1.57 5.356 1.57 9.694a7.84 7.84 0 0 0 5.324 7.44c.387.079.528-.168.528-.376 0-.182-.013-.805-.013-1.454-2.165.467-2.616-.935-2.616-.935-.349-.91-.864-1.143-.864-1.143-.71-.48.051-.48.051-.48.787.051 1.2.805 1.2.805.695 1.194 1.817.857 2.268.649.064-.507.27-.857.49-1.052-1.728-.182-3.545-.857-3.545-3.87 0-.857.31-1.558.8-2.104-.078-.195-.349-1 .077-2.078 0 0 .657-.208 2.14.805a7.5 7.5 0 0 1 1.946-.26c.657 0 1.328.092 1.946.26 1.483-1.013 2.14-.805 2.14-.805.426 1.078.155 1.883.078 2.078.502.546.799 1.247.799 2.104 0 3.013-1.818 3.675-3.558 3.87.284.247.528.714.528 1.454 0 1.052-.012 1.896-.012 2.156 0 .208.142.455.528.377a7.84 7.84 0 0 0 5.324-7.441c.013-4.338-3.48-7.844-7.773-7.844" clip-rule="evenodd"/>
|
||||
</symbol>
|
||||
<symbol id="social-icon" viewBox="0 0 20 20">
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M12.5 6.667a4.167 4.167 0 1 0-8.334 0 4.167 4.167 0 0 0 8.334 0"/>
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M2.5 16.667a5.833 5.833 0 0 1 8.75-5.053m3.837.474.513 1.035c.07.144.257.282.414.309l.93.155c.596.1.736.536.307.965l-.723.73a.64.64 0 0 0-.152.531l.207.903c.164.715-.213.991-.84.618l-.872-.52a.63.63 0 0 0-.577 0l-.872.52c-.624.373-1.003.094-.84-.618l.207-.903a.64.64 0 0 0-.152-.532l-.723-.729c-.426-.43-.289-.864.306-.964l.93-.156a.64.64 0 0 0 .412-.31l.513-1.034c.28-.562.735-.562 1.012 0"/>
|
||||
</symbol>
|
||||
<symbol id="x-icon" viewBox="0 0 19 19">
|
||||
<path fill="#08060d" fill-rule="evenodd" d="M1.893 1.98c.052.072 1.245 1.769 2.653 3.77l2.892 4.114c.183.261.333.48.333.486s-.068.089-.152.183l-.522.593-.765.867-3.597 4.087c-.375.426-.734.834-.798.905a1 1 0 0 0-.118.148c0 .01.236.017.664.017h.663l.729-.83c.4-.457.796-.906.879-.999a692 692 0 0 0 1.794-2.038c.034-.037.301-.34.594-.675l.551-.624.345-.392a7 7 0 0 1 .34-.374c.006 0 .93 1.306 2.052 2.903l2.084 2.965.045.063h2.275c1.87 0 2.273-.003 2.266-.021-.008-.02-1.098-1.572-3.894-5.547-2.013-2.862-2.28-3.246-2.273-3.266.008-.019.282-.332 2.085-2.38l2-2.274 1.567-1.782c.022-.028-.016-.03-.65-.03h-.674l-.3.342a871 871 0 0 1-1.782 2.025c-.067.075-.405.458-.75.852a100 100 0 0 1-.803.91c-.148.172-.299.344-.99 1.127-.304.343-.32.358-.345.327-.015-.019-.904-1.282-1.976-2.808L6.365 1.85H1.8zm1.782.91 8.078 11.294c.772 1.08 1.413 1.973 1.425 1.984.016.017.241.02 1.05.017l1.03-.004-2.694-3.766L7.796 5.75 5.722 2.852l-1.039-.004-1.039-.004z" clip-rule="evenodd"/>
|
||||
</symbol>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 4.9 KiB |
1193
src/catalog/apps.js
Normal file
1193
src/catalog/apps.js
Normal file
File diff suppressed because it is too large
Load Diff
316
src/generators/analyser.js
Normal file
316
src/generators/analyser.js
Normal file
@@ -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 });
|
||||
}
|
||||
156
src/generators/compose.js
Normal file
156
src/generators/compose.js
Normal file
@@ -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');
|
||||
}
|
||||
217
src/generators/setup.js
Normal file
217
src/generators/setup.js
Normal file
@@ -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 \'<ApiKey>\\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);
|
||||
}
|
||||
370
src/main.js
Normal file
370
src/main.js
Normal file
@@ -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 ? `<span class="pop-badge" title="${popCount} downloads">🔥 ${popCount}</span>` : '';
|
||||
card.innerHTML = `
|
||||
<div class="app-check">${state.selected.has(app.id) ? '✓' : ''}</div>
|
||||
<div class="app-name">${app.name} ${popBadge}</div>
|
||||
<div class="app-desc">${app.description}</div>
|
||||
${app.url ? `<a class="app-link" href="${app.url}" target="_blank" rel="noopener" onclick="event.stopPropagation()">GitHub →</a>` : ''}
|
||||
`;
|
||||
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 += `<span class="result-badge error">❌ ${results.errors.length} Error${results.errors.length > 1 ? 's' : ''}</span>`;
|
||||
if (results.warnings.length) summary.innerHTML += `<span class="result-badge warning">⚠️ ${results.warnings.length} Warning${results.warnings.length > 1 ? 's' : ''}</span>`;
|
||||
if (results.suggestions.length) summary.innerHTML += `<span class="result-badge suggestion">💡 ${results.suggestions.length} Suggestion${results.suggestions.length > 1 ? 's' : ''}</span>`;
|
||||
if (results.info.length) summary.innerHTML += `<span class="result-badge info">ℹ️ ${results.info.length} Info</span>`;
|
||||
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 = `<div class="result-title">${item.title}</div><div class="result-detail">${item.detail}</div>`;
|
||||
container.appendChild(card);
|
||||
}
|
||||
}
|
||||
505
src/style.css
Normal file
505
src/style.css
Normal file
@@ -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; }
|
||||
}
|
||||
37
src/utils/analytics.js
Normal file
37
src/utils/analytics.js
Normal file
@@ -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;
|
||||
}
|
||||
12
vite.config.js
Normal file
12
vite.config.js
Normal file
@@ -0,0 +1,12 @@
|
||||
import { defineConfig } from 'vite';
|
||||
|
||||
export default defineConfig({
|
||||
server: {
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: 'http://localhost:3052',
|
||||
changeOrigin: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user