#!/usr/bin/env node import fs from 'node:fs' import os from 'node:os' import path from 'node:path' import { execFileSync, spawnSync } from 'node:child_process' import { fileURLToPath } from 'node:url' import { buildRuntimeReadinessReport, fetchLiveAuthHealth, fetchLiveReleaseManifest, fetchLiveWebsiteShell, formatRuntimeReadinessReport, parseEnvFile, } from './runtime-readiness-lib.mjs' import { writeSameOriginBundle } from './render-same-origin-bundle-lib.mjs' import { buildDefaultLiveDeployBundleDir, buildRemoteLiveDeployScript, buildSshArgs, buildStageBundleRemoteCommand, buildStageCheckoutRemoteCommand, extractLiveDeploySummary, loadLiveDeployManifest, parseLiveDeployArgs, resolveLiveDeployOptions, sanitizeLiveDeployManifest, } from './run-vps-same-origin-live-deploy-lib.mjs' const currentFile = fileURLToPath(import.meta.url) const websiteRoot = path.resolve(path.dirname(currentFile), '..') const repoRoot = path.resolve(websiteRoot, '..') function runCommand(command, args, options = {}) { const result = spawnSync(command, args, { encoding: 'utf8', maxBuffer: 64 * 1024 * 1024, ...options, }) if (result.error) { throw result.error } return result } function buildArchiveBuffer(repoRootPath, archiveSource) { if (archiveSource === 'worktree') { return execFileSync('tar', [ '--exclude=website/node_modules', '--exclude=website/dist', '--exclude=website/coverage', '--exclude=website/server/node_modules', '--exclude=website/server/dist', '-cf', '-', 'website', ], { cwd: repoRootPath, encoding: null, maxBuffer: 64 * 1024 * 1024, }) } return execFileSync('git', ['archive', '--format=tar', 'HEAD', 'website'], { cwd: repoRootPath, encoding: null, maxBuffer: 64 * 1024 * 1024, }) } function buildRenderedBundleArchive(renderedDir) { return execFileSync('tar', ['-cf', '-', '-C', renderedDir, '.'], { encoding: null, maxBuffer: 64 * 1024 * 1024, }) } function sleep(ms) { return new Promise((resolve) => { setTimeout(resolve, ms) }) } async function waitForLiveAuthHealth(baseUrl, waitSeconds) { let lastError = null for (let attempt = 1; attempt <= waitSeconds; attempt += 1) { try { return await fetchLiveAuthHealth(baseUrl) } catch (error) { lastError = error if (attempt < waitSeconds) { await sleep(1000) } } } throw lastError || new Error(`Live auth health did not become ready within ${waitSeconds} seconds.`) } async function main() { const rawArgs = parseLiveDeployArgs(process.argv.slice(2)) const gitRef = execFileSync('git', ['rev-parse', '--short', 'HEAD'], { cwd: repoRoot, encoding: 'utf8', }).trim() const { path: manifestPath, manifest } = loadLiveDeployManifest(rawArgs.manifest) const options = resolveLiveDeployOptions({ manifestPath, identityFile: rawArgs.identityFile, vpsHost: rawArgs.vpsHost, vpsUser: rawArgs.vpsUser, archiveSource: rawArgs.archiveSource, bundleDir: rawArgs.bundleDir, healthUrl: rawArgs.healthUrl, waitSeconds: rawArgs.waitSeconds, keepBundleDir: rawArgs.keepBundleDir, skipLiveValidation: rawArgs.skipLiveValidation, json: rawArgs.json, dryRun: rawArgs.dryRun, manifest, gitRef, }) const deployScript = buildRemoteLiveDeployScript({ checkoutRoot: options.checkoutRoot, bundleDir: options.bundleDir, keepBundleDir: options.keepBundleDir, manifest, }) const checkoutStageCommand = buildStageCheckoutRemoteCommand(options.checkoutRoot) const bundleStageCommand = buildStageBundleRemoteCommand(options.bundleDir) const sanitizedManifest = sanitizeLiveDeployManifest(manifest) if (options.dryRun) { const payload = { repoRoot, websiteRoot, manifestPath, vpsHost: options.vpsHost, vpsUser: options.vpsUser, archiveSource: options.archiveSource, checkoutRoot: options.checkoutRoot, bundleDir: options.bundleDir, healthUrl: options.healthUrl, waitSeconds: options.waitSeconds, keepBundleDir: options.keepBundleDir, skipLiveValidation: options.skipLiveValidation, checkoutStageCommand, bundleStageCommand, manifest: sanitizedManifest, deployScript, defaultBundleDirForGitRef: buildDefaultLiveDeployBundleDir(gitRef), } if (options.json) { process.stdout.write(`${JSON.stringify(payload, null, 2)}\n`) } else { process.stdout.write(`[live-deploy] manifest: ${manifestPath}\n`) process.stdout.write(`[live-deploy] vps: ${options.vpsUser}@${options.vpsHost}\n`) process.stdout.write(`[live-deploy] archive source: ${options.archiveSource}\n`) process.stdout.write(`[live-deploy] checkout root: ${options.checkoutRoot}\n`) process.stdout.write(`[live-deploy] bundle dir: ${options.bundleDir}\n`) process.stdout.write(`[live-deploy] health url: ${options.healthUrl}\n`) } return } const localBundleDir = fs.mkdtempSync(path.join(os.tmpdir(), 'hypertwist-live-deploy-')) try { const rendered = writeSameOriginBundle({ manifest, outDir: localBundleDir, }) const frontendEnv = parseEnvFile(rendered.frontendEnvContent) const serverEnv = parseEnvFile(rendered.serverEnvContent) const checkoutArchiveBuffer = buildArchiveBuffer(repoRoot, options.archiveSource) const bundleArchiveBuffer = buildRenderedBundleArchive(localBundleDir) const checkoutStageResult = runCommand('ssh', buildSshArgs({ identityFile: options.identityFile, vpsUser: options.vpsUser, vpsHost: options.vpsHost, remoteCommand: checkoutStageCommand, }), { input: checkoutArchiveBuffer, encoding: 'utf8', }) if (checkoutStageResult.status !== 0) { process.stderr.write(checkoutStageResult.stderr || '') process.exit(checkoutStageResult.status || 1) } const bundleStageResult = runCommand('ssh', buildSshArgs({ identityFile: options.identityFile, vpsUser: options.vpsUser, vpsHost: options.vpsHost, remoteCommand: bundleStageCommand, }), { input: bundleArchiveBuffer, encoding: 'utf8', }) if (bundleStageResult.status !== 0) { process.stderr.write(bundleStageResult.stderr || '') process.exit(bundleStageResult.status || 1) } const deployResult = runCommand('ssh', [ ...buildSshArgs({ identityFile: options.identityFile, vpsUser: options.vpsUser, vpsHost: options.vpsHost, }), 'bash', '-s', ], { input: deployScript, }) process.stderr.write(deployResult.stderr || '') const deploySummary = extractLiveDeploySummary(deployResult.stdout || '') let liveHealth = null let liveReleaseManifest = null let liveWebsiteShell = null const attemptedLiveValidation = !options.skipLiveValidation if (!options.skipLiveValidation) { liveHealth = await waitForLiveAuthHealth(options.healthUrl, options.waitSeconds) liveReleaseManifest = await fetchLiveReleaseManifest(options.healthUrl) liveWebsiteShell = await fetchLiveWebsiteShell(options.healthUrl) } const readinessReport = buildRuntimeReadinessReport({ frontendEnv, serverEnv, liveHealth, liveReleaseManifest, liveWebsiteShell, liveHealthAttempted: attemptedLiveValidation, liveReleaseManifestAttempted: attemptedLiveValidation, liveWebsiteShellAttempted: attemptedLiveValidation, frontendEnvPath: path.join(localBundleDir, 'website.env'), serverEnvPath: path.join(localBundleDir, 'server.env'), healthBaseUrl: options.healthUrl, deploymentTier: manifest.deploymentTier || manifest?.server?.deploymentTier || '', }) const payload = { ok: deployResult.status === 0 && readinessReport.ok, manifestPath, vpsHost: options.vpsHost, vpsUser: options.vpsUser, archiveSource: options.archiveSource, checkoutRoot: options.checkoutRoot, bundleDir: options.bundleDir, healthUrl: options.healthUrl, deploySummary, readinessReport, liveHealth, liveReleaseManifest, liveWebsiteShell, } if (options.json) { process.stdout.write(`${JSON.stringify(payload, null, 2)}\n`) } else { if (deploySummary) { process.stdout.write(`[live-deploy] service status: ${deploySummary.serviceStatus}\n`) process.stdout.write(`[live-deploy] nginx status: ${deploySummary.nginxStatus}\n`) } process.stdout.write(`${formatRuntimeReadinessReport(readinessReport)}\n`) } if (deployResult.status !== 0 || !readinessReport.ok) { process.exit(deployResult.status || 1) } } finally { fs.rmSync(localBundleDir, { recursive: true, force: true }) } } main().catch((error) => { process.stderr.write(`${error.stack || error.message}\n`) process.exit(1) })