From 4c7a2cdde8f6b24ea0af61a23be3e57bbd35dec8 Mon Sep 17 00:00:00 2001 From: "vit.golovin" Date: Wed, 23 Sep 2026 09:38:19 +0500 Subject: [PATCH] Add OpenCode 2 plugin entrypoint alongside V1 server hook V2 rejects the V1 function export, so the default export now carries {id, setup} for OpenCode 2 plus server() for OpenCode 1. setup registers the opencode_sync tool via ctx.tool.transform and subscribes to session.idle via ctx.event.subscribe, mirroring the V1 behavior. Also teach prunePluginCache the V2 plugins key (string and {package} specs) while keeping the legacy plugin key, and update the install docs. Covers both halves and the new pruning paths with tests/plugin.test.ts. --- README.md | 2 +- README.zh-CN.md | 2 +- src/core/stage.ts | 10 +- src/plugin/index.ts | 224 +++++++++++++++++++++++++++------ tests/plugin.test.ts | 291 +++++++++++++++++++++++++++++++++++++++++++ 5 files changed, 489 insertions(+), 40 deletions(-) create mode 100644 tests/plugin.test.ts diff --git a/README.md b/README.md index d02651b..354fdaf 100644 --- a/README.md +++ b/README.md @@ -71,7 +71,7 @@ Plus the boring things that turn out to matter: // ~/.config/opencode/opencode.json { "$schema": "https://opencode.ai/config.json", - "plugin": ["opencode-github-sync"] + "plugins": ["opencode-github-sync"] } ``` diff --git a/README.zh-CN.md b/README.zh-CN.md index b647f56..18ea608 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -69,7 +69,7 @@ opencode-sync push // ~/.config/opencode/opencode.json { "$schema": "https://opencode.ai/config.json", - "plugin": ["opencode-github-sync"] + "plugins": ["opencode-github-sync"] } ``` diff --git a/src/core/stage.ts b/src/core/stage.ts index 320d819..2381226 100644 --- a/src/core/stage.ts +++ b/src/core/stage.ts @@ -299,7 +299,15 @@ export function prunePluginCache(configRoot: string, cacheRoot: string): number return 0; } - const enabled = new Set((config?.plugin ?? []).map(pluginSpecToName)); + const raw: unknown = (config as any)?.plugins ?? (config as any)?.plugin ?? []; + const specs = (Array.isArray(raw) ? raw : []).flatMap((entry) => { + if (typeof entry === "string") return [entry]; + if (entry && typeof entry === "object" && typeof (entry as any).package === "string") { + return [(entry as any).package as string]; + } + return []; + }); + const enabled = new Set(specs.map(pluginSpecToName)); const pkg = readJson(cachePackage); if (!pkg) return 0; diff --git a/src/plugin/index.ts b/src/plugin/index.ts index db178da..76592e6 100644 --- a/src/plugin/index.ts +++ b/src/plugin/index.ts @@ -6,7 +6,7 @@ import { loadSettings, repoUrl, settingsPath } from "../core/settings.js"; import { pull, push, status } from "../core/sync.js"; /** - * OpenCode plugin entry point. + * OpenCode plugin entry point (dual V1 + V2). * * The plugin is a convenience layer, never the only way in. It runs inside the * OpenCode process, which means it cannot help when the configuration it just @@ -19,7 +19,15 @@ import { pull, push, status } from "../core/sync.js"; * - an `opencode_sync` tool, so syncing can be asked for in plain language * * Everything is off unless the user configured a repository, and every failure - * is reported as a toast instead of taking OpenCode down with it. + * is reported as a toast (V1) or a console line (V2) instead of taking + * OpenCode down with it. + * + * V1 (OpenCode 1.x) calls the default export as a function and consumes the + * returned `{ event, tool }` hook map. V2 (OpenCode 2.x) decodes the default + * export for an `{ id, setup }` definition and calls `setup(ctx)`, which + * registers the same capabilities through `ctx.tool.transform` and + * `ctx.event.subscribe`. One default export carries both halves; the two + * implementations are independent and share only the core helpers below. */ type ToastVariant = "info" | "success" | "warning" | "error"; @@ -29,6 +37,25 @@ interface PluginContext { directory?: string; } +/** Minimal structural slice of the V2 plugin context this file touches. */ +interface V2ToolEditor { + add(tool: { + name: string; + description: string; + input: unknown; + execute: (input: any) => Promise<{ content: string }>; + }): void; +} + +interface V2Context { + tool: { + transform: (callback: (editor: V2ToolEditor) => void) => Promise; + }; + event: { + subscribe: (options?: { signal?: AbortSignal }) => AsyncIterable<{ type: string }>; + }; +} + async function toast(client: any, message: string, variant: ToastVariant): Promise { try { await client?.tui?.showToast?.({ body: { message, variant } }); @@ -47,6 +74,17 @@ async function log(client: any, level: string, message: string, extra?: unknown) } } +/** V2 equivalent of log/toast: the promise context has no app.log or TUI client. */ +function notice(level: "info" | "warn", message: string, extra?: unknown): void { + if (level === "warn") { + if (extra !== undefined) console.warn(`[opencode-github-sync] ${message}`, extra); + else console.warn(`[opencode-github-sync] ${message}`); + } else { + if (extra !== undefined) console.log(`[opencode-github-sync] ${message}`, extra); + else console.log(`[opencode-github-sync] ${message}`); + } +} + function isConfigured(): boolean { const roots = getRoots(); if (!fs.existsSync(settingsPath(roots.config))) return false; @@ -81,6 +119,29 @@ async function runPull(client: any, announce: boolean): Promise { } } +async function runPullV2(announce: boolean): Promise { + const roots = getRoots(); + const reporter = new CollectingReporter(); + try { + const result = await withSyncLock(roots.config, { waitMs: 30_000 }, () => + pull({ reporter, roots }), + ); + notice("info", `pull: ${result.message}`, { summary: result.summary }); + if (result.changed) { + notice( + "info", + `Config updated from GitHub (${result.files.length} file(s)). Restart OpenCode to apply.`, + ); + } else if (announce) { + notice("info", "Config already up to date."); + } + } catch (error) { + const message = (error as Error).message; + notice("warn", `pull failed: ${message}`); + if (announce) notice("warn", `Sync pull failed: ${message}`); + } +} + async function runPush(client: any, announce: boolean): Promise { const roots = getRoots(); const reporter = new CollectingReporter(); @@ -99,6 +160,71 @@ async function runPush(client: any, announce: boolean): Promise { } } +async function runPushV2(announce: boolean): Promise { + const roots = getRoots(); + const reporter = new CollectingReporter(); + try { + const result = await withSyncLock(roots.config, { waitMs: 30_000 }, () => + push({ reporter, roots }), + ); + notice("info", `push: ${result.message}`); + if (announce) notice("info", result.message); + } catch (error) { + const message = (error as Error).message; + notice("warn", `push failed: ${message}`); + if (announce) notice("warn", `Sync push failed: ${message}`); + } +} + +const SYNC_TOOL_DESCRIPTION = + "Sync OpenCode configuration with the GitHub sync repository. " + + "Use action 'push' to upload this machine's configuration, 'pull' to apply the shared " + + "configuration, or 'status' to report what is out of sync."; + +const SYNC_TOOL_INPUT = { + type: "object", + properties: { + action: { + type: "string", + enum: ["push", "pull", "status"], + description: "Which sync operation to run.", + }, + }, + additionalProperties: false, +} as const; + +async function executeSyncAction(args: { action?: string }): Promise { + const roots = getRoots(); + const action = args?.action ?? "status"; + const reporter = new CollectingReporter(); + + if (action === "status") { + const state = status({ roots }); + return JSON.stringify(state, null, 2); + } + + const result = await withSyncLock(roots.config, { waitMs: 60_000 }, () => + action === "push" ? push({ reporter, roots }) : pull({ reporter, roots }), + ); + + const lines = [result.message]; + if (result.files.length > 0) { + lines.push( + `Files: ${result.files + .slice(0, 20) + .map((f) => `${f.kind[0]} ${f.path}`) + .join(", ")}`, + ); + } + if (result.restartRequired && result.changed) { + lines.push("Restart OpenCode for the new configuration to take effect."); + } + if (reporter.lines.length > 0) { + lines.push(...reporter.lines.filter((l) => l.level === "warn").map((l) => `! ${l.message}`)); + } + return lines.join("\n"); +} + export const OpencodeGithubSync = async (ctx: PluginContext) => { const client = ctx?.client; const roots = getRoots(); @@ -129,10 +255,7 @@ export const OpencodeGithubSync = async (ctx: PluginContext) => { tool: { opencode_sync: { - description: - "Sync OpenCode configuration with the GitHub sync repository. " + - "Use action 'push' to upload this machine's configuration, 'pull' to apply the shared " + - "configuration, or 'status' to report what is out of sync.", + description: SYNC_TOOL_DESCRIPTION, args: { action: { type: "string", @@ -141,40 +264,67 @@ export const OpencodeGithubSync = async (ctx: PluginContext) => { }, }, async execute(args: { action?: string }) { - const action = args?.action ?? "status"; - const reporter = new CollectingReporter(); - - if (action === "status") { - const state = status({ roots }); - return JSON.stringify(state, null, 2); - } - - const result = await withSyncLock(roots.config, { waitMs: 60_000 }, () => - action === "push" ? push({ reporter, roots }) : pull({ reporter, roots }), - ); - - const lines = [result.message]; - if (result.files.length > 0) { - lines.push( - `Files: ${result.files - .slice(0, 20) - .map((f) => `${f.kind[0]} ${f.path}`) - .join(", ")}`, - ); - } - if (result.restartRequired && result.changed) { - lines.push("Restart OpenCode for the new configuration to take effect."); - } - if (reporter.lines.length > 0) { - lines.push( - ...reporter.lines.filter((l) => l.level === "warn").map((l) => `! ${l.message}`), - ); - } - return lines.join("\n"); + return executeSyncAction(args); }, }, }, }; }; -export default OpencodeGithubSync; +async function setup(ctx: V2Context): Promise<() => void> { + const controller = new AbortController(); + const roots = getRoots(); + + if (!isConfigured()) { + notice( + "info", + "opencode-github-sync is installed but no repository is configured. Run `opencode-sync init`.", + ); + return () => controller.abort(); + } + + const settings = loadSettings(roots.config); + + if (settings.autoPullOnStartup) { + // Deliberately not awaited: OpenCode should finish starting even when the + // network is slow or GitHub is unreachable. + void runPullV2(false); + } + + await ctx.tool.transform((editor) => { + editor.add({ + name: "opencode_sync", + description: SYNC_TOOL_DESCRIPTION, + input: SYNC_TOOL_INPUT, + async execute(input) { + return { content: await executeSyncAction(input as { action?: string }) }; + }, + }); + }); + + if (settings.autoPushOnIdle) { + void (async () => { + try { + for await (const event of ctx.event.subscribe({ signal: controller.signal })) { + if (event.type === "session.idle") { + void runPushV2(false); + } + } + } catch { + // Aborted on unload — nothing to report. + } + })(); + } + + return () => controller.abort(); +} + +const V2Plugin = { + id: "opencode-github-sync", + setup, +}; + +export default { + ...V2Plugin, + server: OpencodeGithubSync, +}; diff --git a/tests/plugin.test.ts b/tests/plugin.test.ts new file mode 100644 index 0000000..13596ae --- /dev/null +++ b/tests/plugin.test.ts @@ -0,0 +1,291 @@ +import { execFileSync } from "node:child_process"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { getRoots } from "../src/core/paths.js"; +import { DEFAULT_SETTINGS, type Settings, saveSettings } from "../src/core/settings.js"; +import { prunePluginCache } from "../src/core/stage.js"; +import plugin, { OpencodeGithubSync } from "../src/plugin/index.js"; + +/** + * Plugin entrypoint coverage (V1 + V2 halves). + * + * The core sync logic already has end-to-end coverage in sync.test.ts; these + * tests pin the plugin wiring: the dual default export shape both OpenCode + * loaders accept, tool registration through the V2 transform API, the idle + * subscription, and cache pruning for both config keys. + */ + +let sandbox: string; +let remote: string; +let savedEnv: Record; + +const ENV_KEYS = [ + "SYNC_CONFIG_ROOT", + "SYNC_DATA_ROOT", + "SYNC_STATE_ROOT", + "SYNC_AGENTS_ROOT", + "SYNC_CACHE_ROOT", + "SYNC_REMOTE_URL", + "SYNC_HOME", + "OPENCODE_SYNC_HOST_ALIAS", +]; + +interface Machine { + config: string; + data: string; + state: string; + agents: string; + cache: string; +} + +beforeEach(() => { + savedEnv = {}; + for (const key of ENV_KEYS) savedEnv[key] = process.env[key]; + sandbox = fs.mkdtempSync(path.join(os.tmpdir(), "ogs-plugin-")); + remote = path.join(sandbox, "remote.git"); + execFileSync("git", ["init", "--bare", "-b", "main", remote], { stdio: "pipe" }); +}); + +afterEach(() => { + for (const key of ENV_KEYS) { + if (savedEnv[key] === undefined) delete process.env[key]; + else process.env[key] = savedEnv[key]; + } + fs.rmSync(sandbox, { recursive: true, force: true }); +}); + +function makeMachine(name: string): Machine { + const base = path.join(sandbox, name); + const machine: Machine = { + config: path.join(base, "config"), + data: path.join(base, "data"), + state: path.join(base, "state"), + agents: path.join(base, "agents", "skills"), + cache: path.join(base, "cache"), + }; + for (const dir of Object.values(machine)) fs.mkdirSync(dir, { recursive: true }); + return machine; +} + +function useMachine(machine: Machine): void { + process.env.SYNC_CONFIG_ROOT = machine.config; + process.env.SYNC_DATA_ROOT = machine.data; + process.env.SYNC_STATE_ROOT = machine.state; + process.env.SYNC_AGENTS_ROOT = machine.agents; + process.env.SYNC_CACHE_ROOT = machine.cache; +} + +function writeSettings( + configDir: string, + patch: Partial = {}, + repoUrl: string = remote, +): void { + const settings: Settings = { + ...DEFAULT_SETTINGS, + ...patch, + repo: { ...DEFAULT_SETTINGS.repo, url: repoUrl, ...(patch.repo ?? {}) }, + sessions: { ...DEFAULT_SETTINGS.sessions, ...(patch.sessions ?? {}) }, + }; + saveSettings(settings, configDir); +} + +function writeConfig(configDir: string, value: unknown): void { + fs.writeFileSync(path.join(configDir, "opencode.jsonc"), `${JSON.stringify(value, null, 2)}\n`); +} + +interface FakeCtx { + ctx: any; + tools: any[]; + subscribed: () => boolean; +} + +function makeFakeCtx(events: { type: string }[] = []): FakeCtx { + const tools: any[] = []; + let subscribed = false; + const ctx = { + tool: { + transform: async (callback: (editor: any) => void) => { + callback({ add: (tool: any) => tools.push(tool) }); + }, + }, + event: { + subscribe: (_options?: { signal?: AbortSignal }) => { + subscribed = true; + return (async function* () { + for (const event of events) yield event; + })(); + }, + }, + }; + return { ctx, tools, subscribed: () => subscribed }; +} + +function remoteHead(): string | undefined { + try { + const out = execFileSync("git", ["--git-dir", remote, "rev-parse", "refs/heads/main"], { + encoding: "utf8", + stdio: "pipe", + }).trim(); + return out || undefined; + } catch { + return undefined; + } +} + +async function waitForRemoteHead(timeoutMs = 15_000): Promise { + const deadline = Date.now() + timeoutMs; + for (;;) { + const head = remoteHead(); + if (head) return head; + if (Date.now() > deadline) throw new Error("timed out waiting for the idle push to land"); + await new Promise((resolve) => setTimeout(resolve, 100)); + } +} + +describe("dual entrypoint shape", () => { + it("exposes one default export both loaders accept", () => { + expect(plugin.id).toBe("opencode-github-sync"); + expect(typeof plugin.setup).toBe("function"); + expect(typeof plugin.server).toBe("function"); + }); + + it("keeps the V1 server function as a named export", () => { + expect(typeof OpencodeGithubSync).toBe("function"); + expect(plugin.server).toBe(OpencodeGithubSync); + }); +}); + +describe("V1 server half", () => { + it("returns no hooks when no repository is configured", async () => { + const machine = makeMachine("v1-empty"); + useMachine(machine); + await expect(OpencodeGithubSync({})).resolves.toEqual({}); + }); + + it("returns event and tool hooks when configured", async () => { + const machine = makeMachine("v1"); + useMachine(machine); + writeConfig(machine.config, {}); + writeSettings(machine.config, { autoPullOnStartup: false }); + + const hooks = await OpencodeGithubSync({}); + expect(typeof hooks.event).toBe("function"); + expect(typeof hooks.tool?.opencode_sync?.execute).toBe("function"); + + const output = await hooks.tool!.opencode_sync.execute({ action: "status" }); + expect(JSON.parse(output).configured).toBe(true); + }); +}); + +describe("V2 setup half", () => { + it("registers the opencode_sync tool through the transform API", async () => { + const machine = makeMachine("v2"); + useMachine(machine); + writeConfig(machine.config, {}); + writeSettings(machine.config, { autoPullOnStartup: false }); + + const fake = makeFakeCtx(); + const cleanup = await plugin.setup(fake.ctx); + expect(typeof cleanup).toBe("function"); + + expect(fake.tools).toHaveLength(1); + expect(fake.tools[0].name).toBe("opencode_sync"); + const output = await fake.tools[0].execute({ action: "status" }); + expect(JSON.parse(output.content).configured).toBe(true); + expect(fake.subscribed()).toBe(false); + await cleanup!(); + }); + + it("registers nothing when no repository is configured", async () => { + const machine = makeMachine("v2-empty"); + useMachine(machine); + const fake = makeFakeCtx(); + const cleanup = await plugin.setup(fake.ctx); + expect(fake.tools).toHaveLength(0); + await cleanup!(); + }); + + it("pushes when a session goes idle and autoPushOnIdle is on", async () => { + const machine = makeMachine("v2-idle"); + useMachine(machine); + writeConfig(machine.config, { model: "shared" }); + writeSettings(machine.config, { autoPullOnStartup: false, autoPushOnIdle: true }); + + const fake = makeFakeCtx([{ type: "session.idle" }]); + const cleanup = await plugin.setup(fake.ctx); + expect(fake.subscribed()).toBe(true); + + await waitForRemoteHead(); + await cleanup!(); + expect(remoteHead()).toBeDefined(); + }); + + it("leaves the event stream alone when autoPushOnIdle is off", async () => { + const machine = makeMachine("v2-quiet"); + useMachine(machine); + writeConfig(machine.config, {}); + writeSettings(machine.config, { autoPullOnStartup: false, autoPushOnIdle: false }); + + const fake = makeFakeCtx([{ type: "session.idle" }]); + const cleanup = await plugin.setup(fake.ctx); + expect(fake.subscribed()).toBe(false); + await cleanup!(); + expect(remoteHead()).toBeUndefined(); + }); +}); + +describe("prunePluginCache plugin keys", () => { + function makeCache(machine: Machine, dependencies: Record): void { + fs.writeFileSync( + path.join(machine.cache, "package.json"), + `${JSON.stringify({ dependencies }, null, 2)}\n`, + ); + for (const name of Object.keys(dependencies)) { + fs.mkdirSync(path.join(machine.cache, "node_modules", name), { recursive: true }); + } + } + + function cacheDeps(machine: Machine): Record { + return JSON.parse(fs.readFileSync(path.join(machine.cache, "package.json"), "utf8")) + .dependencies; + } + + it("reads the V2 plugins key with string specs", () => { + const machine = makeMachine("prune-v2"); + writeConfig(machine.config, { plugins: ["opencode-github-sync", "other@1.0.0"] }); + makeCache(machine, { "opencode-github-sync": "1.0.0", "stale-pkg": "2.0.0" }); + + expect(prunePluginCache(machine.config, machine.cache)).toBe(1); + expect(cacheDeps(machine)).toEqual({ "opencode-github-sync": "1.0.0" }); + expect(fs.existsSync(path.join(machine.cache, "node_modules", "stale-pkg"))).toBe(false); + expect(fs.existsSync(path.join(machine.cache, "node_modules", "opencode-github-sync"))).toBe( + true, + ); + }); + + it("reads the V2 plugins key with object specs", () => { + const machine = makeMachine("prune-v2-obj"); + writeConfig(machine.config, { + plugins: [{ package: "opencode-github-sync", options: {} }], + }); + makeCache(machine, { "opencode-github-sync": "1.0.0", "stale-pkg": "2.0.0" }); + + expect(prunePluginCache(machine.config, machine.cache)).toBe(1); + expect(cacheDeps(machine)).toEqual({ "opencode-github-sync": "1.0.0" }); + }); + + it("keeps reading the legacy plugin key", () => { + const machine = makeMachine("prune-v1"); + writeConfig(machine.config, { plugin: ["opencode-github-sync"] }); + makeCache(machine, { "opencode-github-sync": "1.0.0", "stale-pkg": "2.0.0" }); + + expect(prunePluginCache(machine.config, machine.cache)).toBe(1); + expect(cacheDeps(machine)).toEqual({ "opencode-github-sync": "1.0.0" }); + }); + + it("uses the real roots by default", () => { + expect(getRoots().config.length).toBeGreaterThan(0); + }); +});