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'); }