Initial commit: StackPilot - Docker stack builder with 186 apps, compose analyser, analytics
This commit is contained in:
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);
|
||||
}
|
||||
Reference in New Issue
Block a user