refactor(opencode): replace Bun shell in core flows (#16286)

This commit is contained in:
Dax
2026-03-09 15:19:50 -04:00
committed by GitHub
parent 831eb6881b
commit 2f2856e20a
18 changed files with 681 additions and 364 deletions

View File

@@ -27,8 +27,9 @@ import { Provider } from "../../provider/provider"
import { Bus } from "../../bus" import { Bus } from "../../bus"
import { MessageV2 } from "../../session/message-v2" import { MessageV2 } from "../../session/message-v2"
import { SessionPrompt } from "@/session/prompt" import { SessionPrompt } from "@/session/prompt"
import { $ } from "bun"
import { setTimeout as sleep } from "node:timers/promises" import { setTimeout as sleep } from "node:timers/promises"
import { Process } from "@/util/process"
import { git } from "@/util/git"
type GitHubAuthor = { type GitHubAuthor = {
login: string login: string
@@ -255,7 +256,7 @@ export const GithubInstallCommand = cmd({
} }
// Get repo info // Get repo info
const info = (await $`git remote get-url origin`.quiet().nothrow().text()).trim() const info = (await git(["remote", "get-url", "origin"], { cwd: Instance.worktree })).text().trim()
const parsed = parseGitHubRemote(info) const parsed = parseGitHubRemote(info)
if (!parsed) { if (!parsed) {
prompts.log.error(`Could not find git repository. Please run this command from a git repository.`) prompts.log.error(`Could not find git repository. Please run this command from a git repository.`)
@@ -493,6 +494,26 @@ export const GithubRunCommand = cmd({
? "pr_review" ? "pr_review"
: "issue" : "issue"
: undefined : undefined
const gitText = async (args: string[]) => {
const result = await git(args, { cwd: Instance.worktree })
if (result.exitCode !== 0) {
throw new Process.RunFailedError(["git", ...args], result.exitCode, result.stdout, result.stderr)
}
return result.text().trim()
}
const gitRun = async (args: string[]) => {
const result = await git(args, { cwd: Instance.worktree })
if (result.exitCode !== 0) {
throw new Process.RunFailedError(["git", ...args], result.exitCode, result.stdout, result.stderr)
}
return result
}
const gitStatus = (args: string[]) => git(args, { cwd: Instance.worktree })
const commitChanges = async (summary: string, actor?: string) => {
const args = ["commit", "-m", summary]
if (actor) args.push("-m", `Co-authored-by: ${actor} <${actor}@users.noreply.github.com>`)
await gitRun(args)
}
try { try {
if (useGithubToken) { if (useGithubToken) {
@@ -553,7 +574,7 @@ export const GithubRunCommand = cmd({
} }
const branchPrefix = isWorkflowDispatchEvent ? "dispatch" : "schedule" const branchPrefix = isWorkflowDispatchEvent ? "dispatch" : "schedule"
const branch = await checkoutNewBranch(branchPrefix) const branch = await checkoutNewBranch(branchPrefix)
const head = (await $`git rev-parse HEAD`).stdout.toString().trim() const head = await gitText(["rev-parse", "HEAD"])
const response = await chat(userPrompt, promptFiles) const response = await chat(userPrompt, promptFiles)
const { dirty, uncommittedChanges, switched } = await branchIsDirty(head, branch) const { dirty, uncommittedChanges, switched } = await branchIsDirty(head, branch)
if (switched) { if (switched) {
@@ -587,7 +608,7 @@ export const GithubRunCommand = cmd({
// Local PR // Local PR
if (prData.headRepository.nameWithOwner === prData.baseRepository.nameWithOwner) { if (prData.headRepository.nameWithOwner === prData.baseRepository.nameWithOwner) {
await checkoutLocalBranch(prData) await checkoutLocalBranch(prData)
const head = (await $`git rev-parse HEAD`).stdout.toString().trim() const head = await gitText(["rev-parse", "HEAD"])
const dataPrompt = buildPromptDataForPR(prData) const dataPrompt = buildPromptDataForPR(prData)
const response = await chat(`${userPrompt}\n\n${dataPrompt}`, promptFiles) const response = await chat(`${userPrompt}\n\n${dataPrompt}`, promptFiles)
const { dirty, uncommittedChanges, switched } = await branchIsDirty(head, prData.headRefName) const { dirty, uncommittedChanges, switched } = await branchIsDirty(head, prData.headRefName)
@@ -605,7 +626,7 @@ export const GithubRunCommand = cmd({
// Fork PR // Fork PR
else { else {
const forkBranch = await checkoutForkBranch(prData) const forkBranch = await checkoutForkBranch(prData)
const head = (await $`git rev-parse HEAD`).stdout.toString().trim() const head = await gitText(["rev-parse", "HEAD"])
const dataPrompt = buildPromptDataForPR(prData) const dataPrompt = buildPromptDataForPR(prData)
const response = await chat(`${userPrompt}\n\n${dataPrompt}`, promptFiles) const response = await chat(`${userPrompt}\n\n${dataPrompt}`, promptFiles)
const { dirty, uncommittedChanges, switched } = await branchIsDirty(head, forkBranch) const { dirty, uncommittedChanges, switched } = await branchIsDirty(head, forkBranch)
@@ -624,7 +645,7 @@ export const GithubRunCommand = cmd({
// Issue // Issue
else { else {
const branch = await checkoutNewBranch("issue") const branch = await checkoutNewBranch("issue")
const head = (await $`git rev-parse HEAD`).stdout.toString().trim() const head = await gitText(["rev-parse", "HEAD"])
const issueData = await fetchIssue() const issueData = await fetchIssue()
const dataPrompt = buildPromptDataForIssue(issueData) const dataPrompt = buildPromptDataForIssue(issueData)
const response = await chat(`${userPrompt}\n\n${dataPrompt}`, promptFiles) const response = await chat(`${userPrompt}\n\n${dataPrompt}`, promptFiles)
@@ -658,7 +679,7 @@ export const GithubRunCommand = cmd({
exitCode = 1 exitCode = 1
console.error(e instanceof Error ? e.message : String(e)) console.error(e instanceof Error ? e.message : String(e))
let msg = e let msg = e
if (e instanceof $.ShellError) { if (e instanceof Process.RunFailedError) {
msg = e.stderr.toString() msg = e.stderr.toString()
} else if (e instanceof Error) { } else if (e instanceof Error) {
msg = e.message msg = e.message
@@ -1049,29 +1070,29 @@ export const GithubRunCommand = cmd({
const config = "http.https://github.com/.extraheader" const config = "http.https://github.com/.extraheader"
// actions/checkout@v6 no longer stores credentials in .git/config, // actions/checkout@v6 no longer stores credentials in .git/config,
// so this may not exist - use nothrow() to handle gracefully // so this may not exist - use nothrow() to handle gracefully
const ret = await $`git config --local --get ${config}`.nothrow() const ret = await gitStatus(["config", "--local", "--get", config])
if (ret.exitCode === 0) { if (ret.exitCode === 0) {
gitConfig = ret.stdout.toString().trim() gitConfig = ret.stdout.toString().trim()
await $`git config --local --unset-all ${config}` await gitRun(["config", "--local", "--unset-all", config])
} }
const newCredentials = Buffer.from(`x-access-token:${appToken}`, "utf8").toString("base64") const newCredentials = Buffer.from(`x-access-token:${appToken}`, "utf8").toString("base64")
await $`git config --local ${config} "AUTHORIZATION: basic ${newCredentials}"` await gitRun(["config", "--local", config, `AUTHORIZATION: basic ${newCredentials}`])
await $`git config --global user.name "${AGENT_USERNAME}"` await gitRun(["config", "--global", "user.name", AGENT_USERNAME])
await $`git config --global user.email "${AGENT_USERNAME}@users.noreply.github.com"` await gitRun(["config", "--global", "user.email", `${AGENT_USERNAME}@users.noreply.github.com`])
} }
async function restoreGitConfig() { async function restoreGitConfig() {
if (gitConfig === undefined) return if (gitConfig === undefined) return
const config = "http.https://github.com/.extraheader" const config = "http.https://github.com/.extraheader"
await $`git config --local ${config} "${gitConfig}"` await gitRun(["config", "--local", config, gitConfig])
} }
async function checkoutNewBranch(type: "issue" | "schedule" | "dispatch") { async function checkoutNewBranch(type: "issue" | "schedule" | "dispatch") {
console.log("Checking out new branch...") console.log("Checking out new branch...")
const branch = generateBranchName(type) const branch = generateBranchName(type)
await $`git checkout -b ${branch}` await gitRun(["checkout", "-b", branch])
return branch return branch
} }
@@ -1081,8 +1102,8 @@ export const GithubRunCommand = cmd({
const branch = pr.headRefName const branch = pr.headRefName
const depth = Math.max(pr.commits.totalCount, 20) const depth = Math.max(pr.commits.totalCount, 20)
await $`git fetch origin --depth=${depth} ${branch}` await gitRun(["fetch", "origin", `--depth=${depth}`, branch])
await $`git checkout ${branch}` await gitRun(["checkout", branch])
} }
async function checkoutForkBranch(pr: GitHubPullRequest) { async function checkoutForkBranch(pr: GitHubPullRequest) {
@@ -1092,9 +1113,9 @@ export const GithubRunCommand = cmd({
const localBranch = generateBranchName("pr") const localBranch = generateBranchName("pr")
const depth = Math.max(pr.commits.totalCount, 20) const depth = Math.max(pr.commits.totalCount, 20)
await $`git remote add fork https://github.com/${pr.headRepository.nameWithOwner}.git` await gitRun(["remote", "add", "fork", `https://github.com/${pr.headRepository.nameWithOwner}.git`])
await $`git fetch fork --depth=${depth} ${remoteBranch}` await gitRun(["fetch", "fork", `--depth=${depth}`, remoteBranch])
await $`git checkout -b ${localBranch} fork/${remoteBranch}` await gitRun(["checkout", "-b", localBranch, `fork/${remoteBranch}`])
return localBranch return localBranch
} }
@@ -1115,28 +1136,23 @@ export const GithubRunCommand = cmd({
async function pushToNewBranch(summary: string, branch: string, commit: boolean, isSchedule: boolean) { async function pushToNewBranch(summary: string, branch: string, commit: boolean, isSchedule: boolean) {
console.log("Pushing to new branch...") console.log("Pushing to new branch...")
if (commit) { if (commit) {
await $`git add .` await gitRun(["add", "."])
if (isSchedule) { if (isSchedule) {
// No co-author for scheduled events - the schedule is operating as the repo await commitChanges(summary)
await $`git commit -m "${summary}"`
} else { } else {
await $`git commit -m "${summary} await commitChanges(summary, actor)
Co-authored-by: ${actor} <${actor}@users.noreply.github.com>"`
} }
} }
await $`git push -u origin ${branch}` await gitRun(["push", "-u", "origin", branch])
} }
async function pushToLocalBranch(summary: string, commit: boolean) { async function pushToLocalBranch(summary: string, commit: boolean) {
console.log("Pushing to local branch...") console.log("Pushing to local branch...")
if (commit) { if (commit) {
await $`git add .` await gitRun(["add", "."])
await $`git commit -m "${summary} await commitChanges(summary, actor)
Co-authored-by: ${actor} <${actor}@users.noreply.github.com>"`
} }
await $`git push` await gitRun(["push"])
} }
async function pushToForkBranch(summary: string, pr: GitHubPullRequest, commit: boolean) { async function pushToForkBranch(summary: string, pr: GitHubPullRequest, commit: boolean) {
@@ -1145,30 +1161,28 @@ Co-authored-by: ${actor} <${actor}@users.noreply.github.com>"`
const remoteBranch = pr.headRefName const remoteBranch = pr.headRefName
if (commit) { if (commit) {
await $`git add .` await gitRun(["add", "."])
await $`git commit -m "${summary} await commitChanges(summary, actor)
Co-authored-by: ${actor} <${actor}@users.noreply.github.com>"`
} }
await $`git push fork HEAD:${remoteBranch}` await gitRun(["push", "fork", `HEAD:${remoteBranch}`])
} }
async function branchIsDirty(originalHead: string, expectedBranch: string) { async function branchIsDirty(originalHead: string, expectedBranch: string) {
console.log("Checking if branch is dirty...") console.log("Checking if branch is dirty...")
// Detect if the agent switched branches during chat (e.g. created // Detect if the agent switched branches during chat (e.g. created
// its own branch, committed, and possibly pushed/created a PR). // its own branch, committed, and possibly pushed/created a PR).
const current = (await $`git rev-parse --abbrev-ref HEAD`).stdout.toString().trim() const current = await gitText(["rev-parse", "--abbrev-ref", "HEAD"])
if (current !== expectedBranch) { if (current !== expectedBranch) {
console.log(`Branch changed during chat: expected ${expectedBranch}, now on ${current}`) console.log(`Branch changed during chat: expected ${expectedBranch}, now on ${current}`)
return { dirty: true, uncommittedChanges: false, switched: true } return { dirty: true, uncommittedChanges: false, switched: true }
} }
const ret = await $`git status --porcelain` const ret = await gitStatus(["status", "--porcelain"])
const status = ret.stdout.toString().trim() const status = ret.stdout.toString().trim()
if (status.length > 0) { if (status.length > 0) {
return { dirty: true, uncommittedChanges: true, switched: false } return { dirty: true, uncommittedChanges: true, switched: false }
} }
const head = (await $`git rev-parse HEAD`).stdout.toString().trim() const head = await gitText(["rev-parse", "HEAD"])
return { return {
dirty: head !== originalHead, dirty: head !== originalHead,
uncommittedChanges: false, uncommittedChanges: false,
@@ -1180,11 +1194,11 @@ Co-authored-by: ${actor} <${actor}@users.noreply.github.com>"`
// Falls back to fetching from origin when local refs are missing // Falls back to fetching from origin when local refs are missing
// (common in shallow clones from actions/checkout). // (common in shallow clones from actions/checkout).
async function hasNewCommits(base: string, head: string) { async function hasNewCommits(base: string, head: string) {
const result = await $`git rev-list --count ${base}..${head}`.nothrow() const result = await gitStatus(["rev-list", "--count", `${base}..${head}`])
if (result.exitCode !== 0) { if (result.exitCode !== 0) {
console.log(`rev-list failed, fetching origin/${base}...`) console.log(`rev-list failed, fetching origin/${base}...`)
await $`git fetch origin ${base} --depth=1`.nothrow() await gitStatus(["fetch", "origin", base, "--depth=1"])
const retry = await $`git rev-list --count origin/${base}..${head}`.nothrow() const retry = await gitStatus(["rev-list", "--count", `origin/${base}..${head}`])
if (retry.exitCode !== 0) return true // assume dirty if we can't tell if (retry.exitCode !== 0) return true // assume dirty if we can't tell
return parseInt(retry.stdout.toString().trim()) > 0 return parseInt(retry.stdout.toString().trim()) > 0
} }

View File

@@ -1,7 +1,8 @@
import { UI } from "../ui" import { UI } from "../ui"
import { cmd } from "./cmd" import { cmd } from "./cmd"
import { Instance } from "@/project/instance" import { Instance } from "@/project/instance"
import { $ } from "bun" import { Process } from "@/util/process"
import { git } from "@/util/git"
export const PrCommand = cmd({ export const PrCommand = cmd({
command: "pr <number>", command: "pr <number>",
@@ -27,21 +28,35 @@ export const PrCommand = cmd({
UI.println(`Fetching and checking out PR #${prNumber}...`) UI.println(`Fetching and checking out PR #${prNumber}...`)
// Use gh pr checkout with custom branch name // Use gh pr checkout with custom branch name
const result = await $`gh pr checkout ${prNumber} --branch ${localBranchName} --force`.nothrow() const result = await Process.run(
["gh", "pr", "checkout", `${prNumber}`, "--branch", localBranchName, "--force"],
{
nothrow: true,
},
)
if (result.exitCode !== 0) { if (result.code !== 0) {
UI.error(`Failed to checkout PR #${prNumber}. Make sure you have gh CLI installed and authenticated.`) UI.error(`Failed to checkout PR #${prNumber}. Make sure you have gh CLI installed and authenticated.`)
process.exit(1) process.exit(1)
} }
// Fetch PR info for fork handling and session link detection // Fetch PR info for fork handling and session link detection
const prInfoResult = const prInfoResult = await Process.text(
await $`gh pr view ${prNumber} --json headRepository,headRepositoryOwner,isCrossRepository,headRefName,body`.nothrow() [
"gh",
"pr",
"view",
`${prNumber}`,
"--json",
"headRepository,headRepositoryOwner,isCrossRepository,headRefName,body",
],
{ nothrow: true },
)
let sessionId: string | undefined let sessionId: string | undefined
if (prInfoResult.exitCode === 0) { if (prInfoResult.code === 0) {
const prInfoText = prInfoResult.text() const prInfoText = prInfoResult.text
if (prInfoText.trim()) { if (prInfoText.trim()) {
const prInfo = JSON.parse(prInfoText) const prInfo = JSON.parse(prInfoText)
@@ -52,15 +67,19 @@ export const PrCommand = cmd({
const remoteName = forkOwner const remoteName = forkOwner
// Check if remote already exists // Check if remote already exists
const remotes = (await $`git remote`.nothrow().text()).trim() const remotes = (await git(["remote"], { cwd: Instance.worktree })).text().trim()
if (!remotes.split("\n").includes(remoteName)) { if (!remotes.split("\n").includes(remoteName)) {
await $`git remote add ${remoteName} https://github.com/${forkOwner}/${forkName}.git`.nothrow() await git(["remote", "add", remoteName, `https://github.com/${forkOwner}/${forkName}.git`], {
cwd: Instance.worktree,
})
UI.println(`Added fork remote: ${remoteName}`) UI.println(`Added fork remote: ${remoteName}`)
} }
// Set upstream to the fork so pushes go there // Set upstream to the fork so pushes go there
const headRefName = prInfo.headRefName const headRefName = prInfo.headRefName
await $`git branch --set-upstream-to=${remoteName}/${headRefName} ${localBranchName}`.nothrow() await git(["branch", `--set-upstream-to=${remoteName}/${headRefName}`, localBranchName], {
cwd: Instance.worktree,
})
} }
// Check for opencode session link in PR body // Check for opencode session link in PR body
@@ -71,9 +90,11 @@ export const PrCommand = cmd({
UI.println(`Found opencode session: ${sessionUrl}`) UI.println(`Found opencode session: ${sessionUrl}`)
UI.println(`Importing session...`) UI.println(`Importing session...`)
const importResult = await $`opencode import ${sessionUrl}`.nothrow() const importResult = await Process.text(["opencode", "import", sessionUrl], {
if (importResult.exitCode === 0) { nothrow: true,
const importOutput = importResult.text().trim() })
if (importResult.code === 0) {
const importOutput = importResult.text.trim()
// Extract session ID from the output (format: "Imported session: <session-id>") // Extract session ID from the output (format: "Imported session: <session-id>")
const sessionIdMatch = importOutput.match(/Imported session: ([a-zA-Z0-9_-]+)/) const sessionIdMatch = importOutput.match(/Imported session: ([a-zA-Z0-9_-]+)/)
if (sessionIdMatch) { if (sessionIdMatch) {

View File

@@ -1,9 +1,9 @@
import { $ } from "bun"
import { platform, release } from "os" import { platform, release } from "os"
import clipboardy from "clipboardy" import clipboardy from "clipboardy"
import { lazy } from "../../../../util/lazy.js" import { lazy } from "../../../../util/lazy.js"
import { tmpdir } from "os" import { tmpdir } from "os"
import path from "path" import path from "path"
import fs from "fs/promises"
import { Filesystem } from "../../../../util/filesystem" import { Filesystem } from "../../../../util/filesystem"
import { Process } from "../../../../util/process" import { Process } from "../../../../util/process"
import { which } from "../../../../util/which" import { which } from "../../../../util/which"
@@ -34,23 +34,38 @@ export namespace Clipboard {
if (os === "darwin") { if (os === "darwin") {
const tmpfile = path.join(tmpdir(), "opencode-clipboard.png") const tmpfile = path.join(tmpdir(), "opencode-clipboard.png")
try { try {
await $`osascript -e 'set imageData to the clipboard as "PNGf"' -e 'set fileRef to open for access POSIX file "${tmpfile}" with write permission' -e 'set eof fileRef to 0' -e 'write imageData to fileRef' -e 'close access fileRef'` await Process.run(
.nothrow() [
.quiet() "osascript",
"-e",
'set imageData to the clipboard as "PNGf"',
"-e",
`set fileRef to open for access POSIX file "${tmpfile}" with write permission`,
"-e",
"set eof fileRef to 0",
"-e",
"write imageData to fileRef",
"-e",
"close access fileRef",
],
{ nothrow: true },
)
const buffer = await Filesystem.readBytes(tmpfile) const buffer = await Filesystem.readBytes(tmpfile)
return { data: buffer.toString("base64"), mime: "image/png" } return { data: buffer.toString("base64"), mime: "image/png" }
} catch { } catch {
} finally { } finally {
await $`rm -f "${tmpfile}"`.nothrow().quiet() await fs.rm(tmpfile, { force: true }).catch(() => {})
} }
} }
if (os === "win32" || release().includes("WSL")) { if (os === "win32" || release().includes("WSL")) {
const script = 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()) }" "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()) }"
const base64 = await $`powershell.exe -NonInteractive -NoProfile -command "${script}"`.nothrow().text() const base64 = await Process.text(["powershell.exe", "-NonInteractive", "-NoProfile", "-command", script], {
if (base64) { nothrow: true,
const imageBuffer = Buffer.from(base64.trim(), "base64") })
if (base64.text) {
const imageBuffer = Buffer.from(base64.text.trim(), "base64")
if (imageBuffer.length > 0) { if (imageBuffer.length > 0) {
return { data: imageBuffer.toString("base64"), mime: "image/png" } return { data: imageBuffer.toString("base64"), mime: "image/png" }
} }
@@ -58,13 +73,15 @@ export namespace Clipboard {
} }
if (os === "linux") { if (os === "linux") {
const wayland = await $`wl-paste -t image/png`.nothrow().arrayBuffer() const wayland = await Process.run(["wl-paste", "-t", "image/png"], { nothrow: true })
if (wayland && wayland.byteLength > 0) { if (wayland.stdout.byteLength > 0) {
return { data: Buffer.from(wayland).toString("base64"), mime: "image/png" } return { data: Buffer.from(wayland.stdout).toString("base64"), mime: "image/png" }
} }
const x11 = await $`xclip -selection clipboard -t image/png -o`.nothrow().arrayBuffer() const x11 = await Process.run(["xclip", "-selection", "clipboard", "-t", "image/png", "-o"], {
if (x11 && x11.byteLength > 0) { nothrow: true,
return { data: Buffer.from(x11).toString("base64"), mime: "image/png" } })
if (x11.stdout.byteLength > 0) {
return { data: Buffer.from(x11.stdout).toString("base64"), mime: "image/png" }
} }
} }
@@ -81,7 +98,7 @@ export namespace Clipboard {
console.log("clipboard: using osascript") console.log("clipboard: using osascript")
return async (text: string) => { return async (text: string) => {
const escaped = text.replace(/\\/g, "\\\\").replace(/"/g, '\\"') const escaped = text.replace(/\\/g, "\\\\").replace(/"/g, '\\"')
await $`osascript -e 'set the clipboard to "${escaped}"'`.nothrow().quiet() await Process.run(["osascript", "-e", `set the clipboard to "${escaped}"`], { nothrow: true })
} }
} }

View File

@@ -3,11 +3,11 @@ import { UI } from "../ui"
import * as prompts from "@clack/prompts" import * as prompts from "@clack/prompts"
import { Installation } from "../../installation" import { Installation } from "../../installation"
import { Global } from "../../global" import { Global } from "../../global"
import { $ } from "bun"
import fs from "fs/promises" import fs from "fs/promises"
import path from "path" import path from "path"
import os from "os" import os from "os"
import { Filesystem } from "../../util/filesystem" import { Filesystem } from "../../util/filesystem"
import { Process } from "../../util/process"
interface UninstallArgs { interface UninstallArgs {
keepConfig: boolean keepConfig: boolean
@@ -192,16 +192,13 @@ async function executeUninstall(method: Installation.Method, targets: RemovalTar
const cmd = cmds[method] const cmd = cmds[method]
if (cmd) { if (cmd) {
spinner.start(`Running ${cmd.join(" ")}...`) spinner.start(`Running ${cmd.join(" ")}...`)
const result = const result = await Process.run(method === "choco" ? ["choco", "uninstall", "opencode", "-y", "-r"] : cmd, {
method === "choco" nothrow: true,
? await $`echo Y | choco uninstall opencode -y -r`.quiet().nothrow() })
: await $`${cmd}`.quiet().nothrow() if (result.code !== 0) {
if (result.exitCode !== 0) { spinner.stop(`Package manager uninstall failed: exit code ${result.code}`, 1)
spinner.stop(`Package manager uninstall failed: exit code ${result.exitCode}`, 1) const text = `${result.stdout.toString("utf8")}\n${result.stderr.toString("utf8")}`
if ( if (method === "choco" && text.includes("not running from an elevated command shell")) {
method === "choco" &&
result.stdout.toString("utf8").includes("not running from an elevated command shell")
) {
prompts.log.warn(`You may need to run '${cmd.join(" ")}' from an elevated command shell`) prompts.log.warn(`You may need to run '${cmd.join(" ")}' from an elevated command shell`)
} else { } else {
prompts.log.warn(`You may need to run manually: ${cmd.join(" ")}`) prompts.log.warn(`You may need to run manually: ${cmd.join(" ")}`)

View File

@@ -1,6 +1,5 @@
import { BusEvent } from "@/bus/bus-event" import { BusEvent } from "@/bus/bus-event"
import z from "zod" import z from "zod"
import { $ } from "bun"
import { formatPatch, structuredPatch } from "diff" import { formatPatch, structuredPatch } from "diff"
import path from "path" import path from "path"
import fs from "fs" import fs from "fs"
@@ -11,6 +10,7 @@ import { Instance } from "../project/instance"
import { Ripgrep } from "./ripgrep" import { Ripgrep } from "./ripgrep"
import fuzzysort from "fuzzysort" import fuzzysort from "fuzzysort"
import { Global } from "../global" import { Global } from "../global"
import { git } from "@/util/git"
export namespace File { export namespace File {
const log = Log.create({ service: "file" }) const log = Log.create({ service: "file" })
@@ -418,11 +418,11 @@ export namespace File {
const project = Instance.project const project = Instance.project
if (project.vcs !== "git") return [] if (project.vcs !== "git") return []
const diffOutput = await $`git -c core.fsmonitor=false -c core.quotepath=false diff --numstat HEAD` const diffOutput = (
.cwd(Instance.directory) await git(["-c", "core.fsmonitor=false", "-c", "core.quotepath=false", "diff", "--numstat", "HEAD"], {
.quiet() cwd: Instance.directory,
.nothrow() })
.text() ).text()
const changedFiles: Info[] = [] const changedFiles: Info[] = []
@@ -439,12 +439,14 @@ export namespace File {
} }
} }
const untrackedOutput = const untrackedOutput = (
await $`git -c core.fsmonitor=false -c core.quotepath=false ls-files --others --exclude-standard` await git(
.cwd(Instance.directory) ["-c", "core.fsmonitor=false", "-c", "core.quotepath=false", "ls-files", "--others", "--exclude-standard"],
.quiet() {
.nothrow() cwd: Instance.directory,
.text() },
)
).text()
if (untrackedOutput.trim()) { if (untrackedOutput.trim()) {
const untrackedFiles = untrackedOutput.trim().split("\n") const untrackedFiles = untrackedOutput.trim().split("\n")
@@ -465,12 +467,14 @@ export namespace File {
} }
// Get deleted files // Get deleted files
const deletedOutput = const deletedOutput = (
await $`git -c core.fsmonitor=false -c core.quotepath=false diff --name-only --diff-filter=D HEAD` await git(
.cwd(Instance.directory) ["-c", "core.fsmonitor=false", "-c", "core.quotepath=false", "diff", "--name-only", "--diff-filter=D", "HEAD"],
.quiet() {
.nothrow() cwd: Instance.directory,
.text() },
)
).text()
if (deletedOutput.trim()) { if (deletedOutput.trim()) {
const deletedFiles = deletedOutput.trim().split("\n") const deletedFiles = deletedOutput.trim().split("\n")
@@ -541,16 +545,14 @@ export namespace File {
const content = (await Filesystem.readText(full).catch(() => "")).trim() const content = (await Filesystem.readText(full).catch(() => "")).trim()
if (project.vcs === "git") { if (project.vcs === "git") {
let diff = await $`git -c core.fsmonitor=false diff ${file}`.cwd(Instance.directory).quiet().nothrow().text() let diff = (await git(["-c", "core.fsmonitor=false", "diff", "--", file], { cwd: Instance.directory })).text()
if (!diff.trim()) { if (!diff.trim()) {
diff = await $`git -c core.fsmonitor=false diff --staged ${file}` diff = (
.cwd(Instance.directory) await git(["-c", "core.fsmonitor=false", "diff", "--staged", "--", file], { cwd: Instance.directory })
.quiet() ).text()
.nothrow()
.text()
} }
if (diff.trim()) { if (diff.trim()) {
const original = await $`git show HEAD:${file}`.cwd(Instance.directory).quiet().nothrow().text() const original = (await git(["show", `HEAD:${file}`], { cwd: Instance.directory })).text()
const patch = structuredPatch(file, file, original, content, "old", "new", { const patch = structuredPatch(file, file, original, content, "old", "new", {
context: Infinity, context: Infinity,
ignoreWhitespace: true, ignoreWhitespace: true,

View File

@@ -5,7 +5,7 @@ import fs from "fs/promises"
import z from "zod" import z from "zod"
import { NamedError } from "@opencode-ai/util/error" import { NamedError } from "@opencode-ai/util/error"
import { lazy } from "../util/lazy" import { lazy } from "../util/lazy"
import { $ } from "bun"
import { Filesystem } from "../util/filesystem" import { Filesystem } from "../util/filesystem"
import { Process } from "../util/process" import { Process } from "../util/process"
import { which } from "../util/which" import { which } from "../util/which"
@@ -338,7 +338,7 @@ export namespace Ripgrep {
limit?: number limit?: number
follow?: boolean follow?: boolean
}) { }) {
const args = [`${await filepath()}`, "--json", "--hidden", "--glob='!.git/*'"] const args = [`${await filepath()}`, "--json", "--hidden", "--glob=!.git/*"]
if (input.follow) args.push("--follow") if (input.follow) args.push("--follow")
if (input.glob) { if (input.glob) {
@@ -354,14 +354,16 @@ export namespace Ripgrep {
args.push("--") args.push("--")
args.push(input.pattern) args.push(input.pattern)
const command = args.join(" ") const result = await Process.text(args, {
const result = await $`${{ raw: command }}`.cwd(input.cwd).quiet().nothrow() cwd: input.cwd,
if (result.exitCode !== 0) { nothrow: true,
})
if (result.code !== 0) {
return [] return []
} }
// Handle both Unix (\n) and Windows (\r\n) line endings // Handle both Unix (\n) and Windows (\r\n) line endings
const lines = result.text().trim().split(/\r?\n/).filter(Boolean) const lines = result.text.trim().split(/\r?\n/).filter(Boolean)
// Parse JSON lines from ripgrep output // Parse JSON lines from ripgrep output
return lines return lines

View File

@@ -11,9 +11,9 @@ import { createWrapper } from "@parcel/watcher/wrapper"
import { lazy } from "@/util/lazy" import { lazy } from "@/util/lazy"
import { withTimeout } from "@/util/timeout" import { withTimeout } from "@/util/timeout"
import type ParcelWatcher from "@parcel/watcher" import type ParcelWatcher from "@parcel/watcher"
import { $ } from "bun"
import { Flag } from "@/flag/flag" import { Flag } from "@/flag/flag"
import { readdir } from "fs/promises" import { readdir } from "fs/promises"
import { git } from "@/util/git"
const SUBSCRIBE_TIMEOUT_MS = 10_000 const SUBSCRIBE_TIMEOUT_MS = 10_000
@@ -88,13 +88,10 @@ export namespace FileWatcher {
} }
if (Instance.project.vcs === "git") { if (Instance.project.vcs === "git") {
const vcsDir = await $`git rev-parse --git-dir` const result = await git(["rev-parse", "--git-dir"], {
.quiet() cwd: Instance.worktree,
.nothrow() })
.cwd(Instance.worktree) const vcsDir = result.exitCode === 0 ? path.resolve(Instance.worktree, result.text().trim()) : undefined
.text()
.then((x) => path.resolve(Instance.worktree, x.trim()))
.catch(() => undefined)
if (vcsDir && !cfgIgnores.includes(".git") && !cfgIgnores.includes(vcsDir)) { if (vcsDir && !cfgIgnores.includes(".git") && !cfgIgnores.includes(vcsDir)) {
const gitDirContents = await readdir(vcsDir).catch(() => []) const gitDirContents = await readdir(vcsDir).catch(() => [])
const ignoreList = gitDirContents.filter((entry) => entry !== "HEAD") const ignoreList = gitDirContents.filter((entry) => entry !== "HEAD")

View File

@@ -1,11 +1,12 @@
import { BusEvent } from "@/bus/bus-event" import { BusEvent } from "@/bus/bus-event"
import path from "path" import path from "path"
import { $ } from "bun"
import z from "zod" import z from "zod"
import { NamedError } from "@opencode-ai/util/error" import { NamedError } from "@opencode-ai/util/error"
import { Log } from "../util/log" import { Log } from "../util/log"
import { iife } from "@/util/iife" import { iife } from "@/util/iife"
import { Flag } from "../flag/flag" import { Flag } from "../flag/flag"
import { Process } from "@/util/process"
import { buffer } from "node:stream/consumers"
declare global { declare global {
const OPENCODE_VERSION: string const OPENCODE_VERSION: string
@@ -15,6 +16,38 @@ declare global {
export namespace Installation { export namespace Installation {
const log = Log.create({ service: "installation" }) const log = Log.create({ service: "installation" })
async function text(cmd: string[], opts: { cwd?: string; env?: NodeJS.ProcessEnv } = {}) {
return Process.text(cmd, {
cwd: opts.cwd,
env: opts.env,
nothrow: true,
}).then((x) => x.text)
}
async function upgradeCurl(target: string) {
const body = await fetch("https://opencode.ai/install").then((res) => {
if (!res.ok) throw new Error(res.statusText)
return res.text()
})
const proc = Process.spawn(["bash"], {
stdin: "pipe",
stdout: "pipe",
stderr: "pipe",
env: {
...process.env,
VERSION: target,
},
})
if (!proc.stdin || !proc.stdout || !proc.stderr) throw new Error("Process output not available")
proc.stdin.end(body)
const [code, stdout, stderr] = await Promise.all([proc.exited, buffer(proc.stdout), buffer(proc.stderr)])
return {
code,
stdout,
stderr,
}
}
export type Method = Awaited<ReturnType<typeof method>> export type Method = Awaited<ReturnType<typeof method>>
export const Event = { export const Event = {
@@ -65,31 +98,31 @@ export namespace Installation {
const checks = [ const checks = [
{ {
name: "npm" as const, name: "npm" as const,
command: () => $`npm list -g --depth=0`.throws(false).quiet().text(), command: () => text(["npm", "list", "-g", "--depth=0"]),
}, },
{ {
name: "yarn" as const, name: "yarn" as const,
command: () => $`yarn global list`.throws(false).quiet().text(), command: () => text(["yarn", "global", "list"]),
}, },
{ {
name: "pnpm" as const, name: "pnpm" as const,
command: () => $`pnpm list -g --depth=0`.throws(false).quiet().text(), command: () => text(["pnpm", "list", "-g", "--depth=0"]),
}, },
{ {
name: "bun" as const, name: "bun" as const,
command: () => $`bun pm ls -g`.throws(false).quiet().text(), command: () => text(["bun", "pm", "ls", "-g"]),
}, },
{ {
name: "brew" as const, name: "brew" as const,
command: () => $`brew list --formula opencode`.throws(false).quiet().text(), command: () => text(["brew", "list", "--formula", "opencode"]),
}, },
{ {
name: "scoop" as const, name: "scoop" as const,
command: () => $`scoop list opencode`.throws(false).quiet().text(), command: () => text(["scoop", "list", "opencode"]),
}, },
{ {
name: "choco" as const, name: "choco" as const,
command: () => $`choco list --limit-output opencode`.throws(false).quiet().text(), command: () => text(["choco", "list", "--limit-output", "opencode"]),
}, },
] ]
@@ -121,61 +154,70 @@ export namespace Installation {
) )
async function getBrewFormula() { async function getBrewFormula() {
const tapFormula = await $`brew list --formula anomalyco/tap/opencode`.throws(false).quiet().text() const tapFormula = await text(["brew", "list", "--formula", "anomalyco/tap/opencode"])
if (tapFormula.includes("opencode")) return "anomalyco/tap/opencode" if (tapFormula.includes("opencode")) return "anomalyco/tap/opencode"
const coreFormula = await $`brew list --formula opencode`.throws(false).quiet().text() const coreFormula = await text(["brew", "list", "--formula", "opencode"])
if (coreFormula.includes("opencode")) return "opencode" if (coreFormula.includes("opencode")) return "opencode"
return "opencode" return "opencode"
} }
export async function upgrade(method: Method, target: string) { export async function upgrade(method: Method, target: string) {
let cmd let result: Awaited<ReturnType<typeof upgradeCurl>> | undefined
switch (method) { switch (method) {
case "curl": case "curl":
cmd = $`curl -fsSL https://opencode.ai/install | bash`.env({ result = await upgradeCurl(target)
...process.env,
VERSION: target,
})
break break
case "npm": case "npm":
cmd = $`npm install -g opencode-ai@${target}` result = await Process.run(["npm", "install", "-g", `opencode-ai@${target}`], { nothrow: true })
break break
case "pnpm": case "pnpm":
cmd = $`pnpm install -g opencode-ai@${target}` result = await Process.run(["pnpm", "install", "-g", `opencode-ai@${target}`], { nothrow: true })
break break
case "bun": case "bun":
cmd = $`bun install -g opencode-ai@${target}` result = await Process.run(["bun", "install", "-g", `opencode-ai@${target}`], { nothrow: true })
break break
case "brew": { case "brew": {
const formula = await getBrewFormula() const formula = await getBrewFormula()
if (formula.includes("/")) { const env = {
cmd =
$`brew tap anomalyco/tap && cd "$(brew --repo anomalyco/tap)" && git pull --ff-only && brew upgrade ${formula}`.env(
{
HOMEBREW_NO_AUTO_UPDATE: "1",
...process.env,
},
)
break
}
cmd = $`brew upgrade ${formula}`.env({
HOMEBREW_NO_AUTO_UPDATE: "1", HOMEBREW_NO_AUTO_UPDATE: "1",
...process.env, ...process.env,
}) }
if (formula.includes("/")) {
const tap = await Process.run(["brew", "tap", "anomalyco/tap"], { env, nothrow: true })
if (tap.code !== 0) {
result = tap
break
}
const repo = await Process.text(["brew", "--repo", "anomalyco/tap"], { env, nothrow: true })
if (repo.code !== 0) {
result = repo
break
}
const dir = repo.text.trim()
if (dir) {
const pull = await Process.run(["git", "pull", "--ff-only"], { cwd: dir, env, nothrow: true })
if (pull.code !== 0) {
result = pull
break
}
}
}
result = await Process.run(["brew", "upgrade", formula], { env, nothrow: true })
break break
} }
case "choco": case "choco":
cmd = $`echo Y | choco upgrade opencode --version=${target}` result = await Process.run(["choco", "upgrade", "opencode", `--version=${target}`, "-y"], { nothrow: true })
break break
case "scoop": case "scoop":
cmd = $`scoop install opencode@${target}` result = await Process.run(["scoop", "install", `opencode@${target}`], { nothrow: true })
break break
default: default:
throw new Error(`Unknown method: ${method}`) throw new Error(`Unknown method: ${method}`)
} }
const result = await cmd.quiet().throws(false) if (!result || result.code !== 0) {
if (result.exitCode !== 0) { const stderr =
const stderr = method === "choco" ? "not running from an elevated command shell" : result.stderr.toString("utf8") method === "choco" ? "not running from an elevated command shell" : result?.stderr.toString("utf8") || ""
throw new UpgradeFailedError({ throw new UpgradeFailedError({
stderr: stderr, stderr: stderr,
}) })
@@ -186,7 +228,7 @@ export namespace Installation {
stdout: result.stdout.toString(), stdout: result.stdout.toString(),
stderr: result.stderr.toString(), stderr: result.stderr.toString(),
}) })
await $`${process.execPath} --version`.nothrow().quiet().text() await Process.text([process.execPath, "--version"], { nothrow: true })
} }
export const VERSION = typeof OPENCODE_VERSION === "string" ? OPENCODE_VERSION : "local" export const VERSION = typeof OPENCODE_VERSION === "string" ? OPENCODE_VERSION : "local"
@@ -199,7 +241,7 @@ export namespace Installation {
if (detectedMethod === "brew") { if (detectedMethod === "brew") {
const formula = await getBrewFormula() const formula = await getBrewFormula()
if (formula.includes("/")) { if (formula.includes("/")) {
const infoJson = await $`brew info --json=v2 ${formula}`.quiet().text() const infoJson = await text(["brew", "info", "--json=v2", formula])
const info = JSON.parse(infoJson) const info = JSON.parse(infoJson)
const version = info.formulae?.[0]?.versions?.stable const version = info.formulae?.[0]?.versions?.stable
if (!version) throw new Error(`Could not detect version for tap formula: ${formula}`) if (!version) throw new Error(`Could not detect version for tap formula: ${formula}`)
@@ -215,7 +257,7 @@ export namespace Installation {
if (detectedMethod === "npm" || detectedMethod === "bun" || detectedMethod === "pnpm") { if (detectedMethod === "npm" || detectedMethod === "bun" || detectedMethod === "pnpm") {
const registry = await iife(async () => { const registry = await iife(async () => {
const r = (await $`npm config get registry`.quiet().nothrow().text()).trim() const r = (await text(["npm", "config", "get", "registry"])).trim()
const reg = r || "https://registry.npmjs.org" const reg = r || "https://registry.npmjs.org"
return reg.endsWith("/") ? reg.slice(0, -1) : reg return reg.endsWith("/") ? reg.slice(0, -1) : reg
}) })

View File

@@ -4,7 +4,6 @@ import os from "os"
import { Global } from "../global" import { Global } from "../global"
import { Log } from "../util/log" import { Log } from "../util/log"
import { BunProc } from "../bun" import { BunProc } from "../bun"
import { $ } from "bun"
import { text } from "node:stream/consumers" import { text } from "node:stream/consumers"
import fs from "fs/promises" import fs from "fs/promises"
import { Filesystem } from "../util/filesystem" import { Filesystem } from "../util/filesystem"
@@ -13,6 +12,7 @@ import { Flag } from "../flag/flag"
import { Archive } from "../util/archive" import { Archive } from "../util/archive"
import { Process } from "../util/process" import { Process } from "../util/process"
import { which } from "../util/which" import { which } from "../util/which"
import { Module } from "@opencode-ai/util/module"
export namespace LSPServer { export namespace LSPServer {
const log = Log.create({ service: "lsp.server" }) const log = Log.create({ service: "lsp.server" })
@@ -21,6 +21,8 @@ export namespace LSPServer {
.stat(p) .stat(p)
.then(() => true) .then(() => true)
.catch(() => false) .catch(() => false)
const run = (cmd: string[], opts: Process.RunOptions = {}) => Process.run(cmd, { ...opts, nothrow: true })
const output = (cmd: string[], opts: Process.RunOptions = {}) => Process.text(cmd, { ...opts, nothrow: true })
export interface Handle { export interface Handle {
process: ChildProcessWithoutNullStreams process: ChildProcessWithoutNullStreams
@@ -97,7 +99,7 @@ export namespace LSPServer {
), ),
extensions: [".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs", ".mts", ".cts"], extensions: [".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs", ".mts", ".cts"],
async spawn(root) { async spawn(root) {
const tsserver = await Bun.resolve("typescript/lib/tsserver.js", Instance.directory).catch(() => {}) const tsserver = Module.resolve("typescript/lib/tsserver.js", Instance.directory)
log.info("typescript server", { tsserver }) log.info("typescript server", { tsserver })
if (!tsserver) return if (!tsserver) return
const proc = spawn(BunProc.which(), ["x", "typescript-language-server", "--stdio"], { const proc = spawn(BunProc.which(), ["x", "typescript-language-server", "--stdio"], {
@@ -172,7 +174,7 @@ export namespace LSPServer {
root: NearestRoot(["package-lock.json", "bun.lockb", "bun.lock", "pnpm-lock.yaml", "yarn.lock"]), root: NearestRoot(["package-lock.json", "bun.lockb", "bun.lock", "pnpm-lock.yaml", "yarn.lock"]),
extensions: [".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs", ".mts", ".cts", ".vue"], extensions: [".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs", ".mts", ".cts", ".vue"],
async spawn(root) { async spawn(root) {
const eslint = await Bun.resolve("eslint", Instance.directory).catch(() => {}) const eslint = Module.resolve("eslint", Instance.directory)
if (!eslint) return if (!eslint) return
log.info("spawning eslint server") log.info("spawning eslint server")
const serverPath = path.join(Global.Path.bin, "vscode-eslint", "server", "out", "eslintServer.js") const serverPath = path.join(Global.Path.bin, "vscode-eslint", "server", "out", "eslintServer.js")
@@ -205,8 +207,8 @@ export namespace LSPServer {
await fs.rename(extractedPath, finalPath) await fs.rename(extractedPath, finalPath)
const npmCmd = process.platform === "win32" ? "npm.cmd" : "npm" const npmCmd = process.platform === "win32" ? "npm.cmd" : "npm"
await $`${npmCmd} install`.cwd(finalPath).quiet() await Process.run([npmCmd, "install"], { cwd: finalPath })
await $`${npmCmd} run compile`.cwd(finalPath).quiet() await Process.run([npmCmd, "run", "compile"], { cwd: finalPath })
log.info("installed VS Code ESLint server", { serverPath }) log.info("installed VS Code ESLint server", { serverPath })
} }
@@ -340,7 +342,7 @@ export namespace LSPServer {
let args = ["lsp-proxy", "--stdio"] let args = ["lsp-proxy", "--stdio"]
if (!bin) { if (!bin) {
const resolved = await Bun.resolve("biome", root).catch(() => undefined) const resolved = Module.resolve("biome", root)
if (!resolved) return if (!resolved) return
bin = BunProc.which() bin = BunProc.which()
args = ["x", "biome", "lsp-proxy", "--stdio"] args = ["x", "biome", "lsp-proxy", "--stdio"]
@@ -602,10 +604,11 @@ export namespace LSPServer {
recursive: true, recursive: true,
}) })
await $`mix deps.get && mix compile && mix elixir_ls.release2 -o release` const cwd = path.join(Global.Path.bin, "elixir-ls-master")
.quiet() const env = { MIX_ENV: "prod", ...process.env }
.cwd(path.join(Global.Path.bin, "elixir-ls-master")) await Process.run(["mix", "deps.get"], { cwd, env })
.env({ MIX_ENV: "prod", ...process.env }) await Process.run(["mix", "compile"], { cwd, env })
await Process.run(["mix", "elixir_ls.release2", "-o", "release"], { cwd, env })
log.info(`installed elixir-ls`, { log.info(`installed elixir-ls`, {
path: elixirLsPath, path: elixirLsPath,
@@ -706,7 +709,7 @@ export namespace LSPServer {
}) })
if (!ok) return if (!ok) return
} else { } else {
await $`tar -xf ${tempPath}`.cwd(Global.Path.bin).quiet().nothrow() await run(["tar", "-xf", tempPath], { cwd: Global.Path.bin })
} }
await fs.rm(tempPath, { force: true }) await fs.rm(tempPath, { force: true })
@@ -719,7 +722,7 @@ export namespace LSPServer {
} }
if (platform !== "win32") { if (platform !== "win32") {
await $`chmod +x ${bin}`.quiet().nothrow() await fs.chmod(bin, 0o755).catch(() => {})
} }
log.info(`installed zls`, { bin }) log.info(`installed zls`, { bin })
@@ -831,11 +834,11 @@ export namespace LSPServer {
// This is specific to macOS where sourcekit-lsp is typically installed with Xcode // This is specific to macOS where sourcekit-lsp is typically installed with Xcode
if (!which("xcrun")) return if (!which("xcrun")) return
const lspLoc = await $`xcrun --find sourcekit-lsp`.quiet().nothrow() const lspLoc = await output(["xcrun", "--find", "sourcekit-lsp"])
if (lspLoc.exitCode !== 0) return if (lspLoc.code !== 0) return
const bin = lspLoc.text().trim() const bin = lspLoc.text.trim()
return { return {
process: spawn(bin, { process: spawn(bin, {
@@ -1010,7 +1013,7 @@ export namespace LSPServer {
if (!ok) return if (!ok) return
} }
if (tar) { if (tar) {
await $`tar -xf ${archive}`.cwd(Global.Path.bin).quiet().nothrow() await run(["tar", "-xf", archive], { cwd: Global.Path.bin })
} }
await fs.rm(archive, { force: true }) await fs.rm(archive, { force: true })
@@ -1021,7 +1024,7 @@ export namespace LSPServer {
} }
if (platform !== "win32") { if (platform !== "win32") {
await $`chmod +x ${bin}`.quiet().nothrow() await fs.chmod(bin, 0o755).catch(() => {})
} }
await fs.unlink(path.join(Global.Path.bin, "clangd")).catch(() => {}) await fs.unlink(path.join(Global.Path.bin, "clangd")).catch(() => {})
@@ -1082,7 +1085,7 @@ export namespace LSPServer {
extensions: [".astro"], extensions: [".astro"],
root: NearestRoot(["package-lock.json", "bun.lockb", "bun.lock", "pnpm-lock.yaml", "yarn.lock"]), root: NearestRoot(["package-lock.json", "bun.lockb", "bun.lock", "pnpm-lock.yaml", "yarn.lock"]),
async spawn(root) { async spawn(root) {
const tsserver = await Bun.resolve("typescript/lib/tsserver.js", Instance.directory).catch(() => {}) const tsserver = Module.resolve("typescript/lib/tsserver.js", Instance.directory)
if (!tsserver) { if (!tsserver) {
log.info("typescript not found, required for Astro language server") log.info("typescript not found, required for Astro language server")
return return
@@ -1161,13 +1164,10 @@ export namespace LSPServer {
log.error("Java 21 or newer is required to run the JDTLS. Please install it first.") log.error("Java 21 or newer is required to run the JDTLS. Please install it first.")
return return
} }
const javaMajorVersion = await $`java -version` const javaMajorVersion = await run(["java", "-version"]).then((result) => {
.quiet() const m = /"(\d+)\.\d+\.\d+"/.exec(result.stderr.toString())
.nothrow() return !m ? undefined : parseInt(m[1])
.then(({ stderr }) => { })
const m = /"(\d+)\.\d+\.\d+"/.exec(stderr.toString())
return !m ? undefined : parseInt(m[1])
})
if (javaMajorVersion == null || javaMajorVersion < 21) { if (javaMajorVersion == null || javaMajorVersion < 21) {
log.error("JDTLS requires at least Java 21.") log.error("JDTLS requires at least Java 21.")
return return
@@ -1184,27 +1184,27 @@ export namespace LSPServer {
const archiveName = "release.tar.gz" const archiveName = "release.tar.gz"
log.info("Downloading JDTLS archive", { url: releaseURL, dest: distPath }) log.info("Downloading JDTLS archive", { url: releaseURL, dest: distPath })
const curlResult = await $`curl -L -o ${archiveName} '${releaseURL}'`.cwd(distPath).quiet().nothrow() const download = await fetch(releaseURL)
if (curlResult.exitCode !== 0) { if (!download.ok || !download.body) {
log.error("Failed to download JDTLS", { exitCode: curlResult.exitCode, stderr: curlResult.stderr.toString() }) log.error("Failed to download JDTLS", { status: download.status, statusText: download.statusText })
return return
} }
await Filesystem.writeStream(path.join(distPath, archiveName), download.body)
log.info("Extracting JDTLS archive") log.info("Extracting JDTLS archive")
const tarResult = await $`tar -xzf ${archiveName}`.cwd(distPath).quiet().nothrow() const tarResult = await run(["tar", "-xzf", archiveName], { cwd: distPath })
if (tarResult.exitCode !== 0) { if (tarResult.code !== 0) {
log.error("Failed to extract JDTLS", { exitCode: tarResult.exitCode, stderr: tarResult.stderr.toString() }) log.error("Failed to extract JDTLS", { exitCode: tarResult.code, stderr: tarResult.stderr.toString() })
return return
} }
await fs.rm(path.join(distPath, archiveName), { force: true }) await fs.rm(path.join(distPath, archiveName), { force: true })
log.info("JDTLS download and extraction completed") log.info("JDTLS download and extraction completed")
} }
const jarFileName = await $`ls org.eclipse.equinox.launcher_*.jar` const jarFileName =
.cwd(launcherDir) (await fs.readdir(launcherDir).catch(() => []))
.quiet() .find((item) => /^org\.eclipse\.equinox\.launcher_.*\.jar$/.test(item))
.nothrow() ?.trim() ?? ""
.then(({ stdout }) => stdout.toString().trim())
const launcherJar = path.join(launcherDir, jarFileName) const launcherJar = path.join(launcherDir, jarFileName)
if (!(await pathExists(launcherJar))) { if (!(await pathExists(launcherJar))) {
log.error(`Failed to locate the JDTLS launcher module in the installed directory: ${distPath}.`) log.error(`Failed to locate the JDTLS launcher module in the installed directory: ${distPath}.`)
@@ -1317,7 +1317,15 @@ export namespace LSPServer {
await fs.mkdir(distPath, { recursive: true }) await fs.mkdir(distPath, { recursive: true })
const archivePath = path.join(distPath, "kotlin-ls.zip") const archivePath = path.join(distPath, "kotlin-ls.zip")
await $`curl -L -o '${archivePath}' '${releaseURL}'`.quiet().nothrow() const download = await fetch(releaseURL)
if (!download.ok || !download.body) {
log.error("Failed to download Kotlin Language Server", {
status: download.status,
statusText: download.statusText,
})
return
}
await Filesystem.writeStream(archivePath, download.body)
const ok = await Archive.extractZip(archivePath, distPath) const ok = await Archive.extractZip(archivePath, distPath)
.then(() => true) .then(() => true)
.catch((error) => { .catch((error) => {
@@ -1327,7 +1335,7 @@ export namespace LSPServer {
if (!ok) return if (!ok) return
await fs.rm(archivePath, { force: true }) await fs.rm(archivePath, { force: true })
if (process.platform !== "win32") { if (process.platform !== "win32") {
await $`chmod +x ${launcherScript}`.quiet().nothrow() await fs.chmod(launcherScript, 0o755).catch(() => {})
} }
log.info("Installed Kotlin Language Server", { path: launcherScript }) log.info("Installed Kotlin Language Server", { path: launcherScript })
} }
@@ -1491,10 +1499,9 @@ export namespace LSPServer {
}) })
if (!ok) return if (!ok) return
} else { } else {
const ok = await $`tar -xzf ${tempPath} -C ${installDir}` const ok = await run(["tar", "-xzf", tempPath, "-C", installDir])
.quiet() .then((result) => result.code === 0)
.then(() => true) .catch((error: unknown) => {
.catch((error) => {
log.error("Failed to extract lua-language-server archive", { error }) log.error("Failed to extract lua-language-server archive", { error })
return false return false
}) })
@@ -1512,11 +1519,15 @@ export namespace LSPServer {
} }
if (platform !== "win32") { if (platform !== "win32") {
const ok = await $`chmod +x ${bin}`.quiet().catch((error) => { const ok = await fs
log.error("Failed to set executable permission for lua-language-server binary", { .chmod(bin, 0o755)
error, .then(() => true)
.catch((error: unknown) => {
log.error("Failed to set executable permission for lua-language-server binary", {
error,
})
return false
}) })
})
if (!ok) return if (!ok) return
} }
@@ -1730,7 +1741,7 @@ export namespace LSPServer {
} }
if (platform !== "win32") { if (platform !== "win32") {
await $`chmod +x ${bin}`.quiet().nothrow() await fs.chmod(bin, 0o755).catch(() => {})
} }
log.info(`installed terraform-ls`, { bin }) log.info(`installed terraform-ls`, { bin })
@@ -1813,7 +1824,7 @@ export namespace LSPServer {
if (!ok) return if (!ok) return
} }
if (ext === "tar.gz") { if (ext === "tar.gz") {
await $`tar -xzf ${tempPath}`.cwd(Global.Path.bin).quiet().nothrow() await run(["tar", "-xzf", tempPath], { cwd: Global.Path.bin })
} }
await fs.rm(tempPath, { force: true }) await fs.rm(tempPath, { force: true })
@@ -1826,7 +1837,7 @@ export namespace LSPServer {
} }
if (platform !== "win32") { if (platform !== "win32") {
await $`chmod +x ${bin}`.quiet().nothrow() await fs.chmod(bin, 0o755).catch(() => {})
} }
log.info("installed texlab", { bin }) log.info("installed texlab", { bin })
@@ -2018,7 +2029,7 @@ export namespace LSPServer {
}) })
if (!ok) return if (!ok) return
} else { } else {
await $`tar -xzf ${tempPath} --strip-components=1`.cwd(Global.Path.bin).quiet().nothrow() await run(["tar", "-xzf", tempPath, "--strip-components=1"], { cwd: Global.Path.bin })
} }
await fs.rm(tempPath, { force: true }) await fs.rm(tempPath, { force: true })
@@ -2031,7 +2042,7 @@ export namespace LSPServer {
} }
if (platform !== "win32") { if (platform !== "win32") {
await $`chmod +x ${bin}`.quiet().nothrow() await fs.chmod(bin, 0o755).catch(() => {})
} }
log.info("installed tinymist", { bin }) log.info("installed tinymist", { bin })

View File

@@ -1,11 +1,11 @@
import { BusEvent } from "@/bus/bus-event" import { BusEvent } from "@/bus/bus-event"
import { Bus } from "@/bus" import { Bus } from "@/bus"
import { $ } from "bun"
import path from "path" import path from "path"
import z from "zod" import z from "zod"
import { Log } from "@/util/log" import { Log } from "@/util/log"
import { Instance } from "./instance" import { Instance } from "./instance"
import { FileWatcher } from "@/file/watcher" import { FileWatcher } from "@/file/watcher"
import { git } from "@/util/git"
const log = Log.create({ service: "vcs" }) const log = Log.create({ service: "vcs" })
@@ -29,13 +29,13 @@ export namespace Vcs {
export type Info = z.infer<typeof Info> export type Info = z.infer<typeof Info>
async function currentBranch() { async function currentBranch() {
return $`git rev-parse --abbrev-ref HEAD` const result = await git(["rev-parse", "--abbrev-ref", "HEAD"], {
.quiet() cwd: Instance.worktree,
.nothrow() })
.cwd(Instance.worktree) if (result.exitCode !== 0) return
.text() const text = result.text().trim()
.then((x) => x.trim()) if (!text) return
.catch(() => undefined) return text
} }
const state = Instance.state( const state = Instance.state(

View File

@@ -1,4 +1,3 @@
import { $ } from "bun"
import path from "path" import path from "path"
import fs from "fs/promises" import fs from "fs/promises"
import { Filesystem } from "../util/filesystem" import { Filesystem } from "../util/filesystem"
@@ -9,12 +8,17 @@ import z from "zod"
import { Config } from "../config/config" import { Config } from "../config/config"
import { Instance } from "../project/instance" import { Instance } from "../project/instance"
import { Scheduler } from "../scheduler" import { Scheduler } from "../scheduler"
import { Process } from "@/util/process"
export namespace Snapshot { export namespace Snapshot {
const log = Log.create({ service: "snapshot" }) const log = Log.create({ service: "snapshot" })
const hour = 60 * 60 * 1000 const hour = 60 * 60 * 1000
const prune = "7.days" const prune = "7.days"
function args(git: string, cmd: string[]) {
return ["--git-dir", git, "--work-tree", Instance.worktree, ...cmd]
}
export function init() { export function init() {
Scheduler.register({ Scheduler.register({
id: "snapshot.cleanup", id: "snapshot.cleanup",
@@ -34,13 +38,13 @@ export namespace Snapshot {
.then(() => true) .then(() => true)
.catch(() => false) .catch(() => false)
if (!exists) return if (!exists) return
const result = await $`git --git-dir ${git} --work-tree ${Instance.worktree} gc --prune=${prune}` const result = await Process.run(["git", ...args(git, ["gc", `--prune=${prune}`])], {
.quiet() cwd: Instance.directory,
.cwd(Instance.directory) nothrow: true,
.nothrow() })
if (result.exitCode !== 0) { if (result.code !== 0) {
log.warn("cleanup failed", { log.warn("cleanup failed", {
exitCode: result.exitCode, exitCode: result.code,
stderr: result.stderr.toString(), stderr: result.stderr.toString(),
stdout: result.stdout.toString(), stdout: result.stdout.toString(),
}) })
@@ -55,27 +59,27 @@ export namespace Snapshot {
if (cfg.snapshot === false) return if (cfg.snapshot === false) return
const git = gitdir() const git = gitdir()
if (await fs.mkdir(git, { recursive: true })) { if (await fs.mkdir(git, { recursive: true })) {
await $`git init` await Process.run(["git", "init"], {
.env({ env: {
...process.env, ...process.env,
GIT_DIR: git, GIT_DIR: git,
GIT_WORK_TREE: Instance.worktree, GIT_WORK_TREE: Instance.worktree,
}) },
.quiet() nothrow: true,
.nothrow() })
// Configure git to not convert line endings on Windows // Configure git to not convert line endings on Windows
await $`git --git-dir ${git} config core.autocrlf false`.quiet().nothrow() await Process.run(["git", "--git-dir", git, "config", "core.autocrlf", "false"], { nothrow: true })
await $`git --git-dir ${git} config core.longpaths true`.quiet().nothrow() await Process.run(["git", "--git-dir", git, "config", "core.longpaths", "true"], { nothrow: true })
await $`git --git-dir ${git} config core.symlinks true`.quiet().nothrow() await Process.run(["git", "--git-dir", git, "config", "core.symlinks", "true"], { nothrow: true })
await $`git --git-dir ${git} config core.fsmonitor false`.quiet().nothrow() await Process.run(["git", "--git-dir", git, "config", "core.fsmonitor", "false"], { nothrow: true })
log.info("initialized") log.info("initialized")
} }
await add(git) await add(git)
const hash = await $`git --git-dir ${git} --work-tree ${Instance.worktree} write-tree` const hash = await Process.text(["git", ...args(git, ["write-tree"])], {
.quiet() cwd: Instance.directory,
.cwd(Instance.directory) nothrow: true,
.nothrow() }).then((x) => x.text)
.text()
log.info("tracking", { hash, cwd: Instance.directory, git }) log.info("tracking", { hash, cwd: Instance.directory, git })
return hash.trim() return hash.trim()
} }
@@ -89,19 +93,32 @@ export namespace Snapshot {
export async function patch(hash: string): Promise<Patch> { export async function patch(hash: string): Promise<Patch> {
const git = gitdir() const git = gitdir()
await add(git) await add(git)
const result = const result = await Process.text(
await $`git -c core.autocrlf=false -c core.longpaths=true -c core.symlinks=true -c core.quotepath=false --git-dir ${git} --work-tree ${Instance.worktree} diff --no-ext-diff --name-only ${hash} -- .` [
.quiet() "git",
.cwd(Instance.directory) "-c",
.nothrow() "core.autocrlf=false",
"-c",
"core.longpaths=true",
"-c",
"core.symlinks=true",
"-c",
"core.quotepath=false",
...args(git, ["diff", "--no-ext-diff", "--name-only", hash, "--", "."]),
],
{
cwd: Instance.directory,
nothrow: true,
},
)
// If git diff fails, return empty patch // If git diff fails, return empty patch
if (result.exitCode !== 0) { if (result.code !== 0) {
log.warn("failed to get diff", { hash, exitCode: result.exitCode }) log.warn("failed to get diff", { hash, exitCode: result.code })
return { hash, files: [] } return { hash, files: [] }
} }
const files = result.text() const files = result.text
return { return {
hash, hash,
files: files files: files
@@ -116,20 +133,37 @@ export namespace Snapshot {
export async function restore(snapshot: string) { export async function restore(snapshot: string) {
log.info("restore", { commit: snapshot }) log.info("restore", { commit: snapshot })
const git = gitdir() const git = gitdir()
const result = const result = await Process.run(
await $`git -c core.longpaths=true -c core.symlinks=true --git-dir ${git} --work-tree ${Instance.worktree} read-tree ${snapshot} && git -c core.longpaths=true -c core.symlinks=true --git-dir ${git} --work-tree ${Instance.worktree} checkout-index -a -f` ["git", "-c", "core.longpaths=true", "-c", "core.symlinks=true", ...args(git, ["read-tree", snapshot])],
.quiet() {
.cwd(Instance.worktree) cwd: Instance.worktree,
.nothrow() nothrow: true,
},
if (result.exitCode !== 0) { )
if (result.code === 0) {
const checkout = await Process.run(
["git", "-c", "core.longpaths=true", "-c", "core.symlinks=true", ...args(git, ["checkout-index", "-a", "-f"])],
{
cwd: Instance.worktree,
nothrow: true,
},
)
if (checkout.code === 0) return
log.error("failed to restore snapshot", { log.error("failed to restore snapshot", {
snapshot, snapshot,
exitCode: result.exitCode, exitCode: checkout.code,
stderr: result.stderr.toString(), stderr: checkout.stderr.toString(),
stdout: result.stdout.toString(), stdout: checkout.stdout.toString(),
}) })
return
} }
log.error("failed to restore snapshot", {
snapshot,
exitCode: result.code,
stderr: result.stderr.toString(),
stdout: result.stdout.toString(),
})
} }
export async function revert(patches: Patch[]) { export async function revert(patches: Patch[]) {
@@ -139,19 +173,37 @@ export namespace Snapshot {
for (const file of item.files) { for (const file of item.files) {
if (files.has(file)) continue if (files.has(file)) continue
log.info("reverting", { file, hash: item.hash }) log.info("reverting", { file, hash: item.hash })
const result = const result = await Process.run(
await $`git -c core.longpaths=true -c core.symlinks=true --git-dir ${git} --work-tree ${Instance.worktree} checkout ${item.hash} -- ${file}` [
.quiet() "git",
.cwd(Instance.worktree) "-c",
.nothrow() "core.longpaths=true",
if (result.exitCode !== 0) { "-c",
"core.symlinks=true",
...args(git, ["checkout", item.hash, "--", file]),
],
{
cwd: Instance.worktree,
nothrow: true,
},
)
if (result.code !== 0) {
const relativePath = path.relative(Instance.worktree, file) const relativePath = path.relative(Instance.worktree, file)
const checkTree = const checkTree = await Process.text(
await $`git -c core.longpaths=true -c core.symlinks=true --git-dir ${git} --work-tree ${Instance.worktree} ls-tree ${item.hash} -- ${relativePath}` [
.quiet() "git",
.cwd(Instance.worktree) "-c",
.nothrow() "core.longpaths=true",
if (checkTree.exitCode === 0 && checkTree.text().trim()) { "-c",
"core.symlinks=true",
...args(git, ["ls-tree", item.hash, "--", relativePath]),
],
{
cwd: Instance.worktree,
nothrow: true,
},
)
if (checkTree.code === 0 && checkTree.text.trim()) {
log.info("file existed in snapshot but checkout failed, keeping", { log.info("file existed in snapshot but checkout failed, keeping", {
file, file,
}) })
@@ -168,23 +220,36 @@ export namespace Snapshot {
export async function diff(hash: string) { export async function diff(hash: string) {
const git = gitdir() const git = gitdir()
await add(git) await add(git)
const result = const result = await Process.text(
await $`git -c core.autocrlf=false -c core.longpaths=true -c core.symlinks=true -c core.quotepath=false --git-dir ${git} --work-tree ${Instance.worktree} diff --no-ext-diff ${hash} -- .` [
.quiet() "git",
.cwd(Instance.worktree) "-c",
.nothrow() "core.autocrlf=false",
"-c",
"core.longpaths=true",
"-c",
"core.symlinks=true",
"-c",
"core.quotepath=false",
...args(git, ["diff", "--no-ext-diff", hash, "--", "."]),
],
{
cwd: Instance.worktree,
nothrow: true,
},
)
if (result.exitCode !== 0) { if (result.code !== 0) {
log.warn("failed to get diff", { log.warn("failed to get diff", {
hash, hash,
exitCode: result.exitCode, exitCode: result.code,
stderr: result.stderr.toString(), stderr: result.stderr.toString(),
stdout: result.stdout.toString(), stdout: result.stdout.toString(),
}) })
return "" return ""
} }
return result.text().trim() return result.text.trim()
} }
export const FileDiff = z export const FileDiff = z
@@ -205,12 +270,24 @@ export namespace Snapshot {
const result: FileDiff[] = [] const result: FileDiff[] = []
const status = new Map<string, "added" | "deleted" | "modified">() const status = new Map<string, "added" | "deleted" | "modified">()
const statuses = const statuses = await Process.text(
await $`git -c core.autocrlf=false -c core.longpaths=true -c core.symlinks=true -c core.quotepath=false --git-dir ${git} --work-tree ${Instance.worktree} diff --no-ext-diff --name-status --no-renames ${from} ${to} -- .` [
.quiet() "git",
.cwd(Instance.directory) "-c",
.nothrow() "core.autocrlf=false",
.text() "-c",
"core.longpaths=true",
"-c",
"core.symlinks=true",
"-c",
"core.quotepath=false",
...args(git, ["diff", "--no-ext-diff", "--name-status", "--no-renames", from, to, "--", "."]),
],
{
cwd: Instance.directory,
nothrow: true,
},
).then((x) => x.text)
for (const line of statuses.trim().split("\n")) { for (const line of statuses.trim().split("\n")) {
if (!line) continue if (!line) continue
@@ -220,26 +297,57 @@ export namespace Snapshot {
status.set(file, kind) status.set(file, kind)
} }
for await (const line of $`git -c core.autocrlf=false -c core.longpaths=true -c core.symlinks=true -c core.quotepath=false --git-dir ${git} --work-tree ${Instance.worktree} diff --no-ext-diff --no-renames --numstat ${from} ${to} -- .` for (const line of await Process.lines(
.quiet() [
.cwd(Instance.directory) "git",
.nothrow() "-c",
.lines()) { "core.autocrlf=false",
"-c",
"core.longpaths=true",
"-c",
"core.symlinks=true",
"-c",
"core.quotepath=false",
...args(git, ["diff", "--no-ext-diff", "--no-renames", "--numstat", from, to, "--", "."]),
],
{
cwd: Instance.directory,
nothrow: true,
},
)) {
if (!line) continue if (!line) continue
const [additions, deletions, file] = line.split("\t") const [additions, deletions, file] = line.split("\t")
const isBinaryFile = additions === "-" && deletions === "-" const isBinaryFile = additions === "-" && deletions === "-"
const before = isBinaryFile const before = isBinaryFile
? "" ? ""
: await $`git -c core.autocrlf=false -c core.longpaths=true -c core.symlinks=true --git-dir ${git} --work-tree ${Instance.worktree} show ${from}:${file}` : await Process.text(
.quiet() [
.nothrow() "git",
.text() "-c",
"core.autocrlf=false",
"-c",
"core.longpaths=true",
"-c",
"core.symlinks=true",
...args(git, ["show", `${from}:${file}`]),
],
{ nothrow: true },
).then((x) => x.text)
const after = isBinaryFile const after = isBinaryFile
? "" ? ""
: await $`git -c core.autocrlf=false -c core.longpaths=true -c core.symlinks=true --git-dir ${git} --work-tree ${Instance.worktree} show ${to}:${file}` : await Process.text(
.quiet() [
.nothrow() "git",
.text() "-c",
"core.autocrlf=false",
"-c",
"core.longpaths=true",
"-c",
"core.symlinks=true",
...args(git, ["show", `${to}:${file}`]),
],
{ nothrow: true },
).then((x) => x.text)
const added = isBinaryFile ? 0 : parseInt(additions) const added = isBinaryFile ? 0 : parseInt(additions)
const deleted = isBinaryFile ? 0 : parseInt(deletions) const deleted = isBinaryFile ? 0 : parseInt(deletions)
result.push({ result.push({
@@ -261,10 +369,22 @@ export namespace Snapshot {
async function add(git: string) { async function add(git: string) {
await syncExclude(git) await syncExclude(git)
await $`git -c core.autocrlf=false -c core.longpaths=true -c core.symlinks=true --git-dir ${git} --work-tree ${Instance.worktree} add .` await Process.run(
.quiet() [
.cwd(Instance.directory) "git",
.nothrow() "-c",
"core.autocrlf=false",
"-c",
"core.longpaths=true",
"-c",
"core.symlinks=true",
...args(git, ["add", "."]),
],
{
cwd: Instance.directory,
nothrow: true,
},
)
} }
async function syncExclude(git: string) { async function syncExclude(git: string) {
@@ -281,11 +401,10 @@ export namespace Snapshot {
} }
async function excludes() { async function excludes() {
const file = await $`git rev-parse --path-format=absolute --git-path info/exclude` const file = await Process.text(["git", "rev-parse", "--path-format=absolute", "--git-path", "info/exclude"], {
.quiet() cwd: Instance.worktree,
.cwd(Instance.worktree) nothrow: true,
.nothrow() }).then((x) => x.text)
.text()
if (!file.trim()) return if (!file.trim()) return
const exists = await fs const exists = await fs
.stat(file.trim()) .stat(file.trim())

View File

@@ -5,10 +5,10 @@ import { Global } from "../global"
import { Filesystem } from "../util/filesystem" import { Filesystem } from "../util/filesystem"
import { lazy } from "../util/lazy" import { lazy } from "../util/lazy"
import { Lock } from "../util/lock" import { Lock } from "../util/lock"
import { $ } from "bun"
import { NamedError } from "@opencode-ai/util/error" import { NamedError } from "@opencode-ai/util/error"
import z from "zod" import z from "zod"
import { Glob } from "../util/glob" import { Glob } from "../util/glob"
import { git } from "@/util/git"
export namespace Storage { export namespace Storage {
const log = Log.create({ service: "storage" }) const log = Log.create({ service: "storage" })
@@ -49,18 +49,15 @@ export namespace Storage {
} }
if (!worktree) continue if (!worktree) continue
if (!(await Filesystem.isDir(worktree))) continue if (!(await Filesystem.isDir(worktree))) continue
const [id] = await $`git rev-list --max-parents=0 --all` const result = await git(["rev-list", "--max-parents=0", "--all"], {
.quiet() cwd: worktree,
.nothrow() })
.cwd(worktree) const [id] = result
.text() .text()
.then((x) => .split("\n")
x .filter(Boolean)
.split("\n") .map((x) => x.trim())
.filter(Boolean) .toSorted()
.map((x) => x.trim())
.toSorted(),
)
if (!id) continue if (!id) continue
projectID = id projectID = id

View File

@@ -7,8 +7,8 @@ import { Log } from "../util/log"
import { Instance } from "../project/instance" import { Instance } from "../project/instance"
import { lazy } from "@/util/lazy" import { lazy } from "@/util/lazy"
import { Language } from "web-tree-sitter" import { Language } from "web-tree-sitter"
import fs from "fs/promises"
import { $ } from "bun"
import { Filesystem } from "@/util/filesystem" import { Filesystem } from "@/util/filesystem"
import { fileURLToPath } from "url" import { fileURLToPath } from "url"
import { Flag } from "@/flag/flag.ts" import { Flag } from "@/flag/flag.ts"
@@ -116,12 +116,7 @@ export const BashTool = Tool.define("bash", async () => {
if (["cd", "rm", "cp", "mv", "mkdir", "touch", "chmod", "chown", "cat"].includes(command[0])) { if (["cd", "rm", "cp", "mv", "mkdir", "touch", "chmod", "chown", "cat"].includes(command[0])) {
for (const arg of command.slice(1)) { for (const arg of command.slice(1)) {
if (arg.startsWith("-") || (command[0] === "chmod" && arg.startsWith("+"))) continue if (arg.startsWith("-") || (command[0] === "chmod" && arg.startsWith("+"))) continue
const resolved = await $`realpath ${arg}` const resolved = await fs.realpath(path.resolve(cwd, arg)).catch(() => "")
.cwd(cwd)
.quiet()
.nothrow()
.text()
.then((x) => x.trim())
log.info("resolved path", { arg, resolved }) log.info("resolved path", { arg, resolved })
if (resolved) { if (resolved) {
const normalized = const normalized =

View File

@@ -1,5 +1,5 @@
import { $ } from "bun"
import path from "path" import path from "path"
import { Process } from "./process"
export namespace Archive { export namespace Archive {
export async function extractZip(zipPath: string, destDir: string) { export async function extractZip(zipPath: string, destDir: string) {
@@ -8,9 +8,10 @@ export namespace Archive {
const winDestDir = path.resolve(destDir) const winDestDir = path.resolve(destDir)
// $global:ProgressPreference suppresses PowerShell's blue progress bar popup // $global:ProgressPreference suppresses PowerShell's blue progress bar popup
const cmd = `$global:ProgressPreference = 'SilentlyContinue'; Expand-Archive -Path '${winZipPath}' -DestinationPath '${winDestDir}' -Force` const cmd = `$global:ProgressPreference = 'SilentlyContinue'; Expand-Archive -Path '${winZipPath}' -DestinationPath '${winDestDir}' -Force`
await $`powershell -NoProfile -NonInteractive -Command ${cmd}`.quiet() await Process.run(["powershell", "-NoProfile", "-NonInteractive", "-Command", cmd])
} else { return
await $`unzip -o -q ${zipPath} -d ${destDir}`.quiet()
} }
await Process.run(["unzip", "-o", "-q", zipPath, "-d", destDir])
} }
} }

View File

@@ -25,6 +25,10 @@ export namespace Process {
stderr: Buffer stderr: Buffer
} }
export interface TextResult extends Result {
text: string
}
export class RunFailedError extends Error { export class RunFailedError extends Error {
readonly cmd: string[] readonly cmd: string[]
readonly code: number readonly code: number
@@ -114,13 +118,33 @@ export namespace Process {
if (!proc.stdout || !proc.stderr) throw new Error("Process output not available") if (!proc.stdout || !proc.stderr) throw new Error("Process output not available")
const [code, stdout, stderr] = await Promise.all([proc.exited, buffer(proc.stdout), buffer(proc.stderr)]) const out = await Promise.all([proc.exited, buffer(proc.stdout), buffer(proc.stderr)])
const out = { .then(([code, stdout, stderr]) => ({
code, code,
stdout, stdout,
stderr, stderr,
} }))
.catch((err: unknown) => {
if (!opts.nothrow) throw err
return {
code: 1,
stdout: Buffer.alloc(0),
stderr: Buffer.from(err instanceof Error ? err.message : String(err)),
}
})
if (out.code === 0 || opts.nothrow) return out if (out.code === 0 || opts.nothrow) return out
throw new RunFailedError(cmd, out.code, out.stdout, out.stderr) throw new RunFailedError(cmd, out.code, out.stdout, out.stderr)
} }
export async function text(cmd: string[], opts: RunOptions = {}): Promise<TextResult> {
const out = await run(cmd, opts)
return {
...out,
text: out.stdout.toString(),
}
}
export async function lines(cmd: string[], opts: RunOptions = {}): Promise<string[]> {
return (await text(cmd, opts)).text.split(/\r?\n/).filter(Boolean)
}
} }

View File

@@ -1,4 +1,3 @@
import { $ } from "bun"
import fs from "fs/promises" import fs from "fs/promises"
import path from "path" import path from "path"
import z from "zod" import z from "zod"
@@ -11,6 +10,8 @@ import { Database, eq } from "../storage/db"
import { ProjectTable } from "../project/project.sql" import { ProjectTable } from "../project/project.sql"
import { fn } from "../util/fn" import { fn } from "../util/fn"
import { Log } from "../util/log" import { Log } from "../util/log"
import { Process } from "../util/process"
import { git } from "../util/git"
import { BusEvent } from "@/bus/bus-event" import { BusEvent } from "@/bus/bus-event"
import { GlobalBus } from "@/bus/global" import { GlobalBus } from "@/bus/global"
@@ -248,14 +249,14 @@ export namespace Worktree {
} }
async function sweep(root: string) { async function sweep(root: string) {
const first = await $`git clean -ffdx`.quiet().nothrow().cwd(root) const first = await git(["clean", "-ffdx"], { cwd: root })
if (first.exitCode === 0) return first if (first.exitCode === 0) return first
const entries = failed(first) const entries = failed(first)
if (!entries.length) return first if (!entries.length) return first
await prune(root, entries) await prune(root, entries)
return $`git clean -ffdx`.quiet().nothrow().cwd(root) return git(["clean", "-ffdx"], { cwd: root })
} }
async function canonical(input: string) { async function canonical(input: string) {
@@ -274,7 +275,9 @@ export namespace Worktree {
if (await exists(directory)) continue if (await exists(directory)) continue
const ref = `refs/heads/${branch}` const ref = `refs/heads/${branch}`
const branchCheck = await $`git show-ref --verify --quiet ${ref}`.quiet().nothrow().cwd(Instance.worktree) const branchCheck = await git(["show-ref", "--verify", "--quiet", ref], {
cwd: Instance.worktree,
})
if (branchCheck.exitCode === 0) continue if (branchCheck.exitCode === 0) continue
return Info.parse({ name, branch, directory }) return Info.parse({ name, branch, directory })
@@ -285,9 +288,9 @@ export namespace Worktree {
async function runStartCommand(directory: string, cmd: string) { async function runStartCommand(directory: string, cmd: string) {
if (process.platform === "win32") { if (process.platform === "win32") {
return $`cmd /c ${cmd}`.nothrow().cwd(directory) return Process.run(["cmd", "/c", cmd], { cwd: directory, nothrow: true })
} }
return $`bash -lc ${cmd}`.nothrow().cwd(directory) return Process.run(["bash", "-lc", cmd], { cwd: directory, nothrow: true })
} }
type StartKind = "project" | "worktree" type StartKind = "project" | "worktree"
@@ -297,7 +300,7 @@ export namespace Worktree {
if (!text) return true if (!text) return true
const ran = await runStartCommand(directory, text) const ran = await runStartCommand(directory, text)
if (ran.exitCode === 0) return true if (ran.code === 0) return true
log.error("worktree start command failed", { log.error("worktree start command failed", {
kind, kind,
@@ -344,10 +347,9 @@ export namespace Worktree {
} }
export async function createFromInfo(info: Info, startCommand?: string) { export async function createFromInfo(info: Info, startCommand?: string) {
const created = await $`git worktree add --no-checkout -b ${info.branch} ${info.directory}` const created = await git(["worktree", "add", "--no-checkout", "-b", info.branch, info.directory], {
.quiet() cwd: Instance.worktree,
.nothrow() })
.cwd(Instance.worktree)
if (created.exitCode !== 0) { if (created.exitCode !== 0) {
throw new CreateFailedError({ message: errorText(created) || "Failed to create git worktree" }) throw new CreateFailedError({ message: errorText(created) || "Failed to create git worktree" })
} }
@@ -359,7 +361,7 @@ export namespace Worktree {
return () => { return () => {
const start = async () => { const start = async () => {
const populated = await $`git reset --hard`.quiet().nothrow().cwd(info.directory) const populated = await git(["reset", "--hard"], { cwd: info.directory })
if (populated.exitCode !== 0) { if (populated.exitCode !== 0) {
const message = errorText(populated) || "Failed to populate worktree" const message = errorText(populated) || "Failed to populate worktree"
log.error("worktree checkout failed", { directory: info.directory, message }) log.error("worktree checkout failed", { directory: info.directory, message })
@@ -476,10 +478,10 @@ export namespace Worktree {
const stop = async (target: string) => { const stop = async (target: string) => {
if (!(await exists(target))) return if (!(await exists(target))) return
await $`git fsmonitor--daemon stop`.quiet().nothrow().cwd(target) await git(["fsmonitor--daemon", "stop"], { cwd: target })
} }
const list = await $`git worktree list --porcelain`.quiet().nothrow().cwd(Instance.worktree) const list = await git(["worktree", "list", "--porcelain"], { cwd: Instance.worktree })
if (list.exitCode !== 0) { if (list.exitCode !== 0) {
throw new RemoveFailedError({ message: errorText(list) || "Failed to read git worktrees" }) throw new RemoveFailedError({ message: errorText(list) || "Failed to read git worktrees" })
} }
@@ -496,9 +498,11 @@ export namespace Worktree {
} }
await stop(entry.path) await stop(entry.path)
const removed = await $`git worktree remove --force ${entry.path}`.quiet().nothrow().cwd(Instance.worktree) const removed = await git(["worktree", "remove", "--force", entry.path], {
cwd: Instance.worktree,
})
if (removed.exitCode !== 0) { if (removed.exitCode !== 0) {
const next = await $`git worktree list --porcelain`.quiet().nothrow().cwd(Instance.worktree) const next = await git(["worktree", "list", "--porcelain"], { cwd: Instance.worktree })
if (next.exitCode !== 0) { if (next.exitCode !== 0) {
throw new RemoveFailedError({ throw new RemoveFailedError({
message: errorText(removed) || errorText(next) || "Failed to remove git worktree", message: errorText(removed) || errorText(next) || "Failed to remove git worktree",
@@ -515,7 +519,7 @@ export namespace Worktree {
const branch = entry.branch?.replace(/^refs\/heads\//, "") const branch = entry.branch?.replace(/^refs\/heads\//, "")
if (branch) { if (branch) {
const deleted = await $`git branch -D ${branch}`.quiet().nothrow().cwd(Instance.worktree) const deleted = await git(["branch", "-D", branch], { cwd: Instance.worktree })
if (deleted.exitCode !== 0) { if (deleted.exitCode !== 0) {
throw new RemoveFailedError({ message: errorText(deleted) || "Failed to delete worktree branch" }) throw new RemoveFailedError({ message: errorText(deleted) || "Failed to delete worktree branch" })
} }
@@ -535,7 +539,7 @@ export namespace Worktree {
throw new ResetFailedError({ message: "Cannot reset the primary workspace" }) throw new ResetFailedError({ message: "Cannot reset the primary workspace" })
} }
const list = await $`git worktree list --porcelain`.quiet().nothrow().cwd(Instance.worktree) const list = await git(["worktree", "list", "--porcelain"], { cwd: Instance.worktree })
if (list.exitCode !== 0) { if (list.exitCode !== 0) {
throw new ResetFailedError({ message: errorText(list) || "Failed to read git worktrees" }) throw new ResetFailedError({ message: errorText(list) || "Failed to read git worktrees" })
} }
@@ -568,7 +572,7 @@ export namespace Worktree {
throw new ResetFailedError({ message: "Worktree not found" }) throw new ResetFailedError({ message: "Worktree not found" })
} }
const remoteList = await $`git remote`.quiet().nothrow().cwd(Instance.worktree) const remoteList = await git(["remote"], { cwd: Instance.worktree })
if (remoteList.exitCode !== 0) { if (remoteList.exitCode !== 0) {
throw new ResetFailedError({ message: errorText(remoteList) || "Failed to list git remotes" }) throw new ResetFailedError({ message: errorText(remoteList) || "Failed to list git remotes" })
} }
@@ -587,18 +591,19 @@ export namespace Worktree {
: "" : ""
const remoteHead = remote const remoteHead = remote
? await $`git symbolic-ref refs/remotes/${remote}/HEAD`.quiet().nothrow().cwd(Instance.worktree) ? await git(["symbolic-ref", `refs/remotes/${remote}/HEAD`], { cwd: Instance.worktree })
: { exitCode: 1, stdout: undefined, stderr: undefined } : { exitCode: 1, stdout: undefined, stderr: undefined }
const remoteRef = remoteHead.exitCode === 0 ? outputText(remoteHead.stdout) : "" const remoteRef = remoteHead.exitCode === 0 ? outputText(remoteHead.stdout) : ""
const remoteTarget = remoteRef ? remoteRef.replace(/^refs\/remotes\//, "") : "" const remoteTarget = remoteRef ? remoteRef.replace(/^refs\/remotes\//, "") : ""
const remoteBranch = remote && remoteTarget.startsWith(`${remote}/`) ? remoteTarget.slice(`${remote}/`.length) : "" const remoteBranch = remote && remoteTarget.startsWith(`${remote}/`) ? remoteTarget.slice(`${remote}/`.length) : ""
const mainCheck = await $`git show-ref --verify --quiet refs/heads/main`.quiet().nothrow().cwd(Instance.worktree) const mainCheck = await git(["show-ref", "--verify", "--quiet", "refs/heads/main"], {
const masterCheck = await $`git show-ref --verify --quiet refs/heads/master` cwd: Instance.worktree,
.quiet() })
.nothrow() const masterCheck = await git(["show-ref", "--verify", "--quiet", "refs/heads/master"], {
.cwd(Instance.worktree) cwd: Instance.worktree,
})
const localBranch = mainCheck.exitCode === 0 ? "main" : masterCheck.exitCode === 0 ? "master" : "" const localBranch = mainCheck.exitCode === 0 ? "main" : masterCheck.exitCode === 0 ? "master" : ""
const target = remoteBranch ? `${remote}/${remoteBranch}` : localBranch const target = remoteBranch ? `${remote}/${remoteBranch}` : localBranch
@@ -607,7 +612,7 @@ export namespace Worktree {
} }
if (remoteBranch) { if (remoteBranch) {
const fetch = await $`git fetch ${remote} ${remoteBranch}`.quiet().nothrow().cwd(Instance.worktree) const fetch = await git(["fetch", remote, remoteBranch], { cwd: Instance.worktree })
if (fetch.exitCode !== 0) { if (fetch.exitCode !== 0) {
throw new ResetFailedError({ message: errorText(fetch) || `Failed to fetch ${target}` }) throw new ResetFailedError({ message: errorText(fetch) || `Failed to fetch ${target}` })
} }
@@ -619,7 +624,7 @@ export namespace Worktree {
const worktreePath = entry.path const worktreePath = entry.path
const resetToTarget = await $`git reset --hard ${target}`.quiet().nothrow().cwd(worktreePath) const resetToTarget = await git(["reset", "--hard", target], { cwd: worktreePath })
if (resetToTarget.exitCode !== 0) { if (resetToTarget.exitCode !== 0) {
throw new ResetFailedError({ message: errorText(resetToTarget) || "Failed to reset worktree to target" }) throw new ResetFailedError({ message: errorText(resetToTarget) || "Failed to reset worktree to target" })
} }
@@ -629,22 +634,26 @@ export namespace Worktree {
throw new ResetFailedError({ message: errorText(clean) || "Failed to clean worktree" }) throw new ResetFailedError({ message: errorText(clean) || "Failed to clean worktree" })
} }
const update = await $`git submodule update --init --recursive --force`.quiet().nothrow().cwd(worktreePath) const update = await git(["submodule", "update", "--init", "--recursive", "--force"], { cwd: worktreePath })
if (update.exitCode !== 0) { if (update.exitCode !== 0) {
throw new ResetFailedError({ message: errorText(update) || "Failed to update submodules" }) throw new ResetFailedError({ message: errorText(update) || "Failed to update submodules" })
} }
const subReset = await $`git submodule foreach --recursive git reset --hard`.quiet().nothrow().cwd(worktreePath) const subReset = await git(["submodule", "foreach", "--recursive", "git", "reset", "--hard"], {
cwd: worktreePath,
})
if (subReset.exitCode !== 0) { if (subReset.exitCode !== 0) {
throw new ResetFailedError({ message: errorText(subReset) || "Failed to reset submodules" }) throw new ResetFailedError({ message: errorText(subReset) || "Failed to reset submodules" })
} }
const subClean = await $`git submodule foreach --recursive git clean -fdx`.quiet().nothrow().cwd(worktreePath) const subClean = await git(["submodule", "foreach", "--recursive", "git", "clean", "-fdx"], {
cwd: worktreePath,
})
if (subClean.exitCode !== 0) { if (subClean.exitCode !== 0) {
throw new ResetFailedError({ message: errorText(subClean) || "Failed to clean submodules" }) throw new ResetFailedError({ message: errorText(subClean) || "Failed to clean submodules" })
} }
const status = await $`git -c core.fsmonitor=false status --porcelain=v1`.quiet().nothrow().cwd(worktreePath) const status = await git(["-c", "core.fsmonitor=false", "status", "--porcelain=v1"], { cwd: worktreePath })
if (status.exitCode !== 0) { if (status.exitCode !== 0) {
throw new ResetFailedError({ message: errorText(status) || "Failed to read git status" }) throw new ResetFailedError({ message: errorText(status) || "Failed to read git status" })
} }

View File

@@ -0,0 +1,59 @@
import { describe, expect, test } from "bun:test"
import path from "path"
import { Module } from "@opencode-ai/util/module"
import { Filesystem } from "../../src/util/filesystem"
import { tmpdir } from "../fixture/fixture"
describe("util.module", () => {
test("resolves package subpaths from the provided dir", async () => {
await using tmp = await tmpdir()
const root = path.join(tmp.path, "proj")
const file = path.join(root, "node_modules/typescript/lib/tsserver.js")
await Filesystem.write(file, "export {}\n")
await Filesystem.writeJson(path.join(root, "node_modules/typescript/package.json"), { name: "typescript" })
expect(Module.resolve("typescript/lib/tsserver.js", root)).toBe(file)
})
test("resolves packages through ancestor node_modules", async () => {
await using tmp = await tmpdir()
const root = path.join(tmp.path, "proj")
const cwd = path.join(root, "apps/web")
const file = path.join(root, "node_modules/eslint/lib/api.js")
await Filesystem.write(file, "export {}\n")
await Filesystem.writeJson(path.join(root, "node_modules/eslint/package.json"), {
name: "eslint",
main: "lib/api.js",
})
await Filesystem.write(path.join(cwd, ".keep"), "")
expect(Module.resolve("eslint", cwd)).toBe(file)
})
test("resolves relative to the provided dir", async () => {
await using tmp = await tmpdir()
const a = path.join(tmp.path, "a")
const b = path.join(tmp.path, "b")
const left = path.join(a, "node_modules/biome/index.js")
const right = path.join(b, "node_modules/biome/index.js")
await Filesystem.write(left, "export {}\n")
await Filesystem.write(right, "export {}\n")
await Filesystem.writeJson(path.join(a, "node_modules/biome/package.json"), {
name: "biome",
main: "index.js",
})
await Filesystem.writeJson(path.join(b, "node_modules/biome/package.json"), {
name: "biome",
main: "index.js",
})
expect(Module.resolve("biome", a)).toBe(left)
expect(Module.resolve("biome", b)).toBe(right)
expect(Module.resolve("biome", a)).not.toBe(Module.resolve("biome", b))
})
test("returns undefined when resolution fails", async () => {
await using tmp = await tmpdir()
expect(Module.resolve("missing-package", tmp.path)).toBeUndefined()
})
})

View File

@@ -0,0 +1,10 @@
import { createRequire } from "node:module"
import path from "node:path"
export namespace Module {
export function resolve(id: string, dir: string) {
try {
return createRequire(path.join(dir, "package.json")).resolve(id)
} catch {}
}
}