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
+100
View File
@@ -0,0 +1,100 @@
"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 cronWorker_exports = {};
__export(cronWorker_exports, {
startCronWorkers: () => startCronWorkers
});
module.exports = __toCommonJS(cronWorker_exports);
var import_node_cron = __toESM(require("node-cron"));
var import_db = require("../lib/db");
var import_logger = require("../lib/logger");
var import_adminSettings = require("../lib/adminSettings");
const log = (0, import_logger.createLogger)("CRON");
function startCronWorkers() {
import_node_cron.default.schedule("*/30 * * * *", async () => {
try {
await checkInactivity();
} catch (e) {
log.error({ e }, "Inactivity check error");
}
});
import_node_cron.default.schedule("0 3 * * *", async () => {
try {
await dailyCleanup();
} catch (e) {
log.error({ e }, "Daily cleanup error");
}
});
log.info("Cron workers started");
}
async function checkInactivity() {
const settings = await (0, import_adminSettings.getAdminSettings)();
const now = /* @__PURE__ */ new Date();
const running = await import_db.prisma.preview.findMany({
where: { status: "RUNNING" }
});
for (const preview of running) {
const inactivityMs = settings.maxConcurrentInstancesPerUser;
const repoConfig = await import_db.prisma.repoConfig.findUnique({ where: { id: preview.repoConfigId } });
if (!repoConfig) continue;
const deadline = new Date(preview.lastActivityAt.getTime() + repoConfig.inactivityHours * 3600 * 1e3);
if (now >= deadline) {
log.info({ previewId: preview.id }, "Preview inactive, enqueuing INACTIVITY_STOP");
await import_db.prisma.job.create({
data: {
previewId: preview.id,
type: "INACTIVITY_STOP",
status: "PENDING",
payload: {}
}
});
}
}
}
async function dailyCleanup() {
const settings = await (0, import_adminSettings.getAdminSettings)();
const cutoff = new Date(Date.now() - settings.previewRetentionDays * 86400 * 1e3);
const old = await import_db.prisma.preview.findMany({
where: {
status: { in: ["STOPPED", "FAILED"] },
stoppedAt: { lt: cutoff }
},
select: { id: true }
});
for (const { id } of old) {
await import_db.prisma.job.deleteMany({ where: { previewId: id } });
await import_db.prisma.preview.delete({ where: { id } });
}
if (old.length > 0) log.info({ count: old.length }, "Cleaned up old previews");
}
// Annotate the CommonJS export names for ESM import in node:
0 && (module.exports = {
startCronWorkers
});
//# sourceMappingURL=cronWorker.js.map
+7
View File
@@ -0,0 +1,7 @@
{
"version": 3,
"sources": ["../../src/workers/cronWorker.ts"],
"sourcesContent": ["import cron from \"node-cron\";\nimport { prisma } from \"../lib/db\";\nimport { createLogger } from \"../lib/logger\";\nimport { getAdminSettings } from \"../lib/adminSettings\";\n\nconst log = createLogger(\"CRON\");\n\nexport function startCronWorkers() {\n // Inactivity check every 30 minutes\n cron.schedule(\"*/30 * * * *\", async () => {\n try {\n await checkInactivity();\n } catch (e) {\n log.error({ e }, \"Inactivity check error\");\n }\n });\n\n // Daily cleanup\n cron.schedule(\"0 3 * * *\", async () => {\n try {\n await dailyCleanup();\n } catch (e) {\n log.error({ e }, \"Daily cleanup error\");\n }\n });\n\n log.info(\"Cron workers started\");\n}\n\nasync function checkInactivity() {\n const settings = await getAdminSettings();\n const now = new Date();\n\n const running = await prisma.preview.findMany({\n where: { status: \"RUNNING\" },\n });\n\n for (const preview of running) {\n const inactivityMs = settings.maxConcurrentInstancesPerUser; // will use actual inactivityHours from repoConfig\n const repoConfig = await prisma.repoConfig.findUnique({ where: { id: preview.repoConfigId } });\n if (!repoConfig) continue;\n\n const deadline = new Date(preview.lastActivityAt.getTime() + repoConfig.inactivityHours * 3600 * 1000);\n if (now >= deadline) {\n log.info({ previewId: preview.id }, \"Preview inactive, enqueuing INACTIVITY_STOP\");\n await prisma.job.create({\n data: {\n previewId: preview.id,\n type: \"INACTIVITY_STOP\",\n status: \"PENDING\",\n payload: {},\n },\n });\n }\n }\n}\n\nasync function dailyCleanup() {\n const settings = await getAdminSettings();\n const cutoff = new Date(Date.now() - settings.previewRetentionDays * 86400 * 1000);\n\n const old = await prisma.preview.findMany({\n where: {\n status: { in: [\"STOPPED\", \"FAILED\"] },\n stoppedAt: { lt: cutoff },\n },\n select: { id: true },\n });\n\n for (const { id } of old) {\n await prisma.job.deleteMany({ where: { previewId: id } });\n await prisma.preview.delete({ where: { id } });\n }\n\n if (old.length > 0) log.info({ count: old.length }, \"Cleaned up old previews\");\n}\n"],
"mappings": ";;;;;;;;;;;;;;;;;;;;;;;;;;;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,uBAAiB;AACjB,gBAAuB;AACvB,oBAA6B;AAC7B,2BAAiC;AAEjC,MAAM,UAAM,4BAAa,MAAM;AAExB,SAAS,mBAAmB;AAEjC,mBAAAA,QAAK,SAAS,gBAAgB,YAAY;AACxC,QAAI;AACF,YAAM,gBAAgB;AAAA,IACxB,SAAS,GAAG;AACV,UAAI,MAAM,EAAE,EAAE,GAAG,wBAAwB;AAAA,IAC3C;AAAA,EACF,CAAC;AAGD,mBAAAA,QAAK,SAAS,aAAa,YAAY;AACrC,QAAI;AACF,YAAM,aAAa;AAAA,IACrB,SAAS,GAAG;AACV,UAAI,MAAM,EAAE,EAAE,GAAG,qBAAqB;AAAA,IACxC;AAAA,EACF,CAAC;AAED,MAAI,KAAK,sBAAsB;AACjC;AAEA,eAAe,kBAAkB;AAC/B,QAAM,WAAW,UAAM,uCAAiB;AACxC,QAAM,MAAM,oBAAI,KAAK;AAErB,QAAM,UAAU,MAAM,iBAAO,QAAQ,SAAS;AAAA,IAC5C,OAAO,EAAE,QAAQ,UAAU;AAAA,EAC7B,CAAC;AAED,aAAW,WAAW,SAAS;AAC7B,UAAM,eAAe,SAAS;AAC9B,UAAM,aAAa,MAAM,iBAAO,WAAW,WAAW,EAAE,OAAO,EAAE,IAAI,QAAQ,aAAa,EAAE,CAAC;AAC7F,QAAI,CAAC,WAAY;AAEjB,UAAM,WAAW,IAAI,KAAK,QAAQ,eAAe,QAAQ,IAAI,WAAW,kBAAkB,OAAO,GAAI;AACrG,QAAI,OAAO,UAAU;AACnB,UAAI,KAAK,EAAE,WAAW,QAAQ,GAAG,GAAG,6CAA6C;AACjF,YAAM,iBAAO,IAAI,OAAO;AAAA,QACtB,MAAM;AAAA,UACJ,WAAW,QAAQ;AAAA,UACnB,MAAM;AAAA,UACN,QAAQ;AAAA,UACR,SAAS,CAAC;AAAA,QACZ;AAAA,MACF,CAAC;AAAA,IACH;AAAA,EACF;AACF;AAEA,eAAe,eAAe;AAC5B,QAAM,WAAW,UAAM,uCAAiB;AACxC,QAAM,SAAS,IAAI,KAAK,KAAK,IAAI,IAAI,SAAS,uBAAuB,QAAQ,GAAI;AAEjF,QAAM,MAAM,MAAM,iBAAO,QAAQ,SAAS;AAAA,IACxC,OAAO;AAAA,MACL,QAAQ,EAAE,IAAI,CAAC,WAAW,QAAQ,EAAE;AAAA,MACpC,WAAW,EAAE,IAAI,OAAO;AAAA,IAC1B;AAAA,IACA,QAAQ,EAAE,IAAI,KAAK;AAAA,EACrB,CAAC;AAED,aAAW,EAAE,GAAG,KAAK,KAAK;AACxB,UAAM,iBAAO,IAAI,WAAW,EAAE,OAAO,EAAE,WAAW,GAAG,EAAE,CAAC;AACxD,UAAM,iBAAO,QAAQ,OAAO,EAAE,OAAO,EAAE,GAAG,EAAE,CAAC;AAAA,EAC/C;AAEA,MAAI,IAAI,SAAS,EAAG,KAAI,KAAK,EAAE,OAAO,IAAI,OAAO,GAAG,yBAAyB;AAC/E;",
"names": ["cron"]
}
+114
View File
@@ -0,0 +1,114 @@
"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 jobWorker_exports = {};
__export(jobWorker_exports, {
startJobWorker: () => startJobWorker,
stopJobWorker: () => stopJobWorker
});
module.exports = __toCommonJS(jobWorker_exports);
var import_db = require("../lib/db");
var import_logger = require("../lib/logger");
var import_deploy = require("../services/deploy");
const log = (0, import_logger.createLogger)("JOB_WORKER");
let running = false;
async function startJobWorker() {
if (running) return;
running = true;
log.info("Job worker started");
await resetStuckJobs();
pollLoop();
}
async function resetStuckJobs() {
const count = await import_db.prisma.job.updateMany({
where: { status: "RUNNING" },
data: { status: "PENDING", startedAt: null }
});
if (count.count > 0) log.info({ count: count.count }, "Reset stuck running jobs to PENDING");
}
async function pollLoop() {
while (running) {
try {
await processPendingJobs();
} catch (e) {
log.error({ e }, "Job worker poll error");
}
await sleep(1e3);
}
}
const activePreviewJobs = /* @__PURE__ */ new Map();
async function processPendingJobs() {
const pending = await import_db.prisma.job.findMany({
where: { status: "PENDING" },
orderBy: { createdAt: "asc" },
take: 20
});
for (const job of pending) {
const previewId = job.previewId;
if (!previewId) continue;
if (activePreviewJobs.has(previewId)) {
const existingJobId = activePreviewJobs.get(previewId);
if (job.type === "DEPLOY") {
log.info({ previewId, newJobId: job.id, abortingJobId: existingJobId }, "New deploy cancels existing");
(0, import_deploy.signalAbort)(previewId);
await sleep(500);
} else {
continue;
}
}
activePreviewJobs.set(previewId, job.id);
processJob(job).finally(() => {
if (activePreviewJobs.get(previewId) === job.id) {
activePreviewJobs.delete(previewId);
}
});
}
}
async function processJob(job) {
log.info({ jobId: job.id, type: job.type, previewId: job.previewId }, "Processing job");
try {
if (job.type === "DEPLOY") {
await (0, import_deploy.runDeploy)(job.id);
} else if (job.type === "STOP" || job.type === "INACTIVITY_STOP") {
if (job.previewId) {
await import_db.prisma.job.update({ where: { id: job.id }, data: { status: "RUNNING", startedAt: /* @__PURE__ */ new Date() } });
await (0, import_deploy.stopPreview)(job.previewId);
await import_db.prisma.job.update({ where: { id: job.id }, data: { status: "DONE", finishedAt: /* @__PURE__ */ new Date() } });
}
}
} catch (e) {
log.error({ e, jobId: job.id }, "Job processing error");
await import_db.prisma.job.update({
where: { id: job.id },
data: { status: "FAILED", error: e.message, finishedAt: /* @__PURE__ */ new Date() }
}).catch(() => {
});
}
}
function stopJobWorker() {
running = false;
}
function sleep(ms) {
return new Promise((r) => setTimeout(r, ms));
}
// Annotate the CommonJS export names for ESM import in node:
0 && (module.exports = {
startJobWorker,
stopJobWorker
});
//# sourceMappingURL=jobWorker.js.map
+7
View File
@@ -0,0 +1,7 @@
{
"version": 3,
"sources": ["../../src/workers/jobWorker.ts"],
"sourcesContent": ["import { prisma } from \"../lib/db\";\nimport { createLogger } from \"../lib/logger\";\nimport { runDeploy, stopPreview, signalAbort } from \"../services/deploy\";\n\nconst log = createLogger(\"JOB_WORKER\");\nlet running = false;\n\nexport async function startJobWorker() {\n if (running) return;\n running = true;\n log.info(\"Job worker started\");\n\n await resetStuckJobs();\n pollLoop();\n}\n\nasync function resetStuckJobs() {\n const count = await prisma.job.updateMany({\n where: { status: \"RUNNING\" },\n data: { status: \"PENDING\", startedAt: null },\n });\n if (count.count > 0) log.info({ count: count.count }, \"Reset stuck running jobs to PENDING\");\n}\n\nasync function pollLoop() {\n while (running) {\n try {\n await processPendingJobs();\n } catch (e) {\n log.error({ e }, \"Job worker poll error\");\n }\n await sleep(1000);\n }\n}\n\nconst activePreviewJobs = new Map<number, number>();\n\nasync function processPendingJobs() {\n const pending = await prisma.job.findMany({\n where: { status: \"PENDING\" },\n orderBy: { createdAt: \"asc\" },\n take: 20,\n });\n\n for (const job of pending) {\n const previewId = job.previewId;\n if (!previewId) continue;\n\n if (activePreviewJobs.has(previewId)) {\n const existingJobId = activePreviewJobs.get(previewId)!;\n\n if (job.type === \"DEPLOY\") {\n log.info({ previewId, newJobId: job.id, abortingJobId: existingJobId }, \"New deploy cancels existing\");\n signalAbort(previewId);\n await sleep(500);\n } else {\n continue;\n }\n }\n\n activePreviewJobs.set(previewId, job.id);\n\n processJob(job).finally(() => {\n if (activePreviewJobs.get(previewId) === job.id) {\n activePreviewJobs.delete(previewId);\n }\n });\n }\n}\n\nasync function processJob(job: { id: number; type: string; previewId: number | null; payload: any }) {\n log.info({ jobId: job.id, type: job.type, previewId: job.previewId }, \"Processing job\");\n\n try {\n if (job.type === \"DEPLOY\") {\n await runDeploy(job.id);\n } else if (job.type === \"STOP\" || job.type === \"INACTIVITY_STOP\") {\n if (job.previewId) {\n await prisma.job.update({ where: { id: job.id }, data: { status: \"RUNNING\", startedAt: new Date() } });\n await stopPreview(job.previewId);\n await prisma.job.update({ where: { id: job.id }, data: { status: \"DONE\", finishedAt: new Date() } });\n }\n }\n } catch (e: any) {\n log.error({ e, jobId: job.id }, \"Job processing error\");\n await prisma.job.update({\n where: { id: job.id },\n data: { status: \"FAILED\", error: e.message, finishedAt: new Date() },\n }).catch(() => {});\n }\n}\n\nexport function stopJobWorker() {\n running = false;\n}\n\nfunction sleep(ms: number) {\n return new Promise(r => setTimeout(r, ms));\n}\n"],
"mappings": "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",
"names": []
}