mirror of
https://github.com/anomalyco/opencode.git
synced 2026-03-23 23:24:56 +00:00
Compare commits
118 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b30447b901 | ||
|
|
9a9300b5ca | ||
|
|
4985df42a5 | ||
|
|
bf7d429c3d | ||
|
|
3b51e4be52 | ||
|
|
af95f70e50 | ||
|
|
1400b0c3ee | ||
|
|
65b1eb0659 | ||
|
|
ed78c5875a | ||
|
|
cc66c77500 | ||
|
|
20025a540a | ||
|
|
1f2dd5d3e1 | ||
|
|
5da672be32 | ||
|
|
9a006d8700 | ||
|
|
a9621936fa | ||
|
|
3a0bf2f39f | ||
|
|
b556979634 | ||
|
|
1178de27c5 | ||
|
|
691644eeeb | ||
|
|
4aebaaf067 | ||
|
|
9925faa465 | ||
|
|
77b3b46788 | ||
|
|
36dfe1646b | ||
|
|
6926dc26d1 | ||
|
|
eb74e4a6d2 | ||
|
|
85d8e143bf | ||
|
|
8e1b53b32c | ||
|
|
0a7dfc03ee | ||
|
|
4c27e7fc64 | ||
|
|
0f5626d2e4 | ||
|
|
5ea95451dd | ||
|
|
9239d877b9 | ||
|
|
fc68c24433 | ||
|
|
db9619dad6 | ||
|
|
84d9b38873 | ||
|
|
a9dd7ac3b9 | ||
|
|
18f96e2b6c | ||
|
|
601131c3b1 | ||
|
|
b0cb887df7 | ||
|
|
8035c3435b | ||
|
|
71e7603d71 | ||
|
|
40e49c5b49 | ||
|
|
afe9b97274 | ||
|
|
3b3549902d | ||
|
|
e9a9c75c1f | ||
|
|
2b171828b0 | ||
|
|
8dd817023a | ||
|
|
35df4c0184 | ||
|
|
0d6c601365 | ||
|
|
3a2279419c | ||
|
|
9e29a0d96d | ||
|
|
eab05f5c5e | ||
|
|
f0e214d0ac | ||
|
|
3c8e0fd0f4 | ||
|
|
5460bf9989 | ||
|
|
3042d867bb | ||
|
|
45557d1b55 | ||
|
|
66e293133c | ||
|
|
3aa7fbfeb0 | ||
|
|
b9ca9588f7 | ||
|
|
13f419c4f4 | ||
|
|
211e76523e | ||
|
|
c15bf90847 | ||
|
|
822e2922a3 | ||
|
|
e241b89eae | ||
|
|
34b7173e92 | ||
|
|
b7371de859 | ||
|
|
d1d1c896a0 | ||
|
|
99fc519700 | ||
|
|
aceadf1ef8 | ||
|
|
ea31863bd9 | ||
|
|
aeceb372db | ||
|
|
fcec6216eb | ||
|
|
8c559c2b90 | ||
|
|
0f5bd3214d | ||
|
|
ee8025f556 | ||
|
|
83916ca675 | ||
|
|
050336f160 | ||
|
|
a724b01652 | ||
|
|
9f1f62594d | ||
|
|
e1f06de263 | ||
|
|
5792a80a8c | ||
|
|
df44b41aaa | ||
|
|
db039db7f5 | ||
|
|
c1a3936b61 | ||
|
|
b1d537b49a | ||
|
|
9f5713e802 | ||
|
|
bc43bf378d | ||
|
|
3b669e7f2b | ||
|
|
e48da96886 | ||
|
|
20249bb723 | ||
|
|
205affa0eb | ||
|
|
7409ce8aec | ||
|
|
c075a5d694 | ||
|
|
34c676ba7a | ||
|
|
816a5f793f | ||
|
|
ca2099e69d | ||
|
|
01c0902c96 | ||
|
|
342719dfff | ||
|
|
1392d868b1 | ||
|
|
e993acec31 | ||
|
|
611e616010 | ||
|
|
b286c0ae3f | ||
|
|
81a61f8dbd | ||
|
|
752e449e38 | ||
|
|
5d419a0211 | ||
|
|
8b168981aa | ||
|
|
724dd665ec | ||
|
|
fc258ea74f | ||
|
|
abd9e195ac | ||
|
|
9d78b69cd3 | ||
|
|
a531f3f36d | ||
|
|
bb3382311d | ||
|
|
ad545d0cc9 | ||
|
|
ac244b1458 | ||
|
|
f202536b65 | ||
|
|
405cc3f610 | ||
|
|
878c1b8c2d |
3
.github/VOUCHED.td
vendored
3
.github/VOUCHED.td
vendored
@@ -10,6 +10,8 @@
|
||||
adamdotdevin
|
||||
-agusbasari29 AI PR slop
|
||||
ariane-emory
|
||||
-atharvau AI review spamming literally every PR
|
||||
-danieljoshuanazareth
|
||||
-danieljoshuanazareth
|
||||
edemaine
|
||||
-florianleibert
|
||||
@@ -23,4 +25,3 @@ r44vc0rp
|
||||
rekram1-node
|
||||
-spider-yamet clawdbot/llm psychosis, spam pinging the team
|
||||
thdxr
|
||||
-danieljoshuanazareth
|
||||
|
||||
2
.github/workflows/deploy.yml
vendored
2
.github/workflows/deploy.yml
vendored
@@ -11,7 +11,7 @@ concurrency: ${{ github.workflow }}-${{ github.ref }}
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
runs-on: blacksmith-4vcpu-ubuntu-2404
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
|
||||
|
||||
2
.github/workflows/vouch-manage-by-issue.yml
vendored
2
.github/workflows/vouch-manage-by-issue.yml
vendored
@@ -33,6 +33,6 @@ jobs:
|
||||
with:
|
||||
issue-id: ${{ github.event.issue.number }}
|
||||
comment-id: ${{ github.event.comment.id }}
|
||||
roles: admin,maintain
|
||||
roles: admin,maintain,write
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ steps.committer.outputs.token }}
|
||||
|
||||
5
.opencode/command/changelog.md
Normal file
5
.opencode/command/changelog.md
Normal file
@@ -0,0 +1,5 @@
|
||||
go through each PR merged since the last tag
|
||||
|
||||
for each PR spawn a subagent to summarize what the PR was about. focus on user facing changes. if it was entirely internal or code related you can ignore it. also skip docs updates. each subagent should append its summary to UPCOMING_CHANGELOG.md
|
||||
|
||||
once that is done, read UPCOMING_CHANGELOG.md and group it into sections for better readability. make sure all PR references are preserved
|
||||
@@ -1,8 +1,8 @@
|
||||
{
|
||||
"nodeModules": {
|
||||
"x86_64-linux": "sha256-u+uZX7mhtm5eywGybB7/MjBMG2xl4Ve9VG33AAFgNno=",
|
||||
"aarch64-linux": "sha256-pc1Xhd2bkwNohGMtzRnEuS5ZN1qWhJncYhNVAXega1g=",
|
||||
"aarch64-darwin": "sha256-A5qUpqgm9ZFvWVhn/WdiX4lVs4ihbAclJDvCFAmx5Wg=",
|
||||
"x86_64-darwin": "sha256-ECLrMGE51AlYJ4JKDtziDKxhyK7WLt8R+8RVFdXH1WU="
|
||||
"x86_64-linux": "sha256-E5neEbBiwQDhIQ5QVhijpHCCP9hcxm319S9WrDKngSw=",
|
||||
"aarch64-linux": "sha256-lnwaGSEirl9izskDooB/xQ0ZdirW0t3/S+OoOnfYaoQ=",
|
||||
"aarch64-darwin": "sha256-RDxxW9NMlGMIdIxTsbOYVqxunflkILv2dA7JqjnJgm4=",
|
||||
"x86_64-darwin": "sha256-1tvvktu2NRg6N6ASuKzqzcEmMrzH3/LFey0Vxr4E8zg="
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
"description": "AI-powered development tool",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"packageManager": "bun@1.3.10",
|
||||
"packageManager": "bun@1.3.11",
|
||||
"scripts": {
|
||||
"dev": "bun run --cwd packages/opencode --conditions=browser src/index.ts",
|
||||
"dev:desktop": "bun --cwd packages/desktop tauri dev",
|
||||
@@ -26,7 +26,7 @@
|
||||
],
|
||||
"catalog": {
|
||||
"@effect/platform-node": "4.0.0-beta.35",
|
||||
"@types/bun": "1.3.9",
|
||||
"@types/bun": "1.3.11",
|
||||
"@octokit/rest": "22.0.0",
|
||||
"@hono/zod-validator": "0.4.2",
|
||||
"ulid": "3.0.1",
|
||||
|
||||
@@ -175,9 +175,9 @@ export async function runTerminal(page: Page, input: { cmd: string; token: strin
|
||||
await expect.poll(() => terminalHas(page, { term, token: input.token }), { timeout }).toBe(true)
|
||||
}
|
||||
|
||||
export async function openPalette(page: Page) {
|
||||
export async function openPalette(page: Page, key = "K") {
|
||||
await defocus(page)
|
||||
await page.keyboard.press(`${modKey}+P`)
|
||||
await page.keyboard.press(`${modKey}+${key}`)
|
||||
|
||||
const dialog = page.getByRole("dialog")
|
||||
await expect(dialog).toBeVisible()
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { test, expect } from "../fixtures"
|
||||
import { openPalette } from "../actions"
|
||||
import { closeDialog, openPalette } from "../actions"
|
||||
|
||||
test("search palette opens and closes", async ({ page, gotoSession }) => {
|
||||
await gotoSession()
|
||||
@@ -9,3 +9,12 @@ test("search palette opens and closes", async ({ page, gotoSession }) => {
|
||||
await page.keyboard.press("Escape")
|
||||
await expect(dialog).toHaveCount(0)
|
||||
})
|
||||
|
||||
test("search palette also opens with cmd+p", async ({ page, gotoSession }) => {
|
||||
await gotoSession()
|
||||
|
||||
const dialog = await openPalette(page, "P")
|
||||
|
||||
await closeDialog(page, dialog)
|
||||
await expect(dialog).toHaveCount(0)
|
||||
})
|
||||
|
||||
@@ -108,7 +108,10 @@ test("prompt history restores unsent draft with arrow navigation", async ({ page
|
||||
await page.keyboard.type(draft)
|
||||
await wait(page, draft)
|
||||
|
||||
await edge(page, "start")
|
||||
// Clear the draft before navigating history (ArrowUp only works when prompt is empty)
|
||||
await prompt.fill("")
|
||||
await wait(page, "")
|
||||
|
||||
await page.keyboard.press("ArrowUp")
|
||||
await wait(page, second)
|
||||
|
||||
@@ -119,7 +122,7 @@ test("prompt history restores unsent draft with arrow navigation", async ({ page
|
||||
await wait(page, second)
|
||||
|
||||
await page.keyboard.press("ArrowDown")
|
||||
await wait(page, draft)
|
||||
await wait(page, "")
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -241,7 +241,7 @@ test("changing file open keybind works", async ({ page, gotoSession }) => {
|
||||
await expect(keybindButton).toBeVisible()
|
||||
|
||||
const initialKeybind = await keybindButton.textContent()
|
||||
expect(initialKeybind).toContain("P")
|
||||
expect(initialKeybind).toContain("K")
|
||||
|
||||
await keybindButton.click()
|
||||
await expect(keybindButton).toHaveText(/press/i)
|
||||
|
||||
@@ -1,6 +1,16 @@
|
||||
import { test, expect } from "../fixtures"
|
||||
import { cleanupSession, closeSidebar, hoverSessionItem } from "../actions"
|
||||
import {
|
||||
defocus,
|
||||
cleanupSession,
|
||||
cleanupTestProject,
|
||||
closeSidebar,
|
||||
createTestProject,
|
||||
hoverSessionItem,
|
||||
openSidebar,
|
||||
waitSession,
|
||||
} from "../actions"
|
||||
import { projectSwitchSelector } from "../selectors"
|
||||
import { dirSlug } from "../utils"
|
||||
|
||||
test("collapsed sidebar popover stays open when archiving a session", async ({ page, slug, sdk, gotoSession }) => {
|
||||
const stamp = Date.now()
|
||||
@@ -37,3 +47,72 @@ test("collapsed sidebar popover stays open when archiving a session", async ({ p
|
||||
await cleanupSession({ sdk, sessionID: two.id })
|
||||
}
|
||||
})
|
||||
|
||||
test("open sidebar project popover stays closed after clicking avatar", async ({ page, withProject }) => {
|
||||
await page.setViewportSize({ width: 1400, height: 800 })
|
||||
|
||||
const other = await createTestProject()
|
||||
const slug = dirSlug(other)
|
||||
|
||||
try {
|
||||
await withProject(
|
||||
async () => {
|
||||
await openSidebar(page)
|
||||
|
||||
const project = page.locator(projectSwitchSelector(slug)).first()
|
||||
const card = page.locator('[data-component="hover-card-content"]')
|
||||
|
||||
await expect(project).toBeVisible()
|
||||
await project.hover()
|
||||
await expect(card.getByText(/recent sessions/i)).toBeVisible()
|
||||
|
||||
await page.mouse.down()
|
||||
await expect(card).toHaveCount(0)
|
||||
await page.mouse.up()
|
||||
|
||||
await waitSession(page, { directory: other })
|
||||
await expect(card).toHaveCount(0)
|
||||
},
|
||||
{ extra: [other] },
|
||||
)
|
||||
} finally {
|
||||
await cleanupTestProject(other)
|
||||
}
|
||||
})
|
||||
|
||||
test("open sidebar project switch activates on first tabbed enter", async ({ page, withProject }) => {
|
||||
await page.setViewportSize({ width: 1400, height: 800 })
|
||||
|
||||
const other = await createTestProject()
|
||||
const slug = dirSlug(other)
|
||||
|
||||
try {
|
||||
await withProject(
|
||||
async () => {
|
||||
await openSidebar(page)
|
||||
await defocus(page)
|
||||
|
||||
const project = page.locator(projectSwitchSelector(slug)).first()
|
||||
|
||||
await expect(project).toBeVisible()
|
||||
|
||||
let hit = false
|
||||
for (let i = 0; i < 20; i++) {
|
||||
hit = await project.evaluate((el) => {
|
||||
return el.matches(":focus") || !!el.parentElement?.matches(":focus")
|
||||
})
|
||||
if (hit) break
|
||||
await page.keyboard.press("Tab")
|
||||
}
|
||||
|
||||
expect(hit).toBe(true)
|
||||
|
||||
await page.keyboard.press("Enter")
|
||||
await waitSession(page, { directory: other })
|
||||
},
|
||||
{ extra: [other] },
|
||||
)
|
||||
} finally {
|
||||
await cleanupTestProject(other)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -51,6 +51,7 @@
|
||||
"@solid-primitives/resize-observer": "2.1.3",
|
||||
"@solid-primitives/scroll": "2.1.3",
|
||||
"@solid-primitives/storage": "catalog:",
|
||||
"@solid-primitives/timer": "1.4.4",
|
||||
"@solid-primitives/websocket": "1.3.1",
|
||||
"@solidjs/meta": "catalog:",
|
||||
"@solidjs/router": "catalog:",
|
||||
|
||||
@@ -1043,7 +1043,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
return true
|
||||
}
|
||||
|
||||
const { addAttachment, removeAttachment, handlePaste } = createPromptAttachments({
|
||||
const { addAttachments, removeAttachment, handlePaste } = createPromptAttachments({
|
||||
editor: () => editorRef,
|
||||
isDialogActive: () => !!dialog.active,
|
||||
setDraggingType: (type) => setStore("draggingType", type),
|
||||
@@ -1388,11 +1388,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
class="hidden"
|
||||
onChange={(e) => {
|
||||
const list = e.currentTarget.files
|
||||
if (list) {
|
||||
for (const file of Array.from(list)) {
|
||||
void addAttachment(file)
|
||||
}
|
||||
}
|
||||
if (list) void addAttachments(Array.from(list))
|
||||
e.currentTarget.value = ""
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -71,6 +71,18 @@ export function createPromptAttachments(input: PromptAttachmentsInput) {
|
||||
|
||||
const addAttachment = (file: File) => add(file)
|
||||
|
||||
const addAttachments = async (files: File[], toast = true) => {
|
||||
let found = false
|
||||
|
||||
for (const file of files) {
|
||||
const ok = await add(file, false)
|
||||
if (ok) found = true
|
||||
}
|
||||
|
||||
if (!found && files.length > 0 && toast) warn()
|
||||
return found
|
||||
}
|
||||
|
||||
const removeAttachment = (id: string) => {
|
||||
const current = prompt.current()
|
||||
const next = current.filter((part) => part.type !== "image" || part.id !== id)
|
||||
@@ -84,18 +96,14 @@ export function createPromptAttachments(input: PromptAttachmentsInput) {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
|
||||
const items = Array.from(clipboardData.items)
|
||||
const fileItems = items.filter((item) => item.kind === "file")
|
||||
const files = Array.from(clipboardData.items).flatMap((item) => {
|
||||
if (item.kind !== "file") return []
|
||||
const file = item.getAsFile()
|
||||
return file ? [file] : []
|
||||
})
|
||||
|
||||
if (fileItems.length > 0) {
|
||||
let found = false
|
||||
for (const item of fileItems) {
|
||||
const file = item.getAsFile()
|
||||
if (!file) continue
|
||||
const ok = await add(file, false)
|
||||
if (ok) found = true
|
||||
}
|
||||
if (!found) warn()
|
||||
if (files.length > 0) {
|
||||
await addAttachments(files)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -169,12 +177,7 @@ export function createPromptAttachments(input: PromptAttachmentsInput) {
|
||||
const dropped = event.dataTransfer?.files
|
||||
if (!dropped) return
|
||||
|
||||
let found = false
|
||||
for (const file of Array.from(dropped)) {
|
||||
const ok = await add(file, false)
|
||||
if (ok) found = true
|
||||
}
|
||||
if (!found && dropped.length > 0) warn()
|
||||
await addAttachments(Array.from(dropped))
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
@@ -191,6 +194,7 @@ export function createPromptAttachments(input: PromptAttachmentsInput) {
|
||||
|
||||
return {
|
||||
addAttachment,
|
||||
addAttachments,
|
||||
removeAttachment,
|
||||
handlePaste,
|
||||
}
|
||||
|
||||
@@ -49,6 +49,32 @@ describe("buildRequestParts", () => {
|
||||
expect(result.optimisticParts.every((part) => part.sessionID === "ses_1" && part.messageID === "msg_1")).toBe(true)
|
||||
})
|
||||
|
||||
test("keeps multiple uploaded attachments in order", () => {
|
||||
const result = buildRequestParts({
|
||||
prompt: [{ type: "text", content: "check these", start: 0, end: 11 }],
|
||||
context: [],
|
||||
images: [
|
||||
{ type: "image", id: "img_1", filename: "a.png", mime: "image/png", dataUrl: "data:image/png;base64,AAA" },
|
||||
{
|
||||
type: "image",
|
||||
id: "img_2",
|
||||
filename: "b.pdf",
|
||||
mime: "application/pdf",
|
||||
dataUrl: "data:application/pdf;base64,BBB",
|
||||
},
|
||||
],
|
||||
text: "check these",
|
||||
messageID: "msg_multi",
|
||||
sessionID: "ses_multi",
|
||||
sessionDirectory: "/repo",
|
||||
})
|
||||
|
||||
const files = result.requestParts.filter((part) => part.type === "file" && part.url.startsWith("data:"))
|
||||
|
||||
expect(files).toHaveLength(2)
|
||||
expect(files.map((part) => (part.type === "file" ? part.filename : ""))).toEqual(["a.png", "b.pdf"])
|
||||
})
|
||||
|
||||
test("deduplicates context files when prompt already includes same path", () => {
|
||||
const prompt: Prompt = [{ type: "file", path: "src/foo.ts", content: "@src/foo.ts", start: 0, end: 11 }]
|
||||
|
||||
|
||||
@@ -126,7 +126,7 @@ describe("prompt-input history", () => {
|
||||
test("canNavigateHistoryAtCursor only allows prompt boundaries", () => {
|
||||
const value = "a\nb\nc"
|
||||
|
||||
expect(canNavigateHistoryAtCursor("up", value, 0)).toBe(true)
|
||||
expect(canNavigateHistoryAtCursor("up", value, 0)).toBe(false)
|
||||
expect(canNavigateHistoryAtCursor("down", value, 0)).toBe(false)
|
||||
|
||||
expect(canNavigateHistoryAtCursor("up", value, 2)).toBe(false)
|
||||
@@ -135,11 +135,14 @@ describe("prompt-input history", () => {
|
||||
expect(canNavigateHistoryAtCursor("up", value, 5)).toBe(false)
|
||||
expect(canNavigateHistoryAtCursor("down", value, 5)).toBe(true)
|
||||
|
||||
expect(canNavigateHistoryAtCursor("up", "abc", 0)).toBe(true)
|
||||
expect(canNavigateHistoryAtCursor("up", "abc", 0)).toBe(false)
|
||||
expect(canNavigateHistoryAtCursor("down", "abc", 3)).toBe(true)
|
||||
expect(canNavigateHistoryAtCursor("up", "abc", 1)).toBe(false)
|
||||
expect(canNavigateHistoryAtCursor("down", "abc", 1)).toBe(false)
|
||||
|
||||
expect(canNavigateHistoryAtCursor("up", "", 0)).toBe(true)
|
||||
expect(canNavigateHistoryAtCursor("down", "", 0)).toBe(true)
|
||||
|
||||
expect(canNavigateHistoryAtCursor("up", "abc", 0, true)).toBe(true)
|
||||
expect(canNavigateHistoryAtCursor("up", "abc", 3, true)).toBe(true)
|
||||
expect(canNavigateHistoryAtCursor("down", "abc", 0, true)).toBe(true)
|
||||
|
||||
@@ -27,7 +27,7 @@ export function canNavigateHistoryAtCursor(direction: "up" | "down", text: strin
|
||||
const atStart = position === 0
|
||||
const atEnd = position === text.length
|
||||
if (inHistory) return atStart || atEnd
|
||||
if (direction === "up") return position === 0
|
||||
if (direction === "up") return position === 0 && text.length === 0
|
||||
return position === text.length
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import type { Message } from "@opencode-ai/sdk/v2/client"
|
||||
import { findAssistantMessages } from "@opencode-ai/ui/find-assistant-messages"
|
||||
|
||||
function user(id: string): Message {
|
||||
return {
|
||||
id,
|
||||
role: "user",
|
||||
sessionID: "session-1",
|
||||
time: { created: 1 },
|
||||
} as unknown as Message
|
||||
}
|
||||
|
||||
function assistant(id: string, parentID: string): Message {
|
||||
return {
|
||||
id,
|
||||
role: "assistant",
|
||||
sessionID: "session-1",
|
||||
parentID,
|
||||
time: { created: 1 },
|
||||
} as unknown as Message
|
||||
}
|
||||
|
||||
describe("findAssistantMessages", () => {
|
||||
test("normal ordering: assistant after user in array → found via forward scan", () => {
|
||||
const messages = [user("u1"), assistant("a1", "u1")]
|
||||
const result = findAssistantMessages(messages, 0, "u1")
|
||||
expect(result).toHaveLength(1)
|
||||
expect(result[0].id).toBe("a1")
|
||||
})
|
||||
|
||||
test("clock skew: assistant before user in array → found via backward scan", () => {
|
||||
// When client clock is ahead, user ID sorts after assistant ID,
|
||||
// so assistant appears earlier in the ID-sorted message array
|
||||
const messages = [assistant("a1", "u1"), user("u1")]
|
||||
const result = findAssistantMessages(messages, 1, "u1")
|
||||
expect(result).toHaveLength(1)
|
||||
expect(result[0].id).toBe("a1")
|
||||
})
|
||||
|
||||
test("no assistant messages → returns empty array", () => {
|
||||
const messages = [user("u1"), user("u2")]
|
||||
const result = findAssistantMessages(messages, 0, "u1")
|
||||
expect(result).toHaveLength(0)
|
||||
})
|
||||
|
||||
test("multiple assistant messages with matching parentID → all found", () => {
|
||||
const messages = [user("u1"), assistant("a1", "u1"), assistant("a2", "u1")]
|
||||
const result = findAssistantMessages(messages, 0, "u1")
|
||||
expect(result).toHaveLength(2)
|
||||
expect(result[0].id).toBe("a1")
|
||||
expect(result[1].id).toBe("a2")
|
||||
})
|
||||
|
||||
test("does not return assistant messages with different parentID", () => {
|
||||
const messages = [user("u1"), assistant("a1", "u1"), assistant("a2", "other")]
|
||||
const result = findAssistantMessages(messages, 0, "u1")
|
||||
expect(result).toHaveLength(1)
|
||||
expect(result[0].id).toBe("a1")
|
||||
})
|
||||
|
||||
test("stops forward scan at next user message", () => {
|
||||
const messages = [user("u1"), assistant("a1", "u1"), user("u2"), assistant("a2", "u1")]
|
||||
const result = findAssistantMessages(messages, 0, "u1")
|
||||
expect(result).toHaveLength(1)
|
||||
expect(result[0].id).toBe("a1")
|
||||
})
|
||||
|
||||
test("stops backward scan at previous user message", () => {
|
||||
const messages = [assistant("a0", "u1"), user("u0"), assistant("a1", "u1"), user("u1")]
|
||||
const result = findAssistantMessages(messages, 3, "u1")
|
||||
expect(result).toHaveLength(1)
|
||||
expect(result[0].id).toBe("a1")
|
||||
})
|
||||
|
||||
test("invalid index returns empty array", () => {
|
||||
const messages = [user("u1")]
|
||||
expect(findAssistantMessages(messages, -1, "u1")).toHaveLength(0)
|
||||
expect(findAssistantMessages(messages, 5, "u1")).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
@@ -267,14 +267,14 @@ export function SessionContextTab() {
|
||||
|
||||
return (
|
||||
<ScrollView
|
||||
class="@container h-full pb-10"
|
||||
class="@container h-full"
|
||||
viewportRef={(el) => {
|
||||
scroll = el
|
||||
restoreScroll()
|
||||
}}
|
||||
onScroll={handleScroll}
|
||||
>
|
||||
<div class="px-6 pt-4 flex flex-col gap-10">
|
||||
<div class="px-6 pt-4 pb-10 flex flex-col gap-10">
|
||||
<div class="grid grid-cols-1 @[32rem]:grid-cols-2 gap-4">
|
||||
<For each={stats}>
|
||||
{(stat) => <Stat label={language.t(stat.label as Parameters<typeof language.t>[0])} value={stat.value()} />}
|
||||
|
||||
@@ -287,6 +287,9 @@ export function Titlebar() {
|
||||
</div>
|
||||
</div>
|
||||
<div id="opencode-titlebar-left" class="flex items-center gap-3 min-w-0 px-2" />
|
||||
<div class="bg-icon-interactive-base text-background-base font-medium px-2 rounded-sm uppercase font-mono">
|
||||
BETA
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="min-w-0 flex items-center justify-center pointer-events-none">
|
||||
|
||||
@@ -40,4 +40,11 @@ describe("command keybind helpers", () => {
|
||||
expect(display.includes("Alt") || display.includes("⌥")).toBe(true)
|
||||
expect(formatKeybind("none")).toBe("")
|
||||
})
|
||||
|
||||
test("formatKeybind prefers the first combo", () => {
|
||||
const display = formatKeybind("mod+k,mod+p")
|
||||
|
||||
expect(display.includes("K") || display.includes("k")).toBe(true)
|
||||
expect(display.includes("P") || display.includes("p")).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -276,7 +276,7 @@ export const dict = {
|
||||
"prompt.context.includeActiveFile": "Include active file",
|
||||
"prompt.context.removeActiveFile": "Remove active file from context",
|
||||
"prompt.context.removeFile": "Remove file from context",
|
||||
"prompt.action.attachFile": "Add file",
|
||||
"prompt.action.attachFile": "Add files",
|
||||
"prompt.attachment.remove": "Remove attachment",
|
||||
"prompt.action.send": "Send",
|
||||
"prompt.action.stop": "Stop",
|
||||
|
||||
@@ -211,13 +211,22 @@ export default function Layout(props: ParentProps) {
|
||||
|
||||
onMount(() => {
|
||||
const stop = () => setState("sizing", false)
|
||||
const blur = () => reset()
|
||||
const hide = () => {
|
||||
if (document.visibilityState !== "hidden") return
|
||||
reset()
|
||||
}
|
||||
window.addEventListener("pointerup", stop)
|
||||
window.addEventListener("pointercancel", stop)
|
||||
window.addEventListener("blur", stop)
|
||||
window.addEventListener("blur", blur)
|
||||
document.addEventListener("visibilitychange", hide)
|
||||
onCleanup(() => {
|
||||
window.removeEventListener("pointerup", stop)
|
||||
window.removeEventListener("pointercancel", stop)
|
||||
window.removeEventListener("blur", stop)
|
||||
window.removeEventListener("blur", blur)
|
||||
document.removeEventListener("visibilitychange", hide)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -237,6 +246,12 @@ export default function Layout(props: ParentProps) {
|
||||
navLeave.current = undefined
|
||||
}
|
||||
|
||||
const reset = () => {
|
||||
disarm()
|
||||
setState("hoverSession", undefined)
|
||||
setHoverProject(undefined)
|
||||
}
|
||||
|
||||
const arm = () => {
|
||||
if (layout.sidebar.opened()) return
|
||||
if (state.hoverProject === undefined) return
|
||||
@@ -305,8 +320,7 @@ export default function Layout(props: ParentProps) {
|
||||
|
||||
const clearSidebarHoverState = () => {
|
||||
if (layout.sidebar.opened()) return
|
||||
setState("hoverSession", undefined)
|
||||
setHoverProject(undefined)
|
||||
reset()
|
||||
}
|
||||
|
||||
const navigateWithSidebarReset = (href: string) => {
|
||||
@@ -1975,6 +1989,10 @@ export default function Layout(props: ParentProps) {
|
||||
onProjectMouseEnter: (worktree, event) => aim.enter(worktree, event),
|
||||
onProjectMouseLeave: (worktree) => aim.leave(worktree),
|
||||
onProjectFocus: (worktree) => aim.activate(worktree),
|
||||
onHoverOpenChanged: (worktree, hoverOpen) => {
|
||||
if (!hoverOpen && state.hoverProject && state.hoverProject !== worktree) return
|
||||
setState("hoverProject", hoverOpen ? worktree : undefined)
|
||||
},
|
||||
navigateToProject,
|
||||
openSidebar: () => layout.sidebar.open(),
|
||||
closeProject,
|
||||
|
||||
@@ -157,34 +157,45 @@ const SessionHoverPreview = (props: {
|
||||
messageLabel: (message: Message) => string | undefined
|
||||
onMessageSelect: (message: Message) => void
|
||||
trigger: JSX.Element
|
||||
}): JSX.Element => (
|
||||
<HoverCard
|
||||
openDelay={1000}
|
||||
closeDelay={props.sidebarHovering() ? 600 : 0}
|
||||
placement="right-start"
|
||||
gutter={16}
|
||||
shift={-2}
|
||||
trigger={props.trigger}
|
||||
open={props.hoverSession() === props.session.id}
|
||||
onOpenChange={(open) => props.setHoverSession(open ? props.session.id : undefined)}
|
||||
>
|
||||
<Show
|
||||
when={props.hoverReady()}
|
||||
fallback={<div class="text-12-regular text-text-weak">{props.language.t("session.messages.loading")}</div>}
|
||||
}): JSX.Element => {
|
||||
let ref: HTMLDivElement | undefined
|
||||
|
||||
return (
|
||||
<HoverCard
|
||||
openDelay={1000}
|
||||
closeDelay={props.sidebarHovering() ? 600 : 0}
|
||||
placement="right-start"
|
||||
gutter={16}
|
||||
shift={-2}
|
||||
trigger={<div ref={ref}>{props.trigger}</div>}
|
||||
open={props.hoverSession() === props.session.id}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) {
|
||||
props.setHoverSession(undefined)
|
||||
return
|
||||
}
|
||||
if (!ref?.matches(":hover")) return
|
||||
props.setHoverSession(props.session.id)
|
||||
}}
|
||||
>
|
||||
<div class="overflow-y-auto overflow-x-hidden max-h-72 h-full">
|
||||
<MessageNav
|
||||
messages={props.hoverMessages() ?? []}
|
||||
current={undefined}
|
||||
getLabel={props.messageLabel}
|
||||
onMessageSelect={props.onMessageSelect}
|
||||
size="normal"
|
||||
class="w-60"
|
||||
/>
|
||||
</div>
|
||||
</Show>
|
||||
</HoverCard>
|
||||
)
|
||||
<Show
|
||||
when={props.hoverReady()}
|
||||
fallback={<div class="text-12-regular text-text-weak">{props.language.t("session.messages.loading")}</div>}
|
||||
>
|
||||
<div class="overflow-y-auto overflow-x-hidden max-h-72 h-full">
|
||||
<MessageNav
|
||||
messages={props.hoverMessages() ?? []}
|
||||
current={undefined}
|
||||
getLabel={props.messageLabel}
|
||||
onMessageSelect={props.onMessageSelect}
|
||||
size="normal"
|
||||
class="w-60"
|
||||
/>
|
||||
</div>
|
||||
</Show>
|
||||
</HoverCard>
|
||||
)
|
||||
}
|
||||
|
||||
export const SessionItem = (props: SessionItemProps): JSX.Element => {
|
||||
const params = useParams()
|
||||
|
||||
@@ -23,6 +23,7 @@ export type ProjectSidebarContext = {
|
||||
onProjectMouseEnter: (worktree: string, event: MouseEvent) => void
|
||||
onProjectMouseLeave: (worktree: string) => void
|
||||
onProjectFocus: (worktree: string) => void
|
||||
onHoverOpenChanged: (worktree: string, hovered: boolean) => void
|
||||
navigateToProject: (directory: string) => void
|
||||
openSidebar: () => void
|
||||
closeProject: (directory: string) => void
|
||||
@@ -109,8 +110,14 @@ const ProjectTile = (props: {
|
||||
"bg-surface-base-hover border border-border-weak-base": !props.selected() && props.active(),
|
||||
}}
|
||||
onPointerDown={(event) => {
|
||||
if (event.button === 0 && !event.ctrlKey) {
|
||||
props.setOpen(false)
|
||||
props.setSuppressHover(true)
|
||||
return
|
||||
}
|
||||
if (!props.overlay()) return
|
||||
if (event.button !== 2 && !(event.button === 0 && event.ctrlKey)) return
|
||||
props.setOpen(false)
|
||||
props.setSuppressHover(true)
|
||||
event.preventDefault()
|
||||
}}
|
||||
@@ -130,12 +137,11 @@ const ProjectTile = (props: {
|
||||
props.onProjectFocus(props.project.worktree)
|
||||
}}
|
||||
onClick={() => {
|
||||
props.setOpen(false)
|
||||
if (props.selected()) {
|
||||
props.setSuppressHover(true)
|
||||
layout.sidebar.toggle()
|
||||
return
|
||||
}
|
||||
props.setSuppressHover(false)
|
||||
props.navigateToProject(props.project.worktree)
|
||||
}}
|
||||
onBlur={() => props.setOpen(false)}
|
||||
@@ -192,7 +198,6 @@ const ProjectPreviewPanel = (props: {
|
||||
projectChildren: Accessor<Map<string, string[]>>
|
||||
workspaceSessions: (directory: string) => ReturnType<typeof sortedRootSessions>
|
||||
workspaceChildren: (directory: string) => Map<string, string[]>
|
||||
setOpen: (value: boolean) => void
|
||||
ctx: ProjectSidebarContext
|
||||
language: ReturnType<typeof useLanguage>
|
||||
}): JSX.Element => (
|
||||
@@ -259,7 +264,7 @@ const ProjectPreviewPanel = (props: {
|
||||
class="flex w-full text-left justify-start text-text-base px-2 hover:bg-transparent active:bg-transparent"
|
||||
onClick={() => {
|
||||
props.ctx.openSidebar()
|
||||
props.setOpen(false)
|
||||
props.ctx.onHoverOpenChanged(props.project.worktree, false)
|
||||
if (props.selected()) return
|
||||
props.ctx.navigateToProject(props.project.worktree)
|
||||
}}
|
||||
@@ -284,28 +289,16 @@ export const SortableProject = (props: {
|
||||
const workspaceEnabled = createMemo(() => props.ctx.workspacesEnabled(props.project))
|
||||
const dirs = createMemo(() => props.ctx.workspaceIds(props.project))
|
||||
const [state, setState] = createStore({
|
||||
open: false,
|
||||
menu: false,
|
||||
suppressHover: false,
|
||||
})
|
||||
|
||||
const isHoverProject = () => props.ctx.hoverProject() === props.project.worktree
|
||||
const preview = createMemo(() => !props.mobile && props.ctx.sidebarOpened())
|
||||
const overlay = createMemo(() => !props.mobile && !props.ctx.sidebarOpened())
|
||||
const active = createMemo(
|
||||
() => state.menu || (preview() ? state.open : overlay() && props.ctx.hoverProject() === props.project.worktree),
|
||||
)
|
||||
const active = createMemo(() => state.menu || (preview() ? isHoverProject() : overlay() && isHoverProject()))
|
||||
|
||||
createEffect(() => {
|
||||
if (preview()) return
|
||||
if (!state.open) return
|
||||
setState("open", false)
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
if (!selected()) return
|
||||
if (!state.open) return
|
||||
setState("open", false)
|
||||
})
|
||||
const hoverOpen = () => isHoverProject() && preview() && !selected() && !state.menu
|
||||
|
||||
const label = (directory: string) => {
|
||||
const [data] = globalSync.child(directory, { bootstrap: false })
|
||||
@@ -346,7 +339,7 @@ export const SortableProject = (props: {
|
||||
workspacesEnabled={props.ctx.workspacesEnabled}
|
||||
closeProject={props.ctx.closeProject}
|
||||
setMenu={(value) => setState("menu", value)}
|
||||
setOpen={(value) => setState("open", value)}
|
||||
setOpen={(value) => props.ctx.onHoverOpenChanged(props.project.worktree, value)}
|
||||
setSuppressHover={(value) => setState("suppressHover", value)}
|
||||
language={language}
|
||||
/>
|
||||
@@ -357,7 +350,7 @@ export const SortableProject = (props: {
|
||||
<div use:sortable classList={{ "opacity-30": sortable.isActiveDraggable }}>
|
||||
<Show when={preview() && !selected()} fallback={tile()}>
|
||||
<HoverCard
|
||||
open={!state.suppressHover && state.open && !state.menu}
|
||||
open={!state.suppressHover && hoverOpen() && !state.menu}
|
||||
openDelay={0}
|
||||
closeDelay={0}
|
||||
placement="right-start"
|
||||
@@ -366,7 +359,7 @@ export const SortableProject = (props: {
|
||||
onOpenChange={(value) => {
|
||||
if (state.menu) return
|
||||
if (value && state.suppressHover) return
|
||||
setState("open", value)
|
||||
props.ctx.onHoverOpenChanged(props.project.worktree, value)
|
||||
if (value) props.ctx.setHoverSession(undefined)
|
||||
}}
|
||||
>
|
||||
@@ -381,7 +374,6 @@ export const SortableProject = (props: {
|
||||
projectChildren={projectChildren}
|
||||
workspaceSessions={workspaceSessions}
|
||||
workspaceChildren={workspaceChildren}
|
||||
setOpen={(value) => setState("open", value)}
|
||||
ctx={props.ctx}
|
||||
language={language}
|
||||
/>
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useMutation } from "@tanstack/solid-query"
|
||||
import { Button } from "@opencode-ai/ui/button"
|
||||
import { DockPrompt } from "@opencode-ai/ui/dock-prompt"
|
||||
import { Icon } from "@opencode-ai/ui/icon"
|
||||
import { IconButton } from "@opencode-ai/ui/icon-button"
|
||||
import { showToast } from "@opencode-ai/ui/toast"
|
||||
import type { QuestionAnswer, QuestionRequest } from "@opencode-ai/sdk/v2"
|
||||
import { useLanguage } from "@/context/language"
|
||||
@@ -25,6 +26,7 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
|
||||
custom: cached?.custom ?? ([] as string[]),
|
||||
customOn: cached?.customOn ?? ([] as boolean[]),
|
||||
editing: false,
|
||||
collapsed: false,
|
||||
})
|
||||
|
||||
let root: HTMLDivElement | undefined
|
||||
@@ -35,6 +37,7 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
|
||||
const input = createMemo(() => store.custom[store.tab] ?? "")
|
||||
const on = createMemo(() => store.customOn[store.tab] === true)
|
||||
const multi = createMemo(() => question()?.multiple === true)
|
||||
const picked = createMemo(() => store.answers[store.tab]?.length ?? 0)
|
||||
|
||||
const summary = createMemo(() => {
|
||||
const n = Math.min(store.tab + 1, total())
|
||||
@@ -43,6 +46,8 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
|
||||
|
||||
const last = createMemo(() => store.tab >= total() - 1)
|
||||
|
||||
const fold = () => setStore("collapsed", (value) => !value)
|
||||
|
||||
const customUpdate = (value: string, selected: boolean = on()) => {
|
||||
const prev = input().trim()
|
||||
const next = value.trim()
|
||||
@@ -261,9 +266,21 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
|
||||
kind="question"
|
||||
ref={(el) => (root = el)}
|
||||
header={
|
||||
<>
|
||||
<div
|
||||
data-action="session-question-toggle"
|
||||
class="flex flex-1 min-w-0 items-center gap-2 cursor-default select-none"
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
style={{ margin: "0 -10px", padding: "0 0 0 10px" }}
|
||||
onClick={fold}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key !== "Enter" && event.key !== " ") return
|
||||
event.preventDefault()
|
||||
fold()
|
||||
}}
|
||||
>
|
||||
<div data-slot="question-header-title">{summary()}</div>
|
||||
<div data-slot="question-progress">
|
||||
<div data-slot="question-progress" class="ml-auto mr-1">
|
||||
<For each={questions()}>
|
||||
{(_, i) => (
|
||||
<button
|
||||
@@ -275,13 +292,38 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
|
||||
(store.customOn[i()] === true && (store.custom[i()] ?? "").trim().length > 0)
|
||||
}
|
||||
disabled={sending()}
|
||||
onClick={() => jump(i())}
|
||||
onMouseDown={(event) => {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
}}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation()
|
||||
jump(i())
|
||||
}}
|
||||
aria-label={`${language.t("ui.tool.questions")} ${i() + 1}`}
|
||||
/>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</>
|
||||
<div>
|
||||
<IconButton
|
||||
data-action="session-question-toggle-button"
|
||||
icon="chevron-down"
|
||||
size="normal"
|
||||
variant="ghost"
|
||||
classList={{ "rotate-180": store.collapsed }}
|
||||
onMouseDown={(event) => {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
}}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation()
|
||||
fold()
|
||||
}}
|
||||
aria-label={store.collapsed ? language.t("session.todo.expand") : language.t("session.todo.collapse")}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
footer={
|
||||
<>
|
||||
@@ -301,56 +343,121 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div data-slot="question-text">{question()?.question}</div>
|
||||
<Show when={multi()} fallback={<div data-slot="question-hint">{language.t("ui.question.singleHint")}</div>}>
|
||||
<div data-slot="question-hint">{language.t("ui.question.multiHint")}</div>
|
||||
<div
|
||||
data-slot="question-text"
|
||||
class="cursor-default"
|
||||
classList={{
|
||||
"mb-6": store.collapsed && picked() === 0,
|
||||
}}
|
||||
role={store.collapsed ? "button" : undefined}
|
||||
tabIndex={store.collapsed ? 0 : undefined}
|
||||
onClick={fold}
|
||||
onKeyDown={(event) => {
|
||||
if (!store.collapsed) return
|
||||
if (event.key !== "Enter" && event.key !== " ") return
|
||||
event.preventDefault()
|
||||
fold()
|
||||
}}
|
||||
>
|
||||
{question()?.question}
|
||||
</div>
|
||||
<Show when={store.collapsed && picked() > 0}>
|
||||
<div data-slot="question-hint" class="cursor-default mb-6">
|
||||
{picked()} answer{picked() === 1 ? "" : "s"} selected
|
||||
</div>
|
||||
</Show>
|
||||
<div data-slot="question-options">
|
||||
<For each={options()}>
|
||||
{(opt, i) => {
|
||||
const picked = () => store.answers[store.tab]?.includes(opt.label) ?? false
|
||||
return (
|
||||
<div data-slot="question-answers" hidden={store.collapsed} aria-hidden={store.collapsed}>
|
||||
<Show when={multi()} fallback={<div data-slot="question-hint">{language.t("ui.question.singleHint")}</div>}>
|
||||
<div data-slot="question-hint">{language.t("ui.question.multiHint")}</div>
|
||||
</Show>
|
||||
<div data-slot="question-options">
|
||||
<For each={options()}>
|
||||
{(opt, i) => {
|
||||
const picked = () => store.answers[store.tab]?.includes(opt.label) ?? false
|
||||
return (
|
||||
<button
|
||||
data-slot="question-option"
|
||||
data-picked={picked()}
|
||||
role={multi() ? "checkbox" : "radio"}
|
||||
aria-checked={picked()}
|
||||
disabled={sending()}
|
||||
onClick={() => selectOption(i())}
|
||||
>
|
||||
<span data-slot="question-option-check" aria-hidden="true">
|
||||
<span
|
||||
data-slot="question-option-box"
|
||||
data-type={multi() ? "checkbox" : "radio"}
|
||||
data-picked={picked()}
|
||||
>
|
||||
<Show when={multi()} fallback={<span data-slot="question-option-radio-dot" />}>
|
||||
<Icon name="check-small" size="small" />
|
||||
</Show>
|
||||
</span>
|
||||
</span>
|
||||
<span data-slot="question-option-main">
|
||||
<span data-slot="option-label">{opt.label}</span>
|
||||
<Show when={opt.description}>
|
||||
<span data-slot="option-description">{opt.description}</span>
|
||||
</Show>
|
||||
</span>
|
||||
</button>
|
||||
)
|
||||
}}
|
||||
</For>
|
||||
|
||||
<Show
|
||||
when={store.editing}
|
||||
fallback={
|
||||
<button
|
||||
data-slot="question-option"
|
||||
data-picked={picked()}
|
||||
data-custom="true"
|
||||
data-picked={on()}
|
||||
role={multi() ? "checkbox" : "radio"}
|
||||
aria-checked={picked()}
|
||||
aria-checked={on()}
|
||||
disabled={sending()}
|
||||
onClick={() => selectOption(i())}
|
||||
onClick={customOpen}
|
||||
>
|
||||
<span data-slot="question-option-check" aria-hidden="true">
|
||||
<span
|
||||
data-slot="question-option-box"
|
||||
data-type={multi() ? "checkbox" : "radio"}
|
||||
data-picked={picked()}
|
||||
>
|
||||
<span
|
||||
data-slot="question-option-check"
|
||||
aria-hidden="true"
|
||||
onClick={(e) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
customToggle()
|
||||
}}
|
||||
>
|
||||
<span data-slot="question-option-box" data-type={multi() ? "checkbox" : "radio"} data-picked={on()}>
|
||||
<Show when={multi()} fallback={<span data-slot="question-option-radio-dot" />}>
|
||||
<Icon name="check-small" size="small" />
|
||||
</Show>
|
||||
</span>
|
||||
</span>
|
||||
<span data-slot="question-option-main">
|
||||
<span data-slot="option-label">{opt.label}</span>
|
||||
<Show when={opt.description}>
|
||||
<span data-slot="option-description">{opt.description}</span>
|
||||
</Show>
|
||||
<span data-slot="option-label">{language.t("ui.messagePart.option.typeOwnAnswer")}</span>
|
||||
<span data-slot="option-description">{input() || language.t("ui.question.custom.placeholder")}</span>
|
||||
</span>
|
||||
</button>
|
||||
)
|
||||
}}
|
||||
</For>
|
||||
|
||||
<Show
|
||||
when={store.editing}
|
||||
fallback={
|
||||
<button
|
||||
}
|
||||
>
|
||||
<form
|
||||
data-slot="question-option"
|
||||
data-custom="true"
|
||||
data-picked={on()}
|
||||
role={multi() ? "checkbox" : "radio"}
|
||||
aria-checked={on()}
|
||||
disabled={sending()}
|
||||
onClick={customOpen}
|
||||
onMouseDown={(e) => {
|
||||
if (sending()) {
|
||||
e.preventDefault()
|
||||
return
|
||||
}
|
||||
if (e.target instanceof HTMLTextAreaElement) return
|
||||
const input = e.currentTarget.querySelector('[data-slot="question-custom-input"]')
|
||||
if (input instanceof HTMLTextAreaElement) input.focus()
|
||||
}}
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault()
|
||||
commitCustom()
|
||||
}}
|
||||
>
|
||||
<span
|
||||
data-slot="question-option-check"
|
||||
@@ -369,80 +476,39 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
|
||||
</span>
|
||||
<span data-slot="question-option-main">
|
||||
<span data-slot="option-label">{language.t("ui.messagePart.option.typeOwnAnswer")}</span>
|
||||
<span data-slot="option-description">{input() || language.t("ui.question.custom.placeholder")}</span>
|
||||
</span>
|
||||
</button>
|
||||
}
|
||||
>
|
||||
<form
|
||||
data-slot="question-option"
|
||||
data-custom="true"
|
||||
data-picked={on()}
|
||||
role={multi() ? "checkbox" : "radio"}
|
||||
aria-checked={on()}
|
||||
onMouseDown={(e) => {
|
||||
if (sending()) {
|
||||
e.preventDefault()
|
||||
return
|
||||
}
|
||||
if (e.target instanceof HTMLTextAreaElement) return
|
||||
const input = e.currentTarget.querySelector('[data-slot="question-custom-input"]')
|
||||
if (input instanceof HTMLTextAreaElement) input.focus()
|
||||
}}
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault()
|
||||
commitCustom()
|
||||
}}
|
||||
>
|
||||
<span
|
||||
data-slot="question-option-check"
|
||||
aria-hidden="true"
|
||||
onClick={(e) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
customToggle()
|
||||
}}
|
||||
>
|
||||
<span data-slot="question-option-box" data-type={multi() ? "checkbox" : "radio"} data-picked={on()}>
|
||||
<Show when={multi()} fallback={<span data-slot="question-option-radio-dot" />}>
|
||||
<Icon name="check-small" size="small" />
|
||||
</Show>
|
||||
</span>
|
||||
</span>
|
||||
<span data-slot="question-option-main">
|
||||
<span data-slot="option-label">{language.t("ui.messagePart.option.typeOwnAnswer")}</span>
|
||||
<textarea
|
||||
ref={(el) =>
|
||||
setTimeout(() => {
|
||||
el.focus()
|
||||
el.style.height = "0px"
|
||||
el.style.height = `${el.scrollHeight}px`
|
||||
}, 0)
|
||||
}
|
||||
data-slot="question-custom-input"
|
||||
placeholder={language.t("ui.question.custom.placeholder")}
|
||||
value={input()}
|
||||
rows={1}
|
||||
disabled={sending()}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Escape") {
|
||||
e.preventDefault()
|
||||
setStore("editing", false)
|
||||
return
|
||||
<textarea
|
||||
ref={(el) =>
|
||||
setTimeout(() => {
|
||||
el.focus()
|
||||
el.style.height = "0px"
|
||||
el.style.height = `${el.scrollHeight}px`
|
||||
}, 0)
|
||||
}
|
||||
if (e.key !== "Enter" || e.shiftKey) return
|
||||
e.preventDefault()
|
||||
commitCustom()
|
||||
}}
|
||||
onInput={(e) => {
|
||||
customUpdate(e.currentTarget.value)
|
||||
e.currentTarget.style.height = "0px"
|
||||
e.currentTarget.style.height = `${e.currentTarget.scrollHeight}px`
|
||||
}}
|
||||
/>
|
||||
</span>
|
||||
</form>
|
||||
</Show>
|
||||
data-slot="question-custom-input"
|
||||
placeholder={language.t("ui.question.custom.placeholder")}
|
||||
value={input()}
|
||||
rows={1}
|
||||
disabled={sending()}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Escape") {
|
||||
e.preventDefault()
|
||||
setStore("editing", false)
|
||||
return
|
||||
}
|
||||
if (e.key !== "Enter" || e.shiftKey) return
|
||||
e.preventDefault()
|
||||
commitCustom()
|
||||
}}
|
||||
onInput={(e) => {
|
||||
customUpdate(e.currentTarget.value)
|
||||
e.currentTarget.style.height = "0px"
|
||||
e.currentTarget.style.height = `${e.currentTarget.scrollHeight}px`
|
||||
}}
|
||||
/>
|
||||
</span>
|
||||
</form>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
</DockPrompt>
|
||||
)
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { For, createEffect, createMemo, on, onCleanup, Show, Index, type JSX } from "solid-js"
|
||||
import { For, createEffect, createMemo, on, onCleanup, Show, Index, type JSX, createSignal } from "solid-js"
|
||||
import { createStore, produce } from "solid-js/store"
|
||||
import { useNavigate } from "@solidjs/router"
|
||||
import { useMutation } from "@tanstack/solid-query"
|
||||
@@ -30,6 +30,7 @@ import { useSDK } from "@/context/sdk"
|
||||
import { useSync } from "@/context/sync"
|
||||
import { messageAgentColor } from "@/utils/agent"
|
||||
import { parseCommentNote, readCommentMetadata } from "@/utils/comment-note"
|
||||
import { makeTimer } from "@solid-primitives/timer"
|
||||
|
||||
type MessageComment = {
|
||||
path: string
|
||||
@@ -250,38 +251,21 @@ export function MessageTimeline(props: {
|
||||
const working = createMemo(() => !!pending() || sessionStatus().type !== "idle")
|
||||
const tint = createMemo(() => messageAgentColor(sessionMessages(), sync.data.agent))
|
||||
|
||||
const [slot, setSlot] = createStore({
|
||||
open: false,
|
||||
show: false,
|
||||
fade: false,
|
||||
const [timeoutDone, setTimeoutDone] = createSignal(true)
|
||||
|
||||
const workingStatus = createMemo<"hidden" | "showing" | "hiding">((prev) => {
|
||||
if (working()) return "showing"
|
||||
if (prev === "showing" || !timeoutDone()) return "hiding"
|
||||
return "hidden"
|
||||
})
|
||||
|
||||
let f: number | undefined
|
||||
const clear = () => {
|
||||
if (f !== undefined) window.clearTimeout(f)
|
||||
f = undefined
|
||||
}
|
||||
createEffect(() => {
|
||||
if (workingStatus() !== "hiding") return
|
||||
|
||||
setTimeoutDone(false)
|
||||
makeTimer(() => setTimeoutDone(true), 260, setTimeout)
|
||||
})
|
||||
|
||||
onCleanup(clear)
|
||||
createEffect(
|
||||
on(
|
||||
working,
|
||||
(on, prev) => {
|
||||
clear()
|
||||
if (on) {
|
||||
setSlot({ open: true, show: true, fade: false })
|
||||
return
|
||||
}
|
||||
if (prev) {
|
||||
setSlot({ open: false, show: true, fade: true })
|
||||
f = window.setTimeout(() => setSlot({ show: false, fade: false }), 260)
|
||||
return
|
||||
}
|
||||
setSlot({ open: false, show: false, fade: false })
|
||||
},
|
||||
{ defer: true },
|
||||
),
|
||||
)
|
||||
const activeMessageID = createMemo(() => {
|
||||
const parentID = pending()?.parentID
|
||||
if (parentID) {
|
||||
@@ -676,17 +660,15 @@ export function MessageTimeline(props: {
|
||||
<div
|
||||
class="shrink-0 flex items-center justify-center overflow-hidden transition-[width,margin] duration-300 ease-[cubic-bezier(0.22,1,0.36,1)]"
|
||||
style={{
|
||||
width: slot.open ? "16px" : "0px",
|
||||
"margin-right": slot.open ? "8px" : "0px",
|
||||
width: working() ? "16px" : "0px",
|
||||
"margin-right": working() ? "8px" : "0px",
|
||||
}}
|
||||
aria-hidden="true"
|
||||
>
|
||||
<Show when={slot.show}>
|
||||
<Show when={workingStatus() !== "hidden"}>
|
||||
<div
|
||||
class="transition-opacity duration-200 ease-out"
|
||||
classList={{
|
||||
"opacity-0": slot.fade,
|
||||
}}
|
||||
classList={{ "opacity-0": workingStatus() === "hiding" }}
|
||||
>
|
||||
<Spinner class="size-4" style={{ color: tint() ?? "var(--icon-interactive-base)" }} />
|
||||
</div>
|
||||
@@ -912,7 +894,6 @@ export function MessageTimeline(props: {
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<div
|
||||
role="log"
|
||||
class="flex flex-col gap-12 items-start justify-start pb-16 transition-[margin]"
|
||||
|
||||
@@ -438,12 +438,10 @@ export function SessionSidePanel(props: {
|
||||
size={layout.fileTree.width()}
|
||||
min={200}
|
||||
max={480}
|
||||
collapseThreshold={160}
|
||||
onResize={(width) => {
|
||||
props.size.touch()
|
||||
layout.fileTree.resize(width)
|
||||
}}
|
||||
onCollapse={layout.fileTree.close}
|
||||
/>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
@@ -255,7 +255,7 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
|
||||
id: "file.open",
|
||||
title: language.t("command.file.open"),
|
||||
description: language.t("palette.search.placeholder"),
|
||||
keybind: "mod+p",
|
||||
keybind: "mod+k,mod+p",
|
||||
slash: "open",
|
||||
onSelect: () => dialog.show(() => <DialogSelectFile onOpenFile={showAllFiles} />),
|
||||
}),
|
||||
|
||||
44
packages/app/src/utils/prompt.test.ts
Normal file
44
packages/app/src/utils/prompt.test.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import type { Part } from "@opencode-ai/sdk/v2"
|
||||
import { extractPromptFromParts } from "./prompt"
|
||||
|
||||
describe("extractPromptFromParts", () => {
|
||||
test("restores multiple uploaded attachments", () => {
|
||||
const parts = [
|
||||
{
|
||||
id: "text_1",
|
||||
type: "text",
|
||||
text: "check these",
|
||||
sessionID: "ses_1",
|
||||
messageID: "msg_1",
|
||||
},
|
||||
{
|
||||
id: "file_1",
|
||||
type: "file",
|
||||
mime: "image/png",
|
||||
url: "data:image/png;base64,AAA",
|
||||
filename: "a.png",
|
||||
sessionID: "ses_1",
|
||||
messageID: "msg_1",
|
||||
},
|
||||
{
|
||||
id: "file_2",
|
||||
type: "file",
|
||||
mime: "application/pdf",
|
||||
url: "data:application/pdf;base64,BBB",
|
||||
filename: "b.pdf",
|
||||
sessionID: "ses_1",
|
||||
messageID: "msg_1",
|
||||
},
|
||||
] satisfies Part[]
|
||||
|
||||
const result = extractPromptFromParts(parts)
|
||||
|
||||
expect(result).toHaveLength(3)
|
||||
expect(result[0]).toMatchObject({ type: "text", content: "check these" })
|
||||
expect(result.slice(1)).toMatchObject([
|
||||
{ type: "image", filename: "a.png", mime: "image/png", dataUrl: "data:image/png;base64,AAA" },
|
||||
{ type: "image", filename: "b.pdf", mime: "application/pdf", dataUrl: "data:application/pdf;base64,BBB" },
|
||||
])
|
||||
})
|
||||
})
|
||||
@@ -42,7 +42,7 @@
|
||||
"devDependencies": {
|
||||
"@cloudflare/workers-types": "catalog:",
|
||||
"@tsconfig/node22": "22.0.2",
|
||||
"@types/bun": "1.3.0",
|
||||
"@types/bun": "catalog:",
|
||||
"@types/node": "catalog:",
|
||||
"drizzle-kit": "catalog:",
|
||||
"mysql2": "3.14.4",
|
||||
|
||||
@@ -4,7 +4,7 @@ FROM ${REGISTRY}/build/base:24.04
|
||||
SHELL ["/bin/bash", "-lc"]
|
||||
|
||||
ARG NODE_VERSION=24.4.0
|
||||
ARG BUN_VERSION=1.3.5
|
||||
ARG BUN_VERSION=1.3.11
|
||||
|
||||
ENV BUN_INSTALL=/opt/bun
|
||||
ENV PATH=/opt/bun/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
|
||||
|
||||
@@ -35,6 +35,7 @@ export type CommandEvent =
|
||||
export type SqliteMigrationProgress = { type: "InProgress"; value: number } | { type: "Done" }
|
||||
|
||||
export type CommandChild = {
|
||||
pid: number | undefined
|
||||
kill: () => void
|
||||
}
|
||||
|
||||
@@ -191,7 +192,7 @@ export function spawnCommand(args: string, extraEnv: Record<string, string>) {
|
||||
treeKill(child.pid)
|
||||
}
|
||||
|
||||
return { events, child: { kill }, exit }
|
||||
return { events, child: { pid: child.pid, kill }, exit }
|
||||
}
|
||||
|
||||
function handleSqliteProgress(events: EventEmitter, line: string) {
|
||||
|
||||
@@ -81,6 +81,17 @@ function setupApp() {
|
||||
killSidecar()
|
||||
})
|
||||
|
||||
app.on("will-quit", () => {
|
||||
killSidecar()
|
||||
})
|
||||
|
||||
for (const signal of ["SIGINT", "SIGTERM"] as const) {
|
||||
process.on(signal, () => {
|
||||
killSidecar()
|
||||
app.exit(0)
|
||||
})
|
||||
}
|
||||
|
||||
void app.whenReady().then(async () => {
|
||||
// migrate()
|
||||
app.setAsDefaultProtocolClient("opencode")
|
||||
@@ -234,8 +245,15 @@ registerIpcHandlers({
|
||||
|
||||
function killSidecar() {
|
||||
if (!sidecar) return
|
||||
const pid = sidecar.pid
|
||||
sidecar.kill()
|
||||
sidecar = null
|
||||
// tree-kill is async; also send process group signal as immediate fallback
|
||||
if (pid && process.platform !== "win32") {
|
||||
try {
|
||||
process.kill(-pid, "SIGTERM")
|
||||
} catch {}
|
||||
}
|
||||
}
|
||||
|
||||
function ensureLoopbackNoProxy() {
|
||||
|
||||
@@ -31,12 +31,14 @@ See `specs/effect-migration.md` for the compact pattern reference and examples.
|
||||
- Use `Schema.Defect` instead of `unknown` for defect-like causes.
|
||||
- In `Effect.gen` / `Effect.fn`, prefer `yield* new MyError(...)` over `yield* Effect.fail(new MyError(...))` for direct early-failure branches.
|
||||
|
||||
## Runtime vs Instances
|
||||
## Runtime vs InstanceState
|
||||
|
||||
- Use the shared runtime for process-wide services with one lifecycle for the whole app.
|
||||
- Use `src/effect/instances.ts` for per-directory or per-project services that need `InstanceContext`, per-instance state, or per-instance cleanup.
|
||||
- If two open directories should not share one copy of the service, it belongs in `Instances`.
|
||||
- Instance-scoped services should read context from `InstanceContext`, not `Instance.*` globals.
|
||||
- Use `makeRuntime` (from `src/effect/run-service.ts`) for all services. It returns `{ runPromise, runFork, runCallback }` backed by a shared `memoMap` that deduplicates layers.
|
||||
- Use `InstanceState` (from `src/effect/instance-state.ts`) for per-directory or per-project state that needs per-instance cleanup. It uses `ScopedCache` keyed by directory — each open project gets its own state, automatically cleaned up on disposal.
|
||||
- If two open directories should not share one copy of the service, it needs `InstanceState`.
|
||||
- Do the work directly in the `InstanceState.make` closure — `ScopedCache` handles run-once semantics. Don't add fibers, `ensure()` callbacks, or `started` flags on top.
|
||||
- Use `Effect.addFinalizer` or `Effect.acquireRelease` inside the `InstanceState.make` closure for cleanup (subscriptions, process teardown, etc.).
|
||||
- Use `Effect.forkScoped` inside the closure for background stream consumers — the fiber is interrupted when the instance is disposed.
|
||||
|
||||
## Preferred Effect services
|
||||
|
||||
@@ -51,7 +53,7 @@ See `specs/effect-migration.md` for the compact pattern reference and examples.
|
||||
|
||||
`Instance.bind(fn)` captures the current Instance AsyncLocalStorage context and restores it synchronously when called.
|
||||
|
||||
Use it for native addon callbacks (`@parcel/watcher`, `node-pty`, native `fs.watch`, etc.) that need to call `Bus.publish`, `Instance.state()`, or anything that reads `Instance.directory`.
|
||||
Use it for native addon callbacks (`@parcel/watcher`, `node-pty`, native `fs.watch`, etc.) that need to call `Bus.publish` or anything that reads `Instance.directory`.
|
||||
|
||||
You do not need it for `setTimeout`, `Promise.then`, `EventEmitter.on`, or Effect fibers.
|
||||
|
||||
|
||||
@@ -52,6 +52,7 @@
|
||||
"@types/bun": "catalog:",
|
||||
"@types/cross-spawn": "6.0.6",
|
||||
"@types/mime-types": "3.0.1",
|
||||
"@types/npmcli__arborist": "6.3.3",
|
||||
"@types/semver": "^7.5.8",
|
||||
"@types/turndown": "5.0.5",
|
||||
"@types/which": "3.0.4",
|
||||
@@ -93,6 +94,7 @@
|
||||
"@hono/standard-validator": "0.1.5",
|
||||
"@hono/zod-validator": "catalog:",
|
||||
"@modelcontextprotocol/sdk": "1.25.2",
|
||||
"@npmcli/arborist": "9.4.0",
|
||||
"@octokit/graphql": "9.0.2",
|
||||
"@octokit/rest": "catalog:",
|
||||
"@openauthjs/openauth": "catalog:",
|
||||
@@ -101,8 +103,8 @@
|
||||
"@opencode-ai/sdk": "workspace:*",
|
||||
"@opencode-ai/util": "workspace:*",
|
||||
"@openrouter/ai-sdk-provider": "1.5.4",
|
||||
"@opentui/core": "0.1.88",
|
||||
"@opentui/solid": "0.1.88",
|
||||
"@opentui/core": "0.1.90",
|
||||
"@opentui/solid": "0.1.90",
|
||||
"@parcel/watcher": "2.5.1",
|
||||
"@pierre/diffs": "catalog:",
|
||||
"@solid-primitives/event-bus": "1.1.2",
|
||||
|
||||
@@ -101,6 +101,14 @@ export default {
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
filetype: "kotlin",
|
||||
wasm: "https://github.com/fwcd/tree-sitter-kotlin/releases/download/0.3.8/tree-sitter-kotlin.wasm",
|
||||
queries: {
|
||||
highlights: ["https://raw.githubusercontent.com/fwcd/tree-sitter-kotlin/0.3.8/queries/highlights.scm"],
|
||||
locals: ["https://raw.githubusercontent.com/nvim-treesitter/nvim-treesitter/master/queries/kotlin/locals.scm"],
|
||||
},
|
||||
},
|
||||
{
|
||||
filetype: "ruby",
|
||||
wasm: "https://github.com/tree-sitter/tree-sitter-ruby/releases/download/v0.23.1/tree-sitter-ruby.wasm",
|
||||
@@ -158,6 +166,15 @@ export default {
|
||||
// },
|
||||
// },
|
||||
},
|
||||
{
|
||||
filetype: "hcl",
|
||||
wasm: "https://github.com/tree-sitter-grammars/tree-sitter-hcl/releases/download/v1.2.0/tree-sitter-hcl.wasm",
|
||||
queries: {
|
||||
highlights: [
|
||||
"https://raw.githubusercontent.com/nvim-treesitter/nvim-treesitter/master/queries/hcl/highlights.scm",
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
filetype: "json",
|
||||
wasm: "https://github.com/tree-sitter/tree-sitter-json/releases/download/v0.24.8/tree-sitter-json.wasm",
|
||||
@@ -203,6 +220,16 @@ export default {
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
filetype: "lua",
|
||||
wasm: "https://github.com/tree-sitter-grammars/tree-sitter-lua/releases/download/v0.5.0/tree-sitter-lua.wasm",
|
||||
queries: {
|
||||
highlights: [
|
||||
"https://raw.githubusercontent.com/tree-sitter-grammars/tree-sitter-lua/v0.5.0/queries/highlights.scm",
|
||||
],
|
||||
locals: ["https://raw.githubusercontent.com/tree-sitter-grammars/tree-sitter-lua/v0.5.0/queries/locals.scm"],
|
||||
},
|
||||
},
|
||||
{
|
||||
filetype: "ocaml",
|
||||
wasm: "https://github.com/tree-sitter/tree-sitter-ocaml/releases/download/v0.24.2/tree-sitter-ocaml.wasm",
|
||||
@@ -236,6 +263,15 @@ export default {
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
filetype: "toml",
|
||||
wasm: "https://github.com/tree-sitter-grammars/tree-sitter-toml/releases/download/v0.7.0/tree-sitter-toml.wasm",
|
||||
queries: {
|
||||
highlights: [
|
||||
"https://raw.githubusercontent.com/nvim-treesitter/nvim-treesitter/master/queries/toml/highlights.scm",
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
filetype: "nix",
|
||||
// TODO: Replace with official tree-sitter-nix WASM when published
|
||||
|
||||
@@ -6,7 +6,7 @@ Practical reference for new and migrated Effect code in `packages/opencode`.
|
||||
|
||||
Use `InstanceState` (from `src/effect/instance-state.ts`) for services that need per-directory state, per-instance cleanup, or project-bound background work. InstanceState uses a `ScopedCache` keyed by directory, so each open project gets its own copy of the state that is automatically cleaned up on disposal.
|
||||
|
||||
Use `makeRunPromise` (from `src/effect/run-service.ts`) to create a per-service `ManagedRuntime` that lazily initializes and shares layers via a global `memoMap`.
|
||||
Use `makeRuntime` (from `src/effect/run-service.ts`) to create a per-service `ManagedRuntime` that lazily initializes and shares layers via a global `memoMap`. Returns `{ runPromise, runFork, runCallback }`.
|
||||
|
||||
- Global services (no per-directory state): Account, Auth, Installation, Truncate
|
||||
- Instance-scoped (per-directory state via InstanceState): File, FileTime, FileWatcher, Format, Permission, Question, Skill, Snapshot, Vcs, ProviderAuth
|
||||
@@ -46,7 +46,7 @@ export namespace Foo {
|
||||
export const defaultLayer = layer.pipe(Layer.provide(FooDep.layer))
|
||||
|
||||
// Per-service runtime (inside the namespace)
|
||||
const runPromise = makeRunPromise(Service, defaultLayer)
|
||||
const { runPromise } = makeRuntime(Service, defaultLayer)
|
||||
|
||||
// Async facade functions
|
||||
export async function get(id: FooID) {
|
||||
@@ -79,22 +79,24 @@ See `Auth.ZodInfo` for the canonical example.
|
||||
|
||||
The `InstanceState.make` init callback receives a `Scope`, so you can use `Effect.acquireRelease`, `Effect.addFinalizer`, and `Effect.forkScoped` inside it. Resources acquired this way are automatically cleaned up when the instance is disposed or invalidated by `ScopedCache`. This makes it the right place for:
|
||||
|
||||
- **Subscriptions**: Use `Effect.acquireRelease` to subscribe and auto-unsubscribe:
|
||||
- **Subscriptions**: Yield `Bus.Service` at the layer level, then use `Stream` + `forkScoped` inside the init closure. The fiber is automatically interrupted when the instance scope closes:
|
||||
|
||||
```ts
|
||||
const bus = yield * Bus.Service
|
||||
|
||||
const cache =
|
||||
yield *
|
||||
InstanceState.make<State>(
|
||||
Effect.fn("Foo.state")(function* (ctx) {
|
||||
// ... load state ...
|
||||
|
||||
yield* Effect.acquireRelease(
|
||||
Effect.sync(() =>
|
||||
Bus.subscribeAll((event) => {
|
||||
yield* bus.subscribeAll().pipe(
|
||||
Stream.runForEach((event) =>
|
||||
Effect.sync(() => {
|
||||
/* handle */
|
||||
}),
|
||||
),
|
||||
(unsub) => Effect.sync(unsub),
|
||||
Effect.forkScoped,
|
||||
)
|
||||
|
||||
return {
|
||||
@@ -104,6 +106,16 @@ const cache =
|
||||
)
|
||||
```
|
||||
|
||||
- **Resource cleanup**: Use `Effect.acquireRelease` or `Effect.addFinalizer` for resources that need teardown (native watchers, process handles, etc.):
|
||||
|
||||
```ts
|
||||
yield *
|
||||
Effect.acquireRelease(
|
||||
Effect.sync(() => nativeAddon.watch(dir)),
|
||||
(watcher) => Effect.sync(() => watcher.close()),
|
||||
)
|
||||
```
|
||||
|
||||
- **Background fibers**: Use `Effect.forkScoped` — the fiber is interrupted on disposal.
|
||||
- **Side effects at init**: Config notification, event wiring, etc. all belong in the init closure. Callers just do `InstanceState.get(cache)` to trigger everything, and `ScopedCache` deduplicates automatically.
|
||||
|
||||
@@ -164,8 +176,8 @@ Still open and likely worth migrating:
|
||||
- [x] `Plugin`
|
||||
- [x] `ToolRegistry`
|
||||
- [ ] `Pty`
|
||||
- [ ] `Worktree`
|
||||
- [ ] `Bus`
|
||||
- [x] `Worktree`
|
||||
- [x] `Bus`
|
||||
- [x] `Command`
|
||||
- [ ] `Config`
|
||||
- [ ] `Session`
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Clock, Duration, Effect, Layer, Option, Schema, SchemaGetter, ServiceMap } from "effect"
|
||||
import { FetchHttpClient, HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"
|
||||
|
||||
import { makeRunPromise } from "@/effect/run-service"
|
||||
import { makeRuntime } from "@/effect/run-service"
|
||||
import { withTransientReadRetry } from "@/util/effect-http-client"
|
||||
import { AccountRepo, type AccountRow } from "./repo"
|
||||
import {
|
||||
@@ -379,7 +379,7 @@ export namespace Account {
|
||||
|
||||
export const defaultLayer = layer.pipe(Layer.provide(AccountRepo.layer), Layer.provide(FetchHttpClient.layer))
|
||||
|
||||
export const runPromise = makeRunPromise(Service, defaultLayer)
|
||||
export const { runPromise } = makeRuntime(Service, defaultLayer)
|
||||
|
||||
export async function active(): Promise<Info | undefined> {
|
||||
return Option.getOrUndefined(await runPromise((service) => service.active()))
|
||||
|
||||
@@ -64,6 +64,7 @@ export namespace Agent {
|
||||
question: "deny",
|
||||
plan_enter: "deny",
|
||||
plan_exit: "deny",
|
||||
edit: "ask",
|
||||
// mirrors github.com/github/gitignore Node.gitignore pattern for .env files
|
||||
read: {
|
||||
"*": "allow",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import path from "path"
|
||||
import { Effect, Layer, Record, Result, Schema, ServiceMap } from "effect"
|
||||
import { makeRunPromise } from "@/effect/run-service"
|
||||
import { makeRuntime } from "@/effect/run-service"
|
||||
import { zod } from "@/util/effect-zod"
|
||||
import { Global } from "../global"
|
||||
import { Filesystem } from "../util/filesystem"
|
||||
@@ -95,7 +95,7 @@ export namespace Auth {
|
||||
}),
|
||||
)
|
||||
|
||||
const runPromise = makeRunPromise(Service, layer)
|
||||
const { runPromise } = makeRuntime(Service, layer)
|
||||
|
||||
export async function get(providerID: string) {
|
||||
return runPromise((service) => service.get(providerID))
|
||||
|
||||
@@ -1,127 +0,0 @@
|
||||
import z from "zod"
|
||||
import { Global } from "../global"
|
||||
import { Log } from "../util/log"
|
||||
import path from "path"
|
||||
import { Filesystem } from "../util/filesystem"
|
||||
import { NamedError } from "@opencode-ai/util/error"
|
||||
import { Lock } from "../util/lock"
|
||||
import { PackageRegistry } from "./registry"
|
||||
import { proxied } from "@/util/proxied"
|
||||
import { Process } from "../util/process"
|
||||
|
||||
export namespace BunProc {
|
||||
const log = Log.create({ service: "bun" })
|
||||
|
||||
export async function run(cmd: string[], options?: Process.RunOptions) {
|
||||
const full = [which(), ...cmd]
|
||||
log.info("running", {
|
||||
cmd: full,
|
||||
...options,
|
||||
})
|
||||
const result = await Process.run(full, {
|
||||
cwd: options?.cwd,
|
||||
abort: options?.abort,
|
||||
kill: options?.kill,
|
||||
timeout: options?.timeout,
|
||||
nothrow: options?.nothrow,
|
||||
env: {
|
||||
...process.env,
|
||||
...options?.env,
|
||||
BUN_BE_BUN: "1",
|
||||
},
|
||||
})
|
||||
log.info("done", {
|
||||
code: result.code,
|
||||
stdout: result.stdout.toString(),
|
||||
stderr: result.stderr.toString(),
|
||||
})
|
||||
return result
|
||||
}
|
||||
|
||||
export function which() {
|
||||
return process.execPath
|
||||
}
|
||||
|
||||
export const InstallFailedError = NamedError.create(
|
||||
"BunInstallFailedError",
|
||||
z.object({
|
||||
pkg: z.string(),
|
||||
version: z.string(),
|
||||
}),
|
||||
)
|
||||
|
||||
export async function install(pkg: string, version = "latest") {
|
||||
// Use lock to ensure only one install at a time
|
||||
using _ = await Lock.write("bun-install")
|
||||
|
||||
const mod = path.join(Global.Path.cache, "node_modules", pkg)
|
||||
const pkgjsonPath = path.join(Global.Path.cache, "package.json")
|
||||
const parsed = await Filesystem.readJson<{ dependencies: Record<string, string> }>(pkgjsonPath).catch(async () => {
|
||||
const result = { dependencies: {} as Record<string, string> }
|
||||
await Filesystem.writeJson(pkgjsonPath, result)
|
||||
return result
|
||||
})
|
||||
if (!parsed.dependencies) parsed.dependencies = {} as Record<string, string>
|
||||
const dependencies = parsed.dependencies
|
||||
const modExists = await Filesystem.exists(mod)
|
||||
const cachedVersion = dependencies[pkg]
|
||||
|
||||
if (!modExists || !cachedVersion) {
|
||||
// continue to install
|
||||
} else if (version !== "latest" && cachedVersion === version) {
|
||||
return mod
|
||||
} else if (version === "latest") {
|
||||
const isOutdated = await PackageRegistry.isOutdated(pkg, cachedVersion, Global.Path.cache)
|
||||
if (!isOutdated) return mod
|
||||
log.info("Cached version is outdated, proceeding with install", { pkg, cachedVersion })
|
||||
}
|
||||
|
||||
// Build command arguments
|
||||
const args = [
|
||||
"add",
|
||||
"--force",
|
||||
"--exact",
|
||||
// TODO: get rid of this case (see: https://github.com/oven-sh/bun/issues/19936)
|
||||
...(proxied() || process.env.CI ? ["--no-cache"] : []),
|
||||
"--cwd",
|
||||
Global.Path.cache,
|
||||
pkg + "@" + version,
|
||||
]
|
||||
|
||||
// Let Bun handle registry resolution:
|
||||
// - If .npmrc files exist, Bun will use them automatically
|
||||
// - If no .npmrc files exist, Bun will default to https://registry.npmjs.org
|
||||
// - No need to pass --registry flag
|
||||
log.info("installing package using Bun's default registry resolution", {
|
||||
pkg,
|
||||
version,
|
||||
})
|
||||
|
||||
await BunProc.run(args, {
|
||||
cwd: Global.Path.cache,
|
||||
}).catch((e) => {
|
||||
throw new InstallFailedError(
|
||||
{ pkg, version },
|
||||
{
|
||||
cause: e,
|
||||
},
|
||||
)
|
||||
})
|
||||
|
||||
// Resolve actual version from installed package when using "latest"
|
||||
// This ensures subsequent starts use the cached version until explicitly updated
|
||||
let resolvedVersion = version
|
||||
if (version === "latest") {
|
||||
const installedPkg = await Filesystem.readJson<{ version?: string }>(path.join(mod, "package.json")).catch(
|
||||
() => null,
|
||||
)
|
||||
if (installedPkg?.version) {
|
||||
resolvedVersion = installedPkg.version
|
||||
}
|
||||
}
|
||||
|
||||
parsed.dependencies[pkg] = resolvedVersion
|
||||
await Filesystem.writeJson(pkgjsonPath, parsed)
|
||||
return mod
|
||||
}
|
||||
}
|
||||
@@ -1,44 +0,0 @@
|
||||
import semver from "semver"
|
||||
import { Log } from "../util/log"
|
||||
import { Process } from "../util/process"
|
||||
|
||||
export namespace PackageRegistry {
|
||||
const log = Log.create({ service: "bun" })
|
||||
|
||||
function which() {
|
||||
return process.execPath
|
||||
}
|
||||
|
||||
export async function info(pkg: string, field: string, cwd?: string): Promise<string | null> {
|
||||
const { code, stdout, stderr } = await Process.run([which(), "info", pkg, field], {
|
||||
cwd,
|
||||
env: {
|
||||
...process.env,
|
||||
BUN_BE_BUN: "1",
|
||||
},
|
||||
nothrow: true,
|
||||
})
|
||||
|
||||
if (code !== 0) {
|
||||
log.warn("bun info failed", { pkg, field, code, stderr: stderr.toString() })
|
||||
return null
|
||||
}
|
||||
|
||||
const value = stdout.toString().trim()
|
||||
if (!value) return null
|
||||
return value
|
||||
}
|
||||
|
||||
export async function isOutdated(pkg: string, cachedVersion: string, cwd?: string): Promise<boolean> {
|
||||
const latestVersion = await info(pkg, "version", cwd)
|
||||
if (!latestVersion) {
|
||||
log.warn("Failed to resolve latest version, using cached", { pkg, cachedVersion })
|
||||
return false
|
||||
}
|
||||
|
||||
const isRange = /[\s^~*xX<>|=]/.test(cachedVersion)
|
||||
if (isRange) return !semver.satisfies(latestVersion, cachedVersion)
|
||||
|
||||
return semver.lt(cachedVersion, latestVersion)
|
||||
}
|
||||
}
|
||||
@@ -1,12 +1,14 @@
|
||||
import z from "zod"
|
||||
import { Effect, Exit, Layer, PubSub, Scope, ServiceMap, Stream } from "effect"
|
||||
import { Log } from "../util/log"
|
||||
import { Instance } from "../project/instance"
|
||||
import { BusEvent } from "./bus-event"
|
||||
import { GlobalBus } from "./global"
|
||||
import { InstanceState } from "@/effect/instance-state"
|
||||
import { makeRuntime } from "@/effect/run-service"
|
||||
|
||||
export namespace Bus {
|
||||
const log = Log.create({ service: "bus" })
|
||||
type Subscription = (event: any) => void
|
||||
|
||||
export const InstanceDisposed = BusEvent.define(
|
||||
"server.instance.disposed",
|
||||
@@ -15,91 +17,168 @@ export namespace Bus {
|
||||
}),
|
||||
)
|
||||
|
||||
const state = Instance.state(
|
||||
() => {
|
||||
const subscriptions = new Map<any, Subscription[]>()
|
||||
type Payload<D extends BusEvent.Definition = BusEvent.Definition> = {
|
||||
type: D["type"]
|
||||
properties: z.infer<D["properties"]>
|
||||
}
|
||||
|
||||
return {
|
||||
subscriptions,
|
||||
type State = {
|
||||
wildcard: PubSub.PubSub<Payload>
|
||||
typed: Map<string, PubSub.PubSub<Payload>>
|
||||
}
|
||||
|
||||
export interface Interface {
|
||||
readonly publish: <D extends BusEvent.Definition>(
|
||||
def: D,
|
||||
properties: z.output<D["properties"]>,
|
||||
) => Effect.Effect<void>
|
||||
readonly subscribe: <D extends BusEvent.Definition>(def: D) => Stream.Stream<Payload<D>>
|
||||
readonly subscribeAll: () => Stream.Stream<Payload>
|
||||
readonly subscribeCallback: <D extends BusEvent.Definition>(
|
||||
def: D,
|
||||
callback: (event: Payload<D>) => unknown,
|
||||
) => Effect.Effect<() => void>
|
||||
readonly subscribeAllCallback: (callback: (event: any) => unknown) => Effect.Effect<() => void>
|
||||
}
|
||||
|
||||
export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/Bus") {}
|
||||
|
||||
export const layer = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const cache = yield* InstanceState.make<State>(
|
||||
Effect.fn("Bus.state")(function* (ctx) {
|
||||
const wildcard = yield* PubSub.unbounded<Payload>()
|
||||
const typed = new Map<string, PubSub.PubSub<Payload>>()
|
||||
|
||||
yield* Effect.addFinalizer(() =>
|
||||
Effect.gen(function* () {
|
||||
// Publish InstanceDisposed before shutting down so subscribers see it
|
||||
yield* PubSub.publish(wildcard, {
|
||||
type: InstanceDisposed.type,
|
||||
properties: { directory: ctx.directory },
|
||||
})
|
||||
yield* PubSub.shutdown(wildcard)
|
||||
for (const ps of typed.values()) {
|
||||
yield* PubSub.shutdown(ps)
|
||||
}
|
||||
}),
|
||||
)
|
||||
|
||||
return { wildcard, typed }
|
||||
}),
|
||||
)
|
||||
|
||||
function getOrCreate<D extends BusEvent.Definition>(state: State, def: D) {
|
||||
return Effect.gen(function* () {
|
||||
let ps = state.typed.get(def.type)
|
||||
if (!ps) {
|
||||
ps = yield* PubSub.unbounded<Payload>()
|
||||
state.typed.set(def.type, ps)
|
||||
}
|
||||
return ps as unknown as PubSub.PubSub<Payload<D>>
|
||||
})
|
||||
}
|
||||
},
|
||||
async (entry) => {
|
||||
const wildcard = entry.subscriptions.get("*")
|
||||
if (!wildcard) return
|
||||
const event = {
|
||||
type: InstanceDisposed.type,
|
||||
properties: {
|
||||
directory: Instance.directory,
|
||||
},
|
||||
|
||||
function publish<D extends BusEvent.Definition>(def: D, properties: z.output<D["properties"]>) {
|
||||
return Effect.gen(function* () {
|
||||
const state = yield* InstanceState.get(cache)
|
||||
const payload: Payload = { type: def.type, properties }
|
||||
log.info("publishing", { type: def.type })
|
||||
|
||||
const ps = state.typed.get(def.type)
|
||||
if (ps) yield* PubSub.publish(ps, payload)
|
||||
yield* PubSub.publish(state.wildcard, payload)
|
||||
|
||||
GlobalBus.emit("event", {
|
||||
directory: Instance.directory,
|
||||
payload,
|
||||
})
|
||||
})
|
||||
}
|
||||
for (const sub of [...wildcard]) {
|
||||
sub(event)
|
||||
|
||||
function subscribe<D extends BusEvent.Definition>(def: D): Stream.Stream<Payload<D>> {
|
||||
log.info("subscribing", { type: def.type })
|
||||
return Stream.unwrap(
|
||||
Effect.gen(function* () {
|
||||
const state = yield* InstanceState.get(cache)
|
||||
const ps = yield* getOrCreate(state, def)
|
||||
return Stream.fromPubSub(ps)
|
||||
}),
|
||||
).pipe(Stream.ensuring(Effect.sync(() => log.info("unsubscribing", { type: def.type }))))
|
||||
}
|
||||
},
|
||||
|
||||
function subscribeAll(): Stream.Stream<Payload> {
|
||||
log.info("subscribing", { type: "*" })
|
||||
return Stream.unwrap(
|
||||
Effect.gen(function* () {
|
||||
const state = yield* InstanceState.get(cache)
|
||||
return Stream.fromPubSub(state.wildcard)
|
||||
}),
|
||||
).pipe(Stream.ensuring(Effect.sync(() => log.info("unsubscribing", { type: "*" }))))
|
||||
}
|
||||
|
||||
function on<T>(pubsub: PubSub.PubSub<T>, type: string, callback: (event: T) => unknown) {
|
||||
return Effect.gen(function* () {
|
||||
log.info("subscribing", { type })
|
||||
const scope = yield* Scope.make()
|
||||
const subscription = yield* Scope.provide(scope)(PubSub.subscribe(pubsub))
|
||||
|
||||
yield* Scope.provide(scope)(
|
||||
Stream.fromSubscription(subscription).pipe(
|
||||
Stream.runForEach((msg) =>
|
||||
Effect.tryPromise({
|
||||
try: () => Promise.resolve().then(() => callback(msg)),
|
||||
catch: (cause) => {
|
||||
log.error("subscriber failed", { type, cause })
|
||||
},
|
||||
}).pipe(Effect.ignore),
|
||||
),
|
||||
Effect.forkScoped,
|
||||
),
|
||||
)
|
||||
|
||||
return () => {
|
||||
log.info("unsubscribing", { type })
|
||||
Effect.runFork(Scope.close(scope, Exit.void))
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const subscribeCallback = Effect.fn("Bus.subscribeCallback")(function* <D extends BusEvent.Definition>(
|
||||
def: D,
|
||||
callback: (event: Payload<D>) => unknown,
|
||||
) {
|
||||
const state = yield* InstanceState.get(cache)
|
||||
const ps = yield* getOrCreate(state, def)
|
||||
return yield* on(ps, def.type, callback)
|
||||
})
|
||||
|
||||
const subscribeAllCallback = Effect.fn("Bus.subscribeAllCallback")(function* (callback: (event: any) => unknown) {
|
||||
const state = yield* InstanceState.get(cache)
|
||||
return yield* on(state.wildcard, "*", callback)
|
||||
})
|
||||
|
||||
return Service.of({ publish, subscribe, subscribeAll, subscribeCallback, subscribeAllCallback })
|
||||
}),
|
||||
)
|
||||
|
||||
export async function publish<Definition extends BusEvent.Definition>(
|
||||
def: Definition,
|
||||
properties: z.output<Definition["properties"]>,
|
||||
const { runPromise, runSync } = makeRuntime(Service, layer)
|
||||
|
||||
// runSync is safe here because the subscribe chain (InstanceState.get, PubSub.subscribe,
|
||||
// Scope.make, Effect.forkScoped) is entirely synchronous. If any step becomes async, this will throw.
|
||||
export async function publish<D extends BusEvent.Definition>(def: D, properties: z.output<D["properties"]>) {
|
||||
return runPromise((svc) => svc.publish(def, properties))
|
||||
}
|
||||
|
||||
export function subscribe<D extends BusEvent.Definition>(
|
||||
def: D,
|
||||
callback: (event: { type: D["type"]; properties: z.infer<D["properties"]> }) => unknown,
|
||||
) {
|
||||
const payload = {
|
||||
type: def.type,
|
||||
properties,
|
||||
}
|
||||
log.info("publishing", {
|
||||
type: def.type,
|
||||
})
|
||||
const pending = []
|
||||
for (const key of [def.type, "*"]) {
|
||||
const match = [...(state().subscriptions.get(key) ?? [])]
|
||||
for (const sub of match) {
|
||||
pending.push(sub(payload))
|
||||
}
|
||||
}
|
||||
GlobalBus.emit("event", {
|
||||
directory: Instance.directory,
|
||||
payload,
|
||||
})
|
||||
return Promise.all(pending)
|
||||
return runSync((svc) => svc.subscribeCallback(def, callback))
|
||||
}
|
||||
|
||||
export function subscribe<Definition extends BusEvent.Definition>(
|
||||
def: Definition,
|
||||
callback: (event: { type: Definition["type"]; properties: z.infer<Definition["properties"]> }) => void,
|
||||
) {
|
||||
return raw(def.type, callback)
|
||||
}
|
||||
|
||||
export function once<Definition extends BusEvent.Definition>(
|
||||
def: Definition,
|
||||
callback: (event: {
|
||||
type: Definition["type"]
|
||||
properties: z.infer<Definition["properties"]>
|
||||
}) => "done" | undefined,
|
||||
) {
|
||||
const unsub = subscribe(def, (event) => {
|
||||
if (callback(event)) unsub()
|
||||
})
|
||||
}
|
||||
|
||||
export function subscribeAll(callback: (event: any) => void) {
|
||||
return raw("*", callback)
|
||||
}
|
||||
|
||||
function raw(type: string, callback: (event: any) => void) {
|
||||
log.info("subscribing", { type })
|
||||
const subscriptions = state().subscriptions
|
||||
let match = subscriptions.get(type) ?? []
|
||||
match.push(callback)
|
||||
subscriptions.set(type, match)
|
||||
|
||||
return () => {
|
||||
log.info("unsubscribing", { type })
|
||||
const match = subscriptions.get(type)
|
||||
if (!match) return
|
||||
const index = match.indexOf(callback)
|
||||
if (index === -1) return
|
||||
match.splice(index, 1)
|
||||
}
|
||||
export function subscribeAll(callback: (event: any) => unknown) {
|
||||
return runSync((svc) => svc.subscribeAllCallback(callback))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,6 +10,26 @@ const openBrowser = (url: string) => Effect.promise(() => open(url).catch(() =>
|
||||
|
||||
const println = (msg: string) => Effect.sync(() => UI.println(msg))
|
||||
|
||||
const dim = (value: string) => UI.Style.TEXT_DIM + value + UI.Style.TEXT_NORMAL
|
||||
|
||||
const activeSuffix = (isActive: boolean) => (isActive ? dim(" (active)") : "")
|
||||
|
||||
export const formatAccountLabel = (account: { email: string; url: string }, isActive: boolean) =>
|
||||
`${account.email} ${dim(account.url)}${activeSuffix(isActive)}`
|
||||
|
||||
const formatOrgChoiceLabel = (account: { email: string }, org: { name: string }, isActive: boolean) =>
|
||||
`${org.name} (${account.email})${activeSuffix(isActive)}`
|
||||
|
||||
export const formatOrgLine = (
|
||||
account: { email: string; url: string },
|
||||
org: { id: string; name: string },
|
||||
isActive: boolean,
|
||||
) => {
|
||||
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
|
||||
return ` ${dot} ${name} ${dim(account.email)} ${dim(account.url)} ${dim(org.id)}`
|
||||
}
|
||||
|
||||
const isActiveOrgChoice = (
|
||||
active: Option.Option<{ id: AccountID; active_org_id: OrgID | null }>,
|
||||
choice: { accountID: AccountID; orgID: OrgID },
|
||||
@@ -76,10 +96,9 @@ const logoutEffect = Effect.fn("logout")(function* (email?: string) {
|
||||
|
||||
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}`,
|
||||
label: formatAccountLabel(a, isActive),
|
||||
}
|
||||
})
|
||||
|
||||
@@ -109,9 +128,7 @@ const switchEffect = Effect.fn("switch")(function* () {
|
||||
const isActive = isActiveOrgChoice(active, { accountID: group.account.id, orgID: 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})`,
|
||||
label: formatOrgChoiceLabel(group.account, org, isActive),
|
||||
}
|
||||
}),
|
||||
)
|
||||
@@ -139,15 +156,21 @@ const orgsEffect = Effect.fn("orgs")(function* () {
|
||||
for (const group of groups) {
|
||||
for (const org of group.orgs) {
|
||||
const isActive = isActiveOrgChoice(active, { accountID: group.account.id, orgID: 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}`)
|
||||
yield* println(formatOrgLine(group.account, org, isActive))
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const openEffect = Effect.fn("open")(function* () {
|
||||
const service = yield* Account.Service
|
||||
const active = yield* service.active()
|
||||
if (Option.isNone(active)) return yield* println("No active account")
|
||||
|
||||
const url = active.value.url
|
||||
yield* openBrowser(url)
|
||||
yield* Prompt.outro("Opened " + url)
|
||||
})
|
||||
|
||||
export const LoginCommand = cmd({
|
||||
command: "login <url>",
|
||||
describe: false,
|
||||
@@ -195,6 +218,15 @@ export const OrgsCommand = cmd({
|
||||
},
|
||||
})
|
||||
|
||||
export const OpenCommand = cmd({
|
||||
command: "open",
|
||||
describe: false,
|
||||
async handler() {
|
||||
UI.empty()
|
||||
await Account.runPromise((_svc) => openEffect())
|
||||
},
|
||||
})
|
||||
|
||||
export const ConsoleCommand = cmd({
|
||||
command: "console",
|
||||
describe: false,
|
||||
@@ -216,6 +248,10 @@ export const ConsoleCommand = cmd({
|
||||
...OrgsCommand,
|
||||
describe: "list orgs",
|
||||
})
|
||||
.command({
|
||||
...OpenCommand,
|
||||
describe: "open active console account",
|
||||
})
|
||||
.demandCommand(),
|
||||
async handler() {},
|
||||
})
|
||||
|
||||
@@ -370,6 +370,11 @@ export const RunCommand = cmd({
|
||||
action: "deny",
|
||||
pattern: "*",
|
||||
},
|
||||
{
|
||||
permission: "edit",
|
||||
action: "allow",
|
||||
pattern: "*",
|
||||
},
|
||||
]
|
||||
|
||||
function title() {
|
||||
|
||||
@@ -185,7 +185,7 @@ export function tui(input: {
|
||||
targetFps: 60,
|
||||
gatherStats: false,
|
||||
exitOnCtrlC: false,
|
||||
useKittyKeyboard: {},
|
||||
useKittyKeyboard: { events: process.platform === "win32" },
|
||||
autoFocus: false,
|
||||
openConsoleOnError: false,
|
||||
consoleOptions: {
|
||||
@@ -482,6 +482,7 @@ function App() {
|
||||
{
|
||||
title: "Toggle MCPs",
|
||||
value: "mcp.list",
|
||||
search: "toggle mcps",
|
||||
category: "Agent",
|
||||
slash: {
|
||||
name: "mcps",
|
||||
@@ -557,8 +558,9 @@ function App() {
|
||||
category: "System",
|
||||
},
|
||||
{
|
||||
title: "Toggle appearance",
|
||||
title: mode() === "dark" ? "Light mode" : "Dark mode",
|
||||
value: "theme.switch_mode",
|
||||
search: "toggle appearance",
|
||||
onSelect: (dialog) => {
|
||||
setMode(mode() === "dark" ? "light" : "dark")
|
||||
dialog.clear()
|
||||
@@ -597,6 +599,7 @@ function App() {
|
||||
},
|
||||
{
|
||||
title: "Toggle debug panel",
|
||||
search: "toggle debug",
|
||||
category: "System",
|
||||
value: "app.debug",
|
||||
onSelect: (dialog) => {
|
||||
@@ -606,6 +609,7 @@ function App() {
|
||||
},
|
||||
{
|
||||
title: "Toggle console",
|
||||
search: "toggle console",
|
||||
category: "System",
|
||||
value: "app.console",
|
||||
onSelect: (dialog) => {
|
||||
@@ -646,6 +650,7 @@ function App() {
|
||||
{
|
||||
title: terminalTitleEnabled() ? "Disable terminal title" : "Enable terminal title",
|
||||
value: "terminal.title.toggle",
|
||||
search: "toggle terminal title",
|
||||
keybind: "terminal_title_toggle",
|
||||
category: "System",
|
||||
onSelect: (dialog) => {
|
||||
@@ -661,6 +666,7 @@ function App() {
|
||||
{
|
||||
title: kv.get("animations_enabled", true) ? "Disable animations" : "Enable animations",
|
||||
value: "app.toggle.animations",
|
||||
search: "toggle animations",
|
||||
category: "System",
|
||||
onSelect: (dialog) => {
|
||||
kv.set("animations_enabled", !kv.get("animations_enabled", true))
|
||||
@@ -670,6 +676,7 @@ function App() {
|
||||
{
|
||||
title: kv.get("diff_wrap_mode", "word") === "word" ? "Disable diff wrapping" : "Enable diff wrapping",
|
||||
value: "app.toggle.diffwrap",
|
||||
search: "toggle diff wrapping",
|
||||
category: "System",
|
||||
onSelect: (dialog) => {
|
||||
const current = kv.get("diff_wrap_mode", "word")
|
||||
|
||||
@@ -18,7 +18,7 @@ import { usePromptStash } from "./stash"
|
||||
import { DialogStash } from "../dialog-stash"
|
||||
import { type AutocompleteRef, Autocomplete } from "./autocomplete"
|
||||
import { useCommandDialog } from "../dialog-command"
|
||||
import { useRenderer } from "@opentui/solid"
|
||||
import { useKeyboard, useRenderer } from "@opentui/solid"
|
||||
import { Editor } from "@tui/util/editor"
|
||||
import { useExit } from "../../context/exit"
|
||||
import { Clipboard } from "../../util/clipboard"
|
||||
@@ -79,6 +79,7 @@ export function Prompt(props: PromptProps) {
|
||||
const renderer = useRenderer()
|
||||
const { theme, syntax } = useTheme()
|
||||
const kv = useKV()
|
||||
const [autoaccept, setAutoaccept] = kv.signal<"none" | "edit">("permission_auto_accept", "edit")
|
||||
|
||||
function promptModelWarning() {
|
||||
toast.show({
|
||||
@@ -172,6 +173,17 @@ export function Prompt(props: PromptProps) {
|
||||
|
||||
command.register(() => {
|
||||
return [
|
||||
{
|
||||
title: autoaccept() === "none" ? "Enable autoedit" : "Disable autoedit",
|
||||
value: "permission.auto_accept.toggle",
|
||||
search: "toggle permissions",
|
||||
keybind: "permission_auto_accept_toggle",
|
||||
category: "Agent",
|
||||
onSelect: (dialog) => {
|
||||
setAutoaccept(() => (autoaccept() === "none" ? "edit" : "none"))
|
||||
dialog.clear()
|
||||
},
|
||||
},
|
||||
{
|
||||
title: "Clear prompt",
|
||||
value: "prompt.clear",
|
||||
@@ -356,6 +368,20 @@ export function Prompt(props: PromptProps) {
|
||||
]
|
||||
})
|
||||
|
||||
// Windows Terminal 1.25+ handles Ctrl+V on keydown when kitty events are
|
||||
// enabled, but still reports the kitty key-release event. Probe on release.
|
||||
if (process.platform === "win32") {
|
||||
useKeyboard(
|
||||
(evt) => {
|
||||
if (!input.focused) return
|
||||
if (evt.name === "v" && evt.ctrl && evt.eventType === "release") {
|
||||
command.trigger("prompt.paste")
|
||||
}
|
||||
},
|
||||
{ release: true },
|
||||
)
|
||||
}
|
||||
|
||||
const ref: PromptRef = {
|
||||
get focused() {
|
||||
return input.focused
|
||||
@@ -850,10 +876,9 @@ export function Prompt(props: PromptProps) {
|
||||
e.preventDefault()
|
||||
return
|
||||
}
|
||||
// Handle clipboard paste (Ctrl+V) - check for images first on Windows
|
||||
// This is needed because Windows terminal doesn't properly send image data
|
||||
// through bracketed paste, so we need to intercept the keypress and
|
||||
// directly read from clipboard before the terminal handles it
|
||||
// Check clipboard for images before terminal-handled paste runs.
|
||||
// This helps terminals that forward Ctrl+V to the app; Windows
|
||||
// Terminal 1.25+ usually handles Ctrl+V before this path.
|
||||
if (keybind.match("input_paste", e)) {
|
||||
const content = await Clipboard.read()
|
||||
if (content?.mime.startsWith("image/")) {
|
||||
@@ -936,6 +961,9 @@ export function Prompt(props: PromptProps) {
|
||||
// Replace CRLF first, then any remaining CR
|
||||
const normalizedText = decodePasteBytes(event.bytes).replace(/\r\n/g, "\n").replace(/\r/g, "\n")
|
||||
const pastedContent = normalizedText.trim()
|
||||
|
||||
// Windows Terminal <1.25 can surface image-only clipboard as an
|
||||
// empty bracketed paste. Windows Terminal 1.25+ does not.
|
||||
if (!pastedContent) {
|
||||
command.trigger("prompt.paste")
|
||||
return
|
||||
@@ -1010,23 +1038,30 @@ export function Prompt(props: PromptProps) {
|
||||
cursorColor={theme.text}
|
||||
syntaxStyle={syntax()}
|
||||
/>
|
||||
<box flexDirection="row" flexShrink={0} paddingTop={1} gap={1}>
|
||||
<text fg={highlight()}>
|
||||
{store.mode === "shell" ? "Shell" : Locale.titlecase(local.agent.current().name)}{" "}
|
||||
</text>
|
||||
<Show when={store.mode === "normal"}>
|
||||
<box flexDirection="row" gap={1}>
|
||||
<text flexShrink={0} fg={keybind.leader ? theme.textMuted : theme.text}>
|
||||
{local.model.parsed().model}
|
||||
</text>
|
||||
<text fg={theme.textMuted}>{local.model.parsed().provider}</text>
|
||||
<Show when={showVariant()}>
|
||||
<text fg={theme.textMuted}>·</text>
|
||||
<text>
|
||||
<span style={{ fg: theme.warning, bold: true }}>{local.model.variant.current()}</span>
|
||||
<box flexDirection="row" flexShrink={0} paddingTop={1} gap={1} justifyContent="space-between">
|
||||
<box flexDirection="row" gap={1}>
|
||||
<text fg={highlight()}>
|
||||
{store.mode === "shell" ? "Shell" : Locale.titlecase(local.agent.current().name)}{" "}
|
||||
</text>
|
||||
<Show when={store.mode === "normal"}>
|
||||
<box flexDirection="row" gap={1}>
|
||||
<text flexShrink={0} fg={keybind.leader ? theme.textMuted : theme.text}>
|
||||
{local.model.parsed().model}
|
||||
</text>
|
||||
</Show>
|
||||
</box>
|
||||
<text fg={theme.textMuted}>{local.model.parsed().provider}</text>
|
||||
<Show when={showVariant()}>
|
||||
<text fg={theme.textMuted}>·</text>
|
||||
<text>
|
||||
<span style={{ fg: theme.warning, bold: true }}>{local.model.variant.current()}</span>
|
||||
</text>
|
||||
</Show>
|
||||
</box>
|
||||
</Show>
|
||||
</box>
|
||||
<Show when={autoaccept() === "edit"}>
|
||||
<text>
|
||||
<span style={{ fg: theme.warning }}>autoedit</span>
|
||||
</text>
|
||||
</Show>
|
||||
</box>
|
||||
</box>
|
||||
|
||||
@@ -53,6 +53,7 @@ export const { use: useExit, provider: ExitProvider } = createSimpleContext({
|
||||
message: store,
|
||||
},
|
||||
)
|
||||
process.on("SIGHUP", () => exit())
|
||||
return exit
|
||||
},
|
||||
})
|
||||
|
||||
@@ -25,6 +25,7 @@ import { createSimpleContext } from "./helper"
|
||||
import type { Snapshot } from "@/snapshot"
|
||||
import { useExit } from "./exit"
|
||||
import { useArgs } from "./args"
|
||||
import { useKV } from "./kv"
|
||||
import { batch, onMount } from "solid-js"
|
||||
import { Log } from "@/util/log"
|
||||
import type { Path } from "@opencode-ai/sdk"
|
||||
@@ -106,6 +107,8 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
|
||||
})
|
||||
|
||||
const sdk = useSDK()
|
||||
const kv = useKV()
|
||||
const [autoaccept] = kv.signal<"none" | "edit">("permission_auto_accept", "edit")
|
||||
|
||||
async function syncWorkspaces() {
|
||||
const result = await sdk.client.experimental.workspace.list().catch(() => undefined)
|
||||
@@ -136,6 +139,13 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
|
||||
|
||||
case "permission.asked": {
|
||||
const request = event.properties
|
||||
if (autoaccept() === "edit" && request.permission === "edit") {
|
||||
sdk.client.permission.reply({
|
||||
reply: "once",
|
||||
requestID: request.id,
|
||||
})
|
||||
break
|
||||
}
|
||||
const requests = store.permission[request.sessionID]
|
||||
if (!requests) {
|
||||
setStore("permission", request.sessionID, [request])
|
||||
@@ -451,6 +461,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
|
||||
get ready() {
|
||||
return store.status !== "loading"
|
||||
},
|
||||
|
||||
session: {
|
||||
get(sessionID: string) {
|
||||
const match = Binary.search(store.session, sessionID, (s) => s.id)
|
||||
|
||||
@@ -428,7 +428,7 @@ export function tint(base: RGBA, overlay: RGBA, alpha: number): RGBA {
|
||||
function generateSystem(colors: TerminalColors, mode: "dark" | "light"): ThemeJson {
|
||||
const bg = RGBA.fromHex(colors.defaultBackground ?? colors.palette[0]!)
|
||||
const fg = RGBA.fromHex(colors.defaultForeground ?? colors.palette[7]!)
|
||||
const transparent = RGBA.fromInts(0, 0, 0, 0)
|
||||
const transparent = RGBA.fromValues(bg.r, bg.g, bg.b, 0)
|
||||
const isDark = mode == "dark"
|
||||
|
||||
const col = (i: number) => {
|
||||
|
||||
@@ -47,6 +47,7 @@ export function Home() {
|
||||
{
|
||||
title: tipsHidden() ? "Show tips" : "Hide tips",
|
||||
value: "tips.toggle",
|
||||
search: "toggle tips",
|
||||
keybind: "tips_toggle",
|
||||
category: "System",
|
||||
onSelect: (dialog) => {
|
||||
|
||||
@@ -568,6 +568,7 @@ export function Session() {
|
||||
{
|
||||
title: sidebarVisible() ? "Hide sidebar" : "Show sidebar",
|
||||
value: "session.sidebar.toggle",
|
||||
search: "toggle sidebar",
|
||||
keybind: "sidebar_toggle",
|
||||
category: "Session",
|
||||
onSelect: (dialog) => {
|
||||
@@ -582,6 +583,7 @@ export function Session() {
|
||||
{
|
||||
title: conceal() ? "Disable code concealment" : "Enable code concealment",
|
||||
value: "session.toggle.conceal",
|
||||
search: "toggle code concealment",
|
||||
keybind: "messages_toggle_conceal" as any,
|
||||
category: "Session",
|
||||
onSelect: (dialog) => {
|
||||
@@ -592,6 +594,7 @@ export function Session() {
|
||||
{
|
||||
title: showTimestamps() ? "Hide timestamps" : "Show timestamps",
|
||||
value: "session.toggle.timestamps",
|
||||
search: "toggle timestamps",
|
||||
category: "Session",
|
||||
slash: {
|
||||
name: "timestamps",
|
||||
@@ -605,6 +608,7 @@ export function Session() {
|
||||
{
|
||||
title: showThinking() ? "Hide thinking" : "Show thinking",
|
||||
value: "session.toggle.thinking",
|
||||
search: "toggle thinking",
|
||||
keybind: "display_thinking",
|
||||
category: "Session",
|
||||
slash: {
|
||||
@@ -619,6 +623,7 @@ export function Session() {
|
||||
{
|
||||
title: showDetails() ? "Hide tool details" : "Show tool details",
|
||||
value: "session.toggle.actions",
|
||||
search: "toggle tool details",
|
||||
keybind: "tool_details",
|
||||
category: "Session",
|
||||
onSelect: (dialog) => {
|
||||
@@ -627,8 +632,9 @@ export function Session() {
|
||||
},
|
||||
},
|
||||
{
|
||||
title: "Toggle session scrollbar",
|
||||
title: showScrollbar() ? "Hide session scrollbar" : "Show session scrollbar",
|
||||
value: "session.toggle.scrollbar",
|
||||
search: "toggle session scrollbar",
|
||||
keybind: "scrollbar_toggle",
|
||||
category: "Session",
|
||||
onSelect: (dialog) => {
|
||||
@@ -1465,6 +1471,8 @@ function TextPart(props: { last: boolean; part: TextPart; message: AssistantMess
|
||||
streaming={true}
|
||||
content={props.part.text.trim()}
|
||||
conceal={ctx.conceal()}
|
||||
fg={theme.markdownText}
|
||||
bg={theme.background}
|
||||
/>
|
||||
</Match>
|
||||
<Match when={!Flag.OPENCODE_EXPERIMENTAL_MARKDOWN}>
|
||||
|
||||
@@ -34,6 +34,7 @@ export interface DialogSelectOption<T = any> {
|
||||
title: string
|
||||
value: T
|
||||
description?: string
|
||||
search?: string
|
||||
footer?: JSX.Element | string
|
||||
category?: string
|
||||
disabled?: boolean
|
||||
@@ -85,8 +86,8 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
|
||||
// users typically search by the item name, and not its category.
|
||||
const result = fuzzysort
|
||||
.go(needle, options, {
|
||||
keys: ["title", "category"],
|
||||
scoreFn: (r) => r[0].score * 2 + r[1].score,
|
||||
keys: ["title", "category", "search"],
|
||||
scoreFn: (r) => r[0].score * 2 + r[1].score + r[2].score,
|
||||
})
|
||||
.map((x) => x.obj)
|
||||
|
||||
|
||||
@@ -28,6 +28,14 @@ export namespace Clipboard {
|
||||
mime: string
|
||||
}
|
||||
|
||||
// Checks clipboard for images first, then falls back to text.
|
||||
//
|
||||
// On Windows prompt/ can call this from multiple paste signals because
|
||||
// terminals surface image paste differently:
|
||||
// 1. A forwarded Ctrl+V keypress
|
||||
// 2. An empty bracketed-paste hint for image-only clipboard in Windows
|
||||
// Terminal <1.25
|
||||
// 3. A kitty Ctrl+V key-release fallback for Windows Terminal 1.25+
|
||||
export async function read(): Promise<Content | undefined> {
|
||||
const os = platform()
|
||||
|
||||
@@ -58,6 +66,8 @@ export namespace Clipboard {
|
||||
}
|
||||
}
|
||||
|
||||
// Windows/WSL: probe clipboard for images via PowerShell.
|
||||
// Bracketed paste can't carry image data so we read it directly.
|
||||
if (os === "win32" || release().includes("WSL")) {
|
||||
const script =
|
||||
"Add-Type -AssemblyName System.Windows.Forms; $img = [System.Windows.Forms.Clipboard]::GetImage(); if ($img) { $ms = New-Object System.IO.MemoryStream; $img.Save($ms, [System.Drawing.Imaging.ImageFormat]::Png); [System.Convert]::ToBase64String($ms.ToArray()) }"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { BusEvent } from "@/bus/bus-event"
|
||||
import { InstanceState } from "@/effect/instance-state"
|
||||
import { makeRunPromise } from "@/effect/run-service"
|
||||
import { makeRuntime } from "@/effect/run-service"
|
||||
import { SessionID, MessageID } from "@/session/schema"
|
||||
import { Effect, Layer, ServiceMap } from "effect"
|
||||
import z from "zod"
|
||||
@@ -173,7 +173,7 @@ export namespace Command {
|
||||
}),
|
||||
)
|
||||
|
||||
const runPromise = makeRunPromise(Service, layer)
|
||||
const { runPromise } = makeRuntime(Service, layer)
|
||||
|
||||
export async function get(name: string) {
|
||||
return runPromise((svc) => svc.get(name))
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Log } from "../util/log"
|
||||
import path from "path"
|
||||
import { pathToFileURL, fileURLToPath } from "url"
|
||||
import { pathToFileURL } from "url"
|
||||
import { createRequire } from "module"
|
||||
import os from "os"
|
||||
import z from "zod"
|
||||
@@ -22,7 +22,6 @@ import {
|
||||
} from "jsonc-parser"
|
||||
import { Instance } from "../project/instance"
|
||||
import { LSPServer } from "../lsp/server"
|
||||
import { BunProc } from "@/bun"
|
||||
import { Installation } from "@/installation"
|
||||
import { ConfigMarkdown } from "./markdown"
|
||||
import { constants, existsSync } from "fs"
|
||||
@@ -30,14 +29,11 @@ import { Bus } from "@/bus"
|
||||
import { GlobalBus } from "@/bus/global"
|
||||
import { Event } from "../server/event"
|
||||
import { Glob } from "../util/glob"
|
||||
import { PackageRegistry } from "@/bun/registry"
|
||||
import { proxied } from "@/util/proxied"
|
||||
import { iife } from "@/util/iife"
|
||||
import { Account } from "@/account"
|
||||
import { ConfigPaths } from "./paths"
|
||||
import { Filesystem } from "@/util/filesystem"
|
||||
import { Process } from "@/util/process"
|
||||
import { Lock } from "@/util/lock"
|
||||
import { Npm } from "@/npm"
|
||||
|
||||
export namespace Config {
|
||||
const ModelId = z.string().meta({ $ref: "https://models.dev/model-schema.json#/$defs/Model" })
|
||||
@@ -154,8 +150,7 @@ export namespace Config {
|
||||
|
||||
deps.push(
|
||||
iife(async () => {
|
||||
const shouldInstall = await needsInstall(dir)
|
||||
if (shouldInstall) await installDependencies(dir)
|
||||
await installDependencies(dir)
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -271,6 +266,10 @@ export namespace Config {
|
||||
}
|
||||
|
||||
export async function installDependencies(dir: string) {
|
||||
if (!(await isWritable(dir))) {
|
||||
log.info("config dir is not writable, skipping dependency install", { dir })
|
||||
return
|
||||
}
|
||||
const pkg = path.join(dir, "package.json")
|
||||
const targetVersion = Installation.isLocal() ? "*" : Installation.VERSION
|
||||
|
||||
@@ -284,43 +283,15 @@ export namespace Config {
|
||||
await Filesystem.writeJson(pkg, json)
|
||||
|
||||
const gitignore = path.join(dir, ".gitignore")
|
||||
const hasGitIgnore = await Filesystem.exists(gitignore)
|
||||
if (!hasGitIgnore)
|
||||
await Filesystem.write(gitignore, ["node_modules", "package.json", "bun.lock", ".gitignore"].join("\n"))
|
||||
if (!(await Filesystem.exists(gitignore)))
|
||||
await Filesystem.write(
|
||||
gitignore,
|
||||
["node_modules", "plans", "package.json", "bun.lock", ".gitignore", "package-lock.json"].join("\n"),
|
||||
)
|
||||
|
||||
// Install any additional dependencies defined in the package.json
|
||||
// This allows local plugins and custom tools to use external packages
|
||||
using _ = await Lock.write("bun-install")
|
||||
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) => {
|
||||
if (err instanceof Process.RunFailedError) {
|
||||
const detail = {
|
||||
dir,
|
||||
cmd: err.cmd,
|
||||
code: err.code,
|
||||
stdout: err.stdout.toString(),
|
||||
stderr: err.stderr.toString(),
|
||||
}
|
||||
if (Flag.OPENCODE_STRICT_CONFIG_DEPS) {
|
||||
log.error("failed to install dependencies", detail)
|
||||
throw err
|
||||
}
|
||||
log.warn("failed to install dependencies", detail)
|
||||
return
|
||||
}
|
||||
|
||||
if (Flag.OPENCODE_STRICT_CONFIG_DEPS) {
|
||||
log.error("failed to install dependencies", { dir, error: err })
|
||||
throw err
|
||||
}
|
||||
log.warn("failed to install dependencies", { dir, error: err })
|
||||
})
|
||||
await Npm.install(dir)
|
||||
}
|
||||
|
||||
async function isWritable(dir: string) {
|
||||
@@ -332,41 +303,6 @@ export namespace Config {
|
||||
}
|
||||
}
|
||||
|
||||
export async function needsInstall(dir: string) {
|
||||
// Some config dirs may be read-only.
|
||||
// Installing deps there will fail; skip installation in that case.
|
||||
const writable = await isWritable(dir)
|
||||
if (!writable) {
|
||||
log.debug("config dir is not writable, skipping dependency install", { dir })
|
||||
return false
|
||||
}
|
||||
|
||||
const nodeModules = path.join(dir, "node_modules")
|
||||
if (!existsSync(nodeModules)) return true
|
||||
|
||||
const pkg = path.join(dir, "package.json")
|
||||
const pkgExists = await Filesystem.exists(pkg)
|
||||
if (!pkgExists) return true
|
||||
|
||||
const parsed = await Filesystem.readJson<{ dependencies?: Record<string, string> }>(pkg).catch(() => null)
|
||||
const dependencies = parsed?.dependencies ?? {}
|
||||
const depVersion = dependencies["@opencode-ai/plugin"]
|
||||
if (!depVersion) return true
|
||||
|
||||
const targetVersion = Installation.isLocal() ? "latest" : Installation.VERSION
|
||||
if (targetVersion === "latest") {
|
||||
const isOutdated = await PackageRegistry.isOutdated("@opencode-ai/plugin", depVersion, dir)
|
||||
if (!isOutdated) return false
|
||||
log.info("Cached version is outdated, proceeding with install", {
|
||||
pkg: "@opencode-ai/plugin",
|
||||
cachedVersion: depVersion,
|
||||
})
|
||||
return true
|
||||
}
|
||||
if (depVersion === targetVersion) return false
|
||||
return true
|
||||
}
|
||||
|
||||
function rel(item: string, patterns: string[]) {
|
||||
const normalizedItem = item.replaceAll("\\", "/")
|
||||
for (const pattern of patterns) {
|
||||
@@ -858,7 +794,12 @@ export namespace Config {
|
||||
command_list: z.string().optional().default("ctrl+p").describe("List available commands"),
|
||||
agent_list: z.string().optional().default("<leader>a").describe("List agents"),
|
||||
agent_cycle: z.string().optional().default("tab").describe("Next agent"),
|
||||
agent_cycle_reverse: z.string().optional().default("shift+tab").describe("Previous agent"),
|
||||
agent_cycle_reverse: z.string().optional().default("none").describe("Previous agent"),
|
||||
permission_auto_accept_toggle: z
|
||||
.string()
|
||||
.optional()
|
||||
.default("shift+tab")
|
||||
.describe("Toggle auto-accept mode for permissions"),
|
||||
variant_cycle: z.string().optional().default("ctrl+t").describe("Cycle model variants"),
|
||||
input_clear: z.string().optional().default("ctrl+c").describe("Clear input field"),
|
||||
input_paste: z.string().optional().default("ctrl+v").describe("Paste from clipboard"),
|
||||
|
||||
@@ -22,12 +22,11 @@ export const WorktreeAdaptor: Adaptor = {
|
||||
},
|
||||
async create(info) {
|
||||
const config = Config.parse(info)
|
||||
const bootstrap = await Worktree.createFromInfo({
|
||||
await Worktree.createFromInfo({
|
||||
name: config.name,
|
||||
directory: config.directory,
|
||||
branch: config.branch,
|
||||
})
|
||||
return bootstrap()
|
||||
},
|
||||
async remove(info) {
|
||||
const config = Config.parse(info)
|
||||
|
||||
464
packages/opencode/src/effect/cross-spawn-spawner.ts
Normal file
464
packages/opencode/src/effect/cross-spawn-spawner.ts
Normal file
@@ -0,0 +1,464 @@
|
||||
import type * as Arr from "effect/Array"
|
||||
import { NodeSink, NodeStream } from "@effect/platform-node"
|
||||
import * as Deferred from "effect/Deferred"
|
||||
import * as Effect from "effect/Effect"
|
||||
import * as Exit from "effect/Exit"
|
||||
import * as FileSystem from "effect/FileSystem"
|
||||
import * as Layer from "effect/Layer"
|
||||
import * as Path from "effect/Path"
|
||||
import * as PlatformError from "effect/PlatformError"
|
||||
import * as Predicate from "effect/Predicate"
|
||||
import type * as Scope from "effect/Scope"
|
||||
import * as Sink from "effect/Sink"
|
||||
import * as Stream from "effect/Stream"
|
||||
import * as ChildProcess from "effect/unstable/process/ChildProcess"
|
||||
import type { ChildProcessHandle } from "effect/unstable/process/ChildProcessSpawner"
|
||||
import {
|
||||
ChildProcessSpawner,
|
||||
ExitCode,
|
||||
make as makeSpawner,
|
||||
makeHandle,
|
||||
ProcessId,
|
||||
} from "effect/unstable/process/ChildProcessSpawner"
|
||||
import * as NodeChildProcess from "node:child_process"
|
||||
import { PassThrough } from "node:stream"
|
||||
import launch from "cross-spawn"
|
||||
|
||||
const toError = (err: unknown): Error => (err instanceof globalThis.Error ? err : new globalThis.Error(String(err)))
|
||||
|
||||
const toTag = (err: NodeJS.ErrnoException): PlatformError.SystemErrorTag => {
|
||||
switch (err.code) {
|
||||
case "ENOENT":
|
||||
return "NotFound"
|
||||
case "EACCES":
|
||||
return "PermissionDenied"
|
||||
case "EEXIST":
|
||||
return "AlreadyExists"
|
||||
case "EISDIR":
|
||||
return "BadResource"
|
||||
case "ENOTDIR":
|
||||
return "BadResource"
|
||||
case "EBUSY":
|
||||
return "Busy"
|
||||
case "ELOOP":
|
||||
return "BadResource"
|
||||
default:
|
||||
return "Unknown"
|
||||
}
|
||||
}
|
||||
|
||||
const flatten = (command: ChildProcess.Command) => {
|
||||
const commands: Array<ChildProcess.StandardCommand> = []
|
||||
const opts: Array<ChildProcess.PipeOptions> = []
|
||||
|
||||
const walk = (cmd: ChildProcess.Command): void => {
|
||||
switch (cmd._tag) {
|
||||
case "StandardCommand":
|
||||
commands.push(cmd)
|
||||
return
|
||||
case "PipedCommand":
|
||||
walk(cmd.left)
|
||||
opts.push(cmd.options)
|
||||
walk(cmd.right)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
walk(command)
|
||||
if (commands.length === 0) throw new Error("flatten produced empty commands array")
|
||||
const [head, ...tail] = commands
|
||||
return {
|
||||
commands: [head, ...tail] as Arr.NonEmptyReadonlyArray<ChildProcess.StandardCommand>,
|
||||
opts,
|
||||
}
|
||||
}
|
||||
|
||||
const toPlatformError = (
|
||||
method: string,
|
||||
err: NodeJS.ErrnoException,
|
||||
command: ChildProcess.Command,
|
||||
): PlatformError.PlatformError => {
|
||||
const cmd = flatten(command)
|
||||
.commands.map((x) => `${x.command} ${x.args.join(" ")}`)
|
||||
.join(" | ")
|
||||
return PlatformError.systemError({
|
||||
_tag: toTag(err),
|
||||
module: "ChildProcess",
|
||||
method,
|
||||
pathOrDescriptor: cmd,
|
||||
syscall: err.syscall,
|
||||
cause: err,
|
||||
})
|
||||
}
|
||||
|
||||
type ExitSignal = Deferred.Deferred<readonly [code: number | null, signal: NodeJS.Signals | null]>
|
||||
|
||||
export const make = Effect.gen(function* () {
|
||||
const fs = yield* FileSystem.FileSystem
|
||||
const path = yield* Path.Path
|
||||
|
||||
const cwd = Effect.fnUntraced(function* (opts: ChildProcess.CommandOptions) {
|
||||
if (Predicate.isUndefined(opts.cwd)) return undefined
|
||||
yield* fs.access(opts.cwd)
|
||||
return path.resolve(opts.cwd)
|
||||
})
|
||||
|
||||
const env = (opts: ChildProcess.CommandOptions) =>
|
||||
opts.extendEnv ? { ...globalThis.process.env, ...opts.env } : opts.env
|
||||
|
||||
const input = (x: ChildProcess.CommandInput | undefined): NodeChildProcess.IOType | undefined =>
|
||||
Stream.isStream(x) ? "pipe" : x
|
||||
|
||||
const output = (x: ChildProcess.CommandOutput | undefined): NodeChildProcess.IOType | undefined =>
|
||||
Sink.isSink(x) ? "pipe" : x
|
||||
|
||||
const stdin = (opts: ChildProcess.CommandOptions): ChildProcess.StdinConfig => {
|
||||
const cfg: ChildProcess.StdinConfig = { stream: "pipe", encoding: "utf-8", endOnDone: true }
|
||||
if (Predicate.isUndefined(opts.stdin)) return cfg
|
||||
if (typeof opts.stdin === "string") return { ...cfg, stream: opts.stdin }
|
||||
if (Stream.isStream(opts.stdin)) return { ...cfg, stream: opts.stdin }
|
||||
return {
|
||||
stream: opts.stdin.stream,
|
||||
encoding: opts.stdin.encoding ?? cfg.encoding,
|
||||
endOnDone: opts.stdin.endOnDone ?? cfg.endOnDone,
|
||||
}
|
||||
}
|
||||
|
||||
const stdio = (opts: ChildProcess.CommandOptions, key: "stdout" | "stderr"): ChildProcess.StdoutConfig => {
|
||||
const cfg = opts[key]
|
||||
if (Predicate.isUndefined(cfg)) return { stream: "pipe" }
|
||||
if (typeof cfg === "string") return { stream: cfg }
|
||||
if (Sink.isSink(cfg)) return { stream: cfg }
|
||||
return { stream: cfg.stream }
|
||||
}
|
||||
|
||||
const fds = (opts: ChildProcess.CommandOptions) => {
|
||||
if (Predicate.isUndefined(opts.additionalFds)) return []
|
||||
return Object.entries(opts.additionalFds)
|
||||
.flatMap(([name, config]) => {
|
||||
const fd = ChildProcess.parseFdName(name)
|
||||
return Predicate.isUndefined(fd) ? [] : [{ fd, config }]
|
||||
})
|
||||
.toSorted((a, b) => a.fd - b.fd)
|
||||
}
|
||||
|
||||
const stdios = (
|
||||
sin: ChildProcess.StdinConfig,
|
||||
sout: ChildProcess.StdoutConfig,
|
||||
serr: ChildProcess.StderrConfig,
|
||||
extra: ReadonlyArray<{ fd: number; config: ChildProcess.AdditionalFdConfig }>,
|
||||
): NodeChildProcess.StdioOptions => {
|
||||
const arr: Array<NodeChildProcess.IOType | undefined> = [
|
||||
input(sin.stream),
|
||||
output(sout.stream),
|
||||
output(serr.stream),
|
||||
]
|
||||
if (extra.length === 0) return arr as NodeChildProcess.StdioOptions
|
||||
const max = extra.reduce((acc, x) => Math.max(acc, x.fd), 2)
|
||||
for (let i = 3; i <= max; i++) arr[i] = "ignore"
|
||||
for (const x of extra) arr[x.fd] = "pipe"
|
||||
return arr as NodeChildProcess.StdioOptions
|
||||
}
|
||||
|
||||
const setupFds = Effect.fnUntraced(function* (
|
||||
command: ChildProcess.StandardCommand,
|
||||
proc: NodeChildProcess.ChildProcess,
|
||||
extra: ReadonlyArray<{ fd: number; config: ChildProcess.AdditionalFdConfig }>,
|
||||
) {
|
||||
if (extra.length === 0) {
|
||||
return {
|
||||
getInputFd: () => Sink.drain,
|
||||
getOutputFd: () => Stream.empty,
|
||||
}
|
||||
}
|
||||
|
||||
const ins = new Map<number, Sink.Sink<void, Uint8Array, never, PlatformError.PlatformError>>()
|
||||
const outs = new Map<number, Stream.Stream<Uint8Array, PlatformError.PlatformError>>()
|
||||
|
||||
for (const x of extra) {
|
||||
const node = proc.stdio[x.fd]
|
||||
switch (x.config.type) {
|
||||
case "input": {
|
||||
let sink: Sink.Sink<void, Uint8Array, never, PlatformError.PlatformError> = Sink.drain
|
||||
if (node && "write" in node) {
|
||||
sink = NodeSink.fromWritable({
|
||||
evaluate: () => node,
|
||||
onError: (err) => toPlatformError(`fromWritable(fd${x.fd})`, toError(err), command),
|
||||
})
|
||||
}
|
||||
if (x.config.stream) yield* Effect.forkScoped(Stream.run(x.config.stream, sink))
|
||||
ins.set(x.fd, sink)
|
||||
break
|
||||
}
|
||||
case "output": {
|
||||
let stream: Stream.Stream<Uint8Array, PlatformError.PlatformError> = Stream.empty
|
||||
if (node && "read" in node) {
|
||||
const tap = new PassThrough()
|
||||
node.on("error", (err) => tap.destroy(toError(err)))
|
||||
node.pipe(tap)
|
||||
stream = NodeStream.fromReadable({
|
||||
evaluate: () => tap,
|
||||
onError: (err) => toPlatformError(`fromReadable(fd${x.fd})`, toError(err), command),
|
||||
})
|
||||
}
|
||||
if (x.config.sink) stream = Stream.transduce(stream, x.config.sink)
|
||||
outs.set(x.fd, stream)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
getInputFd: (fd: number) => ins.get(fd) ?? Sink.drain,
|
||||
getOutputFd: (fd: number) => outs.get(fd) ?? Stream.empty,
|
||||
}
|
||||
})
|
||||
|
||||
const setupStdin = (
|
||||
command: ChildProcess.StandardCommand,
|
||||
proc: NodeChildProcess.ChildProcess,
|
||||
cfg: ChildProcess.StdinConfig,
|
||||
) =>
|
||||
Effect.suspend(() => {
|
||||
let sink: Sink.Sink<void, unknown, never, PlatformError.PlatformError> = Sink.drain
|
||||
if (Predicate.isNotNull(proc.stdin)) {
|
||||
sink = NodeSink.fromWritable({
|
||||
evaluate: () => proc.stdin!,
|
||||
onError: (err) => toPlatformError("fromWritable(stdin)", toError(err), command),
|
||||
endOnDone: cfg.endOnDone,
|
||||
encoding: cfg.encoding,
|
||||
})
|
||||
}
|
||||
if (Stream.isStream(cfg.stream)) return Effect.as(Effect.forkScoped(Stream.run(cfg.stream, sink)), sink)
|
||||
return Effect.succeed(sink)
|
||||
})
|
||||
|
||||
const setupOutput = (
|
||||
command: ChildProcess.StandardCommand,
|
||||
proc: NodeChildProcess.ChildProcess,
|
||||
out: ChildProcess.StdoutConfig,
|
||||
err: ChildProcess.StderrConfig,
|
||||
) => {
|
||||
let stdout = proc.stdout
|
||||
? NodeStream.fromReadable({
|
||||
evaluate: () => proc.stdout!,
|
||||
onError: (cause) => toPlatformError("fromReadable(stdout)", toError(cause), command),
|
||||
})
|
||||
: Stream.empty
|
||||
let stderr = proc.stderr
|
||||
? NodeStream.fromReadable({
|
||||
evaluate: () => proc.stderr!,
|
||||
onError: (cause) => toPlatformError("fromReadable(stderr)", toError(cause), command),
|
||||
})
|
||||
: Stream.empty
|
||||
|
||||
if (Sink.isSink(out.stream)) stdout = Stream.transduce(stdout, out.stream)
|
||||
if (Sink.isSink(err.stream)) stderr = Stream.transduce(stderr, err.stream)
|
||||
|
||||
return { stdout, stderr, all: Stream.merge(stdout, stderr) }
|
||||
}
|
||||
|
||||
const spawn = (command: ChildProcess.StandardCommand, opts: NodeChildProcess.SpawnOptions) =>
|
||||
Effect.callback<readonly [NodeChildProcess.ChildProcess, ExitSignal], PlatformError.PlatformError>((resume) => {
|
||||
const signal = Deferred.makeUnsafe<readonly [code: number | null, signal: NodeJS.Signals | null]>()
|
||||
const proc = launch(command.command, command.args, opts)
|
||||
proc.on("error", (err) => {
|
||||
resume(Effect.fail(toPlatformError("spawn", err, command)))
|
||||
})
|
||||
proc.on("exit", (...args) => {
|
||||
Deferred.doneUnsafe(signal, Exit.succeed(args))
|
||||
})
|
||||
proc.on("spawn", () => {
|
||||
resume(Effect.succeed([proc, signal]))
|
||||
})
|
||||
return Effect.sync(() => {
|
||||
proc.kill("SIGTERM")
|
||||
})
|
||||
})
|
||||
|
||||
const killGroup = (
|
||||
command: ChildProcess.StandardCommand,
|
||||
proc: NodeChildProcess.ChildProcess,
|
||||
signal: NodeJS.Signals,
|
||||
) => {
|
||||
if (globalThis.process.platform === "win32") {
|
||||
return Effect.callback<void, PlatformError.PlatformError>((resume) => {
|
||||
NodeChildProcess.exec(`taskkill /pid ${proc.pid} /T /F`, (err) => {
|
||||
if (err) return resume(Effect.fail(toPlatformError("kill", toError(err), command)))
|
||||
resume(Effect.void)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
return Effect.try({
|
||||
try: () => {
|
||||
globalThis.process.kill(-proc.pid!, signal)
|
||||
},
|
||||
catch: (err) => toPlatformError("kill", toError(err), command),
|
||||
})
|
||||
}
|
||||
|
||||
const killOne = (
|
||||
command: ChildProcess.StandardCommand,
|
||||
proc: NodeChildProcess.ChildProcess,
|
||||
signal: NodeJS.Signals,
|
||||
) =>
|
||||
Effect.suspend(() => {
|
||||
if (proc.kill(signal)) return Effect.void
|
||||
return Effect.fail(toPlatformError("kill", new Error("Failed to kill child process"), command))
|
||||
})
|
||||
|
||||
const timeout =
|
||||
(
|
||||
proc: NodeChildProcess.ChildProcess,
|
||||
command: ChildProcess.StandardCommand,
|
||||
opts: ChildProcess.KillOptions | undefined,
|
||||
) =>
|
||||
<A, E, R>(
|
||||
f: (
|
||||
command: ChildProcess.StandardCommand,
|
||||
proc: NodeChildProcess.ChildProcess,
|
||||
signal: NodeJS.Signals,
|
||||
) => Effect.Effect<A, E, R>,
|
||||
) => {
|
||||
const signal = opts?.killSignal ?? "SIGTERM"
|
||||
if (Predicate.isUndefined(opts?.forceKillAfter)) return f(command, proc, signal)
|
||||
return Effect.timeoutOrElse(f(command, proc, signal), {
|
||||
duration: opts.forceKillAfter,
|
||||
onTimeout: () => f(command, proc, "SIGKILL"),
|
||||
})
|
||||
}
|
||||
|
||||
const source = (handle: ChildProcessHandle, from: ChildProcess.PipeFromOption | undefined) => {
|
||||
const opt = from ?? "stdout"
|
||||
switch (opt) {
|
||||
case "stdout":
|
||||
return handle.stdout
|
||||
case "stderr":
|
||||
return handle.stderr
|
||||
case "all":
|
||||
return handle.all
|
||||
default: {
|
||||
const fd = ChildProcess.parseFdName(opt)
|
||||
return Predicate.isNotUndefined(fd) ? handle.getOutputFd(fd) : handle.stdout
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const spawnCommand: (
|
||||
command: ChildProcess.Command,
|
||||
) => Effect.Effect<ChildProcessHandle, PlatformError.PlatformError, Scope.Scope> = Effect.fnUntraced(
|
||||
function* (command) {
|
||||
switch (command._tag) {
|
||||
case "StandardCommand": {
|
||||
const sin = stdin(command.options)
|
||||
const sout = stdio(command.options, "stdout")
|
||||
const serr = stdio(command.options, "stderr")
|
||||
const extra = fds(command.options)
|
||||
const dir = yield* cwd(command.options)
|
||||
|
||||
const [proc, signal] = yield* Effect.acquireRelease(
|
||||
spawn(command, {
|
||||
cwd: dir,
|
||||
env: env(command.options),
|
||||
stdio: stdios(sin, sout, serr, extra),
|
||||
detached: command.options.detached ?? process.platform !== "win32",
|
||||
shell: command.options.shell,
|
||||
}),
|
||||
Effect.fnUntraced(function* ([proc, signal]) {
|
||||
const done = yield* Deferred.isDone(signal)
|
||||
const kill = timeout(proc, command, command.options)
|
||||
if (done) {
|
||||
const [code] = yield* Deferred.await(signal)
|
||||
if (code !== 0 && Predicate.isNotNull(code)) return yield* Effect.ignore(kill(killGroup))
|
||||
return yield* Effect.void
|
||||
}
|
||||
return yield* kill((command, proc, signal) =>
|
||||
Effect.catch(killGroup(command, proc, signal), () => killOne(command, proc, signal)),
|
||||
).pipe(Effect.andThen(Deferred.await(signal)), Effect.ignore)
|
||||
}),
|
||||
)
|
||||
|
||||
const fd = yield* setupFds(command, proc, extra)
|
||||
const out = setupOutput(command, proc, sout, serr)
|
||||
return makeHandle({
|
||||
pid: ProcessId(proc.pid!),
|
||||
stdin: yield* setupStdin(command, proc, sin),
|
||||
stdout: out.stdout,
|
||||
stderr: out.stderr,
|
||||
all: out.all,
|
||||
getInputFd: fd.getInputFd,
|
||||
getOutputFd: fd.getOutputFd,
|
||||
isRunning: Effect.map(Deferred.isDone(signal), (done) => !done),
|
||||
exitCode: Effect.flatMap(Deferred.await(signal), ([code, signal]) => {
|
||||
if (Predicate.isNotNull(code)) return Effect.succeed(ExitCode(code))
|
||||
return Effect.fail(
|
||||
toPlatformError(
|
||||
"exitCode",
|
||||
new Error(`Process interrupted due to receipt of signal: '${signal}'`),
|
||||
command,
|
||||
),
|
||||
)
|
||||
}),
|
||||
kill: (opts?: ChildProcess.KillOptions) =>
|
||||
timeout(
|
||||
proc,
|
||||
command,
|
||||
opts,
|
||||
)((command, proc, signal) =>
|
||||
Effect.catch(killGroup(command, proc, signal), () => killOne(command, proc, signal)),
|
||||
).pipe(Effect.andThen(Deferred.await(signal)), Effect.asVoid),
|
||||
})
|
||||
}
|
||||
case "PipedCommand": {
|
||||
const flat = flatten(command)
|
||||
const [head, ...tail] = flat.commands
|
||||
let handle = spawnCommand(head)
|
||||
for (let i = 0; i < tail.length; i++) {
|
||||
const next = tail[i]
|
||||
const opts = flat.opts[i] ?? {}
|
||||
const sin = stdin(next.options)
|
||||
const stream = Stream.unwrap(Effect.map(handle, (x) => source(x, opts.from)))
|
||||
const to = opts.to ?? "stdin"
|
||||
if (to === "stdin") {
|
||||
handle = spawnCommand(
|
||||
ChildProcess.make(next.command, next.args, {
|
||||
...next.options,
|
||||
stdin: { ...sin, stream },
|
||||
}),
|
||||
)
|
||||
continue
|
||||
}
|
||||
const fd = ChildProcess.parseFdName(to)
|
||||
if (Predicate.isUndefined(fd)) {
|
||||
handle = spawnCommand(
|
||||
ChildProcess.make(next.command, next.args, {
|
||||
...next.options,
|
||||
stdin: { ...sin, stream },
|
||||
}),
|
||||
)
|
||||
continue
|
||||
}
|
||||
handle = spawnCommand(
|
||||
ChildProcess.make(next.command, next.args, {
|
||||
...next.options,
|
||||
additionalFds: {
|
||||
...next.options.additionalFds,
|
||||
[ChildProcess.fdName(fd) as `fd${number}`]: { type: "input", stream },
|
||||
},
|
||||
}),
|
||||
)
|
||||
}
|
||||
return yield* handle
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
return makeSpawner(spawnCommand)
|
||||
})
|
||||
|
||||
export const layer: Layer.Layer<ChildProcessSpawner, never, FileSystem.FileSystem | Path.Path> = Layer.effect(
|
||||
ChildProcessSpawner,
|
||||
make,
|
||||
)
|
||||
@@ -1,14 +0,0 @@
|
||||
import { ServiceMap } from "effect"
|
||||
import type { Project } from "@/project/project"
|
||||
|
||||
export declare namespace InstanceContext {
|
||||
export interface Shape {
|
||||
readonly directory: string
|
||||
readonly worktree: string
|
||||
readonly project: Project.Info
|
||||
}
|
||||
}
|
||||
|
||||
export class InstanceContext extends ServiceMap.Service<InstanceContext, InstanceContext.Shape>()(
|
||||
"opencode/InstanceContext",
|
||||
) {}
|
||||
@@ -3,11 +3,15 @@ import * as ServiceMap from "effect/ServiceMap"
|
||||
|
||||
export const memoMap = Layer.makeMemoMapUnsafe()
|
||||
|
||||
export function makeRunPromise<I, S, E>(service: ServiceMap.Service<I, S>, layer: Layer.Layer<I, E>) {
|
||||
export function makeRuntime<I, S, E>(service: ServiceMap.Service<I, S>, layer: Layer.Layer<I, E>) {
|
||||
let rt: ManagedRuntime.ManagedRuntime<I, E> | undefined
|
||||
const getRuntime = () => (rt ??= ManagedRuntime.make(layer, { memoMap }))
|
||||
|
||||
return <A, Err>(fn: (svc: S) => Effect.Effect<A, Err, I>, options?: Effect.RunOptions) => {
|
||||
rt ??= ManagedRuntime.make(layer, { memoMap })
|
||||
return rt.runPromise(service.use(fn), options)
|
||||
return {
|
||||
runSync: <A, Err>(fn: (svc: S) => Effect.Effect<A, Err, I>) => getRuntime().runSync(service.use(fn)),
|
||||
runPromise: <A, Err>(fn: (svc: S) => Effect.Effect<A, Err, I>, options?: Effect.RunOptions) =>
|
||||
getRuntime().runPromise(service.use(fn), options),
|
||||
runFork: <A, Err>(fn: (svc: S) => Effect.Effect<A, Err, I>) => getRuntime().runFork(service.use(fn)),
|
||||
runCallback: <A, Err>(fn: (svc: S) => Effect.Effect<A, Err, I>) => getRuntime().runCallback(service.use(fn)),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { BusEvent } from "@/bus/bus-event"
|
||||
import { InstanceState } from "@/effect/instance-state"
|
||||
import { makeRunPromise } from "@/effect/run-service"
|
||||
import { makeRuntime } from "@/effect/run-service"
|
||||
import { git } from "@/util/git"
|
||||
import { Effect, Fiber, Layer, Scope, ServiceMap } from "effect"
|
||||
import { formatPatch, structuredPatch } from "diff"
|
||||
@@ -688,7 +688,7 @@ export namespace File {
|
||||
}),
|
||||
)
|
||||
|
||||
const runPromise = makeRunPromise(Service, layer)
|
||||
const { runPromise } = makeRuntime(Service, layer)
|
||||
|
||||
export function init() {
|
||||
return runPromise((svc) => svc.init())
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { DateTime, Effect, Layer, Semaphore, ServiceMap } from "effect"
|
||||
import { InstanceState } from "@/effect/instance-state"
|
||||
import { makeRunPromise } from "@/effect/run-service"
|
||||
import { makeRuntime } from "@/effect/run-service"
|
||||
import { Flag } from "@/flag/flag"
|
||||
import type { SessionID } from "@/session/schema"
|
||||
import { Filesystem } from "../util/filesystem"
|
||||
@@ -108,7 +108,7 @@ export namespace FileTime {
|
||||
}),
|
||||
).pipe(Layer.orDie)
|
||||
|
||||
const runPromise = makeRunPromise(Service, layer)
|
||||
const { runPromise } = makeRuntime(Service, layer)
|
||||
|
||||
export function read(sessionID: SessionID, file: string) {
|
||||
return runPromise((s) => s.read(sessionID, file))
|
||||
|
||||
@@ -8,7 +8,7 @@ import z from "zod"
|
||||
import { Bus } from "@/bus"
|
||||
import { BusEvent } from "@/bus/bus-event"
|
||||
import { InstanceState } from "@/effect/instance-state"
|
||||
import { makeRunPromise } from "@/effect/run-service"
|
||||
import { makeRuntime } from "@/effect/run-service"
|
||||
import { Flag } from "@/flag/flag"
|
||||
import { Instance } from "@/project/instance"
|
||||
import { git } from "@/util/git"
|
||||
@@ -159,7 +159,7 @@ export namespace FileWatcher {
|
||||
}),
|
||||
)
|
||||
|
||||
const runPromise = makeRunPromise(Service, layer)
|
||||
const { runPromise } = makeRuntime(Service, layer)
|
||||
|
||||
export function init() {
|
||||
return runPromise((svc) => svc.init())
|
||||
|
||||
@@ -1,43 +1,40 @@
|
||||
import { text } from "node:stream/consumers"
|
||||
import { BunProc } from "../bun"
|
||||
import { Instance } from "../project/instance"
|
||||
import { Filesystem } from "../util/filesystem"
|
||||
import { Process } from "../util/process"
|
||||
import { which } from "../util/which"
|
||||
import { Flag } from "@/flag/flag"
|
||||
import { Npm } from "@/npm"
|
||||
|
||||
export interface Info {
|
||||
name: string
|
||||
command: string[]
|
||||
environment?: Record<string, string>
|
||||
extensions: string[]
|
||||
enabled(): Promise<boolean>
|
||||
enabled(): Promise<string[] | false>
|
||||
}
|
||||
|
||||
export const gofmt: Info = {
|
||||
name: "gofmt",
|
||||
command: ["gofmt", "-w", "$FILE"],
|
||||
extensions: [".go"],
|
||||
async enabled() {
|
||||
return which("gofmt") !== null
|
||||
const p = which("gofmt")
|
||||
if (p === null) return false
|
||||
return [p, "-w", "$FILE"]
|
||||
},
|
||||
}
|
||||
|
||||
export const mix: Info = {
|
||||
name: "mix",
|
||||
command: ["mix", "format", "$FILE"],
|
||||
extensions: [".ex", ".exs", ".eex", ".heex", ".leex", ".neex", ".sface"],
|
||||
async enabled() {
|
||||
return which("mix") !== null
|
||||
const p = which("mix")
|
||||
if (p === null) return false
|
||||
return [p, "format", "$FILE"]
|
||||
},
|
||||
}
|
||||
|
||||
export const prettier: Info = {
|
||||
name: "prettier",
|
||||
command: [BunProc.which(), "x", "prettier", "--write", "$FILE"],
|
||||
environment: {
|
||||
BUN_BE_BUN: "1",
|
||||
},
|
||||
extensions: [
|
||||
".js",
|
||||
".jsx",
|
||||
@@ -73,8 +70,11 @@ export const prettier: Info = {
|
||||
dependencies?: Record<string, string>
|
||||
devDependencies?: Record<string, string>
|
||||
}>(item)
|
||||
if (json.dependencies?.prettier) return true
|
||||
if (json.devDependencies?.prettier) return true
|
||||
if (json.dependencies?.prettier || json.devDependencies?.prettier) {
|
||||
const bin = await Npm.which("prettier").catch(() => null)
|
||||
if (!bin) return false
|
||||
return [bin, "--write", "$FILE"]
|
||||
}
|
||||
}
|
||||
return false
|
||||
},
|
||||
@@ -82,10 +82,6 @@ export const prettier: Info = {
|
||||
|
||||
export const oxfmt: Info = {
|
||||
name: "oxfmt",
|
||||
command: [BunProc.which(), "x", "oxfmt", "$FILE"],
|
||||
environment: {
|
||||
BUN_BE_BUN: "1",
|
||||
},
|
||||
extensions: [".js", ".jsx", ".mjs", ".cjs", ".ts", ".tsx", ".mts", ".cts"],
|
||||
async enabled() {
|
||||
if (!Flag.OPENCODE_EXPERIMENTAL_OXFMT) return false
|
||||
@@ -95,8 +91,11 @@ export const oxfmt: Info = {
|
||||
dependencies?: Record<string, string>
|
||||
devDependencies?: Record<string, string>
|
||||
}>(item)
|
||||
if (json.dependencies?.oxfmt) return true
|
||||
if (json.devDependencies?.oxfmt) return true
|
||||
if (json.dependencies?.oxfmt || json.devDependencies?.oxfmt) {
|
||||
const bin = await Npm.which("oxfmt")
|
||||
if (!bin) return false
|
||||
return [bin, "$FILE"]
|
||||
}
|
||||
}
|
||||
return false
|
||||
},
|
||||
@@ -104,10 +103,6 @@ export const oxfmt: Info = {
|
||||
|
||||
export const biome: Info = {
|
||||
name: "biome",
|
||||
command: [BunProc.which(), "x", "@biomejs/biome", "check", "--write", "$FILE"],
|
||||
environment: {
|
||||
BUN_BE_BUN: "1",
|
||||
},
|
||||
extensions: [
|
||||
".js",
|
||||
".jsx",
|
||||
@@ -141,7 +136,9 @@ export const biome: Info = {
|
||||
for (const config of configs) {
|
||||
const found = await Filesystem.findUp(config, Instance.directory, Instance.worktree)
|
||||
if (found.length > 0) {
|
||||
return true
|
||||
const bin = await Npm.which("@biomejs/biome")
|
||||
if (!bin) return false
|
||||
return [bin, "check", "--write", "$FILE"]
|
||||
}
|
||||
}
|
||||
return false
|
||||
@@ -150,47 +147,49 @@ export const biome: Info = {
|
||||
|
||||
export const zig: Info = {
|
||||
name: "zig",
|
||||
command: ["zig", "fmt", "$FILE"],
|
||||
extensions: [".zig", ".zon"],
|
||||
async enabled() {
|
||||
return which("zig") !== null
|
||||
const p = which("zig")
|
||||
if (p === null) return false
|
||||
return [p, "fmt", "$FILE"]
|
||||
},
|
||||
}
|
||||
|
||||
export const clang: Info = {
|
||||
name: "clang-format",
|
||||
command: ["clang-format", "-i", "$FILE"],
|
||||
extensions: [".c", ".cc", ".cpp", ".cxx", ".c++", ".h", ".hh", ".hpp", ".hxx", ".h++", ".ino", ".C", ".H"],
|
||||
async enabled() {
|
||||
const items = await Filesystem.findUp(".clang-format", Instance.directory, Instance.worktree)
|
||||
return items.length > 0
|
||||
if (items.length === 0) return false
|
||||
return ["clang-format", "-i", "$FILE"]
|
||||
},
|
||||
}
|
||||
|
||||
export const ktlint: Info = {
|
||||
name: "ktlint",
|
||||
command: ["ktlint", "-F", "$FILE"],
|
||||
extensions: [".kt", ".kts"],
|
||||
async enabled() {
|
||||
return which("ktlint") !== null
|
||||
const p = which("ktlint")
|
||||
if (p === null) return false
|
||||
return [p, "-F", "$FILE"]
|
||||
},
|
||||
}
|
||||
|
||||
export const ruff: Info = {
|
||||
name: "ruff",
|
||||
command: ["ruff", "format", "$FILE"],
|
||||
extensions: [".py", ".pyi"],
|
||||
async enabled() {
|
||||
if (!which("ruff")) return false
|
||||
const p = which("ruff")
|
||||
if (p === null) return false
|
||||
const configs = ["pyproject.toml", "ruff.toml", ".ruff.toml"]
|
||||
for (const config of configs) {
|
||||
const found = await Filesystem.findUp(config, Instance.directory, Instance.worktree)
|
||||
if (found.length > 0) {
|
||||
if (config === "pyproject.toml") {
|
||||
const content = await Filesystem.readText(found[0])
|
||||
if (content.includes("[tool.ruff]")) return true
|
||||
if (content.includes("[tool.ruff]")) return [p, "format", "$FILE"]
|
||||
} else {
|
||||
return true
|
||||
return [p, "format", "$FILE"]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -199,7 +198,7 @@ export const ruff: Info = {
|
||||
const found = await Filesystem.findUp(dep, Instance.directory, Instance.worktree)
|
||||
if (found.length > 0) {
|
||||
const content = await Filesystem.readText(found[0])
|
||||
if (content.includes("ruff")) return true
|
||||
if (content.includes("ruff")) return [p, "format", "$FILE"]
|
||||
}
|
||||
}
|
||||
return false
|
||||
@@ -208,14 +207,13 @@ export const ruff: Info = {
|
||||
|
||||
export const rlang: Info = {
|
||||
name: "air",
|
||||
command: ["air", "format", "$FILE"],
|
||||
extensions: [".R"],
|
||||
async enabled() {
|
||||
const airPath = which("air")
|
||||
if (airPath == null) return false
|
||||
|
||||
try {
|
||||
const proc = Process.spawn(["air", "--help"], {
|
||||
const proc = Process.spawn([airPath, "--help"], {
|
||||
stdout: "pipe",
|
||||
stderr: "pipe",
|
||||
})
|
||||
@@ -227,7 +225,10 @@ export const rlang: Info = {
|
||||
const firstLine = output.split("\n")[0]
|
||||
const hasR = firstLine.includes("R language")
|
||||
const hasFormatter = firstLine.includes("formatter")
|
||||
return hasR && hasFormatter
|
||||
if (hasR && hasFormatter) {
|
||||
return [airPath, "format", "$FILE"]
|
||||
}
|
||||
return false
|
||||
} catch (error) {
|
||||
return false
|
||||
}
|
||||
@@ -236,14 +237,14 @@ export const rlang: Info = {
|
||||
|
||||
export const uvformat: Info = {
|
||||
name: "uv",
|
||||
command: ["uv", "format", "--", "$FILE"],
|
||||
extensions: [".py", ".pyi"],
|
||||
async enabled() {
|
||||
if (await ruff.enabled()) return false
|
||||
if (which("uv") !== null) {
|
||||
const proc = Process.spawn(["uv", "format", "--help"], { stderr: "pipe", stdout: "pipe" })
|
||||
const uvPath = which("uv")
|
||||
if (uvPath !== null) {
|
||||
const proc = Process.spawn([uvPath, "format", "--help"], { stderr: "pipe", stdout: "pipe" })
|
||||
const code = await proc.exited
|
||||
return code === 0
|
||||
if (code === 0) return [uvPath, "format", "--", "$FILE"]
|
||||
}
|
||||
return false
|
||||
},
|
||||
@@ -251,108 +252,118 @@ export const uvformat: Info = {
|
||||
|
||||
export const rubocop: Info = {
|
||||
name: "rubocop",
|
||||
command: ["rubocop", "--autocorrect", "$FILE"],
|
||||
extensions: [".rb", ".rake", ".gemspec", ".ru"],
|
||||
async enabled() {
|
||||
return which("rubocop") !== null
|
||||
const path = which("rubocop")
|
||||
if (path === null) return false
|
||||
return [path, "--autocorrect", "$FILE"]
|
||||
},
|
||||
}
|
||||
|
||||
export const standardrb: Info = {
|
||||
name: "standardrb",
|
||||
command: ["standardrb", "--fix", "$FILE"],
|
||||
extensions: [".rb", ".rake", ".gemspec", ".ru"],
|
||||
async enabled() {
|
||||
return which("standardrb") !== null
|
||||
const path = which("standardrb")
|
||||
if (path === null) return false
|
||||
return [path, "--fix", "$FILE"]
|
||||
},
|
||||
}
|
||||
|
||||
export const htmlbeautifier: Info = {
|
||||
name: "htmlbeautifier",
|
||||
command: ["htmlbeautifier", "$FILE"],
|
||||
extensions: [".erb", ".html.erb"],
|
||||
async enabled() {
|
||||
return which("htmlbeautifier") !== null
|
||||
const path = which("htmlbeautifier")
|
||||
if (path === null) return false
|
||||
return [path, "$FILE"]
|
||||
},
|
||||
}
|
||||
|
||||
export const dart: Info = {
|
||||
name: "dart",
|
||||
command: ["dart", "format", "$FILE"],
|
||||
extensions: [".dart"],
|
||||
async enabled() {
|
||||
return which("dart") !== null
|
||||
const path = which("dart")
|
||||
if (path === null) return false
|
||||
return [path, "format", "$FILE"]
|
||||
},
|
||||
}
|
||||
|
||||
export const ocamlformat: Info = {
|
||||
name: "ocamlformat",
|
||||
command: ["ocamlformat", "-i", "$FILE"],
|
||||
extensions: [".ml", ".mli"],
|
||||
async enabled() {
|
||||
if (!which("ocamlformat")) return false
|
||||
const path = which("ocamlformat")
|
||||
if (!path) return false
|
||||
const items = await Filesystem.findUp(".ocamlformat", Instance.directory, Instance.worktree)
|
||||
return items.length > 0
|
||||
if (items.length === 0) return false
|
||||
return [path, "-i", "$FILE"]
|
||||
},
|
||||
}
|
||||
|
||||
export const terraform: Info = {
|
||||
name: "terraform",
|
||||
command: ["terraform", "fmt", "$FILE"],
|
||||
extensions: [".tf", ".tfvars"],
|
||||
async enabled() {
|
||||
return which("terraform") !== null
|
||||
const path = which("terraform")
|
||||
if (path === null) return false
|
||||
return [path, "fmt", "$FILE"]
|
||||
},
|
||||
}
|
||||
|
||||
export const latexindent: Info = {
|
||||
name: "latexindent",
|
||||
command: ["latexindent", "-w", "-s", "$FILE"],
|
||||
extensions: [".tex"],
|
||||
async enabled() {
|
||||
return which("latexindent") !== null
|
||||
const path = which("latexindent")
|
||||
if (path === null) return false
|
||||
return [path, "-w", "-s", "$FILE"]
|
||||
},
|
||||
}
|
||||
|
||||
export const gleam: Info = {
|
||||
name: "gleam",
|
||||
command: ["gleam", "format", "$FILE"],
|
||||
extensions: [".gleam"],
|
||||
async enabled() {
|
||||
return which("gleam") !== null
|
||||
const path = which("gleam")
|
||||
if (path === null) return false
|
||||
return [path, "format", "$FILE"]
|
||||
},
|
||||
}
|
||||
|
||||
export const shfmt: Info = {
|
||||
name: "shfmt",
|
||||
command: ["shfmt", "-w", "$FILE"],
|
||||
extensions: [".sh", ".bash"],
|
||||
async enabled() {
|
||||
return which("shfmt") !== null
|
||||
const path = which("shfmt")
|
||||
if (path === null) return false
|
||||
return [path, "-w", "$FILE"]
|
||||
},
|
||||
}
|
||||
|
||||
export const nixfmt: Info = {
|
||||
name: "nixfmt",
|
||||
command: ["nixfmt", "$FILE"],
|
||||
extensions: [".nix"],
|
||||
async enabled() {
|
||||
return which("nixfmt") !== null
|
||||
const path = which("nixfmt")
|
||||
if (path === null) return false
|
||||
return [path, "$FILE"]
|
||||
},
|
||||
}
|
||||
|
||||
export const rustfmt: Info = {
|
||||
name: "rustfmt",
|
||||
command: ["rustfmt", "$FILE"],
|
||||
extensions: [".rs"],
|
||||
async enabled() {
|
||||
return which("rustfmt") !== null
|
||||
const path = which("rustfmt")
|
||||
if (path === null) return false
|
||||
return [path, "$FILE"]
|
||||
},
|
||||
}
|
||||
|
||||
export const pint: Info = {
|
||||
name: "pint",
|
||||
command: ["./vendor/bin/pint", "$FILE"],
|
||||
extensions: [".php"],
|
||||
async enabled() {
|
||||
const items = await Filesystem.findUp("composer.json", Instance.directory, Instance.worktree)
|
||||
@@ -361,8 +372,9 @@ export const pint: Info = {
|
||||
require?: Record<string, string>
|
||||
"require-dev"?: Record<string, string>
|
||||
}>(item)
|
||||
if (json.require?.["laravel/pint"]) return true
|
||||
if (json["require-dev"]?.["laravel/pint"]) return true
|
||||
if (json.require?.["laravel/pint"] || json["require-dev"]?.["laravel/pint"]) {
|
||||
return ["./vendor/bin/pint", "$FILE"]
|
||||
}
|
||||
}
|
||||
return false
|
||||
},
|
||||
@@ -370,27 +382,30 @@ export const pint: Info = {
|
||||
|
||||
export const ormolu: Info = {
|
||||
name: "ormolu",
|
||||
command: ["ormolu", "-i", "$FILE"],
|
||||
extensions: [".hs"],
|
||||
async enabled() {
|
||||
return which("ormolu") !== null
|
||||
const path = which("ormolu")
|
||||
if (path === null) return false
|
||||
return [path, "-i", "$FILE"]
|
||||
},
|
||||
}
|
||||
|
||||
export const cljfmt: Info = {
|
||||
name: "cljfmt",
|
||||
command: ["cljfmt", "fix", "--quiet", "$FILE"],
|
||||
extensions: [".clj", ".cljs", ".cljc", ".edn"],
|
||||
async enabled() {
|
||||
return which("cljfmt") !== null
|
||||
const path = which("cljfmt")
|
||||
if (path === null) return false
|
||||
return [path, "fix", "--quiet", "$FILE"]
|
||||
},
|
||||
}
|
||||
|
||||
export const dfmt: Info = {
|
||||
name: "dfmt",
|
||||
command: ["dfmt", "-i", "$FILE"],
|
||||
extensions: [".d"],
|
||||
async enabled() {
|
||||
return which("dfmt") !== null
|
||||
const path = which("dfmt")
|
||||
if (path === null) return false
|
||||
return [path, "-i", "$FILE"]
|
||||
},
|
||||
}
|
||||
|
||||
@@ -1,12 +1,10 @@
|
||||
import { Effect, Layer, ServiceMap } from "effect"
|
||||
import { InstanceState } from "@/effect/instance-state"
|
||||
import { makeRunPromise } from "@/effect/run-service"
|
||||
import { makeRuntime } from "@/effect/run-service"
|
||||
import path from "path"
|
||||
import { mergeDeep } from "remeda"
|
||||
import z from "zod"
|
||||
import { Bus } from "../bus"
|
||||
import { Config } from "../config/config"
|
||||
import { File } from "../file"
|
||||
import { Instance } from "../project/instance"
|
||||
import { Process } from "../util/process"
|
||||
import { Log } from "../util/log"
|
||||
@@ -29,6 +27,7 @@ export namespace Format {
|
||||
export interface Interface {
|
||||
readonly init: () => Effect.Effect<void>
|
||||
readonly status: () => Effect.Effect<Status[]>
|
||||
readonly file: (filepath: string) => Effect.Effect<void>
|
||||
}
|
||||
|
||||
export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/Format") {}
|
||||
@@ -37,8 +36,8 @@ export namespace Format {
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const state = yield* InstanceState.make(
|
||||
Effect.fn("Format.state")(function* (_ctx) {
|
||||
const enabled: Record<string, boolean> = {}
|
||||
Effect.fn("Format.state")(function* (ctx) {
|
||||
const enabled: Record<string, string[] | false> = {}
|
||||
const formatters: Record<string, Formatter.Info> = {}
|
||||
|
||||
const cfg = yield* Effect.promise(() => Config.get())
|
||||
@@ -63,7 +62,7 @@ export namespace Format {
|
||||
formatters[name] = {
|
||||
...info,
|
||||
name,
|
||||
enabled: async () => true,
|
||||
enabled: async () => info.command,
|
||||
}
|
||||
}
|
||||
} else {
|
||||
@@ -80,70 +79,66 @@ export namespace Format {
|
||||
}
|
||||
|
||||
async function getFormatter(ext: string) {
|
||||
const matching = Object.values(formatters).filter((item) => item.extensions.includes(ext))
|
||||
const checks = await Promise.all(
|
||||
matching.map(async (item) => {
|
||||
log.info("checking", { name: item.name, ext })
|
||||
const on = await isEnabled(item)
|
||||
if (on) {
|
||||
log.info("enabled", { name: item.name, ext })
|
||||
}
|
||||
return {
|
||||
item,
|
||||
enabled: on,
|
||||
}
|
||||
}),
|
||||
)
|
||||
return checks.filter((x) => x.enabled).map((x) => x.item)
|
||||
const result: Array<{
|
||||
name: string
|
||||
command: string[]
|
||||
environment?: Record<string, string>
|
||||
}> = []
|
||||
for (const item of Object.values(formatters)) {
|
||||
log.info("checking", { name: item.name, ext })
|
||||
if (!item.extensions.includes(ext)) continue
|
||||
const cmd = await isEnabled(item)
|
||||
if (!cmd) continue
|
||||
log.info("enabled", { name: item.name, ext })
|
||||
result.push({
|
||||
name: item.name,
|
||||
command: cmd,
|
||||
environment: item.environment,
|
||||
})
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
yield* Effect.acquireRelease(
|
||||
Effect.sync(() =>
|
||||
Bus.subscribe(
|
||||
File.Event.Edited,
|
||||
Instance.bind(async (payload) => {
|
||||
const file = payload.properties.file
|
||||
log.info("formatting", { file })
|
||||
const ext = path.extname(file)
|
||||
async function formatFile(filepath: string) {
|
||||
log.info("formatting", { file: filepath })
|
||||
const ext = path.extname(filepath)
|
||||
|
||||
for (const item of await getFormatter(ext)) {
|
||||
log.info("running", { command: item.command })
|
||||
try {
|
||||
const proc = Process.spawn(
|
||||
item.command.map((x) => x.replace("$FILE", filepath)),
|
||||
{
|
||||
cwd: Instance.directory,
|
||||
env: { ...process.env, ...item.environment },
|
||||
stdout: "ignore",
|
||||
stderr: "ignore",
|
||||
},
|
||||
)
|
||||
const exit = await proc.exited
|
||||
if (exit !== 0) {
|
||||
log.error("failed", {
|
||||
command: item.command,
|
||||
...item.environment,
|
||||
})
|
||||
}
|
||||
} catch (error) {
|
||||
log.error("failed to format file", {
|
||||
error,
|
||||
command: item.command,
|
||||
...item.environment,
|
||||
file: filepath,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const item of await getFormatter(ext)) {
|
||||
log.info("running", { command: item.command })
|
||||
try {
|
||||
const proc = Process.spawn(
|
||||
item.command.map((x) => x.replace("$FILE", file)),
|
||||
{
|
||||
cwd: Instance.directory,
|
||||
env: { ...process.env, ...item.environment },
|
||||
stdout: "ignore",
|
||||
stderr: "ignore",
|
||||
},
|
||||
)
|
||||
const exit = await proc.exited
|
||||
if (exit !== 0) {
|
||||
log.error("failed", {
|
||||
command: item.command,
|
||||
...item.environment,
|
||||
})
|
||||
}
|
||||
} catch (error) {
|
||||
log.error("failed to format file", {
|
||||
error,
|
||||
command: item.command,
|
||||
...item.environment,
|
||||
file,
|
||||
})
|
||||
}
|
||||
}
|
||||
}),
|
||||
),
|
||||
),
|
||||
(unsubscribe) => Effect.sync(unsubscribe),
|
||||
)
|
||||
log.info("init")
|
||||
|
||||
return {
|
||||
formatters,
|
||||
isEnabled,
|
||||
formatFile,
|
||||
}
|
||||
}),
|
||||
)
|
||||
@@ -160,17 +155,22 @@ export namespace Format {
|
||||
result.push({
|
||||
name: formatter.name,
|
||||
extensions: formatter.extensions,
|
||||
enabled: isOn,
|
||||
enabled: !!isOn,
|
||||
})
|
||||
}
|
||||
return result
|
||||
})
|
||||
|
||||
return Service.of({ init, status })
|
||||
const file = Effect.fn("Format.file")(function* (filepath: string) {
|
||||
const { formatFile } = yield* InstanceState.get(state)
|
||||
yield* Effect.promise(() => formatFile(filepath))
|
||||
})
|
||||
|
||||
return Service.of({ init, status, file })
|
||||
}),
|
||||
)
|
||||
|
||||
const runPromise = makeRunPromise(Service, layer)
|
||||
const { runPromise } = makeRuntime(Service, layer)
|
||||
|
||||
export async function init() {
|
||||
return runPromise((s) => s.init())
|
||||
@@ -179,4 +179,8 @@ export namespace Format {
|
||||
export async function status() {
|
||||
return runPromise((s) => s.status())
|
||||
}
|
||||
|
||||
export async function file(filepath: string) {
|
||||
return runPromise((s) => s.file(filepath))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { NodeChildProcessSpawner, NodeFileSystem, NodePath } from "@effect/platform-node"
|
||||
import { NodeFileSystem, NodePath } from "@effect/platform-node"
|
||||
import { Effect, Layer, Schema, ServiceMap, Stream } from "effect"
|
||||
import { FetchHttpClient, HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"
|
||||
import { makeRunPromise } from "@/effect/run-service"
|
||||
import * as CrossSpawnSpawner from "@/effect/cross-spawn-spawner"
|
||||
import { makeRuntime } from "@/effect/run-service"
|
||||
import { withTransientReadRetry } from "@/util/effect-http-client"
|
||||
import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"
|
||||
import path from "path"
|
||||
@@ -340,12 +341,12 @@ export namespace Installation {
|
||||
|
||||
export const defaultLayer = layer.pipe(
|
||||
Layer.provide(FetchHttpClient.layer),
|
||||
Layer.provide(NodeChildProcessSpawner.layer),
|
||||
Layer.provide(CrossSpawnSpawner.layer),
|
||||
Layer.provide(NodeFileSystem.layer),
|
||||
Layer.provide(NodePath.layer),
|
||||
)
|
||||
|
||||
const runPromise = makeRunPromise(Service, defaultLayer)
|
||||
const { runPromise } = makeRuntime(Service, defaultLayer)
|
||||
|
||||
export async function info(): Promise<Info> {
|
||||
return runPromise((svc) => svc.info())
|
||||
|
||||
@@ -3,7 +3,6 @@ import path from "path"
|
||||
import os from "os"
|
||||
import { Global } from "../global"
|
||||
import { Log } from "../util/log"
|
||||
import { BunProc } from "../bun"
|
||||
import { text } from "node:stream/consumers"
|
||||
import fs from "fs/promises"
|
||||
import { Filesystem } from "../util/filesystem"
|
||||
@@ -14,6 +13,7 @@ import { Process } from "../util/process"
|
||||
import { which } from "../util/which"
|
||||
import { Module } from "@opencode-ai/util/module"
|
||||
import { spawn } from "./launch"
|
||||
import { Npm } from "@/npm"
|
||||
|
||||
export namespace LSPServer {
|
||||
const log = Log.create({ service: "lsp.server" })
|
||||
@@ -103,11 +103,12 @@ export namespace LSPServer {
|
||||
const tsserver = Module.resolve("typescript/lib/tsserver.js", Instance.directory)
|
||||
log.info("typescript server", { tsserver })
|
||||
if (!tsserver) return
|
||||
const proc = spawn(BunProc.which(), ["x", "typescript-language-server", "--stdio"], {
|
||||
const bin = await Npm.which("typescript-language-server")
|
||||
if (!bin) return
|
||||
const proc = spawn(bin, ["--stdio"], {
|
||||
cwd: root,
|
||||
env: {
|
||||
...process.env,
|
||||
BUN_BE_BUN: "1",
|
||||
},
|
||||
})
|
||||
return {
|
||||
@@ -129,36 +130,16 @@ export namespace LSPServer {
|
||||
let binary = which("vue-language-server")
|
||||
const args: string[] = []
|
||||
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
|
||||
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)
|
||||
if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return
|
||||
const resolved = await Npm.which("@vue/language-server")
|
||||
if (!resolved) return
|
||||
binary = resolved
|
||||
}
|
||||
args.push("--stdio")
|
||||
const proc = spawn(binary, args, {
|
||||
cwd: root,
|
||||
env: {
|
||||
...process.env,
|
||||
BUN_BE_BUN: "1",
|
||||
},
|
||||
})
|
||||
return {
|
||||
@@ -214,11 +195,10 @@ export namespace LSPServer {
|
||||
log.info("installed VS Code ESLint server", { serverPath })
|
||||
}
|
||||
|
||||
const proc = spawn(BunProc.which(), [serverPath, "--stdio"], {
|
||||
const proc = spawn("node", [serverPath, "--stdio"], {
|
||||
cwd: root,
|
||||
env: {
|
||||
...process.env,
|
||||
BUN_BE_BUN: "1",
|
||||
},
|
||||
})
|
||||
|
||||
@@ -345,15 +325,15 @@ export namespace LSPServer {
|
||||
if (!bin) {
|
||||
const resolved = Module.resolve("biome", root)
|
||||
if (!resolved) return
|
||||
bin = BunProc.which()
|
||||
args = ["x", "biome", "lsp-proxy", "--stdio"]
|
||||
bin = await Npm.which("biome")
|
||||
if (!bin) return
|
||||
args = ["lsp-proxy", "--stdio"]
|
||||
}
|
||||
|
||||
const proc = spawn(bin, args, {
|
||||
cwd: root,
|
||||
env: {
|
||||
...process.env,
|
||||
BUN_BE_BUN: "1",
|
||||
},
|
||||
})
|
||||
|
||||
@@ -372,9 +352,7 @@ export namespace LSPServer {
|
||||
},
|
||||
extensions: [".go"],
|
||||
async spawn(root) {
|
||||
let bin = which("gopls", {
|
||||
PATH: process.env["PATH"] + path.delimiter + Global.Path.bin,
|
||||
})
|
||||
let bin = which("gopls")
|
||||
if (!bin) {
|
||||
if (!which("go")) return
|
||||
if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return
|
||||
@@ -409,9 +387,7 @@ export namespace LSPServer {
|
||||
root: NearestRoot(["Gemfile"]),
|
||||
extensions: [".rb", ".rake", ".gemspec", ".ru"],
|
||||
async spawn(root) {
|
||||
let bin = which("rubocop", {
|
||||
PATH: process.env["PATH"] + path.delimiter + Global.Path.bin,
|
||||
})
|
||||
let bin = which("rubocop")
|
||||
if (!bin) {
|
||||
const ruby = which("ruby")
|
||||
const gem = which("gem")
|
||||
@@ -516,19 +492,10 @@ export namespace LSPServer {
|
||||
let binary = which("pyright-langserver")
|
||||
const args = []
|
||||
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
|
||||
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])
|
||||
if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return
|
||||
const resolved = await Npm.which("pyright")
|
||||
if (!resolved) return
|
||||
binary = resolved
|
||||
}
|
||||
args.push("--stdio")
|
||||
|
||||
@@ -552,7 +519,6 @@ export namespace LSPServer {
|
||||
cwd: root,
|
||||
env: {
|
||||
...process.env,
|
||||
BUN_BE_BUN: "1",
|
||||
},
|
||||
})
|
||||
return {
|
||||
@@ -630,9 +596,7 @@ export namespace LSPServer {
|
||||
extensions: [".zig", ".zon"],
|
||||
root: NearestRoot(["build.zig"]),
|
||||
async spawn(root) {
|
||||
let bin = which("zls", {
|
||||
PATH: process.env["PATH"] + path.delimiter + Global.Path.bin,
|
||||
})
|
||||
let bin = which("zls")
|
||||
|
||||
if (!bin) {
|
||||
const zig = which("zig")
|
||||
@@ -742,9 +706,7 @@ export namespace LSPServer {
|
||||
root: NearestRoot([".slnx", ".sln", ".csproj", "global.json"]),
|
||||
extensions: [".cs"],
|
||||
async spawn(root) {
|
||||
let bin = which("csharp-ls", {
|
||||
PATH: process.env["PATH"] + path.delimiter + Global.Path.bin,
|
||||
})
|
||||
let bin = which("csharp-ls")
|
||||
if (!bin) {
|
||||
if (!which("dotnet")) {
|
||||
log.error(".NET SDK is required to install csharp-ls")
|
||||
@@ -781,9 +743,7 @@ export namespace LSPServer {
|
||||
root: NearestRoot([".slnx", ".sln", ".fsproj", "global.json"]),
|
||||
extensions: [".fs", ".fsi", ".fsx", ".fsscript"],
|
||||
async spawn(root) {
|
||||
let bin = which("fsautocomplete", {
|
||||
PATH: process.env["PATH"] + path.delimiter + Global.Path.bin,
|
||||
})
|
||||
let bin = which("fsautocomplete")
|
||||
if (!bin) {
|
||||
if (!which("dotnet")) {
|
||||
log.error(".NET SDK is required to install fsautocomplete")
|
||||
@@ -1049,29 +1009,16 @@ export namespace LSPServer {
|
||||
let binary = which("svelteserver")
|
||||
const args: string[] = []
|
||||
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
|
||||
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)
|
||||
if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return
|
||||
const resolved = await Npm.which("svelte-language-server")
|
||||
if (!resolved) return
|
||||
binary = resolved
|
||||
}
|
||||
args.push("--stdio")
|
||||
const proc = spawn(binary, args, {
|
||||
cwd: root,
|
||||
env: {
|
||||
...process.env,
|
||||
BUN_BE_BUN: "1",
|
||||
},
|
||||
})
|
||||
return {
|
||||
@@ -1096,29 +1043,16 @@ export namespace LSPServer {
|
||||
let binary = which("astro-ls")
|
||||
const args: string[] = []
|
||||
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
|
||||
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)
|
||||
if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return
|
||||
const resolved = await Npm.which("@astrojs/language-server")
|
||||
if (!resolved) return
|
||||
binary = resolved
|
||||
}
|
||||
args.push("--stdio")
|
||||
const proc = spawn(binary, args, {
|
||||
cwd: root,
|
||||
env: {
|
||||
...process.env,
|
||||
BUN_BE_BUN: "1",
|
||||
},
|
||||
})
|
||||
return {
|
||||
@@ -1360,38 +1294,16 @@ export namespace LSPServer {
|
||||
let binary = which("yaml-language-server")
|
||||
const args: string[] = []
|
||||
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
|
||||
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)
|
||||
if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return
|
||||
const resolved = await Npm.which("yaml-language-server")
|
||||
if (!resolved) return
|
||||
binary = resolved
|
||||
}
|
||||
args.push("--stdio")
|
||||
const proc = spawn(binary, args, {
|
||||
cwd: root,
|
||||
env: {
|
||||
...process.env,
|
||||
BUN_BE_BUN: "1",
|
||||
},
|
||||
})
|
||||
return {
|
||||
@@ -1413,9 +1325,7 @@ export namespace LSPServer {
|
||||
]),
|
||||
extensions: [".lua"],
|
||||
async spawn(root) {
|
||||
let bin = which("lua-language-server", {
|
||||
PATH: process.env["PATH"] + path.delimiter + Global.Path.bin,
|
||||
})
|
||||
let bin = which("lua-language-server")
|
||||
|
||||
if (!bin) {
|
||||
if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return
|
||||
@@ -1551,29 +1461,16 @@ export namespace LSPServer {
|
||||
let binary = which("intelephense")
|
||||
const args: string[] = []
|
||||
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
|
||||
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)
|
||||
if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return
|
||||
const resolved = await Npm.which("intelephense")
|
||||
if (!resolved) return
|
||||
binary = resolved
|
||||
}
|
||||
args.push("--stdio")
|
||||
const proc = spawn(binary, args, {
|
||||
cwd: root,
|
||||
env: {
|
||||
...process.env,
|
||||
BUN_BE_BUN: "1",
|
||||
},
|
||||
})
|
||||
return {
|
||||
@@ -1648,29 +1545,16 @@ export namespace LSPServer {
|
||||
let binary = which("bash-language-server")
|
||||
const args: string[] = []
|
||||
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
|
||||
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)
|
||||
if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return
|
||||
const resolved = await Npm.which("bash-language-server")
|
||||
if (!resolved) return
|
||||
binary = resolved
|
||||
}
|
||||
args.push("start")
|
||||
const proc = spawn(binary, args, {
|
||||
cwd: root,
|
||||
env: {
|
||||
...process.env,
|
||||
BUN_BE_BUN: "1",
|
||||
},
|
||||
})
|
||||
return {
|
||||
@@ -1684,9 +1568,7 @@ export namespace LSPServer {
|
||||
extensions: [".tf", ".tfvars"],
|
||||
root: NearestRoot([".terraform.lock.hcl", "terraform.tfstate", "*.tf"]),
|
||||
async spawn(root) {
|
||||
let bin = which("terraform-ls", {
|
||||
PATH: process.env["PATH"] + path.delimiter + Global.Path.bin,
|
||||
})
|
||||
let bin = which("terraform-ls")
|
||||
|
||||
if (!bin) {
|
||||
if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return
|
||||
@@ -1767,9 +1649,7 @@ export namespace LSPServer {
|
||||
extensions: [".tex", ".bib"],
|
||||
root: NearestRoot([".latexmkrc", "latexmkrc", ".texlabroot", "texlabroot"]),
|
||||
async spawn(root) {
|
||||
let bin = which("texlab", {
|
||||
PATH: process.env["PATH"] + path.delimiter + Global.Path.bin,
|
||||
})
|
||||
let bin = which("texlab")
|
||||
|
||||
if (!bin) {
|
||||
if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return
|
||||
@@ -1860,29 +1740,16 @@ export namespace LSPServer {
|
||||
let binary = which("docker-langserver")
|
||||
const args: string[] = []
|
||||
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
|
||||
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)
|
||||
if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return
|
||||
const resolved = await Npm.which("dockerfile-language-server-nodejs")
|
||||
if (!resolved) return
|
||||
binary = resolved
|
||||
}
|
||||
args.push("--stdio")
|
||||
const proc = spawn(binary, args, {
|
||||
cwd: root,
|
||||
env: {
|
||||
...process.env,
|
||||
BUN_BE_BUN: "1",
|
||||
},
|
||||
})
|
||||
return {
|
||||
@@ -1966,9 +1833,7 @@ export namespace LSPServer {
|
||||
extensions: [".typ", ".typc"],
|
||||
root: NearestRoot(["typst.toml"]),
|
||||
async spawn(root) {
|
||||
let bin = which("tinymist", {
|
||||
PATH: process.env["PATH"] + path.delimiter + Global.Path.bin,
|
||||
})
|
||||
let bin = which("tinymist")
|
||||
|
||||
if (!bin) {
|
||||
if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return
|
||||
|
||||
@@ -455,9 +455,8 @@ export namespace MCP {
|
||||
cwd,
|
||||
env: {
|
||||
...process.env,
|
||||
...(cmd === "opencode" ? { BUN_BE_BUN: "1" } : {}),
|
||||
...mcp.environment,
|
||||
},
|
||||
} as any,
|
||||
})
|
||||
transport.stderr?.on("data", (chunk: Buffer) => {
|
||||
log.info(`mcp stderr: ${chunk.toString()}`, { key })
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { createConnection } from "net"
|
||||
import { createServer } from "http"
|
||||
import { Log } from "../util/log"
|
||||
import { OAUTH_CALLBACK_PORT, OAUTH_CALLBACK_PATH } from "./oauth-provider"
|
||||
|
||||
@@ -52,11 +53,74 @@ interface PendingAuth {
|
||||
}
|
||||
|
||||
export namespace McpOAuthCallback {
|
||||
let server: ReturnType<typeof Bun.serve> | undefined
|
||||
let server: ReturnType<typeof createServer> | undefined
|
||||
const pendingAuths = new Map<string, PendingAuth>()
|
||||
|
||||
const CALLBACK_TIMEOUT_MS = 5 * 60 * 1000 // 5 minutes
|
||||
|
||||
function handleRequest(req: import("http").IncomingMessage, res: import("http").ServerResponse) {
|
||||
const url = new URL(req.url || "/", `http://localhost:${OAUTH_CALLBACK_PORT}`)
|
||||
|
||||
if (url.pathname !== OAUTH_CALLBACK_PATH) {
|
||||
res.writeHead(404)
|
||||
res.end("Not found")
|
||||
return
|
||||
}
|
||||
|
||||
const code = url.searchParams.get("code")
|
||||
const state = url.searchParams.get("state")
|
||||
const error = url.searchParams.get("error")
|
||||
const errorDescription = url.searchParams.get("error_description")
|
||||
|
||||
log.info("received oauth callback", { hasCode: !!code, state, error })
|
||||
|
||||
// Enforce state parameter presence
|
||||
if (!state) {
|
||||
const errorMsg = "Missing required state parameter - potential CSRF attack"
|
||||
log.error("oauth callback missing state parameter", { url: url.toString() })
|
||||
res.writeHead(400, { "Content-Type": "text/html" })
|
||||
res.end(HTML_ERROR(errorMsg))
|
||||
return
|
||||
}
|
||||
|
||||
if (error) {
|
||||
const errorMsg = errorDescription || error
|
||||
if (pendingAuths.has(state)) {
|
||||
const pending = pendingAuths.get(state)!
|
||||
clearTimeout(pending.timeout)
|
||||
pendingAuths.delete(state)
|
||||
pending.reject(new Error(errorMsg))
|
||||
}
|
||||
res.writeHead(200, { "Content-Type": "text/html" })
|
||||
res.end(HTML_ERROR(errorMsg))
|
||||
return
|
||||
}
|
||||
|
||||
if (!code) {
|
||||
res.writeHead(400, { "Content-Type": "text/html" })
|
||||
res.end(HTML_ERROR("No authorization code provided"))
|
||||
return
|
||||
}
|
||||
|
||||
// Validate state parameter
|
||||
if (!pendingAuths.has(state)) {
|
||||
const errorMsg = "Invalid or expired state parameter - potential CSRF attack"
|
||||
log.error("oauth callback with invalid state", { state, pendingStates: Array.from(pendingAuths.keys()) })
|
||||
res.writeHead(400, { "Content-Type": "text/html" })
|
||||
res.end(HTML_ERROR(errorMsg))
|
||||
return
|
||||
}
|
||||
|
||||
const pending = pendingAuths.get(state)!
|
||||
|
||||
clearTimeout(pending.timeout)
|
||||
pendingAuths.delete(state)
|
||||
pending.resolve(code)
|
||||
|
||||
res.writeHead(200, { "Content-Type": "text/html" })
|
||||
res.end(HTML_SUCCESS)
|
||||
}
|
||||
|
||||
export async function ensureRunning(): Promise<void> {
|
||||
if (server) return
|
||||
|
||||
@@ -66,75 +130,14 @@ export namespace McpOAuthCallback {
|
||||
return
|
||||
}
|
||||
|
||||
server = Bun.serve({
|
||||
port: OAUTH_CALLBACK_PORT,
|
||||
fetch(req) {
|
||||
const url = new URL(req.url)
|
||||
|
||||
if (url.pathname !== OAUTH_CALLBACK_PATH) {
|
||||
return new Response("Not found", { status: 404 })
|
||||
}
|
||||
|
||||
const code = url.searchParams.get("code")
|
||||
const state = url.searchParams.get("state")
|
||||
const error = url.searchParams.get("error")
|
||||
const errorDescription = url.searchParams.get("error_description")
|
||||
|
||||
log.info("received oauth callback", { hasCode: !!code, state, error })
|
||||
|
||||
// Enforce state parameter presence
|
||||
if (!state) {
|
||||
const errorMsg = "Missing required state parameter - potential CSRF attack"
|
||||
log.error("oauth callback missing state parameter", { url: url.toString() })
|
||||
return new Response(HTML_ERROR(errorMsg), {
|
||||
status: 400,
|
||||
headers: { "Content-Type": "text/html" },
|
||||
})
|
||||
}
|
||||
|
||||
if (error) {
|
||||
const errorMsg = errorDescription || error
|
||||
if (pendingAuths.has(state)) {
|
||||
const pending = pendingAuths.get(state)!
|
||||
clearTimeout(pending.timeout)
|
||||
pendingAuths.delete(state)
|
||||
pending.reject(new Error(errorMsg))
|
||||
}
|
||||
return new Response(HTML_ERROR(errorMsg), {
|
||||
headers: { "Content-Type": "text/html" },
|
||||
})
|
||||
}
|
||||
|
||||
if (!code) {
|
||||
return new Response(HTML_ERROR("No authorization code provided"), {
|
||||
status: 400,
|
||||
headers: { "Content-Type": "text/html" },
|
||||
})
|
||||
}
|
||||
|
||||
// Validate state parameter
|
||||
if (!pendingAuths.has(state)) {
|
||||
const errorMsg = "Invalid or expired state parameter - potential CSRF attack"
|
||||
log.error("oauth callback with invalid state", { state, pendingStates: Array.from(pendingAuths.keys()) })
|
||||
return new Response(HTML_ERROR(errorMsg), {
|
||||
status: 400,
|
||||
headers: { "Content-Type": "text/html" },
|
||||
})
|
||||
}
|
||||
|
||||
const pending = pendingAuths.get(state)!
|
||||
|
||||
clearTimeout(pending.timeout)
|
||||
pendingAuths.delete(state)
|
||||
pending.resolve(code)
|
||||
|
||||
return new Response(HTML_SUCCESS, {
|
||||
headers: { "Content-Type": "text/html" },
|
||||
})
|
||||
},
|
||||
server = createServer(handleRequest)
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
server!.listen(OAUTH_CALLBACK_PORT, () => {
|
||||
log.info("oauth callback server started", { port: OAUTH_CALLBACK_PORT })
|
||||
resolve()
|
||||
})
|
||||
server!.on("error", reject)
|
||||
})
|
||||
|
||||
log.info("oauth callback server started", { port: OAUTH_CALLBACK_PORT })
|
||||
}
|
||||
|
||||
export function waitForCallback(oauthState: string): Promise<string> {
|
||||
@@ -174,7 +177,7 @@ export namespace McpOAuthCallback {
|
||||
|
||||
export async function stop(): Promise<void> {
|
||||
if (server) {
|
||||
server.stop()
|
||||
await new Promise<void>((resolve) => server!.close(() => resolve()))
|
||||
server = undefined
|
||||
log.info("oauth callback server stopped")
|
||||
}
|
||||
|
||||
181
packages/opencode/src/npm/index.ts
Normal file
181
packages/opencode/src/npm/index.ts
Normal file
@@ -0,0 +1,181 @@
|
||||
// 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, rm } 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 range = /[\s^~*xX<>|=]/.test(cachedVersion)
|
||||
if (range) return !semver.satisfies(latestVersion, cachedVersion)
|
||||
|
||||
return semver.lt(cachedVersion, latestVersion)
|
||||
}
|
||||
|
||||
export async function add(pkg: string) {
|
||||
using _ = await Lock.write(`npm-install:${pkg}`)
|
||||
log.info("installing package", {
|
||||
pkg,
|
||||
})
|
||||
const dir = directory(pkg)
|
||||
|
||||
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) {
|
||||
using _ = await Lock.write(`npm-install:${dir}`)
|
||||
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 = directory(pkg)
|
||||
const binDir = path.join(dir, "node_modules", ".bin")
|
||||
|
||||
const pick = async () => {
|
||||
const files = await readdir(binDir).catch(() => [])
|
||||
if (files.length === 0) return undefined
|
||||
if (files.length === 1) return files[0]
|
||||
// Multiple binaries — resolve from package.json bin field like npx does
|
||||
const pkgJson = await Filesystem.readJson<{ bin?: string | Record<string, string> }>(
|
||||
path.join(dir, "node_modules", pkg, "package.json"),
|
||||
).catch(() => undefined)
|
||||
if (pkgJson?.bin) {
|
||||
const unscoped = pkg.startsWith("@") ? pkg.split("/")[1] : pkg
|
||||
const bin = pkgJson.bin
|
||||
if (typeof bin === "string") return unscoped
|
||||
const keys = Object.keys(bin)
|
||||
if (keys.length === 1) return keys[0]
|
||||
return bin[unscoped] ? unscoped : keys[0]
|
||||
}
|
||||
return files[0]
|
||||
}
|
||||
|
||||
const bin = await pick()
|
||||
if (bin) return path.join(binDir, bin)
|
||||
|
||||
await rm(path.join(dir, "package-lock.json"), { force: true })
|
||||
await add(pkg)
|
||||
const resolved = await pick()
|
||||
if (!resolved) return
|
||||
return path.join(binDir, resolved)
|
||||
}
|
||||
}
|
||||
@@ -2,7 +2,7 @@ import { Bus } from "@/bus"
|
||||
import { BusEvent } from "@/bus/bus-event"
|
||||
import { Config } from "@/config/config"
|
||||
import { InstanceState } from "@/effect/instance-state"
|
||||
import { makeRunPromise } from "@/effect/run-service"
|
||||
import { makeRuntime } from "@/effect/run-service"
|
||||
import { ProjectID } from "@/project/schema"
|
||||
import { Instance } from "@/project/instance"
|
||||
import { MessageID, SessionID } from "@/session/schema"
|
||||
@@ -306,7 +306,7 @@ export namespace Permission {
|
||||
return result
|
||||
}
|
||||
|
||||
export const runPromise = makeRunPromise(Service, layer)
|
||||
export const { runPromise } = makeRuntime(Service, layer)
|
||||
|
||||
export async function ask(input: z.infer<typeof AskInput>) {
|
||||
return runPromise((s) => s.ask(input))
|
||||
|
||||
@@ -6,6 +6,7 @@ import os from "os"
|
||||
import { ProviderTransform } from "@/provider/transform"
|
||||
import { ModelID, ProviderID } from "@/provider/schema"
|
||||
import { setTimeout as sleep } from "node:timers/promises"
|
||||
import { createServer } from "http"
|
||||
|
||||
const log = Log.create({ service: "plugin.codex" })
|
||||
|
||||
@@ -241,7 +242,7 @@ interface PendingOAuth {
|
||||
reject: (error: Error) => void
|
||||
}
|
||||
|
||||
let oauthServer: ReturnType<typeof Bun.serve> | undefined
|
||||
let oauthServer: ReturnType<typeof createServer> | undefined
|
||||
let pendingOAuth: PendingOAuth | undefined
|
||||
|
||||
async function startOAuthServer(): Promise<{ port: number; redirectUri: string }> {
|
||||
@@ -249,77 +250,83 @@ async function startOAuthServer(): Promise<{ port: number; redirectUri: string }
|
||||
return { port: OAUTH_PORT, redirectUri: `http://localhost:${OAUTH_PORT}/auth/callback` }
|
||||
}
|
||||
|
||||
oauthServer = Bun.serve({
|
||||
port: OAUTH_PORT,
|
||||
fetch(req) {
|
||||
const url = new URL(req.url)
|
||||
oauthServer = createServer((req, res) => {
|
||||
const url = new URL(req.url || "/", `http://localhost:${OAUTH_PORT}`)
|
||||
|
||||
if (url.pathname === "/auth/callback") {
|
||||
const code = url.searchParams.get("code")
|
||||
const state = url.searchParams.get("state")
|
||||
const error = url.searchParams.get("error")
|
||||
const errorDescription = url.searchParams.get("error_description")
|
||||
if (url.pathname === "/auth/callback") {
|
||||
const code = url.searchParams.get("code")
|
||||
const state = url.searchParams.get("state")
|
||||
const error = url.searchParams.get("error")
|
||||
const errorDescription = url.searchParams.get("error_description")
|
||||
|
||||
if (error) {
|
||||
const errorMsg = errorDescription || error
|
||||
pendingOAuth?.reject(new Error(errorMsg))
|
||||
pendingOAuth = undefined
|
||||
return new Response(HTML_ERROR(errorMsg), {
|
||||
headers: { "Content-Type": "text/html" },
|
||||
})
|
||||
}
|
||||
|
||||
if (!code) {
|
||||
const errorMsg = "Missing authorization code"
|
||||
pendingOAuth?.reject(new Error(errorMsg))
|
||||
pendingOAuth = undefined
|
||||
return new Response(HTML_ERROR(errorMsg), {
|
||||
status: 400,
|
||||
headers: { "Content-Type": "text/html" },
|
||||
})
|
||||
}
|
||||
|
||||
if (!pendingOAuth || state !== pendingOAuth.state) {
|
||||
const errorMsg = "Invalid state - potential CSRF attack"
|
||||
pendingOAuth?.reject(new Error(errorMsg))
|
||||
pendingOAuth = undefined
|
||||
return new Response(HTML_ERROR(errorMsg), {
|
||||
status: 400,
|
||||
headers: { "Content-Type": "text/html" },
|
||||
})
|
||||
}
|
||||
|
||||
const current = pendingOAuth
|
||||
if (error) {
|
||||
const errorMsg = errorDescription || error
|
||||
pendingOAuth?.reject(new Error(errorMsg))
|
||||
pendingOAuth = undefined
|
||||
|
||||
exchangeCodeForTokens(code, `http://localhost:${OAUTH_PORT}/auth/callback`, current.pkce)
|
||||
.then((tokens) => current.resolve(tokens))
|
||||
.catch((err) => current.reject(err))
|
||||
|
||||
return new Response(HTML_SUCCESS, {
|
||||
headers: { "Content-Type": "text/html" },
|
||||
})
|
||||
res.writeHead(200, { "Content-Type": "text/html" })
|
||||
res.end(HTML_ERROR(errorMsg))
|
||||
return
|
||||
}
|
||||
|
||||
if (url.pathname === "/cancel") {
|
||||
pendingOAuth?.reject(new Error("Login cancelled"))
|
||||
if (!code) {
|
||||
const errorMsg = "Missing authorization code"
|
||||
pendingOAuth?.reject(new Error(errorMsg))
|
||||
pendingOAuth = undefined
|
||||
return new Response("Login cancelled", { status: 200 })
|
||||
res.writeHead(400, { "Content-Type": "text/html" })
|
||||
res.end(HTML_ERROR(errorMsg))
|
||||
return
|
||||
}
|
||||
|
||||
return new Response("Not found", { status: 404 })
|
||||
},
|
||||
if (!pendingOAuth || state !== pendingOAuth.state) {
|
||||
const errorMsg = "Invalid state - potential CSRF attack"
|
||||
pendingOAuth?.reject(new Error(errorMsg))
|
||||
pendingOAuth = undefined
|
||||
res.writeHead(400, { "Content-Type": "text/html" })
|
||||
res.end(HTML_ERROR(errorMsg))
|
||||
return
|
||||
}
|
||||
|
||||
const current = pendingOAuth
|
||||
pendingOAuth = undefined
|
||||
|
||||
exchangeCodeForTokens(code, `http://localhost:${OAUTH_PORT}/auth/callback`, current.pkce)
|
||||
.then((tokens) => current.resolve(tokens))
|
||||
.catch((err) => current.reject(err))
|
||||
|
||||
res.writeHead(200, { "Content-Type": "text/html" })
|
||||
res.end(HTML_SUCCESS)
|
||||
return
|
||||
}
|
||||
|
||||
if (url.pathname === "/cancel") {
|
||||
pendingOAuth?.reject(new Error("Login cancelled"))
|
||||
pendingOAuth = undefined
|
||||
res.writeHead(200)
|
||||
res.end("Login cancelled")
|
||||
return
|
||||
}
|
||||
|
||||
res.writeHead(404)
|
||||
res.end("Not found")
|
||||
})
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
oauthServer!.listen(OAUTH_PORT, () => {
|
||||
log.info("codex oauth server started", { port: OAUTH_PORT })
|
||||
resolve()
|
||||
})
|
||||
oauthServer!.on("error", reject)
|
||||
})
|
||||
|
||||
log.info("codex oauth server started", { port: OAUTH_PORT })
|
||||
return { port: OAUTH_PORT, redirectUri: `http://localhost:${OAUTH_PORT}/auth/callback` }
|
||||
}
|
||||
|
||||
function stopOAuthServer() {
|
||||
if (oauthServer) {
|
||||
oauthServer.stop()
|
||||
oauthServer.close(() => {
|
||||
log.info("codex oauth server stopped")
|
||||
})
|
||||
oauthServer = undefined
|
||||
log.info("codex oauth server stopped")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -4,16 +4,16 @@ import { Bus } from "../bus"
|
||||
import { Log } from "../util/log"
|
||||
import { createOpencodeClient } from "@opencode-ai/sdk"
|
||||
import { Server } from "../server/server"
|
||||
import { BunProc } from "../bun"
|
||||
import { Npm } from "../npm"
|
||||
import { Flag } from "../flag/flag"
|
||||
import { CodexAuthPlugin } from "./codex"
|
||||
import { Session } from "../session"
|
||||
import { NamedError } from "@opencode-ai/util/error"
|
||||
import { CopilotAuthPlugin } from "./copilot"
|
||||
import { gitlabAuthPlugin as GitlabAuthPlugin } from "opencode-gitlab-auth"
|
||||
import { Effect, Layer, ServiceMap } from "effect"
|
||||
import { Effect, Layer, ServiceMap, Stream } from "effect"
|
||||
import { InstanceState } from "@/effect/instance-state"
|
||||
import { makeRunPromise } from "@/effect/run-service"
|
||||
import { makeRuntime } from "@/effect/run-service"
|
||||
|
||||
export namespace Plugin {
|
||||
const log = Log.create({ service: "plugin" })
|
||||
@@ -52,6 +52,8 @@ export namespace Plugin {
|
||||
export const layer = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const bus = yield* Bus.Service
|
||||
|
||||
const cache = yield* InstanceState.make<State>(
|
||||
Effect.fn("Plugin.state")(function* (ctx) {
|
||||
const hooks: Hooks[] = []
|
||||
@@ -94,16 +96,13 @@ export namespace Plugin {
|
||||
if (DEPRECATED_PLUGIN_PACKAGES.some((pkg) => plugin.includes(pkg))) continue
|
||||
log.info("loading plugin", { path: plugin })
|
||||
if (!plugin.startsWith("file://")) {
|
||||
const idx = plugin.lastIndexOf("@")
|
||||
const pkg = idx > 0 ? plugin.substring(0, idx) : plugin
|
||||
const version = idx > 0 ? plugin.substring(idx + 1) : "latest"
|
||||
plugin = await BunProc.install(pkg, version).catch((err) => {
|
||||
plugin = await Npm.add(plugin).catch((err) => {
|
||||
const cause = err instanceof Error ? err.cause : err
|
||||
const detail = cause instanceof Error ? cause.message : String(cause ?? err)
|
||||
log.error("failed to install plugin", { pkg, version, error: detail })
|
||||
log.error("failed to install plugin", { plugin, error: detail })
|
||||
Bus.publish(Session.Event.Error, {
|
||||
error: new NamedError.Unknown({
|
||||
message: `Failed to install plugin ${pkg}@${version}: ${detail}`,
|
||||
message: `Failed to install plugin ${plugin}: ${detail}`,
|
||||
}).toObject(),
|
||||
})
|
||||
return ""
|
||||
@@ -140,17 +139,19 @@ export namespace Plugin {
|
||||
}
|
||||
})
|
||||
|
||||
// Subscribe to bus events, clean up when scope is closed
|
||||
yield* Effect.acquireRelease(
|
||||
Effect.sync(() =>
|
||||
Bus.subscribeAll(async (input) => {
|
||||
for (const hook of hooks) {
|
||||
hook["event"]?.({ event: input })
|
||||
}
|
||||
}),
|
||||
),
|
||||
(unsub) => Effect.sync(unsub),
|
||||
)
|
||||
// Subscribe to bus events, fiber interrupted when scope closes
|
||||
yield* bus
|
||||
.subscribeAll()
|
||||
.pipe(
|
||||
Stream.runForEach((input) =>
|
||||
Effect.sync(() => {
|
||||
for (const hook of hooks) {
|
||||
hook["event"]?.({ event: input as any })
|
||||
}
|
||||
}),
|
||||
),
|
||||
Effect.forkScoped,
|
||||
)
|
||||
|
||||
return { hooks }
|
||||
}),
|
||||
@@ -186,7 +187,8 @@ export namespace Plugin {
|
||||
}),
|
||||
)
|
||||
|
||||
const runPromise = makeRunPromise(Service, layer)
|
||||
const defaultLayer = layer.pipe(Layer.provide(Bus.layer))
|
||||
const { runPromise } = makeRuntime(Service, defaultLayer)
|
||||
|
||||
export async function trigger<
|
||||
Name extends TriggerName,
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { Effect, Layer, ServiceMap } from "effect"
|
||||
import { Effect, Layer, ServiceMap, Stream } from "effect"
|
||||
import { Bus } from "@/bus"
|
||||
import { BusEvent } from "@/bus/bus-event"
|
||||
import { InstanceState } from "@/effect/instance-state"
|
||||
import { makeRunPromise } from "@/effect/run-service"
|
||||
import { makeRuntime } from "@/effect/run-service"
|
||||
import { FileWatcher } from "@/file/watcher"
|
||||
import { Log } from "@/util/log"
|
||||
import { git } from "@/util/git"
|
||||
@@ -44,6 +44,8 @@ export namespace Vcs {
|
||||
export const layer = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const bus = yield* Bus.Service
|
||||
|
||||
const state = yield* InstanceState.make<State>(
|
||||
Effect.fn("Vcs.state")((ctx) =>
|
||||
Effect.gen(function* () {
|
||||
@@ -65,23 +67,22 @@ export namespace Vcs {
|
||||
}
|
||||
log.info("initialized", { branch: value.current })
|
||||
|
||||
yield* Effect.acquireRelease(
|
||||
Effect.sync(() =>
|
||||
Bus.subscribe(
|
||||
FileWatcher.Event.Updated,
|
||||
Instance.bind(async (evt) => {
|
||||
if (!evt.properties.file.endsWith("HEAD")) return
|
||||
const next = await getCurrentBranch()
|
||||
yield* bus
|
||||
.subscribe(FileWatcher.Event.Updated)
|
||||
.pipe(
|
||||
Stream.filter((evt) => evt.properties.file.endsWith("HEAD")),
|
||||
Stream.runForEach((evt) =>
|
||||
Effect.gen(function* () {
|
||||
const next = yield* Effect.promise(() => getCurrentBranch())
|
||||
if (next !== value.current) {
|
||||
log.info("branch changed", { from: value.current, to: next })
|
||||
value.current = next
|
||||
Bus.publish(Event.BranchUpdated, { branch: next })
|
||||
yield* bus.publish(Event.BranchUpdated, { branch: next })
|
||||
}
|
||||
}),
|
||||
),
|
||||
),
|
||||
(unsubscribe) => Effect.sync(unsubscribe),
|
||||
)
|
||||
Effect.forkScoped,
|
||||
)
|
||||
|
||||
return value
|
||||
}),
|
||||
@@ -99,7 +100,8 @@ export namespace Vcs {
|
||||
}),
|
||||
)
|
||||
|
||||
const runPromise = makeRunPromise(Service, layer)
|
||||
const defaultLayer = layer.pipe(Layer.provide(Bus.layer))
|
||||
const { runPromise } = makeRuntime(Service, defaultLayer)
|
||||
|
||||
export function init() {
|
||||
return runPromise((svc) => svc.init())
|
||||
|
||||
@@ -2,7 +2,7 @@ import type { AuthOuathResult, Hooks } from "@opencode-ai/plugin"
|
||||
import { NamedError } from "@opencode-ai/util/error"
|
||||
import { Auth } from "@/auth"
|
||||
import { InstanceState } from "@/effect/instance-state"
|
||||
import { makeRunPromise } from "@/effect/run-service"
|
||||
import { makeRuntime } from "@/effect/run-service"
|
||||
import { Plugin } from "../plugin"
|
||||
import { ProviderID } from "./schema"
|
||||
import { Array as Arr, Effect, Layer, Record, Result, ServiceMap } from "effect"
|
||||
@@ -231,7 +231,7 @@ export namespace ProviderAuth {
|
||||
|
||||
export const defaultLayer = layer.pipe(Layer.provide(Auth.layer))
|
||||
|
||||
const runPromise = makeRunPromise(Service, defaultLayer)
|
||||
const { runPromise } = makeRuntime(Service, defaultLayer)
|
||||
|
||||
export async function methods() {
|
||||
return runPromise((svc) => svc.methods())
|
||||
|
||||
@@ -5,7 +5,7 @@ import { Config } from "../config/config"
|
||||
import { mapValues, mergeDeep, omit, pickBy, sortBy } from "remeda"
|
||||
import { NoSuchModelError, type Provider as SDK } from "ai"
|
||||
import { Log } from "../util/log"
|
||||
import { BunProc } from "../bun"
|
||||
import { Npm } from "../npm"
|
||||
import { Hash } from "../util/hash"
|
||||
import { Plugin } from "../plugin"
|
||||
import { NamedError } from "@opencode-ai/util/error"
|
||||
@@ -1296,7 +1296,7 @@ export namespace Provider {
|
||||
|
||||
let installedPath: string
|
||||
if (!model.api.npm.startsWith("file://")) {
|
||||
installedPath = await BunProc.install(model.api.npm, "latest")
|
||||
installedPath = await Npm.add(model.api.npm)
|
||||
} else {
|
||||
log.info("loading local provider", { pkg: model.api.npm })
|
||||
installedPath = model.api.npm
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { BusEvent } from "@/bus/bus-event"
|
||||
import { Bus } from "@/bus"
|
||||
import { InstanceState } from "@/effect/instance-state"
|
||||
import { makeRunPromise } from "@/effect/run-service"
|
||||
import { makeRuntime } from "@/effect/run-service"
|
||||
import { Instance } from "@/project/instance"
|
||||
import { type IPty } from "bun-pty"
|
||||
import z from "zod"
|
||||
@@ -361,7 +361,7 @@ export namespace Pty {
|
||||
}),
|
||||
)
|
||||
|
||||
const runPromise = makeRunPromise(Service, layer)
|
||||
const { runPromise } = makeRuntime(Service, layer)
|
||||
|
||||
export async function list() {
|
||||
return runPromise((svc) => svc.list())
|
||||
|
||||
@@ -2,7 +2,7 @@ import { Deferred, Effect, Layer, Schema, ServiceMap } from "effect"
|
||||
import { Bus } from "@/bus"
|
||||
import { BusEvent } from "@/bus/bus-event"
|
||||
import { InstanceState } from "@/effect/instance-state"
|
||||
import { makeRunPromise } from "@/effect/run-service"
|
||||
import { makeRuntime } from "@/effect/run-service"
|
||||
import { SessionID, MessageID } from "@/session/schema"
|
||||
import { Log } from "@/util/log"
|
||||
import z from "zod"
|
||||
@@ -197,7 +197,7 @@ export namespace Question {
|
||||
}),
|
||||
)
|
||||
|
||||
const runPromise = makeRunPromise(Service, layer)
|
||||
const { runPromise } = makeRuntime(Service, layer)
|
||||
|
||||
export async function ask(input: {
|
||||
sessionID: SessionID
|
||||
|
||||
@@ -108,7 +108,7 @@ export const ExperimentalRoutes = lazy(() =>
|
||||
...errors(400),
|
||||
},
|
||||
}),
|
||||
validator("json", Worktree.create.schema),
|
||||
validator("json", Worktree.CreateInput.optional()),
|
||||
async (c) => {
|
||||
const body = c.req.valid("json")
|
||||
const worktree = await Worktree.create(body)
|
||||
@@ -155,7 +155,7 @@ export const ExperimentalRoutes = lazy(() =>
|
||||
...errors(400),
|
||||
},
|
||||
}),
|
||||
validator("json", Worktree.remove.schema),
|
||||
validator("json", Worktree.RemoveInput),
|
||||
async (c) => {
|
||||
const body = c.req.valid("json")
|
||||
await Worktree.remove(body)
|
||||
@@ -181,7 +181,7 @@ export const ExperimentalRoutes = lazy(() =>
|
||||
...errors(400),
|
||||
},
|
||||
}),
|
||||
validator("json", Worktree.reset.schema),
|
||||
validator("json", Worktree.ResetInput),
|
||||
async (c) => {
|
||||
const body = c.req.valid("json")
|
||||
await Worktree.reset(body)
|
||||
|
||||
@@ -250,12 +250,16 @@ export namespace LLM {
|
||||
maxOutputTokens,
|
||||
abortSignal: input.abort,
|
||||
headers: {
|
||||
...(input.model.providerID.startsWith("opencode") && {
|
||||
"x-opencode-project": Instance.project.id,
|
||||
"x-opencode-session": input.sessionID,
|
||||
"x-opencode-request": input.user.id,
|
||||
"x-opencode-client": Flag.OPENCODE_CLIENT,
|
||||
}),
|
||||
...(input.model.providerID.startsWith("opencode")
|
||||
? {
|
||||
"x-opencode-project": Instance.project.id,
|
||||
"x-opencode-session": input.sessionID,
|
||||
"x-opencode-request": input.user.id,
|
||||
"x-opencode-client": Flag.OPENCODE_CLIENT,
|
||||
}
|
||||
: {
|
||||
"User-Agent": `opencode/${Installation.VERSION}`,
|
||||
}),
|
||||
...input.model.headers,
|
||||
...headers,
|
||||
},
|
||||
|
||||
@@ -319,11 +319,7 @@ export namespace SessionPrompt {
|
||||
}
|
||||
|
||||
if (!lastUser) throw new Error("No user message found in stream. This should never happen.")
|
||||
if (
|
||||
lastAssistant?.finish &&
|
||||
!["tool-calls", "unknown"].includes(lastAssistant.finish) &&
|
||||
lastUser.id < lastAssistant.id
|
||||
) {
|
||||
if (shouldExitLoop(lastUser, lastAssistant)) {
|
||||
log.info("exiting loop", { sessionID })
|
||||
break
|
||||
}
|
||||
@@ -1998,4 +1994,15 @@ NOTE: At any point in time through this workflow you should feel free to ask the
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/** @internal Exported for testing — determines whether the prompt loop should exit */
|
||||
export function shouldExitLoop(
|
||||
lastUser: MessageV2.User | undefined,
|
||||
lastAssistant: MessageV2.Assistant | undefined,
|
||||
): boolean {
|
||||
if (!lastUser) return false
|
||||
if (!lastAssistant?.finish) return false
|
||||
if (["tool-calls", "unknown"].includes(lastAssistant.finish)) return false
|
||||
return lastAssistant.parentID === lastUser.id
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { BusEvent } from "@/bus/bus-event"
|
||||
import { Bus } from "@/bus"
|
||||
import { InstanceState } from "@/effect/instance-state"
|
||||
import { makeRunPromise } from "@/effect/run-service"
|
||||
import { makeRuntime } from "@/effect/run-service"
|
||||
import { SessionID } from "./schema"
|
||||
import { Effect, Layer, ServiceMap } from "effect"
|
||||
import z from "zod"
|
||||
@@ -55,6 +55,8 @@ export namespace SessionStatus {
|
||||
export const layer = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const bus = yield* Bus.Service
|
||||
|
||||
const state = yield* InstanceState.make(
|
||||
Effect.fn("SessionStatus.state")(() => Effect.succeed(new Map<SessionID, Info>())),
|
||||
)
|
||||
@@ -70,9 +72,9 @@ export namespace SessionStatus {
|
||||
|
||||
const set = Effect.fn("SessionStatus.set")(function* (sessionID: SessionID, status: Info) {
|
||||
const data = yield* InstanceState.get(state)
|
||||
yield* Effect.promise(() => Bus.publish(Event.Status, { sessionID, status }))
|
||||
yield* bus.publish(Event.Status, { sessionID, status })
|
||||
if (status.type === "idle") {
|
||||
yield* Effect.promise(() => Bus.publish(Event.Idle, { sessionID }))
|
||||
yield* bus.publish(Event.Idle, { sessionID })
|
||||
data.delete(sessionID)
|
||||
return
|
||||
}
|
||||
@@ -83,7 +85,8 @@ export namespace SessionStatus {
|
||||
}),
|
||||
)
|
||||
|
||||
const runPromise = makeRunPromise(Service, layer)
|
||||
const defaultLayer = layer.pipe(Layer.provide(Bus.layer))
|
||||
const { runPromise } = makeRuntime(Service, defaultLayer)
|
||||
|
||||
export async function get(sessionID: SessionID) {
|
||||
return runPromise((svc) => svc.get(sessionID))
|
||||
|
||||
@@ -7,7 +7,7 @@ import { NamedError } from "@opencode-ai/util/error"
|
||||
import type { Agent } from "@/agent/agent"
|
||||
import { Bus } from "@/bus"
|
||||
import { InstanceState } from "@/effect/instance-state"
|
||||
import { makeRunPromise } from "@/effect/run-service"
|
||||
import { makeRuntime } from "@/effect/run-service"
|
||||
import { Flag } from "@/flag/flag"
|
||||
import { Global } from "@/global"
|
||||
import { Permission } from "@/permission"
|
||||
@@ -242,7 +242,7 @@ export namespace Skill {
|
||||
return ["## Available Skills", ...list.map((skill) => `- **${skill.name}**: ${skill.description}`)].join("\n")
|
||||
}
|
||||
|
||||
const runPromise = makeRunPromise(Service, defaultLayer)
|
||||
const { runPromise } = makeRuntime(Service, defaultLayer)
|
||||
|
||||
export async function get(name: string) {
|
||||
return runPromise((skill) => skill.get(name))
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import { NodeChildProcessSpawner, NodeFileSystem, NodePath } from "@effect/platform-node"
|
||||
import { NodeFileSystem, NodePath } from "@effect/platform-node"
|
||||
import { Cause, Duration, Effect, Layer, Schedule, ServiceMap, Stream } from "effect"
|
||||
import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"
|
||||
import path from "path"
|
||||
import z from "zod"
|
||||
import * as CrossSpawnSpawner from "@/effect/cross-spawn-spawner"
|
||||
import { InstanceState } from "@/effect/instance-state"
|
||||
import { makeRunPromise } from "@/effect/run-service"
|
||||
import { makeRuntime } from "@/effect/run-service"
|
||||
import { AppFileSystem } from "@/filesystem"
|
||||
import { Config } from "../config/config"
|
||||
import { Global } from "../global"
|
||||
@@ -354,13 +355,13 @@ export namespace Snapshot {
|
||||
)
|
||||
|
||||
export const defaultLayer = layer.pipe(
|
||||
Layer.provide(NodeChildProcessSpawner.layer),
|
||||
Layer.provide(CrossSpawnSpawner.layer),
|
||||
Layer.provide(AppFileSystem.defaultLayer),
|
||||
Layer.provide(NodeFileSystem.layer), // needed by NodeChildProcessSpawner
|
||||
Layer.provide(NodeFileSystem.layer), // needed by CrossSpawnSpawner
|
||||
Layer.provide(NodePath.layer),
|
||||
)
|
||||
|
||||
const runPromise = makeRunPromise(Service, defaultLayer)
|
||||
const { runPromise } = makeRuntime(Service, defaultLayer)
|
||||
|
||||
export async function init() {
|
||||
return runPromise((svc) => svc.init())
|
||||
|
||||
@@ -13,6 +13,7 @@ import { LSP } from "../lsp"
|
||||
import { Filesystem } from "../util/filesystem"
|
||||
import DESCRIPTION from "./apply_patch.txt"
|
||||
import { File } from "../file"
|
||||
import { Format } from "../format"
|
||||
|
||||
const PatchParams = z.object({
|
||||
patchText: z.string().describe("The full patch text that describes all changes to be made"),
|
||||
@@ -220,9 +221,8 @@ export const ApplyPatchTool = Tool.define("apply_patch", {
|
||||
}
|
||||
|
||||
if (edited) {
|
||||
await Bus.publish(File.Event.Edited, {
|
||||
file: edited,
|
||||
})
|
||||
await Format.file(edited)
|
||||
Bus.publish(File.Event.Edited, { file: edited })
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -12,6 +12,7 @@ import DESCRIPTION from "./edit.txt"
|
||||
import { File } from "../file"
|
||||
import { FileWatcher } from "../file/watcher"
|
||||
import { Bus } from "../bus"
|
||||
import { Format } from "../format"
|
||||
import { FileTime } from "../file/time"
|
||||
import { Filesystem } from "../util/filesystem"
|
||||
import { Instance } from "../project/instance"
|
||||
@@ -71,9 +72,8 @@ export const EditTool = Tool.define("edit", {
|
||||
},
|
||||
})
|
||||
await Filesystem.write(filePath, params.newString)
|
||||
await Bus.publish(File.Event.Edited, {
|
||||
file: filePath,
|
||||
})
|
||||
await Format.file(filePath)
|
||||
Bus.publish(File.Event.Edited, { file: filePath })
|
||||
await Bus.publish(FileWatcher.Event.Updated, {
|
||||
file: filePath,
|
||||
event: existed ? "change" : "add",
|
||||
@@ -108,9 +108,8 @@ export const EditTool = Tool.define("edit", {
|
||||
})
|
||||
|
||||
await Filesystem.write(filePath, contentNew)
|
||||
await Bus.publish(File.Event.Edited, {
|
||||
file: filePath,
|
||||
})
|
||||
await Format.file(filePath)
|
||||
Bus.publish(File.Event.Edited, { file: filePath })
|
||||
await Bus.publish(FileWatcher.Event.Updated, {
|
||||
file: filePath,
|
||||
event: "change",
|
||||
|
||||
@@ -31,7 +31,7 @@ import { Glob } from "../util/glob"
|
||||
import { pathToFileURL } from "url"
|
||||
import { Effect, Layer, ServiceMap } from "effect"
|
||||
import { InstanceState } from "@/effect/instance-state"
|
||||
import { makeRunPromise } from "@/effect/run-service"
|
||||
import { makeRuntime } from "@/effect/run-service"
|
||||
|
||||
export namespace ToolRegistry {
|
||||
const log = Log.create({ service: "tool.registry" })
|
||||
@@ -198,7 +198,7 @@ export namespace ToolRegistry {
|
||||
}),
|
||||
)
|
||||
|
||||
const runPromise = makeRunPromise(Service, layer)
|
||||
const { runPromise } = makeRuntime(Service, layer)
|
||||
|
||||
export async function register(tool: Tool.Info) {
|
||||
return runPromise((svc) => svc.register(tool))
|
||||
|
||||
@@ -2,7 +2,7 @@ import { NodePath } from "@effect/platform-node"
|
||||
import { Cause, Duration, Effect, Layer, Schedule, ServiceMap } from "effect"
|
||||
import path from "path"
|
||||
import type { Agent } from "../agent/agent"
|
||||
import { makeRunPromise } from "@/effect/run-service"
|
||||
import { makeRuntime } from "@/effect/run-service"
|
||||
import { AppFileSystem } from "@/filesystem"
|
||||
import { evaluate } from "@/permission/evaluate"
|
||||
import { Identifier } from "../id/id"
|
||||
@@ -136,7 +136,7 @@ export namespace Truncate {
|
||||
|
||||
export const defaultLayer = layer.pipe(Layer.provide(AppFileSystem.defaultLayer), Layer.provide(NodePath.layer))
|
||||
|
||||
const runPromise = makeRunPromise(Service, defaultLayer)
|
||||
const { runPromise } = makeRuntime(Service, defaultLayer)
|
||||
|
||||
export async function output(text: string, options: Options = {}, agent?: Agent.Info): Promise<Result> {
|
||||
return runPromise((s) => s.output(text, options, agent))
|
||||
|
||||
@@ -7,6 +7,7 @@ import DESCRIPTION from "./write.txt"
|
||||
import { Bus } from "../bus"
|
||||
import { File } from "../file"
|
||||
import { FileWatcher } from "../file/watcher"
|
||||
import { Format } from "../format"
|
||||
import { FileTime } from "../file/time"
|
||||
import { Filesystem } from "../util/filesystem"
|
||||
import { Instance } from "../project/instance"
|
||||
@@ -42,9 +43,8 @@ export const WriteTool = Tool.define("write", {
|
||||
})
|
||||
|
||||
await Filesystem.write(filepath, params.content)
|
||||
await Bus.publish(File.Event.Edited, {
|
||||
file: filepath,
|
||||
})
|
||||
await Format.file(filepath)
|
||||
Bus.publish(File.Event.Edited, { file: filepath })
|
||||
await Bus.publish(FileWatcher.Event.Updated, {
|
||||
file: filepath,
|
||||
event: exists ? "change" : "add",
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
import fs from "fs/promises"
|
||||
import path from "path"
|
||||
import z from "zod"
|
||||
import { NamedError } from "@opencode-ai/util/error"
|
||||
import { Global } from "../global"
|
||||
@@ -9,12 +7,13 @@ import { Project } from "../project/project"
|
||||
import { Database, eq } from "../storage/db"
|
||||
import { ProjectTable } from "../project/project.sql"
|
||||
import type { ProjectID } from "../project/schema"
|
||||
import { fn } from "../util/fn"
|
||||
import { Log } from "../util/log"
|
||||
import { Process } from "../util/process"
|
||||
import { git } from "../util/git"
|
||||
import { BusEvent } from "@/bus/bus-event"
|
||||
import { GlobalBus } from "@/bus/global"
|
||||
import { Effect, FileSystem, Layer, Path, Scope, ServiceMap, Stream } from "effect"
|
||||
import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"
|
||||
import { NodeChildProcessSpawner, NodeFileSystem, NodePath } from "@effect/platform-node"
|
||||
import { makeRunPromise } from "@/effect/run-service"
|
||||
|
||||
export namespace Worktree {
|
||||
const log = Log.create({ service: "worktree" })
|
||||
@@ -206,24 +205,8 @@ export namespace Worktree {
|
||||
return `${pick(ADJECTIVES)}-${pick(NOUNS)}`
|
||||
}
|
||||
|
||||
async function exists(target: string) {
|
||||
return fs
|
||||
.stat(target)
|
||||
.then(() => true)
|
||||
.catch(() => false)
|
||||
}
|
||||
|
||||
function outputText(input: Uint8Array | undefined) {
|
||||
if (!input?.length) return ""
|
||||
return new TextDecoder().decode(input).trim()
|
||||
}
|
||||
|
||||
function errorText(result: { stdout?: Uint8Array; stderr?: Uint8Array }) {
|
||||
return [outputText(result.stderr), outputText(result.stdout)].filter(Boolean).join("\n")
|
||||
}
|
||||
|
||||
function failed(result: { stdout?: Uint8Array; stderr?: Uint8Array }) {
|
||||
return [outputText(result.stderr), outputText(result.stdout)].filter(Boolean).flatMap((chunk) =>
|
||||
function failedRemoves(...chunks: string[]) {
|
||||
return chunks.filter(Boolean).flatMap((chunk) =>
|
||||
chunk
|
||||
.split("\n")
|
||||
.map((line) => line.trim())
|
||||
@@ -237,436 +220,460 @@ export namespace Worktree {
|
||||
)
|
||||
}
|
||||
|
||||
async function prune(root: string, entries: string[]) {
|
||||
const base = await canonical(root)
|
||||
await Promise.all(
|
||||
entries.map(async (entry) => {
|
||||
const target = await canonical(path.resolve(root, entry))
|
||||
if (target === base) return
|
||||
if (!target.startsWith(`${base}${path.sep}`)) return
|
||||
await fs.rm(target, { recursive: true, force: true }).catch(() => undefined)
|
||||
}),
|
||||
)
|
||||
// ---------------------------------------------------------------------------
|
||||
// Effect service
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface Interface {
|
||||
readonly makeWorktreeInfo: (name?: string) => Effect.Effect<Info>
|
||||
readonly createFromInfo: (info: Info, startCommand?: string) => Effect.Effect<void>
|
||||
readonly create: (input?: CreateInput) => Effect.Effect<Info>
|
||||
readonly remove: (input: RemoveInput) => Effect.Effect<boolean>
|
||||
readonly reset: (input: ResetInput) => Effect.Effect<boolean>
|
||||
}
|
||||
|
||||
async function sweep(root: string) {
|
||||
const first = await git(["clean", "-ffdx"], { cwd: root })
|
||||
if (first.exitCode === 0) return first
|
||||
export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/Worktree") {}
|
||||
|
||||
const entries = failed(first)
|
||||
if (!entries.length) return first
|
||||
type GitResult = { code: number; text: string; stderr: string }
|
||||
|
||||
await prune(root, entries)
|
||||
return git(["clean", "-ffdx"], { cwd: root })
|
||||
}
|
||||
export const layer: Layer.Layer<
|
||||
Service,
|
||||
never,
|
||||
FileSystem.FileSystem | Path.Path | ChildProcessSpawner.ChildProcessSpawner
|
||||
> = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const scope = yield* Scope.Scope
|
||||
const fsys = yield* FileSystem.FileSystem
|
||||
const pathSvc = yield* Path.Path
|
||||
const spawner = yield* ChildProcessSpawner.ChildProcessSpawner
|
||||
|
||||
async function canonical(input: string) {
|
||||
const abs = path.resolve(input)
|
||||
const real = await fs.realpath(abs).catch(() => abs)
|
||||
const normalized = path.normalize(real)
|
||||
return process.platform === "win32" ? normalized.toLowerCase() : normalized
|
||||
}
|
||||
const git = Effect.fnUntraced(
|
||||
function* (args: string[], opts?: { cwd?: string }) {
|
||||
const handle = yield* spawner.spawn(ChildProcess.make("git", args, { cwd: opts?.cwd, extendEnv: true }))
|
||||
const [text, stderr] = yield* Effect.all(
|
||||
[Stream.mkString(Stream.decodeText(handle.stdout)), Stream.mkString(Stream.decodeText(handle.stderr))],
|
||||
{ concurrency: 2 },
|
||||
)
|
||||
const code = yield* handle.exitCode
|
||||
return { code, text, stderr } satisfies GitResult
|
||||
},
|
||||
Effect.scoped,
|
||||
Effect.catch(() => Effect.succeed({ code: 1, text: "", stderr: "" } satisfies GitResult)),
|
||||
)
|
||||
|
||||
async function candidate(root: string, base?: string) {
|
||||
for (const attempt of Array.from({ length: 26 }, (_, i) => i)) {
|
||||
const name = base ? (attempt === 0 ? base : `${base}-${randomName()}`) : randomName()
|
||||
const branch = `opencode/${name}`
|
||||
const directory = path.join(root, name)
|
||||
const candidate = Effect.fn("Worktree.candidate")(function* (root: string, base?: string) {
|
||||
for (const attempt of Array.from({ length: 26 }, (_, i) => i)) {
|
||||
const name = base ? (attempt === 0 ? base : `${base}-${randomName()}`) : randomName()
|
||||
const branch = `opencode/${name}`
|
||||
const directory = pathSvc.join(root, name)
|
||||
|
||||
if (await exists(directory)) continue
|
||||
if (yield* fsys.exists(directory).pipe(Effect.orDie)) continue
|
||||
|
||||
const ref = `refs/heads/${branch}`
|
||||
const branchCheck = await git(["show-ref", "--verify", "--quiet", ref], {
|
||||
cwd: Instance.worktree,
|
||||
const ref = `refs/heads/${branch}`
|
||||
const branchCheck = yield* git(["show-ref", "--verify", "--quiet", ref], { cwd: Instance.worktree })
|
||||
if (branchCheck.code === 0) continue
|
||||
|
||||
return Info.parse({ name, branch, directory })
|
||||
}
|
||||
throw new NameGenerationFailedError({ message: "Failed to generate a unique worktree name" })
|
||||
})
|
||||
if (branchCheck.exitCode === 0) continue
|
||||
|
||||
return Info.parse({ name, branch, directory })
|
||||
}
|
||||
const makeWorktreeInfo = Effect.fn("Worktree.makeWorktreeInfo")(function* (name?: string) {
|
||||
if (Instance.project.vcs !== "git") {
|
||||
throw new NotGitError({ message: "Worktrees are only supported for git projects" })
|
||||
}
|
||||
|
||||
throw new NameGenerationFailedError({ message: "Failed to generate a unique worktree name" })
|
||||
}
|
||||
const root = pathSvc.join(Global.Path.data, "worktree", Instance.project.id)
|
||||
yield* fsys.makeDirectory(root, { recursive: true }).pipe(Effect.orDie)
|
||||
|
||||
async function runStartCommand(directory: string, cmd: string) {
|
||||
if (process.platform === "win32") {
|
||||
return Process.run(["cmd", "/c", cmd], { cwd: directory, nothrow: true })
|
||||
}
|
||||
return Process.run(["bash", "-lc", cmd], { cwd: directory, nothrow: true })
|
||||
}
|
||||
|
||||
type StartKind = "project" | "worktree"
|
||||
|
||||
async function runStartScript(directory: string, cmd: string, kind: StartKind) {
|
||||
const text = cmd.trim()
|
||||
if (!text) return true
|
||||
|
||||
const ran = await runStartCommand(directory, text)
|
||||
if (ran.code === 0) return true
|
||||
|
||||
log.error("worktree start command failed", {
|
||||
kind,
|
||||
directory,
|
||||
message: errorText(ran),
|
||||
})
|
||||
return false
|
||||
}
|
||||
|
||||
async function runStartScripts(directory: string, input: { projectID: ProjectID; extra?: string }) {
|
||||
const row = Database.use((db) => db.select().from(ProjectTable).where(eq(ProjectTable.id, input.projectID)).get())
|
||||
const project = row ? Project.fromRow(row) : undefined
|
||||
const startup = project?.commands?.start?.trim() ?? ""
|
||||
const ok = await runStartScript(directory, startup, "project")
|
||||
if (!ok) return false
|
||||
|
||||
const extra = input.extra ?? ""
|
||||
await runStartScript(directory, extra, "worktree")
|
||||
return true
|
||||
}
|
||||
|
||||
function queueStartScripts(directory: string, input: { projectID: ProjectID; extra?: string }) {
|
||||
setTimeout(() => {
|
||||
const start = async () => {
|
||||
await runStartScripts(directory, input)
|
||||
}
|
||||
|
||||
void start().catch((error) => {
|
||||
log.error("worktree start task failed", { directory, error })
|
||||
const base = name ? slug(name) : ""
|
||||
return yield* candidate(root, base || undefined)
|
||||
})
|
||||
}, 0)
|
||||
}
|
||||
|
||||
export async function makeWorktreeInfo(name?: string): Promise<Info> {
|
||||
if (Instance.project.vcs !== "git") {
|
||||
throw new NotGitError({ message: "Worktrees are only supported for git projects" })
|
||||
}
|
||||
const createFromInfo = Effect.fn("Worktree.createFromInfo")(function* (info: Info, startCommand?: string) {
|
||||
const created = yield* git(["worktree", "add", "--no-checkout", "-b", info.branch, info.directory], {
|
||||
cwd: Instance.worktree,
|
||||
})
|
||||
if (created.code !== 0) {
|
||||
throw new CreateFailedError({ message: created.stderr || created.text || "Failed to create git worktree" })
|
||||
}
|
||||
|
||||
const root = path.join(Global.Path.data, "worktree", Instance.project.id)
|
||||
await fs.mkdir(root, { recursive: true })
|
||||
yield* Effect.promise(() => Project.addSandbox(Instance.project.id, info.directory).catch(() => undefined))
|
||||
|
||||
const base = name ? slug(name) : ""
|
||||
return candidate(root, base || undefined)
|
||||
}
|
||||
const projectID = Instance.project.id
|
||||
const extra = startCommand?.trim()
|
||||
|
||||
export async function createFromInfo(info: Info, startCommand?: string) {
|
||||
const created = await git(["worktree", "add", "--no-checkout", "-b", info.branch, info.directory], {
|
||||
cwd: Instance.worktree,
|
||||
})
|
||||
if (created.exitCode !== 0) {
|
||||
throw new CreateFailedError({ message: errorText(created) || "Failed to create git worktree" })
|
||||
}
|
||||
|
||||
await Project.addSandbox(Instance.project.id, info.directory).catch(() => undefined)
|
||||
|
||||
const projectID = Instance.project.id
|
||||
const extra = startCommand?.trim()
|
||||
|
||||
return () => {
|
||||
const start = async () => {
|
||||
const populated = await git(["reset", "--hard"], { cwd: info.directory })
|
||||
if (populated.exitCode !== 0) {
|
||||
const message = errorText(populated) || "Failed to populate worktree"
|
||||
const populated = yield* git(["reset", "--hard"], { cwd: info.directory })
|
||||
if (populated.code !== 0) {
|
||||
const message = populated.stderr || populated.text || "Failed to populate worktree"
|
||||
log.error("worktree checkout failed", { directory: info.directory, message })
|
||||
GlobalBus.emit("event", {
|
||||
directory: info.directory,
|
||||
payload: {
|
||||
type: Event.Failed.type,
|
||||
properties: {
|
||||
message,
|
||||
},
|
||||
},
|
||||
payload: { type: Event.Failed.type, properties: { message } },
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
const booted = await Instance.provide({
|
||||
directory: info.directory,
|
||||
init: InstanceBootstrap,
|
||||
fn: () => undefined,
|
||||
})
|
||||
.then(() => true)
|
||||
.catch((error) => {
|
||||
const message = error instanceof Error ? error.message : String(error)
|
||||
log.error("worktree bootstrap failed", { directory: info.directory, message })
|
||||
GlobalBus.emit("event", {
|
||||
directory: info.directory,
|
||||
payload: {
|
||||
type: Event.Failed.type,
|
||||
properties: {
|
||||
message,
|
||||
},
|
||||
},
|
||||
})
|
||||
return false
|
||||
const booted = yield* Effect.promise(() =>
|
||||
Instance.provide({
|
||||
directory: info.directory,
|
||||
init: InstanceBootstrap,
|
||||
fn: () => undefined,
|
||||
})
|
||||
.then(() => true)
|
||||
.catch((error) => {
|
||||
const message = error instanceof Error ? error.message : String(error)
|
||||
log.error("worktree bootstrap failed", { directory: info.directory, message })
|
||||
GlobalBus.emit("event", {
|
||||
directory: info.directory,
|
||||
payload: { type: Event.Failed.type, properties: { message } },
|
||||
})
|
||||
return false
|
||||
}),
|
||||
)
|
||||
if (!booted) return
|
||||
|
||||
GlobalBus.emit("event", {
|
||||
directory: info.directory,
|
||||
payload: {
|
||||
type: Event.Ready.type,
|
||||
properties: {
|
||||
name: info.name,
|
||||
branch: info.branch,
|
||||
},
|
||||
properties: { name: info.name, branch: info.branch },
|
||||
},
|
||||
})
|
||||
|
||||
await runStartScripts(info.directory, { projectID, extra })
|
||||
yield* runStartScripts(info.directory, { projectID, extra })
|
||||
})
|
||||
|
||||
const create = Effect.fn("Worktree.create")(function* (input?: CreateInput) {
|
||||
const info = yield* makeWorktreeInfo(input?.name)
|
||||
yield* createFromInfo(info, input?.startCommand).pipe(
|
||||
Effect.catchCause((cause) => Effect.sync(() => log.error("worktree bootstrap failed", { cause }))),
|
||||
Effect.forkIn(scope),
|
||||
)
|
||||
return info
|
||||
})
|
||||
|
||||
const canonical = Effect.fnUntraced(function* (input: string) {
|
||||
const abs = pathSvc.resolve(input)
|
||||
const real = yield* fsys.realPath(abs).pipe(Effect.catch(() => Effect.succeed(abs)))
|
||||
const normalized = pathSvc.normalize(real)
|
||||
return process.platform === "win32" ? normalized.toLowerCase() : normalized
|
||||
})
|
||||
|
||||
function parseWorktreeList(text: string) {
|
||||
return text
|
||||
.split("\n")
|
||||
.map((line) => line.trim())
|
||||
.reduce<{ path?: string; branch?: string }[]>((acc, line) => {
|
||||
if (!line) return acc
|
||||
if (line.startsWith("worktree ")) {
|
||||
acc.push({ path: line.slice("worktree ".length).trim() })
|
||||
return acc
|
||||
}
|
||||
const current = acc[acc.length - 1]
|
||||
if (!current) return acc
|
||||
if (line.startsWith("branch ")) {
|
||||
current.branch = line.slice("branch ".length).trim()
|
||||
}
|
||||
return acc
|
||||
}, [])
|
||||
}
|
||||
|
||||
return start().catch((error) => {
|
||||
log.error("worktree start task failed", { directory: info.directory, error })
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
export const create = fn(CreateInput.optional(), async (input) => {
|
||||
const info = await makeWorktreeInfo(input?.name)
|
||||
const bootstrap = await createFromInfo(info, input?.startCommand)
|
||||
// This is needed due to how worktrees currently work in the
|
||||
// desktop app
|
||||
setTimeout(() => {
|
||||
bootstrap()
|
||||
}, 0)
|
||||
return info
|
||||
})
|
||||
|
||||
export const remove = fn(RemoveInput, async (input) => {
|
||||
if (Instance.project.vcs !== "git") {
|
||||
throw new NotGitError({ message: "Worktrees are only supported for git projects" })
|
||||
}
|
||||
|
||||
const directory = await canonical(input.directory)
|
||||
const locate = async (stdout: Uint8Array | undefined) => {
|
||||
const lines = outputText(stdout)
|
||||
.split("\n")
|
||||
.map((line) => line.trim())
|
||||
const entries = lines.reduce<{ path?: string; branch?: string }[]>((acc, line) => {
|
||||
if (!line) return acc
|
||||
if (line.startsWith("worktree ")) {
|
||||
acc.push({ path: line.slice("worktree ".length).trim() })
|
||||
return acc
|
||||
}
|
||||
const current = acc[acc.length - 1]
|
||||
if (!current) return acc
|
||||
if (line.startsWith("branch ")) {
|
||||
current.branch = line.slice("branch ".length).trim()
|
||||
}
|
||||
return acc
|
||||
}, [])
|
||||
|
||||
return (async () => {
|
||||
const locateWorktree = Effect.fnUntraced(function* (
|
||||
entries: { path?: string; branch?: string }[],
|
||||
directory: string,
|
||||
) {
|
||||
for (const item of entries) {
|
||||
if (!item.path) continue
|
||||
const key = await canonical(item.path)
|
||||
const key = yield* canonical(item.path)
|
||||
if (key === directory) return item
|
||||
}
|
||||
})()
|
||||
}
|
||||
return undefined
|
||||
})
|
||||
|
||||
const clean = (target: string) =>
|
||||
fs
|
||||
.rm(target, {
|
||||
recursive: true,
|
||||
force: true,
|
||||
maxRetries: 5,
|
||||
retryDelay: 100,
|
||||
})
|
||||
.catch((error) => {
|
||||
const message = error instanceof Error ? error.message : String(error)
|
||||
throw new RemoveFailedError({ message: message || "Failed to remove git worktree directory" })
|
||||
})
|
||||
|
||||
const stop = async (target: string) => {
|
||||
if (!(await exists(target))) return
|
||||
await git(["fsmonitor--daemon", "stop"], { cwd: target })
|
||||
}
|
||||
|
||||
const list = await git(["worktree", "list", "--porcelain"], { cwd: Instance.worktree })
|
||||
if (list.exitCode !== 0) {
|
||||
throw new RemoveFailedError({ message: errorText(list) || "Failed to read git worktrees" })
|
||||
}
|
||||
|
||||
const entry = await locate(list.stdout)
|
||||
|
||||
if (!entry?.path) {
|
||||
const directoryExists = await exists(directory)
|
||||
if (directoryExists) {
|
||||
await stop(directory)
|
||||
await clean(directory)
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
await stop(entry.path)
|
||||
const removed = await git(["worktree", "remove", "--force", entry.path], {
|
||||
cwd: Instance.worktree,
|
||||
})
|
||||
if (removed.exitCode !== 0) {
|
||||
const next = await git(["worktree", "list", "--porcelain"], { cwd: Instance.worktree })
|
||||
if (next.exitCode !== 0) {
|
||||
throw new RemoveFailedError({
|
||||
message: errorText(removed) || errorText(next) || "Failed to remove git worktree",
|
||||
})
|
||||
function stopFsmonitor(target: string) {
|
||||
return fsys.exists(target).pipe(
|
||||
Effect.orDie,
|
||||
Effect.flatMap((exists) => (exists ? git(["fsmonitor--daemon", "stop"], { cwd: target }) : Effect.void)),
|
||||
)
|
||||
}
|
||||
|
||||
const stale = await locate(next.stdout)
|
||||
if (stale?.path) {
|
||||
throw new RemoveFailedError({ message: errorText(removed) || "Failed to remove git worktree" })
|
||||
function cleanDirectory(target: string) {
|
||||
return fsys.remove(target, { recursive: true }).pipe(Effect.ignore)
|
||||
}
|
||||
}
|
||||
|
||||
await clean(entry.path)
|
||||
const remove = Effect.fn("Worktree.remove")(function* (input: RemoveInput) {
|
||||
if (Instance.project.vcs !== "git") {
|
||||
throw new NotGitError({ message: "Worktrees are only supported for git projects" })
|
||||
}
|
||||
|
||||
const branch = entry.branch?.replace(/^refs\/heads\//, "")
|
||||
if (branch) {
|
||||
const deleted = await git(["branch", "-D", branch], { cwd: Instance.worktree })
|
||||
if (deleted.exitCode !== 0) {
|
||||
throw new RemoveFailedError({ message: errorText(deleted) || "Failed to delete worktree branch" })
|
||||
}
|
||||
}
|
||||
const directory = yield* canonical(input.directory)
|
||||
|
||||
return true
|
||||
})
|
||||
const list = yield* git(["worktree", "list", "--porcelain"], { cwd: Instance.worktree })
|
||||
if (list.code !== 0) {
|
||||
throw new RemoveFailedError({ message: list.stderr || list.text || "Failed to read git worktrees" })
|
||||
}
|
||||
|
||||
export const reset = fn(ResetInput, async (input) => {
|
||||
if (Instance.project.vcs !== "git") {
|
||||
throw new NotGitError({ message: "Worktrees are only supported for git projects" })
|
||||
}
|
||||
const entries = parseWorktreeList(list.text)
|
||||
const entry = yield* locateWorktree(entries, directory)
|
||||
|
||||
const directory = await canonical(input.directory)
|
||||
const primary = await canonical(Instance.worktree)
|
||||
if (directory === primary) {
|
||||
throw new ResetFailedError({ message: "Cannot reset the primary workspace" })
|
||||
}
|
||||
if (!entry?.path) {
|
||||
const directoryExists = yield* fsys.exists(directory).pipe(Effect.orDie)
|
||||
if (directoryExists) {
|
||||
yield* stopFsmonitor(directory)
|
||||
yield* cleanDirectory(directory)
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
const list = await git(["worktree", "list", "--porcelain"], { cwd: Instance.worktree })
|
||||
if (list.exitCode !== 0) {
|
||||
throw new ResetFailedError({ message: errorText(list) || "Failed to read git worktrees" })
|
||||
}
|
||||
yield* stopFsmonitor(entry.path)
|
||||
const removed = yield* git(["worktree", "remove", "--force", entry.path], { cwd: Instance.worktree })
|
||||
if (removed.code !== 0) {
|
||||
const next = yield* git(["worktree", "list", "--porcelain"], { cwd: Instance.worktree })
|
||||
if (next.code !== 0) {
|
||||
throw new RemoveFailedError({
|
||||
message: removed.stderr || removed.text || next.stderr || next.text || "Failed to remove git worktree",
|
||||
})
|
||||
}
|
||||
|
||||
const lines = outputText(list.stdout)
|
||||
.split("\n")
|
||||
.map((line) => line.trim())
|
||||
const entries = lines.reduce<{ path?: string; branch?: string }[]>((acc, line) => {
|
||||
if (!line) return acc
|
||||
if (line.startsWith("worktree ")) {
|
||||
acc.push({ path: line.slice("worktree ".length).trim() })
|
||||
return acc
|
||||
}
|
||||
const current = acc[acc.length - 1]
|
||||
if (!current) return acc
|
||||
if (line.startsWith("branch ")) {
|
||||
current.branch = line.slice("branch ".length).trim()
|
||||
}
|
||||
return acc
|
||||
}, [])
|
||||
const stale = yield* locateWorktree(parseWorktreeList(next.text), directory)
|
||||
if (stale?.path) {
|
||||
throw new RemoveFailedError({ message: removed.stderr || removed.text || "Failed to remove git worktree" })
|
||||
}
|
||||
}
|
||||
|
||||
const entry = await (async () => {
|
||||
for (const item of entries) {
|
||||
if (!item.path) continue
|
||||
const key = await canonical(item.path)
|
||||
if (key === directory) return item
|
||||
}
|
||||
})()
|
||||
if (!entry?.path) {
|
||||
throw new ResetFailedError({ message: "Worktree not found" })
|
||||
}
|
||||
yield* cleanDirectory(entry.path)
|
||||
|
||||
const remoteList = await git(["remote"], { cwd: Instance.worktree })
|
||||
if (remoteList.exitCode !== 0) {
|
||||
throw new ResetFailedError({ message: errorText(remoteList) || "Failed to list git remotes" })
|
||||
}
|
||||
const branch = entry.branch?.replace(/^refs\/heads\//, "")
|
||||
if (branch) {
|
||||
const deleted = yield* git(["branch", "-D", branch], { cwd: Instance.worktree })
|
||||
if (deleted.code !== 0) {
|
||||
throw new RemoveFailedError({
|
||||
message: deleted.stderr || deleted.text || "Failed to delete worktree branch",
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const remotes = outputText(remoteList.stdout)
|
||||
.split("\n")
|
||||
.map((line) => line.trim())
|
||||
.filter(Boolean)
|
||||
return true
|
||||
})
|
||||
|
||||
const remote = remotes.includes("origin")
|
||||
? "origin"
|
||||
: remotes.length === 1
|
||||
? remotes[0]
|
||||
: remotes.includes("upstream")
|
||||
? "upstream"
|
||||
: ""
|
||||
const gitExpect = Effect.fnUntraced(function* (
|
||||
args: string[],
|
||||
opts: { cwd: string },
|
||||
error: (r: GitResult) => Error,
|
||||
) {
|
||||
const result = yield* git(args, opts)
|
||||
if (result.code !== 0) throw error(result)
|
||||
return result
|
||||
})
|
||||
|
||||
const remoteHead = remote
|
||||
? await git(["symbolic-ref", `refs/remotes/${remote}/HEAD`], { cwd: Instance.worktree })
|
||||
: { exitCode: 1, stdout: undefined, stderr: undefined }
|
||||
const runStartCommand = Effect.fnUntraced(
|
||||
function* (directory: string, cmd: string) {
|
||||
const [shell, args] = process.platform === "win32" ? ["cmd", ["/c", cmd]] : ["bash", ["-lc", cmd]]
|
||||
const handle = yield* spawner.spawn(ChildProcess.make(shell, args, { cwd: directory, extendEnv: true }))
|
||||
const code = yield* handle.exitCode
|
||||
return code
|
||||
},
|
||||
Effect.scoped,
|
||||
Effect.catch(() => Effect.succeed(1)),
|
||||
)
|
||||
|
||||
const remoteRef = remoteHead.exitCode === 0 ? outputText(remoteHead.stdout) : ""
|
||||
const remoteTarget = remoteRef ? remoteRef.replace(/^refs\/remotes\//, "") : ""
|
||||
const remoteBranch = remote && remoteTarget.startsWith(`${remote}/`) ? remoteTarget.slice(`${remote}/`.length) : ""
|
||||
const runStartScript = Effect.fnUntraced(function* (directory: string, cmd: string, kind: string) {
|
||||
const text = cmd.trim()
|
||||
if (!text) return true
|
||||
const code = yield* runStartCommand(directory, text)
|
||||
if (code === 0) return true
|
||||
log.error("worktree start command failed", { kind, directory })
|
||||
return false
|
||||
})
|
||||
|
||||
const mainCheck = await git(["show-ref", "--verify", "--quiet", "refs/heads/main"], {
|
||||
cwd: Instance.worktree,
|
||||
})
|
||||
const masterCheck = await git(["show-ref", "--verify", "--quiet", "refs/heads/master"], {
|
||||
cwd: Instance.worktree,
|
||||
})
|
||||
const localBranch = mainCheck.exitCode === 0 ? "main" : masterCheck.exitCode === 0 ? "master" : ""
|
||||
const runStartScripts = Effect.fnUntraced(function* (
|
||||
directory: string,
|
||||
input: { projectID: ProjectID; extra?: string },
|
||||
) {
|
||||
const row = yield* Effect.sync(() =>
|
||||
Database.use((db) => db.select().from(ProjectTable).where(eq(ProjectTable.id, input.projectID)).get()),
|
||||
)
|
||||
const project = row ? Project.fromRow(row) : undefined
|
||||
const startup = project?.commands?.start?.trim() ?? ""
|
||||
const ok = yield* runStartScript(directory, startup, "project")
|
||||
if (!ok) return false
|
||||
yield* runStartScript(directory, input.extra ?? "", "worktree")
|
||||
return true
|
||||
})
|
||||
|
||||
const target = remoteBranch ? `${remote}/${remoteBranch}` : localBranch
|
||||
if (!target) {
|
||||
throw new ResetFailedError({ message: "Default branch not found" })
|
||||
}
|
||||
const prune = Effect.fnUntraced(function* (root: string, entries: string[]) {
|
||||
const base = yield* canonical(root)
|
||||
yield* Effect.forEach(
|
||||
entries,
|
||||
(entry) =>
|
||||
Effect.gen(function* () {
|
||||
const target = yield* canonical(pathSvc.resolve(root, entry))
|
||||
if (target === base) return
|
||||
if (!target.startsWith(`${base}${pathSvc.sep}`)) return
|
||||
yield* fsys.remove(target, { recursive: true }).pipe(Effect.ignore)
|
||||
}),
|
||||
{ concurrency: "unbounded" },
|
||||
)
|
||||
})
|
||||
|
||||
if (remoteBranch) {
|
||||
const fetch = await git(["fetch", remote, remoteBranch], { cwd: Instance.worktree })
|
||||
if (fetch.exitCode !== 0) {
|
||||
throw new ResetFailedError({ message: errorText(fetch) || `Failed to fetch ${target}` })
|
||||
}
|
||||
}
|
||||
const sweep = Effect.fnUntraced(function* (root: string) {
|
||||
const first = yield* git(["clean", "-ffdx"], { cwd: root })
|
||||
if (first.code === 0) return first
|
||||
|
||||
if (!entry.path) {
|
||||
throw new ResetFailedError({ message: "Worktree path not found" })
|
||||
}
|
||||
const entries = failedRemoves(first.stderr, first.text)
|
||||
if (!entries.length) return first
|
||||
|
||||
const worktreePath = entry.path
|
||||
yield* prune(root, entries)
|
||||
return yield* git(["clean", "-ffdx"], { cwd: root })
|
||||
})
|
||||
|
||||
const resetToTarget = await git(["reset", "--hard", target], { cwd: worktreePath })
|
||||
if (resetToTarget.exitCode !== 0) {
|
||||
throw new ResetFailedError({ message: errorText(resetToTarget) || "Failed to reset worktree to target" })
|
||||
}
|
||||
const reset = Effect.fn("Worktree.reset")(function* (input: ResetInput) {
|
||||
if (Instance.project.vcs !== "git") {
|
||||
throw new NotGitError({ message: "Worktrees are only supported for git projects" })
|
||||
}
|
||||
|
||||
const clean = await sweep(worktreePath)
|
||||
if (clean.exitCode !== 0) {
|
||||
throw new ResetFailedError({ message: errorText(clean) || "Failed to clean worktree" })
|
||||
}
|
||||
const directory = yield* canonical(input.directory)
|
||||
const primary = yield* canonical(Instance.worktree)
|
||||
if (directory === primary) {
|
||||
throw new ResetFailedError({ message: "Cannot reset the primary workspace" })
|
||||
}
|
||||
|
||||
const update = await git(["submodule", "update", "--init", "--recursive", "--force"], { cwd: worktreePath })
|
||||
if (update.exitCode !== 0) {
|
||||
throw new ResetFailedError({ message: errorText(update) || "Failed to update submodules" })
|
||||
}
|
||||
const list = yield* git(["worktree", "list", "--porcelain"], { cwd: Instance.worktree })
|
||||
if (list.code !== 0) {
|
||||
throw new ResetFailedError({ message: list.stderr || list.text || "Failed to read git worktrees" })
|
||||
}
|
||||
|
||||
const subReset = await git(["submodule", "foreach", "--recursive", "git", "reset", "--hard"], {
|
||||
cwd: worktreePath,
|
||||
})
|
||||
if (subReset.exitCode !== 0) {
|
||||
throw new ResetFailedError({ message: errorText(subReset) || "Failed to reset submodules" })
|
||||
}
|
||||
const entry = yield* locateWorktree(parseWorktreeList(list.text), directory)
|
||||
if (!entry?.path) {
|
||||
throw new ResetFailedError({ message: "Worktree not found" })
|
||||
}
|
||||
|
||||
const subClean = await git(["submodule", "foreach", "--recursive", "git", "clean", "-fdx"], {
|
||||
cwd: worktreePath,
|
||||
})
|
||||
if (subClean.exitCode !== 0) {
|
||||
throw new ResetFailedError({ message: errorText(subClean) || "Failed to clean submodules" })
|
||||
}
|
||||
const worktreePath = entry.path
|
||||
|
||||
const status = await git(["-c", "core.fsmonitor=false", "status", "--porcelain=v1"], { cwd: worktreePath })
|
||||
if (status.exitCode !== 0) {
|
||||
throw new ResetFailedError({ message: errorText(status) || "Failed to read git status" })
|
||||
}
|
||||
const remoteList = yield* git(["remote"], { cwd: Instance.worktree })
|
||||
if (remoteList.code !== 0) {
|
||||
throw new ResetFailedError({ message: remoteList.stderr || remoteList.text || "Failed to list git remotes" })
|
||||
}
|
||||
|
||||
const dirty = outputText(status.stdout)
|
||||
if (dirty) {
|
||||
throw new ResetFailedError({ message: `Worktree reset left local changes:\n${dirty}` })
|
||||
}
|
||||
const remotes = remoteList.text
|
||||
.split("\n")
|
||||
.map((l) => l.trim())
|
||||
.filter(Boolean)
|
||||
const remote = remotes.includes("origin")
|
||||
? "origin"
|
||||
: remotes.length === 1
|
||||
? remotes[0]
|
||||
: remotes.includes("upstream")
|
||||
? "upstream"
|
||||
: ""
|
||||
|
||||
const projectID = Instance.project.id
|
||||
queueStartScripts(worktreePath, { projectID })
|
||||
const remoteHead = remote
|
||||
? yield* git(["symbolic-ref", `refs/remotes/${remote}/HEAD`], { cwd: Instance.worktree })
|
||||
: { code: 1, text: "", stderr: "" }
|
||||
|
||||
return true
|
||||
})
|
||||
const remoteRef = remoteHead.code === 0 ? remoteHead.text.trim() : ""
|
||||
const remoteTarget = remoteRef ? remoteRef.replace(/^refs\/remotes\//, "") : ""
|
||||
const remoteBranch =
|
||||
remote && remoteTarget.startsWith(`${remote}/`) ? remoteTarget.slice(`${remote}/`.length) : ""
|
||||
|
||||
const [mainCheck, masterCheck] = yield* Effect.all(
|
||||
[
|
||||
git(["show-ref", "--verify", "--quiet", "refs/heads/main"], { cwd: Instance.worktree }),
|
||||
git(["show-ref", "--verify", "--quiet", "refs/heads/master"], { cwd: Instance.worktree }),
|
||||
],
|
||||
{ concurrency: 2 },
|
||||
)
|
||||
const localBranch = mainCheck.code === 0 ? "main" : masterCheck.code === 0 ? "master" : ""
|
||||
|
||||
const target = remoteBranch ? `${remote}/${remoteBranch}` : localBranch
|
||||
if (!target) {
|
||||
throw new ResetFailedError({ message: "Default branch not found" })
|
||||
}
|
||||
|
||||
if (remoteBranch) {
|
||||
yield* gitExpect(
|
||||
["fetch", remote, remoteBranch],
|
||||
{ cwd: Instance.worktree },
|
||||
(r) => new ResetFailedError({ message: r.stderr || r.text || `Failed to fetch ${target}` }),
|
||||
)
|
||||
}
|
||||
|
||||
yield* gitExpect(
|
||||
["reset", "--hard", target],
|
||||
{ cwd: worktreePath },
|
||||
(r) => new ResetFailedError({ message: r.stderr || r.text || "Failed to reset worktree to target" }),
|
||||
)
|
||||
|
||||
const cleanResult = yield* sweep(worktreePath)
|
||||
if (cleanResult.code !== 0) {
|
||||
throw new ResetFailedError({ message: cleanResult.stderr || cleanResult.text || "Failed to clean worktree" })
|
||||
}
|
||||
|
||||
yield* gitExpect(
|
||||
["submodule", "update", "--init", "--recursive", "--force"],
|
||||
{ cwd: worktreePath },
|
||||
(r) => new ResetFailedError({ message: r.stderr || r.text || "Failed to update submodules" }),
|
||||
)
|
||||
|
||||
yield* gitExpect(
|
||||
["submodule", "foreach", "--recursive", "git", "reset", "--hard"],
|
||||
{ cwd: worktreePath },
|
||||
(r) => new ResetFailedError({ message: r.stderr || r.text || "Failed to reset submodules" }),
|
||||
)
|
||||
|
||||
yield* gitExpect(
|
||||
["submodule", "foreach", "--recursive", "git", "clean", "-fdx"],
|
||||
{ cwd: worktreePath },
|
||||
(r) => new ResetFailedError({ message: r.stderr || r.text || "Failed to clean submodules" }),
|
||||
)
|
||||
|
||||
const status = yield* git(["-c", "core.fsmonitor=false", "status", "--porcelain=v1"], { cwd: worktreePath })
|
||||
if (status.code !== 0) {
|
||||
throw new ResetFailedError({ message: status.stderr || status.text || "Failed to read git status" })
|
||||
}
|
||||
|
||||
if (status.text.trim()) {
|
||||
throw new ResetFailedError({ message: `Worktree reset left local changes:\n${status.text.trim()}` })
|
||||
}
|
||||
|
||||
yield* runStartScripts(worktreePath, { projectID: Instance.project.id }).pipe(
|
||||
Effect.catchCause((cause) => Effect.sync(() => log.error("worktree start task failed", { cause }))),
|
||||
Effect.forkIn(scope),
|
||||
)
|
||||
|
||||
return true
|
||||
})
|
||||
|
||||
return Service.of({ makeWorktreeInfo, createFromInfo, create, remove, reset })
|
||||
}),
|
||||
)
|
||||
|
||||
const defaultLayer = layer.pipe(
|
||||
Layer.provide(NodeChildProcessSpawner.layer),
|
||||
Layer.provide(NodeFileSystem.layer),
|
||||
Layer.provide(NodePath.layer),
|
||||
)
|
||||
const runPromise = makeRunPromise(Service, defaultLayer)
|
||||
|
||||
export async function makeWorktreeInfo(name?: string) {
|
||||
return runPromise((svc) => svc.makeWorktreeInfo(name))
|
||||
}
|
||||
|
||||
export async function createFromInfo(info: Info, startCommand?: string) {
|
||||
return runPromise((svc) => svc.createFromInfo(info, startCommand))
|
||||
}
|
||||
|
||||
export async function create(input?: CreateInput) {
|
||||
return runPromise((svc) => svc.create(input))
|
||||
}
|
||||
|
||||
export async function remove(input: RemoveInput) {
|
||||
return runPromise((svc) => svc.remove(input))
|
||||
}
|
||||
|
||||
export async function reset(input: ResetInput) {
|
||||
return runPromise((svc) => svc.reset(input))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -42,7 +42,7 @@ test("build agent has correct default properties", async () => {
|
||||
expect(build).toBeDefined()
|
||||
expect(build?.mode).toBe("primary")
|
||||
expect(build?.native).toBe(true)
|
||||
expect(evalPerm(build, "edit")).toBe("allow")
|
||||
expect(evalPerm(build, "edit")).toBe("ask")
|
||||
expect(evalPerm(build, "bash")).toBe("allow")
|
||||
},
|
||||
})
|
||||
@@ -221,8 +221,8 @@ test("agent permission config merges with defaults", async () => {
|
||||
expect(build).toBeDefined()
|
||||
// Specific pattern is denied
|
||||
expect(Permission.evaluate("bash", "rm -rf *", build!.permission).action).toBe("deny")
|
||||
// Edit still allowed
|
||||
expect(evalPerm(build, "edit")).toBe("allow")
|
||||
// Edit still asks (default behavior)
|
||||
expect(evalPerm(build, "edit")).toBe("ask")
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,53 +0,0 @@
|
||||
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')
|
||||
}
|
||||
})
|
||||
})
|
||||
164
packages/opencode/test/bus/bus-effect.test.ts
Normal file
164
packages/opencode/test/bus/bus-effect.test.ts
Normal file
@@ -0,0 +1,164 @@
|
||||
import { NodeChildProcessSpawner, NodeFileSystem, NodePath } from "@effect/platform-node"
|
||||
import { describe, expect } from "bun:test"
|
||||
import { Deferred, Effect, Layer, Stream } from "effect"
|
||||
import z from "zod"
|
||||
import { Bus } from "../../src/bus"
|
||||
import { BusEvent } from "../../src/bus/bus-event"
|
||||
import { Instance } from "../../src/project/instance"
|
||||
import { provideInstance, provideTmpdirInstance, tmpdirScoped } from "../fixture/fixture"
|
||||
import { testEffect } from "../lib/effect"
|
||||
|
||||
const TestEvent = {
|
||||
Ping: BusEvent.define("test.effect.ping", z.object({ value: z.number() })),
|
||||
Pong: BusEvent.define("test.effect.pong", z.object({ message: z.string() })),
|
||||
}
|
||||
|
||||
const node = NodeChildProcessSpawner.layer.pipe(
|
||||
Layer.provideMerge(Layer.mergeAll(NodeFileSystem.layer, NodePath.layer)),
|
||||
)
|
||||
|
||||
const live = Layer.mergeAll(Bus.layer, node)
|
||||
|
||||
const it = testEffect(live)
|
||||
|
||||
describe("Bus (Effect-native)", () => {
|
||||
it.effect("publish + subscribe stream delivers events", () =>
|
||||
provideTmpdirInstance(() =>
|
||||
Effect.gen(function* () {
|
||||
const bus = yield* Bus.Service
|
||||
const received: number[] = []
|
||||
const done = yield* Deferred.make<void>()
|
||||
|
||||
yield* Stream.runForEach(bus.subscribe(TestEvent.Ping), (evt) =>
|
||||
Effect.sync(() => {
|
||||
received.push(evt.properties.value)
|
||||
if (received.length === 2) Deferred.doneUnsafe(done, Effect.void)
|
||||
}),
|
||||
).pipe(Effect.forkScoped)
|
||||
|
||||
yield* Effect.sleep("10 millis")
|
||||
yield* bus.publish(TestEvent.Ping, { value: 1 })
|
||||
yield* bus.publish(TestEvent.Ping, { value: 2 })
|
||||
yield* Deferred.await(done)
|
||||
|
||||
expect(received).toEqual([1, 2])
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
it.effect("subscribe filters by event type", () =>
|
||||
provideTmpdirInstance(() =>
|
||||
Effect.gen(function* () {
|
||||
const bus = yield* Bus.Service
|
||||
const pings: number[] = []
|
||||
const done = yield* Deferred.make<void>()
|
||||
|
||||
yield* Stream.runForEach(bus.subscribe(TestEvent.Ping), (evt) =>
|
||||
Effect.sync(() => {
|
||||
pings.push(evt.properties.value)
|
||||
Deferred.doneUnsafe(done, Effect.void)
|
||||
}),
|
||||
).pipe(Effect.forkScoped)
|
||||
|
||||
yield* Effect.sleep("10 millis")
|
||||
yield* bus.publish(TestEvent.Pong, { message: "ignored" })
|
||||
yield* bus.publish(TestEvent.Ping, { value: 42 })
|
||||
yield* Deferred.await(done)
|
||||
|
||||
expect(pings).toEqual([42])
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
it.effect("subscribeAll receives all types", () =>
|
||||
provideTmpdirInstance(() =>
|
||||
Effect.gen(function* () {
|
||||
const bus = yield* Bus.Service
|
||||
const types: string[] = []
|
||||
const done = yield* Deferred.make<void>()
|
||||
|
||||
yield* Stream.runForEach(bus.subscribeAll(), (evt) =>
|
||||
Effect.sync(() => {
|
||||
types.push(evt.type)
|
||||
if (types.length === 2) Deferred.doneUnsafe(done, Effect.void)
|
||||
}),
|
||||
).pipe(Effect.forkScoped)
|
||||
|
||||
yield* Effect.sleep("10 millis")
|
||||
yield* bus.publish(TestEvent.Ping, { value: 1 })
|
||||
yield* bus.publish(TestEvent.Pong, { message: "hi" })
|
||||
yield* Deferred.await(done)
|
||||
|
||||
expect(types).toContain("test.effect.ping")
|
||||
expect(types).toContain("test.effect.pong")
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
it.effect("multiple subscribers each receive the event", () =>
|
||||
provideTmpdirInstance(() =>
|
||||
Effect.gen(function* () {
|
||||
const bus = yield* Bus.Service
|
||||
const a: number[] = []
|
||||
const b: number[] = []
|
||||
const doneA = yield* Deferred.make<void>()
|
||||
const doneB = yield* Deferred.make<void>()
|
||||
|
||||
yield* Stream.runForEach(bus.subscribe(TestEvent.Ping), (evt) =>
|
||||
Effect.sync(() => {
|
||||
a.push(evt.properties.value)
|
||||
Deferred.doneUnsafe(doneA, Effect.void)
|
||||
}),
|
||||
).pipe(Effect.forkScoped)
|
||||
|
||||
yield* Stream.runForEach(bus.subscribe(TestEvent.Ping), (evt) =>
|
||||
Effect.sync(() => {
|
||||
b.push(evt.properties.value)
|
||||
Deferred.doneUnsafe(doneB, Effect.void)
|
||||
}),
|
||||
).pipe(Effect.forkScoped)
|
||||
|
||||
yield* Effect.sleep("10 millis")
|
||||
yield* bus.publish(TestEvent.Ping, { value: 99 })
|
||||
yield* Deferred.await(doneA)
|
||||
yield* Deferred.await(doneB)
|
||||
|
||||
expect(a).toEqual([99])
|
||||
expect(b).toEqual([99])
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
it.effect("subscribeAll stream sees InstanceDisposed on disposal", () =>
|
||||
Effect.gen(function* () {
|
||||
const dir = yield* tmpdirScoped()
|
||||
const types: string[] = []
|
||||
const seen = yield* Deferred.make<void>()
|
||||
const disposed = yield* Deferred.make<void>()
|
||||
|
||||
// Set up subscriber inside the instance
|
||||
yield* Effect.gen(function* () {
|
||||
const bus = yield* Bus.Service
|
||||
|
||||
yield* Stream.runForEach(bus.subscribeAll(), (evt) =>
|
||||
Effect.sync(() => {
|
||||
types.push(evt.type)
|
||||
if (evt.type === TestEvent.Ping.type) Deferred.doneUnsafe(seen, Effect.void)
|
||||
if (evt.type === Bus.InstanceDisposed.type) Deferred.doneUnsafe(disposed, Effect.void)
|
||||
}),
|
||||
).pipe(Effect.forkScoped)
|
||||
|
||||
yield* Effect.sleep("10 millis")
|
||||
yield* bus.publish(TestEvent.Ping, { value: 1 })
|
||||
yield* Deferred.await(seen)
|
||||
}).pipe(provideInstance(dir))
|
||||
|
||||
// Dispose from OUTSIDE the instance scope
|
||||
yield* Effect.promise(() => Instance.disposeAll())
|
||||
yield* Deferred.await(disposed).pipe(Effect.timeout("2 seconds"))
|
||||
|
||||
expect(types).toContain("test.effect.ping")
|
||||
expect(types).toContain(Bus.InstanceDisposed.type)
|
||||
}),
|
||||
)
|
||||
})
|
||||
87
packages/opencode/test/bus/bus-integration.test.ts
Normal file
87
packages/opencode/test/bus/bus-integration.test.ts
Normal file
@@ -0,0 +1,87 @@
|
||||
import { afterEach, describe, expect, test } from "bun:test"
|
||||
import z from "zod"
|
||||
import { Bus } from "../../src/bus"
|
||||
import { BusEvent } from "../../src/bus/bus-event"
|
||||
import { Instance } from "../../src/project/instance"
|
||||
import { tmpdir } from "../fixture/fixture"
|
||||
|
||||
const TestEvent = BusEvent.define("test.integration", z.object({ value: z.number() }))
|
||||
|
||||
function withInstance(directory: string, fn: () => Promise<void>) {
|
||||
return Instance.provide({ directory, fn })
|
||||
}
|
||||
|
||||
describe("Bus integration: acquireRelease subscriber pattern", () => {
|
||||
afterEach(() => Instance.disposeAll())
|
||||
|
||||
test("subscriber via callback facade receives events and cleans up on unsub", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
const received: number[] = []
|
||||
|
||||
await withInstance(tmp.path, async () => {
|
||||
const unsub = Bus.subscribe(TestEvent, (evt) => {
|
||||
received.push(evt.properties.value)
|
||||
})
|
||||
await Bun.sleep(10)
|
||||
await Bus.publish(TestEvent, { value: 1 })
|
||||
await Bus.publish(TestEvent, { value: 2 })
|
||||
await Bun.sleep(10)
|
||||
|
||||
expect(received).toEqual([1, 2])
|
||||
|
||||
unsub()
|
||||
await Bun.sleep(10)
|
||||
await Bus.publish(TestEvent, { value: 3 })
|
||||
await Bun.sleep(10)
|
||||
|
||||
expect(received).toEqual([1, 2])
|
||||
})
|
||||
})
|
||||
|
||||
test("subscribeAll receives events from multiple types", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
const received: Array<{ type: string; value?: number }> = []
|
||||
|
||||
const OtherEvent = BusEvent.define("test.other", z.object({ value: z.number() }))
|
||||
|
||||
await withInstance(tmp.path, async () => {
|
||||
Bus.subscribeAll((evt) => {
|
||||
received.push({ type: evt.type, value: evt.properties.value })
|
||||
})
|
||||
await Bun.sleep(10)
|
||||
await Bus.publish(TestEvent, { value: 10 })
|
||||
await Bus.publish(OtherEvent, { value: 20 })
|
||||
await Bun.sleep(10)
|
||||
})
|
||||
|
||||
expect(received).toEqual([
|
||||
{ type: "test.integration", value: 10 },
|
||||
{ type: "test.other", value: 20 },
|
||||
])
|
||||
})
|
||||
|
||||
test("subscriber cleanup on instance disposal interrupts the stream", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
const received: number[] = []
|
||||
let disposed = false
|
||||
|
||||
await withInstance(tmp.path, async () => {
|
||||
Bus.subscribeAll((evt) => {
|
||||
if (evt.type === Bus.InstanceDisposed.type) {
|
||||
disposed = true
|
||||
return
|
||||
}
|
||||
received.push(evt.properties.value)
|
||||
})
|
||||
await Bun.sleep(10)
|
||||
await Bus.publish(TestEvent, { value: 1 })
|
||||
await Bun.sleep(10)
|
||||
})
|
||||
|
||||
await Instance.disposeAll()
|
||||
await Bun.sleep(50)
|
||||
|
||||
expect(received).toEqual([1])
|
||||
expect(disposed).toBe(true)
|
||||
})
|
||||
})
|
||||
219
packages/opencode/test/bus/bus.test.ts
Normal file
219
packages/opencode/test/bus/bus.test.ts
Normal file
@@ -0,0 +1,219 @@
|
||||
import { afterEach, describe, expect, test } from "bun:test"
|
||||
import z from "zod"
|
||||
import { Bus } from "../../src/bus"
|
||||
import { BusEvent } from "../../src/bus/bus-event"
|
||||
import { Instance } from "../../src/project/instance"
|
||||
import { tmpdir } from "../fixture/fixture"
|
||||
|
||||
const TestEvent = {
|
||||
Ping: BusEvent.define("test.ping", z.object({ value: z.number() })),
|
||||
Pong: BusEvent.define("test.pong", z.object({ message: z.string() })),
|
||||
}
|
||||
|
||||
function withInstance(directory: string, fn: () => Promise<void>) {
|
||||
return Instance.provide({ directory, fn })
|
||||
}
|
||||
|
||||
describe("Bus", () => {
|
||||
afterEach(() => Instance.disposeAll())
|
||||
|
||||
describe("publish + subscribe", () => {
|
||||
test("subscriber is live immediately after subscribe returns", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
const received: number[] = []
|
||||
|
||||
await withInstance(tmp.path, async () => {
|
||||
Bus.subscribe(TestEvent.Ping, (evt) => {
|
||||
received.push(evt.properties.value)
|
||||
})
|
||||
await Bus.publish(TestEvent.Ping, { value: 42 })
|
||||
await Bun.sleep(10)
|
||||
})
|
||||
|
||||
expect(received).toEqual([42])
|
||||
})
|
||||
|
||||
test("subscriber receives matching events", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
const received: number[] = []
|
||||
|
||||
await withInstance(tmp.path, async () => {
|
||||
Bus.subscribe(TestEvent.Ping, (evt) => {
|
||||
received.push(evt.properties.value)
|
||||
})
|
||||
// Give the subscriber fiber time to start consuming
|
||||
await Bun.sleep(10)
|
||||
await Bus.publish(TestEvent.Ping, { value: 42 })
|
||||
await Bus.publish(TestEvent.Ping, { value: 99 })
|
||||
// Give subscriber time to process
|
||||
await Bun.sleep(10)
|
||||
})
|
||||
|
||||
expect(received).toEqual([42, 99])
|
||||
})
|
||||
|
||||
test("subscriber does not receive events of other types", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
const pings: number[] = []
|
||||
|
||||
await withInstance(tmp.path, async () => {
|
||||
Bus.subscribe(TestEvent.Ping, (evt) => {
|
||||
pings.push(evt.properties.value)
|
||||
})
|
||||
await Bun.sleep(10)
|
||||
await Bus.publish(TestEvent.Pong, { message: "hello" })
|
||||
await Bus.publish(TestEvent.Ping, { value: 1 })
|
||||
await Bun.sleep(10)
|
||||
})
|
||||
|
||||
expect(pings).toEqual([1])
|
||||
})
|
||||
|
||||
test("publish with no subscribers does not throw", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
|
||||
await withInstance(tmp.path, async () => {
|
||||
await Bus.publish(TestEvent.Ping, { value: 1 })
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe("unsubscribe", () => {
|
||||
test("unsubscribe stops delivery", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
const received: number[] = []
|
||||
|
||||
await withInstance(tmp.path, async () => {
|
||||
const unsub = Bus.subscribe(TestEvent.Ping, (evt) => {
|
||||
received.push(evt.properties.value)
|
||||
})
|
||||
await Bun.sleep(10)
|
||||
await Bus.publish(TestEvent.Ping, { value: 1 })
|
||||
await Bun.sleep(10)
|
||||
unsub()
|
||||
await Bun.sleep(10)
|
||||
await Bus.publish(TestEvent.Ping, { value: 2 })
|
||||
await Bun.sleep(10)
|
||||
})
|
||||
|
||||
expect(received).toEqual([1])
|
||||
})
|
||||
})
|
||||
|
||||
describe("subscribeAll", () => {
|
||||
test("subscribeAll is live immediately after subscribe returns", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
const received: string[] = []
|
||||
|
||||
await withInstance(tmp.path, async () => {
|
||||
Bus.subscribeAll((evt) => {
|
||||
received.push(evt.type)
|
||||
})
|
||||
await Bus.publish(TestEvent.Ping, { value: 1 })
|
||||
await Bun.sleep(10)
|
||||
})
|
||||
|
||||
expect(received).toEqual(["test.ping"])
|
||||
})
|
||||
|
||||
test("receives all event types", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
const received: string[] = []
|
||||
|
||||
await withInstance(tmp.path, async () => {
|
||||
Bus.subscribeAll((evt) => {
|
||||
received.push(evt.type)
|
||||
})
|
||||
await Bun.sleep(10)
|
||||
await Bus.publish(TestEvent.Ping, { value: 1 })
|
||||
await Bus.publish(TestEvent.Pong, { message: "hi" })
|
||||
await Bun.sleep(10)
|
||||
})
|
||||
|
||||
expect(received).toContain("test.ping")
|
||||
expect(received).toContain("test.pong")
|
||||
})
|
||||
})
|
||||
|
||||
describe("multiple subscribers", () => {
|
||||
test("all subscribers for same event type are called", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
const a: number[] = []
|
||||
const b: number[] = []
|
||||
|
||||
await withInstance(tmp.path, async () => {
|
||||
Bus.subscribe(TestEvent.Ping, (evt) => {
|
||||
a.push(evt.properties.value)
|
||||
})
|
||||
Bus.subscribe(TestEvent.Ping, (evt) => {
|
||||
b.push(evt.properties.value)
|
||||
})
|
||||
await Bun.sleep(10)
|
||||
await Bus.publish(TestEvent.Ping, { value: 7 })
|
||||
await Bun.sleep(10)
|
||||
})
|
||||
|
||||
expect(a).toEqual([7])
|
||||
expect(b).toEqual([7])
|
||||
})
|
||||
})
|
||||
|
||||
describe("instance isolation", () => {
|
||||
test("events in one directory do not reach subscribers in another", async () => {
|
||||
await using tmpA = await tmpdir()
|
||||
await using tmpB = await tmpdir()
|
||||
const receivedA: number[] = []
|
||||
const receivedB: number[] = []
|
||||
|
||||
await withInstance(tmpA.path, async () => {
|
||||
Bus.subscribe(TestEvent.Ping, (evt) => {
|
||||
receivedA.push(evt.properties.value)
|
||||
})
|
||||
await Bun.sleep(10)
|
||||
})
|
||||
|
||||
await withInstance(tmpB.path, async () => {
|
||||
Bus.subscribe(TestEvent.Ping, (evt) => {
|
||||
receivedB.push(evt.properties.value)
|
||||
})
|
||||
await Bun.sleep(10)
|
||||
})
|
||||
|
||||
await withInstance(tmpA.path, async () => {
|
||||
await Bus.publish(TestEvent.Ping, { value: 1 })
|
||||
await Bun.sleep(10)
|
||||
})
|
||||
|
||||
await withInstance(tmpB.path, async () => {
|
||||
await Bus.publish(TestEvent.Ping, { value: 2 })
|
||||
await Bun.sleep(10)
|
||||
})
|
||||
|
||||
expect(receivedA).toEqual([1])
|
||||
expect(receivedB).toEqual([2])
|
||||
})
|
||||
})
|
||||
|
||||
describe("instance disposal", () => {
|
||||
test("InstanceDisposed is delivered to wildcard subscribers before stream ends", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
const received: string[] = []
|
||||
|
||||
await withInstance(tmp.path, async () => {
|
||||
Bus.subscribeAll((evt) => {
|
||||
received.push(evt.type)
|
||||
})
|
||||
await Bun.sleep(10)
|
||||
await Bus.publish(TestEvent.Ping, { value: 1 })
|
||||
await Bun.sleep(10)
|
||||
})
|
||||
|
||||
// Instance.disposeAll triggers the finalizer which publishes InstanceDisposed
|
||||
await Instance.disposeAll()
|
||||
await Bun.sleep(50)
|
||||
|
||||
expect(received).toContain("test.ping")
|
||||
expect(received).toContain(Bus.InstanceDisposed.type)
|
||||
})
|
||||
})
|
||||
})
|
||||
26
packages/opencode/test/cli/account.test.ts
Normal file
26
packages/opencode/test/cli/account.test.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import stripAnsi from "strip-ansi"
|
||||
|
||||
import { formatAccountLabel, formatOrgLine } from "../../src/cli/cmd/account"
|
||||
|
||||
describe("console account display", () => {
|
||||
test("includes the account url in account labels", () => {
|
||||
expect(stripAnsi(formatAccountLabel({ email: "one@example.com", url: "https://one.example.com" }, false))).toBe(
|
||||
"one@example.com https://one.example.com",
|
||||
)
|
||||
})
|
||||
|
||||
test("includes the active marker in account labels", () => {
|
||||
expect(stripAnsi(formatAccountLabel({ email: "one@example.com", url: "https://one.example.com" }, true))).toBe(
|
||||
"one@example.com https://one.example.com (active)",
|
||||
)
|
||||
})
|
||||
|
||||
test("includes the account url in org rows", () => {
|
||||
expect(
|
||||
stripAnsi(
|
||||
formatOrgLine({ email: "one@example.com", url: "https://one.example.com" }, { id: "org-1", name: "One" }, true),
|
||||
),
|
||||
).toBe(" ● One one@example.com https://one.example.com org-1")
|
||||
})
|
||||
})
|
||||
@@ -1,4 +1,4 @@
|
||||
import { test, expect, describe, mock, afterEach, spyOn } from "bun:test"
|
||||
import { test, expect, describe, mock, afterEach } from "bun:test"
|
||||
import { Config } from "../../src/config/config"
|
||||
import { Instance } from "../../src/project/instance"
|
||||
import { Auth } from "../../src/auth"
|
||||
@@ -10,7 +10,6 @@ import { pathToFileURL } from "url"
|
||||
import { Global } from "../../src/global"
|
||||
import { ProjectID } from "../../src/project/schema"
|
||||
import { Filesystem } from "../../src/util/filesystem"
|
||||
import { BunProc } from "../../src/bun"
|
||||
|
||||
// Get managed config directory from environment (set in preload.ts)
|
||||
const managedConfigDir = process.env.OPENCODE_TEST_MANAGED_CONFIG_DIR!
|
||||
@@ -764,39 +763,6 @@ test("installs dependencies in writable OPENCODE_CONFIG_DIR", async () => {
|
||||
}
|
||||
})
|
||||
|
||||
test("serializes concurrent config dependency installs", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
const dirs = [path.join(tmp.path, "a"), path.join(tmp.path, "b")]
|
||||
await Promise.all(dirs.map((dir) => fs.mkdir(dir, { recursive: true })))
|
||||
|
||||
const seen: string[] = []
|
||||
let active = 0
|
||||
let max = 0
|
||||
const run = spyOn(BunProc, "run").mockImplementation(async (_cmd, opts) => {
|
||||
active++
|
||||
max = Math.max(max, active)
|
||||
seen.push(opts?.cwd ?? "")
|
||||
await new Promise((resolve) => setTimeout(resolve, 25))
|
||||
active--
|
||||
return {
|
||||
code: 0,
|
||||
stdout: Buffer.alloc(0),
|
||||
stderr: Buffer.alloc(0),
|
||||
}
|
||||
})
|
||||
|
||||
try {
|
||||
await Promise.all(dirs.map((dir) => Config.installDependencies(dir)))
|
||||
} finally {
|
||||
run.mockRestore()
|
||||
}
|
||||
|
||||
expect(max).toBe(1)
|
||||
expect(seen.toSorted()).toEqual(dirs.toSorted())
|
||||
expect(await Filesystem.exists(path.join(dirs[0], "package.json"))).toBe(true)
|
||||
expect(await Filesystem.exists(path.join(dirs[1], "package.json"))).toBe(true)
|
||||
})
|
||||
|
||||
test("resolves scoped npm plugins in config", async () => {
|
||||
await using tmp = await tmpdir({
|
||||
init: async (dir) => {
|
||||
|
||||
402
packages/opencode/test/effect/cross-spawn-spawner.test.ts
Normal file
402
packages/opencode/test/effect/cross-spawn-spawner.test.ts
Normal file
@@ -0,0 +1,402 @@
|
||||
import { NodeFileSystem, NodePath } from "@effect/platform-node"
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import fs from "node:fs/promises"
|
||||
import path from "node:path"
|
||||
import { Effect, Exit, Layer, Stream } from "effect"
|
||||
import type * as PlatformError from "effect/PlatformError"
|
||||
import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"
|
||||
import * as CrossSpawnSpawner from "../../src/effect/cross-spawn-spawner"
|
||||
import { tmpdir } from "../fixture/fixture"
|
||||
|
||||
const live = CrossSpawnSpawner.layer.pipe(Layer.provide(NodeFileSystem.layer), Layer.provide(NodePath.layer))
|
||||
|
||||
function run<A, E>(effect: Effect.Effect<A, E, ChildProcessSpawner.ChildProcessSpawner>) {
|
||||
return Effect.runPromise(effect.pipe(Effect.provide(live)))
|
||||
}
|
||||
|
||||
function runScoped<A, E>(
|
||||
effect: Effect.Effect<A, E, ChildProcessSpawner.ChildProcessSpawner | import("effect/Scope").Scope>,
|
||||
) {
|
||||
return Effect.runPromise(Effect.scoped(effect).pipe(Effect.provide(live)))
|
||||
}
|
||||
|
||||
function decodeByteStream(stream: Stream.Stream<Uint8Array, PlatformError.PlatformError>) {
|
||||
return Stream.runCollect(stream).pipe(
|
||||
Effect.map((chunks) => {
|
||||
const totalLength = chunks.reduce((acc, c) => acc + c.length, 0)
|
||||
const result = new Uint8Array(totalLength)
|
||||
let offset = 0
|
||||
for (const chunk of chunks) {
|
||||
result.set(chunk, offset)
|
||||
offset += chunk.length
|
||||
}
|
||||
return new TextDecoder("utf-8").decode(result).trim()
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
describe("cross-spawn spawner", () => {
|
||||
describe("basic spawning", () => {
|
||||
test("captures stdout", async () => {
|
||||
const out = await run(
|
||||
ChildProcessSpawner.ChildProcessSpawner.use((svc) =>
|
||||
svc.string(ChildProcess.make(process.execPath, ["-e", 'process.stdout.write("ok")'])),
|
||||
),
|
||||
)
|
||||
expect(out).toBe("ok")
|
||||
})
|
||||
|
||||
test("captures multiple lines", async () => {
|
||||
const out = await runScoped(
|
||||
Effect.gen(function* () {
|
||||
const handle = yield* ChildProcess.make("sh", ["-c", "echo line1; echo line2; echo line3"])
|
||||
return yield* decodeByteStream(handle.stdout)
|
||||
}),
|
||||
)
|
||||
expect(out).toBe("line1\nline2\nline3")
|
||||
})
|
||||
|
||||
test("returns exit code", async () => {
|
||||
const code = await runScoped(
|
||||
Effect.gen(function* () {
|
||||
const handle = yield* ChildProcess.make("sh", ["-c", "exit 0"])
|
||||
return yield* handle.exitCode
|
||||
}),
|
||||
)
|
||||
expect(code).toBe(ChildProcessSpawner.ExitCode(0))
|
||||
})
|
||||
|
||||
test("returns non-zero exit code", async () => {
|
||||
const code = await runScoped(
|
||||
Effect.gen(function* () {
|
||||
const handle = yield* ChildProcess.make("sh", ["-c", "exit 42"])
|
||||
return yield* handle.exitCode
|
||||
}),
|
||||
)
|
||||
expect(code).toBe(ChildProcessSpawner.ExitCode(42))
|
||||
})
|
||||
})
|
||||
|
||||
describe("cwd option", () => {
|
||||
test("uses cwd when spawning commands", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
|
||||
const out = await run(
|
||||
ChildProcessSpawner.ChildProcessSpawner.use((svc) =>
|
||||
svc.string(
|
||||
ChildProcess.make(process.execPath, ["-e", "process.stdout.write(process.cwd())"], { cwd: tmp.path }),
|
||||
),
|
||||
),
|
||||
)
|
||||
expect(out).toBe(tmp.path)
|
||||
})
|
||||
|
||||
test("fails for invalid cwd", async () => {
|
||||
const exit = await Effect.runPromiseExit(
|
||||
Effect.scoped(ChildProcess.make("echo", ["test"], { cwd: "/nonexistent/directory/path" }).asEffect()).pipe(
|
||||
Effect.provide(live),
|
||||
),
|
||||
)
|
||||
expect(Exit.isFailure(exit)).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe("env option", () => {
|
||||
test("passes environment variables with extendEnv", async () => {
|
||||
const out = await runScoped(
|
||||
Effect.gen(function* () {
|
||||
const handle = yield* ChildProcess.make("sh", ["-c", "echo $TEST_VAR"], {
|
||||
env: { TEST_VAR: "test_value" },
|
||||
extendEnv: true,
|
||||
})
|
||||
return yield* decodeByteStream(handle.stdout)
|
||||
}),
|
||||
)
|
||||
expect(out).toBe("test_value")
|
||||
})
|
||||
|
||||
test("passes multiple environment variables", async () => {
|
||||
const out = await runScoped(
|
||||
Effect.gen(function* () {
|
||||
const handle = yield* ChildProcess.make("sh", ["-c", "echo $VAR1-$VAR2-$VAR3"], {
|
||||
env: { VAR1: "one", VAR2: "two", VAR3: "three" },
|
||||
extendEnv: true,
|
||||
})
|
||||
return yield* decodeByteStream(handle.stdout)
|
||||
}),
|
||||
)
|
||||
expect(out).toBe("one-two-three")
|
||||
})
|
||||
})
|
||||
|
||||
describe("stderr", () => {
|
||||
test("captures stderr output", async () => {
|
||||
const err = await runScoped(
|
||||
Effect.gen(function* () {
|
||||
const handle = yield* ChildProcess.make("sh", ["-c", "echo error message >&2"])
|
||||
return yield* decodeByteStream(handle.stderr)
|
||||
}),
|
||||
)
|
||||
expect(err).toBe("error message")
|
||||
})
|
||||
|
||||
test("captures both stdout and stderr", async () => {
|
||||
const { stdout, stderr } = await runScoped(
|
||||
Effect.gen(function* () {
|
||||
const handle = yield* ChildProcess.make("sh", ["-c", "echo stdout; echo stderr >&2"])
|
||||
const [stdout, stderr] = yield* Effect.all([decodeByteStream(handle.stdout), decodeByteStream(handle.stderr)])
|
||||
return { stdout, stderr }
|
||||
}),
|
||||
)
|
||||
expect(stdout).toBe("stdout")
|
||||
expect(stderr).toBe("stderr")
|
||||
})
|
||||
})
|
||||
|
||||
describe("combined output (all)", () => {
|
||||
test("captures stdout via .all when no stderr", async () => {
|
||||
const all = await runScoped(
|
||||
Effect.gen(function* () {
|
||||
const handle = yield* ChildProcess.make("echo", ["hello from stdout"])
|
||||
return yield* decodeByteStream(handle.all)
|
||||
}),
|
||||
)
|
||||
expect(all).toBe("hello from stdout")
|
||||
})
|
||||
|
||||
test("captures stderr via .all when no stdout", async () => {
|
||||
const all = await runScoped(
|
||||
Effect.gen(function* () {
|
||||
const handle = yield* ChildProcess.make("sh", ["-c", "echo hello from stderr >&2"])
|
||||
return yield* decodeByteStream(handle.all)
|
||||
}),
|
||||
)
|
||||
expect(all).toBe("hello from stderr")
|
||||
})
|
||||
})
|
||||
|
||||
describe("stdin", () => {
|
||||
test("allows providing standard input to a command", async () => {
|
||||
const out = await runScoped(
|
||||
Effect.gen(function* () {
|
||||
const input = "a b c"
|
||||
const stdin = Stream.make(Buffer.from(input, "utf-8"))
|
||||
const handle = yield* ChildProcess.make("cat", { stdin })
|
||||
const output = yield* decodeByteStream(handle.stdout)
|
||||
yield* handle.exitCode
|
||||
return output
|
||||
}),
|
||||
)
|
||||
expect(out).toBe("a b c")
|
||||
})
|
||||
})
|
||||
|
||||
describe("process control", () => {
|
||||
test("kills a running process", async () => {
|
||||
const exit = await Effect.runPromiseExit(
|
||||
Effect.scoped(
|
||||
Effect.gen(function* () {
|
||||
const handle = yield* ChildProcess.make("sleep", ["10"])
|
||||
yield* handle.kill()
|
||||
return yield* handle.exitCode
|
||||
}),
|
||||
).pipe(Effect.provide(live)),
|
||||
)
|
||||
expect(Exit.isFailure(exit)).toBe(true)
|
||||
})
|
||||
|
||||
test("isRunning reflects process state", async () => {
|
||||
await runScoped(
|
||||
Effect.gen(function* () {
|
||||
const handle = yield* ChildProcess.make("sh", ["-c", "echo done"])
|
||||
yield* handle.exitCode
|
||||
const running = yield* handle.isRunning
|
||||
expect(running).toBe(false)
|
||||
}),
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe("error handling", () => {
|
||||
test("fails for invalid command", async () => {
|
||||
const exit = await Effect.runPromiseExit(
|
||||
Effect.scoped(ChildProcess.make("nonexistent-command-12345").asEffect()).pipe(Effect.provide(live)),
|
||||
)
|
||||
expect(Exit.isFailure(exit)).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe("pipeline", () => {
|
||||
test("pipes stdout of one command to stdin of another", async () => {
|
||||
const out = await runScoped(
|
||||
Effect.gen(function* () {
|
||||
const handle = yield* ChildProcess.make`echo hello world`.pipe(
|
||||
ChildProcess.pipeTo(ChildProcess.make`tr a-z A-Z`),
|
||||
)
|
||||
const output = yield* decodeByteStream(handle.stdout)
|
||||
yield* handle.exitCode
|
||||
return output
|
||||
}),
|
||||
)
|
||||
expect(out).toBe("HELLO WORLD")
|
||||
})
|
||||
|
||||
test("three-stage pipeline", async () => {
|
||||
const out = await runScoped(
|
||||
Effect.gen(function* () {
|
||||
const handle = yield* ChildProcess.make`echo hello world`.pipe(
|
||||
ChildProcess.pipeTo(ChildProcess.make`tr a-z A-Z`),
|
||||
ChildProcess.pipeTo(ChildProcess.make("tr", [" ", "-"])),
|
||||
)
|
||||
const output = yield* decodeByteStream(handle.stdout)
|
||||
yield* handle.exitCode
|
||||
return output
|
||||
}),
|
||||
)
|
||||
expect(out).toBe("HELLO-WORLD")
|
||||
})
|
||||
|
||||
test("pipes stderr with { from: 'stderr' }", async () => {
|
||||
const out = await runScoped(
|
||||
Effect.gen(function* () {
|
||||
const handle = yield* ChildProcess.make("sh", ["-c", "echo error >&2"]).pipe(
|
||||
ChildProcess.pipeTo(ChildProcess.make`cat`, { from: "stderr" }),
|
||||
)
|
||||
const output = yield* decodeByteStream(handle.stdout)
|
||||
yield* handle.exitCode
|
||||
return output
|
||||
}),
|
||||
)
|
||||
expect(out).toBe("error")
|
||||
})
|
||||
})
|
||||
|
||||
describe("additional fds", () => {
|
||||
test("reads data from output fd3", async () => {
|
||||
const out = await runScoped(
|
||||
Effect.gen(function* () {
|
||||
const handle = yield* ChildProcess.make("sh", ["-c", "echo 'hello from fd3' >&3"], {
|
||||
additionalFds: { fd3: { type: "output" } },
|
||||
})
|
||||
const fd3Output = yield* decodeByteStream(handle.getOutputFd(3))
|
||||
yield* handle.exitCode
|
||||
return fd3Output
|
||||
}),
|
||||
)
|
||||
expect(out).toBe("hello from fd3")
|
||||
})
|
||||
|
||||
test("writes data to input fd3", async () => {
|
||||
const out = await runScoped(
|
||||
Effect.gen(function* () {
|
||||
const inputData = "data from parent"
|
||||
const inputStream = Stream.make(new TextEncoder().encode(inputData))
|
||||
const handle = yield* ChildProcess.make("sh", ["-c", "cat <&3"], {
|
||||
additionalFds: { fd3: { type: "input", stream: inputStream } },
|
||||
})
|
||||
const stdout = yield* decodeByteStream(handle.stdout)
|
||||
yield* handle.exitCode
|
||||
return stdout
|
||||
}),
|
||||
)
|
||||
expect(out).toBe("data from parent")
|
||||
})
|
||||
|
||||
test("returns empty stream for unconfigured fd", async () => {
|
||||
const out = await runScoped(
|
||||
Effect.gen(function* () {
|
||||
const handle = yield* ChildProcess.make("sh", ["-c", "echo test"])
|
||||
const fd3Output = yield* decodeByteStream(handle.getOutputFd(3))
|
||||
yield* handle.exitCode
|
||||
return fd3Output
|
||||
}),
|
||||
)
|
||||
expect(out).toBe("")
|
||||
})
|
||||
|
||||
test("works alongside normal stdout and stderr", async () => {
|
||||
const result = await runScoped(
|
||||
Effect.gen(function* () {
|
||||
const handle = yield* ChildProcess.make("sh", ["-c", "echo 'stdout'; echo 'stderr' >&2; echo 'fd3' >&3"], {
|
||||
additionalFds: { fd3: { type: "output" } },
|
||||
})
|
||||
const stdout = yield* decodeByteStream(handle.stdout)
|
||||
const stderr = yield* decodeByteStream(handle.stderr)
|
||||
const fd3 = yield* decodeByteStream(handle.getOutputFd(3))
|
||||
yield* handle.exitCode
|
||||
return { stdout, stderr, fd3 }
|
||||
}),
|
||||
)
|
||||
expect(result.stdout).toBe("stdout")
|
||||
expect(result.stderr).toBe("stderr")
|
||||
expect(result.fd3).toBe("fd3")
|
||||
})
|
||||
})
|
||||
|
||||
describe("large output", () => {
|
||||
test(
|
||||
"does not deadlock on large stdout",
|
||||
async () => {
|
||||
const out = await runScoped(
|
||||
Effect.gen(function* () {
|
||||
const handle = yield* ChildProcess.make("sh", ["-c", "seq 1 100000"])
|
||||
const output = yield* handle.stdout.pipe(
|
||||
Stream.decodeText(),
|
||||
Stream.runFold(() => "", (acc, chunk) => acc + chunk),
|
||||
)
|
||||
yield* handle.exitCode
|
||||
return output
|
||||
}),
|
||||
)
|
||||
const lines = out.trim().split("\n")
|
||||
expect(lines.length).toBe(100000)
|
||||
expect(lines[0]).toBe("1")
|
||||
expect(lines[99999]).toBe("100000")
|
||||
},
|
||||
{ timeout: 10_000 },
|
||||
)
|
||||
})
|
||||
|
||||
describe("Windows-specific", () => {
|
||||
test("uses shell routing on Windows", async () => {
|
||||
if (process.platform !== "win32") return
|
||||
|
||||
const out = await run(
|
||||
ChildProcessSpawner.ChildProcessSpawner.use((svc) =>
|
||||
svc.string(
|
||||
ChildProcess.make("set", ["OPENCODE_TEST_SHELL"], {
|
||||
shell: true,
|
||||
extendEnv: true,
|
||||
env: { OPENCODE_TEST_SHELL: "ok" },
|
||||
}),
|
||||
),
|
||||
),
|
||||
)
|
||||
expect(out).toContain("OPENCODE_TEST_SHELL=ok")
|
||||
})
|
||||
|
||||
test("runs cmd scripts with spaces on Windows without shell", async () => {
|
||||
if (process.platform !== "win32") return
|
||||
|
||||
await using tmp = await tmpdir()
|
||||
const dir = path.join(tmp.path, "with space")
|
||||
const file = path.join(dir, "echo cmd.cmd")
|
||||
|
||||
await fs.mkdir(dir, { recursive: true })
|
||||
await Bun.write(file, "@echo off\r\nif %~1==--stdio exit /b 0\r\nexit /b 7\r\n")
|
||||
|
||||
const code = await run(
|
||||
ChildProcessSpawner.ChildProcessSpawner.use((svc) =>
|
||||
svc.exitCode(
|
||||
ChildProcess.make(file, ["--stdio"], {
|
||||
stdin: "pipe",
|
||||
stdout: "pipe",
|
||||
stderr: "pipe",
|
||||
}),
|
||||
),
|
||||
),
|
||||
)
|
||||
expect(code).toBe(ChildProcessSpawner.ExitCode(0))
|
||||
})
|
||||
})
|
||||
})
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user