feat: add Windows project registry API and persistence
This commit is contained in:
parent
cc616c1543
commit
c836be46cf
5 changed files with 396 additions and 1 deletions
|
|
@ -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 --import tsx --test tests/lib/kanban.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/api/mutations-routes.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/kanban.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/registry.test.ts && node --import tsx --test tests/api/projects-route.test.ts && node --import tsx --test tests/api/mutations-routes.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": {
|
||||
"framer-motion": "^11.18.2",
|
||||
|
|
|
|||
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 });
|
||||
}
|
||||
}
|
||||
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 };
|
||||
}
|
||||
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, []);
|
||||
});
|
||||
});
|
||||
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);
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue