import fs from 'node:fs' import path from 'node:path' import posixPath from 'node:path/posix' import { buildSshArgs, sanitizeStagingProofManifest } from './run-vps-same-origin-staging-proof-lib.mjs' function normalizeTrimmed(value) { return String(value || '').trim() } function normalizeBoolean(value, fallback = false) { if (typeof value === 'boolean') { return value } const normalized = normalizeTrimmed(value).toLowerCase() if (!normalized) { return fallback } return normalized === '1' || normalized === 'true' || normalized === 'yes' || normalized === 'on' } function requireNonEmpty(value, label) { const normalized = normalizeTrimmed(value) if (!normalized) { throw new Error(`${label} is required.`) } return normalized } function requirePositiveInteger(value, label) { const parsed = Number.parseInt(String(value || ''), 10) if (!Number.isInteger(parsed) || parsed <= 0) { throw new Error(`${label} must be a positive integer.`) } return parsed } function normalizeArchiveSource(value, fallback = 'git-head') { const normalized = normalizeTrimmed(value).toLowerCase() if (normalized === 'worktree') { return 'worktree' } if (normalized === 'git' || normalized === 'head' || normalized === 'git-head') { return 'git-head' } return fallback } export function parseLiveDeployArgs(argv) { const options = { manifest: '', identityFile: '', vpsHost: '212.227.13.220', vpsUser: 'root', archiveSource: 'git-head', bundleDir: '', healthUrl: '', waitSeconds: '30', keepBundleDir: false, skipLiveValidation: false, json: false, dryRun: false, } for (let index = 0; index < argv.length; index += 1) { const argument = argv[index] const next = argv[index + 1] || '' switch (argument) { case '--manifest': options.manifest = next index += 1 break case '--identity-file': options.identityFile = next index += 1 break case '--vps-host': options.vpsHost = next index += 1 break case '--vps-user': options.vpsUser = next index += 1 break case '--archive-source': options.archiveSource = next index += 1 break case '--bundle-dir': options.bundleDir = next index += 1 break case '--health-url': options.healthUrl = next index += 1 break case '--wait-seconds': options.waitSeconds = next index += 1 break case '--keep-bundle-dir': options.keepBundleDir = true break case '--skip-live-validation': options.skipLiveValidation = true break case '--json': options.json = true break case '--dry-run': options.dryRun = true break default: break } } return options } export function loadLiveDeployManifest(filePath) { const absolutePath = path.resolve(filePath) const raw = fs.readFileSync(absolutePath, 'utf8') return { path: absolutePath, manifest: JSON.parse(raw), } } export function buildDefaultLiveDeployBundleDir(gitRef = 'head') { const normalized = normalizeTrimmed(gitRef).replace(/[^a-zA-Z0-9._-]/g, '-') return `/tmp/hypertwist-live-deploy-${normalized || 'head'}` } export function resolveLiveDeployOptions({ manifestPath, identityFile, vpsHost, vpsUser, archiveSource, bundleDir, healthUrl, waitSeconds, keepBundleDir, skipLiveValidation, json, dryRun, manifest, gitRef, }) { const checkoutRoot = requireNonEmpty(manifest?.checkoutRoot || '/srv/hypertwist/current', 'manifest.checkoutRoot') const publicOrigin = requireNonEmpty(manifest?.publicOrigin || '', 'manifest.publicOrigin') return { manifestPath: path.resolve(requireNonEmpty(manifestPath, 'manifestPath')), identityFile: path.resolve(requireNonEmpty(identityFile, 'identityFile')), vpsHost: requireNonEmpty(vpsHost || '212.227.13.220', 'vpsHost'), vpsUser: requireNonEmpty(vpsUser || 'root', 'vpsUser'), archiveSource: normalizeArchiveSource(archiveSource, 'git-head'), bundleDir: normalizeTrimmed(bundleDir) || buildDefaultLiveDeployBundleDir(gitRef), checkoutRoot, healthUrl: normalizeTrimmed(healthUrl) || publicOrigin, waitSeconds: requirePositiveInteger(waitSeconds || '30', 'waitSeconds'), keepBundleDir: normalizeBoolean(keepBundleDir, false), skipLiveValidation: normalizeBoolean(skipLiveValidation, false), json: normalizeBoolean(json, false), dryRun: normalizeBoolean(dryRun, false), } } export function buildStageCheckoutRemoteCommand(checkoutRoot) { const parentDir = posixPath.dirname(checkoutRoot) return `rm -rf ${JSON.stringify(checkoutRoot)} && mkdir -p ${JSON.stringify(parentDir)} ${JSON.stringify(checkoutRoot)} && tar -xf - -C ${JSON.stringify(checkoutRoot)}` } export function buildStageBundleRemoteCommand(bundleDir) { return `rm -rf ${JSON.stringify(bundleDir)} && mkdir -p ${JSON.stringify(bundleDir)} && tar -xf - -C ${JSON.stringify(bundleDir)}` } export function sanitizeLiveDeployManifest(manifest) { return sanitizeStagingProofManifest(manifest) } export function buildRemoteLiveDeployScript({ checkoutRoot, bundleDir, keepBundleDir, manifest, }) { const serviceUser = normalizeTrimmed(manifest?.serviceUser || 'hypertwist') const serviceGroup = normalizeTrimmed(manifest?.serviceGroup || serviceUser) const billingStatePath = normalizeTrimmed(manifest?.server?.billingStatePath || '/var/lib/hypertwist/auth/hypertwist-billing-state.json') const billingStateDir = posixPath.dirname(billingStatePath) return `set -euo pipefail CHECKOUT_ROOT=${JSON.stringify(checkoutRoot)} BUNDLE_DIR=${JSON.stringify(bundleDir)} SERVICE_USER=${JSON.stringify(serviceUser)} SERVICE_GROUP=${JSON.stringify(serviceGroup)} BILLING_STATE_DIR=${JSON.stringify(billingStateDir)} KEEP_BUNDLE_DIR=${keepBundleDir ? '1' : '0'} SERVICE_NAME="hypertwist-website-auth-server.service" NGINX_CONF_NAME="hypertwist-app.conf" STAMP=$(date -u +%Y%m%dT%H%M%SZ) CHECKOUT_PARENT=$(dirname "$CHECKOUT_ROOT") WEBSITE_DIR="$CHECKOUT_ROOT/website" SERVER_DIR="$WEBSITE_DIR/server" NGINX_BACKUP="" PLACEHOLDER_BACKUP="" cleanup() { if [ "$KEEP_BUNDLE_DIR" = "1" ]; then return fi rm -rf "$BUNDLE_DIR" } trap cleanup EXIT if ! getent group "$SERVICE_GROUP" >/dev/null 2>&1; then groupadd --system "$SERVICE_GROUP" fi if ! id "$SERVICE_USER" >/dev/null 2>&1; then useradd --system --create-home --home-dir "$CHECKOUT_PARENT" --shell /usr/sbin/nologin --gid "$SERVICE_GROUP" "$SERVICE_USER" fi install -d -o "$SERVICE_USER" -g "$SERVICE_GROUP" "$CHECKOUT_PARENT" "$CHECKOUT_ROOT" "$(dirname "$BILLING_STATE_DIR")" "$BILLING_STATE_DIR" cp "$BUNDLE_DIR/website.env" "$WEBSITE_DIR/.env" cp "$BUNDLE_DIR/server.env" "$SERVER_DIR/.env" chown -R "$SERVICE_USER:$SERVICE_GROUP" "$CHECKOUT_ROOT" "$(dirname "$BILLING_STATE_DIR")" runuser -u "$SERVICE_USER" -- bash -lc "cd $(printf '%q' "$WEBSITE_DIR") && npm ci && npm --prefix server ci && npm run build" systemd-analyze verify "$BUNDLE_DIR/hypertwist-website-auth-server.service" if [ -f "/etc/nginx/sites-available/$NGINX_CONF_NAME" ]; then NGINX_BACKUP="/etc/nginx/sites-available/$NGINX_CONF_NAME.bak-$STAMP" cp "/etc/nginx/sites-available/$NGINX_CONF_NAME" "$NGINX_BACKUP" fi if [ -f "/var/www/hypertwist/index.html" ]; then PLACEHOLDER_BACKUP="/var/www/hypertwist/index.html.pre-same-origin-$STAMP" cp "/var/www/hypertwist/index.html" "$PLACEHOLDER_BACKUP" fi cp "$BUNDLE_DIR/hypertwist-website-auth-server.service" "/etc/systemd/system/$SERVICE_NAME" cp "$BUNDLE_DIR/hypertwist.app.conf" "/etc/nginx/sites-available/$NGINX_CONF_NAME" ln -sf "/etc/nginx/sites-available/$NGINX_CONF_NAME" "/etc/nginx/sites-enabled/$NGINX_CONF_NAME" systemctl daemon-reload systemctl enable "$SERVICE_NAME" systemctl restart "$SERVICE_NAME" nginx -t systemctl reload nginx SERVICE_STATUS=$(systemctl is-active "$SERVICE_NAME") NGINX_STATUS=$(systemctl is-active nginx) node - "$CHECKOUT_ROOT" "$BUNDLE_DIR" "$SERVICE_STATUS" "$NGINX_STATUS" "$NGINX_BACKUP" "$PLACEHOLDER_BACKUP" <<'__HYPERTWIST_LIVE_DEPLOY_SUMMARY__' const checkoutRoot = process.argv[2] const bundleDir = process.argv[3] const serviceStatus = process.argv[4] const nginxStatus = process.argv[5] const nginxBackup = process.argv[6] const placeholderBackup = process.argv[7] const summary = { ok: serviceStatus === 'active' && nginxStatus === 'active', checkoutRoot, bundleDir, serviceStatus, nginxStatus, nginxBackup: nginxBackup || null, placeholderBackup: placeholderBackup || null, } process.stdout.write(\`__HYPERTWIST_LIVE_DEPLOY_SUMMARY__\${JSON.stringify(summary)}\\n\`) if (!summary.ok) { process.exit(1) } __HYPERTWIST_LIVE_DEPLOY_SUMMARY__ ` } export function extractLiveDeploySummary(output) { const marker = '__HYPERTWIST_LIVE_DEPLOY_SUMMARY__' const lines = String(output || '').split('\n') const summaryLine = [...lines].reverse().find((line) => line.startsWith(marker)) if (!summaryLine) { return null } return JSON.parse(summaryLine.slice(marker.length)) } export { buildSshArgs }