157 lines
4.5 KiB
JavaScript
157 lines
4.5 KiB
JavaScript
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');
|
|
}
|