Compare commits

..
108 changed files with 1284 additions and 6010 deletions
-4
View File
@@ -31,10 +31,6 @@ runs:
bun-version-file: ${{ !steps.bun-url.outputs.url && 'package.json' || '' }} bun-version-file: ${{ !steps.bun-url.outputs.url && 'package.json' || '' }}
bun-download-url: ${{ steps.bun-url.outputs.url }} bun-download-url: ${{ steps.bun-url.outputs.url }}
- name: Install setuptools for distutils compatibility
run: python3 -m pip install setuptools || pip install setuptools || true
shell: bash
- name: Install dependencies - name: Install dependencies
run: bun install run: bun install
shell: bash shell: bash
+15 -8
View File
@@ -6,14 +6,6 @@ on:
- dev - dev
pull_request: pull_request:
workflow_dispatch: workflow_dispatch:
concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true
permissions:
contents: read
jobs: jobs:
unit: unit:
name: unit (${{ matrix.settings.name }}) name: unit (${{ matrix.settings.name }})
@@ -94,3 +86,18 @@ jobs:
path: | path: |
packages/app/e2e/test-results packages/app/e2e/test-results
packages/app/e2e/playwright-report packages/app/e2e/playwright-report
required:
name: test (linux)
runs-on: blacksmith-4vcpu-ubuntu-2404
needs:
- unit
- e2e
if: always()
steps:
- name: Verify upstream test jobs passed
run: |
echo "unit=${{ needs.unit.result }}"
echo "e2e=${{ needs.e2e.result }}"
test "${{ needs.unit.result }}" = "success"
test "${{ needs.e2e.result }}" = "success"
+2 -5
View File
@@ -1,6 +1,3 @@
node_modules plans/
plans
package.json
bun.lock bun.lock
.gitignore package.json
package-lock.json
+2 -10
View File
@@ -1,5 +1,6 @@
/// <reference path="../env.d.ts" /> /// <reference path="../env.d.ts" />
import { tool } from "@opencode-ai/plugin" import { tool } from "@opencode-ai/plugin"
import DESCRIPTION from "./github-pr-search.txt"
async function githubFetch(endpoint: string, options: RequestInit = {}) { async function githubFetch(endpoint: string, options: RequestInit = {}) {
const response = await fetch(`https://api.github.com${endpoint}`, { const response = await fetch(`https://api.github.com${endpoint}`, {
@@ -23,16 +24,7 @@ interface PR {
} }
export default tool({ export default tool({
description: `Use this tool to search GitHub pull requests by title and description. description: DESCRIPTION,
This tool searches PRs in the anomalyco/opencode repository and returns LLM-friendly results including:
- PR number and title
- Author
- State (open/closed/merged)
- Labels
- Description snippet
Use the query parameter to search for keywords that might appear in PR titles or descriptions.`,
args: { args: {
query: tool.schema.string().describe("Search query for PR titles and descriptions"), query: tool.schema.string().describe("Search query for PR titles and descriptions"),
limit: tool.schema.number().describe("Maximum number of results to return").default(10), limit: tool.schema.number().describe("Maximum number of results to return").default(10),
+10
View File
@@ -0,0 +1,10 @@
Use this tool to search GitHub pull requests by title and description.
This tool searches PRs in the anomalyco/opencode repository and returns LLM-friendly results including:
- PR number and title
- Author
- State (open/closed/merged)
- Labels
- Description snippet
Use the query parameter to search for keywords that might appear in PR titles or descriptions.
+2 -6
View File
@@ -1,5 +1,6 @@
/// <reference path="../env.d.ts" /> /// <reference path="../env.d.ts" />
import { tool } from "@opencode-ai/plugin" import { tool } from "@opencode-ai/plugin"
import DESCRIPTION from "./github-triage.txt"
const TEAM = { const TEAM = {
desktop: ["adamdotdevin", "iamdavidhill", "Brendonovich", "nexxeln"], desktop: ["adamdotdevin", "iamdavidhill", "Brendonovich", "nexxeln"],
@@ -39,12 +40,7 @@ async function githubFetch(endpoint: string, options: RequestInit = {}) {
} }
export default tool({ export default tool({
description: `Use this tool to assign and/or label a GitHub issue. description: DESCRIPTION,
Choose labels and assignee using the current triage policy and ownership rules.
Pick the most fitting labels for the issue and assign one owner.
If unsure, choose the team/section with the most overlap with the issue and assign a member from that team at random.`,
args: { args: {
assignee: tool.schema assignee: tool.schema
.enum(ASSIGNEES as [string, ...string[]]) .enum(ASSIGNEES as [string, ...string[]])
+6
View File
@@ -0,0 +1,6 @@
Use this tool to assign and/or label a GitHub issue.
Choose labels and assignee using the current triage policy and ownership rules.
Pick the most fitting labels for the issue and assign one owner.
If unsure, choose the team/section with the most overlap with the issue and assign a member from that team at random.
-4
View File
@@ -122,7 +122,3 @@ const table = sqliteTable("session", {
- Avoid mocks as much as possible - Avoid mocks as much as possible
- Test actual implementation, do not duplicate logic into tests - Test actual implementation, do not duplicate logic into tests
- Tests cannot run from repo root (guard: `do-not-run-tests-from-root`); run from package dirs like `packages/opencode`. - Tests cannot run from repo root (guard: `do-not-run-tests-from-root`); run from package dirs like `packages/opencode`.
## Type Checking
- Always run `bun typecheck` from package directories (e.g., `packages/opencode`), never `tsc` directly.
+1 -1
View File
@@ -128,7 +128,7 @@ If you are working on a project that's related to OpenCode and is using "opencod
#### How is this different from Claude Code? #### How is this different from Claude Code?
It's very similar to Claude Code in terms of capability. Here are the key differences:: It's very similar to Claude Code in terms of capability. Here are the key differences:
- 100% open source - 100% open source
- Not coupled to any provider. Although we recommend the models we provide through [OpenCode Zen](https://opencode.ai/zen), OpenCode can be used with Claude, OpenAI, Google, or even local models. As models evolve, the gaps between them will close and pricing will drop, so being provider-agnostic is important. - Not coupled to any provider. Although we recommend the models we provide through [OpenCode Zen](https://opencode.ai/zen), OpenCode can be used with Claude, OpenAI, Google, or even local models. As models evolve, the gaps between them will close and pricing will drop, so being provider-agnostic is important.
+96 -301
View File
File diff suppressed because it is too large Load Diff
+4 -4
View File
@@ -1,8 +1,8 @@
{ {
"nodeModules": { "nodeModules": {
"x86_64-linux": "sha256-dhL4YeSi4Lm9yDp919Fx7N2hyLUbZQa2qWoCf/50ce8=", "x86_64-linux": "sha256-duBedS4ZTc1as03OM0KB9mKKU21Cywv4o9GHwQZv6Ts=",
"aarch64-linux": "sha256-//YxCsrvYlxuvd0MtFFO+pLxjmuemyrvGzSIPxzO+rA=", "aarch64-linux": "sha256-juvQfuNBqqzeB/TIY9PuUDqgpsdyI54ImowjQLrNhns=",
"aarch64-darwin": "sha256-c65kSWteQNaBcQUsjbXNqT61vt98JPNYo9yMNvUygCw=", "aarch64-darwin": "sha256-kKgcuEN1oJqHJc+sGjcZ4INWvbZczSTDJ8VHIWAquD4=",
"x86_64-darwin": "sha256-hlTzEFv3nZHwlDXU65LfMC+NaqYjjyZqagdJ366CNxY=" "x86_64-darwin": "sha256-hXkFWOL4wi9s8HSrChpqtH4PKSNzbzVgU+0GbAxEUT4="
} }
} }
+2 -5
View File
@@ -11,7 +11,6 @@
"dev:web": "bun --cwd packages/app dev", "dev:web": "bun --cwd packages/app dev",
"dev:storybook": "bun --cwd packages/storybook storybook", "dev:storybook": "bun --cwd packages/storybook storybook",
"typecheck": "bun turbo typecheck", "typecheck": "bun turbo typecheck",
"postinstall": "bun run --cwd packages/opencode fix-node-pty",
"prepare": "husky", "prepare": "husky",
"random": "echo 'Random script'", "random": "echo 'Random script'",
"hello": "echo 'Hello World!'", "hello": "echo 'Hello World!'",
@@ -42,9 +41,8 @@
"@tailwindcss/vite": "4.1.11", "@tailwindcss/vite": "4.1.11",
"diff": "8.0.2", "diff": "8.0.2",
"dompurify": "3.3.1", "dompurify": "3.3.1",
"effect": "4.0.0-beta.29", "drizzle-kit": "1.0.0-beta.16-ea816b6",
"drizzle-kit": "1.0.0-beta.16-c2458b2", "drizzle-orm": "1.0.0-beta.16-ea816b6",
"drizzle-orm": "1.0.0-beta.16-c2458b2",
"ai": "5.0.124", "ai": "5.0.124",
"hono": "4.10.7", "hono": "4.10.7",
"hono-openapi": "1.1.2", "hono-openapi": "1.1.2",
@@ -99,7 +97,6 @@
}, },
"trustedDependencies": [ "trustedDependencies": [
"esbuild", "esbuild",
"node-pty",
"protobufjs", "protobufjs",
"tree-sitter", "tree-sitter",
"tree-sitter-bash", "tree-sitter-bash",
@@ -21,8 +21,6 @@ import { useLayout } from "@/context/layout"
import { usePlatform } from "@/context/platform" import { usePlatform } from "@/context/platform"
import { useServer } from "@/context/server" import { useServer } from "@/context/server"
import { useSync } from "@/context/sync" import { useSync } from "@/context/sync"
import { useTerminal } from "@/context/terminal"
import { focusTerminalById } from "@/pages/session/helpers"
import { decode64 } from "@/utils/base64" import { decode64 } from "@/utils/base64"
import { Persist, persisted } from "@/utils/persist" import { Persist, persisted } from "@/utils/persist"
import { StatusPopover } from "../status-popover" import { StatusPopover } from "../status-popover"
@@ -231,7 +229,6 @@ export function SessionHeader() {
const sync = useSync() const sync = useSync()
const platform = usePlatform() const platform = usePlatform()
const language = useLanguage() const language = useLanguage()
const terminal = useTerminal()
const projectDirectory = createMemo(() => decode64(params.dir) ?? "") const projectDirectory = createMemo(() => decode64(params.dir) ?? "")
const project = createMemo(() => { const project = createMemo(() => {
@@ -299,16 +296,6 @@ export function SessionHeader() {
] as const ] as const
}) })
const toggleTerminal = () => {
const next = !view().terminal.opened()
view().terminal.toggle()
if (!next) return
const id = terminal.active()
if (!id) return
focusTerminalById(id)
}
const [prefs, setPrefs] = persisted(Persist.global("open.app"), createStore({ app: "finder" as OpenApp })) const [prefs, setPrefs] = persisted(Persist.global("open.app"), createStore({ app: "finder" as OpenApp }))
const [menu, setMenu] = createStore({ open: false }) const [menu, setMenu] = createStore({ open: false })
const [openRequest, setOpenRequest] = createStore({ const [openRequest, setOpenRequest] = createStore({
@@ -630,14 +617,15 @@ export function SessionHeader() {
</div> </div>
</Show> </Show>
<div class="flex items-center gap-1"> <div class="flex items-center gap-1">
<div class="hidden md:flex items-center gap-1 shrink-0">
<TooltipKeybind <TooltipKeybind
title={language.t("command.terminal.toggle")} title={language.t("command.terminal.toggle")}
keybind={command.keybind("terminal.toggle")} keybind={command.keybind("terminal.toggle")}
> >
<Button <Button
variant="ghost" variant="ghost"
class="group/terminal-toggle titlebar-icon w-8 h-6 p-0 box-border shrink-0" class="group/terminal-toggle titlebar-icon w-8 h-6 p-0 box-border"
onClick={toggleTerminal} onClick={() => view().terminal.toggle()}
aria-label={language.t("command.terminal.toggle")} aria-label={language.t("command.terminal.toggle")}
aria-expanded={view().terminal.opened()} aria-expanded={view().terminal.opened()}
aria-controls="terminal-panel" aria-controls="terminal-panel"
@@ -662,7 +650,6 @@ export function SessionHeader() {
</Button> </Button>
</TooltipKeybind> </TooltipKeybind>
<div class="hidden md:flex items-center gap-1 shrink-0">
<TooltipKeybind <TooltipKeybind
title={language.t("command.review.toggle")} title={language.t("command.review.toggle")}
keybind={command.keybind("review.toggle")} keybind={command.keybind("review.toggle")}
+2 -3
View File
@@ -17,7 +17,6 @@ const TOGGLE_TERMINAL_ID = "terminal.toggle"
const DEFAULT_TOGGLE_TERMINAL_KEYBIND = "ctrl+`" const DEFAULT_TOGGLE_TERMINAL_KEYBIND = "ctrl+`"
export interface TerminalProps extends ComponentProps<"div"> { export interface TerminalProps extends ComponentProps<"div"> {
pty: LocalPTY pty: LocalPTY
autoFocus?: boolean
onSubmit?: () => void onSubmit?: () => void
onCleanup?: (pty: Partial<LocalPTY> & { id: string }) => void onCleanup?: (pty: Partial<LocalPTY> & { id: string }) => void
onConnect?: () => void onConnect?: () => void
@@ -158,7 +157,7 @@ export const Terminal = (props: TerminalProps) => {
const language = useLanguage() const language = useLanguage()
const server = useServer() const server = useServer()
let container!: HTMLDivElement let container!: HTMLDivElement
const [local, others] = splitProps(props, ["pty", "class", "classList", "autoFocus", "onConnect", "onConnectError"]) const [local, others] = splitProps(props, ["pty", "class", "classList", "onConnect", "onConnectError"])
const id = local.pty.id const id = local.pty.id
const restore = typeof local.pty.buffer === "string" ? local.pty.buffer : "" const restore = typeof local.pty.buffer === "string" ? local.pty.buffer : ""
const restoreSize = const restoreSize =
@@ -387,7 +386,7 @@ export const Terminal = (props: TerminalProps) => {
handleLinkClick, handleLinkClick,
}) })
if (local.autoFocus !== false) focusTerminal() focusTerminal()
if (typeof document !== "undefined" && document.fonts) { if (typeof document !== "undefined" && document.fonts) {
document.fonts.ready.then(scheduleFit) document.fonts.ready.then(scheduleFit)
+3 -8
View File
@@ -32,9 +32,8 @@ import { useLayout } from "@/context/layout"
import { usePrompt } from "@/context/prompt" import { usePrompt } from "@/context/prompt"
import { useSDK } from "@/context/sdk" import { useSDK } from "@/context/sdk"
import { useSync } from "@/context/sync" import { useSync } from "@/context/sync"
import { useTerminal } from "@/context/terminal"
import { createSessionComposerState, SessionComposerRegion } from "@/pages/session/composer" import { createSessionComposerState, SessionComposerRegion } from "@/pages/session/composer"
import { createOpenReviewFile, createSizing, focusTerminalById } from "@/pages/session/helpers" import { createOpenReviewFile, createSizing } from "@/pages/session/helpers"
import { MessageTimeline } from "@/pages/session/message-timeline" import { MessageTimeline } from "@/pages/session/message-timeline"
import { type DiffStyle, SessionReviewTab, type SessionReviewTabProps } from "@/pages/session/review-tab" import { type DiffStyle, SessionReviewTab, type SessionReviewTabProps } from "@/pages/session/review-tab"
import { resetSessionModel, syncSessionModel } from "@/pages/session/session-model-helpers" import { resetSessionModel, syncSessionModel } from "@/pages/session/session-model-helpers"
@@ -268,7 +267,6 @@ export default function Page() {
const sdk = useSDK() const sdk = useSDK()
const prompt = usePrompt() const prompt = usePrompt()
const comments = useComments() const comments = useComments()
const terminal = useTerminal()
const [searchParams, setSearchParams] = useSearchParams<{ prompt?: string }>() const [searchParams, setSearchParams] = useSearchParams<{ prompt?: string }>()
createEffect(() => { createEffect(() => {
@@ -761,11 +759,8 @@ export default function Page() {
return return
} }
// Prefer the open terminal over the composer when it can take focus // Don't autofocus chat if desktop terminal panel is open
if (view().terminal.opened()) { if (isDesktop() && view().terminal.opened()) return
const id = terminal.active()
if (id && focusTerminalById(id)) return
}
// Only treat explicit scroll keys as potential "user scroll" gestures. // Only treat explicit scroll keys as potential "user scroll" gestures.
if (event.key === "PageUp" || event.key === "PageDown" || event.key === "Home" || event.key === "End") { if (event.key === "PageUp" || event.key === "PageDown" || event.key === "Home" || event.key === "End") {
@@ -138,6 +138,7 @@ export function SessionTodoDock(props: {
"--tool-motion-mask-height": `${props.countMaskHeight ?? 0}px`, "--tool-motion-mask-height": `${props.countMaskHeight ?? 0}px`,
"--tool-motion-spring-ms": `${props.countWidthDuration ?? 560}ms`, "--tool-motion-spring-ms": `${props.countWidthDuration ?? 560}ms`,
opacity: `${Math.max(0, Math.min(1, 1 - shut()))}`, opacity: `${Math.max(0, Math.min(1, 1 - shut()))}`,
filter: `blur(${Math.max(0, Math.min(1, shut())) * 2}px)`,
}} }}
> >
<AnimatedNumber value={done()} /> <AnimatedNumber value={done()} />
@@ -195,6 +196,7 @@ export function SessionTodoDock(props: {
style={{ style={{
visibility: off() ? "hidden" : "visible", visibility: off() ? "hidden" : "visible",
opacity: `${Math.max(0, Math.min(1, 1 - hide()))}`, opacity: `${Math.max(0, Math.min(1, 1 - hide()))}`,
filter: `blur(${Math.max(0, Math.min(1, hide())) * 2}px)`,
}} }}
> >
<TodoList todos={props.todos} open={!store.collapsed} /> <TodoList todos={props.todos} open={!store.collapsed} />
@@ -1,5 +1,6 @@
import { For, Show, createEffect, createMemo, on, onCleanup } from "solid-js" import { For, Show, createEffect, createMemo, on } from "solid-js"
import { createStore } from "solid-js/store" import { createStore } from "solid-js/store"
import { createMediaQuery } from "@solid-primitives/media"
import { useParams } from "@solidjs/router" import { useParams } from "@solidjs/router"
import { Tabs } from "@opencode-ai/ui/tabs" import { Tabs } from "@opencode-ai/ui/tabs"
import { ResizeHandle } from "@opencode-ai/ui/resize-handle" import { ResizeHandle } from "@opencode-ai/ui/resize-handle"
@@ -16,7 +17,7 @@ import { useLanguage } from "@/context/language"
import { useLayout } from "@/context/layout" import { useLayout } from "@/context/layout"
import { useTerminal, type LocalPTY } from "@/context/terminal" import { useTerminal, type LocalPTY } from "@/context/terminal"
import { terminalTabLabel } from "@/pages/session/terminal-label" import { terminalTabLabel } from "@/pages/session/terminal-label"
import { createSizing, focusTerminalById } from "@/pages/session/helpers" import { createPresence, createSizing, focusTerminalById } from "@/pages/session/helpers"
import { getTerminalHandoff, setTerminalHandoff } from "@/pages/session/handoff" import { getTerminalHandoff, setTerminalHandoff } from "@/pages/session/handoff"
export function TerminalPanel() { export function TerminalPanel() {
@@ -26,10 +27,13 @@ export function TerminalPanel() {
const language = useLanguage() const language = useLanguage()
const command = useCommand() const command = useCommand()
const isDesktop = createMediaQuery("(min-width: 768px)")
const sessionKey = createMemo(() => `${params.dir}${params.id ? "/" + params.id : ""}`) const sessionKey = createMemo(() => `${params.dir}${params.id ? "/" + params.id : ""}`)
const view = createMemo(() => layout.view(sessionKey)) const view = createMemo(() => layout.view(sessionKey))
const opened = createMemo(() => view().terminal.opened()) const opened = createMemo(() => view().terminal.opened())
const open = createMemo(() => isDesktop() && opened())
const panel = createPresence(open)
const size = createSizing() const size = createSizing()
const height = createMemo(() => layout.terminal.height()) const height = createMemo(() => layout.terminal.height())
const close = () => view().terminal.close() const close = () => view().terminal.close()
@@ -38,25 +42,6 @@ export function TerminalPanel() {
const [store, setStore] = createStore({ const [store, setStore] = createStore({
autoCreated: false, autoCreated: false,
activeDraggable: undefined as string | undefined, activeDraggable: undefined as string | undefined,
view: typeof window === "undefined" ? 1000 : (window.visualViewport?.height ?? window.innerHeight),
})
const max = () => store.view * 0.6
const pane = () => Math.min(height(), max())
createEffect(() => {
if (typeof window === "undefined") return
const sync = () => setStore("view", window.visualViewport?.height ?? window.innerHeight)
const port = window.visualViewport
sync()
window.addEventListener("resize", sync)
port?.addEventListener("resize", sync)
onCleanup(() => {
window.removeEventListener("resize", sync)
port?.removeEventListener("resize", sync)
})
}) })
createEffect(() => { createEffect(() => {
@@ -81,42 +66,21 @@ export function TerminalPanel() {
), ),
) )
const focus = (id: string) => {
focusTerminalById(id)
const frame = requestAnimationFrame(() => {
if (!opened()) return
if (terminal.active() !== id) return
focusTerminalById(id)
})
const timers = [120, 240].map((ms) =>
window.setTimeout(() => {
if (!opened()) return
if (terminal.active() !== id) return
focusTerminalById(id)
}, ms),
)
return () => {
cancelAnimationFrame(frame)
for (const timer of timers) clearTimeout(timer)
}
}
createEffect( createEffect(
on( on(
() => [opened(), terminal.active()] as const, () => terminal.active(),
([next, id]) => { (activeId) => {
if (!next || !id) return if (!activeId || !panel.open()) return
const stop = focus(id) if (document.activeElement instanceof HTMLElement) {
onCleanup(stop) document.activeElement.blur()
}
setTimeout(() => focusTerminalById(activeId), 0)
}, },
), ),
) )
createEffect(() => { createEffect(() => {
if (opened()) return if (panel.open()) return
const active = document.activeElement const active = document.activeElement
if (!(active instanceof HTMLElement)) return if (!(active instanceof HTMLElement)) return
if (!root?.contains(active)) return if (!root?.contains(active)) return
@@ -174,44 +138,36 @@ export function TerminalPanel() {
const activeId = terminal.active() const activeId = terminal.active()
if (!activeId) return if (!activeId) return
requestAnimationFrame(() => { setTimeout(() => {
if (terminal.active() !== activeId) return
focusTerminalById(activeId) focusTerminalById(activeId)
}) }, 0)
} }
return ( return (
<Show when={panel.show()}>
<div <div
ref={root} ref={root}
id="terminal-panel" id="terminal-panel"
role="region" role="region"
aria-label={language.t("terminal.title")} aria-label={language.t("terminal.title")}
aria-hidden={!opened()} aria-hidden={!panel.open()}
inert={!opened()} inert={!panel.open()}
class="relative w-full shrink-0 overflow-hidden bg-background-stronger" class="relative w-full shrink-0 overflow-hidden"
classList={{ classList={{
"border-t border-border-weak-base": opened(), "opacity-100": panel.open(),
"transition-[height] duration-200 ease-[cubic-bezier(0.22,1,0.36,1)] will-change-[height] motion-reduce:transition-none": "opacity-0 pointer-events-none": !panel.open(),
"transition-[height,opacity] duration-200 ease-[cubic-bezier(0.22,1,0.36,1)] will-change-[height] motion-reduce:transition-none":
!size.active(), !size.active(),
}} }}
style={{ height: opened() ? `${pane()}px` : "0px" }} style={{ height: panel.open() ? `${height()}px` : "0px" }}
> >
<div <div class="size-full flex flex-col border-t border-border-weak-base">
class="absolute inset-x-0 top-0 flex flex-col" <div onPointerDown={() => size.start()}>
classList={{
"translate-y-0": opened(),
"translate-y-full pointer-events-none": !opened(),
"transition-transform duration-200 ease-[cubic-bezier(0.22,1,0.36,1)] will-change-transform motion-reduce:transition-none":
!size.active(),
}}
style={{ height: `${pane()}px` }}
>
<div class="hidden md:block" onPointerDown={() => size.start()}>
<ResizeHandle <ResizeHandle
direction="vertical" direction="vertical"
size={pane()} size={height()}
min={100} min={100}
max={max()} max={typeof window === "undefined" ? 1000 : window.innerHeight * 0.6}
collapseThreshold={50} collapseThreshold={50}
onResize={(next) => { onResize={(next) => {
size.touch() size.touch()
@@ -238,7 +194,9 @@ export function TerminalPanel() {
{language.t("common.loading.ellipsis")} {language.t("common.loading.ellipsis")}
</div> </div>
</div> </div>
<div class="flex-1 flex items-center justify-center text-text-weak">{language.t("terminal.loading")}</div> <div class="flex-1 flex items-center justify-center text-text-weak">
{language.t("terminal.loading")}
</div>
</div> </div>
} }
> >
@@ -292,7 +250,6 @@ export function TerminalPanel() {
<div id={`terminal-wrapper-${id}`} class="absolute inset-0"> <div id={`terminal-wrapper-${id}`} class="absolute inset-0">
<Terminal <Terminal
pty={pty()} pty={pty()}
autoFocus={opened()}
onConnect={() => terminal.trim(id)} onConnect={() => terminal.trim(id)}
onCleanup={terminal.update} onCleanup={terminal.update}
onConnectError={() => terminal.clone(id)} onConnectError={() => terminal.clone(id)}
@@ -325,5 +282,6 @@ export function TerminalPanel() {
</Show> </Show>
</div> </div>
</div> </div>
</Show>
) )
} }
@@ -99,13 +99,7 @@ export async function handler(
const dataDumper = createDataDumper(sessionId, requestId, projectId) const dataDumper = createDataDumper(sessionId, requestId, projectId)
const trialLimiter = createTrialLimiter(modelInfo.trialProvider, ip) const trialLimiter = createTrialLimiter(modelInfo.trialProvider, ip)
const trialProvider = await trialLimiter?.check() const trialProvider = await trialLimiter?.check()
const rateLimiter = createRateLimiter( const rateLimiter = createRateLimiter(modelInfo.allowAnonymous, ip, input.request)
modelInfo.id,
modelInfo.allowAnonymous,
modelInfo.rateLimit,
ip,
input.request,
)
await rateLimiter?.check() await rateLimiter?.check()
const stickyTracker = createStickyTracker(modelInfo.stickyProvider, sessionId) const stickyTracker = createStickyTracker(modelInfo.stickyProvider, sessionId)
const stickyProvider = await stickyTracker?.get() const stickyProvider = await stickyTracker?.get()
@@ -6,63 +6,39 @@ import { i18n } from "~/i18n"
import { localeFromRequest } from "~/lib/language" import { localeFromRequest } from "~/lib/language"
import { Subscription } from "@opencode-ai/console-core/subscription.js" import { Subscription } from "@opencode-ai/console-core/subscription.js"
export function createRateLimiter( export function createRateLimiter(allowAnonymous: boolean | undefined, rawIp: string, request: Request) {
modelId: string,
allowAnonymous: boolean | undefined,
rateLimit: number | undefined,
rawIp: string,
request: Request,
) {
if (!allowAnonymous) return if (!allowAnonymous) return
const dict = i18n(localeFromRequest(request)) const dict = i18n(localeFromRequest(request))
const limits = Subscription.getFreeLimits() const limits = Subscription.getFreeLimits()
const headerExists = request.headers.has(limits.checkHeader) const limitValue =
const dailyLimit = !headerExists ? limits.fallbackValue : (rateLimit ?? limits.dailyRequests) limits.checkHeader && !request.headers.get(limits.checkHeader) ? limits.fallbackValue : limits.dailyRequests
const isDefaultModel = headerExists && !rateLimit
const ip = !rawIp.length ? "unknown" : rawIp const ip = !rawIp.length ? "unknown" : rawIp
const now = Date.now() const now = Date.now()
const lifetimeInterval = "" const interval = buildYYYYMMDD(now)
const dailyInterval = rateLimit ? `${buildYYYYMMDD(now)}${modelId.substring(0, 2)}` : buildYYYYMMDD(now)
let _isNew: boolean
return { return {
track: async () => {
await Database.use((tx) =>
tx
.insert(IpRateLimitTable)
.values({ ip, interval, count: 1 })
.onDuplicateKeyUpdate({ set: { count: sql`${IpRateLimitTable.count} + 1` } }),
)
},
check: async () => { check: async () => {
const rows = await Database.use((tx) => const rows = await Database.use((tx) =>
tx tx
.select({ interval: IpRateLimitTable.interval, count: IpRateLimitTable.count }) .select({ interval: IpRateLimitTable.interval, count: IpRateLimitTable.count })
.from(IpRateLimitTable) .from(IpRateLimitTable)
.where( .where(and(eq(IpRateLimitTable.ip, ip), inArray(IpRateLimitTable.interval, [interval]))),
and(
eq(IpRateLimitTable.ip, ip),
isDefaultModel
? inArray(IpRateLimitTable.interval, [lifetimeInterval, dailyInterval])
: inArray(IpRateLimitTable.interval, [dailyInterval]),
),
),
) )
const lifetimeCount = rows.find((r) => r.interval === lifetimeInterval)?.count ?? 0 const total = rows.reduce((sum, r) => sum + r.count, 0)
const dailyCount = rows.find((r) => r.interval === dailyInterval)?.count ?? 0 logger.debug(`rate limit total: ${total}`)
logger.debug(`rate limit lifetime: ${lifetimeCount}, daily: ${dailyCount}`) if (total >= limitValue)
_isNew = isDefaultModel && lifetimeCount < dailyLimit * 7
if ((_isNew && dailyCount >= dailyLimit * 2) || (!_isNew && dailyCount >= dailyLimit))
throw new FreeUsageLimitError(dict["zen.api.error.rateLimitExceeded"], getRetryAfterDay(now)) throw new FreeUsageLimitError(dict["zen.api.error.rateLimitExceeded"], getRetryAfterDay(now))
}, },
track: async () => {
await Database.use((tx) =>
tx
.insert(IpRateLimitTable)
.values([
{ ip, interval: dailyInterval, count: 1 },
...(_isNew ? [{ ip, interval: lifetimeInterval, count: 1 }] : []),
])
.onDuplicateKeyUpdate({ set: { count: sql`${IpRateLimitTable.count} + 1` } }),
)
},
} }
} }
-1
View File
@@ -28,7 +28,6 @@ export namespace ZenData {
stickyProvider: z.enum(["strict", "prefer"]).optional(), stickyProvider: z.enum(["strict", "prefer"]).optional(),
trialProvider: z.string().optional(), trialProvider: z.string().optional(),
fallbackProvider: z.string().optional(), fallbackProvider: z.string().optional(),
rateLimit: z.number().optional(),
providers: z.array( providers: z.array(
z.object({ z.object({
id: z.string(), id: z.string(),
@@ -1,17 +0,0 @@
CREATE TABLE `account` (
`id` text PRIMARY KEY,
`email` text NOT NULL,
`url` text NOT NULL,
`access_token` text NOT NULL,
`refresh_token` text NOT NULL,
`token_expiry` integer,
`selected_org_id` text,
`time_created` integer NOT NULL,
`time_updated` integer NOT NULL
);
--> statement-breakpoint
CREATE TABLE `account_state` (
`id` integer PRIMARY KEY NOT NULL,
`active_account_id` text,
FOREIGN KEY (`active_account_id`) REFERENCES `account`(`id`) ON UPDATE no action ON DELETE set null
);
File diff suppressed because it is too large Load Diff
@@ -1,3 +0,0 @@
ALTER TABLE `account_state` ADD `active_org_id` text;--> statement-breakpoint
UPDATE `account_state` SET `active_org_id` = (SELECT `selected_org_id` FROM `account` WHERE `account`.`id` = `account_state`.`active_account_id`);--> statement-breakpoint
ALTER TABLE `account` DROP COLUMN `selected_org_id`;
File diff suppressed because it is too large Load Diff
+8 -24
View File
@@ -7,9 +7,8 @@
"private": true, "private": true,
"scripts": { "scripts": {
"typecheck": "tsgo --noEmit", "typecheck": "tsgo --noEmit",
"test": "bun test --timeout 30000 registry", "test": "bun test --timeout 30000",
"build": "bun run script/build.ts", "build": "bun run script/build.ts",
"fix-node-pty": "bun run script/fix-node-pty.ts",
"dev": "bun run --conditions=browser ./src/index.ts", "dev": "bun run --conditions=browser ./src/index.ts",
"random": "echo 'Random script updated at $(date)' && echo 'Change queued successfully' && echo 'Another change made' && echo 'Yet another change' && echo 'One more change' && echo 'Final change' && echo 'Another final change' && echo 'Yet another final change'", "random": "echo 'Random script updated at $(date)' && echo 'Change queued successfully' && echo 'Another change made' && echo 'Yet another change' && echo 'One more change' && echo 'Final change' && echo 'Another final change' && echo 'Yet another final change'",
"clean": "echo 'Cleaning up...' && rm -rf node_modules dist", "clean": "echo 'Cleaning up...' && rm -rf node_modules dist",
@@ -26,18 +25,6 @@
"exports": { "exports": {
"./*": "./src/*.ts" "./*": "./src/*.ts"
}, },
"imports": {
"#db": {
"bun": "./src/storage/db.bun.ts",
"node": "./src/storage/db.node.ts",
"default": "./src/storage/db.bun.ts"
},
"#pty": {
"bun": "./src/pty/pty.bun.ts",
"node": "./src/pty/pty.node.ts",
"default": "./src/pty/pty.bun.ts"
}
},
"devDependencies": { "devDependencies": {
"@babel/core": "7.28.4", "@babel/core": "7.28.4",
"@octokit/webhooks-types": "7.6.1", "@octokit/webhooks-types": "7.6.1",
@@ -54,14 +41,13 @@
"@types/babel__core": "7.20.5", "@types/babel__core": "7.20.5",
"@types/bun": "catalog:", "@types/bun": "catalog:",
"@types/mime-types": "3.0.1", "@types/mime-types": "3.0.1",
"@types/npmcli__arborist": "6.3.3",
"@types/semver": "^7.5.8", "@types/semver": "^7.5.8",
"@types/turndown": "5.0.5", "@types/turndown": "5.0.5",
"@types/which": "3.0.4",
"@types/yargs": "17.0.33", "@types/yargs": "17.0.33",
"@types/which": "3.0.4",
"@typescript/native-preview": "catalog:", "@typescript/native-preview": "catalog:",
"effect": "catalog:", "drizzle-kit": "1.0.0-beta.16-ea816b6",
"drizzle-kit": "catalog:", "drizzle-orm": "1.0.0-beta.16-ea816b6",
"typescript": "catalog:", "typescript": "catalog:",
"vscode-languageserver-types": "3.17.5", "vscode-languageserver-types": "3.17.5",
"why-is-node-running": "3.2.2", "why-is-node-running": "3.2.2",
@@ -94,12 +80,11 @@
"@clack/prompts": "1.0.0-alpha.1", "@clack/prompts": "1.0.0-alpha.1",
"@gitlab/gitlab-ai-provider": "3.6.0", "@gitlab/gitlab-ai-provider": "3.6.0",
"@gitlab/opencode-gitlab-auth": "1.3.3", "@gitlab/opencode-gitlab-auth": "1.3.3",
"@hono/standard-validator": "0.1.5",
"@hono/node-server": "1.19.11", "@hono/node-server": "1.19.11",
"@hono/node-ws": "1.3.0", "@hono/node-ws": "1.3.0",
"@hono/standard-validator": "0.1.5",
"@hono/zod-validator": "catalog:", "@hono/zod-validator": "catalog:",
"@modelcontextprotocol/sdk": "1.25.2", "@modelcontextprotocol/sdk": "1.25.2",
"@npmcli/arborist": "9.4.0",
"@octokit/graphql": "9.0.2", "@octokit/graphql": "9.0.2",
"@octokit/rest": "catalog:", "@octokit/rest": "catalog:",
"@openauthjs/openauth": "catalog:", "@openauthjs/openauth": "catalog:",
@@ -108,8 +93,8 @@
"@opencode-ai/sdk": "workspace:*", "@opencode-ai/sdk": "workspace:*",
"@opencode-ai/util": "workspace:*", "@opencode-ai/util": "workspace:*",
"@openrouter/ai-sdk-provider": "1.5.4", "@openrouter/ai-sdk-provider": "1.5.4",
"@opentui/core": "0.1.86", "@opentui/core": "0.1.87",
"@opentui/solid": "0.1.86", "@opentui/solid": "0.1.87",
"@parcel/watcher": "2.5.1", "@parcel/watcher": "2.5.1",
"@pierre/diffs": "catalog:", "@pierre/diffs": "catalog:",
"@solid-primitives/event-bus": "1.1.2", "@solid-primitives/event-bus": "1.1.2",
@@ -124,7 +109,7 @@
"clipboardy": "4.0.0", "clipboardy": "4.0.0",
"decimal.js": "10.5.0", "decimal.js": "10.5.0",
"diff": "catalog:", "diff": "catalog:",
"drizzle-orm": "catalog:", "drizzle-orm": "1.0.0-beta.16-ea816b6",
"fuzzysort": "3.1.0", "fuzzysort": "3.1.0",
"glob": "13.0.5", "glob": "13.0.5",
"google-auth-library": "10.5.0", "google-auth-library": "10.5.0",
@@ -135,7 +120,6 @@
"jsonc-parser": "3.3.1", "jsonc-parser": "3.3.1",
"mime-types": "3.0.2", "mime-types": "3.0.2",
"minimatch": "10.0.3", "minimatch": "10.0.3",
"node-pty": "1.1.0",
"open": "10.1.2", "open": "10.1.2",
"opentui-spinner": "0.0.6", "opentui-spinner": "0.0.6",
"partial-json": "0.1.7", "partial-json": "0.1.7",
-54
View File
@@ -1,54 +0,0 @@
#!/usr/bin/env bun
import fs from "fs"
import path from "path"
import { fileURLToPath } from "url"
const __filename = fileURLToPath(import.meta.url)
const __dirname = path.dirname(__filename)
const dir = path.resolve(__dirname, "..")
process.chdir(dir)
// Load migrations from migration directories
const migrationDirs = (
await fs.promises.readdir(path.join(dir, "migration"), {
withFileTypes: true,
})
)
.filter((entry) => entry.isDirectory() && /^\d{4}\d{2}\d{2}\d{2}\d{2}\d{2}/.test(entry.name))
.map((entry) => entry.name)
.sort()
const migrations = await Promise.all(
migrationDirs.map(async (name) => {
const file = path.join(dir, "migration", name, "migration.sql")
const sql = await Bun.file(file).text()
const match = /^(\d{4})(\d{2})(\d{2})(\d{2})(\d{2})(\d{2})/.exec(name)
const timestamp = match
? Date.UTC(
Number(match[1]),
Number(match[2]) - 1,
Number(match[3]),
Number(match[4]),
Number(match[5]),
Number(match[6]),
)
: 0
return { sql, timestamp, name }
}),
)
console.log(`Loaded ${migrations.length} migrations`)
await Bun.build({
target: "node",
entrypoints: ["./src/node.ts"],
outdir: "./dist",
format: "esm",
external: ["jsonc-parser", "node-pty"],
define: {
OPENCODE_MIGRATIONS: JSON.stringify(migrations),
},
})
console.log("Build complete")
-28
View File
@@ -1,28 +0,0 @@
#!/usr/bin/env bun
import fs from "fs/promises"
import path from "path"
import { fileURLToPath } from "url"
const __filename = fileURLToPath(import.meta.url)
const __dirname = path.dirname(__filename)
const dir = path.resolve(__dirname, "..")
if (process.platform !== "win32") {
const root = path.join(dir, "node_modules", "node-pty", "prebuilds")
const dirs = await fs.readdir(root, { withFileTypes: true }).catch(() => [])
const files = dirs.filter((x) => x.isDirectory()).map((x) => path.join(root, x.name, "spawn-helper"))
const result = await Promise.all(
files.map(async (file) => {
const stat = await fs.stat(file).catch(() => undefined)
if (!stat) return
if ((stat.mode & 0o111) === 0o111) return
await fs.chmod(file, stat.mode | 0o755)
return file
}),
)
const fixed = result.filter(Boolean)
if (fixed.length) {
console.log(`fixed node-pty permissions for ${fixed.length} helper${fixed.length === 1 ? "" : "s"}`)
}
}
@@ -1,35 +0,0 @@
import { sqliteTable, text, integer, primaryKey } from "drizzle-orm/sqlite-core"
import { Timestamps } from "../storage/schema.sql"
export const AccountTable = sqliteTable("account", {
id: text().primaryKey(),
email: text().notNull(),
url: text().notNull(),
access_token: text().notNull(),
refresh_token: text().notNull(),
token_expiry: integer(),
...Timestamps,
})
export const AccountStateTable = sqliteTable("account_state", {
id: integer().primaryKey(),
active_account_id: text().references(() => AccountTable.id, { onDelete: "set null" }),
active_org_id: text(),
})
// LEGACY
export const ControlAccountTable = sqliteTable(
"control_account",
{
email: text().notNull(),
url: text().notNull(),
access_token: text().notNull(),
refresh_token: text().notNull(),
token_expiry: integer(),
active: integer({ mode: "boolean" })
.notNull()
.$default(() => false),
...Timestamps,
},
(table) => [primaryKey({ columns: [table.email, table.url] })],
)
-43
View File
@@ -1,43 +0,0 @@
import { Effect, Option, ServiceMap } from "effect"
import {
Account as AccountSchema,
type AccountError,
type AccessToken,
AccountID,
AccountService,
OrgID,
} from "./service"
export { AccessToken, AccountID, OrgID } from "./service"
import { runtime } from "@/effect/runtime"
type AccountServiceShape = ServiceMap.Service.Shape<typeof AccountService>
function runSync<A>(f: (service: AccountServiceShape) => Effect.Effect<A, AccountError>) {
return runtime.runSync(AccountService.use(f))
}
function runPromise<A>(f: (service: AccountServiceShape) => Effect.Effect<A, AccountError>) {
return runtime.runPromise(AccountService.use(f))
}
export namespace Account {
export const Account = AccountSchema
export type Account = AccountSchema
export function active(): Account | undefined {
return Option.getOrUndefined(runSync((service) => service.active()))
}
export async function config(accountID: AccountID, orgID: OrgID): Promise<Record<string, unknown> | undefined> {
const config = await runPromise((service) => service.config(accountID, orgID))
return Option.getOrUndefined(config)
}
export async function token(accountID: AccountID): Promise<AccessToken | undefined> {
const token = await runPromise((service) => service.token(accountID))
return Option.getOrUndefined(token)
}
}
-146
View File
@@ -1,146 +0,0 @@
import { eq } from "drizzle-orm"
import { Effect, Layer, Option, Schema, ServiceMap } from "effect"
import { Database } from "@/storage/db"
import { AccountStateTable, AccountTable } from "./account.sql"
import { Account, AccountID, AccountRepoError, OrgID } from "./schema"
export type AccountRow = (typeof AccountTable)["$inferSelect"]
const decodeAccount = Schema.decodeUnknownSync(Account)
type DbClient = Parameters<typeof Database.use>[0] extends (db: infer T) => unknown ? T : never
const ACCOUNT_STATE_ID = 1
const db = <A>(run: (db: DbClient) => A) =>
Effect.try({
try: () => Database.use(run),
catch: (cause) => new AccountRepoError({ message: "Database operation failed", cause }),
})
const current = (db: DbClient) => {
const state = db.select().from(AccountStateTable).where(eq(AccountStateTable.id, ACCOUNT_STATE_ID)).get()
if (!state?.active_account_id) return
const account = db.select().from(AccountTable).where(eq(AccountTable.id, state.active_account_id)).get()
if (!account) return
return { ...account, active_org_id: state.active_org_id ?? null }
}
const setState = (db: DbClient, accountID: AccountID, orgID: string | null) =>
db
.insert(AccountStateTable)
.values({ id: ACCOUNT_STATE_ID, active_account_id: accountID, active_org_id: orgID })
.onConflictDoUpdate({
target: AccountStateTable.id,
set: { active_account_id: accountID, active_org_id: orgID },
})
.run()
export class AccountRepo extends ServiceMap.Service<
AccountRepo,
{
readonly active: () => Effect.Effect<Option.Option<Account>, AccountRepoError>
readonly list: () => Effect.Effect<Account[], AccountRepoError>
readonly remove: (accountID: AccountID) => Effect.Effect<void, AccountRepoError>
readonly use: (accountID: AccountID, orgID: Option.Option<OrgID>) => Effect.Effect<void, AccountRepoError>
readonly getRow: (accountID: AccountID) => Effect.Effect<Option.Option<AccountRow>, AccountRepoError>
readonly persistToken: (input: {
accountID: AccountID
accessToken: string
refreshToken: string
expiry: Option.Option<number>
}) => Effect.Effect<void, AccountRepoError>
readonly persistAccount: (input: {
id: AccountID
email: string
url: string
accessToken: string
refreshToken: string
expiry: number
orgID: Option.Option<OrgID>
}) => Effect.Effect<void, AccountRepoError>
}
>()("@opencode/AccountRepo") {
static readonly layer: Layer.Layer<AccountRepo> = Layer.succeed(
AccountRepo,
AccountRepo.of({
active: Effect.fn("AccountRepo.active")(() =>
db((db) => current(db)).pipe(Effect.map((row) => (row ? Option.some(decodeAccount(row)) : Option.none()))),
),
list: Effect.fn("AccountRepo.list")(() =>
db((db) =>
db
.select()
.from(AccountTable)
.all()
.map((row) => decodeAccount({ ...row, active_org_id: null })),
),
),
remove: Effect.fn("AccountRepo.remove")((accountID: AccountID) =>
db((db) =>
Database.transaction((tx) => {
tx.update(AccountStateTable)
.set({ active_account_id: null, active_org_id: null })
.where(eq(AccountStateTable.active_account_id, accountID))
.run()
tx.delete(AccountTable).where(eq(AccountTable.id, accountID)).run()
}),
).pipe(Effect.asVoid),
),
use: Effect.fn("AccountRepo.use")((accountID: AccountID, orgID: Option.Option<OrgID>) =>
db((db) => setState(db, accountID, Option.getOrNull(orgID))).pipe(Effect.asVoid),
),
getRow: Effect.fn("AccountRepo.getRow")((accountID: AccountID) =>
db((db) => db.select().from(AccountTable).where(eq(AccountTable.id, accountID)).get()).pipe(
Effect.map(Option.fromNullishOr),
),
),
persistToken: Effect.fn("AccountRepo.persistToken")((input) =>
db((db) =>
db
.update(AccountTable)
.set({
access_token: input.accessToken,
refresh_token: input.refreshToken,
token_expiry: Option.getOrNull(input.expiry),
})
.where(eq(AccountTable.id, input.accountID))
.run(),
).pipe(Effect.asVoid),
),
persistAccount: Effect.fn("AccountRepo.persistAccount")((input) => {
const orgID = Option.getOrNull(input.orgID)
return db((db) =>
Database.transaction((tx) => {
tx.insert(AccountTable)
.values({
id: input.id,
email: input.email,
url: input.url,
access_token: input.accessToken,
refresh_token: input.refreshToken,
token_expiry: input.expiry,
})
.onConflictDoUpdate({
target: AccountTable.id,
set: {
access_token: input.accessToken,
refresh_token: input.refreshToken,
token_expiry: input.expiry,
},
})
.run()
setState(tx, input.id, orgID)
}),
).pipe(Effect.asVoid)
}),
}),
)
}
-73
View File
@@ -1,73 +0,0 @@
import { Schema } from "effect"
import { withStatics } from "@/util/schema"
export const AccountID = Schema.String.pipe(
Schema.brand("AccountId"),
withStatics((s) => ({ make: (id: string) => s.makeUnsafe(id) })),
)
export type AccountID = Schema.Schema.Type<typeof AccountID>
export const OrgID = Schema.String.pipe(
Schema.brand("OrgId"),
withStatics((s) => ({ make: (id: string) => s.makeUnsafe(id) })),
)
export type OrgID = Schema.Schema.Type<typeof OrgID>
export const AccessToken = Schema.String.pipe(
Schema.brand("AccessToken"),
withStatics((s) => ({ make: (token: string) => s.makeUnsafe(token) })),
)
export type AccessToken = Schema.Schema.Type<typeof AccessToken>
export class Account extends Schema.Class<Account>("Account")({
id: AccountID,
email: Schema.String,
url: Schema.String,
active_org_id: Schema.NullOr(OrgID),
}) {}
export class Org extends Schema.Class<Org>("Org")({
id: OrgID,
name: Schema.String,
}) {}
export class AccountRepoError extends Schema.TaggedErrorClass<AccountRepoError>()("AccountRepoError", {
message: Schema.String,
cause: Schema.optional(Schema.Defect),
}) {}
export class AccountServiceError extends Schema.TaggedErrorClass<AccountServiceError>()("AccountServiceError", {
message: Schema.String,
cause: Schema.optional(Schema.Defect),
}) {}
export type AccountError = AccountRepoError | AccountServiceError
export class Login extends Schema.Class<Login>("Login")({
code: Schema.String,
user: Schema.String,
url: Schema.String,
server: Schema.String,
expiry: Schema.Number,
interval: Schema.Number,
}) {}
export class PollSuccess extends Schema.TaggedClass<PollSuccess>()("PollSuccess", {
email: Schema.String,
}) {}
export class PollPending extends Schema.TaggedClass<PollPending>()("PollPending", {}) {}
export class PollSlow extends Schema.TaggedClass<PollSlow>()("PollSlow", {}) {}
export class PollExpired extends Schema.TaggedClass<PollExpired>()("PollExpired", {}) {}
export class PollDenied extends Schema.TaggedClass<PollDenied>()("PollDenied", {}) {}
export class PollError extends Schema.TaggedClass<PollError>()("PollError", {
cause: Schema.Defect,
}) {}
export const PollResult = Schema.Union([PollSuccess, PollPending, PollSlow, PollExpired, PollDenied, PollError])
export type PollResult = Schema.Schema.Type<typeof PollResult>
-386
View File
@@ -1,386 +0,0 @@
import { Clock, Effect, Layer, Option, Schema, ServiceMap } from "effect"
import {
FetchHttpClient,
HttpClient,
HttpClientError,
HttpClientRequest,
HttpClientResponse,
} from "effect/unstable/http"
import { withTransientReadRetry } from "@/util/effect-http-client"
import { AccountRepo, type AccountRow } from "./repo"
import {
type AccountError,
AccessToken,
Account,
AccountID,
AccountServiceError,
Login,
Org,
OrgID,
PollDenied,
PollError,
PollExpired,
PollPending,
type PollResult,
PollSlow,
PollSuccess,
} from "./schema"
export * from "./schema"
export type AccountOrgs = {
account: Account
orgs: Org[]
}
const RemoteOrg = Schema.Struct({
id: Schema.optional(OrgID),
name: Schema.optional(Schema.String),
})
const RemoteOrgs = Schema.Array(RemoteOrg)
const RemoteConfig = Schema.Struct({
config: Schema.Record(Schema.String, Schema.Json),
})
const TokenRefresh = Schema.Struct({
access_token: Schema.String,
refresh_token: Schema.optional(Schema.String),
expires_in: Schema.optional(Schema.Number),
})
const DeviceCode = Schema.Struct({
device_code: Schema.String,
user_code: Schema.String,
verification_uri_complete: Schema.String,
expires_in: Schema.Number,
interval: Schema.Number,
})
const DeviceToken = Schema.Struct({
access_token: Schema.optional(Schema.String),
refresh_token: Schema.optional(Schema.String),
expires_in: Schema.optional(Schema.Number),
error: Schema.optional(Schema.String),
error_description: Schema.optional(Schema.String),
})
const User = Schema.Struct({
id: Schema.optional(AccountID),
email: Schema.optional(Schema.String),
})
const ClientId = Schema.Struct({ client_id: Schema.String })
const DeviceTokenRequest = Schema.Struct({
grant_type: Schema.String,
device_code: Schema.String,
client_id: Schema.String,
})
const clientId = "opencode-cli"
const toAccountServiceError = (message: string, cause?: unknown) => new AccountServiceError({ message, cause })
const mapAccountServiceError =
(operation: string, message = "Account service operation failed") =>
<A, E, R>(effect: Effect.Effect<A, E, R>): Effect.Effect<A, AccountServiceError, R> =>
effect.pipe(
Effect.mapError((error) =>
error instanceof AccountServiceError ? error : toAccountServiceError(`${message} (${operation})`, error),
),
)
export class AccountService extends ServiceMap.Service<
AccountService,
{
readonly active: () => Effect.Effect<Option.Option<Account>, AccountError>
readonly list: () => Effect.Effect<Account[], AccountError>
readonly orgsByAccount: () => Effect.Effect<AccountOrgs[], AccountError>
readonly remove: (accountID: AccountID) => Effect.Effect<void, AccountError>
readonly use: (accountID: AccountID, orgID: Option.Option<OrgID>) => Effect.Effect<void, AccountError>
readonly orgs: (accountID: AccountID) => Effect.Effect<Org[], AccountError>
readonly config: (
accountID: AccountID,
orgID: OrgID,
) => Effect.Effect<Option.Option<Record<string, unknown>>, AccountError>
readonly token: (accountID: AccountID) => Effect.Effect<Option.Option<AccessToken>, AccountError>
readonly login: (url: string) => Effect.Effect<Login, AccountError>
readonly poll: (input: Login) => Effect.Effect<PollResult, AccountError>
}
>()("@opencode/Account") {
static readonly layer: Layer.Layer<AccountService, never, AccountRepo | HttpClient.HttpClient> = Layer.effect(
AccountService,
Effect.gen(function* () {
const repo = yield* AccountRepo
const http = yield* HttpClient.HttpClient
const httpRead = withTransientReadRetry(http)
const execute = (operation: string, request: HttpClientRequest.HttpClientRequest) =>
http.execute(request).pipe(mapAccountServiceError(operation, "HTTP request failed"))
const executeRead = (operation: string, request: HttpClientRequest.HttpClientRequest) =>
httpRead.execute(request).pipe(mapAccountServiceError(operation, "HTTP request failed"))
const executeEffect = <E>(operation: string, request: Effect.Effect<HttpClientRequest.HttpClientRequest, E>) =>
request.pipe(
Effect.flatMap((req) => http.execute(req)),
mapAccountServiceError(operation, "HTTP request failed"),
)
const okOrNone = (operation: string, response: HttpClientResponse.HttpClientResponse) =>
HttpClientResponse.filterStatusOk(response).pipe(
Effect.map(Option.some),
Effect.catch((error) =>
HttpClientError.isHttpClientError(error) && error.reason._tag === "StatusCodeError"
? Effect.succeed(Option.none<HttpClientResponse.HttpClientResponse>())
: Effect.fail(error),
),
mapAccountServiceError(operation),
)
const tokenForRow = Effect.fn("AccountService.tokenForRow")(function* (found: AccountRow) {
const now = yield* Clock.currentTimeMillis
if (found.token_expiry && found.token_expiry > now) return Option.some(AccessToken.make(found.access_token))
const response = yield* execute(
"token.refresh",
HttpClientRequest.post(`${found.url}/oauth/token`).pipe(
HttpClientRequest.acceptJson,
HttpClientRequest.bodyUrlParams({
grant_type: "refresh_token",
refresh_token: found.refresh_token,
}),
),
)
const ok = yield* okOrNone("token.refresh", response)
if (Option.isNone(ok)) return Option.none()
const parsed = yield* HttpClientResponse.schemaBodyJson(TokenRefresh)(ok.value).pipe(
mapAccountServiceError("token.refresh", "Failed to decode response"),
)
const expiry = Option.fromNullishOr(parsed.expires_in).pipe(Option.map((e) => now + e * 1000))
yield* repo.persistToken({
accountID: AccountID.make(found.id),
accessToken: parsed.access_token,
refreshToken: parsed.refresh_token ?? found.refresh_token,
expiry,
})
return Option.some(AccessToken.make(parsed.access_token))
})
const resolveAccess = Effect.fn("AccountService.resolveAccess")(function* (accountID: AccountID) {
const maybeAccount = yield* repo.getRow(accountID)
if (Option.isNone(maybeAccount)) return Option.none<{ account: AccountRow; accessToken: AccessToken }>()
const account = maybeAccount.value
const accessToken = yield* tokenForRow(account)
if (Option.isNone(accessToken)) return Option.none<{ account: AccountRow; accessToken: AccessToken }>()
return Option.some({ account, accessToken: accessToken.value })
})
const token = Effect.fn("AccountService.token")((accountID: AccountID) =>
resolveAccess(accountID).pipe(Effect.map(Option.map((r) => r.accessToken))),
)
const orgsByAccount = Effect.fn("AccountService.orgsByAccount")(function* () {
const accounts = yield* repo.list()
return yield* Effect.forEach(
accounts,
(account) => orgs(account.id).pipe(Effect.map((orgs) => ({ account, orgs }))),
{ concurrency: 3 },
)
})
const orgs = Effect.fn("AccountService.orgs")(function* (accountID: AccountID) {
const resolved = yield* resolveAccess(accountID)
if (Option.isNone(resolved)) return []
const { account, accessToken } = resolved.value
const response = yield* executeRead(
"orgs",
HttpClientRequest.get(`${account.url}/api/orgs`).pipe(
HttpClientRequest.acceptJson,
HttpClientRequest.bearerToken(accessToken),
),
)
const ok = yield* okOrNone("orgs", response)
if (Option.isNone(ok)) return []
const orgs = yield* HttpClientResponse.schemaBodyJson(RemoteOrgs)(ok.value).pipe(
mapAccountServiceError("orgs", "Failed to decode response"),
)
return orgs
.filter((org) => org.id !== undefined && org.name !== undefined)
.map((org) => new Org({ id: org.id!, name: org.name! }))
})
const config = Effect.fn("AccountService.config")(function* (accountID: AccountID, orgID: OrgID) {
const resolved = yield* resolveAccess(accountID)
if (Option.isNone(resolved)) return Option.none()
const { account, accessToken } = resolved.value
const response = yield* executeRead(
"config",
HttpClientRequest.get(`${account.url}/api/config`).pipe(
HttpClientRequest.acceptJson,
HttpClientRequest.bearerToken(accessToken),
HttpClientRequest.setHeaders({ "x-org-id": orgID }),
),
)
const ok = yield* okOrNone("config", response)
if (Option.isNone(ok)) return Option.none()
const parsed = yield* HttpClientResponse.schemaBodyJson(RemoteConfig)(ok.value).pipe(
mapAccountServiceError("config", "Failed to decode response"),
)
return Option.some(parsed.config)
})
const login = Effect.fn("AccountService.login")(function* (server: string) {
const response = yield* executeEffect(
"login",
HttpClientRequest.post(`${server}/auth/device/code`).pipe(
HttpClientRequest.acceptJson,
HttpClientRequest.schemaBodyJson(ClientId)({ client_id: clientId }),
),
)
const ok = yield* okOrNone("login", response)
if (Option.isNone(ok)) {
const body = yield* response.text.pipe(Effect.orElseSucceed(() => ""))
return yield* toAccountServiceError(`Failed to initiate device flow: ${body || response.status}`)
}
const parsed = yield* HttpClientResponse.schemaBodyJson(DeviceCode)(ok.value).pipe(
mapAccountServiceError("login", "Failed to decode response"),
)
return new Login({
code: parsed.device_code,
user: parsed.user_code,
url: `${server}${parsed.verification_uri_complete}`,
server,
expiry: parsed.expires_in,
interval: parsed.interval,
})
})
const poll = Effect.fn("AccountService.poll")(function* (input: Login) {
const response = yield* executeEffect(
"poll",
HttpClientRequest.post(`${input.server}/auth/device/token`).pipe(
HttpClientRequest.acceptJson,
HttpClientRequest.schemaBodyJson(DeviceTokenRequest)({
grant_type: "urn:ietf:params:oauth:grant-type:device_code",
device_code: input.code,
client_id: clientId,
}),
),
)
const parsed = yield* HttpClientResponse.schemaBodyJson(DeviceToken)(response).pipe(
mapAccountServiceError("poll", "Failed to decode response"),
)
if (!parsed.access_token) {
if (parsed.error === "authorization_pending") return new PollPending()
if (parsed.error === "slow_down") return new PollSlow()
if (parsed.error === "expired_token") return new PollExpired()
if (parsed.error === "access_denied") return new PollDenied()
return new PollError({ cause: parsed.error })
}
const access = parsed.access_token
const fetchUser = executeRead(
"poll.user",
HttpClientRequest.get(`${input.server}/api/user`).pipe(
HttpClientRequest.acceptJson,
HttpClientRequest.bearerToken(access),
),
).pipe(
Effect.flatMap((r) =>
HttpClientResponse.schemaBodyJson(User)(r).pipe(
mapAccountServiceError("poll.user", "Failed to decode response"),
),
),
)
const fetchOrgs = executeRead(
"poll.orgs",
HttpClientRequest.get(`${input.server}/api/orgs`).pipe(
HttpClientRequest.acceptJson,
HttpClientRequest.bearerToken(access),
),
).pipe(
Effect.flatMap((r) =>
HttpClientResponse.schemaBodyJson(RemoteOrgs)(r).pipe(
mapAccountServiceError("poll.orgs", "Failed to decode response"),
),
),
)
const [user, remoteOrgs] = yield* Effect.all([fetchUser, fetchOrgs], { concurrency: 2 })
const userId = user.id
const userEmail = user.email
if (!userId || !userEmail) {
return new PollError({ cause: "No id or email in response" })
}
const firstOrgID = remoteOrgs.length > 0 ? Option.fromNullishOr(remoteOrgs[0].id) : Option.none()
const now = yield* Clock.currentTimeMillis
const expiry = now + (parsed.expires_in ?? 0) * 1000
const refresh = parsed.refresh_token ?? ""
if (!refresh) {
yield* Effect.logWarning(
"Server did not return a refresh token — session may expire without ability to refresh",
)
}
yield* repo.persistAccount({
id: userId,
email: userEmail,
url: input.server,
accessToken: access,
refreshToken: refresh,
expiry,
orgID: firstOrgID,
})
return new PollSuccess({ email: userEmail })
})
return AccountService.of({
active: repo.active,
list: repo.list,
orgsByAccount,
remove: repo.remove,
use: repo.use,
orgs,
config,
token,
login,
poll,
})
}),
)
static readonly defaultLayer = AccountService.layer.pipe(
Layer.provide(AccountRepo.layer),
Layer.provide(FetchHttpClient.layer),
)
}
+91
View File
@@ -1,5 +1,13 @@
import z from "zod"
import { Global } from "../global"
import { Log } from "../util/log" import { Log } from "../util/log"
import path from "path"
import { Filesystem } from "../util/filesystem"
import { NamedError } from "@opencode-ai/util/error"
import { text } from "node:stream/consumers" import { text } from "node:stream/consumers"
import { Lock } from "../util/lock"
import { PackageRegistry } from "./registry"
import { proxied } from "@/util/proxied"
import { Process } from "../util/process" import { Process } from "../util/process"
export namespace BunProc { export namespace BunProc {
@@ -37,4 +45,87 @@ export namespace BunProc {
export function which() { export function which() {
return process.execPath return process.execPath
} }
export const InstallFailedError = NamedError.create(
"BunInstallFailedError",
z.object({
pkg: z.string(),
version: z.string(),
}),
)
export async function install(pkg: string, version = "latest") {
// Use lock to ensure only one install at a time
using _ = await Lock.write("bun-install")
const mod = path.join(Global.Path.cache, "node_modules", pkg)
const pkgjsonPath = path.join(Global.Path.cache, "package.json")
const parsed = await Filesystem.readJson<{ dependencies: Record<string, string> }>(pkgjsonPath).catch(async () => {
const result = { dependencies: {} as Record<string, string> }
await Filesystem.writeJson(pkgjsonPath, result)
return result
})
if (!parsed.dependencies) parsed.dependencies = {} as Record<string, string>
const dependencies = parsed.dependencies
const modExists = await Filesystem.exists(mod)
const cachedVersion = dependencies[pkg]
if (!modExists || !cachedVersion) {
// continue to install
} else if (version !== "latest" && cachedVersion === version) {
return mod
} else if (version === "latest") {
const isOutdated = await PackageRegistry.isOutdated(pkg, cachedVersion, Global.Path.cache)
if (!isOutdated) return mod
log.info("Cached version is outdated, proceeding with install", { pkg, cachedVersion })
}
// Build command arguments
const args = [
"add",
"--force",
"--exact",
// TODO: get rid of this case (see: https://github.com/oven-sh/bun/issues/19936)
...(proxied() || process.env.CI ? ["--no-cache"] : []),
"--cwd",
Global.Path.cache,
pkg + "@" + version,
]
// Let Bun handle registry resolution:
// - If .npmrc files exist, Bun will use them automatically
// - If no .npmrc files exist, Bun will default to https://registry.npmjs.org
// - No need to pass --registry flag
log.info("installing package using Bun's default registry resolution", {
pkg,
version,
})
await BunProc.run(args, {
cwd: Global.Path.cache,
}).catch((e) => {
throw new InstallFailedError(
{ pkg, version },
{
cause: e,
},
)
})
// Resolve actual version from installed package when using "latest"
// This ensures subsequent starts use the cached version until explicitly updated
let resolvedVersion = version
if (version === "latest") {
const installedPkg = await Filesystem.readJson<{ version?: string }>(path.join(mod, "package.json")).catch(
() => null,
)
if (installedPkg?.version) {
resolvedVersion = installedPkg.version
}
}
parsed.dependencies[pkg] = resolvedVersion
await Filesystem.writeJson(pkgjsonPath, parsed)
return mod
}
} }
+14
View File
@@ -1,3 +1,4 @@
import semver from "semver"
import { text } from "node:stream/consumers" import { text } from "node:stream/consumers"
import { Log } from "../util/log" import { Log } from "../util/log"
import { Process } from "../util/process" import { Process } from "../util/process"
@@ -33,4 +34,17 @@ export namespace PackageRegistry {
if (!value) return null if (!value) return null
return value return value
} }
export async function isOutdated(pkg: string, cachedVersion: string, cwd?: string): Promise<boolean> {
const latestVersion = await info(pkg, "version", cwd)
if (!latestVersion) {
log.warn("Failed to resolve latest version, using cached", { pkg, cachedVersion })
return false
}
const isRange = /[\s^~*xX<>|=]/.test(cachedVersion)
if (isRange) return !semver.satisfies(latestVersion, cachedVersion)
return semver.lt(cachedVersion, latestVersion)
}
} }
-194
View File
@@ -1,194 +0,0 @@
import { cmd } from "./cmd"
import { Duration, Effect, Match, Option } from "effect"
import { UI } from "../ui"
import { runtime } from "@/effect/runtime"
import { AccountID, AccountService, OrgID, PollExpired, type PollResult } from "@/account/service"
import { type AccountError } from "@/account/schema"
import * as Prompt from "../effect/prompt"
import open from "open"
const openBrowser = (url: string) => Effect.promise(() => open(url).catch(() => undefined))
const println = (msg: string) => Effect.sync(() => UI.println(msg))
const loginEffect = Effect.fn("login")(function* (url: string) {
const service = yield* AccountService
yield* Prompt.intro("Log in")
const login = yield* service.login(url)
yield* Prompt.log.info("Go to: " + login.url)
yield* Prompt.log.info("Enter code: " + login.user)
yield* openBrowser(login.url)
const s = Prompt.spinner()
yield* s.start("Waiting for authorization...")
const poll = (wait: number): Effect.Effect<PollResult, AccountError> =>
Effect.gen(function* () {
yield* Effect.sleep(wait)
const result = yield* service.poll(login)
if (result._tag === "PollPending") return yield* poll(wait)
if (result._tag === "PollSlow") return yield* poll(wait + 5000)
return result
})
const result = yield* poll(login.interval * 1000).pipe(
Effect.timeout(Duration.seconds(login.expiry)),
Effect.catchTag("TimeoutError", () => Effect.succeed(new PollExpired())),
)
yield* Match.valueTags(result, {
PollSuccess: (r) =>
Effect.gen(function* () {
yield* s.stop("Logged in as " + r.email)
yield* Prompt.outro("Done")
}),
PollExpired: () => s.stop("Device code expired", 1),
PollDenied: () => s.stop("Authorization denied", 1),
PollError: (r) => s.stop("Error: " + String(r.cause), 1),
PollPending: () => s.stop("Unexpected state", 1),
PollSlow: () => s.stop("Unexpected state", 1),
})
})
const logoutEffect = Effect.fn("logout")(function* (email?: string) {
const service = yield* AccountService
const accounts = yield* service.list()
if (accounts.length === 0) return yield* println("Not logged in")
if (email) {
const match = accounts.find((a) => a.email === email)
if (!match) return yield* println("Account not found: " + email)
yield* service.remove(match.id)
yield* Prompt.outro("Logged out from " + email)
return
}
const active = yield* service.active()
const activeID = Option.map(active, (a) => a.id)
yield* Prompt.intro("Log out")
const opts = accounts.map((a) => {
const isActive = Option.isSome(activeID) && activeID.value === a.id
const server = UI.Style.TEXT_DIM + a.url + UI.Style.TEXT_NORMAL
return {
value: a,
label: isActive ? `${a.email} ${server}` + UI.Style.TEXT_DIM + " (active)" : `${a.email} ${server}`,
}
})
const selected = yield* Prompt.select({ message: "Select account to log out", options: opts })
if (Option.isNone(selected)) return
yield* service.remove(selected.value.id)
yield* Prompt.outro("Logged out from " + selected.value.email)
})
interface OrgChoice {
orgID: OrgID
accountID: AccountID
label: string
}
const switchEffect = Effect.fn("switch")(function* () {
const service = yield* AccountService
const groups = yield* service.orgsByAccount()
if (groups.length === 0) return yield* println("Not logged in")
const active = yield* service.active()
const activeOrgID = Option.flatMap(active, (a) => Option.fromNullishOr(a.active_org_id))
const opts = groups.flatMap((group) =>
group.orgs.map((org) => {
const isActive = Option.isSome(activeOrgID) && activeOrgID.value === org.id
return {
value: { orgID: org.id, accountID: group.account.id, label: org.name },
label: isActive
? `${org.name} (${group.account.email})` + UI.Style.TEXT_DIM + " (active)"
: `${org.name} (${group.account.email})`,
}
}),
)
if (opts.length === 0) return yield* println("No orgs found")
yield* Prompt.intro("Switch org")
const selected = yield* Prompt.select<OrgChoice>({ message: "Select org", options: opts })
if (Option.isNone(selected)) return
const choice = selected.value
yield* service.use(choice.accountID, Option.some(choice.orgID))
yield* Prompt.outro("Switched to " + choice.label)
})
const orgsEffect = Effect.fn("orgs")(function* () {
const service = yield* AccountService
const groups = yield* service.orgsByAccount()
if (groups.length === 0) return yield* println("No accounts found")
if (!groups.some((group) => group.orgs.length > 0)) return yield* println("No orgs found")
const active = yield* service.active()
const activeOrgID = Option.flatMap(active, (a) => Option.fromNullishOr(a.active_org_id))
for (const group of groups) {
for (const org of group.orgs) {
const isActive = Option.isSome(activeOrgID) && activeOrgID.value === org.id
const dot = isActive ? UI.Style.TEXT_SUCCESS + "●" + UI.Style.TEXT_NORMAL : " "
const name = isActive ? UI.Style.TEXT_HIGHLIGHT_BOLD + org.name + UI.Style.TEXT_NORMAL : org.name
const email = UI.Style.TEXT_DIM + group.account.email + UI.Style.TEXT_NORMAL
const id = UI.Style.TEXT_DIM + org.id + UI.Style.TEXT_NORMAL
yield* println(` ${dot} ${name} ${email} ${id}`)
}
}
})
export const LoginCommand = cmd({
command: "login <url>",
describe: false,
builder: (yargs) =>
yargs.positional("url", {
describe: "server URL",
type: "string",
demandOption: true,
}),
async handler(args) {
UI.empty()
await runtime.runPromise(loginEffect(args.url))
},
})
export const LogoutCommand = cmd({
command: "logout [email]",
describe: false,
builder: (yargs) =>
yargs.positional("email", {
describe: "account email to log out from",
type: "string",
}),
async handler(args) {
UI.empty()
await runtime.runPromise(logoutEffect(args.email))
},
})
export const SwitchCommand = cmd({
command: "switch",
describe: false,
async handler() {
UI.empty()
await runtime.runPromise(switchEffect())
},
})
export const OrgsCommand = cmd({
command: "orgs",
describe: false,
async handler() {
UI.empty()
await runtime.runPromise(orgsEffect())
},
})
@@ -13,9 +13,14 @@ import { Instance } from "../../project/instance"
import type { Hooks } from "@opencode-ai/plugin" import type { Hooks } from "@opencode-ai/plugin"
import { Process } from "../../util/process" import { Process } from "../../util/process"
import { text } from "node:stream/consumers" import { text } from "node:stream/consumers"
import { setTimeout as sleep } from "node:timers/promises"
type PluginAuth = NonNullable<Hooks["auth"]> type PluginAuth = NonNullable<Hooks["auth"]>
/**
* Handle plugin-based authentication flow.
* Returns true if auth was handled, false if it should fall through to default handling.
*/
async function handlePluginAuth(plugin: { auth: PluginAuth }, provider: string, methodName?: string): Promise<boolean> { async function handlePluginAuth(plugin: { auth: PluginAuth }, provider: string, methodName?: string): Promise<boolean> {
let index = 0 let index = 0
if (methodName) { if (methodName) {
@@ -28,7 +33,7 @@ async function handlePluginAuth(plugin: { auth: PluginAuth }, provider: string,
} }
index = match index = match
} else if (plugin.auth.methods.length > 1) { } else if (plugin.auth.methods.length > 1) {
const method = await prompts.select({ const selected = await prompts.select({
message: "Login method", message: "Login method",
options: [ options: [
...plugin.auth.methods.map((x, index) => ({ ...plugin.auth.methods.map((x, index) => ({
@@ -37,12 +42,13 @@ async function handlePluginAuth(plugin: { auth: PluginAuth }, provider: string,
})), })),
], ],
}) })
if (prompts.isCancel(method)) throw new UI.CancelledError() if (prompts.isCancel(selected)) throw new UI.CancelledError()
index = parseInt(method) index = parseInt(selected)
} }
const method = plugin.auth.methods[index] const method = plugin.auth.methods[index]
await new Promise((r) => setTimeout(r, 10)) // Handle prompts for all auth types
await sleep(10)
const inputs: Record<string, string> = {} const inputs: Record<string, string> = {}
if (method.prompts) { if (method.prompts) {
for (const prompt of method.prompts) { for (const prompt of method.prompts) {
@@ -165,6 +171,11 @@ async function handlePluginAuth(plugin: { auth: PluginAuth }, provider: string,
return false return false
} }
/**
* Build a deduplicated list of plugin-registered auth providers that are not
* already present in models.dev, respecting enabled/disabled provider lists.
* Pure function with no side effects; safe to test without mocking.
*/
export function resolvePluginProviders(input: { export function resolvePluginProviders(input: {
hooks: Hooks[] hooks: Hooks[]
existingProviders: Record<string, unknown> existingProviders: Record<string, unknown>
@@ -192,20 +203,19 @@ export function resolvePluginProviders(input: {
return result return result
} }
export const ProvidersCommand = cmd({ export const AuthCommand = cmd({
command: "providers", command: "auth",
aliases: ["auth"], describe: "manage credentials",
describe: "manage AI providers and credentials",
builder: (yargs) => builder: (yargs) =>
yargs.command(ProvidersListCommand).command(ProvidersLoginCommand).command(ProvidersLogoutCommand).demandCommand(), yargs.command(AuthLoginCommand).command(AuthLogoutCommand).command(AuthListCommand).demandCommand(),
async handler() {}, async handler() {},
}) })
export const ProvidersListCommand = cmd({ export const AuthListCommand = cmd({
command: "list", command: "list",
aliases: ["ls"], aliases: ["ls"],
describe: "list providers and credentials", describe: "list providers",
async handler(_args) { async handler() {
UI.empty() UI.empty()
const authPath = path.join(Global.Path.data, "auth.json") const authPath = path.join(Global.Path.data, "auth.json")
const homedir = os.homedir() const homedir = os.homedir()
@@ -221,6 +231,7 @@ export const ProvidersListCommand = cmd({
prompts.outro(`${results.length} credentials`) prompts.outro(`${results.length} credentials`)
// Environment variables section
const activeEnvVars: Array<{ provider: string; envVar: string }> = [] const activeEnvVars: Array<{ provider: string; envVar: string }> = []
for (const [providerID, provider] of Object.entries(database)) { for (const [providerID, provider] of Object.entries(database)) {
@@ -247,7 +258,7 @@ export const ProvidersListCommand = cmd({
}, },
}) })
export const ProvidersLoginCommand = cmd({ export const AuthLoginCommand = cmd({
command: "login [url]", command: "login [url]",
describe: "log in to a provider", describe: "log in to a provider",
builder: (yargs) => builder: (yargs) =>
@@ -345,7 +356,7 @@ export const ProvidersLoginCommand = cmd({
value: x.id, value: x.id,
hint: { hint: {
opencode: "recommended", opencode: "recommended",
anthropic: "API key", anthropic: "Claude Max or API key",
openai: "ChatGPT Plus/Pro or API key", openai: "ChatGPT Plus/Pro or API key",
}[x.id], }[x.id],
})), })),
@@ -398,6 +409,7 @@ export const ProvidersLoginCommand = cmd({
if (prompts.isCancel(custom)) throw new UI.CancelledError() if (prompts.isCancel(custom)) throw new UI.CancelledError()
provider = custom.replace(/^@ai-sdk\//, "") provider = custom.replace(/^@ai-sdk\//, "")
// Check if a plugin provides auth for this custom provider
const customPlugin = await Plugin.list().then((x) => x.findLast((x) => x.auth?.provider === provider)) const customPlugin = await Plugin.list().then((x) => x.findLast((x) => x.auth?.provider === provider))
if (customPlugin && customPlugin.auth) { if (customPlugin && customPlugin.auth) {
const handled = await handlePluginAuth({ auth: customPlugin.auth }, provider, args.method) const handled = await handlePluginAuth({ auth: customPlugin.auth }, provider, args.method)
@@ -449,10 +461,10 @@ export const ProvidersLoginCommand = cmd({
}, },
}) })
export const ProvidersLogoutCommand = cmd({ export const AuthLogoutCommand = cmd({
command: "logout", command: "logout",
describe: "log out from a configured provider", describe: "log out from a configured provider",
async handler(_args) { async handler() {
UI.empty() UI.empty()
const credentials = await Auth.all().then((x) => Object.entries(x)) const credentials = await Auth.all().then((x) => Object.entries(x))
prompts.intro("Remove credential") prompts.intro("Remove credential")
+3 -24
View File
@@ -10,7 +10,7 @@ import { ShareNext } from "../../share/share-next"
import { EOL } from "os" import { EOL } from "os"
import { Filesystem } from "../../util/filesystem" import { Filesystem } from "../../util/filesystem"
/** Discriminated union returned by the ShareNext API (GET /api/shares/:id/data) */ /** Discriminated union returned by the ShareNext API (GET /api/share/:id/data) */
export type ShareData = export type ShareData =
| { type: "session"; data: SDKSession } | { type: "session"; data: SDKSession }
| { type: "message"; data: Message } | { type: "message"; data: Message }
@@ -24,14 +24,6 @@ export function parseShareUrl(url: string): string | null {
return match ? match[1] : null return match ? match[1] : null
} }
export function shouldAttachShareAuthHeaders(shareUrl: string, accountBaseUrl: string): boolean {
try {
return new URL(shareUrl).origin === new URL(accountBaseUrl).origin
} catch {
return false
}
}
/** /**
* Transform ShareNext API response (flat array) into the nested structure for local file storage. * Transform ShareNext API response (flat array) into the nested structure for local file storage.
* *
@@ -105,21 +97,8 @@ export const ImportCommand = cmd({
return return
} }
const parsed = new URL(args.file) const baseUrl = await ShareNext.url()
const baseUrl = parsed.origin const response = await fetch(`${baseUrl}/api/share/${slug}/data`)
const req = await ShareNext.request()
const headers = shouldAttachShareAuthHeaders(args.file, req.baseUrl) ? req.headers : {}
const dataPath = req.api.data(slug)
let response = await fetch(`${baseUrl}${dataPath}`, {
headers,
})
if (!response.ok && dataPath !== `/api/share/${slug}/data`) {
response = await fetch(`${baseUrl}/api/share/${slug}/data`, {
headers,
})
}
if (!response.ok) { if (!response.ok) {
process.stdout.write(`Failed to fetch share data: ${response.statusText}`) process.stdout.write(`Failed to fetch share data: ${response.statusText}`)
@@ -36,7 +36,7 @@ export function createDialogProviderOptions() {
value: provider.id, value: provider.id,
description: { description: {
opencode: "(Recommended)", opencode: "(Recommended)",
anthropic: "(API key)", anthropic: "(Claude Max or API key)",
openai: "(ChatGPT Plus/Pro or API key)", openai: "(ChatGPT Plus/Pro or API key)",
"opencode-go": "Low cost subscription for everyone", "opencode-go": "Low cost subscription for everyone",
}[provider.id], }[provider.id],
@@ -9,7 +9,6 @@ import { useToast } from "../ui/toast"
import { useKeybind } from "../context/keybind" import { useKeybind } from "../context/keybind"
import { DialogSessionList } from "./workspace/dialog-session-list" import { DialogSessionList } from "./workspace/dialog-session-list"
import { createOpencodeClient } from "@opencode-ai/sdk/v2" import { createOpencodeClient } from "@opencode-ai/sdk/v2"
import { setTimeout as sleep } from "node:timers/promises"
async function openWorkspace(input: { async function openWorkspace(input: {
dialog: ReturnType<typeof useDialog> dialog: ReturnType<typeof useDialog>
@@ -57,7 +56,7 @@ async function openWorkspace(input: {
return return
} }
if (result.response.status >= 500 && result.response.status < 600) { if (result.response.status >= 500 && result.response.status < 600) {
await sleep(1000) await Bun.sleep(1000)
continue continue
} }
if (!result.data) { if (!result.data) {
@@ -383,12 +383,7 @@ export function Session() {
sessionID: route.sessionID, sessionID: route.sessionID,
}) })
.then((res) => copy(res.data!.share!.url)) .then((res) => copy(res.data!.share!.url))
.catch((error) => { .catch(() => toast.show({ message: "Failed to share session", variant: "error" }))
toast.show({
message: error instanceof Error ? error.message : "Failed to share session",
variant: "error",
})
})
dialog.clear() dialog.clear()
}, },
}, },
@@ -491,12 +486,7 @@ export function Session() {
sessionID: route.sessionID, sessionID: route.sessionID,
}) })
.then(() => toast.show({ message: "Session unshared successfully", variant: "success" })) .then(() => toast.show({ message: "Session unshared successfully", variant: "success" }))
.catch((error) => { .catch(() => toast.show({ message: "Failed to unshare session", variant: "error" }))
toast.show({
message: error instanceof Error ? error.message : "Failed to unshare session",
variant: "error",
})
})
dialog.clear() dialog.clear()
}, },
}, },
@@ -907,12 +897,12 @@ export function Session() {
const filename = options.filename.trim() const filename = options.filename.trim()
const filepath = path.join(exportDir, filename) const filepath = path.join(exportDir, filename)
await Filesystem.write(filepath, transcript) await Bun.write(filepath, transcript)
// Open with EDITOR if available // Open with EDITOR if available
const result = await Editor.open({ value: transcript, renderer }) const result = await Editor.open({ value: transcript, renderer })
if (result !== undefined) { if (result !== undefined) {
await Filesystem.write(filepath, result) await Bun.write(filepath, result)
} }
toast.show({ message: `Session exported to ${filename}`, variant: "success" }) toast.show({ message: `Session exported to ${filename}`, variant: "success" })
@@ -1,25 +0,0 @@
import * as prompts from "@clack/prompts"
import { Effect, Option } from "effect"
export const intro = (msg: string) => Effect.sync(() => prompts.intro(msg))
export const outro = (msg: string) => Effect.sync(() => prompts.outro(msg))
export const log = {
info: (msg: string) => Effect.sync(() => prompts.log.info(msg)),
}
export const select = <Value>(opts: Parameters<typeof prompts.select<Value>>[0]) =>
Effect.tryPromise(() => prompts.select(opts)).pipe(
Effect.map((result) => {
if (prompts.isCancel(result)) return Option.none<Value>()
return Option.some(result)
}),
)
export const spinner = () => {
const s = prompts.spinner()
return {
start: (msg: string) => Effect.sync(() => s.start(msg)),
stop: (msg: string, code?: number) => Effect.sync(() => s.stop(msg, code)),
}
}
+59 -41
View File
@@ -1,6 +1,6 @@
import { Log } from "../util/log" import { Log } from "../util/log"
import path from "path" import path from "path"
import { pathToFileURL } from "url" import { pathToFileURL, fileURLToPath } from "url"
import { createRequire } from "module" import { createRequire } from "module"
import os from "os" import os from "os"
import z from "zod" import z from "zod"
@@ -12,7 +12,6 @@ import { lazy } from "../util/lazy"
import { NamedError } from "@opencode-ai/util/error" import { NamedError } from "@opencode-ai/util/error"
import { Flag } from "../flag/flag" import { Flag } from "../flag/flag"
import { Auth } from "../auth" import { Auth } from "../auth"
import { Env } from "../env"
import { import {
type ParseError as JsoncParseError, type ParseError as JsoncParseError,
applyEdits, applyEdits,
@@ -22,6 +21,7 @@ import {
} from "jsonc-parser" } from "jsonc-parser"
import { Instance } from "../project/instance" import { Instance } from "../project/instance"
import { LSPServer } from "../lsp/server" import { LSPServer } from "../lsp/server"
import { BunProc } from "@/bun"
import { Installation } from "@/installation" import { Installation } from "@/installation"
import { ConfigMarkdown } from "./markdown" import { ConfigMarkdown } from "./markdown"
import { constants, existsSync } from "fs" import { constants, existsSync } from "fs"
@@ -29,11 +29,12 @@ import { Bus } from "@/bus"
import { GlobalBus } from "@/bus/global" import { GlobalBus } from "@/bus/global"
import { Event } from "../server/event" import { Event } from "../server/event"
import { Glob } from "../util/glob" import { Glob } from "../util/glob"
import { PackageRegistry } from "@/bun/registry"
import { proxied } from "@/util/proxied"
import { iife } from "@/util/iife" import { iife } from "@/util/iife"
import { Account } from "@/account" import { Control } from "@/control"
import { ConfigPaths } from "./paths" import { ConfigPaths } from "./paths"
import { Filesystem } from "@/util/filesystem" import { Filesystem } from "@/util/filesystem"
import { Npm } from "@/npm"
export namespace Config { export namespace Config {
const ModelId = z.string().meta({ $ref: "https://models.dev/model-schema.json#/$defs/Model" }) const ModelId = z.string().meta({ $ref: "https://models.dev/model-schema.json#/$defs/Model" })
@@ -107,6 +108,10 @@ export namespace Config {
} }
} }
const token = await Control.token()
if (token) {
}
// Global user config overrides remote config. // Global user config overrides remote config.
result = mergeConfigConcatArrays(result, await global()) result = mergeConfigConcatArrays(result, await global())
@@ -150,7 +155,8 @@ export namespace Config {
deps.push( deps.push(
iife(async () => { iife(async () => {
await installDependencies(dir) const shouldInstall = await needsInstall(dir)
if (shouldInstall) await installDependencies(dir)
}), }),
) )
@@ -172,32 +178,6 @@ export namespace Config {
log.debug("loaded custom config from OPENCODE_CONFIG_CONTENT") log.debug("loaded custom config from OPENCODE_CONFIG_CONTENT")
} }
const active = Account.active()
if (active?.active_org_id) {
try {
const [config, token] = await Promise.all([
Account.config(active.id, active.active_org_id),
Account.token(active.id),
])
if (token) {
process.env["OPENCODE_CONSOLE_TOKEN"] = token
Env.set("OPENCODE_CONSOLE_TOKEN", token)
}
if (config) {
result = mergeConfigConcatArrays(
result,
await load(JSON.stringify(config), {
dir: path.dirname(`${active.url}/api/config`),
source: `${active.url}/api/config`,
}),
)
}
} catch (err: any) {
log.debug("failed to fetch remote account config", { error: err?.message ?? err })
}
}
// Load managed config files last (highest priority) - enterprise admin-controlled // Load managed config files last (highest priority) - enterprise admin-controlled
// Kept separate from directories array to avoid write operations when installing plugins // Kept separate from directories array to avoid write operations when installing plugins
// which would fail on system directories requiring elevated permissions // which would fail on system directories requiring elevated permissions
@@ -266,10 +246,6 @@ export namespace Config {
} }
export async function installDependencies(dir: string) { export async function installDependencies(dir: string) {
if (!(await isWritable(dir))) {
log.info("config dir is not writable, skipping dependency install", { dir })
return
}
const pkg = path.join(dir, "package.json") const pkg = path.join(dir, "package.json")
const targetVersion = Installation.isLocal() ? "*" : Installation.VERSION const targetVersion = Installation.isLocal() ? "*" : Installation.VERSION
@@ -283,15 +259,22 @@ export namespace Config {
await Filesystem.writeJson(pkg, json) await Filesystem.writeJson(pkg, json)
const gitignore = path.join(dir, ".gitignore") const gitignore = path.join(dir, ".gitignore")
if (!(await Filesystem.exists(gitignore))) const hasGitIgnore = await Filesystem.exists(gitignore)
await Filesystem.write( if (!hasGitIgnore)
gitignore, await Filesystem.write(gitignore, ["node_modules", "package.json", "bun.lock", ".gitignore"].join("\n"))
["node_modules", "plans", "package.json", "bun.lock", ".gitignore", "package-lock.json"].join("\n"),
)
// Install any additional dependencies defined in the package.json // Install any additional dependencies defined in the package.json
// This allows local plugins and custom tools to use external packages // This allows local plugins and custom tools to use external packages
await Npm.install(dir) await BunProc.run(
[
"install",
// TODO: get rid of this case (see: https://github.com/oven-sh/bun/issues/19936)
...(proxied() || process.env.CI ? ["--no-cache"] : []),
],
{ cwd: dir },
).catch((err) => {
log.warn("failed to install dependencies", { dir, error: err })
})
} }
async function isWritable(dir: string) { async function isWritable(dir: string) {
@@ -303,6 +286,41 @@ export namespace Config {
} }
} }
export async function needsInstall(dir: string) {
// Some config dirs may be read-only.
// Installing deps there will fail; skip installation in that case.
const writable = await isWritable(dir)
if (!writable) {
log.debug("config dir is not writable, skipping dependency install", { dir })
return false
}
const nodeModules = path.join(dir, "node_modules")
if (!existsSync(nodeModules)) return true
const pkg = path.join(dir, "package.json")
const pkgExists = await Filesystem.exists(pkg)
if (!pkgExists) return true
const parsed = await Filesystem.readJson<{ dependencies?: Record<string, string> }>(pkg).catch(() => null)
const dependencies = parsed?.dependencies ?? {}
const depVersion = dependencies["@opencode-ai/plugin"]
if (!depVersion) return true
const targetVersion = Installation.isLocal() ? "latest" : Installation.VERSION
if (targetVersion === "latest") {
const isOutdated = await PackageRegistry.isOutdated("@opencode-ai/plugin", depVersion, dir)
if (!isOutdated) return false
log.info("Cached version is outdated, proceeding with install", {
pkg: "@opencode-ai/plugin",
cachedVersion: depVersion,
})
return true
}
if (depVersion === targetVersion) return false
return true
}
function rel(item: string, patterns: string[]) { function rel(item: string, patterns: string[]) {
const normalizedItem = item.replaceAll("\\", "/") const normalizedItem = item.replaceAll("\\", "/")
for (const pattern of patterns) { for (const pattern of patterns) {
@@ -1,4 +1,3 @@
import { createAdaptorServer } from "@hono/node-server"
import { Hono } from "hono" import { Hono } from "hono"
import { Instance } from "../../project/instance" import { Instance } from "../../project/instance"
import { InstanceBootstrap } from "../../project/bootstrap" import { InstanceBootstrap } from "../../project/bootstrap"
@@ -56,24 +55,10 @@ export namespace WorkspaceServer {
} }
export function Listen(opts: { hostname: string; port: number }) { export function Listen(opts: { hostname: string; port: number }) {
const server = createAdaptorServer({ return Bun.serve({
fetch: App().fetch,
})
server.listen(opts.port, opts.hostname)
return {
hostname: opts.hostname, hostname: opts.hostname,
port: opts.port, port: opts.port,
stop() { fetch: App().fetch,
return new Promise<void>((resolve, reject) => {
server.close((err) => {
if (err) {
reject(err)
return
}
resolve()
}) })
})
},
}
} }
} }
@@ -1,5 +1,5 @@
import { sqliteTable, text } from "drizzle-orm/sqlite-core" import { sqliteTable, text } from "drizzle-orm/sqlite-core"
import { ProjectTable } from "../project/project.sql" import { ProjectTable } from "@/project/project.sql"
export const WorkspaceTable = sqliteTable("workspace", { export const WorkspaceTable = sqliteTable("workspace", {
id: text().primaryKey(), id: text().primaryKey(),
@@ -1,5 +1,4 @@
import z from "zod" import z from "zod"
import { setTimeout as sleep } from "node:timers/promises"
import { Identifier } from "@/id/id" import { Identifier } from "@/id/id"
import { fn } from "@/util/fn" import { fn } from "@/util/fn"
import { Database, eq } from "@/storage/db" import { Database, eq } from "@/storage/db"
@@ -117,7 +116,7 @@ export namespace Workspace {
const adaptor = await getAdaptor(space.type) const adaptor = await getAdaptor(space.type)
const res = await adaptor.fetch(space, "/event", { method: "GET", signal: stop }).catch(() => undefined) const res = await adaptor.fetch(space, "/event", { method: "GET", signal: stop }).catch(() => undefined)
if (!res || !res.ok || !res.body) { if (!res || !res.ok || !res.body) {
await sleep(1000) await Bun.sleep(1000)
continue continue
} }
await parseSSE(res.body, stop, (event) => { await parseSSE(res.body, stop, (event) => {
@@ -127,7 +126,7 @@ export namespace Workspace {
}) })
}) })
// Wait 250ms and retry if SSE connection fails // Wait 250ms and retry if SSE connection fails
await sleep(250) await Bun.sleep(250)
} }
} }
@@ -0,0 +1,22 @@
import { sqliteTable, text, integer, primaryKey, uniqueIndex } from "drizzle-orm/sqlite-core"
import { eq } from "drizzle-orm"
import { Timestamps } from "@/storage/schema.sql"
export const ControlAccountTable = sqliteTable(
"control_account",
{
email: text().notNull(),
url: text().notNull(),
access_token: text().notNull(),
refresh_token: text().notNull(),
token_expiry: integer(),
active: integer({ mode: "boolean" })
.notNull()
.$default(() => false),
...Timestamps,
},
(table) => [
primaryKey({ columns: [table.email, table.url] }),
// uniqueIndex("control_account_active_idx").on(table.email).where(eq(table.active, true)),
],
)
+67
View File
@@ -0,0 +1,67 @@
import { eq, and } from "drizzle-orm"
import { Database } from "@/storage/db"
import { ControlAccountTable } from "./control.sql"
import z from "zod"
export * from "./control.sql"
export namespace Control {
export const Account = z.object({
email: z.string(),
url: z.string(),
})
export type Account = z.infer<typeof Account>
function fromRow(row: (typeof ControlAccountTable)["$inferSelect"]): Account {
return {
email: row.email,
url: row.url,
}
}
export function account(): Account | undefined {
const row = Database.use((db) =>
db.select().from(ControlAccountTable).where(eq(ControlAccountTable.active, true)).get(),
)
return row ? fromRow(row) : undefined
}
export async function token(): Promise<string | undefined> {
const row = Database.use((db) =>
db.select().from(ControlAccountTable).where(eq(ControlAccountTable.active, true)).get(),
)
if (!row) return undefined
if (row.token_expiry && row.token_expiry > Date.now()) return row.access_token
const res = await fetch(`${row.url}/oauth/token`, {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: new URLSearchParams({
grant_type: "refresh_token",
refresh_token: row.refresh_token,
}).toString(),
})
if (!res.ok) return
const json = (await res.json()) as {
access_token: string
refresh_token?: string
expires_in?: number
}
Database.use((db) =>
db
.update(ControlAccountTable)
.set({
access_token: json.access_token,
refresh_token: json.refresh_token ?? row.refresh_token,
token_expiry: json.expires_in ? Date.now() + json.expires_in * 1000 : undefined,
})
.where(and(eq(ControlAccountTable.email, row.email), eq(ControlAccountTable.url, row.url)))
.run(),
)
return json.access_token
}
}
-4
View File
@@ -1,4 +0,0 @@
import { ManagedRuntime } from "effect"
import { AccountService } from "@/account/service"
export const runtime = ManagedRuntime.make(AccountService.defaultLayer)
+4 -2
View File
@@ -11,7 +11,6 @@ import { Ripgrep } from "./ripgrep"
import fuzzysort from "fuzzysort" import fuzzysort from "fuzzysort"
import { Global } from "../global" import { Global } from "../global"
import { git } from "@/util/git" import { git } from "@/util/git"
import { Protected } from "./protected"
export namespace File { export namespace File {
const log = Log.create({ service: "file" }) const log = Log.create({ service: "file" })
@@ -346,7 +345,10 @@ export namespace File {
if (isGlobalHome) { if (isGlobalHome) {
const dirs = new Set<string>() const dirs = new Set<string>()
const ignore = Protected.names() const ignore = new Set<string>()
if (process.platform === "darwin") ignore.add("Library")
if (process.platform === "win32") ignore.add("AppData")
const ignoreNested = new Set(["node_modules", "dist", "build", "target", "vendor"]) const ignoreNested = new Set(["node_modules", "dist", "build", "target", "vendor"])
const shouldIgnore = (name: string) => name.startsWith(".") || ignore.has(name) const shouldIgnore = (name: string) => name.startsWith(".") || ignore.has(name)
-59
View File
@@ -1,59 +0,0 @@
import path from "path"
import os from "os"
const home = os.homedir()
// macOS directories that trigger TCC (Transparency, Consent, and Control)
// permission prompts when accessed by a non-sandboxed process.
const DARWIN_HOME = [
// Media
"Music",
"Pictures",
"Movies",
// User-managed folders synced via iCloud / subject to TCC
"Downloads",
"Desktop",
"Documents",
// Other system-managed
"Public",
"Applications",
"Library",
]
const DARWIN_LIBRARY = [
"Application Support/AddressBook",
"Calendars",
"Mail",
"Messages",
"Safari",
"Cookies",
"Application Support/com.apple.TCC",
"PersonalizationPortrait",
"Metadata/CoreSpotlight",
"Suggestions",
]
const DARWIN_ROOT = ["/.DocumentRevisions-V100", "/.Spotlight-V100", "/.Trashes", "/.fseventsd"]
const WIN32_HOME = ["AppData", "Downloads", "Desktop", "Documents", "Pictures", "Music", "Videos", "OneDrive"]
export namespace Protected {
/** Directory basenames to skip when scanning the home directory. */
export function names(): ReadonlySet<string> {
if (process.platform === "darwin") return new Set(DARWIN_HOME)
if (process.platform === "win32") return new Set(WIN32_HOME)
return new Set()
}
/** Absolute paths that should never be watched, stated, or scanned. */
export function paths(): string[] {
if (process.platform === "darwin")
return [
...DARWIN_HOME.map((n) => path.join(home, n)),
...DARWIN_LIBRARY.map((n) => path.join(home, "Library", n)),
...DARWIN_ROOT,
]
if (process.platform === "win32") return WIN32_HOME.map((n) => path.join(home, n))
return []
}
}
+1 -2
View File
@@ -14,7 +14,6 @@ import type ParcelWatcher from "@parcel/watcher"
import { Flag } from "@/flag/flag" import { Flag } from "@/flag/flag"
import { readdir } from "fs/promises" import { readdir } from "fs/promises"
import { git } from "@/util/git" import { git } from "@/util/git"
import { Protected } from "./protected"
const SUBSCRIBE_TIMEOUT_MS = 10_000 const SUBSCRIBE_TIMEOUT_MS = 10_000
@@ -77,7 +76,7 @@ export namespace FileWatcher {
if (Flag.OPENCODE_EXPERIMENTAL_FILEWATCHER) { if (Flag.OPENCODE_EXPERIMENTAL_FILEWATCHER) {
const pending = w.subscribe(Instance.directory, subscribe, { const pending = w.subscribe(Instance.directory, subscribe, {
ignore: [...FileIgnore.PATTERNS, ...cfgIgnores, ...Protected.paths()], ignore: [...FileIgnore.PATTERNS, ...cfgIgnores],
backend, backend,
}) })
const sub = await withTimeout(pending, SUBSCRIBE_TIMEOUT_MS).catch((err) => { const sub = await withTimeout(pending, SUBSCRIBE_TIMEOUT_MS).catch((err) => {
+65 -83
View File
@@ -1,40 +1,40 @@
import { text } from "node:stream/consumers" import { text } from "node:stream/consumers"
import { BunProc } from "../bun"
import { Instance } from "../project/instance" import { Instance } from "../project/instance"
import { Filesystem } from "../util/filesystem" import { Filesystem } from "../util/filesystem"
import { Process } from "../util/process" import { Process } from "../util/process"
import { which } from "../util/which" import { which } from "../util/which"
import { Flag } from "@/flag/flag" import { Flag } from "@/flag/flag"
import { Npm } from "@/npm"
export interface Info { export interface Info {
name: string name: string
command: string[]
environment?: Record<string, string> environment?: Record<string, string>
extensions: string[] extensions: string[]
enabled(): Promise<string[] | false> enabled(): Promise<boolean>
} }
export const gofmt: Info = { export const gofmt: Info = {
name: "gofmt", name: "gofmt",
command: ["gofmt", "-w", "$FILE"],
extensions: [".go"], extensions: [".go"],
async enabled() { async enabled() {
const p = which("gofmt") return which("gofmt") !== null
if (p === null) return false
return [p, "-w", "$FILE"]
}, },
} }
export const mix: Info = { export const mix: Info = {
name: "mix", name: "mix",
command: ["mix", "format", "$FILE"],
extensions: [".ex", ".exs", ".eex", ".heex", ".leex", ".neex", ".sface"], extensions: [".ex", ".exs", ".eex", ".heex", ".leex", ".neex", ".sface"],
async enabled() { async enabled() {
const p = which("mix") return which("mix") !== null
if (p === null) return false
return [p, "format", "$FILE"]
}, },
} }
export const prettier: Info = { export const prettier: Info = {
name: "prettier", name: "prettier",
command: [BunProc.which(), "x", "prettier", "--write", "$FILE"],
environment: { environment: {
BUN_BE_BUN: "1", BUN_BE_BUN: "1",
}, },
@@ -73,9 +73,8 @@ export const prettier: Info = {
dependencies?: Record<string, string> dependencies?: Record<string, string>
devDependencies?: Record<string, string> devDependencies?: Record<string, string>
}>(item) }>(item)
if (json.dependencies?.prettier || json.devDependencies?.prettier) { if (json.dependencies?.prettier) return true
return [await Npm.which("prettier"), "--write", "$FILE"] if (json.devDependencies?.prettier) return true
}
} }
return false return false
}, },
@@ -83,6 +82,7 @@ export const prettier: Info = {
export const oxfmt: Info = { export const oxfmt: Info = {
name: "oxfmt", name: "oxfmt",
command: [BunProc.which(), "x", "oxfmt", "$FILE"],
environment: { environment: {
BUN_BE_BUN: "1", BUN_BE_BUN: "1",
}, },
@@ -95,9 +95,8 @@ export const oxfmt: Info = {
dependencies?: Record<string, string> dependencies?: Record<string, string>
devDependencies?: Record<string, string> devDependencies?: Record<string, string>
}>(item) }>(item)
if (json.dependencies?.oxfmt || json.devDependencies?.oxfmt) { if (json.dependencies?.oxfmt) return true
return [await Npm.which("oxfmt"), "$FILE"] if (json.devDependencies?.oxfmt) return true
}
} }
return false return false
}, },
@@ -105,6 +104,7 @@ export const oxfmt: Info = {
export const biome: Info = { export const biome: Info = {
name: "biome", name: "biome",
command: [BunProc.which(), "x", "@biomejs/biome", "check", "--write", "$FILE"],
environment: { environment: {
BUN_BE_BUN: "1", BUN_BE_BUN: "1",
}, },
@@ -141,7 +141,7 @@ export const biome: Info = {
for (const config of configs) { for (const config of configs) {
const found = await Filesystem.findUp(config, Instance.directory, Instance.worktree) const found = await Filesystem.findUp(config, Instance.directory, Instance.worktree)
if (found.length > 0) { if (found.length > 0) {
return [await Npm.which("@biomejs/biome"), "check", "--write", "$FILE"] return true
} }
} }
return false return false
@@ -150,49 +150,47 @@ export const biome: Info = {
export const zig: Info = { export const zig: Info = {
name: "zig", name: "zig",
command: ["zig", "fmt", "$FILE"],
extensions: [".zig", ".zon"], extensions: [".zig", ".zon"],
async enabled() { async enabled() {
const p = which("zig") return which("zig") !== null
if (p === null) return false
return [p, "fmt", "$FILE"]
}, },
} }
export const clang: Info = { export const clang: Info = {
name: "clang-format", name: "clang-format",
command: ["clang-format", "-i", "$FILE"],
extensions: [".c", ".cc", ".cpp", ".cxx", ".c++", ".h", ".hh", ".hpp", ".hxx", ".h++", ".ino", ".C", ".H"], extensions: [".c", ".cc", ".cpp", ".cxx", ".c++", ".h", ".hh", ".hpp", ".hxx", ".h++", ".ino", ".C", ".H"],
async enabled() { async enabled() {
const items = await Filesystem.findUp(".clang-format", Instance.directory, Instance.worktree) const items = await Filesystem.findUp(".clang-format", Instance.directory, Instance.worktree)
if (items.length === 0) return false return items.length > 0
return ["clang-format", "-i", "$FILE"]
}, },
} }
export const ktlint: Info = { export const ktlint: Info = {
name: "ktlint", name: "ktlint",
command: ["ktlint", "-F", "$FILE"],
extensions: [".kt", ".kts"], extensions: [".kt", ".kts"],
async enabled() { async enabled() {
const p = which("ktlint") return which("ktlint") !== null
if (p === null) return false
return [p, "-F", "$FILE"]
}, },
} }
export const ruff: Info = { export const ruff: Info = {
name: "ruff", name: "ruff",
command: ["ruff", "format", "$FILE"],
extensions: [".py", ".pyi"], extensions: [".py", ".pyi"],
async enabled() { async enabled() {
const p = which("ruff") if (!which("ruff")) return false
if (p === null) return false
const configs = ["pyproject.toml", "ruff.toml", ".ruff.toml"] const configs = ["pyproject.toml", "ruff.toml", ".ruff.toml"]
for (const config of configs) { for (const config of configs) {
const found = await Filesystem.findUp(config, Instance.directory, Instance.worktree) const found = await Filesystem.findUp(config, Instance.directory, Instance.worktree)
if (found.length > 0) { if (found.length > 0) {
if (config === "pyproject.toml") { if (config === "pyproject.toml") {
const content = await Filesystem.readText(found[0]) const content = await Filesystem.readText(found[0])
if (content.includes("[tool.ruff]")) return [p, "format", "$FILE"] if (content.includes("[tool.ruff]")) return true
} else { } else {
return [p, "format", "$FILE"] return true
} }
} }
} }
@@ -201,7 +199,7 @@ export const ruff: Info = {
const found = await Filesystem.findUp(dep, Instance.directory, Instance.worktree) const found = await Filesystem.findUp(dep, Instance.directory, Instance.worktree)
if (found.length > 0) { if (found.length > 0) {
const content = await Filesystem.readText(found[0]) const content = await Filesystem.readText(found[0])
if (content.includes("ruff")) return [p, "format", "$FILE"] if (content.includes("ruff")) return true
} }
} }
return false return false
@@ -210,13 +208,14 @@ export const ruff: Info = {
export const rlang: Info = { export const rlang: Info = {
name: "air", name: "air",
command: ["air", "format", "$FILE"],
extensions: [".R"], extensions: [".R"],
async enabled() { async enabled() {
const airPath = which("air") const airPath = which("air")
if (airPath == null) return false if (airPath == null) return false
try { try {
const proc = Process.spawn([airPath, "--help"], { const proc = Process.spawn(["air", "--help"], {
stdout: "pipe", stdout: "pipe",
stderr: "pipe", stderr: "pipe",
}) })
@@ -228,10 +227,7 @@ export const rlang: Info = {
const firstLine = output.split("\n")[0] const firstLine = output.split("\n")[0]
const hasR = firstLine.includes("R language") const hasR = firstLine.includes("R language")
const hasFormatter = firstLine.includes("formatter") const hasFormatter = firstLine.includes("formatter")
if (hasR && hasFormatter) { return hasR && hasFormatter
return [airPath, "format", "$FILE"]
}
return false
} catch (error) { } catch (error) {
return false return false
} }
@@ -240,14 +236,14 @@ export const rlang: Info = {
export const uvformat: Info = { export const uvformat: Info = {
name: "uv", name: "uv",
command: ["uv", "format", "--", "$FILE"],
extensions: [".py", ".pyi"], extensions: [".py", ".pyi"],
async enabled() { async enabled() {
if (await ruff.enabled()) return false if (await ruff.enabled()) return false
const uvPath = which("uv") if (which("uv") !== null) {
if (uvPath !== null) { const proc = Process.spawn(["uv", "format", "--help"], { stderr: "pipe", stdout: "pipe" })
const proc = Process.spawn([uvPath, "format", "--help"], { stderr: "pipe", stdout: "pipe" })
const code = await proc.exited const code = await proc.exited
if (code === 0) return [uvPath, "format", "--", "$FILE"] return code === 0
} }
return false return false
}, },
@@ -255,118 +251,108 @@ export const uvformat: Info = {
export const rubocop: Info = { export const rubocop: Info = {
name: "rubocop", name: "rubocop",
command: ["rubocop", "--autocorrect", "$FILE"],
extensions: [".rb", ".rake", ".gemspec", ".ru"], extensions: [".rb", ".rake", ".gemspec", ".ru"],
async enabled() { async enabled() {
const path = which("rubocop") return which("rubocop") !== null
if (path === null) return false
return [path, "--autocorrect", "$FILE"]
}, },
} }
export const standardrb: Info = { export const standardrb: Info = {
name: "standardrb", name: "standardrb",
command: ["standardrb", "--fix", "$FILE"],
extensions: [".rb", ".rake", ".gemspec", ".ru"], extensions: [".rb", ".rake", ".gemspec", ".ru"],
async enabled() { async enabled() {
const path = which("standardrb") return which("standardrb") !== null
if (path === null) return false
return [path, "--fix", "$FILE"]
}, },
} }
export const htmlbeautifier: Info = { export const htmlbeautifier: Info = {
name: "htmlbeautifier", name: "htmlbeautifier",
command: ["htmlbeautifier", "$FILE"],
extensions: [".erb", ".html.erb"], extensions: [".erb", ".html.erb"],
async enabled() { async enabled() {
const path = which("htmlbeautifier") return which("htmlbeautifier") !== null
if (path === null) return false
return [path, "$FILE"]
}, },
} }
export const dart: Info = { export const dart: Info = {
name: "dart", name: "dart",
command: ["dart", "format", "$FILE"],
extensions: [".dart"], extensions: [".dart"],
async enabled() { async enabled() {
const path = which("dart") return which("dart") !== null
if (path === null) return false
return [path, "format", "$FILE"]
}, },
} }
export const ocamlformat: Info = { export const ocamlformat: Info = {
name: "ocamlformat", name: "ocamlformat",
command: ["ocamlformat", "-i", "$FILE"],
extensions: [".ml", ".mli"], extensions: [".ml", ".mli"],
async enabled() { async enabled() {
const path = which("ocamlformat") if (!which("ocamlformat")) return false
if (!path) return false
const items = await Filesystem.findUp(".ocamlformat", Instance.directory, Instance.worktree) const items = await Filesystem.findUp(".ocamlformat", Instance.directory, Instance.worktree)
if (items.length === 0) return false return items.length > 0
return [path, "-i", "$FILE"]
}, },
} }
export const terraform: Info = { export const terraform: Info = {
name: "terraform", name: "terraform",
command: ["terraform", "fmt", "$FILE"],
extensions: [".tf", ".tfvars"], extensions: [".tf", ".tfvars"],
async enabled() { async enabled() {
const path = which("terraform") return which("terraform") !== null
if (path === null) return false
return [path, "fmt", "$FILE"]
}, },
} }
export const latexindent: Info = { export const latexindent: Info = {
name: "latexindent", name: "latexindent",
command: ["latexindent", "-w", "-s", "$FILE"],
extensions: [".tex"], extensions: [".tex"],
async enabled() { async enabled() {
const path = which("latexindent") return which("latexindent") !== null
if (path === null) return false
return [path, "-w", "-s", "$FILE"]
}, },
} }
export const gleam: Info = { export const gleam: Info = {
name: "gleam", name: "gleam",
command: ["gleam", "format", "$FILE"],
extensions: [".gleam"], extensions: [".gleam"],
async enabled() { async enabled() {
const path = which("gleam") return which("gleam") !== null
if (path === null) return false
return [path, "format", "$FILE"]
}, },
} }
export const shfmt: Info = { export const shfmt: Info = {
name: "shfmt", name: "shfmt",
command: ["shfmt", "-w", "$FILE"],
extensions: [".sh", ".bash"], extensions: [".sh", ".bash"],
async enabled() { async enabled() {
const path = which("shfmt") return which("shfmt") !== null
if (path === null) return false
return [path, "-w", "$FILE"]
}, },
} }
export const nixfmt: Info = { export const nixfmt: Info = {
name: "nixfmt", name: "nixfmt",
command: ["nixfmt", "$FILE"],
extensions: [".nix"], extensions: [".nix"],
async enabled() { async enabled() {
const path = which("nixfmt") return which("nixfmt") !== null
if (path === null) return false
return [path, "$FILE"]
}, },
} }
export const rustfmt: Info = { export const rustfmt: Info = {
name: "rustfmt", name: "rustfmt",
command: ["rustfmt", "$FILE"],
extensions: [".rs"], extensions: [".rs"],
async enabled() { async enabled() {
const path = which("rustfmt") return which("rustfmt") !== null
if (path === null) return false
return [path, "$FILE"]
}, },
} }
export const pint: Info = { export const pint: Info = {
name: "pint", name: "pint",
command: ["./vendor/bin/pint", "$FILE"],
extensions: [".php"], extensions: [".php"],
async enabled() { async enabled() {
const items = await Filesystem.findUp("composer.json", Instance.directory, Instance.worktree) const items = await Filesystem.findUp("composer.json", Instance.directory, Instance.worktree)
@@ -375,9 +361,8 @@ export const pint: Info = {
require?: Record<string, string> require?: Record<string, string>
"require-dev"?: Record<string, string> "require-dev"?: Record<string, string>
}>(item) }>(item)
if (json.require?.["laravel/pint"] || json["require-dev"]?.["laravel/pint"]) { if (json.require?.["laravel/pint"]) return true
return ["./vendor/bin/pint", "$FILE"] if (json["require-dev"]?.["laravel/pint"]) return true
}
} }
return false return false
}, },
@@ -385,30 +370,27 @@ export const pint: Info = {
export const ormolu: Info = { export const ormolu: Info = {
name: "ormolu", name: "ormolu",
command: ["ormolu", "-i", "$FILE"],
extensions: [".hs"], extensions: [".hs"],
async enabled() { async enabled() {
const path = which("ormolu") return which("ormolu") !== null
if (path === null) return false
return [path, "-i", "$FILE"]
}, },
} }
export const cljfmt: Info = { export const cljfmt: Info = {
name: "cljfmt", name: "cljfmt",
command: ["cljfmt", "fix", "--quiet", "$FILE"],
extensions: [".clj", ".cljs", ".cljc", ".edn"], extensions: [".clj", ".cljs", ".cljc", ".edn"],
async enabled() { async enabled() {
const path = which("cljfmt") return which("cljfmt") !== null
if (path === null) return false
return [path, "fix", "--quiet", "$FILE"]
}, },
} }
export const dfmt: Info = { export const dfmt: Info = {
name: "dfmt", name: "dfmt",
command: ["dfmt", "-i", "$FILE"],
extensions: [".d"], extensions: [".d"],
async enabled() { async enabled() {
const path = which("dfmt") return which("dfmt") !== null
if (path === null) return false
return [path, "-i", "$FILE"]
}, },
} }
+31 -27
View File
@@ -25,14 +25,14 @@ export namespace Format {
export type Status = z.infer<typeof Status> export type Status = z.infer<typeof Status>
const state = Instance.state(async () => { const state = Instance.state(async () => {
const cache: Record<string, string[] | false> = {} const enabled: Record<string, boolean> = {}
const cfg = await Config.get() const cfg = await Config.get()
const formatters: Record<string, Formatter.Info> = {} const formatters: Record<string, Formatter.Info> = {}
if (cfg.formatter === false) { if (cfg.formatter === false) {
log.info("all formatters are disabled") log.info("all formatters are disabled")
return { return {
cache, enabled,
formatters, formatters,
} }
} }
@@ -46,41 +46,43 @@ export namespace Format {
continue continue
} }
const result: Formatter.Info = mergeDeep(formatters[name] ?? {}, { const result: Formatter.Info = mergeDeep(formatters[name] ?? {}, {
command: [],
extensions: [], extensions: [],
...item, ...item,
}) })
result.enabled = async () => item.command ?? false if (result.command.length === 0) continue
result.enabled = async () => true
result.name = name result.name = name
formatters[name] = result formatters[name] = result
} }
return { return {
cache, enabled,
formatters, formatters,
} }
}) })
async function resolveCommand(item: Formatter.Info) { async function isEnabled(item: Formatter.Info) {
const s = await state() const s = await state()
let command = s.cache[item.name] let status = s.enabled[item.name]
if (command === undefined) { if (status === undefined) {
log.info("resolving command", { name: item.name }) status = await item.enabled()
command = await item.enabled() s.enabled[item.name] = status
s.cache[item.name] = command
} }
return command return status
} }
async function getFormatter(ext: string) { async function getFormatter(ext: string) {
const formatters = await state().then((x) => x.formatters) const formatters = await state().then((x) => x.formatters)
const result: { info: Formatter.Info; command: string[] }[] = [] const result = []
for (const item of Object.values(formatters)) { for (const item of Object.values(formatters)) {
log.info("checking", { name: item.name, ext })
if (!item.extensions.includes(ext)) continue if (!item.extensions.includes(ext)) continue
const command = await resolveCommand(item) if (!(await isEnabled(item))) continue
if (!command) continue
log.info("enabled", { name: item.name, ext }) log.info("enabled", { name: item.name, ext })
result.push({ info: item, command }) result.push(item)
} }
return result return result
} }
@@ -89,11 +91,11 @@ export namespace Format {
const s = await state() const s = await state()
const result: Status[] = [] const result: Status[] = []
for (const formatter of Object.values(s.formatters)) { for (const formatter of Object.values(s.formatters)) {
const command = await resolveCommand(formatter) const enabled = await isEnabled(formatter)
result.push({ result.push({
name: formatter.name, name: formatter.name,
extensions: formatter.extensions, extensions: formatter.extensions,
enabled: !!command, enabled,
}) })
} }
return result return result
@@ -106,27 +108,29 @@ export namespace Format {
log.info("formatting", { file }) log.info("formatting", { file })
const ext = path.extname(file) const ext = path.extname(file)
for (const { info, command } of await getFormatter(ext)) { for (const item of await getFormatter(ext)) {
const replaced = command.map((x) => x.replace("$FILE", file)) log.info("running", { command: item.command })
log.info("running", { replaced })
try { try {
const proc = Process.spawn(replaced, { const proc = Process.spawn(
item.command.map((x) => x.replace("$FILE", file)),
{
cwd: Instance.directory, cwd: Instance.directory,
env: { ...process.env, ...info.environment }, env: { ...process.env, ...item.environment },
stdout: "ignore", stdout: "ignore",
stderr: "ignore", stderr: "ignore",
}) },
)
const exit = await proc.exited const exit = await proc.exited
if (exit !== 0) if (exit !== 0)
log.error("failed", { log.error("failed", {
command, command: item.command,
...info.environment, ...item.environment,
}) })
} catch (error) { } catch (error) {
log.error("failed to format file", { log.error("failed to format file", {
error, error,
command, command: item.command,
...info.environment, ...item.environment,
file, file,
}) })
} }
+1 -1
View File
@@ -18,7 +18,7 @@ export namespace Global {
return process.env.OPENCODE_TEST_HOME || os.homedir() return process.env.OPENCODE_TEST_HOME || os.homedir()
}, },
data, data,
bin: path.join(cache, "bin"), bin: path.join(data, "bin"),
log: path.join(data, "log"), log: path.join(data, "log"),
cache, cache,
config, config,
+2 -7
View File
@@ -3,8 +3,7 @@ import { hideBin } from "yargs/helpers"
import { RunCommand } from "./cli/cmd/run" import { RunCommand } from "./cli/cmd/run"
import { GenerateCommand } from "./cli/cmd/generate" import { GenerateCommand } from "./cli/cmd/generate"
import { Log } from "./util/log" import { Log } from "./util/log"
import { LoginCommand, LogoutCommand, SwitchCommand, OrgsCommand } from "./cli/cmd/account" import { AuthCommand } from "./cli/cmd/auth"
import { ProvidersCommand } from "./cli/cmd/providers"
import { AgentCommand } from "./cli/cmd/agent" import { AgentCommand } from "./cli/cmd/agent"
import { UpgradeCommand } from "./cli/cmd/upgrade" import { UpgradeCommand } from "./cli/cmd/upgrade"
import { UninstallCommand } from "./cli/cmd/uninstall" import { UninstallCommand } from "./cli/cmd/uninstall"
@@ -135,11 +134,7 @@ let cli = yargs(hideBin(process.argv))
.command(RunCommand) .command(RunCommand)
.command(GenerateCommand) .command(GenerateCommand)
.command(DebugCommand) .command(DebugCommand)
.command(LoginCommand) .command(AuthCommand)
.command(LogoutCommand)
.command(SwitchCommand)
.command(OrgsCommand)
.command(ProvidersCommand)
.command(AgentCommand) .command(AgentCommand)
.command(UpgradeCommand) .command(UpgradeCommand)
.command(UninstallCommand) .command(UninstallCommand)
+165 -22
View File
@@ -3,6 +3,7 @@ import path from "path"
import os from "os" import os from "os"
import { Global } from "../global" import { Global } from "../global"
import { Log } from "../util/log" import { Log } from "../util/log"
import { BunProc } from "../bun"
import { text } from "node:stream/consumers" import { text } from "node:stream/consumers"
import fs from "fs/promises" import fs from "fs/promises"
import { Filesystem } from "../util/filesystem" import { Filesystem } from "../util/filesystem"
@@ -12,7 +13,6 @@ import { Archive } from "../util/archive"
import { Process } from "../util/process" import { Process } from "../util/process"
import { which } from "../util/which" import { which } from "../util/which"
import { Module } from "@opencode-ai/util/module" import { Module } from "@opencode-ai/util/module"
import { Npm } from "@/npm"
export namespace LSPServer { export namespace LSPServer {
const log = Log.create({ service: "lsp.server" }) const log = Log.create({ service: "lsp.server" })
@@ -102,7 +102,7 @@ export namespace LSPServer {
const tsserver = Module.resolve("typescript/lib/tsserver.js", Instance.directory) const tsserver = Module.resolve("typescript/lib/tsserver.js", Instance.directory)
log.info("typescript server", { tsserver }) log.info("typescript server", { tsserver })
if (!tsserver) return if (!tsserver) return
const proc = spawn(await Npm.which("typescript-language-server"), ["--stdio"], { const proc = spawn(BunProc.which(), ["x", "typescript-language-server", "--stdio"], {
cwd: root, cwd: root,
env: { env: {
...process.env, ...process.env,
@@ -128,8 +128,29 @@ export namespace LSPServer {
let binary = which("vue-language-server") let binary = which("vue-language-server")
const args: string[] = [] const args: string[] = []
if (!binary) { if (!binary) {
const js = path.join(
Global.Path.bin,
"node_modules",
"@vue",
"language-server",
"bin",
"vue-language-server.js",
)
if (!(await Filesystem.exists(js))) {
if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return
binary = await Npm.which("@vue/language-server") await Process.spawn([BunProc.which(), "install", "@vue/language-server"], {
cwd: Global.Path.bin,
env: {
...process.env,
BUN_BE_BUN: "1",
},
stdout: "pipe",
stderr: "pipe",
stdin: "pipe",
}).exited
}
binary = BunProc.which()
args.push("run", js)
} }
args.push("--stdio") args.push("--stdio")
const proc = spawn(binary, args, { const proc = spawn(binary, args, {
@@ -192,7 +213,7 @@ export namespace LSPServer {
log.info("installed VS Code ESLint server", { serverPath }) log.info("installed VS Code ESLint server", { serverPath })
} }
const proc = spawn(await Npm.which("tsx"), [serverPath, "--stdio"], { const proc = spawn(BunProc.which(), [serverPath, "--stdio"], {
cwd: root, cwd: root,
env: { env: {
...process.env, ...process.env,
@@ -323,8 +344,8 @@ export namespace LSPServer {
if (!bin) { if (!bin) {
const resolved = Module.resolve("biome", root) const resolved = Module.resolve("biome", root)
if (!resolved) return if (!resolved) return
bin = await Npm.which("biome") bin = BunProc.which()
args = ["lsp-proxy", "--stdio"] args = ["x", "biome", "lsp-proxy", "--stdio"]
} }
const proc = spawn(bin, args, { const proc = spawn(bin, args, {
@@ -350,7 +371,9 @@ export namespace LSPServer {
}, },
extensions: [".go"], extensions: [".go"],
async spawn(root) { async spawn(root) {
let bin = which("gopls") let bin = which("gopls", {
PATH: process.env["PATH"] + path.delimiter + Global.Path.bin,
})
if (!bin) { if (!bin) {
if (!which("go")) return if (!which("go")) return
if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return
@@ -385,7 +408,9 @@ export namespace LSPServer {
root: NearestRoot(["Gemfile"]), root: NearestRoot(["Gemfile"]),
extensions: [".rb", ".rake", ".gemspec", ".ru"], extensions: [".rb", ".rake", ".gemspec", ".ru"],
async spawn(root) { async spawn(root) {
let bin = which("rubocop") let bin = which("rubocop", {
PATH: process.env["PATH"] + path.delimiter + Global.Path.bin,
})
if (!bin) { if (!bin) {
const ruby = which("ruby") const ruby = which("ruby")
const gem = which("gem") const gem = which("gem")
@@ -490,8 +515,19 @@ export namespace LSPServer {
let binary = which("pyright-langserver") let binary = which("pyright-langserver")
const args = [] const args = []
if (!binary) { if (!binary) {
const js = path.join(Global.Path.bin, "node_modules", "pyright", "dist", "pyright-langserver.js")
if (!(await Filesystem.exists(js))) {
if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return
binary = await Npm.which("pyright") await Process.spawn([BunProc.which(), "install", "pyright"], {
cwd: Global.Path.bin,
env: {
...process.env,
BUN_BE_BUN: "1",
},
}).exited
}
binary = BunProc.which()
args.push(...["run", js])
} }
args.push("--stdio") args.push("--stdio")
@@ -593,7 +629,9 @@ export namespace LSPServer {
extensions: [".zig", ".zon"], extensions: [".zig", ".zon"],
root: NearestRoot(["build.zig"]), root: NearestRoot(["build.zig"]),
async spawn(root) { async spawn(root) {
let bin = which("zls") let bin = which("zls", {
PATH: process.env["PATH"] + path.delimiter + Global.Path.bin,
})
if (!bin) { if (!bin) {
const zig = which("zig") const zig = which("zig")
@@ -703,7 +741,9 @@ export namespace LSPServer {
root: NearestRoot([".slnx", ".sln", ".csproj", "global.json"]), root: NearestRoot([".slnx", ".sln", ".csproj", "global.json"]),
extensions: [".cs"], extensions: [".cs"],
async spawn(root) { async spawn(root) {
let bin = which("csharp-ls") let bin = which("csharp-ls", {
PATH: process.env["PATH"] + path.delimiter + Global.Path.bin,
})
if (!bin) { if (!bin) {
if (!which("dotnet")) { if (!which("dotnet")) {
log.error(".NET SDK is required to install csharp-ls") log.error(".NET SDK is required to install csharp-ls")
@@ -740,7 +780,9 @@ export namespace LSPServer {
root: NearestRoot([".slnx", ".sln", ".fsproj", "global.json"]), root: NearestRoot([".slnx", ".sln", ".fsproj", "global.json"]),
extensions: [".fs", ".fsi", ".fsx", ".fsscript"], extensions: [".fs", ".fsi", ".fsx", ".fsscript"],
async spawn(root) { async spawn(root) {
let bin = which("fsautocomplete") let bin = which("fsautocomplete", {
PATH: process.env["PATH"] + path.delimiter + Global.Path.bin,
})
if (!bin) { if (!bin) {
if (!which("dotnet")) { if (!which("dotnet")) {
log.error(".NET SDK is required to install fsautocomplete") log.error(".NET SDK is required to install fsautocomplete")
@@ -1006,8 +1048,22 @@ export namespace LSPServer {
let binary = which("svelteserver") let binary = which("svelteserver")
const args: string[] = [] const args: string[] = []
if (!binary) { if (!binary) {
const js = path.join(Global.Path.bin, "node_modules", "svelte-language-server", "bin", "server.js")
if (!(await Filesystem.exists(js))) {
if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return
binary = await Npm.which("svelte-language-server") await Process.spawn([BunProc.which(), "install", "svelte-language-server"], {
cwd: Global.Path.bin,
env: {
...process.env,
BUN_BE_BUN: "1",
},
stdout: "pipe",
stderr: "pipe",
stdin: "pipe",
}).exited
}
binary = BunProc.which()
args.push("run", js)
} }
args.push("--stdio") args.push("--stdio")
const proc = spawn(binary, args, { const proc = spawn(binary, args, {
@@ -1039,8 +1095,22 @@ export namespace LSPServer {
let binary = which("astro-ls") let binary = which("astro-ls")
const args: string[] = [] const args: string[] = []
if (!binary) { if (!binary) {
const js = path.join(Global.Path.bin, "node_modules", "@astrojs", "language-server", "bin", "nodeServer.js")
if (!(await Filesystem.exists(js))) {
if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return
binary = await Npm.which("@astrojs/language-server") await Process.spawn([BunProc.which(), "install", "@astrojs/language-server"], {
cwd: Global.Path.bin,
env: {
...process.env,
BUN_BE_BUN: "1",
},
stdout: "pipe",
stderr: "pipe",
stdin: "pipe",
}).exited
}
binary = BunProc.which()
args.push("run", js)
} }
args.push("--stdio") args.push("--stdio")
const proc = spawn(binary, args, { const proc = spawn(binary, args, {
@@ -1289,8 +1359,31 @@ export namespace LSPServer {
let binary = which("yaml-language-server") let binary = which("yaml-language-server")
const args: string[] = [] const args: string[] = []
if (!binary) { if (!binary) {
const js = path.join(
Global.Path.bin,
"node_modules",
"yaml-language-server",
"out",
"server",
"src",
"server.js",
)
const exists = await Filesystem.exists(js)
if (!exists) {
if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return
binary = await Npm.which("yaml-language-server") await Process.spawn([BunProc.which(), "install", "yaml-language-server"], {
cwd: Global.Path.bin,
env: {
...process.env,
BUN_BE_BUN: "1",
},
stdout: "pipe",
stderr: "pipe",
stdin: "pipe",
}).exited
}
binary = BunProc.which()
args.push("run", js)
} }
args.push("--stdio") args.push("--stdio")
const proc = spawn(binary, args, { const proc = spawn(binary, args, {
@@ -1319,7 +1412,9 @@ export namespace LSPServer {
]), ]),
extensions: [".lua"], extensions: [".lua"],
async spawn(root) { async spawn(root) {
let bin = which("lua-language-server") let bin = which("lua-language-server", {
PATH: process.env["PATH"] + path.delimiter + Global.Path.bin,
})
if (!bin) { if (!bin) {
if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return
@@ -1455,8 +1550,22 @@ export namespace LSPServer {
let binary = which("intelephense") let binary = which("intelephense")
const args: string[] = [] const args: string[] = []
if (!binary) { if (!binary) {
const js = path.join(Global.Path.bin, "node_modules", "intelephense", "lib", "intelephense.js")
if (!(await Filesystem.exists(js))) {
if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return
binary = await Npm.which("intelephense") await Process.spawn([BunProc.which(), "install", "intelephense"], {
cwd: Global.Path.bin,
env: {
...process.env,
BUN_BE_BUN: "1",
},
stdout: "pipe",
stderr: "pipe",
stdin: "pipe",
}).exited
}
binary = BunProc.which()
args.push("run", js)
} }
args.push("--stdio") args.push("--stdio")
const proc = spawn(binary, args, { const proc = spawn(binary, args, {
@@ -1538,8 +1647,22 @@ export namespace LSPServer {
let binary = which("bash-language-server") let binary = which("bash-language-server")
const args: string[] = [] const args: string[] = []
if (!binary) { if (!binary) {
const js = path.join(Global.Path.bin, "node_modules", "bash-language-server", "out", "cli.js")
if (!(await Filesystem.exists(js))) {
if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return
binary = await Npm.which("bash-language-server") await Process.spawn([BunProc.which(), "install", "bash-language-server"], {
cwd: Global.Path.bin,
env: {
...process.env,
BUN_BE_BUN: "1",
},
stdout: "pipe",
stderr: "pipe",
stdin: "pipe",
}).exited
}
binary = BunProc.which()
args.push("run", js)
} }
args.push("start") args.push("start")
const proc = spawn(binary, args, { const proc = spawn(binary, args, {
@@ -1560,7 +1683,9 @@ export namespace LSPServer {
extensions: [".tf", ".tfvars"], extensions: [".tf", ".tfvars"],
root: NearestRoot([".terraform.lock.hcl", "terraform.tfstate", "*.tf"]), root: NearestRoot([".terraform.lock.hcl", "terraform.tfstate", "*.tf"]),
async spawn(root) { async spawn(root) {
let bin = which("terraform-ls") let bin = which("terraform-ls", {
PATH: process.env["PATH"] + path.delimiter + Global.Path.bin,
})
if (!bin) { if (!bin) {
if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return
@@ -1641,7 +1766,9 @@ export namespace LSPServer {
extensions: [".tex", ".bib"], extensions: [".tex", ".bib"],
root: NearestRoot([".latexmkrc", "latexmkrc", ".texlabroot", "texlabroot"]), root: NearestRoot([".latexmkrc", "latexmkrc", ".texlabroot", "texlabroot"]),
async spawn(root) { async spawn(root) {
let bin = which("texlab") let bin = which("texlab", {
PATH: process.env["PATH"] + path.delimiter + Global.Path.bin,
})
if (!bin) { if (!bin) {
if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return
@@ -1732,8 +1859,22 @@ export namespace LSPServer {
let binary = which("docker-langserver") let binary = which("docker-langserver")
const args: string[] = [] const args: string[] = []
if (!binary) { if (!binary) {
const js = path.join(Global.Path.bin, "node_modules", "dockerfile-language-server-nodejs", "lib", "server.js")
if (!(await Filesystem.exists(js))) {
if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return
binary = await Npm.which("dockerfile-language-server-nodejs") await Process.spawn([BunProc.which(), "install", "dockerfile-language-server-nodejs"], {
cwd: Global.Path.bin,
env: {
...process.env,
BUN_BE_BUN: "1",
},
stdout: "pipe",
stderr: "pipe",
stdin: "pipe",
}).exited
}
binary = BunProc.which()
args.push("run", js)
} }
args.push("--stdio") args.push("--stdio")
const proc = spawn(binary, args, { const proc = spawn(binary, args, {
@@ -1824,7 +1965,9 @@ export namespace LSPServer {
extensions: [".typ", ".typc"], extensions: [".typ", ".typc"],
root: NearestRoot(["typst.toml"]), root: NearestRoot(["typst.toml"]),
async spawn(root) { async spawn(root) {
let bin = which("tinymist") let bin = which("tinymist", {
PATH: process.env["PATH"] + path.delimiter + Global.Path.bin,
})
if (!bin) { if (!bin) {
if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return
+7 -4
View File
@@ -11,7 +11,6 @@ import {
} from "@modelcontextprotocol/sdk/types.js" } from "@modelcontextprotocol/sdk/types.js"
import { Config } from "../config/config" import { Config } from "../config/config"
import { Log } from "../util/log" import { Log } from "../util/log"
import { Process } from "../util/process"
import { NamedError } from "@opencode-ai/util/error" import { NamedError } from "@opencode-ai/util/error"
import z from "zod/v4" import z from "zod/v4"
import { Instance } from "../project/instance" import { Instance } from "../project/instance"
@@ -167,10 +166,14 @@ export namespace MCP {
const queue = [pid] const queue = [pid]
while (queue.length > 0) { while (queue.length > 0) {
const current = queue.shift()! const current = queue.shift()!
const lines = await Process.lines(["pgrep", "-P", String(current)], { nothrow: true }) const proc = Bun.spawn(["pgrep", "-P", String(current)], { stdout: "pipe", stderr: "pipe" })
for (const tok of lines) { const [code, out] = await Promise.all([proc.exited, new Response(proc.stdout).text()]).catch(
() => [-1, ""] as const,
)
if (code !== 0) continue
for (const tok of out.trim().split(/\s+/)) {
const cpid = parseInt(tok, 10) const cpid = parseInt(tok, 10)
if (!isNaN(cpid) && !pids.includes(cpid)) { if (!isNaN(cpid) && pids.indexOf(cpid) === -1) {
pids.push(cpid) pids.push(cpid)
queue.push(cpid) queue.push(cpid)
} }
+36 -39
View File
@@ -1,5 +1,4 @@
import { createConnection } from "net" import { createConnection } from "net"
import { createServer } from "http"
import { Log } from "../util/log" import { Log } from "../util/log"
import { OAUTH_CALLBACK_PORT, OAUTH_CALLBACK_PATH } from "./oauth-provider" import { OAUTH_CALLBACK_PORT, OAUTH_CALLBACK_PATH } from "./oauth-provider"
@@ -53,18 +52,27 @@ interface PendingAuth {
} }
export namespace McpOAuthCallback { export namespace McpOAuthCallback {
let server: ReturnType<typeof createServer> | undefined let server: ReturnType<typeof Bun.serve> | undefined
const pendingAuths = new Map<string, PendingAuth>() const pendingAuths = new Map<string, PendingAuth>()
const CALLBACK_TIMEOUT_MS = 5 * 60 * 1000 // 5 minutes const CALLBACK_TIMEOUT_MS = 5 * 60 * 1000 // 5 minutes
function handleRequest(req: import("http").IncomingMessage, res: import("http").ServerResponse) { export async function ensureRunning(): Promise<void> {
const url = new URL(req.url || "/", `http://localhost:${OAUTH_CALLBACK_PORT}`) if (server) return
const running = await isPortInUse()
if (running) {
log.info("oauth callback server already running on another instance", { port: OAUTH_CALLBACK_PORT })
return
}
server = Bun.serve({
port: OAUTH_CALLBACK_PORT,
fetch(req) {
const url = new URL(req.url)
if (url.pathname !== OAUTH_CALLBACK_PATH) { if (url.pathname !== OAUTH_CALLBACK_PATH) {
res.writeHead(404) return new Response("Not found", { status: 404 })
res.end("Not found")
return
} }
const code = url.searchParams.get("code") const code = url.searchParams.get("code")
@@ -78,9 +86,10 @@ export namespace McpOAuthCallback {
if (!state) { if (!state) {
const errorMsg = "Missing required state parameter - potential CSRF attack" const errorMsg = "Missing required state parameter - potential CSRF attack"
log.error("oauth callback missing state parameter", { url: url.toString() }) log.error("oauth callback missing state parameter", { url: url.toString() })
res.writeHead(400, { "Content-Type": "text/html" }) return new Response(HTML_ERROR(errorMsg), {
res.end(HTML_ERROR(errorMsg)) status: 400,
return headers: { "Content-Type": "text/html" },
})
} }
if (error) { if (error) {
@@ -91,24 +100,26 @@ export namespace McpOAuthCallback {
pendingAuths.delete(state) pendingAuths.delete(state)
pending.reject(new Error(errorMsg)) pending.reject(new Error(errorMsg))
} }
res.writeHead(200, { "Content-Type": "text/html" }) return new Response(HTML_ERROR(errorMsg), {
res.end(HTML_ERROR(errorMsg)) headers: { "Content-Type": "text/html" },
return })
} }
if (!code) { if (!code) {
res.writeHead(400, { "Content-Type": "text/html" }) return new Response(HTML_ERROR("No authorization code provided"), {
res.end(HTML_ERROR("No authorization code provided")) status: 400,
return headers: { "Content-Type": "text/html" },
})
} }
// Validate state parameter // Validate state parameter
if (!pendingAuths.has(state)) { if (!pendingAuths.has(state)) {
const errorMsg = "Invalid or expired state parameter - potential CSRF attack" const errorMsg = "Invalid or expired state parameter - potential CSRF attack"
log.error("oauth callback with invalid state", { state, pendingStates: Array.from(pendingAuths.keys()) }) log.error("oauth callback with invalid state", { state, pendingStates: Array.from(pendingAuths.keys()) })
res.writeHead(400, { "Content-Type": "text/html" }) return new Response(HTML_ERROR(errorMsg), {
res.end(HTML_ERROR(errorMsg)) status: 400,
return headers: { "Content-Type": "text/html" },
})
} }
const pending = pendingAuths.get(state)! const pending = pendingAuths.get(state)!
@@ -117,27 +128,13 @@ export namespace McpOAuthCallback {
pendingAuths.delete(state) pendingAuths.delete(state)
pending.resolve(code) pending.resolve(code)
res.writeHead(200, { "Content-Type": "text/html" }) return new Response(HTML_SUCCESS, {
res.end(HTML_SUCCESS) headers: { "Content-Type": "text/html" },
} })
},
})
export async function ensureRunning(): Promise<void> {
if (server) return
const running = await isPortInUse()
if (running) {
log.info("oauth callback server already running on another instance", { port: OAUTH_CALLBACK_PORT })
return
}
server = createServer(handleRequest)
await new Promise<void>((resolve, reject) => {
server!.listen(OAUTH_CALLBACK_PORT, () => {
log.info("oauth callback server started", { port: OAUTH_CALLBACK_PORT }) log.info("oauth callback server started", { port: OAUTH_CALLBACK_PORT })
resolve()
})
server!.on("error", reject)
})
} }
export function waitForCallback(oauthState: string): Promise<string> { export function waitForCallback(oauthState: string): Promise<string> {
@@ -177,7 +174,7 @@ export namespace McpOAuthCallback {
export async function stop(): Promise<void> { export async function stop(): Promise<void> {
if (server) { if (server) {
await new Promise<void>((resolve) => server!.close(() => resolve())) server.stop()
server = undefined server = undefined
log.info("oauth callback server stopped") log.info("oauth callback server stopped")
} }
-8
View File
@@ -1,8 +0,0 @@
import { Server } from "./server/server"
const result = await Server.listen({
port: 1338,
hostname: "0.0.0.0",
})
console.log(result)
-160
View File
@@ -1,160 +0,0 @@
// Workaround: Bun on Windows does not support the UV_FS_O_FILEMAP flag that
// the `tar` package uses for files < 512KB (fs.open returns EINVAL).
// tar silently swallows the error and skips writing files, leaving only empty
// directories. Setting __FAKE_PLATFORM__ makes tar fall back to the plain 'w'
// flag. See tar's get-write-flag.js.
// Must be set before @npmcli/arborist is imported since tar caches the flag
// at module evaluation time — so we use a dynamic import() below.
if (process.platform === "win32") {
process.env.__FAKE_PLATFORM__ = "linux"
}
import semver from "semver"
import z from "zod"
import { NamedError } from "@opencode-ai/util/error"
import { Global } from "../global"
import { Lock } from "../util/lock"
import { Log } from "../util/log"
import path from "path"
import { readdir } from "fs/promises"
import { Filesystem } from "@/util/filesystem"
const { Arborist } = await import("@npmcli/arborist")
export namespace Npm {
const log = Log.create({ service: "npm" })
export const InstallFailedError = NamedError.create(
"NpmInstallFailedError",
z.object({
pkg: z.string(),
}),
)
function directory(pkg: string) {
return path.join(Global.Path.cache, "packages", pkg)
}
export async function outdated(pkg: string, cachedVersion: string): Promise<boolean> {
const response = await fetch(`https://registry.npmjs.org/${pkg}`)
if (!response.ok) {
log.warn("Failed to resolve latest version, using cached", { pkg, cachedVersion })
return false
}
const data = (await response.json()) as { "dist-tags"?: { latest?: string } }
const latestVersion = data?.["dist-tags"]?.latest
if (!latestVersion) {
log.warn("No latest version found, using cached", { pkg, cachedVersion })
return false
}
const isRange = /[\s^~*xX<>|=]/.test(cachedVersion)
if (isRange) return !semver.satisfies(latestVersion, cachedVersion)
return semver.lt(cachedVersion, latestVersion)
}
export async function add(pkg: string) {
using _ = await Lock.write("npm-install")
log.info("installing package", {
pkg,
})
const hash = pkg
const dir = directory(hash)
const arborist = new Arborist({
path: dir,
binLinks: true,
progress: false,
savePrefix: "",
})
const tree = await arborist.loadVirtual().catch(() => {})
if (tree) {
const first = tree.edgesOut.values().next().value?.to
if (first) {
log.info("package already installed", { pkg })
return first.path
}
}
const result = await arborist
.reify({
add: [pkg],
save: true,
saveType: "prod",
})
.catch((cause) => {
throw new InstallFailedError(
{ pkg },
{
cause,
},
)
})
const first = result.edgesOut.values().next().value?.to
if (!first) throw new InstallFailedError({ pkg })
return first.path
}
export async function install(dir: string) {
log.info("checking dependencies", { dir })
const reify = async () => {
const arb = new Arborist({
path: dir,
binLinks: true,
progress: false,
savePrefix: "",
})
await arb.reify().catch(() => {})
}
if (!(await Filesystem.exists(path.join(dir, "node_modules")))) {
log.info("node_modules missing, reifying")
await reify()
return
}
const pkg = await Filesystem.readJson(path.join(dir, "package.json")).catch(() => ({}))
const lock = await Filesystem.readJson(path.join(dir, "package-lock.json")).catch(() => ({}))
const declared = new Set([
...Object.keys(pkg.dependencies || {}),
...Object.keys(pkg.devDependencies || {}),
...Object.keys(pkg.peerDependencies || {}),
...Object.keys(pkg.optionalDependencies || {}),
])
const root = lock.packages?.[""] || {}
const locked = new Set([
...Object.keys(root.dependencies || {}),
...Object.keys(root.devDependencies || {}),
...Object.keys(root.peerDependencies || {}),
...Object.keys(root.optionalDependencies || {}),
])
for (const name of declared) {
if (!locked.has(name)) {
log.info("dependency not in lock file, reifying", { name })
await reify()
return
}
}
log.info("dependencies in sync")
}
export async function which(pkg: string) {
const dir = path.join(directory(pkg), "node_modules", ".bin")
const files = await readdir(dir).catch(() => [])
if (!files.length) {
await add(pkg)
const retry = await readdir(dir).catch(() => [])
if (!retry.length) throw new Error(`No binary found for package "${pkg}" after install`)
return path.join(dir, retry[0])
}
return path.join(dir, files[0])
}
}
+24 -31
View File
@@ -5,7 +5,6 @@ import { Auth, OAUTH_DUMMY_KEY } from "../auth"
import os from "os" import os from "os"
import { ProviderTransform } from "@/provider/transform" import { ProviderTransform } from "@/provider/transform"
import { setTimeout as sleep } from "node:timers/promises" import { setTimeout as sleep } from "node:timers/promises"
import { createServer } from "http"
const log = Log.create({ service: "plugin.codex" }) const log = Log.create({ service: "plugin.codex" })
@@ -241,7 +240,7 @@ interface PendingOAuth {
reject: (error: Error) => void reject: (error: Error) => void
} }
let oauthServer: ReturnType<typeof createServer> | undefined let oauthServer: ReturnType<typeof Bun.serve> | undefined
let pendingOAuth: PendingOAuth | undefined let pendingOAuth: PendingOAuth | undefined
async function startOAuthServer(): Promise<{ port: number; redirectUri: string }> { async function startOAuthServer(): Promise<{ port: number; redirectUri: string }> {
@@ -249,8 +248,10 @@ async function startOAuthServer(): Promise<{ port: number; redirectUri: string }
return { port: OAUTH_PORT, redirectUri: `http://localhost:${OAUTH_PORT}/auth/callback` } return { port: OAUTH_PORT, redirectUri: `http://localhost:${OAUTH_PORT}/auth/callback` }
} }
oauthServer = createServer((req, res) => { oauthServer = Bun.serve({
const url = new URL(req.url || "/", `http://localhost:${OAUTH_PORT}`) port: OAUTH_PORT,
fetch(req) {
const url = new URL(req.url)
if (url.pathname === "/auth/callback") { if (url.pathname === "/auth/callback") {
const code = url.searchParams.get("code") const code = url.searchParams.get("code")
@@ -262,27 +263,29 @@ async function startOAuthServer(): Promise<{ port: number; redirectUri: string }
const errorMsg = errorDescription || error const errorMsg = errorDescription || error
pendingOAuth?.reject(new Error(errorMsg)) pendingOAuth?.reject(new Error(errorMsg))
pendingOAuth = undefined pendingOAuth = undefined
res.writeHead(200, { "Content-Type": "text/html" }) return new Response(HTML_ERROR(errorMsg), {
res.end(HTML_ERROR(errorMsg)) headers: { "Content-Type": "text/html" },
return })
} }
if (!code) { if (!code) {
const errorMsg = "Missing authorization code" const errorMsg = "Missing authorization code"
pendingOAuth?.reject(new Error(errorMsg)) pendingOAuth?.reject(new Error(errorMsg))
pendingOAuth = undefined pendingOAuth = undefined
res.writeHead(400, { "Content-Type": "text/html" }) return new Response(HTML_ERROR(errorMsg), {
res.end(HTML_ERROR(errorMsg)) status: 400,
return headers: { "Content-Type": "text/html" },
})
} }
if (!pendingOAuth || state !== pendingOAuth.state) { if (!pendingOAuth || state !== pendingOAuth.state) {
const errorMsg = "Invalid state - potential CSRF attack" const errorMsg = "Invalid state - potential CSRF attack"
pendingOAuth?.reject(new Error(errorMsg)) pendingOAuth?.reject(new Error(errorMsg))
pendingOAuth = undefined pendingOAuth = undefined
res.writeHead(400, { "Content-Type": "text/html" }) return new Response(HTML_ERROR(errorMsg), {
res.end(HTML_ERROR(errorMsg)) status: 400,
return headers: { "Content-Type": "text/html" },
})
} }
const current = pendingOAuth const current = pendingOAuth
@@ -292,40 +295,30 @@ async function startOAuthServer(): Promise<{ port: number; redirectUri: string }
.then((tokens) => current.resolve(tokens)) .then((tokens) => current.resolve(tokens))
.catch((err) => current.reject(err)) .catch((err) => current.reject(err))
res.writeHead(200, { "Content-Type": "text/html" }) return new Response(HTML_SUCCESS, {
res.end(HTML_SUCCESS) headers: { "Content-Type": "text/html" },
return })
} }
if (url.pathname === "/cancel") { if (url.pathname === "/cancel") {
pendingOAuth?.reject(new Error("Login cancelled")) pendingOAuth?.reject(new Error("Login cancelled"))
pendingOAuth = undefined pendingOAuth = undefined
res.writeHead(200) return new Response("Login cancelled", { status: 200 })
res.end("Login cancelled")
return
} }
res.writeHead(404) return new Response("Not found", { status: 404 })
res.end("Not found") },
}) })
await new Promise<void>((resolve, reject) => {
oauthServer!.listen(OAUTH_PORT, () => {
log.info("codex oauth server started", { port: OAUTH_PORT }) log.info("codex oauth server started", { port: OAUTH_PORT })
resolve()
})
oauthServer!.on("error", reject)
})
return { port: OAUTH_PORT, redirectUri: `http://localhost:${OAUTH_PORT}/auth/callback` } return { port: OAUTH_PORT, redirectUri: `http://localhost:${OAUTH_PORT}/auth/callback` }
} }
function stopOAuthServer() { function stopOAuthServer() {
if (oauthServer) { if (oauthServer) {
oauthServer.close(() => { oauthServer.stop()
log.info("codex oauth server stopped")
})
oauthServer = undefined oauthServer = undefined
log.info("codex oauth server stopped")
} }
} }
+8 -8
View File
@@ -4,7 +4,7 @@ import { Bus } from "../bus"
import { Log } from "../util/log" import { Log } from "../util/log"
import { createOpencodeClient } from "@opencode-ai/sdk" import { createOpencodeClient } from "@opencode-ai/sdk"
import { Server } from "../server/server" import { Server } from "../server/server"
import { Npm } from "../npm" import { BunProc } from "../bun"
import { Instance } from "../project/instance" import { Instance } from "../project/instance"
import { Flag } from "../flag/flag" import { Flag } from "../flag/flag"
import { CodexAuthPlugin } from "./codex" import { CodexAuthPlugin } from "./codex"
@@ -27,9 +27,7 @@ export namespace Plugin {
directory: Instance.directory, directory: Instance.directory,
fetch: async (...args) => Server.Default().fetch(...args), fetch: async (...args) => Server.Default().fetch(...args),
}) })
log.info("loading config")
const config = await Config.get() const config = await Config.get()
log.info("config loaded")
const hooks: Hooks[] = [] const hooks: Hooks[] = []
const input: PluginInput = { const input: PluginInput = {
client, client,
@@ -39,8 +37,7 @@ export namespace Plugin {
get serverUrl(): URL { get serverUrl(): URL {
throw new Error("Server URL is no longer supported in plugins") throw new Error("Server URL is no longer supported in plugins")
}, },
// @ts-expect-error $: Bun.$,
$: typeof Bun === "undefined" ? undefined : Bun.$,
} }
for (const plugin of INTERNAL_PLUGINS) { for (const plugin of INTERNAL_PLUGINS) {
@@ -62,13 +59,16 @@ export namespace Plugin {
if (plugin.includes("opencode-openai-codex-auth") || plugin.includes("opencode-copilot-auth")) continue if (plugin.includes("opencode-openai-codex-auth") || plugin.includes("opencode-copilot-auth")) continue
log.info("loading plugin", { path: plugin }) log.info("loading plugin", { path: plugin })
if (!plugin.startsWith("file://")) { if (!plugin.startsWith("file://")) {
plugin = await Npm.add(plugin).catch((err) => { const lastAtIndex = plugin.lastIndexOf("@")
const pkg = lastAtIndex > 0 ? plugin.substring(0, lastAtIndex) : plugin
const version = lastAtIndex > 0 ? plugin.substring(lastAtIndex + 1) : "latest"
plugin = await BunProc.install(pkg, version).catch((err) => {
const cause = err instanceof Error ? err.cause : err const cause = err instanceof Error ? err.cause : err
const detail = cause instanceof Error ? cause.message : String(cause ?? err) const detail = cause instanceof Error ? cause.message : String(cause ?? err)
log.error("failed to install plugin", { plugin, error: detail }) log.error("failed to install plugin", { pkg, version, error: detail })
Bus.publish(Session.Event.Error, { Bus.publish(Session.Event.Error, {
error: new NamedError.Unknown({ error: new NamedError.Unknown({
message: `Failed to install plugin ${plugin}: ${detail}`, message: `Failed to install plugin ${pkg}@${version}: ${detail}`,
}).toObject(), }).toObject(),
}) })
return "" return ""
+1 -1
View File
@@ -1,5 +1,5 @@
import { sqliteTable, text, integer } from "drizzle-orm/sqlite-core" import { sqliteTable, text, integer } from "drizzle-orm/sqlite-core"
import { Timestamps } from "../storage/schema.sql" import { Timestamps } from "@/storage/schema.sql"
export const ProjectTable = sqliteTable("project", { export const ProjectTable = sqliteTable("project", {
id: text().primaryKey(), id: text().primaryKey(),
+2 -2
View File
@@ -5,7 +5,7 @@ import { Config } from "../config/config"
import { mapValues, mergeDeep, omit, pickBy, sortBy } from "remeda" import { mapValues, mergeDeep, omit, pickBy, sortBy } from "remeda"
import { NoSuchModelError, type Provider as SDK } from "ai" import { NoSuchModelError, type Provider as SDK } from "ai"
import { Log } from "../util/log" import { Log } from "../util/log"
import { Npm } from "../npm" import { BunProc } from "../bun"
import { Hash } from "../util/hash" import { Hash } from "../util/hash"
import { Plugin } from "../plugin" import { Plugin } from "../plugin"
import { NamedError } from "@opencode-ai/util/error" import { NamedError } from "@opencode-ai/util/error"
@@ -1201,7 +1201,7 @@ export namespace Provider {
let installedPath: string let installedPath: string
if (!model.api.npm.startsWith("file://")) { if (!model.api.npm.startsWith("file://")) {
installedPath = await Npm.add(model.api.npm) installedPath = await BunProc.install(model.api.npm, "latest")
} else { } else {
log.info("loading local provider", { pkg: model.api.npm }) log.info("loading local provider", { pkg: model.api.npm })
installedPath = model.api.npm installedPath = model.api.npm
+7 -4
View File
@@ -1,6 +1,6 @@
import { BusEvent } from "@/bus/bus-event" import { BusEvent } from "@/bus/bus-event"
import { Bus } from "@/bus" import { Bus } from "@/bus"
import type { Proc } from "#pty" import { type IPty } from "bun-pty"
import z from "zod" import z from "zod"
import { Identifier } from "../id/id" import { Identifier } from "../id/id"
import { Log } from "../util/log" import { Log } from "../util/log"
@@ -35,7 +35,10 @@ export namespace Pty {
return out return out
} }
const pty = lazy(() => import("#pty")) const pty = lazy(async () => {
const { spawn } = await import("bun-pty")
return spawn
})
export const Info = z export const Info = z
.object({ .object({
@@ -82,7 +85,7 @@ export namespace Pty {
interface ActiveSession { interface ActiveSession {
info: Info info: Info
process: Proc process: IPty
buffer: string buffer: string
bufferCursor: number bufferCursor: number
cursor: number cursor: number
@@ -141,7 +144,7 @@ export namespace Pty {
} }
log.info("creating session", { id, cmd: command, args, cwd }) log.info("creating session", { id, cmd: command, args, cwd })
const { spawn } = await pty() const spawn = await pty()
const ptyProcess = spawn(command, args, { const ptyProcess = spawn(command, args, {
name: "xterm-256color", name: "xterm-256color",
cwd, cwd,
-26
View File
@@ -1,26 +0,0 @@
import { spawn as create } from "bun-pty"
import type { Opts, Proc } from "./pty"
export type { Disp, Exit, Opts, Proc } from "./pty"
export function spawn(file: string, args: string[], opts: Opts): Proc {
const pty = create(file, args, opts)
return {
pid: pty.pid,
onData(listener) {
return pty.onData(listener)
},
onExit(listener) {
return pty.onExit(listener)
},
write(data) {
pty.write(data)
},
resize(cols, rows) {
pty.resize(cols, rows)
},
kill(signal) {
pty.kill(signal)
},
}
}
-26
View File
@@ -1,26 +0,0 @@
import * as pty from "node-pty"
import type { Opts, Proc } from "./pty"
export type { Disp, Exit, Opts, Proc } from "./pty"
export function spawn(file: string, args: string[], opts: Opts): Proc {
const proc = pty.spawn(file, args, opts)
return {
pid: proc.pid,
onData(listener) {
return proc.onData(listener)
},
onExit(listener) {
return proc.onExit(listener)
},
write(data) {
proc.write(data)
},
resize(cols, rows) {
proc.resize(cols, rows)
},
kill(signal) {
proc.kill(signal)
},
}
}
-25
View File
@@ -1,25 +0,0 @@
export type Disp = {
dispose(): void
}
export type Exit = {
exitCode: number
signal?: number | string
}
export type Opts = {
name: string
cols?: number
rows?: number
cwd?: string
env?: Record<string, string>
}
export type Proc = {
pid: number
onData(listener: (data: string) => void): Disp
onExit(listener: (event: Exit) => void): Disp
write(data: string): void
resize(cols: number, rows: number): void
kill(signal?: string): void
}
@@ -28,7 +28,7 @@ export const ProjectRoutes = lazy(() =>
}, },
}), }),
async (c) => { async (c) => {
const projects = Project.list() const projects = await Project.list()
return c.json(projects) return c.json(projects)
}, },
) )
+4 -1
View File
@@ -8,9 +8,12 @@ import { Snapshot } from "@/snapshot"
import { fn } from "@/util/fn" import { fn } from "@/util/fn"
import { Database, eq, desc, inArray } from "@/storage/db" import { Database, eq, desc, inArray } from "@/storage/db"
import { MessageTable, PartTable } from "./session.sql" import { MessageTable, PartTable } from "./session.sql"
import { ProviderTransform } from "@/provider/transform"
import { STATUS_CODES } from "http"
import { Storage } from "@/storage/storage"
import { ProviderError } from "@/provider/error" import { ProviderError } from "@/provider/error"
import { iife } from "@/util/iife" import { iife } from "@/util/iife"
import type { SystemError } from "bun" import { type SystemError } from "bun"
import type { Provider } from "@/provider/provider" import type { Provider } from "@/provider/provider"
export namespace MessageV2 { export namespace MessageV2 {
+9 -7
View File
@@ -31,6 +31,7 @@ import { Flag } from "../flag/flag"
import { ulid } from "ulid" import { ulid } from "ulid"
import { spawn } from "child_process" import { spawn } from "child_process"
import { Command } from "../command" import { Command } from "../command"
import { $ } from "bun"
import { pathToFileURL, fileURLToPath } from "url" import { pathToFileURL, fileURLToPath } from "url"
import { ConfigMarkdown } from "../config/markdown" import { ConfigMarkdown } from "../config/markdown"
import { SessionSummary } from "./summary" import { SessionSummary } from "./summary"
@@ -45,7 +46,6 @@ import { LLM } from "./llm"
import { iife } from "@/util/iife" import { iife } from "@/util/iife"
import { Shell } from "@/shell/shell" import { Shell } from "@/shell/shell"
import { Truncate } from "@/tool/truncation" import { Truncate } from "@/tool/truncation"
import { Process } from "@/util/process"
// @ts-ignore // @ts-ignore
globalThis.AI_SDK_LOG_WARNINGS = false globalThis.AI_SDK_LOG_WARNINGS = false
@@ -1778,13 +1778,15 @@ NOTE: At any point in time through this workflow you should feel free to ask the
template = template + "\n\n" + input.arguments template = template + "\n\n" + input.arguments
} }
const shellMatches = ConfigMarkdown.shell(template) const shell = ConfigMarkdown.shell(template)
if (shellMatches.length > 0) { if (shell.length > 0) {
const sh = Shell.preferred()
const results = await Promise.all( const results = await Promise.all(
shellMatches.map(async ([, cmd]) => { shell.map(async ([, cmd]) => {
const out = await Process.text([cmd], { shell: sh, nothrow: true }) try {
return out.text return await $`${{ raw: cmd }}`.quiet().nothrow().text()
} catch (error) {
return `Error executing command: ${error instanceof Error ? error.message : String(error)}`
}
}), }),
) )
let index = 0 let index = 0
+3 -3
View File
@@ -1,9 +1,9 @@
import { sqliteTable, text, integer, index, primaryKey } from "drizzle-orm/sqlite-core" import { sqliteTable, text, integer, index, primaryKey } from "drizzle-orm/sqlite-core"
import { ProjectTable } from "../project/project.sql" import { ProjectTable } from "../project/project.sql"
import type { MessageV2 } from "./message-v2" import type { MessageV2 } from "./message-v2"
import type { Snapshot } from "../snapshot" import type { Snapshot } from "@/snapshot"
import type { PermissionNext } from "../permission/next" import type { PermissionNext } from "@/permission/next"
import { Timestamps } from "../storage/schema.sql" import { Timestamps } from "@/storage/schema.sql"
type PartData = Omit<MessageV2.Part, "id" | "sessionID" | "messageID"> type PartData = Omit<MessageV2.Part, "id" | "sessionID" | "messageID">
type InfoData = Omit<MessageV2.Info, "id" | "sessionID"> type InfoData = Omit<MessageV2.Info, "id" | "sessionID">
+15 -73
View File
@@ -1,5 +1,4 @@
import { Bus } from "@/bus" import { Bus } from "@/bus"
import { Account } from "@/account"
import { Config } from "@/config/config" import { Config } from "@/config/config"
import { Provider } from "@/provider/provider" import { Provider } from "@/provider/provider"
import { Session } from "@/session" import { Session } from "@/session"
@@ -12,51 +11,8 @@ import type * as SDK from "@opencode-ai/sdk/v2"
export namespace ShareNext { export namespace ShareNext {
const log = Log.create({ service: "share-next" }) const log = Log.create({ service: "share-next" })
type ApiEndpoints = {
create: string
sync: (shareId: string) => string
remove: (shareId: string) => string
data: (shareId: string) => string
}
function apiEndpoints(resource: string): ApiEndpoints {
return {
create: `/api/${resource}`,
sync: (shareId) => `/api/${resource}/${shareId}/sync`,
remove: (shareId) => `/api/${resource}/${shareId}`,
data: (shareId) => `/api/${resource}/${shareId}/data`,
}
}
const legacyApi = apiEndpoints("share")
const consoleApi = apiEndpoints("shares")
export async function url() { export async function url() {
const req = await request() return Config.get().then((x) => x.enterprise?.url ?? "https://opncd.ai")
return req.baseUrl
}
export async function request(): Promise<{
headers: Record<string, string>
api: ApiEndpoints
baseUrl: string
}> {
const headers: Record<string, string> = {}
const active = Account.active()
if (!active?.active_org_id) {
const baseUrl = await Config.get().then((x) => x.enterprise?.url ?? "https://opncd.ai")
return { headers, api: legacyApi, baseUrl }
}
const token = await Account.token(active.id)
if (!token) {
throw new Error("No active account token available for sharing")
}
headers["authorization"] = `Bearer ${token}`
headers["x-org-id"] = active.active_org_id
return { headers, api: consoleApi, baseUrl: active.url }
} }
const disabled = process.env["OPENCODE_DISABLE_SHARE"] === "true" || process.env["OPENCODE_DISABLE_SHARE"] === "1" const disabled = process.env["OPENCODE_DISABLE_SHARE"] === "true" || process.env["OPENCODE_DISABLE_SHARE"] === "1"
@@ -112,20 +68,15 @@ export namespace ShareNext {
export async function create(sessionID: string) { export async function create(sessionID: string) {
if (disabled) return { id: "", url: "", secret: "" } if (disabled) return { id: "", url: "", secret: "" }
log.info("creating share", { sessionID }) log.info("creating share", { sessionID })
const req = await request() const result = await fetch(`${await url()}/api/share`, {
const response = await fetch(`${req.baseUrl}${req.api.create}`, {
method: "POST", method: "POST",
headers: { ...req.headers, "Content-Type": "application/json" }, headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ sessionID: sessionID }), body: JSON.stringify({ sessionID: sessionID }),
}) })
.then((x) => x.json())
if (!response.ok) { .then((x) => x as { id: string; url: string; secret: string })
const message = await response.text().catch(() => response.statusText)
throw new Error(`Failed to create share (${response.status}): ${message || response.statusText}`)
}
const result = (await response.json()) as { id: string; url: string; secret: string }
Database.use((db) => Database.use((db) =>
db db
.insert(SessionShareTable) .insert(SessionShareTable)
@@ -208,19 +159,16 @@ export namespace ShareNext {
const share = get(sessionID) const share = get(sessionID)
if (!share) return if (!share) return
const req = await request() await fetch(`${await url()}/api/share/${share.id}/sync`, {
const response = await fetch(`${req.baseUrl}${req.api.sync(share.id)}`, {
method: "POST", method: "POST",
headers: { ...req.headers, "Content-Type": "application/json" }, headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ body: JSON.stringify({
secret: share.secret, secret: share.secret,
data: Array.from(queued.data.values()), data: Array.from(queued.data.values()),
}), }),
}) })
if (!response.ok) {
log.warn("failed to sync share", { sessionID, shareID: share.id, status: response.status })
}
}, 1000) }, 1000)
queue.set(sessionID, { timeout, data: dataMap }) queue.set(sessionID, { timeout, data: dataMap })
} }
@@ -230,21 +178,15 @@ export namespace ShareNext {
log.info("removing share", { sessionID }) log.info("removing share", { sessionID })
const share = get(sessionID) const share = get(sessionID)
if (!share) return if (!share) return
await fetch(`${await url()}/api/share/${share.id}`, {
const req = await request()
const response = await fetch(`${req.baseUrl}${req.api.remove(share.id)}`, {
method: "DELETE", method: "DELETE",
headers: { ...req.headers, "Content-Type": "application/json" }, headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ body: JSON.stringify({
secret: share.secret, secret: share.secret,
}), }),
}) })
if (!response.ok) {
const message = await response.text().catch(() => response.statusText)
throw new Error(`Failed to remove share (${response.status}): ${message || response.statusText}`)
}
Database.use((db) => db.delete(SessionShareTable).where(eq(SessionShareTable.session_id, sessionID)).run()) Database.use((db) => db.delete(SessionShareTable).where(eq(SessionShareTable.session_id, sessionID)).run())
} }
+1 -1
View File
@@ -1,6 +1,6 @@
import { sqliteTable, text } from "drizzle-orm/sqlite-core" import { sqliteTable, text } from "drizzle-orm/sqlite-core"
import { SessionTable } from "../session/session.sql" import { SessionTable } from "../session/session.sql"
import { Timestamps } from "../storage/schema.sql" import { Timestamps } from "@/storage/schema.sql"
export const SessionShareTable = sqliteTable("session_share", { export const SessionShareTable = sqliteTable("session_share", {
session_id: text() session_id: text()
-8
View File
@@ -1,8 +0,0 @@
import { Database } from "bun:sqlite"
import { drizzle } from "drizzle-orm/bun-sqlite"
export function init(path: string) {
const sqlite = new Database(path, { create: true })
const db = drizzle({ client: sqlite })
return db
}
-8
View File
@@ -1,8 +0,0 @@
import { DatabaseSync } from "node:sqlite"
import { drizzle } from "drizzle-orm/node-sqlite"
export function init(path: string) {
const sqlite = new DatabaseSync(path)
const db = drizzle({ client: sqlite })
return db
}
+24 -12
View File
@@ -1,4 +1,5 @@
import { type SQLiteBunDatabase } from "drizzle-orm/bun-sqlite" import { Database as BunDatabase } from "bun:sqlite"
import { drizzle, type SQLiteBunDatabase } from "drizzle-orm/bun-sqlite"
import { migrate } from "drizzle-orm/bun-sqlite/migrator" import { migrate } from "drizzle-orm/bun-sqlite/migrator"
import { type SQLiteTransaction } from "drizzle-orm/sqlite-core" import { type SQLiteTransaction } from "drizzle-orm/sqlite-core"
export * from "drizzle-orm" export * from "drizzle-orm"
@@ -10,10 +11,10 @@ import { NamedError } from "@opencode-ai/util/error"
import z from "zod" import z from "zod"
import path from "path" import path from "path"
import { readFileSync, readdirSync, existsSync } from "fs" import { readFileSync, readdirSync, existsSync } from "fs"
import * as schema from "./schema"
import { Installation } from "../installation" import { Installation } from "../installation"
import { Flag } from "../flag/flag" import { Flag } from "../flag/flag"
import { iife } from "@/util/iife" import { iife } from "@/util/iife"
import { init } from "#db"
declare const OPENCODE_MIGRATIONS: { sql: string; timestamp: number; name: string }[] | undefined declare const OPENCODE_MIGRATIONS: { sql: string; timestamp: number; name: string }[] | undefined
@@ -35,12 +36,17 @@ export namespace Database {
return path.join(Global.Path.data, `opencode-${safe}.db`) return path.join(Global.Path.data, `opencode-${safe}.db`)
}) })
export type Transaction = SQLiteTransaction<"sync", void> type Schema = typeof schema
export type Transaction = SQLiteTransaction<"sync", void, Schema>
type Client = SQLiteBunDatabase type Client = SQLiteBunDatabase<Schema>
type Journal = { sql: string; timestamp: number; name: string }[] type Journal = { sql: string; timestamp: number; name: string }[]
const state = {
sqlite: undefined as BunDatabase | undefined,
}
function time(tag: string) { function time(tag: string) {
const match = /^(\d{4})(\d{2})(\d{2})(\d{2})(\d{2})(\d{2})/.exec(tag) const match = /^(\d{4})(\d{2})(\d{2})(\d{2})(\d{2})(\d{2})/.exec(tag)
if (!match) return 0 if (!match) return 0
@@ -77,14 +83,17 @@ export namespace Database {
export const Client = lazy(() => { export const Client = lazy(() => {
log.info("opening database", { path: Path }) log.info("opening database", { path: Path })
const db = init(Path) const sqlite = new BunDatabase(Path, { create: true })
state.sqlite = sqlite
db.run("PRAGMA journal_mode = WAL") sqlite.run("PRAGMA journal_mode = WAL")
db.run("PRAGMA synchronous = NORMAL") sqlite.run("PRAGMA synchronous = NORMAL")
db.run("PRAGMA busy_timeout = 5000") sqlite.run("PRAGMA busy_timeout = 5000")
db.run("PRAGMA cache_size = -64000") sqlite.run("PRAGMA cache_size = -64000")
db.run("PRAGMA foreign_keys = ON") sqlite.run("PRAGMA foreign_keys = ON")
db.run("PRAGMA wal_checkpoint(PASSIVE)") sqlite.run("PRAGMA wal_checkpoint(PASSIVE)")
const db = drizzle({ client: sqlite, schema })
// Apply schema migrations // Apply schema migrations
const entries = const entries =
@@ -108,7 +117,10 @@ export namespace Database {
}) })
export function close() { export function close() {
Client().$client.close() const sqlite = state.sqlite
if (!sqlite) return
sqlite.close()
state.sqlite = undefined
Client.reset() Client.reset()
} }
+2 -2
View File
@@ -1,5 +1,5 @@
export { AccountTable, AccountStateTable, ControlAccountTable } from "../account/account.sql" export { ControlAccountTable } from "../control/control.sql"
export { ProjectTable } from "../project/project.sql"
export { SessionTable, MessageTable, PartTable, TodoTable, PermissionTable } from "../session/session.sql" export { SessionTable, MessageTable, PartTable, TodoTable, PermissionTable } from "../session/session.sql"
export { SessionShareTable } from "../share/share.sql" export { SessionShareTable } from "../share/share.sql"
export { ProjectTable } from "../project/project.sql"
export { WorkspaceTable } from "../control-plane/workspace.sql" export { WorkspaceTable } from "../control-plane/workspace.sql"
+1 -1
View File
@@ -45,7 +45,7 @@ export namespace ToolRegistry {
if (matches.length) await Config.waitForDependencies() if (matches.length) await Config.waitForDependencies()
for (const match of matches) { for (const match of matches) {
const namespace = path.basename(match, path.extname(match)) const namespace = path.basename(match, path.extname(match))
const mod = await import(process.platform === "win32" ? match : pathToFileURL(match).href) const mod = await import(pathToFileURL(match).href)
for (const [id, def] of Object.entries<ToolDefinition>(mod)) { for (const [id, def] of Object.entries<ToolDefinition>(mod)) {
custom.push(fromPlugin(id === "default" ? namespace : `${namespace}_${id}`, def)) custom.push(fromPlugin(id === "default" ? namespace : `${namespace}_${id}`, def))
} }
@@ -1,11 +0,0 @@
import { Schedule } from "effect"
import { HttpClient } from "effect/unstable/http"
export const withTransientReadRetry = <E, R>(client: HttpClient.HttpClient.With<E, R>) =>
client.pipe(
HttpClient.retryTransient({
retryOn: "errors-and-responses",
times: 2,
schedule: Schedule.exponential(200).pipe(Schedule.jittered),
}),
)
-2
View File
@@ -13,7 +13,6 @@ export namespace Process {
abort?: AbortSignal abort?: AbortSignal
kill?: NodeJS.Signals | number kill?: NodeJS.Signals | number
timeout?: number timeout?: number
shell?: string
} }
export interface RunOptions extends Omit<Options, "stdout" | "stderr"> { export interface RunOptions extends Omit<Options, "stdout" | "stderr"> {
@@ -59,7 +58,6 @@ export namespace Process {
const proc = launch(cmd[0], cmd.slice(1), { const proc = launch(cmd[0], cmd.slice(1), {
cwd: opts.cwd, cwd: opts.cwd,
shell: opts.shell,
env: opts.env === null ? {} : opts.env ? { ...process.env, ...opts.env } : undefined, env: opts.env === null ? {} : opts.env ? { ...process.env, ...opts.env } : undefined,
stdio: [opts.stdin ?? "ignore", opts.stdout ?? "ignore", opts.stderr ?? "ignore"], stdio: [opts.stdin ?? "ignore", opts.stdout ?? "ignore", opts.stderr ?? "ignore"],
}) })
-17
View File
@@ -1,17 +0,0 @@
import { Schema } from "effect"
/**
* Attach static methods to a schema object. Designed to be used with `.pipe()`:
*
* @example
* export const Foo = fooSchema.pipe(
* withStatics((schema) => ({
* zero: schema.makeUnsafe(0),
* from: Schema.decodeUnknownOption(schema),
* }))
* )
*/
export const withStatics =
<S extends object, M extends Record<string, unknown>>(methods: (schema: S) => M) =>
(schema: S): S & M =>
Object.assign(schema, methods(schema))
+1 -5
View File
@@ -1,13 +1,9 @@
import whichPkg from "which" import whichPkg from "which"
import path from "path"
import { Global } from "../global"
export function which(cmd: string, env?: NodeJS.ProcessEnv) { export function which(cmd: string, env?: NodeJS.ProcessEnv) {
const base = env?.PATH ?? env?.Path ?? process.env.PATH ?? process.env.Path ?? ""
const full = base ? base + path.delimiter + Global.Path.bin : Global.Path.bin
const result = whichPkg.sync(cmd, { const result = whichPkg.sync(cmd, {
nothrow: true, nothrow: true,
path: full, path: env?.PATH ?? env?.Path ?? process.env.PATH ?? process.env.Path,
pathExt: env?.PATHEXT ?? env?.PathExt ?? process.env.PATHEXT ?? process.env.PathExt, pathExt: env?.PATHEXT ?? env?.PathExt ?? process.env.PATHEXT ?? process.env.PathExt,
}) })
return typeof result === "string" ? result : null return typeof result === "string" ? result : null
-338
View File
@@ -1,338 +0,0 @@
import { expect } from "bun:test"
import { Effect, Layer, Option } from "effect"
import { AccountRepo } from "../../src/account/repo"
import { AccountID, OrgID } from "../../src/account/schema"
import { Database } from "../../src/storage/db"
import { testEffect } from "../fixture/effect"
const truncate = Layer.effectDiscard(
Effect.sync(() => {
const db = Database.Client()
db.run(/*sql*/ `DELETE FROM account_state`)
db.run(/*sql*/ `DELETE FROM account`)
}),
)
const it = testEffect(Layer.merge(AccountRepo.layer, truncate))
it.effect(
"list returns empty when no accounts exist",
Effect.gen(function* () {
const accounts = yield* AccountRepo.use((r) => r.list())
expect(accounts).toEqual([])
}),
)
it.effect(
"active returns none when no accounts exist",
Effect.gen(function* () {
const active = yield* AccountRepo.use((r) => r.active())
expect(Option.isNone(active)).toBe(true)
}),
)
it.effect(
"persistAccount inserts and getRow retrieves",
Effect.gen(function* () {
const id = AccountID.make("user-1")
yield* AccountRepo.use((r) =>
r.persistAccount({
id,
email: "test@example.com",
url: "https://control.example.com",
accessToken: "at_123",
refreshToken: "rt_456",
expiry: Date.now() + 3600_000,
orgID: Option.some(OrgID.make("org-1")),
}),
)
const row = yield* AccountRepo.use((r) => r.getRow(id))
expect(Option.isSome(row)).toBe(true)
const value = Option.getOrThrow(row)
expect(value.id).toBe("user-1")
expect(value.email).toBe("test@example.com")
const active = yield* AccountRepo.use((r) => r.active())
expect(Option.getOrThrow(active).active_org_id).toBe(OrgID.make("org-1"))
}),
)
it.effect(
"persistAccount sets the active account and org",
Effect.gen(function* () {
const id1 = AccountID.make("user-1")
const id2 = AccountID.make("user-2")
yield* AccountRepo.use((r) =>
r.persistAccount({
id: id1,
email: "first@example.com",
url: "https://control.example.com",
accessToken: "at_1",
refreshToken: "rt_1",
expiry: Date.now() + 3600_000,
orgID: Option.some(OrgID.make("org-1")),
}),
)
yield* AccountRepo.use((r) =>
r.persistAccount({
id: id2,
email: "second@example.com",
url: "https://control.example.com",
accessToken: "at_2",
refreshToken: "rt_2",
expiry: Date.now() + 3600_000,
orgID: Option.some(OrgID.make("org-2")),
}),
)
// Last persisted account is active with its org
const active = yield* AccountRepo.use((r) => r.active())
expect(Option.isSome(active)).toBe(true)
expect(Option.getOrThrow(active).id).toBe(AccountID.make("user-2"))
expect(Option.getOrThrow(active).active_org_id).toBe(OrgID.make("org-2"))
}),
)
it.effect(
"list returns all accounts",
Effect.gen(function* () {
const id1 = AccountID.make("user-1")
const id2 = AccountID.make("user-2")
yield* AccountRepo.use((r) =>
r.persistAccount({
id: id1,
email: "a@example.com",
url: "https://control.example.com",
accessToken: "at_1",
refreshToken: "rt_1",
expiry: Date.now() + 3600_000,
orgID: Option.none(),
}),
)
yield* AccountRepo.use((r) =>
r.persistAccount({
id: id2,
email: "b@example.com",
url: "https://control.example.com",
accessToken: "at_2",
refreshToken: "rt_2",
expiry: Date.now() + 3600_000,
orgID: Option.some(OrgID.make("org-1")),
}),
)
const accounts = yield* AccountRepo.use((r) => r.list())
expect(accounts.length).toBe(2)
expect(accounts.map((a) => a.email).sort()).toEqual(["a@example.com", "b@example.com"])
}),
)
it.effect(
"remove deletes an account",
Effect.gen(function* () {
const id = AccountID.make("user-1")
yield* AccountRepo.use((r) =>
r.persistAccount({
id,
email: "test@example.com",
url: "https://control.example.com",
accessToken: "at_1",
refreshToken: "rt_1",
expiry: Date.now() + 3600_000,
orgID: Option.none(),
}),
)
yield* AccountRepo.use((r) => r.remove(id))
const row = yield* AccountRepo.use((r) => r.getRow(id))
expect(Option.isNone(row)).toBe(true)
}),
)
it.effect(
"use stores the selected org and marks the account active",
Effect.gen(function* () {
const id1 = AccountID.make("user-1")
const id2 = AccountID.make("user-2")
yield* AccountRepo.use((r) =>
r.persistAccount({
id: id1,
email: "first@example.com",
url: "https://control.example.com",
accessToken: "at_1",
refreshToken: "rt_1",
expiry: Date.now() + 3600_000,
orgID: Option.none(),
}),
)
yield* AccountRepo.use((r) =>
r.persistAccount({
id: id2,
email: "second@example.com",
url: "https://control.example.com",
accessToken: "at_2",
refreshToken: "rt_2",
expiry: Date.now() + 3600_000,
orgID: Option.none(),
}),
)
yield* AccountRepo.use((r) => r.use(id1, Option.some(OrgID.make("org-99"))))
const active1 = yield* AccountRepo.use((r) => r.active())
expect(Option.getOrThrow(active1).id).toBe(id1)
expect(Option.getOrThrow(active1).active_org_id).toBe(OrgID.make("org-99"))
yield* AccountRepo.use((r) => r.use(id1, Option.none()))
const active2 = yield* AccountRepo.use((r) => r.active())
expect(Option.getOrThrow(active2).active_org_id).toBeNull()
}),
)
it.effect(
"persistToken updates token fields",
Effect.gen(function* () {
const id = AccountID.make("user-1")
yield* AccountRepo.use((r) =>
r.persistAccount({
id,
email: "test@example.com",
url: "https://control.example.com",
accessToken: "old_token",
refreshToken: "old_refresh",
expiry: 1000,
orgID: Option.none(),
}),
)
const expiry = Date.now() + 7200_000
yield* AccountRepo.use((r) =>
r.persistToken({
accountID: id,
accessToken: "new_token",
refreshToken: "new_refresh",
expiry: Option.some(expiry),
}),
)
const row = yield* AccountRepo.use((r) => r.getRow(id))
const value = Option.getOrThrow(row)
expect(value.access_token).toBe("new_token")
expect(value.refresh_token).toBe("new_refresh")
expect(value.token_expiry).toBe(expiry)
}),
)
it.effect(
"persistToken with no expiry sets token_expiry to null",
Effect.gen(function* () {
const id = AccountID.make("user-1")
yield* AccountRepo.use((r) =>
r.persistAccount({
id,
email: "test@example.com",
url: "https://control.example.com",
accessToken: "old_token",
refreshToken: "old_refresh",
expiry: 1000,
orgID: Option.none(),
}),
)
yield* AccountRepo.use((r) =>
r.persistToken({
accountID: id,
accessToken: "new_token",
refreshToken: "new_refresh",
expiry: Option.none(),
}),
)
const row = yield* AccountRepo.use((r) => r.getRow(id))
expect(Option.getOrThrow(row).token_expiry).toBeNull()
}),
)
it.effect(
"persistAccount upserts on conflict",
Effect.gen(function* () {
const id = AccountID.make("user-1")
yield* AccountRepo.use((r) =>
r.persistAccount({
id,
email: "test@example.com",
url: "https://control.example.com",
accessToken: "at_v1",
refreshToken: "rt_v1",
expiry: 1000,
orgID: Option.some(OrgID.make("org-1")),
}),
)
yield* AccountRepo.use((r) =>
r.persistAccount({
id,
email: "test@example.com",
url: "https://control.example.com",
accessToken: "at_v2",
refreshToken: "rt_v2",
expiry: 2000,
orgID: Option.some(OrgID.make("org-2")),
}),
)
const accounts = yield* AccountRepo.use((r) => r.list())
expect(accounts.length).toBe(1)
const row = yield* AccountRepo.use((r) => r.getRow(id))
const value = Option.getOrThrow(row)
expect(value.access_token).toBe("at_v2")
const active = yield* AccountRepo.use((r) => r.active())
expect(Option.getOrThrow(active).active_org_id).toBe(OrgID.make("org-2"))
}),
)
it.effect(
"remove clears active state when deleting the active account",
Effect.gen(function* () {
const id = AccountID.make("user-1")
yield* AccountRepo.use((r) =>
r.persistAccount({
id,
email: "test@example.com",
url: "https://control.example.com",
accessToken: "at_1",
refreshToken: "rt_1",
expiry: Date.now() + 3600_000,
orgID: Option.some(OrgID.make("org-1")),
}),
)
yield* AccountRepo.use((r) => r.remove(id))
const active = yield* AccountRepo.use((r) => r.active())
expect(Option.isNone(active)).toBe(true)
}),
)
it.effect(
"getRow returns none for nonexistent account",
Effect.gen(function* () {
const row = yield* AccountRepo.use((r) => r.getRow(AccountID.make("nope")))
expect(Option.isNone(row)).toBe(true)
}),
)
@@ -1,223 +0,0 @@
import { expect } from "bun:test"
import { Effect, Layer, Option, Ref, Schema } from "effect"
import { HttpClient, HttpClientResponse } from "effect/unstable/http"
import { AccountRepo } from "../../src/account/repo"
import { AccountService } from "../../src/account/service"
import { AccountID, Login, Org, OrgID } from "../../src/account/schema"
import { Database } from "../../src/storage/db"
import { testEffect } from "../fixture/effect"
const truncate = Layer.effectDiscard(
Effect.sync(() => {
const db = Database.Client()
db.run(/*sql*/ `DELETE FROM account_state`)
db.run(/*sql*/ `DELETE FROM account`)
}),
)
const it = testEffect(Layer.merge(AccountRepo.layer, truncate))
const live = (client: HttpClient.HttpClient) =>
AccountService.layer.pipe(Layer.provide(Layer.succeed(HttpClient.HttpClient, client)))
const json = (req: Parameters<typeof HttpClientResponse.fromWeb>[0], body: unknown, status = 200) =>
HttpClientResponse.fromWeb(
req,
new Response(JSON.stringify(body), {
status,
headers: { "content-type": "application/json" },
}),
)
const encodeOrg = Schema.encodeSync(Org)
const org = (id: string, name: string) => encodeOrg(new Org({ id: OrgID.make(id), name }))
it.effect(
"orgsByAccount groups orgs per account",
Effect.gen(function* () {
yield* AccountRepo.use((r) =>
r.persistAccount({
id: AccountID.make("user-1"),
email: "one@example.com",
url: "https://one.example.com",
accessToken: "at_1",
refreshToken: "rt_1",
expiry: Date.now() + 60_000,
orgID: Option.none(),
}),
)
yield* AccountRepo.use((r) =>
r.persistAccount({
id: AccountID.make("user-2"),
email: "two@example.com",
url: "https://two.example.com",
accessToken: "at_2",
refreshToken: "rt_2",
expiry: Date.now() + 60_000,
orgID: Option.none(),
}),
)
const seen = yield* Ref.make<string[]>([])
const client = HttpClient.make((req) =>
Effect.gen(function* () {
yield* Ref.update(seen, (xs) => [...xs, `${req.method} ${req.url}`])
if (req.url === "https://one.example.com/api/orgs") {
return json(req, [org("org-1", "One")])
}
if (req.url === "https://two.example.com/api/orgs") {
return json(req, [org("org-2", "Two A"), org("org-3", "Two B")])
}
return json(req, [], 404)
}),
)
const rows = yield* AccountService.use((s) => s.orgsByAccount()).pipe(Effect.provide(live(client)))
expect(rows.map((row) => [row.account.id, row.orgs.map((org) => org.id)]).map(([id, orgs]) => [id, orgs])).toEqual([
[AccountID.make("user-1"), [OrgID.make("org-1")]],
[AccountID.make("user-2"), [OrgID.make("org-2"), OrgID.make("org-3")]],
])
expect(yield* Ref.get(seen)).toEqual([
"GET https://one.example.com/api/orgs",
"GET https://two.example.com/api/orgs",
])
}),
)
it.effect(
"token refresh persists the new token",
Effect.gen(function* () {
const id = AccountID.make("user-1")
yield* AccountRepo.use((r) =>
r.persistAccount({
id,
email: "user@example.com",
url: "https://one.example.com",
accessToken: "at_old",
refreshToken: "rt_old",
expiry: Date.now() - 1_000,
orgID: Option.none(),
}),
)
const client = HttpClient.make((req) =>
Effect.succeed(
req.url === "https://one.example.com/oauth/token"
? json(req, {
access_token: "at_new",
refresh_token: "rt_new",
expires_in: 60,
})
: json(req, {}, 404),
),
)
const token = yield* AccountService.use((s) => s.token(id)).pipe(Effect.provide(live(client)))
expect(Option.getOrThrow(token)).toBeDefined()
expect(String(Option.getOrThrow(token))).toBe("at_new")
const row = yield* AccountRepo.use((r) => r.getRow(id))
const value = Option.getOrThrow(row)
expect(value.access_token).toBe("at_new")
expect(value.refresh_token).toBe("rt_new")
expect(value.token_expiry).toBeGreaterThan(Date.now())
}),
)
it.effect(
"config sends the selected org header",
Effect.gen(function* () {
const id = AccountID.make("user-1")
yield* AccountRepo.use((r) =>
r.persistAccount({
id,
email: "user@example.com",
url: "https://one.example.com",
accessToken: "at_1",
refreshToken: "rt_1",
expiry: Date.now() + 60_000,
orgID: Option.none(),
}),
)
const seen = yield* Ref.make<{ auth?: string; org?: string }>({})
const client = HttpClient.make((req) =>
Effect.gen(function* () {
yield* Ref.set(seen, {
auth: req.headers.authorization,
org: req.headers["x-org-id"],
})
if (req.url === "https://one.example.com/api/config") {
return json(req, { config: { theme: "light", seats: 5 } })
}
return json(req, {}, 404)
}),
)
const cfg = yield* AccountService.use((s) => s.config(id, OrgID.make("org-9"))).pipe(Effect.provide(live(client)))
expect(Option.getOrThrow(cfg)).toEqual({ theme: "light", seats: 5 })
expect(yield* Ref.get(seen)).toEqual({
auth: "Bearer at_1",
org: "org-9",
})
}),
)
it.effect(
"poll stores the account and first org on success",
Effect.gen(function* () {
const login = new Login({
code: "device-code",
user: "user-code",
url: "https://one.example.com/verify",
server: "https://one.example.com",
expiry: 600,
interval: 5,
})
const client = HttpClient.make((req) =>
Effect.succeed(
req.url === "https://one.example.com/auth/device/token"
? json(req, {
access_token: "at_1",
refresh_token: "rt_1",
expires_in: 60,
})
: req.url === "https://one.example.com/api/user"
? json(req, { id: "user-1", email: "user@example.com" })
: req.url === "https://one.example.com/api/orgs"
? json(req, [org("org-1", "One")])
: json(req, {}, 404),
),
)
const res = yield* AccountService.use((s) => s.poll(login)).pipe(Effect.provide(live(client)))
expect(res._tag).toBe("PollSuccess")
if (res._tag === "PollSuccess") {
expect(res.email).toBe("user@example.com")
}
const active = yield* AccountRepo.use((r) => r.active())
expect(Option.getOrThrow(active)).toEqual(
expect.objectContaining({
id: "user-1",
email: "user@example.com",
active_org_id: "org-1",
}),
)
}),
)
+53
View File
@@ -0,0 +1,53 @@
import { describe, expect, test } from "bun:test"
import fs from "fs/promises"
import path from "path"
describe("BunProc registry configuration", () => {
test("should not contain hardcoded registry parameters", async () => {
// Read the bun/index.ts file
const bunIndexPath = path.join(__dirname, "../src/bun/index.ts")
const content = await fs.readFile(bunIndexPath, "utf-8")
// Verify that no hardcoded registry is present
expect(content).not.toContain("--registry=")
expect(content).not.toContain("hasNpmRcConfig")
expect(content).not.toContain("NpmRc")
})
test("should use Bun's default registry resolution", async () => {
// Read the bun/index.ts file
const bunIndexPath = path.join(__dirname, "../src/bun/index.ts")
const content = await fs.readFile(bunIndexPath, "utf-8")
// Verify that it uses Bun's default resolution
expect(content).toContain("Bun's default registry resolution")
expect(content).toContain("Bun will use them automatically")
expect(content).toContain("No need to pass --registry flag")
})
test("should have correct command structure without registry", async () => {
// Read the bun/index.ts file
const bunIndexPath = path.join(__dirname, "../src/bun/index.ts")
const content = await fs.readFile(bunIndexPath, "utf-8")
// Extract the install function
const installFunctionMatch = content.match(/export async function install[\s\S]*?^ }/m)
expect(installFunctionMatch).toBeTruthy()
if (installFunctionMatch) {
const installFunction = installFunctionMatch[0]
// Verify expected arguments are present
expect(installFunction).toContain('"add"')
expect(installFunction).toContain('"--force"')
expect(installFunction).toContain('"--exact"')
expect(installFunction).toContain('"--cwd"')
expect(installFunction).toContain("Global.Path.cache")
expect(installFunction).toContain('pkg + "@" + version')
// Verify no registry argument is added
expect(installFunction).not.toContain('"--registry"')
expect(installFunction).not.toContain('args.push("--registry')
}
})
})
+1 -17
View File
@@ -1,10 +1,5 @@
import { test, expect } from "bun:test" import { test, expect } from "bun:test"
import { import { parseShareUrl, transformShareData, type ShareData } from "../../src/cli/cmd/import"
parseShareUrl,
shouldAttachShareAuthHeaders,
transformShareData,
type ShareData,
} from "../../src/cli/cmd/import"
// parseShareUrl tests // parseShareUrl tests
test("parses valid share URLs", () => { test("parses valid share URLs", () => {
@@ -20,17 +15,6 @@ test("rejects invalid URLs", () => {
expect(parseShareUrl("not-a-url")).toBeNull() expect(parseShareUrl("not-a-url")).toBeNull()
}) })
test("only attaches share auth headers for same-origin URLs", () => {
expect(shouldAttachShareAuthHeaders("https://control.example.com/share/abc", "https://control.example.com")).toBe(
true,
)
expect(shouldAttachShareAuthHeaders("https://other.example.com/share/abc", "https://control.example.com")).toBe(false)
expect(shouldAttachShareAuthHeaders("https://control.example.com:443/share/abc", "https://control.example.com")).toBe(
true,
)
expect(shouldAttachShareAuthHeaders("not-a-url", "https://control.example.com")).toBe(false)
})
// transformShareData tests // transformShareData tests
test("transforms share data to storage format", () => { test("transforms share data to storage format", () => {
const data: ShareData[] = [ const data: ShareData[] = [
@@ -1,5 +1,5 @@
import { test, expect, describe } from "bun:test" import { test, expect, describe } from "bun:test"
import { resolvePluginProviders } from "../../src/cli/cmd/providers" import { resolvePluginProviders } from "../../src/cli/cmd/auth"
import type { Hooks } from "@opencode-ai/plugin" import type { Hooks } from "@opencode-ai/plugin"
function hookWithAuth(provider: string): Hooks { function hookWithAuth(provider: string): Hooks {
@@ -2,7 +2,6 @@ import { test, expect, describe, mock, afterEach } from "bun:test"
import { Config } from "../../src/config/config" import { Config } from "../../src/config/config"
import { Instance } from "../../src/project/instance" import { Instance } from "../../src/project/instance"
import { Auth } from "../../src/auth" import { Auth } from "../../src/auth"
import { AccessToken, Account, AccountID, OrgID } from "../../src/account"
import { tmpdir } from "../fixture/fixture" import { tmpdir } from "../fixture/fixture"
import path from "path" import path from "path"
import fs from "fs/promises" import fs from "fs/promises"
@@ -243,52 +242,6 @@ test("preserves env variables when adding $schema to config", async () => {
} }
}) })
test("resolves env templates in account config with account token", async () => {
const originalActive = Account.active
const originalConfig = Account.config
const originalToken = Account.token
const originalControlToken = process.env["OPENCODE_CONSOLE_TOKEN"]
Account.active = mock(() => ({
id: AccountID.make("account-1"),
email: "user@example.com",
url: "https://control.example.com",
active_org_id: OrgID.make("org-1"),
}))
Account.config = mock(async () => ({
provider: {
opencode: {
options: {
apiKey: "{env:OPENCODE_CONSOLE_TOKEN}",
},
},
},
}))
Account.token = mock(async () => AccessToken.make("st_test_token"))
try {
await using tmp = await tmpdir()
await Instance.provide({
directory: tmp.path,
fn: async () => {
const config = await Config.get()
expect(config.provider?.["opencode"]?.options?.apiKey).toBe("st_test_token")
},
})
} finally {
Account.active = originalActive
Account.config = originalConfig
Account.token = originalToken
if (originalControlToken !== undefined) {
process.env["OPENCODE_CONSOLE_TOKEN"] = originalControlToken
} else {
delete process.env["OPENCODE_CONSOLE_TOKEN"]
}
}
})
test("handles file inclusion substitution", async () => { test("handles file inclusion substitution", async () => {
await using tmp = await tmpdir({ await using tmp = await tmpdir({
init: async (dir) => { init: async (dir) => {
-7
View File
@@ -1,7 +0,0 @@
import { test } from "bun:test"
import { Effect, Layer } from "effect"
export const testEffect = <R, E>(layer: Layer.Layer<R, E, never>) => ({
effect: <A, E2>(name: string, value: Effect.Effect<A, E2, R>) =>
test(name, () => Effect.runPromise(value.pipe(Effect.provide(layer)))),
})
@@ -1,76 +0,0 @@
import { test, expect, mock } from "bun:test"
import { ShareNext } from "../../src/share/share-next"
import { AccessToken, Account, AccountID, OrgID } from "../../src/account"
import { Config } from "../../src/config/config"
test("ShareNext.request uses legacy share API without active org account", async () => {
const originalActive = Account.active
const originalConfigGet = Config.get
Account.active = mock(() => undefined)
Config.get = mock(async () => ({ enterprise: { url: "https://legacy-share.example.com" } }))
try {
const req = await ShareNext.request()
expect(req.api.create).toBe("/api/share")
expect(req.api.sync("shr_123")).toBe("/api/share/shr_123/sync")
expect(req.api.remove("shr_123")).toBe("/api/share/shr_123")
expect(req.api.data("shr_123")).toBe("/api/share/shr_123/data")
expect(req.baseUrl).toBe("https://legacy-share.example.com")
expect(req.headers).toEqual({})
} finally {
Account.active = originalActive
Config.get = originalConfigGet
}
})
test("ShareNext.request uses org share API with auth headers when account is active", async () => {
const originalActive = Account.active
const originalToken = Account.token
Account.active = mock(() => ({
id: AccountID.make("account-1"),
email: "user@example.com",
url: "https://control.example.com",
active_org_id: OrgID.make("org-1"),
}))
Account.token = mock(async () => AccessToken.make("st_test_token"))
try {
const req = await ShareNext.request()
expect(req.api.create).toBe("/api/shares")
expect(req.api.sync("shr_123")).toBe("/api/shares/shr_123/sync")
expect(req.api.remove("shr_123")).toBe("/api/shares/shr_123")
expect(req.api.data("shr_123")).toBe("/api/shares/shr_123/data")
expect(req.baseUrl).toBe("https://control.example.com")
expect(req.headers).toEqual({
authorization: "Bearer st_test_token",
"x-org-id": "org-1",
})
} finally {
Account.active = originalActive
Account.token = originalToken
}
})
test("ShareNext.request fails when org account has no token", async () => {
const originalActive = Account.active
const originalToken = Account.token
Account.active = mock(() => ({
id: AccountID.make("account-1"),
email: "user@example.com",
url: "https://control.example.com",
active_org_id: OrgID.make("org-1"),
}))
Account.token = mock(async () => undefined)
try {
await expect(ShareNext.request()).rejects.toThrow("No active account token available for sharing")
} finally {
Account.active = originalActive
Account.token = originalToken
}
})
-7
View File
@@ -11,13 +11,6 @@
"paths": { "paths": {
"@/*": ["./src/*"], "@/*": ["./src/*"],
"@tui/*": ["./src/cli/cmd/tui/*"] "@tui/*": ["./src/cli/cmd/tui/*"]
},
"plugins": [
{
"name": "@effect/language-service",
"transform": "@effect/language-service/transform",
"namespaceImportPackages": ["effect", "@effect/*"]
} }
]
} }
} }
+2 -2
View File
@@ -1,5 +1,5 @@
import { Plugin } from "./index.js" import { Plugin } from "./index"
import { tool } from "./tool.js" import { tool } from "./tool"
export const ExamplePlugin: Plugin = async (ctx) => { export const ExamplePlugin: Plugin = async (ctx) => {
return { return {
+3 -3
View File
@@ -12,10 +12,10 @@ import type {
Config, Config,
} from "@opencode-ai/sdk" } from "@opencode-ai/sdk"
import type { BunShell } from "./shell.js" import type { BunShell } from "./shell"
import { type ToolDefinition } from "./tool.js" import { type ToolDefinition } from "./tool"
export * from "./tool.js" export * from "./tool"
export type ProviderContext = { export type ProviderContext = {
source: "env" | "config" | "custom" | "api" source: "env" | "config" | "custom" | "api"
+2 -2
View File
@@ -3,9 +3,9 @@
"extends": "@tsconfig/node22/tsconfig.json", "extends": "@tsconfig/node22/tsconfig.json",
"compilerOptions": { "compilerOptions": {
"outDir": "dist", "outDir": "dist",
"module": "nodenext", "module": "preserve",
"declaration": true, "declaration": true,
"moduleResolution": "nodenext", "moduleResolution": "bundler",
"lib": ["es2022", "dom", "dom.iterable"] "lib": ["es2022", "dom", "dom.iterable"]
}, },
"include": ["src"] "include": ["src"]
+15 -80
View File
@@ -1,94 +1,29 @@
[data-component="card"] { [data-component="card"] {
--card-pad-y: 10px;
--card-pad-r: 12px;
--card-pad-l: 10px;
width: 100%; width: 100%;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
position: relative; background-color: var(--surface-inset-base);
background: transparent; border: 1px solid var(--border-weaker-base);
border: none; transition: background-color 0.15s ease;
border-radius: var(--radius-md); border-radius: var(--radius-md);
padding: var(--card-pad-y) var(--card-pad-r) var(--card-pad-y) var(--card-pad-l); padding: 6px 12px;
overflow: clip;
/* text-14-regular */ &[data-variant="error"] {
background-color: var(--surface-critical-weak);
border: 1px solid var(--border-critical-base);
color: rgba(218, 51, 25, 0.6);
/* text-12-regular */
font-family: var(--font-family-sans); font-family: var(--font-family-sans);
font-size: var(--font-size-base); font-size: var(--font-size-small);
font-style: normal; font-style: normal;
font-weight: var(--font-weight-regular); font-weight: var(--font-weight-regular);
line-height: var(--line-height-large); line-height: var(--line-height-large); /* 166.667% */
letter-spacing: var(--letter-spacing-normal); letter-spacing: var(--letter-spacing-normal);
color: var(--text-strong);
--card-gap: 8px; &[data-component="icon"] {
--card-icon: 16px; color: var(--icon-critical-active);
--card-indent: 0px;
--card-line-pad: 8px;
--card-accent: var(--icon-active);
&:has([data-slot="card-title"]) {
gap: 8px;
} }
&:has([data-slot="card-title-icon"]) {
--card-indent: calc(var(--card-icon) + var(--card-gap));
}
&::before {
content: "";
position: absolute;
left: 0;
top: var(--card-line-pad);
bottom: var(--card-line-pad);
width: 2px;
border-radius: 2px;
background-color: var(--card-accent);
}
:where([data-card="title"], [data-slot="card-title"]) {
color: var(--text-strong);
font-weight: var(--font-weight-medium);
}
:where([data-slot="card-title"]) {
display: flex;
align-items: center;
gap: var(--card-gap);
}
:where([data-slot="card-title"]) [data-component="icon"] {
color: var(--card-accent);
}
:where([data-slot="card-title-icon"]) {
display: inline-flex;
align-items: center;
justify-content: center;
width: var(--card-icon);
height: var(--card-icon);
flex: 0 0 auto;
}
:where([data-slot="card-title-icon"][data-placeholder]) [data-component="icon"] {
color: var(--text-weak);
}
:where([data-slot="card-title-icon"])
[data-slot="icon-svg"]
:is(path, line, polyline, polygon, rect, circle, ellipse)[stroke] {
stroke-width: 1.5px !important;
}
:where([data-card="description"], [data-slot="card-description"]) {
color: var(--text-base);
white-space: pre-wrap;
overflow-wrap: anywhere;
word-break: break-word;
}
:where([data-card="actions"], [data-slot="card-actions"]) {
padding-left: var(--card-indent);
} }
} }
+8 -6
View File
@@ -1,5 +1,5 @@
// @ts-nocheck // @ts-nocheck
import { Card, CardActions, CardDescription, CardTitle } from "./card" import { Card } from "./card"
import { Button } from "./button" import { Button } from "./button"
const docs = `### Overview const docs = `### Overview
@@ -49,13 +49,15 @@ export default {
render: (props: { variant?: "normal" | "error" | "warning" | "success" | "info" }) => { render: (props: { variant?: "normal" | "error" | "warning" | "success" | "info" }) => {
return ( return (
<Card variant={props.variant}> <Card variant={props.variant}>
<CardTitle variant={props.variant}>Card title</CardTitle> <div style={{ display: "flex", alignItems: "center", gap: "12px" }}>
<CardDescription>Small supporting text.</CardDescription> <div style={{ flex: 1 }}>
<CardActions> <div style={{ fontWeight: 500 }}>Card title</div>
<Button size="small" variant="secondary"> <div style={{ color: "var(--text-weak)", fontSize: "13px" }}>Small supporting text.</div>
</div>
<Button size="small" variant="ghost">
Action Action
</Button> </Button>
</CardActions> </div>
</Card> </Card>
) )
}, },
+3 -104
View File
@@ -1,57 +1,16 @@
import { type ComponentProps, splitProps } from "solid-js" import { type ComponentProps, splitProps } from "solid-js"
import { Icon, type IconProps } from "./icon"
type Variant = "normal" | "error" | "warning" | "success" | "info"
export interface CardProps extends ComponentProps<"div"> { export interface CardProps extends ComponentProps<"div"> {
variant?: Variant variant?: "normal" | "error" | "warning" | "success" | "info"
}
export interface CardTitleProps extends ComponentProps<"div"> {
variant?: Variant
/**
* Optional title icon.
*
* - `undefined`: picks a default icon based on `variant` (error/warning/success/info)
* - `false`/`null`: disables the icon
* - `Icon` name: forces a specific icon
*/
icon?: IconProps["name"] | false | null
}
function pick(variant: Variant) {
if (variant === "error") return "circle-ban-sign" as const
if (variant === "warning") return "warning" as const
if (variant === "success") return "circle-check" as const
if (variant === "info") return "help" as const
return
}
function mix(style: ComponentProps<"div">["style"], value?: string) {
if (!value) return style
if (!style) return { "--card-accent": value }
if (typeof style === "string") return `${style};--card-accent:${value};`
return { ...(style as Record<string, string | number>), "--card-accent": value }
} }
export function Card(props: CardProps) { export function Card(props: CardProps) {
const [split, rest] = splitProps(props, ["variant", "style", "class", "classList"]) const [split, rest] = splitProps(props, ["variant", "class", "classList"])
const variant = () => split.variant ?? "normal"
const accent = () => {
const v = variant()
if (v === "error") return "var(--icon-critical-base)"
if (v === "warning") return "var(--icon-warning-active)"
if (v === "success") return "var(--icon-success-active)"
if (v === "info") return "var(--icon-info-active)"
return
}
return ( return (
<div <div
{...rest} {...rest}
data-component="card" data-component="card"
data-variant={variant()} data-variant={split.variant || "normal"}
style={mix(split.style, accent())}
classList={{ classList={{
...(split.classList ?? {}), ...(split.classList ?? {}),
[split.class ?? ""]: !!split.class, [split.class ?? ""]: !!split.class,
@@ -61,63 +20,3 @@ export function Card(props: CardProps) {
</div> </div>
) )
} }
export function CardTitle(props: CardTitleProps) {
const [split, rest] = splitProps(props, ["variant", "icon", "class", "classList", "children"])
const show = () => split.icon !== false && split.icon !== null
const name = () => {
if (split.icon === false || split.icon === null) return
if (typeof split.icon === "string") return split.icon
return pick(split.variant ?? "normal")
}
const placeholder = () => !name()
return (
<div
{...rest}
data-slot="card-title"
classList={{
...(split.classList ?? {}),
[split.class ?? ""]: !!split.class,
}}
>
{show() ? (
<span data-slot="card-title-icon" data-placeholder={placeholder() || undefined}>
<Icon name={name() ?? "dash"} size="small" />
</span>
) : null}
{split.children}
</div>
)
}
export function CardDescription(props: ComponentProps<"div">) {
const [split, rest] = splitProps(props, ["class", "classList", "children"])
return (
<div
{...rest}
data-slot="card-description"
classList={{
...(split.classList ?? {}),
[split.class ?? ""]: !!split.class,
}}
>
{split.children}
</div>
)
}
export function CardActions(props: ComponentProps<"div">) {
const [split, rest] = splitProps(props, ["class", "classList", "children"])
return (
<div
{...rest}
data-slot="card-actions"
classList={{
...(split.classList ?? {}),
[split.class ?? ""]: !!split.class,
}}
>
{split.children}
</div>
)
}
-6
View File
@@ -60,7 +60,6 @@
ol { ol {
margin-top: 0.5rem; margin-top: 0.5rem;
margin-bottom: 1rem; margin-bottom: 1rem;
margin-left: 0;
padding-left: 1.5rem; padding-left: 1.5rem;
list-style-position: outside; list-style-position: outside;
} }
@@ -71,7 +70,6 @@
ol { ol {
list-style-type: decimal; list-style-type: decimal;
padding-left: 2.25rem;
} }
li { li {
@@ -100,10 +98,6 @@
padding-left: 1rem; /* Minimal indent for nesting only */ padding-left: 1rem; /* Minimal indent for nesting only */
} }
li > ol {
padding-left: 1.75rem;
}
/* Blockquotes */ /* Blockquotes */
blockquote { blockquote {
border-left: 2px solid var(--border-weak-base); border-left: 2px solid var(--border-weak-base);
@@ -309,6 +309,41 @@
} }
} }
[data-component="tool-error"] {
display: flex;
align-items: start;
gap: 8px;
[data-slot="icon-svg"] {
color: var(--icon-critical-base);
margin-top: 4px;
}
[data-slot="message-part-tool-error-content"] {
display: flex;
align-items: start;
gap: 8px;
}
[data-slot="message-part-tool-error-title"] {
font-family: var(--font-family-sans);
font-size: var(--font-size-base);
font-style: normal;
font-weight: var(--font-weight-medium);
line-height: var(--line-height-large);
letter-spacing: var(--letter-spacing-normal);
color: var(--text-on-critical-base);
white-space: nowrap;
}
[data-slot="message-part-tool-error-message"] {
color: var(--text-on-critical-weak);
max-height: 240px;
overflow-y: auto;
word-break: break-word;
}
}
[data-component="tool-output"] { [data-component="tool-output"] {
white-space: pre; white-space: pre;
padding: 0; padding: 0;
@@ -682,6 +717,7 @@
[data-component="user-message"] [data-slot="user-message-text"], [data-component="user-message"] [data-slot="user-message-text"],
[data-component="text-part"], [data-component="text-part"],
[data-component="reasoning-part"], [data-component="reasoning-part"],
[data-component="tool-error"],
[data-component="tool-output"], [data-component="tool-output"],
[data-component="bash-output"], [data-component="bash-output"],
[data-component="edit-content"], [data-component="edit-content"],

Some files were not shown because too many files have changed in this diff Show More