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>
This commit is contained in:
2026-07-25 00:18:08 +02:00
parent ca2efdadea
commit 40d484bede
108 changed files with 13393 additions and 0 deletions
+440
View File
@@ -0,0 +1,440 @@
"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
File diff suppressed because one or more lines are too long
+249
View File
@@ -0,0 +1,249 @@
"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 ec2_exports = {};
__export(ec2_exports, {
createPreviewSecurityGroup: () => createPreviewSecurityGroup,
deleteKeyPairAws: () => deleteKeyPairAws,
deleteSecurityGroupAws: () => deleteSecurityGroupAws,
describeAllManagedInstances: () => describeAllManagedInstances,
generateAndImportKeyPair: () => generateAndImportKeyPair,
generateSshKeyPair: () => generateSshKeyPair,
launchInstance: () => launchInstance,
makeEc2Client: () => makeEc2Client,
makeStsClient: () => makeStsClient,
terminateInstance: () => terminateInstance,
validateAwsCredentials: () => validateAwsCredentials,
waitForInstanceRunning: () => waitForInstanceRunning
});
module.exports = __toCommonJS(ec2_exports);
var import_client_ec2 = require("@aws-sdk/client-ec2");
var import_client_sts = require("@aws-sdk/client-sts");
var import_crypto = require("crypto");
var import_logger = require("../lib/logger");
var import_encryption = require("../lib/encryption");
const log = (0, import_logger.createLogger)("EC2");
const UBUNTU_22_04_AMI = {
"us-east-1": "ami-0e86e20dae9224db8",
"us-east-2": "ami-0a0d9cf81c479446a",
"us-west-1": "ami-05c969369880fa2c2",
"us-west-2": "ami-03f8acd418785369b",
"eu-west-1": "ami-0694d931cee176e7d",
"eu-west-2": "ami-0f3d9639a5674d559",
"eu-west-3": "ami-022e307f4b9e39f45",
"eu-central-1": "ami-0faab6bdbac9486fb",
"ap-southeast-1": "ami-0823c236601fef765",
"ap-southeast-2": "ami-07620139298af599e",
"ap-northeast-1": "ami-0b7546e839d7ace12",
"ap-northeast-2": "ami-042e76978adeb8c48",
"ap-south-1": "ami-076e3a557efe1aa9c",
"sa-east-1": "ami-0eed58016fbe42de3",
"ca-central-1": "ami-024f768de9e73d4f4",
"eu-north-1": "ami-00381a880aa48c6c6",
"me-south-1": "ami-09574f34b8dcd2eac",
"af-south-1": "ami-08fdcf06b39fe83ec"
};
function makeEc2Client(user) {
const accessKeyId = user.awsAccessKeyId ? (0, import_encryption.decrypt)(user.awsAccessKeyId) : "";
const secretAccessKey = user.awsSecretAccessKey ? (0, import_encryption.decrypt)(user.awsSecretAccessKey) : "";
return new import_client_ec2.EC2Client({
region: user.awsRegion,
credentials: { accessKeyId, secretAccessKey }
});
}
function makeStsClient(user) {
const accessKeyId = user.awsAccessKeyId ? (0, import_encryption.decrypt)(user.awsAccessKeyId) : "";
const secretAccessKey = user.awsSecretAccessKey ? (0, import_encryption.decrypt)(user.awsSecretAccessKey) : "";
return new import_client_sts.STSClient({
region: user.awsRegion,
credentials: { accessKeyId, secretAccessKey }
});
}
async function validateAwsCredentials(user) {
try {
const sts = makeStsClient(user);
const res = await sts.send(new import_client_sts.GetCallerIdentityCommand({}));
return { success: true, arn: res.Arn };
} catch (e) {
return { success: false, error: e.message };
}
}
function generateSshKeyPair() {
const { privateKey, publicKey } = (0, import_crypto.generateKeyPairSync)("rsa", {
modulusLength: 2048,
publicKeyEncoding: { type: "pkcs1", format: "pem" },
privateKeyEncoding: { type: "pkcs1", format: "pem" }
});
const pubKeyOpenSsh = rsaPemToOpenSsh(publicKey);
return { privateKey, publicKey: pubKeyOpenSsh };
}
function rsaPemToOpenSsh(pem) {
const { publicKeyEncoding } = (0, import_crypto.generateKeyPairSync)("rsa", {
modulusLength: 2048,
publicKeyEncoding: { type: "pkcs8", format: "pem" },
privateKeyEncoding: { type: "pkcs8", format: "pem" }
});
void publicKeyEncoding;
const der = Buffer.from(
pem.replace(/-----BEGIN RSA PUBLIC KEY-----/, "").replace(/-----END RSA PUBLIC KEY-----/, "").replace(/\n/g, ""),
"base64"
);
const type = Buffer.from("ssh-rsa");
function encodeBuffer(buf) {
const len = Buffer.allocUnsafe(4);
len.writeUInt32BE(buf.length, 0);
return Buffer.concat([len, buf]);
}
const typeEncoded = encodeBuffer(type);
const rsaKeyData = der;
const base64Key = Buffer.concat([typeEncoded, rsaKeyData]).toString("base64");
return `ssh-rsa ${base64Key} pp-generated`;
}
async function generateAndImportKeyPair(ec2, keyName) {
const { privateKey, publicKey } = generateSshKeyPair();
await ec2.send(new import_client_ec2.ImportKeyPairCommand({
KeyName: keyName,
PublicKeyMaterial: Buffer.from(publicKey)
}));
return { privateKey };
}
async function createPreviewSecurityGroup(ec2, groupName, port) {
const describe = await ec2.send(new import_client_ec2.DescribeSecurityGroupsCommand({
Filters: [{ Name: "group-name", Values: [groupName] }]
}));
if (describe.SecurityGroups && describe.SecurityGroups.length > 0) {
return describe.SecurityGroups[0].GroupId;
}
const res = await ec2.send(new import_client_ec2.CreateSecurityGroupCommand({
GroupName: groupName,
Description: `PP Preview security group: ${groupName}`
}));
const groupId = res.GroupId;
await ec2.send(new import_client_ec2.AuthorizeSecurityGroupIngressCommand({
GroupId: groupId,
IpPermissions: [
{
IpProtocol: "tcp",
FromPort: 22,
ToPort: 22,
IpRanges: [{ CidrIp: "0.0.0.0/0" }]
},
{
IpProtocol: "tcp",
FromPort: port,
ToPort: port,
IpRanges: [{ CidrIp: "0.0.0.0/0" }]
}
]
}));
return groupId;
}
const BOOTSTRAP_SCRIPT = `#!/bin/bash
set -e
apt-get update -y
apt-get install -y curl git unzip build-essential
curl -fsSL https://get.docker.com | sh
systemctl enable docker
systemctl start docker
apt-get install -y docker-compose-plugin
curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.39.7/install.sh | bash
export NVM_DIR="/root/.nvm"
source "$NVM_DIR/nvm.sh"
nvm install --lts
nvm alias default lts/*
`;
async function launchInstance(opts) {
const ami = UBUNTU_22_04_AMI[opts.region] ?? UBUNTU_22_04_AMI["us-east-1"];
const tagSpecs = Object.entries(opts.tags).map(([k, v]) => ({ Key: k, Value: v }));
tagSpecs.push({ Key: "Name", Value: `pp-preview-${opts.tags["pp:previewId"]}` });
const res = await opts.ec2.send(new import_client_ec2.RunInstancesCommand({
ImageId: ami,
InstanceType: opts.instanceType,
MinCount: 1,
MaxCount: 1,
KeyName: opts.keyName,
SecurityGroupIds: [opts.securityGroupId],
UserData: Buffer.from(BOOTSTRAP_SCRIPT).toString("base64"),
TagSpecifications: [
{ ResourceType: "instance", Tags: tagSpecs }
]
}));
return res.Instances[0].InstanceId;
}
async function waitForInstanceRunning(ec2, instanceId, maxWaitMs = 3e5) {
const start = Date.now();
while (Date.now() - start < maxWaitMs) {
const res = await ec2.send(new import_client_ec2.DescribeInstancesCommand({
InstanceIds: [instanceId]
}));
const inst = res.Reservations?.[0]?.Instances?.[0];
if (inst?.State?.Name === "running" && inst.PublicIpAddress) {
return inst.PublicIpAddress;
}
await sleep(5e3);
}
throw new Error(`Instance ${instanceId} did not reach running state within timeout`);
}
async function terminateInstance(ec2, instanceId) {
await ec2.send(new import_client_ec2.TerminateInstancesCommand({ InstanceIds: [instanceId] }));
}
async function deleteKeyPairAws(ec2, keyName) {
try {
await ec2.send(new import_client_ec2.DeleteKeyPairCommand({ KeyName: keyName }));
} catch (e) {
log.warn({ e, keyName }, "Failed to delete key pair");
}
}
async function deleteSecurityGroupAws(ec2, groupName) {
try {
const describe = await ec2.send(new import_client_ec2.DescribeSecurityGroupsCommand({
Filters: [{ Name: "group-name", Values: [groupName] }]
}));
const groupId = describe.SecurityGroups?.[0]?.GroupId;
if (groupId) {
await ec2.send(new import_client_ec2.DeleteSecurityGroupCommand({ GroupId: groupId }));
}
} catch (e) {
log.warn({ e, groupName }, "Failed to delete security group");
}
}
async function describeAllManagedInstances(ec2) {
const res = await ec2.send(new import_client_ec2.DescribeInstancesCommand({
Filters: [{ Name: "tag:pp:managed", Values: ["true"] }, { Name: "instance-state-name", Values: ["running", "pending", "stopping", "stopped"] }]
}));
return (res.Reservations ?? []).flatMap((r) => r.Instances ?? []);
}
function sleep(ms) {
return new Promise((r) => setTimeout(r, ms));
}
// Annotate the CommonJS export names for ESM import in node:
0 && (module.exports = {
createPreviewSecurityGroup,
deleteKeyPairAws,
deleteSecurityGroupAws,
describeAllManagedInstances,
generateAndImportKeyPair,
generateSshKeyPair,
launchInstance,
makeEc2Client,
makeStsClient,
terminateInstance,
validateAwsCredentials,
waitForInstanceRunning
});
//# sourceMappingURL=ec2.js.map
File diff suppressed because one or more lines are too long
+170
View File
@@ -0,0 +1,170 @@
"use strict";
var __create = Object.create;
var __defProp = Object.defineProperty;
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
var __getOwnPropNames = Object.getOwnPropertyNames;
var __getProtoOf = Object.getPrototypeOf;
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 __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(
// If the importer is in node compatibility mode or this is not an ESM
// file that has been converted to a CommonJS file using a Babel-
// compatible transform (i.e. "__esModule" has not been set), then set
// "default" to the CommonJS "module.exports" for node compatibility.
isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target,
mod
));
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
var gitea_exports = {};
__export(gitea_exports, {
buildPrCommentBody: () => buildPrCommentBody,
checkUserPermission: () => checkUserPermission,
deleteWebhook: () => deleteWebhook,
fetchUserRepos: () => fetchUserRepos,
getRepoCollaboratorPermission: () => getRepoCollaboratorPermission,
giteaApi: () => giteaApi,
postComment: () => postComment,
registerWebhook: () => registerWebhook,
updateComment: () => updateComment,
updateWebhookSecret: () => updateWebhookSecret,
validateGiteaUrl: () => validateGiteaUrl
});
module.exports = __toCommonJS(gitea_exports);
var import_axios = __toESM(require("axios"));
var import_encryption = require("../lib/encryption");
function giteaApi(user) {
const pat = user.giteaPAT ? (0, import_encryption.decrypt)(user.giteaPAT) : "";
return import_axios.default.create({
baseURL: `${user.giteaInstanceUrl}/api/v1`,
headers: {
Authorization: `token ${pat}`,
"Content-Type": "application/json"
},
timeout: 15e3
});
}
async function validateGiteaUrl(url) {
try {
const res = await import_axios.default.get(`${url}/api/v1/version`, { timeout: 1e4 });
return { success: true, version: res.data.version };
} catch (e) {
return { success: false, error: e.message };
}
}
async function fetchUserRepos(user) {
const api = giteaApi(user);
const repos = [];
let page = 1;
while (true) {
const res = await api.get(`/repos/search?limit=50&page=${page}`);
const data = res.data?.data ?? [];
if (data.length === 0) break;
repos.push(...data);
if (data.length < 50) break;
page++;
}
return repos;
}
async function registerWebhook(user, owner, repo, webhookUrl, secret) {
const api = giteaApi(user);
const res = await api.post(`/repos/${owner}/${repo}/hooks`, {
type: "gitea",
config: {
url: webhookUrl,
secret,
content_type: "json"
},
events: ["pull_request", "issue_comment"],
active: true
});
return res.data.id;
}
async function deleteWebhook(user, owner, repo, hookId) {
const api = giteaApi(user);
await api.delete(`/repos/${owner}/${repo}/hooks/${hookId}`);
}
async function updateWebhookSecret(user, owner, repo, hookId, webhookUrl, newSecret) {
const api = giteaApi(user);
await api.patch(`/repos/${owner}/${repo}/hooks/${hookId}`, {
config: {
url: webhookUrl,
secret: newSecret,
content_type: "json"
},
events: ["pull_request", "issue_comment"],
active: true
});
}
async function postComment(user, owner, repo, issueNumber, body) {
const api = giteaApi(user);
const res = await api.post(`/repos/${owner}/${repo}/issues/${issueNumber}/comments`, { body });
return res.data.id;
}
async function updateComment(user, owner, repo, commentId, body) {
const api = giteaApi(user);
await api.patch(`/repos/${owner}/${repo}/issues/comments/${commentId}`, { body });
}
async function checkUserPermission(user, owner, repo, username) {
try {
const api = giteaApi(user);
const res = await api.get(`/repos/${owner}/${repo}/collaborators/${username}`);
return res.status === 204;
} catch {
return false;
}
}
async function getRepoCollaboratorPermission(user, owner, repo, username) {
try {
const api = giteaApi(user);
const res = await api.get(`/repos/${owner}/${repo}/collaborators/${username}/permission`);
return res.data?.permission ?? null;
} catch {
return null;
}
}
function buildPrCommentBody(opts) {
const { owner, repo, prNumber, status, commitSha, updatedAt, ppBaseUrl, lastLogLines, instanceIp, port } = opts;
const ts = updatedAt.toISOString().replace("T", " ").slice(0, 19) + " UTC";
let statusLine = status;
if (lastLogLines) {
statusLine += `
\`\`\`
${lastLogLines}
\`\`\``;
}
return `## \u{1F680} PR Preview \u2014 \`${owner}/${repo}\` #${prNumber}
**Status:** ${statusLine}
**Commit:** \`${commitSha.slice(0, 8)}\`
**Updated:** ${ts}
---
_Powered by [PR Previews](${ppBaseUrl})_`;
}
// Annotate the CommonJS export names for ESM import in node:
0 && (module.exports = {
buildPrCommentBody,
checkUserPermission,
deleteWebhook,
fetchUserRepos,
getRepoCollaboratorPermission,
giteaApi,
postComment,
registerWebhook,
updateComment,
updateWebhookSecret,
validateGiteaUrl
});
//# sourceMappingURL=gitea.js.map
+7
View File
@@ -0,0 +1,7 @@
{
"version": 3,
"sources": ["../../src/services/gitea.ts"],
"sourcesContent": ["import axios from \"axios\";\nimport { decrypt } from \"../lib/encryption\";\nimport type { User } from \"@prisma/client\";\n\nexport function giteaApi(user: User) {\n const pat = user.giteaPAT ? decrypt(user.giteaPAT) : \"\";\n return axios.create({\n baseURL: `${user.giteaInstanceUrl}/api/v1`,\n headers: {\n Authorization: `token ${pat}`,\n \"Content-Type\": \"application/json\",\n },\n timeout: 15000,\n });\n}\n\nexport async function validateGiteaUrl(url: string): Promise<{ success: boolean; version?: string; error?: string }> {\n try {\n const res = await axios.get(`${url}/api/v1/version`, { timeout: 10000 });\n return { success: true, version: res.data.version };\n } catch (e: any) {\n return { success: false, error: e.message };\n }\n}\n\nexport async function fetchUserRepos(user: User): Promise<any[]> {\n const api = giteaApi(user);\n const repos: any[] = [];\n let page = 1;\n while (true) {\n const res = await api.get(`/repos/search?limit=50&page=${page}`);\n const data = res.data?.data ?? [];\n if (data.length === 0) break;\n repos.push(...data);\n if (data.length < 50) break;\n page++;\n }\n return repos;\n}\n\nexport async function registerWebhook(user: User, owner: string, repo: string, webhookUrl: string, secret: string): Promise<number> {\n const api = giteaApi(user);\n const res = await api.post(`/repos/${owner}/${repo}/hooks`, {\n type: \"gitea\",\n config: {\n url: webhookUrl,\n secret,\n content_type: \"json\",\n },\n events: [\"pull_request\", \"issue_comment\"],\n active: true,\n });\n return res.data.id;\n}\n\nexport async function deleteWebhook(user: User, owner: string, repo: string, hookId: string): Promise<void> {\n const api = giteaApi(user);\n await api.delete(`/repos/${owner}/${repo}/hooks/${hookId}`);\n}\n\nexport async function updateWebhookSecret(user: User, owner: string, repo: string, hookId: string, webhookUrl: string, newSecret: string): Promise<void> {\n const api = giteaApi(user);\n await api.patch(`/repos/${owner}/${repo}/hooks/${hookId}`, {\n config: {\n url: webhookUrl,\n secret: newSecret,\n content_type: \"json\",\n },\n events: [\"pull_request\", \"issue_comment\"],\n active: true,\n });\n}\n\nexport async function postComment(user: User, owner: string, repo: string, issueNumber: number, body: string): Promise<number> {\n const api = giteaApi(user);\n const res = await api.post(`/repos/${owner}/${repo}/issues/${issueNumber}/comments`, { body });\n return res.data.id;\n}\n\nexport async function updateComment(user: User, owner: string, repo: string, commentId: number, body: string): Promise<void> {\n const api = giteaApi(user);\n await api.patch(`/repos/${owner}/${repo}/issues/comments/${commentId}`, { body });\n}\n\nexport async function checkUserPermission(user: User, owner: string, repo: string, username: string): Promise<boolean> {\n try {\n const api = giteaApi(user);\n const res = await api.get(`/repos/${owner}/${repo}/collaborators/${username}`);\n return res.status === 204;\n } catch {\n return false;\n }\n}\n\nexport async function getRepoCollaboratorPermission(user: User, owner: string, repo: string, username: string): Promise<string | null> {\n try {\n const api = giteaApi(user);\n const res = await api.get(`/repos/${owner}/${repo}/collaborators/${username}/permission`);\n return res.data?.permission ?? null;\n } catch {\n return null;\n }\n}\n\nexport function buildPrCommentBody(opts: {\n owner: string;\n repo: string;\n prNumber: number;\n status: string;\n commitSha: string;\n updatedAt: Date;\n ppBaseUrl: string;\n lastLogLines?: string;\n instanceIp?: string;\n port?: number;\n}): string {\n const { owner, repo, prNumber, status, commitSha, updatedAt, ppBaseUrl, lastLogLines, instanceIp, port } = opts;\n const ts = updatedAt.toISOString().replace(\"T\", \" \").slice(0, 19) + \" UTC\";\n\n let statusLine = status;\n if (lastLogLines) {\n statusLine += `\\n\\n\\`\\`\\`\\n${lastLogLines}\\n\\`\\`\\``;\n }\n\n return `## \uD83D\uDE80 PR Preview \u2014 \\`${owner}/${repo}\\` #${prNumber}\n\n**Status:** ${statusLine}\n**Commit:** \\`${commitSha.slice(0, 8)}\\`\n**Updated:** ${ts}\n\n---\n_Powered by [PR Previews](${ppBaseUrl})_`;\n}\n"],
"mappings": "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",
"names": ["axios"]
}
+115
View File
@@ -0,0 +1,115 @@
"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 ssh_exports = {};
__export(ssh_exports, {
connectSsh: () => connectSsh
});
module.exports = __toCommonJS(ssh_exports);
var import_ssh2 = require("ssh2");
var import_logger = require("../lib/logger");
const log = (0, import_logger.createLogger)("SSH");
async function connectSsh(host, privateKey, maxWaitMs = 3e5) {
const start = Date.now();
while (Date.now() - start < maxWaitMs) {
try {
const conn = await tryConnect(host, privateKey, 1e4);
let aborted = false;
return {
get aborted() {
return aborted;
},
abort() {
aborted = true;
try {
conn.end();
} catch {
}
},
async exec(command) {
if (aborted) throw new Error("SSH session aborted");
return execOnConn(conn, command);
},
close() {
try {
conn.end();
} catch {
}
}
};
} catch (e) {
if (Date.now() - start > maxWaitMs) throw e;
log.debug({ host, error: e.message }, "SSH connect retry");
await sleep(5e3);
}
}
throw new Error(`Could not SSH into ${host} within timeout`);
}
function tryConnect(host, privateKey, timeoutMs) {
return new Promise((resolve, reject) => {
const conn = new import_ssh2.Client();
const timer = setTimeout(() => {
conn.end();
reject(new Error(`SSH connection to ${host} timed out`));
}, timeoutMs);
conn.on("ready", () => {
clearTimeout(timer);
resolve(conn);
});
conn.on("error", (e) => {
clearTimeout(timer);
reject(e);
});
conn.connect({
host,
port: 22,
username: "ubuntu",
privateKey,
readyTimeout: timeoutMs,
algorithms: {
serverHostKey: ["ssh-rsa", "ecdsa-sha2-nistp256", "ecdsa-sha2-nistp384", "ecdsa-sha2-nistp521"]
}
});
});
}
function execOnConn(conn, command) {
return new Promise((resolve, reject) => {
conn.exec(command, (err, stream) => {
if (err) return reject(err);
let stdout = "";
let stderr = "";
stream.on("data", (d) => {
stdout += d.toString();
});
stream.stderr.on("data", (d) => {
stderr += d.toString();
});
stream.on("close", (code) => {
resolve({ stdout, stderr, code: code ?? 0 });
});
});
});
}
function sleep(ms) {
return new Promise((r) => setTimeout(r, ms));
}
// Annotate the CommonJS export names for ESM import in node:
0 && (module.exports = {
connectSsh
});
//# sourceMappingURL=ssh.js.map
+7
View File
@@ -0,0 +1,7 @@
{
"version": 3,
"sources": ["../../src/services/ssh.ts"],
"sourcesContent": ["import { Client } from \"ssh2\";\nimport { createLogger } from \"../lib/logger\";\n\nconst log = createLogger(\"SSH\");\n\nexport interface SshSession {\n exec(command: string): Promise<{ stdout: string; stderr: string; code: number }>;\n close(): void;\n aborted: boolean;\n abort(): void;\n}\n\nexport async function connectSsh(host: string, privateKey: string, maxWaitMs = 300_000): Promise<SshSession> {\n const start = Date.now();\n\n while (Date.now() - start < maxWaitMs) {\n try {\n const conn = await tryConnect(host, privateKey, 10000);\n let aborted = false;\n\n return {\n get aborted() { return aborted; },\n abort() {\n aborted = true;\n try { conn.end(); } catch {}\n },\n async exec(command: string) {\n if (aborted) throw new Error(\"SSH session aborted\");\n return execOnConn(conn, command);\n },\n close() {\n try { conn.end(); } catch {}\n },\n };\n } catch (e: any) {\n if (Date.now() - start > maxWaitMs) throw e;\n log.debug({ host, error: e.message }, \"SSH connect retry\");\n await sleep(5000);\n }\n }\n throw new Error(`Could not SSH into ${host} within timeout`);\n}\n\nfunction tryConnect(host: string, privateKey: string, timeoutMs: number): Promise<Client> {\n return new Promise((resolve, reject) => {\n const conn = new Client();\n const timer = setTimeout(() => {\n conn.end();\n reject(new Error(`SSH connection to ${host} timed out`));\n }, timeoutMs);\n\n conn.on(\"ready\", () => {\n clearTimeout(timer);\n resolve(conn);\n });\n conn.on(\"error\", (e) => {\n clearTimeout(timer);\n reject(e);\n });\n conn.connect({\n host,\n port: 22,\n username: \"ubuntu\",\n privateKey,\n readyTimeout: timeoutMs,\n algorithms: {\n serverHostKey: [\"ssh-rsa\", \"ecdsa-sha2-nistp256\", \"ecdsa-sha2-nistp384\", \"ecdsa-sha2-nistp521\"],\n },\n });\n });\n}\n\nfunction execOnConn(conn: Client, command: string): Promise<{ stdout: string; stderr: string; code: number }> {\n return new Promise((resolve, reject) => {\n conn.exec(command, (err, stream) => {\n if (err) return reject(err);\n let stdout = \"\";\n let stderr = \"\";\n stream.on(\"data\", (d: Buffer) => { stdout += d.toString(); });\n stream.stderr.on(\"data\", (d: Buffer) => { stderr += d.toString(); });\n stream.on(\"close\", (code: number) => {\n resolve({ stdout, stderr, code: code ?? 0 });\n });\n });\n });\n}\n\nfunction sleep(ms: number) {\n return new Promise(r => setTimeout(r, ms));\n}\n"],
"mappings": ";;;;;;;;;;;;;;;;;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,kBAAuB;AACvB,oBAA6B;AAE7B,MAAM,UAAM,4BAAa,KAAK;AAS9B,eAAsB,WAAW,MAAc,YAAoB,YAAY,KAA8B;AAC3G,QAAM,QAAQ,KAAK,IAAI;AAEvB,SAAO,KAAK,IAAI,IAAI,QAAQ,WAAW;AACrC,QAAI;AACF,YAAM,OAAO,MAAM,WAAW,MAAM,YAAY,GAAK;AACrD,UAAI,UAAU;AAEd,aAAO;AAAA,QACL,IAAI,UAAU;AAAE,iBAAO;AAAA,QAAS;AAAA,QAChC,QAAQ;AACN,oBAAU;AACV,cAAI;AAAE,iBAAK,IAAI;AAAA,UAAG,QAAQ;AAAA,UAAC;AAAA,QAC7B;AAAA,QACA,MAAM,KAAK,SAAiB;AAC1B,cAAI,QAAS,OAAM,IAAI,MAAM,qBAAqB;AAClD,iBAAO,WAAW,MAAM,OAAO;AAAA,QACjC;AAAA,QACA,QAAQ;AACN,cAAI;AAAE,iBAAK,IAAI;AAAA,UAAG,QAAQ;AAAA,UAAC;AAAA,QAC7B;AAAA,MACF;AAAA,IACF,SAAS,GAAQ;AACf,UAAI,KAAK,IAAI,IAAI,QAAQ,UAAW,OAAM;AAC1C,UAAI,MAAM,EAAE,MAAM,OAAO,EAAE,QAAQ,GAAG,mBAAmB;AACzD,YAAM,MAAM,GAAI;AAAA,IAClB;AAAA,EACF;AACA,QAAM,IAAI,MAAM,sBAAsB,IAAI,iBAAiB;AAC7D;AAEA,SAAS,WAAW,MAAc,YAAoB,WAAoC;AACxF,SAAO,IAAI,QAAQ,CAAC,SAAS,WAAW;AACtC,UAAM,OAAO,IAAI,mBAAO;AACxB,UAAM,QAAQ,WAAW,MAAM;AAC7B,WAAK,IAAI;AACT,aAAO,IAAI,MAAM,qBAAqB,IAAI,YAAY,CAAC;AAAA,IACzD,GAAG,SAAS;AAEZ,SAAK,GAAG,SAAS,MAAM;AACrB,mBAAa,KAAK;AAClB,cAAQ,IAAI;AAAA,IACd,CAAC;AACD,SAAK,GAAG,SAAS,CAAC,MAAM;AACtB,mBAAa,KAAK;AAClB,aAAO,CAAC;AAAA,IACV,CAAC;AACD,SAAK,QAAQ;AAAA,MACX;AAAA,MACA,MAAM;AAAA,MACN,UAAU;AAAA,MACV;AAAA,MACA,cAAc;AAAA,MACd,YAAY;AAAA,QACV,eAAe,CAAC,WAAW,uBAAuB,uBAAuB,qBAAqB;AAAA,MAChG;AAAA,IACF,CAAC;AAAA,EACH,CAAC;AACH;AAEA,SAAS,WAAW,MAAc,SAA4E;AAC5G,SAAO,IAAI,QAAQ,CAAC,SAAS,WAAW;AACtC,SAAK,KAAK,SAAS,CAAC,KAAK,WAAW;AAClC,UAAI,IAAK,QAAO,OAAO,GAAG;AAC1B,UAAI,SAAS;AACb,UAAI,SAAS;AACb,aAAO,GAAG,QAAQ,CAAC,MAAc;AAAE,kBAAU,EAAE,SAAS;AAAA,MAAG,CAAC;AAC5D,aAAO,OAAO,GAAG,QAAQ,CAAC,MAAc;AAAE,kBAAU,EAAE,SAAS;AAAA,MAAG,CAAC;AACnE,aAAO,GAAG,SAAS,CAAC,SAAiB;AACnC,gBAAQ,EAAE,QAAQ,QAAQ,MAAM,QAAQ,EAAE,CAAC;AAAA,MAC7C,CAAC;AAAA,IACH,CAAC;AAAA,EACH,CAAC;AACH;AAEA,SAAS,MAAM,IAAY;AACzB,SAAO,IAAI,QAAQ,OAAK,WAAW,GAAG,EAAE,CAAC;AAC3C;",
"names": []
}