mirror of
https://gitee.com/zhangsken/opencode.git
synced 2026-10-16 10:30:01 +08:00
feat(server): add fs.write endpoint (#49466)
This commit is contained in:
@@ -263,7 +263,8 @@ test("concurrent service processes elect one server", async () => {
|
||||
version: info.version,
|
||||
pid: info.pid,
|
||||
urls: [info.url],
|
||||
paths: { tmp: path.join(os.tmpdir(), "opencode") },
|
||||
// The server reports the canonical tmp directory; Windows os.tmpdir() can be an 8.3 short name.
|
||||
paths: { tmp: await fs.realpath(path.join(os.tmpdir(), "opencode")) },
|
||||
})
|
||||
const contender = Bun.spawn(command, { env, stderr: "pipe", stdout: "ignore" })
|
||||
try {
|
||||
|
||||
@@ -175,6 +175,8 @@ import type {
|
||||
FileListOutput,
|
||||
FileFindInput,
|
||||
FileFindOutput,
|
||||
FileWriteInput,
|
||||
FileWriteOutput,
|
||||
CommandListInput,
|
||||
CommandListOutput,
|
||||
SkillListInput,
|
||||
@@ -277,6 +279,7 @@ interface RequestDescriptor {
|
||||
readonly query?: Record<string, unknown>
|
||||
readonly headers?: Record<string, unknown>
|
||||
readonly body?: unknown
|
||||
readonly binaryBody?: true
|
||||
readonly successStatus: number
|
||||
readonly declaredStatuses: ReadonlyArray<number>
|
||||
readonly empty: boolean
|
||||
@@ -296,14 +299,20 @@ export function make(options: ClientOptions) {
|
||||
if (value !== undefined && value !== null) headers.set(key, String(value))
|
||||
}
|
||||
for (const [key, value] of new Headers(requestOptions?.headers)) headers.set(key, value)
|
||||
if (descriptor.body !== undefined && !headers.has("content-type")) headers.set("content-type", "application/json")
|
||||
if (descriptor.body !== undefined && !headers.has("content-type"))
|
||||
headers.set("content-type", descriptor.binaryBody ? "application/octet-stream" : "application/json")
|
||||
return {
|
||||
url,
|
||||
init: {
|
||||
method: descriptor.method,
|
||||
signal: requestOptions?.signal,
|
||||
headers,
|
||||
body: descriptor.body === undefined ? undefined : JSON.stringify(descriptor.body),
|
||||
body:
|
||||
descriptor.body === undefined
|
||||
? undefined
|
||||
: descriptor.binaryBody
|
||||
? (descriptor.body as RequestInit["body"])
|
||||
: JSON.stringify(descriptor.body),
|
||||
} satisfies RequestInit,
|
||||
}
|
||||
}
|
||||
@@ -1566,6 +1575,20 @@ export function make(options: ClientOptions) {
|
||||
},
|
||||
requestOptions,
|
||||
),
|
||||
write: (input: FileWriteInput, requestOptions?: RequestOptions) =>
|
||||
request<FileWriteOutput>(
|
||||
{
|
||||
method: "POST",
|
||||
path: `/api/experimental/fs/write`,
|
||||
query: { location: input["location"], path: input["path"] },
|
||||
body: input["payload"],
|
||||
successStatus: 200,
|
||||
declaredStatuses: [400, 401],
|
||||
empty: false,
|
||||
binaryBody: true,
|
||||
},
|
||||
requestOptions,
|
||||
),
|
||||
},
|
||||
command: {
|
||||
list: (input?: CommandListInput, requestOptions?: RequestOptions) =>
|
||||
|
||||
@@ -319,6 +319,8 @@ export type PermissionSavedInfo = {
|
||||
|
||||
export type FileSystemEntry = { path: string; type: "file" | "directory" }
|
||||
|
||||
export type FileSystemWrite = { path: string }
|
||||
|
||||
export type CommandInfo = { name: string; description?: string }
|
||||
|
||||
export type SkillInfo = {
|
||||
@@ -5793,6 +5795,20 @@ export type FileFindInput = {
|
||||
|
||||
export type FileFindOutput = { location: LocationPublicRef; data: Array<FileSystemEntry> }
|
||||
|
||||
export type FileWriteInput = {
|
||||
readonly location?: {
|
||||
readonly location?: { readonly directory?: string | undefined } | undefined
|
||||
readonly path: string
|
||||
}["location"]
|
||||
readonly path: {
|
||||
readonly location?: { readonly directory?: string | undefined } | undefined
|
||||
readonly path: string
|
||||
}["path"]
|
||||
readonly payload: globalThis.Uint8Array
|
||||
}
|
||||
|
||||
export type FileWriteOutput = { location: LocationPublicRef; data: FileSystemWrite }
|
||||
|
||||
export type CommandListInput = {
|
||||
readonly location?: { readonly location?: { readonly directory?: string | undefined } | undefined }["location"]
|
||||
}
|
||||
|
||||
@@ -5,9 +5,9 @@ import path from "path"
|
||||
import { Context, Effect, Layer, Schema } from "effect"
|
||||
import { FSUtil } from "@opencode/util/fs-util"
|
||||
import { Location } from "./location.js"
|
||||
import { PositiveInt, RelativePath } from "./schema.js"
|
||||
import { AbsolutePath, PositiveInt, RelativePath } from "./schema.js"
|
||||
import { FileSystemSearch } from "./filesystem/search.js"
|
||||
import { Entry, FileSystem, FindInput } from "@opencode/schema/filesystem"
|
||||
import { Entry, FileSystem, FindInput, Write } from "@opencode/schema/filesystem"
|
||||
export { Entry, Match, Submatch } from "@opencode/schema/filesystem"
|
||||
|
||||
export const ReadInput = Schema.Struct({
|
||||
@@ -15,6 +15,13 @@ export const ReadInput = Schema.Struct({
|
||||
})
|
||||
export type ReadInput = typeof ReadInput.Type
|
||||
|
||||
export const WriteInput = Schema.Struct({
|
||||
/** Absolute, or relative to the location directory. */
|
||||
path: Schema.String,
|
||||
data: Schema.Uint8Array,
|
||||
})
|
||||
export type WriteInput = typeof WriteInput.Type
|
||||
|
||||
export class NotFoundError extends Schema.TaggedError<NotFoundError>()("FileSystem.NotFoundError", {
|
||||
path: RelativePath,
|
||||
}) {}
|
||||
@@ -33,7 +40,7 @@ export const ListInput = Schema.Struct({
|
||||
})
|
||||
export type ListInput = typeof ListInput.Type
|
||||
|
||||
export { FindInput }
|
||||
export { FindInput, Write }
|
||||
|
||||
export const DEFAULT_SEARCH_LIMIT = 100
|
||||
export const DEFAULT_SEARCH_TIMEOUT_MS = 30_000
|
||||
@@ -62,6 +69,8 @@ export interface Interface {
|
||||
) => Effect.Effect<{ readonly content: Uint8Array; readonly mime: string }, NotFoundError>
|
||||
readonly list: (input?: ListInput) => Effect.Effect<Entry[]>
|
||||
readonly find: (input: FindInput) => Effect.Effect<Entry[]>
|
||||
/** Writes a file at an absolute path or one relative to the location; not confined to it. */
|
||||
readonly write: (input: WriteInput) => Effect.Effect<Write>
|
||||
}
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/FileSystem") {}
|
||||
@@ -143,6 +152,13 @@ const baseLayer = Layer.effect(
|
||||
),
|
||||
)
|
||||
}),
|
||||
// Unlike read, write reaches outside the location so clients can stage files in the
|
||||
// server tmp directory, which the model is already told to prefer and permitted to access.
|
||||
write: Effect.fn("FileSystem.write")(function* (input) {
|
||||
const target = path.resolve(location.directory, input.path)
|
||||
yield* fs.writeWithDirs(target, input.data).pipe(Effect.orDie)
|
||||
return Write.make({ path: AbsolutePath.make(target) })
|
||||
}),
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -585,11 +585,11 @@ function groupShapeTypeName(group: Group, endpoint: Endpoint) {
|
||||
function assertPromiseEndpoint(endpoint: Endpoint) {
|
||||
const name = `${endpoint.group}.${endpoint.endpoint.identifier}`
|
||||
const payload = endpoint.payloads[0]
|
||||
const payloadEncoding = payload === undefined ? undefined : resolveHttpApiEncoding(payload.ast)
|
||||
if (
|
||||
payload !== undefined &&
|
||||
(payloadEncoding?._tag ?? (HttpMethod.hasBody(endpoint.endpoint.method) ? "Json" : "FormUrlEncoded")) !== "Json"
|
||||
) {
|
||||
const payloadEncoding =
|
||||
payload === undefined
|
||||
? undefined
|
||||
: (resolveHttpApiEncoding(payload.ast)?._tag ?? (HttpMethod.hasBody(endpoint.endpoint.method) ? "Json" : "FormUrlEncoded"))
|
||||
if (payloadEncoding !== undefined && payloadEncoding !== "Json" && payloadEncoding !== "Uint8Array") {
|
||||
throw new GenerationError({ reason: `Unsupported Promise payload encoding: ${name}` })
|
||||
}
|
||||
const success = endpoint.successes[0]
|
||||
@@ -945,7 +945,7 @@ function renderPromiseClient(groups: ReadonlyArray<Group>) {
|
||||
endpoint.payloads.length === 0 ? undefined : `body: ${part("payload")}`,
|
||||
].filter((value): value is string => value !== undefined)
|
||||
const declaredStatuses = [...new Set(endpoint.errors.map((error) => error.status))]
|
||||
const descriptor = `{ method: ${JSON.stringify(endpoint.endpoint.method)}, path: ${path}${parts.length === 0 ? "" : `, ${parts.join(", ")}`}, successStatus: ${resolveHttpApiStatus(endpoint.successes[0].ast) ?? 200}, declaredStatuses: [${declaredStatuses.join(", ")}], empty: ${endpoint.operation.success === "void"}${isBinarySchema(endpoint.successes[0]) ? ", binary: true" : ""} }`
|
||||
const descriptor = `{ method: ${JSON.stringify(endpoint.endpoint.method)}, path: ${path}${parts.length === 0 ? "" : `, ${parts.join(", ")}`}, successStatus: ${resolveHttpApiStatus(endpoint.successes[0].ast) ?? 200}, declaredStatuses: [${declaredStatuses.join(", ")}], empty: ${endpoint.operation.success === "void"}${isBinarySchema(endpoint.successes[0]) ? ", binary: true" : ""}${isBinaryPayload(endpoint) ? ", binaryBody: true" : ""} }`
|
||||
if (endpoint.operation.success === "stream") {
|
||||
const success = endpoint.successes[0]
|
||||
if (!isStreamSchema(success) || success._tag !== "StreamSse" || success.sseMode !== "data") {
|
||||
@@ -1241,13 +1241,33 @@ function normalizePromiseClientContent(content: string, groups: ReadonlyArray<Gr
|
||||
"if (descriptor.binary) return new Uint8Array(await response.arrayBuffer()) as A\n if (descriptor.empty) {",
|
||||
)
|
||||
: sseReady
|
||||
const binaryBodyReady = endpoints.some(isBinaryPayload)
|
||||
? replaceOne(
|
||||
replaceOne(
|
||||
replaceOne(
|
||||
binaryReady,
|
||||
"readonly body?: unknown\n",
|
||||
"readonly body?: unknown\n readonly binaryBody?: true\n",
|
||||
),
|
||||
'if (descriptor.body !== undefined && !headers.has("content-type")) headers.set("content-type", "application/json")',
|
||||
'if (descriptor.body !== undefined && !headers.has("content-type"))\n headers.set("content-type", descriptor.binaryBody ? "application/octet-stream" : "application/json")',
|
||||
),
|
||||
"body: descriptor.body === undefined ? undefined : JSON.stringify(descriptor.body),",
|
||||
"body:\n descriptor.body === undefined\n ? undefined\n : descriptor.binaryBody\n ? (descriptor.body as RequestInit[\"body\"])\n : JSON.stringify(descriptor.body),",
|
||||
)
|
||||
: binaryReady
|
||||
return usesWildcard
|
||||
? replaceOne(
|
||||
binaryReady,
|
||||
binaryBodyReady,
|
||||
"function appendQuery(params: URLSearchParams, key: string, value: unknown): void {",
|
||||
'function encodePath(value: string): string {\n return value.split("/").map(encodeURIComponent).join("/")\n}\n\nfunction appendQuery(params: URLSearchParams, key: string, value: unknown): void {',
|
||||
)
|
||||
: binaryReady
|
||||
: binaryBodyReady
|
||||
}
|
||||
|
||||
function isBinaryPayload(endpoint: Endpoint) {
|
||||
const payload = endpoint.payloads[0]
|
||||
return payload !== undefined && resolveHttpApiEncoding(payload.ast)?._tag === "Uint8Array"
|
||||
}
|
||||
|
||||
function replaceOne(input: string, search: string, replacement: string) {
|
||||
|
||||
@@ -66,4 +66,4 @@ export const groupNames = {
|
||||
} as const
|
||||
|
||||
export const promiseOmitEndpoints = new Set(["pty.connect", "persistentPty.connect"])
|
||||
export const effectOmitEndpoints = new Set(["fs.read", "pty.connect", "persistentPty.connect"])
|
||||
export const effectOmitEndpoints = new Set(["fs.read", "fs.write", "pty.connect", "persistentPty.connect"])
|
||||
|
||||
@@ -13,6 +13,13 @@ const ListQuery = Schema.Struct({
|
||||
}),
|
||||
})
|
||||
|
||||
const WriteQuery = Schema.Struct({
|
||||
...LocationQuery.fields,
|
||||
path: Schema.String.annotate({
|
||||
description: "An absolute path or a path relative to the requested location. Missing parent directories are created.",
|
||||
}),
|
||||
})
|
||||
|
||||
const FindQuery = Schema.Struct({
|
||||
...LocationQuery.fields,
|
||||
query: FileSystem.FindInput.fields.query,
|
||||
@@ -65,6 +72,22 @@ export const FileSystemGroup = HttpApiGroup.make("server.fs")
|
||||
}),
|
||||
),
|
||||
)
|
||||
.add(
|
||||
HttpApiEndpoint.post("fs.write", "/api/experimental/fs/write", {
|
||||
query: WriteQuery,
|
||||
payload: Schema.Uint8Array.pipe(HttpApiSchema.asUint8Array()),
|
||||
success: Location.response(FileSystem.Write),
|
||||
})
|
||||
.annotateMerge(locationQueryOpenApi)
|
||||
.annotateMerge(
|
||||
OpenApi.annotations({
|
||||
identifier: "experimental.fs.write",
|
||||
summary: "Write file",
|
||||
description:
|
||||
"Write the raw request body to an absolute path or a path relative to the requested location, creating parent directories, and return the resolved absolute path. Unlike read, the target is not confined to the location. Experimental: may change without compatibility guarantees.",
|
||||
}),
|
||||
),
|
||||
)
|
||||
.annotateMerge(
|
||||
OpenApi.annotations({
|
||||
title: "filesystem",
|
||||
|
||||
@@ -3,7 +3,7 @@ export * as FileSystem from "./filesystem.js"
|
||||
import { Schema } from "effect"
|
||||
import { optional } from "./schema.js"
|
||||
import { ephemeral, inventory } from "./event.js"
|
||||
import { NonNegativeInt, PositiveInt, RelativePath } from "./schema.js"
|
||||
import { AbsolutePath, NonNegativeInt, PositiveInt, RelativePath } from "./schema.js"
|
||||
|
||||
const Changed = ephemeral({
|
||||
type: "filesystem.changed",
|
||||
@@ -41,3 +41,8 @@ export class FindInput extends Schema.Class<FindInput>("FileSystem.FindInput")({
|
||||
type: Schema.Literals(["file", "directory"]).pipe(optional),
|
||||
limit: PositiveInt.pipe(optional),
|
||||
}) {}
|
||||
|
||||
export interface Write extends Schema.Schema.Type<typeof Write> {}
|
||||
export const Write = Schema.Struct({
|
||||
path: AbsolutePath,
|
||||
}).annotate({ identifier: "FileSystem.Write" })
|
||||
|
||||
@@ -43,5 +43,13 @@ export const FileSystemHandler = HttpApiBuilder.group(Api, "server.fs", (handler
|
||||
}),
|
||||
),
|
||||
)
|
||||
.handle("fs.write", (ctx) =>
|
||||
response(
|
||||
Effect.gen(function* () {
|
||||
const fs = yield* FileSystem.Service
|
||||
return yield* fs.write({ path: ctx.query.path, data: ctx.payload })
|
||||
}),
|
||||
),
|
||||
)
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -113,6 +113,7 @@ const fileSystemLayer = Layer.succeed(
|
||||
read: () => unavailable("FileSystem.read"),
|
||||
list: () => unavailable("FileSystem.list"),
|
||||
find: () => unavailable("FileSystem.find"),
|
||||
write: () => unavailable("FileSystem.write"),
|
||||
}),
|
||||
)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user