Files
pr-preview/backend/dist/services/deploy.js
T
space a71f801c3f feat: initial scaffold - backend, frontend, Prisma schema, Docker
- Prisma schema: User, Session, RepoConfig, Preview, Job, WebhookToken, NoConfigComment, AdminSettings
- Backend: auth (login/logout/me/first-user setup), webhook handler with HMAC verification, EC2 service, SSH service, deploy pipeline, job queue worker, cron workers
- Frontend: Login with first-user detection, Dashboard, PreviewDetail with live log streaming, Settings, Repos config, Admin panel, SetupWizard, Privacy page
- Docker Compose and Dockerfile for self-hosted deployment
- Uses bcryptjs for Node 24 compatibility

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-25 00:18:08 +02:00

441 lines
18 KiB
JavaScript

"use strict";
var __defProp = Object.defineProperty;
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
var __getOwnPropNames = Object.getOwnPropertyNames;
var __hasOwnProp = Object.prototype.hasOwnProperty;
var __export = (target, all) => {
for (var name in all)
__defProp(target, name, { get: all[name], enumerable: true });
};
var __copyProps = (to, from, except, desc) => {
if (from && typeof from === "object" || typeof from === "function") {
for (let key of __getOwnPropNames(from))
if (!__hasOwnProp.call(to, key) && key !== except)
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
}
return to;
};
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
var deploy_exports = {};
__export(deploy_exports, {
abortJobForPreview: () => abortJobForPreview,
appendLog: () => appendLog,
runDeploy: () => runDeploy,
signalAbort: () => signalAbort,
stopPreview: () => stopPreview,
subscribeToLogs: () => subscribeToLogs
});
module.exports = __toCommonJS(deploy_exports);
var import_db = require("../lib/db");
var import_logger = require("../lib/logger");
var import_encryption = require("../lib/encryption");
var import_adminSettings = require("../lib/adminSettings");
var import_env = require("../lib/env");
var import_ec2 = require("./ec2");
var import_ssh = require("./ssh");
var import_gitea = require("./gitea");
const log = (0, import_logger.createLogger)("DEPLOY");
const activeSshSessions = /* @__PURE__ */ new Map();
function abortJobForPreview(previewId) {
const session = activeSshSessions.get(previewId);
if (session) {
session.abort();
activeSshSessions.delete(previewId);
}
}
async function appendLog(previewId, text) {
const settings = await (0, import_adminSettings.getAdminSettings)();
const preview = await import_db.prisma.preview.findUnique({ where: { id: previewId } });
if (!preview) return;
let logs = (preview.logs || "") + text;
if (Buffer.byteLength(logs, "utf8") > settings.logSizeLimitBytes) {
const marker = "--- logs truncated ---\n";
while (Buffer.byteLength(logs, "utf8") > settings.logSizeLimitBytes) {
const nl = logs.indexOf("\n");
if (nl === -1) break;
logs = logs.slice(nl + 1);
}
logs = marker + logs;
}
await import_db.prisma.preview.update({ where: { id: previewId }, data: { logs } });
broadcastLogUpdate(previewId, text);
}
const logSubscribers = /* @__PURE__ */ new Map();
function subscribeToLogs(previewId, cb) {
if (!logSubscribers.has(previewId)) logSubscribers.set(previewId, /* @__PURE__ */ new Set());
logSubscribers.get(previewId).add(cb);
return () => logSubscribers.get(previewId)?.delete(cb);
}
function broadcastLogUpdate(previewId, text) {
logSubscribers.get(previewId)?.forEach((cb) => cb(text));
}
async function updateStatus(previewId, status, extra = {}) {
await import_db.prisma.preview.update({ where: { id: previewId }, data: { status, ...extra } });
}
async function updateGiteaComment(user, preview, repoConfig, statusLine, lastLogLines) {
const body = (0, import_gitea.buildPrCommentBody)({
owner: repoConfig.repoOwner,
repo: repoConfig.repoName,
prNumber: preview.prNumber,
status: statusLine,
commitSha: preview.commitSha,
updatedAt: /* @__PURE__ */ new Date(),
ppBaseUrl: import_env.env.PP_BASE_URL,
lastLogLines,
instanceIp: preview.instanceIp ?? void 0,
port: preview.port
});
if (preview.giteaCommentId) {
try {
await (0, import_gitea.updateComment)(user, repoConfig.repoOwner, repoConfig.repoName, preview.giteaCommentId, body);
} catch (e) {
log.warn({ e }, "Failed to update Gitea comment");
}
}
}
async function runDeploy(jobId) {
const job = await import_db.prisma.job.findUnique({ where: { id: jobId }, include: { preview: { include: { repoConfig: { include: { user: true } } } } } });
if (!job || !job.preview) {
log.error({ jobId }, "Job or preview not found");
return;
}
await import_db.prisma.job.update({ where: { id: jobId }, data: { status: "RUNNING", startedAt: /* @__PURE__ */ new Date() } });
const preview = job.preview;
const repoConfig = preview.repoConfig;
const user = preview.repoConfig.user;
const payload = job.payload;
const { commitSha, prNumber, prTitle, cloneUrl, isFirstDeploy } = payload;
try {
if (isFirstDeploy) {
await firstDeploy(jobId, preview, repoConfig, user, commitSha, prNumber, prTitle, cloneUrl);
} else {
await redeploy(jobId, preview, repoConfig, user, commitSha, prNumber, prTitle);
}
await import_db.prisma.job.update({ where: { id: jobId }, data: { status: "DONE", finishedAt: /* @__PURE__ */ new Date() } });
} catch (e) {
if (e.message === "ABORTED") {
log.info({ jobId, previewId: preview.id }, "Job aborted");
await import_db.prisma.job.update({ where: { id: jobId }, data: { status: "FAILED", error: "Aborted by newer deploy", finishedAt: /* @__PURE__ */ new Date() } });
return;
}
log.error({ e, jobId, previewId: preview.id }, "Deploy failed");
await import_db.prisma.job.update({ where: { id: jobId }, data: { status: "FAILED", error: e.message, finishedAt: /* @__PURE__ */ new Date() } });
const freshPreview = await import_db.prisma.preview.findUnique({ where: { id: preview.id } });
if (!freshPreview) return;
const lastLines = (freshPreview.logs || "").split("\n").slice(-10).join("\n");
await updateStatus(preview.id, "FAILED");
const failBody = (0, import_gitea.buildPrCommentBody)({
owner: repoConfig.repoOwner,
repo: repoConfig.repoName,
prNumber: freshPreview.prNumber,
status: `\u{1F534} Failed \u2014 last log lines:
\`\`\`
${lastLines}
\`\`\``,
commitSha: freshPreview.commitSha,
updatedAt: /* @__PURE__ */ new Date(),
ppBaseUrl: import_env.env.PP_BASE_URL
});
if (freshPreview.giteaCommentId) {
try {
await (0, import_gitea.updateComment)(user, repoConfig.repoOwner, repoConfig.repoName, freshPreview.giteaCommentId, failBody);
} catch {
}
}
}
}
async function firstDeploy(jobId, preview, repoConfig, user, commitSha, prNumber, prTitle, cloneUrl) {
const settings = await (0, import_adminSettings.getAdminSettings)();
const ec2 = (0, import_ec2.makeEc2Client)(user);
const previewId = preview.id;
const activeCount = await import_db.prisma.preview.count({
where: {
repoConfig: { userId: user.id },
status: { in: ["PROVISIONING", "BUILDING", "RUNNING"] },
id: { not: previewId }
}
});
if (activeCount >= settings.maxConcurrentInstancesPerUser) {
const body = (0, import_gitea.buildPrCommentBody)({
owner: repoConfig.repoOwner,
repo: repoConfig.repoName,
prNumber,
status: `\u{1F534} Cannot provision preview \u2014 concurrent instance limit (${settings.maxConcurrentInstancesPerUser}) reached.`,
commitSha,
updatedAt: /* @__PURE__ */ new Date(),
ppBaseUrl: import_env.env.PP_BASE_URL
});
const commentId2 = await (0, import_gitea.postComment)(user, repoConfig.repoOwner, repoConfig.repoName, prNumber, body);
await import_db.prisma.preview.update({ where: { id: previewId }, data: { giteaCommentId: commentId2 } });
throw new Error("Concurrent instance limit reached");
}
await updateStatus(previewId, "PROVISIONING", { commitSha, prNumber, prTitle });
const commentBody = (0, import_gitea.buildPrCommentBody)({
owner: repoConfig.repoOwner,
repo: repoConfig.repoName,
prNumber,
status: "\u{1F7E1} Provisioning EC2 instance...",
commitSha,
updatedAt: /* @__PURE__ */ new Date(),
ppBaseUrl: import_env.env.PP_BASE_URL
});
let commentId = await (0, import_gitea.postComment)(user, repoConfig.repoOwner, repoConfig.repoName, prNumber, commentBody);
await import_db.prisma.preview.update({ where: { id: previewId }, data: { giteaCommentId: commentId } });
checkAbort(previewId);
const keyName = `pp-preview-${previewId}`;
const { privateKey } = await (0, import_ec2.generateAndImportKeyPair)(ec2, keyName);
const encPrivateKey = (0, import_encryption.encrypt)(privateKey);
await import_db.prisma.preview.update({ where: { id: previewId }, data: { sshPrivateKey: encPrivateKey, sshKeyName: keyName } });
checkAbort(previewId);
const sgName = `pp-preview-${previewId}`;
const securityGroupId = await (0, import_ec2.createPreviewSecurityGroup)(ec2, sgName, repoConfig.port);
checkAbort(previewId);
const instanceId = await (0, import_ec2.launchInstance)({
ec2,
region: user.awsRegion,
instanceType: repoConfig.instanceType,
keyName,
securityGroupId,
tags: {
"pp:managed": "true",
"pp:userId": String(user.id),
"pp:repo": `${repoConfig.repoOwner}/${repoConfig.repoName}`,
"pp:prNumber": String(prNumber),
"pp:previewId": String(previewId)
}
});
await import_db.prisma.preview.update({ where: { id: previewId }, data: { instanceId } });
await appendLog(previewId, `[PP] EC2 instance ${instanceId} launched. Waiting for it to be running...
`);
checkAbort(previewId);
const instanceIp = await (0, import_ec2.waitForInstanceRunning)(ec2, instanceId);
await import_db.prisma.preview.update({ where: { id: previewId }, data: { instanceIp, port: repoConfig.port } });
await (0, import_gitea.updateComment)(
user,
repoConfig.repoOwner,
repoConfig.repoName,
commentId,
(0, import_gitea.buildPrCommentBody)({
owner: repoConfig.repoOwner,
repo: repoConfig.repoName,
prNumber,
status: `\u{1F7E1} Building... (EC2 ready at ${instanceIp})`,
commitSha,
updatedAt: /* @__PURE__ */ new Date(),
ppBaseUrl: import_env.env.PP_BASE_URL
})
);
await appendLog(previewId, `[PP] Instance running at ${instanceIp}. Waiting for SSH...
`);
checkAbort(previewId);
const sshSession = await (0, import_ssh.connectSsh)(instanceIp, privateKey, 3e5);
activeSshSessions.set(previewId, sshSession);
try {
if (repoConfig.aptPackages.length > 0) {
await runSshStep(previewId, sshSession, `sudo apt-get install -y ${repoConfig.aptPackages.join(" ")}`);
}
const giteaPat = user.giteaPAT ? (0, import_encryption.decrypt)(user.giteaPAT) : "";
const authCloneUrl = cloneUrl.replace("https://", `https://${user.giteaUsername}:${giteaPat}@`);
await runSshStep(previewId, sshSession, `git clone ${authCloneUrl} /opt/app`);
await runSshStep(previewId, sshSession, `cd /opt/app && git fetch origin pull/${prNumber}/head:pp-pr && git checkout pp-pr`);
await setupAndBuild(previewId, sshSession, repoConfig, preview, commitSha, true);
await updateStatus(previewId, "RUNNING", { commitSha, instanceIp, port: repoConfig.port, lastActivityAt: /* @__PURE__ */ new Date() });
const freshPreview = await import_db.prisma.preview.findUnique({ where: { id: previewId } });
await (0, import_gitea.updateComment)(
user,
repoConfig.repoOwner,
repoConfig.repoName,
commentId,
(0, import_gitea.buildPrCommentBody)({
owner: repoConfig.repoOwner,
repo: repoConfig.repoName,
prNumber,
status: `\u{1F7E2} Live at http://${instanceIp}:${repoConfig.port}`,
commitSha,
updatedAt: /* @__PURE__ */ new Date(),
ppBaseUrl: import_env.env.PP_BASE_URL
})
);
} finally {
sshSession.close();
activeSshSessions.delete(previewId);
}
}
async function redeploy(jobId, preview, repoConfig, user, commitSha, prNumber, prTitle) {
const previewId = preview.id;
const instanceIp = preview.instanceIp;
const privateKey = (0, import_encryption.decrypt)(preview.sshPrivateKey);
const sshSession = await (0, import_ssh.connectSsh)(instanceIp, privateKey, 3e4);
activeSshSessions.set(previewId, sshSession);
await appendLog(previewId, `
--- Redeploy: ${commitSha} ---
`);
const freshPreview = await import_db.prisma.preview.findUnique({ where: { id: previewId } });
const commentBody = (0, import_gitea.buildPrCommentBody)({
owner: repoConfig.repoOwner,
repo: repoConfig.repoName,
prNumber,
status: `\u{1F7E1} Building... (EC2 at ${instanceIp})`,
commitSha,
updatedAt: /* @__PURE__ */ new Date(),
ppBaseUrl: import_env.env.PP_BASE_URL
});
const newCommentId = await (0, import_gitea.postComment)(user, repoConfig.repoOwner, repoConfig.repoName, prNumber, commentBody);
await import_db.prisma.preview.update({ where: { id: previewId }, data: { giteaCommentId: newCommentId, commitSha } });
try {
if (repoConfig.useDockerCompose) {
const composePath = repoConfig.composeFilePath || "docker-compose.yml";
await runSshStep(previewId, sshSession, `cd /opt/app && docker compose -f ${composePath} down 2>&1 || true`);
} else if (freshPreview?.pid) {
await runSshStep(previewId, sshSession, `kill ${freshPreview.pid} 2>/dev/null || true; sleep 5; kill -9 ${freshPreview.pid} 2>/dev/null || true`);
}
await runSshStep(previewId, sshSession, `cd /opt/app && git fetch origin pull/${prNumber}/head:pp-pr && git checkout pp-pr && git reset --hard FETCH_HEAD`);
await updateStatus(previewId, "BUILDING");
await setupAndBuild(previewId, sshSession, repoConfig, preview, commitSha, false);
await updateStatus(previewId, "RUNNING", { commitSha, lastActivityAt: /* @__PURE__ */ new Date() });
await (0, import_gitea.updateComment)(
user,
repoConfig.repoOwner,
repoConfig.repoName,
newCommentId,
(0, import_gitea.buildPrCommentBody)({
owner: repoConfig.repoOwner,
repo: repoConfig.repoName,
prNumber,
status: `\u{1F7E2} Live at http://${instanceIp}:${repoConfig.port}`,
commitSha,
updatedAt: /* @__PURE__ */ new Date(),
ppBaseUrl: import_env.env.PP_BASE_URL
})
);
} finally {
sshSession.close();
activeSshSessions.delete(previewId);
}
}
async function setupAndBuild(previewId, sshSession, repoConfig, preview, commitSha, isFirstProvision) {
await detectAndUseNode(previewId, sshSession);
const envVars = repoConfig.envVars;
const envContent = Object.entries(envVars).map(([k, v]) => `${k}=${v}`).join("\n");
await runSshStep(previewId, sshSession, `cat > /opt/app/.env << 'PPEOF'
${envContent}
PPEOF`);
if (isFirstProvision) {
for (const cmd of repoConfig.setupCommands) {
await runSshStep(previewId, sshSession, `cd /opt/app && ${cmd}`);
}
}
await updateStatus(previewId, "BUILDING");
if (repoConfig.useDockerCompose) {
const composePath = repoConfig.composeFilePath || "docker-compose.yml";
await runSshStep(previewId, sshSession, `cd /opt/app && docker compose -f ${composePath} up -d --build --force-recreate 2>&1`);
} else {
for (const cmd of repoConfig.buildCommands) {
await runSshStep(previewId, sshSession, `cd /opt/app && ${cmd}`);
}
for (const cmd of repoConfig.postBuildCommands) {
await runSshStep(previewId, sshSession, `cd /opt/app && ${cmd}`);
}
if (repoConfig.runCommand) {
const res = await runSshStep(
previewId,
sshSession,
`cd /opt/app && nohup ${repoConfig.runCommand} > /opt/app/pp.log 2>&1 & echo $!`
);
const pid = parseInt(res.stdout.trim(), 10);
if (!isNaN(pid)) {
await import_db.prisma.preview.update({ where: { id: previewId }, data: { pid } });
}
}
}
}
async function detectAndUseNode(previewId, sshSession) {
const nvmSource = `export NVM_DIR="/root/.nvm" && source "$NVM_DIR/nvm.sh"`;
const res = await runSshStep(previewId, sshSession, `${nvmSource} && [ -f /opt/app/.nvmrc ] && nvm install && nvm use || nvm use default 2>&1`, false);
}
async function runSshStep(previewId, sshSession, command, throwOnFail = true) {
checkAbortSession(sshSession);
await appendLog(previewId, `$ ${command}
`);
const res = await sshSession.exec(command);
if (res.stdout) await appendLog(previewId, res.stdout);
if (res.stderr) await appendLog(previewId, res.stderr);
if (throwOnFail && res.code !== 0) {
throw new Error(`Command failed with exit code ${res.code}: ${command}`);
}
return res;
}
function checkAbortSession(session) {
if (session.aborted) throw new Error("ABORTED");
}
const abortSignals = /* @__PURE__ */ new Set();
function signalAbort(previewId) {
abortSignals.add(previewId);
abortJobForPreview(previewId);
}
function checkAbort(previewId) {
if (abortSignals.has(previewId)) {
abortSignals.delete(previewId);
throw new Error("ABORTED");
}
}
async function stopPreview(previewId, reason = "STOPPED") {
const preview = await import_db.prisma.preview.findUnique({
where: { id: previewId },
include: { repoConfig: { include: { user: true } } }
});
if (!preview) return;
const user = preview.repoConfig.user;
const repoConfig = preview.repoConfig;
if (preview.instanceId) {
const ec2 = (0, import_ec2.makeEc2Client)(user);
try {
await (0, import_ec2.deleteKeyPairAws)(ec2, `pp-preview-${previewId}`);
} catch {
}
try {
await (0, import_ec2.deleteSecurityGroupAws)(ec2, `pp-preview-${previewId}`);
} catch {
}
try {
await (0, import_ec2.terminateInstance)(ec2, preview.instanceId);
} catch {
}
}
await import_db.prisma.preview.update({
where: { id: previewId },
data: {
status: reason,
stoppedAt: /* @__PURE__ */ new Date(),
sshPrivateKey: null,
sshKeyName: null,
instanceId: null
}
});
const stoppedBody = (0, import_gitea.buildPrCommentBody)({
owner: repoConfig.repoOwner,
repo: repoConfig.repoName,
prNumber: preview.prNumber,
status: "\u26AB Stopped (inactivity timeout / PR closed / manual stop)",
commitSha: preview.commitSha,
updatedAt: /* @__PURE__ */ new Date(),
ppBaseUrl: import_env.env.PP_BASE_URL
});
if (preview.giteaCommentId) {
try {
await (0, import_gitea.updateComment)(user, repoConfig.repoOwner, repoConfig.repoName, preview.giteaCommentId, stoppedBody);
} catch {
}
}
}
// Annotate the CommonJS export names for ESM import in node:
0 && (module.exports = {
abortJobForPreview,
appendLog,
runDeploy,
signalAbort,
stopPreview,
subscribeToLogs
});
//# sourceMappingURL=deploy.js.map