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