From 17bc185ce5b17af3e6ad515ec065986113f6693d Mon Sep 17 00:00:00 2001 From: zenchantlive Date: Wed, 11 Feb 2026 17:53:40 -0800 Subject: [PATCH] feat: add Windows project registry API and persistence --- package.json | 2 +- src/app/api/projects/route.ts | 60 +++++++++++++ src/lib/registry.ts | 140 +++++++++++++++++++++++++++++++ tests/api/projects-route.test.ts | 109 ++++++++++++++++++++++++ tests/lib/registry.test.ts | 86 +++++++++++++++++++ 5 files changed, 396 insertions(+), 1 deletion(-) create mode 100644 src/app/api/projects/route.ts create mode 100644 src/lib/registry.ts create mode 100644 tests/api/projects-route.test.ts create mode 100644 tests/lib/registry.test.ts diff --git a/package.json b/package.json index c1860d3..0936d32 100644 --- a/package.json +++ b/package.json @@ -9,7 +9,7 @@ "start": "next start", "lint": "next lint", "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 --test tests/guards/no-direct-jsonl-write.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/registry.test.ts && node --import tsx --test tests/api/projects-route.test.ts && node --test tests/guards/no-direct-jsonl-write.test.mjs" }, "dependencies": { "next": "15.5.7", diff --git a/src/app/api/projects/route.ts b/src/app/api/projects/route.ts new file mode 100644 index 0000000..240c7c4 --- /dev/null +++ b/src/app/api/projects/route.ts @@ -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 { + 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 { + const projects = await listProjects(); + return NextResponse.json(projectsPayload(projects), { status: 200 }); +} + +export async function POST(request: Request): Promise { + 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 { + 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 }); + } +} diff --git a/src/lib/registry.ts b/src/lib/registry.ts new file mode 100644 index 0000000..eacd45b --- /dev/null +++ b/src/lib/registry.ts @@ -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(); + 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 { + 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 { + 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 { + 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 }; +} diff --git a/tests/api/projects-route.test.ts b/tests/api/projects-route.test.ts new file mode 100644 index 0000000..0f4d77e --- /dev/null +++ b/tests/api/projects-route.test.ts @@ -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): Promise { + 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 { + 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, []); + }); +}); diff --git a/tests/lib/registry.test.ts b/tests/lib/registry.test.ts new file mode 100644 index 0000000..15537df --- /dev/null +++ b/tests/lib/registry.test.ts @@ -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): Promise { + 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); + }); +});