mirror of
https://github.com/anomalyco/opencode.git
synced 2026-09-05 00:16:22 +00:00
Compare commits
7
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ef359de8c3 | ||
|
|
65b632d7b3 | ||
|
|
3dc5417a1b | ||
|
|
b274224af1 | ||
|
|
7a050a19a1 | ||
|
|
16601775f1 | ||
|
|
0991e8b5a5 |
@@ -872,6 +872,7 @@
|
||||
"name": "@opencode-ai/tui",
|
||||
"version": "1.18.4",
|
||||
"dependencies": {
|
||||
"@babel/core": "7.29.7",
|
||||
"@opencode-ai/client": "workspace:*",
|
||||
"@opencode-ai/core": "workspace:*",
|
||||
"@opencode-ai/latex": "workspace:*",
|
||||
@@ -899,6 +900,7 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tsconfig/bun": "catalog:",
|
||||
"@types/babel__core": "7.20.5",
|
||||
"@types/bun": "catalog:",
|
||||
"@typescript/native-preview": "catalog:",
|
||||
},
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
}
|
||||
|
||||
[data-color-scheme="dark"] [data-component="composer"][data-dock-border-underlay="true"],
|
||||
[data-color-scheme="dark"] [data-component="session-composer-dock"] [data-component="composer"],
|
||||
[data-color-scheme="dark"] [data-component="new-session"] [data-component="composer"] {
|
||||
background: var(--v2-background-bg-layer-01);
|
||||
}
|
||||
|
||||
@@ -3,11 +3,9 @@ import { useLocation } from "@solidjs/router"
|
||||
import { ComposerEditor } from "@/composer/editor/editor"
|
||||
import { setCursorPosition } from "@/composer/editor/dom"
|
||||
import { createComposerEditor } from "@/composer/editor/interaction"
|
||||
import { useLanguage } from "@/runtime/i18n/language"
|
||||
import type { PendingSession } from "@/shell/tabs/tabs"
|
||||
|
||||
export function PreparingComposer(props: { pending: PendingSession }) {
|
||||
const language = useLanguage()
|
||||
const location = useLocation()
|
||||
let element: HTMLElement | undefined
|
||||
const editor = createComposerEditor({
|
||||
@@ -20,7 +18,6 @@ export function PreparingComposer(props: { pending: PendingSession }) {
|
||||
},
|
||||
view: {
|
||||
draftOnly: true,
|
||||
placeholder: () => language.t("session.new.worktree.draftPlaceholder"),
|
||||
submit: { stopping: () => false, onSubmit() {}, onStop() {} },
|
||||
},
|
||||
})
|
||||
|
||||
@@ -64,7 +64,7 @@ function PreparingSession(props: { sessionID: string; pending: PendingSession })
|
||||
provider: { all: providers.all(), default: providers.default(), connected: [] },
|
||||
}}
|
||||
>
|
||||
<div data-component="session-preparing" class="min-h-0 flex-1 overflow-y-auto">
|
||||
<div data-component="session-preparing" data-workspace-session class="min-h-0 flex-1 overflow-y-auto">
|
||||
<SessionIdentityHeader sessionID={props.sessionID} />
|
||||
<div class="mx-auto w-full min-w-0 max-w-[1000px] px-4 pb-5 md:px-5">
|
||||
<SessionUserMessage
|
||||
|
||||
@@ -7,30 +7,27 @@ import { createWorktree } from "./create"
|
||||
describe("worktree creation", () => {
|
||||
test.each(
|
||||
[
|
||||
{ name: "clone", directory: "/copies/repo", root: "/copies/repo", canonical: "/copies/repo", parent: "/copies/" },
|
||||
{ name: "clone", directory: "/copies/repo", root: "/copies/repo", canonical: "/copies/repo" },
|
||||
{
|
||||
name: "clone subdirectory",
|
||||
directory: "/copies/repo/packages/app",
|
||||
root: "/copies/repo",
|
||||
canonical: "/copies/repo",
|
||||
parent: "/copies/",
|
||||
},
|
||||
{
|
||||
name: "linked worktree subdirectory",
|
||||
directory: "/linked/task/packages/app",
|
||||
root: "/linked/task",
|
||||
canonical: "/copies/repo",
|
||||
parent: "/copies/",
|
||||
},
|
||||
{
|
||||
name: "Windows clone",
|
||||
directory: "C:\\copies\\repo\\packages\\app",
|
||||
root: "C:\\copies\\repo",
|
||||
canonical: "C:\\copies\\repo",
|
||||
parent: "C:/copies/",
|
||||
},
|
||||
].flatMap((input) => [true, false].map((cached) => ({ ...input, cached }))),
|
||||
)("uses the clone-local main for $name (cached: $cached)", async (input) => {
|
||||
)("uses the server destination and clone-local main for $name (cached: $cached)", async (input) => {
|
||||
const project = { id: "proj_clone", directory: input.root, canonical: input.canonical }
|
||||
const requests: Request[] = []
|
||||
const api = OpenCode.make({
|
||||
@@ -66,7 +63,6 @@ describe("worktree creation", () => {
|
||||
strategy: "git",
|
||||
from: input.canonical,
|
||||
branch: "clone-only",
|
||||
directory: input.parent,
|
||||
})
|
||||
expect(requests.find((request) => request.method === "POST")?.url).toBe(
|
||||
"http://localhost:3000/api/worktree/proj_clone",
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import type { LocationGetOutput, OpenCodeClient } from "@opencode-ai/client/promise"
|
||||
import type { Data } from "@opencode-ai/client/solid"
|
||||
import { getDirectory } from "@opencode-ai/util/path"
|
||||
|
||||
export async function createWorktree(input: {
|
||||
api: Pick<OpenCodeClient, "location" | "worktree">
|
||||
@@ -15,7 +14,6 @@ export async function createWorktree(input: {
|
||||
strategy: "git",
|
||||
from: project.canonical,
|
||||
branch: input.branch,
|
||||
directory: getDirectory(project.canonical),
|
||||
})
|
||||
// Populate the client cache before the destination session mounts.
|
||||
await input.data.location.syncInfo({ directory: created.directory })
|
||||
|
||||
@@ -75,7 +75,7 @@ const make = Effect.gen(function* () {
|
||||
Effect.orElseSucceed(() => undefined),
|
||||
),
|
||||
)
|
||||
return values.findLast((value) => value !== undefined) ?? "auto"
|
||||
return values.findLast((value) => value !== undefined) ?? "notify"
|
||||
})
|
||||
|
||||
const exec = Effect.fnUntraced(function* (command: string[], timeout: Duration.Input = "10 seconds") {
|
||||
|
||||
@@ -1940,7 +1940,7 @@ export type WorktreeCreateInput = {
|
||||
readonly strategy: Worktree.StrategyID
|
||||
readonly from?: AbsolutePath | undefined
|
||||
readonly branch?: string | undefined
|
||||
readonly directory: AbsolutePath
|
||||
readonly directory?: AbsolutePath | undefined
|
||||
readonly name?: string | undefined
|
||||
}
|
||||
export type WorktreeCreateOutput = Worktree.Info
|
||||
|
||||
@@ -6087,35 +6087,35 @@ export type WorktreeCreateInput = {
|
||||
readonly strategy: string
|
||||
readonly from?: string
|
||||
readonly branch?: string
|
||||
readonly directory: string
|
||||
readonly directory?: string
|
||||
readonly name?: string
|
||||
}["strategy"]
|
||||
readonly from?: {
|
||||
readonly strategy: string
|
||||
readonly from?: string
|
||||
readonly branch?: string
|
||||
readonly directory: string
|
||||
readonly directory?: string
|
||||
readonly name?: string
|
||||
}["from"]
|
||||
readonly branch?: {
|
||||
readonly strategy: string
|
||||
readonly from?: string
|
||||
readonly branch?: string
|
||||
readonly directory: string
|
||||
readonly directory?: string
|
||||
readonly name?: string
|
||||
}["branch"]
|
||||
readonly directory: {
|
||||
readonly directory?: {
|
||||
readonly strategy: string
|
||||
readonly from?: string
|
||||
readonly branch?: string
|
||||
readonly directory: string
|
||||
readonly directory?: string
|
||||
readonly name?: string
|
||||
}["directory"]
|
||||
readonly name?: {
|
||||
readonly strategy: string
|
||||
readonly from?: string
|
||||
readonly branch?: string
|
||||
readonly directory: string
|
||||
readonly directory?: string
|
||||
readonly name?: string
|
||||
}["name"]
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import { and, asc, desc, eq, isNotNull, isNull, ne, or } from "drizzle-orm"
|
||||
import path from "path"
|
||||
import { AbsolutePath } from "./schema.js"
|
||||
import { FSUtil } from "@opencode-ai/util/fs-util"
|
||||
import { Global } from "@opencode-ai/util/global"
|
||||
import { Git } from "./git.js"
|
||||
import { makeGlobalNode, makeLocationNode } from "@opencode-ai/util/effect/app-node"
|
||||
import { ProjectSchema } from "./project/schema.js"
|
||||
@@ -149,6 +150,7 @@ const layer = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const fs = yield* FSUtil.Service
|
||||
const global = yield* Global.Service
|
||||
const db = (yield* Database.Service).db
|
||||
const bus = yield* Bus.Service
|
||||
const processService = yield* AppProcess.Service
|
||||
@@ -243,14 +245,15 @@ const layer = Layer.effect(
|
||||
const create = Effect.fn("Worktree.create")(function* (input: CreateInput) {
|
||||
const selected = yield* getStrategy(input.strategy)
|
||||
const sourceDirectory = yield* source(input.from, input.projectID)
|
||||
yield* fs.makeDirectory(input.directory, { recursive: true }).pipe(Effect.orDie)
|
||||
const directory = input.directory ?? path.join(global.data, "worktree", input.projectID.slice(0, 6))
|
||||
yield* fs.makeDirectory(directory, { recursive: true }).pipe(Effect.orDie)
|
||||
const name = input.name ?? Slug.create()
|
||||
let suffix = 1
|
||||
let worktreeDirectory = AbsolutePath.make(path.join(input.directory, name))
|
||||
let worktreeDirectory = AbsolutePath.make(path.join(directory, name))
|
||||
while (yield* fs.existsSafe(worktreeDirectory)) {
|
||||
suffix++
|
||||
if (suffix > 10) return yield* new DestinationExistsError({ directory: worktreeDirectory })
|
||||
worktreeDirectory = AbsolutePath.make(path.join(input.directory, `${name}-${suffix}`))
|
||||
worktreeDirectory = AbsolutePath.make(path.join(directory, `${name}-${suffix}`))
|
||||
}
|
||||
|
||||
const result = yield* selected.create({
|
||||
@@ -368,7 +371,7 @@ const layer = Layer.effect(
|
||||
export const node = makeGlobalNode({
|
||||
service: Service,
|
||||
layer: layer,
|
||||
deps: [FSUtil.node, Git.node, Bus.node, Database.node, AppProcess.node],
|
||||
deps: [FSUtil.node, Global.node, Git.node, Bus.node, Database.node, AppProcess.node],
|
||||
})
|
||||
|
||||
export const refreshNode = makeLocationNode({
|
||||
|
||||
@@ -6,6 +6,7 @@ import { and, eq, isNull } from "drizzle-orm"
|
||||
import { Effect, Fiber, Stream } from "effect"
|
||||
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
||||
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
|
||||
import { Global } from "@opencode-ai/util/global"
|
||||
import { AbsolutePath } from "@opencode-ai/core/schema"
|
||||
import { Git } from "@opencode-ai/core/git"
|
||||
import { Database } from "@opencode-ai/core/database/database"
|
||||
@@ -17,12 +18,18 @@ import { WorktreeDirectory } from "@opencode-ai/core/worktree/directory"
|
||||
import { WorktreeTable } from "@opencode-ai/core/worktree/sql"
|
||||
import { initRepo } from "./fixture/git"
|
||||
import { tmpdir } from "./fixture/tmpdir"
|
||||
import { tempGlobalLayer } from "./fixture/global"
|
||||
import { testEffect } from "./lib/effect"
|
||||
|
||||
const it = testEffect(AppNodeBuilder.build(LayerNode.group([Worktree.node, Database.node, Bus.node])))
|
||||
const projectIt = testEffect(
|
||||
AppNodeBuilder.build(LayerNode.group([Project.node, Worktree.node, Database.node, Bus.node])),
|
||||
)
|
||||
const defaultIt = testEffect(
|
||||
AppNodeBuilder.build(LayerNode.group([Worktree.node, Database.node, Global.node]), [
|
||||
Global.node.replace(tempGlobalLayer),
|
||||
]),
|
||||
)
|
||||
|
||||
function abs(input: string) {
|
||||
return AbsolutePath.make(input)
|
||||
@@ -180,6 +187,34 @@ describe("Worktree", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
defaultIt.live("defaults to the TUI worktree directory and suffixes duplicate names", () =>
|
||||
Effect.gen(function* () {
|
||||
const input = yield* setup()
|
||||
const worktree = yield* Worktree.Service
|
||||
const global = yield* Global.Service
|
||||
const parent = path.join(global.data, "worktree", "worktr")
|
||||
|
||||
const created = yield* worktree.create({
|
||||
projectID: input.projectID,
|
||||
strategy: gitWorktree,
|
||||
from: input.sourceDirectory,
|
||||
name: "task",
|
||||
})
|
||||
const duplicate = yield* worktree.create({
|
||||
projectID: input.projectID,
|
||||
strategy: gitWorktree,
|
||||
from: input.sourceDirectory,
|
||||
name: "task",
|
||||
})
|
||||
|
||||
expect(created.directory).toBe(abs(path.join(parent, "task")))
|
||||
expect(duplicate.directory).toBe(abs(path.join(parent, "task-2")))
|
||||
expect(yield* Effect.promise(() => Bun.file(path.join(created.directory, ".git")).exists())).toBe(true)
|
||||
yield* worktree.remove({ projectID: input.projectID, directory: created.directory, force: false })
|
||||
yield* worktree.remove({ projectID: input.projectID, directory: duplicate.directory, force: false })
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("runs the project setup script with worktree paths", () =>
|
||||
Effect.gen(function* () {
|
||||
const input = yield* setup()
|
||||
|
||||
@@ -12124,7 +12124,7 @@
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": ["strategy", "directory"],
|
||||
"required": ["strategy"],
|
||||
"additionalProperties": false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,7 +14,10 @@ export const CreateInput = Schema.Struct({
|
||||
strategy: StrategyID,
|
||||
from: optional(AbsolutePath),
|
||||
branch: optional(Schema.Trim.pipe(Schema.check(Schema.isNonEmpty()))),
|
||||
directory: AbsolutePath,
|
||||
directory: optional(AbsolutePath).annotate({
|
||||
description:
|
||||
"Parent directory for the new worktree. Defaults to the server's data directory under worktree/<first six project ID characters>.",
|
||||
}),
|
||||
name: optional(Schema.String),
|
||||
}).annotate({ identifier: "Worktree.CreateInput" })
|
||||
export interface CreateInput extends Schema.Schema.Type<typeof CreateInput> {}
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { Schema } from "effect"
|
||||
import { Worktree } from "../src/worktree.js"
|
||||
|
||||
describe("Worktree.CreateInput", () => {
|
||||
test("allows the server to choose the destination", () => {
|
||||
const input = Schema.decodeUnknownSync(Worktree.CreateInput)({
|
||||
projectID: "project",
|
||||
strategy: "git",
|
||||
})
|
||||
expect(input.directory).toBeUndefined()
|
||||
expect(Schema.encodeSync(Worktree.CreateInput)({ ...input, directory: undefined })).toEqual({
|
||||
projectID: "project",
|
||||
strategy: "git",
|
||||
})
|
||||
})
|
||||
|
||||
test("preserves an explicit destination", () => {
|
||||
const input = { projectID: "project", strategy: "git", directory: "/custom/worktrees" }
|
||||
expect(Schema.encodeSync(Worktree.CreateInput)(Schema.decodeUnknownSync(Worktree.CreateInput)(input))).toEqual(
|
||||
input,
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -54,6 +54,11 @@
|
||||
"./component/register-spinner": "./src/component/register-spinner.ts"
|
||||
},
|
||||
"imports": {
|
||||
"#plugin-source": {
|
||||
"bun": "./src/plugin/source.bun.ts",
|
||||
"node": "./src/plugin/source.node.ts",
|
||||
"default": "./src/plugin/source.node.ts"
|
||||
},
|
||||
"#attention-sounds": {
|
||||
"bun": "./src/attention-sounds.bun.ts",
|
||||
"node": "./src/attention-sounds.node.ts",
|
||||
@@ -76,6 +81,7 @@
|
||||
}
|
||||
},
|
||||
"dependencies": {
|
||||
"@babel/core": "7.29.7",
|
||||
"@opencode-ai/client": "workspace:*",
|
||||
"@opencode-ai/core": "workspace:*",
|
||||
"@opencode-ai/latex": "workspace:*",
|
||||
@@ -102,6 +108,7 @@
|
||||
"uqr": "0.1.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/babel__core": "7.20.5",
|
||||
"@tsconfig/bun": "catalog:",
|
||||
"@types/bun": "catalog:",
|
||||
"@typescript/native-preview": "catalog:"
|
||||
|
||||
@@ -14,10 +14,9 @@ import {
|
||||
type ParentProps,
|
||||
} from "solid-js"
|
||||
import path from "path"
|
||||
import { readFile, stat } from "fs/promises"
|
||||
import { stat } from "fs/promises"
|
||||
import { fileURLToPath } from "url"
|
||||
import type { Page } from "@opencode-ai/plugin/tui/context"
|
||||
import { Hash } from "@opencode-ai/util/hash"
|
||||
import { Host } from "@opencode-ai/plugin/host"
|
||||
import { resolveSlots, type Claim } from "./structure"
|
||||
import { createStore, produce, reconcile as reconcileStore, unwrap } from "solid-js/store"
|
||||
@@ -31,7 +30,8 @@ import { errorMessage } from "../util/error"
|
||||
import { builtins } from "./builtins"
|
||||
import { createPluginContext, usePluginHost, type Dispose, type RegisteredSlot, type SlotRender } from "./api"
|
||||
import { createSourceWatcher } from "./watch"
|
||||
import { discoverPluginTargets, freshSpecifier, localSource } from "./discovery"
|
||||
import { discoverPluginTargets, localSource } from "./discovery"
|
||||
import { createPluginSources } from "./source"
|
||||
import { isMissingPath } from "../util/config-directories"
|
||||
import { createMarkdownRenderer } from "./markdown"
|
||||
|
||||
@@ -83,7 +83,6 @@ type Registration = {
|
||||
type Desired = Pick<Registration, "plugin" | "source" | "target" | "version" | "options"> & { enabled: boolean }
|
||||
|
||||
const PluginContext = createContext<Value>()
|
||||
let sourceVersion = Date.now()
|
||||
|
||||
export function PluginProvider(props: ParentProps<{ packages: PackageSource; directories: string[] }>) {
|
||||
const host = usePluginHost()
|
||||
@@ -109,15 +108,6 @@ export function PluginProvider(props: ParentProps<{ packages: PackageSource; dir
|
||||
// One save can emit several watch events. Remember setup failures so those
|
||||
// events do not repeatedly tear down and restore the last good generation.
|
||||
const setupFailures = new Map<string, { version: string; options: Registration["options"]; error: string }>()
|
||||
const sourceVersions = new Map<string, { digest: string; generation: number }>()
|
||||
const sourceGeneration = async (entrypoint: string) => {
|
||||
const digest = Hash.sha256(await readFile(new URL(entrypoint)))
|
||||
const previous = sourceVersions.get(entrypoint)
|
||||
if (previous?.digest === digest) return previous.generation
|
||||
const generation = ++sourceVersion
|
||||
sourceVersions.set(entrypoint, { digest, generation })
|
||||
return generation
|
||||
}
|
||||
const markdown = createMarkdownRenderer(() =>
|
||||
Object.values(store.registrations).flatMap((registration) => (registration.active ? [registration.markdown] : [])),
|
||||
)
|
||||
@@ -241,6 +231,7 @@ export function PluginProvider(props: ParentProps<{ packages: PackageSource; dir
|
||||
clearTimeout(pending)
|
||||
watcher.dispose()
|
||||
}
|
||||
const sources = createPluginSources(watcher.wait)
|
||||
onCleanup(stopWatching)
|
||||
|
||||
// Rebuild the plugin generation as resolve → compare → swap, mirroring the
|
||||
@@ -304,7 +295,7 @@ export function PluginProvider(props: ParentProps<{ packages: PackageSource; dir
|
||||
const memo = local ? undefined : npmFailures.get(target)
|
||||
const resolved = memo
|
||||
? { status: "failed" as const, error: memo }
|
||||
: await resolvePlugin(target, local, options, previous, props.packages, source.install, sourceGeneration).catch(
|
||||
: await resolvePlugin(target, local, options, previous, props.packages, source.install, sources.read).catch(
|
||||
(error) => ({
|
||||
status: "failed" as const,
|
||||
error: errorMessage(error),
|
||||
@@ -533,6 +524,7 @@ export function PluginProvider(props: ParentProps<{ packages: PackageSource; dir
|
||||
),
|
||||
)
|
||||
.then(() => setStore("registrations", reconcileStore({})))
|
||||
.finally(sources.dispose)
|
||||
return disposing
|
||||
}
|
||||
const unregister = lifecycle.add(dispose)
|
||||
@@ -605,7 +597,7 @@ async function resolvePlugin(
|
||||
previous: Registration | undefined,
|
||||
packages: PackageSource,
|
||||
install: boolean,
|
||||
sourceGeneration: (entrypoint: string) => Promise<number>,
|
||||
readSource: ReturnType<typeof createPluginSources>["read"],
|
||||
) {
|
||||
// Package entrypoints never change within a session, so a loaded previous
|
||||
// version needs no re-resolution (which could otherwise hit npm).
|
||||
@@ -616,18 +608,18 @@ async function resolvePlugin(
|
||||
if (!entrypoint) return { status: "unsupported" as const }
|
||||
// Content remains stable across the several mtimes one save may expose to
|
||||
// filesystem watchers, while the generation keeps reverted modules fresh.
|
||||
let generation = local ? await sourceGeneration(entrypoint) : undefined
|
||||
let source = local ? await readSource(entrypoint) : { version: entrypoint, module: await Host.load(entrypoint) }
|
||||
while (true) {
|
||||
const version = generation === undefined ? entrypoint : freshSpecifier(entrypoint, generation)
|
||||
const version = source.version
|
||||
if (previous && previous.version === version && sameOptions(previous.options, options))
|
||||
return { status: "unchanged" as const, plugin: previous.plugin, version }
|
||||
const mod = await Host.load(version)
|
||||
if (generation !== undefined) {
|
||||
const observed = await sourceGeneration(entrypoint)
|
||||
const mod = source.module
|
||||
if (local) {
|
||||
const observed = await readSource(entrypoint)
|
||||
// In-place saves can change the file between hashing and import. Retry
|
||||
// so setup always runs under the generation of the imported bytes.
|
||||
if (generation !== observed) {
|
||||
generation = observed
|
||||
if (version !== observed.version) {
|
||||
source = observed
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,169 @@
|
||||
import { createSolidTransformPlugin } from "@opentui/solid/bun-plugin"
|
||||
import { runtimeModuleIdForSpecifier } from "@opentui/core/runtime-plugin"
|
||||
import { isBuiltin } from "node:module"
|
||||
import path from "node:path"
|
||||
import { fileURLToPath, pathToFileURL } from "node:url"
|
||||
import { localSource } from "./discovery"
|
||||
import { transformAsync, types, type PluginObj } from "@babel/core"
|
||||
|
||||
const shared = new Set([
|
||||
"@opencode-ai/plugin/tui",
|
||||
"@opentui/core",
|
||||
"@opentui/core/testing",
|
||||
"@opentui/solid",
|
||||
"@opentui/solid/components",
|
||||
"@opentui/solid/jsx-runtime",
|
||||
"@opentui/solid/jsx-dev-runtime",
|
||||
"solid-js",
|
||||
"solid-js/store",
|
||||
])
|
||||
|
||||
export async function prepareSource(entrypoint: string, track: (file: string, directory?: boolean) => void) {
|
||||
const solid = createSolidTransformPlugin()
|
||||
// Snapshot deferred local imports too. Evicting require.cache alone lets an
|
||||
// old callback import new code after a failed replacement, breaking fallback.
|
||||
const result = await Bun.build({
|
||||
entrypoints: [fileURLToPath(entrypoint)],
|
||||
target: "bun",
|
||||
format: "esm",
|
||||
throw: false,
|
||||
plugins: [
|
||||
{
|
||||
name: "local-plugin-source",
|
||||
async setup(build) {
|
||||
build.onResolve({ filter: /.*/ }, (args) => {
|
||||
if (!args.importer) return undefined
|
||||
const local = localSource(args.path, args.resolveDir)
|
||||
if (local) {
|
||||
try {
|
||||
const resolved = Bun.resolveSync(fileURLToPath(local), args.resolveDir)
|
||||
const external = !isSource(resolved)
|
||||
return { path: external ? pathToFileURL(resolved).href : resolved, external }
|
||||
} catch (error) {
|
||||
track(path.dirname(fileURLToPath(local)), true)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
return { path: args.path, external: true }
|
||||
})
|
||||
// Reuse OpenTUI's Solid compiler, preserving per-module source paths
|
||||
// before bundling so new URL("./asset", import.meta.url) still works.
|
||||
await solid.setup({
|
||||
...build,
|
||||
onLoad(options, load) {
|
||||
return build.onLoad(options, async (args) => {
|
||||
track(args.path)
|
||||
const result = await load(args)
|
||||
if (!result || !("contents" in result)) return result
|
||||
return {
|
||||
...result,
|
||||
contents: await transformSource(
|
||||
typeof result.contents === "string" ? result.contents : new TextDecoder().decode(result.contents),
|
||||
args.path,
|
||||
"js",
|
||||
),
|
||||
loader: "js",
|
||||
}
|
||||
})
|
||||
},
|
||||
})
|
||||
build.onLoad({ filter: /\.[cm]?[jt]s$/ }, async (args) => {
|
||||
track(args.path)
|
||||
return {
|
||||
contents: await transformSource(
|
||||
await Bun.file(args.path).text(),
|
||||
args.path,
|
||||
/\.[cm]?ts$/.test(args.path) ? "ts" : "js",
|
||||
),
|
||||
loader: "js",
|
||||
}
|
||||
})
|
||||
build.onLoad({ filter: /\.json$/ }, (args) => {
|
||||
track(args.path)
|
||||
})
|
||||
},
|
||||
},
|
||||
],
|
||||
})
|
||||
if (!result.success) throw new Error(result.logs.map(String).join("\n"))
|
||||
const version = URL.createObjectURL(result.outputs[0])
|
||||
return { version, dispose: () => URL.revokeObjectURL(version) }
|
||||
}
|
||||
|
||||
async function transformSource(contents: string, file: string, loader: "js" | "ts") {
|
||||
// Bun's build onResolve({ external: true }) preserves the original import
|
||||
// spelling, ignoring its returned path. Rewrite before bundling so packages
|
||||
// resolve beside their importing source, not beside the generated bundle.
|
||||
const rewrite = (source: { value: string }) => {
|
||||
if (isBuiltin(source.value) || source.value === "bun" || source.value.startsWith("opentui:")) return
|
||||
const local = localSource(source.value, path.dirname(file))
|
||||
if (local) {
|
||||
// Keep build-time resolution (and failed-source watching) for code. Data
|
||||
// and native modules stay runtime imports anchored at the original file.
|
||||
if (!/\.(?:[cm]?[jt]sx?|json)$/.test(local.pathname) && path.extname(local.pathname)) source.value = local.href
|
||||
return
|
||||
}
|
||||
source.value = shared.has(source.value)
|
||||
? runtimeModuleIdForSpecifier(source.value)
|
||||
: pathToFileURL(Bun.resolveSync(source.value, path.dirname(file))).href
|
||||
}
|
||||
const imports: PluginObj = {
|
||||
visitor: {
|
||||
ImportDeclaration: (p) => rewrite(p.node.source),
|
||||
ExportNamedDeclaration: (p) => {
|
||||
if (p.node.source) rewrite(p.node.source)
|
||||
},
|
||||
ExportAllDeclaration: (p) => rewrite(p.node.source),
|
||||
CallExpression: (p) => {
|
||||
if (
|
||||
p.node.callee.type !== "Import" &&
|
||||
!(p.node.callee.type === "Identifier" && p.node.callee.name === "require")
|
||||
)
|
||||
return
|
||||
const argument = p.node.arguments[0]
|
||||
if (argument?.type === "StringLiteral") {
|
||||
rewrite(argument)
|
||||
return
|
||||
}
|
||||
if (p.node.callee.type === "Import" && types.isExpression(argument)) {
|
||||
// Computed imports cannot join a static bundle; preserve their
|
||||
// original resolution base rather than resolving beside a blob URL.
|
||||
p.node.arguments[0] = types.callExpression(
|
||||
types.memberExpression(
|
||||
types.metaProperty(types.identifier("import"), types.identifier("meta")),
|
||||
types.identifier("resolve"),
|
||||
),
|
||||
[argument, types.stringLiteral(file)],
|
||||
)
|
||||
}
|
||||
},
|
||||
},
|
||||
}
|
||||
let name = "__opencodePluginMeta"
|
||||
while (contents.includes(name)) name += "_"
|
||||
const transformed = new Bun.Transpiler({
|
||||
loader,
|
||||
target: "bun",
|
||||
define: {
|
||||
"import.meta": name,
|
||||
},
|
||||
}).transformSync(contents)
|
||||
const code = !transformed.includes(name)
|
||||
? transformed
|
||||
: `const ${name} = { ...import.meta,
|
||||
url: ${JSON.stringify(pathToFileURL(file).href)},
|
||||
dir: ${JSON.stringify(path.dirname(file))},
|
||||
dirname: ${JSON.stringify(path.dirname(file))},
|
||||
path: ${JSON.stringify(file)},
|
||||
filename: ${JSON.stringify(file)},
|
||||
resolve: (specifier, parent) => import.meta.resolve(specifier, parent ?? ${JSON.stringify(file)}),
|
||||
require: (specifier) => import.meta.require(import.meta.resolve(specifier, ${JSON.stringify(file)})),
|
||||
};\n${transformed}`
|
||||
const result = await transformAsync(code, { filename: file, configFile: false, babelrc: false, plugins: [imports] })
|
||||
if (result?.code == null) throw new Error(`Could not transform local plugin source: ${file}`)
|
||||
return result.code
|
||||
}
|
||||
|
||||
function isSource(file: string) {
|
||||
return !file.split(path.sep).includes("node_modules") && /\.(?:[cm]?[jt]sx?|json)$/.test(file)
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import { registerHooks } from "node:module"
|
||||
import path from "node:path"
|
||||
import { fileURLToPath } from "node:url"
|
||||
import { freshSpecifier, localSource } from "./discovery"
|
||||
|
||||
let generation = Date.now()
|
||||
|
||||
export async function prepareSource(entrypoint: string, track: (file: string, directory?: boolean) => void) {
|
||||
const version = ++generation
|
||||
const hook = registerHooks({
|
||||
resolve(specifier, context, nextResolve) {
|
||||
if (!context.parentURL?.endsWith(`?mtime=${version}`)) return nextResolve(specifier, context)
|
||||
const local = localSource(specifier, path.dirname(fileURLToPath(context.parentURL)))
|
||||
if (!local) return nextResolve(specifier, context)
|
||||
const resolved = (() => {
|
||||
try {
|
||||
return nextResolve(specifier, context)
|
||||
} catch (error) {
|
||||
track(path.dirname(fileURLToPath(local)), true)
|
||||
throw error
|
||||
}
|
||||
})()
|
||||
if (!resolved.url.startsWith("file:")) return resolved
|
||||
const file = fileURLToPath(resolved.url)
|
||||
if (file.split(path.sep).includes("node_modules")) return resolved
|
||||
track(file)
|
||||
return { ...resolved, url: freshSpecifier(resolved.url, version) }
|
||||
},
|
||||
})
|
||||
return { version: freshSpecifier(entrypoint, version), dispose: () => hook.deregister() }
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import { readFileSync, readdirSync } from "node:fs"
|
||||
import { fileURLToPath } from "node:url"
|
||||
import { Hash } from "@opencode-ai/util/hash"
|
||||
import { Host } from "@opencode-ai/plugin/host"
|
||||
|
||||
// Keep source identity and runtime module identity together. A new entrypoint
|
||||
// alone is not a new plugin: its local imports must use the same generation.
|
||||
export function createPluginSources(watch: (file: string) => Promise<void>) {
|
||||
const sources = new Map<string, Source>()
|
||||
const cleanups: Array<() => void> = []
|
||||
const watching = new Set<Promise<void>>()
|
||||
return {
|
||||
read: async (entrypoint: string) => {
|
||||
await Promise.all(watching)
|
||||
const previous = sources.get(entrypoint)
|
||||
if (previous && [...previous.files].every(([file, item]) => item.digest === digest(file, item.directory)))
|
||||
return previous.loaded
|
||||
|
||||
const files: Source["files"] = new Map()
|
||||
const track = (file: string, directory = false) => {
|
||||
if (files.has(file)) return
|
||||
files.set(file, { digest: digest(file, directory), directory })
|
||||
const pending = watch(file).finally(() => watching.delete(pending))
|
||||
watching.add(pending)
|
||||
}
|
||||
track(fileURLToPath(entrypoint))
|
||||
const { prepareSource } = await import("#plugin-source")
|
||||
const prepared = await prepareSource(entrypoint, track)
|
||||
cleanups.push(prepared.dispose)
|
||||
// Cache the attempt before evaluating it: unchanged failing modules must
|
||||
// not repeat import-time effects on every filesystem notification.
|
||||
const loaded = Host.load(prepared.version).then((module) => ({ version: prepared.version, module }))
|
||||
sources.set(entrypoint, { loaded, files })
|
||||
return loaded.finally(() => Promise.all(watching))
|
||||
},
|
||||
dispose: () => {
|
||||
for (const cleanup of cleanups.splice(0)) cleanup()
|
||||
sources.clear()
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
type Source = {
|
||||
loaded: Promise<{ version: string; module: unknown }>
|
||||
files: Map<string, { digest: string; directory: boolean }>
|
||||
}
|
||||
|
||||
function digest(file: string, directory: boolean) {
|
||||
try {
|
||||
return Hash.sha256(directory ? JSON.stringify(readdirSync(file).sort()) : readFileSync(file))
|
||||
} catch {
|
||||
return "missing"
|
||||
}
|
||||
}
|
||||
@@ -7,9 +7,9 @@ import { lstat, realpath, stat } from "fs/promises"
|
||||
// kills a direct file watch) and filtered by basename so bursts in busy
|
||||
// directories stay quiet. Symlinked files are additionally watched at their
|
||||
// resolved target, since edits there emit nothing at the link's location.
|
||||
// Directory targets are watched at their root only: edits to nested helper
|
||||
// files do not change the entrypoint mtime and are not detected. Watches are
|
||||
// never torn down individually (a stale watch costs one fs handle and a
|
||||
// Directory targets are watched at their root only; the plugin source loader
|
||||
// adds each resolved local dependency separately, including nested helpers.
|
||||
// Watches are never torn down individually (a stale watch costs one fs handle and a
|
||||
// spurious onChange); all die with dispose(). Missing retryable targets are
|
||||
// polled until they can be armed without relying on a racy chain of ancestor
|
||||
// watches.
|
||||
|
||||
@@ -144,8 +144,8 @@ function UpdateNotification() {
|
||||
onMouseUp={() => update.open?.("notification")}
|
||||
>
|
||||
<UpdateMessage
|
||||
title="Update available"
|
||||
description={`Version ${state.version} is available. Click for more details`}
|
||||
title={state.type === "installed" ? "Update installed" : "Update available"}
|
||||
description={`Version ${state.version} is ${state.type === "installed" ? "installed" : "available"}. Click for more details`}
|
||||
backdrop={
|
||||
hovered() === "primary" ? theme.background.action.primary.hovered : theme.background.default
|
||||
}
|
||||
|
||||
@@ -0,0 +1,336 @@
|
||||
import { expect, test } from "bun:test"
|
||||
import path from "node:path"
|
||||
import { rename, symlink } from "node:fs/promises"
|
||||
import { fileURLToPath, pathToFileURL } from "node:url"
|
||||
import { Host } from "@opencode-ai/plugin/host"
|
||||
import "../src/plugin/runtime-plugin-support.bun"
|
||||
import { createPluginSources } from "../src/plugin/source"
|
||||
import { createSourceWatcher } from "../src/plugin/watch"
|
||||
import { createSignal } from "solid-js"
|
||||
import { Plugin } from "@opencode-ai/plugin/tui"
|
||||
import { tmpdir } from "./fixture/fixture"
|
||||
|
||||
test("a fresh local plugin generation observes edited helper exports", async () => {
|
||||
await using sources = await fixture()
|
||||
const entry = new URL("tui.ts", sources.url)
|
||||
const helper = new URL("helper.ts", sources.url)
|
||||
await Bun.write(entry, 'export { value as default } from "./helper.ts"')
|
||||
await Bun.write(helper, 'export const value = "before"')
|
||||
const before = await sources.read(entry.href)
|
||||
expect(before.module).toMatchObject({ default: "before" })
|
||||
await Bun.write(helper, 'export const value = "after"')
|
||||
expect((await sources.read(entry.href)).module).toMatchObject({ default: "after" })
|
||||
expect(before.module).toMatchObject({ default: "before" })
|
||||
})
|
||||
|
||||
test("tracks transitive imports through the Solid runtime transform", async () => {
|
||||
const watched: string[] = []
|
||||
await using sources = await fixture(async (file) => {
|
||||
watched.push(file)
|
||||
})
|
||||
const entry = new URL("tui.tsx", sources.url)
|
||||
const helper = new URL("nested/label.ts", sources.url)
|
||||
await Bun.write(entry, 'export { value as default } from "./panel"')
|
||||
await Bun.write(
|
||||
new URL("panel.tsx", sources.url),
|
||||
'import { label } from "./nested/label"; export const Panel = () => <text>{label}</text>; export const value = label',
|
||||
)
|
||||
await Bun.write(helper, 'export const label = "before"')
|
||||
const before = await sources.read(entry.href)
|
||||
expect(before.module).toMatchObject({ default: "before" })
|
||||
expect(watched).toContain(fileURLToPath(helper))
|
||||
await Bun.write(helper, 'export const label = "after"')
|
||||
const after = await sources.read(entry.href)
|
||||
expect(after.version).not.toBe(before.version)
|
||||
expect(after.module).toMatchObject({ default: "after" })
|
||||
})
|
||||
|
||||
test("unchanged bytes are a no-op, reverted bytes get a fresh module", async () => {
|
||||
await using sources = await fixture()
|
||||
const entry = new URL("tui.ts", sources.url)
|
||||
await Bun.write(entry, "export default { value: 1 }")
|
||||
const first = await sources.read(entry.href)
|
||||
await Bun.write(entry, "export default { value: 1 }")
|
||||
expect(await sources.read(entry.href)).toBe(first)
|
||||
await Bun.write(entry, "export default { value: 2 }")
|
||||
expect((await sources.read(entry.href)).module).toMatchObject({ default: { value: 2 } })
|
||||
await Bun.write(entry, "export default { value: 1 }")
|
||||
const reverted = await sources.read(entry.href)
|
||||
expect(reverted.version).not.toBe(first.version)
|
||||
expect(reverted.module).not.toBe(first.module)
|
||||
expect(reverted.module).toMatchObject({ default: { value: 1 } })
|
||||
})
|
||||
|
||||
test("renamed exports, failed loads, and new dependencies recover without cached helpers", async () => {
|
||||
await using sources = await fixture()
|
||||
const entry = new URL("tui.ts", sources.url)
|
||||
const helper = new URL("helper.ts", sources.url)
|
||||
await Bun.write(entry, 'import { value } from "./helper"; export default value')
|
||||
await Bun.write(helper, "export const value = 1")
|
||||
const before = await sources.read(entry.href)
|
||||
expect(before.module).toMatchObject({ default: 1 })
|
||||
await Bun.write(helper, "export const renamed = 2")
|
||||
await expect(sources.read(entry.href)).rejects.toThrow()
|
||||
expect(before.module).toMatchObject({ default: 1 })
|
||||
await Bun.write(entry, 'import { renamed } from "./helper"; export default renamed')
|
||||
expect((await sources.read(entry.href)).module).toMatchObject({ default: 2 })
|
||||
await Bun.write(helper, 'export { value as renamed } from "./new/leaf"')
|
||||
await expect(sources.read(entry.href)).rejects.toThrow("leaf")
|
||||
await Bun.write(new URL("new/leaf.ts", sources.url), "export const value = 3")
|
||||
expect((await sources.read(entry.href)).module).toMatchObject({ default: 3 })
|
||||
})
|
||||
|
||||
test("shared runtime and ordinary package identities survive plugin generations", async () => {
|
||||
await using sources = await fixture()
|
||||
const entry = new URL("tui.ts", sources.url)
|
||||
await Bun.write(new URL("node_modules/example/package.json", sources.url), '{"type":"module","main":"index.js"}')
|
||||
const library = new URL("node_modules/example/index.js", sources.url)
|
||||
await Bun.write(library, 'export default { value: "package" }')
|
||||
const pkg = await Host.load(library.href)
|
||||
if (typeof pkg !== "object" || pkg === null || !("default" in pkg)) throw new Error("Missing package fixture export")
|
||||
for (const label of ["before", "after"]) {
|
||||
await Bun.write(
|
||||
entry,
|
||||
`import { createSignal } from "solid-js"
|
||||
import { Plugin } from "@opencode-ai/plugin/tui"
|
||||
import value from "example"
|
||||
export { createSignal, Plugin, value }; export const label = ${JSON.stringify(label)}`,
|
||||
)
|
||||
const loaded = (await sources.read(entry.href)).module
|
||||
if (typeof loaded !== "object" || loaded === null) throw new Error("Missing plugin fixture exports")
|
||||
expect("createSignal" in loaded && loaded.createSignal).toBe(createSignal)
|
||||
expect("Plugin" in loaded && loaded.Plugin).toBe(Plugin)
|
||||
expect("value" in loaded && loaded.value).toBe(pkg.default)
|
||||
expect(loaded).toMatchObject({ label })
|
||||
}
|
||||
})
|
||||
|
||||
test("helper import.meta stays anchored to its source, including assets and resolution", async () => {
|
||||
await using sources = await fixture()
|
||||
const entry = new URL("tui.ts", sources.url)
|
||||
const helper = new URL("nested/helper.ts", sources.url)
|
||||
await Bun.write(entry, 'export { default } from "./nested/helper"')
|
||||
await Bun.write(new URL("nested/asset.txt", sources.url), "asset")
|
||||
await Bun.write(
|
||||
helper,
|
||||
`export default {
|
||||
url: import.meta.url, dir: import.meta.dirname,
|
||||
resolved: import.meta.resolve("./asset.txt"),
|
||||
asset: await Bun.file(new URL("./asset.txt", import.meta.url)).text(),
|
||||
}`,
|
||||
)
|
||||
expect((await sources.read(entry.href)).module).toMatchObject({
|
||||
default: {
|
||||
url: helper.href,
|
||||
dir: path.dirname(fileURLToPath(helper)),
|
||||
asset: "asset",
|
||||
resolved: new URL("nested/asset.txt", sources.url).href,
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("literal dynamic imports and JSON join the source graph", async () => {
|
||||
const watched: string[] = []
|
||||
await using sources = await fixture(async (file) => {
|
||||
watched.push(file)
|
||||
})
|
||||
const entry = new URL("tui.ts", sources.url)
|
||||
const json = new URL("data.json", sources.url)
|
||||
await Bun.write(entry, 'export default (await import("./helper")).default')
|
||||
await Bun.write(new URL("helper.ts", sources.url), 'import data from "./data.json"; export default data.value')
|
||||
await Bun.write(json, '{"value":1}')
|
||||
expect((await sources.read(entry.href)).module).toMatchObject({ default: 1 })
|
||||
expect(watched).toContain(fileURLToPath(json))
|
||||
await Bun.write(json, '{"value":2}')
|
||||
expect((await sources.read(entry.href)).module).toMatchObject({ default: 2 })
|
||||
})
|
||||
|
||||
test("real watchers observe atomic saves to nested, outside-root, and symlinked helpers", async () => {
|
||||
let changes = 0
|
||||
const watcher = createSourceWatcher(() => {
|
||||
changes++
|
||||
})
|
||||
using _watcher = { [Symbol.dispose]: watcher.dispose }
|
||||
await using sources = await fixture(watcher.wait)
|
||||
const entry = new URL("plugin/tui.ts", sources.url)
|
||||
const helper = new URL("shared/nested/helper.ts", sources.url)
|
||||
await Bun.write(helper, "export const value = 1")
|
||||
await Bun.write(entry, 'export { value as default } from "./link"')
|
||||
await symlink(fileURLToPath(helper), fileURLToPath(new URL("plugin/link.ts", sources.url)))
|
||||
expect((await sources.read(entry.href)).module).toMatchObject({ default: 1 })
|
||||
const count = changes
|
||||
await Bun.write(new URL(helper.href + ".new"), "export const value = 2")
|
||||
await rename(new URL(helper.href + ".new"), helper)
|
||||
const deadline = Date.now() + 3000
|
||||
while (Date.now() < deadline) {
|
||||
if (changes > count) break
|
||||
await Bun.sleep(10)
|
||||
}
|
||||
expect(changes).toBeGreaterThan(count)
|
||||
expect((await sources.read(entry.href)).module).toMatchObject({ default: 2 })
|
||||
})
|
||||
|
||||
test("Node reloads the local ESM graph without relocating source files", async () => {
|
||||
await using dir = await tmpdir()
|
||||
const script = path.join(dir.path, "probe.ts")
|
||||
await Bun.write(
|
||||
script,
|
||||
`
|
||||
import { createPluginSources } from ${JSON.stringify(fileURLToPath(new URL("../src/plugin/source.ts", import.meta.url)))}
|
||||
import { writeFile } from "node:fs/promises"
|
||||
import assert from "node:assert/strict"
|
||||
const entry = new URL("./entry.mjs", import.meta.url)
|
||||
const helper = new URL("./helper.mjs", import.meta.url)
|
||||
const sources = createPluginSources(async () => {})
|
||||
try {
|
||||
await writeFile(entry, 'export { value as default, source } from "./helper.mjs"')
|
||||
await writeFile(helper, 'export const value = 1; export const source = import.meta.url')
|
||||
const initial = await sources.read(entry.href)
|
||||
assert.equal(initial.module.default, 1)
|
||||
assert.equal(new URL(initial.module.source).pathname, helper.pathname)
|
||||
assert.equal(await sources.read(entry.href), initial)
|
||||
await writeFile(helper, 'export const value = 2; export const source = import.meta.url')
|
||||
const updated = (await sources.read(entry.href)).module
|
||||
assert.equal(updated.default, 2)
|
||||
assert.equal(new URL(updated.source).pathname, helper.pathname)
|
||||
console.log("node graph reload passed")
|
||||
} finally { sources.dispose() }
|
||||
`,
|
||||
)
|
||||
const build = await Bun.build({
|
||||
entrypoints: [script],
|
||||
target: "node",
|
||||
format: "esm",
|
||||
outdir: dir.path,
|
||||
naming: "probe.mjs",
|
||||
})
|
||||
expect(build.success).toBe(true)
|
||||
const child = Bun.spawn(["node", "--no-warnings", path.join(dir.path, "probe.mjs")], {
|
||||
stdout: "pipe",
|
||||
stderr: "pipe",
|
||||
})
|
||||
const [stdout, stderr, exit] = await Promise.all([
|
||||
new Response(child.stdout).text(),
|
||||
new Response(child.stderr).text(),
|
||||
child.exited,
|
||||
])
|
||||
expect({ stdout, stderr, exit }).toEqual({ stdout: "node graph reload passed\n", stderr: "", exit: 0 })
|
||||
})
|
||||
|
||||
test("computed imports retain the importing helper's resolution base", async () => {
|
||||
await using sources = await fixture()
|
||||
const entry = new URL("tui.ts", sources.url)
|
||||
await Bun.write(entry, 'import { read } from "./nested/reader"; export default await read("./leaf.mjs")')
|
||||
await Bun.write(
|
||||
new URL("nested/reader.ts", sources.url),
|
||||
"export const read = async (name: string) => (await import(name)).default",
|
||||
)
|
||||
await Bun.write(new URL("nested/leaf.mjs", sources.url), 'export default "computed"')
|
||||
expect((await sources.read(entry.href)).module).toMatchObject({ default: "computed" })
|
||||
})
|
||||
|
||||
test("empty source modules remain valid dependencies", async () => {
|
||||
await using sources = await fixture()
|
||||
const entry = new URL("tui.ts", sources.url)
|
||||
await Bun.write(entry, 'import "./empty"; export default "ready"')
|
||||
await Bun.write(new URL("empty.ts", sources.url), "")
|
||||
expect((await sources.read(entry.href)).module).toMatchObject({ default: "ready" })
|
||||
})
|
||||
|
||||
test("folded imports are watched and dead imports need not be installed", async () => {
|
||||
await using sources = await fixture()
|
||||
const entry = new URL("tui.ts", sources.url)
|
||||
const helper = new URL("helper.ts", sources.url)
|
||||
await Bun.write(entry, 'if (false) require("not-installed"); export default (await import("./" + "helper")).default')
|
||||
await Bun.write(helper, 'export default "before"')
|
||||
expect((await sources.read(entry.href)).module).toMatchObject({ default: "before" })
|
||||
await Bun.write(helper, 'export default "after"')
|
||||
expect((await sources.read(entry.href)).module).toMatchObject({ default: "after" })
|
||||
})
|
||||
|
||||
test("cycles retain one canonical entrypoint per generation and old bindings stay pinned", async () => {
|
||||
await using sources = await fixture()
|
||||
const entry = new URL("tui.ts", sources.url)
|
||||
await Bun.write(
|
||||
entry,
|
||||
'import { read } from "./helper"; export const value = {}; export default () => read() === value',
|
||||
)
|
||||
await Bun.write(new URL("helper.ts", sources.url), 'import { value } from "./tui"; export const read = () => value')
|
||||
const before = (await sources.read(entry.href)).module
|
||||
if (typeof before !== "object" || before === null || !("default" in before) || typeof before.default !== "function")
|
||||
throw new Error("Missing cycle fixture")
|
||||
expect(before.default()).toBe(true)
|
||||
await Bun.write(
|
||||
entry,
|
||||
'import { read } from "./helper"; export const value = { changed: true }; export default () => read() === value',
|
||||
)
|
||||
const after = (await sources.read(entry.href)).module
|
||||
if (typeof after !== "object" || after === null || !("default" in after) || typeof after.default !== "function")
|
||||
throw new Error("Missing cycle fixture")
|
||||
expect(after.default()).toBe(true)
|
||||
expect(before.default()).toBe(true)
|
||||
})
|
||||
|
||||
test("an old callback retains its deferred helper after a failed replacement", async () => {
|
||||
await using sources = await fixture()
|
||||
const entry = new URL("tui.ts", sources.url)
|
||||
const helper = new URL("helper.ts", sources.url)
|
||||
await Bun.write(entry, 'export default async () => (await import("./helper")).default')
|
||||
await Bun.write(helper, 'export default "old helper"')
|
||||
const before = (await sources.read(entry.href)).module
|
||||
if (typeof before !== "object" || before === null || !("default" in before) || typeof before.default !== "function")
|
||||
throw new Error("Missing deferred fixture")
|
||||
await Bun.write(helper, 'export default "new helper"')
|
||||
await Bun.write(entry, 'throw new Error("replacement failed"); export default null')
|
||||
await expect(sources.read(entry.href)).rejects.toThrow("replacement failed")
|
||||
expect(await before.default()).toBe("old helper")
|
||||
})
|
||||
|
||||
test("each helper resolves packages from its own directory", async () => {
|
||||
await using sources = await fixture()
|
||||
const entry = new URL("tui.ts", sources.url)
|
||||
await Bun.write(
|
||||
entry,
|
||||
'import { Plugin } from "@opencode-ai/plugin/tui"; import value from "./nested/helper"; export default { Plugin, value }',
|
||||
)
|
||||
await Bun.write(new URL("nested/helper.ts", sources.url), 'import value from "example"; export default value')
|
||||
for (const directory of ["", "nested/"]) {
|
||||
await Bun.write(
|
||||
new URL(directory + "node_modules/example/package.json", sources.url),
|
||||
'{"type":"module","main":"index.js"}',
|
||||
)
|
||||
await Bun.write(
|
||||
new URL(directory + "node_modules/example/index.js", sources.url),
|
||||
`export default ${JSON.stringify(directory || "root")}`,
|
||||
)
|
||||
}
|
||||
expect((await sources.read(entry.href)).module).toMatchObject({ default: { value: "nested/" } })
|
||||
})
|
||||
|
||||
test("unchanged evaluation failures do not repeat import-time effects", async () => {
|
||||
await using sources = await fixture()
|
||||
const entry = new URL("tui.ts", sources.url)
|
||||
const code = `import { appendFileSync } from "node:fs"
|
||||
appendFileSync(new URL("./attempts.log", import.meta.url), "attempt\\n")
|
||||
throw new Error("broken evaluation")`
|
||||
await Bun.write(entry, code)
|
||||
await expect(sources.read(entry.href)).rejects.toThrow("broken evaluation")
|
||||
await expect(sources.read(entry.href)).rejects.toThrow("broken evaluation")
|
||||
expect(await Bun.file(new URL("attempts.log", sources.url)).text()).toBe("attempt\n")
|
||||
await Bun.write(entry, code + "\n// another generation")
|
||||
await expect(sources.read(entry.href)).rejects.toThrow("broken evaluation")
|
||||
expect(await Bun.file(new URL("attempts.log", sources.url)).text()).toBe("attempt\nattempt\n")
|
||||
})
|
||||
|
||||
async function fixture(watch: (file: string) => Promise<void> = async () => {}) {
|
||||
const dir = await tmpdir()
|
||||
const sources = createPluginSources(watch)
|
||||
return {
|
||||
...sources,
|
||||
url: pathToFileURL(dir.path + path.sep),
|
||||
async [Symbol.asyncDispose]() {
|
||||
sources.dispose()
|
||||
await dir[Symbol.asyncDispose]()
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -12124,7 +12124,7 @@
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": ["strategy", "directory"],
|
||||
"required": ["strategy"],
|
||||
"additionalProperties": false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12124,7 +12124,7 @@
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": ["strategy", "directory"],
|
||||
"required": ["strategy"],
|
||||
"additionalProperties": false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -131,7 +131,7 @@ agents.
|
||||
|
||||
Control update checks from the global config. Set `update` to `"disable"` to
|
||||
skip them, `"notify"` to show available updates before installing them, or
|
||||
`"auto"` to install updates automatically. When omitted, `update` defaults to `"auto"`.
|
||||
`"auto"` to install updates automatically. When omitted, `update` defaults to `"notify"`.
|
||||
|
||||
Automatic installation does not restart a running server. Restart it manually to activate the installed update.
|
||||
Project-level values are ignored.
|
||||
|
||||
Reference in New Issue
Block a user