Merge bb-6aj-3-scanner

This commit is contained in:
zenchantlive 2026-02-11 21:00:28 -08:00
commit 89a9941d88
29 changed files with 2036 additions and 49 deletions

78
src/lib/bd-path.ts Normal file
View file

@ -0,0 +1,78 @@
import fs from 'node:fs/promises';
import path from 'node:path';
export interface ResolveBdExecutableOptions {
explicitPath?: string | null;
env?: NodeJS.ProcessEnv;
}
export interface BdExecutableResolution {
executable: string;
source: 'config' | 'path';
}
export class BdExecutableNotFoundError extends Error {
readonly code = 'BD_NOT_FOUND';
constructor(message: string) {
super(message);
this.name = 'BdExecutableNotFoundError';
}
}
async function fileExists(filePath: string): Promise<boolean> {
try {
await fs.access(filePath);
return true;
} catch {
return false;
}
}
function splitEnvPath(env: NodeJS.ProcessEnv = process.env): string[] {
const value = env.Path ?? env.PATH ?? '';
if (!value.trim()) {
return [];
}
return value.split(';').map((segment) => segment.trim()).filter(Boolean);
}
function executableCandidates(directory: string): string[] {
return ['bd.exe', 'bd.cmd', 'bd.bat', 'bd'].map((name) => path.join(directory, name));
}
function buildNotFoundMessage(explicitPath?: string | null): string {
const lines = [
'bd.exe was not found.',
'Install it with: npm install -g @beads/bd',
'Or configure an explicit executable path in request payload/config.',
];
if (explicitPath) {
lines.push(`Configured path was not found: ${explicitPath}`);
}
return lines.join(' ');
}
export async function resolveBdExecutable(options: ResolveBdExecutableOptions = {}): Promise<BdExecutableResolution> {
if (options.explicitPath && options.explicitPath.trim()) {
const explicit = path.resolve(options.explicitPath);
if (await fileExists(explicit)) {
return { executable: explicit, source: 'config' };
}
throw new BdExecutableNotFoundError(buildNotFoundMessage(options.explicitPath));
}
for (const dir of splitEnvPath(options.env)) {
for (const candidate of executableCandidates(dir)) {
if (await fileExists(candidate)) {
return { executable: candidate, source: 'path' };
}
}
}
throw new BdExecutableNotFoundError(buildNotFoundMessage());
}

163
src/lib/bridge.ts Normal file
View file

