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

View file

@ -0,0 +1,51 @@
import { NextResponse } from 'next/server';
import { executeMutation, MutationValidationError, validateMutationPayload, type MutationOperation } from '../../../lib/mutations';
function badRequest(message: string, operation: MutationOperation) {
return NextResponse.json(
{
ok: false,
operation,
error: {
classification: 'bad_args',
message,
},
},
{ status: 400 },
);
}
export async function handleMutationRequest(request: Request, operation: MutationOperation): Promise<Response> {
let body: unknown;
try {
body = await request.json();
} catch {
return badRequest('Invalid JSON body.', operation);
}
try {
const payload = validateMutationPayload(operation, body);
const result = await executeMutation(operation, payload);
const status = result.ok ? 200 : result.error?.classification === 'not_found' ? 404 : 400;
return NextResponse.json(result, { status });
} catch (error) {
if (error instanceof MutationValidationError) {
return badRequest(error.message, operation);
}
return NextResponse.json(
{
ok: false,
operation,
error: {
classification: 'unknown',
message: error instanceof Error ? error.message : 'Unknown mutation error.',
},
},
{ status: 500 },
);
}
}

View file

@ -0,0 +1,5 @@
import { handleMutationRequest } from '../_shared';
export async function POST(request: Request): Promise<Response> {
return handleMutationRequest(request, 'close');
}

View file

@ -0,0 +1,5 @@
import { handleMutationRequest } from '../_shared';
export async function POST(request: Request): Promise<Response> {
return handleMutationRequest(request, 'comment');
}

View file

@ -0,0 +1,5 @@
import { handleMutationRequest } from '../_shared';
export async function POST(request: Request): Promise<Response> {
return handleMutationRequest(request, 'create');
}

View file

@ -0,0 +1,24 @@
import { NextResponse } from 'next/server';
import { readIssuesFromDisk } from '../../../../lib/read-issues';
export async function GET(request: Request): Promise<Response> {
const url = new URL(request.url);
const projectRoot = url.searchParams.get('projectRoot') ?? process.cwd();
try {
const issues = await readIssuesFromDisk({ projectRoot });
return NextResponse.json({ ok: true, issues });
} catch (error) {
return NextResponse.json(
{
ok: false,
error: {
classification: 'unknown',
message: error instanceof Error ? error.message : 'Failed to read issues.',
},
},
{ status: 500 },
);
}
}

View file

@ -0,0 +1,5 @@
import { handleMutationRequest } from '../_shared';
export async function POST(request: Request): Promise<Response> {
return handleMutationRequest(request, 'reopen');
}

View file

@ -0,0 +1,5 @@
import { handleMutationRequest } from '../_shared';
export async function POST(request: Request): Promise<Response> {
return handleMutationRequest(request, 'update');
}

View file

@ -0,0 +1,60 @@
import { NextResponse } from 'next/server';
import { addProject, listProjects, RegistryValidationError, removeProject } from '../../../lib/registry';
export const runtime = 'nodejs';
function projectsPayload(projects: Array<{ path: string }>): { projects: Array<{ path: string }> } {
return {
projects: projects.map((project) => ({ path: project.path })),
};
}
async function readPathFromBody(request: Request): Promise<string> {
let body: unknown;
try {
body = await request.json();
} catch {
throw new RegistryValidationError('Request body must be valid JSON.');
}
const path = (body as { path?: unknown }).path;
if (typeof path !== 'string' || path.trim().length === 0) {
throw new RegistryValidationError('`path` is required and must be a non-empty string.');
}
return path;
}
export async function GET(): Promise<Response> {
const projects = await listProjects();
return NextResponse.json(projectsPayload(projects), { status: 200 });
}
export async function POST(request: Request): Promise<Response> {
try {
const projectPath = await readPathFromBody(request);
const result = await addProject(projectPath);
return NextResponse.json(projectsPayload(result.projects), { status: result.added ? 201 : 200 });
} catch (error) {
if (error instanceof RegistryValidationError) {
return NextResponse.json({ error: error.message }, { status: 400 });
}
return NextResponse.json({ error: 'Failed to add project.' }, { status: 500 });
}
}
export async function DELETE(request: Request): Promise<Response> {
try {
const projectPath = await readPathFromBody(request);
const result = await removeProject(projectPath);
return NextResponse.json({ removed: result.removed, ...projectsPayload(result.projects) }, { status: 200 });
} catch (error) {
if (error instanceof RegistryValidationError) {
return NextResponse.json({ error: error.message }, { status: 400 });
}
return NextResponse.json({ error: 'Failed to remove project.' }, { status: 500 });
}
}

