Merge main into master and unify realtime + project-context test matrix

This commit is contained in:
zenchantlive 2026-02-11 21:06:38 -08:00
commit b4cb09a6cc
13 changed files with 806 additions and 6 deletions

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

@ -0,0 +1,25 @@
import path from 'node:path';
import { canonicalizeWindowsPath, toDisplayPath, windowsPathKey } from './pathing';
import type { ProjectContext, ProjectSource } from './types';
interface BuildProjectContextOptions {
source?: ProjectSource;
addedAt?: string | null;
}
export function buildProjectContext(root: string, options: BuildProjectContextOptions = {}): ProjectContext {
if (!root) {
throw new Error('Project root is required to build project context.');
}
const normalizedRoot = canonicalizeWindowsPath(root);
return {
key: windowsPathKey(normalizedRoot),
root: normalizedRoot,
displayPath: toDisplayPath(normalizedRoot),
name: path.basename(normalizedRoot),
source: options.source ?? 'local',
addedAt: options.addedAt ?? null,
};
}

View file

@ -3,11 +3,14 @@ import path from 'node:path';
import { parseIssuesJsonl } from './parser';
import { canonicalizeWindowsPath } from './pathing';
import { readTextFileWithRetry } from './read-text-retry';
import type { BeadIssue } from './types';
import { buildProjectContext } from './project-context';
import type { BeadIssueWithProject, ProjectSource } from './types';
export interface ReadIssuesOptions {
projectRoot?: string;
includeTombstones?: boolean;
projectSource?: ProjectSource;
projectAddedAt?: string | null;
}
export function resolveIssuesJsonlPathCandidates(projectRoot: string = process.cwd()): string[] {
@ -21,15 +24,23 @@ export function resolveIssuesJsonlPath(projectRoot: string = process.cwd()): str
return resolveIssuesJsonlPathCandidates(projectRoot)[0];
}
export async function readIssuesFromDisk(options: ReadIssuesOptions = {}): Promise<BeadIssue[]> {
const candidates = resolveIssuesJsonlPathCandidates(options.projectRoot);
export async function readIssuesFromDisk(options: ReadIssuesOptions = {}): Promise<BeadIssueWithProject[]> {
const projectRoot = options.projectRoot ?? process.cwd();
const candidates = resolveIssuesJsonlPathCandidates(projectRoot);
const project = buildProjectContext(projectRoot, {
source: options.projectSource ?? 'local',
addedAt: options.projectAddedAt ?? null,
});
for (const issuesPath of candidates) {
try {
const jsonl = await readTextFileWithRetry(issuesPath);
return parseIssuesJsonl(jsonl, {
includeTombstones: options.includeTombstones ?? false,
});
}).map((issue) => ({
...issue,
project,
}));
} catch (error) {
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
continue;

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

View file

@ -59,3 +59,16 @@ export interface ParseableBeadIssue extends Partial<BeadIssue> {
id: string;
title: string;
}
export type ProjectSource = 'local' | 'registry' | 'scanner';
export interface ProjectContext {
key: string;
root: string;
displayPath: string;
name: string;
source: ProjectSource;
addedAt: string | null;
}
export type BeadIssueWithProject = BeadIssue & { project: ProjectContext };