Initial commit: StackPilot - Docker stack builder with 186 apps, compose analyser, analytics

This commit is contained in:
2026-08-03 23:28:15 +01:00
commit 341700ddac
18 changed files with 5042 additions and 0 deletions

156
src/generators/compose.js Normal file
View 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');
}