@ -0,0 +1,163 @@
import { execFile as nodeExecFile } from 'node:child_process';
import { promisify } from 'node:util';
import { BdExecutableNotFoundError, resolveBdExecutable } from './bd-path';
const execFileAsync = promisify(nodeExecFile);
export type BdFailureClassification = 'not_found' | 'timeout' | 'non_zero_exit' | 'bad_args' | 'unknown';
export interface RunBdCommandOptions {
projectRoot: string;
args: string[];
timeoutMs?: number;
explicitBdPath?: string | null;
}
export interface RunBdCommandResult {
success: boolean;
classification: BdFailureClassification | null;
command: string;
args: string[];
cwd: string;
stdout: string;
stderr: string;
code: number | null;
durationMs: number;
error: string | null;
}
type ExecFileOptions = {
cwd: string;
timeout: number;
windowsHide: boolean;
env: NodeJS.ProcessEnv;
};
type ExecFileLike = (
command: string,
args: string[],
options: ExecFileOptions,
) => Promise<{ stdout: string; stderr: string }>;
interface RunBdCommandDeps {
resolveBdExecutable: typeof resolveBdExecutable;
execFile: ExecFileLike;
env: NodeJS.ProcessEnv;
}
function normalizeOutput(text: unknown): string {
if (typeof text !== 'string') {
return '';
}
return text.replaceAll('\r\n', '\n').trim();
}
function toErrorMessage(value: unknown): string {
if (value instanceof Error) {
return value.message;
}
return String(value ?? 'Unknown error');
}
function classifyFailure(error: NodeJS.ErrnoException & { stderr?: string; killed?: boolean; signal?: string }): BdFailureClassification {
if (error.code === 'ENOENT') {
return 'not_found';
}
if (error.code === 'ETIMEDOUT' || error.killed || error.signal === 'SIGTERM') {
return 'timeout';
}
const stderr = normalizeOutput(error.stderr);
if (typeof error.code === 'number') {
if (/(unknown|invalid|required|usage)/i.test(stderr)) {
return 'bad_args';
}
return 'non_zero_exit';
}
return 'unknown';
}
export async function runBdCommand(
options: RunBdCommandOptions,
injectedDeps?: Partial<RunBdCommandDeps>,
): Promise<RunBdCommandResult> {
const startedAt = Date.now();
const timeoutMs = options.timeoutMs ?? 30_000;
const cwd = options.projectRoot;
const args = [...options.args];
const deps: RunBdCommandDeps = {
resolveBdExecutable: injectedDeps?.resolveBdExecutable ?? resolveBdExecutable,
execFile: injectedDeps?.execFile ?? execFileAsync,
env: injectedDeps?.env ?? process.env,
};
let command = options.explicitBdPath ?? 'bd.exe';
try {
const resolved = await deps.resolveBdExecutable({
explicitPath: options.explicitBdPath,
env: deps.env,
});
command = resolved.executable;
const { stdout, stderr } = await deps.execFile(command, args, {
cwd,
timeout: timeoutMs,
windowsHide: true,
env: deps.env,
});
return {
success: true,
classification: null,
command,
args,
cwd,
stdout: normalizeOutput(stdout),
stderr: normalizeOutput(stderr),
code: 0,
durationMs: Date.now() - startedAt,
error: null,
};
} catch (rawError) {
if (rawError instanceof BdExecutableNotFoundError) {
return {
success: false,
classification: 'not_found',
command,
args,
cwd,
stdout: '',
stderr: '',
code: null,
durationMs: Date.now() - startedAt,
error: rawError.message,
};
}
const error = rawError as NodeJS.ErrnoException & {
stderr?: string;
stdout?: string;
killed?: boolean;
signal?: string;
};
return {
success: false,
classification: classifyFailure(error),
command,
args,
cwd,
stdout: normalizeOutput(error.stdout),
stderr: normalizeOutput(error.stderr),
code: typeof error.code === 'number' ? error.code : null,
durationMs: Date.now() - startedAt,
error: toErrorMessage(error),
};
}
}

295
src/lib/mutations.ts Normal file
View file

