1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165
| import { createWriteStream, existsSync } from 'node:fs'; import { rm } from 'node:fs/promises'; import { resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; import { spawn } from 'node:child_process'; import { ZipArchive } from 'archiver'; import dotenv from 'dotenv'; import { Client } from 'ssh2';
const projectRoot = resolve(fileURLToPath(new URL('..', import.meta.url))); const distDir = resolve(projectRoot, 'dist'); const archivePath = resolve(projectRoot, 'dist.zip'); const dryRun = process.argv.includes('--dry-run'); const deployTarget = process.argv.find(argument => ['dev', 'prod'].includes(argument)); const pnpmCommand = process.platform === 'win32' ? 'pnpm.cmd' : 'pnpm';
dotenv.config({ path: resolve(projectRoot, '.env.deploy.local'), quiet: true }); dotenv.config({ path: resolve(projectRoot, '.env.deploy'), quiet: true });
const config = { target: process.env.DEPLOY_TARGET, host: process.env.DEPLOY_HOST, port: Number(process.env.DEPLOY_PORT), username: process.env.DEPLOY_USER, password: process.env.DEPLOY_PASSWORD, readyTimeout: 20_000, }; const remoteDir = process.env.DEPLOY_REMOTE_DIR ?? '/opt/front'; const remoteArchive = `${remoteDir}/dist.zip`;
function run(command, args) { return new Promise((resolvePromise, reject) => { const child = spawn(command, args, { cwd: projectRoot, stdio: 'inherit', shell: process.platform === 'win32', }); child.once('error', reject); child.once('exit', code => { if (code === 0) resolvePromise(); else reject(new Error(`${command} exited with code ${code}`)); }); }); }
async function createArchive() { await rm(archivePath, { force: true });
await new Promise((resolvePromise, reject) => { const output = createWriteStream(archivePath); const archive = new ZipArchive({ zlib: { level: 9 } }); output.once('close', resolvePromise); output.once('error', reject); archive.once('error', reject); archive.pipe(output); archive.glob('**/*', { cwd: distDir, dot: false }, { prefix: 'dist' }); archive.finalize(); }); }
function connect() { return new Promise((resolvePromise, reject) => { const client = new Client(); client.once('ready', () => resolvePromise(client)); client.once('error', reject); client.connect(config); }); }
function upload(client) { return new Promise((resolvePromise, reject) => { client.sftp((sftpError, sftp) => { if (sftpError) return reject(sftpError); sftp.fastPut(archivePath, remoteArchive, putError => { if (putError) reject(putError); else resolvePromise(); }); }); }); }
function executeRemote(client) { const command = [ 'set -e', `cd ${remoteDir}`, 'unzip -tq dist.zip', 'rm -rf dist', 'unzip -o dist.zip', 'nginx -s reload', ].join(' && ');
return new Promise((resolvePromise, reject) => { client.exec(command, (execError, stream) => { if (execError) return reject(execError); stream.pipe(process.stdout); stream.stderr.pipe(process.stderr); stream.once('close', code => { if (code === 0) resolvePromise(); else reject(new Error(`Remote deployment command exited with code ${code}`)); }); }); }); }
async function deploy() { if (!deployTarget) { throw new Error( 'Deployment target is missing. Use pnpm run deploy:dev or pnpm run deploy:prod.', ); } if (config.target !== deployTarget) { throw new Error( `DEPLOY_TARGET is "${config.target ?? ''}", but the command targets "${deployTarget}". ` + 'Comment/uncomment the matching environment block in .env.deploy.local.', ); } if (!config.host) { throw new Error('DEPLOY_HOST is missing. Set it in .env.deploy.local.'); } if (!config.username) { throw new Error('DEPLOY_USER is missing. Set it in .env.deploy.local.'); } if (!config.password) { throw new Error('DEPLOY_PASSWORD is missing. Set it in .env.deploy.local.'); } if (!Number.isInteger(config.port) || config.port < 1) { throw new Error('DEPLOY_PORT must be a valid port number.'); } if (!/^\/[A-Za-z0-9._/-]+$/.test(remoteDir)) { throw new Error('DEPLOY_REMOTE_DIR must be an absolute path containing safe characters.'); }
console.log(`[1/4] Building application for ${deployTarget} deployment...`); await run(pnpmCommand, ['run', 'build:prod']); if (!existsSync(distDir)) throw new Error('Build completed without creating dist/.');
console.log('[2/4] Creating dist.zip...'); await createArchive();
if (dryRun) { console.log('Dry run completed: build and dist.zip are ready; no server changes were made.'); return; }
console.log(`[3/4] Uploading dist.zip to ${config.host}:${remoteDir}/...`); const client = await connect(); try { await upload(client); console.log('[4/4] Replacing dist and reloading Nginx...'); await executeRemote(client); } finally { client.end(); }
await rm(archivePath, { force: true }); console.log('Deployment completed successfully.'); }
deploy().catch(error => { console.error(`Deployment failed: ${error.message}`); process.exitCode = 1; });
|