mirror of
https://gitea.toothfairyai.com/ToothFairyAI/tf_code.git
synced 2026-04-18 22:54:41 +00:00
refactor(opencode): replace Bun shell in core flows (#16286)
This commit is contained in:
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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 })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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(" ")}`)
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
@@ -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
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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 })
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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())
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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 =
|
||||||
|
|||||||
@@ -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])
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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" })
|
||||||
}
|
}
|
||||||
|
|||||||
59
packages/opencode/test/util/module.test.ts
Normal file
59
packages/opencode/test/util/module.test.ts
Normal 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()
|
||||||
|
})
|
||||||
|
})
|
||||||
10
packages/util/src/module.ts
Normal file
10
packages/util/src/module.ts
Normal 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 {}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user