ignore: rework bootstrap so server lazy starts it

This commit is contained in:
Dax Raad
2025-09-19 05:11:29 -04:00
parent 0e19ca21ed
commit ae6154e1c3
13 changed files with 690 additions and 637 deletions

View File

@@ -1,19 +1,14 @@
import { Format } from "../format" import { InstanceBootstrap } from "../project/bootstrap"
import { LSP } from "../lsp"
import { Plugin } from "../plugin"
import { Instance } from "../project/instance" import { Instance } from "../project/instance"
import { Share } from "../share/share"
import { Snapshot } from "../snapshot"
export async function bootstrap<T>(directory: string, cb: () => Promise<T>) { export async function bootstrap<T>(directory: string, cb: () => Promise<T>) {
return Instance.provide(directory, async () => { return Instance.provide({
await Plugin.init() directory,
Share.init() init: InstanceBootstrap,
Format.init() fn: async () => {
LSP.init() const result = await cb()
Snapshot.init() await Instance.dispose()
const result = await cb() return result
await Instance.dispose() },
return result
}) })
} }

View File

@@ -11,121 +11,124 @@ const AgentCreateCommand = cmd({
command: "create", command: "create",
describe: "create a new agent", describe: "create a new agent",
async handler() { async handler() {
await Instance.provide(process.cwd(), async () => { await Instance.provide({
UI.empty() directory: process.cwd(),
prompts.intro("Create agent") async fn() {
const project = Instance.project UI.empty()
prompts.intro("Create agent")
const project = Instance.project
let scope: "global" | "project" = "global" let scope: "global" | "project" = "global"
if (project.vcs === "git") { if (project.vcs === "git") {
const scopeResult = await prompts.select({ const scopeResult = await prompts.select({
message: "Location", message: "Location",
options: [
{
label: "Current project",
value: "project" as const,
hint: Instance.worktree,
},
{
label: "Global",
value: "global" as const,
hint: Global.Path.config,
},
],
})
if (prompts.isCancel(scopeResult)) throw new UI.CancelledError()
scope = scopeResult
}
const query = await prompts.text({
message: "Description",
placeholder: "What should this agent do?",
validate: (x) => (x && x.length > 0 ? undefined : "Required"),
})
if (prompts.isCancel(query)) throw new UI.CancelledError()
const spinner = prompts.spinner()
spinner.start("Generating agent configuration...")
const generated = await Agent.generate({ description: query }).catch((error) => {
spinner.stop(`LLM failed to generate agent: ${error.message}`, 1)
throw new UI.CancelledError()
})
spinner.stop(`Agent ${generated.identifier} generated`)
const availableTools = [
"bash",
"read",
"write",
"edit",
"list",
"glob",
"grep",
"webfetch",
"task",
"todowrite",
"todoread",
]
const selectedTools = await prompts.multiselect({
message: "Select tools to enable",
options: availableTools.map((tool) => ({
label: tool,
value: tool,
})),
initialValues: availableTools,
})
if (prompts.isCancel(selectedTools)) throw new UI.CancelledError()
const modeResult = await prompts.select({
message: "Agent mode",
options: [ options: [
{ {
label: "Current project", label: "All",
value: "project" as const, value: "all" as const,
hint: Instance.worktree, hint: "Can function in both primary and subagent roles",
}, },
{ {
label: "Global", label: "Primary",
value: "global" as const, value: "primary" as const,
hint: Global.Path.config, hint: "Acts as a primary/main agent",
},
{
label: "Subagent",
value: "subagent" as const,
hint: "Can be used as a subagent by other agents",
}, },
], ],
initialValue: "all",
}) })
if (prompts.isCancel(scopeResult)) throw new UI.CancelledError() if (prompts.isCancel(modeResult)) throw new UI.CancelledError()
scope = scopeResult
}
const query = await prompts.text({ const tools: Record<string, boolean> = {}
message: "Description", for (const tool of availableTools) {
placeholder: "What should this agent do?", if (!selectedTools.includes(tool)) {
validate: (x) => (x && x.length > 0 ? undefined : "Required"), tools[tool] = false
}) }
if (prompts.isCancel(query)) throw new UI.CancelledError()
const spinner = prompts.spinner()
spinner.start("Generating agent configuration...")
const generated = await Agent.generate({ description: query }).catch((error) => {
spinner.stop(`LLM failed to generate agent: ${error.message}`, 1)
throw new UI.CancelledError()
})
spinner.stop(`Agent ${generated.identifier} generated`)
const availableTools = [
"bash",
"read",
"write",
"edit",
"list",
"glob",
"grep",
"webfetch",
"task",
"todowrite",
"todoread",
]
const selectedTools = await prompts.multiselect({
message: "Select tools to enable",
options: availableTools.map((tool) => ({
label: tool,
value: tool,
})),
initialValues: availableTools,
})
if (prompts.isCancel(selectedTools)) throw new UI.CancelledError()
const modeResult = await prompts.select({
message: "Agent mode",
options: [
{
label: "All",
value: "all" as const,
hint: "Can function in both primary and subagent roles",
},
{
label: "Primary",
value: "primary" as const,
hint: "Acts as a primary/main agent",
},
{
label: "Subagent",
value: "subagent" as const,
hint: "Can be used as a subagent by other agents",
},
],
initialValue: "all",
})
if (prompts.isCancel(modeResult)) throw new UI.CancelledError()
const tools: Record<string, boolean> = {}
for (const tool of availableTools) {
if (!selectedTools.includes(tool)) {
tools[tool] = false
} }
}
const frontmatter: any = { const frontmatter: any = {
description: generated.whenToUse, description: generated.whenToUse,
mode: modeResult, mode: modeResult,
} }
if (Object.keys(tools).length > 0) { if (Object.keys(tools).length > 0) {
frontmatter.tools = tools frontmatter.tools = tools
} }
const content = matter.stringify(generated.systemPrompt, frontmatter) const content = matter.stringify(generated.systemPrompt, frontmatter)
const filePath = path.join( const filePath = path.join(
scope === "global" ? Global.Path.config : path.join(Instance.worktree, ".opencode"), scope === "global" ? Global.Path.config : path.join(Instance.worktree, ".opencode"),
`agent`, `agent`,
`${generated.identifier}.md`, `${generated.identifier}.md`,
) )
await Bun.write(filePath, content) await Bun.write(filePath, content)
prompts.log.success(`Agent created: ${filePath}`) prompts.log.success(`Agent created: ${filePath}`)
prompts.outro("Done") prompts.outro("Done")
},
}) })
}, },
}) })

View File

