Merge main into master and unify realtime + project-context test matrix
This commit is contained in:
commit
b4cb09a6cc
13 changed files with 806 additions and 6 deletions
|
|
@ -9,7 +9,7 @@
|
||||||
"start": "next start",
|
"start": "next start",
|
||||||
"lint": "next lint",
|
"lint": "next lint",
|
||||||
"typecheck": "tsc --noEmit",
|
"typecheck": "tsc --noEmit",
|
||||||
"test": "node --test tests/bootstrap.test.mjs && node --import tsx --test tests/lib/parser.test.ts && node --import tsx --test tests/lib/pathing.test.ts && node --import tsx --test tests/lib/kanban.test.ts && node --import tsx --test tests/lib/read-text-retry.test.ts && node --import tsx --test tests/lib/read-issues.test.ts && node --import tsx --test tests/lib/bd-path.test.ts && node --import tsx --test tests/lib/bridge.test.ts && node --import tsx --test tests/lib/mutations.test.ts && node --import tsx --test tests/lib/writeback.test.ts && node --import tsx --test tests/lib/realtime.test.ts && node --import tsx --test tests/lib/coalescer.test.ts && node --import tsx --test tests/lib/watcher.test.ts && node --import tsx --test tests/api/mutations-routes.test.ts && node --import tsx --test tests/api/events-route.test.ts && node --test tests/guards/no-direct-jsonl-write.test.mjs && node --test tests/guards/no-inline-style-in-kanban.test.mjs && node --test tests/guards/kanban-responsive-contract.test.mjs"
|
"test": "node --test tests/bootstrap.test.mjs && node --import tsx --test tests/lib/parser.test.ts && node --import tsx --test tests/lib/pathing.test.ts && node --import tsx --test tests/lib/project-context.test.ts && node --import tsx --test tests/lib/kanban.test.ts && node --import tsx --test tests/lib/read-text-retry.test.ts && node --import tsx --test tests/lib/read-issues.test.ts && node --import tsx --test tests/lib/bd-path.test.ts && node --import tsx --test tests/lib/bridge.test.ts && node --import tsx --test tests/lib/mutations.test.ts && node --import tsx --test tests/lib/writeback.test.ts && node --import tsx --test tests/lib/realtime.test.ts && node --import tsx --test tests/lib/coalescer.test.ts && node --import tsx --test tests/lib/watcher.test.ts && node --import tsx --test tests/lib/registry.test.ts && node --import tsx --test tests/lib/scanner.test.ts && node --import tsx --test tests/api/projects-route.test.ts && node --import tsx --test tests/api/mutations-routes.test.ts && node --import tsx --test tests/api/events-route.test.ts && node --test tests/guards/no-direct-jsonl-write.test.mjs && node --test tests/guards/no-inline-style-in-kanban.test.mjs && node --test tests/guards/kanban-responsive-contract.test.mjs"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"chokidar": "^5.0.0",
|
"chokidar": "^5.0.0",
|
||||||
|
|
|
||||||
60
src/app/api/projects/route.ts
Normal file
60
src/app/api/projects/route.ts
Normal 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
44
src/app/api/scan/route.ts
Normal 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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
25
src/lib/project-context.ts
Normal file
25
src/lib/project-context.ts
Normal 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -3,11 +3,14 @@ import path from 'node:path';
|
||||||
import { parseIssuesJsonl } from './parser';
|
import { parseIssuesJsonl } from './parser';
|
||||||
import { canonicalizeWindowsPath } from './pathing';
|
import { canonicalizeWindowsPath } from './pathing';
|
||||||
import { readTextFileWithRetry } from './read-text-retry';
|
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 {
|
export interface ReadIssuesOptions {
|
||||||
projectRoot?: string;
|
projectRoot?: string;
|
||||||
includeTombstones?: boolean;
|
includeTombstones?: boolean;
|
||||||
|
projectSource?: ProjectSource;
|
||||||
|
projectAddedAt?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function resolveIssuesJsonlPathCandidates(projectRoot: string = process.cwd()): string[] {
|
export function resolveIssuesJsonlPathCandidates(projectRoot: string = process.cwd()): string[] {
|
||||||
|
|
@ -21,15 +24,23 @@ export function resolveIssuesJsonlPath(projectRoot: string = process.cwd()): str
|
||||||
return resolveIssuesJsonlPathCandidates(projectRoot)[0];
|
return resolveIssuesJsonlPathCandidates(projectRoot)[0];
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function readIssuesFromDisk(options: ReadIssuesOptions = {}): Promise<BeadIssue[]> {
|
export async function readIssuesFromDisk(options: ReadIssuesOptions = {}): Promise<BeadIssueWithProject[]> {
|
||||||
const candidates = resolveIssuesJsonlPathCandidates(options.projectRoot);
|
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) {
|
for (const issuesPath of candidates) {
|
||||||
try {
|
try {
|
||||||
const jsonl = await readTextFileWithRetry(issuesPath);
|
const jsonl = await readTextFileWithRetry(issuesPath);
|
||||||
return parseIssuesJsonl(jsonl, {
|
return parseIssuesJsonl(jsonl, {
|
||||||
includeTombstones: options.includeTombstones ?? false,
|
includeTombstones: options.includeTombstones ?? false,
|
||||||
});
|
}).map((issue) => ({
|
||||||
|
...issue,
|
||||||
|
project,
|
||||||
|
}));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
|
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
|
||||||
continue;
|
continue;
|
||||||
|
|
|
||||||
140
src/lib/registry.ts
Normal file
140
src/lib/registry.ts
Normal 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
223
src/lib/scanner.ts
Normal 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -59,3 +59,16 @@ export interface ParseableBeadIssue extends Partial<BeadIssue> {
|
||||||
id: string;
|
id: string;
|
||||||
title: 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 };
|
||||||
|
|
|
||||||
109
tests/api/projects-route.test.ts
Normal file
109
tests/api/projects-route.test.ts
Normal file
|
|
@ -0,0 +1,109 @@
|
||||||
|
import test from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import fs from 'node:fs/promises';
|
||||||
|
import os from 'node:os';
|
||||||
|
import path from 'node:path';
|
||||||
|
|
||||||
|
import { DELETE, GET, POST } from '../../src/app/api/projects/route';
|
||||||
|
|
||||||
|
async function withTempUserProfile(run: (userProfile: string) => Promise<void>): Promise<void> {
|
||||||
|
const previous = process.env.USERPROFILE;
|
||||||
|
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'beadboard-api-'));
|
||||||
|
process.env.USERPROFILE = tempDir;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await run(tempDir);
|
||||||
|
} finally {
|
||||||
|
if (previous === undefined) {
|
||||||
|
delete process.env.USERPROFILE;
|
||||||
|
} else {
|
||||||
|
process.env.USERPROFILE = previous;
|
||||||
|
}
|
||||||
|
|
||||||
|
await fs.rm(tempDir, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function readJson(response: Response): Promise<unknown> {
|
||||||
|
return response.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
test('GET /api/projects returns empty list initially', async () => {
|
||||||
|
await withTempUserProfile(async () => {
|
||||||
|
const response = await GET();
|
||||||
|
assert.equal(response.status, 200);
|
||||||
|
|
||||||
|
const body = (await readJson(response)) as { projects: unknown[] };
|
||||||
|
assert.deepEqual(body.projects, []);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('POST /api/projects validates payload and path', async () => {
|
||||||
|
await withTempUserProfile(async () => {
|
||||||
|
const missing = await POST(new Request('http://localhost/api/projects', { method: 'POST', body: '{}' }));
|
||||||
|
assert.equal(missing.status, 400);
|
||||||
|
|
||||||
|
const missingBody = (await readJson(missing)) as { error: string };
|
||||||
|
assert.match(missingBody.error, /path/i);
|
||||||
|
|
||||||
|
const invalidPath = await POST(
|
||||||
|
new Request('http://localhost/api/projects', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ path: '/tmp/project' }),
|
||||||
|
headers: { 'content-type': 'application/json' },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
assert.equal(invalidPath.status, 400);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('POST deduplicates and GET returns normalized path', async () => {
|
||||||
|
await withTempUserProfile(async () => {
|
||||||
|
const first = await POST(
|
||||||
|
new Request('http://localhost/api/projects', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ path: 'c:/Users/Zenchant/codex/beadboard/' }),
|
||||||
|
headers: { 'content-type': 'application/json' },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
assert.equal(first.status, 201);
|
||||||
|
|
||||||
|
const dup = await POST(
|
||||||
|
new Request('http://localhost/api/projects', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ path: 'C:\\users\\zenchant\\codex\\beadboard' }),
|
||||||
|
headers: { 'content-type': 'application/json' },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
assert.equal(dup.status, 200);
|
||||||
|
|
||||||
|
const list = await GET();
|
||||||
|
const body = (await readJson(list)) as { projects: Array<{ path: string }> };
|
||||||
|
assert.deepEqual(body.projects, [{ path: 'C:/Users/Zenchant/codex/beadboard' }]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('DELETE /api/projects removes by normalized path', async () => {
|
||||||
|
await withTempUserProfile(async () => {
|
||||||
|
await POST(
|
||||||
|
new Request('http://localhost/api/projects', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ path: 'D:/Repos/One' }),
|
||||||
|
headers: { 'content-type': 'application/json' },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const removed = await DELETE(
|
||||||
|
new Request('http://localhost/api/projects', {
|
||||||
|
method: 'DELETE',
|
||||||
|
body: JSON.stringify({ path: 'd:\\repos\\one\\' }),
|
||||||
|
headers: { 'content-type': 'application/json' },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
assert.equal(removed.status, 200);
|
||||||
|
|
||||||
|
const list = await GET();
|
||||||
|
const body = (await readJson(list)) as { projects: unknown[] };
|
||||||
|
assert.deepEqual(body.projects, []);
|
||||||
|
});
|
||||||
|
});
|
||||||
15
tests/lib/project-context.test.ts
Normal file
15
tests/lib/project-context.test.ts
Normal file
|
|
@ -0,0 +1,15 @@
|
||||||
|
import test from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
|
||||||
|
import { buildProjectContext } from '../../src/lib/project-context';
|
||||||
|
|
||||||
|
test('buildProjectContext derives normalized project identity', () => {
|
||||||
|
const project = buildProjectContext('C:/Repo/Project');
|
||||||
|
|
||||||
|
assert.equal(project.root, 'C:\\Repo\\Project');
|
||||||
|
assert.equal(project.key, 'c:\\repo\\project');
|
||||||
|
assert.equal(project.displayPath, 'C:/Repo/Project');
|
||||||
|
assert.equal(project.name, 'Project');
|
||||||
|
assert.equal(project.source, 'local');
|
||||||
|
assert.equal(project.addedAt, null);
|
||||||
|
});
|
||||||
|
|
@ -5,7 +5,7 @@ import os from 'node:os';
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
|
|
||||||
import { readIssuesFromDisk, resolveIssuesJsonlPath, resolveIssuesJsonlPathCandidates } from '../../src/lib/read-issues';
|
import { readIssuesFromDisk, resolveIssuesJsonlPath, resolveIssuesJsonlPathCandidates } from '../../src/lib/read-issues';
|
||||||
import { sameWindowsPath } from '../../src/lib/pathing';
|
import { canonicalizeWindowsPath, sameWindowsPath, toDisplayPath, windowsPathKey } from '../../src/lib/pathing';
|
||||||
|
|
||||||
test('resolveIssuesJsonlPath appends .beads/issues.jsonl using windows-safe pathing', () => {
|
test('resolveIssuesJsonlPath appends .beads/issues.jsonl using windows-safe pathing', () => {
|
||||||
const resolved = resolveIssuesJsonlPath('C:/Repo/Project');
|
const resolved = resolveIssuesJsonlPath('C:/Repo/Project');
|
||||||
|
|
@ -38,6 +38,12 @@ test('readIssuesFromDisk parses JSONL issues from disk', async () => {
|
||||||
assert.equal(issues.length, 1);
|
assert.equal(issues.length, 1);
|
||||||
assert.equal(issues[0].id, 'bb-1');
|
assert.equal(issues[0].id, 'bb-1');
|
||||||
assert.equal(issues[0].priority, 0);
|
assert.equal(issues[0].priority, 0);
|
||||||
|
assert.equal(issues[0].project.root, canonicalizeWindowsPath(root));
|
||||||
|
assert.equal(issues[0].project.key, windowsPathKey(root));
|
||||||
|
assert.equal(issues[0].project.displayPath, toDisplayPath(root));
|
||||||
|
assert.equal(issues[0].project.name, path.basename(canonicalizeWindowsPath(root)));
|
||||||
|
assert.equal(issues[0].project.source, 'local');
|
||||||
|
assert.equal(issues[0].project.addedAt, null);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('readIssuesFromDisk returns empty list when issues file does not exist', async () => {
|
test('readIssuesFromDisk returns empty list when issues file does not exist', async () => {
|
||||||
|
|
|
||||||
86
tests/lib/registry.test.ts
Normal file
86
tests/lib/registry.test.ts
Normal file
|
|
@ -0,0 +1,86 @@
|
||||||
|
import test from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import fs from 'node:fs/promises';
|
||||||
|
import os from 'node:os';
|
||||||
|
import path from 'node:path';
|
||||||
|
|
||||||
|
import {
|
||||||
|
addProject,
|
||||||
|
listProjects,
|
||||||
|
removeProject,
|
||||||
|
registryFilePath,
|
||||||
|
type RegistryProject,
|
||||||
|
} from '../../src/lib/registry';
|
||||||
|
|
||||||
|
async function withTempUserProfile(run: (userProfile: string) => Promise<void>): Promise<void> {
|
||||||
|
const previous = process.env.USERPROFILE;
|
||||||
|
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'beadboard-registry-'));
|
||||||
|
process.env.USERPROFILE = tempDir;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await run(tempDir);
|
||||||
|
} finally {
|
||||||
|
if (previous === undefined) {
|
||||||
|
delete process.env.USERPROFILE;
|
||||||
|
} else {
|
||||||
|
process.env.USERPROFILE = previous;
|
||||||
|
}
|
||||||
|
|
||||||
|
await fs.rm(tempDir, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
test('registryFilePath resolves under %USERPROFILE%/.beadboard/projects.json', async () => {
|
||||||
|
await withTempUserProfile(async (userProfile) => {
|
||||||
|
const result = registryFilePath();
|
||||||
|
assert.equal(result, path.join(userProfile, '.beadboard', 'projects.json'));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('listProjects returns empty when registry does not exist', async () => {
|
||||||
|
await withTempUserProfile(async () => {
|
||||||
|
const result = await listProjects();
|
||||||
|
assert.deepEqual(result, []);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('addProject persists normalized path and deduplicates case/separators', async () => {
|
||||||
|
await withTempUserProfile(async () => {
|
||||||
|
const first = await addProject('c:/Work/Alpha/');
|
||||||
|
assert.equal(first.added, true);
|
||||||
|
|
||||||
|
const second = await addProject('C:\\work\\alpha');
|
||||||
|
assert.equal(second.added, false);
|
||||||
|
|
||||||
|
const listed = await listProjects();
|
||||||
|
assert.equal(listed.length, 1);
|
||||||
|
assert.equal(listed[0].path, 'C:/Work/Alpha');
|
||||||
|
|
||||||
|
const file = await fs.readFile(registryFilePath(), 'utf8');
|
||||||
|
const parsed = JSON.parse(file) as { projects: RegistryProject[] };
|
||||||
|
assert.equal(parsed.projects.length, 1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('removeProject removes matching normalized path', async () => {
|
||||||
|
await withTempUserProfile(async () => {
|
||||||
|
await addProject('D:/Repos/One');
|
||||||
|
await addProject('D:/Repos/Two');
|
||||||
|
|
||||||
|
const removed = await removeProject('d:\\repos\\one\\');
|
||||||
|
assert.equal(removed.removed, true);
|
||||||
|
|
||||||
|
const listed = await listProjects();
|
||||||
|
assert.deepEqual(
|
||||||
|
listed.map((project) => project.path),
|
||||||
|
['D:/Repos/Two'],
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('addProject rejects non-Windows absolute paths', async () => {
|
||||||
|
await withTempUserProfile(async () => {
|
||||||
|
await assert.rejects(() => addProject('/tmp/project'), /Windows absolute path/i);
|
||||||
|
await assert.rejects(() => addProject('relative/path'), /Windows absolute path/i);
|
||||||
|
});
|
||||||
|
});
|
||||||
68
tests/lib/scanner.test.ts
Normal file
68
tests/lib/scanner.test.ts
Normal file
|
|
@ -0,0 +1,68 @@
|
||||||
|
import test from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import fs from 'node:fs/promises';
|
||||||
|
import os from 'node:os';
|
||||||
|
import path from 'node:path';
|
||||||
|
|
||||||
|
import { addProject } from '../../src/lib/registry';
|
||||||
|
import { scanForProjects, resolveScanRoots } from '../../src/lib/scanner';
|
||||||
|
import { canonicalizeWindowsPath, sameWindowsPath, windowsPathKey } from '../../src/lib/pathing';
|
||||||
|
|
||||||
|
async function withTempUserProfile(run: (userProfile: string) => Promise<void>): Promise<void> {
|
||||||
|
const previous = process.env.USERPROFILE;
|
||||||
|
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'beadboard-scan-'));
|
||||||
|
process.env.USERPROFILE = tempDir;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await run(tempDir);
|
||||||
|
} finally {
|
||||||
|
if (previous === undefined) {
|
||||||
|
delete process.env.USERPROFILE;
|
||||||
|
} else {
|
||||||
|
process.env.USERPROFILE = previous;
|
||||||
|
}
|
||||||
|
|
||||||
|
await fs.rm(tempDir, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
test('resolveScanRoots includes profile and registry roots by default', async () => {
|
||||||
|
await withTempUserProfile(async (userProfile) => {
|
||||||
|
const registryRoot = path.join(userProfile, 'Registered');
|
||||||
|
await fs.mkdir(registryRoot, { recursive: true });
|
||||||
|
await addProject(registryRoot);
|
||||||
|
|
||||||
|
const roots = await resolveScanRoots();
|
||||||
|
|
||||||
|
assert.equal(roots.some((root) => sameWindowsPath(root, userProfile)), true);
|
||||||
|
assert.equal(roots.some((root) => sameWindowsPath(root, registryRoot)), true);
|
||||||
|
assert.equal(roots.some((root) => windowsPathKey(root) === windowsPathKey('C:\\')), false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('resolveScanRoots includes full-drive roots only when requested', async () => {
|
||||||
|
await withTempUserProfile(async () => {
|
||||||
|
const roots = await resolveScanRoots({ mode: 'full-drive' });
|
||||||
|
assert.equal(roots.some((root) => windowsPathKey(root) === windowsPathKey('C:\\')), true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('scanForProjects respects depth limits and ignore list', async () => {
|
||||||
|
await withTempUserProfile(async (userProfile) => {
|
||||||
|
const projectRoot = path.join(userProfile, 'ProjectA');
|
||||||
|
await fs.mkdir(path.join(projectRoot, '.beads'), { recursive: true });
|
||||||
|
|
||||||
|
const ignoredRoot = path.join(userProfile, 'node_modules', 'Ignored');
|
||||||
|
await fs.mkdir(path.join(ignoredRoot, '.beads'), { recursive: true });
|
||||||
|
|
||||||
|
const deepRoot = path.join(userProfile, 'Deep', 'Level1', 'Level2', 'ProjectDeep');
|
||||||
|
await fs.mkdir(path.join(deepRoot, '.beads'), { recursive: true });
|
||||||
|
|
||||||
|
const result = await scanForProjects({ maxDepth: 1 });
|
||||||
|
const keys = result.projects.map((project) => project.key);
|
||||||
|
|
||||||
|
assert.equal(keys.includes(windowsPathKey(canonicalizeWindowsPath(projectRoot))), true);
|
||||||
|
assert.equal(keys.includes(windowsPathKey(canonicalizeWindowsPath(ignoredRoot))), false);
|
||||||
|
assert.equal(keys.includes(windowsPathKey(canonicalizeWindowsPath(deepRoot))), false);
|
||||||
|
});
|
||||||
|
});
|
||||||
Loading…
Add table
Add a link
Reference in a new issue