@ -0,0 +1,295 @@
import { runBdCommand, type RunBdCommandResult } from './bridge';
export type MutationOperation = 'create' | 'update' | 'close' | 'reopen' | 'comment';
export type MutationStatus = 'open' | 'in_progress' | 'blocked' | 'deferred' | 'closed';
interface MutationBasePayload {
projectRoot: string;
bdPath?: string;
}
export interface CreateMutationPayload extends MutationBasePayload {
title: string;
description?: string;
priority?: number;
issueType?: string;
assignee?: string;
labels?: string[];
}
export interface UpdateMutationPayload extends MutationBasePayload {
id: string;
title?: string;
description?: string;
status?: MutationStatus;
priority?: number;
assignee?: string;
labels?: string[];
}
export interface CloseMutationPayload extends MutationBasePayload {
id: string;
reason?: string;
}
export interface ReopenMutationPayload extends MutationBasePayload {
id: string;
reason?: string;
}
export interface CommentMutationPayload extends MutationBasePayload {
id: string;
text: string;
}
export type MutationPayload =
| CreateMutationPayload
| UpdateMutationPayload
| CloseMutationPayload
| ReopenMutationPayload
| CommentMutationPayload;
export interface MutationErrorShape {
classification: 'bad_args' | 'not_found' | 'timeout' | 'non_zero_exit' | 'unknown';
message: string;
}
export interface MutationResponse {
ok: boolean;
operation: MutationOperation;
command: RunBdCommandResult;
error?: MutationErrorShape;
}
export class MutationValidationError extends Error {
readonly code = 'MUTATION_VALIDATION_ERROR';
constructor(message: string) {
super(message);
this.name = 'MutationValidationError';
}
}
function asNonEmptyString(value: unknown, field: string): string {
if (typeof value !== 'string' || !value.trim()) {
throw new MutationValidationError(`"${field}" is required.`);
}
return value.trim();
}
function asOptionalString(value: unknown): string | undefined {
if (value === undefined || value === null) {
return undefined;
}
if (typeof value !== 'string') {
throw new MutationValidationError('Expected a string value.');
}
const trimmed = value.trim();
return trimmed ? trimmed : undefined;
}
function asOptionalPriority(value: unknown): number | undefined {
if (value === undefined || value === null) {
return undefined;
}
if (typeof value !== 'number' || Number.isNaN(value) || value < 0 || value > 4) {
throw new MutationValidationError('"priority" must be a number between 0 and 4.');
}
return value;
}
function asOptionalLabels(value: unknown): string[] | undefined {
if (value === undefined || value === null) {
return undefined;
}
if (!Array.isArray(value)) {
throw new MutationValidationError('"labels" must be an array of strings.');
}
const labels = value.map((label) => {
if (typeof label !== 'string' || !label.trim()) {
throw new MutationValidationError('"labels" must be an array of non-empty strings.');
}
return label.trim();
});
return labels.length ? labels : undefined;
}
function asOptionalStatus(value: unknown): MutationStatus | undefined {
if (value === undefined || value === null) {
return undefined;
}
const status = asNonEmptyString(value, 'status');
if (!['open', 'in_progress', 'blocked', 'deferred', 'closed'].includes(status)) {
throw new MutationValidationError('"status" is invalid.');
}
return status as MutationStatus;
}
function parseBasePayload(raw: unknown): MutationBasePayload {
if (!raw || typeof raw !== 'object') {
throw new MutationValidationError('Payload must be a JSON object.');
}
const data = raw as Record<string, unknown>;
return {
projectRoot: asNonEmptyString(data.projectRoot, 'projectRoot'),
bdPath: asOptionalString(data.bdPath),
};
}
export function validateMutationPayload(operation: MutationOperation, payload: unknown): MutationPayload {
const base = parseBasePayload(payload);
const data = payload as Record<string, unknown>;
if (operation === 'create') {
return {
...base,
title: asNonEmptyString(data.title, 'title'),
description: asOptionalString(data.description),
priority: asOptionalPriority(data.priority),
issueType: asOptionalString(data.issueType),
assignee: asOptionalString(data.assignee),
labels: asOptionalLabels(data.labels),
};
}
if (operation === 'update') {
const mapped: UpdateMutationPayload = {
...base,
id: asNonEmptyString(data.id, 'id'),
title: asOptionalString(data.title),
description: asOptionalString(data.description),
status: asOptionalStatus(data.status),
priority: asOptionalPriority(data.priority),
assignee: asOptionalString(data.assignee),
labels: asOptionalLabels(data.labels),
};
if (!mapped.title && !mapped.description && !mapped.status && mapped.priority === undefined && !mapped.assignee && !mapped.labels) {
throw new MutationValidationError('At least one update field is required.');
}
return mapped;
}
if (operation === 'close') {
return {
...base,
id: asNonEmptyString(data.id, 'id'),
reason: asOptionalString(data.reason),
};
}
if (operation === 'reopen') {
return {
...base,
id: asNonEmptyString(data.id, 'id'),
reason: asOptionalString(data.reason),
};
}
return {
...base,
id: asNonEmptyString(data.id, 'id'),
text: asNonEmptyString(data.text, 'text'),
};
}
function pushOptionalArg(args: string[], flag: string, value: string | undefined): void {
if (value) {
args.push(flag, value);
}
}
function pushOptionalLabels(args: string[], labels: string[] | undefined): void {
if (labels && labels.length > 0) {
args.push('-l', labels.join(','));
}
}
export function buildBdMutationArgs(operation: MutationOperation, payload: MutationPayload): string[] {
if (operation === 'create') {
const data = payload as CreateMutationPayload;
const args = ['create', data.title];
pushOptionalArg(args, '-d', data.description);
if (data.priority !== undefined) {
args.push('-p', String(data.priority));
}
pushOptionalArg(args, '-t', data.issueType);
pushOptionalArg(args, '-a', data.assignee);
pushOptionalLabels(args, data.labels);
args.push('--json');
return args;
}
if (operation === 'update') {
const data = payload as UpdateMutationPayload;
const args = ['update', data.id];
pushOptionalArg(args, '--title', data.title);
pushOptionalArg(args, '-d', data.description);
pushOptionalArg(args, '-s', data.status);
if (data.priority !== undefined) {
args.push('-p', String(data.priority));
}
pushOptionalArg(args, '-a', data.assignee);
pushOptionalLabels(args, data.labels);
args.push('--json');
return args;
}
if (operation === 'close') {
const data = payload as CloseMutationPayload;
const args = ['close', data.id];
pushOptionalArg(args, '-r', data.reason);
args.push('--json');
return args;
}
if (operation === 'reopen') {
const data = payload as ReopenMutationPayload;
const args = ['reopen', data.id];
pushOptionalArg(args, '-r', data.reason);
args.push('--json');
return args;
}
const data = payload as CommentMutationPayload;
return ['comments', 'add', data.id, data.text, '--json'];
}
interface ExecuteMutationDeps {
runBdCommand: typeof runBdCommand;
}
export async function executeMutation(
operation: MutationOperation,
payload: MutationPayload,
deps: Partial<ExecuteMutationDeps> = {},
): Promise<MutationResponse> {
const runner = deps.runBdCommand ?? runBdCommand;
const args = buildBdMutationArgs(operation, payload);
const command = await runner({
projectRoot: payload.projectRoot,
args,
explicitBdPath: payload.bdPath,
});
if (!command.success) {
return {
ok: false,
operation,
command,
error: {
classification: command.classification ?? 'unknown',
message: command.error ?? (command.stderr || 'Mutation command failed.'),
},
};
}
return {
ok: true,
operation,
command,
};
}