@@ -74,196 +74,199 @@ export const AuthLoginCommand = cmd({
type: "string", type: "string",
}), }),
async handler(args) { async handler(args) {
await Instance.provide(process.cwd(), async () => { await Instance.provide({
UI.empty() directory: process.cwd(),
prompts.intro("Add credential") async fn() {
if (args.url) { UI.empty()
const wellknown = await fetch(`${args.url}/.well-known/opencode`).then((x) => x.json()) prompts.intro("Add credential")
prompts.log.info(`Running \`${wellknown.auth.command.join(" ")}\``) if (args.url) {
const proc = Bun.spawn({ const wellknown = await fetch(`${args.url}/.well-known/opencode`).then((x) => x.json())
cmd: wellknown.auth.command, prompts.log.info(`Running \`${wellknown.auth.command.join(" ")}\``)
stdout: "pipe", const proc = Bun.spawn({
}) cmd: wellknown.auth.command,
const exit = await proc.exited stdout: "pipe",
if (exit !== 0) {
prompts.log.error("Failed")
prompts.outro("Done")
return
}
const token = await new Response(proc.stdout).text()
await Auth.set(args.url, {
type: "wellknown",
key: wellknown.auth.env,
token: token.trim(),
})
prompts.log.success("Logged into " + args.url)
prompts.outro("Done")
return
}
await ModelsDev.refresh().catch(() => {})
const providers = await ModelsDev.get()
const priority: Record<string, number> = {
opencode: 0,
anthropic: 1,
"github-copilot": 2,
openai: 3,
google: 4,
openrouter: 5,
vercel: 6,
}
let provider = await prompts.autocomplete({
message: "Select provider",
maxItems: 8,
options: [
...pipe(
providers,
values(),
sortBy(
(x) => priority[x.id] ?? 99,
(x) => x.name ?? x.id,
),
map((x) => ({
label: x.name,
value: x.id,
hint: priority[x.id] <= 1 ? "recommended" : undefined,
})),
),
{
value: "other",
label: "Other",
},
],
})
if (prompts.isCancel(provider)) throw new UI.CancelledError()
const plugin = await Plugin.list().then((x) => x.find((x) => x.auth?.provider === provider))
if (plugin && plugin.auth) {
let index = 0
if (plugin.auth.methods.length > 1) {
const method = await prompts.select({
message: "Login method",
options: [
...plugin.auth.methods.map((x, index) => ({
label: x.label,
value: index.toString(),
})),
],
}) })
if (prompts.isCancel(method)) throw new UI.CancelledError() const exit = await proc.exited
index = parseInt(method) if (exit !== 0) {
} prompts.log.error("Failed")
const method = plugin.auth.methods[index] prompts.outro("Done")
if (method.type === "oauth") { return
await new Promise((resolve) => setTimeout(resolve, 10))
const authorize = await method.authorize()
if (authorize.url) {
prompts.log.info("Go to: " + authorize.url)
}
if (authorize.method === "auto") {
if (authorize.instructions) {
prompts.log.info(authorize.instructions)
}
const spinner = prompts.spinner()
spinner.start("Waiting for authorization...")
const result = await authorize.callback()
if (result.type === "failed") {
spinner.stop("Failed to authorize", 1)
}
if (result.type === "success") {
if ("refresh" in result) {
await Auth.set(provider, {
type: "oauth",
refresh: result.refresh,
access: result.access,
expires: result.expires,
})
}
if ("key" in result) {
await Auth.set(provider, {
type: "api",
key: result.key,
})
}
spinner.stop("Login successful")
}
}
if (authorize.method === "code") {
const code = await prompts.text({
message: "Paste the authorization code here: ",
validate: (x) => (x && x.length > 0 ? undefined : "Required"),
})
if (prompts.isCancel(code)) throw new UI.CancelledError()
const result = await authorize.callback(code)
if (result.type === "failed") {
prompts.log.error("Failed to authorize")
}
if (result.type === "success") {
if ("refresh" in result) {
await Auth.set(provider, {
type: "oauth",
refresh: result.refresh,
access: result.access,
expires: result.expires,
})
}
if ("key" in result) {
await Auth.set(provider, {
type: "api",
key: result.key,
})
}
prompts.log.success("Login successful")
}
} }
const token = await new Response(proc.stdout).text()
await Auth.set(args.url, {
type: "wellknown",
key: wellknown.auth.env,
token: token.trim(),
})
prompts.log.success("Logged into " + args.url)
prompts.outro("Done") prompts.outro("Done")
return return
} }
} await ModelsDev.refresh().catch(() => {})
const providers = await ModelsDev.get()
if (provider === "other") { const priority: Record<string, number> = {
provider = await prompts.text({ opencode: 0,
message: "Enter provider id", anthropic: 1,
validate: (x) => (x && x.match(/^[0-9a-z-]+$/) ? undefined : "a-z, 0-9 and hyphens only"), "github-copilot": 2,
openai: 3,
google: 4,
openrouter: 5,
vercel: 6,
}
let provider = await prompts.autocomplete({
message: "Select provider",
maxItems: 8,
options: [
...pipe(
providers,
values(),
sortBy(
(x) => priority[x.id] ?? 99,
(x) => x.name ?? x.id,
),
map((x) => ({
label: x.name,
value: x.id,
hint: priority[x.id] <= 1 ? "recommended" : undefined,
})),
),
{
value: "other",
label: "Other",
},
],
})
if (prompts.isCancel(provider)) throw new UI.CancelledError()
const plugin = await Plugin.list().then((x) => x.find((x) => x.auth?.provider === provider))
if (plugin && plugin.auth) {
let index = 0
if (plugin.auth.methods.length > 1) {
const method = await prompts.select({
message: "Login method",
options: [
...plugin.auth.methods.map((x, index) => ({
label: x.label,
value: index.toString(),
})),
],
})
if (prompts.isCancel(method)) throw new UI.CancelledError()
index = parseInt(method)
}
const method = plugin.auth.methods[index]
if (method.type === "oauth") {
await new Promise((resolve) => setTimeout(resolve, 10))
const authorize = await method.authorize()
if (authorize.url) {
prompts.log.info("Go to: " + authorize.url)
}
if (authorize.method === "auto") {
if (authorize.instructions) {
prompts.log.info(authorize.instructions)
}
const spinner = prompts.spinner()
spinner.start("Waiting for authorization...")
const result = await authorize.callback()
if (result.type === "failed") {
spinner.stop("Failed to authorize", 1)
}
if (result.type === "success") {
if ("refresh" in result) {
await Auth.set(provider, {
type: "oauth",
refresh: result.refresh,
access: result.access,
expires: result.expires,
})
}
if ("key" in result) {
await Auth.set(provider, {
type: "api",
key: result.key,
})
}
spinner.stop("Login successful")
}
}
if (authorize.method === "code") {
const code = await prompts.text({
message: "Paste the authorization code here: ",
validate: (x) => (x && x.length > 0 ? undefined : "Required"),
})
if (prompts.isCancel(code)) throw new UI.CancelledError()
const result = await authorize.callback(code)
if (result.type === "failed") {
prompts.log.error("Failed to authorize")
}
if (result.type === "success") {
if ("refresh" in result) {
await Auth.set(provider, {
type: "oauth",
refresh: result.refresh,
access: result.access,
expires: result.expires,
})
}
if ("key" in result) {
await Auth.set(provider, {
type: "api",
key: result.key,
})
}
prompts.log.success("Login successful")
}
}
prompts.outro("Done")
return
}
}
if (provider === "other") {
provider = await prompts.text({
message: "Enter provider id",
validate: (x) => (x && x.match(/^[0-9a-z-]+$/) ? undefined : "a-z, 0-9 and hyphens only"),
})
if (prompts.isCancel(provider)) throw new UI.CancelledError()
provider = provider.replace(/^@ai-sdk\//, "")
if (prompts.isCancel(provider)) throw new UI.CancelledError()
prompts.log.warn(
`This only stores a credential for ${provider} - you will need configure it in opencode.json, check the docs for examples.`,
)
}
if (provider === "amazon-bedrock") {
prompts.log.info(
"Amazon bedrock can be configured with standard AWS environment variables like AWS_BEARER_TOKEN_BEDROCK, AWS_PROFILE or AWS_ACCESS_KEY_ID",
)
prompts.outro("Done")
return
}
if (provider === "opencode") {
prompts.log.info("Create an api key at https://opencode.ai/auth")
}
if (provider === "vercel") {
prompts.log.info("You can create an api key at https://vercel.link/ai-gateway-token")
}
const key = await prompts.password({
message: "Enter your API key",
validate: (x) => (x && x.length > 0 ? undefined : "Required"),
})
if (prompts.isCancel(key)) throw new UI.CancelledError()
await Auth.set(provider, {
type: "api",
key,
}) })
if (prompts.isCancel(provider)) throw new UI.CancelledError()
provider = provider.replace(/^@ai-sdk\//, "")
if (prompts.isCancel(provider)) throw new UI.CancelledError()
prompts.log.warn(
`This only stores a credential for ${provider} - you will need configure it in opencode.json, check the docs for examples.`,
)
}
if (provider === "amazon-bedrock") {
prompts.log.info(
"Amazon bedrock can be configured with standard AWS environment variables like AWS_BEARER_TOKEN_BEDROCK, AWS_PROFILE or AWS_ACCESS_KEY_ID",
)
prompts.outro("Done") prompts.outro("Done")
return },
}
if (provider === "opencode") {
prompts.log.info("Create an api key at https://opencode.ai/auth")
}
if (provider === "vercel") {
prompts.log.info("You can create an api key at https://vercel.link/ai-gateway-token")
}
const key = await prompts.password({
message: "Enter your API key",
validate: (x) => (x && x.length > 0 ? undefined : "Required"),
})
if (prompts.isCancel(key)) throw new UI.CancelledError()
await Auth.set(provider, {
type: "api",
key,
})
prompts.outro("Done")
}) })
}, },
}) })

