deepdebug-local-agent 1.0.19 → 1.0.21

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/server.js CHANGED
@@ -5,7 +5,7 @@ import path from "path";
5
5
  import os from "os";
6
6
  import fs from "fs";
7
7
  const fsPromises = fs.promises;
8
- import { exec } from "child_process";
8
+ import { exec, spawn } from "child_process";
9
9
  import { promisify } from "util";
10
10
  import { EventEmitter } from "events";
11
11
  import { exists, listRecursive, readFile, writeFile } from "./fs-utils.js";
@@ -24,6 +24,7 @@ import { DTOAnalyzer } from "./analyzers/dto-analyzer.js";
24
24
  import { ConfigAnalyzer } from "./analyzers/config-analyzer.js";
25
25
  import { WorkspaceManager } from "./workspace-manager.js";
26
26
  import { startMCPHttpServer } from "./mcp-http-server.js";
27
+ import { registerVercelRoutes } from "./vercel-proxy.js";
27
28
 
28
29
  const execAsync = promisify(exec);
29
30
 
@@ -545,24 +546,49 @@ const app = express();
545
546
  // FIXED: Support Cloud Run PORT environment variable (GCP uses PORT)
546
547
  const PORT = process.env.PORT || process.env.LOCAL_AGENT_PORT || 5055;
547
548
 
548
- // FIXED: Allow CORS from Cloud Run and local development
549
+ // CORS - Allow from Cloud Run, local dev, and Lovable frontend domains
549
550
  app.use(cors({
550
- origin: [
551
- "http://localhost:3010",
552
- "http://localhost:3000",
553
- "http://localhost:8085",
554
- "http://127.0.0.1:3010",
555
- "http://127.0.0.1:3000",
556
- "http://127.0.0.1:8085",
557
- // Cloud Run URLs (regex patterns)
558
- /https:\/\/.*\.run\.app$/,
559
- /https:\/\/.*\.web\.app$/,
560
- // Production frontend
561
- "https://deepdebug.ai",
562
- "https://www.deepdebug.ai"
563
- ],
551
+ origin: function(origin, callback) {
552
+ // Allow requests with no origin (curl, mobile apps, server-to-server)
553
+ if (!origin) return callback(null, true);
554
+
555
+ const allowed = [
556
+ // Local development
557
+ "http://localhost:3010",
558
+ "http://localhost:3000",
559
+ "http://localhost:8085",
560
+ "http://127.0.0.1:3010",
561
+ "http://127.0.0.1:3000",
562
+ "http://127.0.0.1:8085",
563
+ ];
564
+
565
+ const allowedPatterns = [
566
+ /https:\/\/.*\.run\.app$/, // Cloud Run
567
+ /https:\/\/.*\.web\.app$/, // Firebase hosting
568
+ /https:\/\/.*\.lovable\.app$/, // Lovable preview
569
+ /https:\/\/.*\.lovableproject\.com$/, // Lovable project domains
570
+ /https:\/\/.*\.netlify\.app$/, // Netlify
571
+ /https:\/\/.*\.vercel\.app$/, // Vercel
572
+ /https:\/\/deepdebug\.ai$/, // Production
573
+ /https:\/\/.*\.deepdebug\.ai$/, // Production subdomains
574
+ ];
575
+
576
+ if (allowed.includes(origin) || allowedPatterns.some(p => p.test(origin))) {
577
+ return callback(null, true);
578
+ }
579
+
580
+ // In development/QA, log and allow unknown origins instead of blocking
581
+ const env = process.env.NODE_ENV || 'development';
582
+ if (env !== 'production') {
583
+ console.warn(`CORS: allowing unknown origin in non-prod: ${origin}`);
584
+ return callback(null, true);
585
+ }
586
+
587
+ console.warn(`CORS: blocked origin: ${origin}`);
588
+ return callback(new Error(`CORS: origin ${origin} not allowed`));
589
+ },
564
590
  methods: ["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"],
565
- allowedHeaders: ["Content-Type", "Authorization", "X-Tenant-ID"],
591
+ allowedHeaders: ["Content-Type", "Authorization", "X-Tenant-ID", "X-User-ID"],
566
592
  credentials: true
567
593
  }));
568
594
 
@@ -573,7 +599,9 @@ app.use(bodyParser.json({ limit: "50mb" }));
573
599
 
574
600
  // DEFAULT WORKSPACE - Define o workspace padro
575
601
  // Pode ser sobrescrito via varivel de ambiente ou POST /workspace/open
576
- const DEFAULT_WORKSPACE = process.env.DEFAULT_WORKSPACE || '/Users/macintosh/IdeaProjects/pure-core-ms';
602
+ const DEFAULT_WORKSPACE = process.env.WORKSPACE_ROOT
603
+ || process.env.DEFAULT_WORKSPACE
604
+ || '/Users/macintosh/IdeaProjects/pure-core-ms';
577
605
 
578
606
  let WORKSPACE_ROOT = fs.existsSync(DEFAULT_WORKSPACE) ? DEFAULT_WORKSPACE : null;
