Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
84 changes: 83 additions & 1 deletion packages/opencode/src/plugin/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string> = {
"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]

Expand Down Expand Up @@ -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(),
})

Expand Down
34 changes: 34 additions & 0 deletions packages/opencode/src/server/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"

Expand Down Expand Up @@ -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({
Expand Down
94 changes: 94 additions & 0 deletions packages/opencode/test/plugin/failed-plugins.test.ts
Original file line number Diff line number Diff line change
@@ -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")
})
})
54 changes: 54 additions & 0 deletions packages/opencode/test/server/plugin-failed.test.ts
Original file line number Diff line number Diff line change
@@ -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")
}
},
})
})
})
24 changes: 24 additions & 0 deletions packages/sdk/js/src/v2/gen/sdk.gen.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@ import type {
PermissionRespondErrors,
PermissionRespondResponses,
PermissionRuleset,
PluginFailedResponses,
ProjectCurrentResponses,
ProjectListResponses,
ProjectUpdateErrors,
Expand Down Expand Up @@ -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<ThrowOnError extends boolean = false>(
parameters?: {
directory?: string
},
options?: Options<never, ThrowOnError>,
) {
const params = buildClientParams([parameters], [{ args: [{ in: "query", key: "directory" }] }])
return (options?.client ?? this.client).get<PluginFailedResponses, unknown, ThrowOnError>({
url: "/plugin/failed",
...options,
...params,
})
}
}

export class Find extends HeyApiClient {
/**
* Find text
Expand Down Expand Up @@ -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 })
Expand Down
37 changes: 37 additions & 0 deletions packages/sdk/js/src/v2/gen/types.gen.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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?: {
/**
Expand Down
Loading