import fs from 'node:fs' import path from 'node:path' function normalizeTrimmed(value) { return String(value || '').trim() } function normalizeServerNames(serverNames) { const names = Array.isArray(serverNames) ? serverNames : String(serverNames || '').split(',') const normalized = names .map((item) => normalizeTrimmed(item)) .filter(Boolean) if (normalized.length === 0) { throw new Error('At least one server name is required.') } return normalized } function normalizePositiveInteger(value, label) { const parsed = Number(value) if (!Number.isInteger(parsed) || parsed <= 0) { throw new Error(`${label} must be a positive integer.`) } return parsed } export function resolveDeploymentRenderOptions(options = {}) { const checkoutRoot = normalizeTrimmed(options.checkoutRoot) if (!checkoutRoot) { throw new Error('checkoutRoot is required.') } const serviceUser = normalizeTrimmed(options.serviceUser || 'hypertwist') const serviceGroup = normalizeTrimmed(options.serviceGroup || serviceUser) const serverNames = normalizeServerNames(options.serverNames || ['hypertwist.app', 'www.hypertwist.app']) const certificateName = normalizeTrimmed(options.certificateName || serverNames[0]) const upstreamPort = normalizePositiveInteger(options.upstreamPort || 3011, 'upstreamPort') const upstreamName = normalizeTrimmed(options.upstreamName || 'hypertwist_website_auth_server') const websiteRoot = normalizeTrimmed(options.websiteRoot || path.join(checkoutRoot, 'website')) const serverRoot = normalizeTrimmed(options.serverRoot || path.join(websiteRoot, 'server')) const environmentFile = normalizeTrimmed(options.environmentFile || path.join(serverRoot, '.env')) const nodeEnv = normalizeTrimmed(options.nodeEnv || 'production') return { checkoutRoot, websiteRoot, serverRoot, environmentFile, serviceUser, serviceGroup, nodeEnv, serverNames, certificateName, upstreamPort, upstreamName, } } export function renderSystemdService(options = {}) { const resolved = resolveDeploymentRenderOptions(options) return `[Unit] Description=HyperTwist same-origin website and auth server After=network-online.target Wants=network-online.target [Service] Type=simple User=${resolved.serviceUser} Group=${resolved.serviceGroup} WorkingDirectory=${resolved.serverRoot} Environment=NODE_ENV=${resolved.nodeEnv} EnvironmentFile=${resolved.environmentFile} ExecStart=/usr/bin/npm start Restart=always RestartSec=5 TimeoutStopSec=20 KillSignal=SIGTERM StandardOutput=journal StandardError=journal [Install] WantedBy=multi-user.target ` } export function renderNginxConfig(options = {}) { const resolved = resolveDeploymentRenderOptions(options) const joinedServerNames = resolved.serverNames.join(' ') return `map $http_upgrade $connection_upgrade { default upgrade; '' close; } upstream ${resolved.upstreamName} { server 127.0.0.1:${resolved.upstreamPort}; keepalive 32; } server { listen 80; listen [::]:80; server_name ${joinedServerNames}; return 301 https://$host$request_uri; } server { listen 443 ssl http2; listen [::]:443 ssl http2; server_name ${joinedServerNames}; ssl_certificate /etc/letsencrypt/live/${resolved.certificateName}/fullchain.pem; ssl_certificate_key /etc/letsencrypt/live/${resolved.certificateName}/privkey.pem; include /etc/letsencrypt/options-ssl-nginx.conf; ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem; client_max_body_size 10m; location / { proxy_pass http://${resolved.upstreamName}; proxy_http_version 1.1; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto https; proxy_set_header X-Forwarded-Host $host; proxy_set_header X-Forwarded-Port 443; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection $connection_upgrade; proxy_read_timeout 60s; proxy_send_timeout 60s; } } ` } export function writeRenderedDeploymentFiles({ systemdOut, nginxOut, ...options }) { const systemdContent = renderSystemdService(options) const nginxContent = renderNginxConfig(options) if (systemdOut) { fs.mkdirSync(path.dirname(systemdOut), { recursive: true }) fs.writeFileSync(systemdOut, systemdContent, 'utf8') } if (nginxOut) { fs.mkdirSync(path.dirname(nginxOut), { recursive: true }) fs.writeFileSync(nginxOut, nginxContent, 'utf8') } return { systemdContent, nginxContent, } }