140
src/lib/registry.ts Normal file
View file

@ -0,0 +1,140 @@
import fs from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import { canonicalizeWindowsPath, toDisplayPath, windowsPathKey } from './pathing';
export interface RegistryProject {
path: string;
key: string;
}
interface RegistryDocument {
version: 1;
projects: RegistryProject[];
}
export class RegistryValidationError extends Error {
constructor(message: string) {
super(message);
this.name = 'RegistryValidationError';
}
}
function userProfileRoot(): string {
return process.env.USERPROFILE?.trim() || os.homedir();
}
export function registryFilePath(): string {
return path.join(userProfileRoot(), '.beadboard', 'projects.json');
}
function ensureWindowsAbsolutePath(input: string): string {
const normalized = canonicalizeWindowsPath(input.trim());
if (!/^[A-Za-z]:\\/.test(normalized)) {
throw new RegistryValidationError('Project path must be a Windows absolute path (e.g. C:\\Repos\\Project).');
}
return normalized;
}
function normalizeProject(input: string): RegistryProject {
const normalized = ensureWindowsAbsolutePath(input);
return {
path: toDisplayPath(normalized),
key: windowsPathKey(normalized),
};
}
function normalizeProjects(input: unknown): RegistryProject[] {
if (!Array.isArray(input)) {
return [];
}
const seen = new Set<string>();
const normalized: RegistryProject[] = [];
for (const item of input) {
if (!item || typeof item !== 'object') {
continue;
}
const candidate = item as { path?: unknown };
if (typeof candidate.path !== 'string') {
continue;
}
try {
const project = normalizeProject(candidate.path);
if (!seen.has(project.key)) {
seen.add(project.key);
normalized.push(project);
}
} catch {
continue;
}
}
return normalized;
}
async function readRegistryDocument(): Promise<RegistryDocument> {
const filePath = registryFilePath();
try {
const raw = await fs.readFile(filePath, 'utf8');
const parsed = JSON.parse(raw) as { projects?: unknown };
return {
version: 1,
projects: normalizeProjects(parsed.projects),
};
} catch (error) {
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
return { version: 1, projects: [] };
}
throw error;
}
}
async function writeRegistryDocument(document: RegistryDocument): Promise<void> {
const filePath = registryFilePath();
await fs.mkdir(path.dirname(filePath), { recursive: true });
await fs.writeFile(filePath, `${JSON.stringify(document, null, 2)}\n`, 'utf8');
}
export async function listProjects(): Promise<RegistryProject[]> {
const document = await readRegistryDocument();
return document.projects;
}
export async function addProject(projectPath: string): Promise<{ added: boolean; projects: RegistryProject[] }> {
const document = await readRegistryDocument();
const project = normalizeProject(projectPath);
if (document.projects.some((entry) => entry.key === project.key)) {
return { added: false, projects: document.projects };
}
document.projects.push(project);
await writeRegistryDocument(document);
return { added: true, projects: document.projects };
}
export async function removeProject(projectPath: string): Promise<{ removed: boolean; projects: RegistryProject[] }> {
const document = await readRegistryDocument();
const project = normalizeProject(projectPath);
const nextProjects = document.projects.filter((entry) => entry.key !== project.key);
if (nextProjects.length === document.projects.length) {
return { removed: false, projects: document.projects };
}
const nextDocument: RegistryDocument = {
version: 1,
projects: nextProjects,
};
await writeRegistryDocument(nextDocument);
return { removed: true, projects: nextDocument.projects };
}

