deepdebug-local-agent 1.0.20 → 1.0.22

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
@@ -867,7 +867,7 @@ app.post("/workspace/open", async (req, res) => {
867
867
 
868
868
  return res.json({
869
869
  ok: true,
870
- root: WORKSPACE_ROOT,
870
+ root: clonePath,
871
871
  workspaceId: wsId,
872
872
  mode: "cloud-clone",
873
873
  meta,
@@ -988,13 +988,16 @@ app.post("/workspace/clone", async (req, res) => {
988
988
  const safeLog = authenticatedUrl.replace(/x-token-auth:[^@]+@/g, 'x-token-auth:***@').replace(/oauth2:[^@]+@/g, 'oauth2:***@').replace(/x-access-token:[^@]+@/g, 'x-access-token:***@');
989
989
  const cloneCmd = branch ? `git clone -b ${branch} "${authenticatedUrl}" "${absTarget}"` : `git clone "${authenticatedUrl}" "${absTarget}"`;
990
990
  console.log(`Running: git clone${branch ? ' -b ' + branch : ''} "${safeLog}" "${absTarget}"`);
991
- await execAsync(cloneCmd, { timeout: 300000 });
991
+ const cloneEnvWorkspace = { ...process.env, GIT_TERMINAL_PROMPT: "0", HOME: process.env.HOME || "/tmp" };
992
+ await execAsync(cloneCmd, { timeout: 300000, env: cloneEnvWorkspace });
992
993
  console.log(`Clone complete: ${absTarget}`);
993
994
  }
994
995
 
995
996
  // Open the cloned workspace
996
- WORKSPACE_ROOT = absTarget;
997
997
  const wsId = workspaceId || "default";
998
+ if (wsId === "default") {
999
+ WORKSPACE_ROOT = absTarget;
1000
+ }
998
1001
  try {
999
1002
  await wsManager.open(wsId, absTarget);
1000
1003
  } catch (err) {
@@ -1096,6 +1099,398 @@ app.get("/workspace/file-content", async (req, res) => {
1096
1099
  }
1097
1100
  });
1098
1101
 
1102
+ /**
1103
+ * POST /workspace/build-push
1104
+ * Compress workspace, upload to GCS, submit Cloud Build.
1105
+ * No Docker daemon needed — uses Cloud Build API + GCS.
1106
+ * Body: { workspaceId, imageName, gcpProject, saKeyJson }
1107
+ */
1108
+ app.post("/workspace/build-push", async (req, res) => {
1109
+ const { workspaceId, imageName, gcpProject, saKeyJson } = req.body || {};
1110
+ if (!workspaceId || !imageName || !gcpProject || !saKeyJson) {
1111
+ return res.status(400).json({ error: "workspaceId, imageName, gcpProject and saKeyJson required" });
1112
+ }
1113
+
1114
+ res.setHeader("Content-Type", "text/event-stream");
1115
+ res.setHeader("Cache-Control", "no-cache");
1116
+ res.setHeader("Connection", "keep-alive");
1117
+ res.setHeader("X-Accel-Buffering", "no");
1118
+
1119
+ const emit = (event, msg) => {
1120
+ const data = typeof msg === "object" ? JSON.stringify(msg) : JSON.stringify({ message: msg });
1121
+ res.write(`event:${event}\ndata:${data}\n\n`);
1122
+ };
1123
+
1124
+ try {
1125
+ const nfsBase = process.env.WORKSPACES_MOUNT || "/mnt/workspaces";
1126
+ const wsPath = path.join(nfsBase, workspaceId);
1127
+
1128
+ if (!fs.existsSync(wsPath)) {
1129
+ emit("error", { error: "Workspace not found: " + wsPath });
1130
+ return res.end();
1131
+ }
1132
+ if (!fs.existsSync(path.join(wsPath, "Dockerfile"))) {
1133
+ emit("error", { error: "No Dockerfile in workspace. Run detect+generate first." });
1134
+ return res.end();
1135
+ }
1136
+
1137
+ // Step 1: Get GCP access token from SA key
1138
+ emit("progress", "Authenticating with GCP...");
1139
+ let accessToken;
1140
+ try {
1141
+ const { createSign } = await import("crypto");
1142
+ const sa = JSON.parse(saKeyJson);
1143
+ const now = Math.floor(Date.now() / 1000);
1144
+ const h = Buffer.from(JSON.stringify({ alg: "RS256", typ: "JWT" })).toString("base64url");
1145
+ const c = Buffer.from(JSON.stringify({
1146
+ iss: sa.client_email,
1147
+ scope: "https://www.googleapis.com/auth/cloud-platform",
1148
+ aud: "https://oauth2.googleapis.com/token",
1149
+ iat: now, exp: now + 3600
1150
+ })).toString("base64url");
1151
+ const sign = createSign("SHA256");
1152
+ sign.update(`${h}.${c}`);
1153
+ const sig = sign.sign(sa.private_key, "base64url");
1154
+ const jwt = `${h}.${c}.${sig}`;
1155
+
1156
+ const tokenResp = await fetch("https://oauth2.googleapis.com/token", {
1157
+ method: "POST",
1158
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
1159
+ body: `grant_type=urn:ietf:params:oauth:grant-type:jwt-bearer&assertion=${jwt}`
1160
+ });
1161
+ const tokenData = await tokenResp.json();
1162
+ accessToken = tokenData.access_token;
1163
+ if (!accessToken) throw new Error("No access_token: " + JSON.stringify(tokenData));
1164
+ emit("progress", "GCP authentication successful");
1165
+ } catch (e) {
1166
+ emit("error", { error: "GCP auth failed: " + e.message });
1167
+ return res.end();
1168
+ }
1169
+
1170
+ // Step 2: Compress workspace to tar.gz
1171
+ emit("progress", "Compressing source code...");
1172
+ const { execSync } = await import("child_process");
1173
+ const tarPath = `/tmp/source-${workspaceId}-${Date.now()}.tar.gz`;
1174
+ try {
1175
+ execSync(
1176
+ `tar -czf "${tarPath}" --exclude=".git" --exclude="node_modules" ` +
1177
+ `--exclude="target" --exclude="*.class" --exclude=".gradle" ` +
1178
+ `-C "${wsPath}" . 2>&1`,
1179
+ { stdio: "pipe", timeout: 120000 }
1180
+ );
1181
+ const sizeKB = Math.round(fs.statSync(tarPath).size / 1024);
1182
+ emit("progress", `Source compressed: ${sizeKB}KB`);
1183
+ } catch (e) {
1184
+ emit("error", { error: "Compression failed: " + (e.message || "").substring(0, 200) });
1185
+ return res.end();
1186
+ }
1187
+
1188
+ // Step 3: Upload to GCS
1189
+ const bucketName = `${gcpProject}_cloudbuild`;
1190
+ const objectName = `source/${workspaceId}-${Date.now()}.tar.gz`;
1191
+ emit("progress", `Uploading source to GCS...`);
1192
+ try {
1193
+ // Create bucket (ignore if exists)
1194
+ await fetch(`https://storage.googleapis.com/storage/v1/b?project=${gcpProject}`, {
1195
+ method: "POST",
1196
+ headers: { "Authorization": `Bearer ${accessToken}`, "Content-Type": "application/json" },
1197
+ body: JSON.stringify({ name: bucketName, location: "US" })
1198
+ });
1199
+
1200
+ const fileBuffer = fs.readFileSync(tarPath);
1201
+ const uploadResp = await fetch(
1202
+ `https://storage.googleapis.com/upload/storage/v1/b/${encodeURIComponent(bucketName)}/o?uploadType=media&name=${encodeURIComponent(objectName)}`,
1203
+ {
1204
+ method: "POST",
1205
+ headers: { "Authorization": `Bearer ${accessToken}`, "Content-Type": "application/gzip" },
1206
+ body: fileBuffer
1207
+ }
1208
+ );
1209
+ if (!uploadResp.ok) {
1210
+ const errText = await uploadResp.text();
1211
+ throw new Error(`GCS upload ${uploadResp.status}: ${errText.substring(0, 200)}`);
1212
+ }
1213
+ emit("progress", "Source uploaded to GCS");
1214
+ } catch (e) {
1215
+ emit("error", { error: "GCS upload failed: " + e.message });
1216
+ return res.end();
1217
+ } finally {
1218
+ try { fs.unlinkSync(tarPath); } catch {}
1219
+ }
1220
+
1221
+ // Step 4: Submit Cloud Build
1222
+ emit("progress", "Submitting Cloud Build...");
1223
+ let buildId = "unknown";
1224
+ try {
1225
+ const buildReq = {
1226
+ source: { storageSource: { bucket: bucketName, object: objectName } },
1227
+ steps: [
1228
+ { name: "gcr.io/cloud-builders/docker", args: ["build", "-t", imageName, "."], id: "build" },
1229
+ { name: "gcr.io/cloud-builders/docker", args: ["push", imageName], id: "push", waitFor: ["build"] }
1230
+ ],
1231
+ images: [imageName],
1232
+ timeout: "1200s"
1233
+ };
1234
+
1235
+ const buildResp = await fetch(
1236
+ `https://cloudbuild.googleapis.com/v1/projects/${gcpProject}/builds`,
1237
+ {
1238
+ method: "POST",
1239
+ headers: { "Authorization": `Bearer ${accessToken}`, "Content-Type": "application/json" },
1240
+ body: JSON.stringify(buildReq)
1241
+ }
1242
+ );
1243
+ if (!buildResp.ok) {
1244
+ const errText = await buildResp.text();
1245
+ throw new Error(`Cloud Build ${buildResp.status}: ${errText.substring(0, 300)}`);
1246
+ }
1247
+ const buildData = await buildResp.json();
1248
+ buildId = buildData.metadata?.build?.id || buildData.name?.split("/").pop() || "unknown";
1249
+ emit("progress", `Cloud Build started: ${buildId}`);
1250
+ } catch (e) {
1251
+ emit("error", { error: "Cloud Build submit failed: " + e.message });
1252
+ return res.end();
1253
+ }
1254
+
1255
+ // Step 5: Poll build status
1256
+ emit("progress", "Building Docker image (3-5 minutes)...");
1257
+ let status = "WORKING";
1258
+ let attempts = 0;
1259
+ while (!["SUCCESS", "FAILURE", "CANCELLED", "TIMEOUT"].includes(status) && attempts < 40) {
1260
+ await new Promise(r => setTimeout(r, 15000));
1261
+ attempts++;
1262
+ try {
1263
+ const statusResp = await fetch(
1264
+ `https://cloudbuild.googleapis.com/v1/projects/${gcpProject}/builds/${buildId}`,
1265
+ { headers: { "Authorization": `Bearer ${accessToken}` } }
1266
+ );
1267
+ const statusData = await statusResp.json();
1268
+ status = statusData.status || "WORKING";
1269
+ emit("progress", `Build status: ${status} (${attempts}/40)`);
1270
+ } catch (e) {
1271
+ emit("progress", `Status check error: ${e.message}`);
1272
+ }
1273
+ }
1274
+
1275
+ if (status === "SUCCESS") {
1276
+ emit("progress", "Docker image built and pushed successfully!");
1277
+ emit("complete", { success: true, imageName, buildId });
1278
+ } else {
1279
+ emit("error", { error: `Cloud Build ${status}. Check GCP Console.` });
1280
+ }
1281
+ res.end();
1282
+
1283
+ } catch (err) {
1284
+ console.error("[build-push] Fatal:", err.message);
1285
+ emit("error", { error: err.message });
1286
+ res.end();
1287
+ }
1288
+ });
1289
+
1290
+ /**
1291
+ * POST /workspace/setup
1292
+ * AI Cloud Engineering Agent - autonomous setup:
1293
+ * 1. Clone repo 2. Detect stack 3. Generate Dockerfile 4. Register workspace
1294
+ * Returns SSE stream with real-time progress.
1295
+ */
1296
+ app.post("/workspace/setup", async (req, res) => {
1297
+ const { workspaceId, repoUrl, gitToken, provider = "deepdebug_cloud",
1298
+ cloudProjectId, cloudToken } = req.body || {};
1299
+ // gitProvider is passed by the gateway (OnboardingSetupController) and used below
1300
+ // to pick the correct authenticated-clone username. Auto-detect from the URL if
1301
+ // the gateway didn't send it, so older callers keep working.
1302
+ const gitProvider = (req.body && req.body.gitProvider
1303
+ ? String(req.body.gitProvider)
1304
+ : (repoUrl && repoUrl.includes("bitbucket.org") ? "bitbucket"
1305
+ : repoUrl && repoUrl.includes("gitlab.com") ? "gitlab"
1306
+ : "github")).toLowerCase();
1307
+
1308
+ if (!workspaceId || !repoUrl) {
1309
+ return res.status(400).json({ error: "workspaceId and repoUrl required" });
1310
+ }
1311
+
1312
+ res.setHeader("Content-Type", "text/event-stream");
1313
+ res.setHeader("Cache-Control", "no-cache");
1314
+ res.setHeader("Connection", "keep-alive");
1315
+ res.setHeader("X-Accel-Buffering", "no");
1316
+
1317
+ const emit = (event, msg) => {
1318
+ const data = typeof msg === "object" ? JSON.stringify(msg) : JSON.stringify({ message: msg });
1319
+ res.write(`event:${event}\ndata:${data}\n\n`);
1320
+ };
1321
+
1322
+ try {
1323
+ // Use /mnt/workspaces as base (not WORKSPACE_ROOT which is a specific workspace)
1324
+ const nfsBase = process.env.WORKSPACES_MOUNT || "/mnt/workspaces";
1325
+ const wsPath = path.join(nfsBase, workspaceId);
1326
+
1327
+ emit("progress", `Starting setup for workspace: ${workspaceId}`);
1328
+ emit("progress", `Workspace path: ${wsPath}`);
1329
+
1330
+ // STEP 1 - Clone
1331
+ emit("progress", "Cloning repository...");
1332
+ let authUrl = repoUrl;
1333
+ if (gitToken && gitToken.length > 5) {
1334
+ // Provider-specific auth username:
1335
+ // bitbucket -> "x-token-auth" (required by Bitbucket Cloud)
1336
+ // gitlab -> "oauth2"
1337
+ // github/* -> "x-access-token" (GitHub's recommended)
1338
+ const authUser = gitProvider === "bitbucket" ? "x-token-auth"
1339
+ : gitProvider === "gitlab" ? "oauth2"
1340
+ : "x-access-token";
1341
+ try {
1342
+ const urlObj = new URL(repoUrl);
1343
+ urlObj.username = authUser;
1344
+ urlObj.password = gitToken;
1345
+ authUrl = urlObj.toString();
1346
+ emit("progress", `Using authenticated clone for ${urlObj.hostname} (auth=${authUser})`);
1347
+ } catch {
1348
+ authUrl = repoUrl.replace("https://", `https://${authUser}:${gitToken}@`);
1349
+ emit("progress", `Using authenticated clone (fallback, auth=${authUser})`);
1350
+ }
1351
+ }
1352
+
1353
+ const { execSync } = await import("child_process");
1354
+ const cloneEnv = { ...process.env, GIT_TERMINAL_PROMPT: "0", HOME: process.env.HOME || "/tmp" };
1355
+ const execOpts = { stdio: "pipe", timeout: 300000, encoding: "utf8", env: cloneEnv };
1356
+ let cloneBranch = "main";
1357
+
1358
+ // Configure safe directory
1359
+ try { execSync(`git config --global --add safe.directory "${wsPath}"`, execOpts); } catch {}
1360
+
1361
+ if (fs.existsSync(path.join(wsPath, ".git"))) {
1362
+ emit("progress", "Workspace exists, updating...");
1363
+ try {
1364
+ if (gitToken && gitToken.length > 5) {
1365
+ execSync(`git remote set-url origin "${authUrl}"`, { ...execOpts, cwd: wsPath });
1366
+ }
1367
+ execSync("git fetch origin", { ...execOpts, cwd: wsPath });
1368
+ cloneBranch = execSync("git branch --show-current", { ...execOpts, cwd: wsPath }).trim() || "main";
1369
+ execSync(`git reset --hard origin/${cloneBranch}`, { ...execOpts, cwd: wsPath });
1370
+ } catch (e) {
1371
+ const safeMsg = gitToken ? e.message.replace(gitToken, "***") : e.message;
1372
+ emit("progress", `Update failed (${safeMsg.substring(0, 100)}), re-cloning...`);
1373
+ fs.rmSync(wsPath, { recursive: true, force: true });
1374
+ }
1375
+ }
1376
+
1377
+ if (!fs.existsSync(path.join(wsPath, ".git"))) {
1378
+ // Remove existing empty dir before clone (git clone needs non-existent or empty target)
1379
+ if (fs.existsSync(wsPath)) {
1380
+ const contents = fs.readdirSync(wsPath);
1381
+ if (contents.length === 0) fs.rmSync(wsPath, { recursive: true });
1382
+ }
1383
+
1384
+ for (const b of ["main", "master", "develop"]) {
1385
+ try {
1386
+ emit("progress", `Trying branch: ${b}...`);
1387
+ execSync(`git clone --depth=1 --branch ${b} "${authUrl}" "${wsPath}"`, execOpts);
1388
+ cloneBranch = b;
1389
+ emit("progress", `Cloned on branch: ${b}`);
1390
+ break;
1391
+ } catch (e) {
1392
+ const stderr = (e.stderr || e.stdout || e.message || "").toString();
1393
+ const safeMsg = gitToken ? stderr.replace(new RegExp(gitToken, 'g'), "***") : stderr;
1394
+ console.warn(`[Clone] Branch ${b} failed: ${safeMsg.substring(0, 500)}`);
1395
+ emit("progress", `Branch ${b} failed: ${safeMsg.substring(0, 200)}`);
1396
+ // Clean up partial clone
1397
+ if (fs.existsSync(wsPath)) fs.rmSync(wsPath, { recursive: true, force: true });
1398
+ }
1399
+ }
1400
+
1401
+ // Fallback: clone without specifying branch
1402
+ if (!fs.existsSync(path.join(wsPath, ".git"))) {
1403
+ try {
1404
+ emit("progress", "Trying default branch...");
1405
+ execSync(`git clone --depth=1 "${authUrl}" "${wsPath}" 2>&1`, execOpts);
1406
+ cloneBranch = execSync("git branch --show-current", { ...execOpts, cwd: wsPath }).trim() || "main";
1407
+ emit("progress", `Cloned on default branch: ${cloneBranch}`);
1408
+ } catch (e) {
1409
+ const stderr = (e.stderr || e.stdout || e.message || "").toString();
1410
+ const safeMsg = gitToken ? stderr.replace(new RegExp(gitToken, 'g'), "***") : stderr;
1411
+ console.error(`[Clone] Default branch failed: ${safeMsg.substring(0, 500)}`);
1412
+ emit("progress", `Default branch failed: ${safeMsg.substring(0, 200)}`);
1413
+ if (fs.existsSync(wsPath)) fs.rmSync(wsPath, { recursive: true, force: true });
1414
+ }
1415
+ }
1416
+ }
1417
+
1418
+ if (!fs.existsSync(path.join(wsPath, ".git"))) {
1419
+ emit("error", { error: "Clone failed for all branches. Check repo URL and permissions." });
1420
+ return res.end();
1421
+ }
1422
+ emit("progress", `Repository cloned on branch: ${cloneBranch}`);
1423
+
1424
+ // STEP 2 - Detect stack
1425
+ emit("progress", "AI analysing project structure...");
1426
+ const meta = await detectProject(wsPath);
1427
+ emit("progress", `Stack: ${meta.language} / ${meta.buildTool} / ${meta.framework || "unknown"}`);
1428
+
1429
+ // STEP 3 - Generate Dockerfile if missing
1430
+ const dockerfilePath = path.join(wsPath, "Dockerfile");
1431
+ if (!fs.existsSync(dockerfilePath)) {
1432
+ emit("progress", `Generating Dockerfile for ${meta.language}...`);
1433
+ const content = generateDockerfileContent(meta.language, meta.buildTool);
1434
+ fs.writeFileSync(dockerfilePath, content, "utf8");
1435
+ emit("progress", "Dockerfile generated");
1436
+ } else {
1437
+ emit("progress", "Dockerfile already exists");
1438
+ }
1439
+
1440
+ // STEP 4 - Register
1441
+ emit("progress", `Provider: ${provider}. Workspace ready.`);
1442
+
1443
+ emit("progress", "AI Cloud Engineering setup complete!");
1444
+ emit("complete", {
1445
+ success: true, workspaceId, wsPath, provider, branch: cloneBranch,
1446
+ stack: { language: meta.language, buildTool: meta.buildTool, framework: meta.framework },
1447
+ message: "Setup complete. Monitoring is now active."
1448
+ });
1449
+ res.end();
1450
+
1451
+ } catch (err) {
1452
+ console.error("[Setup] Error:", err.message);
1453
+ emit("error", { error: err.message });
1454
+ res.end();
1455
+ }
1456
+ });
1457
+
1458
+ function generateDockerfileContent(language, buildTool) {
1459
+ if (language === "java" && (buildTool === "maven" || buildTool === "mvn"))
1460
+ 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`;
1461
+ if (language === "java")
1462
+ 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`;
1463
+ if (language === "node" || language === "javascript" || language === "typescript")
1464
+ 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`;
1465
+ if (language === "python")
1466
+ 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`;
1467
+ if (language === "go")
1468
+ 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`;
1469
+ return `FROM ubuntu:22.04\nWORKDIR /app\nCOPY . .\nEXPOSE 8080\nCMD ["./start.sh"]\n`;
1470
+ }
1471
+
1472
+ /** Detect project stack from files (pom.xml, package.json, etc.) */
1473
+ app.post("/workspace/detect", async (req, res) => {
1474
+ const wsRoot = resolveWorkspaceRoot(req);
1475
+ if (!wsRoot) return res.status(400).json({ ok: false, error: "workspace not set" });
1476
+ try {
1477
+ const meta = await detectProject(wsRoot);
1478
+ const fs = await import("fs");
1479
+ const path = await import("path");
1480
+ res.json({
1481
+ ok: true,
1482
+ language: meta.language || "unknown",
1483
+ buildTool: meta.buildTool || "unknown",
1484
+ framework: meta.framework || "unknown",
1485
+ hasDockerfile: fs.existsSync(path.join(wsRoot, "Dockerfile")),
1486
+ path: wsRoot
1487
+ });
1488
+ } catch (e) {
1489
+ res.json({ ok: false, language: "unknown", buildTool: "unknown",
1490
+ framework: "unknown", error: e.message });
1491
+ }
1492
+ });
1493
+
1099
1494
  /** Escreve/salva conteudo de arquivo no workspace */
1100
1495
  app.post("/workspace/write-file", async (req, res) => {
1101
1496
  const workspaceRoot = resolveWorkspaceRoot(req);
@@ -1826,6 +2221,156 @@ app.get("/workspace/test-local/state", async (req, res) => {
1826
2221
  });
1827
2222
  });
1828
2223
 
2224
+ /**
2225
+ * POST /workspace/git/prepare
2226
+ * Prepares a clean workspace for a new fix:
2227
+ * 1. Detects main branch (main, master, develop)
2228
+ * 2. git fetch origin
2229
+ * 3. git checkout main branch
2230
+ * 4. git reset --hard origin/main branch
2231
+ */
2232
+ app.post("/workspace/git/prepare", async (req, res) => {
2233
+ const wsRoot = resolveWorkspaceRoot(req);
2234
+ if (!wsRoot) {
2235
+ return res.status(400).json({ ok: false, error: "workspace not set" });
2236
+ }
2237
+
2238
+ const { gitToken, repoUrl, gitProvider, branch: reqBranch, workspaceId } = req.body || {};
2239
+ console.log(`[git-prepare] Starting workspace preparation: wsRoot=${wsRoot} repoUrl=${repoUrl || 'none'}`);
2240
+
2241
+ try {
2242
+ const { execSync } = await import("child_process");
2243
+ const execOpts = { encoding: "utf8", timeout: 120000, env: { ...process.env, GIT_TERMINAL_PROMPT: "0" } };
2244
+ const execGit = (cmd) => {
2245
+ try {
2246
+ return { ok: true, output: execSync(cmd, {
2247
+ cwd: wsRoot, ...execOpts
2248
+ }).trim() };
2249
+ } catch (err) {
2250
+ return { ok: false, output: (err.stderr || err.stdout || err.message || "unknown").toString().trim() };
2251
+ }
2252
+ };
2253
+
2254
+ execGit(`git config --global --add safe.directory "${wsRoot}"`);
2255
+
2256
+ // If workspace is empty (no .git), clone first before attempting fetch/reset
2257
+ const gitDir = path.join(wsRoot, ".git");
2258
+ if (!fs.existsSync(gitDir)) {
2259
+ if (!repoUrl) {
2260
+ return res.json({ ok: false, error: "Workspace is empty and no repoUrl provided for clone" });
2261
+ }
2262
+ console.log(`[git-prepare] No .git found at ${wsRoot}, cloning from ${repoUrl}`);
2263
+ fs.mkdirSync(wsRoot, { recursive: true });
2264
+
2265
+ // Build authenticated URL
2266
+ let authUrl = repoUrl;
2267
+ if (gitToken) {
2268
+ const prov = (gitProvider || "").toLowerCase();
2269
+ if (repoUrl.includes("bitbucket.org") || prov === "bitbucket") {
2270
+ authUrl = repoUrl.replace(/https:\/\/([^@]*@)?/, `https://x-token-auth:${gitToken}@`);
2271
+ } else if (repoUrl.includes("gitlab.com") || prov === "gitlab") {
2272
+ authUrl = repoUrl.replace(/https:\/\/([^@]*@)?/, `https://oauth2:${gitToken}@`);
2273
+ } else {
2274
+ authUrl = repoUrl.replace(/https:\/\/([^@]*@)?/, `https://x-access-token:${gitToken}@`);
2275
+ }
2276
+ }
2277
+ if (!authUrl.endsWith(".git")) authUrl += ".git";
2278
+
2279
+ const branchesToTry = [reqBranch || "main", "main", "master"].filter((b, i, a) => a.indexOf(b) === i);
2280
+ let cloned = false;
2281
+ let clonedBranch = "main";
2282
+ for (const b of branchesToTry) {
2283
+ try {
2284
+ fs.rmSync(wsRoot, { recursive: true, force: true });
2285
+ fs.mkdirSync(wsRoot, { recursive: true });
2286
+ execSync(`git clone --depth 50 --branch "${b}" "${authUrl}" "${wsRoot}"`, execOpts);
2287
+ clonedBranch = b;
2288
+ cloned = true;
2289
+ console.log(`[git-prepare] Cloned branch ${b} successfully`);
2290
+ break;
2291
+ } catch (cloneErr) {
2292
+ console.warn(`[git-prepare] Clone branch ${b} failed: ${(cloneErr.message || "").slice(0, 100)}`);
2293
+ }
2294
+ }
2295
+ if (!cloned) {
2296
+ try {
2297
+ fs.rmSync(wsRoot, { recursive: true, force: true });
2298
+ fs.mkdirSync(wsRoot, { recursive: true });
2299
+ execSync(`git clone --depth 50 "${authUrl}" "${wsRoot}"`, execOpts);
2300
+ cloned = true;
2301
+ clonedBranch = "default";
2302
+ } catch (e) {
2303
+ return res.json({ ok: false, error: "Clone failed: " + (e.message || "").slice(0, 200) });
2304
+ }
2305
+ }
2306
+
2307
+ // Update WORKSPACE_ROOT so subsequent tool calls find the files
2308
+ WORKSPACE_ROOT = wsRoot;
2309
+ try { await wsManager.open(workspaceId || path.basename(wsRoot), wsRoot); } catch (_) {}
2310
+
2311
+ const commit = execGit("git rev-parse --short HEAD");
2312
+ let fileCount = 0;
2313
+ try { function walk(d) { for (const e of fs.readdirSync(d, {withFileTypes:true})) { if (e.name==='.git') continue; if (e.isDirectory()) walk(path.join(d,e.name)); else fileCount++; } } walk(wsRoot); } catch (_) {}
2314
+ return res.json({ ok: true, cloned: true, mainBranch: clonedBranch,
2315
+ currentCommit: commit.ok ? commit.output : "unknown", fileCount,
2316
+ message: `Repository cloned: ${clonedBranch} (${fileCount} files)` });
2317
+ }
2318
+
2319
+ if (gitToken && repoUrl) {
2320
+ let authUrl = repoUrl;
2321
+ const prov = (gitProvider || "").toLowerCase();
2322
+ if (repoUrl.includes("bitbucket.org") || prov === "bitbucket") {
2323
+ authUrl = repoUrl.replace(/https:\/\/([^@]*@)?/, `https://x-token-auth:${gitToken}@`);
2324
+ } else if (repoUrl.includes("gitlab.com") || prov === "gitlab") {
2325
+ authUrl = repoUrl.replace(/https:\/\/([^@]*@)?/, `https://oauth2:${gitToken}@`);
2326
+ } else {
2327
+ authUrl = repoUrl.replace(/https:\/\/([^@]*@)?/, `https://x-access-token:${gitToken}@`);
2328
+ }
2329
+ execGit(`git remote set-url origin "${authUrl}"`);
2330
+ }
2331
+
2332
+ console.log("[git-prepare] Fetching origin...");
2333
+ execGit("git fetch origin --prune");
2334
+
2335
+ let mainBranch = null;
2336
+ for (const branch of ["main", "master", "develop", "dev"]) {
2337
+ if (execGit(`git rev-parse --verify origin/${branch}`).ok) {
2338
+ mainBranch = branch;
2339
+ break;
2340
+ }
2341
+ }
2342
+ if (!mainBranch) {
2343
+ const cur = execGit("git rev-parse --abbrev-ref HEAD");
2344
+ mainBranch = cur.ok ? cur.output : "main";
2345
+ }
2346
+
2347
+ const prevBranch = (execGit("git rev-parse --abbrev-ref HEAD").output || "unknown");
2348
+ const prevCommit = (execGit("git rev-parse --short HEAD").output || "unknown");
2349
+
2350
+ console.log(`[git-prepare] ${prevBranch}@${prevCommit} -> ${mainBranch}`);
2351
+
2352
+ let checkout = execGit(`git checkout ${mainBranch}`);
2353
+ if (!checkout.ok) checkout = execGit(`git checkout -f ${mainBranch}`);
2354
+ if (!checkout.ok) {
2355
+ return res.json({ ok: false, error: `Could not checkout ${mainBranch}: ${checkout.output}`,
2356
+ mainBranch, previousBranch: prevBranch, previousCommit: prevCommit });
2357
+ }
2358
+
2359
+ execGit(`git reset --hard origin/${mainBranch}`);
2360
+ execGit('git clean -fd --exclude="*.env" --exclude=".env*"');
2361
+
2362
+ const newCommit = (execGit("git rev-parse --short HEAD").output || "unknown");
2363
+ console.log(`[git-prepare] Ready: ${mainBranch}@${newCommit}`);
2364
+
2365
+ res.json({ ok: true, mainBranch, currentCommit: newCommit,
2366
+ previousBranch: prevBranch, previousCommit: prevCommit,
2367
+ message: `Workspace prepared: on ${mainBranch} at ${newCommit}` });
2368
+ } catch (err) {
2369
+ console.error("[git-prepare] Error:", err.message);
2370
+ res.status(500).json({ ok: false, error: err.message });
2371
+ }
2372
+ });
2373
+
1829
2374
  /**
1830
2375
  * POST /workspace/test-local/compile
1831
2376
  * Compiles the project without starting server
@@ -6310,6 +6855,186 @@ app.delete("/workspace/vibe/tunnel/:sessionId", async (req, res) => {
6310
6855
  // ============================================
6311
6856
  registerVercelRoutes(app);
6312
6857
 
6858
+ // ============================================
6859
+ // LIVE PREVIEW — iOS / Android / Web
6860
+ // ============================================
6861
+
6862
+ app.get('/workspace/preview', async (req, res) => {
6863
+ const wsRoot = req.headers['x-workspace-root'] || WORKSPACE_ROOT;
6864
+ if (!wsRoot) {
6865
+ return res.json({ type: 'none', message: 'Workspace not set' });
6866
+ }
6867
+
6868
+ try {
6869
+ const meta = await detectProject(wsRoot);
6870
+ const lang = (meta.language || '').toLowerCase();
6871
+ const fw = (meta.framework || '').toLowerCase();
6872
+
6873
+ if (lang === 'ios' || lang === 'swift' || fw.includes('swiftui') || fw.includes('uikit')) {
6874
+ const html = renderSwiftUIPreview(wsRoot);
6875
+ return res.json({ type: 'ios', html, framework: fw, message: 'SwiftUI static render' });
6876
+ }
6877
+
6878
+ if (lang === 'android' || lang === 'kotlin' || fw.includes('compose') || fw.includes('android')) {
6879
+ const html = renderAndroidPreview(wsRoot);
6880
+ return res.json({ type: 'android', html, framework: fw, message: 'Android static render' });
6881
+ }
6882
+
6883
+ const webFw = ['react','next','vue','nuxt','angular','svelte','vite'];
6884
+ if (webFw.some(f => fw.includes(f))) {
6885
+ let cmd = 'npm run dev';
6886
+ if (fw.includes('angular')) cmd = 'npx ng serve --port 4200 --host 0.0.0.0';
6887
+ return res.json({ type: 'web', framework: fw, startCommand: cmd, message: 'Start dev server for live preview' });
6888
+ }
6889
+
6890
+ return res.json({ type: 'none', language: lang, framework: fw, message: 'No preview for ' + lang + '/' + fw });
6891
+ } catch (err) {
6892
+ console.error('[preview]', err.message);
6893
+ res.json({ type: 'none', message: err.message });
6894
+ }
6895
+ });
6896
+
6897
+ function renderSwiftUIPreview(wsRoot) {
6898
+ const swiftFiles = [];
6899
+ function walk(dir) {
6900
+ try {
6901
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
6902
+ const full = path.join(dir, entry.name);
6903
+ if (entry.isDirectory() && !['node_modules','.git','build','DerivedData','.build'].includes(entry.name)) {
6904
+ walk(full);
6905
+ } else if (entry.name.endsWith('.swift')) {
6906
+ swiftFiles.push(full);
6907
+ }
6908
+ }
6909
+ } catch (_) {}
6910
+ }
6911
+ walk(wsRoot);
6912
+ if (swiftFiles.length === 0) return buildPhoneFrame('<p style="color:#999;padding:20px">No Swift files found</p>', 'iOS');
6913
+
6914
+ const priority = ['ContentView.swift','TaskListView.swift','HomeView.swift','MainView.swift'];
6915
+ swiftFiles.sort((a, b) => {
6916
+ const ai = priority.findIndex(p => a.endsWith(p));
6917
+ const bi = priority.findIndex(p => b.endsWith(p));
6918
+ return (ai === -1 ? 99 : ai) - (bi === -1 ? 99 : bi);
6919
+ });
6920
+
6921
+ const bodyParts = [];
6922
+ for (const f of swiftFiles.slice(0, 5)) {
6923
+ try { bodyParts.push(parseSwiftUI(path.basename(f), fs.readFileSync(f, 'utf8'))); } catch (_) {}
6924
+ }
6925
+ return buildPhoneFrame(bodyParts.join('\n'), 'iOS');
6926
+ }
6927
+
6928
+ function parseSwiftUI(name, src) {
6929
+ let h = '<div class="vb"><div class="vn">' + name + '</div>';
6930
+ if (src.includes('TabView')) {
6931
+ const tabs = [...src.matchAll(/\.tabItem\s*\{[^}]*Label\s*\(\s*"([^"]+)"/g)].map(m => m[1]);
6932
+ if (tabs.length) h += '<div class="tb">' + tabs.map(t => '<div class="ti">' + t + '</div>').join('') + '</div>';
6933
+ }
6934
+ if (src.includes('NavigationStack') || src.includes('NavigationView')) {
6935
+ const m = src.match(/\.navigationTitle\s*\(\s*"([^"]+)"/);
6936
+ h += '<div class="nb"><span>' + (m ? m[1] : 'Navigation') + '</span></div>';
6937
+ }
6938
+ if (src.includes('List') || src.includes('ForEach')) {
6939
+ h += '<div class="lc">';
6940
+ const rows = [...src.matchAll(/Text\s*\(\s*"([^"]+)"\s*\)/g)].slice(0, 6);
6941
+ (rows.length ? rows : [{1:'Task item'},{1:'Task item'},{1:'Task item'}]).forEach(r => {
6942
+ h += '<div class="lr">' + (r[1] || 'Item') + '</div>';
6943
+ });
6944
+ h += '</div>';
6945
+ }
6946
+ const btns = [...src.matchAll(/Button\s*\(?\s*"([^"]+)"/g)].slice(0, 3);
6947
+ if (btns.length) { h += '<div class="bg">'; btns.forEach(b => { h += '<button class="ib">' + b[1] + '</button>'; }); h += '</div>'; }
6948
+ if (src.includes('import Charts') || src.includes('Chart {')) {
6949
+ h += '<div class="cp"><div class="cb">' + [60,85,45,90,70].map(v => '<div class="bar" style="height:' + v + '%"></div>').join('') + '</div><div class="cl">Charts</div></div>';
6950
+ }
6951
+ const texts = [...src.matchAll(/Text\s*\(\s*"([^"]+)"\s*\)/g)].map(m => m[1]).filter(t => t.length > 2 && t.length < 60).slice(0, 4);
6952
+ if (texts.length && !src.includes('List')) { h += '<div class="tg">'; texts.forEach(t => { h += '<p class="it">' + t + '</p>'; }); h += '</div>'; }
6953
+ return h + '</div>';
6954
+ }
6955
+
6956
+ function renderAndroidPreview(wsRoot) {
6957
+ const ktFiles = [];
6958
+ function walk(dir) {
6959
+ try {
6960
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
6961
+ const full = path.join(dir, entry.name);
6962
+ if (entry.isDirectory() && !['node_modules','.git','build','.gradle'].includes(entry.name)) {
6963
+ walk(full);
6964
+ } else if (entry.name.endsWith('.kt')) {
6965
+ ktFiles.push(full);
6966
+ }
6967
+ }
6968
+ } catch (_) {}
6969
+ }
6970
+ walk(wsRoot);
6971
+ if (ktFiles.length === 0) return buildPhoneFrame('<p style="color:#999;padding:20px">No Kotlin files found</p>', 'Android');
6972
+
6973
+ const priority = ['MainActivity.kt','MainScreen.kt','HomeScreen.kt'];
6974
+ ktFiles.sort((a, b) => {
6975
+ const ai = priority.findIndex(p => a.endsWith(p));
6976
+ const bi = priority.findIndex(p => b.endsWith(p));
6977
+ return (ai === -1 ? 99 : ai) - (bi === -1 ? 99 : bi);
6978
+ });
6979
+
6980
+ const bodyParts = [];
6981
+ for (const f of ktFiles.slice(0, 5)) {
6982
+ try { bodyParts.push(parseKotlin(path.basename(f), fs.readFileSync(f, 'utf8'))); } catch (_) {}
6983
+ }
6984
+ return buildPhoneFrame(bodyParts.join('\n'), 'Android');
6985
+ }
6986
+
6987
+ function parseKotlin(name, src) {
6988
+ let h = '<div class="vb"><div class="vn">' + name + '</div>';
6989
+ const tm = src.match(/title\s*=\s*\{\s*Text\s*\(\s*"([^"]+)"/);
6990
+ if (tm) h += '<div class="nb mat"><span>' + tm[1] + '</span></div>';
6991
+ if (src.includes('LazyColumn') || src.includes('items(')) {
6992
+ h += '<div class="lc mat">';
6993
+ const rows = [...src.matchAll(/Text\s*\(\s*(?:text\s*=\s*)?"([^"]+)"/g)].slice(0, 5);
6994
+ (rows.length ? rows : [{1:'List item'},{1:'List item'},{1:'List item'}]).forEach(r => {
6995
+ h += '<div class="lr mat">' + (r[1] || 'Item') + '</div>';
6996
+ });
6997
+ h += '</div>';
6998
+ }
6999
+ const btns = [...src.matchAll(/Button\s*\([^)]*onClick[^)]*\)\s*\{[^}]*Text\s*\(\s*"([^"]+)"/g)].slice(0, 3);
7000
+ if (btns.length) { h += '<div class="bg">'; btns.forEach(b => { h += '<button class="mb">' + b[1] + '</button>'; }); h += '</div>'; }
7001
+ return h + '</div>';
7002
+ }
7003
+
7004
+ function buildPhoneFrame(body, platform) {
7005
+ const iOS = platform === 'iOS';
7006
+ const bg = iOS ? '#f2f2f7' : '#fafafa';
7007
+ const accent = iOS ? '#007AFF' : '#6200EA';
7008
+ const font = iOS ? "-apple-system,BlinkMacSystemFont,'SF Pro Display',sans-serif" : "'Roboto','Google Sans',sans-serif";
7009
+ const sb = iOS
7010
+ ? '<div class="sb ios"><span>9:41</span><span style="margin-left:auto">WiFi 100%</span></div>'
7011
+ : '<div class="sb and"><span>12:00</span><span style="margin-left:auto">WiFi</span></div>';
7012
+ return '<!DOCTYPE html><html><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1"><style>'
7013
+ + '*{box-sizing:border-box;margin:0;padding:0}'
7014
+ + 'body{background:#1a1a2e;display:flex;align-items:center;justify-content:center;min-height:100vh;font-family:' + font + '}'
7015
+ + '.pf{width:375px;min-height:812px;background:' + bg + ';border-radius:44px;border:8px solid #2d2d2d;overflow:hidden;box-shadow:0 30px 80px rgba(0,0,0,.8);position:relative;display:flex;flex-direction:column}'
7016
+ + '.pf::before{content:"";position:absolute;top:14px;left:50%;transform:translateX(-50%);width:' + (iOS?'120':'100') + 'px;height:' + (iOS?'28':'24') + 'px;background:#2d2d2d;border-radius:14px;z-index:10}'
7017
+ + '.pc{flex:1;overflow-y:auto;padding-top:' + (iOS?'56':'48') + 'px;padding-bottom:34px}'
7018
+ + '.sb{position:absolute;top:0;left:0;right:0;height:' + (iOS?'54':'44') + 'px;display:flex;align-items:flex-end;padding:0 24px 8px;font-size:12px;font-weight:600;z-index:9;background:' + bg + '}'
7019
+ + '.nb{background:' + bg + ';border-bottom:.5px solid rgba(0,0,0,.12);padding:12px 16px;font-size:17px;font-weight:600;text-align:' + (iOS?'center':'left') + '}'
7020
+ + '.nb.mat{background:' + accent + ';color:#fff;font-size:20px;font-weight:500;padding:16px;text-align:left}'
7021
+ + '.tb{position:absolute;bottom:0;left:0;right:0;height:83px;background:' + bg + ';border-top:.5px solid rgba(0,0,0,.12);display:flex;align-items:flex-start;padding-top:10px}'
7022
+ + '.ti{flex:1;text-align:center;font-size:10px;color:' + accent + ';padding-top:24px}'
7023
+ + '.lc{background:#fff;margin:16px 0;border-top:.5px solid rgba(0,0,0,.12);border-bottom:.5px solid rgba(0,0,0,.12)}.lc.mat{margin:0}'
7024
+ + '.lr{padding:14px 16px;border-bottom:.5px solid rgba(0,0,0,.08);font-size:16px;color:#1c1c1e}.lr::after{content:">";margin-left:auto;color:#c7c7cc;float:right}.lr.mat{background:#fff}'
7025
+ + '.bg{padding:16px;display:flex;flex-direction:column;gap:10px}'
7026
+ + '.ib{background:' + accent + ';color:#fff;border:none;border-radius:12px;padding:14px;font-size:16px;font-weight:600;width:100%;cursor:pointer}'
7027
+ + '.mb{background:' + accent + ';color:#fff;border:none;border-radius:4px;padding:12px 24px;font-size:14px;font-weight:500;text-transform:uppercase;letter-spacing:.5px;cursor:pointer;box-shadow:0 2px 4px rgba(0,0,0,.2)}'
7028
+ + '.tg{padding:16px}.it{font-size:15px;color:#1c1c1e;margin:6px 0;line-height:1.4}'
7029
+ + '.vb{margin-bottom:4px}.vn{font-size:10px;color:#8e8e93;padding:4px 16px;background:rgba(0,0,0,.03);font-family:monospace}'
7030
+ + '.cp{margin:16px;background:#fff;border-radius:12px;padding:16px;box-shadow:0 1px 3px rgba(0,0,0,.1)}'
7031
+ + '.cb{display:flex;align-items:flex-end;gap:8px;height:100px;padding:8px 0}'
7032
+ + '.bar{flex:1;background:' + accent + ';border-radius:4px 4px 0 0;opacity:.8}'
7033
+ + '.cl{text-align:center;font-size:11px;color:#8e8e93;margin-top:8px}'
7034
+ + '.badge{position:absolute;top:-6px;right:20px;background:' + accent + ';color:#fff;font-size:10px;font-weight:700;padding:2px 8px;border-radius:4px;letter-spacing:1px}'
7035
+ + '</style></head><body><div style="position:relative"><div class="badge">' + platform.toUpperCase() + '</div><div class="pf">' + sb + '<div class="pc">' + body + '</div></div></div></body></html>';
7036
+ }
7037
+
6313
7038
  // ============================================
6314
7039
  // START SERVER
6315
7040
 
@@ -6358,4 +7083,4 @@ try {
6358
7083
  // Auto-register default workspace in WorkspaceManager
6359
7084
  if (WORKSPACE_ROOT) {
6360
7085
  wsManager.open("default", WORKSPACE_ROOT).catch(() => {});
6361
- }
7086
+ }