|
|
@@ -1,23 +1,34 @@
|
|
|
import fs from "fs/promises"
|
|
|
import path from "path"
|
|
|
-import { pathToFileURL } from "url"
|
|
|
+import { fileURLToPath } from "url"
|
|
|
import { describe, expect } from "bun:test"
|
|
|
import { Effect, Exit, Layer } from "effect"
|
|
|
import { AppFileSystem } from "@opencode-ai/core/filesystem"
|
|
|
import { Location } from "@opencode-ai/core/location"
|
|
|
import { LocationFileSystem } from "@opencode-ai/core/location-filesystem"
|
|
|
+import { ProjectReference } from "@opencode-ai/core/project-reference"
|
|
|
+import { Repository } from "@opencode-ai/core/repository"
|
|
|
import { AbsolutePath, RelativePath } from "@opencode-ai/core/schema"
|
|
|
import { tmpdir } from "./fixture/tmpdir"
|
|
|
import { location } from "./fixture/location"
|
|
|
import { it } from "./lib/effect"
|
|
|
|
|
|
-function provide(directory: string) {
|
|
|
+const inertReferences = ProjectReference.Service.of({
|
|
|
+ list: () => Effect.succeed([]),
|
|
|
+ get: () => Effect.succeed(undefined),
|
|
|
+ resolveMention: () => Effect.succeed(undefined),
|
|
|
+ ensurePath: () => Effect.void,
|
|
|
+ containsManagedPath: () => Effect.succeed(false),
|
|
|
+})
|
|
|
+
|
|
|
+function provide(directory: string, references = inertReferences) {
|
|
|
return Effect.provide(
|
|
|
- LocationFileSystem.locationLayer.pipe(
|
|
|
+ LocationFileSystem.layer.pipe(
|
|
|
Layer.provide(
|
|
|
Layer.mergeAll(
|
|
|
AppFileSystem.defaultLayer,
|
|
|
Layer.succeed(Location.Service, Location.Service.of(location({ directory: AbsolutePath.make(directory) }))),
|
|
|
+ Layer.succeed(ProjectReference.Service, references),
|
|
|
),
|
|
|
),
|
|
|
),
|
|
|
@@ -28,7 +39,7 @@ function withTmp<A, E, R>(f: (directory: string) => Effect.Effect<A, E, R>) {
|
|
|
return Effect.acquireRelease(
|
|
|
Effect.promise(() => tmpdir()),
|
|
|
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
|
|
|
- ).pipe(Effect.flatMap((tmp) => f(tmp.path).pipe(provide(tmp.path))))
|
|
|
+ ).pipe(Effect.flatMap((tmp) => f(tmp.path)))
|
|
|
}
|
|
|
|
|
|
describe("LocationFileSystem", () => {
|
|
|
@@ -50,7 +61,7 @@ describe("LocationFileSystem", () => {
|
|
|
encoding: "base64",
|
|
|
mime: "application/octet-stream",
|
|
|
})
|
|
|
- }),
|
|
|
+ }).pipe(provide(directory)),
|
|
|
),
|
|
|
)
|
|
|
|
|
|
@@ -61,21 +72,27 @@ describe("LocationFileSystem", () => {
|
|
|
yield* Effect.promise(() => fs.writeFile(path.join(directory, "README.md"), "# Test"))
|
|
|
const service = yield* LocationFileSystem.Service
|
|
|
|
|
|
- expect(yield* service.list()).toEqual([
|
|
|
+ const entries = yield* service.list()
|
|
|
+ expect(entries.map(({ uri: _uri, ...entry }) => entry)).toEqual([
|
|
|
{
|
|
|
path: RelativePath.make("src"),
|
|
|
- uri: pathToFileURL(path.join(directory, "src")).href,
|
|
|
type: "directory",
|
|
|
mime: "application/x-directory",
|
|
|
},
|
|
|
{
|
|
|
path: RelativePath.make("README.md"),
|
|
|
- uri: pathToFileURL(path.join(directory, "README.md")).href,
|
|
|
type: "file",
|
|
|
mime: "text/markdown",
|
|
|
},
|
|
|
])
|
|
|
- }),
|
|
|
+ expect(
|
|
|
+ yield* Effect.promise(() => Promise.all(entries.map((entry) => fs.realpath(fileURLToPath(entry.uri))))),
|
|
|
+ ).toEqual(
|
|
|
+ yield* Effect.promise(() =>
|
|
|
+ Promise.all([fs.realpath(path.join(directory, "src")), fs.realpath(path.join(directory, "README.md"))]),
|
|
|
+ ),
|
|
|
+ )
|
|
|
+ }).pipe(provide(directory)),
|
|
|
),
|
|
|
)
|
|
|
|
|
|
@@ -86,7 +103,129 @@ describe("LocationFileSystem", () => {
|
|
|
expect(
|
|
|
Exit.isFailure(yield* service.read({ path: RelativePath.make("../outside.txt") }).pipe(Effect.exit)),
|
|
|
).toBe(true)
|
|
|
- }),
|
|
|
+ }).pipe(provide(directory)),
|
|
|
),
|
|
|
)
|
|
|
+
|
|
|
+ it.live("reads and lists paths relative to a local project reference", () =>
|
|
|
+ withTmp((directory) => {
|
|
|
+ const docs = path.join(directory, "docs")
|
|
|
+ return Effect.gen(function* () {
|
|
|
+ yield* Effect.promise(async () => {
|
|
|
+ await fs.mkdir(docs)
|
|
|
+ await fs.writeFile(path.join(docs, "README.md"), "docs")
|
|
|
+ })
|
|
|
+ const service = yield* LocationFileSystem.Service
|
|
|
+
|
|
|
+ expect(yield* service.read({ reference: "docs", path: RelativePath.make("README.md") })).toMatchObject({
|
|
|
+ type: "text",
|
|
|
+ content: "docs",
|
|
|
+ })
|
|
|
+ expect(yield* service.list({ reference: "docs" })).toMatchObject([{ path: "README.md", type: "file" }])
|
|
|
+ }).pipe(provide(directory, references({ docs: { name: "docs", kind: "local", path: docs } })))
|
|
|
+ }),
|
|
|
+ )
|
|
|
+
|
|
|
+ it.live("materializes Git references before filesystem access", () =>
|
|
|
+ withTmp((directory) => {
|
|
|
+ const docs = path.join(directory, "docs")
|
|
|
+ const ensured: string[] = []
|
|
|
+ return Effect.gen(function* () {
|
|
|
+ yield* Effect.promise(async () => {
|
|
|
+ await fs.mkdir(docs)
|
|
|
+ await fs.writeFile(path.join(docs, "README.md"), "docs")
|
|
|
+ })
|
|
|
+ expect(
|
|
|
+ yield* (yield* LocationFileSystem.Service).read({ reference: "sdk", path: RelativePath.make("README.md") }),
|
|
|
+ ).toMatchObject({ content: "docs" })
|
|
|
+ expect(ensured).toEqual([docs])
|
|
|
+ }).pipe(
|
|
|
+ provide(
|
|
|
+ directory,
|
|
|
+ references(
|
|
|
+ {
|
|
|
+ sdk: {
|
|
|
+ name: "sdk",
|
|
|
+ kind: "git",
|
|
|
+ repository: "owner/repo",
|
|
|
+ reference: Repository.parseRemote("owner/repo"),
|
|
|
+ path: docs,
|
|
|
+ },
|
|
|
+ },
|
|
|
+ (target) => Effect.sync(() => ensured.push(target ?? "")),
|
|
|
+ ),
|
|
|
+ ),
|
|
|
+ )
|
|
|
+ }),
|
|
|
+ )
|
|
|
+
|
|
|
+ it.live("rejects unknown, invalid, and escaping project reference paths", () =>
|
|
|
+ withTmp((directory) => {
|
|
|
+ const docs = path.join(directory, "docs")
|
|
|
+ return Effect.gen(function* () {
|
|
|
+ yield* Effect.promise(() => fs.mkdir(docs))
|
|
|
+ const service = yield* LocationFileSystem.Service
|
|
|
+ expect(Exit.isFailure(yield* service.list({ reference: "unknown" }).pipe(Effect.exit))).toBe(true)
|
|
|
+ expect(Exit.isFailure(yield* service.list({ reference: "invalid" }).pipe(Effect.exit))).toBe(true)
|
|
|
+ expect(
|
|
|
+ Exit.isFailure(
|
|
|
+ yield* service.read({ reference: "docs", path: RelativePath.make("../outside") }).pipe(Effect.exit),
|
|
|
+ ),
|
|
|
+ ).toBe(true)
|
|
|
+ }).pipe(
|
|
|
+ provide(
|
|
|
+ directory,
|
|
|
+ references({
|
|
|
+ docs: { name: "docs", kind: "local", path: docs },
|
|
|
+ invalid: { name: "invalid", kind: "invalid", message: "invalid reference" },
|
|
|
+ }),
|
|
|
+ ),
|
|
|
+ )
|
|
|
+ }),
|
|
|
+ )
|
|
|
+
|
|
|
+ it.live("rejects aliases when project references are disabled", () =>
|
|
|
+ withTmp((directory) =>
|
|
|
+ Effect.gen(function* () {
|
|
|
+ expect(
|
|
|
+ Exit.isFailure(yield* (yield* LocationFileSystem.Service).list({ reference: "docs" }).pipe(Effect.exit)),
|
|
|
+ ).toBe(true)
|
|
|
+ }).pipe(provide(directory)),
|
|
|
+ ),
|
|
|
+ )
|
|
|
+
|
|
|
+ it.live("rejects symlink escapes from project references", () =>
|
|
|
+ withTmp((directory) => {
|
|
|
+ const docs = path.join(directory, "docs")
|
|
|
+ const outside = path.join(directory, "outside.txt")
|
|
|
+ return Effect.gen(function* () {
|
|
|
+ if (process.platform === "win32") return
|
|
|
+ yield* Effect.promise(async () => {
|
|
|
+ await fs.mkdir(docs)
|
|
|
+ await fs.writeFile(outside, "outside")
|
|
|
+ await fs.symlink(outside, path.join(docs, "link.txt"))
|
|
|
+ })
|
|
|
+ expect(
|
|
|
+ Exit.isFailure(
|
|
|
+ yield* (yield* LocationFileSystem.Service)
|
|
|
+ .read({ reference: "docs", path: RelativePath.make("link.txt") })
|
|
|
+ .pipe(Effect.exit),
|
|
|
+ ),
|
|
|
+ ).toBe(true)
|
|
|
+ }).pipe(provide(directory, references({ docs: { name: "docs", kind: "local", path: docs } })))
|
|
|
+ }),
|
|
|
+ )
|
|
|
})
|
|
|
+
|
|
|
+function references(
|
|
|
+ entries: Record<string, ProjectReference.Resolved>,
|
|
|
+ ensurePath: ProjectReference.Interface["ensurePath"] = () => Effect.void,
|
|
|
+) {
|
|
|
+ return ProjectReference.Service.of({
|
|
|
+ list: () => Effect.succeed(Object.values(entries)),
|
|
|
+ get: (name) => Effect.succeed(entries[name]),
|
|
|
+ resolveMention: () => Effect.succeed(undefined),
|
|
|
+ ensurePath,
|
|
|
+ containsManagedPath: () => Effect.succeed(false),
|
|
|
+ })
|
|
|
+}
|