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/CLAUDE_CODE_AGENT_INFINITE.md +623 -0
- package/Dockerfile +57 -20
- package/Dockerfile.full +63 -0
- package/cloudbuild-agent-staging.yaml +40 -0
- package/local-agent-source.tar.gz +0 -0
- package/package.json +4 -3
- package/src/detectors/framework-detector.js +33 -0
- package/src/detectors/language-detector.js +54 -12
- package/src/detectors.js +291 -8
- package/src/exec-utils.js +201 -16
- package/src/local-agent-source.tar.gz +0 -0
- package/src/runtimes/java/maven-runtime.js +4 -3
- package/src/server.js +729 -4
- package/src/utils/exec-utils.js +8 -4
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:
|
|
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
|
-
|
|
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
|
+
}
|