579
607
  if (WORKSPACE_ROOT) {
@@ -622,7 +650,19 @@ aiEngine = new AIVibeCodingEngine(processManager, () => WORKSPACE_ROOT);
622
650
  // ============================================
623
651
  const BACKUPS = new Map();
624
652
  const MAX_BACKUPS = 50;
625
- const BACKUP_INDEX_PATH = path.join(os.tmpdir(), 'deepdebug-backups-index.json');
653
+ // Store backup index on NFS Filestore so it survives container restarts
654
+ // Falls back to /tmp if NFS is not mounted (local dev)
655
+ const NFS_BACKUPS_DIR = process.env.WORKSPACE_MOUNT || '/mnt/workspaces';
656
+ const NFS_BACKUP_INDEX = path.join(NFS_BACKUPS_DIR, '.deepdebug-backups-index.json');
657
+ const BACKUP_INDEX_PATH = (() => {
658
+ try {
659
+ if (fs.existsSync(NFS_BACKUPS_DIR)) {
660
+ return NFS_BACKUP_INDEX;
661
+ }
662
+ } catch {}
663
+ return path.join(os.tmpdir(), 'deepdebug-backups-index.json');
664
+ })();
665
+ console.log(`[BACKUPS] Index path: ${BACKUP_INDEX_PATH}`);
626
666
 
627
667
  /**
628
668
  * Persist backup index to disk so diffs survive server restarts.
@@ -884,11 +924,15 @@ app.post("/workspace/clone", async (req, res) => {
884
924
  const tenantId = body.tenantId;
885
925
  const workspaceId = body.workspaceId;
886
926
  const gitToken = body.gitToken;
887
- const branch = body.branch;
927
+ // Accept gitBranch (onboarding frontend) OR branch (legacy)
928
+ const branch = body.branch || body.gitBranch;
929
+ // Accept gitProvider (onboarding frontend) for auth URL construction
930
+ const gitProvider = body.gitProvider || (gitUrl && gitUrl.includes('bitbucket') ? 'bitbucket' : gitUrl && gitUrl.includes('gitlab') ? 'gitlab' : 'github');
888
931
 
889
932
  if (!gitUrl) return res.status(400).json({ ok: false, error: "gitUrl is required" });
890
933
 
891
- const repoName = gitUrl.split('/').pop().replace('.git', '');
934
+ // Strip ALL trailing .git suffixes (handles accidental double .git.git from frontend)
935
+ const repoName = gitUrl.split('/').pop().replace(/\.git$/i, '').replace(/\.git$/i, '');
892
936
 
893
937
  const NFS_MOUNT = '/mnt/workspaces';
894
938
  let absTarget;
@@ -903,17 +947,22 @@ app.post("/workspace/clone", async (req, res) => {
903
947
  console.log(`[clone] Using fallback path: ${absTarget}`);
904
948
  }
905
949
 
906
- let authenticatedUrl = gitUrl;
907
- if (gitToken && gitUrl.startsWith('https://') && !gitUrl.includes('@')) {
908
- if (gitUrl.includes('bitbucket.org')) {
909
- authenticatedUrl = gitUrl.replace('https://', `https://x-token-auth:${gitToken}@`);
910
- } else if (gitUrl.includes('gitlab.com')) {
911
- authenticatedUrl = gitUrl.replace('https://', `https://oauth2:${gitToken}@`);
950
+ // Clean the gitUrl before using it (strip accidental double .git suffix)
951
+ const cleanGitUrl = gitUrl.replace(/\.git\.git$/i, '.git');
952
+
953
+ let authenticatedUrl = cleanGitUrl;
954
+ if (gitToken && cleanGitUrl.startsWith('https://') && !cleanGitUrl.includes('@')) {
955
+ const providerLower = (gitProvider || '').toLowerCase();
956
+ if (providerLower === 'bitbucket' || cleanGitUrl.includes('bitbucket.org')) {
957
+ authenticatedUrl = cleanGitUrl.replace('https://', `https://x-token-auth:${gitToken}@`);
958
+ } else if (providerLower === 'gitlab' || cleanGitUrl.includes('gitlab.com')) {
959
+ authenticatedUrl = cleanGitUrl.replace('https://', `https://oauth2:${gitToken}@`);
912
960
  } else {
913
- authenticatedUrl = gitUrl.replace('https://', `https://x-access-token:${gitToken}@`);
961
+ // github or any other provider
962
+ authenticatedUrl = cleanGitUrl.replace('https://', `https://x-access-token:${gitToken}@`);
914
963
  }
915
964
  }
916
- console.log(`Clone request: ${gitUrl} -> ${absTarget}`);
965
+ console.log(`Clone request: ${cleanGitUrl} -> ${absTarget}`);
917
966
 
918
967
  try {
919
968
  // Ensure parent directory exists
@@ -1047,9 +1096,386 @@ app.get("/workspace/file-content", async (req, res) => {
1047
1096
  }
1048
1097
  });
1049
1098
 
1099
+ /**
1100
+ * POST /workspace/build-push
1101
+ * Compress workspace, upload to GCS, submit Cloud Build.
1102
+ * No Docker daemon needed — uses Cloud Build API + GCS.
1103
+ * Body: { workspaceId, imageName, gcpProject, saKeyJson }
1104
+ */
1105
+ app.post("/workspace/build-push", async (req, res) => {
1106
+ const { workspaceId, imageName, gcpProject, saKeyJson } = req.body || {};
1107
+ if (!workspaceId || !imageName || !gcpProject || !saKeyJson) {
1108
+ return res.status(400).json({ error: "workspaceId, imageName, gcpProject and saKeyJson required" });
1109
+ }
1110
+
1111
+ res.setHeader("Content-Type", "text/event-stream");
1112
+ res.setHeader("Cache-Control", "no-cache");
1113
+ res.setHeader("Connection", "keep-alive");
1114
+ res.setHeader("X-Accel-Buffering", "no");
1115
+
1116
+ const emit = (event, msg) => {
1117
+ const data = typeof msg === "object" ? JSON.stringify(msg) : JSON.stringify({ message: msg });
1118
+ res.write(`event:${event}\ndata:${data}\n\n`);
1119
+ };
1120
+
1121
+ try {
1122
+ const nfsBase = process.env.WORKSPACES_MOUNT || "/mnt/workspaces";
1123
+ const wsPath = path.join(nfsBase, workspaceId);
1124
+
1125
+ if (!fs.existsSync(wsPath)) {
1126
+ emit("error", { error: "Workspace not found: " + wsPath });
1127
+ return res.end();
1128
+ }
1129
+ if (!fs.existsSync(path.join(wsPath, "Dockerfile"))) {
1130
+ emit("error", { error: "No Dockerfile in workspace. Run detect+generate first." });
1131
+ return res.end();
1132
+ }
1133
+
1134
+ // Step 1: Get GCP access token from SA key
1135
+ emit("progress", "Authenticating with GCP...");
1136
+ let accessToken;
1137
+ try {
1138
+ const { createSign } = await import("crypto");
1139
+ const sa = JSON.parse(saKeyJson);
1140
+ const now = Math.floor(Date.now() / 1000);
1141
+ const h = Buffer.from(JSON.stringify({ alg: "RS256", typ: "JWT" })).toString("base64url");
1142
+ const c = Buffer.from(JSON.stringify({
1143
+ iss: sa.client_email,
1144
+ scope: "https://www.googleapis.com/auth/cloud-platform",
1145
+ aud: "https://oauth2.googleapis.com/token",
1146
+ iat: now, exp: now + 3600
1147
+ })).toString("base64url");
1148
+ const sign = createSign("SHA256");
1149
+ sign.update(`${h}.${c}`);
1150
+ const sig = sign.sign(sa.private_key, "base64url");
1151
+ const jwt = `${h}.${c}.${sig}`;
1152
+
1153
+ const tokenResp = await fetch("https://oauth2.googleapis.com/token", {
1154
+ method: "POST",
1155
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
1156
+ body: `grant_type=urn:ietf:params:oauth:grant-type:jwt-bearer&assertion=${jwt}`
1157
+ });
1158
+ const tokenData = await tokenResp.json();
1159
+ accessToken = tokenData.access_token;
1160
+ if (!accessToken) throw new Error("No access_token: " + JSON.stringify(tokenData));
1161
+ emit("progress", "GCP authentication successful");
1162
+ } catch (e) {
1163
+ emit("error", { error: "GCP auth failed: " + e.message });
1164
+ return res.end();
1165
+ }
1166
+
1167
+ // Step 2: Compress workspace to tar.gz
1168
+ emit("progress", "Compressing source code...");
1169
+ const { execSync } = await import("child_process");
1170
+ const tarPath = `/tmp/source-${workspaceId}-${Date.now()}.tar.gz`;
1171
+ try {
1172
+ execSync(
1173
+ `tar -czf "${tarPath}" --exclude=".git" --exclude="node_modules" ` +
1174
+ `--exclude="target" --exclude="*.class" --exclude=".gradle" ` +
1175
+ `-C "${wsPath}" . 2>&1`,
1176
+ { stdio: "pipe", timeout: 120000 }
1177
+ );
1178
+ const sizeKB = Math.round(fs.statSync(tarPath).size / 1024);
1179
+ emit("progress", `Source compressed: ${sizeKB}KB`);
1180
+ } catch (e) {
1181
+ emit("error", { error: "Compression failed: " + (e.message || "").substring(0, 200) });
1182
+ return res.end();
1183
+ }
1184
+
1185
+ // Step 3: Upload to GCS
1186
+ const bucketName = `${gcpProject}_cloudbuild`;
1187
+ const objectName = `source/${workspaceId}-${Date.now()}.tar.gz`;
1188
+ emit("progress", `Uploading source to GCS...`);
1189
+ try {
1190
+ // Create bucket (ignore if exists)
1191
+ await fetch(`https://storage.googleapis.com/storage/v1/b?project=${gcpProject}`, {
1192
+ method: "POST",
1193
+ headers: { "Authorization": `Bearer ${accessToken}`, "Content-Type": "application/json" },
1194
+ body: JSON.stringify({ name: bucketName, location: "US" })
1195
+ });
1196
+
1197
+ const fileBuffer = fs.readFileSync(tarPath);
1198
+ const uploadResp = await fetch(
1199
+ `https://storage.googleapis.com/upload/storage/v1/b/${encodeURIComponent(bucketName)}/o?uploadType=media&name=${encodeURIComponent(objectName)}`,
1200
+ {
1201
+ method: "POST",
1202
+ headers: { "Authorization": `Bearer ${accessToken}`, "Content-Type": "application/gzip" },
1203
+ body: fileBuffer
1204
+ }
1205
+ );
1206
+ if (!uploadResp.ok) {
1207
+ const errText = await uploadResp.text();
1208
+ throw new Error(`GCS upload ${uploadResp.status}: ${errText.substring(0, 200)}`);
1209
+ }
1210
+ emit("progress", "Source uploaded to GCS");
1211
+ } catch (e) {
1212
+ emit("error", { error: "GCS upload failed: " + e.message });
1213
+ return res.end();
1214
+ } finally {
1215
+ try { fs.unlinkSync(tarPath); } catch {}
1216
+ }
1217
+
1218
+ // Step 4: Submit Cloud Build
1219
+ emit("progress", "Submitting Cloud Build...");
1220
+ let buildId = "unknown";
1221
+ try {
1222
+ const buildReq = {
1223
+ source: { storageSource: { bucket: bucketName, object: objectName } },
1224
+ steps: [
1225
+ { name: "gcr.io/cloud-builders/docker", args: ["build", "-t", imageName, "."], id: "build" },
1226
+ { name: "gcr.io/cloud-builders/docker", args: ["push", imageName], id: "push", waitFor: ["build"] }
1227
+ ],
1228
+ images: [imageName],
1229
+ timeout: "1200s"
1230
+ };
1231
+
1232
+ const buildResp = await fetch(
1233
+ `https://cloudbuild.googleapis.com/v1/projects/${gcpProject}/builds`,
1234
+ {
1235
+ method: "POST",
1236
+ headers: { "Authorization": `Bearer ${accessToken}`, "Content-Type": "application/json" },
1237
+ body: JSON.stringify(buildReq)
1238
+ }
1239
+ );
1240
+ if (!buildResp.ok) {
1241
+ const errText = await buildResp.text();
1242
+ throw new Error(`Cloud Build ${buildResp.status}: ${errText.substring(0, 300)}`);
1243
+ }
1244
+ const buildData = await buildResp.json();
1245
+ buildId = buildData.metadata?.build?.id || buildData.name?.split("/").pop() || "unknown";
1246
+ emit("progress", `Cloud Build started: ${buildId}`);
1247
+ } catch (e) {
1248
+ emit("error", { error: "Cloud Build submit failed: " + e.message });
1249
+ return res.end();
1250
+ }
1251
+
1252
+ // Step 5: Poll build status
1253
+ emit("progress", "Building Docker image (3-5 minutes)...");
1254
+ let status = "WORKING";
1255
+ let attempts = 0;
1256
+ while (!["SUCCESS", "FAILURE", "CANCELLED", "TIMEOUT"].includes(status) && attempts < 40) {
1257
+ await new Promise(r => setTimeout(r, 15000));
1258
+ attempts++;
1259
+ try {
1260
+ const statusResp = await fetch(
1261
+ `https://cloudbuild.googleapis.com/v1/projects/${gcpProject}/builds/${buildId}`,
1262
+ { headers: { "Authorization": `Bearer ${accessToken}` } }
1263
+ );
1264
+ const statusData = await statusResp.json();
1265
+ status = statusData.status || "WORKING";
1266
+ emit("progress", `Build status: ${status} (${attempts}/40)`);
1267
+ } catch (e) {
1268
+ emit("progress", `Status check error: ${e.message}`);
1269
+ }
1270
+ }
1271
+
1272
+ if (status === "SUCCESS") {
1273
+ emit("progress", "Docker image built and pushed successfully!");
1274
+ emit("complete", { success: true, imageName, buildId });
1275
+ } else {
1276
+ emit("error", { error: `Cloud Build ${status}. Check GCP Console.` });
1277
+ }
1278
+ res.end();
1279
+
1280
+ } catch (err) {
1281
+ console.error("[build-push] Fatal:", err.message);
1282
+ emit("error", { error: err.message });
1283
+ res.end();
1284
+ }
1285
+ });
1286
+
1287
+ /**
1288
+ * POST /workspace/setup
1289
+ * AI Cloud Engineering Agent - autonomous setup:
1290
+ * 1. Clone repo 2. Detect stack 3. Generate Dockerfile 4. Register workspace
1291
+ * Returns SSE stream with real-time progress.
1292
+ */
1293
+ app.post("/workspace/setup", async (req, res) => {
1294
+ const { workspaceId, repoUrl, gitToken, provider = "deepdebug_cloud",
1295
+ cloudProjectId, cloudToken } = req.body || {};
1296
+
1297
+ if (!workspaceId || !repoUrl) {
1298
+ return res.status(400).json({ error: "workspaceId and repoUrl required" });
1299
+ }
1300
+
1301
+ res.setHeader("Content-Type", "text/event-stream");
1302
+ res.setHeader("Cache-Control", "no-cache");
1303
+ res.setHeader("Connection", "keep-alive");
1304
+ res.setHeader("X-Accel-Buffering", "no");
1305
+
1306
+ const emit = (event, msg) => {
1307
+ const data = typeof msg === "object" ? JSON.stringify(msg) : JSON.stringify({ message: msg });
1308
+ res.write(`event:${event}\ndata:${data}\n\n`);
1309
+ };
1310
+
1311
+ try {
1312
+ // Use /mnt/workspaces as base (not WORKSPACE_ROOT which is a specific workspace)
1313
+ const nfsBase = process.env.WORKSPACES_MOUNT || "/mnt/workspaces";
1314
+ const wsPath = path.join(nfsBase, workspaceId);
1315
+
1316
+ emit("progress", `Starting setup for workspace: ${workspaceId}`);
1317
+ emit("progress", `Workspace path: ${wsPath}`);
1318
+
1319
+ // STEP 1 - Clone
1320
+ emit("progress", "Cloning repository...");
1321
+ let authUrl = repoUrl;
1322
+ if (gitToken && gitToken.length > 5) {
1323
+ // Build authenticated URL
1324
+ try {
1325
+ const urlObj = new URL(repoUrl);
1326
+ urlObj.username = "x-access-token";
1327
+ urlObj.password = gitToken;
1328
+ authUrl = urlObj.toString();
1329
+ emit("progress", `Using authenticated clone for ${urlObj.hostname}`);
1330
+ } catch {
1331
+ authUrl = repoUrl.replace("https://", `https://x-access-token:${gitToken}@`);
1332
+ }
1333
+ }
1334
+
1335
+ const { execSync } = await import("child_process");
1336
+ const cloneEnv = { ...process.env, GIT_TERMINAL_PROMPT: "0", HOME: process.env.HOME || "/tmp" };
1337
+ const execOpts = { stdio: "pipe", timeout: 300000, encoding: "utf8", env: cloneEnv };
1338
+ let cloneBranch = "main";
1339
+
1340
+ // Configure safe directory
1341
+ try { execSync(`git config --global --add safe.directory "${wsPath}"`, execOpts); } catch {}
1342
+
1343
+ if (fs.existsSync(path.join(wsPath, ".git"))) {
1344
+ emit("progress", "Workspace exists, updating...");
1345
+ try {
1346
+ if (gitToken && gitToken.length > 5) {
1347
+ execSync(`git remote set-url origin "${authUrl}"`, { ...execOpts, cwd: wsPath });
1348
+ }
1349
+ execSync("git fetch origin", { ...execOpts, cwd: wsPath });
1350
+ cloneBranch = execSync("git branch --show-current", { ...execOpts, cwd: wsPath }).trim() || "main";
1351
+ execSync(`git reset --hard origin/${cloneBranch}`, { ...execOpts, cwd: wsPath });
1352
+ } catch (e) {
1353
+ const safeMsg = gitToken ? e.message.replace(gitToken, "***") : e.message;
1354
+ emit("progress", `Update failed (${safeMsg.substring(0, 100)}), re-cloning...`);
1355
+ fs.rmSync(wsPath, { recursive: true, force: true });
1356
+ }
1357
+ }
1358
+
1359
+ if (!fs.existsSync(path.join(wsPath, ".git"))) {
1360
+ // Remove existing empty dir before clone (git clone needs non-existent or empty target)
1361
+ if (fs.existsSync(wsPath)) {
1362
+ const contents = fs.readdirSync(wsPath);
1363
+ if (contents.length === 0) fs.rmSync(wsPath, { recursive: true });
1364
+ }
1365
+
1366
+ for (const b of ["main", "master", "develop"]) {
1367
+ try {
1368
+ emit("progress", `Trying branch: ${b}...`);
1369
+ execSync(`git clone --depth=1 --branch ${b} "${authUrl}" "${wsPath}"`, execOpts);
1370
+ cloneBranch = b;
1371
+ emit("progress", `Cloned on branch: ${b}`);
1372
+ break;
1373
+ } catch (e) {
1374
+ const stderr = (e.stderr || e.stdout || e.message || "").toString();
1375
+ const safeMsg = gitToken ? stderr.replace(new RegExp(gitToken, 'g'), "***") : stderr;
1376
+ console.warn(`[Clone] Branch ${b} failed: ${safeMsg.substring(0, 500)}`);
1377
+ emit("progress", `Branch ${b} failed: ${safeMsg.substring(0, 200)}`);
1378
+ // Clean up partial clone
1379
+ if (fs.existsSync(wsPath)) fs.rmSync(wsPath, { recursive: true, force: true });
1380
+ }
1381
+ }
1382
+
1383
+ // Fallback: clone without specifying branch
1384
+ if (!fs.existsSync(path.join(wsPath, ".git"))) {
1385
+ try {
1386
+ emit("progress", "Trying default branch...");
1387
+ execSync(`git clone --depth=1 "${authUrl}" "${wsPath}" 2>&1`, execOpts);
1388
+ cloneBranch = execSync("git branch --show-current", { ...execOpts, cwd: wsPath }).trim() || "main";
1389
+ emit("progress", `Cloned on default branch: ${cloneBranch}`);
1390
+ } catch (e) {
1391
+ const stderr = (e.stderr || e.stdout || e.message || "").toString();
1392
+ const safeMsg = gitToken ? stderr.replace(new RegExp(gitToken, 'g'), "***") : stderr;
1393
+ console.error(`[Clone] Default branch failed: ${safeMsg.substring(0, 500)}`);
1394
+ emit("progress", `Default branch failed: ${safeMsg.substring(0, 200)}`);
1395
+ if (fs.existsSync(wsPath)) fs.rmSync(wsPath, { recursive: true, force: true });
1396
+ }
1397
+ }
1398
+ }
1399
+
1400
+ if (!fs.existsSync(path.join(wsPath, ".git"))) {
1401
+ emit("error", { error: "Clone failed for all branches. Check repo URL and permissions." });
1402
+ return res.end();
1403
+ }
1404
+ emit("progress", `Repository cloned on branch: ${cloneBranch}`);
1405
+
1406
+ // STEP 2 - Detect stack
1407
+ emit("progress", "AI analysing project structure...");
1408
+ const meta = await detectProject(wsPath);
1409
+ emit("progress", `Stack: ${meta.language} / ${meta.buildTool} / ${meta.framework || "unknown"}`);
1410
+
1411
+ // STEP 3 - Generate Dockerfile if missing
1412
+ const dockerfilePath = path.join(wsPath, "Dockerfile");
1413
+ if (!fs.existsSync(dockerfilePath)) {
1414
+ emit("progress", `Generating Dockerfile for ${meta.language}...`);
1415
+ const content = generateDockerfileContent(meta.language, meta.buildTool);
1416
+ fs.writeFileSync(dockerfilePath, content, "utf8");
1417
+ emit("progress", "Dockerfile generated");
1418
+ } else {
1419
+ emit("progress", "Dockerfile already exists");
1420
+ }
1421
+
1422
+ // STEP 4 - Register
1423
+ emit("progress", `Provider: ${provider}. Workspace ready.`);
1424
+
1425
+ emit("progress", "AI Cloud Engineering setup complete!");
1426
+ emit("complete", {
1427
+ success: true, workspaceId, wsPath, provider, branch: cloneBranch,
1428
+ stack: { language: meta.language, buildTool: meta.buildTool, framework: meta.framework },
1429
+ message: "Setup complete. Monitoring is now active."
1430
+ });
1431
+ res.end();
1432
+
1433
+ } catch (err) {
1434
+ console.error("[Setup] Error:", err.message);
1435
+ emit("error", { error: err.message });
1436
+ res.end();
1437
+ }
1438
+ });
1439
+
1440
+ function generateDockerfileContent(language, buildTool) {
1441
+ if (language === "java" && (buildTool === "maven" || buildTool === "mvn"))
1442
+ return `FROM maven:3.9-eclipse-temurin-17 AS build\nWORKDIR /app\nCOPY pom.xml .\nRUN mvn dependency:go-offline -B\nCOPY src ./src\nRUN mvn clean package -DskipTests -B\nFROM eclipse-temurin:17-jre-jammy\nWORKDIR /app\nCOPY --from=build /app/target/*.jar app.jar\nEXPOSE 8080\nENTRYPOINT ["java", "-jar", "app.jar"]\n`;
1443
+ if (language === "java")
1444
+ return `FROM gradle:8-jdk17 AS build\nWORKDIR /app\nCOPY . .\nRUN gradle build -x test\nFROM eclipse-temurin:17-jre-jammy\nWORKDIR /app\nCOPY --from=build /app/build/libs/*.jar app.jar\nEXPOSE 8080\nENTRYPOINT ["java", "-jar", "app.jar"]\n`;
1445
+ if (language === "node" || language === "javascript" || language === "typescript")
1446
+ return `FROM node:20-alpine\nWORKDIR /app\nCOPY package*.json ./\nRUN npm ci --only=production\nCOPY . .\nEXPOSE 8080\nENV PORT=8080\nCMD ["node", "src/index.js"]\n`;
1447
+ if (language === "python")
1448
+ return `FROM python:3.11-slim\nWORKDIR /app\nCOPY requirements.txt .\nRUN pip install --no-cache-dir -r requirements.txt\nCOPY . .\nEXPOSE 8080\nCMD ["python", "app.py"]\n`;
1449
+ if (language === "go")
1450
+ return `FROM golang:1.21-alpine AS build\nWORKDIR /app\nCOPY go.mod go.sum ./\nRUN go mod download\nCOPY . .\nRUN CGO_ENABLED=0 go build -o main .\nFROM alpine:3.18\nCOPY --from=build /app/main /app/main\nEXPOSE 8080\nCMD ["/app/main"]\n`;
1451
+ return `FROM ubuntu:22.04\nWORKDIR /app\nCOPY . .\nEXPOSE 8080\nCMD ["./start.sh"]\n`;
1452
+ }
1453
+
1454
+ /** Detect project stack from files (pom.xml, package.json, etc.) */
1455
+ app.post("/workspace/detect", async (req, res) => {
1456
+ const wsRoot = resolveWorkspaceRoot(req);
1457
+ if (!wsRoot) return res.status(400).json({ ok: false, error: "workspace not set" });
1458
+ try {
1459
+ const meta = await detectProject(wsRoot);
1460
+ const fs = await import("fs");
1461
+ const path = await import("path");
1462
+ res.json({
1463
+ ok: true,
1464
+ language: meta.language || "unknown",
1465
+ buildTool: meta.buildTool || "unknown",
1466
+ framework: meta.framework || "unknown",
1467
+ hasDockerfile: fs.existsSync(path.join(wsRoot, "Dockerfile")),
1468
+ path: wsRoot
1469
+ });
1470
+ } catch (e) {
1471
+ res.json({ ok: false, language: "unknown", buildTool: "unknown",
1472
+ framework: "unknown", error: e.message });
1473
+ }
1474
+ });
1475
+
1050
1476
  /** Escreve/salva conteudo de arquivo no workspace */
1051
1477
  app.post("/workspace/write-file", async (req, res) => {
1052
- const workspaceRoot = getEffectiveRoot(req);
1478
+ const workspaceRoot = resolveWorkspaceRoot(req);
1053
1479
  if (!workspaceRoot) return res.status(400).json({ error: "workspace not set" });
1054
1480
 
1055
1481
  const { path: relativePath, content: fileContent } = req.body || {};
@@ -1653,7 +2079,9 @@ app.post("/workspace/write", async (req, res) => {
1653
2079
  const { path: rel, content } = req.body || {};
1654
2080
  if (!rel) return res.status(400).json({ error: "path is required" });
1655
2081
  try {
1656
- await writeFile(path.join(wsRoot, rel), content ?? "", "utf8");
2082
+ const fullPath = path.join(wsRoot, rel);
2083
+ await fsPromises.mkdir(path.dirname(fullPath), { recursive: true });
2084
+ await writeFile(fullPath, content ?? "", "utf8");
1657
2085
  res.json({ ok: true, path: rel, bytes: Buffer.byteLength(content ?? "", "utf8") });
1658
2086
  } catch (e) {
1659
2087
  res.status(400).json({ error: "write failed", details: String(e) });
@@ -1775,6 +2203,85 @@ app.get("/workspace/test-local/state", async (req, res) => {
1775
2203
  });
1776
2204
  });
1777
2205
 
2206
+ /**
2207
+ * POST /workspace/git/prepare
2208
+ * Prepares a clean workspace for a new fix:
2209
+ * 1. Detects main branch (main, master, develop)
2210
+ * 2. git fetch origin
2211
+ * 3. git checkout main branch
2212
+ * 4. git reset --hard origin/main branch
2213
+ */
2214
+ app.post("/workspace/git/prepare", async (req, res) => {
2215
+ const wsRoot = resolveWorkspaceRoot(req);
2216
+ if (!wsRoot) {
2217
+ return res.status(400).json({ ok: false, error: "workspace not set" });
2218
+ }
2219
+
2220
+ const { gitToken, repoUrl } = req.body || {};
2221
+ console.log("[git-prepare] Starting workspace preparation...");
2222
+
2223
+ try {
2224
+ const { execSync } = await import("child_process");
2225
+ const execGit = (cmd) => {
2226
+ try {
2227
+ return { ok: true, output: execSync(cmd, {
2228
+ cwd: wsRoot, encoding: "utf8", timeout: 60000,
2229
+ env: { ...process.env, GIT_TERMINAL_PROMPT: "0" }
2230
+ }).trim() };
2231
+ } catch (err) {
2232
+ return { ok: false, output: (err.stderr || err.stdout || err.message || "unknown").toString().trim() };
2233
+ }
2234
+ };
2235
+
2236
+ execGit(`git config --global --add safe.directory "${wsRoot}"`);
2237
+
2238
+ if (gitToken && repoUrl) {
2239
+ const urlWithToken = repoUrl.replace("https://", `https://x-access-token:${gitToken}@`);
2240
+ execGit(`git remote set-url origin "${urlWithToken}"`);
2241
+ }
2242
+
2243
+ console.log("[git-prepare] Fetching origin...");
2244
+ execGit("git fetch origin --prune");
2245
+
2246
+ let mainBranch = null;
2247
+ for (const branch of ["main", "master", "develop", "dev"]) {
2248
+ if (execGit(`git rev-parse --verify origin/${branch}`).ok) {
2249
+ mainBranch = branch;
2250
+ break;
2251
+ }
2252
+ }
2253
+ if (!mainBranch) {
2254
+ const cur = execGit("git rev-parse --abbrev-ref HEAD");
2255
+ mainBranch = cur.ok ? cur.output : "main";
2256
+ }
2257
+
2258
+ const prevBranch = (execGit("git rev-parse --abbrev-ref HEAD").output || "unknown");
2259
+ const prevCommit = (execGit("git rev-parse --short HEAD").output || "unknown");
2260
+
2261
+ console.log(`[git-prepare] ${prevBranch}@${prevCommit} -> ${mainBranch}`);
2262
+
2263
+ let checkout = execGit(`git checkout ${mainBranch}`);
2264
+ if (!checkout.ok) checkout = execGit(`git checkout -f ${mainBranch}`);
2265
+ if (!checkout.ok) {
2266
+ return res.json({ ok: false, error: `Could not checkout ${mainBranch}: ${checkout.output}`,
2267
+ mainBranch, previousBranch: prevBranch, previousCommit: prevCommit });
2268
+ }
2269
+
2270
+ execGit(`git reset --hard origin/${mainBranch}`);
2271
+ execGit('git clean -fd --exclude="*.env" --exclude=".env*"');
2272
+
2273
+ const newCommit = (execGit("git rev-parse --short HEAD").output || "unknown");
2274
+ console.log(`[git-prepare] Ready: ${mainBranch}@${newCommit}`);
2275
+
2276
+ res.json({ ok: true, mainBranch, currentCommit: newCommit,
2277
+ previousBranch: prevBranch, previousCommit: prevCommit,
2278
+ message: `Workspace prepared: on ${mainBranch} at ${newCommit}` });
2279
+ } catch (err) {
2280
+ console.error("[git-prepare] Error:", err.message);
2281
+ res.status(500).json({ ok: false, error: err.message });
2282
+ }
2283
+ });
2284
+
1778
2285
  /**
1779
2286
  * POST /workspace/test-local/compile
1780
2287
  * Compiles the project without starting server
@@ -1795,23 +2302,29 @@ app.post("/workspace/test-local/compile", async (req, res) => {
1795
2302
  skipTests: true
1796
2303
  });
1797
2304
 
1798
- if (!compileResult.success) {
1799
- TEST_LOCAL_STATE.status = "error";
2305
+ // Support both formats:
2306
+ // - new format: { success, code, steps[] } from updated exec-utils.js
2307
+ // - legacy format: { code, stdout, stderr } from older exec-utils.js
2308
+ const isSuccess = compileResult.success !== undefined
2309
+ ? compileResult.success
2310
+ : compileResult.code === 0;
1800
2311
 
1801
- const errorOutput = (compileResult.steps || [])
1802
- .map(s => s.stderr || '')
1803
- .filter(Boolean)
1804
- .join('\n')
1805
- .trim();
2312
+ const errorOutput = compileResult.steps
2313
+ ? compileResult.steps.map(s => s.stderr || '').filter(Boolean).join('\n').trim()
2314
+ : (compileResult.stderr || '');
1806
2315
 
1807
- const stdoutOutput = (compileResult.steps || [])
1808
- .map(s => s.stdout || '')
1809
- .filter(Boolean)
1810
- .join('\n')
1811
- .trim();
2316
+ const stdoutOutput = compileResult.steps
2317
+ ? compileResult.steps.map(s => s.stdout || '').filter(Boolean).join('\n').trim()
2318
+ : (compileResult.stdout || '');
1812
2319
 
1813
- const totalDuration = (compileResult.steps || [])
1814
- .reduce((sum, s) => sum + (s.duration || 0), 0);
2320
+ const totalDuration = compileResult.steps
2321
+ ? compileResult.steps.reduce((sum, s) => sum + (s.duration || 0), 0)
2322
+ : (compileResult.duration || 0);
2323
+
2324
+ if (!isSuccess) {
2325
+ TEST_LOCAL_STATE.status = "error";
2326
+ console.error("[TEST-LOCAL] Compilation failed. stderr:", errorOutput.substring(0, 500));
2327
+ console.error("[TEST-LOCAL] Compilation failed. stdout:", stdoutOutput.substring(0, 500));
1815
2328
 
1816
2329
  TEST_LOCAL_STATE.compilationResult = {
1817
2330
  success: false,
@@ -1828,9 +2341,6 @@ app.post("/workspace/test-local/compile", async (req, res) => {
1828
2341
  });
1829
2342
  }
1830
2343
 
1831
- const totalDuration = (compileResult.steps || [])
1832
- .reduce((sum, s) => sum + (s.duration || 0), 0);
1833
-
1834
2344
  TEST_LOCAL_STATE.status = "compiled";
1835
2345
  TEST_LOCAL_STATE.compilationResult = {
1836
2346
  success: true,
@@ -1846,7 +2356,7 @@ app.post("/workspace/test-local/compile", async (req, res) => {
1846
2356
  language: meta.language,
1847
2357
  buildTool: meta.buildTool,
1848
2358
  duration: totalDuration,
1849
- stdout: (compileResult.steps || []).map(s => s.stdout || '').filter(Boolean).join('\n').trim()
2359
+ stdout: stdoutOutput
1850
2360
  });
1851
2361
  } catch (err) {
1852
2362
  console.error("[TEST-LOCAL] Compilation failed:", err.message);
@@ -2367,8 +2877,9 @@ app.post("/workspace/safe-patch", async (req, res) => {
2367
2877
  incidentId: incidentId || null
2368
2878
  });
2369
2879
 
2370
- // Save backup files to disk for persistence across restarts
2371
- const backupDir = path.join(os.tmpdir(), 'deepdebug-backups', backupId);
2880
+ // Save backup files to NFS Filestore for persistence across container restarts
2881
+ const backupsBase = fs.existsSync(NFS_BACKUPS_DIR) ? path.join(NFS_BACKUPS_DIR, '.deepdebug-backups') : path.join(os.tmpdir(), 'deepdebug-backups');
2882
+ const backupDir = path.join(backupsBase, backupId);
2372
2883
  try {
2373
2884
  await fsPromises.mkdir(backupDir, { recursive: true });
2374
2885
  for (const file of backupFiles) {
@@ -5858,14 +6369,24 @@ app.post("/workspace/test-local/run-single", async (req, res) => {
5858
6369
  try {
5859
6370
  const meta = await detectProject(wsRoot);
5860
6371
 
6372
+ // Build JAVA_HOME env (same fix as compile endpoint)
6373
+ const javaHome = process.env.JAVA_HOME || "/usr/local/java-home";
6374
+ const mavenEnv = {
6375
+ ...process.env,
6376
+ JAVA_HOME: javaHome,
6377
+ PATH: `${javaHome}/bin:${process.env.PATH}`
6378
+ };
6379
+
5861
6380
  let result;
5862
6381
 
5863
6382
  if (meta.buildTool === 'maven') {
5864
- // mvn test -Dtest=ClassName -q (sem clean para ser mais rapido)
5865
- result = await run('mvn', ['test', `-Dtest=${resolvedClass}`, '-q'], wsRoot);
6383
+ // Prefer ./mvnw wrapper, pass JAVA_HOME env
6384
+ const hasMvnw = fs.existsSync(path.join(wsRoot, 'mvnw'));
6385
+ const mvnCmd = hasMvnw ? './mvnw' : 'mvn';
6386
+ result = await run(mvnCmd, ['test', `-Dtest=${resolvedClass}`, '-DskipTests=false'], wsRoot, 3 * 60 * 1000, mavenEnv);
5866
6387
  } else if (meta.buildTool === 'gradle') {
5867
6388
  const gradleCmd = fs.existsSync(path.join(wsRoot, 'gradlew')) ? './gradlew' : 'gradle';
5868
- result = await run(gradleCmd, ['test', '--tests', `*.${resolvedClass}`], wsRoot);
6389
+ result = await run(gradleCmd, ['test', '--tests', `*.${resolvedClass}`], wsRoot, 3 * 60 * 1000, mavenEnv);
5869
6390
  } else if (meta.language === 'node') {
5870
6391
  result = await run('npx', ['jest', resolvedClass, '--no-coverage'], wsRoot);
5871
6392
  } else {
@@ -5931,6 +6452,320 @@ app.post("/workspace/test-local/run-single", async (req, res) => {
5931
6452
  }
5932
6453
  });
5933
6454
 
6455
+ // ============================================
6456
+ // SPRINT 1 - S1-T4: Session Isolation via Git Worktree
6457
+ // ============================================
6458
+
6459
+ /**
6460
+ * POST /workspace/session/create
6461
+ *
6462
+ * Creates an isolated git worktree for a session.
6463
+ * Each session gets its own directory and branch - zero collision between parallel sessions.
6464
+ *
6465
+ * Body: { sessionId, branchName, tenantId }
6466
+ * Response: { ok, sessionId, worktreePath, branchName }
6467
+ */
6468
+ app.post("/workspace/session/create", async (req, res) => {
6469
+ const wsRoot = resolveWorkspaceRoot(req);
6470
+ if (!wsRoot) return res.status(400).json({ ok: false, error: "workspace not set" });
6471
+
6472
+ const { sessionId, branchName, tenantId } = req.body || {};
6473
+ if (!sessionId) return res.status(400).json({ ok: false, error: "sessionId is required" });
6474
+
6475
+ const safeBranch = (branchName || `session-${sessionId}`).replace(/[^a-zA-Z0-9/_-]/g, "-").toLowerCase();
6476
+ const tenantPart = tenantId || path.basename(path.dirname(wsRoot));
6477
+
6478
+ // Sessions dir: /mnt/workspaces/{tenantId}/sessions/{sessionId}
6479
+ const sessionsBase = path.join(path.dirname(wsRoot), "sessions");
6480
+ const worktreePath = path.join(sessionsBase, sessionId);
6481
+
6482
+ console.log(`[SESSION] Creating worktree: ${worktreePath} (branch: ${safeBranch})`);
6483
+
6484
+ try {
6485
+ await fsPromises.mkdir(sessionsBase, { recursive: true });
6486
+
6487
+ // Remove stale worktree if exists
6488
+ if (fs.existsSync(worktreePath)) {
6489
+ console.log(`[SESSION] Removing stale worktree: ${worktreePath}`);
6490
+ await execAsync(`git worktree remove --force "${worktreePath}"`, { cwd: wsRoot }).catch(() => {});
6491
+ await fsPromises.rm(worktreePath, { recursive: true, force: true }).catch(() => {});
6492
+ }
6493
+
6494
+ // Create new worktree with new branch
6495
+ await execAsync(`git worktree add "${worktreePath}" -b "${safeBranch}"`, { cwd: wsRoot });
6496
+
6497
+ console.log(`[SESSION] Worktree created: ${worktreePath}`);
6498
+ return res.json({
6499
+ ok: true,
6500
+ sessionId,
6501
+ worktreePath,
6502
+ branchName: safeBranch
6503
+ });
6504
+ } catch (err) {
6505
+ console.error(`[SESSION] Failed to create worktree: ${err.message}`);
6506
+ // Fallback: return main workspace path (no isolation but no crash)
6507
+ return res.status(500).json({
6508
+ ok: false,
6509
+ sessionId,
6510
+ worktreePath: wsRoot,
6511
+ branchName: safeBranch,
6512
+ error: err.message,
6513
+ fallback: true
6514
+ });
6515
+ }
6516
+ });
6517
+
6518
+ /**
6519
+ * DELETE /workspace/session/:sessionId
6520
+ *
6521
+ * Removes a session worktree and its branch.
6522
+ * Called after PR is created or session times out.
6523
+ *
6524
+ * Query: ?tenantId=xxx
6525
+ */
6526
+ app.delete("/workspace/session/:sessionId", async (req, res) => {
6527
+ const wsRoot = resolveWorkspaceRoot(req);
6528
+ if (!wsRoot) return res.status(400).json({ ok: false, error: "workspace not set" });
6529
+
6530
+ const { sessionId } = req.params;
6531
+ const sessionsBase = path.join(path.dirname(wsRoot), "sessions");
6532
+ const worktreePath = path.join(sessionsBase, sessionId);
6533
+
6534
+ console.log(`[SESSION] Removing worktree: ${worktreePath}`);
6535
+
6536
+ try {
6537
+ if (fs.existsSync(worktreePath)) {
6538
+ await execAsync(`git worktree remove --force "${worktreePath}"`, { cwd: wsRoot }).catch(() => {});
6539
+ await fsPromises.rm(worktreePath, { recursive: true, force: true }).catch(() => {});
6540
+ }
6541
+
6542
+ // Clean up branch
6543
+ const branchName = `session-${sessionId}`;
6544
+ await execAsync(`git branch -D "${branchName}"`, { cwd: wsRoot }).catch(() => {});
6545
+
6546
+ console.log(`[SESSION] Worktree removed: ${worktreePath}`);
6547
+ return res.json({ ok: true, sessionId, removed: worktreePath });
6548
+ } catch (err) {
6549
+ console.error(`[SESSION] Failed to remove worktree: ${err.message}`);
6550
+ return res.status(500).json({ ok: false, sessionId, error: err.message });
6551
+ }
6552
+ });
6553
+
6554
+ /**
6555
+ * GET /workspace/session/:sessionId/status
6556
+ *
6557
+ * Returns status of a session worktree.
6558
+ */
6559
+ app.get("/workspace/session/:sessionId/status", async (req, res) => {
6560
+ const wsRoot = resolveWorkspaceRoot(req);
6561
+ if (!wsRoot) return res.status(400).json({ ok: false, error: "workspace not set" });
6562
+
6563
+ const { sessionId } = req.params;
6564
+ const sessionsBase = path.join(path.dirname(wsRoot), "sessions");
6565
+ const worktreePath = path.join(sessionsBase, sessionId);
6566
+ const exists = fs.existsSync(worktreePath);
6567
+
6568
+ return res.json({
6569
+ ok: true,
6570
+ sessionId,
6571
+ worktreePath,
6572
+ exists
6573
+ });
6574
+ });
6575
+
6576
+
6577
+ // ============================================
6578
+ // FASE 5 - ENV MANAGER + PREVIEW TUNNEL
6579
+ // State: active vibe environments per sessionId
6580
+ // ============================================
6581
+ const vibeEnvs = new Map(); // sessionId -> { port, pid, serviceId, tunnelUrl, tunnelPid, worktreePath, status }
6582
+
6583
+ function findFreePort(min = 9000, max = 9999) {
6584
+ const used = new Set([...vibeEnvs.values()].map(e => e.port));
6585
+ for (let p = min; p <= max; p++) {
6586
+ if (!used.has(p)) return p;
6587
+ }
6588
+ throw new Error('No free ports available in range 9000-9999');
6589
+ }
6590
+
6591
+ /**
6592
+ * POST /workspace/vibe/env/start
6593
+ *
6594
+ * Compiles (if needed) and starts the service on a dynamic port.
6595
+ * Body: { sessionId, worktreePath }
6596
+ * Response: { ok, port, serviceId, readyUrl }
6597
+ */
6598
+ app.post("/workspace/vibe/env/start", async (req, res) => {
6599
+ const { sessionId, worktreePath } = req.body || {};
6600
+ if (!sessionId || !worktreePath) {
6601
+ return res.status(400).json({ ok: false, error: "sessionId and worktreePath required" });
6602
+ }
6603
+ if (vibeEnvs.has(sessionId)) {
6604
+ const env = vibeEnvs.get(sessionId);
6605
+ return res.json({ ok: true, port: env.port, serviceId: env.serviceId, readyUrl: `http://localhost:${env.port}`, alreadyRunning: true });
6606
+ }
6607
+ try {
6608
+ const port = findFreePort();
6609
+ const serviceId = `vibe-${sessionId}`;
6610
+ const meta = await detectProject(worktreePath);
6611
+ let startConfig;
6612
+ if (meta.language === 'java') {
6613
+ const targetDir = path.join(worktreePath, 'target');
6614
+ let jarPath = null;
6615
+ if (fs.existsSync(targetDir)) {
6616
+ const jars = fs.readdirSync(targetDir).filter(f =>
6617
+ f.endsWith('.jar') && !f.endsWith('.original') &&
6618
+ !f.includes('-sources') && !f.includes('-javadoc'));
6619
+ if (jars.length > 0) jarPath = path.join(targetDir, jars[0]);
6620
+ }
6621
+ if (!jarPath) {
6622
+ return res.status(400).json({ ok: false, error: "No JAR found. Run compile_project first." });
6623
+ }
6624
+ const cleanEnv = { ...process.env };
6625
+ Object.keys(cleanEnv).forEach(k => { if (k.startsWith('SPRING_')) delete cleanEnv[k]; });
6626
+ cleanEnv.SERVER_PORT = String(port);
6627
+ cleanEnv.PORT = String(port);
6628
+ startConfig = { command: 'java', args: ['-jar', jarPath, `--server.port=${port}`], cwd: worktreePath, port, env: cleanEnv };
6629
+ } else if (meta.language === 'node' || meta.language === 'javascript') {
6630
+ startConfig = { command: 'node', args: ['index.js'], cwd: worktreePath, port, env: { ...process.env, PORT: String(port) } };
6631
+ } else {
6632
+ return res.status(400).json({ ok: false, error: `Unsupported language: ${meta.language}` });
6633
+ }
6634
+ vibeEnvs.set(sessionId, { port, serviceId, tunnelUrl: null, tunnelPid: null, worktreePath, status: 'starting' });
6635
+ await processManager.start(serviceId, startConfig);
6636
+ vibeEnvs.get(sessionId).status = 'running';
6637
+ console.log(`[VIBE-ENV] Started ${serviceId} on port ${port}`);
6638
+ return res.json({ ok: true, port, serviceId, readyUrl: `http://localhost:${port}` });
6639
+ } catch (err) {
6640
+ console.error(`[VIBE-ENV] Start failed: ${err.message}`);
6641
+ vibeEnvs.delete(sessionId);
6642
+ return res.status(500).json({ ok: false, error: err.message });
6643
+ }
6644
+ });
6645
+
6646
+ /**
6647
+ * POST /workspace/vibe/env/stop
6648
+ *
6649
+ * Stops the service and tunnel for a session.
6650
+ * Body: { sessionId }
6651
+ */
6652
+ app.post("/workspace/vibe/env/stop", async (req, res) => {
6653
+ const { sessionId } = req.body || {};
6654
+ if (!sessionId) return res.status(400).json({ ok: false, error: "sessionId required" });
6655
+ const env = vibeEnvs.get(sessionId);
6656
+ if (!env) return res.json({ ok: true, message: "Not running" });
6657
+ try {
6658
+ await processManager.stop(env.serviceId);
6659
+ if (env.tunnelPid) {
6660
+ try { process.kill(env.tunnelPid, 'SIGTERM'); } catch (_) {}
6661
+ }
6662
+ vibeEnvs.delete(sessionId);
6663
+ console.log(`[VIBE-ENV] Stopped ${env.serviceId}`);
6664
+ return res.json({ ok: true });
6665
+ } catch (err) {
6666
+ return res.status(500).json({ ok: false, error: err.message });
6667
+ }
6668
+ });
6669
+
6670
+ /**
6671
+ * GET /workspace/vibe/env/status/:sessionId
6672
+ *
6673
+ * Returns current status of a vibe environment.
6674
+ */
6675
+ app.get("/workspace/vibe/env/status/:sessionId", async (req, res) => {
6676
+ const { sessionId } = req.params;
6677
+ const env = vibeEnvs.get(sessionId);
6678
+ if (!env) return res.json({ ok: true, status: 'stopped', sessionId });
6679
+ return res.json({ ok: true, sessionId, status: env.status, port: env.port, serviceId: env.serviceId, tunnelUrl: env.tunnelUrl, readyUrl: `http://localhost:${env.port}` });
6680
+ });
6681
+
6682
+ /**
6683
+ * POST /workspace/vibe/tunnel/start
6684
+ *
6685
+ * Starts a cloudflared tunnel for a vibe environment.
6686
+ * Body: { sessionId }
6687
+ * Response: { ok, tunnelUrl }
6688
+ *
6689
+ * Downloads cloudflared binary automatically if not present.
6690
+ */
6691
+ app.post("/workspace/vibe/tunnel/start", async (req, res) => {
6692
+ const { sessionId } = req.body || {};
6693
+ if (!sessionId) return res.status(400).json({ ok: false, error: "sessionId required" });
6694
+ const env = vibeEnvs.get(sessionId);
6695
+ if (!env) return res.status(404).json({ ok: false, error: "Environment not running. Call /vibe/env/start first." });
6696
+ if (env.tunnelUrl) return res.json({ ok: true, tunnelUrl: env.tunnelUrl, alreadyRunning: true });
6697
+ try {
6698
+ // Resolve cloudflared binary path
6699
+ const cfDir = path.join(process.env.HOME || '/tmp', '.deepdebug', 'bin');
6700
+ const cfPath = path.join(cfDir, 'cloudflared');
6701
+ // Download if missing
6702
+ if (!fs.existsSync(cfPath)) {
6703
+ fs.mkdirSync(cfDir, { recursive: true });
6704
+ const arch = process.arch === 'arm64' ? 'arm64' : 'amd64';
6705
+ const platform = process.platform === 'darwin' ? 'darwin' : 'linux';
6706
+ const url = `https://github.com/cloudflare/cloudflared/releases/latest/download/cloudflared-${platform}-${arch}`;
6707
+ console.log(`[VIBE-TUNNEL] Downloading cloudflared from ${url}...`);
6708
+ await execAsync(`curl -fsSL "${url}" -o "${cfPath}" && chmod +x "${cfPath}"`);
6709
+ console.log(`[VIBE-TUNNEL] cloudflared downloaded to ${cfPath}`);
6710
+ }
6711
+ // Start tunnel
6712
+ const tunnelProcess = spawn(cfPath, ['tunnel', '--url', `http://localhost:${env.port}`], {
6713
+ stdio: ['ignore', 'pipe', 'pipe'],
6714
+ detached: false
6715
+ });
6716
+ env.tunnelPid = tunnelProcess.pid;
6717
+ env.status = 'tunneling';
6718
+ // Capture tunnel URL from stdout/stderr
6719
+ const tunnelUrl = await new Promise((resolve, reject) => {
6720
+ const timeout = setTimeout(() => reject(new Error('Tunnel URL not found within 30s')), 30000);
6721
+ const handler = (data) => {
6722
+ const text = data.toString();
6723
+ const match = text.match(/https:\/\/[a-zA-Z0-9-]+\.trycloudflare\.com/);
6724
+ if (match) {
6725
+ clearTimeout(timeout);
6726
+ tunnelProcess.stdout.off('data', handler);
6727
+ tunnelProcess.stderr.off('data', handler);
6728
+ resolve(match[0]);
6729
+ }
6730
+ };
6731
+ tunnelProcess.stdout.on('data', handler);
6732
+ tunnelProcess.stderr.on('data', handler);
6733
+ tunnelProcess.on('error', (err) => { clearTimeout(timeout); reject(err); });
6734
+ });
6735
+ env.tunnelUrl = tunnelUrl;
6736
+ console.log(`[VIBE-TUNNEL] Tunnel started: ${tunnelUrl}`);
6737
+ return res.json({ ok: true, tunnelUrl });
6738
+ } catch (err) {
6739
+ console.error(`[VIBE-TUNNEL] Failed: ${err.message}`);
6740
+ return res.status(500).json({ ok: false, error: err.message });
6741
+ }
6742
+ });
6743
+
6744
+ /**
6745
+ * DELETE /workspace/vibe/tunnel/:sessionId
6746
+ *
6747
+ * Stops only the tunnel (keeps service running).
6748
+ */
6749
+ app.delete("/workspace/vibe/tunnel/:sessionId", async (req, res) => {
6750
+ const { sessionId } = req.params;
6751
+ const env = vibeEnvs.get(sessionId);
6752
+ if (!env || !env.tunnelPid) return res.json({ ok: true, message: "No tunnel running" });
6753
+ try {
6754
+ process.kill(env.tunnelPid, 'SIGTERM');
6755
+ env.tunnelPid = null;
6756
+ env.tunnelUrl = null;
6757
+ env.status = 'running';
6758
+ return res.json({ ok: true });
6759
+ } catch (err) {
6760
+ return res.status(500).json({ ok: false, error: err.message });
6761
+ }
6762
+ });
6763
+
6764
+ // ============================================
6765
+ // VERCEL PROXY ROUTES
6766
+ // ============================================
6767
+ registerVercelRoutes(app);
6768
+
5934
6769
  // ============================================
5935
6770
  // START SERVER
5936
6771