mirror of
https://github.com/aljazceru/opencode.git
synced 2025-12-21 17:54:23 +01:00
ignore: rework bootstrap so server lazy starts it
This commit is contained in:
@@ -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
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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")
|
||||||
|
},
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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")
|
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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}"`)
|
||||||
}
|
}
|
||||||
|
},
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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}`)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
13
packages/opencode/src/project/bootstrap.ts
Normal file
13
packages/opencode/src/project/bootstrap.ts
Normal 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()
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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())
|
||||||
|
|||||||
@@ -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)
|
||||||
})
|
}})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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")
|
||||||
|
},
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user