Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
53 changes: 53 additions & 0 deletions apps/server/src/server.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1927,6 +1927,59 @@ it.layer(NodeServices.layer)("server router seam", (it) => {
}).pipe(Effect.provide(NodeHttpServer.layerTest)),
);

it.effect("routes websocket rpc projects.createSymlink", () =>
Effect.gen(function* () {
const fs = yield* FileSystem.FileSystem;
const path = yield* Path.Path;
const workspaceDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-ws-project-link-" });

yield* fs.writeFileString(path.join(workspaceDir, "AGENTS.md"), "# Repo\n");
yield* buildAppUnderTest();

const wsUrl = yield* getWsServerUrl("/ws");
const response = yield* Effect.scoped(
withWsRpcClient(wsUrl, (client) =>
client[WS_METHODS.projectsCreateSymlink]({
cwd: workspaceDir,
targetRelativePath: "AGENTS.md",
linkRelativePath: "CLAUDE.md",
}),
),
);

assert.equal(response.relativePath, "CLAUDE.md");
const linked = yield* fs.readFileString(path.join(workspaceDir, "CLAUDE.md"));
assert.equal(linked, "# Repo\n");
}).pipe(Effect.provide(NodeHttpServer.layerTest)),
);

it.effect("routes websocket rpc projects.createSymlink errors", () =>
Effect.gen(function* () {
const fs = yield* FileSystem.FileSystem;
const workspaceDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-ws-project-link-" });

yield* buildAppUnderTest();

const wsUrl = yield* getWsServerUrl("/ws");
const result = yield* Effect.scoped(
withWsRpcClient(wsUrl, (client) =>
client[WS_METHODS.projectsCreateSymlink]({
cwd: workspaceDir,
targetRelativePath: "AGENTS.md",
linkRelativePath: "../CLAUDE.md",
}),
).pipe(Effect.result),
);

assertTrue(result._tag === "Failure");
assertTrue(result.failure._tag === "ProjectCreateSymlinkError");
assert.equal(
result.failure.message,
"Workspace symlink path must stay within the project root.",
);
}).pipe(Effect.provide(NodeHttpServer.layerTest)),
);

