feat(core): expose project reference filesystem access (#30423)
This commit is contained in:
@@ -5,10 +5,12 @@ import { pathToFileURL } from "url"
|
|||||||
import { Context, Effect, Layer, Schema } from "effect"
|
import { Context, Effect, Layer, Schema } from "effect"
|
||||||
import { AppFileSystem } from "./filesystem"
|
import { AppFileSystem } from "./filesystem"
|
||||||
import { Location } from "./location"
|
import { Location } from "./location"
|
||||||
|
import { ProjectReference } from "./project-reference"
|
||||||
import { NonNegativeInt, PositiveInt, RelativePath } from "./schema"
|
import { NonNegativeInt, PositiveInt, RelativePath } from "./schema"
|
||||||
|
|
||||||
export const ReadInput = Schema.Struct({
|
export const ReadInput = Schema.Struct({
|
||||||
path: RelativePath,
|
path: RelativePath,
|
||||||
|
reference: Schema.String.pipe(Schema.optional),
|
||||||
})
|
})
|
||||||
export type ReadInput = typeof ReadInput.Type
|
export type ReadInput = typeof ReadInput.Type
|
||||||
|
|
||||||
@@ -30,6 +32,7 @@ export type Content = typeof Content.Type
|
|||||||
|
|
||||||
export const ListInput = Schema.Struct({
|
export const ListInput = Schema.Struct({
|
||||||
path: RelativePath.pipe(Schema.optional),
|
path: RelativePath.pipe(Schema.optional),
|
||||||
|
reference: Schema.String.pipe(Schema.optional),
|
||||||
})
|
})
|
||||||
export type ListInput = typeof ListInput.Type
|
export type ListInput = typeof ListInput.Type
|
||||||
|
|
||||||
@@ -77,31 +80,41 @@ export interface Interface {
|
|||||||
|
|
||||||
export class Service extends Context.Service<Service, Interface>()("@opencode/v2/LocationFileSystem") {}
|
export class Service extends Context.Service<Service, Interface>()("@opencode/v2/LocationFileSystem") {}
|
||||||
|
|
||||||
export const locationLayer = Layer.effect(
|
export const layer = Layer.effect(
|
||||||
Service,
|
Service,
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const fs = yield* AppFileSystem.Service
|
const fs = yield* AppFileSystem.Service
|
||||||
const location = yield* Location.Service
|
const location = yield* Location.Service
|
||||||
|
const references = yield* ProjectReference.Service
|
||||||
const root = yield* fs.realPath(location.directory).pipe(Effect.orDie)
|
const root = yield* fs.realPath(location.directory).pipe(Effect.orDie)
|
||||||
const resolve = Effect.fnUntraced(function* (input?: RelativePath) {
|
const select = Effect.fnUntraced(function* (reference?: string) {
|
||||||
|
if (!reference) return { directory: location.directory, root }
|
||||||
|
const resolved = yield* references.get(reference)
|
||||||
|
if (!resolved) return yield* Effect.die(new Error(`Unknown project reference: ${reference}`))
|
||||||
|
if (resolved.kind === "invalid") return yield* Effect.die(new Error(resolved.message))
|
||||||
|
if (resolved.kind === "git") yield* references.ensurePath(resolved.path).pipe(Effect.orDie)
|
||||||
|
return { directory: resolved.path, root: yield* fs.realPath(resolved.path).pipe(Effect.orDie) }
|
||||||
|
})
|
||||||
|
const resolve = Effect.fnUntraced(function* (input?: RelativePath, reference?: string) {
|
||||||
if (input && path.isAbsolute(input)) return yield* Effect.die(new Error("Path must be relative to the location"))
|
if (input && path.isAbsolute(input)) return yield* Effect.die(new Error("Path must be relative to the location"))
|
||||||
const absolute = path.resolve(location.directory, input ?? ".")
|
const selected = yield* select(reference)
|
||||||
if (!AppFileSystem.contains(location.directory, absolute))
|
const absolute = path.resolve(selected.directory, input ?? ".")
|
||||||
|
if (!AppFileSystem.contains(selected.directory, absolute))
|
||||||
return yield* Effect.die(new Error("Path escapes the location"))
|
return yield* Effect.die(new Error("Path escapes the location"))
|
||||||
const real = yield* fs.realPath(absolute).pipe(Effect.orDie)
|
const real = yield* fs.realPath(absolute).pipe(Effect.orDie)
|
||||||
if (!AppFileSystem.contains(root, real)) return yield* Effect.die(new Error("Path escapes the location"))
|
if (!AppFileSystem.contains(selected.root, real)) return yield* Effect.die(new Error("Path escapes the location"))
|
||||||
return { absolute, real }
|
return { absolute, real, ...selected }
|
||||||
})
|
})
|
||||||
const entry = Effect.fnUntraced(function* (absolute: string) {
|
const entry = Effect.fnUntraced(function* (absolute: string, selected: { directory: string; root: string }) {
|
||||||
const real = yield* fs.realPath(absolute).pipe(Effect.catch(() => Effect.void))
|
const real = yield* fs.realPath(absolute).pipe(Effect.catch(() => Effect.void))
|
||||||
if (!real) return
|
if (!real) return
|
||||||
if (!AppFileSystem.contains(root, real)) return
|
if (!AppFileSystem.contains(selected.root, real)) return
|
||||||
const info = yield* fs.stat(real).pipe(Effect.catch(() => Effect.void))
|
const info = yield* fs.stat(real).pipe(Effect.catch(() => Effect.void))
|
||||||
if (!info) return
|
if (!info) return
|
||||||
const type = info.type === "Directory" ? "directory" : info.type === "File" ? "file" : undefined
|
const type = info.type === "Directory" ? "directory" : info.type === "File" ? "file" : undefined
|
||||||
if (!type) return
|
if (!type) return
|
||||||
return new Entry({
|
return new Entry({
|
||||||
path: RelativePath.make(path.relative(location.directory, absolute)),
|
path: RelativePath.make(path.relative(selected.directory, absolute)),
|
||||||
uri: pathToFileURL(real).href,
|
uri: pathToFileURL(real).href,
|
||||||
type,
|
type,
|
||||||
mime: type === "directory" ? "application/x-directory" : AppFileSystem.mimeType(real),
|
mime: type === "directory" ? "application/x-directory" : AppFileSystem.mimeType(real),
|
||||||
@@ -124,7 +137,7 @@ export const locationLayer = Layer.effect(
|
|||||||
|
|
||||||
return Service.of({
|
return Service.of({
|
||||||
read: Effect.fn("LocationFileSystem.read")(function* (input) {
|
read: Effect.fn("LocationFileSystem.read")(function* (input) {
|
||||||
const file = yield* resolve(input.path)
|
const file = yield* resolve(input.path, input.reference)
|
||||||
const info = yield* fs.stat(file.real).pipe(Effect.orDie)
|
const info = yield* fs.stat(file.real).pipe(Effect.orDie)
|
||||||
if (info.type !== "File") return yield* Effect.die(new Error("Path is not a file"))
|
if (info.type !== "File") return yield* Effect.die(new Error("Path is not a file"))
|
||||||
const bytes = yield* fs.readFile(file.real).pipe(Effect.orDie)
|
const bytes = yield* fs.readFile(file.real).pipe(Effect.orDie)
|
||||||
@@ -143,13 +156,13 @@ export const locationLayer = Layer.effect(
|
|||||||
})
|
})
|
||||||
}),
|
}),
|
||||||
list: Effect.fn("LocationFileSystem.list")(function* (input = {}) {
|
list: Effect.fn("LocationFileSystem.list")(function* (input = {}) {
|
||||||
const directory = yield* resolve(input.path)
|
const directory = yield* resolve(input.path, input.reference)
|
||||||
const info = yield* fs.stat(directory.real).pipe(Effect.orDie)
|
const info = yield* fs.stat(directory.real).pipe(Effect.orDie)
|
||||||
if (info.type !== "Directory") return yield* Effect.die(new Error("Path is not a directory"))
|
if (info.type !== "Directory") return yield* Effect.die(new Error("Path is not a directory"))
|
||||||
return yield* fs.readDirectoryEntries(directory.real).pipe(
|
return yield* fs.readDirectoryEntries(directory.real).pipe(
|
||||||
Effect.orDie,
|
Effect.orDie,
|
||||||
Effect.flatMap((items) =>
|
Effect.flatMap((items) =>
|
||||||
Effect.forEach(items, (item) => entry(path.join(directory.absolute, item.name)), {
|
Effect.forEach(items, (item) => entry(path.join(directory.absolute, item.name), directory), {
|
||||||
concurrency: "unbounded",
|
concurrency: "unbounded",
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
@@ -177,3 +190,5 @@ export const locationLayer = Layer.effect(
|
|||||||
})
|
})
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
export const locationLayer = layer.pipe(Layer.provideMerge(ProjectReference.locationLayer))
|
||||||
|
|||||||
@@ -1,23 +1,34 @@
|
|||||||
import fs from "fs/promises"
|
import fs from "fs/promises"
|
||||||
import path from "path"
|
import path from "path"
|
||||||
import { pathToFileURL } from "url"
|
import { fileURLToPath } from "url"
|
||||||
import { describe, expect } from "bun:test"
|
import { describe, expect } from "bun:test"
|
||||||
import { Effect, Exit, Layer } from "effect"
|
import { Effect, Exit, Layer } from "effect"
|
||||||
import { AppFileSystem } from "@opencode-ai/core/filesystem"
|
import { AppFileSystem } from "@opencode-ai/core/filesystem"
|
||||||
import { Location } from "@opencode-ai/core/location"
|
import { Location } from "@opencode-ai/core/location"
|
||||||
import { LocationFileSystem } from "@opencode-ai/core/location-filesystem"
|
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 { AbsolutePath, RelativePath } from "@opencode-ai/core/schema"
|
||||||
import { tmpdir } from "./fixture/tmpdir"
|
import { tmpdir } from "./fixture/tmpdir"
|
||||||
import { location } from "./fixture/location"
|
import { location } from "./fixture/location"
|
||||||
import { it } from "./lib/effect"
|
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(
|
return Effect.provide(
|
||||||
LocationFileSystem.locationLayer.pipe(
|
LocationFileSystem.layer.pipe(
|
||||||
Layer.provide(
|
Layer.provide(
|
||||||
Layer.mergeAll(
|
Layer.mergeAll(
|
||||||
AppFileSystem.defaultLayer,
|
AppFileSystem.defaultLayer,
|
||||||
Layer.succeed(Location.Service, Location.Service.of(location({ directory: AbsolutePath.make(directory) }))),
|
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(
|
return Effect.acquireRelease(
|
||||||
Effect.promise(() => tmpdir()),
|
Effect.promise(() => tmpdir()),
|
||||||
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
|
(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", () => {
|
describe("LocationFileSystem", () => {
|
||||||
@@ -50,7 +61,7 @@ describe("LocationFileSystem", () => {
|
|||||||
encoding: "base64",
|
encoding: "base64",
|
||||||
mime: "application/octet-stream",
|
mime: "application/octet-stream",
|
||||||
})
|
})
|
||||||
}),
|
}).pipe(provide(directory)),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -61,21 +72,27 @@ describe("LocationFileSystem", () => {
|
|||||||
yield* Effect.promise(() => fs.writeFile(path.join(directory, "README.md"), "# Test"))
|
yield* Effect.promise(() => fs.writeFile(path.join(directory, "README.md"), "# Test"))
|
||||||
const service = yield* LocationFileSystem.Service
|
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"),
|
path: RelativePath.make("src"),
|
||||||
uri: pathToFileURL(path.join(directory, "src")).href,
|
|
||||||
type: "directory",
|
type: "directory",
|
||||||
mime: "application/x-directory",
|
mime: "application/x-directory",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: RelativePath.make("README.md"),
|
path: RelativePath.make("README.md"),
|
||||||
uri: pathToFileURL(path.join(directory, "README.md")).href,
|
|
||||||
type: "file",
|
type: "file",
|
||||||
mime: "text/markdown",
|
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(
|
expect(
|
||||||
Exit.isFailure(yield* service.read({ path: RelativePath.make("../outside.txt") }).pipe(Effect.exit)),
|
Exit.isFailure(yield* service.read({ path: RelativePath.make("../outside.txt") }).pipe(Effect.exit)),
|
||||||
).toBe(true)
|
).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),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|||||||
@@ -8,11 +8,13 @@ import { LocationQuery, locationQueryOpenApi, V2LocationMiddleware } from "./loc
|
|||||||
const ReadQuery = Schema.Struct({
|
const ReadQuery = Schema.Struct({
|
||||||
...LocationQuery.fields,
|
...LocationQuery.fields,
|
||||||
path: RelativePath,
|
path: RelativePath,
|
||||||
|
reference: Schema.String.pipe(Schema.optional),
|
||||||
})
|
})
|
||||||
|
|
||||||
const ListQuery = Schema.Struct({
|
const ListQuery = Schema.Struct({
|
||||||
...LocationQuery.fields,
|
...LocationQuery.fields,
|
||||||
path: RelativePath.pipe(Schema.optional),
|
path: RelativePath.pipe(Schema.optional),
|
||||||
|
reference: Schema.String.pipe(Schema.optional),
|
||||||
})
|
})
|
||||||
|
|
||||||
export const FileSystemGroup = HttpApiGroup.make("v2.fs")
|
export const FileSystemGroup = HttpApiGroup.make("v2.fs")
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { Location } from "@opencode-ai/core/location"
|
|||||||
import { LocationServiceMap } from "@opencode-ai/core/location-layer"
|
import { LocationServiceMap } from "@opencode-ai/core/location-layer"
|
||||||
import { LocationFileSystem } from "@opencode-ai/core/location-filesystem"
|
import { LocationFileSystem } from "@opencode-ai/core/location-filesystem"
|
||||||
import { PermissionV2 } from "@opencode-ai/core/permission"
|
import { PermissionV2 } from "@opencode-ai/core/permission"
|
||||||
|
import { ProjectReference } from "@opencode-ai/core/project-reference"
|
||||||
import { AbsolutePath } from "@opencode-ai/core/schema"
|
import { AbsolutePath } from "@opencode-ai/core/schema"
|
||||||
import { PluginBoot } from "@opencode-ai/core/plugin/boot"
|
import { PluginBoot } from "@opencode-ai/core/plugin/boot"
|
||||||
import { Effect, Layer, Schema } from "effect"
|
import { Effect, Layer, Schema } from "effect"
|
||||||
@@ -36,7 +37,12 @@ export const locationQueryOpenApi = OpenApi.annotations({
|
|||||||
export class V2LocationMiddleware extends HttpApiMiddleware.Service<
|
export class V2LocationMiddleware extends HttpApiMiddleware.Service<
|
||||||
V2LocationMiddleware,
|
V2LocationMiddleware,
|
||||||
{
|
{
|
||||||
provides: Catalog.Service | PluginBoot.Service | PermissionV2.Service | LocationFileSystem.Service
|
provides:
|
||||||
|
| Catalog.Service
|
||||||
|
| PluginBoot.Service
|
||||||
|
| PermissionV2.Service
|
||||||
|
| ProjectReference.Service
|
||||||
|
| LocationFileSystem.Service
|
||||||
}
|
}
|
||||||
>()("@opencode/ExperimentalHttpApiV2Location") {}
|
>()("@opencode/ExperimentalHttpApiV2Location") {}
|
||||||
|
|
||||||
|
|||||||
@@ -9,7 +9,12 @@ type OpenApiResponse = {
|
|||||||
readonly content?: Record<string, { readonly schema?: OpenApiSchema }>
|
readonly content?: Record<string, { readonly schema?: OpenApiSchema }>
|
||||||
}
|
}
|
||||||
type OpenApiOperation = {
|
type OpenApiOperation = {
|
||||||
readonly parameters?: ReadonlyArray<{ readonly name: string; readonly in: string }>
|
readonly parameters?: ReadonlyArray<{
|
||||||
|
readonly name: string
|
||||||
|
readonly in: string
|
||||||
|
readonly required?: boolean
|
||||||
|
readonly schema?: { readonly type?: string }
|
||||||
|
}>
|
||||||
readonly responses?: Record<string, OpenApiResponse>
|
readonly responses?: Record<string, OpenApiResponse>
|
||||||
readonly security?: unknown
|
readonly security?: unknown
|
||||||
}
|
}
|
||||||
@@ -53,6 +58,19 @@ describe("PublicApi OpenAPI v2 errors", () => {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test("documents optional project reference aliases for filesystem reads and lists", () => {
|
||||||
|
const spec = OpenApi.fromApi(PublicApi) as OpenApiSpec
|
||||||
|
|
||||||
|
for (const path of ["/api/fs/read", "/api/fs/list"]) {
|
||||||
|
expect(spec.paths[path]?.get?.parameters, path).toContainEqual({
|
||||||
|
in: "query",
|
||||||
|
name: "reference",
|
||||||
|
required: false,
|
||||||
|
schema: { type: "string" },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
test("does not rewrite /api endpoint errors to legacy error components", () => {
|
test("does not rewrite /api endpoint errors to legacy error components", () => {
|
||||||
const spec = OpenApi.fromApi(PublicApi) as OpenApiSpec
|
const spec = OpenApi.fromApi(PublicApi) as OpenApiSpec
|
||||||
const refs = v2Operations(spec)
|
const refs = v2Operations(spec)
|
||||||
|
|||||||
Reference in New Issue
Block a user