Theo Brownerebrandb5bab3e9 months agoHistory
172 lines · 4.6 KB · TypeScript
RawDownload
import { randomUUID } from "node:crypto";import fs from "node:fs";import path from "node:path"; import {  type ProjectAddInput,  type ProjectAddResult,  type ProjectListResult,  type ProjectRecord,  type ProjectRemoveInput,  projectAddInputSchema,  projectRecordSchema,  projectRemoveInputSchema,} from "@t3tools/contracts"; function normalizeCwd(rawCwd: string): string {  const resolved = path.resolve(rawCwd.trim());  const normalized = path.normalize(resolved);  if (process.platform === "win32") {    return normalized.toLowerCase();  }  return normalized;} function isDirectory(cwd: string): boolean {  try {    return fs.statSync(cwd).isDirectory();  } catch {    return false;  }} function inferProjectName(cwd: string): string {  const name = path.basename(cwd);  return name.length > 0 ? name : "project";} export class ProjectRegistry {  private readonly stateDir: string;  private readonly filePath: string;  private projects: ProjectRecord[];   constructor(stateDir: string) {    this.stateDir = stateDir;    this.filePath = path.join(this.stateDir, "projects.json");    fs.mkdirSync(this.stateDir, { recursive: true });    this.projects = this.loadFromDisk();  }   list(): ProjectListResult {    return this.projects.map((project) => ({ ...project }));  }   add(raw: ProjectAddInput): ProjectAddResult {    const input = projectAddInputSchema.parse(raw);    const normalizedCwd = normalizeCwd(input.cwd);    if (!isDirectory(normalizedCwd)) {      throw new Error(`Project path does not exist: ${normalizedCwd}`);    }     const existing = this.projects.find((project) => normalizeCwd(project.cwd) === normalizedCwd);    if (existing) {      return { project: { ...existing }, created: false };    }     const now = new Date().toISOString();    const project: ProjectRecord = {      id: randomUUID(),      cwd: normalizedCwd,      name: inferProjectName(normalizedCwd),      createdAt: now,      updatedAt: now,    };     this.projects = [project, ...this.projects];    this.persist();    return { project: { ...project }, created: true };  }   remove(raw: ProjectRemoveInput): void {    const input = projectRemoveInputSchema.parse(raw);    const next = this.projects.filter((project) => project.id !== input.id);    if (next.length === this.projects.length) {      return;    }     this.projects = next;    this.persist();  }   private loadFromDisk(): ProjectRecord[] {    const loaded = this.readPersistedProjects();    const deduped = new Map<string, ProjectRecord>();    const normalizedProjects: ProjectRecord[] = [];     for (const project of loaded) {      const normalizedCwd = normalizeCwd(project.cwd);      if (!isDirectory(normalizedCwd)) {        continue;      }      if (deduped.has(normalizedCwd)) {        continue;      }       const normalizedProject: ProjectRecord = {        ...project,        cwd: normalizedCwd,        name:          project.name.trim().length > 0 ? project.name.trim() : inferProjectName(normalizedCwd),      };      deduped.set(normalizedCwd, normalizedProject);      normalizedProjects.push(normalizedProject);    }     const changed =      normalizedProjects.length !== loaded.length ||      normalizedProjects.some((project, index) => {        const source = loaded[index];        if (!source) return true;        return (          source.id !== project.id ||          source.cwd !== project.cwd ||          source.name !== project.name ||          source.createdAt !== project.createdAt ||          source.updatedAt !== project.updatedAt        );      });     if (changed) {      this.projects = normalizedProjects;      this.persist();    }     return normalizedProjects;  }   private readPersistedProjects(): ProjectRecord[] {    try {      const raw = fs.readFileSync(this.filePath, "utf8");      const parsed = JSON.parse(raw) as { projects?: unknown };      if (!parsed || !Array.isArray(parsed.projects)) {        return [];      }       const projects: ProjectRecord[] = [];      for (const candidate of parsed.projects) {        const row = projectRecordSchema.safeParse(candidate);        if (!row.success) {          continue;        }        projects.push(row.data);      }      return projects;    } catch {      return [];    }  }   private persist(): void {    const payload = JSON.stringify(      {        version: 1,        projects: this.projects,      },      null,      2,    );    const tempFile = `${this.filePath}.tmp-${process.pid}-${Date.now()}`;    fs.writeFileSync(tempFile, payload);    fs.renameSync(tempFile, this.filePath);  }}