it.effect("routes websocket rpc shell.openInEditor", () =>
Effect.gen(function* () {
let openedInput: { cwd: string; editor: EditorId } | null = null;
Expand Down
15 changes: 15 additions & 0 deletions apps/server/src/workspace/Layers/WorkspaceEntries.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -198,6 +198,21 @@ it.layer(TestLayer)("WorkspaceEntriesLive", (it) => {
}),
);

it.effect("indexes symlinks in non-git workspaces", () =>
Effect.gen(function* () {
const cwd = yield* makeTempDir({ prefix: "t3code-workspace-non-git-symlink-" });
const path = yield* Path.Path;
yield* writeTextFile(cwd, "AGENTS.md", "# Repository Guidelines\n");
yield* Effect.promise(() => fsPromises.symlink("AGENTS.md", path.join(cwd, "CLAUDE.md")));

const result = yield* searchWorkspaceEntries({ cwd, query: "CLAUDE.md", limit: 10 });

expect(result.entries).toEqual(
expect.arrayContaining([expect.objectContaining({ kind: "file", path: "CLAUDE.md" })]),
);
}),
);

it.effect("deduplicates concurrent index builds for the same cwd", () =>
Effect.gen(function* () {
const cwd = yield* makeTempDir({ prefix: "t3code-workspace-concurrent-build-" });
Expand Down
2 changes: 1 addition & 1 deletion apps/server/src/workspace/Layers/WorkspaceEntries.ts
Original file line number Diff line number Diff line change
Expand Up @@ -362,7 +362,7 @@ export const makeWorkspaceEntries = Effect.gen(function* () {
if (dirent.isDirectory() && IGNORED_DIRECTORY_NAMES.has(dirent.name)) {
continue;
}
if (!dirent.isDirectory() && !dirent.isFile()) {
if (!dirent.isDirectory() && !dirent.isFile() && !dirent.isSymbolicLink()) {
continue;
}

Expand Down
55 changes: 54 additions & 1 deletion apps/server/src/workspace/Layers/WorkspaceFileSystem.test.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import fsPromises from "node:fs/promises";

import * as NodeServices from "@effect/platform-node/NodeServices";
import { it, describe, expect } from "@effect/vitest";
import { Effect, FileSystem, Layer, Path } from "effect";
Expand Down Expand Up @@ -110,8 +112,8 @@ it.layer(TestLayer)("WorkspaceFileSystemLive", (it) => {
Effect.gen(function* () {
const workspaceFileSystem = yield* WorkspaceFileSystem;
const cwd = yield* makeTempDir;
const path = yield* Path.Path;
const fileSystem = yield* FileSystem.FileSystem;
const path = yield* Path.Path;

const error = yield* workspaceFileSystem
.writeFile({
Expand All @@ -133,4 +135,55 @@ it.layer(TestLayer)("WorkspaceFileSystemLive", (it) => {
}),
);
});

describe("createSymlink", () => {
it.effect("creates symlinks relative to the workspace root", () =>
Effect.gen(function* () {
const workspaceFileSystem = yield* WorkspaceFileSystem;
const cwd = yield* makeTempDir;
const fileSystem = yield* FileSystem.FileSystem;
const path = yield* Path.Path;

yield* workspaceFileSystem.writeFile({
cwd,
relativePath: "AGENTS.md",
contents: "# Repository Guidelines\n",
});
const result = yield* workspaceFileSystem.createSymlink({
cwd,
targetRelativePath: "AGENTS.md",
linkRelativePath: "CLAUDE.md",
});

const linkPath = path.join(cwd, "CLAUDE.md");
const target = yield* Effect.promise(() => fsPromises.readlink(linkPath)).pipe(
Effect.orDie,
);
const linkedContents = yield* fileSystem.readFileString(linkPath).pipe(Effect.orDie);

expect(result).toEqual({ relativePath: "CLAUDE.md" });
expect(target).toBe("AGENTS.md");
expect(linkedContents).toBe("# Repository Guidelines\n");
}),
);

it.effect("rejects symlinks outside the workspace root", () =>
Effect.gen(function* () {
const workspaceFileSystem = yield* WorkspaceFileSystem;
const cwd = yield* makeTempDir;

const error = yield* workspaceFileSystem
.createSymlink({
cwd,
targetRelativePath: "AGENTS.md",
linkRelativePath: "../CLAUDE.md",
})
.pipe(Effect.flip);

expect(error.message).toContain(
"Workspace file path must be relative to the project root: ../CLAUDE.md",
);
}),
);
});
});
45 changes: 44 additions & 1 deletion apps/server/src/workspace/Layers/WorkspaceFileSystem.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import fsPromises from "node:fs/promises";

import { Effect, FileSystem, Layer, Path } from "effect";

import {
Expand Down Expand Up @@ -49,7 +51,48 @@ export const makeWorkspaceFileSystem = Effect.gen(function* () {
yield* workspaceEntries.invalidate(input.cwd);
return { relativePath: target.relativePath };
});
return { writeFile } satisfies WorkspaceFileSystemShape;
const createSymlink: WorkspaceFileSystemShape["createSymlink"] = Effect.fn(
"WorkspaceFileSystem.createSymlink",
)(function* (input) {
const target = yield* workspacePaths.resolveRelativePathWithinRoot({
workspaceRoot: input.cwd,
relativePath: input.targetRelativePath,
});
const link = yield* workspacePaths.resolveRelativePathWithinRoot({
workspaceRoot: input.cwd,
relativePath: input.linkRelativePath,
});

yield* fileSystem.makeDirectory(path.dirname(link.absolutePath), { recursive: true }).pipe(
Effect.mapError(
(cause) =>
new WorkspaceFileSystemError({
cwd: input.cwd,
relativePath: input.linkRelativePath,
operation: "workspaceFileSystem.makeDirectory",
detail: cause.message,
cause,
}),
),
);
const relativeTargetPath = path.relative(path.dirname(link.absolutePath), target.absolutePath);
yield* Effect.tryPromise({
try: async () => {
await fsPromises.symlink(relativeTargetPath, link.absolutePath);
},
catch: (cause) =>
new WorkspaceFileSystemError({
cwd: input.cwd,
relativePath: input.linkRelativePath,
operation: "workspaceFileSystem.createSymlink",
detail: cause instanceof Error ? cause.message : String(cause),
cause,
}),
});
yield* workspaceEntries.invalidate(input.cwd);
return { relativePath: link.relativePath };
});
return { writeFile, createSymlink } satisfies WorkspaceFileSystemShape;
});

export const WorkspaceFileSystemLive = Layer.effect(WorkspaceFileSystem, makeWorkspaceFileSystem);
20 changes: 19 additions & 1 deletion apps/server/src/workspace/Services/WorkspaceFileSystem.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,12 @@
import { Schema, ServiceMap } from "effect";
import type { Effect } from "effect";

import type { ProjectWriteFileInput, ProjectWriteFileResult } from "@t3tools/contracts";
import type {
ProjectCreateSymlinkInput,
ProjectCreateSymlinkResult,
ProjectWriteFileInput,
ProjectWriteFileResult,
} from "@t3tools/contracts";
import { WorkspacePathOutsideRootError } from "./WorkspacePaths.ts";

export class WorkspaceFileSystemError extends Schema.TaggedErrorClass<WorkspaceFileSystemError>()(
Expand Down Expand Up @@ -39,6 +44,19 @@ export interface WorkspaceFileSystemShape {
ProjectWriteFileResult,
WorkspaceFileSystemError | WorkspacePathOutsideRootError
>;

/**
* Create a symlink relative to the workspace root.
*
* Creates parent directories as needed and rejects paths that escape the
* workspace root.
*/
readonly createSymlink: (
input: ProjectCreateSymlinkInput,
) => Effect.Effect<
ProjectCreateSymlinkResult,
WorkspaceFileSystemError | WorkspacePathOutsideRootError
>;
}

/**
Expand Down
17 changes: 17 additions & 0 deletions apps/server/src/ws.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import {
OrchestrationGetSnapshotError,
OrchestrationGetTurnDiffError,
ORCHESTRATION_WS_METHODS,
ProjectCreateSymlinkError,
ProjectSearchEntriesError,
ProjectWriteFileError,
OrchestrationReplayEventsError,
Expand Down Expand Up @@ -680,6 +681,22 @@ const makeWsRpcLayer = () =>
),
{ "rpc.aggregate": "workspace" },
),
[WS_METHODS.projectsCreateSymlink]: (input) =>
observeRpcEffect(
WS_METHODS.projectsCreateSymlink,
workspaceFileSystem.createSymlink(input).pipe(
Effect.mapError((cause) => {
const message = Schema.is(WorkspacePathOutsideRootError)(cause)
? "Workspace symlink path must stay within the project root."
: "Failed to create workspace symlink";
return new ProjectCreateSymlinkError({
message,
cause,
});
}),
),
{ "rpc.aggregate": "workspace" },
),
[WS_METHODS.shellOpenInEditor]: (input) =>
observeRpcEffect(WS_METHODS.shellOpenInEditor, open.openInEditor(input), {
"rpc.aggregate": "workspace",
Expand Down
Loading