diff --git a/packages/opencode/src/plugin/index.ts b/packages/opencode/src/plugin/index.ts index 8ce6dfd3c3d..da20dddb6a0 100644 --- a/packages/opencode/src/plugin/index.ts +++ b/packages/opencode/src/plugin/index.ts @@ -20,6 +20,56 @@ export namespace Plugin { "@gitlab/opencode-gitlab-auth@1.3.0", ] + /** + * Maps plugin package names to human-readable descriptions of what + * authentication methods they provide. Used for user-facing error messages. + */ + const PLUGIN_AUTH_DESCRIPTIONS: Record = { + "opencode-copilot-auth": "GitHub Copilot OAuth", + "opencode-anthropic-auth": "Anthropic OAuth (Claude Max/Pro)", + "@gitlab/opencode-gitlab-auth": "GitLab Duo OAuth", + } + + /** + * Tracks plugins that failed to install, including the package name, + * version, error message, and the affected authentication method. + */ + export interface FailedPlugin { + pkg: string + version: string + error: string + authMethod: string + } + + const failedPlugins: FailedPlugin[] = [] + + /** + * Returns the list of plugins that failed to install. + * Useful for diagnostic purposes and showing users which + * authentication methods are unavailable. + */ + export function getFailedPlugins(): readonly FailedPlugin[] { + return [...failedPlugins] + } + + /** + * Returns the human-readable auth method description for a plugin package. + * Falls back to the package name if no description is defined. + */ + export function getAuthDescription(pkg: string): string { + return PLUGIN_AUTH_DESCRIPTIONS[pkg] ?? pkg + } + + /** @internal For testing purposes only */ + export const _test = { + trackFailure(failure: FailedPlugin) { + failedPlugins.push(failure) + }, + clearFailures() { + failedPlugins.length = 0 + }, + } + // Built-in plugins that are directly imported (not installed from npm) const INTERNAL_PLUGINS: PluginInstance[] = [CodexAuthPlugin] @@ -64,14 +114,46 @@ export namespace Plugin { if (!builtin) throw err const message = err instanceof Error ? err.message : String(err) + const authMethod = PLUGIN_AUTH_DESCRIPTIONS[pkg] ?? pkg + + // Track the failed plugin for diagnostic access + failedPlugins.push({ + pkg, + version, + error: message, + authMethod, + }) + log.error("failed to install builtin plugin", { pkg, version, + authMethod, error: message, }) + + // Build a user-friendly error message with troubleshooting steps + const troubleshootingSteps = [ + `The "${authMethod}" authentication option will not be available.`, + "", + "Troubleshooting steps:", + " 1. Check your network connection", + " 2. If using a corporate npm proxy (JFrog, Artifactory, Nexus), verify your .npmrc configuration", + " 3. Try running: npm config list", + " 4. Check if the registry is accessible: npm ping", + " 5. Review proxy settings: HTTP_PROXY, HTTPS_PROXY environment variables", + ].join("\n") + + const detailedMessage = [ + `Failed to install authentication plugin: ${pkg}@${version}`, + "", + troubleshootingSteps, + "", + `Error details: ${message}`, + ].join("\n") + Bus.publish(Session.Event.Error, { error: new NamedError.Unknown({ - message: `Failed to install built-in plugin ${pkg}@${version}: ${message}`, + message: detailedMessage, }).toObject(), }) diff --git a/packages/opencode/src/server/server.ts b/packages/opencode/src/server/server.ts index 52457515b8e..98002b83b33 100644 --- a/packages/opencode/src/server/server.ts +++ b/packages/opencode/src/server/server.ts @@ -53,6 +53,7 @@ import { Pty } from "@/pty" import { PermissionNext } from "@/permission/next" import { QuestionRoute } from "./question" import { Installation } from "@/installation" +import { Plugin } from "@/plugin" import { MDNS } from "./mdns" import { Worktree } from "../worktree" @@ -1827,6 +1828,39 @@ export namespace Server { return c.json(await ProviderAuth.methods()) }, ) + .get( + "/plugin/failed", + describeRoute({ + summary: "Get failed plugins", + description: + "Retrieve diagnostic information about authentication plugins that failed to install. Useful for troubleshooting missing OAuth options.", + operationId: "plugin.failed", + responses: { + 200: { + description: "List of failed plugins with diagnostic information", + content: { + "application/json": { + schema: resolver( + z + .array( + z.object({ + pkg: z.string().describe("The npm package name"), + version: z.string().describe("The requested version"), + error: z.string().describe("The error message from the installation failure"), + authMethod: z.string().describe("The authentication method that is now unavailable"), + }), + ) + .meta({ ref: "FailedPlugins" }), + ), + }, + }, + }, + }, + }), + async (c) => { + return c.json(Plugin.getFailedPlugins()) + }, + ) .post( "/provider/:providerID/oauth/authorize", describeRoute({ diff --git a/packages/opencode/test/plugin/failed-plugins.test.ts b/packages/opencode/test/plugin/failed-plugins.test.ts new file mode 100644 index 00000000000..897efab97d3 --- /dev/null +++ b/packages/opencode/test/plugin/failed-plugins.test.ts @@ -0,0 +1,94 @@ +import { describe, expect, test, beforeEach } from "bun:test" +import { Plugin } from "../../src/plugin" + +describe("Plugin.getFailedPlugins", () => { + beforeEach(() => { + Plugin._test.clearFailures() + }) + + test("returns empty array when no failures", () => { + const failed = Plugin.getFailedPlugins() + expect(Array.isArray(failed)).toBe(true) + expect(failed.length).toBe(0) + }) + + test("returns tracked failures", () => { + Plugin._test.trackFailure({ + pkg: "opencode-copilot-auth", + version: "0.0.12", + error: "ECONNREFUSED", + authMethod: "GitHub Copilot OAuth", + }) + + const failed = Plugin.getFailedPlugins() + expect(failed.length).toBe(1) + expect(failed[0].pkg).toBe("opencode-copilot-auth") + expect(failed[0].error).toBe("ECONNREFUSED") + }) + + test("returns a shallow copy to protect internal state", () => { + Plugin._test.trackFailure({ + pkg: "test-pkg", + version: "1.0.0", + error: "test error", + authMethod: "Test OAuth", + }) + + const first = Plugin.getFailedPlugins() + const second = Plugin.getFailedPlugins() + + // Should be different array references + expect(first).not.toBe(second) + // But same content + expect(first).toEqual(second) + }) + + test("tracks multiple failures", () => { + Plugin._test.trackFailure({ + pkg: "opencode-copilot-auth", + version: "0.0.12", + error: "ECONNREFUSED", + authMethod: "GitHub Copilot OAuth", + }) + Plugin._test.trackFailure({ + pkg: "opencode-anthropic-auth", + version: "0.0.8", + error: "403 Forbidden", + authMethod: "Anthropic OAuth (Claude Max/Pro)", + }) + + const failed = Plugin.getFailedPlugins() + expect(failed.length).toBe(2) + expect(failed[0].pkg).toBe("opencode-copilot-auth") + expect(failed[1].pkg).toBe("opencode-anthropic-auth") + }) +}) + +describe("Plugin.getAuthDescription", () => { + test("returns description for known plugins", () => { + expect(Plugin.getAuthDescription("opencode-copilot-auth")).toBe("GitHub Copilot OAuth") + expect(Plugin.getAuthDescription("opencode-anthropic-auth")).toBe("Anthropic OAuth (Claude Max/Pro)") + expect(Plugin.getAuthDescription("@gitlab/opencode-gitlab-auth")).toBe("GitLab Duo OAuth") + }) + + test("falls back to package name for unknown plugins", () => { + expect(Plugin.getAuthDescription("unknown-plugin")).toBe("unknown-plugin") + expect(Plugin.getAuthDescription("my-custom-auth")).toBe("my-custom-auth") + }) +}) + +describe("Plugin.FailedPlugin interface", () => { + test("has expected structure", () => { + const failure: Plugin.FailedPlugin = { + pkg: "test-package", + version: "1.0.0", + error: "Test error message", + authMethod: "Test OAuth", + } + + expect(failure.pkg).toBe("test-package") + expect(failure.version).toBe("1.0.0") + expect(failure.error).toBe("Test error message") + expect(failure.authMethod).toBe("Test OAuth") + }) +}) diff --git a/packages/opencode/test/server/plugin-failed.test.ts b/packages/opencode/test/server/plugin-failed.test.ts new file mode 100644 index 00000000000..e95a5a3b614 --- /dev/null +++ b/packages/opencode/test/server/plugin-failed.test.ts @@ -0,0 +1,54 @@ +import { describe, expect, test, mock } from "bun:test" +import path from "path" +import { Log } from "../../src/util/log" +import { Instance } from "../../src/project/instance" +import { Server } from "../../src/server/server" + +const projectRoot = path.join(__dirname, "../..") +Log.init({ print: false }) + +describe("/plugin/failed endpoint", () => { + test("should return 200 and an array", async () => { + await Instance.provide({ + directory: projectRoot, + fn: async () => { + // #when + const app = Server.App() + const response = await app.request("/plugin/failed", { + method: "GET", + }) + + // #then + expect(response.status).toBe(200) + const body = await response.json() + expect(Array.isArray(body)).toBe(true) + }, + }) + }) + + test("should return items with correct structure when plugins fail", async () => { + await Instance.provide({ + directory: projectRoot, + fn: async () => { + // #when + const app = Server.App() + const response = await app.request("/plugin/failed", { + method: "GET", + }) + + // #then + expect(response.status).toBe(200) + const body = await response.json() + expect(Array.isArray(body)).toBe(true) + + // If there are any failed plugins, verify their structure + for (const item of body) { + expect(typeof item.pkg).toBe("string") + expect(typeof item.version).toBe("string") + expect(typeof item.error).toBe("string") + expect(typeof item.authMethod).toBe("string") + } + }, + }) + }) +}) diff --git a/packages/sdk/js/src/v2/gen/sdk.gen.ts b/packages/sdk/js/src/v2/gen/sdk.gen.ts index f83913ea5e1..7e0e043cb07 100644 --- a/packages/sdk/js/src/v2/gen/sdk.gen.ts +++ b/packages/sdk/js/src/v2/gen/sdk.gen.ts @@ -63,6 +63,7 @@ import type { PermissionRespondErrors, PermissionRespondResponses, PermissionRuleset, + PluginFailedResponses, ProjectCurrentResponses, ProjectListResponses, ProjectUpdateErrors, @@ -2024,6 +2025,27 @@ export class Provider extends HeyApiClient { oauth = new Oauth({ client: this.client }) } +export class Plugin extends HeyApiClient { + /** + * Get failed plugins + * + * Retrieve diagnostic information about authentication plugins that failed to install. Useful for troubleshooting missing OAuth options. + */ + public failed( + parameters?: { + directory?: string + }, + options?: Options, + ) { + const params = buildClientParams([parameters], [{ args: [{ in: "query", key: "directory" }] }]) + return (options?.client ?? this.client).get({ + url: "/plugin/failed", + ...options, + ...params, + }) + } +} + export class Find extends HeyApiClient { /** * Find text @@ -3012,6 +3034,8 @@ export class OpencodeClient extends HeyApiClient { provider = new Provider({ client: this.client }) + plugin = new Plugin({ client: this.client }) + find = new Find({ client: this.client }) file = new File({ client: this.client }) diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index acc29d9b43e..791bc5f2891 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -1939,6 +1939,25 @@ export type ProviderAuthMethod = { label: string } +export type FailedPlugins = Array<{ + /** + * The npm package name + */ + pkg: string + /** + * The requested version + */ + version: string + /** + * The error message from the installation failure + */ + error: string + /** + * The authentication method that is now unavailable + */ + authMethod: string +}> + export type ProviderAuthAuthorization = { url: string method: "auto" | "code" @@ -3855,6 +3874,24 @@ export type ProviderAuthResponses = { export type ProviderAuthResponse = ProviderAuthResponses[keyof ProviderAuthResponses] +export type PluginFailedData = { + body?: never + path?: never + query?: { + directory?: string + } + url: "/plugin/failed" +} + +export type PluginFailedResponses = { + /** + * List of failed plugins with diagnostic information + */ + 200: FailedPlugins +} + +export type PluginFailedResponse = PluginFailedResponses[keyof PluginFailedResponses] + export type ProviderOauthAuthorizeData = { body?: { /** diff --git a/packages/sdk/openapi.json b/packages/sdk/openapi.json index a31f0d67ab2..1ee3a9fbb94 100644 --- a/packages/sdk/openapi.json +++ b/packages/sdk/openapi.json @@ -3725,6 +3725,40 @@ ] } }, + "/plugin/failed": { + "get": { + "operationId": "plugin.failed", + "parameters": [ + { + "in": "query", + "name": "directory", + "schema": { + "type": "string" + } + } + ], + "summary": "Get failed plugins", + "description": "Retrieve diagnostic information about authentication plugins that failed to install. Useful for troubleshooting missing OAuth options.", + "responses": { + "200": { + "description": "List of failed plugins with diagnostic information", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/FailedPlugins" + } + } + } + } + }, + "x-codeSamples": [ + { + "lang": "js", + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.plugin.failed({\n ...\n})" + } + ] + } + }, "/provider/{providerID}/oauth/authorize": { "post": { "operationId": "provider.oauth.authorize", @@ -10088,6 +10122,31 @@ }, "required": ["type", "label"] }, + "FailedPlugins": { + "type": "array", + "items": { + "type": "object", + "properties": { + "pkg": { + "description": "The npm package name", + "type": "string" + }, + "version": { + "description": "The requested version", + "type": "string" + }, + "error": { + "description": "The error message from the installation failure", + "type": "string" + }, + "authMethod": { + "description": "The authentication method that is now unavailable", + "type": "string" + } + }, + "required": ["pkg", "version", "error", "authMethod"] + } + }, "ProviderAuthAuthorization": { "type": "object", "properties": {