branch:
workspace.ts
6025 bytesRaw
import type { Workspace } from "./filesystem";
import type { FileSystem, FileSystemDirent, FsStat } from "./fs/interface";
import { FileSystemStateBackend } from "./memory";

const MAX_SYMLINK_DEPTH = 40;

// ── WorkspaceFileSystem ───────────────────────────────────────────────
//
// Thin adapter that makes `Workspace` satisfy the `FileSystem` interface.
// Handles the two main API differences:
//   - Workspace.readFile / readFileBytes return null on missing;
//     FileSystem requires ENOENT to be thrown.
//   - Workspace.stat / lstat return Workspace-specific FileStat;
//     FileSystem.stat / lstat return FsStat = { type, size, mtime, mode? }.

export class WorkspaceFileSystem implements FileSystem {
  constructor(private readonly ws: Workspace) {}

  async readFile(path: string): Promise<string> {
    const content = await this.ws.readFile(path);
    if (content === null) {
      throw new Error(`ENOENT: no such file or directory: ${path}`);
    }
    return content;
  }

  async readFileBytes(path: string): Promise<Uint8Array> {
    const bytes = await this.ws.readFileBytes(path);
    if (bytes === null) {
      throw new Error(`ENOENT: no such file or directory: ${path}`);
    }
    return bytes;
  }

  async writeFile(path: string, content: string): Promise<void> {
    await this.ws.writeFile(path, content);
  }

  async writeFileBytes(path: string, content: Uint8Array): Promise<void> {
    await this.ws.writeFileBytes(path, content);
  }

  async appendFile(path: string, content: string | Uint8Array): Promise<void> {
    if (typeof content === "string") {
      await this.ws.appendFile(path, content);
      return;
    }

    const existing = await this.ws.readFileBytes(path);
    if (existing === null) {
      await this.ws.writeFileBytes(path, content);
      return;
    }

    const combined = new Uint8Array(existing.byteLength + content.byteLength);
    combined.set(existing);
    combined.set(content, existing.byteLength);
    await this.ws.writeFileBytes(path, combined);
  }

  async exists(path: string): Promise<boolean> {
    return this.ws.exists(path);
  }

  async stat(path: string): Promise<FsStat> {
    const s = await this.ws.stat(path);
    if (!s) {
      throw new Error(`ENOENT: no such file or directory: ${path}`);
    }
    return fromWorkspaceStat(s);
  }

  async lstat(path: string): Promise<FsStat> {
    const s = await this.ws.lstat(path);
    if (!s) {
      throw new Error(`ENOENT: no such file or directory: ${path}`);
    }
    return fromWorkspaceStat(s);
  }

  async mkdir(path: string, options?: { recursive?: boolean }): Promise<void> {
    await this.ws.mkdir(path, options);
  }

  async readdir(path: string): Promise<string[]> {
    return (await this.ws.readDir(path)).map((e) => e.name);
  }

  async readdirWithFileTypes(path: string): Promise<FileSystemDirent[]> {
    return (await this.ws.readDir(path)).map((e) => ({
      name: e.name,
      type: e.type
    }));
  }

  async rm(
    path: string,
    options?: { recursive?: boolean; force?: boolean }
  ): Promise<void> {
    await this.ws.rm(path, options);
  }

  async cp(
    src: string,
    dest: string,
    options?: { recursive?: boolean }
  ): Promise<void> {
    await this.ws.cp(src, dest, options);
  }

  async mv(src: string, dest: string): Promise<void> {
    await this.ws.mv(src, dest);
  }

  async symlink(target: string, linkPath: string): Promise<void> {
    await this.ws.symlink(target, linkPath);
  }

  async readlink(path: string): Promise<string> {
    return this.ws.readlink(path);
  }

  async realpath(path: string, _depth = 0): Promise<string> {
    if (_depth > MAX_SYMLINK_DEPTH) {
      throw new Error(`ELOOP: too many levels of symbolic links: ${path}`);
    }

    const stat = await this.ws.lstat(path);
    if (!stat) {
      throw new Error(`ENOENT: no such file or directory: ${path}`);
    }

    if (stat.type !== "symlink") {
      return normalizePath(path);
    }

    const target = await this.ws.readlink(path);
    const resolved = target.startsWith("/")
      ? normalizePath(target)
      : normalizePath(`${dirname(path)}/${target}`);
    return this.realpath(resolved, _depth + 1);
  }

  resolvePath(base: string, path: string): string {
    return normalizePath(path.startsWith("/") ? path : `${base}/${path}`);
  }

  async glob(pattern: string): Promise<string[]> {
    return (await this.ws.glob(pattern)).map((e) => e.path);
  }
}

// ── Factory ───────────────────────────────────────────────────────────

export function createWorkspaceStateBackend(
  workspace: Workspace
): FileSystemStateBackend {
  return new FileSystemStateBackend(new WorkspaceFileSystem(workspace));
}

/** @deprecated Use `FileSystemStateBackend` */
export const WorkspaceStateBackend = FileSystemStateBackend;

// ── Private helpers ───────────────────────────────────────────────────

function fromWorkspaceStat(stat: {
  type: string;
  size: number;
  updatedAt: number;
}): FsStat {
  return {
    type: stat.type as FsStat["type"],
    size: stat.size,
    mtime: new Date(stat.updatedAt)
  };
}

function normalizePath(path: string): string {
  if (!path.startsWith("/")) path = "/" + path;
  const parts = path.split("/");
  const resolved: string[] = [];
  for (const part of parts) {
    if (part === "" || part === ".") continue;
    if (part === "..") {
      resolved.pop();
    } else {
      resolved.push(part);
    }
  }
  return "/" + resolved.join("/");
}

function dirname(path: string): string {
  const normalized = normalizePath(path);
  if (normalized === "/") return "/";
  const lastSlash = normalized.lastIndexOf("/");
  return lastSlash <= 0 ? "/" : normalized.slice(0, lastSlash);
}