mirror of
https://github.com/anomalyco/opencode.git
synced 2026-03-12 17:43:56 +00:00
Compare commits
2 Commits
node-pty
...
refactor/n
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0ea7e75e0b | ||
|
|
2724335b28 |
4
.github/actions/setup-bun/action.yml
vendored
4
.github/actions/setup-bun/action.yml
vendored
@@ -31,10 +31,6 @@ runs:
|
||||
bun-version-file: ${{ !steps.bun-url.outputs.url && 'package.json' || '' }}
|
||||
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
|
||||
run: bun install
|
||||
shell: bash
|
||||
|
||||
23
.github/workflows/test.yml
vendored
23
.github/workflows/test.yml
vendored
@@ -6,14 +6,6 @@ on:
|
||||
- dev
|
||||
pull_request:
|
||||
workflow_dispatch:
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
unit:
|
||||
name: unit (${{ matrix.settings.name }})
|
||||
@@ -94,3 +86,18 @@ jobs:
|
||||
path: |
|
||||
packages/app/e2e/test-results
|
||||
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"
|
||||
|
||||
7
.opencode/.gitignore
vendored
7
.opencode/.gitignore
vendored
@@ -1,6 +1,3 @@
|
||||
node_modules
|
||||
plans
|
||||
package.json
|
||||
plans/
|
||||
bun.lock
|
||||
.gitignore
|
||||
package-lock.json
|
||||
package.json
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
/// <reference path="../env.d.ts" />
|
||||
import { tool } from "@opencode-ai/plugin"
|
||||
import DESCRIPTION from "./github-pr-search.txt"
|
||||
|
||||
async function githubFetch(endpoint: string, options: RequestInit = {}) {
|
||||
const response = await fetch(`https://api.github.com${endpoint}`, {
|
||||
@@ -23,16 +24,7 @@ interface PR {
|
||||
}
|
||||
|
||||
export default tool({
|
||||
description: `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.`,
|
||||
description: DESCRIPTION,
|
||||
args: {
|
||||
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),
|
||||
|
||||
10
.opencode/tool/github-pr-search.txt
Normal file
10
.opencode/tool/github-pr-search.txt
Normal 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.
|
||||
@@ -1,5 +1,6 @@
|
||||
/// <reference path="../env.d.ts" />
|
||||
import { tool } from "@opencode-ai/plugin"
|
||||
import DESCRIPTION from "./github-triage.txt"
|
||||
|
||||
const TEAM = {
|
||||
desktop: ["adamdotdevin", "iamdavidhill", "Brendonovich", "nexxeln"],
|
||||
@@ -39,12 +40,7 @@ async function githubFetch(endpoint: string, options: RequestInit = {}) {
|
||||
}
|
||||
|
||||
export default tool({
|
||||
description: `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.`,
|
||||
description: DESCRIPTION,
|
||||
args: {
|
||||
assignee: tool.schema
|
||||
.enum(ASSIGNEES as [string, ...string[]])
|
||||
|
||||
6
.opencode/tool/github-triage.txt
Normal file
6
.opencode/tool/github-triage.txt
Normal 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.
|
||||
@@ -122,7 +122,3 @@ const table = sqliteTable("session", {
|
||||
- Avoid mocks as much as possible
|
||||
- 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`.
|
||||
|
||||
## Type Checking
|
||||
|
||||
- Always run `bun typecheck` from package directories (e.g., `packages/opencode`), never `tsc` directly.
|
||||
|
||||
@@ -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?
|
||||
|
||||
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
|
||||
- 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.
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
{
|
||||
"nodeModules": {
|
||||
"x86_64-linux": "sha256-dhL4YeSi4Lm9yDp919Fx7N2hyLUbZQa2qWoCf/50ce8=",
|
||||
"aarch64-linux": "sha256-//YxCsrvYlxuvd0MtFFO+pLxjmuemyrvGzSIPxzO+rA=",
|
||||
"aarch64-darwin": "sha256-c65kSWteQNaBcQUsjbXNqT61vt98JPNYo9yMNvUygCw=",
|
||||
"x86_64-darwin": "sha256-hlTzEFv3nZHwlDXU65LfMC+NaqYjjyZqagdJ366CNxY="
|
||||
"x86_64-linux": "sha256-duBedS4ZTc1as03OM0KB9mKKU21Cywv4o9GHwQZv6Ts=",
|
||||
"aarch64-linux": "sha256-juvQfuNBqqzeB/TIY9PuUDqgpsdyI54ImowjQLrNhns=",
|
||||
"aarch64-darwin": "sha256-kKgcuEN1oJqHJc+sGjcZ4INWvbZczSTDJ8VHIWAquD4=",
|
||||
"x86_64-darwin": "sha256-hXkFWOL4wi9s8HSrChpqtH4PKSNzbzVgU+0GbAxEUT4="
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,7 +11,6 @@
|
||||
"dev:web": "bun --cwd packages/app dev",
|
||||
"dev:storybook": "bun --cwd packages/storybook storybook",
|
||||
"typecheck": "bun turbo typecheck",
|
||||
"postinstall": "bun run --cwd packages/opencode fix-node-pty",
|
||||
"prepare": "husky",
|
||||
"random": "echo 'Random script'",
|
||||
"hello": "echo 'Hello World!'",
|
||||
@@ -42,9 +41,8 @@
|
||||
"@tailwindcss/vite": "4.1.11",
|
||||
"diff": "8.0.2",
|
||||
"dompurify": "3.3.1",
|
||||
"effect": "4.0.0-beta.29",
|
||||
"drizzle-kit": "1.0.0-beta.16-c2458b2",
|
||||
"drizzle-orm": "1.0.0-beta.16-c2458b2",
|
||||
"drizzle-kit": "1.0.0-beta.16-ea816b6",
|
||||
"drizzle-orm": "1.0.0-beta.16-ea816b6",
|
||||
"ai": "5.0.124",
|
||||
"hono": "4.10.7",
|
||||
"hono-openapi": "1.1.2",
|
||||
@@ -99,7 +97,6 @@
|
||||
},
|
||||
"trustedDependencies": [
|
||||
"esbuild",
|
||||
"node-pty",
|
||||
"protobufjs",
|
||||
"tree-sitter",
|
||||
"tree-sitter-bash",
|
||||
|
||||
@@ -21,8 +21,6 @@ import { useLayout } from "@/context/layout"
|
||||
import { usePlatform } from "@/context/platform"
|
||||
import { useServer } from "@/context/server"
|
||||
import { useSync } from "@/context/sync"
|
||||
import { useTerminal } from "@/context/terminal"
|
||||
import { focusTerminalById } from "@/pages/session/helpers"
|
||||
import { decode64 } from "@/utils/base64"
|
||||
import { Persist, persisted } from "@/utils/persist"
|
||||
import { StatusPopover } from "../status-popover"
|
||||
@@ -231,7 +229,6 @@ export function SessionHeader() {
|
||||
const sync = useSync()
|
||||
const platform = usePlatform()
|
||||
const language = useLanguage()
|
||||
const terminal = useTerminal()
|
||||
|
||||
const projectDirectory = createMemo(() => decode64(params.dir) ?? "")
|
||||
const project = createMemo(() => {
|
||||
@@ -299,16 +296,6 @@ export function SessionHeader() {
|
||||
] 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 [menu, setMenu] = createStore({ open: false })
|
||||
const [openRequest, setOpenRequest] = createStore({
|
||||
@@ -630,39 +617,39 @@ export function SessionHeader() {
|
||||
</div>
|
||||
</Show>
|
||||
<div class="flex items-center gap-1">
|
||||
<TooltipKeybind
|
||||
title={language.t("command.terminal.toggle")}
|
||||
keybind={command.keybind("terminal.toggle")}
|
||||
>
|
||||
<Button
|
||||
variant="ghost"
|
||||
class="group/terminal-toggle titlebar-icon w-8 h-6 p-0 box-border shrink-0"
|
||||
onClick={toggleTerminal}
|
||||
aria-label={language.t("command.terminal.toggle")}
|
||||
aria-expanded={view().terminal.opened()}
|
||||
aria-controls="terminal-panel"
|
||||
>
|
||||
<div class="relative flex items-center justify-center size-4 [&>*]:absolute [&>*]:inset-0">
|
||||
<Icon
|
||||
size="small"
|
||||
name={view().terminal.opened() ? "layout-bottom-partial" : "layout-bottom"}
|
||||
class="group-hover/terminal-toggle:hidden"
|
||||
/>
|
||||
<Icon
|
||||
size="small"
|
||||
name="layout-bottom-partial"
|
||||
class="hidden group-hover/terminal-toggle:inline-block"
|
||||
/>
|
||||
<Icon
|
||||
size="small"
|
||||
name={view().terminal.opened() ? "layout-bottom" : "layout-bottom-partial"}
|
||||
class="hidden group-active/terminal-toggle:inline-block"
|
||||
/>
|
||||
</div>
|
||||
</Button>
|
||||
</TooltipKeybind>
|
||||
|
||||
<div class="hidden md:flex items-center gap-1 shrink-0">
|
||||
<TooltipKeybind
|
||||
title={language.t("command.terminal.toggle")}
|
||||
keybind={command.keybind("terminal.toggle")}
|
||||
>
|
||||
<Button
|
||||
variant="ghost"
|
||||
class="group/terminal-toggle titlebar-icon w-8 h-6 p-0 box-border"
|
||||
onClick={() => view().terminal.toggle()}
|
||||
aria-label={language.t("command.terminal.toggle")}
|
||||
aria-expanded={view().terminal.opened()}
|
||||
aria-controls="terminal-panel"
|
||||
>
|
||||
<div class="relative flex items-center justify-center size-4 [&>*]:absolute [&>*]:inset-0">
|
||||
<Icon
|
||||
size="small"
|
||||
name={view().terminal.opened() ? "layout-bottom-partial" : "layout-bottom"}
|
||||
class="group-hover/terminal-toggle:hidden"
|
||||
/>
|
||||
<Icon
|
||||
size="small"
|
||||
name="layout-bottom-partial"
|
||||
class="hidden group-hover/terminal-toggle:inline-block"
|
||||
/>
|
||||
<Icon
|
||||
size="small"
|
||||
name={view().terminal.opened() ? "layout-bottom" : "layout-bottom-partial"}
|
||||
class="hidden group-active/terminal-toggle:inline-block"
|
||||
/>
|
||||
</div>
|
||||
</Button>
|
||||
</TooltipKeybind>
|
||||
|
||||
<TooltipKeybind
|
||||
title={language.t("command.review.toggle")}
|
||||
keybind={command.keybind("review.toggle")}
|
||||
|
||||
@@ -17,7 +17,6 @@ const TOGGLE_TERMINAL_ID = "terminal.toggle"
|
||||
const DEFAULT_TOGGLE_TERMINAL_KEYBIND = "ctrl+`"
|
||||
export interface TerminalProps extends ComponentProps<"div"> {
|
||||
pty: LocalPTY
|
||||
autoFocus?: boolean
|
||||
onSubmit?: () => void
|
||||
onCleanup?: (pty: Partial<LocalPTY> & { id: string }) => void
|
||||
onConnect?: () => void
|
||||
@@ -158,7 +157,7 @@ export const Terminal = (props: TerminalProps) => {
|
||||
const language = useLanguage()
|
||||
const server = useServer()
|
||||
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 restore = typeof local.pty.buffer === "string" ? local.pty.buffer : ""
|
||||
const restoreSize =
|
||||
@@ -387,7 +386,7 @@ export const Terminal = (props: TerminalProps) => {
|
||||
handleLinkClick,
|
||||
})
|
||||
|
||||
if (local.autoFocus !== false) focusTerminal()
|
||||
focusTerminal()
|
||||
|
||||
if (typeof document !== "undefined" && document.fonts) {
|
||||
document.fonts.ready.then(scheduleFit)
|
||||
|
||||
@@ -32,9 +32,8 @@ import { useLayout } from "@/context/layout"
|
||||
import { usePrompt } from "@/context/prompt"
|
||||
import { useSDK } from "@/context/sdk"
|
||||
import { useSync } from "@/context/sync"
|
||||
import { useTerminal } from "@/context/terminal"
|
||||
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 { type DiffStyle, SessionReviewTab, type SessionReviewTabProps } from "@/pages/session/review-tab"
|
||||
import { resetSessionModel, syncSessionModel } from "@/pages/session/session-model-helpers"
|
||||
@@ -268,7 +267,6 @@ export default function Page() {
|
||||
const sdk = useSDK()
|
||||
const prompt = usePrompt()
|
||||
const comments = useComments()
|
||||
const terminal = useTerminal()
|
||||
const [searchParams, setSearchParams] = useSearchParams<{ prompt?: string }>()
|
||||
|
||||
createEffect(() => {
|
||||
@@ -761,11 +759,8 @@ export default function Page() {
|
||||
return
|
||||
}
|
||||
|
||||
// Prefer the open terminal over the composer when it can take focus
|
||||
if (view().terminal.opened()) {
|
||||
const id = terminal.active()
|
||||
if (id && focusTerminalById(id)) return
|
||||
}
|
||||
// Don't autofocus chat if desktop terminal panel is open
|
||||
if (isDesktop() && view().terminal.opened()) return
|
||||
|
||||
// Only treat explicit scroll keys as potential "user scroll" gestures.
|
||||
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-spring-ms": `${props.countWidthDuration ?? 560}ms`,
|
||||
opacity: `${Math.max(0, Math.min(1, 1 - shut()))}`,
|
||||
filter: `blur(${Math.max(0, Math.min(1, shut())) * 2}px)`,
|
||||
}}
|
||||
>
|
||||
<AnimatedNumber value={done()} />
|
||||
@@ -195,6 +196,7 @@ export function SessionTodoDock(props: {
|
||||
style={{
|
||||
visibility: off() ? "hidden" : "visible",
|
||||
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} />
|
||||
|
||||
@@ -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 { createMediaQuery } from "@solid-primitives/media"
|
||||
import { useParams } from "@solidjs/router"
|
||||
import { Tabs } from "@opencode-ai/ui/tabs"
|
||||
import { ResizeHandle } from "@opencode-ai/ui/resize-handle"
|
||||
@@ -16,7 +17,7 @@ import { useLanguage } from "@/context/language"
|
||||
import { useLayout } from "@/context/layout"
|
||||
import { useTerminal, type LocalPTY } from "@/context/terminal"
|
||||
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"
|
||||
|
||||
export function TerminalPanel() {
|
||||
@@ -26,10 +27,13 @@ export function TerminalPanel() {
|
||||
const language = useLanguage()
|
||||
const command = useCommand()
|
||||
|
||||
const isDesktop = createMediaQuery("(min-width: 768px)")
|
||||
const sessionKey = createMemo(() => `${params.dir}${params.id ? "/" + params.id : ""}`)
|
||||
const view = createMemo(() => layout.view(sessionKey))
|
||||
|
||||
const opened = createMemo(() => view().terminal.opened())
|
||||
const open = createMemo(() => isDesktop() && opened())
|
||||
const panel = createPresence(open)
|
||||
const size = createSizing()
|
||||
const height = createMemo(() => layout.terminal.height())
|
||||
const close = () => view().terminal.close()
|
||||
@@ -38,25 +42,6 @@ export function TerminalPanel() {
|
||||
const [store, setStore] = createStore({
|
||||
autoCreated: false,
|
||||
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(() => {
|
||||
@@ -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(
|
||||
on(
|
||||
() => [opened(), terminal.active()] as const,
|
||||
([next, id]) => {
|
||||
if (!next || !id) return
|
||||
const stop = focus(id)
|
||||
onCleanup(stop)
|
||||
() => terminal.active(),
|
||||
(activeId) => {
|
||||
if (!activeId || !panel.open()) return
|
||||
if (document.activeElement instanceof HTMLElement) {
|
||||
document.activeElement.blur()
|
||||
}
|
||||
setTimeout(() => focusTerminalById(activeId), 0)
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
createEffect(() => {
|
||||
if (opened()) return
|
||||
if (panel.open()) return
|
||||
const active = document.activeElement
|
||||
if (!(active instanceof HTMLElement)) return
|
||||
if (!root?.contains(active)) return
|
||||
@@ -174,156 +138,150 @@ export function TerminalPanel() {
|
||||
|
||||
const activeId = terminal.active()
|
||||
if (!activeId) return
|
||||
requestAnimationFrame(() => {
|
||||
if (terminal.active() !== activeId) return
|
||||
setTimeout(() => {
|
||||
focusTerminalById(activeId)
|
||||
})
|
||||
}, 0)
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={root}
|
||||
id="terminal-panel"
|
||||
role="region"
|
||||
aria-label={language.t("terminal.title")}
|
||||
aria-hidden={!opened()}
|
||||
inert={!opened()}
|
||||
class="relative w-full shrink-0 overflow-hidden bg-background-stronger"
|
||||
classList={{
|
||||
"border-t border-border-weak-base": opened(),
|
||||
"transition-[height] duration-200 ease-[cubic-bezier(0.22,1,0.36,1)] will-change-[height] motion-reduce:transition-none":
|
||||
!size.active(),
|
||||
}}
|
||||
style={{ height: opened() ? `${pane()}px` : "0px" }}
|
||||
>
|
||||
<Show when={panel.show()}>
|
||||
<div
|
||||
class="absolute inset-x-0 top-0 flex flex-col"
|
||||
ref={root}
|
||||
id="terminal-panel"
|
||||
role="region"
|
||||
aria-label={language.t("terminal.title")}
|
||||
aria-hidden={!panel.open()}
|
||||
inert={!panel.open()}
|
||||
class="relative w-full shrink-0 overflow-hidden"
|
||||
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":
|
||||
"opacity-100": panel.open(),
|
||||
"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(),
|
||||
}}
|
||||
style={{ height: `${pane()}px` }}
|
||||
style={{ height: panel.open() ? `${height()}px` : "0px" }}
|
||||
>
|
||||
<div class="hidden md:block" onPointerDown={() => size.start()}>
|
||||
<ResizeHandle
|
||||
direction="vertical"
|
||||
size={pane()}
|
||||
min={100}
|
||||
max={max()}
|
||||
collapseThreshold={50}
|
||||
onResize={(next) => {
|
||||
size.touch()
|
||||
layout.terminal.resize(next)
|
||||
}}
|
||||
onCollapse={close}
|
||||
/>
|
||||
</div>
|
||||
<Show
|
||||
when={terminal.ready()}
|
||||
fallback={
|
||||
<div class="flex flex-col h-full pointer-events-none">
|
||||
<div class="h-10 flex items-center gap-2 px-2 border-b border-border-weaker-base bg-background-stronger overflow-hidden">
|
||||
<For each={handoff()}>
|
||||
{(title) => (
|
||||
<div class="px-2 py-1 rounded-md bg-surface-base text-14-regular text-text-weak truncate max-w-40">
|
||||
{title}
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
<div class="flex-1" />
|
||||
<div class="text-text-weak pr-2">
|
||||
{language.t("common.loading")}
|
||||
{language.t("common.loading.ellipsis")}
|
||||
<div class="size-full flex flex-col border-t border-border-weak-base">
|
||||
<div onPointerDown={() => size.start()}>
|
||||
<ResizeHandle
|
||||
direction="vertical"
|
||||
size={height()}
|
||||
min={100}
|
||||
max={typeof window === "undefined" ? 1000 : window.innerHeight * 0.6}
|
||||
collapseThreshold={50}
|
||||
onResize={(next) => {
|
||||
size.touch()
|
||||
layout.terminal.resize(next)
|
||||
}}
|
||||
onCollapse={close}
|
||||
/>
|
||||
</div>
|
||||
<Show
|
||||
when={terminal.ready()}
|
||||
fallback={
|
||||
<div class="flex flex-col h-full pointer-events-none">
|
||||
<div class="h-10 flex items-center gap-2 px-2 border-b border-border-weaker-base bg-background-stronger overflow-hidden">
|
||||
<For each={handoff()}>
|
||||
{(title) => (
|
||||
<div class="px-2 py-1 rounded-md bg-surface-base text-14-regular text-text-weak truncate max-w-40">
|
||||
{title}
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
<div class="flex-1" />
|
||||
<div class="text-text-weak pr-2">
|
||||
{language.t("common.loading")}
|
||||
{language.t("common.loading.ellipsis")}
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex-1 flex items-center justify-center text-text-weak">
|
||||
{language.t("terminal.loading")}
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex-1 flex items-center justify-center text-text-weak">{language.t("terminal.loading")}</div>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<DragDropProvider
|
||||
onDragStart={handleTerminalDragStart}
|
||||
onDragEnd={handleTerminalDragEnd}
|
||||
onDragOver={handleTerminalDragOver}
|
||||
collisionDetector={closestCenter}
|
||||
}
|
||||
>
|
||||
<DragDropSensors />
|
||||
<ConstrainDragYAxis />
|
||||
<div class="flex flex-col h-full">
|
||||
<Tabs
|
||||
variant="alt"
|
||||
value={terminal.active()}
|
||||
onChange={(id) => terminal.open(id)}
|
||||
class="!h-auto !flex-none"
|
||||
>
|
||||
<Tabs.List class="h-10 border-b border-border-weaker-base">
|
||||
<SortableProvider ids={ids()}>
|
||||
<For each={ids()}>
|
||||
{(id) => (
|
||||
<Show when={byId().get(id)}>
|
||||
{(pty) => <SortableTerminalTab terminal={pty()} onClose={close} />}
|
||||
</Show>
|
||||
)}
|
||||
</For>
|
||||
</SortableProvider>
|
||||
<div class="h-full flex items-center justify-center">
|
||||
<TooltipKeybind
|
||||
title={language.t("command.terminal.new")}
|
||||
keybind={command.keybind("terminal.new")}
|
||||
class="flex items-center"
|
||||
>
|
||||
<IconButton
|
||||
icon="plus-small"
|
||||
variant="ghost"
|
||||
iconSize="large"
|
||||
onClick={terminal.new}
|
||||
aria-label={language.t("command.terminal.new")}
|
||||
/>
|
||||
</TooltipKeybind>
|
||||
</div>
|
||||
</Tabs.List>
|
||||
</Tabs>
|
||||
<div class="flex-1 min-h-0 relative">
|
||||
<Show when={terminal.active()} keyed>
|
||||
{(id) => (
|
||||
<Show when={byId().get(id)}>
|
||||
{(pty) => (
|
||||
<div id={`terminal-wrapper-${id}`} class="absolute inset-0">
|
||||
<Terminal
|
||||
pty={pty()}
|
||||
autoFocus={opened()}
|
||||
onConnect={() => terminal.trim(id)}
|
||||
onCleanup={terminal.update}
|
||||
onConnectError={() => terminal.clone(id)}
|
||||
/>
|
||||
<DragDropProvider
|
||||
onDragStart={handleTerminalDragStart}
|
||||
onDragEnd={handleTerminalDragEnd}
|
||||
onDragOver={handleTerminalDragOver}
|
||||
collisionDetector={closestCenter}
|
||||
>
|
||||
<DragDropSensors />
|
||||
<ConstrainDragYAxis />
|
||||
<div class="flex flex-col h-full">
|
||||
<Tabs
|
||||
variant="alt"
|
||||
value={terminal.active()}
|
||||
onChange={(id) => terminal.open(id)}
|
||||
class="!h-auto !flex-none"
|
||||
>
|
||||
<Tabs.List class="h-10 border-b border-border-weaker-base">
|
||||
<SortableProvider ids={ids()}>
|
||||
<For each={ids()}>
|
||||
{(id) => (
|
||||
<Show when={byId().get(id)}>
|
||||
{(pty) => <SortableTerminalTab terminal={pty()} onClose={close} />}
|
||||
</Show>
|
||||
)}
|
||||
</For>
|
||||
</SortableProvider>
|
||||
<div class="h-full flex items-center justify-center">
|
||||
<TooltipKeybind
|
||||
title={language.t("command.terminal.new")}
|
||||
keybind={command.keybind("terminal.new")}
|
||||
class="flex items-center"
|
||||
>
|
||||
<IconButton
|
||||
icon="plus-small"
|
||||
variant="ghost"
|
||||
iconSize="large"
|
||||
onClick={terminal.new}
|
||||
aria-label={language.t("command.terminal.new")}
|
||||
/>
|
||||
</TooltipKeybind>
|
||||
</div>
|
||||
</Tabs.List>
|
||||
</Tabs>
|
||||
<div class="flex-1 min-h-0 relative">
|
||||
<Show when={terminal.active()} keyed>
|
||||
{(id) => (
|
||||
<Show when={byId().get(id)}>
|
||||
{(pty) => (
|
||||
<div id={`terminal-wrapper-${id}`} class="absolute inset-0">
|
||||
<Terminal
|
||||
pty={pty()}
|
||||
onConnect={() => terminal.trim(id)}
|
||||
onCleanup={terminal.update}
|
||||
onConnectError={() => terminal.clone(id)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</Show>
|
||||
)}
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
<DragOverlay>
|
||||
<Show when={store.activeDraggable}>
|
||||
{(draggedId) => (
|
||||
<Show when={byId().get(draggedId())}>
|
||||
{(t) => (
|
||||
<div class="relative p-1 h-10 flex items-center bg-background-stronger text-14-regular">
|
||||
{terminalTabLabel({
|
||||
title: t().title,
|
||||
titleNumber: t().titleNumber,
|
||||
t: language.t as (key: string, vars?: Record<string, string | number | boolean>) => string,
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</Show>
|
||||
)}
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
<DragOverlay>
|
||||
<Show when={store.activeDraggable}>
|
||||
{(draggedId) => (
|
||||
<Show when={byId().get(draggedId())}>
|
||||
{(t) => (
|
||||
<div class="relative p-1 h-10 flex items-center bg-background-stronger text-14-regular">
|
||||
{terminalTabLabel({
|
||||
title: t().title,
|
||||
titleNumber: t().titleNumber,
|
||||
t: language.t as (key: string, vars?: Record<string, string | number | boolean>) => string,
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</Show>
|
||||
)}
|
||||
</Show>
|
||||
</DragOverlay>
|
||||
</DragDropProvider>
|
||||
</Show>
|
||||
</DragOverlay>
|
||||
</DragDropProvider>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -99,13 +99,7 @@ export async function handler(
|
||||
const dataDumper = createDataDumper(sessionId, requestId, projectId)
|
||||
const trialLimiter = createTrialLimiter(modelInfo.trialProvider, ip)
|
||||
const trialProvider = await trialLimiter?.check()
|
||||
const rateLimiter = createRateLimiter(
|
||||
modelInfo.id,
|
||||
modelInfo.allowAnonymous,
|
||||
modelInfo.rateLimit,
|
||||
ip,
|
||||
input.request,
|
||||
)
|
||||
const rateLimiter = createRateLimiter(modelInfo.allowAnonymous, ip, input.request)
|
||||
await rateLimiter?.check()
|
||||
const stickyTracker = createStickyTracker(modelInfo.stickyProvider, sessionId)
|
||||
const stickyProvider = await stickyTracker?.get()
|
||||
|
||||
@@ -6,63 +6,39 @@ import { i18n } from "~/i18n"
|
||||
import { localeFromRequest } from "~/lib/language"
|
||||
import { Subscription } from "@opencode-ai/console-core/subscription.js"
|
||||
|
||||
export function createRateLimiter(
|
||||
modelId: string,
|
||||
allowAnonymous: boolean | undefined,
|
||||
rateLimit: number | undefined,
|
||||
rawIp: string,
|
||||
request: Request,
|
||||
) {
|
||||
export function createRateLimiter(allowAnonymous: boolean | undefined, rawIp: string, request: Request) {
|
||||
if (!allowAnonymous) return
|
||||
const dict = i18n(localeFromRequest(request))
|
||||
|
||||
const limits = Subscription.getFreeLimits()
|
||||
const headerExists = request.headers.has(limits.checkHeader)
|
||||
const dailyLimit = !headerExists ? limits.fallbackValue : (rateLimit ?? limits.dailyRequests)
|
||||
const isDefaultModel = headerExists && !rateLimit
|
||||
const limitValue =
|
||||
limits.checkHeader && !request.headers.get(limits.checkHeader) ? limits.fallbackValue : limits.dailyRequests
|
||||
|
||||
const ip = !rawIp.length ? "unknown" : rawIp
|
||||
const now = Date.now()
|
||||
const lifetimeInterval = ""
|
||||
const dailyInterval = rateLimit ? `${buildYYYYMMDD(now)}${modelId.substring(0, 2)}` : buildYYYYMMDD(now)
|
||||
|
||||
let _isNew: boolean
|
||||
const interval = buildYYYYMMDD(now)
|
||||
|
||||
return {
|
||||
track: async () => {
|
||||
await Database.use((tx) =>
|
||||
tx
|
||||
.insert(IpRateLimitTable)
|
||||
.values({ ip, interval, count: 1 })
|
||||
.onDuplicateKeyUpdate({ set: { count: sql`${IpRateLimitTable.count} + 1` } }),
|
||||
)
|
||||
},
|
||||
check: async () => {
|
||||
const rows = await Database.use((tx) =>
|
||||
tx
|
||||
.select({ interval: IpRateLimitTable.interval, count: IpRateLimitTable.count })
|
||||
.from(IpRateLimitTable)
|
||||
.where(
|
||||
and(
|
||||
eq(IpRateLimitTable.ip, ip),
|
||||
isDefaultModel
|
||||
? inArray(IpRateLimitTable.interval, [lifetimeInterval, dailyInterval])
|
||||
: inArray(IpRateLimitTable.interval, [dailyInterval]),
|
||||
),
|
||||
),
|
||||
.where(and(eq(IpRateLimitTable.ip, ip), inArray(IpRateLimitTable.interval, [interval]))),
|
||||
)
|
||||
const lifetimeCount = rows.find((r) => r.interval === lifetimeInterval)?.count ?? 0
|
||||
const dailyCount = rows.find((r) => r.interval === dailyInterval)?.count ?? 0
|
||||
logger.debug(`rate limit lifetime: ${lifetimeCount}, daily: ${dailyCount}`)
|
||||
|
||||
_isNew = isDefaultModel && lifetimeCount < dailyLimit * 7
|
||||
|
||||
if ((_isNew && dailyCount >= dailyLimit * 2) || (!_isNew && dailyCount >= dailyLimit))
|
||||
const total = rows.reduce((sum, r) => sum + r.count, 0)
|
||||
logger.debug(`rate limit total: ${total}`)
|
||||
if (total >= limitValue)
|
||||
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` } }),
|
||||
)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -28,7 +28,6 @@ export namespace ZenData {
|
||||
stickyProvider: z.enum(["strict", "prefer"]).optional(),
|
||||
trialProvider: z.string().optional(),
|
||||
fallbackProvider: z.string().optional(),
|
||||
rateLimit: z.number().optional(),
|
||||
providers: z.array(
|
||||
z.object({
|
||||
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
@@ -7,9 +7,8 @@
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"typecheck": "tsgo --noEmit",
|
||||
"test": "bun test --timeout 30000 registry",
|
||||
"test": "bun test --timeout 30000",
|
||||
"build": "bun run script/build.ts",
|
||||
"fix-node-pty": "bun run script/fix-node-pty.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'",
|
||||
"clean": "echo 'Cleaning up...' && rm -rf node_modules dist",
|
||||
@@ -26,18 +25,6 @@
|
||||
"exports": {
|
||||
"./*": "./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": {
|
||||
"@babel/core": "7.28.4",
|
||||
"@octokit/webhooks-types": "7.6.1",
|
||||
@@ -54,14 +41,13 @@
|
||||
"@types/babel__core": "7.20.5",
|
||||
"@types/bun": "catalog:",
|
||||
"@types/mime-types": "3.0.1",
|
||||
"@types/npmcli__arborist": "6.3.3",
|
||||
"@types/semver": "^7.5.8",
|
||||
"@types/turndown": "5.0.5",
|
||||
"@types/which": "3.0.4",
|
||||
"@types/yargs": "17.0.33",
|
||||
"@types/which": "3.0.4",
|
||||
"@typescript/native-preview": "catalog:",
|
||||
"effect": "catalog:",
|
||||
"drizzle-kit": "catalog:",
|
||||
"drizzle-kit": "1.0.0-beta.16-ea816b6",
|
||||
"drizzle-orm": "1.0.0-beta.16-ea816b6",
|
||||
"typescript": "catalog:",
|
||||
"vscode-languageserver-types": "3.17.5",
|
||||
"why-is-node-running": "3.2.2",
|
||||
@@ -94,12 +80,11 @@
|
||||
"@clack/prompts": "1.0.0-alpha.1",
|
||||
"@gitlab/gitlab-ai-provider": "3.6.0",
|
||||
"@gitlab/opencode-gitlab-auth": "1.3.3",
|
||||
"@hono/standard-validator": "0.1.5",
|
||||
"@hono/node-server": "1.19.11",
|
||||
"@hono/node-ws": "1.3.0",
|
||||
"@hono/standard-validator": "0.1.5",
|
||||
"@hono/zod-validator": "catalog:",
|
||||
"@modelcontextprotocol/sdk": "1.25.2",
|
||||
"@npmcli/arborist": "9.4.0",
|
||||
"@octokit/graphql": "9.0.2",
|
||||
"@octokit/rest": "catalog:",
|
||||
"@openauthjs/openauth": "catalog:",
|
||||
@@ -108,8 +93,8 @@
|
||||
"@opencode-ai/sdk": "workspace:*",
|
||||
"@opencode-ai/util": "workspace:*",
|
||||
"@openrouter/ai-sdk-provider": "1.5.4",
|
||||
"@opentui/core": "0.1.86",
|
||||
"@opentui/solid": "0.1.86",
|
||||
"@opentui/core": "0.1.87",
|
||||
"@opentui/solid": "0.1.87",
|
||||
"@parcel/watcher": "2.5.1",
|
||||
"@pierre/diffs": "catalog:",
|
||||
"@solid-primitives/event-bus": "1.1.2",
|
||||
@@ -124,7 +109,7 @@
|
||||
"clipboardy": "4.0.0",
|
||||
"decimal.js": "10.5.0",
|
||||
"diff": "catalog:",
|
||||
"drizzle-orm": "catalog:",
|
||||
"drizzle-orm": "1.0.0-beta.16-ea816b6",
|
||||
"fuzzysort": "3.1.0",
|
||||
"glob": "13.0.5",
|
||||
"google-auth-library": "10.5.0",
|
||||
@@ -135,7 +120,6 @@
|
||||
"jsonc-parser": "3.3.1",
|
||||
"mime-types": "3.0.2",
|
||||
"minimatch": "10.0.3",
|
||||
"node-pty": "1.1.0",
|
||||
"open": "10.1.2",
|
||||
"opentui-spinner": "0.0.6",
|
||||
"partial-json": "0.1.7",
|
||||
|
||||
@@ -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")
|
||||
@@ -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] })],
|
||||
)
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}),
|
||||
}),
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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),
|
||||
)
|
||||
}
|
||||
@@ -1,5 +1,13 @@
|
||||
import z from "zod"
|
||||
import { Global } from "../global"
|
||||
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 { Lock } from "../util/lock"
|
||||
import { PackageRegistry } from "./registry"
|
||||
import { proxied } from "@/util/proxied"
|
||||
import { Process } from "../util/process"
|
||||
|
||||
export namespace BunProc {
|
||||
@@ -37,4 +45,87 @@ export namespace BunProc {
|
||||
export function which() {
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import semver from "semver"
|
||||
import { text } from "node:stream/consumers"
|
||||
import { Log } from "../util/log"
|
||||
import { Process } from "../util/process"
|
||||
@@ -33,4 +34,17 @@ export namespace PackageRegistry {
|
||||
if (!value) return null
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 { Process } from "../../util/process"
|
||||
import { text } from "node:stream/consumers"
|
||||
import { setTimeout as sleep } from "node:timers/promises"
|
||||
|
||||
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> {
|
||||
let index = 0
|
||||
if (methodName) {
|
||||
@@ -28,7 +33,7 @@ async function handlePluginAuth(plugin: { auth: PluginAuth }, provider: string,
|
||||
}
|
||||
index = match
|
||||
} else if (plugin.auth.methods.length > 1) {
|
||||
const method = await prompts.select({
|
||||
const selected = await prompts.select({
|
||||
message: "Login method",
|
||||
options: [
|
||||
...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()
|
||||
index = parseInt(method)
|
||||
if (prompts.isCancel(selected)) throw new UI.CancelledError()
|
||||
index = parseInt(selected)
|
||||
}
|
||||
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> = {}
|
||||
if (method.prompts) {
|
||||
for (const prompt of method.prompts) {
|
||||
@@ -165,6 +171,11 @@ async function handlePluginAuth(plugin: { auth: PluginAuth }, provider: string,
|
||||
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: {
|
||||
hooks: Hooks[]
|
||||
existingProviders: Record<string, unknown>
|
||||
@@ -192,20 +203,19 @@ export function resolvePluginProviders(input: {
|
||||
return result
|
||||
}
|
||||
|
||||
export const ProvidersCommand = cmd({
|
||||
command: "providers",
|
||||
aliases: ["auth"],
|
||||
describe: "manage AI providers and credentials",
|
||||
export const AuthCommand = cmd({
|
||||
command: "auth",
|
||||
describe: "manage credentials",
|
||||
builder: (yargs) =>
|
||||
yargs.command(ProvidersListCommand).command(ProvidersLoginCommand).command(ProvidersLogoutCommand).demandCommand(),
|
||||
yargs.command(AuthLoginCommand).command(AuthLogoutCommand).command(AuthListCommand).demandCommand(),
|
||||
async handler() {},
|
||||
})
|
||||
|
||||
export const ProvidersListCommand = cmd({
|
||||
export const AuthListCommand = cmd({
|
||||
command: "list",
|
||||
aliases: ["ls"],
|
||||
describe: "list providers and credentials",
|
||||
async handler(_args) {
|
||||
describe: "list providers",
|
||||
async handler() {
|
||||
UI.empty()
|
||||
const authPath = path.join(Global.Path.data, "auth.json")
|
||||
const homedir = os.homedir()
|
||||
@@ -221,6 +231,7 @@ export const ProvidersListCommand = cmd({
|
||||
|
||||
prompts.outro(`${results.length} credentials`)
|
||||
|
||||
// Environment variables section
|
||||
const activeEnvVars: Array<{ provider: string; envVar: string }> = []
|
||||
|
||||
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]",
|
||||
describe: "log in to a provider",
|
||||
builder: (yargs) =>
|
||||
@@ -345,7 +356,7 @@ export const ProvidersLoginCommand = cmd({
|
||||
value: x.id,
|
||||
hint: {
|
||||
opencode: "recommended",
|
||||
anthropic: "API key",
|
||||
anthropic: "Claude Max or API key",
|
||||
openai: "ChatGPT Plus/Pro or API key",
|
||||
}[x.id],
|
||||
})),
|
||||
@@ -398,6 +409,7 @@ export const ProvidersLoginCommand = cmd({
|
||||
if (prompts.isCancel(custom)) throw new UI.CancelledError()
|
||||
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))
|
||||
if (customPlugin && customPlugin.auth) {
|
||||
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",
|
||||
describe: "log out from a configured provider",
|
||||
async handler(_args) {
|
||||
async handler() {
|
||||
UI.empty()
|
||||
const credentials = await Auth.all().then((x) => Object.entries(x))
|
||||
prompts.intro("Remove credential")
|
||||
@@ -10,7 +10,7 @@ import { ShareNext } from "../../share/share-next"
|
||||
import { EOL } from "os"
|
||||
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 =
|
||||
| { type: "session"; data: SDKSession }
|
||||
| { type: "message"; data: Message }
|
||||
@@ -24,14 +24,6 @@ export function parseShareUrl(url: string): string | 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.
|
||||
*
|
||||
@@ -105,21 +97,8 @@ export const ImportCommand = cmd({
|
||||
return
|
||||
}
|
||||
|
||||
const parsed = new URL(args.file)
|
||||
const baseUrl = parsed.origin
|
||||
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,
|
||||
})
|
||||
}
|
||||
const baseUrl = await ShareNext.url()
|
||||
const response = await fetch(`${baseUrl}/api/share/${slug}/data`)
|
||||
|
||||
if (!response.ok) {
|
||||
process.stdout.write(`Failed to fetch share data: ${response.statusText}`)
|
||||
|
||||
@@ -36,7 +36,7 @@ export function createDialogProviderOptions() {
|
||||
value: provider.id,
|
||||
description: {
|
||||
opencode: "(Recommended)",
|
||||
anthropic: "(API key)",
|
||||
anthropic: "(Claude Max or API key)",
|
||||
openai: "(ChatGPT Plus/Pro or API key)",
|
||||
"opencode-go": "Low cost subscription for everyone",
|
||||
}[provider.id],
|
||||
|
||||
@@ -9,7 +9,6 @@ import { useToast } from "../ui/toast"
|
||||
import { useKeybind } from "../context/keybind"
|
||||
import { DialogSessionList } from "./workspace/dialog-session-list"
|
||||
import { createOpencodeClient } from "@opencode-ai/sdk/v2"
|
||||
import { setTimeout as sleep } from "node:timers/promises"
|
||||
|
||||
async function openWorkspace(input: {
|
||||
dialog: ReturnType<typeof useDialog>
|
||||
@@ -57,7 +56,7 @@ async function openWorkspace(input: {
|
||||
return
|
||||
}
|
||||
if (result.response.status >= 500 && result.response.status < 600) {
|
||||
await sleep(1000)
|
||||
await Bun.sleep(1000)
|
||||
continue
|
||||
}
|
||||
if (!result.data) {
|
||||
|
||||
@@ -383,12 +383,7 @@ export function Session() {
|
||||
sessionID: route.sessionID,
|
||||
})
|
||||
.then((res) => copy(res.data!.share!.url))
|
||||
.catch((error) => {
|
||||
toast.show({
|
||||
message: error instanceof Error ? error.message : "Failed to share session",
|
||||
variant: "error",
|
||||
})
|
||||
})
|
||||
.catch(() => toast.show({ message: "Failed to share session", variant: "error" }))
|
||||
dialog.clear()
|
||||
},
|
||||
},
|
||||
@@ -491,12 +486,7 @@ export function Session() {
|
||||
sessionID: route.sessionID,
|
||||
})
|
||||
.then(() => toast.show({ message: "Session unshared successfully", variant: "success" }))
|
||||
.catch((error) => {
|
||||
toast.show({
|
||||
message: error instanceof Error ? error.message : "Failed to unshare session",
|
||||
variant: "error",
|
||||
})
|
||||
})
|
||||
.catch(() => toast.show({ message: "Failed to unshare session", variant: "error" }))
|
||||
dialog.clear()
|
||||
},
|
||||
},
|
||||
@@ -907,12 +897,12 @@ export function Session() {
|
||||
const filename = options.filename.trim()
|
||||
const filepath = path.join(exportDir, filename)
|
||||
|
||||
await Filesystem.write(filepath, transcript)
|
||||
await Bun.write(filepath, transcript)
|
||||
|
||||
// Open with EDITOR if available
|
||||
const result = await Editor.open({ value: transcript, renderer })
|
||||
if (result !== undefined) {
|
||||
await Filesystem.write(filepath, result)
|
||||
await Bun.write(filepath, result)
|
||||
}
|
||||
|
||||
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)),
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Log } from "../util/log"
|
||||
import path from "path"
|
||||
import { pathToFileURL } from "url"
|
||||
import { pathToFileURL, fileURLToPath } from "url"
|
||||
import { createRequire } from "module"
|
||||
import os from "os"
|
||||
import z from "zod"
|
||||
@@ -12,7 +12,6 @@ import { lazy } from "../util/lazy"
|
||||
import { NamedError } from "@opencode-ai/util/error"
|
||||
import { Flag } from "../flag/flag"
|
||||
import { Auth } from "../auth"
|
||||
import { Env } from "../env"
|
||||
import {
|
||||
type ParseError as JsoncParseError,
|
||||
applyEdits,
|
||||
@@ -22,6 +21,7 @@ import {
|
||||
} from "jsonc-parser"
|
||||
import { Instance } from "../project/instance"
|
||||
import { LSPServer } from "../lsp/server"
|
||||
import { BunProc } from "@/bun"
|
||||
import { Installation } from "@/installation"
|
||||
import { ConfigMarkdown } from "./markdown"
|
||||
import { constants, existsSync } from "fs"
|
||||
@@ -29,11 +29,12 @@ import { Bus } from "@/bus"
|
||||
import { GlobalBus } from "@/bus/global"
|
||||
import { Event } from "../server/event"
|
||||
import { Glob } from "../util/glob"
|
||||
import { PackageRegistry } from "@/bun/registry"
|
||||
import { proxied } from "@/util/proxied"
|
||||
import { iife } from "@/util/iife"
|
||||
import { Account } from "@/account"
|
||||
import { Control } from "@/control"
|
||||
import { ConfigPaths } from "./paths"
|
||||
import { Filesystem } from "@/util/filesystem"
|
||||
import { Npm } from "@/npm"
|
||||
|
||||
export namespace Config {
|
||||
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.
|
||||
result = mergeConfigConcatArrays(result, await global())
|
||||
|
||||
@@ -150,7 +155,8 @@ export namespace Config {
|
||||
|
||||
deps.push(
|
||||
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")
|
||||
}
|
||||
|
||||
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
|
||||
// Kept separate from directories array to avoid write operations when installing plugins
|
||||
// which would fail on system directories requiring elevated permissions
|
||||
@@ -266,10 +246,6 @@ export namespace Config {
|
||||
}
|
||||
|
||||
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 targetVersion = Installation.isLocal() ? "*" : Installation.VERSION
|
||||
|
||||
@@ -283,15 +259,22 @@ export namespace Config {
|
||||
await Filesystem.writeJson(pkg, json)
|
||||
|
||||
const gitignore = path.join(dir, ".gitignore")
|
||||
if (!(await Filesystem.exists(gitignore)))
|
||||
await Filesystem.write(
|
||||
gitignore,
|
||||
["node_modules", "plans", "package.json", "bun.lock", ".gitignore", "package-lock.json"].join("\n"),
|
||||
)
|
||||
const hasGitIgnore = await Filesystem.exists(gitignore)
|
||||
if (!hasGitIgnore)
|
||||
await Filesystem.write(gitignore, ["node_modules", "package.json", "bun.lock", ".gitignore"].join("\n"))
|
||||
|
||||
// Install any additional dependencies defined in the package.json
|
||||
// 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) {
|
||||
@@ -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[]) {
|
||||
const normalizedItem = item.replaceAll("\\", "/")
|
||||
for (const pattern of patterns) {
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { createAdaptorServer } from "@hono/node-server"
|
||||
import { Hono } from "hono"
|
||||
import { Instance } from "../../project/instance"
|
||||
import { InstanceBootstrap } from "../../project/bootstrap"
|
||||
@@ -56,24 +55,10 @@ export namespace WorkspaceServer {
|
||||
}
|
||||
|
||||
export function Listen(opts: { hostname: string; port: number }) {
|
||||
const server = createAdaptorServer({
|
||||
fetch: App().fetch,
|
||||
})
|
||||
server.listen(opts.port, opts.hostname)
|
||||
return {
|
||||
return Bun.serve({
|
||||
hostname: opts.hostname,
|
||||
port: opts.port,
|
||||
stop() {
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
server.close((err) => {
|
||||
if (err) {
|
||||
reject(err)
|
||||
return
|
||||
}
|
||||
resolve()
|
||||
})
|
||||
})
|
||||
},
|
||||
}
|
||||
fetch: App().fetch,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
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", {
|
||||
id: text().primaryKey(),
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import z from "zod"
|
||||
import { setTimeout as sleep } from "node:timers/promises"
|
||||
import { Identifier } from "@/id/id"
|
||||
import { fn } from "@/util/fn"
|
||||
import { Database, eq } from "@/storage/db"
|
||||
@@ -117,7 +116,7 @@ export namespace Workspace {
|
||||
const adaptor = await getAdaptor(space.type)
|
||||
const res = await adaptor.fetch(space, "/event", { method: "GET", signal: stop }).catch(() => undefined)
|
||||
if (!res || !res.ok || !res.body) {
|
||||
await sleep(1000)
|
||||
await Bun.sleep(1000)
|
||||
continue
|
||||
}
|
||||
await parseSSE(res.body, stop, (event) => {
|
||||
@@ -127,7 +126,7 @@ export namespace Workspace {
|
||||
})
|
||||
})
|
||||
// Wait 250ms and retry if SSE connection fails
|
||||
await sleep(250)
|
||||
await Bun.sleep(250)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
22
packages/opencode/src/control/control.sql.ts
Normal file
22
packages/opencode/src/control/control.sql.ts
Normal file
@@ -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
packages/opencode/src/control/index.ts
Normal file
67
packages/opencode/src/control/index.ts
Normal 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
|
||||
}
|
||||
}
|
||||
@@ -1,4 +0,0 @@
|
||||
import { ManagedRuntime } from "effect"
|
||||
import { AccountService } from "@/account/service"
|
||||
|
||||
export const runtime = ManagedRuntime.make(AccountService.defaultLayer)
|
||||
@@ -11,7 +11,6 @@ import { Ripgrep } from "./ripgrep"
|
||||
import fuzzysort from "fuzzysort"
|
||||
import { Global } from "../global"
|
||||
import { git } from "@/util/git"
|
||||
import { Protected } from "./protected"
|
||||
|
||||
export namespace File {
|
||||
const log = Log.create({ service: "file" })
|
||||
@@ -346,7 +345,10 @@ export namespace File {
|
||||
|
||||
if (isGlobalHome) {
|
||||
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 shouldIgnore = (name: string) => name.startsWith(".") || ignore.has(name)
|
||||
|
||||
@@ -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 []
|
||||
}
|
||||
}
|
||||
@@ -14,7 +14,6 @@ import type ParcelWatcher from "@parcel/watcher"
|
||||
import { Flag } from "@/flag/flag"
|
||||
import { readdir } from "fs/promises"
|
||||
import { git } from "@/util/git"
|
||||
import { Protected } from "./protected"
|
||||
|
||||
const SUBSCRIBE_TIMEOUT_MS = 10_000
|
||||
|
||||
@@ -77,7 +76,7 @@ export namespace FileWatcher {
|
||||
|
||||
if (Flag.OPENCODE_EXPERIMENTAL_FILEWATCHER) {
|
||||
const pending = w.subscribe(Instance.directory, subscribe, {
|
||||
ignore: [...FileIgnore.PATTERNS, ...cfgIgnores, ...Protected.paths()],
|
||||
ignore: [...FileIgnore.PATTERNS, ...cfgIgnores],
|
||||
backend,
|
||||
})
|
||||
const sub = await withTimeout(pending, SUBSCRIBE_TIMEOUT_MS).catch((err) => {
|
||||
|
||||
@@ -1,40 +1,40 @@
|
||||
import { text } from "node:stream/consumers"
|
||||
import { BunProc } from "../bun"
|
||||
import { Instance } from "../project/instance"
|
||||
import { Filesystem } from "../util/filesystem"
|
||||
import { Process } from "../util/process"
|
||||
import { which } from "../util/which"
|
||||
import { Flag } from "@/flag/flag"
|
||||
import { Npm } from "@/npm"
|
||||
|
||||
export interface Info {
|
||||
name: string
|
||||
command: string[]
|
||||
environment?: Record<string, string>
|
||||
extensions: string[]
|
||||
enabled(): Promise<string[] | false>
|
||||
enabled(): Promise<boolean>
|
||||
}
|
||||
|
||||
export const gofmt: Info = {
|
||||
name: "gofmt",
|
||||
command: ["gofmt", "-w", "$FILE"],
|
||||
extensions: [".go"],
|
||||
async enabled() {
|
||||
const p = which("gofmt")
|
||||
if (p === null) return false
|
||||
return [p, "-w", "$FILE"]
|
||||
return which("gofmt") !== null
|
||||
},
|
||||
}
|
||||
|
||||
export const mix: Info = {
|
||||
name: "mix",
|
||||
command: ["mix", "format", "$FILE"],
|
||||
extensions: [".ex", ".exs", ".eex", ".heex", ".leex", ".neex", ".sface"],
|
||||
async enabled() {
|
||||
const p = which("mix")
|
||||
if (p === null) return false
|
||||
return [p, "format", "$FILE"]
|
||||
return which("mix") !== null
|
||||
},
|
||||
}
|
||||
|
||||
export const prettier: Info = {
|
||||
name: "prettier",
|
||||
command: [BunProc.which(), "x", "prettier", "--write", "$FILE"],
|
||||
environment: {
|
||||
BUN_BE_BUN: "1",
|
||||
},
|
||||
@@ -73,9 +73,8 @@ export const prettier: Info = {
|
||||
dependencies?: Record<string, string>
|
||||
devDependencies?: Record<string, string>
|
||||
}>(item)
|
||||
if (json.dependencies?.prettier || json.devDependencies?.prettier) {
|
||||
return [await Npm.which("prettier"), "--write", "$FILE"]
|
||||
}
|
||||
if (json.dependencies?.prettier) return true
|
||||
if (json.devDependencies?.prettier) return true
|
||||
}
|
||||
return false
|
||||
},
|
||||
@@ -83,6 +82,7 @@ export const prettier: Info = {
|
||||
|
||||
export const oxfmt: Info = {
|
||||
name: "oxfmt",
|
||||
command: [BunProc.which(), "x", "oxfmt", "$FILE"],
|
||||
environment: {
|
||||
BUN_BE_BUN: "1",
|
||||
},
|
||||
@@ -95,9 +95,8 @@ export const oxfmt: Info = {
|
||||
dependencies?: Record<string, string>
|
||||
devDependencies?: Record<string, string>
|
||||
}>(item)
|
||||
if (json.dependencies?.oxfmt || json.devDependencies?.oxfmt) {
|
||||
return [await Npm.which("oxfmt"), "$FILE"]
|
||||
}
|
||||
if (json.dependencies?.oxfmt) return true
|
||||
if (json.devDependencies?.oxfmt) return true
|
||||
}
|
||||
return false
|
||||
},
|
||||
@@ -105,6 +104,7 @@ export const oxfmt: Info = {
|
||||
|
||||
export const biome: Info = {
|
||||
name: "biome",
|
||||
command: [BunProc.which(), "x", "@biomejs/biome", "check", "--write", "$FILE"],
|
||||
environment: {
|
||||
BUN_BE_BUN: "1",
|
||||
},
|
||||
@@ -141,7 +141,7 @@ export const biome: Info = {
|
||||
for (const config of configs) {
|
||||
const found = await Filesystem.findUp(config, Instance.directory, Instance.worktree)
|
||||
if (found.length > 0) {
|
||||
return [await Npm.which("@biomejs/biome"), "check", "--write", "$FILE"]
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
@@ -150,49 +150,47 @@ export const biome: Info = {
|
||||
|
||||
export const zig: Info = {
|
||||
name: "zig",
|
||||
command: ["zig", "fmt", "$FILE"],
|
||||
extensions: [".zig", ".zon"],
|
||||
async enabled() {
|
||||
const p = which("zig")
|
||||
if (p === null) return false
|
||||
return [p, "fmt", "$FILE"]
|
||||
return which("zig") !== null
|
||||
},
|
||||
}
|
||||
|
||||
export const clang: Info = {
|
||||
name: "clang-format",
|
||||
command: ["clang-format", "-i", "$FILE"],
|
||||
extensions: [".c", ".cc", ".cpp", ".cxx", ".c++", ".h", ".hh", ".hpp", ".hxx", ".h++", ".ino", ".C", ".H"],
|
||||
async enabled() {
|
||||
const items = await Filesystem.findUp(".clang-format", Instance.directory, Instance.worktree)
|
||||
if (items.length === 0) return false
|
||||
return ["clang-format", "-i", "$FILE"]
|
||||
return items.length > 0
|
||||
},
|
||||
}
|
||||
|
||||
export const ktlint: Info = {
|
||||
name: "ktlint",
|
||||
command: ["ktlint", "-F", "$FILE"],
|
||||
extensions: [".kt", ".kts"],
|
||||
async enabled() {
|
||||
const p = which("ktlint")
|
||||
if (p === null) return false
|
||||
return [p, "-F", "$FILE"]
|
||||
return which("ktlint") !== null
|
||||
},
|
||||
}
|
||||
|
||||
export const ruff: Info = {
|
||||
name: "ruff",
|
||||
command: ["ruff", "format", "$FILE"],
|
||||
extensions: [".py", ".pyi"],
|
||||
async enabled() {
|
||||
const p = which("ruff")
|
||||
if (p === null) return false
|
||||
if (!which("ruff")) return false
|
||||
const configs = ["pyproject.toml", "ruff.toml", ".ruff.toml"]
|
||||
for (const config of configs) {
|
||||
const found = await Filesystem.findUp(config, Instance.directory, Instance.worktree)
|
||||
if (found.length > 0) {
|
||||
if (config === "pyproject.toml") {
|
||||
const content = await Filesystem.readText(found[0])
|
||||
if (content.includes("[tool.ruff]")) return [p, "format", "$FILE"]
|
||||
if (content.includes("[tool.ruff]")) return true
|
||||
} 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)
|
||||
if (found.length > 0) {
|
||||
const content = await Filesystem.readText(found[0])
|
||||
if (content.includes("ruff")) return [p, "format", "$FILE"]
|
||||
if (content.includes("ruff")) return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
@@ -210,13 +208,14 @@ export const ruff: Info = {
|
||||
|
||||
export const rlang: Info = {
|
||||
name: "air",
|
||||
command: ["air", "format", "$FILE"],
|
||||
extensions: [".R"],
|
||||
async enabled() {
|
||||
const airPath = which("air")
|
||||
if (airPath == null) return false
|
||||
|
||||
try {
|
||||
const proc = Process.spawn([airPath, "--help"], {
|
||||
const proc = Process.spawn(["air", "--help"], {
|
||||
stdout: "pipe",
|
||||
stderr: "pipe",
|
||||
})
|
||||
@@ -228,10 +227,7 @@ export const rlang: Info = {
|
||||
const firstLine = output.split("\n")[0]
|
||||
const hasR = firstLine.includes("R language")
|
||||
const hasFormatter = firstLine.includes("formatter")
|
||||
if (hasR && hasFormatter) {
|
||||
return [airPath, "format", "$FILE"]
|
||||
}
|
||||
return false
|
||||
return hasR && hasFormatter
|
||||
} catch (error) {
|
||||
return false
|
||||
}
|
||||
@@ -240,14 +236,14 @@ export const rlang: Info = {
|
||||
|
||||
export const uvformat: Info = {
|
||||
name: "uv",
|
||||
command: ["uv", "format", "--", "$FILE"],
|
||||
extensions: [".py", ".pyi"],
|
||||
async enabled() {
|
||||
if (await ruff.enabled()) return false
|
||||
const uvPath = which("uv")
|
||||
if (uvPath !== null) {
|
||||
const proc = Process.spawn([uvPath, "format", "--help"], { stderr: "pipe", stdout: "pipe" })
|
||||
if (which("uv") !== null) {
|
||||
const proc = Process.spawn(["uv", "format", "--help"], { stderr: "pipe", stdout: "pipe" })
|
||||
const code = await proc.exited
|
||||
if (code === 0) return [uvPath, "format", "--", "$FILE"]
|
||||
return code === 0
|
||||
}
|
||||
return false
|
||||
},
|
||||
@@ -255,118 +251,108 @@ export const uvformat: Info = {
|
||||
|
||||
export const rubocop: Info = {
|
||||
name: "rubocop",
|
||||
command: ["rubocop", "--autocorrect", "$FILE"],
|
||||
extensions: [".rb", ".rake", ".gemspec", ".ru"],
|
||||
async enabled() {
|
||||
const path = which("rubocop")
|
||||
if (path === null) return false
|
||||
return [path, "--autocorrect", "$FILE"]
|
||||
return which("rubocop") !== null
|
||||
},
|
||||
}
|
||||
|
||||
export const standardrb: Info = {
|
||||
name: "standardrb",
|
||||
command: ["standardrb", "--fix", "$FILE"],
|
||||
extensions: [".rb", ".rake", ".gemspec", ".ru"],
|
||||
async enabled() {
|
||||
const path = which("standardrb")
|
||||
if (path === null) return false
|
||||
return [path, "--fix", "$FILE"]
|
||||
return which("standardrb") !== null
|
||||
},
|
||||
}
|
||||
|
||||
export const htmlbeautifier: Info = {
|
||||
name: "htmlbeautifier",
|
||||
command: ["htmlbeautifier", "$FILE"],
|
||||
extensions: [".erb", ".html.erb"],
|
||||
async enabled() {
|
||||
const path = which("htmlbeautifier")
|
||||
if (path === null) return false
|
||||
return [path, "$FILE"]
|
||||
return which("htmlbeautifier") !== null
|
||||
},
|
||||
}
|
||||
|
||||
export const dart: Info = {
|
||||
name: "dart",
|
||||
command: ["dart", "format", "$FILE"],
|
||||
extensions: [".dart"],
|
||||
async enabled() {
|
||||
const path = which("dart")
|
||||
if (path === null) return false
|
||||
return [path, "format", "$FILE"]
|
||||
return which("dart") !== null
|
||||
},
|
||||
}
|
||||
|
||||
export const ocamlformat: Info = {
|
||||
name: "ocamlformat",
|
||||
command: ["ocamlformat", "-i", "$FILE"],
|
||||
extensions: [".ml", ".mli"],
|
||||
async enabled() {
|
||||
const path = which("ocamlformat")
|
||||
if (!path) return false
|
||||
if (!which("ocamlformat")) return false
|
||||
const items = await Filesystem.findUp(".ocamlformat", Instance.directory, Instance.worktree)
|
||||
if (items.length === 0) return false
|
||||
return [path, "-i", "$FILE"]
|
||||
return items.length > 0
|
||||
},
|
||||
}
|
||||
|
||||
export const terraform: Info = {
|
||||
name: "terraform",
|
||||
command: ["terraform", "fmt", "$FILE"],
|
||||
extensions: [".tf", ".tfvars"],
|
||||
async enabled() {
|
||||
const path = which("terraform")
|
||||
if (path === null) return false
|
||||
return [path, "fmt", "$FILE"]
|
||||
return which("terraform") !== null
|
||||
},
|
||||
}
|
||||
|
||||
export const latexindent: Info = {
|
||||
name: "latexindent",
|
||||
command: ["latexindent", "-w", "-s", "$FILE"],
|
||||
extensions: [".tex"],
|
||||
async enabled() {
|
||||
const path = which("latexindent")
|
||||
if (path === null) return false
|
||||
return [path, "-w", "-s", "$FILE"]
|
||||
return which("latexindent") !== null
|
||||
},
|
||||
}
|
||||
|
||||
export const gleam: Info = {
|
||||
name: "gleam",
|
||||
command: ["gleam", "format", "$FILE"],
|
||||
extensions: [".gleam"],
|
||||
async enabled() {
|
||||
const path = which("gleam")
|
||||
if (path === null) return false
|
||||
return [path, "format", "$FILE"]
|
||||
return which("gleam") !== null
|
||||
},
|
||||
}
|
||||
|
||||
export const shfmt: Info = {
|
||||
name: "shfmt",
|
||||
command: ["shfmt", "-w", "$FILE"],
|
||||
extensions: [".sh", ".bash"],
|
||||
async enabled() {
|
||||
const path = which("shfmt")
|
||||
if (path === null) return false
|
||||
return [path, "-w", "$FILE"]
|
||||
return which("shfmt") !== null
|
||||
},
|
||||
}
|
||||
|
||||
export const nixfmt: Info = {
|
||||
name: "nixfmt",
|
||||
command: ["nixfmt", "$FILE"],
|
||||
extensions: [".nix"],
|
||||
async enabled() {
|
||||
const path = which("nixfmt")
|
||||
if (path === null) return false
|
||||
return [path, "$FILE"]
|
||||
return which("nixfmt") !== null
|
||||
},
|
||||
}
|
||||
|
||||
export const rustfmt: Info = {
|
||||
name: "rustfmt",
|
||||
command: ["rustfmt", "$FILE"],
|
||||
extensions: [".rs"],
|
||||
async enabled() {
|
||||
const path = which("rustfmt")
|
||||
if (path === null) return false
|
||||
return [path, "$FILE"]
|
||||
return which("rustfmt") !== null
|
||||
},
|
||||
}
|
||||
|
||||
export const pint: Info = {
|
||||
name: "pint",
|
||||
command: ["./vendor/bin/pint", "$FILE"],
|
||||
extensions: [".php"],
|
||||
async enabled() {
|
||||
const items = await Filesystem.findUp("composer.json", Instance.directory, Instance.worktree)
|
||||
@@ -375,9 +361,8 @@ export const pint: Info = {
|
||||
require?: Record<string, string>
|
||||
"require-dev"?: Record<string, string>
|
||||
}>(item)
|
||||
if (json.require?.["laravel/pint"] || json["require-dev"]?.["laravel/pint"]) {
|
||||
return ["./vendor/bin/pint", "$FILE"]
|
||||
}
|
||||
if (json.require?.["laravel/pint"]) return true
|
||||
if (json["require-dev"]?.["laravel/pint"]) return true
|
||||
}
|
||||
return false
|
||||
},
|
||||
@@ -385,30 +370,27 @@ export const pint: Info = {
|
||||
|
||||
export const ormolu: Info = {
|
||||
name: "ormolu",
|
||||
command: ["ormolu", "-i", "$FILE"],
|
||||
extensions: [".hs"],
|
||||
async enabled() {
|
||||
const path = which("ormolu")
|
||||
if (path === null) return false
|
||||
return [path, "-i", "$FILE"]
|
||||
return which("ormolu") !== null
|
||||
},
|
||||
}
|
||||
|
||||
export const cljfmt: Info = {
|
||||
name: "cljfmt",
|
||||
command: ["cljfmt", "fix", "--quiet", "$FILE"],
|
||||
extensions: [".clj", ".cljs", ".cljc", ".edn"],
|
||||
async enabled() {
|
||||
const path = which("cljfmt")
|
||||
if (path === null) return false
|
||||
return [path, "fix", "--quiet", "$FILE"]
|
||||
return which("cljfmt") !== null
|
||||
},
|
||||
}
|
||||
|
||||
export const dfmt: Info = {
|
||||
name: "dfmt",
|
||||
command: ["dfmt", "-i", "$FILE"],
|
||||
extensions: [".d"],
|
||||
async enabled() {
|
||||
const path = which("dfmt")
|
||||
if (path === null) return false
|
||||
return [path, "-i", "$FILE"]
|
||||
return which("dfmt") !== null
|
||||
},
|
||||
}
|
||||
|
||||
@@ -25,14 +25,14 @@ export namespace Format {
|
||||
export type Status = z.infer<typeof Status>
|
||||
|
||||
const state = Instance.state(async () => {
|
||||
const cache: Record<string, string[] | false> = {}
|
||||
const enabled: Record<string, boolean> = {}
|
||||
const cfg = await Config.get()
|
||||
|
||||
const formatters: Record<string, Formatter.Info> = {}
|
||||
if (cfg.formatter === false) {
|
||||
log.info("all formatters are disabled")
|
||||
return {
|
||||
cache,
|
||||
enabled,
|
||||
formatters,
|
||||
}
|
||||
}
|
||||
@@ -46,41 +46,43 @@ export namespace Format {
|
||||
continue
|
||||
}
|
||||
const result: Formatter.Info = mergeDeep(formatters[name] ?? {}, {
|
||||
command: [],
|
||||
extensions: [],
|
||||
...item,
|
||||
})
|
||||
|
||||
result.enabled = async () => item.command ?? false
|
||||
if (result.command.length === 0) continue
|
||||
|
||||
result.enabled = async () => true
|
||||
result.name = name
|
||||
formatters[name] = result
|
||||
}
|
||||
|
||||
return {
|
||||
cache,
|
||||
enabled,
|
||||
formatters,
|
||||
}
|
||||
})
|
||||
|
||||
async function resolveCommand(item: Formatter.Info) {
|
||||
async function isEnabled(item: Formatter.Info) {
|
||||
const s = await state()
|
||||
let command = s.cache[item.name]
|
||||
if (command === undefined) {
|
||||
log.info("resolving command", { name: item.name })
|
||||
command = await item.enabled()
|
||||
s.cache[item.name] = command
|
||||
let status = s.enabled[item.name]
|
||||
if (status === undefined) {
|
||||
status = await item.enabled()
|
||||
s.enabled[item.name] = status
|
||||
}
|
||||
return command
|
||||
return status
|
||||
}
|
||||
|
||||
async function getFormatter(ext: string) {
|
||||
const formatters = await state().then((x) => x.formatters)
|
||||
const result: { info: Formatter.Info; command: string[] }[] = []
|
||||
const result = []
|
||||
for (const item of Object.values(formatters)) {
|
||||
log.info("checking", { name: item.name, ext })
|
||||
if (!item.extensions.includes(ext)) continue
|
||||
const command = await resolveCommand(item)
|
||||
if (!command) continue
|
||||
if (!(await isEnabled(item))) continue
|
||||
log.info("enabled", { name: item.name, ext })
|
||||
result.push({ info: item, command })
|
||||
result.push(item)
|
||||
}
|
||||
return result
|
||||
}
|
||||
@@ -89,11 +91,11 @@ export namespace Format {
|
||||
const s = await state()
|
||||
const result: Status[] = []
|
||||
for (const formatter of Object.values(s.formatters)) {
|
||||
const command = await resolveCommand(formatter)
|
||||
const enabled = await isEnabled(formatter)
|
||||
result.push({
|
||||
name: formatter.name,
|
||||
extensions: formatter.extensions,
|
||||
enabled: !!command,
|
||||
enabled,
|
||||
})
|
||||
}
|
||||
return result
|
||||
@@ -106,27 +108,29 @@ export namespace Format {
|
||||
log.info("formatting", { file })
|
||||
const ext = path.extname(file)
|
||||
|
||||
for (const { info, command } of await getFormatter(ext)) {
|
||||
const replaced = command.map((x) => x.replace("$FILE", file))
|
||||
log.info("running", { replaced })
|
||||
for (const item of await getFormatter(ext)) {
|
||||
log.info("running", { command: item.command })
|
||||
try {
|
||||
const proc = Process.spawn(replaced, {
|
||||
cwd: Instance.directory,
|
||||
env: { ...process.env, ...info.environment },
|
||||
stdout: "ignore",
|
||||
stderr: "ignore",
|
||||
})
|
||||
const proc = Process.spawn(
|
||||
item.command.map((x) => x.replace("$FILE", file)),
|
||||
{
|
||||
cwd: Instance.directory,
|
||||
env: { ...process.env, ...item.environment },
|
||||
stdout: "ignore",
|
||||
stderr: "ignore",
|
||||
},
|
||||
)
|
||||
const exit = await proc.exited
|
||||
if (exit !== 0)
|
||||
log.error("failed", {
|
||||
command,
|
||||
...info.environment,
|
||||
command: item.command,
|
||||
...item.environment,
|
||||
})
|
||||
} catch (error) {
|
||||
log.error("failed to format file", {
|
||||
error,
|
||||
command,
|
||||
...info.environment,
|
||||
command: item.command,
|
||||
...item.environment,
|
||||
file,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -18,7 +18,7 @@ export namespace Global {
|
||||
return process.env.OPENCODE_TEST_HOME || os.homedir()
|
||||
},
|
||||
data,
|
||||
bin: path.join(cache, "bin"),
|
||||
bin: path.join(data, "bin"),
|
||||
log: path.join(data, "log"),
|
||||
cache,
|
||||
config,
|
||||
|
||||
@@ -3,8 +3,7 @@ import { hideBin } from "yargs/helpers"
|
||||
import { RunCommand } from "./cli/cmd/run"
|
||||
import { GenerateCommand } from "./cli/cmd/generate"
|
||||
import { Log } from "./util/log"
|
||||
import { LoginCommand, LogoutCommand, SwitchCommand, OrgsCommand } from "./cli/cmd/account"
|
||||
import { ProvidersCommand } from "./cli/cmd/providers"
|
||||
import { AuthCommand } from "./cli/cmd/auth"
|
||||
import { AgentCommand } from "./cli/cmd/agent"
|
||||
import { UpgradeCommand } from "./cli/cmd/upgrade"
|
||||
import { UninstallCommand } from "./cli/cmd/uninstall"
|
||||
@@ -135,11 +134,7 @@ let cli = yargs(hideBin(process.argv))
|
||||
.command(RunCommand)
|
||||
.command(GenerateCommand)
|
||||
.command(DebugCommand)
|
||||
.command(LoginCommand)
|
||||
.command(LogoutCommand)
|
||||
.command(SwitchCommand)
|
||||
.command(OrgsCommand)
|
||||
.command(ProvidersCommand)
|
||||
.command(AuthCommand)
|
||||
.command(AgentCommand)
|
||||
.command(UpgradeCommand)
|
||||
.command(UninstallCommand)
|
||||
|
||||
@@ -3,6 +3,7 @@ import path from "path"
|
||||
import os from "os"
|
||||
import { Global } from "../global"
|
||||
import { Log } from "../util/log"
|
||||
import { BunProc } from "../bun"
|
||||
import { text } from "node:stream/consumers"
|
||||
import fs from "fs/promises"
|
||||
import { Filesystem } from "../util/filesystem"
|
||||
@@ -12,7 +13,6 @@ import { Archive } from "../util/archive"
|
||||
import { Process } from "../util/process"
|
||||
import { which } from "../util/which"
|
||||
import { Module } from "@opencode-ai/util/module"
|
||||
import { Npm } from "@/npm"
|
||||
|
||||
export namespace LSPServer {
|
||||
const log = Log.create({ service: "lsp.server" })
|
||||
@@ -102,7 +102,7 @@ export namespace LSPServer {
|
||||
const tsserver = Module.resolve("typescript/lib/tsserver.js", Instance.directory)
|
||||
log.info("typescript server", { tsserver })
|
||||
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,
|
||||
env: {
|
||||
...process.env,
|
||||
@@ -128,8 +128,29 @@ export namespace LSPServer {
|
||||
let binary = which("vue-language-server")
|
||||
const args: string[] = []
|
||||
if (!binary) {
|
||||
if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return
|
||||
binary = await Npm.which("@vue/language-server")
|
||||
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
|
||||
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")
|
||||
const proc = spawn(binary, args, {
|
||||
@@ -192,7 +213,7 @@ export namespace LSPServer {
|
||||
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,
|
||||
env: {
|
||||
...process.env,
|
||||
@@ -323,8 +344,8 @@ export namespace LSPServer {
|
||||
if (!bin) {
|
||||
const resolved = Module.resolve("biome", root)
|
||||
if (!resolved) return
|
||||
bin = await Npm.which("biome")
|
||||
args = ["lsp-proxy", "--stdio"]
|
||||
bin = BunProc.which()
|
||||
args = ["x", "biome", "lsp-proxy", "--stdio"]
|
||||
}
|
||||
|
||||
const proc = spawn(bin, args, {
|
||||
@@ -350,7 +371,9 @@ export namespace LSPServer {
|
||||
},
|
||||
extensions: [".go"],
|
||||
async spawn(root) {
|
||||
let bin = which("gopls")
|
||||
let bin = which("gopls", {
|
||||
PATH: process.env["PATH"] + path.delimiter + Global.Path.bin,
|
||||
})
|
||||
if (!bin) {
|
||||
if (!which("go")) return
|
||||
if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return
|
||||
@@ -385,7 +408,9 @@ export namespace LSPServer {
|
||||
root: NearestRoot(["Gemfile"]),
|
||||
extensions: [".rb", ".rake", ".gemspec", ".ru"],
|
||||
async spawn(root) {
|
||||
let bin = which("rubocop")
|
||||
let bin = which("rubocop", {
|
||||
PATH: process.env["PATH"] + path.delimiter + Global.Path.bin,
|
||||
})
|
||||
if (!bin) {
|
||||
const ruby = which("ruby")
|
||||
const gem = which("gem")
|
||||
@@ -490,8 +515,19 @@ export namespace LSPServer {
|
||||
let binary = which("pyright-langserver")
|
||||
const args = []
|
||||
if (!binary) {
|
||||
if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return
|
||||
binary = await Npm.which("pyright")
|
||||
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
|
||||
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")
|
||||
|
||||
@@ -593,7 +629,9 @@ export namespace LSPServer {
|
||||
extensions: [".zig", ".zon"],
|
||||
root: NearestRoot(["build.zig"]),
|
||||
async spawn(root) {
|
||||
let bin = which("zls")
|
||||
let bin = which("zls", {
|
||||
PATH: process.env["PATH"] + path.delimiter + Global.Path.bin,
|
||||
})
|
||||
|
||||
if (!bin) {
|
||||
const zig = which("zig")
|
||||
@@ -703,7 +741,9 @@ export namespace LSPServer {
|
||||
root: NearestRoot([".slnx", ".sln", ".csproj", "global.json"]),
|
||||
extensions: [".cs"],
|
||||
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 (!which("dotnet")) {
|
||||
log.error(".NET SDK is required to install csharp-ls")
|
||||
@@ -740,7 +780,9 @@ export namespace LSPServer {
|
||||
root: NearestRoot([".slnx", ".sln", ".fsproj", "global.json"]),
|
||||
extensions: [".fs", ".fsi", ".fsx", ".fsscript"],
|
||||
async spawn(root) {
|
||||
let bin = which("fsautocomplete")
|
||||
let bin = which("fsautocomplete", {
|
||||
PATH: process.env["PATH"] + path.delimiter + Global.Path.bin,
|
||||
})
|
||||
if (!bin) {
|
||||
if (!which("dotnet")) {
|
||||
log.error(".NET SDK is required to install fsautocomplete")
|
||||
@@ -1006,8 +1048,22 @@ export namespace LSPServer {
|
||||
let binary = which("svelteserver")
|
||||
const args: string[] = []
|
||||
if (!binary) {
|
||||
if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return
|
||||
binary = await Npm.which("svelte-language-server")
|
||||
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
|
||||
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")
|
||||
const proc = spawn(binary, args, {
|
||||
@@ -1039,8 +1095,22 @@ export namespace LSPServer {
|
||||
let binary = which("astro-ls")
|
||||
const args: string[] = []
|
||||
if (!binary) {
|
||||
if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return
|
||||
binary = await Npm.which("@astrojs/language-server")
|
||||
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
|
||||
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")
|
||||
const proc = spawn(binary, args, {
|
||||
@@ -1289,8 +1359,31 @@ export namespace LSPServer {
|
||||
let binary = which("yaml-language-server")
|
||||
const args: string[] = []
|
||||
if (!binary) {
|
||||
if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return
|
||||
binary = await Npm.which("yaml-language-server")
|
||||
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
|
||||
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")
|
||||
const proc = spawn(binary, args, {
|
||||
@@ -1319,7 +1412,9 @@ export namespace LSPServer {
|
||||
]),
|
||||
extensions: [".lua"],
|
||||
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 (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return
|
||||
@@ -1455,8 +1550,22 @@ export namespace LSPServer {
|
||||
let binary = which("intelephense")
|
||||
const args: string[] = []
|
||||
if (!binary) {
|
||||
if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return
|
||||
binary = await Npm.which("intelephense")
|
||||
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
|
||||
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")
|
||||
const proc = spawn(binary, args, {
|
||||
@@ -1538,8 +1647,22 @@ export namespace LSPServer {
|
||||
let binary = which("bash-language-server")
|
||||
const args: string[] = []
|
||||
if (!binary) {
|
||||
if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return
|
||||
binary = await Npm.which("bash-language-server")
|
||||
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
|
||||
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")
|
||||
const proc = spawn(binary, args, {
|
||||
@@ -1560,7 +1683,9 @@ export namespace LSPServer {
|
||||
extensions: [".tf", ".tfvars"],
|
||||
root: NearestRoot([".terraform.lock.hcl", "terraform.tfstate", "*.tf"]),
|
||||
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 (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return
|
||||
@@ -1641,7 +1766,9 @@ export namespace LSPServer {
|
||||
extensions: [".tex", ".bib"],
|
||||
root: NearestRoot([".latexmkrc", "latexmkrc", ".texlabroot", "texlabroot"]),
|
||||
async spawn(root) {
|
||||
let bin = which("texlab")
|
||||
let bin = which("texlab", {
|
||||
PATH: process.env["PATH"] + path.delimiter + Global.Path.bin,
|
||||
})
|
||||
|
||||
if (!bin) {
|
||||
if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return
|
||||
@@ -1732,8 +1859,22 @@ export namespace LSPServer {
|
||||
let binary = which("docker-langserver")
|
||||
const args: string[] = []
|
||||
if (!binary) {
|
||||
if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return
|
||||
binary = await Npm.which("dockerfile-language-server-nodejs")
|
||||
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
|
||||
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")
|
||||
const proc = spawn(binary, args, {
|
||||
@@ -1824,7 +1965,9 @@ export namespace LSPServer {
|
||||
extensions: [".typ", ".typc"],
|
||||
root: NearestRoot(["typst.toml"]),
|
||||
async spawn(root) {
|
||||
let bin = which("tinymist")
|
||||
let bin = which("tinymist", {
|
||||
PATH: process.env["PATH"] + path.delimiter + Global.Path.bin,
|
||||
})
|
||||
|
||||
if (!bin) {
|
||||
if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return
|
||||
|
||||
@@ -11,7 +11,6 @@ import {
|
||||
} from "@modelcontextprotocol/sdk/types.js"
|
||||
import { Config } from "../config/config"
|
||||
import { Log } from "../util/log"
|
||||
import { Process } from "../util/process"
|
||||
import { NamedError } from "@opencode-ai/util/error"
|
||||
import z from "zod/v4"
|
||||
import { Instance } from "../project/instance"
|
||||
@@ -167,10 +166,14 @@ export namespace MCP {
|
||||
const queue = [pid]
|
||||
while (queue.length > 0) {
|
||||
const current = queue.shift()!
|
||||
const lines = await Process.lines(["pgrep", "-P", String(current)], { nothrow: true })
|
||||
for (const tok of lines) {
|
||||
const proc = Bun.spawn(["pgrep", "-P", String(current)], { stdout: "pipe", stderr: "pipe" })
|
||||
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)
|
||||
if (!isNaN(cpid) && !pids.includes(cpid)) {
|
||||
if (!isNaN(cpid) && pids.indexOf(cpid) === -1) {
|
||||
pids.push(cpid)
|
||||
queue.push(cpid)
|
||||
}
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { createConnection } from "net"
|
||||
import { createServer } from "http"
|
||||
import { Log } from "../util/log"
|
||||
import { OAUTH_CALLBACK_PORT, OAUTH_CALLBACK_PATH } from "./oauth-provider"
|
||||
|
||||
@@ -53,74 +52,11 @@ interface PendingAuth {
|
||||
}
|
||||
|
||||
export namespace McpOAuthCallback {
|
||||
let server: ReturnType<typeof createServer> | undefined
|
||||
let server: ReturnType<typeof Bun.serve> | undefined
|
||||
const pendingAuths = new Map<string, PendingAuth>()
|
||||
|
||||
const CALLBACK_TIMEOUT_MS = 5 * 60 * 1000 // 5 minutes
|
||||
|
||||
function handleRequest(req: import("http").IncomingMessage, res: import("http").ServerResponse) {
|
||||
const url = new URL(req.url || "/", `http://localhost:${OAUTH_CALLBACK_PORT}`)
|
||||
|
||||
if (url.pathname !== OAUTH_CALLBACK_PATH) {
|
||||
res.writeHead(404)
|
||||
res.end("Not found")
|
||||
return
|
||||
}
|
||||
|
||||
const code = url.searchParams.get("code")
|
||||
const state = url.searchParams.get("state")
|
||||
const error = url.searchParams.get("error")
|
||||
const errorDescription = url.searchParams.get("error_description")
|
||||
|
||||
log.info("received oauth callback", { hasCode: !!code, state, error })
|
||||
|
||||
// Enforce state parameter presence
|
||||
if (!state) {
|
||||
const errorMsg = "Missing required state parameter - potential CSRF attack"
|
||||
log.error("oauth callback missing state parameter", { url: url.toString() })
|
||||
res.writeHead(400, { "Content-Type": "text/html" })
|
||||
res.end(HTML_ERROR(errorMsg))
|
||||
return
|
||||
}
|
||||
|
||||
if (error) {
|
||||
const errorMsg = errorDescription || error
|
||||
if (pendingAuths.has(state)) {
|
||||
const pending = pendingAuths.get(state)!
|
||||
clearTimeout(pending.timeout)
|
||||
pendingAuths.delete(state)
|
||||
pending.reject(new Error(errorMsg))
|
||||
}
|
||||
res.writeHead(200, { "Content-Type": "text/html" })
|
||||
res.end(HTML_ERROR(errorMsg))
|
||||
return
|
||||
}
|
||||
|
||||
if (!code) {
|
||||
res.writeHead(400, { "Content-Type": "text/html" })
|
||||
res.end(HTML_ERROR("No authorization code provided"))
|
||||
return
|
||||
}
|
||||
|
||||
// Validate state parameter
|
||||
if (!pendingAuths.has(state)) {
|
||||
const errorMsg = "Invalid or expired state parameter - potential CSRF attack"
|
||||
log.error("oauth callback with invalid state", { state, pendingStates: Array.from(pendingAuths.keys()) })
|
||||
res.writeHead(400, { "Content-Type": "text/html" })
|
||||
res.end(HTML_ERROR(errorMsg))
|
||||
return
|
||||
}
|
||||
|
||||
const pending = pendingAuths.get(state)!
|
||||
|
||||
clearTimeout(pending.timeout)
|
||||
pendingAuths.delete(state)
|
||||
pending.resolve(code)
|
||||
|
||||
res.writeHead(200, { "Content-Type": "text/html" })
|
||||
res.end(HTML_SUCCESS)
|
||||
}
|
||||
|
||||
export async function ensureRunning(): Promise<void> {
|
||||
if (server) return
|
||||
|
||||
@@ -130,14 +66,75 @@ export namespace McpOAuthCallback {
|
||||
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 })
|
||||
resolve()
|
||||
})
|
||||
server!.on("error", reject)
|
||||
server = Bun.serve({
|
||||
port: OAUTH_CALLBACK_PORT,
|
||||
fetch(req) {
|
||||
const url = new URL(req.url)
|
||||
|
||||
if (url.pathname !== OAUTH_CALLBACK_PATH) {
|
||||
return new Response("Not found", { status: 404 })
|
||||
}
|
||||
|
||||
const code = url.searchParams.get("code")
|
||||
const state = url.searchParams.get("state")
|
||||
const error = url.searchParams.get("error")
|
||||
const errorDescription = url.searchParams.get("error_description")
|
||||
|
||||
log.info("received oauth callback", { hasCode: !!code, state, error })
|
||||
|
||||
// Enforce state parameter presence
|
||||
if (!state) {
|
||||
const errorMsg = "Missing required state parameter - potential CSRF attack"
|
||||
log.error("oauth callback missing state parameter", { url: url.toString() })
|
||||
return new Response(HTML_ERROR(errorMsg), {
|
||||
status: 400,
|
||||
headers: { "Content-Type": "text/html" },
|
||||
})
|
||||
}
|
||||
|
||||
if (error) {
|
||||
const errorMsg = errorDescription || error
|
||||
if (pendingAuths.has(state)) {
|
||||
const pending = pendingAuths.get(state)!
|
||||
clearTimeout(pending.timeout)
|
||||
pendingAuths.delete(state)
|
||||
pending.reject(new Error(errorMsg))
|
||||
}
|
||||
return new Response(HTML_ERROR(errorMsg), {
|
||||
headers: { "Content-Type": "text/html" },
|
||||
})
|
||||
}
|
||||
|
||||
if (!code) {
|
||||
return new Response(HTML_ERROR("No authorization code provided"), {
|
||||
status: 400,
|
||||
headers: { "Content-Type": "text/html" },
|
||||
})
|
||||
}
|
||||
|
||||
// Validate state parameter
|
||||
if (!pendingAuths.has(state)) {
|
||||
const errorMsg = "Invalid or expired state parameter - potential CSRF attack"
|
||||
log.error("oauth callback with invalid state", { state, pendingStates: Array.from(pendingAuths.keys()) })
|
||||
return new Response(HTML_ERROR(errorMsg), {
|
||||
status: 400,
|
||||
headers: { "Content-Type": "text/html" },
|
||||
})
|
||||
}
|
||||
|
||||
const pending = pendingAuths.get(state)!
|
||||
|
||||
clearTimeout(pending.timeout)
|
||||
pendingAuths.delete(state)
|
||||
pending.resolve(code)
|
||||
|
||||
return new Response(HTML_SUCCESS, {
|
||||
headers: { "Content-Type": "text/html" },
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
log.info("oauth callback server started", { port: OAUTH_CALLBACK_PORT })
|
||||
}
|
||||
|
||||
export function waitForCallback(oauthState: string): Promise<string> {
|
||||
@@ -177,7 +174,7 @@ export namespace McpOAuthCallback {
|
||||
|
||||
export async function stop(): Promise<void> {
|
||||
if (server) {
|
||||
await new Promise<void>((resolve) => server!.close(() => resolve()))
|
||||
server.stop()
|
||||
server = undefined
|
||||
log.info("oauth callback server stopped")
|
||||
}
|
||||
|
||||
@@ -1,8 +0,0 @@
|
||||
import { Server } from "./server/server"
|
||||
|
||||
const result = await Server.listen({
|
||||
port: 1338,
|
||||
hostname: "0.0.0.0",
|
||||
})
|
||||
|
||||
console.log(result)
|
||||
@@ -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])
|
||||
}
|
||||
}
|
||||
@@ -5,7 +5,6 @@ import { Auth, OAUTH_DUMMY_KEY } from "../auth"
|
||||
import os from "os"
|
||||
import { ProviderTransform } from "@/provider/transform"
|
||||
import { setTimeout as sleep } from "node:timers/promises"
|
||||
import { createServer } from "http"
|
||||
|
||||
const log = Log.create({ service: "plugin.codex" })
|
||||
|
||||
@@ -241,7 +240,7 @@ interface PendingOAuth {
|
||||
reject: (error: Error) => void
|
||||
}
|
||||
|
||||
let oauthServer: ReturnType<typeof createServer> | undefined
|
||||
let oauthServer: ReturnType<typeof Bun.serve> | undefined
|
||||
let pendingOAuth: PendingOAuth | undefined
|
||||
|
||||
async function startOAuthServer(): Promise<{ port: number; redirectUri: string }> {
|
||||
@@ -249,83 +248,77 @@ async function startOAuthServer(): Promise<{ port: number; redirectUri: string }
|
||||
return { port: OAUTH_PORT, redirectUri: `http://localhost:${OAUTH_PORT}/auth/callback` }
|
||||
}
|
||||
|
||||
oauthServer = createServer((req, res) => {
|
||||
const url = new URL(req.url || "/", `http://localhost:${OAUTH_PORT}`)
|
||||
oauthServer = Bun.serve({
|
||||
port: OAUTH_PORT,
|
||||
fetch(req) {
|
||||
const url = new URL(req.url)
|
||||
|
||||
if (url.pathname === "/auth/callback") {
|
||||
const code = url.searchParams.get("code")
|
||||
const state = url.searchParams.get("state")
|
||||
const error = url.searchParams.get("error")
|
||||
const errorDescription = url.searchParams.get("error_description")
|
||||
if (url.pathname === "/auth/callback") {
|
||||
const code = url.searchParams.get("code")
|
||||
const state = url.searchParams.get("state")
|
||||
const error = url.searchParams.get("error")
|
||||
const errorDescription = url.searchParams.get("error_description")
|
||||
|
||||
if (error) {
|
||||
const errorMsg = errorDescription || error
|
||||
pendingOAuth?.reject(new Error(errorMsg))
|
||||
if (error) {
|
||||
const errorMsg = errorDescription || error
|
||||
pendingOAuth?.reject(new Error(errorMsg))
|
||||
pendingOAuth = undefined
|
||||
return new Response(HTML_ERROR(errorMsg), {
|
||||
headers: { "Content-Type": "text/html" },
|
||||
})
|
||||
}
|
||||
|
||||
if (!code) {
|
||||
const errorMsg = "Missing authorization code"
|
||||
pendingOAuth?.reject(new Error(errorMsg))
|
||||
pendingOAuth = undefined
|
||||
return new Response(HTML_ERROR(errorMsg), {
|
||||
status: 400,
|
||||
headers: { "Content-Type": "text/html" },
|
||||
})
|
||||
}
|
||||
|
||||
if (!pendingOAuth || state !== pendingOAuth.state) {
|
||||
const errorMsg = "Invalid state - potential CSRF attack"
|
||||
pendingOAuth?.reject(new Error(errorMsg))
|
||||
pendingOAuth = undefined
|
||||
return new Response(HTML_ERROR(errorMsg), {
|
||||
status: 400,
|
||||
headers: { "Content-Type": "text/html" },
|
||||
})
|
||||
}
|
||||
|
||||
const current = pendingOAuth
|
||||
pendingOAuth = undefined
|
||||
res.writeHead(200, { "Content-Type": "text/html" })
|
||||
res.end(HTML_ERROR(errorMsg))
|
||||
return
|
||||
|
||||
exchangeCodeForTokens(code, `http://localhost:${OAUTH_PORT}/auth/callback`, current.pkce)
|
||||
.then((tokens) => current.resolve(tokens))
|
||||
.catch((err) => current.reject(err))
|
||||
|
||||
return new Response(HTML_SUCCESS, {
|
||||
headers: { "Content-Type": "text/html" },
|
||||
})
|
||||
}
|
||||
|
||||
if (!code) {
|
||||
const errorMsg = "Missing authorization code"
|
||||
pendingOAuth?.reject(new Error(errorMsg))
|
||||
if (url.pathname === "/cancel") {
|
||||
pendingOAuth?.reject(new Error("Login cancelled"))
|
||||
pendingOAuth = undefined
|
||||
res.writeHead(400, { "Content-Type": "text/html" })
|
||||
res.end(HTML_ERROR(errorMsg))
|
||||
return
|
||||
return new Response("Login cancelled", { status: 200 })
|
||||
}
|
||||
|
||||
if (!pendingOAuth || state !== pendingOAuth.state) {
|
||||
const errorMsg = "Invalid state - potential CSRF attack"
|
||||
pendingOAuth?.reject(new Error(errorMsg))
|
||||
pendingOAuth = undefined
|
||||
res.writeHead(400, { "Content-Type": "text/html" })
|
||||
res.end(HTML_ERROR(errorMsg))
|
||||
return
|
||||
}
|
||||
|
||||
const current = pendingOAuth
|
||||
pendingOAuth = undefined
|
||||
|
||||
exchangeCodeForTokens(code, `http://localhost:${OAUTH_PORT}/auth/callback`, current.pkce)
|
||||
.then((tokens) => current.resolve(tokens))
|
||||
.catch((err) => current.reject(err))
|
||||
|
||||
res.writeHead(200, { "Content-Type": "text/html" })
|
||||
res.end(HTML_SUCCESS)
|
||||
return
|
||||
}
|
||||
|
||||
if (url.pathname === "/cancel") {
|
||||
pendingOAuth?.reject(new Error("Login cancelled"))
|
||||
pendingOAuth = undefined
|
||||
res.writeHead(200)
|
||||
res.end("Login cancelled")
|
||||
return
|
||||
}
|
||||
|
||||
res.writeHead(404)
|
||||
res.end("Not found")
|
||||
})
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
oauthServer!.listen(OAUTH_PORT, () => {
|
||||
log.info("codex oauth server started", { port: OAUTH_PORT })
|
||||
resolve()
|
||||
})
|
||||
oauthServer!.on("error", reject)
|
||||
return new Response("Not found", { status: 404 })
|
||||
},
|
||||
})
|
||||
|
||||
log.info("codex oauth server started", { port: OAUTH_PORT })
|
||||
return { port: OAUTH_PORT, redirectUri: `http://localhost:${OAUTH_PORT}/auth/callback` }
|
||||
}
|
||||
|
||||
function stopOAuthServer() {
|
||||
if (oauthServer) {
|
||||
oauthServer.close(() => {
|
||||
log.info("codex oauth server stopped")
|
||||
})
|
||||
oauthServer.stop()
|
||||
oauthServer = undefined
|
||||
log.info("codex oauth server stopped")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ import { Bus } from "../bus"
|
||||
import { Log } from "../util/log"
|
||||
import { createOpencodeClient } from "@opencode-ai/sdk"
|
||||
import { Server } from "../server/server"
|
||||
import { Npm } from "../npm"
|
||||
import { BunProc } from "../bun"
|
||||
import { Instance } from "../project/instance"
|
||||
import { Flag } from "../flag/flag"
|
||||
import { CodexAuthPlugin } from "./codex"
|
||||
@@ -27,9 +27,7 @@ export namespace Plugin {
|
||||
directory: Instance.directory,
|
||||
fetch: async (...args) => Server.Default().fetch(...args),
|
||||
})
|
||||
log.info("loading config")
|
||||
const config = await Config.get()
|
||||
log.info("config loaded")
|
||||
const hooks: Hooks[] = []
|
||||
const input: PluginInput = {
|
||||
client,
|
||||
@@ -39,8 +37,7 @@ export namespace Plugin {
|
||||
get serverUrl(): URL {
|
||||
throw new Error("Server URL is no longer supported in plugins")
|
||||
},
|
||||
// @ts-expect-error
|
||||
$: typeof Bun === "undefined" ? undefined : Bun.$,
|
||||
$: Bun.$,
|
||||
}
|
||||
|
||||
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
|
||||
log.info("loading plugin", { path: plugin })
|
||||
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 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, {
|
||||
error: new NamedError.Unknown({
|
||||
message: `Failed to install plugin ${plugin}: ${detail}`,
|
||||
message: `Failed to install plugin ${pkg}@${version}: ${detail}`,
|
||||
}).toObject(),
|
||||
})
|
||||
return ""
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
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", {
|
||||
id: text().primaryKey(),
|
||||
|
||||
@@ -5,7 +5,7 @@ import { Config } from "../config/config"
|
||||
import { mapValues, mergeDeep, omit, pickBy, sortBy } from "remeda"
|
||||
import { NoSuchModelError, type Provider as SDK } from "ai"
|
||||
import { Log } from "../util/log"
|
||||
import { Npm } from "../npm"
|
||||
import { BunProc } from "../bun"
|
||||
import { Hash } from "../util/hash"
|
||||
import { Plugin } from "../plugin"
|
||||
import { NamedError } from "@opencode-ai/util/error"
|
||||
@@ -1201,7 +1201,7 @@ export namespace Provider {
|
||||
|
||||
let installedPath: string
|
||||
if (!model.api.npm.startsWith("file://")) {
|
||||
installedPath = await Npm.add(model.api.npm)
|
||||
installedPath = await BunProc.install(model.api.npm, "latest")
|
||||
} else {
|
||||
log.info("loading local provider", { pkg: model.api.npm })
|
||||
installedPath = model.api.npm
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { BusEvent } from "@/bus/bus-event"
|
||||
import { Bus } from "@/bus"
|
||||
import type { Proc } from "#pty"
|
||||
import { type IPty } from "bun-pty"
|
||||
import z from "zod"
|
||||
import { Identifier } from "../id/id"
|
||||
import { Log } from "../util/log"
|
||||
@@ -35,7 +35,10 @@ export namespace Pty {
|
||||
return out
|
||||
}
|
||||
|
||||
const pty = lazy(() => import("#pty"))
|
||||
const pty = lazy(async () => {
|
||||
const { spawn } = await import("bun-pty")
|
||||
return spawn
|
||||
})
|
||||
|
||||
export const Info = z
|
||||
.object({
|
||||
@@ -82,7 +85,7 @@ export namespace Pty {
|
||||
|
||||
interface ActiveSession {
|
||||
info: Info
|
||||
process: Proc
|
||||
process: IPty
|
||||
buffer: string
|
||||
bufferCursor: number
|
||||
cursor: number
|
||||
@@ -141,7 +144,7 @@ export namespace Pty {
|
||||
}
|
||||
log.info("creating session", { id, cmd: command, args, cwd })
|
||||
|
||||
const { spawn } = await pty()
|
||||
const spawn = await pty()
|
||||
const ptyProcess = spawn(command, args, {
|
||||
name: "xterm-256color",
|
||||
cwd,
|
||||
|
||||
@@ -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)
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -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) => {
|
||||
const projects = Project.list()
|
||||
const projects = await Project.list()
|
||||
return c.json(projects)
|
||||
},
|
||||
)
|
||||
|
||||
@@ -8,9 +8,12 @@ import { Snapshot } from "@/snapshot"
|
||||
import { fn } from "@/util/fn"
|
||||
import { Database, eq, desc, inArray } from "@/storage/db"
|
||||
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 { iife } from "@/util/iife"
|
||||
import type { SystemError } from "bun"
|
||||
import { type SystemError } from "bun"
|
||||
import type { Provider } from "@/provider/provider"
|
||||
|
||||
export namespace MessageV2 {
|
||||
|
||||
@@ -31,6 +31,7 @@ import { Flag } from "../flag/flag"
|
||||
import { ulid } from "ulid"
|
||||
import { spawn } from "child_process"
|
||||
import { Command } from "../command"
|
||||
import { $ } from "bun"
|
||||
import { pathToFileURL, fileURLToPath } from "url"
|
||||
import { ConfigMarkdown } from "../config/markdown"
|
||||
import { SessionSummary } from "./summary"
|
||||
@@ -45,7 +46,6 @@ import { LLM } from "./llm"
|
||||
import { iife } from "@/util/iife"
|
||||
import { Shell } from "@/shell/shell"
|
||||
import { Truncate } from "@/tool/truncation"
|
||||
import { Process } from "@/util/process"
|
||||
|
||||
// @ts-ignore
|
||||
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
|
||||
}
|
||||
|
||||
const shellMatches = ConfigMarkdown.shell(template)
|
||||
if (shellMatches.length > 0) {
|
||||
const sh = Shell.preferred()
|
||||
const shell = ConfigMarkdown.shell(template)
|
||||
if (shell.length > 0) {
|
||||
const results = await Promise.all(
|
||||
shellMatches.map(async ([, cmd]) => {
|
||||
const out = await Process.text([cmd], { shell: sh, nothrow: true })
|
||||
return out.text
|
||||
shell.map(async ([, cmd]) => {
|
||||
try {
|
||||
return await $`${{ raw: cmd }}`.quiet().nothrow().text()
|
||||
} catch (error) {
|
||||
return `Error executing command: ${error instanceof Error ? error.message : String(error)}`
|
||||
}
|
||||
}),
|
||||
)
|
||||
let index = 0
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { sqliteTable, text, integer, index, primaryKey } from "drizzle-orm/sqlite-core"
|
||||
import { ProjectTable } from "../project/project.sql"
|
||||
import type { MessageV2 } from "./message-v2"
|
||||
import type { Snapshot } from "../snapshot"
|
||||
import type { PermissionNext } from "../permission/next"
|
||||
import { Timestamps } from "../storage/schema.sql"
|
||||
import type { Snapshot } from "@/snapshot"
|
||||
import type { PermissionNext } from "@/permission/next"
|
||||
import { Timestamps } from "@/storage/schema.sql"
|
||||
|
||||
type PartData = Omit<MessageV2.Part, "id" | "sessionID" | "messageID">
|
||||
type InfoData = Omit<MessageV2.Info, "id" | "sessionID">
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { Bus } from "@/bus"
|
||||
import { Account } from "@/account"
|
||||
import { Config } from "@/config/config"
|
||||
import { Provider } from "@/provider/provider"
|
||||
import { Session } from "@/session"
|
||||
@@ -12,51 +11,8 @@ import type * as SDK from "@opencode-ai/sdk/v2"
|
||||
export namespace ShareNext {
|
||||
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() {
|
||||
const req = await request()
|
||||
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 }
|
||||
return Config.get().then((x) => x.enterprise?.url ?? "https://opncd.ai")
|
||||
}
|
||||
|
||||
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) {
|
||||
if (disabled) return { id: "", url: "", secret: "" }
|
||||
log.info("creating share", { sessionID })
|
||||
const req = await request()
|
||||
const response = await fetch(`${req.baseUrl}${req.api.create}`, {
|
||||
const result = await fetch(`${await url()}/api/share`, {
|
||||
method: "POST",
|
||||
headers: { ...req.headers, "Content-Type": "application/json" },
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({ sessionID: sessionID }),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
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 }
|
||||
|
||||
.then((x) => x.json())
|
||||
.then((x) => x as { id: string; url: string; secret: string })
|
||||
Database.use((db) =>
|
||||
db
|
||||
.insert(SessionShareTable)
|
||||
@@ -208,19 +159,16 @@ export namespace ShareNext {
|
||||
const share = get(sessionID)
|
||||
if (!share) return
|
||||
|
||||
const req = await request()
|
||||
const response = await fetch(`${req.baseUrl}${req.api.sync(share.id)}`, {
|
||||
await fetch(`${await url()}/api/share/${share.id}/sync`, {
|
||||
method: "POST",
|
||||
headers: { ...req.headers, "Content-Type": "application/json" },
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
secret: share.secret,
|
||||
data: Array.from(queued.data.values()),
|
||||
}),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
log.warn("failed to sync share", { sessionID, shareID: share.id, status: response.status })
|
||||
}
|
||||
}, 1000)
|
||||
queue.set(sessionID, { timeout, data: dataMap })
|
||||
}
|
||||
@@ -230,21 +178,15 @@ export namespace ShareNext {
|
||||
log.info("removing share", { sessionID })
|
||||
const share = get(sessionID)
|
||||
if (!share) return
|
||||
|
||||
const req = await request()
|
||||
const response = await fetch(`${req.baseUrl}${req.api.remove(share.id)}`, {
|
||||
await fetch(`${await url()}/api/share/${share.id}`, {
|
||||
method: "DELETE",
|
||||
headers: { ...req.headers, "Content-Type": "application/json" },
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
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())
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { sqliteTable, text } from "drizzle-orm/sqlite-core"
|
||||
import { SessionTable } from "../session/session.sql"
|
||||
import { Timestamps } from "../storage/schema.sql"
|
||||
import { Timestamps } from "@/storage/schema.sql"
|
||||
|
||||
export const SessionShareTable = sqliteTable("session_share", {
|
||||
session_id: text()
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 { type SQLiteTransaction } from "drizzle-orm/sqlite-core"
|
||||
export * from "drizzle-orm"
|
||||
@@ -10,10 +11,10 @@ import { NamedError } from "@opencode-ai/util/error"
|
||||
import z from "zod"
|
||||
import path from "path"
|
||||
import { readFileSync, readdirSync, existsSync } from "fs"
|
||||
import * as schema from "./schema"
|
||||
import { Installation } from "../installation"
|
||||
import { Flag } from "../flag/flag"
|
||||
import { iife } from "@/util/iife"
|
||||
import { init } from "#db"
|
||||
|
||||
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`)
|
||||
})
|
||||
|
||||
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 }[]
|
||||
|
||||
const state = {
|
||||
sqlite: undefined as BunDatabase | undefined,
|
||||
}
|
||||
|
||||
function time(tag: string) {
|
||||
const match = /^(\d{4})(\d{2})(\d{2})(\d{2})(\d{2})(\d{2})/.exec(tag)
|
||||
if (!match) return 0
|
||||
@@ -77,14 +83,17 @@ export namespace Database {
|
||||
export const Client = lazy(() => {
|
||||
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")
|
||||
db.run("PRAGMA synchronous = NORMAL")
|
||||
db.run("PRAGMA busy_timeout = 5000")
|
||||
db.run("PRAGMA cache_size = -64000")
|
||||
db.run("PRAGMA foreign_keys = ON")
|
||||
db.run("PRAGMA wal_checkpoint(PASSIVE)")
|
||||
sqlite.run("PRAGMA journal_mode = WAL")
|
||||
sqlite.run("PRAGMA synchronous = NORMAL")
|
||||
sqlite.run("PRAGMA busy_timeout = 5000")
|
||||
sqlite.run("PRAGMA cache_size = -64000")
|
||||
sqlite.run("PRAGMA foreign_keys = ON")
|
||||
sqlite.run("PRAGMA wal_checkpoint(PASSIVE)")
|
||||
|
||||
const db = drizzle({ client: sqlite, schema })
|
||||
|
||||
// Apply schema migrations
|
||||
const entries =
|
||||
@@ -108,7 +117,10 @@ export namespace Database {
|
||||
})
|
||||
|
||||
export function close() {
|
||||
Client().$client.close()
|
||||
const sqlite = state.sqlite
|
||||
if (!sqlite) return
|
||||
sqlite.close()
|
||||
state.sqlite = undefined
|
||||
Client.reset()
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
export { AccountTable, AccountStateTable, ControlAccountTable } from "../account/account.sql"
|
||||
export { ProjectTable } from "../project/project.sql"
|
||||
export { ControlAccountTable } from "../control/control.sql"
|
||||
export { SessionTable, MessageTable, PartTable, TodoTable, PermissionTable } from "../session/session.sql"
|
||||
export { SessionShareTable } from "../share/share.sql"
|
||||
export { ProjectTable } from "../project/project.sql"
|
||||
export { WorkspaceTable } from "../control-plane/workspace.sql"
|
||||
|
||||
@@ -45,7 +45,7 @@ export namespace ToolRegistry {
|
||||
if (matches.length) await Config.waitForDependencies()
|
||||
for (const match of matches) {
|
||||
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)) {
|
||||
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),
|
||||
}),
|
||||
)
|
||||
@@ -13,7 +13,6 @@ export namespace Process {
|
||||
abort?: AbortSignal
|
||||
kill?: NodeJS.Signals | number
|
||||
timeout?: number
|
||||
shell?: string
|
||||
}
|
||||
|
||||
export interface RunOptions extends Omit<Options, "stdout" | "stderr"> {
|
||||
@@ -59,7 +58,6 @@ export namespace Process {
|
||||
|
||||
const proc = launch(cmd[0], cmd.slice(1), {
|
||||
cwd: opts.cwd,
|
||||
shell: opts.shell,
|
||||
env: opts.env === null ? {} : opts.env ? { ...process.env, ...opts.env } : undefined,
|
||||
stdio: [opts.stdin ?? "ignore", opts.stdout ?? "ignore", opts.stderr ?? "ignore"],
|
||||
})
|
||||
|
||||
@@ -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,13 +1,9 @@
|
||||
import whichPkg from "which"
|
||||
import path from "path"
|
||||
import { Global } from "../global"
|
||||
|
||||
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, {
|
||||
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,
|
||||
})
|
||||
return typeof result === "string" ? result : null
|
||||
|
||||
@@ -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
packages/opencode/test/bun.test.ts
Normal file
53
packages/opencode/test/bun.test.ts
Normal 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,10 +1,5 @@
|
||||
import { test, expect } from "bun:test"
|
||||
import {
|
||||
parseShareUrl,
|
||||
shouldAttachShareAuthHeaders,
|
||||
transformShareData,
|
||||
type ShareData,
|
||||
} from "../../src/cli/cmd/import"
|
||||
import { parseShareUrl, transformShareData, type ShareData } from "../../src/cli/cmd/import"
|
||||
|
||||
// parseShareUrl tests
|
||||
test("parses valid share URLs", () => {
|
||||
@@ -20,17 +15,6 @@ test("rejects invalid URLs", () => {
|
||||
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
|
||||
test("transforms share data to storage format", () => {
|
||||
const data: ShareData[] = [
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
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"
|
||||
|
||||
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 { Instance } from "../../src/project/instance"
|
||||
import { Auth } from "../../src/auth"
|
||||
import { AccessToken, Account, AccountID, OrgID } from "../../src/account"
|
||||
import { tmpdir } from "../fixture/fixture"
|
||||
import path from "path"
|
||||
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 () => {
|
||||
await using tmp = await tmpdir({
|
||||
init: async (dir) => {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
})
|
||||
@@ -11,13 +11,6 @@
|
||||
"paths": {
|
||||
"@/*": ["./src/*"],
|
||||
"@tui/*": ["./src/cli/cmd/tui/*"]
|
||||
},
|
||||
"plugins": [
|
||||
{
|
||||
"name": "@effect/language-service",
|
||||
"transform": "@effect/language-service/transform",
|
||||
"namespaceImportPackages": ["effect", "@effect/*"]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Plugin } from "./index.js"
|
||||
import { tool } from "./tool.js"
|
||||
import { Plugin } from "./index"
|
||||
import { tool } from "./tool"
|
||||
|
||||
export const ExamplePlugin: Plugin = async (ctx) => {
|
||||
return {
|
||||
|
||||
@@ -12,10 +12,10 @@ import type {
|
||||
Config,
|
||||
} from "@opencode-ai/sdk"
|
||||
|
||||
import type { BunShell } from "./shell.js"
|
||||
import { type ToolDefinition } from "./tool.js"
|
||||
import type { BunShell } from "./shell"
|
||||
import { type ToolDefinition } from "./tool"
|
||||
|
||||
export * from "./tool.js"
|
||||
export * from "./tool"
|
||||
|
||||
export type ProviderContext = {
|
||||
source: "env" | "config" | "custom" | "api"
|
||||
|
||||
@@ -3,9 +3,9 @@
|
||||
"extends": "@tsconfig/node22/tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "dist",
|
||||
"module": "nodenext",
|
||||
"module": "preserve",
|
||||
"declaration": true,
|
||||
"moduleResolution": "nodenext",
|
||||
"moduleResolution": "bundler",
|
||||
"lib": ["es2022", "dom", "dom.iterable"]
|
||||
},
|
||||
"include": ["src"]
|
||||
|
||||
@@ -1,94 +1,29 @@
|
||||
[data-component="card"] {
|
||||
--card-pad-y: 10px;
|
||||
--card-pad-r: 12px;
|
||||
--card-pad-l: 10px;
|
||||
|
||||
width: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
position: relative;
|
||||
background: transparent;
|
||||
border: none;
|
||||
background-color: var(--surface-inset-base);
|
||||
border: 1px solid var(--border-weaker-base);
|
||||
transition: background-color 0.15s ease;
|
||||
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 */
|
||||
font-family: var(--font-family-sans);
|
||||
font-size: var(--font-size-base);
|
||||
font-style: normal;
|
||||
font-weight: var(--font-weight-regular);
|
||||
line-height: var(--line-height-large);
|
||||
letter-spacing: var(--letter-spacing-normal);
|
||||
color: var(--text-strong);
|
||||
&[data-variant="error"] {
|
||||
background-color: var(--surface-critical-weak);
|
||||
border: 1px solid var(--border-critical-base);
|
||||
color: rgba(218, 51, 25, 0.6);
|
||||
|
||||
--card-gap: 8px;
|
||||
--card-icon: 16px;
|
||||
--card-indent: 0px;
|
||||
--card-line-pad: 8px;
|
||||
/* text-12-regular */
|
||||
font-family: var(--font-family-sans);
|
||||
font-size: var(--font-size-small);
|
||||
font-style: normal;
|
||||
font-weight: var(--font-weight-regular);
|
||||
line-height: var(--line-height-large); /* 166.667% */
|
||||
letter-spacing: var(--letter-spacing-normal);
|
||||
|
||||
--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);
|
||||
&[data-component="icon"] {
|
||||
color: var(--icon-critical-active);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// @ts-nocheck
|
||||
import { Card, CardActions, CardDescription, CardTitle } from "./card"
|
||||
import { Card } from "./card"
|
||||
import { Button } from "./button"
|
||||
|
||||
const docs = `### Overview
|
||||
@@ -49,13 +49,15 @@ export default {
|
||||
render: (props: { variant?: "normal" | "error" | "warning" | "success" | "info" }) => {
|
||||
return (
|
||||
<Card variant={props.variant}>
|
||||
<CardTitle variant={props.variant}>Card title</CardTitle>
|
||||
<CardDescription>Small supporting text.</CardDescription>
|
||||
<CardActions>
|
||||
<Button size="small" variant="secondary">
|
||||
<div style={{ display: "flex", alignItems: "center", gap: "12px" }}>
|
||||
<div style={{ flex: 1 }}>
|
||||
<div style={{ fontWeight: 500 }}>Card title</div>
|
||||
<div style={{ color: "var(--text-weak)", fontSize: "13px" }}>Small supporting text.</div>
|
||||
</div>
|
||||
<Button size="small" variant="ghost">
|
||||
Action
|
||||
</Button>
|
||||
</CardActions>
|
||||
</div>
|
||||
</Card>
|
||||
)
|
||||
},
|
||||
|
||||
@@ -1,57 +1,16 @@
|
||||
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"> {
|
||||
variant?: Variant
|
||||
}
|
||||
|
||||
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 }
|
||||
variant?: "normal" | "error" | "warning" | "success" | "info"
|
||||
}
|
||||
|
||||
export function Card(props: CardProps) {
|
||||
const [split, rest] = splitProps(props, ["variant", "style", "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
|
||||
}
|
||||
const [split, rest] = splitProps(props, ["variant", "class", "classList"])
|
||||
return (
|
||||
<div
|
||||
{...rest}
|
||||
data-component="card"
|
||||
data-variant={variant()}
|
||||
style={mix(split.style, accent())}
|
||||
data-variant={split.variant || "normal"}
|
||||
classList={{
|
||||
...(split.classList ?? {}),
|
||||
[split.class ?? ""]: !!split.class,
|
||||
@@ -61,63 +20,3 @@ export function Card(props: CardProps) {
|
||||
</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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -60,7 +60,6 @@
|
||||
ol {
|
||||
margin-top: 0.5rem;
|
||||
margin-bottom: 1rem;
|
||||
margin-left: 0;
|
||||
padding-left: 1.5rem;
|
||||
list-style-position: outside;
|
||||
}
|
||||
@@ -71,7 +70,6 @@
|
||||
|
||||
ol {
|
||||
list-style-type: decimal;
|
||||
padding-left: 2.25rem;
|
||||
}
|
||||
|
||||
li {
|
||||
@@ -100,10 +98,6 @@
|
||||
padding-left: 1rem; /* Minimal indent for nesting only */
|
||||
}
|
||||
|
||||
li > ol {
|
||||
padding-left: 1.75rem;
|
||||
}
|
||||
|
||||
/* Blockquotes */
|
||||
blockquote {
|
||||
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"] {
|
||||
white-space: pre;
|
||||
padding: 0;
|
||||
@@ -682,6 +717,7 @@
|
||||
[data-component="user-message"] [data-slot="user-message-text"],
|
||||
[data-component="text-part"],
|
||||
[data-component="reasoning-part"],
|
||||
[data-component="tool-error"],
|
||||
[data-component="tool-output"],
|
||||
[data-component="bash-output"],
|
||||
[data-component="edit-content"],
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user