bosun 0.28.2 → 0.28.4

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/ui-server.mjs CHANGED
@@ -618,6 +618,8 @@ const SETTINGS_KNOWN_KEYS = [
618
618
  "KANBAN_BACKEND", "KANBAN_SYNC_POLICY", "BOSUN_TASK_LABEL",
619
619
  "BOSUN_ENFORCE_TASK_LABEL", "STALE_TASK_AGE_HOURS",
620
620
  "TASK_PLANNER_MODE", "TASK_PLANNER_DEDUP_HOURS",
621
+ "TASK_BRANCH_MODE", "TASK_BRANCH_AUTO_MODULE", "TASK_UPSTREAM_SYNC_MAIN",
622
+ "MODULE_BRANCH_PREFIX", "DEFAULT_TARGET_BRANCH",
621
623
  "BOSUN_PROMPT_PLANNER",
622
624
  "GITHUB_TOKEN", "GITHUB_REPOSITORY", "GITHUB_PROJECT_MODE",
623
625
  "GITHUB_PROJECT_NUMBER", "GITHUB_DEFAULT_ASSIGNEE", "GITHUB_AUTO_ASSIGN_CREATOR",
@@ -2113,6 +2115,430 @@ async function handleGitHubProjectWebhook(req, res) {
2113
2115
  }
2114
2116
  }
2115
2117
 