44
src/app/api/scan/route.ts Normal file
View file

@ -0,0 +1,44 @@
import { NextResponse } from 'next/server';
import { scanForProjects } from '../../../lib/scanner';
import type { ScanMode } from '../../../lib/scanner';
export const runtime = 'nodejs';
function parseMode(value: string | null): ScanMode {
if (!value || value === 'default') {
return 'default';
}
if (value === 'full-drive') {
return 'full-drive';
}
throw new Error('Invalid scan mode. Use mode=default or mode=full-drive.');
}
function parseDepth(value: string | null): number | undefined {
if (!value) {
return undefined;
}
const parsed = Number.parseInt(value, 10);
if (!Number.isFinite(parsed) || parsed < 0) {
throw new Error('Depth must be a non-negative integer.');
}
return parsed;
}
export async function GET(request: Request): Promise<Response> {
try {
const url = new URL(request.url);
const mode = parseMode(url.searchParams.get('mode'));
const maxDepth = parseDepth(url.searchParams.get('depth'));
const result = await scanForProjects({ mode, maxDepth });
return NextResponse.json(result, { status: 200 });
} catch (error) {
const message = error instanceof Error ? error.message : 'Failed to scan projects.';
return NextResponse.json({ error: message }, { status: 400 });
}
}

View file

@ -3,5 +3,5 @@ import { readIssuesFromDisk } from '../lib/read-issues';
export default async function Page() {
const issues = await readIssuesFromDisk();
return <KanbanPage issues={issues} />;
return <KanbanPage issues={issues} projectRoot={process.cwd()} />;
}

View file