View File

@@ -21,190 +21,194 @@ export const GithubInstallCommand = cmd({
command: "install", command: "install",
describe: "install the GitHub agent", describe: "install the GitHub agent",
async handler() { async handler() {
await Instance.provide(process.cwd(), async () => { await Instance.provide({
UI.empty() directory: process.cwd(),
prompts.intro("Install GitHub agent") async fn() {
const app = await getAppInfo() UI.empty()
await installGitHubApp() prompts.intro("Install GitHub agent")
const app = await getAppInfo()
await installGitHubApp()
const providers = await ModelsDev.get() const providers = await ModelsDev.get()
const provider = await promptProvider() const provider = await promptProvider()
const model = await promptModel() const model = await promptModel()
//const key = await promptKey() //const key = await promptKey()
await addWorkflowFiles() await addWorkflowFiles()
printNextSteps() printNextSteps()
function printNextSteps() { function printNextSteps() {
let step2 let step2
if (provider === "amazon-bedrock") { if (provider === "amazon-bedrock") {
step2 = step2 =
"Configure OIDC in AWS - https://docs.github.com/en/actions/how-tos/security-for-github-actions/security-hardening-your-deployments/configuring-openid-connect-in-amazon-web-services" "Configure OIDC in AWS - https://docs.github.com/en/actions/how-tos/security-for-github-actions/security-hardening-your-deployments/configuring-openid-connect-in-amazon-web-services"
} else { } else {
step2 = [ step2 = [
` 2. Add the following secrets in org or repo (${app.owner}/${app.repo}) settings`, ` 2. Add the following secrets in org or repo (${app.owner}/${app.repo}) settings`,
"", "",
...providers[provider].env.map((e) => ` - ${e}`), ...providers[provider].env.map((e) => ` - ${e}`),
].join("\n") ].join("\n")
}
prompts.outro(
[
"Next steps:",
"",
` 1. Commit the \`${WORKFLOW_FILE}\` file and push`,
step2,
"",
" 3. Go to a GitHub issue and comment `/oc summarize` to see the agent in action",
"",
" Learn more about the GitHub agent - https://opencode.ai/docs/github/#usage-examples",
].join("\n"),
)
}
async function getAppInfo() {
const project = Instance.project
if (project.vcs !== "git") {
prompts.log.error(`Could not find git repository. Please run this command from a git repository.`)
throw new UI.CancelledError()
}
// Get repo info
const info = await $`git remote get-url origin`
.quiet()
.nothrow()
.text()
.then((text) => text.trim())
// match https or git pattern
// ie. https://github.com/sst/opencode.git
// ie. https://github.com/sst/opencode
// ie. git@github.com:sst/opencode.git
// ie. git@github.com:sst/opencode
// ie. ssh://git@github.com/sst/opencode.git
// ie. ssh://git@github.com/sst/opencode
const parsed = info.match(/^(?:(?:https?|ssh):\/\/)?(?:git@)?github\.com[:/]([^/]+)\/([^/.]+?)(?:\.git)?$/)
if (!parsed) {
prompts.log.error(`Could not find git repository. Please run this command from a git repository.`)
throw new UI.CancelledError()
}
const [, owner, repo] = parsed
return { owner, repo, root: Instance.worktree }
}
async function promptProvider() {
const priority: Record<string, number> = {
opencode: 0,
anthropic: 1,
"github-copilot": 2,
openai: 3,
google: 4,
openrouter: 5,
vercel: 6,
}
let provider = await prompts.select({
message: "Select provider",
maxItems: 8,
options: pipe(
providers,
values(),
sortBy(
(x) => priority[x.id] ?? 99,
(x) => x.name ?? x.id,
),
map((x) => ({
label: x.name,
value: x.id,
hint: priority[x.id] <= 1 ? "recommended" : undefined,
})),
),
})
if (prompts.isCancel(provider)) throw new UI.CancelledError()
return provider
}
async function promptModel() {
const providerData = providers[provider]!
const model = await prompts.select({
message: "Select model",
maxItems: 8,
options: pipe(
providerData.models,
values(),
sortBy((x) => x.name ?? x.id),
map((x) => ({
label: x.name ?? x.id,
value: x.id,
})),
),
})
if (prompts.isCancel(model)) throw new UI.CancelledError()
return model
}
async function installGitHubApp() {
const s = prompts.spinner()
s.start("Installing GitHub app")
// Get installation
const installation = await getInstallation()
if (installation) return s.stop("GitHub app already installed")
// Open browser
const url = "https://github.com/apps/opencode-agent"
const command =
process.platform === "darwin"
? `open "${url}"`
: process.platform === "win32"
? `start "${url}"`
: `xdg-open "${url}"`
exec(command, (error) => {
if (error) {
prompts.log.warn(`Could not open browser. Please visit: ${url}`)
} }
})
// Wait for installation prompts.outro(
s.message("Waiting for GitHub app to be installed") [
const MAX_RETRIES = 120 "Next steps:",
let retries = 0 "",
do { ` 1. Commit the \`${WORKFLOW_FILE}\` file and push`,
const installation = await getInstallation() step2,
if (installation) break "",
" 3. Go to a GitHub issue and comment `/oc summarize` to see the agent in action",
"",
" Learn more about the GitHub agent - https://opencode.ai/docs/github/#usage-examples",
].join("\n"),
)
}
if (retries > MAX_RETRIES) { async function getAppInfo() {
s.stop( const project = Instance.project
`Failed to detect GitHub app installation. Make sure to install the app for the \`${app.owner}/${app.repo}\` repository.`, if (project.vcs !== "git") {
) prompts.log.error(`Could not find git repository. Please run this command from a git repository.`)
throw new UI.CancelledError() throw new UI.CancelledError()
} }
retries++ // Get repo info
await new Promise((resolve) => setTimeout(resolve, 1000)) const info = await $`git remote get-url origin`
} while (true) .quiet()
.nothrow()
s.stop("Installed GitHub app") .text()
.then((text) => text.trim())
async function getInstallation() { // match https or git pattern
return await fetch(`https://api.opencode.ai/get_github_app_installation?owner=${app.owner}&repo=${app.repo}`) // ie. https://github.com/sst/opencode.git
.then((res) => res.json()) // ie. https://github.com/sst/opencode
.then((data) => data.installation) // ie. git@github.com:sst/opencode.git
// ie. git@github.com:sst/opencode
// ie. ssh://git@github.com/sst/opencode.git
// ie. ssh://git@github.com/sst/opencode
const parsed = info.match(/^(?:(?:https?|ssh):\/\/)?(?:git@)?github\.com[:/]([^/]+)\/([^/.]+?)(?:\.git)?$/)
if (!parsed) {
prompts.log.error(`Could not find git repository. Please run this command from a git repository.`)
throw new UI.CancelledError()
}
const [, owner, repo] = parsed
return { owner, repo, root: Instance.worktree }
} }
}
async function addWorkflowFiles() { async function promptProvider() {
const envStr = const priority: Record<string, number> = {
provider === "amazon-bedrock" opencode: 0,
? "" anthropic: 1,
: `\n env:${providers[provider].env.map((e) => `\n ${e}: \${{ secrets.${e} }}`).join("")}` "github-copilot": 2,
openai: 3,
google: 4,
openrouter: 5,
vercel: 6,
}
let provider = await prompts.select({
message: "Select provider",
maxItems: 8,
options: pipe(
providers,
values(),
sortBy(
(x) => priority[x.id] ?? 99,
(x) => x.name ?? x.id,
),
map((x) => ({
label: x.name,
value: x.id,
hint: priority[x.id] <= 1 ? "recommended" : undefined,
})),
),
})
await Bun.write( if (prompts.isCancel(provider)) throw new UI.CancelledError()
path.join(app.root, WORKFLOW_FILE),
` return provider
}
async function promptModel() {
const providerData = providers[provider]!
const model = await prompts.select({
message: "Select model",
maxItems: 8,
options: pipe(
providerData.models,
values(),
sortBy((x) => x.name ?? x.id),
map((x) => ({
label: x.name ?? x.id,
value: x.id,
})),
),
})
if (prompts.isCancel(model)) throw new UI.CancelledError()
return model
}
async function installGitHubApp() {
const s = prompts.spinner()
s.start("Installing GitHub app")
// Get installation
const installation = await getInstallation()
if (installation) return s.stop("GitHub app already installed")
// Open browser
const url = "https://github.com/apps/opencode-agent"
const command =
process.platform === "darwin"
? `open "${url}"`
: process.platform === "win32"
? `start "${url}"`
: `xdg-open "${url}"`
exec(command, (error) => {
if (error) {
prompts.log.warn(`Could not open browser. Please visit: ${url}`)
}
})
// Wait for installation
s.message("Waiting for GitHub app to be installed")
const MAX_RETRIES = 120
let retries = 0
do {
const installation = await getInstallation()
if (installation) break
if (retries > MAX_RETRIES) {
s.stop(
`Failed to detect GitHub app installation. Make sure to install the app for the \`${app.owner}/${app.repo}\` repository.`,
)
throw new UI.CancelledError()
}
retries++
await new Promise((resolve) => setTimeout(resolve, 1000))
} while (true)
s.stop("Installed GitHub app")
async function getInstallation() {
return await fetch(
`https://api.opencode.ai/get_github_app_installation?owner=${app.owner}&repo=${app.repo}`,
)
.then((res) => res.json())
.then((data) => data.installation)
}
}
async function addWorkflowFiles() {
const envStr =
provider === "amazon-bedrock"
? ""
: `\n env:${providers[provider].env.map((e) => `\n ${e}: \${{ secrets.${e} }}`).join("")}`
await Bun.write(
path.join(app.root, WORKFLOW_FILE),
`
name: opencode name: opencode
on: on:
@@ -231,10 +235,11 @@ jobs:
with: with:
model: ${provider}/${model} model: ${provider}/${model}
`.trim(), `.trim(),
) )
prompts.log.success(`Added workflow file: "${WORKFLOW_FILE}"`) prompts.log.success(`Added workflow file: "${WORKFLOW_FILE}"`)
} }
},
}) })
}, },
}) })

View File

@@ -6,14 +6,17 @@ export const ModelsCommand = cmd({
command: "models", command: "models",
describe: "list all available models", describe: "list all available models",
handler: async () => { handler: async () => {
await Instance.provide(process.cwd(), async () => { await Instance.provide({
const providers = await Provider.list() directory: process.cwd(),
async fn() {
const providers = await Provider.list()
for (const [providerID, provider] of Object.entries(providers)) { for (const [providerID, provider] of Object.entries(providers)) {
for (const modelID of Object.keys(provider.info.models)) { for (const modelID of Object.keys(provider.info.models)) {
console.log(`${providerID}/${modelID}`) console.log(`${providerID}/${modelID}`)
}
} }
} },
}) })
}, },
}) })

View File

@@ -1,7 +1,6 @@
import { Global } from "../../global" import { Global } from "../../global"
import { Provider } from "../../provider/provider" import { Provider } from "../../provider/provider"
import { Server } from "../../server/server" import { Server } from "../../server/server"
import { bootstrap } from "../bootstrap"
import { UI } from "../ui" import { UI } from "../ui"
import { cmd } from "./cmd" import { cmd } from "./cmd"
import path from "path" import path from "path"
@@ -16,6 +15,7 @@ import { Ide } from "../../ide"
import { Flag } from "../../flag/flag" import { Flag } from "../../flag/flag"
import { Session } from "../../session" import { Session } from "../../session"
import { $ } from "bun" import { $ } from "bun"
import { bootstrap } from "../bootstrap"
declare global { declare global {
const OPENCODE_TUI_PATH: string const OPENCODE_TUI_PATH: string

View File

@@ -0,0 +1,13 @@
import { Plugin } from "../plugin"
import { Share } from "../share/share"
import { Format } from "../format"
import { LSP } from "../lsp"
import { Snapshot } from "../snapshot"
export async function InstanceBootstrap() {
await Plugin.init()
Share.init()
Format.init()
LSP.init()
Snapshot.init()
}

View File

@@ -2,12 +2,32 @@ import { Context } from "../util/context"
import { Project } from "./project" import { Project } from "./project"
import { State } from "./state" import { State } from "./state"
const context = Context.create<{ directory: string; worktree: string; project: Project.Info }>("path") interface Context {
directory: string
worktree: string
project: Project.Info
}
const context = Context.create<Context>("instance")
const cache = new Map<string, Context>()
export const Instance = { export const Instance = {
async provide<R>(directory: string, cb: () => R): Promise<R> { async provide<R>(input: { directory: string; init?: () => Promise<any>; fn: () => R }): Promise<R> {
const project = await Project.fromDirectory(directory) let existing = cache.get(input.directory)
return context.provide({ directory, worktree: project.worktree, project }, cb) if (!existing) {
const project = await Project.fromDirectory(input.directory)
existing = {
directory: input.directory,
worktree: project.worktree,
project,
}
}
return context.provide(existing, async () => {
if (!cache.has(input.directory)) {
await input.init?.()
cache.set(input.directory, existing)
}
return input.fn()
})
}, },
get directory() { get directory() {
return context.use().directory return context.use().directory

View File

@@ -22,73 +22,64 @@ export namespace Project {
}) })
export type Info = z.infer<typeof Info> export type Info = z.infer<typeof Info>
const cache = new Map<string, Info>()
export async function fromDirectory(directory: string) { export async function fromDirectory(directory: string) {
log.info("fromDirectory", { directory }) log.info("fromDirectory", { directory })
const fn = async () => { const matches = Filesystem.up({ targets: [".git"], start: directory })
const matches = Filesystem.up({ targets: [".git"], start: directory }) const git = await matches.next().then((x) => x.value)
const git = await matches.next().then((x) => x.value) await matches.return()
await matches.return() if (!git) {
if (!git) {
const project: Info = {
id: "global",
worktree: "/",
time: {
created: Date.now(),
},
}
await Storage.write<Info>(["project", "global"], project)
return project
}
let worktree = path.dirname(git)
const [id] = await $`git rev-list --max-parents=0 --all`
.quiet()
.nothrow()
.cwd(worktree)
.text()
.then((x) =>
x
.split("\n")
.filter(Boolean)
.map((x) => x.trim())
.toSorted(),
)
if (!id) {
const project: Info = {
id: "global",
worktree: "/",
time: {
created: Date.now(),
},
}
await Storage.write<Info>(["project", "global"], project)
return project
}
worktree = path.dirname(
await $`git rev-parse --path-format=absolute --git-common-dir`
.quiet()
.nothrow()
.cwd(worktree)
.text()
.then((x) => x.trim()),
)
const project: Info = { const project: Info = {
id, id: "global",
worktree, worktree: "/",
vcs: "git",
time: { time: {
created: Date.now(), created: Date.now(),
}, },
} }
await Storage.write<Info>(["project", id], project) await Storage.write<Info>(["project", "global"], project)
return project return project
} }
if (cache.has(directory)) { let worktree = path.dirname(git)
return cache.get(directory)! const [id] = await $`git rev-list --max-parents=0 --all`
.quiet()
.nothrow()
.cwd(worktree)
.text()
.then((x) =>
x
.split("\n")
.filter(Boolean)
.map((x) => x.trim())
.toSorted(),
)
if (!id) {
const project: Info = {
id: "global",
worktree: "/",
time: {
created: Date.now(),
},
}
await Storage.write<Info>(["project", "global"], project)
return project
} }
const result = await fn() worktree = path.dirname(
cache.set(directory, result) await $`git rev-parse --path-format=absolute --git-common-dir`
return result .quiet()
.nothrow()
.cwd(worktree)
.text()
.then((x) => x.trim()),
)
const project: Info = {
id,
worktree,
vcs: "git",
time: {
created: Date.now(),
},
}
await Storage.write<Info>(["project", id], project)
return project
} }
export async function setInitialized(projectID: string) { export async function setInitialized(projectID: string) {

View File

@@ -29,6 +29,7 @@ import { SessionPrompt } from "../session/prompt"
import { SessionCompaction } from "../session/compaction" import { SessionCompaction } from "../session/compaction"
import { SessionRevert } from "../session/revert" import { SessionRevert } from "../session/revert"
import { lazy } from "../util/lazy" import { lazy } from "../util/lazy"
import { InstanceBootstrap } from "../project/bootstrap"
const ERRORS = { const ERRORS = {
400: { 400: {
@@ -90,8 +91,12 @@ export namespace Server {
}) })
.use(async (c, next) => { .use(async (c, next) => {
const directory = c.req.query("directory") ?? process.cwd() const directory = c.req.query("directory") ?? process.cwd()
return Instance.provide(directory, async () => { return Instance.provide({
return next() directory,
init: InstanceBootstrap,
async fn() {
return next()
},
}) })
}) })
.use(cors()) .use(cors())

View File

@@ -27,19 +27,19 @@ async function bootstrap() {
test("tracks deleted files correctly", async () => { test("tracks deleted files correctly", async () => {
await using tmp = await bootstrap() await using tmp = await bootstrap()
await Instance.provide(tmp.dir, async () => { await Instance.provide({ directory: tmp.dir, fn: async () => {
const before = await Snapshot.track() const before = await Snapshot.track()
expect(before).toBeTruthy() expect(before).toBeTruthy()
await $`rm ${tmp.dir}/a.txt`.quiet() await $`rm ${tmp.dir}/a.txt`.quiet()
expect((await Snapshot.patch(before!)).files).toContain(`${tmp.dir}/a.txt`) expect((await Snapshot.patch(before!)).files).toContain(`${tmp.dir}/a.txt`)
}) }})
}) })
test("revert should remove new files", async () => { test("revert should remove new files", async () => {
await using tmp = await bootstrap() await using tmp = await bootstrap()
await Instance.provide(tmp.dir, async () => { await Instance.provide({ directory: tmp.dir, fn: async () => {
const before = await Snapshot.track() const before = await Snapshot.track()
expect(before).toBeTruthy() expect(before).toBeTruthy()
@@ -48,12 +48,12 @@ test("revert should remove new files", async () => {
await Snapshot.revert([await Snapshot.patch(before!)]) await Snapshot.revert([await Snapshot.patch(before!)])
expect(await Bun.file(`${tmp.dir}/new.txt`).exists()).toBe(false) expect(await Bun.file(`${tmp.dir}/new.txt`).exists()).toBe(false)
}) }})
}) })
test("revert in subdirectory", async () => { test("revert in subdirectory", async () => {
await using tmp = await bootstrap() await using tmp = await bootstrap()
await Instance.provide(tmp.dir, async () => { await Instance.provide({ directory: tmp.dir, fn: async () => {
const before = await Snapshot.track() const before = await Snapshot.track()
expect(before).toBeTruthy() expect(before).toBeTruthy()
@@ -65,12 +65,12 @@ test("revert in subdirectory", async () => {
expect(await Bun.file(`${tmp.dir}/sub/file.txt`).exists()).toBe(false) expect(await Bun.file(`${tmp.dir}/sub/file.txt`).exists()).toBe(false)
// Note: revert currently only removes files, not directories // Note: revert currently only removes files, not directories
// The empty subdirectory will remain // The empty subdirectory will remain
}) }})
}) })
test("multiple file operations", async () => { test("multiple file operations", async () => {
await using tmp = await bootstrap() await using tmp = await bootstrap()
await Instance.provide(tmp.dir, async () => { await Instance.provide({ directory: tmp.dir, fn: async () => {
const before = await Snapshot.track() const before = await Snapshot.track()
expect(before).toBeTruthy() expect(before).toBeTruthy()
@@ -87,24 +87,24 @@ test("multiple file operations", async () => {
// Note: revert currently only removes files, not directories // Note: revert currently only removes files, not directories
// The empty directory will remain // The empty directory will remain
expect(await Bun.file(`${tmp.dir}/b.txt`).text()).toBe(tmp.bContent) expect(await Bun.file(`${tmp.dir}/b.txt`).text()).toBe(tmp.bContent)
}) }})
}) })
test("empty directory handling", async () => { test("empty directory handling", async () => {
await using tmp = await bootstrap() await using tmp = await bootstrap()
await Instance.provide(tmp.dir, async () => { await Instance.provide({ directory: tmp.dir, fn: async () => {
const before = await Snapshot.track() const before = await Snapshot.track()
expect(before).toBeTruthy() expect(before).toBeTruthy()
await $`mkdir ${tmp.dir}/empty`.quiet() await $`mkdir ${tmp.dir}/empty`.quiet()
expect((await Snapshot.patch(before!)).files.length).toBe(0) expect((await Snapshot.patch(before!)).files.length).toBe(0)
}) }})
}) })
test("binary file handling", async () => { test("binary file handling", async () => {
await using tmp = await bootstrap() await using tmp = await bootstrap()
await Instance.provide(tmp.dir, async () => { await Instance.provide({ directory: tmp.dir, fn: async () => {
const before = await Snapshot.track() const before = await Snapshot.track()
expect(before).toBeTruthy() expect(before).toBeTruthy()
@@ -115,36 +115,36 @@ test("binary file handling", async () => {
await Snapshot.revert([patch]) await Snapshot.revert([patch])
expect(await Bun.file(`${tmp.dir}/image.png`).exists()).toBe(false) expect(await Bun.file(`${tmp.dir}/image.png`).exists()).toBe(false)
}) }})
}) })
test("symlink handling", async () => { test("symlink handling", async () => {
await using tmp = await bootstrap() await using tmp = await bootstrap()
await Instance.provide(tmp.dir, async () => { await Instance.provide({ directory: tmp.dir, fn: async () => {
const before = await Snapshot.track() const before = await Snapshot.track()
expect(before).toBeTruthy() expect(before).toBeTruthy()
await $`ln -s ${tmp.dir}/a.txt ${tmp.dir}/link.txt`.quiet() await $`ln -s ${tmp.dir}/a.txt ${tmp.dir}/link.txt`.quiet()
expect((await Snapshot.patch(before!)).files).toContain(`${tmp.dir}/link.txt`) expect((await Snapshot.patch(before!)).files).toContain(`${tmp.dir}/link.txt`)
}) }})
}) })
test("large file handling", async () => { test("large file handling", async () => {
await using tmp = await bootstrap() await using tmp = await bootstrap()
await Instance.provide(tmp.dir, async () => { await Instance.provide({ directory: tmp.dir, fn: async () => {
const before = await Snapshot.track() const before = await Snapshot.track()
expect(before).toBeTruthy() expect(before).toBeTruthy()
await Bun.write(`${tmp.dir}/large.txt`, "x".repeat(1024 * 1024)) await Bun.write(`${tmp.dir}/large.txt`, "x".repeat(1024 * 1024))
expect((await Snapshot.patch(before!)).files).toContain(`${tmp.dir}/large.txt`) expect((await Snapshot.patch(before!)).files).toContain(`${tmp.dir}/large.txt`)
}) }})
}) })
test("nested directory revert", async () => { test("nested directory revert", async () => {
await using tmp = await bootstrap() await using tmp = await bootstrap()
await Instance.provide(tmp.dir, async () => { await Instance.provide({ directory: tmp.dir, fn: async () => {
const before = await Snapshot.track() const before = await Snapshot.track()
expect(before).toBeTruthy() expect(before).toBeTruthy()
@@ -154,12 +154,12 @@ test("nested directory revert", async () => {
await Snapshot.revert([await Snapshot.patch(before!)]) await Snapshot.revert([await Snapshot.patch(before!)])
expect(await Bun.file(`${tmp.dir}/level1/level2/level3/deep.txt`).exists()).toBe(false) expect(await Bun.file(`${tmp.dir}/level1/level2/level3/deep.txt`).exists()).toBe(false)
}) }})
}) })
test("special characters in filenames", async () => { test("special characters in filenames", async () => {
await using tmp = await bootstrap() await using tmp = await bootstrap()
await Instance.provide(tmp.dir, async () => { await Instance.provide({ directory: tmp.dir, fn: async () => {
const before = await Snapshot.track() const before = await Snapshot.track()
expect(before).toBeTruthy() expect(before).toBeTruthy()
@@ -171,23 +171,23 @@ test("special characters in filenames", async () => {
expect(files).toContain(`${tmp.dir}/file with spaces.txt`) expect(files).toContain(`${tmp.dir}/file with spaces.txt`)
expect(files).toContain(`${tmp.dir}/file-with-dashes.txt`) expect(files).toContain(`${tmp.dir}/file-with-dashes.txt`)
expect(files).toContain(`${tmp.dir}/file_with_underscores.txt`) expect(files).toContain(`${tmp.dir}/file_with_underscores.txt`)
}) }})
}) })
test("revert with empty patches", async () => { test("revert with empty patches", async () => {
await using tmp = await bootstrap() await using tmp = await bootstrap()
await Instance.provide(tmp.dir, async () => { await Instance.provide({ directory: tmp.dir, fn: async () => {
// Should not crash with empty patches // Should not crash with empty patches
expect(Snapshot.revert([])).resolves.toBeUndefined() expect(Snapshot.revert([])).resolves.toBeUndefined()
// Should not crash with patches that have empty file lists // Should not crash with patches that have empty file lists
expect(Snapshot.revert([{ hash: "dummy", files: [] }])).resolves.toBeUndefined() expect(Snapshot.revert([{ hash: "dummy", files: [] }])).resolves.toBeUndefined()
}) }})
}) })
test("patch with invalid hash", async () => { test("patch with invalid hash", async () => {
await using tmp = await bootstrap() await using tmp = await bootstrap()
await Instance.provide(tmp.dir, async () => { await Instance.provide({ directory: tmp.dir, fn: async () => {
const before = await Snapshot.track() const before = await Snapshot.track()
expect(before).toBeTruthy() expect(before).toBeTruthy()
@@ -198,12 +198,12 @@ test("patch with invalid hash", async () => {
const patch = await Snapshot.patch("invalid-hash-12345") const patch = await Snapshot.patch("invalid-hash-12345")
expect(patch.files).toEqual([]) expect(patch.files).toEqual([])
expect(patch.hash).toBe("invalid-hash-12345") expect(patch.hash).toBe("invalid-hash-12345")
}) }})
}) })
test("revert non-existent file", async () => { test("revert non-existent file", async () => {
await using tmp = await bootstrap() await using tmp = await bootstrap()
await Instance.provide(tmp.dir, async () => { await Instance.provide({ directory: tmp.dir, fn: async () => {
const before = await Snapshot.track() const before = await Snapshot.track()
expect(before).toBeTruthy() expect(before).toBeTruthy()
@@ -217,12 +217,12 @@ test("revert non-existent file", async () => {
}, },
]), ]),
).resolves.toBeUndefined() ).resolves.toBeUndefined()
}) }})
}) })
test("unicode filenames", async () => { test("unicode filenames", async () => {
await using tmp = await bootstrap() await using tmp = await bootstrap()
await Instance.provide(tmp.dir, async () => { await Instance.provide({ directory: tmp.dir, fn: async () => {
const before = await Snapshot.track() const before = await Snapshot.track()
expect(before).toBeTruthy() expect(before).toBeTruthy()
@@ -244,12 +244,12 @@ test("unicode filenames", async () => {
// Skip revert test due to git filename escaping issues // Skip revert test due to git filename escaping issues
// The functionality works but git uses escaped filenames internally // The functionality works but git uses escaped filenames internally
}) }})
}) })
test("very long filenames", async () => { test("very long filenames", async () => {
await using tmp = await bootstrap() await using tmp = await bootstrap()
await Instance.provide(tmp.dir, async () => { await Instance.provide({ directory: tmp.dir, fn: async () => {
const before = await Snapshot.track() const before = await Snapshot.track()
expect(before).toBeTruthy() expect(before).toBeTruthy()
@@ -263,12 +263,12 @@ test("very long filenames", async () => {
await Snapshot.revert([patch]) await Snapshot.revert([patch])
expect(await Bun.file(longFile).exists()).toBe(false) expect(await Bun.file(longFile).exists()).toBe(false)
}) }})
}) })
test("hidden files", async () => { test("hidden files", async () => {
await using tmp = await bootstrap() await using tmp = await bootstrap()
await Instance.provide(tmp.dir, async () => { await Instance.provide({ directory: tmp.dir, fn: async () => {
const before = await Snapshot.track() const before = await Snapshot.track()
expect(before).toBeTruthy() expect(before).toBeTruthy()
@@ -280,12 +280,12 @@ test("hidden files", async () => {
expect(patch.files).toContain(`${tmp.dir}/.hidden`) expect(patch.files).toContain(`${tmp.dir}/.hidden`)
expect(patch.files).toContain(`${tmp.dir}/.gitignore`) expect(patch.files).toContain(`${tmp.dir}/.gitignore`)
expect(patch.files).toContain(`${tmp.dir}/.config`) expect(patch.files).toContain(`${tmp.dir}/.config`)
}) }})
}) })
test("nested symlinks", async () => { test("nested symlinks", async () => {
await using tmp = await bootstrap() await using tmp = await bootstrap()
await Instance.provide(tmp.dir, async () => { await Instance.provide({ directory: tmp.dir, fn: async () => {
const before = await Snapshot.track() const before = await Snapshot.track()
expect(before).toBeTruthy() expect(before).toBeTruthy()
@@ -297,12 +297,12 @@ test("nested symlinks", async () => {
const patch = await Snapshot.patch(before!) const patch = await Snapshot.patch(before!)
expect(patch.files).toContain(`${tmp.dir}/sub/dir/link.txt`) expect(patch.files).toContain(`${tmp.dir}/sub/dir/link.txt`)
expect(patch.files).toContain(`${tmp.dir}/sub-link`) expect(patch.files).toContain(`${tmp.dir}/sub-link`)
}) }})
}) })
test("file permissions and ownership changes", async () => { test("file permissions and ownership changes", async () => {
await using tmp = await bootstrap() await using tmp = await bootstrap()
await Instance.provide(tmp.dir, async () => { await Instance.provide({ directory: tmp.dir, fn: async () => {
const before = await Snapshot.track() const before = await Snapshot.track()
expect(before).toBeTruthy() expect(before).toBeTruthy()
@@ -315,12 +315,12 @@ test("file permissions and ownership changes", async () => {
// Note: git doesn't track permission changes on existing files by default // Note: git doesn't track permission changes on existing files by default
// Only tracks executable bit when files are first added // Only tracks executable bit when files are first added
expect(patch.files.length).toBe(0) expect(patch.files.length).toBe(0)
}) }})
}) })
test("circular symlinks", async () => { test("circular symlinks", async () => {
await using tmp = await bootstrap() await using tmp = await bootstrap()
await Instance.provide(tmp.dir, async () => { await Instance.provide({ directory: tmp.dir, fn: async () => {
const before = await Snapshot.track() const before = await Snapshot.track()
expect(before).toBeTruthy() expect(before).toBeTruthy()
@@ -329,12 +329,12 @@ test("circular symlinks", async () => {
const patch = await Snapshot.patch(before!) const patch = await Snapshot.patch(before!)
expect(patch.files.length).toBeGreaterThanOrEqual(0) // Should not crash expect(patch.files.length).toBeGreaterThanOrEqual(0) // Should not crash
}) }})
}) })
test("gitignore changes", async () => { test("gitignore changes", async () => {
await using tmp = await bootstrap() await using tmp = await bootstrap()
await Instance.provide(tmp.dir, async () => { await Instance.provide({ directory: tmp.dir, fn: async () => {
const before = await Snapshot.track() const before = await Snapshot.track()
expect(before).toBeTruthy() expect(before).toBeTruthy()
@@ -350,12 +350,12 @@ test("gitignore changes", async () => {
expect(patch.files).toContain(`${tmp.dir}/normal.txt`) expect(patch.files).toContain(`${tmp.dir}/normal.txt`)
// Should not track ignored files (git won't see them) // Should not track ignored files (git won't see them)
expect(patch.files).not.toContain(`${tmp.dir}/test.ignored`) expect(patch.files).not.toContain(`${tmp.dir}/test.ignored`)
}) }})
}) })
test("concurrent file operations during patch", async () => { test("concurrent file operations during patch", async () => {
await using tmp = await bootstrap() await using tmp = await bootstrap()
await Instance.provide(tmp.dir, async () => { await Instance.provide({ directory: tmp.dir, fn: async () => {
const before = await Snapshot.track() const before = await Snapshot.track()
expect(before).toBeTruthy() expect(before).toBeTruthy()
@@ -376,7 +376,7 @@ test("concurrent file operations during patch", async () => {
// Should capture some or all of the concurrent files // Should capture some or all of the concurrent files
expect(patch.files.length).toBeGreaterThanOrEqual(0) expect(patch.files.length).toBeGreaterThanOrEqual(0)
}) }})
}) })
test("snapshot state isolation between projects", async () => { test("snapshot state isolation between projects", async () => {
@@ -384,14 +384,14 @@ test("snapshot state isolation between projects", async () => {
await using tmp1 = await bootstrap() await using tmp1 = await bootstrap()
await using tmp2 = await bootstrap() await using tmp2 = await bootstrap()
await Instance.provide(tmp1.dir, async () => { await Instance.provide({ directory: tmp1.dir, fn: async () => {
const before1 = await Snapshot.track() const before1 = await Snapshot.track()
await Bun.write(`${tmp1.dir}/project1.txt`, "project1 content") await Bun.write(`${tmp1.dir}/project1.txt`, "project1 content")
const patch1 = await Snapshot.patch(before1!) const patch1 = await Snapshot.patch(before1!)
expect(patch1.files).toContain(`${tmp1.dir}/project1.txt`) expect(patch1.files).toContain(`${tmp1.dir}/project1.txt`)
}) }})
await Instance.provide(tmp2.dir, async () => { await Instance.provide({ directory: tmp2.dir, fn: async () => {
const before2 = await Snapshot.track() const before2 = await Snapshot.track()
await Bun.write(`${tmp2.dir}/project2.txt`, "project2 content") await Bun.write(`${tmp2.dir}/project2.txt`, "project2 content")
const patch2 = await Snapshot.patch(before2!) const patch2 = await Snapshot.patch(before2!)
@@ -399,12 +399,12 @@ test("snapshot state isolation between projects", async () => {
// Ensure project1 files don't appear in project2 // Ensure project1 files don't appear in project2
expect(patch2.files).not.toContain(`${tmp1?.dir}/project1.txt`) expect(patch2.files).not.toContain(`${tmp1?.dir}/project1.txt`)
}) }})
}) })
test("track with no changes returns same hash", async () => { test("track with no changes returns same hash", async () => {
await using tmp = await bootstrap() await using tmp = await bootstrap()
await Instance.provide(tmp.dir, async () => { await Instance.provide({ directory: tmp.dir, fn: async () => {
const hash1 = await Snapshot.track() const hash1 = await Snapshot.track()
expect(hash1).toBeTruthy() expect(hash1).toBeTruthy()
@@ -415,12 +415,12 @@ test("track with no changes returns same hash", async () => {
// Track again // Track again
const hash3 = await Snapshot.track() const hash3 = await Snapshot.track()
expect(hash3).toBe(hash1!) expect(hash3).toBe(hash1!)
}) }})
}) })
test("diff function with various changes", async () => { test("diff function with various changes", async () => {
await using tmp = await bootstrap() await using tmp = await bootstrap()
await Instance.provide(tmp.dir, async () => { await Instance.provide({ directory: tmp.dir, fn: async () => {
const before = await Snapshot.track() const before = await Snapshot.track()
expect(before).toBeTruthy() expect(before).toBeTruthy()
@@ -433,12 +433,12 @@ test("diff function with various changes", async () => {
expect(diff).toContain("deleted") expect(diff).toContain("deleted")
expect(diff).toContain("modified") expect(diff).toContain("modified")
// Note: git diff only shows changes to tracked files, not untracked files like new.txt // Note: git diff only shows changes to tracked files, not untracked files like new.txt
}) }})
}) })
test("restore function", async () => { test("restore function", async () => {
await using tmp = await bootstrap() await using tmp = await bootstrap()
await Instance.provide(tmp.dir, async () => { await Instance.provide({ directory: tmp.dir, fn: async () => {
const before = await Snapshot.track() const before = await Snapshot.track()
expect(before).toBeTruthy() expect(before).toBeTruthy()
@@ -454,5 +454,5 @@ test("restore function", async () => {
expect(await Bun.file(`${tmp.dir}/a.txt`).text()).toBe(tmp.aContent) expect(await Bun.file(`${tmp.dir}/a.txt`).text()).toBe(tmp.aContent)
expect(await Bun.file(`${tmp.dir}/new.txt`).exists()).toBe(true) // New files should remain expect(await Bun.file(`${tmp.dir}/new.txt`).exists()).toBe(true) // New files should remain
expect(await Bun.file(`${tmp.dir}/b.txt`).text()).toBe(tmp.bContent) expect(await Bun.file(`${tmp.dir}/b.txt`).text()).toBe(tmp.bContent)
}) }})
}) })

View File

@@ -19,30 +19,36 @@ Log.init({ print: false })
describe("tool.bash", () => { describe("tool.bash", () => {
test("basic", async () => { test("basic", async () => {
await Instance.provide(projectRoot, async () => { await Instance.provide({
const result = await bash.execute( directory: projectRoot,
{ fn: async () => {
command: "echo 'test'", const result = await bash.execute(
description: "Echo test message", {
}, command: "echo 'test'",
ctx, description: "Echo test message",
) },
expect(result.metadata.exit).toBe(0) ctx,
expect(result.metadata.output).toContain("test") )
expect(result.metadata.exit).toBe(0)
expect(result.metadata.output).toContain("test")
},
}) })
}) })
test("cd ../ should fail outside of project root", async () => { test("cd ../ should fail outside of project root", async () => {
await Instance.provide(projectRoot, async () => { await Instance.provide({
expect( directory: projectRoot,
bash.execute( fn: async () => {
{ expect(
command: "cd ../", bash.execute(
description: "Try to cd to parent directory", {
}, command: "cd ../",
ctx, description: "Try to cd to parent directory",
), },
).rejects.toThrow("This command references paths outside of") ctx,
),
).rejects.toThrow("This command references paths outside of")
},
}) })
}) })
}) })

View File

@@ -20,38 +20,47 @@ const fixturePath = path.join(__dirname, "../fixtures/example")
describe("tool.glob", () => { describe("tool.glob", () => {
test("truncate", async () => { test("truncate", async () => {
await Instance.provide(projectRoot, async () => { await Instance.provide({
let result = await glob.execute( directory: projectRoot,
{ fn: async () => {
pattern: "**/*", let result = await glob.execute(
path: "../../node_modules", {
}, pattern: "**/*",
ctx, path: "../../node_modules",
) },
expect(result.metadata.truncated).toBe(true) ctx,
)
expect(result.metadata.truncated).toBe(true)
},
}) })
}) })
test("basic", async () => { test("basic", async () => {
await Instance.provide(projectRoot, async () => { await Instance.provide({
let result = await glob.execute( directory: projectRoot,
{ fn: async () => {
pattern: "*.json", let result = await glob.execute(
path: undefined, {
}, pattern: "*.json",
ctx, path: undefined,
) },
expect(result.metadata).toMatchObject({ ctx,
truncated: false, )
count: 2, expect(result.metadata).toMatchObject({
}) truncated: false,
count: 2,
})
},
}) })
}) })
}) })
describe("tool.ls", () => { describe("tool.ls", () => {
test("basic", async () => { test("basic", async () => {
const result = await Instance.provide(projectRoot, async () => { const result = await Instance.provide({
return await list.execute({ path: fixturePath, ignore: [".git"] }, ctx) directory: projectRoot,
fn: async () => {
return await list.execute({ path: fixturePath, ignore: [".git"] }, ctx)
},
}) })
// Normalize absolute path to relative for consistent snapshots // Normalize absolute path to relative for consistent snapshots