2118
+ // ─── GitHub App webhook ──────────────────────────────────────────────────────
2119
+
2120
+ function getAppWebhookPath() {
2121
+ return (
2122
+ process.env.BOSUN_GITHUB_APP_WEBHOOK_PATH ||
2123
+ "/api/webhooks/github/app"
2124
+ );
2125
+ }
2126
+
2127
+ /**
2128
+ * Handles App-level webhook deliveries from GitHub.
2129
+ * Events: installation, installation_repositories, ping, pull_request, push…
2130
+ * Validates HMAC-SHA256 signature using BOSUN_GITHUB_WEBHOOK_SECRET.
2131
+ */
2132
+ async function handleGitHubAppWebhook(req, res) {
2133
+ if (req.method !== "POST") {
2134
+ jsonResponse(res, 405, { ok: false, error: "Method not allowed" });
2135
+ return;
2136
+ }
2137
+
2138
+ const deliveryId = String(req.headers["x-github-delivery"] || "");
2139
+ const eventType = String(req.headers["x-github-event"] || "").toLowerCase();
2140
+
2141
+ let rawBody;
2142
+ try {
2143
+ rawBody = await readRawBody(req);
2144
+ } catch (err) {
2145
+ jsonResponse(res, 400, { ok: false, error: "Failed to read body" });
2146
+ return;
2147
+ }
2148
+
2149
+ // Validate HMAC signature if secret is configured
2150
+ const webhookSecret = process.env.BOSUN_GITHUB_WEBHOOK_SECRET || "";
2151
+ if (webhookSecret) {
2152
+ const sigHeader = req.headers["x-hub-signature-256"] || "";
2153
+ if (!verifyGitHubWebhookSignature(rawBody, sigHeader, webhookSecret)) {
2154
+ console.warn(
2155
+ `[app-webhook] delivery=${deliveryId} invalid signature — check BOSUN_GITHUB_WEBHOOK_SECRET`,
2156
+ );
2157
+ jsonResponse(res, 401, { ok: false, error: "Invalid webhook signature" });
2158
+ return;
2159
+ }
2160
+ }
2161
+
2162
+ let payload = {};
2163
+ try {
2164
+ payload = rawBody ? JSON.parse(rawBody) : {};
2165
+ } catch {
2166
+ jsonResponse(res, 400, { ok: false, error: "Invalid JSON payload" });
2167
+ return;
2168
+ }
2169
+
2170
+ // Acknowledge immediately — GitHub requires a fast response
2171
+ jsonResponse(res, 202, { ok: true, deliveryId, eventType });
2172
+
2173
+ // Process asynchronously
2174
+ setImmediate(() => {
2175
+ try {
2176
+ _processAppWebhookEvent(eventType, payload, deliveryId);
2177
+ } catch (err) {
2178
+ console.warn(`[app-webhook] processing error delivery=${deliveryId}: ${err.message}`);
2179
+ }
2180
+ });
2181
+ }
2182
+
2183
+ function _processAppWebhookEvent(eventType, payload, deliveryId) {
2184
+ switch (eventType) {
2185
+ case "ping":
2186
+ console.log(
2187
+ `[app-webhook] ping delivery=${deliveryId} zen="${payload.zen || ""}"`,
2188
+ );
2189
+ break;
2190
+
2191
+ case "installation": {
2192
+ const action = payload.action || "";
2193
+ const login = payload.installation?.account?.login || "unknown";
2194
+ const repos = (payload.repositories || []).map((r) => r.full_name);
2195
+ console.log(
2196
+ `[app-webhook] installation ${action} account=${login} repos=${repos.join(",") || "(all)"}`,
2197
+ );
2198
+ broadcastUiEvent(["overview"], "invalidate", {
2199
+ reason: `github-app-installation-${action}`,
2200
+ account: login,
2201
+ });
2202
+ break;
2203
+ }
2204
+
2205
+ case "installation_repositories": {
2206
+ const action = payload.action || "";
2207
+ const added = (payload.repositories_added || []).map((r) => r.full_name);
2208
+ const removed = (payload.repositories_removed || []).map((r) => r.full_name);
2209
+ console.log(
2210
+ `[app-webhook] installation_repositories ${action} added=${added.join(",")} removed=${removed.join(",")}`,
2211
+ );
2212
+ break;
2213
+ }
2214
+
2215
+ default:
2216
+ console.log(
2217
+ `[app-webhook] delivery=${deliveryId} event=${eventType} action=${payload.action || ""} (unhandled, ack'd)`,
2218
+ );
2219
+ }
2220
+ }
2221
+
2222
+ // ─── GitHub OAuth callback ────────────────────────────────────────────────────
2223
+
2224
+ /**
2225
+ * Handles the OAuth redirect from GitHub after a user installs/authorizes
2226
+ * the Bosun[botswain] GitHub App.
2227
+ *
2228
+ * Flow:
2229
+ * 1. GitHub redirects: GET /api/github/callback?code=xxx&installation_id=yyy
2230
+ * 2. We exchange `code` for a user access token
2231
+ * 3. We fetch the user's GitHub login for confirmation
2232
+ * 4. We redirect the user to the main app UI with a success banner
2233
+ *
2234
+ * The token is surfaced in the response so the operator can copy it;
2235
+ * it is also written to .env as GH_TOKEN if GH_TOKEN is currently unset.
2236
+ */
2237
+ async function handleGitHubOAuthCallback(req, res) {
2238
+ if (req.method !== "GET") {
2239
+ jsonResponse(res, 405, { ok: false, error: "Method not allowed" });
2240
+ return;
2241
+ }
2242
+
2243
+ const urlObj = new URL(req.url || "/", `http://${req.headers.host || "localhost"}`);
2244
+ const code = urlObj.searchParams.get("code") || "";
2245
+ const installationId = urlObj.searchParams.get("installation_id") || "";
2246
+ const setupAction = urlObj.searchParams.get("setup_action") || "";
2247
+
2248
+ // If no code, this might be a test ping — just return 200
2249
+ if (!code) {
2250
+ jsonResponse(res, 400, { ok: false, error: "Missing code parameter" });
2251
+ return;
2252
+ }
2253
+
2254
+ const clientId = process.env.BOSUN_GITHUB_CLIENT_ID || "";
2255
+ const clientSecret = process.env.BOSUN_GITHUB_CLIENT_SECRET || "";
2256
+
2257
+ if (!clientId || !clientSecret) {
2258
+ console.warn("[oauth-callback] BOSUN_GITHUB_CLIENT_ID/CLIENT_SECRET not set — cannot exchange code");
2259
+ res.writeHead(302, { Location: "/?oauth=error&reason=not_configured" });
2260
+ res.end();
2261
+ return;
2262
+ }
2263
+
2264
+ try {
2265
+ // Exchange code for user access token
2266
+ const tokenBody = new URLSearchParams({
2267
+ client_id: clientId,
2268
+ client_secret: clientSecret,
2269
+ code,
2270
+ });
2271
+ const tokenRes = await fetch("https://github.com/login/oauth/access_token", {
2272
+ method: "POST",
2273
+ headers: {
2274
+ Accept: "application/json",
2275
+ "Content-Type": "application/x-www-form-urlencoded",
2276
+ "User-Agent": "bosun-botswain",
2277
+ },
2278
+ body: tokenBody.toString(),
2279
+ });
2280
+
2281
+ if (!tokenRes.ok) {
2282
+ throw new Error(`Token exchange HTTP ${tokenRes.status}`);
2283
+ }
2284
+ const tokenData = await tokenRes.json();
2285
+ if (tokenData.error) {
2286
+ throw new Error(`${tokenData.error}: ${tokenData.error_description || ""}`);
2287
+ }
2288
+
2289
+ const accessToken = tokenData.access_token;
2290
+
2291
+ // Fetch the user identity for logging / display
2292
+ let login = "unknown";
2293
+ try {
2294
+ const userRes = await fetch("https://api.github.com/user", {
2295
+ headers: {
2296
+ Authorization: `Bearer ${accessToken}`,
2297
+ Accept: "application/vnd.github+json",
2298
+ "X-GitHub-Api-Version": "2022-11-28",
2299
+ "User-Agent": "bosun-botswain",
2300
+ },
2301
+ });
2302
+ if (userRes.ok) {
2303
+ const u = await userRes.json();
2304
+ login = u.login || "unknown";
2305
+ }
2306
+ } catch {
2307
+ // non-fatal
2308
+ }
2309
+
2310
+ console.log(
2311
+ `[oauth-callback] authorized user=${login} installation_id=${installationId} setup_action=${setupAction}`,
2312
+ );
2313
+
2314
+ // If GH_TOKEN is not already set, write the token to .env so Bosun can use it.
2315
+ if (!process.env.GH_TOKEN && accessToken) {
2316
+ try {
2317
+ updateEnvFile({ GH_TOKEN: accessToken });
2318
+ process.env.GH_TOKEN = accessToken;
2319
+ console.log(`[oauth-callback] wrote GH_TOKEN to .env for user=${login}`);
2320
+ } catch (err) {
2321
+ console.warn(`[oauth-callback] could not write GH_TOKEN to .env: ${err.message}`);
2322
+ }
2323
+ }
2324
+
2325
+ broadcastUiEvent(["overview"], "invalidate", {
2326
+ reason: "github-oauth-authorized",
2327
+ login,
2328
+ installationId,
2329
+ });
2330
+
2331
+ // Redirect to the main UI with success indication
2332
+ const redirectUrl = `/?oauth=success&gh_user=${encodeURIComponent(login)}${installationId ? `&installation_id=${encodeURIComponent(installationId)}` : ""}`;
2333
+ res.writeHead(302, { Location: redirectUrl });
2334
+ res.end();
2335
+ } catch (err) {
2336
+ console.warn(`[oauth-callback] error: ${err.message}`);
2337
+ res.writeHead(302, {
2338
+ Location: `/?oauth=error&reason=${encodeURIComponent(err.message.slice(0, 100))}`,
2339
+ });
2340
+ res.end();
2341
+ }
2342
+ }
2343
+
2344
+ // ─── GitHub Device Flow ───────────────────────────────────────────────────────
2345
+
2346
+ /**
2347
+ * POST /api/github/device/start
2348
+ * Kicks off the OAuth Device Flow — returns a user code + verification URL.
2349
+ * No public URL, no callback, no client secret needed.
2350
+ * User visits github.com/login/device, enters the code, done.
2351
+ */
2352
+ async function handleDeviceFlowStart(req, res) {
2353
+ if (req.method !== "POST") {
2354
+ jsonResponse(res, 405, { ok: false, error: "Method not allowed" });
2355
+ return;
2356
+ }
2357
+
2358
+ const clientId = (process.env.BOSUN_GITHUB_CLIENT_ID || "").trim();
2359
+ if (!clientId) {
2360
+ jsonResponse(res, 400, {
2361
+ ok: false,
2362
+ error: "BOSUN_GITHUB_CLIENT_ID is not set. Configure it in Settings → GitHub.",
2363
+ });
2364
+ return;
2365
+ }
2366
+
2367
+ try {
2368
+ const body = new URLSearchParams({ client_id: clientId, scope: "repo" });
2369
+ const ghRes = await fetch("https://github.com/login/device/code", {
2370
+ method: "POST",
2371
+ headers: {
2372
+ Accept: "application/json",
2373
+ "Content-Type": "application/x-www-form-urlencoded",
2374
+ "User-Agent": "bosun-botswain",
2375
+ },
2376
+ body: body.toString(),
2377
+ });
2378
+
2379
+ if (!ghRes.ok) {
2380
+ const text = await ghRes.text();
2381
+ throw new Error(`GitHub device/code ${ghRes.status}: ${text}`);
2382
+ }
2383
+
2384
+ const data = await ghRes.json();
2385
+ if (data.error) {
2386
+ throw new Error(`${data.error}: ${data.error_description || ""}`);
2387
+ }
2388
+
2389
+ console.log(`[device-flow] started — user code: ${data.user_code}`);
2390
+
2391
+ jsonResponse(res, 200, {
2392
+ ok: true,
2393
+ data: {
2394
+ deviceCode: data.device_code,
2395
+ userCode: data.user_code,
2396
+ verificationUri: data.verification_uri,
2397
+ expiresIn: data.expires_in,
2398
+ interval: data.interval || 5,
2399
+ },
2400
+ });
2401
+ } catch (err) {
2402
+ console.warn(`[device-flow] start error: ${err.message}`);
2403
+ jsonResponse(res, 500, { ok: false, error: err.message });
2404
+ }
2405
+ }
2406
+
2407
+ /**
2408
+ * POST /api/github/device/poll
2409
+ * Polls GitHub to check if the user has entered the device code.
2410
+ * Body: { deviceCode: "..." }
2411
+ *
2412
+ * Returns:
2413
+ * { status: "pending" } — still waiting
2414
+ * { status: "complete", login } — done, token saved
2415
+ * { status: "expired" } — code expired, restart
2416
+ * { status: "error", error } — something went wrong
2417
+ */
2418
+ async function handleDeviceFlowPoll(req, res) {
2419
+ if (req.method !== "POST") {
2420
+ jsonResponse(res, 405, { ok: false, error: "Method not allowed" });
2421
+ return;
2422
+ }
2423
+
2424
+ const clientId = (process.env.BOSUN_GITHUB_CLIENT_ID || "").trim();
2425
+ if (!clientId) {
2426
+ jsonResponse(res, 400, { ok: false, error: "BOSUN_GITHUB_CLIENT_ID not set" });
2427
+ return;
2428
+ }
2429
+
2430
+ let deviceCode;
2431
+ try {
2432
+ const body = await readJsonBody(req);
2433
+ deviceCode = body?.deviceCode;
2434
+ } catch {
2435
+ jsonResponse(res, 400, { ok: false, error: "Invalid JSON body" });
2436
+ return;
2437
+ }
2438
+
2439
+ if (!deviceCode) {
2440
+ jsonResponse(res, 400, { ok: false, error: "deviceCode is required" });
2441
+ return;
2442
+ }
2443
+
2444
+ try {
2445
+ const body = new URLSearchParams({
2446
+ client_id: clientId,
2447
+ device_code: deviceCode,
2448
+ grant_type: "urn:ietf:params:oauth:grant-type:device_code",
2449
+ });
2450
+
2451
+ const ghRes = await fetch("https://github.com/login/oauth/access_token", {
2452
+ method: "POST",
2453
+ headers: {
2454
+ Accept: "application/json",
2455
+ "Content-Type": "application/x-www-form-urlencoded",
2456
+ "User-Agent": "bosun-botswain",
2457
+ },
2458
+ body: body.toString(),
2459
+ });
2460
+
2461
+ if (!ghRes.ok) {
2462
+ throw new Error(`Token poll HTTP ${ghRes.status}`);
2463
+ }
2464
+
2465
+ const data = await ghRes.json();
2466
+
2467
+ // Success — got a token
2468
+ if (data.access_token) {
2469
+ const accessToken = data.access_token;
2470
+
2471
+ // Fetch user identity
2472
+ let login = "unknown";
2473
+ try {
2474
+ const userRes = await fetch("https://api.github.com/user", {
2475
+ headers: {
2476
+ Authorization: `Bearer ${accessToken}`,
2477
+ Accept: "application/vnd.github+json",
2478
+ "X-GitHub-Api-Version": "2022-11-28",
2479
+ "User-Agent": "bosun-botswain",
2480
+ },
2481
+ });
2482
+ if (userRes.ok) {
2483
+ const u = await userRes.json();
2484
+ login = u.login || "unknown";
2485
+ }
2486
+ } catch {
2487
+ // non-fatal
2488
+ }
2489
+
2490
+ console.log(`[device-flow] authorized user=${login}`);
2491
+
2492
+ // Save token to .env and process.env
2493
+ if (accessToken) {
2494
+ try {
2495
+ updateEnvFile({ GH_TOKEN: accessToken });
2496
+ process.env.GH_TOKEN = accessToken;
2497
+ console.log(`[device-flow] wrote GH_TOKEN to .env for user=${login}`);
2498
+ } catch (err) {
2499
+ console.warn(`[device-flow] could not write GH_TOKEN to .env: ${err.message}`);
2500
+ }
2501
+ }
2502
+
2503
+ broadcastUiEvent(["overview"], "invalidate", {
2504
+ reason: "github-device-flow-authorized",
2505
+ login,
2506
+ });
2507
+
2508
+ jsonResponse(res, 200, { ok: true, data: { status: "complete", login } });
2509
+ return;
2510
+ }
2511
+
2512
+ // Still pending or error
2513
+ switch (data.error) {
2514
+ case "authorization_pending":
2515
+ jsonResponse(res, 200, { ok: true, data: { status: "pending" } });
2516
+ return;
2517
+ case "slow_down":
2518
+ jsonResponse(res, 200, {
2519
+ ok: true,
2520
+ data: { status: "slow_down", interval: data.interval },
2521
+ });
2522
+ return;
2523
+ case "expired_token":
2524
+ jsonResponse(res, 200, { ok: true, data: { status: "expired" } });
2525
+ return;
2526
+ default:
2527
+ jsonResponse(res, 200, {
2528
+ ok: true,
2529
+ data: {
2530
+ status: "error",
2531
+ error: data.error,
2532
+ description: data.error_description || "",
2533
+ },
2534
+ });
2535
+ }
2536
+ } catch (err) {
2537
+ console.warn(`[device-flow] poll error: ${err.message}`);
2538
+ jsonResponse(res, 500, { ok: false, error: err.message });
2539
+ }
2540
+ }
2541
+
2116
2542
  async function readStatusSnapshot() {
2117
2543
  try {
2118
2544
  const raw = await readFile(statusPath, "utf8");
@@ -4045,6 +4471,44 @@ async function handleApi(req, res, url) {
4045
4471
  return;
4046
4472
  }
4047
4473
 
4474
+ if (path === "/api/github/app/config") {
4475
+ const appId = (process.env.BOSUN_GITHUB_APP_ID || "").trim();
4476
+ const privateKeyPath = (process.env.BOSUN_GITHUB_PRIVATE_KEY_PATH || "").trim();
4477
+ const clientId = (process.env.BOSUN_GITHUB_CLIENT_ID || "").trim();
4478
+ const webhookSecretSet = Boolean(process.env.BOSUN_GITHUB_WEBHOOK_SECRET);
4479
+ const appWebhookPath = getAppWebhookPath();
4480
+
4481
+ // Build public URLs from tunnel URL if available, else from request host
4482
+ const host = req.headers["x-forwarded-host"] || req.headers.host || "localhost";
4483
+ const proto = uiServerTls || req.headers["x-forwarded-proto"] === "https" ? "https" : "http";
4484
+ const baseUrl = `${proto}://${host}`;
4485
+
4486
+ jsonResponse(res, 200, {
4487
+ ok: true,
4488
+ data: {
4489
+ appId: appId || null,
4490
+ appSlug: "bosun-botswain",
4491
+ botUsername: "bosun-botswain[bot]",
4492
+ appUrl: "https://github.com/apps/bosun-botswain",
4493
+ configured: {
4494
+ appId: Boolean(appId),
4495
+ privateKey: Boolean(privateKeyPath),
4496
+ oauthClient: Boolean(clientId),
4497
+ webhookSecret: webhookSecretSet,
4498
+ },
4499
+ urls: {
4500
+ webhookUrl: `${baseUrl}${appWebhookPath}`,
4501
+ oauthCallbackUrl: `${baseUrl}/api/github/callback`,
4502
+ },
4503
+ paths: {
4504
+ webhookPath: appWebhookPath,
4505
+ oauthCallbackPath: "/api/github/callback",
4506
+ },
4507
+ },
4508
+ });
4509
+ return;
4510
+ }
4511
+
4048
4512
  if (path === "/api/command") {
4049
4513
  try {
4050
4514
  const body = await readJsonBody(req);
@@ -4789,6 +5253,29 @@ export async function startTelegramUiServer(options = {}) {
4789
5253
  return;
4790
5254
  }
4791
5255
 
5256
+ // GitHub App webhook (installation events, pr events, etc.)
5257
+ const appWebhookPath = getAppWebhookPath();
5258
+ if (url.pathname === appWebhookPath) {
5259
+ await handleGitHubAppWebhook(req, res);
5260
+ return;
5261
+ }
5262
+
5263
+ // GitHub OAuth callback — public (no session auth required)
5264
+ if (url.pathname === "/api/github/callback") {
5265
+ await handleGitHubOAuthCallback(req, res);
5266
+ return;
5267
+ }
5268
+
5269
+ // GitHub Device Flow — no public URL needed
5270
+ if (url.pathname === "/api/github/device/start") {
5271
+ await handleDeviceFlowStart(req, res);
5272
+ return;
5273
+ }
5274
+ if (url.pathname === "/api/github/device/poll") {
5275
+ await handleDeviceFlowPoll(req, res);
5276
+ return;
5277
+ }
5278
+
4792
5279
  if (url.pathname.startsWith("/api/")) {
4793
5280
  await handleApi(req, res, url);
4794
5281
  return;
@@ -432,12 +432,24 @@ export function pullWorkspaceRepos(configDir, workspaceId) {
432
432
  stdio: ["pipe", "pipe", "pipe"],
433
433
  });
434
434
  if (clone.status !== 0) {
435
+ const stderr = String(clone.stderr || clone.stdout || "");
436
+ let hint = "";
437
+ if (/permission denied \(publickey\)/i.test(stderr)) {
438
+ hint =
439
+ "SSH auth failed. Configure SSH keys or use an HTTPS URL instead.";
440
+ } else if (/authentication failed|fatal: authentication failed/i.test(stderr)) {
441
+ hint =
442
+ "HTTPS auth failed. Use a PAT/credential helper or switch to SSH.";
443
+ } else if (/repository .* not found|not found/i.test(stderr)) {
444
+ hint =
445
+ "Repository not found or access denied. Verify the org/repo and permissions.";
446
+ }
435
447
  results.push({
436
448
  name: repo.name,
437
449
  success: false,
438
- error: `git clone failed: ${
439
- clone.stderr || clone.stdout || clone.error?.message || "unknown error"
440
- }`,
450
+ error: `git clone failed (${repoUrl}): ${
451
+ stderr || clone.error?.message || "unknown error"
452
+ }${hint ? ` — ${hint}` : ""}`,
441
453
  });
442
454
  continue;
443
455
  }
@@ -446,18 +458,52 @@ export function pullWorkspaceRepos(configDir, workspaceId) {
446
458
  results.push({
447
459
  name: repo.name,
448
460
  success: false,
449
- error: `git clone failed: ${err.message || err}`,
461
+ error: `git clone failed (${repoUrl}): ${err.message || err}`,
450
462
  });
451
463
  continue;
452
464
  }
453
465
  }
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
- });
460
- continue;
466
+ const gitDir = resolve(repoPath, ".git");
467
+ if (!existsSync(gitDir)) {
468
+ try {
469
+ const contents = existsSync(repoPath) ? readdirSync(repoPath) : [];
470
+ const isEmpty = contents.length === 0;
471
+ const repoUrl =
472
+ repo.url ||
473
+ (repo.slug ? `https://github.com/${repo.slug.replace(/\.git$/i, "")}.git` : "");
474
+ if (isEmpty && repoUrl) {
475
+ console.log(TAG, `Cloning ${repoUrl} into existing empty directory ${repoPath}...`);
476
+ const clone = spawnSync("git", ["clone", repoUrl, "."], {
477
+ encoding: "utf8",
478
+ timeout: 300000,
479
+ stdio: ["pipe", "pipe", "pipe"],
480
+ cwd: repoPath,
481
+ });
482
+ if (clone.status !== 0) {
483
+ const stderr = String(clone.stderr || clone.stdout || "");
484
+ results.push({
485
+ name: repo.name,
486
+ success: false,
487
+ error: `git clone failed (${repoUrl}): ${stderr || clone.error?.message || "unknown error"}`,
488
+ });
489
+ continue;
490
+ }
491
+ } else {
492
+ results.push({
493
+ name: repo.name,
494
+ success: false,
495
+ error: "Directory exists but is not a git repository",
496
+ });
497
+ continue;
498
+ }
499
+ } catch (err) {
500
+ results.push({
501
+ name: repo.name,
502
+ success: false,
503
+ error: `Directory check failed: ${err.message || err}`,
504
+ });
505
+ continue;
506
+ }
461
507
  }
462
508
  try {
463
509
  execSync("git pull --rebase", {