223
src/lib/scanner.ts Normal file
View file

@ -0,0 +1,223 @@
import fs from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import { canonicalizeWindowsPath, toDisplayPath, windowsPathKey } from './pathing';
import { listProjects } from './registry';
export type ScanMode = 'default' | 'full-drive';
export interface ScannerProject {
root: string;
key: string;
displayPath: string;
}
export interface ScanStats {
scannedDirectories: number;
ignoredDirectories: number;
skippedDirectories: number;
elapsedMs: number;
}
export interface ScanOptions {
mode?: ScanMode;
maxDepth?: number;
roots?: string[];
ignoreDirectories?: string[];
}
export interface ScanResult {
mode: ScanMode;
roots: string[];
projects: ScannerProject[];
stats: ScanStats;
}
const DEFAULT_MAX_DEPTH = 6;
const DEFAULT_IGNORE_DIRECTORIES = [
'node_modules',
'.git',
'.next',
'dist',
'build',
'out',
'coverage',
'artifacts',
'logs',
'.worktrees', // TODO: confirm whether worktrees should be scan targets.
];
function userProfileRoot(): string {
return process.env.USERPROFILE?.trim() || os.homedir();
}
function toCanonicalRoot(input: string): string {
return canonicalizeWindowsPath(input);
}
function shouldSkipFsError(error: NodeJS.ErrnoException): boolean {
return error.code === 'ENOENT' || error.code === 'ENOTDIR' || error.code === 'EACCES' || error.code === 'EPERM';
}
async function ensureDirectoryExists(input: string): Promise<string | null> {
try {
const stat = await fs.stat(input);
return stat.isDirectory() ? input : null;
} catch (error) {
if (shouldSkipFsError(error as NodeJS.ErrnoException)) {
return null;
}
throw error;
}
}
async function resolveFullDriveRoots(): Promise<string[]> {
const candidates = ['C:\\', 'D:\\'];
const roots: string[] = [];
for (const candidate of candidates) {
const existing = await ensureDirectoryExists(candidate);
if (existing) {
roots.push(existing);
}
}
return roots;
}
export async function resolveScanRoots(options: ScanOptions = {}): Promise<string[]> {
const mode: ScanMode = options.mode ?? 'default';
const registryProjects = await listProjects();
const roots = [
userProfileRoot(),
...registryProjects.map((project) => project.path),
...(options.roots ?? []),
];
if (mode === 'full-drive') {
roots.push(...(await resolveFullDriveRoots()));
}
const seen = new Set<string>();
const normalizedRoots: string[] = [];
for (const root of roots) {
const normalized = toCanonicalRoot(root);
const key = windowsPathKey(normalized);
if (seen.has(key)) {
continue;
}
const existing = await ensureDirectoryExists(normalized);
if (!existing) {
continue;
}
seen.add(key);
normalizedRoots.push(existing);
}
return normalizedRoots;
}
function buildIgnoreSet(additional: string[] = []): Set<string> {
return new Set(
[...DEFAULT_IGNORE_DIRECTORIES, ...additional].map((entry) => entry.trim().toLowerCase()).filter(Boolean),
);
}
function recordProject(projects: Map<string, ScannerProject>, root: string): void {
const normalized = toCanonicalRoot(root);
const key = windowsPathKey(normalized);
if (!projects.has(key)) {
projects.set(key, {
root: normalized,
key,
displayPath: toDisplayPath(normalized),
});
}
}
async function scanRoot(
root: string,
maxDepth: number,
ignoreSet: Set<string>,
projects: Map<string, ScannerProject>,
stats: ScanStats,
): Promise<void> {
const queue: Array<{ dir: string; depth: number }> = [{ dir: root, depth: 0 }];
while (queue.length > 0) {
const current = queue.shift();
if (!current) {
continue;
}
stats.scannedDirectories += 1;
let entries: fs.Dirent[];
try {
entries = await fs.readdir(current.dir, { withFileTypes: true });
} catch (error) {
if (shouldSkipFsError(error as NodeJS.ErrnoException)) {
stats.skippedDirectories += 1;
continue;
}
throw error;
}
let hasBeads = false;
for (const entry of entries) {
if (!entry.isDirectory()) {
continue;
}
if (entry.name === '.beads') {
hasBeads = true;
continue;
}
const entryName = entry.name.toLowerCase();
if (ignoreSet.has(entryName)) {
stats.ignoredDirectories += 1;
continue;
}
if (current.depth < maxDepth) {
queue.push({ dir: path.join(current.dir, entry.name), depth: current.depth + 1 });
}
}
if (hasBeads) {
recordProject(projects, current.dir);
}
}
}
export async function scanForProjects(options: ScanOptions = {}): Promise<ScanResult> {
const mode: ScanMode = options.mode ?? 'default';
const maxDepth = options.maxDepth ?? DEFAULT_MAX_DEPTH;
const ignoreSet = buildIgnoreSet(options.ignoreDirectories);
const roots = await resolveScanRoots(options);
const projects = new Map<string, ScannerProject>();
const stats: ScanStats = {
scannedDirectories: 0,
ignoredDirectories: 0,
skippedDirectories: 0,
elapsedMs: 0,
};
const start = Date.now();
for (const root of roots) {
await scanRoot(root, maxDepth, ignoreSet, projects, stats);
}
stats.elapsedMs = Date.now() - start;
return {
mode,
roots,
projects: Array.from(projects.values()),
stats,
};
}

