import { existsSync, readFileSync, realpathSync, statSync } from "fs"; import { join, resolve, dirname, basename, parse as pathParse } from "path"; import { execSync } from "child_process"; import { parse as parseYaml } from "yaml"; export interface BeadsDiscovery { beadsDir: string; // Absolute path to the .beads/ directory repoRoot: string; // Parent directory of .beads/ repoName: string; // Directory name of repoRoot (e.g. 'my-project') issuePrefix?: string; // From config.yaml issue-prefix field, if set additionalRepos: number; // Count of repos.additional entries (0 for single-repo) } /** * Read config.yaml from a .beads directory and extract metadata. */ function readBeadsConfig(beadsDir: string): { issuePrefix?: string; additionalRepos: number; } { const configPath = join(beadsDir, "config.yaml"); try { if (!existsSync(configPath)) return { additionalRepos: 0 }; const content = readFileSync(configPath, "utf-8"); const config = parseYaml(content); const issuePrefix = config?.["issue-prefix"] || undefined; const additional = config?.repos?.additional; const additionalRepos = Array.isArray(additional) ? additional.length : 0; return { issuePrefix, additionalRepos }; } catch { return { additionalRepos: 0 }; } } /** * Auto-discover the .beads/ directory by walking up from a starting directory, * similar to how git finds .git/ or how bd itself finds .beads/. * * Resolution order: * 1. BEADS_DIR environment variable (if set) * 2. Walk up from startDir (or process.cwd()) looking for .beads/issues.jsonl * * @param startDir - Directory to start searching from (defaults to process.cwd()) * @throws Error if no .beads/ directory is found */ export function discoverBeadsDir(startDir?: string): BeadsDiscovery { // 1. Check BEADS_DIR env var const envDir = process.env.BEADS_DIR; if (envDir) { const resolved = resolve(envDir); // Accept both /path/to/.beads and /path/to/repo (auto-append .beads/) let beadsDir: string; if (basename(resolved) === ".beads") { beadsDir = resolved; } else if (existsSync(join(resolved, ".beads"))) { beadsDir = join(resolved, ".beads"); } else { // Treat as .beads dir directly even if it doesn't exist yet beadsDir = resolved; } try { beadsDir = realpathSync(beadsDir); } catch { // realpathSync fails if path doesn't exist — use as-is } const repoRoot = dirname(beadsDir); const repoName = basename(repoRoot); const configData = readBeadsConfig(beadsDir); return { beadsDir, repoRoot, repoName, ...configData, }; } // 2. Walk up directory tree from startDir or cwd let current = resolve(startDir || process.cwd()); const { root } = pathParse(current); while (true) { const candidate = join(current, ".beads"); // Check for .beads/ directory (with or without issues.jsonl — empty projects are valid) if (existsSync(candidate) && statSync(candidate).isDirectory()) { let beadsDir: string; try { beadsDir = realpathSync(candidate); } catch { beadsDir = candidate; } const repoRoot = dirname(beadsDir); const repoName = basename(repoRoot); const configData = readBeadsConfig(beadsDir); return { beadsDir, repoRoot, repoName, ...configData, }; } // Reached filesystem root without finding .beads/ if (current === root) { throw new Error( `No .beads/ directory found.\n` + `Searched from: ${resolve(startDir || process.cwd())}\n\n` + `To initialize beads in your project, run:\n` + ` bd init\n\n` + `Or set the BEADS_DIR environment variable:\n` + ` BEADS_DIR=/path/to/project/.beads heartbeads` ); } // Move up one level current = dirname(current); } } /** * Convert a raw git remote URL to a browser-friendly HTTPS URL. * Handles: git@github.com:Org/repo.git, https://github.com/Org/repo.git, etc. */ function gitUrlToHttps(rawUrl: string): string | null { let url = rawUrl.trim().replace(/\.git$/, ""); // SSH format: git@github.com:Org/repo const sshMatch = url.match(/^git@([^:]+):(.+)$/); if (sshMatch) { return `https://${sshMatch[1]}/${sshMatch[2]}`; } // Already HTTPS if (url.startsWith("https://") || url.startsWith("http://")) { return url; } return null; } /** * Get the git remote origin URL for a directory, if it's a git repo. */ function getGitRemoteUrl(dir: string): string | null { try { const raw = execSync("git config --get remote.origin.url", { cwd: dir, encoding: "utf-8", timeout: 3000, stdio: ["pipe", "pipe", "pipe"], }); return gitUrlToHttps(raw); } catch { return null; } } /** * Extract the prefix from a bead ID (e.g. "my-app-5gw" -> "my-app") */ function extractPrefix(id: string): string { const lastDash = id.lastIndexOf("-"); if (lastDash === -1) return id; return id.substring(0, lastDash); } /** * Get the actual issue prefix from a repo's issues.jsonl file. * Reads the first issue and extracts its prefix. * Returns null if the file doesn't exist or has no issues. */ function getActualPrefix(beadsDir: string): string | null { const jsonlPath = join(beadsDir, "issues.jsonl"); try { const content = readFileSync(jsonlPath, "utf-8"); const firstLine = content.split("\n")[0]; if (!firstLine?.trim()) return null; const issue = JSON.parse(firstLine); return issue.id ? extractPrefix(issue.id) : null; } catch { return null; } } /** * Build a mapping of repo prefix → GitHub HTTPS URL for all discovered repos. * Uses the primary repo + any additional repos from config.yaml. */ export function getRepoUrls(beadsDir: string): Record { const urls: Record = {}; // Primary repo const repoRoot = dirname(beadsDir); const primaryUrl = getGitRemoteUrl(repoRoot); // Read config to get issue-prefix and additional repos const configPath = join(beadsDir, "config.yaml"); let issuePrefix: string | undefined; let additionalPaths: string[] = []; try { if (existsSync(configPath)) { const content = readFileSync(configPath, "utf-8"); const config = parseYaml(content); issuePrefix = config?.["issue-prefix"] || undefined; const additional = config?.repos?.additional; if (Array.isArray(additional)) { additionalPaths = additional .map((p: string) => resolve(repoRoot, p)) .filter((p: string) => existsSync(join(p, ".beads"))); } } } catch { // config.yaml unreadable } // Primary repo: use issue-prefix from config, or extract from actual issues, or fall back to directory name const actualPrefix = getActualPrefix(beadsDir); const primaryPrefix = issuePrefix || actualPrefix || basename(repoRoot); if (primaryUrl) { urls[primaryPrefix] = primaryUrl; } // Additional repos for (const repoPath of additionalPaths) { const url = getGitRemoteUrl(repoPath); if (!url) continue; // Read that repo's .beads/config.yaml for its issue-prefix const subConfigPath = join(repoPath, ".beads", "config.yaml"); const subBeadsDir = join(repoPath, ".beads"); let prefix = basename(repoPath); // fallback: directory name try { if (existsSync(subConfigPath)) { const content = readFileSync(subConfigPath, "utf-8"); const config = parseYaml(content); if (config?.["issue-prefix"]) { prefix = config["issue-prefix"]; } else { // If issue-prefix not set in config, extract from actual issues prefix = getActualPrefix(subBeadsDir) || basename(repoPath); } } else { // No config file, try to extract from issues prefix = getActualPrefix(subBeadsDir) || basename(repoPath); } } catch { // use directory name fallback } urls[prefix] = url; } return urls; }