blob: cc8d9efeb9504e6078c5cdce89e0bf2ba28d59d3 [file] [log] [blame]
giod0026612025-05-08 13:00:36 +00001import { PrismaClient } from "@prisma/client";
2import express from "express";
gioa71316d2025-05-24 09:41:36 +04003import fs from "node:fs";
giod0026612025-05-08 13:00:36 +00004import { env } from "node:process";
5import axios from "axios";
gioc31bf142025-06-16 07:48:20 +00006import { GithubClient } from "./github.js";
7import { AppManager } from "./app_manager.js";
gio7d813702025-05-08 18:29:52 +00008import { z } from "zod";
gio78a22882025-07-01 18:56:01 +00009import { ProjectMonitor, WorkerSchema, LogItem } from "./project_monitor.js";
gioa71316d2025-05-24 09:41:36 +040010import tmp from "tmp";
gioc31bf142025-06-16 07:48:20 +000011import { NodeJSAnalyzer } from "./lib/nodejs.js";
gioa71316d2025-05-24 09:41:36 +040012import shell from "shelljs";
gioc31bf142025-06-16 07:48:20 +000013import { RealFileSystem } from "./lib/fs.js";
gioa71316d2025-05-24 09:41:36 +040014import path from "node:path";
gio9b7421a2025-06-18 12:31:13 +000015import {
16 Env,
17 generateDodoConfig,
18 ConfigSchema,
19 AppNode,
20 ConfigWithInput,
21 configToGraph,
22 Network,
23 GithubRepository,
24} from "config";
gio78a22882025-07-01 18:56:01 +000025import { Instant, DateTimeFormatter, ZoneId } from "@js-joda/core";
gio40c0c992025-07-02 13:18:05 +000026import LogStore from "./log.js";
gioa71316d2025-05-24 09:41:36 +040027
28async function generateKey(root: string): Promise<[string, string]> {
29 const privKeyPath = path.join(root, "key");
30 const pubKeyPath = path.join(root, "key.pub");
31 if (shell.exec(`ssh-keygen -t ed25519 -f ${privKeyPath} -N ""`).code !== 0) {
32 throw new Error("Failed to generate SSH key pair");
33 }
34 const publicKey = await fs.promises.readFile(pubKeyPath, "utf8");
35 const privateKey = await fs.promises.readFile(privKeyPath, "utf8");
36 return [publicKey, privateKey];
37}
giod0026612025-05-08 13:00:36 +000038
39const db = new PrismaClient();
gio40c0c992025-07-02 13:18:05 +000040const logStore = new LogStore(db);
gio3ed59592025-05-14 16:51:09 +000041const appManager = new AppManager();
giod0026612025-05-08 13:00:36 +000042
gioa1efbad2025-05-21 07:16:45 +000043const projectMonitors = new Map<number, ProjectMonitor>();
gio7d813702025-05-08 18:29:52 +000044
giod0026612025-05-08 13:00:36 +000045const handleProjectCreate: express.Handler = async (req, resp) => {
46 try {
gioa71316d2025-05-24 09:41:36 +040047 const tmpDir = tmp.dirSync().name;
48 const [publicKey, privateKey] = await generateKey(tmpDir);
giod0026612025-05-08 13:00:36 +000049 const { id } = await db.project.create({
50 data: {
gio09fcab52025-05-12 14:05:07 +000051 userId: resp.locals.userId,
giod0026612025-05-08 13:00:36 +000052 name: req.body.name,
gioa71316d2025-05-24 09:41:36 +040053 deployKey: privateKey,
54 deployKeyPublic: publicKey,
giod0026612025-05-08 13:00:36 +000055 },
56 });
57 resp.status(200);
58 resp.header("Content-Type", "application/json");
59 resp.write(
60 JSON.stringify({
gio74ab7852025-05-13 13:19:31 +000061 id: id.toString(),
giod0026612025-05-08 13:00:36 +000062 }),
63 );
64 } catch (e) {
65 console.log(e);
66 resp.status(500);
67 } finally {
68 resp.end();
69 }
70};
71
72const handleProjectAll: express.Handler = async (req, resp) => {
73 try {
74 const r = await db.project.findMany({
75 where: {
gio09fcab52025-05-12 14:05:07 +000076 userId: resp.locals.userId,
giod0026612025-05-08 13:00:36 +000077 },
78 });
79 resp.status(200);
80 resp.header("Content-Type", "application/json");
81 resp.write(
82 JSON.stringify(
83 r.map((p) => ({
84 id: p.id.toString(),
85 name: p.name,
86 })),
87 ),
88 );
89 } catch (e) {
90 console.log(e);
91 resp.status(500);
92 } finally {
93 resp.end();
94 }
95};
96
97const handleSave: express.Handler = async (req, resp) => {
98 try {
99 await db.project.update({
100 where: {
101 id: Number(req.params["projectId"]),
gio09fcab52025-05-12 14:05:07 +0000102 userId: resp.locals.userId,
giod0026612025-05-08 13:00:36 +0000103 },
104 data: {
giobd37a2b2025-05-15 04:28:42 +0000105 draft: JSON.stringify(req.body),
giod0026612025-05-08 13:00:36 +0000106 },
107 });
108 resp.status(200);
109 } catch (e) {
110 console.log(e);
111 resp.status(500);
112 } finally {
113 resp.end();
114 }
115};
116
gio818da4e2025-05-12 14:45:35 +0000117function handleSavedGet(state: "deploy" | "draft"): express.Handler {
118 return async (req, resp) => {
119 try {
120 const r = await db.project.findUnique({
121 where: {
122 id: Number(req.params["projectId"]),
123 userId: resp.locals.userId,
124 },
125 select: {
126 state: true,
127 draft: true,
128 },
129 });
130 if (r == null) {
131 resp.status(404);
132 return;
133 }
giod0026612025-05-08 13:00:36 +0000134 resp.status(200);
135 resp.header("content-type", "application/json");
gioc31bf142025-06-16 07:48:20 +0000136 let currentState: Record<string, unknown> | null = null;
gio818da4e2025-05-12 14:45:35 +0000137 if (state === "deploy") {
giod0026612025-05-08 13:00:36 +0000138 if (r.state == null) {
gioc31bf142025-06-16 07:48:20 +0000139 currentState = {
giod0026612025-05-08 13:00:36 +0000140 nodes: [],
141 edges: [],
142 viewport: { x: 0, y: 0, zoom: 1 },
gioc31bf142025-06-16 07:48:20 +0000143 };
giod0026612025-05-08 13:00:36 +0000144 } else {
gioc31bf142025-06-16 07:48:20 +0000145 currentState = JSON.parse(Buffer.from(r.state).toString("utf8"));
giod0026612025-05-08 13:00:36 +0000146 }
147 } else {
gio818da4e2025-05-12 14:45:35 +0000148 if (r.draft == null) {
149 if (r.state == null) {
gioc31bf142025-06-16 07:48:20 +0000150 currentState = {
gio818da4e2025-05-12 14:45:35 +0000151 nodes: [],
152 edges: [],
153 viewport: { x: 0, y: 0, zoom: 1 },
gioc31bf142025-06-16 07:48:20 +0000154 };
gio818da4e2025-05-12 14:45:35 +0000155 } else {
gioc31bf142025-06-16 07:48:20 +0000156 currentState = JSON.parse(Buffer.from(r.state).toString("utf8"));
gio818da4e2025-05-12 14:45:35 +0000157 }
158 } else {
gioc31bf142025-06-16 07:48:20 +0000159 currentState = JSON.parse(Buffer.from(r.draft).toString("utf8"));
gio818da4e2025-05-12 14:45:35 +0000160 }
giod0026612025-05-08 13:00:36 +0000161 }
gioc31bf142025-06-16 07:48:20 +0000162 const env = await getEnv(Number(req.params["projectId"]), resp.locals.userId, resp.locals.username);
163 if (currentState) {
164 const config = generateDodoConfig(
165 req.params["projectId"].toString(),
166 currentState.nodes as AppNode[],
167 env,
168 );
169 resp.send({
170 state: currentState,
171 config,
172 });
173 }
gio818da4e2025-05-12 14:45:35 +0000174 } catch (e) {
175 console.log(e);
176 resp.status(500);
177 } finally {
178 resp.end();
giod0026612025-05-08 13:00:36 +0000179 }
gio818da4e2025-05-12 14:45:35 +0000180 };
181}
giod0026612025-05-08 13:00:36 +0000182
gioa71316d2025-05-24 09:41:36 +0400183const projectDeleteReqSchema = z.object({
184 state: z.optional(z.nullable(z.string())),
185});
186
187const handleProjectDelete: express.Handler = async (req, resp) => {
giod0026612025-05-08 13:00:36 +0000188 try {
189 const projectId = Number(req.params["projectId"]);
190 const p = await db.project.findUnique({
191 where: {
192 id: projectId,
gio09fcab52025-05-12 14:05:07 +0000193 userId: resp.locals.userId,
giod0026612025-05-08 13:00:36 +0000194 },
195 select: {
196 instanceId: true,
gioa71316d2025-05-24 09:41:36 +0400197 githubToken: true,
198 deployKeyPublic: true,
199 state: true,
200 draft: true,
giod0026612025-05-08 13:00:36 +0000201 },
202 });
203 if (p === null) {
204 resp.status(404);
205 return;
206 }
gioa71316d2025-05-24 09:41:36 +0400207 const parseResult = projectDeleteReqSchema.safeParse(req.body);
208 if (!parseResult.success) {
209 resp.status(400);
210 resp.write(JSON.stringify({ error: "Invalid request body", issues: parseResult.error.format() }));
211 return;
gioe440db82025-05-13 12:21:44 +0000212 }
gioa71316d2025-05-24 09:41:36 +0400213 if (p.githubToken && p.deployKeyPublic) {
214 const allRepos = [
215 ...new Set([
216 ...extractGithubRepos(p.state),
217 ...extractGithubRepos(p.draft),
218 ...extractGithubRepos(parseResult.data.state),
219 ]),
220 ];
221 if (allRepos.length > 0) {
222 const diff: RepoDiff = { toDelete: allRepos, toAdd: [] };
223 const github = new GithubClient(p.githubToken);
224 await manageGithubRepos(github, diff, p.deployKeyPublic, env.PUBLIC_ADDR);
225 console.log(
226 `Attempted to remove deploy keys for project ${projectId} from associated GitHub repositories.`,
227 );
228 }
giod0026612025-05-08 13:00:36 +0000229 }
gioa71316d2025-05-24 09:41:36 +0400230 if (p.instanceId !== null) {
231 if (!(await appManager.removeInstance(p.instanceId))) {
232 resp.status(500);
233 resp.write(JSON.stringify({ error: "Failed to remove deployment from cluster" }));
234 return;
235 }
236 }
237 await db.project.delete({
238 where: {
239 id: projectId,
240 },
241 });
giod0026612025-05-08 13:00:36 +0000242 resp.status(200);
243 } catch (e) {
244 console.log(e);
245 resp.status(500);
246 } finally {
247 resp.end();
248 }
249};
250
gioa71316d2025-05-24 09:41:36 +0400251function extractGithubRepos(serializedState: string | null | undefined): string[] {
gio3ed59592025-05-14 16:51:09 +0000252 if (!serializedState) {
253 return [];
254 }
255 try {
giobd37a2b2025-05-15 04:28:42 +0000256 const stateObj = JSON.parse(serializedState);
gio3ed59592025-05-14 16:51:09 +0000257 const githubNodes = stateObj.nodes.filter(
258 // eslint-disable-next-line @typescript-eslint/no-explicit-any
259 (n: any) => n.type === "github" && n.data?.repository?.id,
260 );
261 // eslint-disable-next-line @typescript-eslint/no-explicit-any
262 return githubNodes.map((n: any) => n.data.repository.sshURL);
263 } catch (error) {
264 console.error("Failed to parse state or extract GitHub repos:", error);
265 return [];
266 }
267}
268
269type RepoDiff = {
270 toAdd?: string[];
271 toDelete?: string[];
272};
273
274function calculateRepoDiff(oldRepos: string[], newRepos: string[]): RepoDiff {
275 const toAdd = newRepos.filter((repo) => !oldRepos.includes(repo));
276 const toDelete = oldRepos.filter((repo) => !newRepos.includes(repo));
277 return { toAdd, toDelete };
278}
279
gio76d8ae62025-05-19 15:21:54 +0000280async function manageGithubRepos(
281 github: GithubClient,
282 diff: RepoDiff,
283 deployKey: string,
284 publicAddr?: string,
285): Promise<void> {
gio3ed59592025-05-14 16:51:09 +0000286 for (const repoUrl of diff.toDelete ?? []) {
287 try {
288 await github.removeDeployKey(repoUrl, deployKey);
289 console.log(`Removed deploy key from repository ${repoUrl}`);
gio76d8ae62025-05-19 15:21:54 +0000290 if (publicAddr) {
291 const webhookCallbackUrl = `${publicAddr}/api/webhook/github/push`;
292 await github.removePushWebhook(repoUrl, webhookCallbackUrl);
293 console.log(`Removed push webhook from repository ${repoUrl}`);
294 }
gio3ed59592025-05-14 16:51:09 +0000295 } catch (error) {
296 console.error(`Failed to remove deploy key from repository ${repoUrl}:`, error);
297 }
298 }
299 for (const repoUrl of diff.toAdd ?? []) {
300 try {
301 await github.addDeployKey(repoUrl, deployKey);
302 console.log(`Added deploy key to repository ${repoUrl}`);
gio76d8ae62025-05-19 15:21:54 +0000303 if (publicAddr) {
304 const webhookCallbackUrl = `${publicAddr}/api/webhook/github/push`;
305 await github.addPushWebhook(repoUrl, webhookCallbackUrl);
306 console.log(`Added push webhook to repository ${repoUrl}`);
307 }
gio3ed59592025-05-14 16:51:09 +0000308 } catch (error) {
309 console.error(`Failed to add deploy key from repository ${repoUrl}:`, error);
310 }
311 }
312}
313
giod0026612025-05-08 13:00:36 +0000314const handleDeploy: express.Handler = async (req, resp) => {
315 try {
316 const projectId = Number(req.params["projectId"]);
giod0026612025-05-08 13:00:36 +0000317 const p = await db.project.findUnique({
318 where: {
319 id: projectId,
gioc31bf142025-06-16 07:48:20 +0000320 // userId: resp.locals.userId, TODO(gio): validate
giod0026612025-05-08 13:00:36 +0000321 },
322 select: {
323 instanceId: true,
324 githubToken: true,
325 deployKey: true,
gioa71316d2025-05-24 09:41:36 +0400326 deployKeyPublic: true,
gio3ed59592025-05-14 16:51:09 +0000327 state: true,
gio69148322025-06-19 23:16:12 +0400328 geminiApiKey: true,
gio69ff7592025-07-03 06:27:21 +0000329 anthropicApiKey: true,
giod0026612025-05-08 13:00:36 +0000330 },
331 });
332 if (p === null) {
333 resp.status(404);
334 return;
335 }
gioc31bf142025-06-16 07:48:20 +0000336 const config = ConfigSchema.safeParse(req.body.config);
337 if (!config.success) {
338 resp.status(400);
339 resp.write(JSON.stringify({ error: "Invalid configuration", issues: config.error.format() }));
340 return;
341 }
gio9b7421a2025-06-18 12:31:13 +0000342 let repos: GithubRepository[] = [];
343 if (p.githubToken) {
344 const github = new GithubClient(p.githubToken);
345 repos = await github.getRepositories();
346 }
gioc31bf142025-06-16 07:48:20 +0000347 const state = req.body.state
348 ? JSON.stringify(req.body.state)
349 : JSON.stringify(
350 configToGraph(
351 config.data,
352 getNetworks(resp.locals.username),
gio9b7421a2025-06-18 12:31:13 +0000353 repos,
gioc31bf142025-06-16 07:48:20 +0000354 p.state ? JSON.parse(Buffer.from(p.state).toString("utf8")) : null,
355 ),
356 );
giod0026612025-05-08 13:00:36 +0000357 await db.project.update({
358 where: {
359 id: projectId,
360 },
361 data: {
362 draft: state,
363 },
364 });
gioa71316d2025-05-24 09:41:36 +0400365 let deployKey: string | null = p.deployKey;
366 let deployKeyPublic: string | null = p.deployKeyPublic;
367 if (deployKeyPublic == null) {
368 [deployKeyPublic, deployKey] = await generateKey(tmp.dirSync().name);
369 await db.project.update({
370 where: { id: projectId },
371 data: { deployKeyPublic, deployKey },
372 });
373 }
gio3ed59592025-05-14 16:51:09 +0000374 let diff: RepoDiff | null = null;
gioc31bf142025-06-16 07:48:20 +0000375 const cfg: ConfigWithInput = {
376 ...config.data,
377 input: {
378 appId: projectId.toString(),
379 managerAddr: env.INTERNAL_API_ADDR!,
380 key: {
381 public: deployKeyPublic!,
382 private: deployKey!,
383 },
gio69148322025-06-19 23:16:12 +0400384 geminiApiKey: p.geminiApiKey ?? undefined,
gio69ff7592025-07-03 06:27:21 +0000385 anthropicApiKey: p.anthropicApiKey ?? undefined,
gioc31bf142025-06-16 07:48:20 +0000386 },
gioa71316d2025-05-24 09:41:36 +0400387 };
gio3ed59592025-05-14 16:51:09 +0000388 try {
389 if (p.instanceId == null) {
gioc31bf142025-06-16 07:48:20 +0000390 const deployResponse = await appManager.deploy(cfg);
giod0026612025-05-08 13:00:36 +0000391 await db.project.update({
392 where: {
393 id: projectId,
394 },
395 data: {
396 state,
397 draft: null,
gio3ed59592025-05-14 16:51:09 +0000398 instanceId: deployResponse.id,
giob77cb932025-05-19 09:37:14 +0000399 access: JSON.stringify(deployResponse.access),
giod0026612025-05-08 13:00:36 +0000400 },
401 });
gio3ed59592025-05-14 16:51:09 +0000402 diff = { toAdd: extractGithubRepos(state) };
gio3ed59592025-05-14 16:51:09 +0000403 } else {
gioc31bf142025-06-16 07:48:20 +0000404 const deployResponse = await appManager.update(p.instanceId, cfg);
giob77cb932025-05-19 09:37:14 +0000405 diff = calculateRepoDiff(extractGithubRepos(p.state), extractGithubRepos(state));
giob77cb932025-05-19 09:37:14 +0000406 await db.project.update({
407 where: {
408 id: projectId,
409 },
410 data: {
411 state,
412 draft: null,
413 access: JSON.stringify(deployResponse.access),
414 },
415 });
giod0026612025-05-08 13:00:36 +0000416 }
gio3ed59592025-05-14 16:51:09 +0000417 if (diff && p.githubToken && deployKey) {
418 const github = new GithubClient(p.githubToken);
gioa71316d2025-05-24 09:41:36 +0400419 await manageGithubRepos(github, diff, deployKeyPublic!, env.PUBLIC_ADDR);
giod0026612025-05-08 13:00:36 +0000420 }
gio3ed59592025-05-14 16:51:09 +0000421 resp.status(200);
422 } catch (error) {
423 console.error("Deployment error:", error);
424 resp.status(500);
425 resp.write(JSON.stringify({ error: "Deployment failed" }));
giod0026612025-05-08 13:00:36 +0000426 }
427 } catch (e) {
428 console.log(e);
429 resp.status(500);
430 } finally {
431 resp.end();
432 }
433};
434
435const handleStatus: express.Handler = async (req, resp) => {
436 try {
437 const projectId = Number(req.params["projectId"]);
438 const p = await db.project.findUnique({
439 where: {
440 id: projectId,
gio09fcab52025-05-12 14:05:07 +0000441 userId: resp.locals.userId,
giod0026612025-05-08 13:00:36 +0000442 },
443 select: {
444 instanceId: true,
445 },
446 });
giod0026612025-05-08 13:00:36 +0000447 if (p === null) {
448 resp.status(404);
449 return;
450 }
451 if (p.instanceId == null) {
452 resp.status(404);
453 return;
454 }
gio3ed59592025-05-14 16:51:09 +0000455 try {
456 const status = await appManager.getStatus(p.instanceId);
457 resp.status(200);
458 resp.write(JSON.stringify(status));
459 } catch (error) {
460 console.error("Error getting status:", error);
461 resp.status(500);
giod0026612025-05-08 13:00:36 +0000462 }
463 } catch (e) {
464 console.log(e);
465 resp.status(500);
466 } finally {
467 resp.end();
468 }
469};
470
gioc31bf142025-06-16 07:48:20 +0000471const handleConfigGet: express.Handler = async (req, resp) => {
472 try {
473 const projectId = Number(req.params["projectId"]);
474 const project = await db.project.findUnique({
475 where: {
476 id: projectId,
477 },
478 select: {
479 state: true,
480 },
481 });
482
483 if (!project || !project.state) {
484 resp.status(404).send({ error: "No deployed configuration found." });
485 return;
486 }
487
488 const state = JSON.parse(Buffer.from(project.state).toString("utf8"));
489 const env = await getEnv(projectId, resp.locals.userId, resp.locals.username);
490 const config = generateDodoConfig(projectId.toString(), state.nodes, env);
491
492 if (!config) {
493 resp.status(500).send({ error: "Failed to generate configuration." });
494 return;
495 }
496 resp.status(200).json(config);
497 } catch (e) {
498 console.log(e);
499 resp.status(500).send({ error: "Internal server error" });
500 } finally {
501 console.log("config get done");
502 resp.end();
503 }
504};
505
giobd37a2b2025-05-15 04:28:42 +0000506const handleRemoveDeployment: express.Handler = async (req, resp) => {
507 try {
508 const projectId = Number(req.params["projectId"]);
509 const p = await db.project.findUnique({
510 where: {
511 id: projectId,
512 userId: resp.locals.userId,
513 },
514 select: {
515 instanceId: true,
516 githubToken: true,
gioa71316d2025-05-24 09:41:36 +0400517 deployKeyPublic: true,
giobd37a2b2025-05-15 04:28:42 +0000518 state: true,
519 draft: true,
520 },
521 });
522 if (p === null) {
523 resp.status(404);
524 resp.write(JSON.stringify({ error: "Project not found" }));
525 return;
526 }
527 if (p.instanceId == null) {
528 resp.status(400);
529 resp.write(JSON.stringify({ error: "Project not deployed" }));
530 return;
531 }
532 const removed = await appManager.removeInstance(p.instanceId);
533 if (!removed) {
534 resp.status(500);
535 resp.write(JSON.stringify({ error: "Failed to remove deployment from cluster" }));
536 return;
537 }
gioa71316d2025-05-24 09:41:36 +0400538 if (p.githubToken && p.deployKeyPublic && p.state) {
giobd37a2b2025-05-15 04:28:42 +0000539 try {
540 const github = new GithubClient(p.githubToken);
541 const repos = extractGithubRepos(p.state);
542 const diff = { toDelete: repos, toAdd: [] };
gioa71316d2025-05-24 09:41:36 +0400543 await manageGithubRepos(github, diff, p.deployKeyPublic, env.PUBLIC_ADDR);
giobd37a2b2025-05-15 04:28:42 +0000544 } catch (error) {
545 console.error("Error removing GitHub deploy keys:", error);
546 }
547 }
548 await db.project.update({
549 where: {
550 id: projectId,
551 },
552 data: {
553 instanceId: null,
gioa71316d2025-05-24 09:41:36 +0400554 deployKeyPublic: null,
giob77cb932025-05-19 09:37:14 +0000555 access: null,
giobd37a2b2025-05-15 04:28:42 +0000556 state: null,
557 draft: p.draft ?? p.state,
558 },
559 });
560 resp.status(200);
561 resp.write(JSON.stringify({ success: true }));
562 } catch (e) {
563 console.error("Error removing deployment:", e);
564 resp.status(500);
565 resp.write(JSON.stringify({ error: "Internal server error" }));
566 } finally {
567 resp.end();
568 }
569};
570
giod0026612025-05-08 13:00:36 +0000571const handleGithubRepos: express.Handler = async (req, resp) => {
572 try {
573 const projectId = Number(req.params["projectId"]);
574 const project = await db.project.findUnique({
gio09fcab52025-05-12 14:05:07 +0000575 where: {
576 id: projectId,
577 userId: resp.locals.userId,
578 },
579 select: {
580 githubToken: true,
581 },
giod0026612025-05-08 13:00:36 +0000582 });
giod0026612025-05-08 13:00:36 +0000583 if (!project?.githubToken) {
584 resp.status(400);
585 resp.write(JSON.stringify({ error: "GitHub token not configured" }));
586 return;
587 }
giod0026612025-05-08 13:00:36 +0000588 const github = new GithubClient(project.githubToken);
589 const repositories = await github.getRepositories();
giod0026612025-05-08 13:00:36 +0000590 resp.status(200);
591 resp.header("Content-Type", "application/json");
592 resp.write(JSON.stringify(repositories));
593 } catch (e) {
594 console.log(e);
595 resp.status(500);
596 resp.write(JSON.stringify({ error: "Failed to fetch repositories" }));
597 } finally {
598 resp.end();
599 }
600};
601
602const handleUpdateGithubToken: express.Handler = async (req, resp) => {
603 try {
giod0026612025-05-08 13:00:36 +0000604 await db.project.update({
gio09fcab52025-05-12 14:05:07 +0000605 where: {
gio69148322025-06-19 23:16:12 +0400606 id: Number(req.params["projectId"]),
gio09fcab52025-05-12 14:05:07 +0000607 userId: resp.locals.userId,
608 },
gio69148322025-06-19 23:16:12 +0400609 data: {
610 githubToken: req.body.githubToken,
611 },
612 });
613 resp.status(200);
614 } catch (e) {
615 console.log(e);
616 resp.status(500);
617 } finally {
618 resp.end();
619 }
620};
621
622const handleUpdateGeminiToken: express.Handler = async (req, resp) => {
623 try {
624 await db.project.update({
625 where: {
626 id: Number(req.params["projectId"]),
627 userId: resp.locals.userId,
628 },
629 data: {
630 geminiApiKey: req.body.geminiApiKey,
631 },
giod0026612025-05-08 13:00:36 +0000632 });
giod0026612025-05-08 13:00:36 +0000633 resp.status(200);
634 } catch (e) {
635 console.log(e);
636 resp.status(500);
637 } finally {
638 resp.end();
639 }
640};
641
gio69ff7592025-07-03 06:27:21 +0000642const handleUpdateAnthropicToken: express.Handler = async (req, resp) => {
643 try {
644 await db.project.update({
645 where: {
646 id: Number(req.params["projectId"]),
647 userId: resp.locals.userId,
648 },
649 data: {
650 anthropicApiKey: req.body.anthropicApiKey,
651 },
652 });
653 resp.status(200);
654 } catch (e) {
655 console.log(e);
656 resp.status(500);
657 } finally {
658 resp.end();
659 }
660};
661
gioc31bf142025-06-16 07:48:20 +0000662const getNetworks = (username?: string | undefined): Network[] => {
663 return [
664 {
665 name: "Trial",
666 domain: "trial.dodoapp.xyz",
667 hasAuth: false,
668 },
669 // TODO(gio): Remove
670 ].concat(
671 username === "gio" || 1 == 1
672 ? [
673 {
674 name: "Public",
675 domain: "v1.dodo.cloud",
676 hasAuth: true,
677 },
678 {
679 name: "Private",
680 domain: "p.v1.dodo.cloud",
681 hasAuth: true,
682 },
683 ]
684 : [],
685 );
686};
687
688const getEnv = async (projectId: number, userId: string, username: string): Promise<Env> => {
689 const project = await db.project.findUnique({
690 where: {
691 id: projectId,
692 userId,
693 },
694 select: {
695 deployKeyPublic: true,
696 githubToken: true,
gio69148322025-06-19 23:16:12 +0400697 geminiApiKey: true,
gio69ff7592025-07-03 06:27:21 +0000698 anthropicApiKey: true,
gioc31bf142025-06-16 07:48:20 +0000699 access: true,
700 instanceId: true,
701 },
702 });
703 if (!project) {
704 throw new Error("Project not found");
705 }
706 const monitor = projectMonitors.get(projectId);
707 const serviceNames = monitor ? monitor.getAllServiceNames() : [];
708 const services = serviceNames.map((name: string) => ({
709 name,
710 workers: [...(monitor ? monitor.getWorkerStatusesForService(name) : new Map()).entries()].map(
711 ([id, status]) => ({
712 ...status,
713 id,
714 }),
715 ),
716 }));
717 return {
gioc31bf142025-06-16 07:48:20 +0000718 deployKeyPublic: project.deployKeyPublic == null ? undefined : project.deployKeyPublic,
719 instanceId: project.instanceId == null ? undefined : project.instanceId,
720 access: JSON.parse(project.access ?? "[]"),
721 integrations: {
722 github: !!project.githubToken,
gio69148322025-06-19 23:16:12 +0400723 gemini: !!project.geminiApiKey,
gio69ff7592025-07-03 06:27:21 +0000724 anthropic: !!project.anthropicApiKey,
gioc31bf142025-06-16 07:48:20 +0000725 },
726 networks: getNetworks(username),
727 services,
728 user: {
729 id: userId,
730 username: username,
731 },
732 };
733};
734
giod0026612025-05-08 13:00:36 +0000735const handleEnv: express.Handler = async (req, resp) => {
736 const projectId = Number(req.params["projectId"]);
737 try {
gioc31bf142025-06-16 07:48:20 +0000738 const env = await getEnv(projectId, resp.locals.userId, resp.locals.username);
giod0026612025-05-08 13:00:36 +0000739 resp.status(200);
gioc31bf142025-06-16 07:48:20 +0000740 resp.write(JSON.stringify(env));
giod0026612025-05-08 13:00:36 +0000741 } catch (error) {
gioc31bf142025-06-16 07:48:20 +0000742 console.error("Error getting env:", error);
giod0026612025-05-08 13:00:36 +0000743 resp.status(500);
744 resp.write(JSON.stringify({ error: "Internal server error" }));
745 } finally {
746 resp.end();
747 }
748};
749
gio3a921b82025-05-10 07:36:09 +0000750const handleServiceLogs: express.Handler = async (req, resp) => {
gio78a22882025-07-01 18:56:01 +0000751 const projectId = Number(req.params["projectId"]);
752 const service = req.params["service"];
753 const workerId = req.params["workerId"];
754
755 resp.setHeader("Content-Type", "text/event-stream");
756 resp.setHeader("Cache-Control", "no-cache");
757 resp.setHeader("Connection", "keep-alive");
758 resp.flushHeaders();
759
760 const timestampFormat = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
761 const sendLogs = (logs: LogItem[]) => {
762 if (logs.length == 0) {
763 return;
764 }
765 const logString = logs
766 .map((l) => {
767 const t = Instant.ofEpochMilli(l.timestampMilli);
768 const formattedTimestamp = t.atZone(ZoneId.UTC).format(timestampFormat);
769 return `\x1b[38;5;240m${formattedTimestamp}\x1b[0m ${l.contents}`;
770 })
771 .join("\n");
772 resp.write("event: message\n");
773 resp.write(`data: ${JSON.stringify({ logs: logString })}\n\n`);
774 };
775
gio3a921b82025-05-10 07:36:09 +0000776 try {
gio09fcab52025-05-12 14:05:07 +0000777 const project = await db.project.findUnique({
gio78a22882025-07-01 18:56:01 +0000778 where: { id: projectId, userId: resp.locals.userId },
gio09fcab52025-05-12 14:05:07 +0000779 });
gio78a22882025-07-01 18:56:01 +0000780
781 if (!project) {
782 resp.status(404).end();
gio09fcab52025-05-12 14:05:07 +0000783 return;
784 }
gio78a22882025-07-01 18:56:01 +0000785
gioa1efbad2025-05-21 07:16:45 +0000786 const monitor = projectMonitors.get(projectId);
gio78a22882025-07-01 18:56:01 +0000787 if (!monitor) {
788 resp.status(404).end();
gio3a921b82025-05-10 07:36:09 +0000789 return;
790 }
gio78a22882025-07-01 18:56:01 +0000791
gio40c0c992025-07-02 13:18:05 +0000792 let lastLogId: number | undefined = undefined;
793 const initialLogs = (await logStore.get(projectId, service, workerId)) || [];
gio78a22882025-07-01 18:56:01 +0000794 sendLogs(initialLogs);
gio40c0c992025-07-02 13:18:05 +0000795 if (initialLogs.length > 0) {
796 lastLogId = initialLogs[initialLogs.length - 1].id;
797 }
gio78a22882025-07-01 18:56:01 +0000798 resp.flushHeaders();
799
gio40c0c992025-07-02 13:18:05 +0000800 const intervalId = setInterval(async () => {
801 const currentLogs = (await logStore.get(projectId, service, workerId, lastLogId)) || [];
802 if (currentLogs.length > 0) {
803 sendLogs(currentLogs);
804 lastLogId = currentLogs[currentLogs.length - 1].id;
gio78a22882025-07-01 18:56:01 +0000805 }
806 }, 500);
807
808 req.on("close", () => {
809 clearInterval(intervalId);
810 resp.end();
811 });
gio3a921b82025-05-10 07:36:09 +0000812 } catch (e) {
813 console.log(e);
gio78a22882025-07-01 18:56:01 +0000814 resp.status(500).end();
gio3a921b82025-05-10 07:36:09 +0000815 }
816};
817
gio7d813702025-05-08 18:29:52 +0000818const handleRegisterWorker: express.Handler = async (req, resp) => {
819 try {
820 const projectId = Number(req.params["projectId"]);
gio7d813702025-05-08 18:29:52 +0000821 const result = WorkerSchema.safeParse(req.body);
gio7d813702025-05-08 18:29:52 +0000822 if (!result.success) {
gioa70535a2025-07-02 15:50:25 +0000823 console.log(JSON.stringify(result.error));
gio7d813702025-05-08 18:29:52 +0000824 resp.status(400);
825 resp.write(
826 JSON.stringify({
827 error: "Invalid request data",
828 details: result.error.format(),
829 }),
830 );
831 return;
832 }
gioa1efbad2025-05-21 07:16:45 +0000833 let monitor = projectMonitors.get(projectId);
834 if (!monitor) {
835 monitor = new ProjectMonitor();
836 projectMonitors.set(projectId, monitor);
gio7d813702025-05-08 18:29:52 +0000837 }
gioa1efbad2025-05-21 07:16:45 +0000838 monitor.registerWorker(result.data);
gio40c0c992025-07-02 13:18:05 +0000839 if (result.data.logs) {
840 await logStore.store(projectId, result.data.service, result.data.id, result.data.logs);
841 }
gio7d813702025-05-08 18:29:52 +0000842 resp.status(200);
843 resp.write(
844 JSON.stringify({
845 success: true,
gio78a22882025-07-01 18:56:01 +0000846 logItemsConsumed: result.data.logs?.length ?? 0,
gio7d813702025-05-08 18:29:52 +0000847 }),
848 );
849 } catch (e) {
850 console.log(e);
851 resp.status(500);
852 resp.write(JSON.stringify({ error: "Failed to register worker" }));
853 } finally {
854 resp.end();
855 }
856};
857
gio76d8ae62025-05-19 15:21:54 +0000858async function reloadProject(projectId: number): Promise<boolean> {
gioa1efbad2025-05-21 07:16:45 +0000859 const monitor = projectMonitors.get(projectId);
860 const projectWorkers = monitor ? monitor.getWorkerAddresses() : [];
gio76d8ae62025-05-19 15:21:54 +0000861 const workerCount = projectWorkers.length;
862 if (workerCount === 0) {
863 return true;
864 }
865 const results = await Promise.all(
gioc31bf142025-06-16 07:48:20 +0000866 projectWorkers.map(async (workerAddress: string) => {
867 try {
868 const { data } = await axios.get(`http://${workerAddress}/reload`);
869 return data.every((s: { status: string }) => s.status === "ok");
870 } catch (error) {
871 console.error(`Failed to reload worker ${workerAddress}:`, error);
872 return false;
873 }
gio76d8ae62025-05-19 15:21:54 +0000874 }),
875 );
gioc31bf142025-06-16 07:48:20 +0000876 return results.reduce((acc: boolean, curr: boolean) => acc && curr, true);
gio76d8ae62025-05-19 15:21:54 +0000877}
878
gio7d813702025-05-08 18:29:52 +0000879const handleReload: express.Handler = async (req, resp) => {
880 try {
881 const projectId = Number(req.params["projectId"]);
gio76d8ae62025-05-19 15:21:54 +0000882 const projectAuth = await db.project.findFirst({
gio09fcab52025-05-12 14:05:07 +0000883 where: {
884 id: projectId,
885 userId: resp.locals.userId,
886 },
gio76d8ae62025-05-19 15:21:54 +0000887 select: { id: true },
gio09fcab52025-05-12 14:05:07 +0000888 });
gio76d8ae62025-05-19 15:21:54 +0000889 if (!projectAuth) {
gio09fcab52025-05-12 14:05:07 +0000890 resp.status(404);
gio09fcab52025-05-12 14:05:07 +0000891 return;
892 }
gio76d8ae62025-05-19 15:21:54 +0000893 const success = await reloadProject(projectId);
894 if (success) {
895 resp.status(200);
896 } else {
897 resp.status(500);
gio7d813702025-05-08 18:29:52 +0000898 }
gio7d813702025-05-08 18:29:52 +0000899 } catch (e) {
gio76d8ae62025-05-19 15:21:54 +0000900 console.error(e);
gio7d813702025-05-08 18:29:52 +0000901 resp.status(500);
gio7d813702025-05-08 18:29:52 +0000902 }
903};
904
gio577d2342025-07-03 12:50:18 +0000905const handleQuitWorker: express.Handler = async (req, resp) => {
906 const projectId = Number(req.params["projectId"]);
907 const serviceName = req.params["serviceName"];
908 const workerId = req.params["workerId"];
909
910 const projectMonitor = projectMonitors.get(projectId);
911 if (!projectMonitor) {
912 resp.status(404).send({ error: "Project monitor not found" });
913 return;
914 }
915
916 try {
917 await projectMonitor.terminateWorker(serviceName, workerId);
918 resp.status(200).send({ message: "Worker termination initiated" });
919 } catch (error) {
920 console.error(
921 `Failed to terminate worker ${workerId} in service ${serviceName} for project ${projectId}:`,
922 error,
923 );
924 const errorMessage = error instanceof Error ? error.message : "Unknown error";
925 resp.status(500).send({ error: `Failed to terminate worker: ${errorMessage}` });
926 }
927};
928
gio918780d2025-05-22 08:24:41 +0000929const handleReloadWorker: express.Handler = async (req, resp) => {
930 const projectId = Number(req.params["projectId"]);
931 const serviceName = req.params["serviceName"];
932 const workerId = req.params["workerId"];
933
934 const projectMonitor = projectMonitors.get(projectId);
935 if (!projectMonitor) {
936 resp.status(404).send({ error: "Project monitor not found" });
937 return;
938 }
939
940 try {
941 await projectMonitor.reloadWorker(serviceName, workerId);
942 resp.status(200).send({ message: "Worker reload initiated" });
943 } catch (error) {
944 console.error(`Failed to reload worker ${workerId} in service ${serviceName} for project ${projectId}:`, error);
945 const errorMessage = error instanceof Error ? error.message : "Unknown error";
946 resp.status(500).send({ error: `Failed to reload worker: ${errorMessage}` });
947 }
948};
949
gioa71316d2025-05-24 09:41:36 +0400950const analyzeRepoReqSchema = z.object({
951 address: z.string(),
952});
953
954const handleAnalyzeRepo: express.Handler = async (req, resp) => {
955 const projectId = Number(req.params["projectId"]);
956 const project = await db.project.findUnique({
957 where: {
958 id: projectId,
959 userId: resp.locals.userId,
960 },
961 select: {
962 githubToken: true,
963 deployKey: true,
964 deployKeyPublic: true,
965 },
966 });
967 if (!project) {
968 resp.status(404).send({ error: "Project not found" });
969 return;
970 }
971 if (!project.githubToken) {
972 resp.status(400).send({ error: "GitHub token not configured" });
973 return;
974 }
gio8e74dc02025-06-13 10:19:26 +0000975 let tmpDir: tmp.DirResult | null = null;
976 try {
977 let deployKey: string | null = project.deployKey;
978 let deployKeyPublic: string | null = project.deployKeyPublic;
979 if (!deployKeyPublic) {
980 [deployKeyPublic, deployKey] = await generateKey(tmp.dirSync().name);
981 await db.project.update({
982 where: { id: projectId },
983 data: {
984 deployKeyPublic: deployKeyPublic,
985 deployKey: deployKey,
986 },
987 });
988 }
989 const github = new GithubClient(project.githubToken);
990 const result = analyzeRepoReqSchema.safeParse(req.body);
991 if (!result.success) {
992 resp.status(400).send({ error: "Invalid request data" });
993 return;
994 }
995 const { address } = result.data;
996 tmpDir = tmp.dirSync({
997 unsafeCleanup: true,
gioa71316d2025-05-24 09:41:36 +0400998 });
gio8e74dc02025-06-13 10:19:26 +0000999 await github.addDeployKey(address, deployKeyPublic);
1000 await fs.promises.writeFile(path.join(tmpDir.name, "key"), deployKey!, {
1001 mode: 0o600,
1002 });
1003 shell.exec(
1004 `GIT_SSH_COMMAND='ssh -i ${tmpDir.name}/key -o IdentitiesOnly=yes' git clone ${address} ${tmpDir.name}/code`,
1005 );
1006 const fsc = new RealFileSystem(`${tmpDir.name}/code`);
1007 const analyzer = new NodeJSAnalyzer();
1008 const info = await analyzer.analyze(fsc, "/");
1009 resp.status(200).send([info]);
1010 } catch (e) {
1011 console.error(e);
1012 resp.status(500).send({ error: "Failed to analyze repository" });
1013 } finally {
1014 if (tmpDir) {
1015 tmpDir.removeCallback();
1016 }
1017 resp.end();
gioa71316d2025-05-24 09:41:36 +04001018 }
gioa71316d2025-05-24 09:41:36 +04001019};
1020
gio09fcab52025-05-12 14:05:07 +00001021const auth = (req: express.Request, resp: express.Response, next: express.NextFunction) => {
gio69148322025-06-19 23:16:12 +04001022 // Hardcoded user for development
1023 resp.locals.userId = "1";
1024 resp.locals.username = "gio";
gio09fcab52025-05-12 14:05:07 +00001025 next();
1026};
1027
gio76d8ae62025-05-19 15:21:54 +00001028const handleGithubPushWebhook: express.Handler = async (req, resp) => {
1029 try {
1030 // TODO(gio): Implement GitHub signature verification for security
1031 const webhookSchema = z.object({
1032 repository: z.object({
1033 ssh_url: z.string(),
1034 }),
1035 });
1036
1037 const result = webhookSchema.safeParse(req.body);
1038 if (!result.success) {
1039 console.warn("GitHub webhook: Invalid payload:", result.error.issues);
1040 resp.status(400).json({ error: "Invalid webhook payload" });
1041 return;
1042 }
1043 const { ssh_url: addr } = result.data.repository;
1044 const allProjects = await db.project.findMany({
1045 select: {
1046 id: true,
1047 state: true,
1048 },
1049 where: {
1050 instanceId: {
1051 not: null,
1052 },
1053 },
1054 });
1055 // TODO(gio): This should run in background
1056 new Promise<boolean>((resolve, reject) => {
1057 setTimeout(() => {
1058 const projectsToReloadIds: number[] = [];
1059 for (const project of allProjects) {
1060 if (project.state && project.state.length > 0) {
1061 const projectRepos = extractGithubRepos(project.state);
1062 if (projectRepos.includes(addr)) {
1063 projectsToReloadIds.push(project.id);
1064 }
1065 }
1066 }
1067 Promise.all(projectsToReloadIds.map((id) => reloadProject(id)))
1068 .then((results) => {
1069 resolve(results.reduce((acc, curr) => acc && curr, true));
1070 })
1071 // eslint-disable-next-line @typescript-eslint/no-explicit-any
1072 .catch((reason: any) => reject(reason));
1073 }, 10);
1074 });
1075 // eslint-disable-next-line @typescript-eslint/no-explicit-any
1076 } catch (error: any) {
1077 console.error(error);
1078 resp.status(500);
1079 }
1080};
1081
gioc31bf142025-06-16 07:48:20 +00001082const handleValidateConfig: express.Handler = async (req, resp) => {
1083 try {
1084 const validationResult = ConfigSchema.safeParse(req.body);
1085 if (!validationResult.success) {
1086 resp.status(400);
1087 resp.header("Content-Type", "application/json");
1088 resp.write(JSON.stringify({ success: false, errors: validationResult.error.flatten() }));
1089 } else {
1090 resp.status(200);
1091 resp.header("Content-Type", "application/json");
1092 resp.write(JSON.stringify({ success: true }));
1093 }
1094 } catch (e) {
1095 console.log(e);
1096 resp.status(500);
1097 } finally {
1098 resp.end();
1099 }
1100};
1101
giod0026612025-05-08 13:00:36 +00001102async function start() {
1103 await db.$connect();
1104 const app = express();
gioc31bf142025-06-16 07:48:20 +00001105 app.set("json spaces", 2);
gio76d8ae62025-05-19 15:21:54 +00001106 app.use(express.json()); // Global JSON parsing
1107
1108 // Public webhook route - no auth needed
1109 app.post("/api/webhook/github/push", handleGithubPushWebhook);
1110
1111 // Authenticated project routes
1112 const projectRouter = express.Router();
gioa71316d2025-05-24 09:41:36 +04001113 projectRouter.use(auth);
1114 projectRouter.post("/:projectId/analyze", handleAnalyzeRepo);
gio76d8ae62025-05-19 15:21:54 +00001115 projectRouter.post("/:projectId/saved", handleSave);
1116 projectRouter.get("/:projectId/saved/deploy", handleSavedGet("deploy"));
1117 projectRouter.get("/:projectId/saved/draft", handleSavedGet("draft"));
1118 projectRouter.post("/:projectId/deploy", handleDeploy);
1119 projectRouter.get("/:projectId/status", handleStatus);
gioc31bf142025-06-16 07:48:20 +00001120 projectRouter.get("/:projectId/config", handleConfigGet);
gioa71316d2025-05-24 09:41:36 +04001121 projectRouter.delete("/:projectId", handleProjectDelete);
gio76d8ae62025-05-19 15:21:54 +00001122 projectRouter.get("/:projectId/repos/github", handleGithubRepos);
1123 projectRouter.post("/:projectId/github-token", handleUpdateGithubToken);
gio69148322025-06-19 23:16:12 +04001124 projectRouter.post("/:projectId/gemini-token", handleUpdateGeminiToken);
gio69ff7592025-07-03 06:27:21 +00001125 projectRouter.post("/:projectId/anthropic-token", handleUpdateAnthropicToken);
gio76d8ae62025-05-19 15:21:54 +00001126 projectRouter.get("/:projectId/env", handleEnv);
gio918780d2025-05-22 08:24:41 +00001127 projectRouter.post("/:projectId/reload/:serviceName/:workerId", handleReloadWorker);
gio577d2342025-07-03 12:50:18 +00001128 projectRouter.post("/:projectId/quitquitquit/:serviceName/:workerId", handleQuitWorker);
gio76d8ae62025-05-19 15:21:54 +00001129 projectRouter.post("/:projectId/reload", handleReload);
gioa1efbad2025-05-21 07:16:45 +00001130 projectRouter.get("/:projectId/logs/:service/:workerId", handleServiceLogs);
gio76d8ae62025-05-19 15:21:54 +00001131 projectRouter.post("/:projectId/remove-deployment", handleRemoveDeployment);
1132 projectRouter.get("/", handleProjectAll);
1133 projectRouter.post("/", handleProjectCreate);
gio918780d2025-05-22 08:24:41 +00001134
gio76d8ae62025-05-19 15:21:54 +00001135 app.use("/api/project", projectRouter); // Mount the authenticated router
1136
giod0026612025-05-08 13:00:36 +00001137 app.use("/", express.static("../front/dist"));
gio09fcab52025-05-12 14:05:07 +00001138
gio76d8ae62025-05-19 15:21:54 +00001139 const internalApi = express();
1140 internalApi.use(express.json());
1141 internalApi.post("/api/project/:projectId/workers", handleRegisterWorker);
gioc31bf142025-06-16 07:48:20 +00001142 internalApi.get("/api/project/:projectId/config", handleConfigGet);
1143 internalApi.post("/api/project/:projectId/deploy", handleDeploy);
1144 internalApi.post("/api/validate-config", handleValidateConfig);
gio09fcab52025-05-12 14:05:07 +00001145
giod0026612025-05-08 13:00:36 +00001146 app.listen(env.DODO_PORT_WEB, () => {
gio09fcab52025-05-12 14:05:07 +00001147 console.log("Web server started on port", env.DODO_PORT_WEB);
1148 });
1149
gio76d8ae62025-05-19 15:21:54 +00001150 internalApi.listen(env.DODO_PORT_API, () => {
gio09fcab52025-05-12 14:05:07 +00001151 console.log("Internal API server started on port", env.DODO_PORT_API);
giod0026612025-05-08 13:00:36 +00001152 });
1153}
1154
1155start();