56
src/lib/writeback.ts Normal file
View file

@ -0,0 +1,56 @@
import type { BeadIssue, BeadStatus } from './types';
export type MutationStep =
| { operation: 'close'; payload: { id: string; reason?: string } }
| { operation: 'reopen'; payload: { id: string; reason?: string } }
| { operation: 'update'; payload: { id: string; status: 'open' | 'in_progress' | 'blocked' | 'deferred' } };
function isBoardStatus(status: BeadStatus): status is 'open' | 'in_progress' | 'blocked' | 'deferred' | 'closed' {
return ['open', 'in_progress', 'blocked', 'deferred', 'closed'].includes(status);
}
export function planStatusTransition(
issue: Pick<BeadIssue, 'id' | 'status'>,
targetStatus: 'open' | 'in_progress' | 'blocked' | 'deferred' | 'closed',
): MutationStep[] {
if (!isBoardStatus(issue.status) || issue.status === targetStatus) {
return [];
}
if (targetStatus === 'closed') {
return [{ operation: 'close', payload: { id: issue.id, reason: 'Moved to closed via board drag-and-drop' } }];
}
if (issue.status === 'closed') {
if (targetStatus === 'open') {
return [{ operation: 'reopen', payload: { id: issue.id, reason: 'Moved from closed via board drag-and-drop' } }];
}
return [
{ operation: 'reopen', payload: { id: issue.id, reason: 'Moved from closed via board drag-and-drop' } },
{ operation: 'update', payload: { id: issue.id, status: targetStatus } },
];
}
return [{ operation: 'update', payload: { id: issue.id, status: targetStatus } }];
}
export function applyOptimisticStatus(
issues: BeadIssue[],
issueId: string,
targetStatus: 'open' | 'in_progress' | 'blocked' | 'deferred' | 'closed',
atIso: string = new Date().toISOString(),
): BeadIssue[] {
return issues.map((issue) => {
if (issue.id !== issueId) {
return issue;
}
return {
...issue,
status: targetStatus,
updated_at: atIso,
closed_at: targetStatus === 'closed' ? atIso : null,
};
});
}