| Server IP : 35.80.110.71 / Your IP : 216.73.216.221 Web Server : Apache/2.4.58 (Ubuntu) System : Linux ip-172-31-21-44 6.17.0-1019-aws #19~24.04.1-Ubuntu SMP Tue Jun 23 18:53:06 UTC 2026 x86_64 User : ubuntu ( 1000) PHP Version : 8.3.31 Disable Function : NONE MySQL : OFF | cURL : ON | WGET : ON | Perl : ON | Python : OFF | Sudo : ON | Pkexec : OFF Directory : /var/www/webhook-relay/current/scripts/deploy/ |
Upload File : |
/**
* Webhook Relay - Release-Based Deployment Script (Linux/WSL)
*
* Linux/WSL version of the deployment script. Handles WSL-specific issues:
* - Resolves Windows paths (C:/Users/...) to WSL paths (/mnt/c/Users/...)
* - Copies SSH keys from /mnt/c/ to temp with proper 0600 permissions
* - Supports both SERVER_BASE_PATH and SERVER_DEST_PATH env vars
*
* Features:
* - Atomic deployments via symlink switching
* - Instant rollback to previous releases
* - Shared storage/config persists across releases
* - Automatic cleanup of old releases
* - Zero-downtime deployments
*
* Usage: node scripts/deploy/deploy-linux.cjs [options]
* --skip-build Skip npm build step
* --skip-composer Skip composer install step
* --fresh Clear staging directory and rebuild from scratch
* --migrate Run database migrations on server
*
* Server Structure:
* /var/www/webhook-relay/
* ├── releases/
* │ ├── 20260115120000/
* │ └── ...
* ├── current -> releases/{latest}/
* └── shared/
* ├── .env
* └── storage/
*/
const fs = require('fs').promises;
const fsSync = require('fs');
const path = require('path');
const os = require('os');
// Load environment: prefer .env.deployment, fallback to .env
const PROJECT_ROOT_FOR_ENV = path.join(__dirname, '..', '..');
const envDeploymentPath = path.join(PROJECT_ROOT_FOR_ENV, '.env.deployment');
const envPath = path.join(PROJECT_ROOT_FOR_ENV, '.env');
const envFileUsed = fsSync.existsSync(envDeploymentPath) ? envDeploymentPath : envPath;
require('dotenv').config({ path: envFileUsed });
console.log(`[ENV] Loaded: ${path.basename(envFileUsed)}`);
const archiver = require('archiver');
const { NodeSSH } = require('node-ssh');
const { execSync } = require('child_process');
// Project root is two levels up from scripts/deploy/
const PROJECT_ROOT = path.join(__dirname, '..', '..');
const STAGING_DIR = path.join(PROJECT_ROOT, 'deploy-staging');
// ==== WSL/LINUX PATH HELPERS ====
/**
* Convert a Windows path to a WSL-accessible path.
* e.g. "C:/Users/me/.ssh/key" => "/mnt/c/Users/me/.ssh/key"
* "C:\\Users\\me\\.ssh\\key" => "/mnt/c/Users/me/.ssh/key"
* If already a Unix path, returns as-is.
*/
function resolveKeyPath(keyPath) {
if (!keyPath) return keyPath;
// Normalize backslashes to forward slashes
let normalized = keyPath.replace(/\\/g, '/');
// Convert Windows drive letter paths: C:/... => /mnt/c/...
const winDriveMatch = normalized.match(/^([A-Za-z]):\/(.*)/);
if (winDriveMatch) {
normalized = `/mnt/${winDriveMatch[1].toLowerCase()}/${winDriveMatch[2]}`;
}
return normalized;
}
/**
* Prepare SSH key for use on Linux/WSL.
* Files on /mnt/c/ have 777 permissions which SSH rejects.
* Copies to a temp file with 0600 permissions.
*/
function prepareSSHKey(keyPath) {
const resolved = resolveKeyPath(keyPath);
if (!fsSync.existsSync(resolved)) {
throw new Error(`Private key not found: ${resolved} (original: ${keyPath})`);
}
// Check if key is on a Windows mount (permissions can't be changed)
if (resolved.startsWith('/mnt/')) {
const tmpKey = path.join(os.tmpdir(), `.deploy-key-${process.pid}`);
fsSync.copyFileSync(resolved, tmpKey);
fsSync.chmodSync(tmpKey, 0o600);
return { path: tmpKey, isTemp: true };
}
// Native Linux path — ensure proper permissions
try {
fsSync.chmodSync(resolved, 0o600);
} catch {
// May not own the file, that's ok if permissions are already correct
}
return { path: resolved, isTemp: false };
}
// ==== CONFIGURATION ====
const CONFIG = {
server: {
host: (process.env.SERVER_HOST || '').trim(),
username: (process.env.SERVER_USERNAME || '').trim(),
privateKey: (process.env.SERVER_PRIVATE_KEY || '').trim(),
},
paths: {
// Support both SERVER_BASE_PATH and SERVER_DEST_PATH (.env.deployment uses DEST_PATH)
basePath: (process.env.SERVER_BASE_PATH || process.env.SERVER_DEST_PATH || '/var/www/webhook-relay').trim(),
releasesDir: 'releases',
sharedDir: 'shared',
currentLink: 'current',
},
releasesToKeep: parseInt(process.env.RELEASES_TO_KEEP || '5', 10),
};
// Files and folders to sync to staging (source files, excludes vendor which is built there)
const FILES_TO_STAGE = [
'app',
'bootstrap',
'config',
'database',
'docs',
'public',
'resources',
'routes',
'scripts',
'storage/app',
'storage/framework',
'artisan',
'composer.json',
'composer.lock',
'package.json',
'package-lock.json',
'vite.config.js',
'tailwind.config.js',
'postcss.config.js',
];
// Files and folders to include in deployment package (from staging)
const FILES_TO_TRANSFER = [
'app',
'bootstrap',
'config',
'database',
'docs',
'public',
'resources',
'routes',
'scripts',
'storage/app',
'storage/framework',
'vendor',
'artisan',
'composer.json',
'composer.lock',
];
// ==== UTILITIES ====
/**
* Generate release name in YYYYMMDDHHMMSS format
*/
function generateReleaseName() {
const now = new Date();
const pad = (n) => n.toString().padStart(2, '0');
return `${now.getFullYear()}${pad(now.getMonth() + 1)}${pad(now.getDate())}${pad(now.getHours())}${pad(now.getMinutes())}${pad(now.getSeconds())}`;
}
/**
* Log with timestamp and emoji
*/
function log(emoji, message) {
const timestamp = new Date().toISOString().substring(11, 19);
// Extra space after emoji to handle variable-width emoji rendering in terminals
console.log(`[${timestamp}] ${emoji} ${message}`);
}
/**
* Format elapsed time in human-readable format
*/
function formatElapsedTime(ms) {
const seconds = Math.floor(ms / 1000);
const minutes = Math.floor(seconds / 60);
const remainingSeconds = seconds % 60;
if (minutes > 0) {
return `${minutes}m ${remainingSeconds}s`;
}
return `${seconds}s`;
}
/**
* Execute SSH command and handle errors
*/
async function execSSH(ssh, command, options = {}) {
const result = await ssh.execCommand(command, options);
if (result.code !== 0 && !options.ignoreError) {
throw new Error(`Command failed: ${command}\nStderr: ${result.stderr}\nStdout: ${result.stdout}`);
}
return result;
}
/**
* Recursively copy a directory
*/
function copyDirSync(src, dest) {
fsSync.mkdirSync(dest, { recursive: true });
const entries = fsSync.readdirSync(src, { withFileTypes: true });
for (const entry of entries) {
const srcPath = path.join(src, entry.name);
const destPath = path.join(dest, entry.name);
// Skip symlinks (e.g. public/storage -> storage/app/public)
if (entry.isSymbolicLink()) {
continue;
}
if (entry.isDirectory()) {
copyDirSync(srcPath, destPath);
} else {
fsSync.copyFileSync(srcPath, destPath);
}
}
}
/**
* Sync files from project root to staging directory
*/
function syncToStaging() {
log('📂', 'Syncing files to staging directory...');
if (!fsSync.existsSync(STAGING_DIR)) {
fsSync.mkdirSync(STAGING_DIR, { recursive: true });
}
for (const item of FILES_TO_STAGE) {
const srcPath = path.join(PROJECT_ROOT, item);
const destPath = path.join(STAGING_DIR, item);
if (!fsSync.existsSync(srcPath)) {
console.warn(`Warning: ${item} not found, skipping`);
continue;
}
const lstat = fsSync.lstatSync(srcPath);
// Skip symlinks at the top level (e.g. public/storage)
if (lstat.isSymbolicLink()) {
continue;
}
if (lstat.isDirectory()) {
if (fsSync.existsSync(destPath)) {
fsSync.rmSync(destPath, { recursive: true, force: true });
}
copyDirSync(srcPath, destPath);
} else {
const parentDir = path.dirname(destPath);
if (!fsSync.existsSync(parentDir)) {
fsSync.mkdirSync(parentDir, { recursive: true });
}
fsSync.copyFileSync(srcPath, destPath);
}
}
// Clear bootstrap/cache
const cacheDir = path.join(STAGING_DIR, 'bootstrap', 'cache');
if (fsSync.existsSync(cacheDir)) {
const cacheFiles = fsSync.readdirSync(cacheDir);
for (const file of cacheFiles) {
if (file.endsWith('.php')) {
fsSync.unlinkSync(path.join(cacheDir, file));
}
}
log('🗑️', ' Cleared bootstrap cache files');
}
// Remove auto-generated config files (should use dynamic generation)
const autoGeneratedConfigs = [
'api-endpoints.php',
];
for (const configFile of autoGeneratedConfigs) {
const configPath = path.join(STAGING_DIR, 'config', configFile);
if (fsSync.existsSync(configPath)) {
fsSync.unlinkSync(configPath);
log('🗑️', ` Removed auto-generated config: ${configFile}`);
}
}
log('✅', 'Files synced to staging');
}
// ==== BUILD PHASE ====
async function runComposerInstall() {
log('📦', 'Installing production dependencies (composer) in staging...');
try {
execSync(
'composer install --no-dev --optimize-autoloader --no-interaction --no-scripts',
{
stdio: 'inherit',
cwd: STAGING_DIR
}
);
log('✅', 'Composer install completed');
} catch (error) {
throw new Error(`Composer install failed: ${error.message}`);
}
}
async function runNpmBuild() {
log('🔨', 'Building frontend assets (Vite)...');
try {
execSync('npm run build', {
stdio: 'inherit',
cwd: PROJECT_ROOT
});
log('✅', 'Frontend build completed');
// Copy built assets to staging
const srcBuild = path.join(PROJECT_ROOT, 'public', 'build');
const destBuild = path.join(STAGING_DIR, 'public', 'build');
if (fsSync.existsSync(srcBuild)) {
if (fsSync.existsSync(destBuild)) {
fsSync.rmSync(destBuild, { recursive: true, force: true });
}
copyDirSync(srcBuild, destBuild);
log('✅', 'Built assets copied to staging');
}
// Remove hot file from staging
const hotFile = path.join(STAGING_DIR, 'public', 'hot');
if (fsSync.existsSync(hotFile)) {
fsSync.unlinkSync(hotFile);
log('🗑️', ' Removed Vite hot file');
}
} catch (error) {
throw new Error(`npm build failed: ${error.message}`);
}
}
// ==== PACKAGE PHASE ====
async function createDeploymentPackage(releaseName) {
const zipPath = path.join(PROJECT_ROOT, `release-${releaseName}.zip`);
log('📦', 'Creating deployment package from staging...');
return new Promise((resolve, reject) => {
const archive = archiver('zip', { zlib: { level: 6 } });
const stream = fsSync.createWriteStream(zipPath);
stream.on('close', () => {
const sizeMB = (archive.pointer() / 1024 / 1024).toFixed(2);
log('✅', `Package created: ${sizeMB} MB`);
resolve(zipPath);
});
archive.on('error', (err) => reject(err));
archive.on('warning', (err) => {
if (err.code !== 'ENOENT') {
console.warn('Archive warning:', err);
}
});
archive.pipe(stream);
for (const item of FILES_TO_TRANSFER) {
const itemPath = path.join(STAGING_DIR, item);
if (fsSync.existsSync(itemPath)) {
const stat = fsSync.statSync(itemPath);
if (stat.isDirectory()) {
archive.directory(itemPath, item);
} else {
archive.file(itemPath, { name: item });
}
} else {
console.warn(`Warning: ${item} not found in staging, skipping`);
}
}
archive.finalize();
});
}
// ==== SERVER SETUP ====
/**
* Setup shared directory structure (idempotent)
*/
async function setupSharedDirectory(ssh) {
const sharedPath = `${CONFIG.paths.basePath}/${CONFIG.paths.sharedDir}`;
log('📁', 'Setting up shared directory structure...');
await execSSH(ssh, `mkdir -p ${sharedPath}/storage/app/public`);
await execSSH(ssh, `mkdir -p ${sharedPath}/storage/framework/cache/data`);
await execSSH(ssh, `mkdir -p ${sharedPath}/storage/framework/sessions`);
await execSSH(ssh, `mkdir -p ${sharedPath}/storage/framework/views`);
await execSSH(ssh, `mkdir -p ${sharedPath}/storage/logs`);
// Set permissions on shared storage
await execSSH(ssh, `sudo chown -R ubuntu:www-data ${sharedPath}`);
await execSSH(ssh, `sudo chmod -R 775 ${sharedPath}/storage`);
log('✅', 'Shared directory ready');
}
/**
* Link shared resources to release
*/
async function linkSharedResources(ssh, releasePath) {
const sharedPath = `${CONFIG.paths.basePath}/${CONFIG.paths.sharedDir}`;
log('🔗', 'Linking shared resources...');
// Remove storage directory from release (will be symlinked)
await execSSH(ssh, `rm -rf ${releasePath}/storage`);
// Create symlinks
await execSSH(ssh, `ln -sf ${sharedPath}/.env ${releasePath}/.env`);
await execSSH(ssh, `ln -sf ${sharedPath}/storage ${releasePath}/storage`);
// Create public/storage symlink (Laravel's storage:link equivalent)
// This allows serving uploaded files from storage/app/public via /storage URL
await execSSH(ssh, `ln -sf ${sharedPath}/storage/app/public ${releasePath}/public/storage`);
log('✅', 'Shared resources linked');
}
/**
* Switch current symlink to new release (atomic)
*/
async function switchToRelease(ssh, releasePath) {
const currentPath = `${CONFIG.paths.basePath}/${CONFIG.paths.currentLink}`;
log('🔗', 'Switching to new release...');
// ln -sfn is atomic - creates new symlink and replaces old one
await execSSH(ssh, `ln -sfn ${releasePath} ${currentPath}`);
log('✅', 'Symlink updated');
}
/**
* Verify release is functional
*/
async function verifyRelease(ssh, releasePath) {
log('🔍', 'Verifying release...');
// Check artisan works
const artisanCheck = await execSSH(ssh, `cd ${releasePath} && php artisan --version`, { ignoreError: true });
if (artisanCheck.code !== 0) {
throw new Error('Artisan command failed - release may be broken');
}
// Check .env symlink exists
const envCheck = await execSSH(ssh, `test -L ${releasePath}/.env && echo "ok"`, { ignoreError: true });
if (envCheck.stdout.trim() !== 'ok') {
throw new Error('.env symlink not found');
}
// Check storage symlink exists
const storageCheck = await execSSH(ssh, `test -L ${releasePath}/storage && echo "ok"`, { ignoreError: true });
if (storageCheck.stdout.trim() !== 'ok') {
throw new Error('storage symlink not found');
}
log('✅', 'Release verified');
}
/**
* Cleanup old releases, keeping only the most recent N
*/
async function cleanupOldReleases(ssh) {
const releasesPath = `${CONFIG.paths.basePath}/${CONFIG.paths.releasesDir}`;
log('🧹', `Cleaning up old releases (keeping ${CONFIG.releasesToKeep})...`);
try {
const result = await execSSH(ssh, `ls -1 ${releasesPath} | sort`);
const releases = result.stdout.trim().split('\n').filter(r => r && /^\d{14}$/.test(r));
if (releases.length <= CONFIG.releasesToKeep) {
log('✅', `No cleanup needed (${releases.length} releases)`);
return;
}
const toDelete = releases.slice(0, releases.length - CONFIG.releasesToKeep);
for (const release of toDelete) {
log('🗑️', ` Deleting old release: ${release}`);
await execSSH(ssh, `rm -rf ${releasesPath}/${release}`, { ignoreError: true });
}
log('✅', `Cleaned up ${toDelete.length} old release(s)`);
} catch (error) {
log('⚠️', `Cleanup warning: ${error.message}`);
}
}
// ==== OPTIONAL TOOLING CHECK ====
/**
* Verify the OpenAPI Generator CLI is available on the server.
*
* Non-blocking: prints a warning and returns. The admin SDK generator
* (app/Console/Commands/GenerateApiSdk.php) shells out to `npx
* @openapitools/openapi-generator-cli`, so this check uses the same `npx
* --no-install` path that the runtime command will take. If the binary
* exists in a prefix that's not on the Apache user's PATH, the runtime
* call will still fail — flagging that distinction matters.
*
* To install: run `sudo bash scripts/setup/install-openapi-generator.sh`
* on the server.
*/
async function checkOpenApiGeneratorCli(ssh) {
log('🔧', 'Checking optional tooling: OpenAPI Generator CLI...');
const result = await execSSH(
ssh,
'npx --no-install @openapitools/openapi-generator-cli version 2>/dev/null',
{ ignoreError: true }
);
if (result.code === 0 && result.stdout.trim()) {
log('✅', `OpenAPI Generator CLI available: ${result.stdout.trim().split('\n')[0]}`);
return;
}
log('⚠️', 'OpenAPI Generator CLI not available on server (admin SDK generation will fail for languages other than --spec-only).');
log('⚠️', ' Fix: sudo bash scripts/setup/install-openapi-generator.sh');
}
// ==== SENTRY RELEASE TRACKING ====
/**
* Update SENTRY_RELEASE in the shared .env on the server.
* This ensures config('sentry.release') returns the active release name.
*/
async function updateSentryRelease(ssh, releaseName) {
if (!/^\d{14}$/.test(releaseName)) {
log('⚠️', `Skipping Sentry release update: invalid release name "${releaseName}"`);
return;
}
const envFile = `${CONFIG.paths.basePath}/${CONFIG.paths.sharedDir}/.env`;
log('📝', `Setting SENTRY_RELEASE=${releaseName} in shared .env...`);
// Update existing SENTRY_RELEASE or append it
const result = await execSSH(ssh, `grep -q '^SENTRY_RELEASE=' ${envFile} && echo "exists" || echo "missing"`, { ignoreError: true });
if (result.stdout.trim() === 'exists') {
await execSSH(ssh, `sed -i 's/^SENTRY_RELEASE=.*/SENTRY_RELEASE=${releaseName}/' ${envFile}`);
} else {
await execSSH(ssh, `echo 'SENTRY_RELEASE=${releaseName}' >> ${envFile}`);
}
log('✅', 'SENTRY_RELEASE updated in shared .env');
}
/**
* Create a Sentry release and register a deploy via sentry-cli.
* Gated behind SENTRY_AUTH_TOKEN — skips gracefully if not configured.
*/
function createSentryRelease(releaseName) {
if (!/^\d{14}$/.test(releaseName)) {
log('⚠️', `Skipping Sentry release creation: invalid release name "${releaseName}"`);
return;
}
if (!process.env.SENTRY_AUTH_TOKEN) {
log('⚠️', 'Skipping Sentry release creation: SENTRY_AUTH_TOKEN not set');
return;
}
const org = process.env.SENTRY_ORG;
const project = process.env.SENTRY_PROJECT;
if (!org || !project) {
log('⚠️', 'Skipping Sentry release creation: SENTRY_ORG or SENTRY_PROJECT not set');
return;
}
log('📡', `Creating Sentry release: ${releaseName}...`);
const sentryCliPath = path.join(PROJECT_ROOT, 'node_modules', '.bin', 'sentry-cli');
const env = {
...process.env,
SENTRY_AUTH_TOKEN: process.env.SENTRY_AUTH_TOKEN,
SENTRY_ORG: org,
SENTRY_PROJECT: project,
};
const execOpts = { stdio: 'pipe', env };
try {
// Create the release
execSync(`"${sentryCliPath}" releases new ${releaseName}`, execOpts);
// Set commits (auto-detect from git)
execSync(`"${sentryCliPath}" releases set-commits ${releaseName} --auto`, execOpts);
// Finalize the release
execSync(`"${sentryCliPath}" releases finalize ${releaseName}`, execOpts);
// Register the deploy
execSync(`"${sentryCliPath}" deploys new -r ${releaseName} -e production`, execOpts);
log('✅', 'Sentry release created and deploy registered');
} catch (error) {
const stderr = error.stderr ? error.stderr.toString().trim() : error.message;
log('⚠️', `Sentry release creation failed (non-fatal): ${stderr}`);
}
}
// ==== DEPLOYMENT PHASE ====
async function deploy(options = {}) {
const ssh = new NodeSSH();
const releaseName = generateReleaseName();
const releasePath = `${CONFIG.paths.basePath}/${CONFIG.paths.releasesDir}/${releaseName}`;
const releasesPath = `${CONFIG.paths.basePath}/${CONFIG.paths.releasesDir}`;
let releaseCreated = false;
let zipPath = null;
let sshKey = null;
const startTime = Date.now();
log('🚀', `Starting deployment: ${releaseName}`);
log('📍', `Target: ${CONFIG.paths.basePath}`);
try {
// ==== PREPARE SSH KEY ====
sshKey = prepareSSHKey(CONFIG.server.privateKey);
log('🔑', `SSH key ready: ${sshKey.path}${sshKey.isTemp ? ' (temp copy for correct permissions)' : ''}`);
// ==== LOCAL BUILD ====
syncToStaging();
if (!options.skipBuild) {
await runNpmBuild();
} else {
log('⏭️', 'Skipping npm build (--skip-build flag)');
// Copy existing build assets to staging if skipping build
const srcBuild = path.join(PROJECT_ROOT, 'public', 'build');
const destBuild = path.join(STAGING_DIR, 'public', 'build');
if (fsSync.existsSync(srcBuild)) {
if (fsSync.existsSync(destBuild)) {
fsSync.rmSync(destBuild, { recursive: true, force: true });
}
copyDirSync(srcBuild, destBuild);
}
}
// Always remove Vite hot file from staging (prevents dev server usage in production)
const hotFile = path.join(STAGING_DIR, 'public', 'hot');
if (fsSync.existsSync(hotFile)) {
fsSync.unlinkSync(hotFile);
log('🗑️', ' Removed Vite hot file from staging');
}
if (!options.skipComposer) {
await runComposerInstall();
} else {
log('⏭️', 'Skipping composer install (--skip-composer flag)');
}
// ==== CREATE PACKAGE ====
zipPath = await createDeploymentPackage(releaseName);
// ==== UPLOAD PACKAGE ====
const remoteTmpZip = `/tmp/release-${releaseName}.zip`;
log('📤', 'Uploading package to server...');
const scpCommand = `scp -i "${sshKey.path}" -o StrictHostKeyChecking=no "${zipPath}" ${CONFIG.server.username}@${CONFIG.server.host}:${remoteTmpZip}`;
try {
execSync(scpCommand, { stdio: 'inherit' });
} catch (scpError) {
throw new Error(`SCP upload failed: ${scpError.message}`);
}
log('✅', 'Package uploaded');
// ==== CONNECT TO SERVER ====
log('🔌', 'Connecting to server...');
const privateKeyContent = fsSync.readFileSync(sshKey.path, 'utf8');
await ssh.connect({
host: CONFIG.server.host,
username: CONFIG.server.username,
privateKey: privateKeyContent,
});
log('✅', 'Connected to server');
// Delete local zip after upload
await fs.unlink(zipPath);
zipPath = null;
log('🗑️', ' Local package cleaned up');
// ==== SETUP DIRECTORIES ====
await execSSH(ssh, `mkdir -p ${releasesPath}`);
await setupSharedDirectory(ssh);
// ==== CREATE RELEASE DIRECTORY ====
log('📁', `Creating release directory: ${releaseName}`);
await execSSH(ssh, `mkdir -p ${releasePath}`);
releaseCreated = true;
// ==== EXTRACT PACKAGE ====
log('📦', 'Extracting package on server...');
await execSSH(ssh, `unzip -q -o ${remoteTmpZip} -d ${releasePath}`);
await execSSH(ssh, `rm -f ${remoteTmpZip}`);
log('✅', 'Package extracted');
// ==== LINK SHARED RESOURCES ====
await linkSharedResources(ssh, releasePath);
// ==== UPDATE SENTRY RELEASE IN .ENV ====
await updateSentryRelease(ssh, releaseName);
// ==== SET PERMISSIONS ====
log('🔧', 'Setting permissions...');
await execSSH(ssh, `sudo chown -R ubuntu:www-data ${releasePath}`);
await execSSH(ssh, `sudo chmod -R 755 ${releasePath}`);
await execSSH(ssh, `sudo chmod -R 775 ${releasePath}/bootstrap/cache`);
log('✅', 'Permissions set');
// ==== RUN ARTISAN COMMANDS ====
log('⚙️', ' Running artisan commands...');
// Clear bootstrap cache files
await execSSH(ssh, `rm -f ${releasePath}/bootstrap/cache/*.php`, { ignoreError: true });
// Remove auto-generated config files that should use dynamic generation
log('🗑️', ' Removing auto-generated config files...');
await execSSH(ssh, `rm -f ${releasePath}/config/api-endpoints.php`, { ignoreError: true });
// Clear all caches first (important: view cache is in shared storage!)
await execSSH(ssh, `cd ${releasePath} && php artisan config:clear`);
await execSSH(ssh, `cd ${releasePath} && php artisan route:clear`);
await execSSH(ssh, `cd ${releasePath} && php artisan view:clear`);
await execSSH(ssh, `cd ${releasePath} && php artisan cache:clear`);
// Rebuild caches with new release code
await execSSH(ssh, `cd ${releasePath} && php artisan config:cache`);
await execSSH(ssh, `cd ${releasePath} && php artisan route:cache`);
await execSSH(ssh, `cd ${releasePath} && php artisan view:cache`);
log('✅', 'Artisan optimization completed');
// ==== RUN MIGRATIONS ====
if (options.migrate) {
log('🗃️', 'Running database migrations...');
await execSSH(ssh, `cd ${releasePath} && php artisan migrate --force`);
log('✅', 'Migrations completed');
}
// ==== VERIFY RELEASE ====
await verifyRelease(ssh, releasePath);
// ==== SWITCH SYMLINK (ATOMIC!) ====
await switchToRelease(ssh, releasePath);
// ==== RELOAD SERVICES ====
log('♻️', ' Reloading Apache...');
await execSSH(ssh, 'sudo systemctl reload apache2');
log('✅', 'Apache reloaded');
log('♻️', ' Restarting queue workers...');
await execSSH(ssh, 'sudo supervisorctl restart webhook-relay-horizon:*', { ignoreError: true });
log('✅', 'Queue workers restarted');
log('♻️', ' Restarting Reverb WebSocket server...');
await execSSH(ssh, 'sudo supervisorctl restart webhook-relay-reverb', { ignoreError: true });
log('✅', 'Reverb restarted');
// ==== CREATE SENTRY RELEASE ====
createSentryRelease(releaseName);
// ==== POST-DEPLOY HEALTH CHECK ====
if (!options.skipVerify) {
log('🏥', 'Running post-deploy health check...');
let healthOk = false;
const healthUrl = `https://${process.env.MARKETING_DOMAIN || 'api.webhook-relay.dcsuniverse.com'}/health`;
for (let attempt = 1; attempt <= 3; attempt++) {
try {
const result = await execSSH(ssh, `curl -sf --max-time 30 ${healthUrl}`, { ignoreError: true });
if (result && result.stdout && result.stdout.includes('"healthy"')) {
healthOk = true;
break;
}
} catch {
// Retry
}
if (attempt < 3) {
log('⏳', `Health check attempt ${attempt}/3 failed, retrying in 5s...`);
await new Promise(resolve => setTimeout(resolve, 5000));
}
}
if (healthOk) {
log('✅', 'Application healthy');
} else {
log('⚠️', `Health check failed. Verify manually: ${healthUrl}`);
log('⚠️', 'To rollback, re-deploy the previous release.');
}
} else {
log('⏭️', ' Skipping health check (--skip-verify)');
}
// ==== CLEANUP OLD RELEASES ====
await cleanupOldReleases(ssh);
// ==== OPTIONAL TOOLING CHECK ====
await checkOpenApiGeneratorCli(ssh);
// ==== NOTIFY ADMINS ====
log('📢', 'Sending deployment notification to admins...');
await execSSH(ssh, `cd ${CONFIG.paths.basePath}/${CONFIG.paths.currentLink} && php artisan notify:deployment ${releaseName}`, { ignoreError: true });
log('✅', 'Deployment notification sent');
// ==== DONE ====
const elapsed = formatElapsedTime(Date.now() - startTime);
log('🎉', `Deployment successful! Release: ${releaseName}`);
log('⏱️', ` Total time: ${elapsed}`);
log('🌐', 'Site: https://api.webhook-relay.dcsuniverse.com');
} catch (error) {
const elapsed = formatElapsedTime(Date.now() - startTime);
log('❌', `Deployment failed: ${error.message}`);
log('⏱️', `Failed after: ${elapsed}`);
// Cleanup failed release if it was created
if (releaseCreated) {
log('🧹', 'Cleaning up failed release...');
try {
await execSSH(ssh, `rm -rf ${releasePath}`, { ignoreError: true });
log('✅', 'Failed release cleaned up');
} catch (cleanupError) {
log('⚠️', `Failed to cleanup release: ${cleanupError.message}`);
}
}
// Cleanup local zip if it exists
if (zipPath && fsSync.existsSync(zipPath)) {
try {
await fs.unlink(zipPath);
log('🗑️', ' Local package cleaned up');
} catch {
// Ignore cleanup errors
}
}
process.exit(1);
} finally {
ssh.dispose();
// Cleanup temp SSH key
if (sshKey && sshKey.isTemp) {
try {
fsSync.unlinkSync(sshKey.path);
} catch {
// Ignore cleanup errors
}
}
}
}
// ==== MAIN ====
const args = process.argv.slice(2);
const options = {
skipBuild: args.includes('--skip-build'),
skipComposer: args.includes('--skip-composer'),
fresh: args.includes('--fresh'),
migrate: args.includes('--migrate'),
clearCached: args.includes('--clear-cached'),
skipVerify: args.includes('--skip-verify'),
};
// Show help
if (args.includes('--help') || args.includes('-h')) {
console.log(`
Webhook Relay - Release-Based Deployment (Linux/WSL)
Usage: node scripts/deploy/deploy-linux.cjs [options]
Options:
--skip-build Skip npm build step
--skip-composer Skip composer install step
--fresh Clear staging directory and rebuild from scratch
--migrate Run database migrations on server
--clear-cached Clear all auto-generated config files on server (current release)
--skip-verify Skip post-deploy health check
--help, -h Show this help message
Server Structure:
${CONFIG.paths.basePath}/
├── releases/ Release directories
├── current -> releases/{latest}
└── shared/ Shared .env and storage
`);
process.exit(0);
}
// Clear staging if --fresh flag
if (options.fresh && fsSync.existsSync(STAGING_DIR)) {
log('🗑️', ' Clearing staging directory (--fresh flag)...');
fsSync.rmSync(STAGING_DIR, { recursive: true, force: true });
}
// Validate configuration
if (!CONFIG.server.host || !CONFIG.server.username || !CONFIG.server.privateKey) {
console.error('❌ Missing required environment variables:');
console.error(' SERVER_HOST, SERVER_USERNAME, SERVER_PRIVATE_KEY');
process.exit(1);
}
// Resolve the key path for Linux/WSL and validate
const resolvedKeyPath = resolveKeyPath(CONFIG.server.privateKey);
if (!fsSync.existsSync(resolvedKeyPath)) {
console.error(`❌ Private key file not found: ${resolvedKeyPath}`);
if (resolvedKeyPath !== CONFIG.server.privateKey) {
console.error(` (resolved from: ${CONFIG.server.privateKey})`);
}
process.exit(1);
}
// Handle --clear-cached as standalone operation (no deployment)
if (options.clearCached) {
clearCachedConfigs().then(() => process.exit(0)).catch(() => process.exit(1));
} else {
// Normal deployment
deploy(options);
}
/**
* Clear all auto-generated config files on the server (standalone operation)
*/
async function clearCachedConfigs() {
const ssh = new NodeSSH();
const currentPath = `${CONFIG.paths.basePath}/${CONFIG.paths.currentLink}`;
const startTime = Date.now();
let sshKey = null;
log('🧹', 'Clearing cached configs on server...');
try {
// Prepare SSH key
sshKey = prepareSSHKey(CONFIG.server.privateKey);
// Connect to server
log('🔌', 'Connecting to server...');
const privateKeyContent = fsSync.readFileSync(sshKey.path, 'utf8');
await ssh.connect({
host: CONFIG.server.host,
username: CONFIG.server.username,
privateKey: privateKeyContent,
});
log('✅', 'Connected to server');
// Auto-generated config files to remove
const cachedConfigs = [
'config/api-endpoints.php',
];
for (const configFile of cachedConfigs) {
const filePath = `${currentPath}/${configFile}`;
await execSSH(ssh, `rm -f ${filePath}`, { ignoreError: true });
log('🗑️', ` Removed: ${configFile}`);
}
// Clear Laravel caches
log('⚙️', ' Clearing Laravel caches...');
await execSSH(ssh, `cd ${currentPath} && php artisan config:clear`);
await execSSH(ssh, `cd ${currentPath} && php artisan route:clear`);
await execSSH(ssh, `cd ${currentPath} && php artisan view:clear`);
await execSSH(ssh, `cd ${currentPath} && php artisan cache:clear`);
// Rebuild caches
log('⚙️', ' Rebuilding Laravel caches...');
await execSSH(ssh, `cd ${currentPath} && php artisan config:cache`);
await execSSH(ssh, `cd ${currentPath} && php artisan route:cache`);
await execSSH(ssh, `cd ${currentPath} && php artisan view:cache`);
// Reload Apache
log('♻️', ' Reloading Apache...');
await execSSH(ssh, 'sudo systemctl reload apache2');
const elapsed = formatElapsedTime(Date.now() - startTime);
log('🎉', 'Cached configs cleared successfully!');
log('⏱️', `Total deployment time: ${elapsed}`);
} catch (error) {
const elapsed = formatElapsedTime(Date.now() - startTime);
log('❌', `Failed to clear cached configs: ${error.message}`);
log('⏱️', `Failed after: ${elapsed}`);
throw error;
} finally {
ssh.dispose();
if (sshKey && sshKey.isTemp) {
try {
fsSync.unlinkSync(sshKey.path);
} catch {
// Ignore cleanup errors
}
}
}
}