diff --git a/packages/client/src/promise/generated/types.ts b/packages/client/src/promise/generated/types.ts index 10f33ab3d88..f24623020e0 100644 --- a/packages/client/src/promise/generated/types.ts +++ b/packages/client/src/promise/generated/types.ts @@ -293,7 +293,7 @@ export type McpResourceTemplate = { mimeType?: string } -export type ProjectVcs = "git" | "hg" +export type ProjectVcs = string export type ProjectIcon = { url?: string; override?: string; color?: string } diff --git a/packages/core/src/config/plugin/source.ts b/packages/core/src/config/plugin/source.ts index 0ea62434e92..9f3e8408017 100644 --- a/packages/core/src/config/plugin/source.ts +++ b/packages/core/src/config/plugin/source.ts @@ -4,12 +4,13 @@ import { Directory, Document, type Entry } from "@opencode-ai/schema/config" import { ConfigPlugin } from "@opencode-ai/schema/config/plugin" import { FSUtil } from "@opencode-ai/util/fs-util" import { makeLocationNode } from "@opencode-ai/util/effect/app-node" -import { Context, Effect, Layer, Option, Predicate, PubSub, Schema, Scope, Stream } from "effect" +import { Context, Effect, Layer, Option, PubSub, Scope, Stream } from "effect" import path from "path" import { fileURLToPath } from "url" import { Config } from "../../config.js" import { Watcher } from "../../filesystem/watcher.js" import { Location } from "../../location.js" +import { PluginSourceDirectory } from "../../plugin/source-directory.js" export type Operation = | { @@ -124,7 +125,10 @@ const scan = Effect.fn("ConfigPluginSource.scan")(function* ( ) { const discovered = yield* Effect.forEach( entries.filter((entry): entry is Directory => entry.type === "directory"), - (entry) => discoverDirectory(fs, entry.path), + (entry) => + PluginSourceDirectory.discover(fs, entry.path).pipe( + Effect.map((targets) => targets.map((target): Operation => ({ type: "add", target, options: {} }))), + ), ).pipe(Effect.map((items) => items.flat())) const configured = entries .filter((entry): entry is Document => entry.type === "document") @@ -153,75 +157,10 @@ const scan = Effect.fn("ConfigPluginSource.scan")(function* ( }) }) -const sourceDirectories = ["plugin", "plugins"] as const -const Package = Schema.Struct({ - exports: Schema.optional(Schema.Unknown), - module: Schema.optional(Schema.Unknown), - main: Schema.optional(Schema.Unknown), -}) -const decodePackage = Schema.decodeUnknownOption(Package) - -function discoverDirectory(fs: FSUtil.Interface, directory: string) { - return Effect.gen(function* () { - const children = (yield* Effect.forEach(sourceDirectories, (source) => - fs.readDirectoryEntries(path.join(directory, source)).pipe( - Effect.orElseSucceed(() => []), - Effect.map((entries) => - entries.map((entry) => ({ ...entry, target: path.join(directory, source, entry.name) })), - ), - ), - )) - .flat() - .sort((a, b) => (a.target < b.target ? -1 : a.target > b.target ? 1 : 0)) - const targets = yield* Effect.forEach(children, (entry) => discoverChild(fs, entry)) - return targets.flatMap(Option.toArray).map((target): Operation => ({ type: "add", target, options: {} })) - }) -} - -function discoverChild(fs: FSUtil.Interface, entry: FSUtil.DirEntry & { target: string }) { - return Effect.gen(function* () { - const source = entry.target.endsWith(".ts") || entry.target.endsWith(".js") - if (entry.type === "file" && source) return Option.some(entry.target) - if (entry.type === "directory") return yield* discoverPackage(fs, entry.target) - if (entry.type !== "symlink") return Option.none() - if (source && (yield* fs.isFile(entry.target))) return Option.some(entry.target) - if (yield* fs.isDir(entry.target)) return yield* discoverPackage(fs, entry.target) - return Option.none() - }) -} - -function discoverPackage(fs: FSUtil.Interface, directory: string) { - return Effect.gen(function* () { - const root = yield* fs.resolve(directory) - const manifest = yield* fs - .readJson(path.join(directory, "package.json")) - .pipe(Effect.map(decodePackage), Effect.orElseSucceed(Option.none)) - const configured = Option.isSome(manifest) - ? [manifest.value.exports, manifest.value.module, manifest.value.main].filter(Predicate.isString) - : [] - return yield* Effect.findFirst( - [...configured, "index.ts", "index.js"] - .filter((entry) => !path.isAbsolute(entry)) - .map((entry) => path.resolve(directory, entry)) - .filter((entry) => FSUtil.contains(directory, entry)), - (entry) => - fs - .isFile(entry) - .pipe( - Effect.flatMap((exists) => - exists - ? fs.resolve(entry).pipe(Effect.map((resolved) => FSUtil.contains(root, resolved))) - : Effect.succeed(false), - ), - ), - ) - }) -} - function isPluginSource(entries: readonly Entry[], file: string) { return entries.some( (entry) => entry.type === "directory" && - sourceDirectories.some((directory) => FSUtil.contains(path.join(entry.path, directory), file)), + PluginSourceDirectory.names.some((directory) => FSUtil.contains(path.join(entry.path, directory), file)), ) } diff --git a/packages/core/src/location-mutation.ts b/packages/core/src/location-mutation.ts index 07d3ef8bff1..42f272c90d4 100644 --- a/packages/core/src/location-mutation.ts +++ b/packages/core/src/location-mutation.ts @@ -6,6 +6,7 @@ import { Context, Effect, Layer, Schema } from "effect" import { FSUtil } from "@opencode-ai/util/fs-util" import { Location } from "./location.js" import { Project } from "./project.js" +import { ProjectMarkers } from "./project/markers.js" import { AbsolutePath } from "./schema.js" export const Kind = Schema.Literals(["file", "directory"]) @@ -64,6 +65,7 @@ const layer = Layer.effect( Effect.gen(function* () { const fs = yield* FSUtil.Service const location = yield* Location.Service + const markers = yield* ProjectMarkers.Service const resolve = Effect.fnUntraced(function* (input: ResolveInput) { const absolute = path.resolve(location.directory, input.path) @@ -90,7 +92,10 @@ const layer = Layer.effect( directory: externalDirectory, resource: externalResource, save: slash( - path.join((yield* Project.root(fs, AbsolutePath.make(externalDirectory))) ?? externalDirectory, "*"), + path.join( + (yield* Project.root(fs, AbsolutePath.make(externalDirectory), markers.targets())) ?? externalDirectory, + "*", + ), ), }, } satisfies Target @@ -103,5 +108,5 @@ const layer = Layer.effect( export const node = makeLocationNode({ service: Service, layer: layer.pipe(Layer.orDie), - deps: [FSUtil.node, Location.node], + deps: [FSUtil.node, Location.node, ProjectMarkers.node], }) diff --git a/packages/core/src/location.ts b/packages/core/src/location.ts index 459435c70c3..bb1f68b6119 100644 --- a/packages/core/src/location.ts +++ b/packages/core/src/location.ts @@ -10,6 +10,7 @@ export { Info, Ref, response } export interface Interface extends Info { readonly vcs?: Project.Vcs + readonly vcsBackend?: string } export class Service extends Context.Service()("@opencode/Location") {} @@ -27,6 +28,7 @@ const layer = (ref: Ref) => workspaceID: ref.workspaceID, project: { id: resolved.id, directory: resolved.directory, canonical: resolved.canonical }, vcs: resolved.vcs, + vcsBackend: resolved.vcsBackend, }) }), ) diff --git a/packages/core/src/plugin/internal.ts b/packages/core/src/plugin/internal.ts index d626eb2d85e..8e6406fe9ed 100644 --- a/packages/core/src/plugin/internal.ts +++ b/packages/core/src/plugin/internal.ts @@ -287,6 +287,7 @@ export const list = Effect.fn("PluginInternal.list")(function* () { plugins.map( (plugin): Plugin => ({ id: plugin.id, + vcs: plugin.vcs, effect: (host) => plugin.effect(host).pipe(Effect.provide(context)), }), ) diff --git a/packages/core/src/plugin/module.ts b/packages/core/src/plugin/module.ts new file mode 100644 index 00000000000..2d7ef0a2fbb --- /dev/null +++ b/packages/core/src/plugin/module.ts @@ -0,0 +1,62 @@ +export * as PluginModule from "./module.js" + +import type { Plugin } from "@opencode-ai/plugin/effect/plugin" +import { Npm } from "@opencode-ai/util/npm" +import { importModule } from "@opencode-ai/util/runtime-import" +import { Effect, Schema } from "effect" +import path from "path" +import { pathToFileURL } from "url" +import type { ConfigPluginSource } from "../config/plugin/source.js" +import type { Versioned } from "../plugin.js" +import { PluginPromise } from "./promise.js" + +const Discovery = Schema.Struct({ + id: Schema.optional(Schema.String), + markers: Schema.Array(Schema.String), +}) + +const Definition = Schema.Struct({ + default: Schema.Union([ + Schema.Struct({ + id: Schema.String, + tui: Schema.optional(Schema.Boolean), + vcs: Schema.optional(Discovery), + effect: Schema.declare((input): input is Plugin["effect"] => typeof input === "function"), + }), + Schema.Struct({ + id: Schema.String, + tui: Schema.optional(Schema.Boolean), + vcs: Schema.optional(Discovery), + setup: Schema.declare[0]["setup"]>( + (input): input is Parameters[0]["setup"] => typeof input === "function", + ), + }), + ]), +}) + +export const load = Effect.fn("PluginModule.load")(function* ( + operation: Extract, +) { + const npm = yield* Npm.Service + const entrypoint = path.isAbsolute(operation.target) + ? pathToFileURL(operation.target).href + : (yield* npm.add(operation.target, { subpaths: ["server", ""], refresh: true })).entrypoint + if (!entrypoint) return yield* Effect.fail(new Error(`Plugin entrypoint not found: ${operation.target}`)) + // Bun currently ignores query parameters when caching file:// imports. + const target = typeof Bun !== "undefined" ? operation.target.replaceAll("\\", "/") : entrypoint + const source = operation.mtime === undefined ? entrypoint : `${target}?mtime=${operation.mtime}` + yield* Effect.log({ msg: "loading plugin", id: operation.target, entrypoint: source }) + const mod = yield* Effect.promise(() => importModule(source)) + const value = (yield* Schema.decodeUnknownEffect(Definition)(mod)).default + const plugin = "effect" in value ? value : PluginPromise.fromPromise(value) + return { + id: plugin.id, + tui: plugin.tui, + vcs: plugin.vcs, + version: JSON.stringify(operation), + source: path.isAbsolute(operation.target) + ? { type: "local" as const, path: operation.target } + : { type: "package" as const, package: operation.target }, + effect: (host) => plugin.effect({ ...host, options: operation.options }), + } satisfies Versioned +}) diff --git a/packages/core/src/plugin/source-directory.ts b/packages/core/src/plugin/source-directory.ts new file mode 100644 index 00000000000..e4a2d1777a4 --- /dev/null +++ b/packages/core/src/plugin/source-directory.ts @@ -0,0 +1,68 @@ +export * as PluginSourceDirectory from "./source-directory.js" + +import { FSUtil } from "@opencode-ai/util/fs-util" +import { Effect, Option, Predicate, Schema } from "effect" +import path from "path" + +export const names = ["plugin", "plugins"] as const + +const Package = Schema.Struct({ + exports: Schema.optional(Schema.Unknown), + module: Schema.optional(Schema.Unknown), + main: Schema.optional(Schema.Unknown), +}) +const decodePackage = Schema.decodeUnknownOption(Package) + +export const discover = Effect.fn("PluginSourceDirectory.discover")(function* ( + fs: FSUtil.Interface, + directory: string, +) { + const children = (yield* Effect.forEach(names, (source) => + fs.readDirectoryEntries(path.join(directory, source)).pipe( + Effect.orElseSucceed(() => []), + Effect.map((entries) => entries.map((entry) => ({ ...entry, target: path.join(directory, source, entry.name) }))), + ), + )) + .flat() + .sort((a, b) => (a.target < b.target ? -1 : a.target > b.target ? 1 : 0)) + const targets = yield* Effect.forEach(children, (entry) => + Effect.gen(function* () { + const source = entry.target.endsWith(".ts") || entry.target.endsWith(".js") + if (entry.type === "file" && source) return Option.some(entry.target) + if (entry.type === "directory") return yield* packageEntry(fs, entry.target) + if (entry.type !== "symlink") return Option.none() + if (source && (yield* fs.isFile(entry.target))) return Option.some(entry.target) + if (yield* fs.isDir(entry.target)) return yield* packageEntry(fs, entry.target) + return Option.none() + }), + ) + return targets.flatMap(Option.toArray) +}) + +function packageEntry(fs: FSUtil.Interface, directory: string) { + return Effect.gen(function* () { + const root = yield* fs.resolve(directory) + const manifest = yield* fs + .readJson(path.join(directory, "package.json")) + .pipe(Effect.map(decodePackage), Effect.orElseSucceed(Option.none)) + const configured = Option.isSome(manifest) + ? [manifest.value.exports, manifest.value.module, manifest.value.main].filter(Predicate.isString) + : [] + return yield* Effect.findFirst( + [...configured, "index.ts", "index.js"] + .filter((entry) => !path.isAbsolute(entry)) + .map((entry) => path.resolve(directory, entry)) + .filter((entry) => FSUtil.contains(directory, entry)), + (entry) => + fs + .isFile(entry) + .pipe( + Effect.flatMap((exists) => + exists + ? fs.resolve(entry).pipe(Effect.map((resolved) => FSUtil.contains(root, resolved))) + : Effect.succeed(false), + ), + ), + ) + }) +} diff --git a/packages/core/src/plugin/supervisor.ts b/packages/core/src/plugin/supervisor.ts index da010ab69b2..2677aec148d 100644 --- a/packages/core/src/plugin/supervisor.ts +++ b/packages/core/src/plugin/supervisor.ts @@ -1,41 +1,19 @@ export * as PluginSupervisor from "./supervisor.js" export { Service, type Interface } from "./supervisor-service.js" -import type { Plugin as PluginDefinition } from "@opencode-ai/plugin/effect/plugin" import { Event } from "@opencode-ai/schema/config" -import { Cause, Effect, Latch, Layer, Schema, Stream } from "effect" +import { Cause, Effect, Latch, Layer, Stream } from "effect" import path from "path" -import { pathToFileURL } from "url" import { ConfigPluginSource } from "../config/plugin/source.js" import { makeLocationNode } from "@opencode-ai/util/effect/app-node" import { Bus } from "../bus.js" import { Npm } from "@opencode-ai/util/npm" import { Plugin } from "../plugin.js" -import { PluginPromise } from "../plugin/promise.js" import { PluginInternal } from "./internal.js" +import { PluginModule } from "./module.js" import { SdkPlugins } from "./sdk.js" -import { importModule } from "@opencode-ai/util/runtime-import" import { Service } from "./supervisor-service.js" -const PluginModule = Schema.Struct({ - default: Schema.Union([ - Schema.Struct({ - id: Schema.String, - tui: Schema.optional(Schema.Boolean), - effect: Schema.declare( - (input): input is PluginDefinition["effect"] => typeof input === "function", - ), - }), - Schema.Struct({ - id: Schema.String, - tui: Schema.optional(Schema.Boolean), - setup: Schema.declare[0]["setup"]>( - (input): input is Parameters[0]["setup"] => typeof input === "function", - ), - }), - ]), -}) - const resolve = Effect.fn("PluginSupervisor.resolve")(function* ( pre: readonly Plugin.Versioned[], post: readonly Plugin.Versioned[], @@ -69,7 +47,7 @@ const resolve = Effect.fn("PluginSupervisor.resolve")(function* ( continue } - const plugin = yield* load(operation).pipe( + const plugin = yield* PluginModule.load(operation).pipe( Effect.catchCause((cause) => Effect.logWarning("failed to load plugin", { target: operation.target, cause }).pipe( Effect.as({ error: Cause.pretty(cause) }), @@ -102,34 +80,6 @@ const resolve = Effect.fn("PluginSupervisor.resolve")(function* ( } }) -const load = Effect.fn("PluginSupervisor.load")(function* ( - operation: Extract, -) { - const npm = yield* Npm.Service - const entrypoint = path.isAbsolute(operation.target) - ? pathToFileURL(operation.target).href - : (yield* npm.add(operation.target, { subpaths: ["server", ""], refresh: true })).entrypoint - if (!entrypoint) return yield* Effect.fail(new Error(`Plugin entrypoint not found: ${operation.target}`)) - // Bun currently ignores query parameters when caching file:// imports. - const source = - operation.mtime === undefined - ? entrypoint - : typeof Bun !== "undefined" - ? `${operation.target.replaceAll("\\", "/")}?mtime=${operation.mtime}` - : `${entrypoint}?mtime=${operation.mtime}` - yield* Effect.log({ msg: "loading plugin", id: operation.target, entrypoint: source }) - const mod = yield* Effect.promise(() => importModule(source)) - const value = (yield* Schema.decodeUnknownEffect(PluginModule)(mod)).default - const plugin = "effect" in value ? value : PluginPromise.fromPromise(value) - return { - id: plugin.id, - tui: plugin.tui, - version: JSON.stringify(operation), - source: pluginSource(operation.target), - effect: (host) => plugin.effect({ ...host, options: operation.options }), - } satisfies Plugin.Versioned -}) - export const layer = Layer.effect( Service, Effect.gen(function* () { diff --git a/packages/core/src/plugin/vcs/git.ts b/packages/core/src/plugin/vcs/git.ts index 434974307fa..42bf3205f20 100644 --- a/packages/core/src/plugin/vcs/git.ts +++ b/packages/core/src/plugin/vcs/git.ts @@ -8,11 +8,18 @@ import { BranchList, FileStatus, Info, Mode } from "@opencode-ai/schema/vcs" import { AppProcess } from "@opencode-ai/util/process" import { Location } from "../../location.js" import type { Adapter, BranchOptions, DiffOptions } from "../../vcs.js" -import { chunksByFile, emptyPatch, MAX_PATCH_BYTES, MAX_TOTAL_PATCH_BYTES, PATCH_CONTEXT_LINES } from "../../vcs/patch.js" +import { + chunksByFile, + emptyPatch, + MAX_PATCH_BYTES, + MAX_TOTAL_PATCH_BYTES, + PATCH_CONTEXT_LINES, +} from "../../vcs/patch.js" import type { Patch } from "../../vcs/patch.js" export const Plugin = define({ id: "opencode.vcs.git", + vcs: { id: "git", markers: [".git"] }, effect: Effect.fn("VcsGitPlugin")(function* (ctx) { const location = yield* Location.Service if (location.vcs?.type !== "git") return diff --git a/packages/core/src/plugin/vcs/hg.ts b/packages/core/src/plugin/vcs/hg.ts index c4f6dddd6c6..5486ffc6ab8 100644 --- a/packages/core/src/plugin/vcs/hg.ts +++ b/packages/core/src/plugin/vcs/hg.ts @@ -23,6 +23,7 @@ import { export const Plugin = define({ id: "opencode.vcs.hg", + vcs: { id: "hg", markers: [".hg"] }, effect: Effect.fn("VcsHgPlugin")(function* (ctx) { const location = yield* Location.Service if (location.vcs?.type !== "hg") return diff --git a/packages/core/src/project.ts b/packages/core/src/project.ts index d27285550dd..d209ecb7bb1 100644 --- a/packages/core/src/project.ts +++ b/packages/core/src/project.ts @@ -13,6 +13,7 @@ import { Git } from "./git.js" import { AppProcess } from "@opencode-ai/util/process" import { makeGlobalNode } from "@opencode-ai/util/effect/app-node" import { Hash } from "@opencode-ai/util/hash" +import { ProjectMarkers } from "./project/markers.js" import { ProjectSchema } from "./project/schema.js" import { ProjectTable, upsertProject } from "./project/sql.js" import { WorktreeTable } from "./worktree/sql.js" @@ -42,11 +43,16 @@ export interface Resolved { readonly directory: AbsolutePath readonly canonical: AbsolutePath readonly vcs?: Vcs + readonly vcsBackend?: string } // Keep this filesystem-only; permission checks use it and should not execute VCS commands. -export const root = Effect.fn("Project.root")(function* (fs: FSUtil.Interface, input: AbsolutePath) { - return yield* fs.up({ targets: [".git", ".hg"], start: input, mode: "first" }).pipe( +export const root = Effect.fn("Project.root")(function* ( + fs: FSUtil.Interface, + input: AbsolutePath, + markers: readonly string[] = [".git", ".hg"], +) { + return yield* fs.up({ targets: [...markers], start: input, mode: "first" }).pipe( Effect.map((matches) => (matches[0] ? AbsolutePath.make(path.dirname(matches[0])) : undefined)), Effect.orElseSucceed(() => undefined), ) @@ -90,6 +96,7 @@ const layer = Layer.effect( Effect.gen(function* () { const fs = yield* FSUtil.Service const git = yield* Git.Service + const markers = yield* ProjectMarkers.Service const proc = yield* AppProcess.Service const bus = yield* Bus.Service const db = (yield* Database.Service).db @@ -157,11 +164,11 @@ const layer = Layer.effect( Effect.gen(function* () { if (candidate.id === item.projectID) return false if (!FSUtil.contains(directory, candidate.directory)) return false - const markers = yield* fs - .up({ targets: [".git", ".hg"], start: candidate.directory, stop: directory, mode: "first" }) + const found = yield* fs + .up({ targets: [...markers.targets()], start: candidate.directory, stop: directory, mode: "first" }) .pipe(Effect.orElseSucceed(() => [])) - if (!markers[0]) return false - return (yield* fs.resolve(path.dirname(markers[0]))) === directory + if (!found[0]) return false + return (yield* fs.resolve(path.dirname(found[0]))) === directory }), ) yield* bus.publish( @@ -305,15 +312,16 @@ const layer = Layer.effect( const resolve = Effect.fn("Project.resolve")(function* (input: AbsolutePath) { const directory = AbsolutePath.make(yield* fs.resolve(input)) - const marker = yield* fs.up({ targets: [".git", ".hg"], start: directory, mode: "first" }).pipe( + const marker = yield* markers.discover(directory) + const native = yield* fs.up({ targets: [".git", ".hg"], start: directory, mode: "first" }).pipe( Effect.map((matches) => matches[0]), Effect.orElseSucceed(() => undefined), ) const repo = - marker && path.basename(marker) === ".git" - ? yield* git.repo.discover(AbsolutePath.make(path.dirname(marker))) + native && path.basename(native) === ".git" + ? yield* git.repo.discover(AbsolutePath.make(path.dirname(native))) : undefined - if (repo) { + if (repo && (!marker || FSUtil.contains(marker.directory, repo.worktree))) { const previous = yield* cached(repo.commonDirectory) const id = (yield* remote(repo)) ?? previous ?? (yield* rootCommit(repo)) const canonical = @@ -329,11 +337,30 @@ const layer = Layer.effect( directory: repo.worktree, canonical, vcs: { type: "git" as const, store: repo.commonDirectory }, + ...(marker?.directory === repo.worktree && marker.type !== "git" ? { vcsBackend: marker.type } : {}), + }) + } + + const hg = native && path.basename(native) === ".hg" ? yield* hgDiscover(AbsolutePath.make(native)) : undefined + if (hg && (!marker || FSUtil.contains(marker.directory, hg.directory))) { + return yield* persist({ + ...hg, + canonical: hg.directory, + ...(marker?.directory === hg.directory && marker.type !== "hg" ? { vcsBackend: marker.type } : {}), + }) + } + + if (marker) { + const previous = yield* cached(marker.marker) + return yield* persist({ + previous, + id: previous ?? ID.make(Hash.fast(`vcs-repository:${marker.type}:${marker.marker}`)), + directory: marker.directory, + canonical: marker.directory, + vcs: { type: marker.type, store: marker.marker }, }) } - const hg = marker && path.basename(marker) === ".hg" ? yield* hgDiscover(AbsolutePath.make(marker)) : undefined - if (hg) return yield* persist({ ...hg, canonical: hg.directory }) return yield* persist({ id: ID.make(Hash.fast(`directory:${directory}`)), directory, @@ -349,5 +376,5 @@ const layer = Layer.effect( export const node = makeGlobalNode({ service: Service, layer: layer, - deps: [Bus.node, Database.node, FSUtil.node, Git.node, AppProcess.node], + deps: [Bus.node, Database.node, FSUtil.node, Git.node, ProjectMarkers.node, AppProcess.node], }) diff --git a/packages/core/src/project/markers.ts b/packages/core/src/project/markers.ts new file mode 100644 index 00000000000..f6f3bad08c4 --- /dev/null +++ b/packages/core/src/project/markers.ts @@ -0,0 +1,176 @@ +export * as ProjectMarkers from "./markers.js" + +import { FSUtil } from "@opencode-ai/util/fs-util" +import { Global } from "@opencode-ai/util/global" +import { Npm } from "@opencode-ai/util/npm" +import { makeGlobalNode } from "@opencode-ai/util/effect/app-node" +import { Context, Effect, Layer, Option } from "effect" +import { parse, type ParseError } from "jsonc-parser" +import path from "path" +import { fileURLToPath } from "url" +import type { ConfigPluginSource } from "../config/plugin/source.js" +import type { Versioned } from "../plugin.js" +import { PluginModule } from "../plugin/module.js" +import { PluginSourceDirectory } from "../plugin/source-directory.js" +import { SdkPlugins } from "../plugin/sdk.js" +import { AbsolutePath } from "../schema.js" + +export interface Match { + readonly type: string + readonly directory: AbsolutePath + readonly marker: AbsolutePath +} + +export interface Interface { + readonly discover: (directory: AbsolutePath) => Effect.Effect + readonly targets: () => readonly string[] +} + +export class Service extends Context.Service()("@opencode/ProjectMarkers") {} + +const layer = Layer.effect( + Service, + Effect.gen(function* () { + const fs = yield* FSUtil.Service + const global = yield* Global.Service + const npm = yield* Npm.Service + const sdk = yield* SdkPlugins.Service + const known = new Set([".git", ".hg"]) + const loaded = new Map() + + const discover = Effect.fn("ProjectMarkers.discover")(function* (directory: AbsolutePath) { + const found = yield* fs + .up({ targets: [".opencode", "opencode.json", "opencode.jsonc"], start: directory }) + .pipe(Effect.orElseSucceed(() => [])) + const roots = [global.config, ...found.filter((value) => path.basename(value) === ".opencode").toReversed()] + const files = [ + ...["opencode.json", "opencode.jsonc"].map((name) => path.join(global.config, name)), + ...found.filter((value) => path.basename(value) !== ".opencode").toReversed(), + ...roots.slice(1).flatMap((root) => ["opencode.json", "opencode.jsonc"].map((name) => path.join(root, name))), + ] + const automatic = yield* Effect.forEach(roots, (root) => PluginSourceDirectory.discover(fs, root)).pipe( + Effect.map((entries) => entries.flat()), + ) + const configured = yield* Effect.forEach([...new Set(files)], (file) => read(fs, file)).pipe( + Effect.map((entries) => entries.flat()), + ) + const operations = yield* Effect.forEach( + [ + ...automatic.map((target): ConfigPluginSource.Operation => ({ type: "add", target, options: {} })), + ...configured, + ], + (operation) => { + if (operation.type === "remove" || !path.isAbsolute(operation.target)) return Effect.succeed(operation) + return fs.stat(operation.target).pipe( + Effect.map((info) => ({ + ...operation, + mtime: Option.getOrElse(info.mtime, () => new Date(0)).getTime(), + })), + Effect.orElseSucceed(() => operation), + ) + }, + ) + const declarations = new Map() + + for (const plugin of sdk.all()) { + if (!plugin.vcs) continue + declarations.set(plugin.id, { id: plugin.vcs.id ?? plugin.id, markers: plugin.vcs.markers }) + } + + for (const operation of operations) { + if (operation.type === "remove") { + for (const id of declarations.keys()) { + if ( + operation.target === "*" || + (operation.target.endsWith(".*") ? id.startsWith(operation.target.slice(0, -1)) : operation.target === id) + ) { + declarations.delete(id) + } + } + continue + } + if (operation.target === "*" || operation.target.endsWith(".*") || operation.target.startsWith("opencode.")) + continue + const key = JSON.stringify(operation) + const plugin = loaded.has(key) + ? loaded.get(key) + : yield* PluginModule.load(operation).pipe( + Effect.provideService(Npm.Service, npm), + Effect.catchCause((cause) => + Effect.logDebug("failed to discover plugin repository markers", { + target: operation.target, + cause, + }).pipe(Effect.as(undefined)), + ), + Effect.tap((value) => Effect.sync(() => loaded.set(key, value))), + ) + if (!plugin?.vcs) continue + declarations.set(plugin.id, { id: plugin.vcs.id ?? plugin.id, markers: plugin.vcs.markers }) + } + + const markers = new Map() + for (const declaration of declarations.values()) { + if (!/^[a-z][a-z0-9._-]*$/.test(declaration.id)) continue + for (const marker of declaration.markers) { + if (!marker || marker === "." || marker === ".." || /[\\/]/.test(marker)) continue + known.add(marker) + markers.set(marker, declaration.id) + } + } + if (!markers.size) return undefined + + const marker = yield* fs.up({ targets: [...markers.keys()], start: directory, mode: "first" }).pipe( + Effect.map((entries) => entries[0]), + Effect.orElseSucceed(() => undefined), + ) + if (!marker) return undefined + const type = markers.get(path.basename(marker)) + if (!type) return undefined + return { + type, + directory: AbsolutePath.make(path.dirname(marker)), + marker: AbsolutePath.make(marker), + } satisfies Match + }) + + return Service.of({ discover, targets: () => [...known] }) + }), +) + +function read(fs: FSUtil.Interface, file: string): Effect.Effect { + return Effect.gen(function* () { + const source = yield* fs.readFileStringSafe(file).pipe(Effect.orElseSucceed(() => undefined)) + if (!source) return [] + const errors: ParseError[] = [] + const document: unknown = parse(source, errors, { allowTrailingComma: true }) + if (errors.length || typeof document !== "object" || document === null || !("plugins" in document)) return [] + if (!Array.isArray(document.plugins)) return [] + return document.plugins.flatMap((entry) => { + if (typeof entry === "string" && entry.startsWith("-")) { + return [{ type: "remove", target: entry.slice(1) }] + } + if ( + typeof entry !== "string" && + (typeof entry !== "object" || entry === null || !("package" in entry) || typeof entry.package !== "string") + ) { + return [] + } + const target = typeof entry === "string" ? entry : entry.package + const options = + typeof entry !== "string" && "options" in entry && typeof entry.options === "object" && entry.options !== null + ? Object.fromEntries(Object.entries(entry.options)) + : {} + if (target.startsWith("file://")) return [{ type: "add", target: fileURLToPath(target), options }] + if (target.startsWith("./") || target.startsWith("../")) { + return [{ type: "add", target: path.resolve(path.dirname(file), target), options }] + } + return [{ type: "add", target, options }] + }) + }) +} + +export const node = makeGlobalNode({ + service: Service, + layer, + deps: [FSUtil.node, Global.node, Npm.node, SdkPlugins.node], +}) diff --git a/packages/core/src/project/schema.ts b/packages/core/src/project/schema.ts index f2988a2d00f..4d30552797e 100644 --- a/packages/core/src/project/schema.ts +++ b/packages/core/src/project/schema.ts @@ -18,14 +18,8 @@ export type UpdateInput = typeof UpdateInput.Type export const Event = Project.Event -export const Vcs = Schema.Union([ - Schema.Struct({ - type: Schema.Literal("git"), - store: AbsolutePath, - }), - Schema.Struct({ - type: Schema.Literal("hg"), - store: AbsolutePath, - }), -]) +export const Vcs = Schema.Struct({ + type: Project.Vcs, + store: AbsolutePath, +}) export type Vcs = typeof Vcs.Type diff --git a/packages/core/src/project/sql.ts b/packages/core/src/project/sql.ts index 86ff6416ca0..3f262bfc2ba 100644 --- a/packages/core/src/project/sql.ts +++ b/packages/core/src/project/sql.ts @@ -12,7 +12,7 @@ type Transaction = Parameters[0]>[0] export const ProjectTable = sqliteTable("project", { id: text().$type().primaryKey(), worktree: absoluteColumn().notNull(), - vcs: text().$type<"git" | "hg">(), + vcs: text().$type(), name: text(), icon_url: text(), icon_url_override: text(), diff --git a/packages/core/src/vcs.ts b/packages/core/src/vcs.ts index bf2c5304f83..b3d868c4f72 100644 --- a/packages/core/src/vcs.ts +++ b/packages/core/src/vcs.ts @@ -73,7 +73,7 @@ const layer = Layer.effect( }) const selected = () => { const value = state.get() - const id = value.selection ?? vcs?.type + const id = value.selection ?? location.vcsBackend ?? vcs?.type return id ? value.providers.get(id) : undefined } const protect = (provider: VcsDefinition, operation: string, effect: Effect.Effect, fallback: A) => diff --git a/packages/core/test/project.test.ts b/packages/core/test/project.test.ts index 0d964a9f61d..57ed165456e 100644 --- a/packages/core/test/project.test.ts +++ b/packages/core/test/project.test.ts @@ -170,6 +170,106 @@ describe("Project.resolve", () => { }), ) + it.live("discovers repository markers from automatically loaded plugins", () => + Effect.gen(function* () { + const tmp = yield* Effect.acquireRelease( + Effect.promise(() => tmpdir()), + (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()), + ) + yield* Effect.promise(async () => { + await fs.mkdir(path.join(tmp.path, ".opencode", "plugins"), { recursive: true }) + await fs.mkdir(path.join(tmp.path, ".svn")) + await fs.mkdir(path.join(tmp.path, "nested", "directory"), { recursive: true }) + await Bun.write( + path.join(tmp.path, ".opencode", "plugins", "svn.ts"), + 'export default { id: "svn", vcs: { markers: [".svn"] }, setup() {} }', + ) + }) + const project = yield* Project.Service + + const result = yield* project.resolve(abs(path.join(tmp.path, "nested", "directory"))) + + expect(result.directory).toBe(abs(tmp.path)) + expect(result.canonical).toBe(abs(tmp.path)) + expect(result.vcs).toEqual({ type: "svn", store: abs(path.join(tmp.path, ".svn")) }) + expect(result.id).not.toBe(Project.ID.global) + expect((yield* project.list()).find((item) => item.id === result.id)?.vcs).toBe("svn") + }), + ) + + it.live("discovers repository markers from configured plugin files", () => + Effect.gen(function* () { + const tmp = yield* Effect.acquireRelease( + Effect.promise(() => tmpdir()), + (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()), + ) + yield* Effect.promise(async () => { + await fs.mkdir(path.join(tmp.path, ".pijul")) + await Bun.write(path.join(tmp.path, "opencode.jsonc"), '{ "plugins": ["./pijul.ts"] }') + await Bun.write( + path.join(tmp.path, "pijul.ts"), + 'export default { id: "custom.pijul", vcs: { id: "pijul", markers: [".pijul"] }, setup() {} }', + ) + }) + const project = yield* Project.Service + + const result = yield* project.resolve(abs(tmp.path)) + + expect(result.directory).toBe(abs(tmp.path)) + expect(result.vcs?.type).toBe("pijul") + }), + ) + + it.live("prefers a nested plugin repository over its parent git repository", () => + Effect.gen(function* () { + const tmp = yield* Effect.acquireRelease( + Effect.promise(() => tmpdir()), + (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()), + ) + const nested = path.join(tmp.path, "nested") + yield* Effect.promise(async () => { + await initRepo(tmp.path, { commit: true }) + await fs.mkdir(path.join(tmp.path, ".opencode", "plugins"), { recursive: true }) + await fs.mkdir(path.join(nested, ".svn"), { recursive: true }) + await Bun.write( + path.join(tmp.path, ".opencode", "plugins", "svn.ts"), + 'export default { id: "svn", vcs: { markers: [".svn"] }, setup() {} }', + ) + }) + const project = yield* Project.Service + + const result = yield* project.resolve(abs(nested)) + + expect(result.directory).toBe(abs(nested)) + expect(result.vcs?.type).toBe("svn") + }), + ) + + it.live("preserves git identity when a plugin marker shares its repository", () => + Effect.gen(function* () { + const tmp = yield* Effect.acquireRelease( + Effect.promise(() => tmpdir()), + (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()), + ) + yield* Effect.promise(async () => { + await initRepo(tmp.path, { commit: true }) + await fs.mkdir(path.join(tmp.path, ".opencode", "plugins"), { recursive: true }) + await fs.mkdir(path.join(tmp.path, ".jj")) + await Bun.write( + path.join(tmp.path, ".opencode", "plugins", "jj.ts"), + 'export default { id: "jj", vcs: { markers: [".jj"] }, setup() {} }', + ) + }) + const project = yield* Project.Service + + const result = yield* project.resolve(abs(tmp.path)) + + expect(result.id).toBe(Project.ID.make(yield* Effect.promise(() => rootCommit(tmp.path)))) + expect(result.vcs?.type).toBe("git") + expect(result.vcsBackend).toBe("jj") + }), + ) + it.live("repository markers override markerless directory projects", () => Effect.gen(function* () { const tmp = yield* Effect.acquireRelease( diff --git a/packages/plugin/src/effect/plugin.ts b/packages/plugin/src/effect/plugin.ts index 0969b925c3e..09a7aaa8b06 100644 --- a/packages/plugin/src/effect/plugin.ts +++ b/packages/plugin/src/effect/plugin.ts @@ -1,6 +1,7 @@ import type { GenerateApi, PluginApi } from "@opencode-ai/client/effect/api" import type { Effect, Scope } from "effect" import type { PluginOptions } from "../options.js" +import type { VcsDiscovery } from "../vcs.js" import type { App } from "../app.js" import type { AgentDomain } from "./agent.js" import type { AISDKDomain } from "./aisdk.js" @@ -45,6 +46,7 @@ export interface Context { export interface Plugin { readonly id: string readonly tui?: boolean + readonly vcs?: VcsDiscovery readonly effect: (context: Context) => Effect.Effect } diff --git a/packages/plugin/src/promise/adapter.ts b/packages/plugin/src/promise/adapter.ts index 9cc718ec602..02936b6d56b 100644 --- a/packages/plugin/src/promise/adapter.ts +++ b/packages/plugin/src/promise/adapter.ts @@ -69,6 +69,7 @@ export function fromPromise(plugin: Plugin) { return define({ id: plugin.id, tui: plugin.tui, + vcs: plugin.vcs, effect: (host) => Effect.gen(function* () { const [{ ClientApi }, { OpenCodeEvent }] = yield* Effect.promise(() => diff --git a/packages/plugin/src/promise/plugin.ts b/packages/plugin/src/promise/plugin.ts index fa6234f8bfc..c3150efd2ed 100644 --- a/packages/plugin/src/promise/plugin.ts +++ b/packages/plugin/src/promise/plugin.ts @@ -1,5 +1,6 @@ import type { GenerateApi, PluginApi } from "@opencode-ai/client/promise/api" import type { PluginOptions } from "../options.js" +import type { VcsDiscovery } from "../vcs.js" import type { App } from "../app.js" import type { AgentDomain } from "./agent.js" import type { AISDKDomain } from "./aisdk.js" @@ -46,6 +47,7 @@ export type Cleanup = () => Promise | void export interface Plugin { readonly id: string readonly tui?: boolean + readonly vcs?: VcsDiscovery readonly setup: (context: Context) => Promise | Cleanup | void } diff --git a/packages/plugin/src/vcs.ts b/packages/plugin/src/vcs.ts new file mode 100644 index 00000000000..7bc9db89d99 --- /dev/null +++ b/packages/plugin/src/vcs.ts @@ -0,0 +1,4 @@ +export interface VcsDiscovery { + readonly id?: string + readonly markers: readonly string[] +} diff --git a/packages/plugin/test/contract-identity.test.ts b/packages/plugin/test/contract-identity.test.ts index 76cf40ac19c..2281c64d2d7 100644 --- a/packages/plugin/test/contract-identity.test.ts +++ b/packages/plugin/test/contract-identity.test.ts @@ -1,4 +1,5 @@ import { expect, test } from "bun:test" +import { Effect } from "effect" import { Agent } from "@opencode-ai/schema/agent" import { Command } from "@opencode-ai/schema/command" import { Connection } from "@opencode-ai/schema/connection" @@ -49,6 +50,13 @@ test.each([ ]) }) +test.each([ + ["effect", Plugin.Plugin.define({ id: "svn", vcs: { markers: [".svn"] }, effect: () => Effect.void })], + ["promise", PromisePlugin.Plugin.define({ id: "svn", vcs: { markers: [".svn"] }, setup() {} })], +])("%s plugin definitions retain repository markers", (_name, plugin) => { + expect(plugin.vcs).toEqual({ markers: [".svn"] }) +}) + test("tui entrypoint exposes the plugin definition", () => { const plugin = TuiPlugin.Plugin.define({ id: "demo", setup() {} }) expect(plugin.id).toBe("demo") diff --git a/packages/schema/src/project.ts b/packages/schema/src/project.ts index 518eb3219df..219165040d2 100644 --- a/packages/schema/src/project.ts +++ b/packages/schema/src/project.ts @@ -8,7 +8,9 @@ import { ProjectID } from "./project-id.js" export const ID = ProjectID export type ID = typeof ID.Type -export const Vcs = Schema.Literals(["git", "hg"]).annotate({ identifier: "Project.Vcs" }) +export const Vcs = Schema.String.check(Schema.isPattern(/^[a-z][a-z0-9._-]*$/)).annotate({ + identifier: "Project.Vcs", +}) export const Current = Schema.Struct({ id: ID, directory: AbsolutePath,