Compare commits

...

12 Commits

Author SHA1 Message Date
Kit Langton
87a6c54226 Merge branch 'dev' into effect-auth-foundation 2026-03-12 21:12:08 -04:00
Kit Langton
d9dd33aeeb feat(cli): add console account subcommands (#17265) 2026-03-13 00:56:40 +00:00
Kit Langton
0a281c7390 refactor(auth): effectify AuthService (#17212) 2026-03-12 20:43:24 -04:00
Aiden Cline
3016efba47 tweak: rm openrouter warning (#17259) 2026-03-12 19:42:31 -05:00
Luke Parker
3998df8112 fix(app): increase CI e2e workers (#17263) 2026-03-13 10:15:34 +10:00
Kit Langton
7066e2a25e reorder provider list in providers login (#17262) 2026-03-13 00:09:30 +00:00
Adam
c173988aaa feat(app): interruption state 2026-03-12 19:07:23 -05:00
Kit Langton
1739817ee7 style(auth): remove service docstrings
Drop the temporary auth service method comments now that the key normalization behavior has been reviewed.
2026-03-12 14:38:44 -04:00
Kit Langton
11e2c85336 chore: keep effect migration plan local
Remove the draft migration plan from the auth foundation branch and keep it excluded locally instead of shipping it in the PR.
2026-03-12 14:34:29 -04:00
Kit Langton
201e80956a refactor(auth): clarify auth entry filtering
Use Effect Record.filterMap to keep the existing permissive auth-file semantics while making the decode path easier to read. Add service method docs that explain key normalization and why old trailing-slash variants are removed during writes.
2026-03-12 14:23:27 -04:00
Kit Langton
7f12976ea0 refactor(auth): use Effect Schema internally
Model auth entries with Effect Schema inside AuthService and use Schema decoding when reading persisted auth data. Keep the Auth facade on Zod at the boundary so existing validators and callers stay stable during the migration.
2026-03-12 13:25:52 -04:00
Kit Langton
f7259617e5 refactor(auth): extract AuthService
Move auth file I/O and key normalization into an Effect service so auth can migrate like account while the existing Auth facade stays stable for callers. Document the broader Effect rollout and instance-state migration strategy to guide follow-on extractions.
2026-03-12 13:05:50 -04:00
11 changed files with 169 additions and 86 deletions

View File

@@ -6,6 +6,7 @@ const serverHost = process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"
const serverPort = process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"
const command = `bun run dev -- --host 0.0.0.0 --port ${port}`
const reuse = !process.env.CI
const workers = Number(process.env.PLAYWRIGHT_WORKERS ?? (process.env.CI ? 5 : 0)) || undefined
export default defineConfig({
testDir: "./e2e",
@@ -17,6 +18,7 @@ export default defineConfig({
fullyParallel: process.env.PLAYWRIGHT_FULLY_PARALLEL === "1",
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 2 : 0,
workers,
reporter: [["html", { outputFolder: "e2e/playwright-report", open: "never" }], ["line"]],
webServer: {
command,

View File

@@ -1,9 +1,13 @@
import path from "path"
import { Global } from "../global"
import { Effect } from "effect"
import z from "zod"
import { Filesystem } from "../util/filesystem"
import { runtime } from "@/effect/runtime"
import * as S from "./service"
export const OAUTH_DUMMY_KEY = "opencode-oauth-dummy-key"
export { OAUTH_DUMMY_KEY } from "./service"
function runPromise<A>(f: (service: S.AuthService.Service) => Effect.Effect<A, S.AuthServiceError>) {
return runtime.runPromise(S.AuthService.use(f))
}
export namespace Auth {
export const Oauth = z
@@ -35,39 +39,19 @@ export namespace Auth {
export const Info = z.discriminatedUnion("type", [Oauth, Api, WellKnown]).meta({ ref: "Auth" })
export type Info = z.infer<typeof Info>
const filepath = path.join(Global.Path.data, "auth.json")
export async function get(providerID: string) {
const auth = await all()
return auth[providerID]
return runPromise((service) => service.get(providerID))
}
export async function all(): Promise<Record<string, Info>> {
const data = await Filesystem.readJson<Record<string, unknown>>(filepath).catch(() => ({}))
return Object.entries(data).reduce(
(acc, [key, value]) => {
const parsed = Info.safeParse(value)
if (!parsed.success) return acc
acc[key] = parsed.data
return acc
},
{} as Record<string, Info>,
)
return runPromise((service) => service.all())
}
export async function set(key: string, info: Info) {
const normalized = key.replace(/\/+$/, "")
const data = await all()
if (normalized !== key) delete data[key]
delete data[normalized + "/"]
await Filesystem.writeJson(filepath, { ...data, [normalized]: info }, 0o600)
return runPromise((service) => service.set(key, info))
}
export async function remove(key: string) {
const normalized = key.replace(/\/+$/, "")
const data = await all()
delete data[key]
delete data[normalized]
await Filesystem.writeJson(filepath, data, 0o600)
return runPromise((service) => service.remove(key))
}
}

View File

@@ -0,0 +1,101 @@
import path from "path"
import { Effect, Layer, Record, Result, Schema, ServiceMap } from "effect"
import { Global } from "../global"
import { Filesystem } from "../util/filesystem"
export const OAUTH_DUMMY_KEY = "opencode-oauth-dummy-key"
export class Oauth extends Schema.Class<Oauth>("OAuth")({
type: Schema.Literal("oauth"),
refresh: Schema.String,
access: Schema.String,
expires: Schema.Number,
accountId: Schema.optional(Schema.String),
enterpriseUrl: Schema.optional(Schema.String),
}) {}
export class Api extends Schema.Class<Api>("ApiAuth")({
type: Schema.Literal("api"),
key: Schema.String,
}) {}
export class WellKnown extends Schema.Class<WellKnown>("WellKnownAuth")({
type: Schema.Literal("wellknown"),
key: Schema.String,
token: Schema.String,
}) {}
export const Info = Schema.Union([Oauth, Api, WellKnown])
export type Info = Schema.Schema.Type<typeof Info>
export class AuthServiceError extends Schema.TaggedErrorClass<AuthServiceError>()("AuthServiceError", {
message: Schema.String,
cause: Schema.optional(Schema.Defect),
}) {}
const file = path.join(Global.Path.data, "auth.json")
const fail = (message: string) => (cause: unknown) => new AuthServiceError({ message, cause })
export namespace AuthService {
export interface Service {
readonly get: (providerID: string) => Effect.Effect<Info | undefined, AuthServiceError>
readonly all: () => Effect.Effect<Record<string, Info>, AuthServiceError>
readonly set: (key: string, info: Info) => Effect.Effect<void, AuthServiceError>
readonly remove: (key: string) => Effect.Effect<void, AuthServiceError>
}
}
export class AuthService extends ServiceMap.Service<AuthService, AuthService.Service>()("@opencode/Auth") {
static readonly layer = Layer.effect(
AuthService,
Effect.gen(function* () {
const decode = Schema.decodeUnknownOption(Info)
const all = Effect.fn("AuthService.all")(() =>
Effect.tryPromise({
try: async () => {
const data = await Filesystem.readJson<Record<string, unknown>>(file).catch(() => ({}))
return Record.filterMap(data, (value) => Result.fromOption(decode(value), () => undefined))
},
catch: fail("Failed to read auth data"),
}),
)
const get = Effect.fn("AuthService.get")(function* (providerID: string) {
return (yield* all())[providerID]
})
const set = Effect.fn("AuthService.set")(function* (key: string, info: Info) {
const norm = key.replace(/\/+$/, "")
const data = yield* all()
if (norm !== key) delete data[key]
delete data[norm + "/"]
yield* Effect.tryPromise({
try: () => Filesystem.writeJson(file, { ...data, [norm]: info }, 0o600),
catch: fail("Failed to write auth data"),
})
})
const remove = Effect.fn("AuthService.remove")(function* (key: string) {
const norm = key.replace(/\/+$/, "")
const data = yield* all()
delete data[key]
delete data[norm]
yield* Effect.tryPromise({
try: () => Filesystem.writeJson(file, data, 0o600),
catch: fail("Failed to write auth data"),
})
})
return AuthService.of({
get,
all,
set,
remove,
})
}),
)
static readonly defaultLayer = AuthService.layer
}

View File

@@ -192,3 +192,28 @@ export const OrgsCommand = cmd({
await runtime.runPromise(orgsEffect())
},
})
export const ConsoleCommand = cmd({
command: "console",
describe: "manage console account",
builder: (yargs) =>
yargs
.command({
...LoginCommand,
describe: "log in to console",
})
.command({
...LogoutCommand,
describe: "log out from console",
})
.command({
...SwitchCommand,
describe: "switch active org",
})
.command({
...OrgsCommand,
describe: "list orgs",
})
.demandCommand(),
async handler() {},
})

View File

@@ -318,10 +318,10 @@ export const ProvidersLoginCommand = cmd({
const priority: Record<string, number> = {
opencode: 0,
anthropic: 1,
openai: 1,
"github-copilot": 2,
openai: 3,
google: 4,
google: 3,
anthropic: 4,
openrouter: 5,
vercel: 6,
}

View File

@@ -677,20 +677,6 @@ function App() {
},
])
createEffect(() => {
const currentModel = local.model.current()
if (!currentModel) return
if (currentModel.providerID === "openrouter" && !kv.get("openrouter_warning", false)) {
untrack(() => {
DialogAlert.show(
dialog,
"Warning",
"While openrouter is a convenient way to access LLMs your request will often be routed to subpar providers that do not work well in our testing.\n\nFor reliable access to models check out OpenCode Zen\nhttps://opencode.ai/zen",
).then(() => kv.set("openrouter_warning", true))
})
}
})
sdk.event.on(TuiEvent.CommandExecute.type, (evt) => {
command.trigger(evt.properties.command)
})

View File

@@ -1,4 +1,5 @@
import { ManagedRuntime } from "effect"
import { Layer, ManagedRuntime } from "effect"
import { AccountService } from "@/account/service"
import { AuthService } from "@/auth/service"
export const runtime = ManagedRuntime.make(AccountService.defaultLayer)
export const runtime = ManagedRuntime.make(Layer.mergeAll(AccountService.defaultLayer, AuthService.defaultLayer))

View File

@@ -3,7 +3,7 @@ import { hideBin } from "yargs/helpers"
import { RunCommand } from "./cli/cmd/run"
import { GenerateCommand } from "./cli/cmd/generate"
import { Log } from "./util/log"
import { LoginCommand, LogoutCommand, SwitchCommand, OrgsCommand } from "./cli/cmd/account"
import { ConsoleCommand } from "./cli/cmd/account"
import { ProvidersCommand } from "./cli/cmd/providers"
import { AgentCommand } from "./cli/cmd/agent"
import { UpgradeCommand } from "./cli/cmd/upgrade"
@@ -135,10 +135,7 @@ let cli = yargs(hideBin(process.argv))
.command(RunCommand)
.command(GenerateCommand)
.command(DebugCommand)
.command(LoginCommand)
.command(LogoutCommand)
.command(SwitchCommand)
.command(OrgsCommand)
.command(ConsoleCommand)
.command(ProvidersCommand)
.command(AgentCommand)
.command(UpgradeCommand)

View File

@@ -23,10 +23,6 @@
max-width: 100%;
gap: 0;
&[data-interrupted] {
color: var(--text-weak);
}
[data-slot="user-message-attachments"] {
display: flex;
flex-wrap: wrap;
@@ -165,10 +161,6 @@
text-align: right;
}
[data-slot="user-message-copy-wrapper"][data-interrupted] {
gap: 12px;
}
&:hover [data-slot="user-message-copy-wrapper"],
&:focus-within [data-slot="user-message-copy-wrapper"] {
opacity: 1;

View File

@@ -131,7 +131,6 @@ export interface MessageProps {
parts: PartType[]
actions?: UserActions
showAssistantCopyPartID?: string | null
interrupted?: boolean
showReasoningSummaries?: boolean
}
@@ -691,12 +690,7 @@ export function Message(props: MessageProps) {
<Switch>
<Match when={props.message.role === "user" && props.message}>
{(userMessage) => (
<UserMessageDisplay
message={userMessage() as UserMessage}
parts={props.parts}
actions={props.actions}
interrupted={props.interrupted}
/>
<UserMessageDisplay message={userMessage() as UserMessage} parts={props.parts} actions={props.actions} />
)}
</Match>
<Match when={props.message.role === "assistant" && props.message}>
@@ -887,12 +881,7 @@ function ContextToolGroup(props: { parts: ToolPart[]; busy?: boolean }) {
)
}
export function UserMessageDisplay(props: {
message: UserMessage
parts: PartType[]
actions?: UserActions
interrupted?: boolean
}) {
export function UserMessageDisplay(props: { message: UserMessage; parts: PartType[]; actions?: UserActions }) {
const data = useData()
const dialog = useDialog()
const i18n = useI18n()
@@ -947,10 +936,7 @@ export function UserMessageDisplay(props: {
return items.filter((x) => !!x).join("\u00A0\u00B7\u00A0")
})
const metaTail = createMemo(() => {
const items = [stamp(), props.interrupted ? i18n.t("ui.message.interrupted") : ""]
return items.filter((x) => !!x).join("\u00A0\u00B7\u00A0")
})
const metaTail = stamp
const openImagePreview = (url: string, alt?: string) => {
dialog.show(() => <ImagePreview src={url} alt={alt} />)
@@ -981,7 +967,7 @@ export function UserMessageDisplay(props: {
}
return (
<div data-component="user-message" data-interrupted={props.interrupted ? "" : undefined}>
<div data-component="user-message">
<Show when={attachments().length > 0}>
<div data-slot="user-message-attachments">
<For each={attachments()}>
@@ -1021,7 +1007,7 @@ export function UserMessageDisplay(props: {
<HighlightedText text={text()} references={inlineFiles()} agents={agents()} />
</div>
</div>
<div data-slot="user-message-copy-wrapper" data-interrupted={props.interrupted ? "" : undefined}>
<div data-slot="user-message-copy-wrapper">
<Show when={metaHead() || metaTail()}>
<span data-slot="user-message-meta-wrap">
<Show when={metaHead()}>
@@ -1305,14 +1291,13 @@ PART_MAPPING["tool"] = function ToolPartDisplay(props) {
)
}
PART_MAPPING["compaction"] = function CompactionPartDisplay() {
const i18n = useI18n()
export function MessageDivider(props: { label: string }) {
return (
<div data-component="compaction-part">
<div data-slot="compaction-part-divider">
<span data-slot="compaction-part-line" />
<span data-slot="compaction-part-label" class="text-12-regular text-text-weak">
{i18n.t("ui.messagePart.compaction")}
{props.label}
</span>
<span data-slot="compaction-part-line" />
</div>
@@ -1320,6 +1305,11 @@ PART_MAPPING["compaction"] = function CompactionPartDisplay() {
)
}
PART_MAPPING["compaction"] = function CompactionPartDisplay() {
const i18n = useI18n()
return <MessageDivider label={i18n.t("ui.messagePart.compaction")} />
}
PART_MAPPING["text"] = function TextPartDisplay(props) {
const data = useData()
const i18n = useI18n()

View File

@@ -7,7 +7,7 @@ import { Binary } from "@opencode-ai/util/binary"
import { getDirectory, getFilename } from "@opencode-ai/util/path"
import { createEffect, createMemo, createSignal, For, on, ParentProps, Show } from "solid-js"
import { Dynamic } from "solid-js/web"
import { AssistantParts, Message, Part, PART_MAPPING, type UserActions } from "./message-part"
import { AssistantParts, Message, MessageDivider, PART_MAPPING, type UserActions } from "./message-part"
import { Card } from "./card"
import { Accordion } from "./accordion"
import { StickyAccordionHeader } from "./sticky-accordion-header"
@@ -276,6 +276,11 @@ export function SessionTurn(
)
const interrupted = createMemo(() => assistantMessages().some((m) => m.error?.name === "MessageAbortedError"))
const divider = createMemo(() => {
if (compaction()) return i18n.t("ui.messagePart.compaction")
if (interrupted()) return i18n.t("ui.message.interrupted")
return ""
})
const error = createMemo(
() => assistantMessages().find((m) => m.error && m.error.name !== "MessageAbortedError")?.error,
)
@@ -384,11 +389,11 @@ export function SessionTurn(
class={props.classes?.container}
>
<div data-slot="session-turn-message-content" aria-live="off">
<Message message={message()!} parts={parts()} actions={props.actions} interrupted={interrupted()} />
<Message message={message()!} parts={parts()} actions={props.actions} />
</div>
<Show when={compaction()}>
<Show when={divider()}>
<div data-slot="session-turn-compaction">
<Part part={compaction()!} message={message()!} hideDetails />
<MessageDivider label={divider()} />
</div>
</Show>
<Show when={assistantMessages().length > 0}>