bosun 0.28.0 → 0.28.2
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/.env.example +22 -1
- package/README.md +16 -5
- package/agent-pool.mjs +16 -2
- package/agent-work-analyzer.mjs +419 -0
- package/claude-shell.mjs +12 -2
- package/desktop/launch.mjs +31 -2
- package/desktop/main.mjs +132 -10
- package/desktop/package.json +8 -0
- package/desktop-shortcut.mjs +20 -1
- package/monitor.mjs +151 -0
- package/package.json +3 -2
- package/rotate-agent-logs.sh +8 -7
- package/setup.mjs +759 -59
- package/task-executor.mjs +309 -12
- package/telegram-bot.mjs +419 -5
- package/ui/app.js +2 -0
- package/ui/components/shared.js +31 -2
- package/ui/demo.html +8 -0
- package/ui/modules/icons.js +14 -0
- package/ui/modules/router.js +1 -0
- package/ui/modules/settings-schema.js +11 -1
- package/ui/modules/state.js +59 -0
- package/ui/styles/components.css +60 -8
- package/ui/styles.css +88 -0
- package/ui/tabs/tasks.js +110 -72
- package/ui/tabs/telemetry.js +167 -0
- package/ui-server.mjs +158 -0
- package/workspace-manager.mjs +45 -1
- package/ui/styles/workspace-switcher.css.bak +0 -693
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
/* ─────────────────────────────────────────────────────────────
|
|
2
|
+
* Tab: Telemetry — analytics, quality signals, alerts
|
|
3
|
+
* ────────────────────────────────────────────────────────────── */
|
|
4
|
+
import { h } from "preact";
|
|
5
|
+
import { useMemo } from "preact/hooks";
|
|
6
|
+
import htm from "htm";
|
|
7
|
+
|
|
8
|
+
const html = htm.bind(h);
|
|
9
|
+
|
|
10
|
+
import {
|
|
11
|
+
telemetrySummary,
|
|
12
|
+
telemetryErrors,
|
|
13
|
+
telemetryExecutors,
|
|
14
|
+
telemetryAlerts,
|
|
15
|
+
loadTelemetrySummary,
|
|
16
|
+
loadTelemetryErrors,
|
|
17
|
+
loadTelemetryExecutors,
|
|
18
|
+
loadTelemetryAlerts,
|
|
19
|
+
scheduleRefresh,
|
|
20
|
+
} from "../modules/state.js";
|
|
21
|
+
import { Card, EmptyState, SkeletonCard, Badge } from "../components/shared.js";
|
|
22
|
+
|
|
23
|
+
function formatCount(value) {
|
|
24
|
+
if (value == null) return "–";
|
|
25
|
+
return String(value);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function formatSeconds(value) {
|
|
29
|
+
if (!value && value !== 0) return "–";
|
|
30
|
+
if (value >= 60) return `${Math.round(value / 60)}m`;
|
|
31
|
+
return `${value}s`;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function severityBadge(sev = "medium") {
|
|
35
|
+
const normalized = String(sev).toLowerCase();
|
|
36
|
+
if (normalized === "high" || normalized === "critical") return "danger";
|
|
37
|
+
if (normalized === "medium") return "warning";
|
|
38
|
+
return "info";
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function TelemetryTab() {
|
|
42
|
+
const summary = telemetrySummary.value;
|
|
43
|
+
const errors = telemetryErrors.value || [];
|
|
44
|
+
const executors = telemetryExecutors.value || {};
|
|
45
|
+
const alerts = telemetryAlerts.value || [];
|
|
46
|
+
|
|
47
|
+
const hasSummary = summary && summary.total > 0;
|
|
48
|
+
|
|
49
|
+
const executorRows = useMemo(
|
|
50
|
+
() => Object.entries(executors).sort((a, b) => b[1] - a[1]),
|
|
51
|
+
[executors],
|
|
52
|
+
);
|
|
53
|
+
|
|
54
|
+
const alertRows = useMemo(
|
|
55
|
+
() => alerts.slice(-10).reverse(),
|
|
56
|
+
[alerts],
|
|
57
|
+
);
|
|
58
|
+
|
|
59
|
+
return html`
|
|
60
|
+
<section class="telemetry-tab">
|
|
61
|
+
<div class="section-header">
|
|
62
|
+
<h2>Telemetry</h2>
|
|
63
|
+
<button
|
|
64
|
+
class="btn btn-ghost btn-sm"
|
|
65
|
+
onClick=${() => {
|
|
66
|
+
loadTelemetrySummary();
|
|
67
|
+
loadTelemetryErrors();
|
|
68
|
+
loadTelemetryExecutors();
|
|
69
|
+
loadTelemetryAlerts();
|
|
70
|
+
scheduleRefresh(4000);
|
|
71
|
+
}}
|
|
72
|
+
>
|
|
73
|
+
Refresh
|
|
74
|
+
</button>
|
|
75
|
+
</div>
|
|
76
|
+
|
|
77
|
+
${!hasSummary
|
|
78
|
+
? html`<${EmptyState}
|
|
79
|
+
title="No telemetry yet"
|
|
80
|
+
description="Telemetry appears here once agents start running."
|
|
81
|
+
/>`
|
|
82
|
+
: html`<${Card} title="Summary" class="telemetry-summary">
|
|
83
|
+
<div class="metric-grid">
|
|
84
|
+
<div>
|
|
85
|
+
<div class="metric-label">Sessions</div>
|
|
86
|
+
<div class="metric-value">${formatCount(summary.total)}</div>
|
|
87
|
+
</div>
|
|
88
|
+
<div>
|
|
89
|
+
<div class="metric-label">Success</div>
|
|
90
|
+
<div class="metric-value">
|
|
91
|
+
${formatCount(summary.success)} (${summary.successRate}%)
|
|
92
|
+
</div>
|
|
93
|
+
</div>
|
|
94
|
+
<div>
|
|
95
|
+
<div class="metric-label">Avg Duration</div>
|
|
96
|
+
<div class="metric-value">${formatSeconds(summary.avgDuration)}</div>
|
|
97
|
+
</div>
|
|
98
|
+
<div>
|
|
99
|
+
<div class="metric-label">Errors</div>
|
|
100
|
+
<div class="metric-value">${formatCount(summary.totalErrors)}</div>
|
|
101
|
+
</div>
|
|
102
|
+
</div>
|
|
103
|
+
</${Card}>`}
|
|
104
|
+
|
|
105
|
+
<div class="telemetry-grid">
|
|
106
|
+
<${Card} title="Top Errors">
|
|
107
|
+
${errors.length === 0
|
|
108
|
+
? html`<${EmptyState}
|
|
109
|
+
title="No errors logged"
|
|
110
|
+
description="Errors appear here when failures are detected."
|
|
111
|
+
/>`
|
|
112
|
+
: html`<ul class="telemetry-list">
|
|
113
|
+
${errors.slice(0, 8).map(
|
|
114
|
+
(err) => html`<li>
|
|
115
|
+
<span class="telemetry-label">${err.fingerprint}</span>
|
|
116
|
+
<span class="telemetry-count">${err.count}</span>
|
|
117
|
+
</li>`,
|
|
118
|
+
)}
|
|
119
|
+
</ul>`}
|
|
120
|
+
</${Card}>
|
|
121
|
+
|
|
122
|
+
<${Card} title="Executors">
|
|
123
|
+
${executorRows.length === 0
|
|
124
|
+
? html`<${EmptyState}
|
|
125
|
+
title="No executor data"
|
|
126
|
+
description="Run tasks to populate executor usage."
|
|
127
|
+
/>`
|
|
128
|
+
: html`<ul class="telemetry-list">
|
|
129
|
+
${executorRows.map(
|
|
130
|
+
([name, count]) => html`<li>
|
|
131
|
+
<span class="telemetry-label">${name}</span>
|
|
132
|
+
<span class="telemetry-count">${count}</span>
|
|
133
|
+
</li>`,
|
|
134
|
+
)}
|
|
135
|
+
</ul>`}
|
|
136
|
+
</${Card}>
|
|
137
|
+
</div>
|
|
138
|
+
|
|
139
|
+
<${Card} title="Recent Alerts">
|
|
140
|
+
${alertRows.length === 0
|
|
141
|
+
? html`<${EmptyState}
|
|
142
|
+
title="No alerts"
|
|
143
|
+
description="Analyzer alerts will show up here."
|
|
144
|
+
/>`
|
|
145
|
+
: html`<ul class="telemetry-alerts">
|
|
146
|
+
${alertRows.map(
|
|
147
|
+
(alert) => html`<li>
|
|
148
|
+
<div>
|
|
149
|
+
<div class="telemetry-alert-title">
|
|
150
|
+
${alert.type || "alert"}
|
|
151
|
+
<${Badge} tone=${severityBadge(alert.severity)}>${
|
|
152
|
+
String(alert.severity || "medium").toUpperCase()
|
|
153
|
+
}</${Badge}>
|
|
154
|
+
</div>
|
|
155
|
+
<div class="telemetry-alert-meta">
|
|
156
|
+
${alert.attempt_id || "unknown"}
|
|
157
|
+
${alert.executor ? html` · ${alert.executor}` : ""}
|
|
158
|
+
</div>
|
|
159
|
+
</div>
|
|
160
|
+
</li>`,
|
|
161
|
+
)}
|
|
162
|
+
</ul>`}
|
|
163
|
+
</${Card}>
|
|
164
|
+
</section>
|
|
165
|
+
`;
|
|
166
|
+
}
|
|
167
|
+
|
package/ui-server.mjs
CHANGED
|
@@ -600,6 +600,7 @@ const SETTINGS_KNOWN_KEYS = [
|
|
|
600
600
|
"TELEGRAM_COMMAND_CONCURRENCY", "TELEGRAM_VERBOSITY", "TELEGRAM_BATCH_NOTIFICATIONS",
|
|
601
601
|
"TELEGRAM_BATCH_INTERVAL_SEC", "TELEGRAM_BATCH_MAX_SIZE", "TELEGRAM_IMMEDIATE_PRIORITY",
|
|
602
602
|
"TELEGRAM_API_BASE_URL", "TELEGRAM_HTTP_TIMEOUT_MS", "TELEGRAM_RETRY_ATTEMPTS",
|
|
603
|
+
"TELEGRAM_HISTORY_RETENTION_DAYS",
|
|
603
604
|
"PROJECT_NAME", "TELEGRAM_MINIAPP_ENABLED", "TELEGRAM_UI_PORT", "TELEGRAM_UI_HOST",
|
|
604
605
|
"TELEGRAM_UI_PUBLIC_HOST", "TELEGRAM_UI_BASE_URL", "TELEGRAM_UI_ALLOW_UNSAFE",
|
|
605
606
|
"TELEGRAM_UI_AUTH_MAX_AGE_SEC", "TELEGRAM_UI_TUNNEL",
|
|
@@ -1204,6 +1205,17 @@ async function startTunnel(localPort) {
|
|
|
1204
1205
|
return null;
|
|
1205
1206
|
}
|
|
1206
1207
|
|
|
1208
|
+
// ── SECURITY: Block tunnel when auth is disabled ─────────────────────
|
|
1209
|
+
if (isAllowUnsafe()) {
|
|
1210
|
+
console.error(
|
|
1211
|
+
"[telegram-ui] ⛔ REFUSING to start tunnel — TELEGRAM_UI_ALLOW_UNSAFE=true\n" +
|
|
1212
|
+
" A public tunnel with no authentication lets ANYONE on the internet\n" +
|
|
1213
|
+
" control your agents, read secrets, and execute commands.\n" +
|
|
1214
|
+
" Either set TELEGRAM_UI_ALLOW_UNSAFE=false or TELEGRAM_UI_TUNNEL=disabled.",
|
|
1215
|
+
);
|
|
1216
|
+
return null;
|
|
1217
|
+
}
|
|
1218
|
+
|
|
1207
1219
|
const cfBin = await findCloudflared();
|
|
1208
1220
|
if (!cfBin) {
|
|
1209
1221
|
if (tunnelMode === "auto") {
|
|
@@ -2202,6 +2214,60 @@ async function tailFile(filePath, lineCount, maxBytes = 1_000_000) {
|
|
|
2202
2214
|
};
|
|
2203
2215
|
}
|
|
2204
2216
|
|
|
2217
|
+
async function readJsonlTail(filePath, maxLines = 2000) {
|
|
2218
|
+
if (!existsSync(filePath)) return [];
|
|
2219
|
+
const tail = await tailFile(filePath, maxLines);
|
|
2220
|
+
return (tail.lines || [])
|
|
2221
|
+
.map((line) => {
|
|
2222
|
+
try {
|
|
2223
|
+
return JSON.parse(line);
|
|
2224
|
+
} catch {
|
|
2225
|
+
return null;
|
|
2226
|
+
}
|
|
2227
|
+
})
|
|
2228
|
+
.filter(Boolean);
|
|
2229
|
+
}
|
|
2230
|
+
|
|
2231
|
+
function withinDays(entry, days) {
|
|
2232
|
+
if (!days) return true;
|
|
2233
|
+
const ts = Date.parse(entry?.timestamp || "");
|
|
2234
|
+
if (!Number.isFinite(ts)) return true;
|
|
2235
|
+
return ts >= Date.now() - days * 24 * 60 * 60 * 1000;
|
|
2236
|
+
}
|
|
2237
|
+
|
|
2238
|
+
function summarizeTelemetry(metrics, days) {
|
|
2239
|
+
const filtered = metrics.filter((m) => withinDays(m, days));
|
|
2240
|
+
if (filtered.length === 0) return null;
|
|
2241
|
+
const total = filtered.length;
|
|
2242
|
+
const success = filtered.filter(
|
|
2243
|
+
(m) => m.outcome?.status === "completed" || m.metrics?.success === true,
|
|
2244
|
+
).length;
|
|
2245
|
+
const durations = filtered.map((m) => m.metrics?.duration_ms || 0);
|
|
2246
|
+
const avgDuration =
|
|
2247
|
+
durations.length > 0
|
|
2248
|
+
? Math.round(
|
|
2249
|
+
durations.reduce((a, b) => a + b, 0) / durations.length / 1000,
|
|
2250
|
+
)
|
|
2251
|
+
: 0;
|
|
2252
|
+
const totalErrors = filtered.reduce(
|
|
2253
|
+
(sum, m) => sum + (m.error_summary?.total_errors || m.metrics?.errors || 0),
|
|
2254
|
+
0,
|
|
2255
|
+
);
|
|
2256
|
+
const executors = {};
|
|
2257
|
+
for (const m of filtered) {
|
|
2258
|
+
const exec = m.executor || "unknown";
|
|
2259
|
+
executors[exec] = (executors[exec] || 0) + 1;
|
|
2260
|
+
}
|
|
2261
|
+
return {
|
|
2262
|
+
total,
|
|
2263
|
+
success,
|
|
2264
|
+
successRate: total > 0 ? Math.round((success / total) * 100) : 0,
|
|
2265
|
+
avgDuration,
|
|
2266
|
+
totalErrors,
|
|
2267
|
+
executors,
|
|
2268
|
+
};
|
|
2269
|
+
}
|
|
2270
|
+
|
|
2205
2271
|
async function listAgentLogFiles(query = "", limit = 60) {
|
|
2206
2272
|
const entries = [];
|
|
2207
2273
|
const files = await readdir(agentLogsDir).catch(() => []);
|
|
@@ -3172,6 +3238,79 @@ async function handleApi(req, res, url) {
|
|
|
3172
3238
|
return;
|
|
3173
3239
|
}
|
|
3174
3240
|
|
|
3241
|
+
if (path === "/api/telemetry/summary") {
|
|
3242
|
+
try {
|
|
3243
|
+
const days = Number(url.searchParams.get("days") || "7");
|
|
3244
|
+
const logDir = resolve(repoRoot, ".cache", "agent-work-logs");
|
|
3245
|
+
const metricsPath = resolve(logDir, "agent-metrics.jsonl");
|
|
3246
|
+
const metrics = await readJsonlTail(metricsPath, 3000);
|
|
3247
|
+
const summary = summarizeTelemetry(metrics, days);
|
|
3248
|
+
jsonResponse(res, 200, { ok: true, data: summary });
|
|
3249
|
+
} catch (err) {
|
|
3250
|
+
jsonResponse(res, 500, { ok: false, error: err.message });
|
|
3251
|
+
}
|
|
3252
|
+
return;
|
|
3253
|
+
}
|
|
3254
|
+
|
|
3255
|
+
if (path === "/api/telemetry/errors") {
|
|
3256
|
+
try {
|
|
3257
|
+
const days = Number(url.searchParams.get("days") || "7");
|
|
3258
|
+
const logDir = resolve(repoRoot, ".cache", "agent-work-logs");
|
|
3259
|
+
const errorsPath = resolve(logDir, "agent-errors.jsonl");
|
|
3260
|
+
const errors = (await readJsonlTail(errorsPath, 2000)).filter((e) =>
|
|
3261
|
+
withinDays(e, days),
|
|
3262
|
+
);
|
|
3263
|
+
const byFingerprint = new Map();
|
|
3264
|
+
for (const e of errors) {
|
|
3265
|
+
const fp = e.data?.error_fingerprint || e.data?.error_message || "unknown";
|
|
3266
|
+
byFingerprint.set(fp, (byFingerprint.get(fp) || 0) + 1);
|
|
3267
|
+
}
|
|
3268
|
+
const top = [...byFingerprint.entries()]
|
|
3269
|
+
.sort((a, b) => b[1] - a[1])
|
|
3270
|
+
.slice(0, 20)
|
|
3271
|
+
.map(([fingerprint, count]) => ({ fingerprint, count }));
|
|
3272
|
+
jsonResponse(res, 200, { ok: true, data: top });
|
|
3273
|
+
} catch (err) {
|
|
3274
|
+
jsonResponse(res, 500, { ok: false, error: err.message });
|
|
3275
|
+
}
|
|
3276
|
+
return;
|
|
3277
|
+
}
|
|
3278
|
+
|
|
3279
|
+
if (path === "/api/telemetry/executors") {
|
|
3280
|
+
try {
|
|
3281
|
+
const days = Number(url.searchParams.get("days") || "7");
|
|
3282
|
+
const logDir = resolve(repoRoot, ".cache", "agent-work-logs");
|
|
3283
|
+
const metricsPath = resolve(logDir, "agent-metrics.jsonl");
|
|
3284
|
+
const metrics = await readJsonlTail(metricsPath, 3000);
|
|
3285
|
+
const summary = summarizeTelemetry(metrics, days);
|
|
3286
|
+
jsonResponse(res, 200, {
|
|
3287
|
+
ok: true,
|
|
3288
|
+
data: summary?.executors || {},
|
|
3289
|
+
});
|
|
3290
|
+
} catch (err) {
|
|
3291
|
+
jsonResponse(res, 500, { ok: false, error: err.message });
|
|
3292
|
+
}
|
|
3293
|
+
return;
|
|
3294
|
+
}
|
|
3295
|
+
|
|
3296
|
+
if (path === "/api/telemetry/alerts") {
|
|
3297
|
+
try {
|
|
3298
|
+
const days = Number(url.searchParams.get("days") || "7");
|
|
3299
|
+
const logDir = resolve(repoRoot, ".cache", "agent-work-logs");
|
|
3300
|
+
const alertsPath = resolve(logDir, "agent-alerts.jsonl");
|
|
3301
|
+
const alerts = (await readJsonlTail(alertsPath, 500)).filter((a) =>
|
|
3302
|
+
withinDays(a, days),
|
|
3303
|
+
);
|
|
3304
|
+
jsonResponse(res, 200, {
|
|
3305
|
+
ok: true,
|
|
3306
|
+
data: alerts.slice(-50),
|
|
3307
|
+
});
|
|
3308
|
+
} catch (err) {
|
|
3309
|
+
jsonResponse(res, 500, { ok: false, error: err.message });
|
|
3310
|
+
}
|
|
3311
|
+
return;
|
|
3312
|
+
}
|
|
3313
|
+
|
|
3175
3314
|
if (path === "/api/agent-logs/context") {
|
|
3176
3315
|
try {
|
|
3177
3316
|
const query = url.searchParams.get("query") || "";
|
|
@@ -4808,6 +4947,25 @@ export async function startTelegramUiServer(options = {}) {
|
|
|
4808
4947
|
if (uiServerTls) {
|
|
4809
4948
|
console.log(`[telegram-ui] TLS enabled (self-signed) — Telegram WebApp buttons will use HTTPS`);
|
|
4810
4949
|
}
|
|
4950
|
+
|
|
4951
|
+
// ── SECURITY: Warn loudly when auth is disabled ──────────────────────
|
|
4952
|
+
if (isAllowUnsafe()) {
|
|
4953
|
+
const tunnelMode = (process.env.TELEGRAM_UI_TUNNEL || "auto").toLowerCase();
|
|
4954
|
+
const tunnelActive = tunnelMode !== "disabled" && tunnelMode !== "off" && tunnelMode !== "0";
|
|
4955
|
+
const border = "═".repeat(68);
|
|
4956
|
+
console.warn(`\n╔${border}╗`);
|
|
4957
|
+
console.warn(`║ ⛔ DANGER: TELEGRAM_UI_ALLOW_UNSAFE=true — ALL AUTH IS DISABLED ║`);
|
|
4958
|
+
console.warn(`║ ║`);
|
|
4959
|
+
console.warn(`║ Anyone with your URL can control agents, read secrets, and ║`);
|
|
4960
|
+
console.warn(`║ execute arbitrary commands on this machine. ║`);
|
|
4961
|
+
if (tunnelActive) {
|
|
4962
|
+
console.warn(`║ ║`);
|
|
4963
|
+
console.warn(`║ 🔴 TUNNEL IS ACTIVE — your UI is exposed to the PUBLIC INTERNET ║`);
|
|
4964
|
+
console.warn(`║ This means ANYONE can discover your URL and take control. ║`);
|
|
4965
|
+
console.warn(`║ Set TELEGRAM_UI_TUNNEL=disabled or TELEGRAM_UI_ALLOW_UNSAFE=false ║`);
|
|
4966
|
+
}
|
|
4967
|
+
console.warn(`╚${border}╝\n`);
|
|
4968
|
+
}
|
|
4811
4969
|
console.log(`[telegram-ui] LAN access: ${protocol}://${lanIp}:${actualPort}`);
|
|
4812
4970
|
console.log(`[telegram-ui] Browser access: ${protocol}://${lanIp}:${actualPort}/?token=${sessionToken}`);
|
|
4813
4971
|
|
package/workspace-manager.mjs
CHANGED
|
@@ -412,7 +412,51 @@ export function pullWorkspaceRepos(configDir, workspaceId) {
|
|
|
412
412
|
for (const repo of ws.repos || []) {
|
|
413
413
|
const repoPath = resolve(ws.path, repo.name);
|
|
414
414
|
if (!existsSync(repoPath)) {
|
|
415
|
-
|
|
415
|
+
const repoUrl =
|
|
416
|
+
repo.url ||
|
|
417
|
+
(repo.slug ? `https://github.com/${repo.slug.replace(/\.git$/i, "")}.git` : "");
|
|
418
|
+
if (!repoUrl) {
|
|
419
|
+
results.push({
|
|
420
|
+
name: repo.name,
|
|
421
|
+
success: false,
|
|
422
|
+
error: "Directory not found and repo URL is missing",
|
|
423
|
+
});
|
|
424
|
+
continue;
|
|
425
|
+
}
|
|
426
|
+
try {
|
|
427
|
+
mkdirSync(ws.path, { recursive: true });
|
|
428
|
+
console.log(TAG, `Cloning ${repoUrl} into ${repoPath}...`);
|
|
429
|
+
const clone = spawnSync("git", ["clone", repoUrl, repoPath], {
|
|
430
|
+
encoding: "utf8",
|
|
431
|
+
timeout: 300000,
|
|
432
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
433
|
+
});
|
|
434
|
+
if (clone.status !== 0) {
|
|
435
|
+
results.push({
|
|
436
|
+
name: repo.name,
|
|
437
|
+
success: false,
|
|
438
|
+
error: `git clone failed: ${
|
|
439
|
+
clone.stderr || clone.stdout || clone.error?.message || "unknown error"
|
|
440
|
+
}`,
|
|
441
|
+
});
|
|
442
|
+
continue;
|
|
443
|
+
}
|
|
444
|
+
console.log(TAG, `Cloned ${repo.name} successfully`);
|
|
445
|
+
} catch (err) {
|
|
446
|
+
results.push({
|
|
447
|
+
name: repo.name,
|
|
448
|
+
success: false,
|
|
449
|
+
error: `git clone failed: ${err.message || err}`,
|
|
450
|
+
});
|
|
451
|
+
continue;
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
if (!existsSync(resolve(repoPath, ".git"))) {
|
|
455
|
+
results.push({
|
|
456
|
+
name: repo.name,
|
|
457
|
+
success: false,
|
|
458
|
+
error: "Directory exists but is not a git repository",
|
|
459
|
+
});
|
|
416
460
|
continue;
|
|
417
461
|
}
|
|
418
462
|
try {
|