@ -1,8 +1,9 @@
'use client';
import { AnimatePresence } from 'framer-motion';
import type { DragEvent } from 'react';
import { KANBAN_STATUSES } from '../../lib/kanban';
import { KANBAN_STATUSES, type KanbanStatus } from '../../lib/kanban';
import type { BeadIssue } from '../../lib/types';
import { KanbanCard } from './kanban-card';
@ -10,47 +11,153 @@ import { KanbanCard } from './kanban-card';
interface KanbanBoardProps {
columns: Record<(typeof KANBAN_STATUSES)[number], BeadIssue[]>;
selectedIssueId: string | null;
pendingIssueIds: Set<string>;
activeStatus: KanbanStatus | null;
onActivateStatus: (status: KanbanStatus | null) => void;
onMoveIssue: (issue: BeadIssue, targetStatus: KanbanStatus) => void;
onSelect: (issue: BeadIssue) => void;
}
const STATUS_META: Record<(typeof KANBAN_STATUSES)[number], { label: string; dot: string }> = {
open: { label: 'Open', dot: 'bg-sky-300' },
open: { label: 'Open', dot: 'bg-zinc-300' },
in_progress: { label: 'In Progress', dot: 'bg-amber-300' },
blocked: { label: 'Blocked', dot: 'bg-rose-300' },
deferred: { label: 'Deferred', dot: 'bg-slate-300' },
deferred: { label: 'Deferred', dot: 'bg-stone-400' },
closed: { label: 'Done', dot: 'bg-emerald-300' },
};
const STATUS_COLUMN_CLASS: Record<(typeof KANBAN_STATUSES)[number], string> = {
open: 'bg-sky-500/10',
open: 'bg-zinc-500/10',
in_progress: 'bg-amber-500/10',
blocked: 'bg-rose-500/10',
deferred: 'bg-slate-500/10',
deferred: 'bg-stone-500/10',
closed: 'bg-emerald-500/10',
};
export function KanbanBoard({ columns, selectedIssueId, onSelect }: KanbanBoardProps) {
export function KanbanBoard({ columns, selectedIssueId, pendingIssueIds, activeStatus, onActivateStatus, onMoveIssue, onSelect }: KanbanBoardProps) {
const allIssues = KANBAN_STATUSES.flatMap((status) => columns[status]);
const issueLookup = new Map(allIssues.map((issue) => [issue.id, issue]));
const handleExpandAndSelect = (status: KanbanStatus, issue: BeadIssue) => {
onActivateStatus(status);
onSelect(issue);
};
const onDragStart = (issue: BeadIssue, event: DragEvent<HTMLButtonElement>) => {
event.dataTransfer.setData('application/x-bead-id', issue.id);
event.dataTransfer.setData('application/x-bead-status', issue.status);
event.dataTransfer.effectAllowed = 'move';
};
const onDropLane = (targetStatus: KanbanStatus, event: DragEvent<HTMLDivElement>) => {
event.preventDefault();
const issueId = event.dataTransfer.getData('application/x-bead-id');
const sourceStatus = event.dataTransfer.getData('application/x-bead-status') as KanbanStatus;
if (!issueId || !sourceStatus || sourceStatus === targetStatus) {
return;
}
const issue = issueLookup.get(issueId);
if (!issue) {
return;
}
onMoveIssue(issue, targetStatus);
};
return (
<section className="flex min-w-fit snap-x snap-mandatory gap-3 overflow-x-auto overscroll-x-contain pb-2">
<section className="grid min-h-[58vh] gap-2.5">
{KANBAN_STATUSES.map((status) => (
<div
key={status}
className={`w-[clamp(17rem,24vw,22rem)] shrink-0 snap-start rounded-2xl border border-border-soft ${STATUS_COLUMN_CLASS[status]} p-2.5`}
onDragOver={(event) => event.preventDefault()}
onDrop={(event) => onDropLane(status, event)}
className={`rounded-2xl border border-border-soft ${STATUS_COLUMN_CLASS[status]} p-2.5 transition ${
activeStatus === status ? 'shadow-card' : 'opacity-90'
}`}
>
<div className="mb-2 flex items-center justify-between">
<strong className="inline-flex items-center gap-2 text-xs font-semibold uppercase tracking-[0.14em] text-text-body">
<span className={`h-2 w-2 rounded-full ${STATUS_META[status].dot}`} />
{STATUS_META[status].label}
</strong>
<span className="font-mono text-xs text-text-muted">{columns[status].length}</span>
<div className="flex items-center gap-2">
<button
type="button"
aria-expanded={activeStatus === status}
onClick={() => {
onActivateStatus(status);
const firstIssue = columns[status][0];
if (firstIssue) {
onSelect(firstIssue);
}
}}
className="flex w-full items-center justify-between rounded-lg px-1 py-0.5 text-left"
>
<strong className="inline-flex items-center gap-2 text-xs font-semibold uppercase tracking-[0.14em] text-text-body">
<span className={`h-2 w-2 rounded-full ${STATUS_META[status].dot}`} />
{STATUS_META[status].label}
</strong>
<span className="font-mono text-xs text-text-muted">{columns[status].length}</span>
</button>
{activeStatus === status ? (
<button
type="button"
aria-label={`Minimize ${STATUS_META[status].label} lane`}
onClick={() => onActivateStatus(null)}
className="inline-flex h-6 w-6 items-center justify-center rounded-md border border-border-soft bg-surface-muted/60 text-sm text-text-muted hover:border-border-strong hover:text-text-body"
>
-
</button>
) : null}
</div>
<div className="grid h-[clamp(24rem,60vh,48rem)] content-start gap-2 overflow-y-auto pr-1">
<AnimatePresence initial={false}>
{columns[status].map((issue) => (
<KanbanCard key={issue.id} issue={issue} selected={selectedIssueId === issue.id} onSelect={onSelect} />
{activeStatus === status ? (
<div className="mt-2 grid max-h-[50vh] gap-2 overflow-y-auto pr-1 sm:grid-cols-2 2xl:grid-cols-3">
<AnimatePresence initial={false}>
{columns[status].map((issue) => (
<KanbanCard
key={issue.id}
issue={issue}
pending={pendingIssueIds.has(issue.id)}
selected={selectedIssueId === issue.id}
draggable={!pendingIssueIds.has(issue.id)}
onNativeDragStart={onDragStart}
onSelect={onSelect}
/>
))}
</AnimatePresence>
{columns[status].length === 0 ? (
<div className="flex h-24 w-full items-center justify-center rounded-xl border border-dashed border-border-soft/80 bg-surface/35 text-xs text-text-muted">
No beads
</div>
) : null}
</div>
) : (
<div className="mt-2 flex flex-wrap gap-1.5">
{columns[status].slice(0, 6).map((issue) => (
<button
key={issue.id}
type="button"
onClick={() => handleExpandAndSelect(status, issue)}
className="max-w-full rounded-lg border border-border-soft bg-surface-muted/60 px-2 py-1 text-left hover:border-border-strong hover:bg-surface-raised/70"
title={issue.title}
>
<div className="font-mono text-[10px] text-text-muted">{issue.id}</div>
<div className="line-clamp-1 text-xs font-medium text-text-body">{issue.title}</div>
</button>
))}
</AnimatePresence>
</div>
{columns[status].length > 6 ? (
<button
type="button"
onClick={() => onActivateStatus(status)}
className="rounded-lg border border-border-soft bg-surface/50 px-2 py-1 text-xs text-text-muted hover:bg-surface-muted/70"
>
+{columns[status].length - 6} more
</button>
) : null}
{columns[status].length === 0 ? (
<span className="rounded-lg border border-dashed border-border-soft/80 bg-surface/30 px-2 py-1 text-xs text-text-muted">
No beads
</span>
) : null}
</div>
)}
</div>
))}
</section>

View file

@ -1,6 +1,7 @@
'use client';
import { motion } from 'framer-motion';
import type { DragEvent } from 'react';
import type { BeadIssue } from '../../lib/types';
@ -9,6 +10,9 @@ import { Chip } from '../shared/chip';
interface KanbanCardProps {
issue: BeadIssue;
selected: boolean;
pending?: boolean;
draggable?: boolean;
onNativeDragStart?: (issue: BeadIssue, event: DragEvent<HTMLButtonElement>) => void;
onSelect: (issue: BeadIssue) => void;
}
@ -19,7 +23,7 @@ function priorityClass(priority: number): string {
case 1:
return 'border-amber-300/40 bg-amber-500/20 text-amber-50';
case 2:
return 'border-sky-300/40 bg-sky-500/20 text-sky-50';
return 'border-teal-300/40 bg-teal-500/20 text-teal-50';
case 3:
return 'border-slate-300/35 bg-slate-500/22 text-slate-50';
default:
@ -27,9 +31,9 @@ function priorityClass(priority: number): string {
}
}
export function KanbanCard({ issue, selected, onSelect }: KanbanCardProps) {
export function KanbanCard({ issue, selected, pending = false, draggable = false, onNativeDragStart, onSelect }: KanbanCardProps) {
const selectedClass = selected
? 'border-cyan-300/80 bg-surface-raised shadow-card ring-1 ring-cyan-300/35'
? 'border-amber-200/60 bg-surface-raised shadow-card ring-1 ring-amber-200/20'
: 'border-border-soft bg-surface/95 shadow-[0_6px_18px_rgba(4,8,17,0.5)] hover:border-border-strong hover:bg-surface-raised/95';
return (
@ -37,8 +41,12 @@ export function KanbanCard({ issue, selected, onSelect }: KanbanCardProps) {
layout
transition={{ duration: 0.18, ease: 'easeOut' }}
type="button"
draggable={draggable}
onDragStartCapture={(event) => onNativeDragStart?.(issue, event)}
onClick={() => onSelect(issue)}
className={`w-full cursor-pointer rounded-2xl border px-3 py-2.5 text-left transition ${selectedClass}`}
className={`w-full cursor-pointer rounded-2xl border px-3 py-2.5 text-left transition ${selectedClass} ${
pending ? 'opacity-70' : ''
}`}
>
<div className="font-mono text-[11px] text-text-muted break-all">{issue.id}</div>
<div className="mt-1 text-sm font-semibold leading-5 text-text-strong break-words">{issue.title}</div>
@ -51,7 +59,7 @@ export function KanbanCard({ issue, selected, onSelect }: KanbanCardProps) {
<Chip>{issue.issue_type}</Chip>
<Chip tone="status">deps {issue.dependencies.length}</Chip>
</div>
<div className="mt-2 break-words font-mono text-xs text-cyan-100/90">
<div className="mt-2 break-words font-mono text-xs text-amber-100/90">
{issue.assignee ? `@${issue.assignee}` : 'unassigned'}
</div>
{issue.labels.length > 0 ? (
@ -61,6 +69,7 @@ export function KanbanCard({ issue, selected, onSelect }: KanbanCardProps) {
))}
</div>
) : null}
{pending ? <div className="mt-2 text-[11px] font-medium text-amber-200">Saving</div> : null}
</motion.button>
);
}

View file

@ -1,11 +1,12 @@
'use client';
import { motion } from 'framer-motion';
import { useMemo, useState } from 'react';
import { useEffect, useMemo, useState } from 'react';
import type { KanbanFilterOptions } from '../../lib/kanban';
import type { KanbanFilterOptions, KanbanStatus } from '../../lib/kanban';
import { buildKanbanColumns, buildKanbanStats, filterKanbanIssues } from '../../lib/kanban';
import type { BeadIssue } from '../../lib/types';
import { applyOptimisticStatus, planStatusTransition } from '../../lib/writeback';
import { KanbanBoard } from './kanban-board';
import { KanbanControls } from './kanban-controls';
@ -13,49 +14,154 @@ import { KanbanDetail } from './kanban-detail';
interface KanbanPageProps {
issues: BeadIssue[];
projectRoot: string;
}
export function KanbanPage({ issues }: KanbanPageProps) {
type MutationOperation = 'create' | 'update' | 'close' | 'reopen' | 'comment';
interface MutationErrorResponse {
error?: { message?: string };
}
async function postMutation(operation: MutationOperation, body: Record<string, unknown>) {
const response = await fetch(`/api/beads/${operation}`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify(body),
});
const payload = (await response.json()) as { ok: boolean; error?: { message?: string } };
if (!response.ok || !payload.ok) {
throw new Error(payload.error?.message ?? `${operation} failed`);
}
}
async function fetchIssues(projectRoot: string): Promise<BeadIssue[]> {
const response = await fetch(`/api/beads/read?projectRoot=${encodeURIComponent(projectRoot)}`, {
cache: 'no-store',
});
const payload = (await response.json()) as { ok: boolean; issues?: BeadIssue[] } & MutationErrorResponse;
if (!response.ok || !payload.ok || !payload.issues) {
throw new Error(payload.error?.message ?? 'Failed to refresh issues');
}
return payload.issues;
}
export function KanbanPage({ issues, projectRoot }: KanbanPageProps) {
const [localIssues, setLocalIssues] = useState<BeadIssue[]>(issues);
const [filters, setFilters] = useState<KanbanFilterOptions>({
query: '',
type: '',
priority: '',
showClosed: false,
showClosed: true,
});
const [selectedIssueId, setSelectedIssueId] = useState<string | null>(issues[0]?.id ?? null);
const [selectedIssueId, setSelectedIssueId] = useState<string | null>(null);
const [mobileDetailOpen, setMobileDetailOpen] = useState(false);
const [activeStatus, setActiveStatus] = useState<KanbanStatus | null>('open');
const [desktopDetailMinimized, setDesktopDetailMinimized] = useState(false);
const [pendingIssueIds, setPendingIssueIds] = useState<Set<string>>(new Set());
const [mutationError, setMutationError] = useState<string | null>(null);
const filteredIssues = useMemo(() => filterKanbanIssues(issues, filters), [issues, filters]);
useEffect(() => {
setLocalIssues(issues);
}, [issues]);
const filteredIssues = useMemo(() => filterKanbanIssues(localIssues, filters), [localIssues, filters]);
const columns = useMemo(() => buildKanbanColumns(filteredIssues), [filteredIssues]);
const stats = useMemo(() => buildKanbanStats(filteredIssues), [filteredIssues]);
const selectedIssue = useMemo(
() => filteredIssues.find((issue) => issue.id === selectedIssueId) ?? filteredIssues[0] ?? null,
[filteredIssues, selectedIssueId],
);
const selectedIssue = useMemo(() => filteredIssues.find((issue) => issue.id === selectedIssueId) ?? null, [filteredIssues, selectedIssueId]);
const showDesktopDetail = Boolean(selectedIssue) && !desktopDetailMinimized;
const mutateStatus = async (issue: BeadIssue, targetStatus: KanbanStatus) => {
const steps = planStatusTransition(issue, targetStatus);
if (steps.length === 0) {
return;
}
setMutationError(null);
const previous = localIssues;
setPendingIssueIds((value) => new Set(value).add(issue.id));
setLocalIssues((current) => applyOptimisticStatus(current, issue.id, targetStatus));
try {
for (const step of steps) {
await postMutation(step.operation, {
projectRoot,
...step.payload,
});
}
const reconciled = await fetchIssues(projectRoot);
setLocalIssues(reconciled);
} catch (error) {
setLocalIssues(previous);
setMutationError(error instanceof Error ? error.message : 'Mutation failed');
} finally {
setPendingIssueIds((value) => {
const next = new Set(value);
next.delete(issue.id);
return next;
});
}
};
return (
<main className="mx-auto min-h-screen max-w-[1800px] px-4 py-4 sm:px-6 sm:py-6">
<header className="mb-4 rounded-2xl border border-border-soft bg-surface/90 px-4 py-4 shadow-card backdrop-blur md:px-5">
<p className="font-mono text-xs uppercase tracking-[0.14em] text-cyan-100/80">BeadBoard</p>
<p className="font-mono text-xs uppercase tracking-[0.14em] text-text-muted">BeadBoard</p>
<h1 className="mt-1 text-2xl font-semibold text-text-strong sm:text-3xl">Kanban Dashboard</h1>
<p className="mt-2 text-sm text-text-muted">Tracer Bullet 1 from live `.beads/issues.jsonl` on Windows-native paths.</p>
</header>
<KanbanControls filters={filters} stats={stats} onFiltersChange={setFilters} />
<section className="mt-3 grid grid-cols-1 gap-3 lg:grid-cols-[minmax(0,1fr)_minmax(20rem,24rem)] xl:grid-cols-[minmax(0,1fr)_minmax(22rem,26rem)]">
<motion.div layout className="overflow-x-auto rounded-2xl border border-border-soft bg-surface/80 p-2.5 shadow-card">
{mutationError ? (
<div className="mt-3 rounded-xl border border-rose-300/40 bg-rose-950/40 px-3 py-2 text-sm text-rose-100">{mutationError}</div>
) : null}
<section
className={`mt-3 overflow-hidden rounded-2xl border border-border-soft bg-surface/82 shadow-card ${
showDesktopDetail ? 'lg:grid lg:grid-cols-[minmax(0,1fr)_minmax(22rem,26rem)]' : ''
}`}
>
<motion.div layout className="p-2.5 sm:p-3">
<KanbanBoard
columns={columns}
selectedIssueId={selectedIssue?.id ?? null}
pendingIssueIds={pendingIssueIds}
activeStatus={activeStatus}
onActivateStatus={setActiveStatus}
onMoveIssue={mutateStatus}
onSelect={(issue) => {
setSelectedIssueId(issue.id);
setDesktopDetailMinimized(false);
setMobileDetailOpen(true);
}}
/>
</motion.div>
<div className="hidden lg:sticky lg:top-4 lg:block lg:self-start">
<KanbanDetail issue={selectedIssue} />
</div>
{showDesktopDetail ? (
<div className="hidden border-t border-border-soft bg-surface/72 p-3 lg:block lg:border-l lg:border-t-0">
<aside className="rounded-xl border border-border-soft bg-surface/78 p-3">
<div className="mb-2 flex items-center justify-end gap-2 border-b border-border-soft pb-2">
<button
type="button"
onClick={() => setDesktopDetailMinimized(true)}
className="rounded-md border border-border-soft bg-surface-muted/70 px-2 py-1 text-xs text-text-body"
>
Minimize
</button>
<button
type="button"
onClick={() => setSelectedIssueId(null)}
className="rounded-md border border-border-soft bg-surface-muted/70 px-2 py-1 text-xs text-text-muted"
>
Clear
</button>
</div>
<div className="max-h-[calc(100vh-16rem)] overflow-y-auto pr-1">
<KanbanDetail issue={selectedIssue} framed={false} />
</div>
</aside>
</div>
) : null}
</section>
{mobileDetailOpen && selectedIssue ? (

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,
};
});
}