blob: d7e47edddab9a75fce314460c10c4690545ffe22 [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";
gioe085d5b2025-07-08 07:51:36 +00007import { AppManager, DeployResponse } 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,
gio56e9f472025-07-07 03:33:38 +000019 Config,
gio9b7421a2025-06-18 12:31:13 +000020 ConfigWithInput,
21 configToGraph,
22 Network,
23 GithubRepository,
gio8a5f12f2025-07-05 07:02:31 +000024 Graph,
gio9b7421a2025-06-18 12:31:13 +000025} from "config";
gio78a22882025-07-01 18:56:01 +000026import { Instant, DateTimeFormatter, ZoneId } from "@js-joda/core";
gio40c0c992025-07-02 13:18:05 +000027import LogStore from "./log.js";
gio785c9882025-07-07 16:40:25 +000028import { GraphOrConfigSchema, GraphSchema, GraphConfigOrDraft, AgentAccess } from "config/dist/graph.js";
gio43e0aad2025-08-01 16:17:27 +040029import { MachineManager } from "./machine_manager.js";
gioa71316d2025-05-24 09:41:36 +040030
31async function generateKey(root: string): Promise<[string, string]> {
32 const privKeyPath = path.join(root, "key");
33 const pubKeyPath = path.join(root, "key.pub");
34 if (shell.exec(`ssh-keygen -t ed25519 -f ${privKeyPath} -N ""`).code !== 0) {
35 throw new Error("Failed to generate SSH key pair");
36 }
37 const publicKey = await fs.promises.readFile(pubKeyPath, "utf8");
38 const privateKey = await fs.promises.readFile(privKeyPath, "utf8");
39 return [publicKey, privateKey];
40}
giod0026612025-05-08 13:00:36 +000041
42const db = new PrismaClient();
gio40c0c992025-07-02 13:18:05 +000043const logStore = new LogStore(db);
gio3ed59592025-05-14 16:51:09 +000044const appManager = new AppManager();
gio43e0aad2025-08-01 16:17:27 +040045const machineManager = new MachineManager(env.VPN_API_ADDR!);
giod0026612025-05-08 13:00:36 +000046
gioa1efbad2025-05-21 07:16:45 +000047const projectMonitors = new Map<number, ProjectMonitor>();
gio7d813702025-05-08 18:29:52 +000048
gio10ff1342025-07-05 10:22:15 +000049function parseGraph(data: string | null | undefined) {
50 if (data == null) {
51 return null;
52 }
53 return GraphSchema.safeParse(JSON.parse(data));
54}
55
giod0026612025-05-08 13:00:36 +000056const handleProjectCreate: express.Handler = async (req, resp) => {
57 try {
gioa71316d2025-05-24 09:41:36 +040058 const tmpDir = tmp.dirSync().name;
59 const [publicKey, privateKey] = await generateKey(tmpDir);
giod0026612025-05-08 13:00:36 +000060 const { id } = await db.project.create({
61 data: {
gio09fcab52025-05-12 14:05:07 +000062 userId: resp.locals.userId,
giod0026612025-05-08 13:00:36 +000063 name: req.body.name,
gioa71316d2025-05-24 09:41:36 +040064 deployKey: privateKey,
65 deployKeyPublic: publicKey,
giod0026612025-05-08 13:00:36 +000066 },
67 });
68 resp.status(200);
69 resp.header("Content-Type", "application/json");
70 resp.write(
71 JSON.stringify({
gio74ab7852025-05-13 13:19:31 +000072 id: id.toString(),
giod0026612025-05-08 13:00:36 +000073 }),
74 );
75 } catch (e) {
76 console.log(e);
77 resp.status(500);
78 } finally {
79 resp.end();
80 }
81};
82
83const handleProjectAll: express.Handler = async (req, resp) => {
84 try {
85 const r = await db.project.findMany({
86 where: {
gio09fcab52025-05-12 14:05:07 +000087 userId: resp.locals.userId,
giod0026612025-05-08 13:00:36 +000088 },
89 });
90 resp.status(200);
91 resp.header("Content-Type", "application/json");
92 resp.write(
93 JSON.stringify(
94 r.map((p) => ({
95 id: p.id.toString(),
96 name: p.name,
97 })),
98 ),
99 );
100 } catch (e) {
101 console.log(e);
102 resp.status(500);
103 } finally {
104 resp.end();
105 }
106};
107
gio8a5f12f2025-07-05 07:02:31 +0000108async function getState(projectId: number, userId: string, state: "deploy" | "draft"): Promise<Graph | null> {
109 const r = await db.project.findUnique({
110 where: {
111 id: projectId,
112 userId: userId,
113 },
114 select: {
115 state: true,
116 draft: true,
117 },
118 });
119 if (r == null) {
120 return null;
121 }
122 let currentState: Graph | null = null;
123 if (state === "deploy") {
124 if (r.state != null) {
gio10ff1342025-07-05 10:22:15 +0000125 currentState = parseGraph(r.state)!.data!;
gio8a5f12f2025-07-05 07:02:31 +0000126 }
127 } else {
128 if (r.draft == null) {
129 if (r.state == null) {
130 currentState = {
131 nodes: [],
132 edges: [],
133 viewport: { x: 0, y: 0, zoom: 1 },
134 };
135 } else {
gio10ff1342025-07-05 10:22:15 +0000136 currentState = parseGraph(r.state)!.data!;
gio8a5f12f2025-07-05 07:02:31 +0000137 }
138 } else {
gio10ff1342025-07-05 10:22:15 +0000139 currentState = parseGraph(r.draft)!.data!;
gio8a5f12f2025-07-05 07:02:31 +0000140 }
141 }
142 return currentState;
143}
144
gio818da4e2025-05-12 14:45:35 +0000145function handleSavedGet(state: "deploy" | "draft"): express.Handler {
146 return async (req, resp) => {
147 try {
gio8a5f12f2025-07-05 07:02:31 +0000148 const projectId = Number(req.params["projectId"]);
149 const graph = await getState(projectId, resp.locals.userId, state);
150 if (graph == null) {
gio818da4e2025-05-12 14:45:35 +0000151 resp.status(404);
152 return;
153 }
gio8a5f12f2025-07-05 07:02:31 +0000154 const env = await getEnv(projectId, resp.locals.userId, resp.locals.username);
155 const config = generateDodoConfig(projectId.toString(), graph.nodes, env);
giod0026612025-05-08 13:00:36 +0000156 resp.status(200);
157 resp.header("content-type", "application/json");
gio8a5f12f2025-07-05 07:02:31 +0000158 resp.write(
159 JSON.stringify({
160 state: graph,
gioc31bf142025-06-16 07:48:20 +0000161 config,
gio8a5f12f2025-07-05 07:02:31 +0000162 }),
163 );
gio818da4e2025-05-12 14:45:35 +0000164 } catch (e) {
165 console.log(e);
166 resp.status(500);
167 } finally {
168 resp.end();
giod0026612025-05-08 13:00:36 +0000169 }
gio818da4e2025-05-12 14:45:35 +0000170 };
171}
giod0026612025-05-08 13:00:36 +0000172
gioa71316d2025-05-24 09:41:36 +0400173const handleProjectDelete: express.Handler = async (req, resp) => {
giod0026612025-05-08 13:00:36 +0000174 try {
175 const projectId = Number(req.params["projectId"]);
176 const p = await db.project.findUnique({
177 where: {
178 id: projectId,
gio09fcab52025-05-12 14:05:07 +0000179 userId: resp.locals.userId,
giod0026612025-05-08 13:00:36 +0000180 },
181 select: {
182 instanceId: true,
gioa71316d2025-05-24 09:41:36 +0400183 githubToken: true,
184 deployKeyPublic: true,
185 state: true,
186 draft: true,
giod0026612025-05-08 13:00:36 +0000187 },
188 });
189 if (p === null) {
190 resp.status(404);
191 return;
192 }
gioa71316d2025-05-24 09:41:36 +0400193 if (p.githubToken && p.deployKeyPublic) {
gio56e9f472025-07-07 03:33:38 +0000194 const allRepos = [...new Set([...extractGithubRepos(p.state), ...extractGithubRepos(p.draft)])];
gioa71316d2025-05-24 09:41:36 +0400195 if (allRepos.length > 0) {
196 const diff: RepoDiff = { toDelete: allRepos, toAdd: [] };
197 const github = new GithubClient(p.githubToken);
198 await manageGithubRepos(github, diff, p.deployKeyPublic, env.PUBLIC_ADDR);
199 console.log(
200 `Attempted to remove deploy keys for project ${projectId} from associated GitHub repositories.`,
201 );
202 }
giod0026612025-05-08 13:00:36 +0000203 }
gioa71316d2025-05-24 09:41:36 +0400204 if (p.instanceId !== null) {
205 if (!(await appManager.removeInstance(p.instanceId))) {
206 resp.status(500);
207 resp.write(JSON.stringify({ error: "Failed to remove deployment from cluster" }));
208 return;
209 }
210 }
211 await db.project.delete({
212 where: {
213 id: projectId,
214 },
215 });
giod0026612025-05-08 13:00:36 +0000216 resp.status(200);
217 } catch (e) {
218 console.log(e);
219 resp.status(500);
220 } finally {
221 resp.end();
222 }
223};
224
gioa71316d2025-05-24 09:41:36 +0400225function extractGithubRepos(serializedState: string | null | undefined): string[] {
gio3ed59592025-05-14 16:51:09 +0000226 if (!serializedState) {
227 return [];
228 }
229 try {
giobd37a2b2025-05-15 04:28:42 +0000230 const stateObj = JSON.parse(serializedState);
gio3ed59592025-05-14 16:51:09 +0000231 const githubNodes = stateObj.nodes.filter(
232 // eslint-disable-next-line @typescript-eslint/no-explicit-any
233 (n: any) => n.type === "github" && n.data?.repository?.id,
234 );
235 // eslint-disable-next-line @typescript-eslint/no-explicit-any
236 return githubNodes.map((n: any) => n.data.repository.sshURL);
237 } catch (error) {
238 console.error("Failed to parse state or extract GitHub repos:", error);
239 return [];
240 }
241}
242
243type RepoDiff = {
244 toAdd?: string[];
245 toDelete?: string[];
246};
247
248function calculateRepoDiff(oldRepos: string[], newRepos: string[]): RepoDiff {
249 const toAdd = newRepos.filter((repo) => !oldRepos.includes(repo));
250 const toDelete = oldRepos.filter((repo) => !newRepos.includes(repo));
251 return { toAdd, toDelete };
252}
253
gio76d8ae62025-05-19 15:21:54 +0000254async function manageGithubRepos(
255 github: GithubClient,
256 diff: RepoDiff,
257 deployKey: string,
258 publicAddr?: string,
259): Promise<void> {
gio3ed59592025-05-14 16:51:09 +0000260 for (const repoUrl of diff.toDelete ?? []) {
261 try {
262 await github.removeDeployKey(repoUrl, deployKey);
263 console.log(`Removed deploy key from repository ${repoUrl}`);
gio76d8ae62025-05-19 15:21:54 +0000264 if (publicAddr) {
265 const webhookCallbackUrl = `${publicAddr}/api/webhook/github/push`;
266 await github.removePushWebhook(repoUrl, webhookCallbackUrl);
267 console.log(`Removed push webhook from repository ${repoUrl}`);
268 }
gio3ed59592025-05-14 16:51:09 +0000269 } catch (error) {
270 console.error(`Failed to remove deploy key from repository ${repoUrl}:`, error);
271 }
272 }
273 for (const repoUrl of diff.toAdd ?? []) {
274 try {
275 await github.addDeployKey(repoUrl, deployKey);
276 console.log(`Added deploy key to repository ${repoUrl}`);
gio76d8ae62025-05-19 15:21:54 +0000277 if (publicAddr) {
278 const webhookCallbackUrl = `${publicAddr}/api/webhook/github/push`;
279 await github.addPushWebhook(repoUrl, webhookCallbackUrl);
280 console.log(`Added push webhook to repository ${repoUrl}`);
281 }
gio3ed59592025-05-14 16:51:09 +0000282 } catch (error) {
283 console.error(`Failed to add deploy key from repository ${repoUrl}:`, error);
284 }
285 }
286}
287
giod0026612025-05-08 13:00:36 +0000288const handleDeploy: express.Handler = async (req, resp) => {
289 try {
gio56e9f472025-07-07 03:33:38 +0000290 const reqParsed = GraphConfigOrDraft.safeParse(req.body);
291 if (!reqParsed.success) {
292 resp.status(400);
293 resp.write(JSON.stringify({ error: "Invalid request body", issues: reqParsed.error.format() }));
294 return;
295 }
giod0026612025-05-08 13:00:36 +0000296 const projectId = Number(req.params["projectId"]);
giod0026612025-05-08 13:00:36 +0000297 const p = await db.project.findUnique({
298 where: {
299 id: projectId,
gioc31bf142025-06-16 07:48:20 +0000300 // userId: resp.locals.userId, TODO(gio): validate
giod0026612025-05-08 13:00:36 +0000301 },
302 select: {
303 instanceId: true,
304 githubToken: true,
305 deployKey: true,
gioa71316d2025-05-24 09:41:36 +0400306 deployKeyPublic: true,
gio3ed59592025-05-14 16:51:09 +0000307 state: true,
gio56e9f472025-07-07 03:33:38 +0000308 draft: true,
gio69148322025-06-19 23:16:12 +0400309 geminiApiKey: true,
gio69ff7592025-07-03 06:27:21 +0000310 anthropicApiKey: true,
giod0026612025-05-08 13:00:36 +0000311 },
312 });
313 if (p === null) {
314 resp.status(404);
315 return;
316 }
gio56e9f472025-07-07 03:33:38 +0000317 let graph: Graph | null = null;
318 let config: Config | null = null;
319 if (reqParsed.data.type === "config") {
320 const parsed = ConfigSchema.safeParse(reqParsed.data.config);
321 if (parsed.success) {
322 config = parsed.data;
323 } else {
324 resp.status(400);
325 resp.write(JSON.stringify({ error: "Invalid configuration", issues: parsed.error.format() }));
326 return;
327 }
328 let oldGraph: Graph | undefined = undefined;
329 if (p.state != null) {
330 oldGraph = parseGraph(p.state)!.data;
331 } else if (p.draft != null) {
332 oldGraph = parseGraph(p.draft)!.data;
333 }
334 let repos: GithubRepository[] = [];
335 if (p.githubToken) {
336 const github = new GithubClient(p.githubToken);
337 repos = await github.getRepositories();
338 }
339 graph = configToGraph(config, getNetworks(resp.locals.username), repos, oldGraph);
340 } else if (reqParsed.data.type === "graph") {
341 graph = reqParsed.data.graph;
342 const env = await getEnv(projectId, resp.locals.userId, resp.locals.username);
343 config = generateDodoConfig(projectId.toString(), graph?.nodes || [], env);
344 } else if (reqParsed.data.type === "draft") {
345 if (p.draft == null) {
346 resp.status(400);
347 resp.write(JSON.stringify({ error: "Invalid request body" }));
348 return;
349 }
350 graph = parseGraph(p.draft)!.data!;
351 const env = await getEnv(projectId, resp.locals.userId, resp.locals.username);
352 config = generateDodoConfig(projectId.toString(), graph?.nodes || [], env);
353 }
354 if (config == null || graph == null) {
355 resp.status(500);
356 resp.write(JSON.stringify({ error: "Failed to generate configuration" }));
gioc31bf142025-06-16 07:48:20 +0000357 return;
358 }
giod0026612025-05-08 13:00:36 +0000359 await db.project.update({
360 where: {
361 id: projectId,
362 },
363 data: {
gio56e9f472025-07-07 03:33:38 +0000364 draft: JSON.stringify(graph),
giod0026612025-05-08 13:00:36 +0000365 },
366 });
gioa71316d2025-05-24 09:41:36 +0400367 let deployKey: string | null = p.deployKey;
368 let deployKeyPublic: string | null = p.deployKeyPublic;
369 if (deployKeyPublic == null) {
370 [deployKeyPublic, deployKey] = await generateKey(tmp.dirSync().name);
371 await db.project.update({
372 where: { id: projectId },
373 data: { deployKeyPublic, deployKey },
374 });
375 }
gio3ed59592025-05-14 16:51:09 +0000376 let diff: RepoDiff | null = null;
gioc31bf142025-06-16 07:48:20 +0000377 const cfg: ConfigWithInput = {
gio56e9f472025-07-07 03:33:38 +0000378 ...config,
gioc31bf142025-06-16 07:48:20 +0000379 input: {
380 appId: projectId.toString(),
381 managerAddr: env.INTERNAL_API_ADDR!,
382 key: {
383 public: deployKeyPublic!,
384 private: deployKey!,
385 },
gio69148322025-06-19 23:16:12 +0400386 geminiApiKey: p.geminiApiKey ?? undefined,
gio69ff7592025-07-03 06:27:21 +0000387 anthropicApiKey: p.anthropicApiKey ?? undefined,
gioc31bf142025-06-16 07:48:20 +0000388 },
gioa71316d2025-05-24 09:41:36 +0400389 };
gio3ed59592025-05-14 16:51:09 +0000390 try {
gioe085d5b2025-07-08 07:51:36 +0000391 let deployResponse: DeployResponse | null = null;
gio3ed59592025-05-14 16:51:09 +0000392 if (p.instanceId == null) {
gioe085d5b2025-07-08 07:51:36 +0000393 deployResponse = await appManager.deploy(cfg);
gio56e9f472025-07-07 03:33:38 +0000394 diff = { toAdd: extractGithubRepos(JSON.stringify(graph)) };
gio3ed59592025-05-14 16:51:09 +0000395 } else {
gioe085d5b2025-07-08 07:51:36 +0000396 deployResponse = await appManager.update(p.instanceId, cfg);
gio56e9f472025-07-07 03:33:38 +0000397 diff = calculateRepoDiff(extractGithubRepos(p.state), extractGithubRepos(JSON.stringify(graph)));
giod0026612025-05-08 13:00:36 +0000398 }
gioe085d5b2025-07-08 07:51:36 +0000399 if (deployResponse == null) {
400 resp.status(500);
401 resp.write(JSON.stringify({ error: "Failed to deploy" }));
402 return;
403 }
404 await db.project.update({
405 where: {
406 id: projectId,
407 },
408 data: {
409 state: JSON.stringify(graph),
410 draft: null,
411 instanceId: deployResponse.id,
412 access: JSON.stringify(deployResponse.access),
413 envVars: JSON.stringify(deployResponse.envVars),
414 },
415 });
gio3ed59592025-05-14 16:51:09 +0000416 if (diff && p.githubToken && deployKey) {
417 const github = new GithubClient(p.githubToken);
gioa71316d2025-05-24 09:41:36 +0400418 await manageGithubRepos(github, diff, deployKeyPublic!, env.PUBLIC_ADDR);
giod0026612025-05-08 13:00:36 +0000419 }
gio3ed59592025-05-14 16:51:09 +0000420 resp.status(200);
421 } catch (error) {
422 console.error("Deployment error:", error);
423 resp.status(500);
424 resp.write(JSON.stringify({ error: "Deployment failed" }));
giod0026612025-05-08 13:00:36 +0000425 }
426 } catch (e) {
427 console.log(e);
428 resp.status(500);
429 } finally {
430 resp.end();
431 }
432};
433
gio10ff1342025-07-05 10:22:15 +0000434const handleSave: express.Handler = async (req, resp) => {
gio8a5f12f2025-07-05 07:02:31 +0000435 try {
436 const projectId = Number(req.params["projectId"]);
437 const p = await db.project.findUnique({
438 where: {
439 id: projectId,
gio10ff1342025-07-05 10:22:15 +0000440 userId: resp.locals.userId,
gio8a5f12f2025-07-05 07:02:31 +0000441 },
442 select: {
443 instanceId: true,
444 githubToken: true,
445 deployKey: true,
446 deployKeyPublic: true,
447 state: true,
448 geminiApiKey: true,
449 anthropicApiKey: true,
450 },
451 });
452 if (p === null) {
453 resp.status(404);
454 return;
455 }
gio10ff1342025-07-05 10:22:15 +0000456 const gc = GraphOrConfigSchema.safeParse(req.body);
457 if (!gc.success) {
gio8a5f12f2025-07-05 07:02:31 +0000458 resp.status(400);
gio10ff1342025-07-05 10:22:15 +0000459 resp.write(JSON.stringify({ error: "Invalid configuration", issues: gc.error.format() }));
460 return;
461 }
462 if (gc.data.type === "graph") {
463 await db.project.update({
464 where: { id: projectId },
465 data: { draft: JSON.stringify(gc.data.graph) },
466 });
467 resp.status(200);
gio8a5f12f2025-07-05 07:02:31 +0000468 return;
469 }
470 let repos: GithubRepository[] = [];
471 if (p.githubToken) {
472 const github = new GithubClient(p.githubToken);
473 repos = await github.getRepositories();
474 }
475 const state = JSON.stringify(
476 configToGraph(
gio10ff1342025-07-05 10:22:15 +0000477 gc.data.config,
gio8a5f12f2025-07-05 07:02:31 +0000478 getNetworks(resp.locals.username),
479 repos,
gio10ff1342025-07-05 10:22:15 +0000480 p.state ? parseGraph(p.state)!.data! : undefined,
gio8a5f12f2025-07-05 07:02:31 +0000481 ),
482 );
483 await db.project.update({
484 where: { id: projectId },
485 data: { draft: state },
486 });
487 resp.status(200);
488 } catch (e) {
489 console.log(e);
490 resp.status(500);
491 } finally {
492 resp.end();
493 }
494};
495
giod0026612025-05-08 13:00:36 +0000496const handleStatus: express.Handler = async (req, resp) => {
497 try {
498 const projectId = Number(req.params["projectId"]);
499 const p = await db.project.findUnique({
500 where: {
501 id: projectId,
gio09fcab52025-05-12 14:05:07 +0000502 userId: resp.locals.userId,
giod0026612025-05-08 13:00:36 +0000503 },
504 select: {
505 instanceId: true,
506 },
507 });
giod0026612025-05-08 13:00:36 +0000508 if (p === null) {
509 resp.status(404);
510 return;
511 }
512 if (p.instanceId == null) {
513 resp.status(404);
514 return;
515 }
gio3ed59592025-05-14 16:51:09 +0000516 try {
517 const status = await appManager.getStatus(p.instanceId);
518 resp.status(200);
519 resp.write(JSON.stringify(status));
520 } catch (error) {
521 console.error("Error getting status:", error);
522 resp.status(500);
giod0026612025-05-08 13:00:36 +0000523 }
524 } catch (e) {
525 console.log(e);
526 resp.status(500);
527 } finally {
528 resp.end();
529 }
530};
531
gioc31bf142025-06-16 07:48:20 +0000532const handleConfigGet: express.Handler = async (req, resp) => {
533 try {
534 const projectId = Number(req.params["projectId"]);
535 const project = await db.project.findUnique({
536 where: {
537 id: projectId,
538 },
539 select: {
540 state: true,
541 },
542 });
543
544 if (!project || !project.state) {
545 resp.status(404).send({ error: "No deployed configuration found." });
546 return;
547 }
548
gio10ff1342025-07-05 10:22:15 +0000549 const state = parseGraph(project.state)!.data!;
gioc31bf142025-06-16 07:48:20 +0000550 const env = await getEnv(projectId, resp.locals.userId, resp.locals.username);
551 const config = generateDodoConfig(projectId.toString(), state.nodes, env);
552
553 if (!config) {
554 resp.status(500).send({ error: "Failed to generate configuration." });
555 return;
556 }
557 resp.status(200).json(config);
558 } catch (e) {
559 console.log(e);
560 resp.status(500).send({ error: "Internal server error" });
561 } finally {
562 console.log("config get done");
563 resp.end();
564 }
565};
566
giobd37a2b2025-05-15 04:28:42 +0000567const handleRemoveDeployment: express.Handler = async (req, resp) => {
568 try {
569 const projectId = Number(req.params["projectId"]);
570 const p = await db.project.findUnique({
571 where: {
572 id: projectId,
573 userId: resp.locals.userId,
574 },
575 select: {
576 instanceId: true,
577 githubToken: true,
gioa71316d2025-05-24 09:41:36 +0400578 deployKeyPublic: true,
giobd37a2b2025-05-15 04:28:42 +0000579 state: true,
580 draft: true,
581 },
582 });
583 if (p === null) {
584 resp.status(404);
585 resp.write(JSON.stringify({ error: "Project not found" }));
586 return;
587 }
588 if (p.instanceId == null) {
589 resp.status(400);
590 resp.write(JSON.stringify({ error: "Project not deployed" }));
591 return;
592 }
593 const removed = await appManager.removeInstance(p.instanceId);
594 if (!removed) {
595 resp.status(500);
596 resp.write(JSON.stringify({ error: "Failed to remove deployment from cluster" }));
597 return;
598 }
gioa71316d2025-05-24 09:41:36 +0400599 if (p.githubToken && p.deployKeyPublic && p.state) {
giobd37a2b2025-05-15 04:28:42 +0000600 try {
601 const github = new GithubClient(p.githubToken);
602 const repos = extractGithubRepos(p.state);
603 const diff = { toDelete: repos, toAdd: [] };
gioa71316d2025-05-24 09:41:36 +0400604 await manageGithubRepos(github, diff, p.deployKeyPublic, env.PUBLIC_ADDR);
giobd37a2b2025-05-15 04:28:42 +0000605 } catch (error) {
606 console.error("Error removing GitHub deploy keys:", error);
607 }
608 }
609 await db.project.update({
610 where: {
611 id: projectId,
612 },
613 data: {
614 instanceId: null,
gioa71316d2025-05-24 09:41:36 +0400615 deployKeyPublic: null,
giob77cb932025-05-19 09:37:14 +0000616 access: null,
giobd37a2b2025-05-15 04:28:42 +0000617 state: null,
618 draft: p.draft ?? p.state,
619 },
620 });
621 resp.status(200);
622 resp.write(JSON.stringify({ success: true }));
623 } catch (e) {
624 console.error("Error removing deployment:", e);
625 resp.status(500);
626 resp.write(JSON.stringify({ error: "Internal server error" }));
627 } finally {
628 resp.end();
629 }
630};
631
giod0026612025-05-08 13:00:36 +0000632const handleGithubRepos: express.Handler = async (req, resp) => {
633 try {
634 const projectId = Number(req.params["projectId"]);
635 const project = await db.project.findUnique({
gio09fcab52025-05-12 14:05:07 +0000636 where: {
637 id: projectId,
638 userId: resp.locals.userId,
639 },
640 select: {
641 githubToken: true,
642 },
giod0026612025-05-08 13:00:36 +0000643 });
giod0026612025-05-08 13:00:36 +0000644 if (!project?.githubToken) {
645 resp.status(400);
646 resp.write(JSON.stringify({ error: "GitHub token not configured" }));
647 return;
648 }
giod0026612025-05-08 13:00:36 +0000649 const github = new GithubClient(project.githubToken);
650 const repositories = await github.getRepositories();
giod0026612025-05-08 13:00:36 +0000651 resp.status(200);
652 resp.header("Content-Type", "application/json");
653 resp.write(JSON.stringify(repositories));
654 } catch (e) {
655 console.log(e);
656 resp.status(500);
657 resp.write(JSON.stringify({ error: "Failed to fetch repositories" }));
658 } finally {
659 resp.end();
660 }
661};
662
663const handleUpdateGithubToken: express.Handler = async (req, resp) => {
664 try {
giod0026612025-05-08 13:00:36 +0000665 await db.project.update({
gio09fcab52025-05-12 14:05:07 +0000666 where: {
gio69148322025-06-19 23:16:12 +0400667 id: Number(req.params["projectId"]),
gio09fcab52025-05-12 14:05:07 +0000668 userId: resp.locals.userId,
669 },
gio69148322025-06-19 23:16:12 +0400670 data: {
671 githubToken: req.body.githubToken,
672 },
673 });
674 resp.status(200);
675 } catch (e) {
676 console.log(e);
677 resp.status(500);
678 } finally {
679 resp.end();
680 }
681};
682
683const handleUpdateGeminiToken: express.Handler = async (req, resp) => {
684 try {
685 await db.project.update({
686 where: {
687 id: Number(req.params["projectId"]),
688 userId: resp.locals.userId,
689 },
690 data: {
691 geminiApiKey: req.body.geminiApiKey,
692 },
giod0026612025-05-08 13:00:36 +0000693 });
giod0026612025-05-08 13:00:36 +0000694 resp.status(200);
695 } catch (e) {
696 console.log(e);
697 resp.status(500);
698 } finally {
699 resp.end();
700 }
701};
702
gio69ff7592025-07-03 06:27:21 +0000703const handleUpdateAnthropicToken: express.Handler = async (req, resp) => {
704 try {
705 await db.project.update({
706 where: {
707 id: Number(req.params["projectId"]),
708 userId: resp.locals.userId,
709 },
710 data: {
711 anthropicApiKey: req.body.anthropicApiKey,
712 },
713 });
714 resp.status(200);
715 } catch (e) {
716 console.log(e);
717 resp.status(500);
718 } finally {
719 resp.end();
720 }
721};
722
gioc31bf142025-06-16 07:48:20 +0000723const getNetworks = (username?: string | undefined): Network[] => {
724 return [
725 {
726 name: "Trial",
727 domain: "trial.dodoapp.xyz",
728 hasAuth: false,
729 },
730 // TODO(gio): Remove
731 ].concat(
732 username === "gio" || 1 == 1
733 ? [
734 {
735 name: "Public",
736 domain: "v1.dodo.cloud",
737 hasAuth: true,
738 },
739 {
740 name: "Private",
741 domain: "p.v1.dodo.cloud",
742 hasAuth: true,
743 },
744 ]
745 : [],
746 );
747};
748
749const getEnv = async (projectId: number, userId: string, username: string): Promise<Env> => {
750 const project = await db.project.findUnique({
751 where: {
752 id: projectId,
753 userId,
754 },
755 select: {
756 deployKeyPublic: true,
757 githubToken: true,
gio69148322025-06-19 23:16:12 +0400758 geminiApiKey: true,
gio69ff7592025-07-03 06:27:21 +0000759 anthropicApiKey: true,
gioc31bf142025-06-16 07:48:20 +0000760 access: true,
761 instanceId: true,
762 },
763 });
764 if (!project) {
765 throw new Error("Project not found");
766 }
767 const monitor = projectMonitors.get(projectId);
768 const serviceNames = monitor ? monitor.getAllServiceNames() : [];
769 const services = serviceNames.map((name: string) => ({
770 name,
771 workers: [...(monitor ? monitor.getWorkerStatusesForService(name) : new Map()).entries()].map(
772 ([id, status]) => ({
773 ...status,
774 id,
775 }),
776 ),
777 }));
778 return {
gioc31bf142025-06-16 07:48:20 +0000779 deployKeyPublic: project.deployKeyPublic == null ? undefined : project.deployKeyPublic,
780 instanceId: project.instanceId == null ? undefined : project.instanceId,
781 access: JSON.parse(project.access ?? "[]"),
782 integrations: {
783 github: !!project.githubToken,
gio69148322025-06-19 23:16:12 +0400784 gemini: !!project.geminiApiKey,
gio69ff7592025-07-03 06:27:21 +0000785 anthropic: !!project.anthropicApiKey,
gioc31bf142025-06-16 07:48:20 +0000786 },
787 networks: getNetworks(username),
788 services,
789 user: {
790 id: userId,
791 username: username,
792 },
793 };
794};
795
gio43e0aad2025-08-01 16:17:27 +0400796const handleMachines: express.Handler = async (req, resp) => {
797 try {
798 const machines = await machineManager.getUserMachines(resp.locals.username);
799 resp.status(200);
800 resp.write(JSON.stringify(machines));
801 } catch (error) {
802 console.error("Error getting machines:", error);
803 resp.status(500);
804 resp.write(JSON.stringify({ error: "Internal server error" }));
805 } finally {
806 resp.end();
807 }
808};
809
giod0026612025-05-08 13:00:36 +0000810const handleEnv: express.Handler = async (req, resp) => {
811 const projectId = Number(req.params["projectId"]);
812 try {
gioc31bf142025-06-16 07:48:20 +0000813 const env = await getEnv(projectId, resp.locals.userId, resp.locals.username);
giod0026612025-05-08 13:00:36 +0000814 resp.status(200);
gioc31bf142025-06-16 07:48:20 +0000815 resp.write(JSON.stringify(env));
giod0026612025-05-08 13:00:36 +0000816 } catch (error) {
gioc31bf142025-06-16 07:48:20 +0000817 console.error("Error getting env:", error);
giod0026612025-05-08 13:00:36 +0000818 resp.status(500);
819 resp.write(JSON.stringify({ error: "Internal server error" }));
820 } finally {
821 resp.end();
822 }
823};
824
gio007c8572025-07-08 04:27:35 +0000825// eslint-disable-next-line @typescript-eslint/no-unused-vars
826const internalEnvSchema = z.object({
827 githubToken: z.string().optional(),
828 networks: z.array(
829 z.object({
830 name: z.string(),
831 domain: z.string(),
832 hasAuth: z.boolean(),
833 }),
834 ),
gioe085d5b2025-07-08 07:51:36 +0000835 envVars: z.array(z.object({ name: z.string(), value: z.string() })),
gio007c8572025-07-08 04:27:35 +0000836});
837
838type InternalEnv = z.infer<typeof internalEnvSchema>;
839
840const handleInternalEnv: express.Handler = async (req, resp) => {
841 try {
842 console.log("getting internal env");
843 const project = await db.project.findUnique({
844 where: {
845 id: Number(req.params["projectId"]),
846 userId: resp.locals.userId,
847 },
848 select: {
849 githubToken: true,
gioe085d5b2025-07-08 07:51:36 +0000850 envVars: true,
gio007c8572025-07-08 04:27:35 +0000851 },
852 });
853 const networks = getNetworks(resp.locals.username);
854 const env: InternalEnv = {
855 networks,
856 githubToken: project?.githubToken ?? undefined,
gioe085d5b2025-07-08 07:51:36 +0000857 envVars: JSON.parse(project?.envVars ?? "[]"),
gio007c8572025-07-08 04:27:35 +0000858 };
859 resp.status(200);
860 resp.write(JSON.stringify(env));
861 } catch (error) {
862 console.error("Error getting env:", error);
863 resp.status(500);
864 resp.write(JSON.stringify({ error: "Internal server error" }));
865 } finally {
866 resp.end();
867 }
868};
869
gio3a921b82025-05-10 07:36:09 +0000870const handleServiceLogs: express.Handler = async (req, resp) => {
gio78a22882025-07-01 18:56:01 +0000871 const projectId = Number(req.params["projectId"]);
872 const service = req.params["service"];
873 const workerId = req.params["workerId"];
874
875 resp.setHeader("Content-Type", "text/event-stream");
876 resp.setHeader("Cache-Control", "no-cache");
877 resp.setHeader("Connection", "keep-alive");
878 resp.flushHeaders();
879
880 const timestampFormat = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
881 const sendLogs = (logs: LogItem[]) => {
882 if (logs.length == 0) {
883 return;
884 }
885 const logString = logs
886 .map((l) => {
887 const t = Instant.ofEpochMilli(l.timestampMilli);
888 const formattedTimestamp = t.atZone(ZoneId.UTC).format(timestampFormat);
889 return `\x1b[38;5;240m${formattedTimestamp}\x1b[0m ${l.contents}`;
890 })
891 .join("\n");
892 resp.write("event: message\n");
893 resp.write(`data: ${JSON.stringify({ logs: logString })}\n\n`);
894 };
895
gio3a921b82025-05-10 07:36:09 +0000896 try {
gio09fcab52025-05-12 14:05:07 +0000897 const project = await db.project.findUnique({
gio78a22882025-07-01 18:56:01 +0000898 where: { id: projectId, userId: resp.locals.userId },
gio09fcab52025-05-12 14:05:07 +0000899 });
gio78a22882025-07-01 18:56:01 +0000900
901 if (!project) {
902 resp.status(404).end();
gio09fcab52025-05-12 14:05:07 +0000903 return;
904 }
gio78a22882025-07-01 18:56:01 +0000905
gioa1efbad2025-05-21 07:16:45 +0000906 const monitor = projectMonitors.get(projectId);
gio78a22882025-07-01 18:56:01 +0000907 if (!monitor) {
908 resp.status(404).end();
gio3a921b82025-05-10 07:36:09 +0000909 return;
910 }
gio78a22882025-07-01 18:56:01 +0000911
gio40c0c992025-07-02 13:18:05 +0000912 let lastLogId: number | undefined = undefined;
913 const initialLogs = (await logStore.get(projectId, service, workerId)) || [];
gio8a5f12f2025-07-05 07:02:31 +0000914 await sendLogs(initialLogs);
gio40c0c992025-07-02 13:18:05 +0000915 if (initialLogs.length > 0) {
916 lastLogId = initialLogs[initialLogs.length - 1].id;
917 }
gio78a22882025-07-01 18:56:01 +0000918 resp.flushHeaders();
919
gio40c0c992025-07-02 13:18:05 +0000920 const intervalId = setInterval(async () => {
921 const currentLogs = (await logStore.get(projectId, service, workerId, lastLogId)) || [];
922 if (currentLogs.length > 0) {
gio8a5f12f2025-07-05 07:02:31 +0000923 await sendLogs(currentLogs);
gio40c0c992025-07-02 13:18:05 +0000924 lastLogId = currentLogs[currentLogs.length - 1].id;
gio78a22882025-07-01 18:56:01 +0000925 }
926 }, 500);
927
928 req.on("close", () => {
929 clearInterval(intervalId);
930 resp.end();
931 });
gio3a921b82025-05-10 07:36:09 +0000932 } catch (e) {
933 console.log(e);
gio78a22882025-07-01 18:56:01 +0000934 resp.status(500).end();
gio3a921b82025-05-10 07:36:09 +0000935 }
936};
937
gio7d813702025-05-08 18:29:52 +0000938const handleRegisterWorker: express.Handler = async (req, resp) => {
939 try {
940 const projectId = Number(req.params["projectId"]);
gio7d813702025-05-08 18:29:52 +0000941 const result = WorkerSchema.safeParse(req.body);
gio7d813702025-05-08 18:29:52 +0000942 if (!result.success) {
gioa70535a2025-07-02 15:50:25 +0000943 console.log(JSON.stringify(result.error));
gio7d813702025-05-08 18:29:52 +0000944 resp.status(400);
945 resp.write(
946 JSON.stringify({
947 error: "Invalid request data",
948 details: result.error.format(),
949 }),
950 );
951 return;
952 }
gioa1efbad2025-05-21 07:16:45 +0000953 let monitor = projectMonitors.get(projectId);
954 if (!monitor) {
955 monitor = new ProjectMonitor();
956 projectMonitors.set(projectId, monitor);
gio7d813702025-05-08 18:29:52 +0000957 }
gioa1efbad2025-05-21 07:16:45 +0000958 monitor.registerWorker(result.data);
gio40c0c992025-07-02 13:18:05 +0000959 if (result.data.logs) {
960 await logStore.store(projectId, result.data.service, result.data.id, result.data.logs);
961 }
gio7d813702025-05-08 18:29:52 +0000962 resp.status(200);
963 resp.write(
964 JSON.stringify({
965 success: true,
gio78a22882025-07-01 18:56:01 +0000966 logItemsConsumed: result.data.logs?.length ?? 0,
gio7d813702025-05-08 18:29:52 +0000967 }),
968 );
969 } catch (e) {
970 console.log(e);
971 resp.status(500);
972 resp.write(JSON.stringify({ error: "Failed to register worker" }));
973 } finally {
974 resp.end();
975 }
976};
977
gio76d8ae62025-05-19 15:21:54 +0000978async function reloadProject(projectId: number): Promise<boolean> {
gioa1efbad2025-05-21 07:16:45 +0000979 const monitor = projectMonitors.get(projectId);
980 const projectWorkers = monitor ? monitor.getWorkerAddresses() : [];
gio76d8ae62025-05-19 15:21:54 +0000981 const workerCount = projectWorkers.length;
982 if (workerCount === 0) {
983 return true;
984 }
985 const results = await Promise.all(
gioc31bf142025-06-16 07:48:20 +0000986 projectWorkers.map(async (workerAddress: string) => {
987 try {
gio45616792025-08-03 11:44:02 +0000988 const resp = await axios.get(`${workerAddress}/update`);
989 return resp.status === 200;
gioc31bf142025-06-16 07:48:20 +0000990 } catch (error) {
991 console.error(`Failed to reload worker ${workerAddress}:`, error);
992 return false;
993 }
gio76d8ae62025-05-19 15:21:54 +0000994 }),
995 );
gioc31bf142025-06-16 07:48:20 +0000996 return results.reduce((acc: boolean, curr: boolean) => acc && curr, true);
gio76d8ae62025-05-19 15:21:54 +0000997}
998
gio7d813702025-05-08 18:29:52 +0000999const handleReload: express.Handler = async (req, resp) => {
1000 try {
1001 const projectId = Number(req.params["projectId"]);
gio76d8ae62025-05-19 15:21:54 +00001002 const projectAuth = await db.project.findFirst({
gio09fcab52025-05-12 14:05:07 +00001003 where: {
1004 id: projectId,
1005 userId: resp.locals.userId,
1006 },
gio76d8ae62025-05-19 15:21:54 +00001007 select: { id: true },
gio09fcab52025-05-12 14:05:07 +00001008 });
gio76d8ae62025-05-19 15:21:54 +00001009 if (!projectAuth) {
gio09fcab52025-05-12 14:05:07 +00001010 resp.status(404);
gio09fcab52025-05-12 14:05:07 +00001011 return;
1012 }
gio76d8ae62025-05-19 15:21:54 +00001013 const success = await reloadProject(projectId);
1014 if (success) {
1015 resp.status(200);
1016 } else {
1017 resp.status(500);
gio7d813702025-05-08 18:29:52 +00001018 }
gio7d813702025-05-08 18:29:52 +00001019 } catch (e) {
gio76d8ae62025-05-19 15:21:54 +00001020 console.error(e);
gio7d813702025-05-08 18:29:52 +00001021 resp.status(500);
gio7d813702025-05-08 18:29:52 +00001022 }
1023};
1024
gio577d2342025-07-03 12:50:18 +00001025const handleQuitWorker: express.Handler = async (req, resp) => {
1026 const projectId = Number(req.params["projectId"]);
1027 const serviceName = req.params["serviceName"];
1028 const workerId = req.params["workerId"];
1029
1030 const projectMonitor = projectMonitors.get(projectId);
1031 if (!projectMonitor) {
1032 resp.status(404).send({ error: "Project monitor not found" });
1033 return;
1034 }
1035
1036 try {
1037 await projectMonitor.terminateWorker(serviceName, workerId);
1038 resp.status(200).send({ message: "Worker termination initiated" });
1039 } catch (error) {
1040 console.error(
1041 `Failed to terminate worker ${workerId} in service ${serviceName} for project ${projectId}:`,
1042 error,
1043 );
1044 const errorMessage = error instanceof Error ? error.message : "Unknown error";
1045 resp.status(500).send({ error: `Failed to terminate worker: ${errorMessage}` });
1046 }
1047};
1048
gio918780d2025-05-22 08:24:41 +00001049const handleReloadWorker: express.Handler = async (req, resp) => {
1050 const projectId = Number(req.params["projectId"]);
1051 const serviceName = req.params["serviceName"];
1052 const workerId = req.params["workerId"];
1053
1054 const projectMonitor = projectMonitors.get(projectId);
1055 if (!projectMonitor) {
1056 resp.status(404).send({ error: "Project monitor not found" });
1057 return;
1058 }
1059
1060 try {
1061 await projectMonitor.reloadWorker(serviceName, workerId);
1062 resp.status(200).send({ message: "Worker reload initiated" });
1063 } catch (error) {
1064 console.error(`Failed to reload worker ${workerId} in service ${serviceName} for project ${projectId}:`, error);
1065 const errorMessage = error instanceof Error ? error.message : "Unknown error";
1066 resp.status(500).send({ error: `Failed to reload worker: ${errorMessage}` });
1067 }
1068};
1069
gioa71316d2025-05-24 09:41:36 +04001070const analyzeRepoReqSchema = z.object({
1071 address: z.string(),
1072});
1073
1074const handleAnalyzeRepo: express.Handler = async (req, resp) => {
1075 const projectId = Number(req.params["projectId"]);
1076 const project = await db.project.findUnique({
1077 where: {
1078 id: projectId,
1079 userId: resp.locals.userId,
1080 },
1081 select: {
1082 githubToken: true,
1083 deployKey: true,
1084 deployKeyPublic: true,
1085 },
1086 });
1087 if (!project) {
1088 resp.status(404).send({ error: "Project not found" });
1089 return;
1090 }
1091 if (!project.githubToken) {
1092 resp.status(400).send({ error: "GitHub token not configured" });
1093 return;
1094 }
gio8e74dc02025-06-13 10:19:26 +00001095 let tmpDir: tmp.DirResult | null = null;
1096 try {
1097 let deployKey: string | null = project.deployKey;
1098 let deployKeyPublic: string | null = project.deployKeyPublic;
1099 if (!deployKeyPublic) {
1100 [deployKeyPublic, deployKey] = await generateKey(tmp.dirSync().name);
1101 await db.project.update({
1102 where: { id: projectId },
1103 data: {
1104 deployKeyPublic: deployKeyPublic,
1105 deployKey: deployKey,
1106 },
1107 });
1108 }
1109 const github = new GithubClient(project.githubToken);
1110 const result = analyzeRepoReqSchema.safeParse(req.body);
1111 if (!result.success) {
1112 resp.status(400).send({ error: "Invalid request data" });
1113 return;
1114 }
1115 const { address } = result.data;
1116 tmpDir = tmp.dirSync({
1117 unsafeCleanup: true,
gioa71316d2025-05-24 09:41:36 +04001118 });
gio8e74dc02025-06-13 10:19:26 +00001119 await github.addDeployKey(address, deployKeyPublic);
1120 await fs.promises.writeFile(path.join(tmpDir.name, "key"), deployKey!, {
1121 mode: 0o600,
1122 });
1123 shell.exec(
1124 `GIT_SSH_COMMAND='ssh -i ${tmpDir.name}/key -o IdentitiesOnly=yes' git clone ${address} ${tmpDir.name}/code`,
1125 );
1126 const fsc = new RealFileSystem(`${tmpDir.name}/code`);
1127 const analyzer = new NodeJSAnalyzer();
1128 const info = await analyzer.analyze(fsc, "/");
1129 resp.status(200).send([info]);
1130 } catch (e) {
1131 console.error(e);
1132 resp.status(500).send({ error: "Failed to analyze repository" });
1133 } finally {
1134 if (tmpDir) {
1135 tmpDir.removeCallback();
1136 }
1137 resp.end();
gioa71316d2025-05-24 09:41:36 +04001138 }
gioa71316d2025-05-24 09:41:36 +04001139};
1140
gio09fcab52025-05-12 14:05:07 +00001141const auth = (req: express.Request, resp: express.Response, next: express.NextFunction) => {
gio69148322025-06-19 23:16:12 +04001142 // Hardcoded user for development
1143 resp.locals.userId = "1";
1144 resp.locals.username = "gio";
gio09fcab52025-05-12 14:05:07 +00001145 next();
1146};
1147
gio76d8ae62025-05-19 15:21:54 +00001148const handleGithubPushWebhook: express.Handler = async (req, resp) => {
1149 try {
1150 // TODO(gio): Implement GitHub signature verification for security
1151 const webhookSchema = z.object({
1152 repository: z.object({
1153 ssh_url: z.string(),
1154 }),
1155 });
1156
1157 const result = webhookSchema.safeParse(req.body);
1158 if (!result.success) {
1159 console.warn("GitHub webhook: Invalid payload:", result.error.issues);
1160 resp.status(400).json({ error: "Invalid webhook payload" });
1161 return;
1162 }
1163 const { ssh_url: addr } = result.data.repository;
1164 const allProjects = await db.project.findMany({
1165 select: {
1166 id: true,
1167 state: true,
1168 },
1169 where: {
1170 instanceId: {
1171 not: null,
1172 },
1173 },
1174 });
gio76d8ae62025-05-19 15:21:54 +00001175 new Promise<boolean>((resolve, reject) => {
1176 setTimeout(() => {
1177 const projectsToReloadIds: number[] = [];
1178 for (const project of allProjects) {
1179 if (project.state && project.state.length > 0) {
1180 const projectRepos = extractGithubRepos(project.state);
1181 if (projectRepos.includes(addr)) {
1182 projectsToReloadIds.push(project.id);
1183 }
1184 }
1185 }
1186 Promise.all(projectsToReloadIds.map((id) => reloadProject(id)))
1187 .then((results) => {
1188 resolve(results.reduce((acc, curr) => acc && curr, true));
1189 })
1190 // eslint-disable-next-line @typescript-eslint/no-explicit-any
1191 .catch((reason: any) => reject(reason));
1192 }, 10);
1193 });
gio45616792025-08-03 11:44:02 +00001194 resp.status(200);
gio76d8ae62025-05-19 15:21:54 +00001195 // eslint-disable-next-line @typescript-eslint/no-explicit-any
1196 } catch (error: any) {
1197 console.error(error);
1198 resp.status(500);
gio45616792025-08-03 11:44:02 +00001199 } finally {
1200 resp.end();
gio76d8ae62025-05-19 15:21:54 +00001201 }
1202};
1203
gioc31bf142025-06-16 07:48:20 +00001204const handleValidateConfig: express.Handler = async (req, resp) => {
1205 try {
1206 const validationResult = ConfigSchema.safeParse(req.body);
1207 if (!validationResult.success) {
1208 resp.status(400);
1209 resp.header("Content-Type", "application/json");
1210 resp.write(JSON.stringify({ success: false, errors: validationResult.error.flatten() }));
1211 } else {
1212 resp.status(200);
1213 resp.header("Content-Type", "application/json");
1214 resp.write(JSON.stringify({ success: true }));
1215 }
1216 } catch (e) {
1217 console.log(e);
1218 resp.status(500);
1219 } finally {
1220 resp.end();
1221 }
1222};
1223
gio8a5f12f2025-07-05 07:02:31 +00001224function handleStateGetStream(state: "deploy" | "draft"): express.Handler {
1225 return async (req, resp) => {
1226 resp.setHeader("Content-Type", "text/event-stream");
1227 resp.setHeader("Cache-Control", "no-cache");
1228 resp.setHeader("Connection", "keep-alive");
1229 resp.flushHeaders();
1230
1231 try {
1232 let intervalId: NodeJS.Timeout | null = null;
1233 let lastState: Graph | null = null;
1234 const sendState = async () => {
1235 const currentState = await getState(Number(req.params["projectId"]), resp.locals.userId, state);
1236 if (currentState == null) {
1237 resp.status(404).end();
1238 return;
1239 }
1240 if (JSON.stringify(currentState) !== JSON.stringify(lastState)) {
1241 lastState = currentState;
1242 resp.write("event: message\n");
1243 resp.write(`data: ${JSON.stringify(currentState)}\n\n`);
1244 }
1245 intervalId = setTimeout(sendState, 500);
1246 };
1247
1248 await sendState();
1249
1250 req.on("close", () => {
1251 if (intervalId) {
1252 clearTimeout(intervalId);
1253 }
1254 resp.end();
1255 });
1256 } catch (e) {
1257 console.log(e);
1258 resp.end();
1259 }
1260 };
1261}
1262
gio785c9882025-07-07 16:40:25 +00001263const handleAgentStatus: express.Handler = async (req, resp) => {
1264 const projectId = Number(req.params["projectId"]);
1265 const agentName = req.params["agentName"];
1266 try {
1267 const env = await getEnv(projectId, resp.locals.userId, resp.locals.username);
1268 const agent = env.access.find((a): a is AgentAccess => a.type === "https" && a.agentName === agentName);
1269 if (!agent) {
1270 resp.status(404).send({ status: 404 });
1271 return;
1272 }
1273 const agentResp = await axios.get(agent.address);
1274 resp.status(200).send({ status: agentResp.status });
1275 } catch {
1276 resp.status(200).send({ status: 500 });
1277 }
1278};
1279
giod0026612025-05-08 13:00:36 +00001280async function start() {
1281 await db.$connect();
1282 const app = express();
gioc31bf142025-06-16 07:48:20 +00001283 app.set("json spaces", 2);
gio76d8ae62025-05-19 15:21:54 +00001284 app.use(express.json()); // Global JSON parsing
1285
1286 // Public webhook route - no auth needed
1287 app.post("/api/webhook/github/push", handleGithubPushWebhook);
1288
gio43e0aad2025-08-01 16:17:27 +04001289 // Public machines route - no auth needed
1290 app.get("/api/machines", auth, handleMachines);
1291
gio76d8ae62025-05-19 15:21:54 +00001292 // Authenticated project routes
1293 const projectRouter = express.Router();
gioa71316d2025-05-24 09:41:36 +04001294 projectRouter.use(auth);
1295 projectRouter.post("/:projectId/analyze", handleAnalyzeRepo);
gio76d8ae62025-05-19 15:21:54 +00001296 projectRouter.post("/:projectId/saved", handleSave);
gio8a5f12f2025-07-05 07:02:31 +00001297 projectRouter.get("/:projectId/state/stream/deploy", handleStateGetStream("deploy"));
1298 projectRouter.get("/:projectId/state/stream/draft", handleStateGetStream("draft"));
gio76d8ae62025-05-19 15:21:54 +00001299 projectRouter.get("/:projectId/saved/deploy", handleSavedGet("deploy"));
1300 projectRouter.get("/:projectId/saved/draft", handleSavedGet("draft"));
1301 projectRouter.post("/:projectId/deploy", handleDeploy);
1302 projectRouter.get("/:projectId/status", handleStatus);
gioc31bf142025-06-16 07:48:20 +00001303 projectRouter.get("/:projectId/config", handleConfigGet);
gioa71316d2025-05-24 09:41:36 +04001304 projectRouter.delete("/:projectId", handleProjectDelete);
gio76d8ae62025-05-19 15:21:54 +00001305 projectRouter.get("/:projectId/repos/github", handleGithubRepos);
1306 projectRouter.post("/:projectId/github-token", handleUpdateGithubToken);
gio69148322025-06-19 23:16:12 +04001307 projectRouter.post("/:projectId/gemini-token", handleUpdateGeminiToken);
gio69ff7592025-07-03 06:27:21 +00001308 projectRouter.post("/:projectId/anthropic-token", handleUpdateAnthropicToken);
gio76d8ae62025-05-19 15:21:54 +00001309 projectRouter.get("/:projectId/env", handleEnv);
gio918780d2025-05-22 08:24:41 +00001310 projectRouter.post("/:projectId/reload/:serviceName/:workerId", handleReloadWorker);
gio577d2342025-07-03 12:50:18 +00001311 projectRouter.post("/:projectId/quitquitquit/:serviceName/:workerId", handleQuitWorker);
gio76d8ae62025-05-19 15:21:54 +00001312 projectRouter.post("/:projectId/reload", handleReload);
gioa1efbad2025-05-21 07:16:45 +00001313 projectRouter.get("/:projectId/logs/:service/:workerId", handleServiceLogs);
gio785c9882025-07-07 16:40:25 +00001314 projectRouter.get("/:projectId/agent/:agentName/status", handleAgentStatus);
gio76d8ae62025-05-19 15:21:54 +00001315 projectRouter.post("/:projectId/remove-deployment", handleRemoveDeployment);
1316 projectRouter.get("/", handleProjectAll);
1317 projectRouter.post("/", handleProjectCreate);
gio918780d2025-05-22 08:24:41 +00001318
gio76d8ae62025-05-19 15:21:54 +00001319 app.use("/api/project", projectRouter); // Mount the authenticated router
1320
giod0026612025-05-08 13:00:36 +00001321 app.use("/", express.static("../front/dist"));
gio09fcab52025-05-12 14:05:07 +00001322
gio76d8ae62025-05-19 15:21:54 +00001323 const internalApi = express();
1324 internalApi.use(express.json());
1325 internalApi.post("/api/project/:projectId/workers", handleRegisterWorker);
gioc31bf142025-06-16 07:48:20 +00001326 internalApi.get("/api/project/:projectId/config", handleConfigGet);
gio10ff1342025-07-05 10:22:15 +00001327 internalApi.post("/api/project/:projectId/saved", handleSave);
gioc31bf142025-06-16 07:48:20 +00001328 internalApi.post("/api/project/:projectId/deploy", handleDeploy);
gio007c8572025-07-08 04:27:35 +00001329 internalApi.get("/api/project/:projectId/env", handleInternalEnv);
gioc31bf142025-06-16 07:48:20 +00001330 internalApi.post("/api/validate-config", handleValidateConfig);
gio09fcab52025-05-12 14:05:07 +00001331
giod0026612025-05-08 13:00:36 +00001332 app.listen(env.DODO_PORT_WEB, () => {
gio09fcab52025-05-12 14:05:07 +00001333 console.log("Web server started on port", env.DODO_PORT_WEB);
1334 });
1335
gio76d8ae62025-05-19 15:21:54 +00001336 internalApi.listen(env.DODO_PORT_API, () => {
gio09fcab52025-05-12 14:05:07 +00001337 console.log("Internal API server started on port", env.DODO_PORT_API);
giod0026612025-05-08 13:00:36 +00001338 });
1339}
1340
gio166d9922025-07-07 17:30:21 +00001341function cleanupWorkers() {
1342 const now = Date.now();
1343 projectMonitors.forEach((monitor) => {
1344 monitor.cleanupWorkers(now);
1345 });
1346 setTimeout(cleanupWorkers, 1000);
1347}
1348
1349setTimeout(cleanupWorkers, 1000);
1350
giod0026612025-05-08 13:00:36 +00001351start();