/** * WorkspacePaths - Effect service contract for workspace path handling. * * Owns normalization and validation of workspace roots plus safe resolution of * workspace-root-relative paths. * * @module WorkspacePaths */ import * as NodeOS from "node:os"; import * as Context from "effect/Context"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; import * as Path from "effect/Path"; import * as Schema from "effect/Schema"; export class WorkspaceRootNotExistsError extends Schema.TaggedErrorClass()( "WorkspaceRootNotExistsError", { workspaceRoot: Schema.String, normalizedWorkspaceRoot: Schema.String, }, ) { override get message(): string { return `Workspace root does not exist: ${this.normalizedWorkspaceRoot}`; } } export class WorkspaceRootCreateFailedError extends Schema.TaggedErrorClass()( "WorkspaceRootCreateFailedError", { workspaceRoot: Schema.String, normalizedWorkspaceRoot: Schema.String, cause: Schema.Defect(), }, ) { override get message(): string { return `Failed to create workspace root: ${this.normalizedWorkspaceRoot}`; } } export class WorkspaceRootNotDirectoryError extends Schema.TaggedErrorClass()( "WorkspaceRootNotDirectoryError", { workspaceRoot: Schema.String, normalizedWorkspaceRoot: Schema.String, }, ) { override get message(): string { return `Workspace root is not a directory: ${this.normalizedWorkspaceRoot}`; } } export class WorkspacePathOutsideRootError extends Schema.TaggedErrorClass()( "WorkspacePathOutsideRootError", { workspaceRoot: Schema.String, relativePath: Schema.String, }, ) { override get message(): string { return `Workspace file path must be relative to the project root: ${this.relativePath}`; } } export const WorkspacePathsError = Schema.Union([ WorkspaceRootNotExistsError, WorkspaceRootCreateFailedError, WorkspaceRootNotDirectoryError, WorkspacePathOutsideRootError, ]); export type WorkspacePathsError = typeof WorkspacePathsError.Type; /** Service tag for workspace path normalization and resolution. */ export class WorkspacePaths extends Context.Service< WorkspacePaths, { /** Normalize a user-provided workspace root and verify it exists as a directory. */ readonly normalizeWorkspaceRoot: ( workspaceRoot: string, options?: { readonly createIfMissing?: boolean }, ) => Effect.Effect< string, WorkspaceRootNotExistsError | WorkspaceRootCreateFailedError | WorkspaceRootNotDirectoryError >; /** * Resolve a relative path within a validated workspace root. * * Rejects absolute paths and traversal attempts outside the workspace root. */ readonly resolveRelativePathWithinRoot: (input: { workspaceRoot: string; relativePath: string; }) => Effect.Effect< { absolutePath: string; relativePath: string }, WorkspacePathOutsideRootError >; } >()("t3/workspace/WorkspacePaths") {} function toPosixRelativePath(input: string): string { return input.replaceAll("\\", "/"); } function expandHomePath(input: string, path: Path.Path): string { if (input === "~") { return NodeOS.homedir(); } if (input.startsWith("~/") || input.startsWith("~\\")) { return path.join(NodeOS.homedir(), input.slice(2)); } return input; } export const make = Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; const path = yield* Path.Path; const normalizeWorkspaceRoot: WorkspacePaths["Service"]["normalizeWorkspaceRoot"] = Effect.fn( "WorkspacePaths.normalizeWorkspaceRoot", )(function* (workspaceRoot, options) { const normalizedWorkspaceRoot = path.resolve(expandHomePath(workspaceRoot.trim(), path)); let workspaceStat = yield* fileSystem .stat(normalizedWorkspaceRoot) .pipe(Effect.orElseSucceed(() => null)); if (!workspaceStat && options?.createIfMissing) { yield* fileSystem.makeDirectory(normalizedWorkspaceRoot, { recursive: true }).pipe( Effect.mapError( (cause) => new WorkspaceRootCreateFailedError({ workspaceRoot, normalizedWorkspaceRoot, cause, }), ), ); workspaceStat = yield* fileSystem .stat(normalizedWorkspaceRoot) .pipe(Effect.orElseSucceed(() => null)); } if (!workspaceStat) { return yield* new WorkspaceRootNotExistsError({ workspaceRoot, normalizedWorkspaceRoot, }); } if (workspaceStat.type !== "Directory") { return yield* new WorkspaceRootNotDirectoryError({ workspaceRoot, normalizedWorkspaceRoot, }); } return normalizedWorkspaceRoot; }); const resolveRelativePathWithinRoot: WorkspacePaths["Service"]["resolveRelativePathWithinRoot"] = Effect.fn("WorkspacePaths.resolveRelativePathWithinRoot")(function* (input) { const normalizedInputPath = input.relativePath.trim(); if (path.isAbsolute(normalizedInputPath)) { return yield* new WorkspacePathOutsideRootError({ workspaceRoot: input.workspaceRoot, relativePath: input.relativePath, }); } const absolutePath = path.resolve(input.workspaceRoot, normalizedInputPath); const relativeToRoot = toPosixRelativePath(path.relative(input.workspaceRoot, absolutePath)); if ( relativeToRoot.length === 0 || relativeToRoot === "." || relativeToRoot.startsWith("../") || relativeToRoot === ".." || path.isAbsolute(relativeToRoot) ) { return yield* new WorkspacePathOutsideRootError({ workspaceRoot: input.workspaceRoot, relativePath: input.relativePath, }); } return { absolutePath, relativePath: relativeToRoot, }; }); return WorkspacePaths.of({ normalizeWorkspaceRoot, resolveRelativePathWithinRoot }); }); export const layer = Layer.effect(WorkspacePaths, make);