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
3 changes: 3 additions & 0 deletions packages/cli/src/commands/commands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,9 @@ export const Commands = Spec.make(typeof OPENCODE_CLI_NAME === "string" ? OPENCO
url: Argument.string("url").pipe(Argument.withDescription("Console server URL"), Argument.optional),
},
}),
Spec.make("logout", {
description: "Log out of OpenCode Console",
}),
],
}),
Spec.make("auth", {
Expand Down
32 changes: 32 additions & 0 deletions packages/cli/src/commands/handlers/console/logout.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import { EOL } from "node:os"
import { Effect } from "effect"
import { OpenCode } from "@opencode-ai/client"
import { Service } from "@opencode-ai/client/effect/service"
import { Commands } from "../../commands"
import { Runtime } from "../../../framework/runtime"
import { ServiceConfig } from "../../../services/service-config"

const integrationID = "opencode"
const location = { directory: process.cwd() }

export default Runtime.handler(
Commands.commands.console.commands.logout,
Effect.fn("cli.console.logout")(function* () {
const endpoint = yield* Service.ensure(yield* ServiceConfig.options())
const client = OpenCode.make({ baseUrl: endpoint.url, headers: Service.headers(endpoint) })
const found = yield* Effect.promise(() => client.integration.get({ integrationID, location }))
const credentials = found.data?.connections.filter((connection) => connection.type === "credential") ?? []

if (credentials.length === 0) {
process.stdout.write("Not logged in" + EOL)
return
}

yield* Effect.forEach(
credentials,
(connection) => Effect.promise(() => client.credential.remove({ credentialID: connection.id, location })),
{ discard: true },
)
process.stdout.write("Logged out from OpenCode Console" + EOL)
}),
)
1 change: 1 addition & 0 deletions packages/cli/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ const Handlers = Runtime.handlers(Commands, {
},
console: {
login: () => import("./commands/handlers/console/login"),
logout: () => import("./commands/handlers/console/logout"),
},
mcp: {
list: () => import("./commands/handlers/mcp/list"),
Expand Down
86 changes: 86 additions & 0 deletions packages/cli/test/console.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
import { afterEach, expect, test } from "bun:test"
import fs from "node:fs/promises"
import os from "node:os"
import path from "node:path"
import { OPENCODE_VERSION } from "../src/version"

const cleanup: Array<() => Promise<void>> = []

afterEach(async () => {
await Promise.all(cleanup.splice(0).map((fn) => fn()))
})

async function cli(args: string[], env?: Record<string, string>) {
const child = Bun.spawn([process.execPath, "run", "src/index.ts", ...args], {
cwd: path.join(import.meta.dir, ".."),
env: { ...process.env, ...env },
stdout: "pipe",
stderr: "pipe",
})
const [stdout, stderr, exitCode] = await Promise.all([
new Response(child.stdout).text(),
new Response(child.stderr).text(),
child.exited,
])
return { stdout, stderr, exitCode }
}

test("registers the console logout command", async () => {
const result = await cli(["console", "--help"])

expect(result.exitCode).toBe(0)
expect(result.stdout).toContain("logout Log out of OpenCode Console")
})

test("removes stored OpenCode Console credentials", async () => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-console-logout-"))
const removed: string[] = []
const server = Bun.serve({
port: 0,
fetch(request) {
const url = new URL(request.url)
if (url.pathname === "/api/health") {
return Response.json({ healthy: true, version: OPENCODE_VERSION, pid: process.pid })
}
if (request.method === "GET" && url.pathname === "/api/integration/opencode") {
return Response.json({
location: { directory: process.cwd(), project: { id: "global", directory: "/" } },
data: {
id: "opencode",
name: "OpenCode",
methods: [],
connections: removed.length === 0 ? [{ type: "credential", id: "cred_test", label: "default" }] : [],
},
})
}
if (request.method === "DELETE" && url.pathname === "/api/credential/cred_test") {
removed.push(url.pathname)
return new Response(null, { status: 204 })
}
return new Response(null, { status: 404 })
},
})
cleanup.push(async () => {
server.stop(true)
await fs.rm(root, { recursive: true, force: true })
})
await fs.mkdir(path.join(root, "state", "opencode"), { recursive: true })
await fs.writeFile(
path.join(root, "state", "opencode", "service-local.json"),
JSON.stringify({ version: OPENCODE_VERSION, url: server.url.toString(), pid: process.pid }),
)
const env = {
XDG_CACHE_HOME: path.join(root, "cache"),
XDG_CONFIG_HOME: path.join(root, "config"),
XDG_DATA_HOME: path.join(root, "data"),
XDG_STATE_HOME: path.join(root, "state"),
}

const result = await cli(["console", "logout"], env)

expect(result).toMatchObject({ exitCode: 0, stdout: "Logged out from OpenCode Console\n" })
expect(removed).toEqual(["/api/credential/cred_test"])

const repeated = await cli(["console", "logout"], env)
expect(repeated).toMatchObject({ exitCode: 0, stdout: "Not logged in\n" })
})
Loading