From c74ea2166fb6063aa003a0ed761d060705514e87 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Thu, 16 Apr 2026 08:32:08 -0400 Subject: [PATCH] feat: unwrap PluginMeta, PluginLoader, CopilotModels namespaces to flat exports + barrel --- .../src/cli/cmd/tui/plugin/runtime.ts | 4 +- .../src/plugin/github-copilot/copilot.ts | 2 +- .../src/plugin/github-copilot/index.ts | 1 + .../src/plugin/github-copilot/models.ts | 264 ++++++++------- packages/opencode/src/plugin/index.ts | 2 + packages/opencode/src/plugin/loader.ts | 318 +++++++++--------- packages/opencode/src/plugin/meta.ts | 310 +++++++++-------- packages/opencode/src/plugin/plugin.ts | 2 +- .../test/fixture/plugin-meta-worker.ts | 2 +- .../test/plugin/github-copilot-models.test.ts | 2 +- .../test/plugin/loader-shared.test.ts | 2 +- packages/opencode/test/plugin/meta.test.ts | 2 +- 12 files changed, 454 insertions(+), 457 deletions(-) create mode 100644 packages/opencode/src/plugin/github-copilot/index.ts diff --git a/packages/opencode/src/cli/cmd/tui/plugin/runtime.ts b/packages/opencode/src/cli/cmd/tui/plugin/runtime.ts index af37ffbd76..0bcbcc2ef4 100644 --- a/packages/opencode/src/cli/cmd/tui/plugin/runtime.ts +++ b/packages/opencode/src/cli/cmd/tui/plugin/runtime.ts @@ -24,8 +24,8 @@ import { type PluginPackage, type PluginSource, } from "@/plugin/shared" -import { PluginLoader } from "@/plugin/loader" -import { PluginMeta } from "@/plugin/meta" +import { PluginLoader } from "@/plugin" +import { PluginMeta } from "@/plugin" import { installPlugin as installModulePlugin, patchPluginConfig, readPluginManifest } from "@/plugin/install" import { hasTheme, upsertTheme } from "../context/theme" import { Global } from "@/global" diff --git a/packages/opencode/src/plugin/github-copilot/copilot.ts b/packages/opencode/src/plugin/github-copilot/copilot.ts index c9b7e3c1c7..4541d4bbd1 100644 --- a/packages/opencode/src/plugin/github-copilot/copilot.ts +++ b/packages/opencode/src/plugin/github-copilot/copilot.ts @@ -5,7 +5,7 @@ import { InstallationVersion } from "@/installation/version" import { iife } from "@/util/iife" import { Log } from "../../util" import { setTimeout as sleep } from "node:timers/promises" -import { CopilotModels } from "./models" +import * as CopilotModels from "./models" import { MessageV2 } from "@/session/message-v2" const log = Log.create({ service: "plugin.copilot" }) diff --git a/packages/opencode/src/plugin/github-copilot/index.ts b/packages/opencode/src/plugin/github-copilot/index.ts new file mode 100644 index 0000000000..6c55632858 --- /dev/null +++ b/packages/opencode/src/plugin/github-copilot/index.ts @@ -0,0 +1 @@ +export * as CopilotModels from "./models" diff --git a/packages/opencode/src/plugin/github-copilot/models.ts b/packages/opencode/src/plugin/github-copilot/models.ts index dfd6ceceaa..56b747b581 100644 --- a/packages/opencode/src/plugin/github-copilot/models.ts +++ b/packages/opencode/src/plugin/github-copilot/models.ts @@ -1,146 +1,144 @@ import { z } from "zod" import type { Model } from "@opencode-ai/sdk/v2" -export namespace CopilotModels { - export const schema = z.object({ - data: z.array( - z.object({ - model_picker_enabled: z.boolean(), - id: z.string(), - name: z.string(), - // every version looks like: `{model.id}-YYYY-MM-DD` - version: z.string(), - supported_endpoints: z.array(z.string()).optional(), - capabilities: z.object({ - family: z.string(), - limits: z.object({ - max_context_window_tokens: z.number(), - max_output_tokens: z.number(), - max_prompt_tokens: z.number(), - vision: z - .object({ - max_prompt_image_size: z.number(), - max_prompt_images: z.number(), - supported_media_types: z.array(z.string()), - }) - .optional(), - }), - supports: z.object({ - adaptive_thinking: z.boolean().optional(), - max_thinking_budget: z.number().optional(), - min_thinking_budget: z.number().optional(), - reasoning_effort: z.array(z.string()).optional(), - streaming: z.boolean(), - structured_outputs: z.boolean().optional(), - tool_calls: z.boolean(), - vision: z.boolean().optional(), - }), +export const schema = z.object({ + data: z.array( + z.object({ + model_picker_enabled: z.boolean(), + id: z.string(), + name: z.string(), + // every version looks like: `{model.id}-YYYY-MM-DD` + version: z.string(), + supported_endpoints: z.array(z.string()).optional(), + capabilities: z.object({ + family: z.string(), + limits: z.object({ + max_context_window_tokens: z.number(), + max_output_tokens: z.number(), + max_prompt_tokens: z.number(), + vision: z + .object({ + max_prompt_image_size: z.number(), + max_prompt_images: z.number(), + supported_media_types: z.array(z.string()), + }) + .optional(), + }), + supports: z.object({ + adaptive_thinking: z.boolean().optional(), + max_thinking_budget: z.number().optional(), + min_thinking_budget: z.number().optional(), + reasoning_effort: z.array(z.string()).optional(), + streaming: z.boolean(), + structured_outputs: z.boolean().optional(), + tool_calls: z.boolean(), + vision: z.boolean().optional(), }), }), - ), - }) + }), + ), +}) - type Item = z.infer["data"][number] +type Item = z.infer["data"][number] - function build(key: string, remote: Item, url: string, prev?: Model): Model { - const reasoning = - !!remote.capabilities.supports.adaptive_thinking || - !!remote.capabilities.supports.reasoning_effort?.length || - remote.capabilities.supports.max_thinking_budget !== undefined || - remote.capabilities.supports.min_thinking_budget !== undefined - const image = - (remote.capabilities.supports.vision ?? false) || - (remote.capabilities.limits.vision?.supported_media_types ?? []).some((item) => item.startsWith("image/")) +function build(key: string, remote: Item, url: string, prev?: Model): Model { + const reasoning = + !!remote.capabilities.supports.adaptive_thinking || + !!remote.capabilities.supports.reasoning_effort?.length || + remote.capabilities.supports.max_thinking_budget !== undefined || + remote.capabilities.supports.min_thinking_budget !== undefined + const image = + (remote.capabilities.supports.vision ?? false) || + (remote.capabilities.limits.vision?.supported_media_types ?? []).some((item) => item.startsWith("image/")) - const isMsgApi = remote.supported_endpoints?.includes("/v1/messages") + const isMsgApi = remote.supported_endpoints?.includes("/v1/messages") - return { - id: key, - providerID: "github-copilot", - api: { - id: remote.id, - url: isMsgApi ? `${url}/v1` : url, - npm: isMsgApi ? "@ai-sdk/anthropic" : "@ai-sdk/github-copilot", + return { + id: key, + providerID: "github-copilot", + api: { + id: remote.id, + url: isMsgApi ? `${url}/v1` : url, + npm: isMsgApi ? "@ai-sdk/anthropic" : "@ai-sdk/github-copilot", + }, + // API response wins + status: "active", + limit: { + context: remote.capabilities.limits.max_context_window_tokens, + input: remote.capabilities.limits.max_prompt_tokens, + output: remote.capabilities.limits.max_output_tokens, + }, + capabilities: { + temperature: prev?.capabilities.temperature ?? true, + reasoning: prev?.capabilities.reasoning ?? reasoning, + attachment: prev?.capabilities.attachment ?? true, + toolcall: remote.capabilities.supports.tool_calls, + input: { + text: true, + audio: false, + image, + video: false, + pdf: false, }, - // API response wins - status: "active", - limit: { - context: remote.capabilities.limits.max_context_window_tokens, - input: remote.capabilities.limits.max_prompt_tokens, - output: remote.capabilities.limits.max_output_tokens, + output: { + text: true, + audio: false, + image: false, + video: false, + pdf: false, }, - capabilities: { - temperature: prev?.capabilities.temperature ?? true, - reasoning: prev?.capabilities.reasoning ?? reasoning, - attachment: prev?.capabilities.attachment ?? true, - toolcall: remote.capabilities.supports.tool_calls, - input: { - text: true, - audio: false, - image, - video: false, - pdf: false, - }, - output: { - text: true, - audio: false, - image: false, - video: false, - pdf: false, - }, - interleaved: false, - }, - // existing wins - family: prev?.family ?? remote.capabilities.family, - name: prev?.name ?? remote.name, - cost: { - input: 0, - output: 0, - cache: { read: 0, write: 0 }, - }, - options: prev?.options ?? {}, - headers: prev?.headers ?? {}, - release_date: - prev?.release_date ?? - (remote.version.startsWith(`${remote.id}-`) ? remote.version.slice(remote.id.length + 1) : remote.version), - variants: prev?.variants ?? {}, - } - } - - export async function get( - baseURL: string, - headers: HeadersInit = {}, - existing: Record = {}, - ): Promise> { - const data = await fetch(`${baseURL}/models`, { - headers, - signal: AbortSignal.timeout(5_000), - }).then(async (res) => { - if (!res.ok) { - throw new Error(`Failed to fetch models: ${res.status}`) - } - return schema.parse(await res.json()) - }) - - const result = { ...existing } - const remote = new Map(data.data.filter((m) => m.model_picker_enabled).map((m) => [m.id, m] as const)) - - // prune existing models whose api.id isn't in the endpoint response - for (const [key, model] of Object.entries(result)) { - const m = remote.get(model.api.id) - if (!m) { - delete result[key] - continue - } - result[key] = build(key, m, baseURL, model) - } - - // add new endpoint models not already keyed in result - for (const [id, m] of remote) { - if (id in result) continue - result[id] = build(id, m, baseURL) - } - - return result + interleaved: false, + }, + // existing wins + family: prev?.family ?? remote.capabilities.family, + name: prev?.name ?? remote.name, + cost: { + input: 0, + output: 0, + cache: { read: 0, write: 0 }, + }, + options: prev?.options ?? {}, + headers: prev?.headers ?? {}, + release_date: + prev?.release_date ?? + (remote.version.startsWith(`${remote.id}-`) ? remote.version.slice(remote.id.length + 1) : remote.version), + variants: prev?.variants ?? {}, } } + +export async function get( + baseURL: string, + headers: HeadersInit = {}, + existing: Record = {}, +): Promise> { + const data = await fetch(`${baseURL}/models`, { + headers, + signal: AbortSignal.timeout(5_000), + }).then(async (res) => { + if (!res.ok) { + throw new Error(`Failed to fetch models: ${res.status}`) + } + return schema.parse(await res.json()) + }) + + const result = { ...existing } + const remote = new Map(data.data.filter((m) => m.model_picker_enabled).map((m) => [m.id, m] as const)) + + // prune existing models whose api.id isn't in the endpoint response + for (const [key, model] of Object.entries(result)) { + const m = remote.get(model.api.id) + if (!m) { + delete result[key] + continue + } + result[key] = build(key, m, baseURL, model) + } + + // add new endpoint models not already keyed in result + for (const [id, m] of remote) { + if (id in result) continue + result[id] = build(id, m, baseURL) + } + + return result +} diff --git a/packages/opencode/src/plugin/index.ts b/packages/opencode/src/plugin/index.ts index 20f38c41c2..b64c13efab 100644 --- a/packages/opencode/src/plugin/index.ts +++ b/packages/opencode/src/plugin/index.ts @@ -1 +1,3 @@ export * as Plugin from "./plugin" +export * as PluginMeta from "./meta" +export * as PluginLoader from "./loader" diff --git a/packages/opencode/src/plugin/loader.ts b/packages/opencode/src/plugin/loader.ts index 0245d311e0..6d577594fe 100644 --- a/packages/opencode/src/plugin/loader.ts +++ b/packages/opencode/src/plugin/loader.ts @@ -11,164 +11,162 @@ import { import { ConfigPlugin } from "@/config/plugin" import { InstallationVersion } from "@/installation/version" -export namespace PluginLoader { - export type Plan = { - spec: string - options: ConfigPlugin.Options | undefined - deprecated: boolean - } - export type Resolved = Plan & { - source: PluginSource - target: string - entry: string - pkg?: PluginPackage - } - export type Missing = Plan & { - source: PluginSource - target: string - pkg?: PluginPackage - message: string - } - export type Loaded = Resolved & { - mod: Record - } - - type Candidate = { origin: ConfigPlugin.Origin; plan: Plan } - type Report = { - start?: (candidate: Candidate, retry: boolean) => void - missing?: (candidate: Candidate, retry: boolean, message: string, resolved: Missing) => void - error?: ( - candidate: Candidate, - retry: boolean, - stage: "install" | "entry" | "compatibility" | "load", - error: unknown, - resolved?: Resolved, - ) => void - } - - function plan(item: ConfigPlugin.Spec): Plan { - const spec = ConfigPlugin.pluginSpecifier(item) - return { spec, options: ConfigPlugin.pluginOptions(item), deprecated: isDeprecatedPlugin(spec) } - } - - export async function resolve( - plan: Plan, - kind: PluginKind, - ): Promise< - | { ok: true; value: Resolved } - | { ok: false; stage: "missing"; value: Missing } - | { ok: false; stage: "install" | "entry" | "compatibility"; error: unknown } - > { - let target = "" - try { - target = await resolvePluginTarget(plan.spec) - } catch (error) { - return { ok: false, stage: "install", error } - } - if (!target) return { ok: false, stage: "install", error: new Error(`Plugin ${plan.spec} target is empty`) } - - let base - try { - base = await createPluginEntry(plan.spec, target, kind) - } catch (error) { - return { ok: false, stage: "entry", error } - } - if (!base.entry) - return { - ok: false, - stage: "missing", - value: { - ...plan, - source: base.source, - target: base.target, - pkg: base.pkg, - message: `Plugin ${plan.spec} does not expose a ${kind} entrypoint`, - }, - } - - if (base.source === "npm") { - try { - await checkPluginCompatibility(base.target, InstallationVersion, base.pkg) - } catch (error) { - return { ok: false, stage: "compatibility", error } - } - } - return { ok: true, value: { ...plan, source: base.source, target: base.target, entry: base.entry, pkg: base.pkg } } - } - - export async function load(row: Resolved): Promise<{ ok: true; value: Loaded } | { ok: false; error: unknown }> { - let mod - try { - mod = await import(row.entry) - } catch (error) { - return { ok: false, error } - } - if (!mod) return { ok: false, error: new Error(`Plugin ${row.spec} module is empty`) } - return { ok: true, value: { ...row, mod } } - } - - async function attempt( - candidate: Candidate, - kind: PluginKind, - retry: boolean, - finish: ((load: Loaded, origin: ConfigPlugin.Origin, retry: boolean) => Promise) | undefined, - missing: ((value: Missing, origin: ConfigPlugin.Origin, retry: boolean) => Promise) | undefined, - report: Report | undefined, - ): Promise { - const plan = candidate.plan - if (plan.deprecated) return - report?.start?.(candidate, retry) - const resolved = await resolve(plan, kind) - if (!resolved.ok) { - if (resolved.stage === "missing") { - if (missing) { - const value = await missing(resolved.value, candidate.origin, retry) - if (value !== undefined) return value - } - report?.missing?.(candidate, retry, resolved.value.message, resolved.value) - return - } - report?.error?.(candidate, retry, resolved.stage, resolved.error) - return - } - const loaded = await load(resolved.value) - if (!loaded.ok) { - report?.error?.(candidate, retry, "load", loaded.error, resolved.value) - return - } - if (!finish) return loaded.value as R - return finish(loaded.value, candidate.origin, retry) - } - - type Input = { - items: ConfigPlugin.Origin[] - kind: PluginKind - wait?: () => Promise - finish?: (load: Loaded, origin: ConfigPlugin.Origin, retry: boolean) => Promise - missing?: (value: Missing, origin: ConfigPlugin.Origin, retry: boolean) => Promise - report?: Report - } - - export async function loadExternal(input: Input): Promise { - const candidates = input.items.map((origin) => ({ origin, plan: plan(origin.spec) })) - const list: Array> = [] - for (const candidate of candidates) { - list.push(attempt(candidate, input.kind, false, input.finish, input.missing, input.report)) - } - const out = await Promise.all(list) - if (input.wait) { - let deps: Promise | undefined - for (let i = 0; i < candidates.length; i++) { - if (out[i] !== undefined) continue - const candidate = candidates[i] - if (!candidate || pluginSource(candidate.plan.spec) !== "file") continue - deps ??= input.wait() - await deps - out[i] = await attempt(candidate, input.kind, true, input.finish, input.missing, input.report) - } - } - const ready: R[] = [] - for (const item of out) if (item !== undefined) ready.push(item) - return ready - } +export type Plan = { + spec: string + options: ConfigPlugin.Options | undefined + deprecated: boolean +} +export type Resolved = Plan & { + source: PluginSource + target: string + entry: string + pkg?: PluginPackage +} +export type Missing = Plan & { + source: PluginSource + target: string + pkg?: PluginPackage + message: string +} +export type Loaded = Resolved & { + mod: Record +} + +type Candidate = { origin: ConfigPlugin.Origin; plan: Plan } +type Report = { + start?: (candidate: Candidate, retry: boolean) => void + missing?: (candidate: Candidate, retry: boolean, message: string, resolved: Missing) => void + error?: ( + candidate: Candidate, + retry: boolean, + stage: "install" | "entry" | "compatibility" | "load", + error: unknown, + resolved?: Resolved, + ) => void +} + +function plan(item: ConfigPlugin.Spec): Plan { + const spec = ConfigPlugin.pluginSpecifier(item) + return { spec, options: ConfigPlugin.pluginOptions(item), deprecated: isDeprecatedPlugin(spec) } +} + +export async function resolve( + plan: Plan, + kind: PluginKind, +): Promise< + | { ok: true; value: Resolved } + | { ok: false; stage: "missing"; value: Missing } + | { ok: false; stage: "install" | "entry" | "compatibility"; error: unknown } +> { + let target = "" + try { + target = await resolvePluginTarget(plan.spec) + } catch (error) { + return { ok: false, stage: "install", error } + } + if (!target) return { ok: false, stage: "install", error: new Error(`Plugin ${plan.spec} target is empty`) } + + let base + try { + base = await createPluginEntry(plan.spec, target, kind) + } catch (error) { + return { ok: false, stage: "entry", error } + } + if (!base.entry) + return { + ok: false, + stage: "missing", + value: { + ...plan, + source: base.source, + target: base.target, + pkg: base.pkg, + message: `Plugin ${plan.spec} does not expose a ${kind} entrypoint`, + }, + } + + if (base.source === "npm") { + try { + await checkPluginCompatibility(base.target, InstallationVersion, base.pkg) + } catch (error) { + return { ok: false, stage: "compatibility", error } + } + } + return { ok: true, value: { ...plan, source: base.source, target: base.target, entry: base.entry, pkg: base.pkg } } +} + +export async function load(row: Resolved): Promise<{ ok: true; value: Loaded } | { ok: false; error: unknown }> { + let mod + try { + mod = await import(row.entry) + } catch (error) { + return { ok: false, error } + } + if (!mod) return { ok: false, error: new Error(`Plugin ${row.spec} module is empty`) } + return { ok: true, value: { ...row, mod } } +} + +async function attempt( + candidate: Candidate, + kind: PluginKind, + retry: boolean, + finish: ((load: Loaded, origin: ConfigPlugin.Origin, retry: boolean) => Promise) | undefined, + missing: ((value: Missing, origin: ConfigPlugin.Origin, retry: boolean) => Promise) | undefined, + report: Report | undefined, +): Promise { + const plan = candidate.plan + if (plan.deprecated) return + report?.start?.(candidate, retry) + const resolved = await resolve(plan, kind) + if (!resolved.ok) { + if (resolved.stage === "missing") { + if (missing) { + const value = await missing(resolved.value, candidate.origin, retry) + if (value !== undefined) return value + } + report?.missing?.(candidate, retry, resolved.value.message, resolved.value) + return + } + report?.error?.(candidate, retry, resolved.stage, resolved.error) + return + } + const loaded = await load(resolved.value) + if (!loaded.ok) { + report?.error?.(candidate, retry, "load", loaded.error, resolved.value) + return + } + if (!finish) return loaded.value as R + return finish(loaded.value, candidate.origin, retry) +} + +type Input = { + items: ConfigPlugin.Origin[] + kind: PluginKind + wait?: () => Promise + finish?: (load: Loaded, origin: ConfigPlugin.Origin, retry: boolean) => Promise + missing?: (value: Missing, origin: ConfigPlugin.Origin, retry: boolean) => Promise + report?: Report +} + +export async function loadExternal(input: Input): Promise { + const candidates = input.items.map((origin) => ({ origin, plan: plan(origin.spec) })) + const list: Array> = [] + for (const candidate of candidates) { + list.push(attempt(candidate, input.kind, false, input.finish, input.missing, input.report)) + } + const out = await Promise.all(list) + if (input.wait) { + let deps: Promise | undefined + for (let i = 0; i < candidates.length; i++) { + if (out[i] !== undefined) continue + const candidate = candidates[i] + if (!candidate || pluginSource(candidate.plan.spec) !== "file") continue + deps ??= input.wait() + await deps + out[i] = await attempt(candidate, input.kind, true, input.finish, input.missing, input.report) + } + } + const ready: R[] = [] + for (const item of out) if (item !== undefined) ready.push(item) + return ready } diff --git a/packages/opencode/src/plugin/meta.ts b/packages/opencode/src/plugin/meta.ts index 89955d1dfb..b113329967 100644 --- a/packages/opencode/src/plugin/meta.ts +++ b/packages/opencode/src/plugin/meta.ts @@ -8,181 +8,179 @@ import { Flock } from "@opencode-ai/shared/util/flock" import { parsePluginSpecifier, pluginSource } from "./shared" -export namespace PluginMeta { - type Source = "file" | "npm" +type Source = "file" | "npm" - export type Theme = { - src: string - dest: string - mtime?: number - size?: number - } +export type Theme = { + src: string + dest: string + mtime?: number + size?: number +} - export type Entry = { - id: string - source: Source - spec: string - target: string - requested?: string - version?: string - modified?: number - first_time: number - last_time: number - time_changed: number - load_count: number - fingerprint: string - themes?: Record - } +export type Entry = { + id: string + source: Source + spec: string + target: string + requested?: string + version?: string + modified?: number + first_time: number + last_time: number + time_changed: number + load_count: number + fingerprint: string + themes?: Record +} - export type State = "first" | "updated" | "same" +export type State = "first" | "updated" | "same" - export type Touch = { - spec: string - target: string - id: string - } +export type Touch = { + spec: string + target: string + id: string +} - type Store = Record - type Core = Omit - type Row = Touch & { core: Core } +type Store = Record +type Core = Omit +type Row = Touch & { core: Core } - function storePath() { - return Flag.OPENCODE_PLUGIN_META_FILE ?? path.join(Global.Path.state, "plugin-meta.json") - } +function storePath() { + return Flag.OPENCODE_PLUGIN_META_FILE ?? path.join(Global.Path.state, "plugin-meta.json") +} - function lock(file: string) { - return `plugin-meta:${file}` - } +function lock(file: string) { + return `plugin-meta:${file}` +} - function fileTarget(spec: string, target: string) { - if (spec.startsWith("file://")) return fileURLToPath(spec) - if (target.startsWith("file://")) return fileURLToPath(target) - return - } +function fileTarget(spec: string, target: string) { + if (spec.startsWith("file://")) return fileURLToPath(spec) + if (target.startsWith("file://")) return fileURLToPath(target) + return +} - async function modifiedAt(file: string) { - const stat = await Filesystem.statAsync(file) - if (!stat) return - const mtime = stat.mtimeMs - return Math.floor(typeof mtime === "bigint" ? Number(mtime) : mtime) - } +async function modifiedAt(file: string) { + const stat = await Filesystem.statAsync(file) + if (!stat) return + const mtime = stat.mtimeMs + return Math.floor(typeof mtime === "bigint" ? Number(mtime) : mtime) +} - function resolvedTarget(target: string) { - if (target.startsWith("file://")) return fileURLToPath(target) - return target - } +function resolvedTarget(target: string) { + if (target.startsWith("file://")) return fileURLToPath(target) + return target +} - async function npmVersion(target: string) { - const resolved = resolvedTarget(target) - const stat = await Filesystem.statAsync(resolved) - const dir = stat?.isDirectory() ? resolved : path.dirname(resolved) - return Filesystem.readJson<{ version?: string }>(path.join(dir, "package.json")) - .then((item) => item.version) - .catch(() => undefined) - } - - async function entryCore(item: Touch): Promise { - const spec = item.spec - const target = item.target - const source = pluginSource(spec) - if (source === "file") { - const file = fileTarget(spec, target) - return { - id: item.id, - source, - spec, - target, - modified: file ? await modifiedAt(file) : undefined, - } - } +async function npmVersion(target: string) { + const resolved = resolvedTarget(target) + const stat = await Filesystem.statAsync(resolved) + const dir = stat?.isDirectory() ? resolved : path.dirname(resolved) + return Filesystem.readJson<{ version?: string }>(path.join(dir, "package.json")) + .then((item) => item.version) + .catch(() => undefined) +} +async function entryCore(item: Touch): Promise { + const spec = item.spec + const target = item.target + const source = pluginSource(spec) + if (source === "file") { + const file = fileTarget(spec, target) return { id: item.id, source, spec, target, - requested: parsePluginSpecifier(spec).version, - version: await npmVersion(target), + modified: file ? await modifiedAt(file) : undefined, } } - function fingerprint(value: Core) { - if (value.source === "file") return [value.target, value.modified ?? ""].join("|") - return [value.target, value.requested ?? "", value.version ?? ""].join("|") - } - - async function read(file: string): Promise { - return Filesystem.readJson(file).catch(() => ({}) as Store) - } - - async function row(item: Touch): Promise { - return { - ...item, - core: await entryCore(item), - } - } - - function next(prev: Entry | undefined, core: Core, now: number): { state: State; entry: Entry } { - const entry: Entry = { - ...core, - first_time: prev?.first_time ?? now, - last_time: now, - time_changed: prev?.time_changed ?? now, - load_count: (prev?.load_count ?? 0) + 1, - fingerprint: fingerprint(core), - themes: prev?.themes, - } - const state: State = !prev ? "first" : prev.fingerprint === entry.fingerprint ? "same" : "updated" - if (state === "updated") entry.time_changed = now - return { - state, - entry, - } - } - - export async function touchMany(items: Touch[]): Promise> { - if (!items.length) return [] - const file = storePath() - const rows = await Promise.all(items.map((item) => row(item))) - - return Flock.withLock(lock(file), async () => { - const store = await read(file) - const now = Date.now() - const out: Array<{ state: State; entry: Entry }> = [] - for (const item of rows) { - const hit = next(store[item.id], item.core, now) - store[item.id] = hit.entry - out.push(hit) - } - await Filesystem.writeJson(file, store) - return out - }) - } - - export async function touch(spec: string, target: string, id: string): Promise<{ state: State; entry: Entry }> { - return touchMany([{ spec, target, id }]).then((item) => { - const hit = item[0] - if (hit) return hit - throw new Error("Failed to touch plugin metadata.") - }) - } - - export async function setTheme(id: string, name: string, theme: Theme): Promise { - const file = storePath() - await Flock.withLock(lock(file), async () => { - const store = await read(file) - const entry = store[id] - if (!entry) return - entry.themes = { - ...entry.themes, - [name]: theme, - } - await Filesystem.writeJson(file, store) - }) - } - - export async function list(): Promise { - const file = storePath() - return Flock.withLock(lock(file), async () => read(file)) + return { + id: item.id, + source, + spec, + target, + requested: parsePluginSpecifier(spec).version, + version: await npmVersion(target), } } + +function fingerprint(value: Core) { + if (value.source === "file") return [value.target, value.modified ?? ""].join("|") + return [value.target, value.requested ?? "", value.version ?? ""].join("|") +} + +async function read(file: string): Promise { + return Filesystem.readJson(file).catch(() => ({}) as Store) +} + +async function row(item: Touch): Promise { + return { + ...item, + core: await entryCore(item), + } +} + +function next(prev: Entry | undefined, core: Core, now: number): { state: State; entry: Entry } { + const entry: Entry = { + ...core, + first_time: prev?.first_time ?? now, + last_time: now, + time_changed: prev?.time_changed ?? now, + load_count: (prev?.load_count ?? 0) + 1, + fingerprint: fingerprint(core), + themes: prev?.themes, + } + const state: State = !prev ? "first" : prev.fingerprint === entry.fingerprint ? "same" : "updated" + if (state === "updated") entry.time_changed = now + return { + state, + entry, + } +} + +export async function touchMany(items: Touch[]): Promise> { + if (!items.length) return [] + const file = storePath() + const rows = await Promise.all(items.map((item) => row(item))) + + return Flock.withLock(lock(file), async () => { + const store = await read(file) + const now = Date.now() + const out: Array<{ state: State; entry: Entry }> = [] + for (const item of rows) { + const hit = next(store[item.id], item.core, now) + store[item.id] = hit.entry + out.push(hit) + } + await Filesystem.writeJson(file, store) + return out + }) +} + +export async function touch(spec: string, target: string, id: string): Promise<{ state: State; entry: Entry }> { + return touchMany([{ spec, target, id }]).then((item) => { + const hit = item[0] + if (hit) return hit + throw new Error("Failed to touch plugin metadata.") + }) +} + +export async function setTheme(id: string, name: string, theme: Theme): Promise { + const file = storePath() + await Flock.withLock(lock(file), async () => { + const store = await read(file) + const entry = store[id] + if (!entry) return + entry.themes = { + ...entry.themes, + [name]: theme, + } + await Filesystem.writeJson(file, store) + }) +} + +export async function list(): Promise { + const file = storePath() + return Flock.withLock(lock(file), async () => read(file)) +} diff --git a/packages/opencode/src/plugin/plugin.ts b/packages/opencode/src/plugin/plugin.ts index d1fc60d993..87ecd7ac11 100644 --- a/packages/opencode/src/plugin/plugin.ts +++ b/packages/opencode/src/plugin/plugin.ts @@ -21,7 +21,7 @@ import { Effect, Layer, Context, Stream } from "effect" import { EffectBridge } from "@/effect" import { InstanceState } from "@/effect" import { errorMessage } from "@/util/error" -import { PluginLoader } from "./loader" +import * as PluginLoader from "./loader" import { parsePluginSpecifier, readPluginId, readV1Plugin, resolvePluginId } from "./shared" import { registerAdaptor } from "@/control-plane/adaptors" import type { WorkspaceAdaptor } from "@/control-plane/types" diff --git a/packages/opencode/test/fixture/plugin-meta-worker.ts b/packages/opencode/test/fixture/plugin-meta-worker.ts index c02b448ae7..840c50c01f 100644 --- a/packages/opencode/test/fixture/plugin-meta-worker.ts +++ b/packages/opencode/test/fixture/plugin-meta-worker.ts @@ -14,6 +14,6 @@ if (typeof msg.id !== "string") throw new Error("Invalid worker payload") process.env.OPENCODE_PLUGIN_META_FILE = msg.file -const { PluginMeta } = await import("../../src/plugin/meta") +const PluginMeta = await import("../../src/plugin/meta") await PluginMeta.touch(msg.spec, msg.target, msg.id) diff --git a/packages/opencode/test/plugin/github-copilot-models.test.ts b/packages/opencode/test/plugin/github-copilot-models.test.ts index 33ddef5ddf..c6395d065b 100644 --- a/packages/opencode/test/plugin/github-copilot-models.test.ts +++ b/packages/opencode/test/plugin/github-copilot-models.test.ts @@ -1,5 +1,5 @@ import { afterEach, expect, mock, test } from "bun:test" -import { CopilotModels } from "@/plugin/github-copilot/models" +import { CopilotModels } from "@/plugin/github-copilot" import { CopilotAuthPlugin } from "@/plugin/github-copilot/copilot" const originalFetch = globalThis.fetch diff --git a/packages/opencode/test/plugin/loader-shared.test.ts b/packages/opencode/test/plugin/loader-shared.test.ts index 5072c1e748..0760c4000a 100644 --- a/packages/opencode/test/plugin/loader-shared.test.ts +++ b/packages/opencode/test/plugin/loader-shared.test.ts @@ -10,7 +10,7 @@ const disableDefault = process.env.OPENCODE_DISABLE_DEFAULT_PLUGINS process.env.OPENCODE_DISABLE_DEFAULT_PLUGINS = "1" const { Plugin } = await import("../../src/plugin/index") -const { PluginLoader } = await import("../../src/plugin/loader") +const PluginLoader = await import("../../src/plugin/loader") const { readPackageThemes } = await import("../../src/plugin/shared") const { Instance } = await import("../../src/project/instance") const { Npm } = await import("../../src/npm") diff --git a/packages/opencode/test/plugin/meta.test.ts b/packages/opencode/test/plugin/meta.test.ts index 3e2d4c6177..9236a0641b 100644 --- a/packages/opencode/test/plugin/meta.test.ts +++ b/packages/opencode/test/plugin/meta.test.ts @@ -7,7 +7,7 @@ import { tmpdir } from "../fixture/fixture" import { Process } from "../../src/util" import { Filesystem } from "../../src/util" -const { PluginMeta } = await import("../../src/plugin/meta") +const PluginMeta = await import("../../src/plugin/meta") const root = path.join(import.meta.dir, "../..") const worker = path.join(import.meta.dir, "../fixture/plugin-meta-worker.ts")