mirror of
https://github.com/sst/opencode.git
synced 2025-12-23 10:11:41 +00:00
Merge branch 'dev' into opentui
This commit is contained in:
commit
5e5293d98c
58 changed files with 913 additions and 880 deletions
32
.github/workflows/test.yml
vendored
Normal file
32
.github/workflows/test.yml
vendored
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
name: test
|
||||
|
||||
on:
|
||||
push:
|
||||
branches-ignore:
|
||||
- production
|
||||
pull_request:
|
||||
branches-ignore:
|
||||
- production
|
||||
workflow_dispatch:
|
||||
jobs:
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Setup Bun
|
||||
uses: oven-sh/setup-bun@v1
|
||||
with:
|
||||
bun-version: 1.2.21
|
||||
|
||||
- name: run
|
||||
run: |
|
||||
git config --global user.email "bot@opencode.ai"
|
||||
git config --global user.name "opencode"
|
||||
bun install
|
||||
bun turbo test
|
||||
env:
|
||||
CI: true
|
||||
1
STATS.md
1
STATS.md
|
|
@ -90,3 +90,4 @@
|
|||
| 2025-09-23 | 387,008 (+4,516) | 289,129 (+5,120) | 676,137 (+9,636) |
|
||||
| 2025-09-24 | 393,325 (+6,317) | 294,927 (+5,798) | 688,252 (+12,115) |
|
||||
| 2025-09-25 | 398,879 (+5,554) | 301,663 (+6,736) | 700,542 (+12,290) |
|
||||
| 2025-09-26 | 404,334 (+5,455) | 306,713 (+5,050) | 711,047 (+10,505) |
|
||||
|
|
|
|||
18
bun.lock
18
bun.lock
|
|
@ -12,7 +12,7 @@
|
|||
},
|
||||
"packages/app": {
|
||||
"name": "@opencode/app",
|
||||
"version": "0.11.6",
|
||||
"version": "0.12.1",
|
||||
"dependencies": {
|
||||
"@kobalte/core": "0.13.11",
|
||||
"@opencode-ai/sdk": "workspace:*",
|
||||
|
|
@ -60,7 +60,7 @@
|
|||
},
|
||||
"packages/console/core": {
|
||||
"name": "@opencode/console-core",
|
||||
"version": "0.11.6",
|
||||
"version": "0.12.1",
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-sts": "3.782.0",
|
||||
"@opencode/console-resource": "workspace:*",
|
||||
|
|
@ -77,7 +77,7 @@
|
|||
},
|
||||
"packages/console/function": {
|
||||
"name": "@opencode/console-function",
|
||||
"version": "0.11.6",
|
||||
"version": "0.12.1",
|
||||
"dependencies": {
|
||||
"@ai-sdk/anthropic": "2.0.0",
|
||||
"@ai-sdk/openai": "2.0.2",
|
||||
|
|
@ -103,7 +103,7 @@
|
|||
},
|
||||
"packages/console/scripts": {
|
||||
"name": "@opencode/console-scripts",
|
||||
"version": "0.11.6",
|
||||
"version": "0.12.1",
|
||||
"dependencies": {
|
||||
"@opencode/console-core": "workspace:*",
|
||||
"tsx": "4.20.5",
|
||||
|
|
@ -115,7 +115,7 @@
|
|||
},
|
||||
"packages/function": {
|
||||
"name": "@opencode/function",
|
||||
"version": "0.11.6",
|
||||
"version": "0.12.1",
|
||||
"dependencies": {
|
||||
"@octokit/auth-app": "8.0.1",
|
||||
"@octokit/rest": "22.0.0",
|
||||
|
|
@ -130,7 +130,7 @@
|
|||
},
|
||||
"packages/opencode": {
|
||||
"name": "opencode",
|
||||
"version": "0.11.6",
|
||||
"version": "0.12.1",
|
||||
"bin": {
|
||||
"opencode": "./bin/opencode",
|
||||
},
|
||||
|
|
@ -189,7 +189,7 @@
|
|||
},
|
||||
"packages/plugin": {
|
||||
"name": "@opencode-ai/plugin",
|
||||
"version": "0.11.6",
|
||||
"version": "0.12.1",
|
||||
"dependencies": {
|
||||
"@opencode-ai/sdk": "workspace:*",
|
||||
"zod": "catalog:",
|
||||
|
|
@ -201,7 +201,7 @@
|
|||
},
|
||||
"packages/sdk/js": {
|
||||
"name": "@opencode-ai/sdk",
|
||||
"version": "0.11.6",
|
||||
"version": "0.12.1",
|
||||
"dependencies": {
|
||||
"@hey-api/openapi-ts": "0.82.5",
|
||||
},
|
||||
|
|
@ -212,7 +212,7 @@
|
|||
},
|
||||
"packages/web": {
|
||||
"name": "@opencode/web",
|
||||
"version": "0.11.6",
|
||||
"version": "0.12.1",
|
||||
"dependencies": {
|
||||
"@astrojs/cloudflare": "12.6.3",
|
||||
"@astrojs/markdown-remark": "6.3.1",
|
||||
|
|
|
|||
|
|
@ -1,4 +1,3 @@
|
|||
import { WebhookEndpoint } from "pulumi-stripe"
|
||||
import { domain } from "./stage"
|
||||
|
||||
////////////////
|
||||
|
|
@ -68,7 +67,7 @@ export const auth = new sst.cloudflare.Worker("AuthApi", {
|
|||
// GATEWAY
|
||||
////////////////
|
||||
|
||||
export const stripeWebhook = new WebhookEndpoint("StripeWebhookEndpoint", {
|
||||
export const stripeWebhook = new stripe.WebhookEndpoint("StripeWebhookEndpoint", {
|
||||
url: $interpolate`https://${domain}/stripe/webhook`,
|
||||
enabledEvents: [
|
||||
"checkout.session.async_payment_failed",
|
||||
|
|
|
|||
|
|
@ -3,3 +3,11 @@ export const domain = (() => {
|
|||
if ($app.stage === "dev") return "dev.opencode.ai"
|
||||
return `${$app.stage}.dev.opencode.ai`
|
||||
})()
|
||||
|
||||
export const zoneID = "430ba34c138cfb5360826c4909f99be8"
|
||||
|
||||
// new cloudflare.RegionalHostname("RegionalHostname", {
|
||||
// hostname: domain,
|
||||
// regionKey: "us",
|
||||
// zoneId: zoneID,
|
||||
// })
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@
|
|||
"type": "module",
|
||||
"packageManager": "bun@1.2.21",
|
||||
"scripts": {
|
||||
"dev": "bun run --conditions=development packages/opencode/src/index.ts",
|
||||
"dev": "bun run packages/opencode/src/index.ts",
|
||||
"typecheck": "bun turbo typecheck",
|
||||
"prepare": "husky"
|
||||
},
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@opencode/app",
|
||||
"version": "0.11.6",
|
||||
"version": "0.12.1",
|
||||
"description": "",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
|
|
|||
|
|
@ -6,7 +6,6 @@
|
|||
"jsxImportSource": "solid-js",
|
||||
"types": ["vite/client"],
|
||||
"lib": ["DOM", "DOM.Iterable"],
|
||||
"customConditions": ["development"],
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@
|
|||
"dev:remote": "VITE_AUTH_URL=https://auth.dev.opencode.ai bun sst shell --stage=dev bun dev",
|
||||
"build": "vinxi build && ../../opencode/script/schema.ts ./.output/public/config.json",
|
||||
"start": "vinxi start",
|
||||
"version": "0.11.6"
|
||||
"version": "0.12.1"
|
||||
},
|
||||
"dependencies": {
|
||||
"@ibm/plex": "6.4.1",
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ export function useAuthSession() {
|
|||
return useSession<AuthSession>({
|
||||
password: "0".repeat(32),
|
||||
name: "auth",
|
||||
maxAge: 60 * 60 * 24 * 365,
|
||||
cookie: {
|
||||
secure: false,
|
||||
httpOnly: true,
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ import { Resource } from "@opencode/console-resource"
|
|||
import { Billing } from "../../../../core/src/billing"
|
||||
import { Actor } from "@opencode/console-core/actor.js"
|
||||
import { WorkspaceTable } from "@opencode/console-core/schema/workspace.sql.js"
|
||||
import { ZenModel } from "@opencode/console-core/model.js"
|
||||
|
||||
export async function handler(
|
||||
input: APIEvent,
|
||||
|
|
@ -34,32 +35,7 @@ export async function handler(
|
|||
class MonthlyLimitError extends Error {}
|
||||
class ModelError extends Error {}
|
||||
|
||||
const ModelCostSchema = z.object({
|
||||
input: z.number(),
|
||||
output: z.number(),
|
||||
cacheRead: z.number().optional(),
|
||||
cacheWrite5m: z.number().optional(),
|
||||
cacheWrite1h: z.number().optional(),
|
||||
})
|
||||
|
||||
const ModelSchema = z.object({
|
||||
cost: ModelCostSchema,
|
||||
cost200K: ModelCostSchema.optional(),
|
||||
allowAnonymous: z.boolean().optional(),
|
||||
providers: z.array(
|
||||
z.object({
|
||||
id: z.string(),
|
||||
api: z.string(),
|
||||
apiKey: z.string(),
|
||||
model: z.string(),
|
||||
weight: z.number().optional(),
|
||||
headerMappings: z.record(z.string(), z.string()).optional(),
|
||||
disabled: z.boolean().optional(),
|
||||
}),
|
||||
),
|
||||
})
|
||||
|
||||
type Model = z.infer<typeof ModelSchema>
|
||||
type Model = z.infer<typeof ZenModel.ModelSchema>
|
||||
|
||||
const FREE_WORKSPACES = [
|
||||
"wrk_01K46JDFR0E75SG2Q8K172KF3Y", // frank
|
||||
|
|
@ -230,7 +206,7 @@ export async function handler(
|
|||
function validateModel(reqModel: string) {
|
||||
const json = JSON.parse(Resource.ZEN_MODELS.value)
|
||||
|
||||
const allModels = z.record(z.string(), ModelSchema).parse(json)
|
||||
const allModels = ZenModel.ModelsSchema.parse(json)
|
||||
|
||||
if (!(reqModel in allModels)) {
|
||||
throw new ModelError(`Model ${reqModel} not supported`)
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@opencode/console-core",
|
||||
"version": "0.11.6",
|
||||
"version": "0.12.1",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"dependencies": {
|
||||
|
|
@ -20,6 +20,9 @@
|
|||
"db": "sst shell drizzle-kit",
|
||||
"db-dev": "sst shell --stage dev -- drizzle-kit",
|
||||
"db-prod": "sst shell --stage production -- drizzle-kit",
|
||||
"update-models": "script/update-models.ts",
|
||||
"promote-models-to-dev": "script/promote-models.ts dev",
|
||||
"promote-models-to-prod": "script/promote-models.ts production",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
|
|
|||
24
packages/console/core/script/promote-models.ts
Executable file
24
packages/console/core/script/promote-models.ts
Executable file
|
|
@ -0,0 +1,24 @@
|
|||
#!/usr/bin/env bun
|
||||
|
||||
import { $ } from "bun"
|
||||
import path from "path"
|
||||
import { ZenModel } from "../src/model"
|
||||
|
||||
const stage = process.argv[2]
|
||||
if (!stage) throw new Error("Stage is required")
|
||||
|
||||
const root = path.resolve(process.cwd(), "..", "..", "..")
|
||||
|
||||
// read the secret
|
||||
const ret = await $`bun sst secret list`.cwd(root).text()
|
||||
const value = ret
|
||||
.split("\n")
|
||||
.find((line) => line.startsWith("ZEN_MODELS"))
|
||||
?.split("=")[1]
|
||||
if (!value) throw new Error("ZEN_MODELS not found")
|
||||
|
||||
// validate value
|
||||
ZenModel.ModelsSchema.parse(JSON.parse(value))
|
||||
|
||||
// update the secret
|
||||
await $`bun sst secret set ZEN_MODELS ${value} --stage ${stage}`
|
||||
32
packages/console/core/script/update-models.ts
Executable file
32
packages/console/core/script/update-models.ts
Executable file
|
|
@ -0,0 +1,32 @@
|
|||
#!/usr/bin/env bun
|
||||
|
||||
import { $ } from "bun"
|
||||
import path from "path"
|
||||
import os from "os"
|
||||
import { ZenModel } from "../src/model"
|
||||
|
||||
const root = path.resolve(process.cwd(), "..", "..", "..")
|
||||
const models = await $`bun sst secret list`.cwd(root).text()
|
||||
console.log("models", models)
|
||||
|
||||
// read the line starting with "ZEN_MODELS"
|
||||
const oldValue = models
|
||||
.split("\n")
|
||||
.find((line) => line.startsWith("ZEN_MODELS"))
|
||||
?.split("=")[1]
|
||||
if (!oldValue) throw new Error("ZEN_MODELS not found")
|
||||
console.log("oldValue", oldValue)
|
||||
|
||||
// store the prettified json to a temp file
|
||||
const filename = `models-${Date.now()}.json`
|
||||
const tempFile = Bun.file(path.join(os.tmpdir(), filename))
|
||||
await tempFile.write(JSON.stringify(JSON.parse(oldValue), null, 2))
|
||||
console.log("tempFile", tempFile.name)
|
||||
|
||||
// open temp file in vim and read the file on close
|
||||
await $`vim ${tempFile.name}`
|
||||
const newValue = JSON.parse(await tempFile.text())
|
||||
ZenModel.ModelsSchema.parse(newValue)
|
||||
|
||||
// update the secret
|
||||
await $`bun sst secret set ZEN_MODELS ${JSON.stringify(newValue)}`
|
||||
|
|
@ -206,9 +206,6 @@ export namespace Billing {
|
|||
customer_email: user.email,
|
||||
customer_creation: "always",
|
||||
}),
|
||||
metadata: {
|
||||
workspaceID: Actor.workspace(),
|
||||
},
|
||||
currency: "usd",
|
||||
invoice_creation: {
|
||||
enabled: true,
|
||||
|
|
@ -220,6 +217,16 @@ export namespace Billing {
|
|||
payment_method_data: {
|
||||
allow_redisplay: "always",
|
||||
},
|
||||
customer_update: {
|
||||
name: "auto",
|
||||
address: "auto",
|
||||
},
|
||||
tax_id_collection: {
|
||||
enabled: true,
|
||||
},
|
||||
metadata: {
|
||||
workspaceID: Actor.workspace(),
|
||||
},
|
||||
success_url: successUrl,
|
||||
cancel_url: cancelUrl,
|
||||
})
|
||||
|
|
|
|||
30
packages/console/core/src/model.ts
Normal file
30
packages/console/core/src/model.ts
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
import { z } from "zod"
|
||||
|
||||
export namespace ZenModel {
|
||||
const ModelCostSchema = z.object({
|
||||
input: z.number(),
|
||||
output: z.number(),
|
||||
cacheRead: z.number().optional(),
|
||||
cacheWrite5m: z.number().optional(),
|
||||
cacheWrite1h: z.number().optional(),
|
||||
})
|
||||
|
||||
export const ModelSchema = z.object({
|
||||
cost: ModelCostSchema,
|
||||
cost200K: ModelCostSchema.optional(),
|
||||
allowAnonymous: z.boolean().optional(),
|
||||
providers: z.array(
|
||||
z.object({
|
||||
id: z.string(),
|
||||
api: z.string(),
|
||||
apiKey: z.string(),
|
||||
model: z.string(),
|
||||
weight: z.number().optional(),
|
||||
headerMappings: z.record(z.string(), z.string()).optional(),
|
||||
disabled: z.boolean().optional(),
|
||||
}),
|
||||
),
|
||||
})
|
||||
|
||||
export const ModelsSchema = z.record(z.string(), ModelSchema)
|
||||
}
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@opencode/console-function",
|
||||
"version": "0.11.6",
|
||||
"version": "0.12.1",
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@opencode/console-scripts",
|
||||
"version": "0.11.6",
|
||||
"version": "0.12.1",
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@opencode/function",
|
||||
"version": "0.11.6",
|
||||
"version": "0.12.1",
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
|
|
|
|||
|
|
@ -1,2 +1,7 @@
|
|||
<<<<<<< HEAD
|
||||
preload = ["@opentui/solid/preload"]
|
||||
|
||||
=======
|
||||
[test]
|
||||
preload = ["./test/preload.ts"]
|
||||
>>>>>>> dev
|
||||
|
|
|
|||
|
|
@ -1,13 +1,14 @@
|
|||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"version": "0.11.6",
|
||||
"version": "0.12.1",
|
||||
"name": "opencode",
|
||||
"type": "module",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"typecheck": "tsc --noEmit",
|
||||
"dev": "bun run --conditions=development --conditions=browser ./src/index.ts",
|
||||
"build": "./script/build.ts"
|
||||
"test": "bun test",
|
||||
"build": "./script/build.ts",
|
||||
"dev": "bun run --conditions=browser ./src/index.ts"
|
||||
},
|
||||
"bin": {
|
||||
"opencode": "./bin/opencode"
|
||||
|
|
|
|||
|
|
@ -186,6 +186,14 @@ export const RunCommand = cmd({
|
|||
}
|
||||
}
|
||||
|
||||
if (part.type === "step-start") {
|
||||
if (outputJsonEvent("step_start", { part })) return
|
||||
}
|
||||
|
||||
if (part.type === "step-finish") {
|
||||
if (outputJsonEvent("step_finish", { part })) return
|
||||
}
|
||||
|
||||
if (part.type === "text") {
|
||||
text = part.text
|
||||
|
||||
|
|
|
|||
|
|
@ -60,7 +60,7 @@ export namespace Config {
|
|||
]
|
||||
|
||||
for (const dir of directories) {
|
||||
await assertValid(dir)
|
||||
await assertValid(dir).catch(() => {})
|
||||
result.command = mergeDeep(result.command ?? {}, await loadCommand(dir))
|
||||
result.agent = mergeDeep(result.agent, await loadAgent(dir))
|
||||
result.agent = mergeDeep(result.agent, await loadMode(dir))
|
||||
|
|
@ -118,9 +118,15 @@ export namespace Config {
|
|||
})
|
||||
|
||||
async function assertValid(dir: string) {
|
||||
const ALLOWED_DIRS = new Set(["agent", "command", "mode", "plugin", "tool"])
|
||||
const ALLOWED_DIRS = new Set(["agent", "command", "mode", "plugin", "tool", "themes"])
|
||||
const UNEXPECTED_DIR_GLOB = new Bun.Glob("*/")
|
||||
for await (const item of UNEXPECTED_DIR_GLOB.scan({ absolute: true, cwd: dir, onlyFiles: false })) {
|
||||
for await (const item of UNEXPECTED_DIR_GLOB.scan({
|
||||
absolute: true,
|
||||
followSymlinks: true,
|
||||
dot: true,
|
||||
cwd: dir,
|
||||
onlyFiles: false,
|
||||
})) {
|
||||
const dirname = path.basename(item)
|
||||
if (!ALLOWED_DIRS.has(dirname)) {
|
||||
throw new InvalidError({
|
||||
|
|
@ -134,7 +140,7 @@ export namespace Config {
|
|||
const COMMAND_GLOB = new Bun.Glob("command/**/*.md")
|
||||
async function loadCommand(dir: string) {
|
||||
const result: Record<string, Command> = {}
|
||||
for await (const item of COMMAND_GLOB.scan({ absolute: true, cwd: dir })) {
|
||||
for await (const item of COMMAND_GLOB.scan({ absolute: true, followSymlinks: true, dot: true, cwd: dir })) {
|
||||
const content = await Bun.file(item).text()
|
||||
const md = matter(content)
|
||||
if (!md.data) continue
|
||||
|
|
@ -169,7 +175,7 @@ export namespace Config {
|
|||
async function loadAgent(dir: string) {
|
||||
const result: Record<string, Agent> = {}
|
||||
|
||||
for await (const item of AGENT_GLOB.scan({ absolute: true, cwd: dir })) {
|
||||
for await (const item of AGENT_GLOB.scan({ absolute: true, followSymlinks: true, dot: true, cwd: dir })) {
|
||||
const content = await Bun.file(item).text()
|
||||
const md = matter(content)
|
||||
if (!md.data) continue
|
||||
|
|
@ -207,7 +213,7 @@ export namespace Config {
|
|||
const MODE_GLOB = new Bun.Glob("mode/*.md")
|
||||
async function loadMode(dir: string) {
|
||||
const result: Record<string, Agent> = {}
|
||||
for await (const item of MODE_GLOB.scan({ absolute: true, cwd: dir })) {
|
||||
for await (const item of MODE_GLOB.scan({ absolute: true, followSymlinks: true, dot: true, cwd: dir })) {
|
||||
const content = await Bun.file(item).text()
|
||||
const md = matter(content)
|
||||
if (!md.data) continue
|
||||
|
|
@ -233,7 +239,7 @@ export namespace Config {
|
|||
async function loadPlugin(dir: string) {
|
||||
const plugins: string[] = []
|
||||
|
||||
for await (const item of PLUGIN_GLOB.scan({ absolute: true, cwd: dir })) {
|
||||
for await (const item of PLUGIN_GLOB.scan({ absolute: true, followSymlinks: true, dot: true, cwd: dir })) {
|
||||
plugins.push("file://" + item)
|
||||
}
|
||||
return plugins
|
||||
|
|
|
|||
12
packages/opencode/src/config/markdown.ts
Normal file
12
packages/opencode/src/config/markdown.ts
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
export namespace ConfigMarkdown {
|
||||
export const FILE_REGEX = /(?<![\w`])@(\.?[^\s`,.]*(?:\.[^\s`,.]+)*)/g
|
||||
export const SHELL_REGEX = /`[^`]+`/g
|
||||
|
||||
export function files(template: string) {
|
||||
return Array.from(template.matchAll(FILE_REGEX))
|
||||
}
|
||||
|
||||
export function shell(template: string) {
|
||||
return Array.from(template.matchAll(SHELL_REGEX))
|
||||
}
|
||||
}
|
||||
|
|
@ -48,6 +48,7 @@ import { ulid } from "ulid"
|
|||
import { spawn } from "child_process"
|
||||
import { Command } from "../command"
|
||||
import { $ } from "bun"
|
||||
import { ConfigMarkdown } from "../config/markdown"
|
||||
|
||||
export namespace SessionPrompt {
|
||||
const log = Log.create({ service: "session.prompt" })
|
||||
|
|
@ -1354,7 +1355,6 @@ export namespace SessionPrompt {
|
|||
* Matches @ followed by file paths, excluding commas, periods at end of sentences, and backticks
|
||||
* Does not match when preceded by word characters or backticks (to avoid email addresses and quoted references)
|
||||
*/
|
||||
export const fileRegex = /(?<![\w`])@(\.?[^\s`,.]*(?:\.[^\s`,.]+)*)/g
|
||||
|
||||
export async function command(input: CommandInput) {
|
||||
log.info("command", input)
|
||||
|
|
@ -1363,10 +1363,10 @@ export namespace SessionPrompt {
|
|||
|
||||
let template = command.template.replace("$ARGUMENTS", input.arguments)
|
||||
|
||||
const bash = Array.from(template.matchAll(bashRegex))
|
||||
if (bash.length > 0) {
|
||||
const shell = ConfigMarkdown.shell(template)
|
||||
if (shell.length > 0) {
|
||||
const results = await Promise.all(
|
||||
bash.map(async ([, cmd]) => {
|
||||
shell.map(async ([, cmd]) => {
|
||||
try {
|
||||
return await $`${{ raw: cmd }}`.nothrow().text()
|
||||
} catch (error) {
|
||||
|
|
@ -1385,9 +1385,9 @@ export namespace SessionPrompt {
|
|||
},
|
||||
] as PromptInput["parts"]
|
||||
|
||||
const matches = Array.from(template.matchAll(fileRegex))
|
||||
const files = ConfigMarkdown.files(template)
|
||||
await Promise.all(
|
||||
matches.map(async (match) => {
|
||||
files.map(async (match) => {
|
||||
const name = match[1]
|
||||
const filepath = name.startsWith("~/")
|
||||
? path.join(os.homedir(), name.slice(2))
|
||||
|
|
|
|||
|
|
@ -596,13 +596,13 @@ export function replace(content: string, oldString: string, newString: string, r
|
|||
for (const replacer of [
|
||||
SimpleReplacer,
|
||||
LineTrimmedReplacer,
|
||||
// BlockAnchorReplacer,
|
||||
BlockAnchorReplacer,
|
||||
WhitespaceNormalizedReplacer,
|
||||
IndentationFlexibleReplacer,
|
||||
EscapeNormalizedReplacer,
|
||||
// TrimmedBoundaryReplacer,
|
||||
// ContextAwareReplacer,
|
||||
// MultiOccurrenceReplacer,
|
||||
TrimmedBoundaryReplacer,
|
||||
ContextAwareReplacer,
|
||||
MultiOccurrenceReplacer,
|
||||
]) {
|
||||
for (const search of replacer(content, oldString)) {
|
||||
const index = content.indexOf(search)
|
||||
|
|
|
|||
|
|
@ -20,29 +20,12 @@ import z from "zod/v4"
|
|||
import { Plugin } from "../plugin"
|
||||
|
||||
export namespace ToolRegistry {
|
||||
// Built-in tools that ship with opencode
|
||||
const BUILTIN = [
|
||||
InvalidTool,
|
||||
BashTool,
|
||||
EditTool,
|
||||
WebFetchTool,
|
||||
GlobTool,
|
||||
GrepTool,
|
||||
ListTool,
|
||||
PatchTool,
|
||||
ReadTool,
|
||||
WriteTool,
|
||||
TodoWriteTool,
|
||||
TodoReadTool,
|
||||
TaskTool,
|
||||
]
|
||||
|
||||
export const state = Instance.state(async () => {
|
||||
const custom = [] as Tool.Info[]
|
||||
const glob = new Bun.Glob("tool/*.{js,ts}")
|
||||
|
||||
for (const dir of await Config.directories()) {
|
||||
for await (const match of glob.scan({ cwd: dir, absolute: true })) {
|
||||
for await (const match of glob.scan({ cwd: dir, absolute: true, followSymlinks: true, dot: true })) {
|
||||
const namespace = path.basename(match, path.extname(match))
|
||||
const mod = await import(match)
|
||||
for (const [id, def] of Object.entries<ToolDefinition>(mod)) {
|
||||
|
|
@ -91,7 +74,22 @@ export namespace ToolRegistry {
|
|||
|
||||
async function all(): Promise<Tool.Info[]> {
|
||||
const custom = await state().then((x) => x.custom)
|
||||
return [...BUILTIN, ...custom]
|
||||
return [
|
||||
InvalidTool,
|
||||
BashTool,
|
||||
EditTool,
|
||||
WebFetchTool,
|
||||
GlobTool,
|
||||
GrepTool,
|
||||
ListTool,
|
||||
PatchTool,
|
||||
ReadTool,
|
||||
WriteTool,
|
||||
TodoWriteTool,
|
||||
TodoReadTool,
|
||||
TaskTool,
|
||||
...custom,
|
||||
]
|
||||
}
|
||||
|
||||
export async function ids() {
|
||||
|
|
|
|||
352
packages/opencode/test/config/config.test.ts
Normal file
352
packages/opencode/test/config/config.test.ts
Normal file
|
|
@ -0,0 +1,352 @@
|
|||
import { test, expect } from "bun:test"
|
||||
import { Config } from "../../src/config/config"
|
||||
import { Instance } from "../../src/project/instance"
|
||||
import { tmpdir } from "../fixture/fixture"
|
||||
import path from "path"
|
||||
import fs from "fs/promises"
|
||||
|
||||
test("loads config with defaults when no files exist", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const config = await Config.get()
|
||||
expect(config.username).toBeDefined()
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("loads JSON config file", async () => {
|
||||
await using tmp = await tmpdir({
|
||||
init: async (dir) => {
|
||||
await Bun.write(
|
||||
path.join(dir, "opencode.json"),
|
||||
JSON.stringify({
|
||||
$schema: "https://opencode.ai/config.json",
|
||||
model: "test/model",
|
||||
username: "testuser",
|
||||
}),
|
||||
)
|
||||
},
|
||||
})
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const config = await Config.get()
|
||||
expect(config.model).toBe("test/model")
|
||||
expect(config.username).toBe("testuser")
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("loads JSONC config file", async () => {
|
||||
await using tmp = await tmpdir({
|
||||
init: async (dir) => {
|
||||
await Bun.write(
|
||||
path.join(dir, "opencode.jsonc"),
|
||||
`{
|
||||
// This is a comment
|
||||
"$schema": "https://opencode.ai/config.json",
|
||||
"model": "test/model",
|
||||
"username": "testuser"
|
||||
}`,
|
||||
)
|
||||
},
|
||||
})
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const config = await Config.get()
|
||||
expect(config.model).toBe("test/model")
|
||||
expect(config.username).toBe("testuser")
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("merges multiple config files with correct precedence", async () => {
|
||||
await using tmp = await tmpdir({
|
||||
init: async (dir) => {
|
||||
await Bun.write(
|
||||
path.join(dir, "opencode.jsonc"),
|
||||
JSON.stringify({
|
||||
$schema: "https://opencode.ai/config.json",
|
||||
model: "base",
|
||||
username: "base",
|
||||
}),
|
||||
)
|
||||
await Bun.write(
|
||||
path.join(dir, "opencode.json"),
|
||||
JSON.stringify({
|
||||
$schema: "https://opencode.ai/config.json",
|
||||
model: "override",
|
||||
}),
|
||||
)
|
||||
},
|
||||
})
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const config = await Config.get()
|
||||
expect(config.model).toBe("override")
|
||||
expect(config.username).toBe("base")
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("handles environment variable substitution", async () => {
|
||||
const originalEnv = process.env["TEST_VAR"]
|
||||
process.env["TEST_VAR"] = "test_theme"
|
||||
|
||||
try {
|
||||
await using tmp = await tmpdir({
|
||||
init: async (dir) => {
|
||||
await Bun.write(
|
||||
path.join(dir, "opencode.json"),
|
||||
JSON.stringify({
|
||||
$schema: "https://opencode.ai/config.json",
|
||||
theme: "{env:TEST_VAR}",
|
||||
}),
|
||||
)
|
||||
},
|
||||
})
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const config = await Config.get()
|
||||
expect(config.theme).toBe("test_theme")
|
||||
},
|
||||
})
|
||||
} finally {
|
||||
if (originalEnv !== undefined) {
|
||||
process.env["TEST_VAR"] = originalEnv
|
||||
} else {
|
||||
delete process.env["TEST_VAR"]
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
test("handles file inclusion substitution", async () => {
|
||||
await using tmp = await tmpdir({
|
||||
init: async (dir) => {
|
||||
await Bun.write(path.join(dir, "included.txt"), "test_theme")
|
||||
await Bun.write(
|
||||
path.join(dir, "opencode.json"),
|
||||
JSON.stringify({
|
||||
$schema: "https://opencode.ai/config.json",
|
||||
theme: "{file:included.txt}",
|
||||
}),
|
||||
)
|
||||
},
|
||||
})
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const config = await Config.get()
|
||||
expect(config.theme).toBe("test_theme")
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("validates config schema and throws on invalid fields", async () => {
|
||||
await using tmp = await tmpdir({
|
||||
init: async (dir) => {
|
||||
await Bun.write(
|
||||
path.join(dir, "opencode.json"),
|
||||
JSON.stringify({
|
||||
$schema: "https://opencode.ai/config.json",
|
||||
invalid_field: "should cause error",
|
||||
}),
|
||||
)
|
||||
},
|
||||
})
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
// Strict schema should throw an error for invalid fields
|
||||
await expect(Config.get()).rejects.toThrow()
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("throws error for invalid JSON", async () => {
|
||||
await using tmp = await tmpdir({
|
||||
init: async (dir) => {
|
||||
await Bun.write(path.join(dir, "opencode.json"), "{ invalid json }")
|
||||
},
|
||||
})
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
await expect(Config.get()).rejects.toThrow()
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("handles agent configuration", async () => {
|
||||
await using tmp = await tmpdir({
|
||||
init: async (dir) => {
|
||||
await Bun.write(
|
||||
path.join(dir, "opencode.json"),
|
||||
JSON.stringify({
|
||||
$schema: "https://opencode.ai/config.json",
|
||||
agent: {
|
||||
test_agent: {
|
||||
model: "test/model",
|
||||
temperature: 0.7,
|
||||
description: "test agent",
|
||||
},
|
||||
},
|
||||
}),
|
||||
)
|
||||
},
|
||||
})
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const config = await Config.get()
|
||||
expect(config.agent?.["test_agent"]).toEqual({
|
||||
model: "test/model",
|
||||
temperature: 0.7,
|
||||
description: "test agent",
|
||||
})
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("handles command configuration", async () => {
|
||||
await using tmp = await tmpdir({
|
||||
init: async (dir) => {
|
||||
await Bun.write(
|
||||
path.join(dir, "opencode.json"),
|
||||
JSON.stringify({
|
||||
$schema: "https://opencode.ai/config.json",
|
||||
command: {
|
||||
test_command: {
|
||||
template: "test template",
|
||||
description: "test command",
|
||||
agent: "test_agent",
|
||||
},
|
||||
},
|
||||
}),
|
||||
)
|
||||
},
|
||||
})
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const config = await Config.get()
|
||||
expect(config.command?.["test_command"]).toEqual({
|
||||
template: "test template",
|
||||
description: "test command",
|
||||
agent: "test_agent",
|
||||
})
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("migrates autoshare to share field", async () => {
|
||||
await using tmp = await tmpdir({
|
||||
init: async (dir) => {
|
||||
await Bun.write(
|
||||
path.join(dir, "opencode.json"),
|
||||
JSON.stringify({
|
||||
$schema: "https://opencode.ai/config.json",
|
||||
autoshare: true,
|
||||
}),
|
||||
)
|
||||
},
|
||||
})
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const config = await Config.get()
|
||||
expect(config.share).toBe("auto")
|
||||
expect(config.autoshare).toBe(true)
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("migrates mode field to agent field", async () => {
|
||||
await using tmp = await tmpdir({
|
||||
init: async (dir) => {
|
||||
await Bun.write(
|
||||
path.join(dir, "opencode.json"),
|
||||
JSON.stringify({
|
||||
$schema: "https://opencode.ai/config.json",
|
||||
mode: {
|
||||
test_mode: {
|
||||
model: "test/model",
|
||||
temperature: 0.5,
|
||||
},
|
||||
},
|
||||
}),
|
||||
)
|
||||
},
|
||||
})
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const config = await Config.get()
|
||||
expect(config.agent?.["test_mode"]).toEqual({
|
||||
model: "test/model",
|
||||
temperature: 0.5,
|
||||
mode: "primary",
|
||||
})
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("loads config from .opencode directory", async () => {
|
||||
await using tmp = await tmpdir({
|
||||
init: async (dir) => {
|
||||
const opencodeDir = path.join(dir, ".opencode")
|
||||
await fs.mkdir(opencodeDir, { recursive: true })
|
||||
const agentDir = path.join(opencodeDir, "agent")
|
||||
await fs.mkdir(agentDir, { recursive: true })
|
||||
|
||||
await Bun.write(
|
||||
path.join(agentDir, "test.md"),
|
||||
`---
|
||||
model: test/model
|
||||
---
|
||||
Test agent prompt`,
|
||||
)
|
||||
},
|
||||
})
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const config = await Config.get()
|
||||
expect(config.agent?.["test"]).toEqual({
|
||||
name: "test",
|
||||
model: "test/model",
|
||||
prompt: "Test agent prompt",
|
||||
})
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("updates config and writes to file", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const newConfig = { model: "updated/model" }
|
||||
await Config.update(newConfig as any)
|
||||
|
||||
const writtenConfig = JSON.parse(await Bun.file(path.join(tmp.path, "config.json")).text())
|
||||
expect(writtenConfig.model).toBe("updated/model")
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("gets config directories", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const dirs = await Config.directories()
|
||||
expect(dirs.length).toBeGreaterThanOrEqual(1)
|
||||
},
|
||||
})
|
||||
})
|
||||
89
packages/opencode/test/config/markdown.test.ts
Normal file
89
packages/opencode/test/config/markdown.test.ts
Normal file
|
|
@ -0,0 +1,89 @@
|
|||
import { expect, test } from "bun:test"
|
||||
import { ConfigMarkdown } from "../../src/config/markdown"
|
||||
|
||||
const template = `This is a @valid/path/to/a/file and it should also match at
|
||||
the beginning of a line:
|
||||
|
||||
@another-valid/path/to/a/file
|
||||
|
||||
but this is not:
|
||||
|
||||
- Adds a "Co-authored-by:" footer which clarifies which AI agent
|
||||
helped create this commit, using an appropriate \`noreply@...\`
|
||||
or \`noreply@anthropic.com\` email address.
|
||||
|
||||
We also need to deal with files followed by @commas, ones
|
||||
with @file-extensions.md, even @multiple.extensions.bak,
|
||||
hidden directorys like @.config/ or files like @.bashrc
|
||||
and ones at the end of a sentence like @foo.md.
|
||||
|
||||
Also shouldn't forget @/absolute/paths.txt with and @/without/extensions,
|
||||
as well as @~/home-files and @~/paths/under/home.txt.
|
||||
|
||||
If the reference is \`@quoted/in/backticks\` then it shouldn't match at all.`
|
||||
|
||||
const matches = ConfigMarkdown.files(template)
|
||||
|
||||
test("should extract exactly 12 file references", () => {
|
||||
expect(matches.length).toBe(12)
|
||||
})
|
||||
|
||||
test("should extract valid/path/to/a/file", () => {
|
||||
expect(matches[0][1]).toBe("valid/path/to/a/file")
|
||||
})
|
||||
|
||||
test("should extract another-valid/path/to/a/file", () => {
|
||||
expect(matches[1][1]).toBe("another-valid/path/to/a/file")
|
||||
})
|
||||
|
||||
test("should extract paths ignoring comma after", () => {
|
||||
expect(matches[2][1]).toBe("commas")
|
||||
})
|
||||
|
||||
test("should extract a path with a file extension and comma after", () => {
|
||||
expect(matches[3][1]).toBe("file-extensions.md")
|
||||
})
|
||||
|
||||
test("should extract a path with multiple dots and comma after", () => {
|
||||
expect(matches[4][1]).toBe("multiple.extensions.bak")
|
||||
})
|
||||
|
||||
test("should extract hidden directory", () => {
|
||||
expect(matches[5][1]).toBe(".config/")
|
||||
})
|
||||
|
||||
test("should extract hidden file", () => {
|
||||
expect(matches[6][1]).toBe(".bashrc")
|
||||
})
|
||||
|
||||
test("should extract a file ignoring period at end of sentence", () => {
|
||||
expect(matches[7][1]).toBe("foo.md")
|
||||
})
|
||||
|
||||
test("should extract an absolute path with an extension", () => {
|
||||
expect(matches[8][1]).toBe("/absolute/paths.txt")
|
||||
})
|
||||
|
||||
test("should extract an absolute path without an extension", () => {
|
||||
expect(matches[9][1]).toBe("/without/extensions")
|
||||
})
|
||||
|
||||
test("should extract an absolute path in home directory", () => {
|
||||
expect(matches[10][1]).toBe("~/home-files")
|
||||
})
|
||||
|
||||
test("should extract an absolute path under home directory", () => {
|
||||
expect(matches[11][1]).toBe("~/paths/under/home.txt")
|
||||
})
|
||||
|
||||
test("should not match when preceded by backtick", () => {
|
||||
const backtickTest = "This `@should/not/match` should be ignored"
|
||||
const backtickMatches = ConfigMarkdown.files(backtickTest)
|
||||
expect(backtickMatches.length).toBe(0)
|
||||
})
|
||||
|
||||
test("should not match email addresses", () => {
|
||||
const emailTest = "Contact user@example.com for help"
|
||||
const emailMatches = ConfigMarkdown.files(emailTest)
|
||||
expect(emailMatches.length).toBe(0)
|
||||
})
|
||||
24
packages/opencode/test/fixture/fixture.ts
Normal file
24
packages/opencode/test/fixture/fixture.ts
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
import { $ } from "bun"
|
||||
import os from "os"
|
||||
import path from "path"
|
||||
|
||||
type TmpDirOptions<T> = {
|
||||
git?: boolean
|
||||
init?: (dir: string) => Promise<T>
|
||||
dispose?: (dir: string) => Promise<T>
|
||||
}
|
||||
export async function tmpdir<T>(options?: TmpDirOptions<T>) {
|
||||
const dirpath = path.join(os.tmpdir(), "opencode-test-" + Math.random().toString(36).slice(2))
|
||||
await $`mkdir -p ${dirpath}`.quiet()
|
||||
if (options?.git) await $`git init`.cwd(dirpath).quiet()
|
||||
const extra = await options?.init?.(dirpath)
|
||||
const result = {
|
||||
[Symbol.asyncDispose]: async () => {
|
||||
await options?.dispose?.(dirpath)
|
||||
await $`rm -rf ${dirpath}`.quiet()
|
||||
},
|
||||
path: dirpath,
|
||||
extra: extra as T,
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
|
@ -1 +0,0 @@
|
|||
// Test fixture for ListTool
|
||||
|
|
@ -1 +0,0 @@
|
|||
// Test fixture for ListTool
|
||||
|
|
@ -1 +0,0 @@
|
|||
// Test fixture for ListTool
|
||||
7
packages/opencode/test/preload.ts
Normal file
7
packages/opencode/test/preload.ts
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
import { Log } from "../src/util/log"
|
||||
|
||||
Log.init({
|
||||
print: false,
|
||||
dev: true,
|
||||
level: "DEBUG",
|
||||
})
|
||||
|
|
@ -1,91 +0,0 @@
|
|||
import { describe, expect, test } from "bun:test"
|
||||
import { SessionPrompt } from "../../src/session/prompt"
|
||||
|
||||
describe("fileRegex", () => {
|
||||
const template = `This is a @valid/path/to/a/file and it should also match at
|
||||
the beginning of a line:
|
||||
|
||||
@another-valid/path/to/a/file
|
||||
|
||||
but this is not:
|
||||
|
||||
- Adds a "Co-authored-by:" footer which clarifies which AI agent
|
||||
helped create this commit, using an appropriate \`noreply@...\`
|
||||
or \`noreply@anthropic.com\` email address.
|
||||
|
||||
We also need to deal with files followed by @commas, ones
|
||||
with @file-extensions.md, even @multiple.extensions.bak,
|
||||
hidden directorys like @.config/ or files like @.bashrc
|
||||
and ones at the end of a sentence like @foo.md.
|
||||
|
||||
Also shouldn't forget @/absolute/paths.txt with and @/without/extensions,
|
||||
as well as @~/home-files and @~/paths/under/home.txt.
|
||||
|
||||
If the reference is \`@quoted/in/backticks\` then it shouldn't match at all.`
|
||||
|
||||
const matches = Array.from(template.matchAll(SessionPrompt.fileRegex))
|
||||
|
||||
test("should extract exactly 12 file references", () => {
|
||||
expect(matches.length).toBe(12)
|
||||
})
|
||||
|
||||
test("should extract valid/path/to/a/file", () => {
|
||||
expect(matches[0][1]).toBe("valid/path/to/a/file")
|
||||
})
|
||||
|
||||
test("should extract another-valid/path/to/a/file", () => {
|
||||
expect(matches[1][1]).toBe("another-valid/path/to/a/file")
|
||||
})
|
||||
|
||||
test("should extract paths ignoring comma after", () => {
|
||||
expect(matches[2][1]).toBe("commas")
|
||||
})
|
||||
|
||||
test("should extract a path with a file extension and comma after", () => {
|
||||
expect(matches[3][1]).toBe("file-extensions.md")
|
||||
})
|
||||
|
||||
test("should extract a path with multiple dots and comma after", () => {
|
||||
expect(matches[4][1]).toBe("multiple.extensions.bak")
|
||||
})
|
||||
|
||||
test("should extract hidden directory", () => {
|
||||
expect(matches[5][1]).toBe(".config/")
|
||||
})
|
||||
|
||||
test("should extract hidden file", () => {
|
||||
expect(matches[6][1]).toBe(".bashrc")
|
||||
})
|
||||
|
||||
test("should extract a file ignoring period at end of sentence", () => {
|
||||
expect(matches[7][1]).toBe("foo.md")
|
||||
})
|
||||
|
||||
test("should extract an absolute path with an extension", () => {
|
||||
expect(matches[8][1]).toBe("/absolute/paths.txt")
|
||||
})
|
||||
|
||||
test("should extract an absolute path without an extension", () => {
|
||||
expect(matches[9][1]).toBe("/without/extensions")
|
||||
})
|
||||
|
||||
test("should extract an absolute path in home directory", () => {
|
||||
expect(matches[10][1]).toBe("~/home-files")
|
||||
})
|
||||
|
||||
test("should extract an absolute path under home directory", () => {
|
||||
expect(matches[11][1]).toBe("~/paths/under/home.txt")
|
||||
})
|
||||
|
||||
test("should not match when preceded by backtick", () => {
|
||||
const backtickTest = "This `@should/not/match` should be ignored"
|
||||
const backtickMatches = Array.from(backtickTest.matchAll(SessionPrompt.fileRegex))
|
||||
expect(backtickMatches.length).toBe(0)
|
||||
})
|
||||
|
||||
test("should not match email addresses", () => {
|
||||
const emailTest = "Contact user@example.com for help"
|
||||
const emailMatches = Array.from(emailTest.matchAll(SessionPrompt.fileRegex))
|
||||
expect(emailMatches.length).toBe(0)
|
||||
})
|
||||
})
|
||||
|
|
@ -2,40 +2,38 @@ import { test, expect } from "bun:test"
|
|||
import { $ } from "bun"
|
||||
import { Snapshot } from "../../src/snapshot"
|
||||
import { Instance } from "../../src/project/instance"
|
||||
import { tmpdir } from "../fixture/fixture"
|
||||
|
||||
async function bootstrap() {
|
||||
const dir = await $`mktemp -d`.text().then((t) => t.trim())
|
||||
// Randomize file contents to ensure unique git repos
|
||||
const unique = Math.random().toString(36).slice(2)
|
||||
const aContent = `A${unique}`
|
||||
const bContent = `B${unique}`
|
||||
await Bun.write(`${dir}/a.txt`, aContent)
|
||||
await Bun.write(`${dir}/b.txt`, bContent)
|
||||
await $`git init`.cwd(dir).quiet()
|
||||
await $`git add .`.cwd(dir).quiet()
|
||||
await $`git commit -m init`.cwd(dir).quiet()
|
||||
|
||||
return {
|
||||
[Symbol.asyncDispose]: async () => {
|
||||
await $`rm -rf ${dir}`.quiet()
|
||||
return tmpdir({
|
||||
git: true,
|
||||
init: async (dir) => {
|
||||
const unique = Math.random().toString(36).slice(2)
|
||||
const aContent = `A${unique}`
|
||||
const bContent = `B${unique}`
|
||||
await Bun.write(`${dir}/a.txt`, aContent)
|
||||
await Bun.write(`${dir}/b.txt`, bContent)
|
||||
await $`git add .`.cwd(dir).quiet()
|
||||
await $`git commit -m init`.cwd(dir).quiet()
|
||||
return {
|
||||
aContent,
|
||||
bContent,
|
||||
}
|
||||
},
|
||||
dir,
|
||||
aContent,
|
||||
bContent,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
test("tracks deleted files correctly", async () => {
|
||||
await using tmp = await bootstrap()
|
||||
await Instance.provide({
|
||||
directory: tmp.dir,
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const before = await Snapshot.track()
|
||||
expect(before).toBeTruthy()
|
||||
|
||||
await $`rm ${tmp.dir}/a.txt`.quiet()
|
||||
await $`rm ${tmp.path}/a.txt`.quiet()
|
||||
|
||||
expect((await Snapshot.patch(before!)).files).toContain(`${tmp.dir}/a.txt`)
|
||||
expect((await Snapshot.patch(before!)).files).toContain(`${tmp.path}/a.txt`)
|
||||
},
|
||||
})
|
||||
})
|
||||
|
|
@ -43,16 +41,16 @@ test("tracks deleted files correctly", async () => {
|
|||
test("revert should remove new files", async () => {
|
||||
await using tmp = await bootstrap()
|
||||
await Instance.provide({
|
||||
directory: tmp.dir,
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const before = await Snapshot.track()
|
||||
expect(before).toBeTruthy()
|
||||
|
||||
await Bun.write(`${tmp.dir}/new.txt`, "NEW")
|
||||
await Bun.write(`${tmp.path}/new.txt`, "NEW")
|
||||
|
||||
await Snapshot.revert([await Snapshot.patch(before!)])
|
||||
|
||||
expect(await Bun.file(`${tmp.dir}/new.txt`).exists()).toBe(false)
|
||||
expect(await Bun.file(`${tmp.path}/new.txt`).exists()).toBe(false)
|
||||
},
|
||||
})
|
||||
})
|
||||
|
|
@ -60,17 +58,17 @@ test("revert should remove new files", async () => {
|
|||
test("revert in subdirectory", async () => {
|
||||
await using tmp = await bootstrap()
|
||||
await Instance.provide({
|
||||
directory: tmp.dir,
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const before = await Snapshot.track()
|
||||
expect(before).toBeTruthy()
|
||||
|
||||
await $`mkdir -p ${tmp.dir}/sub`.quiet()
|
||||
await Bun.write(`${tmp.dir}/sub/file.txt`, "SUB")
|
||||
await $`mkdir -p ${tmp.path}/sub`.quiet()
|
||||
await Bun.write(`${tmp.path}/sub/file.txt`, "SUB")
|
||||
|
||||
await Snapshot.revert([await Snapshot.patch(before!)])
|
||||
|
||||
expect(await Bun.file(`${tmp.dir}/sub/file.txt`).exists()).toBe(false)
|
||||
expect(await Bun.file(`${tmp.path}/sub/file.txt`).exists()).toBe(false)
|
||||
// Note: revert currently only removes files, not directories
|
||||
// The empty subdirectory will remain
|
||||
},
|
||||
|
|
@ -80,24 +78,24 @@ test("revert in subdirectory", async () => {
|
|||
test("multiple file operations", async () => {
|
||||
await using tmp = await bootstrap()
|
||||
await Instance.provide({
|
||||
directory: tmp.dir,
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const before = await Snapshot.track()
|
||||
expect(before).toBeTruthy()
|
||||
|
||||
await $`rm ${tmp.dir}/a.txt`.quiet()
|
||||
await Bun.write(`${tmp.dir}/c.txt`, "C")
|
||||
await $`mkdir -p ${tmp.dir}/dir`.quiet()
|
||||
await Bun.write(`${tmp.dir}/dir/d.txt`, "D")
|
||||
await Bun.write(`${tmp.dir}/b.txt`, "MODIFIED")
|
||||
await $`rm ${tmp.path}/a.txt`.quiet()
|
||||
await Bun.write(`${tmp.path}/c.txt`, "C")
|
||||
await $`mkdir -p ${tmp.path}/dir`.quiet()
|
||||
await Bun.write(`${tmp.path}/dir/d.txt`, "D")
|
||||
await Bun.write(`${tmp.path}/b.txt`, "MODIFIED")
|
||||
|
||||
await Snapshot.revert([await Snapshot.patch(before!)])
|
||||
|
||||
expect(await Bun.file(`${tmp.dir}/a.txt`).text()).toBe(tmp.aContent)
|
||||
expect(await Bun.file(`${tmp.dir}/c.txt`).exists()).toBe(false)
|
||||
expect(await Bun.file(`${tmp.path}/a.txt`).text()).toBe(tmp.extra.aContent)
|
||||
expect(await Bun.file(`${tmp.path}/c.txt`).exists()).toBe(false)
|
||||
// Note: revert currently only removes files, not directories
|
||||
// The empty directory will remain
|
||||
expect(await Bun.file(`${tmp.dir}/b.txt`).text()).toBe(tmp.bContent)
|
||||
expect(await Bun.file(`${tmp.path}/b.txt`).text()).toBe(tmp.extra.bContent)
|
||||
},
|
||||
})
|
||||
})
|
||||
|
|
@ -105,12 +103,12 @@ test("multiple file operations", async () => {
|
|||
test("empty directory handling", async () => {
|
||||
await using tmp = await bootstrap()
|
||||
await Instance.provide({
|
||||
directory: tmp.dir,
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const before = await Snapshot.track()
|
||||
expect(before).toBeTruthy()
|
||||
|
||||
await $`mkdir ${tmp.dir}/empty`.quiet()
|
||||
await $`mkdir ${tmp.path}/empty`.quiet()
|
||||
|
||||
expect((await Snapshot.patch(before!)).files.length).toBe(0)
|
||||
},
|
||||
|
|
@ -120,18 +118,18 @@ test("empty directory handling", async () => {
|
|||
test("binary file handling", async () => {
|
||||
await using tmp = await bootstrap()
|
||||
await Instance.provide({
|
||||
directory: tmp.dir,
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const before = await Snapshot.track()
|
||||
expect(before).toBeTruthy()
|
||||
|
||||
await Bun.write(`${tmp.dir}/image.png`, Buffer.from([0x89, 0x50, 0x4e, 0x47]))
|
||||
await Bun.write(`${tmp.path}/image.png`, Buffer.from([0x89, 0x50, 0x4e, 0x47]))
|
||||
|
||||
const patch = await Snapshot.patch(before!)
|
||||
expect(patch.files).toContain(`${tmp.dir}/image.png`)
|
||||
expect(patch.files).toContain(`${tmp.path}/image.png`)
|
||||
|
||||
await Snapshot.revert([patch])
|
||||
expect(await Bun.file(`${tmp.dir}/image.png`).exists()).toBe(false)
|
||||
expect(await Bun.file(`${tmp.path}/image.png`).exists()).toBe(false)
|
||||
},
|
||||
})
|
||||
})
|
||||
|
|
@ -139,14 +137,14 @@ test("binary file handling", async () => {
|
|||
test("symlink handling", async () => {
|
||||
await using tmp = await bootstrap()
|
||||
await Instance.provide({
|
||||
directory: tmp.dir,
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const before = await Snapshot.track()
|
||||
expect(before).toBeTruthy()
|
||||
|
||||
await $`ln -s ${tmp.dir}/a.txt ${tmp.dir}/link.txt`.quiet()
|
||||
await $`ln -s ${tmp.path}/a.txt ${tmp.path}/link.txt`.quiet()
|
||||
|
||||
expect((await Snapshot.patch(before!)).files).toContain(`${tmp.dir}/link.txt`)
|
||||
expect((await Snapshot.patch(before!)).files).toContain(`${tmp.path}/link.txt`)
|
||||
},
|
||||
})
|
||||
})
|
||||
|
|
@ -154,14 +152,14 @@ test("symlink handling", async () => {
|
|||
test("large file handling", async () => {
|
||||
await using tmp = await bootstrap()
|
||||
await Instance.provide({
|
||||
directory: tmp.dir,
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const before = await Snapshot.track()
|
||||
expect(before).toBeTruthy()
|
||||
|
||||
await Bun.write(`${tmp.dir}/large.txt`, "x".repeat(1024 * 1024))
|
||||
await Bun.write(`${tmp.path}/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.path}/large.txt`)
|
||||
},
|
||||
})
|
||||
})
|
||||
|
|
@ -169,17 +167,17 @@ test("large file handling", async () => {
|
|||
test("nested directory revert", async () => {
|
||||
await using tmp = await bootstrap()
|
||||
await Instance.provide({
|
||||
directory: tmp.dir,
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const before = await Snapshot.track()
|
||||
expect(before).toBeTruthy()
|
||||
|
||||
await $`mkdir -p ${tmp.dir}/level1/level2/level3`.quiet()
|
||||
await Bun.write(`${tmp.dir}/level1/level2/level3/deep.txt`, "DEEP")
|
||||
await $`mkdir -p ${tmp.path}/level1/level2/level3`.quiet()
|
||||
await Bun.write(`${tmp.path}/level1/level2/level3/deep.txt`, "DEEP")
|
||||
|
||||
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.path}/level1/level2/level3/deep.txt`).exists()).toBe(false)
|
||||
},
|
||||
})
|
||||
})
|
||||
|
|
@ -187,19 +185,19 @@ test("nested directory revert", async () => {
|
|||
test("special characters in filenames", async () => {
|
||||
await using tmp = await bootstrap()
|
||||
await Instance.provide({
|
||||
directory: tmp.dir,
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const before = await Snapshot.track()
|
||||
expect(before).toBeTruthy()
|
||||
|
||||
await Bun.write(`${tmp.dir}/file with spaces.txt`, "SPACES")
|
||||
await Bun.write(`${tmp.dir}/file-with-dashes.txt`, "DASHES")
|
||||
await Bun.write(`${tmp.dir}/file_with_underscores.txt`, "UNDERSCORES")
|
||||
await Bun.write(`${tmp.path}/file with spaces.txt`, "SPACES")
|
||||
await Bun.write(`${tmp.path}/file-with-dashes.txt`, "DASHES")
|
||||
await Bun.write(`${tmp.path}/file_with_underscores.txt`, "UNDERSCORES")
|
||||
|
||||
const files = (await Snapshot.patch(before!)).files
|
||||
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_underscores.txt`)
|
||||
expect(files).toContain(`${tmp.path}/file with spaces.txt`)
|
||||
expect(files).toContain(`${tmp.path}/file-with-dashes.txt`)
|
||||
expect(files).toContain(`${tmp.path}/file_with_underscores.txt`)
|
||||
},
|
||||
})
|
||||
})
|
||||
|
|
@ -207,7 +205,7 @@ test("special characters in filenames", async () => {
|
|||
test("revert with empty patches", async () => {
|
||||
await using tmp = await bootstrap()
|
||||
await Instance.provide({
|
||||
directory: tmp.dir,
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
// Should not crash with empty patches
|
||||
expect(Snapshot.revert([])).resolves.toBeUndefined()
|
||||
|
|
@ -221,13 +219,13 @@ test("revert with empty patches", async () => {
|
|||
test("patch with invalid hash", async () => {
|
||||
await using tmp = await bootstrap()
|
||||
await Instance.provide({
|
||||
directory: tmp.dir,
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const before = await Snapshot.track()
|
||||
expect(before).toBeTruthy()
|
||||
|
||||
// Create a change
|
||||
await Bun.write(`${tmp.dir}/test.txt`, "TEST")
|
||||
await Bun.write(`${tmp.path}/test.txt`, "TEST")
|
||||
|
||||
// Try to patch with invalid hash - should handle gracefully
|
||||
const patch = await Snapshot.patch("invalid-hash-12345")
|
||||
|
|
@ -240,7 +238,7 @@ test("patch with invalid hash", async () => {
|
|||
test("revert non-existent file", async () => {
|
||||
await using tmp = await bootstrap()
|
||||
await Instance.provide({
|
||||
directory: tmp.dir,
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const before = await Snapshot.track()
|
||||
expect(before).toBeTruthy()
|
||||
|
|
@ -251,7 +249,7 @@ test("revert non-existent file", async () => {
|
|||
Snapshot.revert([
|
||||
{
|
||||
hash: before!,
|
||||
files: [`${tmp.dir}/nonexistent.txt`],
|
||||
files: [`${tmp.path}/nonexistent.txt`],
|
||||
},
|
||||
]),
|
||||
).resolves.toBeUndefined()
|
||||
|
|
@ -262,16 +260,16 @@ test("revert non-existent file", async () => {
|
|||
test("unicode filenames", async () => {
|
||||
await using tmp = await bootstrap()
|
||||
await Instance.provide({
|
||||
directory: tmp.dir,
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const before = await Snapshot.track()
|
||||
expect(before).toBeTruthy()
|
||||
|
||||
const unicodeFiles = [
|
||||
`${tmp.dir}/文件.txt`,
|
||||
`${tmp.dir}/🚀rocket.txt`,
|
||||
`${tmp.dir}/café.txt`,
|
||||
`${tmp.dir}/файл.txt`,
|
||||
`${tmp.path}/文件.txt`,
|
||||
`${tmp.path}/🚀rocket.txt`,
|
||||
`${tmp.path}/café.txt`,
|
||||
`${tmp.path}/файл.txt`,
|
||||
]
|
||||
|
||||
for (const file of unicodeFiles) {
|
||||
|
|
@ -292,13 +290,13 @@ test("unicode filenames", async () => {
|
|||
test("very long filenames", async () => {
|
||||
await using tmp = await bootstrap()
|
||||
await Instance.provide({
|
||||
directory: tmp.dir,
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const before = await Snapshot.track()
|
||||
expect(before).toBeTruthy()
|
||||
|
||||
const longName = "a".repeat(200) + ".txt"
|
||||
const longFile = `${tmp.dir}/${longName}`
|
||||
const longFile = `${tmp.path}/${longName}`
|
||||
|
||||
await Bun.write(longFile, "long filename content")
|
||||
|
||||
|
|
@ -314,19 +312,19 @@ test("very long filenames", async () => {
|
|||
test("hidden files", async () => {
|
||||
await using tmp = await bootstrap()
|
||||
await Instance.provide({
|
||||
directory: tmp.dir,
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const before = await Snapshot.track()
|
||||
expect(before).toBeTruthy()
|
||||
|
||||
await Bun.write(`${tmp.dir}/.hidden`, "hidden content")
|
||||
await Bun.write(`${tmp.dir}/.gitignore`, "*.log")
|
||||
await Bun.write(`${tmp.dir}/.config`, "config content")
|
||||
await Bun.write(`${tmp.path}/.hidden`, "hidden content")
|
||||
await Bun.write(`${tmp.path}/.gitignore`, "*.log")
|
||||
await Bun.write(`${tmp.path}/.config`, "config content")
|
||||
|
||||
const patch = await Snapshot.patch(before!)
|
||||
expect(patch.files).toContain(`${tmp.dir}/.hidden`)
|
||||
expect(patch.files).toContain(`${tmp.dir}/.gitignore`)
|
||||
expect(patch.files).toContain(`${tmp.dir}/.config`)
|
||||
expect(patch.files).toContain(`${tmp.path}/.hidden`)
|
||||
expect(patch.files).toContain(`${tmp.path}/.gitignore`)
|
||||
expect(patch.files).toContain(`${tmp.path}/.config`)
|
||||
},
|
||||
})
|
||||
})
|
||||
|
|
@ -334,19 +332,19 @@ test("hidden files", async () => {
|
|||
test("nested symlinks", async () => {
|
||||
await using tmp = await bootstrap()
|
||||
await Instance.provide({
|
||||
directory: tmp.dir,
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const before = await Snapshot.track()
|
||||
expect(before).toBeTruthy()
|
||||
|
||||
await $`mkdir -p ${tmp.dir}/sub/dir`.quiet()
|
||||
await Bun.write(`${tmp.dir}/sub/dir/target.txt`, "target content")
|
||||
await $`ln -s ${tmp.dir}/sub/dir/target.txt ${tmp.dir}/sub/dir/link.txt`.quiet()
|
||||
await $`ln -s ${tmp.dir}/sub ${tmp.dir}/sub-link`.quiet()
|
||||
await $`mkdir -p ${tmp.path}/sub/dir`.quiet()
|
||||
await Bun.write(`${tmp.path}/sub/dir/target.txt`, "target content")
|
||||
await $`ln -s ${tmp.path}/sub/dir/target.txt ${tmp.path}/sub/dir/link.txt`.quiet()
|
||||
await $`ln -s ${tmp.path}/sub ${tmp.path}/sub-link`.quiet()
|
||||
|
||||
const patch = await Snapshot.patch(before!)
|
||||
expect(patch.files).toContain(`${tmp.dir}/sub/dir/link.txt`)
|
||||
expect(patch.files).toContain(`${tmp.dir}/sub-link`)
|
||||
expect(patch.files).toContain(`${tmp.path}/sub/dir/link.txt`)
|
||||
expect(patch.files).toContain(`${tmp.path}/sub-link`)
|
||||
},
|
||||
})
|
||||
})
|
||||
|
|
@ -354,15 +352,15 @@ test("nested symlinks", async () => {
|
|||
test("file permissions and ownership changes", async () => {
|
||||
await using tmp = await bootstrap()
|
||||
await Instance.provide({
|
||||
directory: tmp.dir,
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const before = await Snapshot.track()
|
||||
expect(before).toBeTruthy()
|
||||
|
||||
// Change permissions multiple times
|
||||
await $`chmod 600 ${tmp.dir}/a.txt`.quiet()
|
||||
await $`chmod 755 ${tmp.dir}/a.txt`.quiet()
|
||||
await $`chmod 644 ${tmp.dir}/a.txt`.quiet()
|
||||
await $`chmod 600 ${tmp.path}/a.txt`.quiet()
|
||||
await $`chmod 755 ${tmp.path}/a.txt`.quiet()
|
||||
await $`chmod 644 ${tmp.path}/a.txt`.quiet()
|
||||
|
||||
const patch = await Snapshot.patch(before!)
|
||||
// Note: git doesn't track permission changes on existing files by default
|
||||
|
|
@ -375,13 +373,13 @@ test("file permissions and ownership changes", async () => {
|
|||
test("circular symlinks", async () => {
|
||||
await using tmp = await bootstrap()
|
||||
await Instance.provide({
|
||||
directory: tmp.dir,
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const before = await Snapshot.track()
|
||||
expect(before).toBeTruthy()
|
||||
|
||||
// Create circular symlink
|
||||
await $`ln -s ${tmp.dir}/circular ${tmp.dir}/circular`.quiet().nothrow()
|
||||
await $`ln -s ${tmp.path}/circular ${tmp.path}/circular`.quiet().nothrow()
|
||||
|
||||
const patch = await Snapshot.patch(before!)
|
||||
expect(patch.files.length).toBeGreaterThanOrEqual(0) // Should not crash
|
||||
|
|
@ -392,23 +390,23 @@ test("circular symlinks", async () => {
|
|||
test("gitignore changes", async () => {
|
||||
await using tmp = await bootstrap()
|
||||
await Instance.provide({
|
||||
directory: tmp.dir,
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const before = await Snapshot.track()
|
||||
expect(before).toBeTruthy()
|
||||
|
||||
await Bun.write(`${tmp.dir}/.gitignore`, "*.ignored")
|
||||
await Bun.write(`${tmp.dir}/test.ignored`, "ignored content")
|
||||
await Bun.write(`${tmp.dir}/normal.txt`, "normal content")
|
||||
await Bun.write(`${tmp.path}/.gitignore`, "*.ignored")
|
||||
await Bun.write(`${tmp.path}/test.ignored`, "ignored content")
|
||||
await Bun.write(`${tmp.path}/normal.txt`, "normal content")
|
||||
|
||||
const patch = await Snapshot.patch(before!)
|
||||
|
||||
// Should track gitignore itself
|
||||
expect(patch.files).toContain(`${tmp.dir}/.gitignore`)
|
||||
expect(patch.files).toContain(`${tmp.path}/.gitignore`)
|
||||
// Should track normal files
|
||||
expect(patch.files).toContain(`${tmp.dir}/normal.txt`)
|
||||
expect(patch.files).toContain(`${tmp.path}/normal.txt`)
|
||||
// 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.path}/test.ignored`)
|
||||
},
|
||||
})
|
||||
})
|
||||
|
|
@ -416,7 +414,7 @@ test("gitignore changes", async () => {
|
|||
test("concurrent file operations during patch", async () => {
|
||||
await using tmp = await bootstrap()
|
||||
await Instance.provide({
|
||||
directory: tmp.dir,
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const before = await Snapshot.track()
|
||||
expect(before).toBeTruthy()
|
||||
|
|
@ -424,7 +422,7 @@ test("concurrent file operations during patch", async () => {
|
|||
// Start creating files
|
||||
const createPromise = (async () => {
|
||||
for (let i = 0; i < 10; i++) {
|
||||
await Bun.write(`${tmp.dir}/concurrent${i}.txt`, `concurrent${i}`)
|
||||
await Bun.write(`${tmp.path}/concurrent${i}.txt`, `concurrent${i}`)
|
||||
// Small delay to simulate concurrent operations
|
||||
await new Promise((resolve) => setTimeout(resolve, 1))
|
||||
}
|
||||
|
|
@ -448,25 +446,25 @@ test("snapshot state isolation between projects", async () => {
|
|||
await using tmp2 = await bootstrap()
|
||||
|
||||
await Instance.provide({
|
||||
directory: tmp1.dir,
|
||||
directory: tmp1.path,
|
||||
fn: async () => {
|
||||
const before1 = await Snapshot.track()
|
||||
await Bun.write(`${tmp1.dir}/project1.txt`, "project1 content")
|
||||
await Bun.write(`${tmp1.path}/project1.txt`, "project1 content")
|
||||
const patch1 = await Snapshot.patch(before1!)
|
||||
expect(patch1.files).toContain(`${tmp1.dir}/project1.txt`)
|
||||
expect(patch1.files).toContain(`${tmp1.path}/project1.txt`)
|
||||
},
|
||||
})
|
||||
|
||||
await Instance.provide({
|
||||
directory: tmp2.dir,
|
||||
directory: tmp2.path,
|
||||
fn: async () => {
|
||||
const before2 = await Snapshot.track()
|
||||
await Bun.write(`${tmp2.dir}/project2.txt`, "project2 content")
|
||||
await Bun.write(`${tmp2.path}/project2.txt`, "project2 content")
|
||||
const patch2 = await Snapshot.patch(before2!)
|
||||
expect(patch2.files).toContain(`${tmp2.dir}/project2.txt`)
|
||||
expect(patch2.files).toContain(`${tmp2.path}/project2.txt`)
|
||||
|
||||
// Ensure project1 files don't appear in project2
|
||||
expect(patch2.files).not.toContain(`${tmp1?.dir}/project1.txt`)
|
||||
expect(patch2.files).not.toContain(`${tmp1?.path}/project1.txt`)
|
||||
},
|
||||
})
|
||||
})
|
||||
|
|
@ -474,7 +472,7 @@ test("snapshot state isolation between projects", async () => {
|
|||
test("track with no changes returns same hash", async () => {
|
||||
await using tmp = await bootstrap()
|
||||
await Instance.provide({
|
||||
directory: tmp.dir,
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const hash1 = await Snapshot.track()
|
||||
expect(hash1).toBeTruthy()
|
||||
|
|
@ -493,15 +491,15 @@ test("track with no changes returns same hash", async () => {
|
|||
test("diff function with various changes", async () => {
|
||||
await using tmp = await bootstrap()
|
||||
await Instance.provide({
|
||||
directory: tmp.dir,
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const before = await Snapshot.track()
|
||||
expect(before).toBeTruthy()
|
||||
|
||||
// Make various changes
|
||||
await $`rm ${tmp.dir}/a.txt`.quiet()
|
||||
await Bun.write(`${tmp.dir}/new.txt`, "new content")
|
||||
await Bun.write(`${tmp.dir}/b.txt`, "modified content")
|
||||
await $`rm ${tmp.path}/a.txt`.quiet()
|
||||
await Bun.write(`${tmp.path}/new.txt`, "new content")
|
||||
await Bun.write(`${tmp.path}/b.txt`, "modified content")
|
||||
|
||||
const diff = await Snapshot.diff(before!)
|
||||
expect(diff).toContain("deleted")
|
||||
|
|
@ -514,23 +512,23 @@ test("diff function with various changes", async () => {
|
|||
test("restore function", async () => {
|
||||
await using tmp = await bootstrap()
|
||||
await Instance.provide({
|
||||
directory: tmp.dir,
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const before = await Snapshot.track()
|
||||
expect(before).toBeTruthy()
|
||||
|
||||
// Make changes
|
||||
await $`rm ${tmp.dir}/a.txt`.quiet()
|
||||
await Bun.write(`${tmp.dir}/new.txt`, "new content")
|
||||
await Bun.write(`${tmp.dir}/b.txt`, "modified")
|
||||
await $`rm ${tmp.path}/a.txt`.quiet()
|
||||
await Bun.write(`${tmp.path}/new.txt`, "new content")
|
||||
await Bun.write(`${tmp.path}/b.txt`, "modified")
|
||||
|
||||
// Restore to original state
|
||||
await Snapshot.restore(before!)
|
||||
|
||||
expect(await Bun.file(`${tmp.dir}/a.txt`).exists()).toBe(true)
|
||||
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}/b.txt`).text()).toBe(tmp.bContent)
|
||||
expect(await Bun.file(`${tmp.path}/a.txt`).exists()).toBe(true)
|
||||
expect(await Bun.file(`${tmp.path}/a.txt`).text()).toBe(tmp.extra.aContent)
|
||||
expect(await Bun.file(`${tmp.path}/new.txt`).exists()).toBe(true) // New files should remain
|
||||
expect(await Bun.file(`${tmp.path}/b.txt`).text()).toBe(tmp.extra.bContent)
|
||||
},
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -1,424 +0,0 @@
|
|||
import { describe, expect, test } from "bun:test"
|
||||
import { replace } from "../../src/tool/edit"
|
||||
|
||||
interface TestCase {
|
||||
content: string
|
||||
find: string
|
||||
replace: string
|
||||
all?: boolean
|
||||
fail?: boolean
|
||||
}
|
||||
|
||||
const testCases: TestCase[] = [
|
||||
// SimpleReplacer cases
|
||||
{
|
||||
content: ["function hello() {", ' console.log("world");', "}"].join("\n"),
|
||||
find: 'console.log("world");',
|
||||
replace: 'console.log("universe");',
|
||||
},
|
||||
{
|
||||
content: ["if (condition) {", " doSomething();", " doSomethingElse();", "}"].join("\n"),
|
||||
find: [" doSomething();", " doSomethingElse();"].join("\n"),
|
||||
replace: [" doNewThing();", " doAnotherThing();"].join("\n"),
|
||||
},
|
||||
|
||||
// LineTrimmedReplacer cases
|
||||
{
|
||||
content: ["function test() {", ' console.log("hello");', "}"].join("\n"),
|
||||
find: 'console.log("hello");',
|
||||
replace: 'console.log("goodbye");',
|
||||
},
|
||||
{
|
||||
content: ["const x = 5; ", "const y = 10;"].join("\n"),
|
||||
find: "const x = 5;",
|
||||
replace: "const x = 15;",
|
||||
},
|
||||
{
|
||||
content: [" if (true) {", " return false;", " }"].join("\n"),
|
||||
find: ["if (true) {", "return false;", "}"].join("\n"),
|
||||
replace: ["if (false) {", "return true;", "}"].join("\n"),
|
||||
},
|
||||
|
||||
// BlockAnchorReplacer cases
|
||||
{
|
||||
content: [
|
||||
"function calculate(a, b) {",
|
||||
" const temp = a + b;",
|
||||
" const result = temp * 2;",
|
||||
" return result;",
|
||||
"}",
|
||||
].join("\n"),
|
||||
find: ["function calculate(a, b) {", " // different middle content", " return result;", "}"].join("\n"),
|
||||
replace: ["function calculate(a, b) {", " return a * b * 2;", "}"].join("\n"),
|
||||
},
|
||||
{
|
||||
content: [
|
||||
"class MyClass {",
|
||||
" constructor() {",
|
||||
" this.value = 0;",
|
||||
" }",
|
||||
" ",
|
||||
" getValue() {",
|
||||
" return this.value;",
|
||||
" }",
|
||||
"}",
|
||||
].join("\n"),
|
||||
find: ["class MyClass {", " // different implementation", "}"].join("\n"),
|
||||
replace: ["class MyClass {", " constructor() {", " this.value = 42;", " }", "}"].join("\n"),
|
||||
},
|
||||
|
||||
// WhitespaceNormalizedReplacer cases
|
||||
{
|
||||
content: ["function test() {", '\tconsole.log("hello");', "}"].join("\n"),
|
||||
find: ' console.log("hello");',
|
||||
replace: ' console.log("world");',
|
||||
},
|
||||
{
|
||||
content: "const x = 5;",
|
||||
find: "const x = 5;",
|
||||
replace: "const x = 10;",
|
||||
},
|
||||
{
|
||||
content: "if\t( condition\t) {",
|
||||
find: "if ( condition ) {",
|
||||
replace: "if (newCondition) {",
|
||||
},
|
||||
|
||||
// IndentationFlexibleReplacer cases
|
||||
{
|
||||
content: [" function nested() {", ' console.log("deeply nested");', " return true;", " }"].join(
|
||||
"\n",
|
||||
),
|
||||
find: ["function nested() {", ' console.log("deeply nested");', " return true;", "}"].join("\n"),
|
||||
replace: ["function nested() {", ' console.log("updated");', " return false;", "}"].join("\n"),
|
||||
},
|
||||
{
|
||||
content: [" if (true) {", ' console.log("level 1");', ' console.log("level 2");', " }"].join("\n"),
|
||||
find: ["if (true) {", 'console.log("level 1");', ' console.log("level 2");', "}"].join("\n"),
|
||||
replace: ["if (true) {", 'console.log("updated");', "}"].join("\n"),
|
||||
},
|
||||
|
||||
// replaceAll option cases
|
||||
{
|
||||
content: ['console.log("test");', 'console.log("test");', 'console.log("test");'].join("\n"),
|
||||
find: 'console.log("test");',
|
||||
replace: 'console.log("updated");',
|
||||
all: true,
|
||||
},
|
||||
{
|
||||
content: ['console.log("test");', 'console.log("test");'].join("\n"),
|
||||
find: 'console.log("test");',
|
||||
replace: 'console.log("updated");',
|
||||
all: false,
|
||||
},
|
||||
|
||||
// Error cases
|
||||
{
|
||||
content: 'console.log("hello");',
|
||||
find: "nonexistent string",
|
||||
replace: "updated",
|
||||
fail: true,
|
||||
},
|
||||
{
|
||||
content: ["test", "test", "different content", "test"].join("\n"),
|
||||
find: "test",
|
||||
replace: "updated",
|
||||
all: false,
|
||||
fail: true,
|
||||
},
|
||||
|
||||
// Edge cases
|
||||
{
|
||||
content: "",
|
||||
find: "",
|
||||
replace: "new content",
|
||||
},
|
||||
{
|
||||
content: "const regex = /[.*+?^${}()|[\\\\]\\\\\\\\]/g;",
|
||||
find: "/[.*+?^${}()|[\\\\]\\\\\\\\]/g",
|
||||
replace: "/\\\\w+/g",
|
||||
},
|
||||
{
|
||||
content: 'const message = "Hello 世界! 🌍";',
|
||||
find: "Hello 世界! 🌍",
|
||||
replace: "Hello World! 🌎",
|
||||
},
|
||||
|
||||
// EscapeNormalizedReplacer cases
|
||||
{
|
||||
content: 'console.log("Hello\nWorld");',
|
||||
find: 'console.log("Hello\\nWorld");',
|
||||
replace: 'console.log("Hello\nUniverse");',
|
||||
},
|
||||
{
|
||||
content: "const str = 'It's working';",
|
||||
find: "const str = 'It\\'s working';",
|
||||
replace: "const str = 'It's fixed';",
|
||||
},
|
||||
{
|
||||
content: "const template = `Hello ${name}`;",
|
||||
find: "const template = `Hello \\${name}`;",
|
||||
replace: "const template = `Hi ${name}`;",
|
||||
},
|
||||
{
|
||||
content: "const path = 'C:\\Users\\test';",
|
||||
find: "const path = 'C:\\\\Users\\\\test';",
|
||||
replace: "const path = 'C:\\Users\\admin';",
|
||||
},
|
||||
|
||||
// MultiOccurrenceReplacer cases (with replaceAll)
|
||||
{
|
||||
content: ["debug('start');", "debug('middle');", "debug('end');"].join("\n"),
|
||||
find: "debug",
|
||||
replace: "log",
|
||||
all: true,
|
||||
},
|
||||
{
|
||||
content: "const x = 1; const y = 1; const z = 1;",
|
||||
find: "1",
|
||||
replace: "2",
|
||||
all: true,
|
||||
},
|
||||
|
||||
// TrimmedBoundaryReplacer cases
|
||||
{
|
||||
content: [" function test() {", " return true;", " }"].join("\n"),
|
||||
find: ["function test() {", " return true;", "}"].join("\n"),
|
||||
replace: ["function test() {", " return false;", "}"].join("\n"),
|
||||
},
|
||||
{
|
||||
content: "\n const value = 42; \n",
|
||||
find: "const value = 42;",
|
||||
replace: "const value = 24;",
|
||||
},
|
||||
{
|
||||
content: ["", " if (condition) {", " doSomething();", " }", ""].join("\n"),
|
||||
find: ["if (condition) {", " doSomething();", "}"].join("\n"),
|
||||
replace: ["if (condition) {", " doNothing();", "}"].join("\n"),
|
||||
},
|
||||
|
||||
// ContextAwareReplacer cases
|
||||
{
|
||||
content: [
|
||||
"function calculate(a, b) {",
|
||||
" const temp = a + b;",
|
||||
" const result = temp * 2;",
|
||||
" return result;",
|
||||
"}",
|
||||
].join("\n"),
|
||||
find: [
|
||||
"function calculate(a, b) {",
|
||||
" // some different content here",
|
||||
" // more different content",
|
||||
" return result;",
|
||||
"}",
|
||||
].join("\n"),
|
||||
replace: ["function calculate(a, b) {", " return (a + b) * 2;", "}"].join("\n"),
|
||||
},
|
||||
{
|
||||
content: [
|
||||
"class TestClass {",
|
||||
" constructor() {",
|
||||
" this.value = 0;",
|
||||
" }",
|
||||
" ",
|
||||
" method() {",
|
||||
" return this.value;",
|
||||
" }",
|
||||
"}",
|
||||
].join("\n"),
|
||||
find: ["class TestClass {", " // different implementation", " // with multiple lines", "}"].join("\n"),
|
||||
replace: ["class TestClass {", " getValue() { return 42; }", "}"].join("\n"),
|
||||
},
|
||||
|
||||
// Combined edge cases for new replacers
|
||||
{
|
||||
content: '\tconsole.log("test");\t',
|
||||
find: 'console.log("test");',
|
||||
replace: 'console.log("updated");',
|
||||
},
|
||||
{
|
||||
content: [" ", "function test() {", " return 'value';", "}", " "].join("\n"),
|
||||
find: ["function test() {", "return 'value';", "}"].join("\n"),
|
||||
replace: ["function test() {", "return 'new value';", "}"].join("\n"),
|
||||
},
|
||||
|
||||
// Test for same oldString and newString (should fail)
|
||||
{
|
||||
content: 'console.log("test");',
|
||||
find: 'console.log("test");',
|
||||
replace: 'console.log("test");',
|
||||
fail: true,
|
||||
},
|
||||
|
||||
// Additional tests for fixes made
|
||||
|
||||
// WhitespaceNormalizedReplacer - test regex special characters that could cause errors
|
||||
{
|
||||
content: 'const pattern = "test[123]";',
|
||||
find: "test[123]",
|
||||
replace: "test[456]",
|
||||
},
|
||||
{
|
||||
content: 'const regex = "^start.*end$";',
|
||||
find: "^start.*end$",
|
||||
replace: "^begin.*finish$",
|
||||
},
|
||||
|
||||
// EscapeNormalizedReplacer - test single backslash vs double backslash
|
||||
{
|
||||
content: 'const path = "C:\\Users";',
|
||||
find: 'const path = "C:\\Users";',
|
||||
replace: 'const path = "D:\\Users";',
|
||||
},
|
||||
{
|
||||
content: 'console.log("Line1\\nLine2");',
|
||||
find: 'console.log("Line1\\nLine2");',
|
||||
replace: 'console.log("First\\nSecond");',
|
||||
},
|
||||
|
||||
// BlockAnchorReplacer - test edge case with exact newline boundaries
|
||||
{
|
||||
content: ["function test() {", " return true;", "}"].join("\n"),
|
||||
find: ["function test() {", " // middle", "}"].join("\n"),
|
||||
replace: ["function test() {", " return false;", "}"].join("\n"),
|
||||
},
|
||||
|
||||
// ContextAwareReplacer - test with trailing newline in find string
|
||||
{
|
||||
content: ["class Test {", " method1() {", " return 1;", " }", "}"].join("\n"),
|
||||
find: [
|
||||
"class Test {",
|
||||
" // different content",
|
||||
"}",
|
||||
"", // trailing empty line
|
||||
].join("\n"),
|
||||
replace: ["class Test {", " method2() { return 2; }", "}"].join("\n"),
|
||||
},
|
||||
|
||||
// Test validation for empty strings with same oldString and newString
|
||||
{
|
||||
content: "",
|
||||
find: "",
|
||||
replace: "",
|
||||
fail: true,
|
||||
},
|
||||
|
||||
// Test multiple occurrences with replaceAll=false (should fail)
|
||||
{
|
||||
content: ["const a = 1;", "const b = 1;", "const c = 1;"].join("\n"),
|
||||
find: "= 1",
|
||||
replace: "= 2",
|
||||
all: false,
|
||||
fail: true,
|
||||
},
|
||||
|
||||
// Test whitespace normalization with multiple spaces and tabs mixed
|
||||
{
|
||||
content: "if\t \t( \tcondition\t )\t{",
|
||||
find: "if ( condition ) {",
|
||||
replace: "if (newCondition) {",
|
||||
},
|
||||
|
||||
// Test escape sequences in template literals
|
||||
{
|
||||
content: "const msg = `Hello\\tWorld`;",
|
||||
find: "const msg = `Hello\\tWorld`;",
|
||||
replace: "const msg = `Hi\\tWorld`;",
|
||||
},
|
||||
|
||||
// Test case that reproduces the greedy matching bug - now should fail due to low similarity
|
||||
{
|
||||
content: [
|
||||
"func main() {",
|
||||
" if condition {",
|
||||
" doSomething()",
|
||||
" }",
|
||||
" processData()",
|
||||
" if anotherCondition {",
|
||||
" doOtherThing()",
|
||||
" }",
|
||||
" return mainLayout",
|
||||
"}",
|
||||
"",
|
||||
"func helper() {",
|
||||
" }",
|
||||
" return mainLayout", // This should NOT be matched due to low similarity
|
||||
"}",
|
||||
].join("\n"),
|
||||
find: [" }", " return mainLayout"].join("\n"),
|
||||
replace: [" }", " // Add some code here", " return mainLayout"].join("\n"),
|
||||
fail: true, // This should fail because the pattern has low similarity score
|
||||
},
|
||||
|
||||
// Test case for the fix - more specific pattern should work
|
||||
{
|
||||
content: [
|
||||
"function renderLayout() {",
|
||||
" const header = createHeader()",
|
||||
" const body = createBody()",
|
||||
" return mainLayout",
|
||||
"}",
|
||||
].join("\n"),
|
||||
find: ["function renderLayout() {", " // different content", " return mainLayout", "}"].join("\n"),
|
||||
replace: [
|
||||
"function renderLayout() {",
|
||||
" const header = createHeader()",
|
||||
" const body = createBody()",
|
||||
" // Add minimap overlay",
|
||||
" return mainLayout",
|
||||
"}",
|
||||
].join("\n"),
|
||||
},
|
||||
|
||||
// Test that large blocks without arbitrary size limits can work
|
||||
{
|
||||
content: Array.from({ length: 100 }, (_, i) => `line ${i}`).join("\n"),
|
||||
find: Array.from({ length: 50 }, (_, i) => `line ${i + 25}`).join("\n"),
|
||||
replace: Array.from({ length: 50 }, (_, i) => `updated line ${i + 25}`).join("\n"),
|
||||
},
|
||||
|
||||
// Test case for the fix - more specific pattern should work
|
||||
{
|
||||
content: [
|
||||
"function renderLayout() {",
|
||||
" const header = createHeader()",
|
||||
" const body = createBody()",
|
||||
" return mainLayout",
|
||||
"}",
|
||||
].join("\n"),
|
||||
find: ["function renderLayout() {", " // different content", " return mainLayout", "}"].join("\n"),
|
||||
replace: [
|
||||
"function renderLayout() {",
|
||||
" const header = createHeader()",
|
||||
" const body = createBody()",
|
||||
" // Add minimap overlay",
|
||||
" return mainLayout",
|
||||
"}",
|
||||
].join("\n"),
|
||||
},
|
||||
|
||||
// Test BlockAnchorReplacer with overly large blocks (should fail)
|
||||
{
|
||||
content:
|
||||
Array.from({ length: 100 }, (_, i) => `line ${i}`).join("\n") +
|
||||
"\nfunction test() {\n" +
|
||||
Array.from({ length: 60 }, (_, i) => ` content ${i}`).join("\n") +
|
||||
"\n return result\n}",
|
||||
find: ["function test() {", " // different content", " return result", "}"].join("\n"),
|
||||
replace: ["function test() {", " return 42", "}"].join("\n"),
|
||||
},
|
||||
]
|
||||
|
||||
describe("EditTool Replacers", () => {
|
||||
test.each(testCases)("case %#", (testCase) => {
|
||||
if (testCase.fail) {
|
||||
expect(() => {
|
||||
replace(testCase.content, testCase.find, testCase.replace, testCase.all)
|
||||
}).toThrow()
|
||||
} else {
|
||||
const result = replace(testCase.content, testCase.find, testCase.replace, testCase.all)
|
||||
expect(result).toContain(testCase.replace)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
|
@ -1,70 +0,0 @@
|
|||
import { describe, expect, test } from "bun:test"
|
||||
import { GlobTool } from "../../src/tool/glob"
|
||||
import { ListTool } from "../../src/tool/ls"
|
||||
import path from "path"
|
||||
import { Instance } from "../../src/project/instance"
|
||||
|
||||
const ctx = {
|
||||
sessionID: "test",
|
||||
messageID: "",
|
||||
toolCallID: "",
|
||||
agent: "build",
|
||||
abort: AbortSignal.any([]),
|
||||
metadata: () => {},
|
||||
}
|
||||
const glob = await GlobTool.init()
|
||||
const list = await ListTool.init()
|
||||
|
||||
const projectRoot = path.join(__dirname, "../..")
|
||||
const fixturePath = path.join(__dirname, "../fixtures/example")
|
||||
|
||||
describe("tool.glob", () => {
|
||||
test("truncate", async () => {
|
||||
await Instance.provide({
|
||||
directory: projectRoot,
|
||||
fn: async () => {
|
||||
let result = await glob.execute(
|
||||
{
|
||||
pattern: "**/*",
|
||||
path: "../../node_modules",
|
||||
},
|
||||
ctx,
|
||||
)
|
||||
expect(result.metadata.truncated).toBe(true)
|
||||
},
|
||||
})
|
||||
})
|
||||
test("basic", async () => {
|
||||
await Instance.provide({
|
||||
directory: projectRoot,
|
||||
fn: async () => {
|
||||
let result = await glob.execute(
|
||||
{
|
||||
pattern: "*.json",
|
||||
path: undefined,
|
||||
},
|
||||
ctx,
|
||||
)
|
||||
expect(result.metadata).toMatchObject({
|
||||
truncated: false,
|
||||
count: 2,
|
||||
})
|
||||
},
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe("tool.ls", () => {
|
||||
test("basic", async () => {
|
||||
const result = await Instance.provide({
|
||||
directory: projectRoot,
|
||||
fn: async () => {
|
||||
return await list.execute({ path: fixturePath, ignore: [".git"] }, ctx)
|
||||
},
|
||||
})
|
||||
|
||||
// Normalize absolute path to relative for consistent snapshots
|
||||
const normalizedOutput = result.output.replace(fixturePath, "packages/opencode/test/fixtures/example")
|
||||
expect(normalizedOutput).toMatchSnapshot()
|
||||
})
|
||||
})
|
||||
|
|
@ -5,7 +5,7 @@
|
|||
"jsx": "preserve",
|
||||
"jsxImportSource": "@opentui/solid",
|
||||
"lib": ["ESNext", "DOM", "DOM.Iterable"],
|
||||
"customConditions": ["development", "browser"],
|
||||
"customConditions": ["browser"],
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["./src/*"],
|
||||
|
|
|
|||
|
|
@ -1,21 +1,15 @@
|
|||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@opencode-ai/plugin",
|
||||
"version": "0.11.6",
|
||||
"version": "0.12.1",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"typecheck": "tsc --noEmit",
|
||||
"build": "tsc"
|
||||
},
|
||||
"exports": {
|
||||
".": {
|
||||
"development": "./src/index.ts",
|
||||
"import": "./dist/index.js"
|
||||
},
|
||||
"./tool": {
|
||||
"development": "./src/tool.ts",
|
||||
"import": "./dist/tool.js"
|
||||
}
|
||||
".": "./src/index.ts",
|
||||
"./tool": "./src/tool.ts"
|
||||
},
|
||||
"files": [
|
||||
"dist"
|
||||
|
|
|
|||
|
|
@ -5,14 +5,24 @@ process.chdir(dir)
|
|||
|
||||
import { $ } from "bun"
|
||||
|
||||
const snapshot = process.env["OPENCODE_SNAPSHOT"] === "true"
|
||||
|
||||
await $`bun tsc`
|
||||
|
||||
const pkg = await import("../package.json")
|
||||
for (const [key, value] of Object.entries(pkg.exports)) {
|
||||
const file = value.replace("./src/", "./").replace(".ts", "")
|
||||
// @ts-expect-error
|
||||
pkg.exports[key] = {
|
||||
import: file + ".js",
|
||||
types: file + ".d.ts",
|
||||
}
|
||||
}
|
||||
await Bun.write("./dist/package.json", JSON.stringify(pkg, null, 2))
|
||||
|
||||
const snapshot = process.env["OPENCODE_SNAPSHOT"] === "true"
|
||||
|
||||
if (snapshot) {
|
||||
await $`bun publish --tag snapshot --access public`
|
||||
await $`git checkout package.json`
|
||||
await $`bun publish --tag snapshot --access public`.cwd("./dist")
|
||||
}
|
||||
if (!snapshot) {
|
||||
await $`bun publish --access public`
|
||||
await $`bun publish --access public`.cwd("./dist")
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,8 +6,7 @@
|
|||
"module": "preserve",
|
||||
"declaration": true,
|
||||
"moduleResolution": "bundler",
|
||||
"lib": ["es2022", "dom", "dom.iterable"],
|
||||
"customConditions": ["development"]
|
||||
"lib": ["es2022", "dom", "dom.iterable"]
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,28 +1,16 @@
|
|||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@opencode-ai/sdk",
|
||||
"version": "0.11.6",
|
||||
"version": "0.12.1",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"typecheck": "tsc --noEmit",
|
||||
"build": "tsc"
|
||||
"build": "./script/build.ts"
|
||||
},
|
||||
"exports": {
|
||||
".": {
|
||||
"development": "./src/index.ts",
|
||||
"import": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts"
|
||||
},
|
||||
"./client": {
|
||||
"development": "./src/client.ts",
|
||||
"import": "./dist/client.js",
|
||||
"types": "./dist/client.d.ts"
|
||||
},
|
||||
"./server": {
|
||||
"development": "./src/server.ts",
|
||||
"import": "./dist/server.js",
|
||||
"types": "./dist/server.d.ts"
|
||||
}
|
||||
".": "./src/index.ts",
|
||||
"./client": "./src/client.ts",
|
||||
"./server": "./src/server.ts"
|
||||
},
|
||||
"files": [
|
||||
"dist"
|
||||
|
|
|
|||
|
|
@ -37,3 +37,5 @@ await createClient({
|
|||
],
|
||||
})
|
||||
await $`bun prettier --write src/gen`
|
||||
await $`rm -rf dist`
|
||||
await $`bun tsc`
|
||||
18
packages/sdk/js/script/publish.ts
Normal file → Executable file
18
packages/sdk/js/script/publish.ts
Normal file → Executable file
|
|
@ -5,15 +5,23 @@ process.chdir(dir)
|
|||
|
||||
import { $ } from "bun"
|
||||
|
||||
await import("./generate")
|
||||
await $`rm -rf dist`
|
||||
await $`bun tsc`
|
||||
await import("./build")
|
||||
|
||||
const pkg = await import("../package.json")
|
||||
for (const [key, value] of Object.entries(pkg.exports)) {
|
||||
const file = value.replace("./src/", "./").replace(".ts", "")
|
||||
// @ts-expect-error
|
||||
pkg.exports[key] = {
|
||||
import: file + ".js",
|
||||
types: file + ".d.ts",
|
||||
}
|
||||
}
|
||||
await Bun.write("./dist/package.json", JSON.stringify(pkg, null, 2))
|
||||
const snapshot = process.env["OPENCODE_SNAPSHOT"] === "true"
|
||||
|
||||
if (snapshot) {
|
||||
await $`bun publish --tag snapshot`
|
||||
await $`bun publish --tag snapshot`.cwd("./dist")
|
||||
}
|
||||
if (!snapshot) {
|
||||
await $`bun publish`
|
||||
await $`bun publish`.cwd("./dist")
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,8 +6,7 @@
|
|||
"module": "nodenext",
|
||||
"declaration": true,
|
||||
"moduleResolution": "nodenext",
|
||||
"lib": ["es2022", "dom", "dom.iterable"],
|
||||
"customConditions": ["development"]
|
||||
"lib": ["es2022", "dom", "dom.iterable"]
|
||||
},
|
||||
"include": ["src"],
|
||||
"exclude": ["src/gen"]
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ console.log("=== Generating Stainless SDK ===")
|
|||
console.log(process.cwd())
|
||||
|
||||
await $`rm -rf go`
|
||||
await $`bun run --conditions=development ../../opencode/src/index.ts generate > openapi.json`
|
||||
await $`bun run ../../opencode/src/index.ts generate > openapi.json`
|
||||
await $`stl builds create --branch main --pull --allow-empty --+target go`
|
||||
|
||||
await $`rm -rf ../go`
|
||||
|
|
|
|||
|
|
@ -991,9 +991,9 @@ func (a Model) home() (string, int, int) {
|
|||
)
|
||||
|
||||
// Use limit of 4 for vscode, 6 for others
|
||||
limit := 6
|
||||
limit := 5
|
||||
if util.IsVSCode() {
|
||||
limit = 4
|
||||
limit = 3
|
||||
}
|
||||
|
||||
showVscode := util.IsVSCode()
|
||||
|
|
@ -1043,8 +1043,10 @@ func (a Model) home() (string, int, int) {
|
|||
|
||||
editorX := max(0, (effectiveWidth-editorWidth)/2)
|
||||
editorY := (a.height / 2) + (mainHeight / 2) - 3
|
||||
editorYDelta := 3
|
||||
|
||||
if editorLines > 1 {
|
||||
editorYDelta = 2
|
||||
content := a.editor.Content()
|
||||
editorHeight := lipgloss.Height(content)
|
||||
|
||||
|
|
@ -1073,7 +1075,7 @@ func (a Model) home() (string, int, int) {
|
|||
)
|
||||
}
|
||||
|
||||
return mainLayout, editorX + 5, editorY + 3
|
||||
return mainLayout, editorX + 5, editorY + editorYDelta
|
||||
}
|
||||
|
||||
func (a Model) chat() (string, int, int) {
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
{
|
||||
"name": "@opencode/web",
|
||||
"type": "module",
|
||||
"version": "0.11.6",
|
||||
"version": "0.12.1",
|
||||
"scripts": {
|
||||
"dev": "astro dev",
|
||||
"dev:remote": "VITE_API_URL=https://api.opencode.ai astro dev",
|
||||
|
|
|
|||
|
|
@ -204,6 +204,23 @@ opencode will automatically download any new updates when it starts up. You can
|
|||
|
||||
---
|
||||
|
||||
### TUI
|
||||
|
||||
You can configure TUI-specific settings through the `tui` option.
|
||||
|
||||
```json title="opencode.json"
|
||||
{
|
||||
"$schema": "https://opencode.ai/config.json",
|
||||
"tui": {
|
||||
"scroll_speed": 3
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
[Learn more about using the TUI here](/docs/tui).
|
||||
|
||||
---
|
||||
|
||||
### Formatters
|
||||
|
||||
You can configure code formatters through the `formatter` option.
|
||||
|
|
|
|||
|
|
@ -325,3 +325,22 @@ Some editors like VS Code need to be started with the `--wait` flag.
|
|||
:::
|
||||
|
||||
Some editors need command-line arguments to run in blocking mode. The `--wait` flag makes the editor process block until closed.
|
||||
|
||||
---
|
||||
|
||||
## Configure
|
||||
|
||||
You can customize TUI behavior through your opencode config file.
|
||||
|
||||
```json title="opencode.json"
|
||||
{
|
||||
"$schema": "https://opencode.ai/config.json",
|
||||
"tui": {
|
||||
"scroll_speed": 3
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Options
|
||||
|
||||
- `scroll_speed` - Controls how fast the TUI scrolls when using scroll commands (default: `2`, minimum: `1`)
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
import { $ } from "bun"
|
||||
|
||||
await $`bun run prettier --ignore-unknown --write $(git ls-files)`
|
||||
await $`bun run prettier --ignore-unknown --write`
|
||||
|
||||
if (process.env["CI"] && (await $`git status --porcelain`.text())) {
|
||||
await $`git config --local user.email "action@github.com"`
|
||||
|
|
|
|||
16
script/hooks
16
script/hooks
|
|
@ -1,16 +0,0 @@
|
|||
#!/bin/sh
|
||||
|
||||
if [ ! -d ".git" ]; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
mkdir -p .git/hooks
|
||||
|
||||
cat > .git/hooks/pre-push << 'EOF'
|
||||
#!/bin/sh
|
||||
bun prettier --write --ignore-unknown $(git diff --name-only HEAD~1 HEAD)
|
||||
bun run typecheck
|
||||
EOF
|
||||
|
||||
chmod +x .git/hooks/pre-push
|
||||
echo "✅ Pre-push hook installed"
|
||||
|
|
@ -1,16 +0,0 @@
|
|||
@echo off
|
||||
|
||||
if not exist ".git" (
|
||||
exit /b 0
|
||||
)
|
||||
|
||||
if not exist ".git\hooks" (
|
||||
mkdir ".git\hooks"
|
||||
)
|
||||
|
||||
(
|
||||
echo #!/bin/sh
|
||||
echo bun run typecheck
|
||||
) > ".git\hooks\pre-push"
|
||||
|
||||
echo ✅ Pre-push hook installed
|
||||
|
|
@ -2,7 +2,7 @@
|
|||
"name": "opencode",
|
||||
"displayName": "opencode",
|
||||
"description": "opencode for VS Code",
|
||||
"version": "0.11.6",
|
||||
"version": "0.12.1",
|
||||
"publisher": "sst-dev",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
/// <reference path="./.sst/platform/config.d.ts" />
|
||||
|
||||
export default $config({
|
||||
app(input) {
|
||||
return {
|
||||
|
|
|
|||
|
|
@ -1,7 +1,5 @@
|
|||
{
|
||||
"$schema": "https://json.schemastore.org/tsconfig",
|
||||
"extends": "@tsconfig/bun/tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"customConditions": ["development"]
|
||||
}
|
||||
"compilerOptions": {}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,6 +5,10 @@
|
|||
"build": {
|
||||
"dependsOn": ["^build"],
|
||||
"outputs": ["dist/**"]
|
||||
},
|
||||
"opencode#test": {
|
||||
"dependsOn": ["^build"],
|
||||
"outputs": []
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue