CLI perf: reduce deps (#22652)
This commit is contained in:
@@ -523,7 +523,9 @@
|
|||||||
"zod": "catalog:",
|
"zod": "catalog:",
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@tsconfig/bun": "catalog:",
|
||||||
"@types/bun": "catalog:",
|
"@types/bun": "catalog:",
|
||||||
|
"@types/npmcli__arborist": "6.3.3",
|
||||||
"@types/semver": "catalog:",
|
"@types/semver": "catalog:",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
research
|
research
|
||||||
dist
|
dist
|
||||||
|
dist-*
|
||||||
gen
|
gen
|
||||||
app.log
|
app.log
|
||||||
src/provider/models-snapshot.js
|
src/provider/models-snapshot.js
|
||||||
src/provider/models-snapshot.d.ts
|
src/provider/models-snapshot.d.ts
|
||||||
|
script/build-*.ts
|
||||||
|
temporary-*.md
|
||||||
|
|||||||
@@ -14,6 +14,7 @@
|
|||||||
"fix-node-pty": "bun run script/fix-node-pty.ts",
|
"fix-node-pty": "bun run script/fix-node-pty.ts",
|
||||||
"upgrade-opentui": "bun run script/upgrade-opentui.ts",
|
"upgrade-opentui": "bun run script/upgrade-opentui.ts",
|
||||||
"dev": "bun run --conditions=browser ./src/index.ts",
|
"dev": "bun run --conditions=browser ./src/index.ts",
|
||||||
|
"dev:temporary": "bun run --conditions=browser ./src/temporary.ts",
|
||||||
"db": "bun drizzle-kit"
|
"db": "bun drizzle-kit"
|
||||||
},
|
},
|
||||||
"bin": {
|
"bin": {
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
import { z } from "zod"
|
import { z } from "zod"
|
||||||
import { Config } from "../src/config"
|
import { Config } from "../src/config"
|
||||||
import { TuiConfig } from "../src/config"
|
import { TuiConfig } from "../src/cli/cmd/tui/config/tui"
|
||||||
|
|
||||||
function generate(schema: z.ZodType) {
|
function generate(schema: z.ZodType) {
|
||||||
const result = z.toJSONSchema(schema, {
|
const result = z.toJSONSchema(schema, {
|
||||||
|
|||||||
@@ -49,6 +49,7 @@ import { z } from "zod"
|
|||||||
import { LoadAPIKeyError } from "ai"
|
import { LoadAPIKeyError } from "ai"
|
||||||
import type { AssistantMessage, Event, OpencodeClient, SessionMessageResponse, ToolPart } from "@opencode-ai/sdk/v2"
|
import type { AssistantMessage, Event, OpencodeClient, SessionMessageResponse, ToolPart } from "@opencode-ai/sdk/v2"
|
||||||
import { applyPatch } from "diff"
|
import { applyPatch } from "diff"
|
||||||
|
import { InstallationVersion } from "@/installation/version"
|
||||||
|
|
||||||
type ModeOption = { id: string; name: string; description?: string }
|
type ModeOption = { id: string; name: string; description?: string }
|
||||||
type ModelOption = { modelId: string; name: string }
|
type ModelOption = { modelId: string; name: string }
|
||||||
@@ -570,7 +571,7 @@ export namespace ACP {
|
|||||||
authMethods: [authMethod],
|
authMethods: [authMethod],
|
||||||
agentInfo: {
|
agentInfo: {
|
||||||
name: "OpenCode",
|
name: "OpenCode",
|
||||||
version: Installation.VERSION,
|
version: InstallationVersion,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import { McpOAuthProvider } from "../../mcp/oauth-provider"
|
|||||||
import { Config } from "../../config"
|
import { Config } from "../../config"
|
||||||
import { Instance } from "../../project/instance"
|
import { Instance } from "../../project/instance"
|
||||||
import { Installation } from "../../installation"
|
import { Installation } from "../../installation"
|
||||||
|
import { InstallationVersion } from "../../installation/version"
|
||||||
import path from "path"
|
import path from "path"
|
||||||
import { Global } from "../../global"
|
import { Global } from "../../global"
|
||||||
import { modify, applyEdits } from "jsonc-parser"
|
import { modify, applyEdits } from "jsonc-parser"
|
||||||
@@ -697,7 +698,7 @@ export const McpDebugCommand = cmd({
|
|||||||
params: {
|
params: {
|
||||||
protocolVersion: "2024-11-05",
|
protocolVersion: "2024-11-05",
|
||||||
capabilities: {},
|
capabilities: {},
|
||||||
clientInfo: { name: "opencode-debug", version: Installation.VERSION },
|
clientInfo: { name: "opencode-debug", version: InstallationVersion },
|
||||||
},
|
},
|
||||||
id: 1,
|
id: 1,
|
||||||
}),
|
}),
|
||||||
@@ -746,7 +747,7 @@ export const McpDebugCommand = cmd({
|
|||||||
try {
|
try {
|
||||||
const client = new Client({
|
const client = new Client({
|
||||||
name: "opencode-debug",
|
name: "opencode-debug",
|
||||||
version: Installation.VERSION,
|
version: InstallationVersion,
|
||||||
})
|
})
|
||||||
await client.connect(transport)
|
await client.connect(transport)
|
||||||
prompts.log.success("Connection successful (already authenticated)")
|
prompts.log.success("Connection successful (already authenticated)")
|
||||||
|
|||||||
@@ -57,7 +57,7 @@ import { ArgsProvider, useArgs, type Args } from "./context/args"
|
|||||||
import open from "open"
|
import open from "open"
|
||||||
import { PromptRefProvider, usePromptRef } from "./context/prompt"
|
import { PromptRefProvider, usePromptRef } from "./context/prompt"
|
||||||
import { TuiConfigProvider, useTuiConfig } from "./context/tui-config"
|
import { TuiConfigProvider, useTuiConfig } from "./context/tui-config"
|
||||||
import { TuiConfig } from "@/config"
|
import { TuiConfig } from "@/cli/cmd/tui/config/tui"
|
||||||
import { createTuiApi, TuiPluginRuntime, type RouteMap } from "./plugin"
|
import { createTuiApi, TuiPluginRuntime, type RouteMap } from "./plugin"
|
||||||
import { FormatError, FormatUnknownError } from "@/cli/error"
|
import { FormatError, FormatUnknownError } from "@/cli/error"
|
||||||
|
|
||||||
@@ -235,7 +235,10 @@ function App(props: { onSnapshot?: () => Promise<string[]> }) {
|
|||||||
renderer,
|
renderer,
|
||||||
})
|
})
|
||||||
const [ready, setReady] = createSignal(false)
|
const [ready, setReady] = createSignal(false)
|
||||||
TuiPluginRuntime.init(api)
|
TuiPluginRuntime.init({
|
||||||
|
api,
|
||||||
|
config: tuiConfig,
|
||||||
|
})
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
console.error("Failed to load TUI plugins", error)
|
console.error("Failed to load TUI plugins", error)
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -2,9 +2,7 @@ import { cmd } from "../cmd"
|
|||||||
import { UI } from "@/cli/ui"
|
import { UI } from "@/cli/ui"
|
||||||
import { tui } from "./app"
|
import { tui } from "./app"
|
||||||
import { win32DisableProcessedInput, win32InstallCtrlCGuard } from "./win32"
|
import { win32DisableProcessedInput, win32InstallCtrlCGuard } from "./win32"
|
||||||
import { TuiConfig } from "@/config"
|
import { TuiConfig } from "@/cli/cmd/tui/config/tui"
|
||||||
import { Instance } from "@/project/instance"
|
|
||||||
import { existsSync } from "fs"
|
|
||||||
|
|
||||||
export const AttachCommand = cmd({
|
export const AttachCommand = cmd({
|
||||||
command: "attach <url>",
|
command: "attach <url>",
|
||||||
@@ -66,10 +64,7 @@ export const AttachCommand = cmd({
|
|||||||
const auth = `Basic ${Buffer.from(`opencode:${password}`).toString("base64")}`
|
const auth = `Basic ${Buffer.from(`opencode:${password}`).toString("base64")}`
|
||||||
return { Authorization: auth }
|
return { Authorization: auth }
|
||||||
})()
|
})()
|
||||||
const config = await Instance.provide({
|
const config = await TuiConfig.get()
|
||||||
directory: directory && existsSync(directory) ? directory : process.cwd(),
|
|
||||||
fn: () => TuiConfig.get(),
|
|
||||||
})
|
|
||||||
await tui({
|
await tui({
|
||||||
url: args.url,
|
url: args.url,
|
||||||
config,
|
config,
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ export function DialogAgent() {
|
|||||||
return (
|
return (
|
||||||
<DialogSelect
|
<DialogSelect
|
||||||
title="Select agent"
|
title="Select agent"
|
||||||
current={local.agent.current().name}
|
current={local.agent.current()?.name}
|
||||||
options={options()}
|
options={options()}
|
||||||
onSelect={(option) => {
|
onSelect={(option) => {
|
||||||
local.agent.set(option.value)
|
local.agent.set(option.value)
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { TextAttributes } from "@opentui/core"
|
|||||||
import { useKeyboard, useRenderer, useTerminalDimensions } from "@opentui/solid"
|
import { useKeyboard, useRenderer, useTerminalDimensions } from "@opentui/solid"
|
||||||
import * as Clipboard from "@tui/util/clipboard"
|
import * as Clipboard from "@tui/util/clipboard"
|
||||||
import { createSignal } from "solid-js"
|
import { createSignal } from "solid-js"
|
||||||
import { Installation } from "@/installation"
|
import { InstallationVersion } from "@/installation/version"
|
||||||
import { win32FlushInputBuffer } from "../win32"
|
import { win32FlushInputBuffer } from "../win32"
|
||||||
import { getScrollAcceleration } from "../util/scroll"
|
import { getScrollAcceleration } from "../util/scroll"
|
||||||
|
|
||||||
@@ -53,7 +53,7 @@ export function ErrorComponent(props: {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
issueURL.searchParams.set("opencode-version", Installation.VERSION)
|
issueURL.searchParams.set("opencode-version", InstallationVersion)
|
||||||
|
|
||||||
const copyIssueURL = () => {
|
const copyIssueURL = () => {
|
||||||
void Clipboard.copy(issueURL.toString()).then(() => {
|
void Clipboard.copy(issueURL.toString()).then(() => {
|
||||||
|
|||||||
@@ -602,6 +602,8 @@ export function Prompt(props: PromptProps) {
|
|||||||
if (props.disabled) return
|
if (props.disabled) return
|
||||||
if (autocomplete?.visible) return
|
if (autocomplete?.visible) return
|
||||||
if (!store.prompt.input) return
|
if (!store.prompt.input) return
|
||||||
|
const agent = local.agent.current()
|
||||||
|
if (!agent) return
|
||||||
const trimmed = store.prompt.input.trim()
|
const trimmed = store.prompt.input.trim()
|
||||||
if (trimmed === "exit" || trimmed === "quit" || trimmed === ":q") {
|
if (trimmed === "exit" || trimmed === "quit" || trimmed === ":q") {
|
||||||
void exit()
|
void exit()
|
||||||
@@ -662,7 +664,7 @@ export function Prompt(props: PromptProps) {
|
|||||||
if (store.mode === "shell") {
|
if (store.mode === "shell") {
|
||||||
void sdk.client.session.shell({
|
void sdk.client.session.shell({
|
||||||
sessionID,
|
sessionID,
|
||||||
agent: local.agent.current().name,
|
agent: agent.name,
|
||||||
model: {
|
model: {
|
||||||
providerID: selectedModel.providerID,
|
providerID: selectedModel.providerID,
|
||||||
modelID: selectedModel.modelID,
|
modelID: selectedModel.modelID,
|
||||||
@@ -689,7 +691,7 @@ export function Prompt(props: PromptProps) {
|
|||||||
sessionID,
|
sessionID,
|
||||||
command: command.slice(1),
|
command: command.slice(1),
|
||||||
arguments: args,
|
arguments: args,
|
||||||
agent: local.agent.current().name,
|
agent: agent.name,
|
||||||
model: `${selectedModel.providerID}/${selectedModel.modelID}`,
|
model: `${selectedModel.providerID}/${selectedModel.modelID}`,
|
||||||
messageID,
|
messageID,
|
||||||
variant,
|
variant,
|
||||||
@@ -706,7 +708,7 @@ export function Prompt(props: PromptProps) {
|
|||||||
sessionID,
|
sessionID,
|
||||||
...selectedModel,
|
...selectedModel,
|
||||||
messageID,
|
messageID,
|
||||||
agent: local.agent.current().name,
|
agent: agent.name,
|
||||||
model: selectedModel,
|
model: selectedModel,
|
||||||
variant,
|
variant,
|
||||||
parts: [
|
parts: [
|
||||||
@@ -829,7 +831,9 @@ export function Prompt(props: PromptProps) {
|
|||||||
const highlight = createMemo(() => {
|
const highlight = createMemo(() => {
|
||||||
if (keybind.leader) return theme.border
|
if (keybind.leader) return theme.border
|
||||||
if (store.mode === "shell") return theme.primary
|
if (store.mode === "shell") return theme.primary
|
||||||
return local.agent.color(local.agent.current().name)
|
const agent = local.agent.current()
|
||||||
|
if (!agent) return theme.border
|
||||||
|
return local.agent.color(agent.name)
|
||||||
})
|
})
|
||||||
|
|
||||||
const showVariant = createMemo(() => {
|
const showVariant = createMemo(() => {
|
||||||
@@ -851,7 +855,8 @@ export function Prompt(props: PromptProps) {
|
|||||||
})
|
})
|
||||||
|
|
||||||
const spinnerDef = createMemo(() => {
|
const spinnerDef = createMemo(() => {
|
||||||
const color = local.agent.color(local.agent.current().name)
|
const agent = local.agent.current()
|
||||||
|
const color = agent ? local.agent.color(agent.name) : theme.border
|
||||||
return {
|
return {
|
||||||
frames: createFrames({
|
frames: createFrames({
|
||||||
color,
|
color,
|
||||||
@@ -1041,7 +1046,7 @@ export function Prompt(props: PromptProps) {
|
|||||||
const isUrl = /^(https?):\/\//.test(filepath)
|
const isUrl = /^(https?):\/\//.test(filepath)
|
||||||
if (!isUrl) {
|
if (!isUrl) {
|
||||||
try {
|
try {
|
||||||
const mime = Filesystem.mimeType(filepath)
|
const mime = await Filesystem.mimeType(filepath)
|
||||||
const filename = path.basename(filepath)
|
const filename = path.basename(filepath)
|
||||||
// Handle SVG as raw text content, not as base64 image
|
// Handle SVG as raw text content, not as base64 image
|
||||||
if (mime === "image/svg+xml") {
|
if (mime === "image/svg+xml") {
|
||||||
@@ -1107,22 +1112,26 @@ export function Prompt(props: PromptProps) {
|
|||||||
/>
|
/>
|
||||||
<box flexDirection="row" flexShrink={0} paddingTop={1} gap={1} justifyContent="space-between">
|
<box flexDirection="row" flexShrink={0} paddingTop={1} gap={1} justifyContent="space-between">
|
||||||
<box flexDirection="row" gap={1}>
|
<box flexDirection="row" gap={1}>
|
||||||
<text fg={highlight()}>
|
<Show when={local.agent.current()} fallback={<box height={1} />}>
|
||||||
{store.mode === "shell" ? "Shell" : Locale.titlecase(local.agent.current().name)}{" "}
|
{(agent) => (
|
||||||
</text>
|
<>
|
||||||
<Show when={store.mode === "normal"}>
|
<text fg={highlight()}>{store.mode === "shell" ? "Shell" : Locale.titlecase(agent().name)} </text>
|
||||||
<box flexDirection="row" gap={1}>
|
<Show when={store.mode === "normal"}>
|
||||||
<text flexShrink={0} fg={keybind.leader ? theme.textMuted : theme.text}>
|
<box flexDirection="row" gap={1}>
|
||||||
{local.model.parsed().model}
|
<text flexShrink={0} fg={keybind.leader ? theme.textMuted : theme.text}>
|
||||||
</text>
|
{local.model.parsed().model}
|
||||||
<text fg={theme.textMuted}>{currentProviderLabel()}</text>
|
</text>
|
||||||
<Show when={showVariant()}>
|
<text fg={theme.textMuted}>{currentProviderLabel()}</text>
|
||||||
<text fg={theme.textMuted}>·</text>
|
<Show when={showVariant()}>
|
||||||
<text>
|
<text fg={theme.textMuted}>·</text>
|
||||||
<span style={{ fg: theme.warning, bold: true }}>{local.model.variant.current()}</span>
|
<text>
|
||||||
</text>
|
<span style={{ fg: theme.warning, bold: true }}>{local.model.variant.current()}</span>
|
||||||
</Show>
|
</text>
|
||||||
</box>
|
</Show>
|
||||||
|
</box>
|
||||||
|
</Show>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</Show>
|
</Show>
|
||||||
</box>
|
</box>
|
||||||
<Show when={hasRightContent()}>
|
<Show when={hasRightContent()}>
|
||||||
|
|||||||
@@ -0,0 +1,5 @@
|
|||||||
|
import { Context } from "effect"
|
||||||
|
|
||||||
|
export const CurrentWorkingDirectory = Context.Reference<string>("CurrentWorkingDirectory", {
|
||||||
|
defaultValue: () => process.cwd(),
|
||||||
|
})
|
||||||
+5
-10
@@ -2,13 +2,11 @@ import path from "path"
|
|||||||
import { type ParseError as JsoncParseError, applyEdits, modify, parse as parseJsonc } from "jsonc-parser"
|
import { type ParseError as JsoncParseError, applyEdits, modify, parse as parseJsonc } from "jsonc-parser"
|
||||||
import { unique } from "remeda"
|
import { unique } from "remeda"
|
||||||
import z from "zod"
|
import z from "zod"
|
||||||
import * as ConfigPaths from "./paths"
|
|
||||||
import { TuiInfo, TuiOptions } from "./tui-schema"
|
import { TuiInfo, TuiOptions } from "./tui-schema"
|
||||||
import { Instance } from "@/project/instance"
|
|
||||||
import { Flag } from "@/flag/flag"
|
import { Flag } from "@/flag/flag"
|
||||||
import { Log } from "@/util"
|
|
||||||
import { Filesystem } from "@/util"
|
|
||||||
import { Global } from "@/global"
|
import { Global } from "@/global"
|
||||||
|
import { Filesystem, Log } from "@/util"
|
||||||
|
import * as ConfigPaths from "@/config/paths"
|
||||||
|
|
||||||
const log = Log.create({ service: "tui.migrate" })
|
const log = Log.create({ service: "tui.migrate" })
|
||||||
|
|
||||||
@@ -26,9 +24,9 @@ const TuiLegacy = z
|
|||||||
.strip()
|
.strip()
|
||||||
|
|
||||||
interface MigrateInput {
|
interface MigrateInput {
|
||||||
|
cwd: string
|
||||||
directories: string[]
|
directories: string[]
|
||||||
custom?: string
|
custom?: string
|
||||||
managed: string
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -134,16 +132,13 @@ async function backupAndStripLegacy(file: string, source: string) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
async function opencodeFiles(input: { directories: string[]; managed: string }) {
|
async function opencodeFiles(input: { directories: string[]; cwd: string }) {
|
||||||
const project = Flag.OPENCODE_DISABLE_PROJECT_CONFIG
|
const project = Flag.OPENCODE_DISABLE_PROJECT_CONFIG ? [] : await ConfigPaths.projectFiles("opencode", input.cwd)
|
||||||
? []
|
|
||||||
: await ConfigPaths.projectFiles("opencode", Instance.directory, Instance.worktree)
|
|
||||||
const files = [...project, ...ConfigPaths.fileInDirectory(Global.Path.config, "opencode")]
|
const files = [...project, ...ConfigPaths.fileInDirectory(Global.Path.config, "opencode")]
|
||||||
for (const dir of unique(input.directories)) {
|
for (const dir of unique(input.directories)) {
|
||||||
files.push(...ConfigPaths.fileInDirectory(dir, "opencode"))
|
files.push(...ConfigPaths.fileInDirectory(dir, "opencode"))
|
||||||
}
|
}
|
||||||
if (Flag.OPENCODE_CONFIG) files.push(Flag.OPENCODE_CONFIG)
|
if (Flag.OPENCODE_CONFIG) files.push(Flag.OPENCODE_CONFIG)
|
||||||
files.push(...ConfigPaths.fileInDirectory(input.managed, "opencode"))
|
|
||||||
|
|
||||||
const existing = await Promise.all(
|
const existing = await Promise.all(
|
||||||
unique(files).map(async (file) => {
|
unique(files).map(async (file) => {
|
||||||
+4
-3
@@ -1,9 +1,10 @@
|
|||||||
import z from "zod"
|
import z from "zod"
|
||||||
import * as Config from "./config"
|
import { ConfigPlugin } from "@/config/plugin"
|
||||||
|
import { ConfigKeybinds } from "@/config/keybinds"
|
||||||
|
|
||||||
const KeybindOverride = z
|
const KeybindOverride = z
|
||||||
.object(
|
.object(
|
||||||
Object.fromEntries(Object.keys(Config.Keybinds.shape).map((key) => [key, z.string().optional()])) as Record<
|
Object.fromEntries(Object.keys(ConfigKeybinds.Keybinds.shape).map((key) => [key, z.string().optional()])) as Record<
|
||||||
string,
|
string,
|
||||||
z.ZodOptional<z.ZodString>
|
z.ZodOptional<z.ZodString>
|
||||||
>,
|
>,
|
||||||
@@ -30,7 +31,7 @@ export const TuiInfo = z
|
|||||||
$schema: z.string().optional(),
|
$schema: z.string().optional(),
|
||||||
theme: z.string().optional(),
|
theme: z.string().optional(),
|
||||||
keybinds: KeybindOverride.optional(),
|
keybinds: KeybindOverride.optional(),
|
||||||
plugin: Config.PluginSpec.array().optional(),
|
plugin: ConfigPlugin.Spec.array().optional(),
|
||||||
plugin_enabled: z.record(z.string(), z.boolean()).optional(),
|
plugin_enabled: z.record(z.string(), z.boolean()).optional(),
|
||||||
})
|
})
|
||||||
.extend(TuiOptions.shape)
|
.extend(TuiOptions.shape)
|
||||||
@@ -0,0 +1,208 @@
|
|||||||
|
import z from "zod"
|
||||||
|
import { mergeDeep, unique } from "remeda"
|
||||||
|
import { Context, Effect, Fiber, Layer } from "effect"
|
||||||
|
import * as ConfigPaths from "@/config/paths"
|
||||||
|
import { migrateTuiConfig } from "./tui-migrate"
|
||||||
|
import { TuiInfo } from "./tui-schema"
|
||||||
|
import { Flag } from "@/flag/flag"
|
||||||
|
import { isRecord } from "@/util/record"
|
||||||
|
import { Global } from "@/global"
|
||||||
|
import { AppFileSystem } from "@opencode-ai/shared/filesystem"
|
||||||
|
import { Npm } from "@opencode-ai/shared/npm"
|
||||||
|
import { CurrentWorkingDirectory } from "./cwd"
|
||||||
|
import { ConfigPlugin } from "@/config/plugin"
|
||||||
|
import { ConfigKeybinds } from "@/config/keybinds"
|
||||||
|
import { InstallationLocal, InstallationVersion } from "@/installation/version"
|
||||||
|
import { makeRuntime } from "@/cli/effect/runtime"
|
||||||
|
import { Filesystem, Log } from "@/util"
|
||||||
|
|
||||||
|
export namespace TuiConfig {
|
||||||
|
const log = Log.create({ service: "tui.config" })
|
||||||
|
|
||||||
|
export const Info = TuiInfo
|
||||||
|
|
||||||
|
type Acc = {
|
||||||
|
result: Info
|
||||||
|
}
|
||||||
|
|
||||||
|
type State = {
|
||||||
|
config: Info
|
||||||
|
deps: Array<Fiber.Fiber<void, AppFileSystem.Error>>
|
||||||
|
}
|
||||||
|
|
||||||
|
export type Info = z.output<typeof Info> & {
|
||||||
|
// Internal resolved plugin list used by runtime loading.
|
||||||
|
plugin_origins?: ConfigPlugin.Origin[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Interface {
|
||||||
|
readonly get: () => Effect.Effect<Info>
|
||||||
|
readonly waitForDependencies: () => Effect.Effect<void>
|
||||||
|
}
|
||||||
|
|
||||||
|
export class Service extends Context.Service<Service, Interface>()("@opencode/TuiConfig") {}
|
||||||
|
|
||||||
|
function pluginScope(file: string, ctx: { directory: string }): ConfigPlugin.Scope {
|
||||||
|
if (Filesystem.contains(ctx.directory, file)) return "local"
|
||||||
|
// if (ctx.worktree !== "/" && Filesystem.contains(ctx.worktree, file)) return "local"
|
||||||
|
return "global"
|
||||||
|
}
|
||||||
|
|
||||||
|
function customPath() {
|
||||||
|
return Flag.OPENCODE_TUI_CONFIG
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalize(raw: Record<string, unknown>) {
|
||||||
|
const data = { ...raw }
|
||||||
|
if (!("tui" in data)) return data
|
||||||
|
if (!isRecord(data.tui)) {
|
||||||
|
delete data.tui
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
const tui = data.tui
|
||||||
|
delete data.tui
|
||||||
|
return {
|
||||||
|
...tui,
|
||||||
|
...data,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function mergeFile(acc: Acc, file: string, ctx: { directory: string }) {
|
||||||
|
const data = await loadFile(file)
|
||||||
|
acc.result = mergeDeep(acc.result, data)
|
||||||
|
if (!data.plugin?.length) return
|
||||||
|
|
||||||
|
const scope = pluginScope(file, ctx)
|
||||||
|
const plugins = ConfigPlugin.deduplicatePluginOrigins([
|
||||||
|
...(acc.result.plugin_origins ?? []),
|
||||||
|
...data.plugin.map((spec) => ({ spec, scope, source: file })),
|
||||||
|
])
|
||||||
|
acc.result.plugin = plugins.map((item) => item.spec)
|
||||||
|
acc.result.plugin_origins = plugins
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadState(ctx: { directory: string }) {
|
||||||
|
let projectFiles = Flag.OPENCODE_DISABLE_PROJECT_CONFIG ? [] : await ConfigPaths.projectFiles("tui", ctx.directory)
|
||||||
|
const directories = await ConfigPaths.directories(ctx.directory)
|
||||||
|
const custom = customPath()
|
||||||
|
await migrateTuiConfig({ directories, custom, cwd: ctx.directory })
|
||||||
|
// Re-compute after migration since migrateTuiConfig may have created new tui.json files
|
||||||
|
projectFiles = Flag.OPENCODE_DISABLE_PROJECT_CONFIG ? [] : await ConfigPaths.projectFiles("tui", ctx.directory)
|
||||||
|
|
||||||
|
const acc: Acc = {
|
||||||
|
result: {},
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const file of ConfigPaths.fileInDirectory(Global.Path.config, "tui")) {
|
||||||
|
await mergeFile(acc, file, ctx)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (custom) {
|
||||||
|
await mergeFile(acc, custom, ctx)
|
||||||
|
log.debug("loaded custom tui config", { path: custom })
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const file of projectFiles) {
|
||||||
|
await mergeFile(acc, file, ctx)
|
||||||
|
}
|
||||||
|
|
||||||
|
const dirs = unique(directories).filter((dir) => dir.endsWith(".opencode") || dir === Flag.OPENCODE_CONFIG_DIR)
|
||||||
|
|
||||||
|
for (const dir of dirs) {
|
||||||
|
if (!dir.endsWith(".opencode") && dir !== Flag.OPENCODE_CONFIG_DIR) continue
|
||||||
|
for (const file of ConfigPaths.fileInDirectory(dir, "tui")) {
|
||||||
|
await mergeFile(acc, file, ctx)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const keybinds = { ...(acc.result.keybinds ?? {}) }
|
||||||
|
if (process.platform === "win32") {
|
||||||
|
// Native Windows terminals do not support POSIX suspend, so prefer prompt undo.
|
||||||
|
keybinds.terminal_suspend = "none"
|
||||||
|
keybinds.input_undo ??= unique([
|
||||||
|
"ctrl+z",
|
||||||
|
...ConfigKeybinds.Keybinds.shape.input_undo.parse(undefined).split(","),
|
||||||
|
]).join(",")
|
||||||
|
}
|
||||||
|
acc.result.keybinds = ConfigKeybinds.Keybinds.parse(keybinds)
|
||||||
|
|
||||||
|
return {
|
||||||
|
config: acc.result,
|
||||||
|
dirs: acc.result.plugin?.length ? dirs : [],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const layer = Layer.effect(
|
||||||
|
Service,
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const directory = yield* CurrentWorkingDirectory
|
||||||
|
const npm = yield* Npm.Service
|
||||||
|
const data = yield* Effect.promise(() => loadState({ directory }))
|
||||||
|
const deps = yield* Effect.forEach(
|
||||||
|
data.dirs,
|
||||||
|
(dir) =>
|
||||||
|
npm
|
||||||
|
.install(dir, {
|
||||||
|
add: ["@opencode-ai/plugin" + (InstallationLocal ? "" : "@" + InstallationVersion)],
|
||||||
|
})
|
||||||
|
.pipe(Effect.forkScoped),
|
||||||
|
{
|
||||||
|
concurrency: "unbounded",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
const get = Effect.fn("TuiConfig.get")(() => Effect.succeed(data.config))
|
||||||
|
|
||||||
|
const waitForDependencies = Effect.fn("TuiConfig.waitForDependencies")(() =>
|
||||||
|
Effect.forEach(deps, Fiber.join, { concurrency: "unbounded" }).pipe(Effect.ignore(), Effect.asVoid),
|
||||||
|
)
|
||||||
|
return Service.of({ get, waitForDependencies })
|
||||||
|
}).pipe(Effect.withSpan("TuiConfig.layer")),
|
||||||
|
)
|
||||||
|
|
||||||
|
export const defaultLayer = layer.pipe(Layer.provide(Npm.defaultLayer))
|
||||||
|
|
||||||
|
const { runPromise } = makeRuntime(Service, defaultLayer)
|
||||||
|
|
||||||
|
export async function waitForDependencies() {
|
||||||
|
await runPromise((svc) => svc.waitForDependencies())
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function get() {
|
||||||
|
return runPromise((svc) => svc.get())
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadFile(filepath: string): Promise<Info> {
|
||||||
|
const text = await ConfigPaths.readFile(filepath)
|
||||||
|
if (!text) return {}
|
||||||
|
return load(text, filepath).catch((error) => {
|
||||||
|
log.warn("failed to load tui config", { path: filepath, error })
|
||||||
|
return {}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async function load(text: string, configFilepath: string): Promise<Info> {
|
||||||
|
const raw = await ConfigPaths.parseText(text, configFilepath, "empty")
|
||||||
|
if (!isRecord(raw)) return {}
|
||||||
|
|
||||||
|
// Flatten a nested "tui" key so users who wrote `{ "tui": { ... } }` inside tui.json
|
||||||
|
// (mirroring the old opencode.json shape) still get their settings applied.
|
||||||
|
const normalized = normalize(raw)
|
||||||
|
|
||||||
|
const parsed = Info.safeParse(normalized)
|
||||||
|
if (!parsed.success) {
|
||||||
|
log.warn("invalid tui config", { path: configFilepath, issues: parsed.error.issues })
|
||||||
|
return {}
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = parsed.data
|
||||||
|
if (data.plugin) {
|
||||||
|
for (let i = 0; i < data.plugin.length; i++) {
|
||||||
|
data.plugin[i] = await ConfigPlugin.resolvePluginSpec(data.plugin[i], configFilepath)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import { createMemo } from "solid-js"
|
import { createMemo } from "solid-js"
|
||||||
import { Keybind } from "@/util"
|
import { Keybind } from "@/util"
|
||||||
import { pipe, mapValues } from "remeda"
|
import { pipe, mapValues } from "remeda"
|
||||||
import type { TuiConfig } from "@/config"
|
import type { TuiConfig } from "@/cli/cmd/tui/config/tui"
|
||||||
import type { ParsedKey, Renderable } from "@opentui/core"
|
import type { ParsedKey, Renderable } from "@opentui/core"
|
||||||
import { createStore } from "solid-js/store"
|
import { createStore } from "solid-js/store"
|
||||||
import { useKeyboard, useRenderer } from "@opentui/solid"
|
import { useKeyboard, useRenderer } from "@opentui/solid"
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { createStore } from "solid-js/store"
|
import { createStore } from "solid-js/store"
|
||||||
|
import { createSimpleContext } from "./helper"
|
||||||
import { batch, createEffect, createMemo } from "solid-js"
|
import { batch, createEffect, createMemo } from "solid-js"
|
||||||
import { useSync } from "@tui/context/sync"
|
import { useSync } from "@tui/context/sync"
|
||||||
import { useTheme } from "@tui/context/theme"
|
import { useTheme } from "@tui/context/theme"
|
||||||
@@ -6,14 +7,20 @@ import { uniqueBy } from "remeda"
|
|||||||
import path from "path"
|
import path from "path"
|
||||||
import { Global } from "@/global"
|
import { Global } from "@/global"
|
||||||
import { iife } from "@/util/iife"
|
import { iife } from "@/util/iife"
|
||||||
import { createSimpleContext } from "./helper"
|
|
||||||
import { useToast } from "../ui/toast"
|
import { useToast } from "../ui/toast"
|
||||||
import { Provider } from "@/provider"
|
|
||||||
import { useArgs } from "./args"
|
import { useArgs } from "./args"
|
||||||
import { useSDK } from "./sdk"
|
import { useSDK } from "./sdk"
|
||||||
import { RGBA } from "@opentui/core"
|
import { RGBA } from "@opentui/core"
|
||||||
import { Filesystem } from "@/util"
|
import { Filesystem } from "@/util"
|
||||||
|
|
||||||
|
export function parseModel(model: string) {
|
||||||
|
const [providerID, ...rest] = model.split("/")
|
||||||
|
return {
|
||||||
|
providerID: providerID,
|
||||||
|
modelID: rest.join("/"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
|
export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
|
||||||
name: "Local",
|
name: "Local",
|
||||||
init: () => {
|
init: () => {
|
||||||
@@ -37,10 +44,8 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
|
|||||||
const agent = iife(() => {
|
const agent = iife(() => {
|
||||||
const agents = createMemo(() => sync.data.agent.filter((x) => x.mode !== "subagent" && !x.hidden))
|
const agents = createMemo(() => sync.data.agent.filter((x) => x.mode !== "subagent" && !x.hidden))
|
||||||
const visibleAgents = createMemo(() => sync.data.agent.filter((x) => !x.hidden))
|
const visibleAgents = createMemo(() => sync.data.agent.filter((x) => !x.hidden))
|
||||||
const [agentStore, setAgentStore] = createStore<{
|
const [agentStore, setAgentStore] = createStore({
|
||||||
current: string
|
current: undefined as string | undefined,
|
||||||
}>({
|
|
||||||
current: agents()[0].name,
|
|
||||||
})
|
})
|
||||||
const { theme } = useTheme()
|
const { theme } = useTheme()
|
||||||
const colors = createMemo(() => [
|
const colors = createMemo(() => [
|
||||||
@@ -57,7 +62,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
|
|||||||
return agents()
|
return agents()
|
||||||
},
|
},
|
||||||
current() {
|
current() {
|
||||||
return agents().find((x) => x.name === agentStore.current) ?? agents()[0]
|
return agents().find((x) => x.name === agentStore.current) ?? agents().at(0)
|
||||||
},
|
},
|
||||||
set(name: string) {
|
set(name: string) {
|
||||||
if (!agents().some((x) => x.name === name))
|
if (!agents().some((x) => x.name === name))
|
||||||
@@ -153,7 +158,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
|
|||||||
const args = useArgs()
|
const args = useArgs()
|
||||||
const fallbackModel = createMemo(() => {
|
const fallbackModel = createMemo(() => {
|
||||||
if (args.model) {
|
if (args.model) {
|
||||||
const { providerID, modelID } = Provider.parseModel(args.model)
|
const { providerID, modelID } = parseModel(args.model)
|
||||||
if (isModelValid({ providerID, modelID })) {
|
if (isModelValid({ providerID, modelID })) {
|
||||||
return {
|
return {
|
||||||
providerID,
|
providerID,
|
||||||
@@ -163,7 +168,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (sync.data.config.model) {
|
if (sync.data.config.model) {
|
||||||
const { providerID, modelID } = Provider.parseModel(sync.data.config.model)
|
const { providerID, modelID } = parseModel(sync.data.config.model)
|
||||||
if (isModelValid({ providerID, modelID })) {
|
if (isModelValid({ providerID, modelID })) {
|
||||||
return {
|
return {
|
||||||
providerID,
|
providerID,
|
||||||
@@ -194,8 +199,8 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
|
|||||||
const a = agent.current()
|
const a = agent.current()
|
||||||
return (
|
return (
|
||||||
getFirstValidModel(
|
getFirstValidModel(
|
||||||
() => modelStore.model[a.name],
|
() => a && modelStore.model[a.name],
|
||||||
() => a.model,
|
() => a && a.model,
|
||||||
fallbackModel,
|
fallbackModel,
|
||||||
) ?? undefined
|
) ?? undefined
|
||||||
)
|
)
|
||||||
@@ -240,7 +245,9 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
|
|||||||
if (next >= recent.length) next = 0
|
if (next >= recent.length) next = 0
|
||||||
const val = recent[next]
|
const val = recent[next]
|
||||||
if (!val) return
|
if (!val) return
|
||||||
setModelStore("model", agent.current().name, { ...val })
|
const a = agent.current()
|
||||||
|
if (!a) return
|
||||||
|
setModelStore("model", a.name, { ...val })
|
||||||
},
|
},
|
||||||
cycleFavorite(direction: 1 | -1) {
|
cycleFavorite(direction: 1 | -1) {
|
||||||
const favorites = modelStore.favorite.filter((item) => isModelValid(item))
|
const favorites = modelStore.favorite.filter((item) => isModelValid(item))
|
||||||
@@ -266,7 +273,9 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
|
|||||||
}
|
}
|
||||||
const next = favorites[index]
|
const next = favorites[index]
|
||||||
if (!next) return
|
if (!next) return
|
||||||
setModelStore("model", agent.current().name, { ...next })
|
const a = agent.current()
|
||||||
|
if (!a) return
|
||||||
|
setModelStore("model", a.name, { ...next })
|
||||||
const uniq = uniqueBy([next, ...modelStore.recent], (x) => `${x.providerID}/${x.modelID}`)
|
const uniq = uniqueBy([next, ...modelStore.recent], (x) => `${x.providerID}/${x.modelID}`)
|
||||||
if (uniq.length > 10) uniq.pop()
|
if (uniq.length > 10) uniq.pop()
|
||||||
setModelStore(
|
setModelStore(
|
||||||
@@ -285,7 +294,9 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
|
|||||||
})
|
})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
setModelStore("model", agent.current().name, model)
|
const a = agent.current()
|
||||||
|
if (!a) return
|
||||||
|
setModelStore("model", a.name, model)
|
||||||
if (options?.recent) {
|
if (options?.recent) {
|
||||||
const uniq = uniqueBy([model, ...modelStore.recent], (x) => `${x.providerID}/${x.modelID}`)
|
const uniq = uniqueBy([model, ...modelStore.recent], (x) => `${x.providerID}/${x.modelID}`)
|
||||||
if (uniq.length > 10) uniq.pop()
|
if (uniq.length > 10) uniq.pop()
|
||||||
@@ -387,6 +398,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
|
|||||||
// Automatically update model when agent changes
|
// Automatically update model when agent changes
|
||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
const value = agent.current()
|
const value = agent.current()
|
||||||
|
if (!value) return
|
||||||
if (value.model) {
|
if (value.model) {
|
||||||
if (isModelValid(value.model))
|
if (isModelValid(value.model))
|
||||||
model.set({
|
model.set({
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ import { useExit } from "./exit"
|
|||||||
import { useArgs } from "./args"
|
import { useArgs } from "./args"
|
||||||
import { batch, createEffect, on } from "solid-js"
|
import { batch, createEffect, on } from "solid-js"
|
||||||
import { Log } from "@/util"
|
import { Log } from "@/util"
|
||||||
import { ConsoleState, emptyConsoleState, type ConsoleState as ConsoleStateType } from "@/config/console-state"
|
import { emptyConsoleState, type ConsoleState } from "@/config/console-state"
|
||||||
|
|
||||||
export const { use: useSync, provider: SyncProvider } = createSimpleContext({
|
export const { use: useSync, provider: SyncProvider } = createSimpleContext({
|
||||||
name: "Sync",
|
name: "Sync",
|
||||||
@@ -39,7 +39,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
|
|||||||
provider: Provider[]
|
provider: Provider[]
|
||||||
provider_default: Record<string, string>
|
provider_default: Record<string, string>
|
||||||
provider_next: ProviderListResponse
|
provider_next: ProviderListResponse
|
||||||
console_state: ConsoleStateType
|
console_state: ConsoleState
|
||||||
provider_auth: Record<string, ProviderAuthMethod[]>
|
provider_auth: Record<string, ProviderAuthMethod[]>
|
||||||
agent: Agent[]
|
agent: Agent[]
|
||||||
command: Command[]
|
command: Command[]
|
||||||
@@ -363,7 +363,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
|
|||||||
const providerListPromise = sdk.client.provider.list({ workspace }, { throwOnError: true })
|
const providerListPromise = sdk.client.provider.list({ workspace }, { throwOnError: true })
|
||||||
const consoleStatePromise = sdk.client.experimental.console
|
const consoleStatePromise = sdk.client.experimental.console
|
||||||
.get({ workspace }, { throwOnError: true })
|
.get({ workspace }, { throwOnError: true })
|
||||||
.then((x) => ConsoleState.parse(x.data))
|
.then((x) => x.data)
|
||||||
.catch(() => emptyConsoleState)
|
.catch(() => emptyConsoleState)
|
||||||
const agentsPromise = sdk.client.app.agents({ workspace }, { throwOnError: true })
|
const agentsPromise = sdk.client.app.agents({ workspace }, { throwOnError: true })
|
||||||
const configPromise = sdk.client.config.get({ workspace }, { throwOnError: true })
|
const configPromise = sdk.client.config.get({ workspace }, { throwOnError: true })
|
||||||
@@ -378,7 +378,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
|
|||||||
]
|
]
|
||||||
|
|
||||||
await Promise.all(blockingRequests)
|
await Promise.all(blockingRequests)
|
||||||
.then(() => {
|
.then(async () => {
|
||||||
const providersResponse = providersPromise.then((x) => x.data!)
|
const providersResponse = providersPromise.then((x) => x.data!)
|
||||||
const providerListResponse = providerListPromise.then((x) => x.data!)
|
const providerListResponse = providerListPromise.then((x) => x.data!)
|
||||||
const consoleStateResponse = consoleStatePromise
|
const consoleStateResponse = consoleStatePromise
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { TuiConfig } from "@/config"
|
import { TuiConfig } from "@/cli/cmd/tui/config/tui"
|
||||||
import { createSimpleContext } from "./helper"
|
import { createSimpleContext } from "./helper"
|
||||||
|
|
||||||
export const { use: useTuiConfig, provider: TuiConfigProvider } = createSimpleContext({
|
export const { use: useTuiConfig, provider: TuiConfigProvider } = createSimpleContext({
|
||||||
|
|||||||
@@ -0,0 +1,6 @@
|
|||||||
|
import { Layer } from "effect"
|
||||||
|
import { TuiConfig } from "./config/tui"
|
||||||
|
import { Npm } from "@opencode-ai/shared/npm"
|
||||||
|
import { Observability } from "@/effect/observability"
|
||||||
|
|
||||||
|
export const CliLayer = Observability.layer.pipe(Layer.merge(TuiConfig.layer), Layer.provide(Npm.defaultLayer))
|
||||||
@@ -8,7 +8,7 @@ import type { useSDK } from "@tui/context/sdk"
|
|||||||
import type { useSync } from "@tui/context/sync"
|
import type { useSync } from "@tui/context/sync"
|
||||||
import type { useTheme } from "@tui/context/theme"
|
import type { useTheme } from "@tui/context/theme"
|
||||||
import { Dialog as DialogUI, type useDialog } from "@tui/ui/dialog"
|
import { Dialog as DialogUI, type useDialog } from "@tui/ui/dialog"
|
||||||
import type { TuiConfig } from "@/config"
|
import type { TuiConfig } from "@/cli/cmd/tui/config/tui"
|
||||||
import { createPluginKeybind } from "../context/plugin-keybinds"
|
import { createPluginKeybind } from "../context/plugin-keybinds"
|
||||||
import type { useKV } from "../context/kv"
|
import type { useKV } from "../context/kv"
|
||||||
import { DialogAlert } from "../ui/dialog-alert"
|
import { DialogAlert } from "../ui/dialog-alert"
|
||||||
@@ -18,7 +18,7 @@ import { DialogSelect, type DialogSelectOption as SelectOption } from "../ui/dia
|
|||||||
import { Prompt } from "../component/prompt"
|
import { Prompt } from "../component/prompt"
|
||||||
import { Slot as HostSlot } from "./slots"
|
import { Slot as HostSlot } from "./slots"
|
||||||
import type { useToast } from "../ui/toast"
|
import type { useToast } from "../ui/toast"
|
||||||
import { Installation } from "@/installation"
|
import { InstallationVersion } from "@/installation/version"
|
||||||
|
|
||||||
type RouteEntry = {
|
type RouteEntry = {
|
||||||
key: symbol
|
key: symbol
|
||||||
@@ -189,7 +189,7 @@ function stateApi(sync: ReturnType<typeof useSync>): TuiPluginApi["state"] {
|
|||||||
function appApi(): TuiPluginApi["app"] {
|
function appApi(): TuiPluginApi["app"] {
|
||||||
return {
|
return {
|
||||||
get version() {
|
get version() {
|
||||||
return Installation.VERSION
|
return InstallationVersion
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import "@opentui/solid/runtime-plugin-support"
|
// import "@opentui/solid/runtime-plugin-support"
|
||||||
import {
|
import {
|
||||||
type TuiDispose,
|
type TuiDispose,
|
||||||
type TuiPlugin,
|
type TuiPlugin,
|
||||||
@@ -12,13 +12,10 @@ import {
|
|||||||
} from "@opencode-ai/plugin/tui"
|
} from "@opencode-ai/plugin/tui"
|
||||||
import path from "path"
|
import path from "path"
|
||||||
import { fileURLToPath } from "url"
|
import { fileURLToPath } from "url"
|
||||||
|
import { TuiConfig } from "@/cli/cmd/tui/config/tui"
|
||||||
import { Config } from "@/config"
|
|
||||||
import { TuiConfig } from "@/config"
|
|
||||||
import { Log } from "@/util"
|
import { Log } from "@/util"
|
||||||
import { errorData, errorMessage } from "@/util/error"
|
import { errorData, errorMessage } from "@/util/error"
|
||||||
import { isRecord } from "@/util/record"
|
import { isRecord } from "@/util/record"
|
||||||
import { Instance } from "@/project/instance"
|
|
||||||
import {
|
import {
|
||||||
readPackageThemes,
|
readPackageThemes,
|
||||||
readPluginId,
|
readPluginId,
|
||||||
@@ -39,16 +36,17 @@ import { Flag } from "@/flag/flag"
|
|||||||
import { INTERNAL_TUI_PLUGINS, type InternalTuiPlugin } from "./internal"
|
import { INTERNAL_TUI_PLUGINS, type InternalTuiPlugin } from "./internal"
|
||||||
import { setupSlots, Slot as View } from "./slots"
|
import { setupSlots, Slot as View } from "./slots"
|
||||||
import type { HostPluginApi, HostSlots } from "./slots"
|
import type { HostPluginApi, HostSlots } from "./slots"
|
||||||
|
import { ConfigPlugin } from "@/config/plugin"
|
||||||
|
|
||||||
type PluginLoad = {
|
type PluginLoad = {
|
||||||
options: Config.PluginOptions | undefined
|
options: ConfigPlugin.Options | undefined
|
||||||
spec: string
|
spec: string
|
||||||
target: string
|
target: string
|
||||||
retry: boolean
|
retry: boolean
|
||||||
source: PluginSource | "internal"
|
source: PluginSource | "internal"
|
||||||
id: string
|
id: string
|
||||||
module: TuiPluginModule
|
module: TuiPluginModule
|
||||||
origin: Config.PluginOrigin
|
origin: ConfigPlugin.Origin
|
||||||
theme_root: string
|
theme_root: string
|
||||||
theme_files: string[]
|
theme_files: string[]
|
||||||
}
|
}
|
||||||
@@ -77,7 +75,7 @@ type RuntimeState = {
|
|||||||
slots: HostSlots
|
slots: HostSlots
|
||||||
plugins: PluginEntry[]
|
plugins: PluginEntry[]
|
||||||
plugins_by_id: Map<string, PluginEntry>
|
plugins_by_id: Map<string, PluginEntry>
|
||||||
pending: Map<string, Config.PluginOrigin>
|
pending: Map<string, ConfigPlugin.Origin>
|
||||||
}
|
}
|
||||||
|
|
||||||
const log = Log.create({ service: "tui.plugin" })
|
const log = Log.create({ service: "tui.plugin" })
|
||||||
@@ -147,7 +145,7 @@ function resolveRoot(root: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function createThemeInstaller(
|
function createThemeInstaller(
|
||||||
meta: Config.PluginOrigin,
|
meta: ConfigPlugin.Origin,
|
||||||
root: string,
|
root: string,
|
||||||
spec: string,
|
spec: string,
|
||||||
plugin: PluginEntry,
|
plugin: PluginEntry,
|
||||||
@@ -590,7 +588,7 @@ function applyInitialPluginEnabledState(state: RuntimeState, config: TuiConfig.I
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function resolveExternalPlugins(list: Config.PluginOrigin[], wait: () => Promise<void>) {
|
async function resolveExternalPlugins(list: ConfigPlugin.Origin[], wait: () => Promise<void>) {
|
||||||
return PluginLoader.loadExternal({
|
return PluginLoader.loadExternal({
|
||||||
items: list,
|
items: list,
|
||||||
kind: "tui",
|
kind: "tui",
|
||||||
@@ -745,7 +743,7 @@ async function addExternalPluginEntries(state: RuntimeState, ready: PluginLoad[]
|
|||||||
return { plugins, ok }
|
return { plugins, ok }
|
||||||
}
|
}
|
||||||
|
|
||||||
function defaultPluginOrigin(state: RuntimeState, spec: string): Config.PluginOrigin {
|
function defaultPluginOrigin(state: RuntimeState, spec: string): ConfigPlugin.Origin {
|
||||||
return {
|
return {
|
||||||
spec,
|
spec,
|
||||||
scope: "local",
|
scope: "local",
|
||||||
@@ -786,19 +784,12 @@ async function addPluginBySpec(state: RuntimeState | undefined, raw: string) {
|
|||||||
if (!spec) return false
|
if (!spec) return false
|
||||||
|
|
||||||
const cfg = state.pending.get(spec) ?? defaultPluginOrigin(state, spec)
|
const cfg = state.pending.get(spec) ?? defaultPluginOrigin(state, spec)
|
||||||
const next = Config.pluginSpecifier(cfg.spec)
|
const next = ConfigPlugin.pluginSpecifier(cfg.spec)
|
||||||
if (state.plugins.some((plugin) => plugin.load.spec === next)) {
|
if (state.plugins.some((plugin) => plugin.load.spec === next)) {
|
||||||
state.pending.delete(spec)
|
state.pending.delete(spec)
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
const ready = await resolveExternalPlugins([cfg], () => TuiConfig.waitForDependencies())
|
||||||
const ready = await Instance.provide({
|
|
||||||
directory: state.directory,
|
|
||||||
fn: () => resolveExternalPlugins([cfg], () => TuiConfig.waitForDependencies()),
|
|
||||||
}).catch((error) => {
|
|
||||||
fail("failed to add tui plugin", { path: next, error })
|
|
||||||
return [] as PluginLoad[]
|
|
||||||
})
|
|
||||||
if (!ready.length) {
|
if (!ready.length) {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
@@ -905,7 +896,7 @@ async function installPluginBySpec(
|
|||||||
const tui = manifest.targets.find((item) => item.kind === "tui")
|
const tui = manifest.targets.find((item) => item.kind === "tui")
|
||||||
if (tui) {
|
if (tui) {
|
||||||
const file = patch.items.find((item) => item.kind === "tui")?.file
|
const file = patch.items.find((item) => item.kind === "tui")?.file
|
||||||
const next = tui.opts ? ([spec, tui.opts] as Config.PluginSpec) : spec
|
const next = tui.opts ? ([spec, tui.opts] as ConfigPlugin.Spec) : spec
|
||||||
state.pending.set(spec, {
|
state.pending.set(spec, {
|
||||||
spec: next,
|
spec: next,
|
||||||
scope: global ? "global" : "local",
|
scope: global ? "global" : "local",
|
||||||
@@ -926,7 +917,7 @@ export namespace TuiPluginRuntime {
|
|||||||
let runtime: RuntimeState | undefined
|
let runtime: RuntimeState | undefined
|
||||||
export const Slot = View
|
export const Slot = View
|
||||||
|
|
||||||
export async function init(api: HostPluginApi) {
|
export async function init(input: { api: HostPluginApi; config: TuiConfig.Info }) {
|
||||||
const cwd = process.cwd()
|
const cwd = process.cwd()
|
||||||
if (loaded) {
|
if (loaded) {
|
||||||
if (dir !== cwd) {
|
if (dir !== cwd) {
|
||||||
@@ -936,7 +927,7 @@ export namespace TuiPluginRuntime {
|
|||||||
}
|
}
|
||||||
|
|
||||||
dir = cwd
|
dir = cwd
|
||||||
loaded = load(api)
|
loaded = load(input)
|
||||||
return loaded
|
return loaded
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -975,7 +966,8 @@ export namespace TuiPluginRuntime {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function load(api: Api) {
|
async function load(input: { api: Api; config: TuiConfig.Info }) {
|
||||||
|
const { api, config } = input
|
||||||
const cwd = process.cwd()
|
const cwd = process.cwd()
|
||||||
const slots = setupSlots(api)
|
const slots = setupSlots(api)
|
||||||
const next: RuntimeState = {
|
const next: RuntimeState = {
|
||||||
@@ -987,45 +979,40 @@ export namespace TuiPluginRuntime {
|
|||||||
pending: new Map(),
|
pending: new Map(),
|
||||||
}
|
}
|
||||||
runtime = next
|
runtime = next
|
||||||
|
try {
|
||||||
|
const records = Flag.OPENCODE_PURE ? [] : (config.plugin_origins ?? [])
|
||||||
|
if (Flag.OPENCODE_PURE && config.plugin_origins?.length) {
|
||||||
|
log.info("skipping external tui plugins in pure mode", { count: config.plugin_origins.length })
|
||||||
|
}
|
||||||
|
|
||||||
await Instance.provide({
|
for (const item of INTERNAL_TUI_PLUGINS) {
|
||||||
directory: cwd,
|
log.info("loading internal tui plugin", { id: item.id })
|
||||||
fn: async () => {
|
const entry = loadInternalPlugin(item)
|
||||||
const config = await TuiConfig.get()
|
const meta = createMeta(entry.source, entry.spec, entry.target, undefined, entry.id)
|
||||||
const records = Flag.OPENCODE_PURE ? [] : (config.plugin_origins ?? [])
|
addPluginEntry(next, {
|
||||||
if (Flag.OPENCODE_PURE && config.plugin_origins?.length) {
|
id: entry.id,
|
||||||
log.info("skipping external tui plugins in pure mode", { count: config.plugin_origins.length })
|
load: entry,
|
||||||
}
|
meta,
|
||||||
|
themes: {},
|
||||||
|
plugin: entry.module.tui,
|
||||||
|
enabled: true,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
for (const item of INTERNAL_TUI_PLUGINS) {
|
const ready = await resolveExternalPlugins(records, () => TuiConfig.waitForDependencies())
|
||||||
log.info("loading internal tui plugin", { id: item.id })
|
await addExternalPluginEntries(next, ready)
|
||||||
const entry = loadInternalPlugin(item)
|
|
||||||
const meta = createMeta(entry.source, entry.spec, entry.target, undefined, entry.id)
|
|
||||||
addPluginEntry(next, {
|
|
||||||
id: entry.id,
|
|
||||||
load: entry,
|
|
||||||
meta,
|
|
||||||
themes: {},
|
|
||||||
plugin: entry.module.tui,
|
|
||||||
enabled: true,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
const ready = await resolveExternalPlugins(records, () => TuiConfig.waitForDependencies())
|
applyInitialPluginEnabledState(next, config)
|
||||||
await addExternalPluginEntries(next, ready)
|
for (const plugin of next.plugins) {
|
||||||
|
if (!plugin.enabled) continue
|
||||||
applyInitialPluginEnabledState(next, config)
|
// Keep plugin execution sequential for deterministic side effects:
|
||||||
for (const plugin of next.plugins) {
|
// command registration order affects keybind/command precedence,
|
||||||
if (!plugin.enabled) continue
|
// route registration is last-wins when ids collide,
|
||||||
// Keep plugin execution sequential for deterministic side effects:
|
// and hook chains rely on stable plugin ordering.
|
||||||
// command registration order affects keybind/command precedence,
|
await activatePluginEntry(next, plugin, false)
|
||||||
// route registration is last-wins when ids collide,
|
}
|
||||||
// and hook chains rely on stable plugin ordering.
|
} catch (error) {
|
||||||
await activatePluginEntry(next, plugin, false)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
}).catch((error) => {
|
|
||||||
fail("failed to load tui plugins", { directory: cwd, error })
|
fail("failed to load tui plugins", { directory: cwd, error })
|
||||||
})
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { useSync } from "@tui/context/sync"
|
|||||||
import { createMemo, Show } from "solid-js"
|
import { createMemo, Show } from "solid-js"
|
||||||
import { useTheme } from "../../context/theme"
|
import { useTheme } from "../../context/theme"
|
||||||
import { useTuiConfig } from "../../context/tui-config"
|
import { useTuiConfig } from "../../context/tui-config"
|
||||||
import { Installation } from "@/installation"
|
import { InstallationVersion } from "@/installation/version"
|
||||||
import { TuiPluginRuntime } from "../../plugin"
|
import { TuiPluginRuntime } from "../../plugin"
|
||||||
|
|
||||||
import { getScrollAcceleration } from "../../util/scroll"
|
import { getScrollAcceleration } from "../../util/scroll"
|
||||||
@@ -64,7 +64,7 @@ export function Sidebar(props: { sessionID: string; overlay?: boolean }) {
|
|||||||
<span style={{ fg: theme.text }}>
|
<span style={{ fg: theme.text }}>
|
||||||
<b>Code</b>
|
<b>Code</b>
|
||||||
</span>{" "}
|
</span>{" "}
|
||||||
<span>{Installation.VERSION}</span>
|
<span>{InstallationVersion}</span>
|
||||||
</text>
|
</text>
|
||||||
</TuiPluginRuntime.Slot>
|
</TuiPluginRuntime.Slot>
|
||||||
</box>
|
</box>
|
||||||
|
|||||||
@@ -8,14 +8,13 @@ import { UI } from "@/cli/ui"
|
|||||||
import { Log } from "@/util"
|
import { Log } from "@/util"
|
||||||
import { errorMessage } from "@/util/error"
|
import { errorMessage } from "@/util/error"
|
||||||
import { withTimeout } from "@/util/timeout"
|
import { withTimeout } from "@/util/timeout"
|
||||||
import { withNetworkOptions, resolveNetworkOptions } from "@/cli/network"
|
import { withNetworkOptions, resolveNetworkOptionsNoConfig } from "@/cli/network"
|
||||||
import { Filesystem } from "@/util"
|
import { Filesystem } from "@/util"
|
||||||
import type { GlobalEvent } from "@opencode-ai/sdk/v2"
|
import type { GlobalEvent } from "@opencode-ai/sdk/v2"
|
||||||
import type { EventSource } from "./context/sdk"
|
import type { EventSource } from "./context/sdk"
|
||||||
import { win32DisableProcessedInput, win32InstallCtrlCGuard } from "./win32"
|
import { win32DisableProcessedInput, win32InstallCtrlCGuard } from "./win32"
|
||||||
import { TuiConfig } from "@/config"
|
|
||||||
import { Instance } from "@/project/instance"
|
|
||||||
import { writeHeapSnapshot } from "v8"
|
import { writeHeapSnapshot } from "v8"
|
||||||
|
import { TuiConfig } from "./config/tui"
|
||||||
|
|
||||||
declare global {
|
declare global {
|
||||||
const OPENCODE_WORKER_PATH: string
|
const OPENCODE_WORKER_PATH: string
|
||||||
@@ -177,12 +176,9 @@ export const TuiThreadCommand = cmd({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const prompt = await input(args.prompt)
|
const prompt = await input(args.prompt)
|
||||||
const config = await Instance.provide({
|
const config = await TuiConfig.get()
|
||||||
directory: cwd,
|
|
||||||
fn: () => TuiConfig.get(),
|
|
||||||
})
|
|
||||||
|
|
||||||
const network = await resolveNetworkOptions(args)
|
const network = resolveNetworkOptionsNoConfig(args)
|
||||||
const external =
|
const external =
|
||||||
process.argv.includes("--port") ||
|
process.argv.includes("--port") ||
|
||||||
process.argv.includes("--hostname") ||
|
process.argv.includes("--hostname") ||
|
||||||
@@ -237,3 +233,4 @@ export const TuiThreadCommand = cmd({
|
|||||||
process.exit(0)
|
process.exit(0)
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
// scratch
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { useTerminalDimensions } from "@opentui/solid"
|
|||||||
import { SplitBorder } from "../component/border"
|
import { SplitBorder } from "../component/border"
|
||||||
import { TextAttributes } from "@opentui/core"
|
import { TextAttributes } from "@opentui/core"
|
||||||
import z from "zod"
|
import z from "zod"
|
||||||
import { TuiEvent } from "../event"
|
import { type TuiEvent } from "../event"
|
||||||
|
|
||||||
export type ToastOptions = z.infer<typeof TuiEvent.ToastShow.properties>
|
export type ToastOptions = z.infer<typeof TuiEvent.ToastShow.properties>
|
||||||
|
|
||||||
@@ -56,8 +56,7 @@ function init() {
|
|||||||
|
|
||||||
const toast = {
|
const toast = {
|
||||||
show(options: ToastOptions) {
|
show(options: ToastOptions) {
|
||||||
const parsedOptions = TuiEvent.ToastShow.properties.parse(options)
|
const { duration, ...currentToast } = options
|
||||||
const { duration, ...currentToast } = parsedOptions
|
|
||||||
setStore("currentToast", currentToast)
|
setStore("currentToast", currentToast)
|
||||||
if (timeoutHandle) clearTimeout(timeoutHandle)
|
if (timeoutHandle) clearTimeout(timeoutHandle)
|
||||||
timeoutHandle = setTimeout(() => {
|
timeoutHandle = setTimeout(() => {
|
||||||
|
|||||||
@@ -1,12 +1,21 @@
|
|||||||
import { platform, release } from "os"
|
import { platform, release } from "os"
|
||||||
import clipboardy from "clipboardy"
|
|
||||||
import { lazy } from "../../../../util/lazy.js"
|
import { lazy } from "../../../../util/lazy.js"
|
||||||
import { tmpdir } from "os"
|
import { tmpdir } from "os"
|
||||||
import path from "path"
|
import path from "path"
|
||||||
import fs from "fs/promises"
|
import fs from "fs/promises"
|
||||||
import { Filesystem } from "../../../../util"
|
import * as Filesystem from "../../../../util/filesystem"
|
||||||
import { Process } from "../../../../util"
|
import * as Process from "../../../../util/process"
|
||||||
import { which } from "../../../../util/which"
|
|
||||||
|
// Lazy load which and clipboardy to avoid expensive execa/which/isexe chain at startup
|
||||||
|
const getWhich = lazy(async () => {
|
||||||
|
const { which } = await import("../../../../util/which")
|
||||||
|
return which
|
||||||
|
})
|
||||||
|
|
||||||
|
const getClipboardy = lazy(async () => {
|
||||||
|
const { default: clipboardy } = await import("clipboardy")
|
||||||
|
return clipboardy
|
||||||
|
})
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Writes text to clipboard via OSC 52 escape sequence.
|
* Writes text to clipboard via OSC 52 escape sequence.
|
||||||
@@ -94,14 +103,16 @@ export async function read(): Promise<Content | undefined> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const clipboardy = await getClipboardy()
|
||||||
const text = await clipboardy.read().catch(() => {})
|
const text = await clipboardy.read().catch(() => {})
|
||||||
if (text) {
|
if (text) {
|
||||||
return { data: text, mime: "text/plain" }
|
return { data: text, mime: "text/plain" }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const getCopyMethod = lazy(() => {
|
const getCopyMethod = lazy(async () => {
|
||||||
const os = platform()
|
const os = platform()
|
||||||
|
const which = await getWhich()
|
||||||
|
|
||||||
if (os === "darwin" && which("osascript")) {
|
if (os === "darwin" && which("osascript")) {
|
||||||
console.log("clipboard: using osascript")
|
console.log("clipboard: using osascript")
|
||||||
@@ -180,11 +191,13 @@ const getCopyMethod = lazy(() => {
|
|||||||
|
|
||||||
console.log("clipboard: no native support")
|
console.log("clipboard: no native support")
|
||||||
return async (text: string) => {
|
return async (text: string) => {
|
||||||
|
const clipboardy = await getClipboardy()
|
||||||
await clipboardy.write(text).catch(() => {})
|
await clipboardy.write(text).catch(() => {})
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
export async function copy(text: string): Promise<void> {
|
export async function copy(text: string): Promise<void> {
|
||||||
writeOsc52(text)
|
writeOsc52(text)
|
||||||
await getCopyMethod()(text)
|
const method = await getCopyMethod()
|
||||||
|
await method(text)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { MacOSScrollAccel, type ScrollAcceleration } from "@opentui/core"
|
import { MacOSScrollAccel, type ScrollAcceleration } from "@opentui/core"
|
||||||
import type { TuiConfig } from "@/config"
|
import type { TuiConfig } from "@/cli/cmd/tui/config/tui"
|
||||||
|
|
||||||
export class CustomSpeedScroll implements ScrollAcceleration {
|
export class CustomSpeedScroll implements ScrollAcceleration {
|
||||||
constructor(private speed: number) {}
|
constructor(private speed: number) {}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { UI } from "../ui"
|
|||||||
import * as prompts from "@clack/prompts"
|
import * as prompts from "@clack/prompts"
|
||||||
import { AppRuntime } from "@/effect/app-runtime"
|
import { AppRuntime } from "@/effect/app-runtime"
|
||||||
import { Installation } from "../../installation"
|
import { Installation } from "../../installation"
|
||||||
|
import { InstallationVersion } from "../../installation/version"
|
||||||
|
|
||||||
export const UpgradeCommand = {
|
export const UpgradeCommand = {
|
||||||
command: "upgrade [target]",
|
command: "upgrade [target]",
|
||||||
@@ -47,13 +48,13 @@ export const UpgradeCommand = {
|
|||||||
? args.target.replace(/^v/, "")
|
? args.target.replace(/^v/, "")
|
||||||
: await AppRuntime.runPromise(Installation.Service.use((svc) => svc.latest()))
|
: await AppRuntime.runPromise(Installation.Service.use((svc) => svc.latest()))
|
||||||
|
|
||||||
if (Installation.VERSION === target) {
|
if (InstallationVersion === target) {
|
||||||
prompts.log.warn(`opencode upgrade skipped: ${target} is already installed`)
|
prompts.log.warn(`opencode upgrade skipped: ${target} is already installed`)
|
||||||
prompts.outro("Done")
|
prompts.outro("Done")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
prompts.log.info(`From ${Installation.VERSION} → ${target}`)
|
prompts.log.info(`From ${InstallationVersion} → ${target}`)
|
||||||
const spinner = prompts.spinner()
|
const spinner = prompts.spinner()
|
||||||
spinner.start("Upgrading...")
|
spinner.start("Upgrading...")
|
||||||
const err = await AppRuntime.runPromise(Installation.Service.use((svc) => svc.upgrade(method, target))).catch(
|
const err = await AppRuntime.runPromise(Installation.Service.use((svc) => svc.upgrade(method, target))).catch(
|
||||||
|
|||||||
@@ -0,0 +1,20 @@
|
|||||||
|
import { Observability } from "@/effect/observability"
|
||||||
|
import { Layer, type Context, ManagedRuntime, type Effect } from "effect"
|
||||||
|
|
||||||
|
export const memoMap = Layer.makeMemoMapUnsafe()
|
||||||
|
|
||||||
|
export function makeRuntime<I, S, E>(service: Context.Service<I, S>, layer: Layer.Layer<I, E>) {
|
||||||
|
let rt: ManagedRuntime.ManagedRuntime<I, E> | undefined
|
||||||
|
const getRuntime = () =>
|
||||||
|
(rt ??= ManagedRuntime.make(Layer.merge(layer, Observability.layer) as Layer.Layer<I, E>, { memoMap }))
|
||||||
|
|
||||||
|
return {
|
||||||
|
runSync: <A, Err>(fn: (svc: S) => Effect.Effect<A, Err, I>) => getRuntime().runSync(service.use(fn)),
|
||||||
|
runPromiseExit: <A, Err>(fn: (svc: S) => Effect.Effect<A, Err, I>, options?: Effect.RunOptions) =>
|
||||||
|
getRuntime().runPromiseExit(service.use(fn), options),
|
||||||
|
runPromise: <A, Err>(fn: (svc: S) => Effect.Effect<A, Err, I>, options?: Effect.RunOptions) =>
|
||||||
|
getRuntime().runPromise(service.use(fn), options),
|
||||||
|
runFork: <A, Err>(fn: (svc: S) => Effect.Effect<A, Err, I>) => getRuntime().runFork(service.use(fn)),
|
||||||
|
runCallback: <A, Err>(fn: (svc: S) => Effect.Effect<A, Err, I>) => getRuntime().runCallback(service.use(fn)),
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,48 +1,80 @@
|
|||||||
import { AccountServiceError, AccountTransportError } from "@/account"
|
import { NamedError } from "@opencode-ai/shared/util/error"
|
||||||
import { ConfigMarkdown } from "@/config"
|
|
||||||
import { errorFormat } from "@/util/error"
|
import { errorFormat } from "@/util/error"
|
||||||
import { Config } from "../config"
|
|
||||||
import { MCP } from "../mcp"
|
interface ErrorLike {
|
||||||
import { Provider } from "../provider"
|
name?: string
|
||||||
import { UI } from "./ui"
|
_tag?: string
|
||||||
|
message?: string
|
||||||
|
data?: Record<string, any>
|
||||||
|
}
|
||||||
|
|
||||||
|
function isTaggedError(error: unknown, tag: string): boolean {
|
||||||
|
return (
|
||||||
|
typeof error === "object" && error !== null && "_tag" in error && (error as Record<string, unknown>)._tag === tag
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
export function FormatError(input: unknown) {
|
export function FormatError(input: unknown) {
|
||||||
if (MCP.Failed.isInstance(input))
|
// MCPFailed: { name: string }
|
||||||
return `MCP server "${input.data.name}" failed. Note, opencode does not support MCP authentication yet.`
|
if (NamedError.hasName(input, "MCPFailed")) {
|
||||||
if (input instanceof AccountTransportError || input instanceof AccountServiceError) {
|
return `MCP server "${(input as ErrorLike).data?.name}" failed. Note, opencode does not support MCP authentication yet.`
|
||||||
return input.message
|
|
||||||
}
|
}
|
||||||
if (Provider.ModelNotFoundError.isInstance(input)) {
|
|
||||||
const { providerID, modelID, suggestions } = input.data
|
// AccountServiceError, AccountTransportError: TaggedErrorClass
|
||||||
|
if (isTaggedError(input, "AccountServiceError") || isTaggedError(input, "AccountTransportError")) {
|
||||||
|
return (input as ErrorLike).message ?? ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// ProviderModelNotFoundError: { providerID: string, modelID: string, suggestions?: string[] }
|
||||||
|
if (NamedError.hasName(input, "ProviderModelNotFoundError")) {
|
||||||
|
const data = (input as ErrorLike).data
|
||||||
|
const suggestions = data?.suggestions as string[] | undefined
|
||||||
return [
|
return [
|
||||||
`Model not found: ${providerID}/${modelID}`,
|
`Model not found: ${data?.providerID}/${data?.modelID}`,
|
||||||
...(Array.isArray(suggestions) && suggestions.length ? ["Did you mean: " + suggestions.join(", ")] : []),
|
...(Array.isArray(suggestions) && suggestions.length ? ["Did you mean: " + suggestions.join(", ")] : []),
|
||||||
`Try: \`opencode models\` to list available models`,
|
`Try: \`opencode models\` to list available models`,
|
||||||
`Or check your config (opencode.json) provider/model names`,
|
`Or check your config (opencode.json) provider/model names`,
|
||||||
].join("\n")
|
].join("\n")
|
||||||
}
|
}
|
||||||
if (Provider.InitError.isInstance(input)) {
|
|
||||||
return `Failed to initialize provider "${input.data.providerID}". Check credentials and configuration.`
|
|
||||||
}
|
|
||||||
if (Config.JsonError.isInstance(input)) {
|
|
||||||
return (
|
|
||||||
`Config file at ${input.data.path} is not valid JSON(C)` + (input.data.message ? `: ${input.data.message}` : "")
|
|
||||||
)
|
|
||||||
}
|
|
||||||
if (Config.ConfigDirectoryTypoError.isInstance(input)) {
|
|
||||||
return `Directory "${input.data.dir}" in ${input.data.path} is not valid. Rename the directory to "${input.data.suggestion}" or remove it. This is a common typo.`
|
|
||||||
}
|
|
||||||
if (ConfigMarkdown.FrontmatterError.isInstance(input)) {
|
|
||||||
return input.data.message
|
|
||||||
}
|
|
||||||
if (Config.InvalidError.isInstance(input))
|
|
||||||
return [
|
|
||||||
`Configuration is invalid${input.data.path && input.data.path !== "config" ? ` at ${input.data.path}` : ""}` +
|
|
||||||
(input.data.message ? `: ${input.data.message}` : ""),
|
|
||||||
...(input.data.issues?.map((issue) => "↳ " + issue.message + " " + issue.path.join(".")) ?? []),
|
|
||||||
].join("\n")
|
|
||||||
|
|
||||||
if (UI.CancelledError.isInstance(input)) return ""
|
// ProviderInitError: { providerID: string }
|
||||||
|
if (NamedError.hasName(input, "ProviderInitError")) {
|
||||||
|
return `Failed to initialize provider "${(input as ErrorLike).data?.providerID}". Check credentials and configuration.`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ConfigJsonError: { path: string, message?: string }
|
||||||
|
if (NamedError.hasName(input, "ConfigJsonError")) {
|
||||||
|
const data = (input as ErrorLike).data
|
||||||
|
return `Config file at ${data?.path} is not valid JSON(C)` + (data?.message ? `: ${data.message}` : "")
|
||||||
|
}
|
||||||
|
|
||||||
|
// ConfigDirectoryTypoError: { dir: string, path: string, suggestion: string }
|
||||||
|
if (NamedError.hasName(input, "ConfigDirectoryTypoError")) {
|
||||||
|
const data = (input as ErrorLike).data
|
||||||
|
return `Directory "${data?.dir}" in ${data?.path} is not valid. Rename the directory to "${data?.suggestion}" or remove it. This is a common typo.`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ConfigFrontmatterError: { message: string }
|
||||||
|
if (NamedError.hasName(input, "ConfigFrontmatterError")) {
|
||||||
|
return (input as ErrorLike).data?.message ?? ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// ConfigInvalidError: { path?: string, message?: string, issues?: Array<{ message: string, path: string[] }> }
|
||||||
|
if (NamedError.hasName(input, "ConfigInvalidError")) {
|
||||||
|
const data = (input as ErrorLike).data
|
||||||
|
const path = data?.path
|
||||||
|
const message = data?.message
|
||||||
|
const issues = data?.issues as Array<{ message: string; path: string[] }> | undefined
|
||||||
|
return [
|
||||||
|
`Configuration is invalid${path && path !== "config" ? ` at ${path}` : ""}` + (message ? `: ${message}` : ""),
|
||||||
|
...(issues?.map((issue) => "↳ " + issue.message + " " + issue.path.join(".")) ?? []),
|
||||||
|
].join("\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
// UICancelledError: void (no data)
|
||||||
|
if (NamedError.hasName(input, "UICancelledError")) {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function FormatUnknownError(input: unknown): string {
|
export function FormatUnknownError(input: unknown): string {
|
||||||
|
|||||||
@@ -36,9 +36,12 @@ export type NetworkOptions = InferredOptionTypes<typeof options>
|
|||||||
export function withNetworkOptions<T>(yargs: Argv<T>) {
|
export function withNetworkOptions<T>(yargs: Argv<T>) {
|
||||||
return yargs.options(options)
|
return yargs.options(options)
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function resolveNetworkOptions(args: NetworkOptions) {
|
export async function resolveNetworkOptions(args: NetworkOptions) {
|
||||||
const config = await AppRuntime.runPromise(Config.Service.use((cfg) => cfg.getGlobal()))
|
const config = await AppRuntime.runPromise(Config.Service.use((cfg) => cfg.getGlobal()))
|
||||||
|
return resolveNetworkOptionsNoConfig(args, config)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveNetworkOptionsNoConfig(args: NetworkOptions, config?: Config.Info) {
|
||||||
const portExplicitlySet = process.argv.includes("--port")
|
const portExplicitlySet = process.argv.includes("--port")
|
||||||
const hostnameExplicitlySet = process.argv.includes("--hostname")
|
const hostnameExplicitlySet = process.argv.includes("--hostname")
|
||||||
const mdnsExplicitlySet = process.argv.includes("--mdns")
|
const mdnsExplicitlySet = process.argv.includes("--mdns")
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { Config } from "@/config"
|
|||||||
import { AppRuntime } from "@/effect/app-runtime"
|
import { AppRuntime } from "@/effect/app-runtime"
|
||||||
import { Flag } from "@/flag/flag"
|
import { Flag } from "@/flag/flag"
|
||||||
import { Installation } from "@/installation"
|
import { Installation } from "@/installation"
|
||||||
|
import { InstallationVersion } from "@/installation/version"
|
||||||
|
|
||||||
export async function upgrade() {
|
export async function upgrade() {
|
||||||
const config = await AppRuntime.runPromise(Config.Service.use((cfg) => cfg.getGlobal()))
|
const config = await AppRuntime.runPromise(Config.Service.use((cfg) => cfg.getGlobal()))
|
||||||
@@ -15,10 +16,10 @@ export async function upgrade() {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if (Installation.VERSION === latest) return
|
if (InstallationVersion === latest) return
|
||||||
if (config.autoupdate === false || Flag.OPENCODE_DISABLE_AUTOUPDATE) return
|
if (config.autoupdate === false || Flag.OPENCODE_DISABLE_AUTOUPDATE) return
|
||||||
|
|
||||||
const kind = Installation.getReleaseType(Installation.VERSION, latest)
|
const kind = Installation.getReleaseType(InstallationVersion, latest)
|
||||||
|
|
||||||
if (config.autoupdate === "notify" || kind !== "patch") {
|
if (config.autoupdate === "notify" || kind !== "patch") {
|
||||||
await Bus.publish(Installation.Event.UpdateAvailable, { version: latest })
|
await Bus.publish(Installation.Event.UpdateAvailable, { version: latest })
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ import {
|
|||||||
import { Instance, type InstanceContext } from "../project/instance"
|
import { Instance, type InstanceContext } from "../project/instance"
|
||||||
import * as LSPServer from "../lsp/server"
|
import * as LSPServer from "../lsp/server"
|
||||||
import { Installation } from "@/installation"
|
import { Installation } from "@/installation"
|
||||||
|
import { InstallationVersion } from "@/installation/version"
|
||||||
import * as ConfigMarkdown from "./markdown"
|
import * as ConfigMarkdown from "./markdown"
|
||||||
import { existsSync } from "fs"
|
import { existsSync } from "fs"
|
||||||
import { Bus } from "@/bus"
|
import { Bus } from "@/bus"
|
||||||
@@ -1266,7 +1267,7 @@ export const layer: Layer.Layer<
|
|||||||
const pkg = path.join(dir, "package.json")
|
const pkg = path.join(dir, "package.json")
|
||||||
const gitignore = path.join(dir, ".gitignore")
|
const gitignore = path.join(dir, ".gitignore")
|
||||||
const plugin = path.join(dir, "node_modules", "@opencode-ai", "plugin", "package.json")
|
const plugin = path.join(dir, "node_modules", "@opencode-ai", "plugin", "package.json")
|
||||||
const target = Installation.isLocal() ? "*" : Installation.VERSION
|
const target = Installation.isLocal() ? "*" : InstallationVersion
|
||||||
const json = yield* fs.readJson(pkg).pipe(
|
const json = yield* fs.readJson(pkg).pipe(
|
||||||
Effect.catch(() => Effect.succeed({} satisfies Package)),
|
Effect.catch(() => Effect.succeed({} satisfies Package)),
|
||||||
Effect.map((x): Package => (isRecord(x) ? (x as Package) : {})),
|
Effect.map((x): Package => (isRecord(x) ? (x as Package) : {})),
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
export * as Config from "./config"
|
export * as Config from "./config"
|
||||||
export * as ConfigMarkdown from "./markdown"
|
export * as ConfigMarkdown from "./markdown"
|
||||||
export * as ConfigPaths from "./paths"
|
export * as ConfigPaths from "./paths"
|
||||||
export * as TuiConfig from "./tui"
|
|
||||||
|
|||||||
@@ -0,0 +1,164 @@
|
|||||||
|
import z from "zod"
|
||||||
|
|
||||||
|
export namespace ConfigKeybinds {
|
||||||
|
export const Keybinds = z
|
||||||
|
.object({
|
||||||
|
leader: z.string().optional().default("ctrl+x").describe("Leader key for keybind combinations"),
|
||||||
|
app_exit: z.string().optional().default("ctrl+c,ctrl+d,<leader>q").describe("Exit the application"),
|
||||||
|
editor_open: z.string().optional().default("<leader>e").describe("Open external editor"),
|
||||||
|
theme_list: z.string().optional().default("<leader>t").describe("List available themes"),
|
||||||
|
sidebar_toggle: z.string().optional().default("<leader>b").describe("Toggle sidebar"),
|
||||||
|
scrollbar_toggle: z.string().optional().default("none").describe("Toggle session scrollbar"),
|
||||||
|
username_toggle: z.string().optional().default("none").describe("Toggle username visibility"),
|
||||||
|
status_view: z.string().optional().default("<leader>s").describe("View status"),
|
||||||
|
session_export: z.string().optional().default("<leader>x").describe("Export session to editor"),
|
||||||
|
session_new: z.string().optional().default("<leader>n").describe("Create a new session"),
|
||||||
|
session_list: z.string().optional().default("<leader>l").describe("List all sessions"),
|
||||||
|
session_timeline: z.string().optional().default("<leader>g").describe("Show session timeline"),
|
||||||
|
session_fork: z.string().optional().default("none").describe("Fork session from message"),
|
||||||
|
session_rename: z.string().optional().default("ctrl+r").describe("Rename session"),
|
||||||
|
session_delete: z.string().optional().default("ctrl+d").describe("Delete session"),
|
||||||
|
stash_delete: z.string().optional().default("ctrl+d").describe("Delete stash entry"),
|
||||||
|
model_provider_list: z.string().optional().default("ctrl+a").describe("Open provider list from model dialog"),
|
||||||
|
model_favorite_toggle: z.string().optional().default("ctrl+f").describe("Toggle model favorite status"),
|
||||||
|
session_share: z.string().optional().default("none").describe("Share current session"),
|
||||||
|
session_unshare: z.string().optional().default("none").describe("Unshare current session"),
|
||||||
|
session_interrupt: z.string().optional().default("escape").describe("Interrupt current session"),
|
||||||
|
session_compact: z.string().optional().default("<leader>c").describe("Compact the session"),
|
||||||
|
messages_page_up: z.string().optional().default("pageup,ctrl+alt+b").describe("Scroll messages up by one page"),
|
||||||
|
messages_page_down: z
|
||||||
|
.string()
|
||||||
|
.optional()
|
||||||
|
.default("pagedown,ctrl+alt+f")
|
||||||
|
.describe("Scroll messages down by one page"),
|
||||||
|
messages_line_up: z.string().optional().default("ctrl+alt+y").describe("Scroll messages up by one line"),
|
||||||
|
messages_line_down: z.string().optional().default("ctrl+alt+e").describe("Scroll messages down by one line"),
|
||||||
|
messages_half_page_up: z.string().optional().default("ctrl+alt+u").describe("Scroll messages up by half page"),
|
||||||
|
messages_half_page_down: z
|
||||||
|
.string()
|
||||||
|
.optional()
|
||||||
|
.default("ctrl+alt+d")
|
||||||
|
.describe("Scroll messages down by half page"),
|
||||||
|
messages_first: z.string().optional().default("ctrl+g,home").describe("Navigate to first message"),
|
||||||
|
messages_last: z.string().optional().default("ctrl+alt+g,end").describe("Navigate to last message"),
|
||||||
|
messages_next: z.string().optional().default("none").describe("Navigate to next message"),
|
||||||
|
messages_previous: z.string().optional().default("none").describe("Navigate to previous message"),
|
||||||
|
messages_last_user: z.string().optional().default("none").describe("Navigate to last user message"),
|
||||||
|
messages_copy: z.string().optional().default("<leader>y").describe("Copy message"),
|
||||||
|
messages_undo: z.string().optional().default("<leader>u").describe("Undo message"),
|
||||||
|
messages_redo: z.string().optional().default("<leader>r").describe("Redo message"),
|
||||||
|
messages_toggle_conceal: z
|
||||||
|
.string()
|
||||||
|
.optional()
|
||||||
|
.default("<leader>h")
|
||||||
|
.describe("Toggle code block concealment in messages"),
|
||||||
|
tool_details: z.string().optional().default("none").describe("Toggle tool details visibility"),
|
||||||
|
model_list: z.string().optional().default("<leader>m").describe("List available models"),
|
||||||
|
model_cycle_recent: z.string().optional().default("f2").describe("Next recently used model"),
|
||||||
|
model_cycle_recent_reverse: z.string().optional().default("shift+f2").describe("Previous recently used model"),
|
||||||
|
model_cycle_favorite: z.string().optional().default("none").describe("Next favorite model"),
|
||||||
|
model_cycle_favorite_reverse: z.string().optional().default("none").describe("Previous favorite model"),
|
||||||
|
command_list: z.string().optional().default("ctrl+p").describe("List available commands"),
|
||||||
|
agent_list: z.string().optional().default("<leader>a").describe("List agents"),
|
||||||
|
agent_cycle: z.string().optional().default("tab").describe("Next agent"),
|
||||||
|
agent_cycle_reverse: z.string().optional().default("shift+tab").describe("Previous agent"),
|
||||||
|
variant_cycle: z.string().optional().default("ctrl+t").describe("Cycle model variants"),
|
||||||
|
variant_list: z.string().optional().default("none").describe("List model variants"),
|
||||||
|
input_clear: z.string().optional().default("ctrl+c").describe("Clear input field"),
|
||||||
|
input_paste: z.string().optional().default("ctrl+v").describe("Paste from clipboard"),
|
||||||
|
input_submit: z.string().optional().default("return").describe("Submit input"),
|
||||||
|
input_newline: z
|
||||||
|
.string()
|
||||||
|
.optional()
|
||||||
|
.default("shift+return,ctrl+return,alt+return,ctrl+j")
|
||||||
|
.describe("Insert newline in input"),
|
||||||
|
input_move_left: z.string().optional().default("left,ctrl+b").describe("Move cursor left in input"),
|
||||||
|
input_move_right: z.string().optional().default("right,ctrl+f").describe("Move cursor right in input"),
|
||||||
|
input_move_up: z.string().optional().default("up").describe("Move cursor up in input"),
|
||||||
|
input_move_down: z.string().optional().default("down").describe("Move cursor down in input"),
|
||||||
|
input_select_left: z.string().optional().default("shift+left").describe("Select left in input"),
|
||||||
|
input_select_right: z.string().optional().default("shift+right").describe("Select right in input"),
|
||||||
|
input_select_up: z.string().optional().default("shift+up").describe("Select up in input"),
|
||||||
|
input_select_down: z.string().optional().default("shift+down").describe("Select down in input"),
|
||||||
|
input_line_home: z.string().optional().default("ctrl+a").describe("Move to start of line in input"),
|
||||||
|
input_line_end: z.string().optional().default("ctrl+e").describe("Move to end of line in input"),
|
||||||
|
input_select_line_home: z
|
||||||
|
.string()
|
||||||
|
.optional()
|
||||||
|
.default("ctrl+shift+a")
|
||||||
|
.describe("Select to start of line in input"),
|
||||||
|
input_select_line_end: z.string().optional().default("ctrl+shift+e").describe("Select to end of line in input"),
|
||||||
|
input_visual_line_home: z.string().optional().default("alt+a").describe("Move to start of visual line in input"),
|
||||||
|
input_visual_line_end: z.string().optional().default("alt+e").describe("Move to end of visual line in input"),
|
||||||
|
input_select_visual_line_home: z
|
||||||
|
.string()
|
||||||
|
.optional()
|
||||||
|
.default("alt+shift+a")
|
||||||
|
.describe("Select to start of visual line in input"),
|
||||||
|
input_select_visual_line_end: z
|
||||||
|
.string()
|
||||||
|
.optional()
|
||||||
|
.default("alt+shift+e")
|
||||||
|
.describe("Select to end of visual line in input"),
|
||||||
|
input_buffer_home: z.string().optional().default("home").describe("Move to start of buffer in input"),
|
||||||
|
input_buffer_end: z.string().optional().default("end").describe("Move to end of buffer in input"),
|
||||||
|
input_select_buffer_home: z
|
||||||
|
.string()
|
||||||
|
.optional()
|
||||||
|
.default("shift+home")
|
||||||
|
.describe("Select to start of buffer in input"),
|
||||||
|
input_select_buffer_end: z.string().optional().default("shift+end").describe("Select to end of buffer in input"),
|
||||||
|
input_delete_line: z.string().optional().default("ctrl+shift+d").describe("Delete line in input"),
|
||||||
|
input_delete_to_line_end: z.string().optional().default("ctrl+k").describe("Delete to end of line in input"),
|
||||||
|
input_delete_to_line_start: z.string().optional().default("ctrl+u").describe("Delete to start of line in input"),
|
||||||
|
input_backspace: z.string().optional().default("backspace,shift+backspace").describe("Backspace in input"),
|
||||||
|
input_delete: z.string().optional().default("ctrl+d,delete,shift+delete").describe("Delete character in input"),
|
||||||
|
input_undo: z.string().optional().default("ctrl+-,super+z").describe("Undo in input"),
|
||||||
|
input_redo: z.string().optional().default("ctrl+.,super+shift+z").describe("Redo in input"),
|
||||||
|
input_word_forward: z
|
||||||
|
.string()
|
||||||
|
.optional()
|
||||||
|
.default("alt+f,alt+right,ctrl+right")
|
||||||
|
.describe("Move word forward in input"),
|
||||||
|
input_word_backward: z
|
||||||
|
.string()
|
||||||
|
.optional()
|
||||||
|
.default("alt+b,alt+left,ctrl+left")
|
||||||
|
.describe("Move word backward in input"),
|
||||||
|
input_select_word_forward: z
|
||||||
|
.string()
|
||||||
|
.optional()
|
||||||
|
.default("alt+shift+f,alt+shift+right")
|
||||||
|
.describe("Select word forward in input"),
|
||||||
|
input_select_word_backward: z
|
||||||
|
.string()
|
||||||
|
.optional()
|
||||||
|
.default("alt+shift+b,alt+shift+left")
|
||||||
|
.describe("Select word backward in input"),
|
||||||
|
input_delete_word_forward: z
|
||||||
|
.string()
|
||||||
|
.optional()
|
||||||
|
.default("alt+d,alt+delete,ctrl+delete")
|
||||||
|
.describe("Delete word forward in input"),
|
||||||
|
input_delete_word_backward: z
|
||||||
|
.string()
|
||||||
|
.optional()
|
||||||
|
.default("ctrl+w,ctrl+backspace,alt+backspace")
|
||||||
|
.describe("Delete word backward in input"),
|
||||||
|
history_previous: z.string().optional().default("up").describe("Previous history item"),
|
||||||
|
history_next: z.string().optional().default("down").describe("Next history item"),
|
||||||
|
session_child_first: z.string().optional().default("<leader>down").describe("Go to first child session"),
|
||||||
|
session_child_cycle: z.string().optional().default("right").describe("Go to next child session"),
|
||||||
|
session_child_cycle_reverse: z.string().optional().default("left").describe("Go to previous child session"),
|
||||||
|
session_parent: z.string().optional().default("up").describe("Go to parent session"),
|
||||||
|
terminal_suspend: z.string().optional().default("ctrl+z").describe("Suspend terminal"),
|
||||||
|
terminal_title_toggle: z.string().optional().default("none").describe("Toggle terminal title"),
|
||||||
|
tips_toggle: z.string().optional().default("<leader>h").describe("Toggle tips on home screen"),
|
||||||
|
plugin_manager: z.string().optional().default("none").describe("Open plugin manager dialog"),
|
||||||
|
display_thinking: z.string().optional().default("none").describe("Toggle thinking blocks visibility"),
|
||||||
|
})
|
||||||
|
.strict()
|
||||||
|
.meta({
|
||||||
|
ref: "KeybindsConfig",
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -7,11 +7,11 @@ import { Filesystem } from "@/util"
|
|||||||
import { Flag } from "@/flag/flag"
|
import { Flag } from "@/flag/flag"
|
||||||
import { Global } from "@/global"
|
import { Global } from "@/global"
|
||||||
|
|
||||||
export async function projectFiles(name: string, directory: string, worktree: string) {
|
export async function projectFiles(name: string, directory: string, worktree?: string) {
|
||||||
return Filesystem.findUp([`${name}.json`, `${name}.jsonc`], directory, worktree, { rootFirst: true })
|
return Filesystem.findUp([`${name}.json`, `${name}.jsonc`], directory, worktree, { rootFirst: true })
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function directories(directory: string, worktree: string) {
|
export async function directories(directory: string, worktree?: string) {
|
||||||
return [
|
return [
|
||||||
Global.Path.config,
|
Global.Path.config,
|
||||||
...(!Flag.OPENCODE_DISABLE_PROJECT_CONFIG
|
...(!Flag.OPENCODE_DISABLE_PROJECT_CONFIG
|
||||||
|
|||||||
@@ -0,0 +1,75 @@
|
|||||||
|
import { Glob } from "@opencode-ai/shared/util/glob"
|
||||||
|
import z from "zod"
|
||||||
|
import { pathToFileURL } from "url"
|
||||||
|
import { isPathPluginSpec, parsePluginSpecifier, resolvePathPluginTarget } from "@/plugin/shared"
|
||||||
|
import path from "path"
|
||||||
|
|
||||||
|
export namespace ConfigPlugin {
|
||||||
|
const Options = z.record(z.string(), z.unknown())
|
||||||
|
export type Options = z.infer<typeof Options>
|
||||||
|
|
||||||
|
export const Spec = z.union([z.string(), z.tuple([z.string(), Options])])
|
||||||
|
export type Spec = z.infer<typeof Spec>
|
||||||
|
|
||||||
|
export type Scope = "global" | "local"
|
||||||
|
|
||||||
|
export type Origin = {
|
||||||
|
spec: Spec
|
||||||
|
source: string
|
||||||
|
scope: Scope
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function load(dir: string) {
|
||||||
|
const plugins: ConfigPlugin.Spec[] = []
|
||||||
|
|
||||||
|
for (const item of await Glob.scan("{plugin,plugins}/*.{ts,js}", {
|
||||||
|
cwd: dir,
|
||||||
|
absolute: true,
|
||||||
|
dot: true,
|
||||||
|
symlink: true,
|
||||||
|
})) {
|
||||||
|
plugins.push(pathToFileURL(item).href)
|
||||||
|
}
|
||||||
|
return plugins
|
||||||
|
}
|
||||||
|
|
||||||
|
export function pluginSpecifier(plugin: ConfigPlugin.Spec): string {
|
||||||
|
return Array.isArray(plugin) ? plugin[0] : plugin
|
||||||
|
}
|
||||||
|
|
||||||
|
export function pluginOptions(plugin: Spec): Options | undefined {
|
||||||
|
return Array.isArray(plugin) ? plugin[1] : undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function resolvePluginSpec(plugin: Spec, configFilepath: string): Promise<Spec> {
|
||||||
|
const spec = pluginSpecifier(plugin)
|
||||||
|
if (!isPathPluginSpec(spec)) return plugin
|
||||||
|
|
||||||
|
const base = path.dirname(configFilepath)
|
||||||
|
const file = (() => {
|
||||||
|
if (spec.startsWith("file://")) return spec
|
||||||
|
if (path.isAbsolute(spec) || /^[A-Za-z]:[\\/]/.test(spec)) return pathToFileURL(spec).href
|
||||||
|
return pathToFileURL(path.resolve(base, spec)).href
|
||||||
|
})()
|
||||||
|
|
||||||
|
const resolved = await resolvePathPluginTarget(file).catch(() => file)
|
||||||
|
|
||||||
|
if (Array.isArray(plugin)) return [resolved, plugin[1]]
|
||||||
|
return resolved
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deduplicatePluginOrigins(plugins: Origin[]): Origin[] {
|
||||||
|
const seen = new Set<string>()
|
||||||
|
const list: Origin[] = []
|
||||||
|
|
||||||
|
for (const plugin of plugins.toReversed()) {
|
||||||
|
const spec = pluginSpecifier(plugin.spec)
|
||||||
|
const name = spec.startsWith("file://") ? spec : parsePluginSpecifier(spec).pkg
|
||||||
|
if (seen.has(name)) continue
|
||||||
|
seen.add(name)
|
||||||
|
list.push(plugin)
|
||||||
|
}
|
||||||
|
|
||||||
|
return list.toReversed()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,212 +0,0 @@
|
|||||||
import { existsSync } from "fs"
|
|
||||||
import z from "zod"
|
|
||||||
import { mergeDeep, unique } from "remeda"
|
|
||||||
import { Context, Effect, Fiber, Layer } from "effect"
|
|
||||||
import * as Config from "./config"
|
|
||||||
import * as ConfigPaths from "./paths"
|
|
||||||
import { migrateTuiConfig } from "./tui-migrate"
|
|
||||||
import { TuiInfo } from "./tui-schema"
|
|
||||||
import { Flag } from "@/flag/flag"
|
|
||||||
import { Log } from "@/util"
|
|
||||||
import { isRecord } from "@/util/record"
|
|
||||||
import { Global } from "@/global"
|
|
||||||
import { InstanceState } from "@/effect"
|
|
||||||
import { makeRuntime } from "@/effect/run-service"
|
|
||||||
import { AppFileSystem } from "@opencode-ai/shared/filesystem"
|
|
||||||
|
|
||||||
const log = Log.create({ service: "tui.config" })
|
|
||||||
|
|
||||||
export const Info = TuiInfo
|
|
||||||
|
|
||||||
type Acc = {
|
|
||||||
result: Info
|
|
||||||
}
|
|
||||||
|
|
||||||
type State = {
|
|
||||||
config: Info
|
|
||||||
deps: Array<Fiber.Fiber<void, AppFileSystem.Error>>
|
|
||||||
}
|
|
||||||
|
|
||||||
export type Info = z.output<typeof Info> & {
|
|
||||||
// Internal resolved plugin list used by runtime loading.
|
|
||||||
plugin_origins?: Config.PluginOrigin[]
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface Interface {
|
|
||||||
readonly get: () => Effect.Effect<Info>
|
|
||||||
readonly waitForDependencies: () => Effect.Effect<void, AppFileSystem.Error>
|
|
||||||
}
|
|
||||||
|
|
||||||
export class Service extends Context.Service<Service, Interface>()("@opencode/TuiConfig") {}
|
|
||||||
|
|
||||||
function pluginScope(file: string, ctx: { directory: string; worktree: string }): Config.PluginScope {
|
|
||||||
if (AppFileSystem.contains(ctx.directory, file)) return "local"
|
|
||||||
if (ctx.worktree !== "/" && AppFileSystem.contains(ctx.worktree, file)) return "local"
|
|
||||||
return "global"
|
|
||||||
}
|
|
||||||
|
|
||||||
function customPath() {
|
|
||||||
return Flag.OPENCODE_TUI_CONFIG
|
|
||||||
}
|
|
||||||
|
|
||||||
function normalize(raw: Record<string, unknown>) {
|
|
||||||
const data = { ...raw }
|
|
||||||
if (!("tui" in data)) return data
|
|
||||||
if (!isRecord(data.tui)) {
|
|
||||||
delete data.tui
|
|
||||||
return data
|
|
||||||
}
|
|
||||||
|
|
||||||
const tui = data.tui
|
|
||||||
delete data.tui
|
|
||||||
return {
|
|
||||||
...tui,
|
|
||||||
...data,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function mergeFile(acc: Acc, file: string, ctx: { directory: string; worktree: string }) {
|
|
||||||
const data = await loadFile(file)
|
|
||||||
acc.result = mergeDeep(acc.result, data)
|
|
||||||
if (!data.plugin?.length) return
|
|
||||||
|
|
||||||
const scope = pluginScope(file, ctx)
|
|
||||||
const plugins = Config.deduplicatePluginOrigins([
|
|
||||||
...(acc.result.plugin_origins ?? []),
|
|
||||||
...data.plugin.map((spec) => ({ spec, scope, source: file })),
|
|
||||||
])
|
|
||||||
acc.result.plugin = plugins.map((item) => item.spec)
|
|
||||||
acc.result.plugin_origins = plugins
|
|
||||||
}
|
|
||||||
|
|
||||||
async function loadState(ctx: { directory: string; worktree: string }) {
|
|
||||||
let projectFiles = Flag.OPENCODE_DISABLE_PROJECT_CONFIG
|
|
||||||
? []
|
|
||||||
: await ConfigPaths.projectFiles("tui", ctx.directory, ctx.worktree)
|
|
||||||
const directories = await ConfigPaths.directories(ctx.directory, ctx.worktree)
|
|
||||||
const custom = customPath()
|
|
||||||
const managed = Config.managedConfigDir()
|
|
||||||
await migrateTuiConfig({ directories, custom, managed })
|
|
||||||
// Re-compute after migration since migrateTuiConfig may have created new tui.json files
|
|
||||||
projectFiles = Flag.OPENCODE_DISABLE_PROJECT_CONFIG
|
|
||||||
? []
|
|
||||||
: await ConfigPaths.projectFiles("tui", ctx.directory, ctx.worktree)
|
|
||||||
|
|
||||||
const acc: Acc = {
|
|
||||||
result: {},
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const file of ConfigPaths.fileInDirectory(Global.Path.config, "tui")) {
|
|
||||||
await mergeFile(acc, file, ctx)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (custom) {
|
|
||||||
await mergeFile(acc, custom, ctx)
|
|
||||||
log.debug("loaded custom tui config", { path: custom })
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const file of projectFiles) {
|
|
||||||
await mergeFile(acc, file, ctx)
|
|
||||||
}
|
|
||||||
|
|
||||||
const dirs = unique(directories).filter((dir) => dir.endsWith(".opencode") || dir === Flag.OPENCODE_CONFIG_DIR)
|
|
||||||
|
|
||||||
for (const dir of dirs) {
|
|
||||||
if (!dir.endsWith(".opencode") && dir !== Flag.OPENCODE_CONFIG_DIR) continue
|
|
||||||
for (const file of ConfigPaths.fileInDirectory(dir, "tui")) {
|
|
||||||
await mergeFile(acc, file, ctx)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (existsSync(managed)) {
|
|
||||||
for (const file of ConfigPaths.fileInDirectory(managed, "tui")) {
|
|
||||||
await mergeFile(acc, file, ctx)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const keybinds = { ...acc.result.keybinds }
|
|
||||||
if (process.platform === "win32") {
|
|
||||||
// Native Windows terminals do not support POSIX suspend, so prefer prompt undo.
|
|
||||||
keybinds.terminal_suspend = "none"
|
|
||||||
keybinds.input_undo ??= unique(["ctrl+z", ...Config.Keybinds.shape.input_undo.parse(undefined).split(",")]).join(
|
|
||||||
",",
|
|
||||||
)
|
|
||||||
}
|
|
||||||
acc.result.keybinds = Config.Keybinds.parse(keybinds)
|
|
||||||
|
|
||||||
return {
|
|
||||||
config: acc.result,
|
|
||||||
dirs: acc.result.plugin?.length ? dirs : [],
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export const layer = Layer.effect(
|
|
||||||
Service,
|
|
||||||
Effect.gen(function* () {
|
|
||||||
const cfg = yield* Config.Service
|
|
||||||
const state = yield* InstanceState.make<State>(
|
|
||||||
Effect.fn("TuiConfig.state")(function* (ctx) {
|
|
||||||
const data = yield* Effect.promise(() => loadState(ctx))
|
|
||||||
const deps = yield* Effect.forEach(data.dirs, (dir) => cfg.installDependencies(dir).pipe(Effect.forkScoped), {
|
|
||||||
concurrency: "unbounded",
|
|
||||||
})
|
|
||||||
return { config: data.config, deps }
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
|
|
||||||
const get = Effect.fn("TuiConfig.get")(() => InstanceState.use(state, (s) => s.config))
|
|
||||||
|
|
||||||
const waitForDependencies = Effect.fn("TuiConfig.waitForDependencies")(() =>
|
|
||||||
InstanceState.useEffect(state, (s) =>
|
|
||||||
Effect.forEach(s.deps, Fiber.join, { concurrency: "unbounded" }).pipe(Effect.asVoid),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
return Service.of({ get, waitForDependencies })
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
|
|
||||||
export const defaultLayer = layer.pipe(Layer.provide(Config.defaultLayer))
|
|
||||||
|
|
||||||
const { runPromise } = makeRuntime(Service, defaultLayer)
|
|
||||||
|
|
||||||
export async function get() {
|
|
||||||
return runPromise((svc) => svc.get())
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function waitForDependencies() {
|
|
||||||
await runPromise((svc) => svc.waitForDependencies())
|
|
||||||
}
|
|
||||||
|
|
||||||
async function loadFile(filepath: string): Promise<Info> {
|
|
||||||
const text = await ConfigPaths.readFile(filepath)
|
|
||||||
if (!text) return {}
|
|
||||||
return load(text, filepath).catch((error) => {
|
|
||||||
log.warn("failed to load tui config", { path: filepath, error })
|
|
||||||
return {}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
async function load(text: string, configFilepath: string): Promise<Info> {
|
|
||||||
const raw = await ConfigPaths.parseText(text, configFilepath, "empty")
|
|
||||||
if (!isRecord(raw)) return {}
|
|
||||||
|
|
||||||
// Flatten a nested "tui" key so users who wrote `{ "tui": { ... } }` inside tui.json
|
|
||||||
// (mirroring the old opencode.json shape) still get their settings applied.
|
|
||||||
const normalized = normalize(raw)
|
|
||||||
|
|
||||||
const parsed = Info.safeParse(normalized)
|
|
||||||
if (!parsed.success) {
|
|
||||||
log.warn("invalid tui config", { path: configFilepath, issues: parsed.error.issues })
|
|
||||||
return {}
|
|
||||||
}
|
|
||||||
|
|
||||||
const data = parsed.data
|
|
||||||
if (data.plugin) {
|
|
||||||
for (let i = 0; i < data.plugin.length; i++) {
|
|
||||||
data.plugin[i] = await Config.resolvePluginSpec(data.plugin[i], configFilepath)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return data
|
|
||||||
}
|
|
||||||
@@ -47,8 +47,10 @@ import { Pty } from "@/pty"
|
|||||||
import { Installation } from "@/installation"
|
import { Installation } from "@/installation"
|
||||||
import { ShareNext } from "@/share"
|
import { ShareNext } from "@/share"
|
||||||
import { SessionShare } from "@/share"
|
import { SessionShare } from "@/share"
|
||||||
|
import { Npm } from "@opencode-ai/shared/npm"
|
||||||
|
|
||||||
export const AppLayer = Layer.mergeAll(
|
export const AppLayer = Layer.mergeAll(
|
||||||
|
Npm.defaultLayer,
|
||||||
AppFileSystem.defaultLayer,
|
AppFileSystem.defaultLayer,
|
||||||
Bus.defaultLayer,
|
Bus.defaultLayer,
|
||||||
Auth.defaultLayer,
|
Auth.defaultLayer,
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { FetchHttpClient } from "effect/unstable/http"
|
|||||||
import { OtlpLogger, OtlpSerialization } from "effect/unstable/observability"
|
import { OtlpLogger, OtlpSerialization } from "effect/unstable/observability"
|
||||||
import * as EffectLogger from "./logger"
|
import * as EffectLogger from "./logger"
|
||||||
import { Flag } from "@/flag/flag"
|
import { Flag } from "@/flag/flag"
|
||||||
import { CHANNEL, VERSION } from "@/installation/meta"
|
import { InstallationChannel, InstallationVersion } from "@/installation/version"
|
||||||
|
|
||||||
const base = Flag.OTEL_EXPORTER_OTLP_ENDPOINT
|
const base = Flag.OTEL_EXPORTER_OTLP_ENDPOINT
|
||||||
export const enabled = !!base
|
export const enabled = !!base
|
||||||
@@ -21,9 +21,9 @@ const headers = Flag.OTEL_EXPORTER_OTLP_HEADERS
|
|||||||
|
|
||||||
const resource = {
|
const resource = {
|
||||||
serviceName: "opencode",
|
serviceName: "opencode",
|
||||||
serviceVersion: VERSION,
|
serviceVersion: InstallationVersion,
|
||||||
attributes: {
|
attributes: {
|
||||||
"deployment.environment.name": CHANNEL === "local" ? "local" : CHANNEL,
|
"deployment.environment.name": InstallationChannel,
|
||||||
"opencode.client": Flag.OPENCODE_CLIENT,
|
"opencode.client": Flag.OPENCODE_CLIENT,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
@@ -76,3 +76,5 @@ export const layer = !base
|
|||||||
return Layer.mergeAll(trace, logs)
|
return Layer.mergeAll(trace, logs)
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
export const Observability = { enabled, layer }
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { InstanceState } from "@/effect"
|
|||||||
|
|
||||||
import { AppFileSystem } from "@opencode-ai/shared/filesystem"
|
import { AppFileSystem } from "@opencode-ai/shared/filesystem"
|
||||||
import { Git } from "@/git"
|
import { Git } from "@/git"
|
||||||
import { Effect, Layer, Context } from "effect"
|
import { Effect, Layer, Context, Scope } from "effect"
|
||||||
import * as Stream from "effect/Stream"
|
import * as Stream from "effect/Stream"
|
||||||
import { formatPatch, structuredPatch } from "diff"
|
import { formatPatch, structuredPatch } from "diff"
|
||||||
import fuzzysort from "fuzzysort"
|
import fuzzysort from "fuzzysort"
|
||||||
@@ -345,6 +345,7 @@ export const layer = Layer.effect(
|
|||||||
const appFs = yield* AppFileSystem.Service
|
const appFs = yield* AppFileSystem.Service
|
||||||
const rg = yield* Ripgrep.Service
|
const rg = yield* Ripgrep.Service
|
||||||
const git = yield* Git.Service
|
const git = yield* Git.Service
|
||||||
|
const scope = yield* Scope.Scope
|
||||||
|
|
||||||
const state = yield* InstanceState.make<State>(
|
const state = yield* InstanceState.make<State>(
|
||||||
Effect.fn("File.state")(() =>
|
Effect.fn("File.state")(() =>
|
||||||
@@ -419,7 +420,7 @@ export const layer = Layer.effect(
|
|||||||
})
|
})
|
||||||
|
|
||||||
const init = Effect.fn("File.init")(function* () {
|
const init = Effect.fn("File.init")(function* () {
|
||||||
yield* ensure()
|
yield* ensure().pipe(Effect.forkIn(scope))
|
||||||
})
|
})
|
||||||
|
|
||||||
const status = Effect.fn("File.status")(function* () {
|
const status = Effect.fn("File.status")(function* () {
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import { UninstallCommand } from "./cli/cmd/uninstall"
|
|||||||
import { ModelsCommand } from "./cli/cmd/models"
|
import { ModelsCommand } from "./cli/cmd/models"
|
||||||
import { UI } from "./cli/ui"
|
import { UI } from "./cli/ui"
|
||||||
import { Installation } from "./installation"
|
import { Installation } from "./installation"
|
||||||
|
import { InstallationVersion } from "./installation/version"
|
||||||
import { NamedError } from "@opencode-ai/shared/util/error"
|
import { NamedError } from "@opencode-ai/shared/util/error"
|
||||||
import { FormatError } from "./cli/error"
|
import { FormatError } from "./cli/error"
|
||||||
import { ServeCommand } from "./cli/cmd/serve"
|
import { ServeCommand } from "./cli/cmd/serve"
|
||||||
@@ -68,7 +69,7 @@ const cli = yargs(args)
|
|||||||
.wrap(100)
|
.wrap(100)
|
||||||
.help("help", "show help")
|
.help("help", "show help")
|
||||||
.alias("help", "h")
|
.alias("help", "h")
|
||||||
.version("version", "show version number", Installation.VERSION)
|
.version("version", "show version number", InstallationVersion)
|
||||||
.alias("version", "v")
|
.alias("version", "v")
|
||||||
.option("print-logs", {
|
.option("print-logs", {
|
||||||
describe: "print logs to stderr",
|
describe: "print logs to stderr",
|
||||||
@@ -105,7 +106,7 @@ const cli = yargs(args)
|
|||||||
process.env.OPENCODE_PID = String(process.pid)
|
process.env.OPENCODE_PID = String(process.pid)
|
||||||
|
|
||||||
Log.Default.info("opencode", {
|
Log.Default.info("opencode", {
|
||||||
version: Installation.VERSION,
|
version: InstallationVersion,
|
||||||
args: process.argv.slice(2),
|
args: process.argv.slice(2),
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -8,9 +8,9 @@ import z from "zod"
|
|||||||
import { BusEvent } from "@/bus/bus-event"
|
import { BusEvent } from "@/bus/bus-event"
|
||||||
import { Flag } from "../flag/flag"
|
import { Flag } from "../flag/flag"
|
||||||
import { Log } from "../util"
|
import { Log } from "../util"
|
||||||
import { CHANNEL as channel, VERSION as version } from "./meta"
|
|
||||||
|
|
||||||
import semver from "semver"
|
import semver from "semver"
|
||||||
|
import { InstallationChannel, InstallationVersion } from "./version"
|
||||||
|
|
||||||
const log = Log.create({ service: "installation" })
|
const log = Log.create({ service: "installation" })
|
||||||
|
|
||||||
@@ -54,16 +54,14 @@ export const Info = z
|
|||||||
})
|
})
|
||||||
export type Info = z.infer<typeof Info>
|
export type Info = z.infer<typeof Info>
|
||||||
|
|
||||||
export const VERSION = version
|
export const USER_AGENT = `opencode/${InstallationChannel}/${InstallationVersion}/${Flag.OPENCODE_CLIENT}`
|
||||||
export const CHANNEL = channel
|
|
||||||
export const USER_AGENT = `opencode/${CHANNEL}/${VERSION}/${Flag.OPENCODE_CLIENT}`
|
|
||||||
|
|
||||||
export function isPreview() {
|
export function isPreview() {
|
||||||
return CHANNEL !== "latest"
|
return InstallationChannel !== "latest"
|
||||||
}
|
}
|
||||||
|
|
||||||
export function isLocal() {
|
export function isLocal() {
|
||||||
return CHANNEL === "local"
|
return InstallationChannel === "local"
|
||||||
}
|
}
|
||||||
|
|
||||||
export class UpgradeFailedError extends Schema.TaggedErrorClass<UpgradeFailedError>()("UpgradeFailedError", {
|
export class UpgradeFailedError extends Schema.TaggedErrorClass<UpgradeFailedError>()("UpgradeFailedError", {
|
||||||
@@ -222,7 +220,7 @@ export const layer: Layer.Layer<Service, never, HttpClient.HttpClient | ChildPro
|
|||||||
const r = (yield* text(["npm", "config", "get", "registry"])).trim()
|
const r = (yield* text(["npm", "config", "get", "registry"])).trim()
|
||||||
const reg = r || "https://registry.npmjs.org"
|
const reg = r || "https://registry.npmjs.org"
|
||||||
const registry = reg.endsWith("/") ? reg.slice(0, -1) : reg
|
const registry = reg.endsWith("/") ? reg.slice(0, -1) : reg
|
||||||
const channel = CHANNEL
|
const channel = InstallationChannel
|
||||||
const response = yield* httpOk.execute(
|
const response = yield* httpOk.execute(
|
||||||
HttpClientRequest.get(`${registry}/opencode-ai/${channel}`).pipe(HttpClientRequest.acceptJson),
|
HttpClientRequest.get(`${registry}/opencode-ai/${channel}`).pipe(HttpClientRequest.acceptJson),
|
||||||
)
|
)
|
||||||
@@ -321,7 +319,7 @@ export const layer: Layer.Layer<Service, never, HttpClient.HttpClient | ChildPro
|
|||||||
return Service.of({
|
return Service.of({
|
||||||
info: Effect.fn("Installation.info")(function* () {
|
info: Effect.fn("Installation.info")(function* () {
|
||||||
return {
|
return {
|
||||||
version: VERSION,
|
version: InstallationVersion,
|
||||||
latest: yield* latestImpl(),
|
latest: yield* latestImpl(),
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -1,7 +0,0 @@
|
|||||||
declare global {
|
|
||||||
const OPENCODE_VERSION: string
|
|
||||||
const OPENCODE_CHANNEL: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export const VERSION = typeof OPENCODE_VERSION === "string" ? OPENCODE_VERSION : "local"
|
|
||||||
export const CHANNEL = typeof OPENCODE_CHANNEL === "string" ? OPENCODE_CHANNEL : "local"
|
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
declare global {
|
||||||
|
const OPENCODE_VERSION: string
|
||||||
|
const OPENCODE_CHANNEL: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export const InstallationVersion = typeof OPENCODE_VERSION === "string" ? OPENCODE_VERSION : "local"
|
||||||
|
export const InstallationChannel = typeof OPENCODE_CHANNEL === "string" ? OPENCODE_CHANNEL : "local"
|
||||||
|
export const InstallationLocal = InstallationVersion === "local"
|
||||||
@@ -15,6 +15,7 @@ import { NamedError } from "@opencode-ai/shared/util/error"
|
|||||||
import z from "zod/v4"
|
import z from "zod/v4"
|
||||||
import { Instance } from "../project/instance"
|
import { Instance } from "../project/instance"
|
||||||
import { Installation } from "../installation"
|
import { Installation } from "../installation"
|
||||||
|
import { InstallationVersion } from "../installation/version"
|
||||||
import { withTimeout } from "@/util/timeout"
|
import { withTimeout } from "@/util/timeout"
|
||||||
import { AppFileSystem } from "@opencode-ai/shared/filesystem"
|
import { AppFileSystem } from "@opencode-ai/shared/filesystem"
|
||||||
import { McpOAuthProvider } from "./oauth-provider"
|
import { McpOAuthProvider } from "./oauth-provider"
|
||||||
@@ -265,7 +266,7 @@ export const layer = Layer.effect(
|
|||||||
(t) =>
|
(t) =>
|
||||||
Effect.tryPromise({
|
Effect.tryPromise({
|
||||||
try: () => {
|
try: () => {
|
||||||
const client = new Client({ name: "opencode", version: Installation.VERSION })
|
const client = new Client({ name: "opencode", version: InstallationVersion })
|
||||||
return withTimeout(client.connect(t), timeout).then(() => client)
|
return withTimeout(client.connect(t), timeout).then(() => client)
|
||||||
},
|
},
|
||||||
catch: (e) => (e instanceof Error ? e : new Error(String(e))),
|
catch: (e) => (e instanceof Error ? e : new Error(String(e))),
|
||||||
@@ -763,7 +764,7 @@ export const layer = Layer.effect(
|
|||||||
|
|
||||||
return yield* Effect.tryPromise({
|
return yield* Effect.tryPromise({
|
||||||
try: () => {
|
try: () => {
|
||||||
const client = new Client({ name: "opencode", version: Installation.VERSION })
|
const client = new Client({ name: "opencode", version: InstallationVersion })
|
||||||
return client
|
return client
|
||||||
.connect(transport)
|
.connect(transport)
|
||||||
.then(() => ({ authorizationUrl: "", oauthState, client }) satisfies AuthResult)
|
.then(() => ({ authorizationUrl: "", oauthState, client }) satisfies AuthResult)
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ import path from "path"
|
|||||||
import { readdir, rm } from "fs/promises"
|
import { readdir, rm } from "fs/promises"
|
||||||
import { Filesystem } from "@/util"
|
import { Filesystem } from "@/util"
|
||||||
import { Flock } from "@opencode-ai/shared/util/flock"
|
import { Flock } from "@opencode-ai/shared/util/flock"
|
||||||
import { Arborist } from "@npmcli/arborist"
|
|
||||||
|
|
||||||
const log = Log.create({ service: "npm" })
|
const log = Log.create({ service: "npm" })
|
||||||
const illegal = process.platform === "win32" ? new Set(["<", ">", ":", '"', "|", "?", "*"]) : undefined
|
const illegal = process.platform === "win32" ? new Set(["<", ">", ":", '"', "|", "?", "*"]) : undefined
|
||||||
@@ -61,6 +60,7 @@ export async function outdated(pkg: string, cachedVersion: string): Promise<bool
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function add(pkg: string) {
|
export async function add(pkg: string) {
|
||||||
|
const { Arborist } = await import("@npmcli/arborist")
|
||||||
const dir = directory(pkg)
|
const dir = directory(pkg)
|
||||||
await using _ = await Flock.acquire(`npm-install:${Filesystem.resolve(dir)}`)
|
await using _ = await Flock.acquire(`npm-install:${Filesystem.resolve(dir)}`)
|
||||||
log.info("installing package", {
|
log.info("installing package", {
|
||||||
@@ -107,6 +107,7 @@ export async function install(dir: string) {
|
|||||||
log.info("checking dependencies", { dir })
|
log.info("checking dependencies", { dir })
|
||||||
|
|
||||||
const reify = async () => {
|
const reify = async () => {
|
||||||
|
const { Arborist } = await import("@npmcli/arborist")
|
||||||
const arb = new Arborist({
|
const arb = new Arborist({
|
||||||
path: dir,
|
path: dir,
|
||||||
binLinks: true,
|
binLinks: true,
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import type { Hooks, PluginInput } from "@opencode-ai/plugin"
|
import type { Hooks, PluginInput } from "@opencode-ai/plugin"
|
||||||
import { Log } from "../util"
|
import { Log } from "../util"
|
||||||
import { Installation } from "../installation"
|
import { Installation } from "../installation"
|
||||||
|
import { InstallationVersion } from "../installation/version"
|
||||||
import { OAUTH_DUMMY_KEY } from "../auth"
|
import { OAUTH_DUMMY_KEY } from "../auth"
|
||||||
import os from "os"
|
import os from "os"
|
||||||
import { setTimeout as sleep } from "node:timers/promises"
|
import { setTimeout as sleep } from "node:timers/promises"
|
||||||
@@ -510,7 +511,7 @@ export async function CodexAuthPlugin(input: PluginInput): Promise<Hooks> {
|
|||||||
method: "POST",
|
method: "POST",
|
||||||
headers: {
|
headers: {
|
||||||
"Content-Type": "application/json",
|
"Content-Type": "application/json",
|
||||||
"User-Agent": `opencode/${Installation.VERSION}`,
|
"User-Agent": `opencode/${InstallationVersion}`,
|
||||||
},
|
},
|
||||||
body: JSON.stringify({ client_id: CLIENT_ID }),
|
body: JSON.stringify({ client_id: CLIENT_ID }),
|
||||||
})
|
})
|
||||||
@@ -534,7 +535,7 @@ export async function CodexAuthPlugin(input: PluginInput): Promise<Hooks> {
|
|||||||
method: "POST",
|
method: "POST",
|
||||||
headers: {
|
headers: {
|
||||||
"Content-Type": "application/json",
|
"Content-Type": "application/json",
|
||||||
"User-Agent": `opencode/${Installation.VERSION}`,
|
"User-Agent": `opencode/${InstallationVersion}`,
|
||||||
},
|
},
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
device_auth_id: deviceData.device_auth_id,
|
device_auth_id: deviceData.device_auth_id,
|
||||||
@@ -594,7 +595,7 @@ export async function CodexAuthPlugin(input: PluginInput): Promise<Hooks> {
|
|||||||
"chat.headers": async (input, output) => {
|
"chat.headers": async (input, output) => {
|
||||||
if (input.model.providerID !== "openai") return
|
if (input.model.providerID !== "openai") return
|
||||||
output.headers.originator = "opencode"
|
output.headers.originator = "opencode"
|
||||||
output.headers["User-Agent"] = `opencode/${Installation.VERSION} (${os.platform()} ${os.release()}; ${os.arch()})`
|
output.headers["User-Agent"] = `opencode/${InstallationVersion} (${os.platform()} ${os.release()}; ${os.arch()})`
|
||||||
output.headers.session_id = input.sessionID
|
output.headers.session_id = input.sessionID
|
||||||
},
|
},
|
||||||
"chat.params": async (input, output) => {
|
"chat.params": async (input, output) => {
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import type { Hooks, PluginInput } from "@opencode-ai/plugin"
|
import type { Hooks, PluginInput } from "@opencode-ai/plugin"
|
||||||
import type { Model } from "@opencode-ai/sdk/v2"
|
import type { Model } from "@opencode-ai/sdk/v2"
|
||||||
import { Installation } from "@/installation"
|
import { Installation } from "@/installation"
|
||||||
|
import { InstallationVersion } from "@/installation/version"
|
||||||
import { iife } from "@/util/iife"
|
import { iife } from "@/util/iife"
|
||||||
import { Log } from "../../util"
|
import { Log } from "../../util"
|
||||||
import { setTimeout as sleep } from "node:timers/promises"
|
import { setTimeout as sleep } from "node:timers/promises"
|
||||||
@@ -70,7 +71,7 @@ export async function CopilotAuthPlugin(input: PluginInput): Promise<Hooks> {
|
|||||||
base(auth.enterpriseUrl),
|
base(auth.enterpriseUrl),
|
||||||
{
|
{
|
||||||
Authorization: `Bearer ${auth.refresh}`,
|
Authorization: `Bearer ${auth.refresh}`,
|
||||||
"User-Agent": `opencode/${Installation.VERSION}`,
|
"User-Agent": `opencode/${InstallationVersion}`,
|
||||||
},
|
},
|
||||||
provider.models,
|
provider.models,
|
||||||
).catch((error) => {
|
).catch((error) => {
|
||||||
@@ -150,7 +151,7 @@ export async function CopilotAuthPlugin(input: PluginInput): Promise<Hooks> {
|
|||||||
const headers: Record<string, string> = {
|
const headers: Record<string, string> = {
|
||||||
"x-initiator": isAgent ? "agent" : "user",
|
"x-initiator": isAgent ? "agent" : "user",
|
||||||
...(init?.headers as Record<string, string>),
|
...(init?.headers as Record<string, string>),
|
||||||
"User-Agent": `opencode/${Installation.VERSION}`,
|
"User-Agent": `opencode/${InstallationVersion}`,
|
||||||
Authorization: `Bearer ${info.refresh}`,
|
Authorization: `Bearer ${info.refresh}`,
|
||||||
"Openai-Intent": "conversation-edits",
|
"Openai-Intent": "conversation-edits",
|
||||||
}
|
}
|
||||||
@@ -226,7 +227,7 @@ export async function CopilotAuthPlugin(input: PluginInput): Promise<Hooks> {
|
|||||||
headers: {
|
headers: {
|
||||||
Accept: "application/json",
|
Accept: "application/json",
|
||||||
"Content-Type": "application/json",
|
"Content-Type": "application/json",
|
||||||
"User-Agent": `opencode/${Installation.VERSION}`,
|
"User-Agent": `opencode/${InstallationVersion}`,
|
||||||
},
|
},
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
client_id: CLIENT_ID,
|
client_id: CLIENT_ID,
|
||||||
@@ -256,7 +257,7 @@ export async function CopilotAuthPlugin(input: PluginInput): Promise<Hooks> {
|
|||||||
headers: {
|
headers: {
|
||||||
Accept: "application/json",
|
Accept: "application/json",
|
||||||
"Content-Type": "application/json",
|
"Content-Type": "application/json",
|
||||||
"User-Agent": `opencode/${Installation.VERSION}`,
|
"User-Agent": `opencode/${InstallationVersion}`,
|
||||||
},
|
},
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
client_id: CLIENT_ID,
|
client_id: CLIENT_ID,
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import {
|
|||||||
printParseErrorCode,
|
printParseErrorCode,
|
||||||
} from "jsonc-parser"
|
} from "jsonc-parser"
|
||||||
|
|
||||||
import { ConfigPaths } from "@/config"
|
import * as ConfigPaths from "@/config/paths"
|
||||||
import { Global } from "@/global"
|
import { Global } from "@/global"
|
||||||
import { Filesystem } from "@/util"
|
import { Filesystem } from "@/util"
|
||||||
import { Flock } from "@opencode-ai/shared/util/flock"
|
import { Flock } from "@opencode-ai/shared/util/flock"
|
||||||
|
|||||||
@@ -1,5 +1,3 @@
|
|||||||
import { Config } from "@/config"
|
|
||||||
import { Installation } from "@/installation"
|
|
||||||
import {
|
import {
|
||||||
checkPluginCompatibility,
|
checkPluginCompatibility,
|
||||||
createPluginEntry,
|
createPluginEntry,
|
||||||
@@ -10,11 +8,13 @@ import {
|
|||||||
type PluginPackage,
|
type PluginPackage,
|
||||||
type PluginSource,
|
type PluginSource,
|
||||||
} from "./shared"
|
} from "./shared"
|
||||||
|
import { ConfigPlugin } from "@/config/plugin"
|
||||||
|
import { InstallationVersion } from "@/installation/version"
|
||||||
|
|
||||||
export namespace PluginLoader {
|
export namespace PluginLoader {
|
||||||
export type Plan = {
|
export type Plan = {
|
||||||
spec: string
|
spec: string
|
||||||
options: Config.PluginOptions | undefined
|
options: ConfigPlugin.Options | undefined
|
||||||
deprecated: boolean
|
deprecated: boolean
|
||||||
}
|
}
|
||||||
export type Resolved = Plan & {
|
export type Resolved = Plan & {
|
||||||
@@ -33,7 +33,7 @@ export namespace PluginLoader {
|
|||||||
mod: Record<string, unknown>
|
mod: Record<string, unknown>
|
||||||
}
|
}
|
||||||
|
|
||||||
type Candidate = { origin: Config.PluginOrigin; plan: Plan }
|
type Candidate = { origin: ConfigPlugin.Origin; plan: Plan }
|
||||||
type Report = {
|
type Report = {
|
||||||
start?: (candidate: Candidate, retry: boolean) => void
|
start?: (candidate: Candidate, retry: boolean) => void
|
||||||
missing?: (candidate: Candidate, retry: boolean, message: string, resolved: Missing) => void
|
missing?: (candidate: Candidate, retry: boolean, message: string, resolved: Missing) => void
|
||||||
@@ -46,9 +46,9 @@ export namespace PluginLoader {
|
|||||||
) => void
|
) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
function plan(item: Config.PluginSpec): Plan {
|
function plan(item: ConfigPlugin.Spec): Plan {
|
||||||
const spec = Config.pluginSpecifier(item)
|
const spec = ConfigPlugin.pluginSpecifier(item)
|
||||||
return { spec, options: Config.pluginOptions(item), deprecated: isDeprecatedPlugin(spec) }
|
return { spec, options: ConfigPlugin.pluginOptions(item), deprecated: isDeprecatedPlugin(spec) }
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function resolve(
|
export async function resolve(
|
||||||
@@ -88,7 +88,7 @@ export namespace PluginLoader {
|
|||||||
|
|
||||||
if (base.source === "npm") {
|
if (base.source === "npm") {
|
||||||
try {
|
try {
|
||||||
await checkPluginCompatibility(base.target, Installation.VERSION, base.pkg)
|
await checkPluginCompatibility(base.target, InstallationVersion, base.pkg)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return { ok: false, stage: "compatibility", error }
|
return { ok: false, stage: "compatibility", error }
|
||||||
}
|
}
|
||||||
@@ -111,8 +111,8 @@ export namespace PluginLoader {
|
|||||||
candidate: Candidate,
|
candidate: Candidate,
|
||||||
kind: PluginKind,
|
kind: PluginKind,
|
||||||
retry: boolean,
|
retry: boolean,
|
||||||
finish: ((load: Loaded, origin: Config.PluginOrigin, retry: boolean) => Promise<R | undefined>) | undefined,
|
finish: ((load: Loaded, origin: ConfigPlugin.Origin, retry: boolean) => Promise<R | undefined>) | undefined,
|
||||||
missing: ((value: Missing, origin: Config.PluginOrigin, retry: boolean) => Promise<R | undefined>) | undefined,
|
missing: ((value: Missing, origin: ConfigPlugin.Origin, retry: boolean) => Promise<R | undefined>) | undefined,
|
||||||
report: Report | undefined,
|
report: Report | undefined,
|
||||||
): Promise<R | undefined> {
|
): Promise<R | undefined> {
|
||||||
const plan = candidate.plan
|
const plan = candidate.plan
|
||||||
@@ -141,11 +141,11 @@ export namespace PluginLoader {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type Input<R> = {
|
type Input<R> = {
|
||||||
items: Config.PluginOrigin[]
|
items: ConfigPlugin.Origin[]
|
||||||
kind: PluginKind
|
kind: PluginKind
|
||||||
wait?: () => Promise<void>
|
wait?: () => Promise<void>
|
||||||
finish?: (load: Loaded, origin: Config.PluginOrigin, retry: boolean) => Promise<R | undefined>
|
finish?: (load: Loaded, origin: ConfigPlugin.Origin, retry: boolean) => Promise<R | undefined>
|
||||||
missing?: (value: Missing, origin: Config.PluginOrigin, retry: boolean) => Promise<R | undefined>
|
missing?: (value: Missing, origin: ConfigPlugin.Origin, retry: boolean) => Promise<R | undefined>
|
||||||
report?: Report
|
report?: Report
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ export const InstanceBootstrap = Effect.gen(function* () {
|
|||||||
Vcs.Service,
|
Vcs.Service,
|
||||||
Snapshot.Service,
|
Snapshot.Service,
|
||||||
].map((s) => Effect.forkDetach(s.use((i) => i.init()))),
|
].map((s) => Effect.forkDetach(s.use((i) => i.init()))),
|
||||||
)
|
).pipe(Effect.withSpan("InstanceBootstrap.init"))
|
||||||
|
|
||||||
yield* Bus.Service.use((svc) =>
|
yield* Bus.Service.use((svc) =>
|
||||||
svc.subscribeCallback(Command.Event.Executed, async (payload) => {
|
svc.subscribeCallback(Command.Event.Executed, async (payload) => {
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import * as ModelsDev from "./models"
|
|||||||
import { Auth } from "../auth"
|
import { Auth } from "../auth"
|
||||||
import { Env } from "../env"
|
import { Env } from "../env"
|
||||||
import { Instance } from "../project/instance"
|
import { Instance } from "../project/instance"
|
||||||
|
import { InstallationVersion } from "../installation/version"
|
||||||
import { Flag } from "../flag/flag"
|
import { Flag } from "../flag/flag"
|
||||||
import { iife } from "@/util/iife"
|
import { iife } from "@/util/iife"
|
||||||
import { Global } from "../global"
|
import { Global } from "../global"
|
||||||
@@ -24,39 +25,7 @@ import { InstanceState } from "@/effect"
|
|||||||
import { AppFileSystem } from "@opencode-ai/shared/filesystem"
|
import { AppFileSystem } from "@opencode-ai/shared/filesystem"
|
||||||
import { isRecord } from "@/util/record"
|
import { isRecord } from "@/util/record"
|
||||||
|
|
||||||
// Direct imports for bundled providers
|
|
||||||
import { createAmazonBedrock, type AmazonBedrockProviderSettings } from "@ai-sdk/amazon-bedrock"
|
|
||||||
import { createAnthropic } from "@ai-sdk/anthropic"
|
|
||||||
import { createAzure } from "@ai-sdk/azure"
|
|
||||||
import { createGoogleGenerativeAI } from "@ai-sdk/google"
|
|
||||||
import { createVertex } from "@ai-sdk/google-vertex"
|
|
||||||
import { createVertexAnthropic } from "@ai-sdk/google-vertex/anthropic"
|
|
||||||
import { createOpenAI } from "@ai-sdk/openai"
|
|
||||||
import { createOpenAICompatible } from "@ai-sdk/openai-compatible"
|
|
||||||
import { createOpenRouter } from "@openrouter/ai-sdk-provider"
|
|
||||||
import { createOpenaiCompatible as createGitHubCopilotOpenAICompatible } from "./sdk/copilot"
|
|
||||||
import { createXai } from "@ai-sdk/xai"
|
|
||||||
import { createMistral } from "@ai-sdk/mistral"
|
|
||||||
import { createGroq } from "@ai-sdk/groq"
|
|
||||||
import { createDeepInfra } from "@ai-sdk/deepinfra"
|
|
||||||
import { createCerebras } from "@ai-sdk/cerebras"
|
|
||||||
import { createCohere } from "@ai-sdk/cohere"
|
|
||||||
import { createGateway } from "@ai-sdk/gateway"
|
|
||||||
import { createTogetherAI } from "@ai-sdk/togetherai"
|
|
||||||
import { createPerplexity } from "@ai-sdk/perplexity"
|
|
||||||
import { createVercel } from "@ai-sdk/vercel"
|
|
||||||
import { createVenice } from "venice-ai-sdk-provider"
|
|
||||||
import { createAlibaba } from "@ai-sdk/alibaba"
|
|
||||||
import {
|
|
||||||
createGitLab,
|
|
||||||
VERSION as GITLAB_PROVIDER_VERSION,
|
|
||||||
isWorkflowModel,
|
|
||||||
discoverWorkflowModels,
|
|
||||||
} from "gitlab-ai-provider"
|
|
||||||
import { fromNodeProviderChain } from "@aws-sdk/credential-providers"
|
|
||||||
import { GoogleAuth } from "google-auth-library"
|
|
||||||
import * as ProviderTransform from "./transform"
|
import * as ProviderTransform from "./transform"
|
||||||
import { Installation } from "../installation"
|
|
||||||
import { ModelID, ProviderID } from "./schema"
|
import { ModelID, ProviderID } from "./schema"
|
||||||
|
|
||||||
const log = Log.create({ service: "provider" })
|
const log = Log.create({ service: "provider" })
|
||||||
@@ -119,30 +88,31 @@ type BundledSDK = {
|
|||||||
languageModel(modelId: string): LanguageModelV3
|
languageModel(modelId: string): LanguageModelV3
|
||||||
}
|
}
|
||||||
|
|
||||||
const BUNDLED_PROVIDERS: Record<string, (options: any) => BundledSDK> = {
|
const BUNDLED_PROVIDERS: Record<string, () => Promise<(opts: any) => BundledSDK>> = {
|
||||||
"@ai-sdk/amazon-bedrock": createAmazonBedrock,
|
"@ai-sdk/amazon-bedrock": () => import("@ai-sdk/amazon-bedrock").then((m) => m.createAmazonBedrock),
|
||||||
"@ai-sdk/anthropic": createAnthropic,
|
"@ai-sdk/anthropic": () => import("@ai-sdk/anthropic").then((m) => m.createAnthropic),
|
||||||
"@ai-sdk/azure": createAzure,
|
"@ai-sdk/azure": () => import("@ai-sdk/azure").then((m) => m.createAzure),
|
||||||
"@ai-sdk/google": createGoogleGenerativeAI,
|
"@ai-sdk/google": () => import("@ai-sdk/google").then((m) => m.createGoogleGenerativeAI),
|
||||||
"@ai-sdk/google-vertex": createVertex,
|
"@ai-sdk/google-vertex": () => import("@ai-sdk/google-vertex").then((m) => m.createVertex),
|
||||||
"@ai-sdk/google-vertex/anthropic": createVertexAnthropic,
|
"@ai-sdk/google-vertex/anthropic": () =>
|
||||||
"@ai-sdk/openai": createOpenAI,
|
import("@ai-sdk/google-vertex/anthropic").then((m) => m.createVertexAnthropic),
|
||||||
"@ai-sdk/openai-compatible": createOpenAICompatible,
|
"@ai-sdk/openai": () => import("@ai-sdk/openai").then((m) => m.createOpenAI),
|
||||||
"@openrouter/ai-sdk-provider": createOpenRouter,
|
"@ai-sdk/openai-compatible": () => import("@ai-sdk/openai-compatible").then((m) => m.createOpenAICompatible),
|
||||||
"@ai-sdk/xai": createXai,
|
"@openrouter/ai-sdk-provider": () => import("@openrouter/ai-sdk-provider").then((m) => m.createOpenRouter),
|
||||||
"@ai-sdk/mistral": createMistral,
|
"@ai-sdk/xai": () => import("@ai-sdk/xai").then((m) => m.createXai),
|
||||||
"@ai-sdk/groq": createGroq,
|
"@ai-sdk/mistral": () => import("@ai-sdk/mistral").then((m) => m.createMistral),
|
||||||
"@ai-sdk/deepinfra": createDeepInfra,
|
"@ai-sdk/groq": () => import("@ai-sdk/groq").then((m) => m.createGroq),
|
||||||
"@ai-sdk/cerebras": createCerebras,
|
"@ai-sdk/deepinfra": () => import("@ai-sdk/deepinfra").then((m) => m.createDeepInfra),
|
||||||
"@ai-sdk/cohere": createCohere,
|
"@ai-sdk/cerebras": () => import("@ai-sdk/cerebras").then((m) => m.createCerebras),
|
||||||
"@ai-sdk/gateway": createGateway,
|
"@ai-sdk/cohere": () => import("@ai-sdk/cohere").then((m) => m.createCohere),
|
||||||
"@ai-sdk/togetherai": createTogetherAI,
|
"@ai-sdk/gateway": () => import("@ai-sdk/gateway").then((m) => m.createGateway),
|
||||||
"@ai-sdk/perplexity": createPerplexity,
|
"@ai-sdk/togetherai": () => import("@ai-sdk/togetherai").then((m) => m.createTogetherAI),
|
||||||
"@ai-sdk/vercel": createVercel,
|
"@ai-sdk/perplexity": () => import("@ai-sdk/perplexity").then((m) => m.createPerplexity),
|
||||||
"@ai-sdk/alibaba": createAlibaba,
|
"@ai-sdk/vercel": () => import("@ai-sdk/vercel").then((m) => m.createVercel),
|
||||||
"gitlab-ai-provider": createGitLab,
|
"@ai-sdk/alibaba": () => import("@ai-sdk/alibaba").then((m) => m.createAlibaba),
|
||||||
"@ai-sdk/github-copilot": createGitHubCopilotOpenAICompatible,
|
"gitlab-ai-provider": () => import("gitlab-ai-provider").then((m) => m.createGitLab),
|
||||||
"venice-ai-sdk-provider": createVenice,
|
"@ai-sdk/github-copilot": () => import("./sdk/copilot").then((m) => m.createOpenaiCompatible),
|
||||||
|
"venice-ai-sdk-provider": () => import("venice-ai-sdk-provider").then((m) => m.createVenice),
|
||||||
}
|
}
|
||||||
|
|
||||||
type CustomModelLoader = (sdk: any, modelID: string, options?: Record<string, any>) => Promise<any>
|
type CustomModelLoader = (sdk: any, modelID: string, options?: Record<string, any>) => Promise<any>
|
||||||
@@ -307,7 +277,9 @@ function custom(dep: CustomDep): Record<string, CustomLoader> {
|
|||||||
if (!profile && !awsAccessKeyId && !awsBearerToken && !awsWebIdentityTokenFile && !containerCreds)
|
if (!profile && !awsAccessKeyId && !awsBearerToken && !awsWebIdentityTokenFile && !containerCreds)
|
||||||
return { autoload: false }
|
return { autoload: false }
|
||||||
|
|
||||||
const providerOptions: AmazonBedrockProviderSettings = {
|
const { fromNodeProviderChain } = yield* Effect.promise(() => import("@aws-sdk/credential-providers"))
|
||||||
|
|
||||||
|
const providerOptions: Record<string, any> = {
|
||||||
region: defaultRegion,
|
region: defaultRegion,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -465,6 +437,7 @@ function custom(dep: CustomDep): Record<string, CustomLoader> {
|
|||||||
project,
|
project,
|
||||||
location,
|
location,
|
||||||
fetch: async (input: RequestInfo | URL, init?: RequestInit) => {
|
fetch: async (input: RequestInfo | URL, init?: RequestInit) => {
|
||||||
|
const { GoogleAuth } = await import("google-auth-library")
|
||||||
const auth = new GoogleAuth()
|
const auth = new GoogleAuth()
|
||||||
const client = await auth.getApplicationDefault()
|
const client = await auth.getApplicationDefault()
|
||||||
const token = await client.credential.getAccessToken()
|
const token = await client.credential.getAccessToken()
|
||||||
@@ -534,6 +507,12 @@ function custom(dep: CustomDep): Record<string, CustomLoader> {
|
|||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
gitlab: Effect.fnUntraced(function* (input: Info) {
|
gitlab: Effect.fnUntraced(function* (input: Info) {
|
||||||
|
const {
|
||||||
|
VERSION: GITLAB_PROVIDER_VERSION,
|
||||||
|
isWorkflowModel,
|
||||||
|
discoverWorkflowModels,
|
||||||
|
} = yield* Effect.promise(() => import("gitlab-ai-provider"))
|
||||||
|
|
||||||
const instanceUrl = (yield* dep.get("GITLAB_INSTANCE_URL")) || "https://gitlab.com"
|
const instanceUrl = (yield* dep.get("GITLAB_INSTANCE_URL")) || "https://gitlab.com"
|
||||||
|
|
||||||
const auth = yield* dep.auth(input.id)
|
const auth = yield* dep.auth(input.id)
|
||||||
@@ -547,7 +526,7 @@ function custom(dep: CustomDep): Record<string, CustomLoader> {
|
|||||||
const providerConfig = (yield* dep.config()).provider?.["gitlab"]
|
const providerConfig = (yield* dep.config()).provider?.["gitlab"]
|
||||||
|
|
||||||
const aiGatewayHeaders = {
|
const aiGatewayHeaders = {
|
||||||
"User-Agent": `opencode/${Installation.VERSION} gitlab-ai-provider/${GITLAB_PROVIDER_VERSION} (${os.platform()} ${os.release()}; ${os.arch()})`,
|
"User-Agent": `opencode/${InstallationVersion} gitlab-ai-provider/${GITLAB_PROVIDER_VERSION} (${os.platform()} ${os.release()}; ${os.arch()})`,
|
||||||
"anthropic-beta": "context-1m-2025-08-07",
|
"anthropic-beta": "context-1m-2025-08-07",
|
||||||
...providerConfig?.options?.aiGatewayHeaders,
|
...providerConfig?.options?.aiGatewayHeaders,
|
||||||
}
|
}
|
||||||
@@ -566,7 +545,7 @@ function custom(dep: CustomDep): Record<string, CustomLoader> {
|
|||||||
aiGatewayHeaders,
|
aiGatewayHeaders,
|
||||||
featureFlags,
|
featureFlags,
|
||||||
},
|
},
|
||||||
async getModel(sdk: ReturnType<typeof createGitLab>, modelID: string, options?: Record<string, any>) {
|
async getModel(sdk: any, modelID: string, options?: Record<string, any>) {
|
||||||
if (modelID.startsWith("duo-workflow-")) {
|
if (modelID.startsWith("duo-workflow-")) {
|
||||||
const workflowRef = options?.workflowRef as string | undefined
|
const workflowRef = options?.workflowRef as string | undefined
|
||||||
// Use the static mapping if it exists, otherwise use duo-workflow with selectedModelRef
|
// Use the static mapping if it exists, otherwise use duo-workflow with selectedModelRef
|
||||||
@@ -701,7 +680,7 @@ function custom(dep: CustomDep): Record<string, CustomLoader> {
|
|||||||
options: {
|
options: {
|
||||||
apiKey,
|
apiKey,
|
||||||
headers: {
|
headers: {
|
||||||
"User-Agent": `opencode/${Installation.VERSION} cloudflare-workers-ai (${os.platform()} ${os.release()}; ${os.arch()})`,
|
"User-Agent": `opencode/${InstallationVersion} cloudflare-workers-ai (${os.platform()} ${os.release()}; ${os.arch()})`,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
async getModel(sdk: any, modelID: string) {
|
async getModel(sdk: any, modelID: string) {
|
||||||
@@ -772,7 +751,7 @@ function custom(dep: CustomDep): Record<string, CustomLoader> {
|
|||||||
skipCache: input.options?.skipCache,
|
skipCache: input.options?.skipCache,
|
||||||
collectLog: input.options?.collectLog,
|
collectLog: input.options?.collectLog,
|
||||||
headers: {
|
headers: {
|
||||||
"User-Agent": `opencode/${Installation.VERSION} cloudflare-ai-gateway (${os.platform()} ${os.release()}; ${os.arch()})`,
|
"User-Agent": `opencode/${InstallationVersion} cloudflare-ai-gateway (${os.platform()} ${os.release()}; ${os.arch()})`,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1454,13 +1433,14 @@ const layer: Layer.Layer<
|
|||||||
return wrapSSE(res, chunkTimeout, chunkAbortCtl)
|
return wrapSSE(res, chunkTimeout, chunkAbortCtl)
|
||||||
}
|
}
|
||||||
|
|
||||||
const bundledFn = BUNDLED_PROVIDERS[model.api.npm]
|
const bundledLoader = BUNDLED_PROVIDERS[model.api.npm]
|
||||||
if (bundledFn) {
|
if (bundledLoader) {
|
||||||
log.info("using bundled provider", {
|
log.info("using bundled provider", {
|
||||||
providerID: model.providerID,
|
providerID: model.providerID,
|
||||||
pkg: model.api.npm,
|
pkg: model.api.npm,
|
||||||
})
|
})
|
||||||
const loaded = bundledFn({
|
const factory = await bundledLoader()
|
||||||
|
const loaded = factory({
|
||||||
name: model.providerID,
|
name: model.providerID,
|
||||||
...options,
|
...options,
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import { AppRuntime } from "@/effect/app-runtime"
|
|||||||
import { AsyncQueue } from "@/util/queue"
|
import { AsyncQueue } from "@/util/queue"
|
||||||
import { Instance } from "../../project/instance"
|
import { Instance } from "../../project/instance"
|
||||||
import { Installation } from "@/installation"
|
import { Installation } from "@/installation"
|
||||||
|
import { InstallationVersion } from "@/installation/version"
|
||||||
import { Log } from "../../util"
|
import { Log } from "../../util"
|
||||||
import { lazy } from "../../util/lazy"
|
import { lazy } from "../../util/lazy"
|
||||||
import { Config } from "../../config"
|
import { Config } from "../../config"
|
||||||
@@ -89,7 +90,7 @@ export const GlobalRoutes = lazy(() =>
|
|||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
async (c) => {
|
async (c) => {
|
||||||
return c.json({ healthy: true, version: Installation.VERSION })
|
return c.json({ healthy: true, version: InstallationVersion })
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
.get(
|
.get(
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import { Wildcard } from "@/util"
|
|||||||
import { SessionID } from "@/session/schema"
|
import { SessionID } from "@/session/schema"
|
||||||
import { Auth } from "@/auth"
|
import { Auth } from "@/auth"
|
||||||
import { Installation } from "@/installation"
|
import { Installation } from "@/installation"
|
||||||
|
import { InstallationVersion } from "@/installation/version"
|
||||||
import { EffectBridge } from "@/effect"
|
import { EffectBridge } from "@/effect"
|
||||||
import * as Option from "effect/Option"
|
import * as Option from "effect/Option"
|
||||||
import * as OtelTracer from "@effect/opentelemetry/Tracer"
|
import * as OtelTracer from "@effect/opentelemetry/Tracer"
|
||||||
@@ -365,7 +366,7 @@ export namespace LLM {
|
|||||||
: {
|
: {
|
||||||
"x-session-affinity": input.sessionID,
|
"x-session-affinity": input.sessionID,
|
||||||
...(input.parentSessionID ? { "x-parent-session-id": input.parentSessionID } : {}),
|
...(input.parentSessionID ? { "x-parent-session-id": input.parentSessionID } : {}),
|
||||||
"User-Agent": `opencode/${Installation.VERSION}`,
|
"User-Agent": `opencode/${InstallationVersion}`,
|
||||||
}),
|
}),
|
||||||
...input.model.headers,
|
...input.model.headers,
|
||||||
...headers,
|
...headers,
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import z from "zod"
|
|||||||
import { type ProviderMetadata, type LanguageModelUsage } from "ai"
|
import { type ProviderMetadata, type LanguageModelUsage } from "ai"
|
||||||
import { Flag } from "../flag/flag"
|
import { Flag } from "../flag/flag"
|
||||||
import { Installation } from "../installation"
|
import { Installation } from "../installation"
|
||||||
|
import { InstallationVersion } from "../installation/version"
|
||||||
|
|
||||||
import { Database, NotFoundError, eq, and, gte, isNull, desc, like, inArray, lt } from "../storage"
|
import { Database, NotFoundError, eq, and, gte, isNull, desc, like, inArray, lt } from "../storage"
|
||||||
import { SyncEvent } from "../sync"
|
import { SyncEvent } from "../sync"
|
||||||
@@ -399,7 +400,7 @@ export const layer: Layer.Layer<Service, never, Bus.Service | Storage.Service> =
|
|||||||
const result: Info = {
|
const result: Info = {
|
||||||
id: SessionID.descending(input.id),
|
id: SessionID.descending(input.id),
|
||||||
slug: Slug.create(),
|
slug: Slug.create(),
|
||||||
version: Installation.VERSION,
|
version: InstallationVersion,
|
||||||
projectID: ctx.project.id,
|
projectID: ctx.project.id,
|
||||||
directory: input.directory,
|
directory: input.directory,
|
||||||
workspaceID: input.workspaceID,
|
workspaceID: input.workspaceID,
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import z from "zod"
|
|||||||
import path from "path"
|
import path from "path"
|
||||||
import { readFileSync, readdirSync, existsSync } from "fs"
|
import { readFileSync, readdirSync, existsSync } from "fs"
|
||||||
import { Flag } from "../flag/flag"
|
import { Flag } from "../flag/flag"
|
||||||
import { CHANNEL } from "../installation/meta"
|
import { InstallationChannel } from "../installation/version"
|
||||||
import { InstanceState } from "@/effect"
|
import { InstanceState } from "@/effect"
|
||||||
import { iife } from "@/util/iife"
|
import { iife } from "@/util/iife"
|
||||||
import { init } from "#db"
|
import { init } from "#db"
|
||||||
@@ -28,9 +28,9 @@ export const NotFoundError = NamedError.create(
|
|||||||
const log = Log.create({ service: "db" })
|
const log = Log.create({ service: "db" })
|
||||||
|
|
||||||
export function getChannelPath() {
|
export function getChannelPath() {
|
||||||
if (["latest", "beta", "prod"].includes(CHANNEL) || Flag.OPENCODE_DISABLE_CHANNEL_DB)
|
if (["latest", "beta", "prod"].includes(InstallationChannel) || Flag.OPENCODE_DISABLE_CHANNEL_DB)
|
||||||
return path.join(Global.Path.data, "opencode.db")
|
return path.join(Global.Path.data, "opencode.db")
|
||||||
const safe = CHANNEL.replace(/[^a-zA-Z0-9._-]/g, "-")
|
const safe = InstallationChannel.replace(/[^a-zA-Z0-9._-]/g, "-")
|
||||||
return path.join(Global.Path.data, `opencode-${safe}.db`)
|
return path.join(Global.Path.data, `opencode-${safe}.db`)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import yargs from "yargs"
|
||||||
|
import { TuiThreadCommand } from "./cli/cmd/tui/thread"
|
||||||
|
import { InstallationVersion } from "./installation/version"
|
||||||
|
import { hideBin } from "yargs/helpers"
|
||||||
|
import { Log } from "./node"
|
||||||
|
|
||||||
|
Log.init({
|
||||||
|
print: false,
|
||||||
|
})
|
||||||
|
|
||||||
|
const cli = yargs(hideBin(process.argv))
|
||||||
|
.parserConfiguration({ "populate--": true })
|
||||||
|
.scriptName("opencode")
|
||||||
|
.wrap(100)
|
||||||
|
.help("help", "show help")
|
||||||
|
.alias("help", "h")
|
||||||
|
.version("version", "show version number", InstallationVersion)
|
||||||
|
.alias("version", "v")
|
||||||
|
.option("print-logs", {
|
||||||
|
describe: "print logs to stderr",
|
||||||
|
type: "boolean",
|
||||||
|
})
|
||||||
|
.option("log-level", {
|
||||||
|
describe: "log level",
|
||||||
|
type: "string",
|
||||||
|
choices: ["DEBUG", "INFO", "WARN", "ERROR"],
|
||||||
|
})
|
||||||
|
.option("pure", {
|
||||||
|
describe: "run without external plugins",
|
||||||
|
type: "boolean",
|
||||||
|
})
|
||||||
|
.command(TuiThreadCommand)
|
||||||
|
.parse()
|
||||||
@@ -1,6 +1,5 @@
|
|||||||
import { chmod, mkdir, readFile, stat as statFile, writeFile } from "fs/promises"
|
import { chmod, mkdir, readFile, stat as statFile, writeFile } from "fs/promises"
|
||||||
import { createWriteStream, existsSync, statSync } from "fs"
|
import { createWriteStream, existsSync, statSync } from "fs"
|
||||||
import { lookup } from "mime-types"
|
|
||||||
import { realpathSync } from "fs"
|
import { realpathSync } from "fs"
|
||||||
import { dirname, join, relative, resolve as pathResolve, win32 } from "path"
|
import { dirname, join, relative, resolve as pathResolve, win32 } from "path"
|
||||||
import { Readable } from "stream"
|
import { Readable } from "stream"
|
||||||
@@ -101,7 +100,8 @@ export async function writeStream(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function mimeType(p: string): string {
|
export async function mimeType(p: string): Promise<string> {
|
||||||
|
const { lookup } = await import("mime-types")
|
||||||
return lookup(p) || "application/octet-stream"
|
return lookup(p) || "application/octet-stream"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import path from "path"
|
|||||||
import { pathToFileURL } from "url"
|
import { pathToFileURL } from "url"
|
||||||
import { tmpdir } from "../../fixture/fixture"
|
import { tmpdir } from "../../fixture/fixture"
|
||||||
import { createTuiPluginApi } from "../../fixture/tui-plugin"
|
import { createTuiPluginApi } from "../../fixture/tui-plugin"
|
||||||
import { TuiConfig } from "../../../src/config"
|
import { TuiConfig } from "../../../src/cli/cmd/tui/config/tui"
|
||||||
|
|
||||||
const { TuiPluginRuntime } = await import("../../../src/cli/cmd/tui/plugin/runtime")
|
const { TuiPluginRuntime } = await import("../../../src/cli/cmd/tui/plugin/runtime")
|
||||||
|
|
||||||
@@ -31,15 +31,18 @@ test("adds tui plugin at runtime from spec", async () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
process.env.OPENCODE_PLUGIN_META_FILE = path.join(tmp.path, "plugin-meta.json")
|
process.env.OPENCODE_PLUGIN_META_FILE = path.join(tmp.path, "plugin-meta.json")
|
||||||
const get = spyOn(TuiConfig, "get").mockResolvedValue({
|
const config: TuiConfig.Info = {
|
||||||
plugin: [],
|
plugin: [],
|
||||||
plugin_origins: undefined,
|
plugin_origins: undefined,
|
||||||
})
|
}
|
||||||
const wait = spyOn(TuiConfig, "waitForDependencies").mockResolvedValue()
|
const wait = spyOn(TuiConfig, "waitForDependencies").mockResolvedValue()
|
||||||
const cwd = spyOn(process, "cwd").mockImplementation(() => tmp.path)
|
const cwd = spyOn(process, "cwd").mockImplementation(() => tmp.path)
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await TuiPluginRuntime.init(createTuiPluginApi())
|
await TuiPluginRuntime.init({
|
||||||
|
api: createTuiPluginApi(),
|
||||||
|
config,
|
||||||
|
})
|
||||||
|
|
||||||
await expect(TuiPluginRuntime.addPlugin(tmp.extra.spec)).resolves.toBe(true)
|
await expect(TuiPluginRuntime.addPlugin(tmp.extra.spec)).resolves.toBe(true)
|
||||||
await expect(fs.readFile(tmp.extra.marker, "utf8")).resolves.toBe("called")
|
await expect(fs.readFile(tmp.extra.marker, "utf8")).resolves.toBe("called")
|
||||||
@@ -54,7 +57,6 @@ test("adds tui plugin at runtime from spec", async () => {
|
|||||||
} finally {
|
} finally {
|
||||||
await TuiPluginRuntime.dispose()
|
await TuiPluginRuntime.dispose()
|
||||||
cwd.mockRestore()
|
cwd.mockRestore()
|
||||||
get.mockRestore()
|
|
||||||
wait.mockRestore()
|
wait.mockRestore()
|
||||||
delete process.env.OPENCODE_PLUGIN_META_FILE
|
delete process.env.OPENCODE_PLUGIN_META_FILE
|
||||||
}
|
}
|
||||||
@@ -72,10 +74,10 @@ test("retries runtime add for file plugins after dependency wait", async () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
process.env.OPENCODE_PLUGIN_META_FILE = path.join(tmp.path, "plugin-meta.json")
|
process.env.OPENCODE_PLUGIN_META_FILE = path.join(tmp.path, "plugin-meta.json")
|
||||||
const get = spyOn(TuiConfig, "get").mockResolvedValue({
|
const config: TuiConfig.Info = {
|
||||||
plugin: [],
|
plugin: [],
|
||||||
plugin_origins: undefined,
|
plugin_origins: undefined,
|
||||||
})
|
}
|
||||||
const wait = spyOn(TuiConfig, "waitForDependencies").mockImplementation(async () => {
|
const wait = spyOn(TuiConfig, "waitForDependencies").mockImplementation(async () => {
|
||||||
await Bun.write(
|
await Bun.write(
|
||||||
path.join(tmp.extra.mod, "index.ts"),
|
path.join(tmp.extra.mod, "index.ts"),
|
||||||
@@ -91,7 +93,10 @@ test("retries runtime add for file plugins after dependency wait", async () => {
|
|||||||
const cwd = spyOn(process, "cwd").mockImplementation(() => tmp.path)
|
const cwd = spyOn(process, "cwd").mockImplementation(() => tmp.path)
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await TuiPluginRuntime.init(createTuiPluginApi())
|
await TuiPluginRuntime.init({
|
||||||
|
api: createTuiPluginApi(),
|
||||||
|
config,
|
||||||
|
})
|
||||||
|
|
||||||
await expect(TuiPluginRuntime.addPlugin(tmp.extra.spec)).resolves.toBe(true)
|
await expect(TuiPluginRuntime.addPlugin(tmp.extra.spec)).resolves.toBe(true)
|
||||||
await expect(fs.readFile(tmp.extra.marker, "utf8")).resolves.toBe("called")
|
await expect(fs.readFile(tmp.extra.marker, "utf8")).resolves.toBe("called")
|
||||||
@@ -100,7 +105,6 @@ test("retries runtime add for file plugins after dependency wait", async () => {
|
|||||||
} finally {
|
} finally {
|
||||||
await TuiPluginRuntime.dispose()
|
await TuiPluginRuntime.dispose()
|
||||||
cwd.mockRestore()
|
cwd.mockRestore()
|
||||||
get.mockRestore()
|
|
||||||
wait.mockRestore()
|
wait.mockRestore()
|
||||||
delete process.env.OPENCODE_PLUGIN_META_FILE
|
delete process.env.OPENCODE_PLUGIN_META_FILE
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import path from "path"
|
|||||||
import { pathToFileURL } from "url"
|
import { pathToFileURL } from "url"
|
||||||
import { tmpdir } from "../../fixture/fixture"
|
import { tmpdir } from "../../fixture/fixture"
|
||||||
import { createTuiPluginApi } from "../../fixture/tui-plugin"
|
import { createTuiPluginApi } from "../../fixture/tui-plugin"
|
||||||
import { TuiConfig } from "../../../src/config"
|
import { TuiConfig } from "../../../src/cli/cmd/tui/config/tui"
|
||||||
|
|
||||||
const { TuiPluginRuntime } = await import("../../../src/cli/cmd/tui/plugin/runtime")
|
const { TuiPluginRuntime } = await import("../../../src/cli/cmd/tui/plugin/runtime")
|
||||||
|
|
||||||
@@ -50,11 +50,10 @@ test("installs plugin without loading it", async () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
process.env.OPENCODE_PLUGIN_META_FILE = path.join(tmp.path, "plugin-meta.json")
|
process.env.OPENCODE_PLUGIN_META_FILE = path.join(tmp.path, "plugin-meta.json")
|
||||||
const cfg: Awaited<ReturnType<typeof TuiConfig.get>> = {
|
const config: TuiConfig.Info = {
|
||||||
plugin: [],
|
plugin: [],
|
||||||
plugin_origins: undefined,
|
plugin_origins: undefined,
|
||||||
}
|
}
|
||||||
const get = spyOn(TuiConfig, "get").mockImplementation(async () => cfg)
|
|
||||||
const wait = spyOn(TuiConfig, "waitForDependencies").mockResolvedValue()
|
const wait = spyOn(TuiConfig, "waitForDependencies").mockResolvedValue()
|
||||||
const cwd = spyOn(process, "cwd").mockImplementation(() => tmp.path)
|
const cwd = spyOn(process, "cwd").mockImplementation(() => tmp.path)
|
||||||
const api = createTuiPluginApi({
|
const api = createTuiPluginApi({
|
||||||
@@ -69,7 +68,7 @@ test("installs plugin without loading it", async () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await TuiPluginRuntime.init(api)
|
await TuiPluginRuntime.init({ api, config })
|
||||||
const out = await TuiPluginRuntime.installPlugin(tmp.extra.spec)
|
const out = await TuiPluginRuntime.installPlugin(tmp.extra.spec)
|
||||||
expect(out).toMatchObject({
|
expect(out).toMatchObject({
|
||||||
ok: true,
|
ok: true,
|
||||||
@@ -82,7 +81,6 @@ test("installs plugin without loading it", async () => {
|
|||||||
} finally {
|
} finally {
|
||||||
await TuiPluginRuntime.dispose()
|
await TuiPluginRuntime.dispose()
|
||||||
cwd.mockRestore()
|
cwd.mockRestore()
|
||||||
get.mockRestore()
|
|
||||||
wait.mockRestore()
|
wait.mockRestore()
|
||||||
delete process.env.OPENCODE_PLUGIN_META_FILE
|
delete process.env.OPENCODE_PLUGIN_META_FILE
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -39,10 +39,10 @@ test("runs onDispose callbacks with aborted signal and is idempotent", async ()
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
const restore = mockTuiRuntime(tmp.path, [[tmp.extra.spec, { marker: tmp.extra.marker }]])
|
const { config, restore } = mockTuiRuntime(tmp.path, [[tmp.extra.spec, { marker: tmp.extra.marker }]])
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await TuiPluginRuntime.init(createTuiPluginApi())
|
await TuiPluginRuntime.init({ api: createTuiPluginApi(), config })
|
||||||
await TuiPluginRuntime.dispose()
|
await TuiPluginRuntime.dispose()
|
||||||
|
|
||||||
const marker = await fs.readFile(tmp.extra.marker, "utf8")
|
const marker = await fs.readFile(tmp.extra.marker, "utf8")
|
||||||
@@ -99,13 +99,13 @@ test("rolls back failed plugin and continues loading next", async () => {
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
const restore = mockTuiRuntime(tmp.path, [
|
const { config, restore } = mockTuiRuntime(tmp.path, [
|
||||||
[tmp.extra.badSpec, { bad_marker: tmp.extra.badMarker }],
|
[tmp.extra.badSpec, { bad_marker: tmp.extra.badMarker }],
|
||||||
[tmp.extra.goodSpec, { good_marker: tmp.extra.goodMarker }],
|
[tmp.extra.goodSpec, { good_marker: tmp.extra.goodMarker }],
|
||||||
])
|
])
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await TuiPluginRuntime.init(createTuiPluginApi())
|
await TuiPluginRuntime.init({ api: createTuiPluginApi(), config })
|
||||||
// bad plugin's onDispose ran during rollback
|
// bad plugin's onDispose ran during rollback
|
||||||
await expect(fs.readFile(tmp.extra.badMarker, "utf8")).resolves.toBe("cleaned")
|
await expect(fs.readFile(tmp.extra.badMarker, "utf8")).resolves.toBe("cleaned")
|
||||||
// good plugin still loaded
|
// good plugin still loaded
|
||||||
@@ -155,11 +155,11 @@ export default {
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
const restore = mockTuiRuntime(tmp.path, [tmp.extra.spec])
|
const { config, restore } = mockTuiRuntime(tmp.path, [tmp.extra.spec])
|
||||||
const err = spyOn(console, "error").mockImplementation(() => {})
|
const err = spyOn(console, "error").mockImplementation(() => {})
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await TuiPluginRuntime.init(createTuiPluginApi())
|
await TuiPluginRuntime.init({ api: createTuiPluginApi(), config })
|
||||||
|
|
||||||
const marker = await fs.readFile(tmp.extra.marker, "utf8")
|
const marker = await fs.readFile(tmp.extra.marker, "utf8")
|
||||||
expect(marker).toContain("one")
|
expect(marker).toContain("one")
|
||||||
@@ -202,10 +202,10 @@ test(
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
const restore = mockTuiRuntime(tmp.path, [tmp.extra.spec])
|
const { config, restore } = mockTuiRuntime(tmp.path, [tmp.extra.spec])
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await TuiPluginRuntime.init(createTuiPluginApi())
|
await TuiPluginRuntime.init({ api: createTuiPluginApi(), config })
|
||||||
|
|
||||||
const done = await new Promise<string>((resolve) => {
|
const done = await new Promise<string>((resolve) => {
|
||||||
const timer = setTimeout(() => resolve("timeout"), 7000)
|
const timer = setTimeout(() => resolve("timeout"), 7000)
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import path from "path"
|
|||||||
import { pathToFileURL } from "url"
|
import { pathToFileURL } from "url"
|
||||||
import { tmpdir } from "../../fixture/fixture"
|
import { tmpdir } from "../../fixture/fixture"
|
||||||
import { createTuiPluginApi } from "../../fixture/tui-plugin"
|
import { createTuiPluginApi } from "../../fixture/tui-plugin"
|
||||||
import { TuiConfig } from "../../../src/config"
|
import { TuiConfig } from "../../../src/cli/cmd/tui/config/tui"
|
||||||
import { Npm } from "../../../src/npm"
|
import { Npm } from "../../../src/npm"
|
||||||
|
|
||||||
const { TuiPluginRuntime } = await import("../../../src/cli/cmd/tui/plugin/runtime")
|
const { TuiPluginRuntime } = await import("../../../src/cli/cmd/tui/plugin/runtime")
|
||||||
@@ -44,7 +44,7 @@ test("loads npm tui plugin from package ./tui export", async () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
process.env.OPENCODE_PLUGIN_META_FILE = path.join(tmp.path, "plugin-meta.json")
|
process.env.OPENCODE_PLUGIN_META_FILE = path.join(tmp.path, "plugin-meta.json")
|
||||||
const get = spyOn(TuiConfig, "get").mockResolvedValue({
|
const config: TuiConfig.Info = {
|
||||||
plugin: [[tmp.extra.spec, { marker: tmp.extra.marker }]],
|
plugin: [[tmp.extra.spec, { marker: tmp.extra.marker }]],
|
||||||
plugin_origins: [
|
plugin_origins: [
|
||||||
{
|
{
|
||||||
@@ -53,13 +53,13 @@ test("loads npm tui plugin from package ./tui export", async () => {
|
|||||||
source: path.join(tmp.path, "tui.json"),
|
source: path.join(tmp.path, "tui.json"),
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
})
|
}
|
||||||
const wait = spyOn(TuiConfig, "waitForDependencies").mockResolvedValue()
|
const wait = spyOn(TuiConfig, "waitForDependencies").mockResolvedValue()
|
||||||
const cwd = spyOn(process, "cwd").mockImplementation(() => tmp.path)
|
const cwd = spyOn(process, "cwd").mockImplementation(() => tmp.path)
|
||||||
const install = spyOn(Npm, "add").mockResolvedValue({ directory: tmp.extra.mod, entrypoint: tmp.extra.mod })
|
const install = spyOn(Npm, "add").mockResolvedValue({ directory: tmp.extra.mod, entrypoint: tmp.extra.mod })
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await TuiPluginRuntime.init(createTuiPluginApi())
|
await TuiPluginRuntime.init({ api: createTuiPluginApi(), config })
|
||||||
await expect(fs.readFile(tmp.extra.marker, "utf8")).resolves.toBe("called")
|
await expect(fs.readFile(tmp.extra.marker, "utf8")).resolves.toBe("called")
|
||||||
const hit = TuiPluginRuntime.list().find((item) => item.id === "demo.tui.export")
|
const hit = TuiPluginRuntime.list().find((item) => item.id === "demo.tui.export")
|
||||||
expect(hit?.enabled).toBe(true)
|
expect(hit?.enabled).toBe(true)
|
||||||
@@ -69,7 +69,6 @@ test("loads npm tui plugin from package ./tui export", async () => {
|
|||||||
await TuiPluginRuntime.dispose()
|
await TuiPluginRuntime.dispose()
|
||||||
install.mockRestore()
|
install.mockRestore()
|
||||||
cwd.mockRestore()
|
cwd.mockRestore()
|
||||||
get.mockRestore()
|
|
||||||
wait.mockRestore()
|
wait.mockRestore()
|
||||||
delete process.env.OPENCODE_PLUGIN_META_FILE
|
delete process.env.OPENCODE_PLUGIN_META_FILE
|
||||||
}
|
}
|
||||||
@@ -106,7 +105,7 @@ test("does not use npm package exports dot for tui entry", async () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
process.env.OPENCODE_PLUGIN_META_FILE = path.join(tmp.path, "plugin-meta.json")
|
process.env.OPENCODE_PLUGIN_META_FILE = path.join(tmp.path, "plugin-meta.json")
|
||||||
const get = spyOn(TuiConfig, "get").mockResolvedValue({
|
const config: TuiConfig.Info = {
|
||||||
plugin: [tmp.extra.spec],
|
plugin: [tmp.extra.spec],
|
||||||
plugin_origins: [
|
plugin_origins: [
|
||||||
{
|
{
|
||||||
@@ -115,20 +114,19 @@ test("does not use npm package exports dot for tui entry", async () => {
|
|||||||
source: path.join(tmp.path, "tui.json"),
|
source: path.join(tmp.path, "tui.json"),
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
})
|
}
|
||||||
const wait = spyOn(TuiConfig, "waitForDependencies").mockResolvedValue()
|
const wait = spyOn(TuiConfig, "waitForDependencies").mockResolvedValue()
|
||||||
const cwd = spyOn(process, "cwd").mockImplementation(() => tmp.path)
|
const cwd = spyOn(process, "cwd").mockImplementation(() => tmp.path)
|
||||||
const install = spyOn(Npm, "add").mockResolvedValue({ directory: tmp.extra.mod, entrypoint: tmp.extra.mod })
|
const install = spyOn(Npm, "add").mockResolvedValue({ directory: tmp.extra.mod, entrypoint: tmp.extra.mod })
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await TuiPluginRuntime.init(createTuiPluginApi())
|
await TuiPluginRuntime.init({ api: createTuiPluginApi(), config })
|
||||||
await expect(fs.readFile(tmp.extra.marker, "utf8")).rejects.toThrow()
|
await expect(fs.readFile(tmp.extra.marker, "utf8")).rejects.toThrow()
|
||||||
expect(TuiPluginRuntime.list().some((item) => item.spec === tmp.extra.spec)).toBe(false)
|
expect(TuiPluginRuntime.list().some((item) => item.spec === tmp.extra.spec)).toBe(false)
|
||||||
} finally {
|
} finally {
|
||||||
await TuiPluginRuntime.dispose()
|
await TuiPluginRuntime.dispose()
|
||||||
install.mockRestore()
|
install.mockRestore()
|
||||||
cwd.mockRestore()
|
cwd.mockRestore()
|
||||||
get.mockRestore()
|
|
||||||
wait.mockRestore()
|
wait.mockRestore()
|
||||||
delete process.env.OPENCODE_PLUGIN_META_FILE
|
delete process.env.OPENCODE_PLUGIN_META_FILE
|
||||||
}
|
}
|
||||||
@@ -169,7 +167,7 @@ test("rejects npm tui export that resolves outside plugin directory", async () =
|
|||||||
})
|
})
|
||||||
|
|
||||||
process.env.OPENCODE_PLUGIN_META_FILE = path.join(tmp.path, "plugin-meta.json")
|
process.env.OPENCODE_PLUGIN_META_FILE = path.join(tmp.path, "plugin-meta.json")
|
||||||
const get = spyOn(TuiConfig, "get").mockResolvedValue({
|
const config: TuiConfig.Info = {
|
||||||
plugin: [tmp.extra.spec],
|
plugin: [tmp.extra.spec],
|
||||||
plugin_origins: [
|
plugin_origins: [
|
||||||
{
|
{
|
||||||
@@ -178,13 +176,13 @@ test("rejects npm tui export that resolves outside plugin directory", async () =
|
|||||||
source: path.join(tmp.path, "tui.json"),
|
source: path.join(tmp.path, "tui.json"),
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
})
|
}
|
||||||
const wait = spyOn(TuiConfig, "waitForDependencies").mockResolvedValue()
|
const wait = spyOn(TuiConfig, "waitForDependencies").mockResolvedValue()
|
||||||
const cwd = spyOn(process, "cwd").mockImplementation(() => tmp.path)
|
const cwd = spyOn(process, "cwd").mockImplementation(() => tmp.path)
|
||||||
const install = spyOn(Npm, "add").mockResolvedValue({ directory: tmp.extra.mod, entrypoint: tmp.extra.mod })
|
const install = spyOn(Npm, "add").mockResolvedValue({ directory: tmp.extra.mod, entrypoint: tmp.extra.mod })
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await TuiPluginRuntime.init(createTuiPluginApi())
|
await TuiPluginRuntime.init({ api: createTuiPluginApi(), config })
|
||||||
// plugin code never ran
|
// plugin code never ran
|
||||||
await expect(fs.readFile(tmp.extra.marker, "utf8")).rejects.toThrow()
|
await expect(fs.readFile(tmp.extra.marker, "utf8")).rejects.toThrow()
|
||||||
// plugin not listed
|
// plugin not listed
|
||||||
@@ -193,7 +191,6 @@ test("rejects npm tui export that resolves outside plugin directory", async () =
|
|||||||
await TuiPluginRuntime.dispose()
|
await TuiPluginRuntime.dispose()
|
||||||
install.mockRestore()
|
install.mockRestore()
|
||||||
cwd.mockRestore()
|
cwd.mockRestore()
|
||||||
get.mockRestore()
|
|
||||||
wait.mockRestore()
|
wait.mockRestore()
|
||||||
delete process.env.OPENCODE_PLUGIN_META_FILE
|
delete process.env.OPENCODE_PLUGIN_META_FILE
|
||||||
}
|
}
|
||||||
@@ -232,7 +229,7 @@ test("rejects npm tui plugin that exports server and tui together", async () =>
|
|||||||
})
|
})
|
||||||
|
|
||||||
process.env.OPENCODE_PLUGIN_META_FILE = path.join(tmp.path, "plugin-meta.json")
|
process.env.OPENCODE_PLUGIN_META_FILE = path.join(tmp.path, "plugin-meta.json")
|
||||||
const get = spyOn(TuiConfig, "get").mockResolvedValue({
|
const config: TuiConfig.Info = {
|
||||||
plugin: [tmp.extra.spec],
|
plugin: [tmp.extra.spec],
|
||||||
plugin_origins: [
|
plugin_origins: [
|
||||||
{
|
{
|
||||||
@@ -241,20 +238,19 @@ test("rejects npm tui plugin that exports server and tui together", async () =>
|
|||||||
source: path.join(tmp.path, "tui.json"),
|
source: path.join(tmp.path, "tui.json"),
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
})
|
}
|
||||||
const wait = spyOn(TuiConfig, "waitForDependencies").mockResolvedValue()
|
const wait = spyOn(TuiConfig, "waitForDependencies").mockResolvedValue()
|
||||||
const cwd = spyOn(process, "cwd").mockImplementation(() => tmp.path)
|
const cwd = spyOn(process, "cwd").mockImplementation(() => tmp.path)
|
||||||
const install = spyOn(Npm, "add").mockResolvedValue({ directory: tmp.extra.mod, entrypoint: tmp.extra.mod })
|
const install = spyOn(Npm, "add").mockResolvedValue({ directory: tmp.extra.mod, entrypoint: tmp.extra.mod })
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await TuiPluginRuntime.init(createTuiPluginApi())
|
await TuiPluginRuntime.init({ api: createTuiPluginApi(), config })
|
||||||
await expect(fs.readFile(tmp.extra.marker, "utf8")).rejects.toThrow()
|
await expect(fs.readFile(tmp.extra.marker, "utf8")).rejects.toThrow()
|
||||||
expect(TuiPluginRuntime.list().some((item) => item.spec === tmp.extra.spec)).toBe(false)
|
expect(TuiPluginRuntime.list().some((item) => item.spec === tmp.extra.spec)).toBe(false)
|
||||||
} finally {
|
} finally {
|
||||||
await TuiPluginRuntime.dispose()
|
await TuiPluginRuntime.dispose()
|
||||||
install.mockRestore()
|
install.mockRestore()
|
||||||
cwd.mockRestore()
|
cwd.mockRestore()
|
||||||
get.mockRestore()
|
|
||||||
wait.mockRestore()
|
wait.mockRestore()
|
||||||
delete process.env.OPENCODE_PLUGIN_META_FILE
|
delete process.env.OPENCODE_PLUGIN_META_FILE
|
||||||
}
|
}
|
||||||
@@ -291,7 +287,7 @@ test("does not use npm package main for tui entry", async () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
process.env.OPENCODE_PLUGIN_META_FILE = path.join(tmp.path, "plugin-meta.json")
|
process.env.OPENCODE_PLUGIN_META_FILE = path.join(tmp.path, "plugin-meta.json")
|
||||||
const get = spyOn(TuiConfig, "get").mockResolvedValue({
|
const config: TuiConfig.Info = {
|
||||||
plugin: [tmp.extra.spec],
|
plugin: [tmp.extra.spec],
|
||||||
plugin_origins: [
|
plugin_origins: [
|
||||||
{
|
{
|
||||||
@@ -300,7 +296,7 @@ test("does not use npm package main for tui entry", async () => {
|
|||||||
source: path.join(tmp.path, "tui.json"),
|
source: path.join(tmp.path, "tui.json"),
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
})
|
}
|
||||||
const wait = spyOn(TuiConfig, "waitForDependencies").mockResolvedValue()
|
const wait = spyOn(TuiConfig, "waitForDependencies").mockResolvedValue()
|
||||||
const cwd = spyOn(process, "cwd").mockImplementation(() => tmp.path)
|
const cwd = spyOn(process, "cwd").mockImplementation(() => tmp.path)
|
||||||
const install = spyOn(Npm, "add").mockResolvedValue({ directory: tmp.extra.mod, entrypoint: tmp.extra.mod })
|
const install = spyOn(Npm, "add").mockResolvedValue({ directory: tmp.extra.mod, entrypoint: tmp.extra.mod })
|
||||||
@@ -308,7 +304,7 @@ test("does not use npm package main for tui entry", async () => {
|
|||||||
const error = spyOn(console, "error").mockImplementation(() => {})
|
const error = spyOn(console, "error").mockImplementation(() => {})
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await TuiPluginRuntime.init(createTuiPluginApi())
|
await TuiPluginRuntime.init({ api: createTuiPluginApi(), config })
|
||||||
await expect(fs.readFile(tmp.extra.marker, "utf8")).rejects.toThrow()
|
await expect(fs.readFile(tmp.extra.marker, "utf8")).rejects.toThrow()
|
||||||
expect(TuiPluginRuntime.list().some((item) => item.spec === tmp.extra.spec)).toBe(false)
|
expect(TuiPluginRuntime.list().some((item) => item.spec === tmp.extra.spec)).toBe(false)
|
||||||
expect(error).not.toHaveBeenCalled()
|
expect(error).not.toHaveBeenCalled()
|
||||||
@@ -317,7 +313,6 @@ test("does not use npm package main for tui entry", async () => {
|
|||||||
await TuiPluginRuntime.dispose()
|
await TuiPluginRuntime.dispose()
|
||||||
install.mockRestore()
|
install.mockRestore()
|
||||||
cwd.mockRestore()
|
cwd.mockRestore()
|
||||||
get.mockRestore()
|
|
||||||
wait.mockRestore()
|
wait.mockRestore()
|
||||||
warn.mockRestore()
|
warn.mockRestore()
|
||||||
error.mockRestore()
|
error.mockRestore()
|
||||||
@@ -357,7 +352,7 @@ test("does not use directory package main for tui entry", async () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
process.env.OPENCODE_PLUGIN_META_FILE = path.join(tmp.path, "plugin-meta.json")
|
process.env.OPENCODE_PLUGIN_META_FILE = path.join(tmp.path, "plugin-meta.json")
|
||||||
const get = spyOn(TuiConfig, "get").mockResolvedValue({
|
const config: TuiConfig.Info = {
|
||||||
plugin: [tmp.extra.spec],
|
plugin: [tmp.extra.spec],
|
||||||
plugin_origins: [
|
plugin_origins: [
|
||||||
{
|
{
|
||||||
@@ -366,18 +361,17 @@ test("does not use directory package main for tui entry", async () => {
|
|||||||
source: path.join(tmp.path, "tui.json"),
|
source: path.join(tmp.path, "tui.json"),
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
})
|
}
|
||||||
const wait = spyOn(TuiConfig, "waitForDependencies").mockResolvedValue()
|
const wait = spyOn(TuiConfig, "waitForDependencies").mockResolvedValue()
|
||||||
const cwd = spyOn(process, "cwd").mockImplementation(() => tmp.path)
|
const cwd = spyOn(process, "cwd").mockImplementation(() => tmp.path)
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await TuiPluginRuntime.init(createTuiPluginApi())
|
await TuiPluginRuntime.init({ api: createTuiPluginApi(), config })
|
||||||
await expect(fs.readFile(tmp.extra.marker, "utf8")).rejects.toThrow()
|
await expect(fs.readFile(tmp.extra.marker, "utf8")).rejects.toThrow()
|
||||||
expect(TuiPluginRuntime.list().some((item) => item.spec === tmp.extra.spec)).toBe(false)
|
expect(TuiPluginRuntime.list().some((item) => item.spec === tmp.extra.spec)).toBe(false)
|
||||||
} finally {
|
} finally {
|
||||||
await TuiPluginRuntime.dispose()
|
await TuiPluginRuntime.dispose()
|
||||||
cwd.mockRestore()
|
cwd.mockRestore()
|
||||||
get.mockRestore()
|
|
||||||
wait.mockRestore()
|
wait.mockRestore()
|
||||||
delete process.env.OPENCODE_PLUGIN_META_FILE
|
delete process.env.OPENCODE_PLUGIN_META_FILE
|
||||||
}
|
}
|
||||||
@@ -405,7 +399,7 @@ test("uses directory index fallback for tui when package.json is missing", async
|
|||||||
})
|
})
|
||||||
|
|
||||||
process.env.OPENCODE_PLUGIN_META_FILE = path.join(tmp.path, "plugin-meta.json")
|
process.env.OPENCODE_PLUGIN_META_FILE = path.join(tmp.path, "plugin-meta.json")
|
||||||
const get = spyOn(TuiConfig, "get").mockResolvedValue({
|
const config: TuiConfig.Info = {
|
||||||
plugin: [tmp.extra.spec],
|
plugin: [tmp.extra.spec],
|
||||||
plugin_origins: [
|
plugin_origins: [
|
||||||
{
|
{
|
||||||
@@ -414,18 +408,17 @@ test("uses directory index fallback for tui when package.json is missing", async
|
|||||||
source: path.join(tmp.path, "tui.json"),
|
source: path.join(tmp.path, "tui.json"),
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
})
|
}
|
||||||
const wait = spyOn(TuiConfig, "waitForDependencies").mockResolvedValue()
|
const wait = spyOn(TuiConfig, "waitForDependencies").mockResolvedValue()
|
||||||
const cwd = spyOn(process, "cwd").mockImplementation(() => tmp.path)
|
const cwd = spyOn(process, "cwd").mockImplementation(() => tmp.path)
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await TuiPluginRuntime.init(createTuiPluginApi())
|
await TuiPluginRuntime.init({ api: createTuiPluginApi(), config })
|
||||||
await expect(fs.readFile(tmp.extra.marker, "utf8")).resolves.toBe("called")
|
await expect(fs.readFile(tmp.extra.marker, "utf8")).resolves.toBe("called")
|
||||||
expect(TuiPluginRuntime.list().find((item) => item.id === "demo.dir.index")?.active).toBe(true)
|
expect(TuiPluginRuntime.list().find((item) => item.id === "demo.dir.index")?.active).toBe(true)
|
||||||
} finally {
|
} finally {
|
||||||
await TuiPluginRuntime.dispose()
|
await TuiPluginRuntime.dispose()
|
||||||
cwd.mockRestore()
|
cwd.mockRestore()
|
||||||
get.mockRestore()
|
|
||||||
wait.mockRestore()
|
wait.mockRestore()
|
||||||
delete process.env.OPENCODE_PLUGIN_META_FILE
|
delete process.env.OPENCODE_PLUGIN_META_FILE
|
||||||
}
|
}
|
||||||
@@ -463,7 +456,7 @@ test("uses npm package name when tui plugin id is omitted", async () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
process.env.OPENCODE_PLUGIN_META_FILE = path.join(tmp.path, "plugin-meta.json")
|
process.env.OPENCODE_PLUGIN_META_FILE = path.join(tmp.path, "plugin-meta.json")
|
||||||
const get = spyOn(TuiConfig, "get").mockResolvedValue({
|
const config: TuiConfig.Info = {
|
||||||
plugin: [[tmp.extra.spec, { marker: tmp.extra.marker }]],
|
plugin: [[tmp.extra.spec, { marker: tmp.extra.marker }]],
|
||||||
plugin_origins: [
|
plugin_origins: [
|
||||||
{
|
{
|
||||||
@@ -472,20 +465,19 @@ test("uses npm package name when tui plugin id is omitted", async () => {
|
|||||||
source: path.join(tmp.path, "tui.json"),
|
source: path.join(tmp.path, "tui.json"),
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
})
|
}
|
||||||
const wait = spyOn(TuiConfig, "waitForDependencies").mockResolvedValue()
|
const wait = spyOn(TuiConfig, "waitForDependencies").mockResolvedValue()
|
||||||
const cwd = spyOn(process, "cwd").mockImplementation(() => tmp.path)
|
const cwd = spyOn(process, "cwd").mockImplementation(() => tmp.path)
|
||||||
const install = spyOn(Npm, "add").mockResolvedValue({ directory: tmp.extra.mod, entrypoint: tmp.extra.mod })
|
const install = spyOn(Npm, "add").mockResolvedValue({ directory: tmp.extra.mod, entrypoint: tmp.extra.mod })
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await TuiPluginRuntime.init(createTuiPluginApi())
|
await TuiPluginRuntime.init({ api: createTuiPluginApi(), config })
|
||||||
await expect(fs.readFile(tmp.extra.marker, "utf8")).resolves.toBe("called")
|
await expect(fs.readFile(tmp.extra.marker, "utf8")).resolves.toBe("called")
|
||||||
expect(TuiPluginRuntime.list().find((item) => item.spec === tmp.extra.spec)?.id).toBe("acme-plugin")
|
expect(TuiPluginRuntime.list().find((item) => item.spec === tmp.extra.spec)?.id).toBe("acme-plugin")
|
||||||
} finally {
|
} finally {
|
||||||
await TuiPluginRuntime.dispose()
|
await TuiPluginRuntime.dispose()
|
||||||
install.mockRestore()
|
install.mockRestore()
|
||||||
cwd.mockRestore()
|
cwd.mockRestore()
|
||||||
get.mockRestore()
|
|
||||||
wait.mockRestore()
|
wait.mockRestore()
|
||||||
delete process.env.OPENCODE_PLUGIN_META_FILE
|
delete process.env.OPENCODE_PLUGIN_META_FILE
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import path from "path"
|
|||||||
import { pathToFileURL } from "url"
|
import { pathToFileURL } from "url"
|
||||||
import { tmpdir } from "../../fixture/fixture"
|
import { tmpdir } from "../../fixture/fixture"
|
||||||
import { createTuiPluginApi } from "../../fixture/tui-plugin"
|
import { createTuiPluginApi } from "../../fixture/tui-plugin"
|
||||||
import { TuiConfig } from "../../../src/config"
|
import { TuiConfig } from "../../../src/cli/cmd/tui/config/tui"
|
||||||
|
|
||||||
const { TuiPluginRuntime } = await import("../../../src/cli/cmd/tui/plugin/runtime")
|
const { TuiPluginRuntime } = await import("../../../src/cli/cmd/tui/plugin/runtime")
|
||||||
|
|
||||||
@@ -37,7 +37,7 @@ test("skips external tui plugins in pure mode", async () => {
|
|||||||
process.env.OPENCODE_PURE = "1"
|
process.env.OPENCODE_PURE = "1"
|
||||||
process.env.OPENCODE_PLUGIN_META_FILE = tmp.extra.meta
|
process.env.OPENCODE_PLUGIN_META_FILE = tmp.extra.meta
|
||||||
|
|
||||||
const get = spyOn(TuiConfig, "get").mockResolvedValue({
|
const config: TuiConfig.Info = {
|
||||||
plugin: [[tmp.extra.spec, { marker: tmp.extra.marker }]],
|
plugin: [[tmp.extra.spec, { marker: tmp.extra.marker }]],
|
||||||
plugin_origins: [
|
plugin_origins: [
|
||||||
{
|
{
|
||||||
@@ -46,17 +46,16 @@ test("skips external tui plugins in pure mode", async () => {
|
|||||||
source: path.join(tmp.path, "tui.json"),
|
source: path.join(tmp.path, "tui.json"),
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
})
|
}
|
||||||
const wait = spyOn(TuiConfig, "waitForDependencies").mockResolvedValue()
|
const wait = spyOn(TuiConfig, "waitForDependencies").mockResolvedValue()
|
||||||
const cwd = spyOn(process, "cwd").mockImplementation(() => tmp.path)
|
const cwd = spyOn(process, "cwd").mockImplementation(() => tmp.path)
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await TuiPluginRuntime.init(createTuiPluginApi())
|
await TuiPluginRuntime.init({ api: createTuiPluginApi(), config })
|
||||||
await expect(fs.readFile(tmp.extra.marker, "utf8")).rejects.toThrow()
|
await expect(fs.readFile(tmp.extra.marker, "utf8")).rejects.toThrow()
|
||||||
} finally {
|
} finally {
|
||||||
await TuiPluginRuntime.dispose()
|
await TuiPluginRuntime.dispose()
|
||||||
cwd.mockRestore()
|
cwd.mockRestore()
|
||||||
get.mockRestore()
|
|
||||||
wait.mockRestore()
|
wait.mockRestore()
|
||||||
if (pure === undefined) {
|
if (pure === undefined) {
|
||||||
delete process.env.OPENCODE_PURE
|
delete process.env.OPENCODE_PURE
|
||||||
|
|||||||
@@ -5,8 +5,8 @@ import { pathToFileURL } from "url"
|
|||||||
import { tmpdir } from "../../fixture/fixture"
|
import { tmpdir } from "../../fixture/fixture"
|
||||||
import { createTuiPluginApi } from "../../fixture/tui-plugin"
|
import { createTuiPluginApi } from "../../fixture/tui-plugin"
|
||||||
import { Global } from "../../../src/global"
|
import { Global } from "../../../src/global"
|
||||||
import { TuiConfig } from "../../../src/config"
|
import { TuiConfig } from "../../../src/cli/cmd/tui/config/tui"
|
||||||
import { Filesystem } from "../../../src/util"
|
import { Filesystem } from "../../../src/util/"
|
||||||
|
|
||||||
const { allThemes, addTheme } = await import("../../../src/cli/cmd/tui/context/theme")
|
const { allThemes, addTheme } = await import("../../../src/cli/cmd/tui/context/theme")
|
||||||
const { TuiPluginRuntime } = await import("../../../src/cli/cmd/tui/plugin/runtime")
|
const { TuiPluginRuntime } = await import("../../../src/cli/cmd/tui/plugin/runtime")
|
||||||
@@ -328,8 +328,55 @@ export default {
|
|||||||
try {
|
try {
|
||||||
expect(addTheme(tmp.extra.preloadedThemeName, { theme: { primary: "#303030" } })).toBe(true)
|
expect(addTheme(tmp.extra.preloadedThemeName, { theme: { primary: "#303030" } })).toBe(true)
|
||||||
|
|
||||||
await TuiPluginRuntime.init(
|
const localOpts = {
|
||||||
createTuiPluginApi({
|
fn_marker: tmp.extra.fnMarker,
|
||||||
|
marker: tmp.extra.localMarker,
|
||||||
|
source: tmp.extra.localDest.replace(".opencode/themes/", ""),
|
||||||
|
dest: tmp.extra.localDest,
|
||||||
|
theme_path: `./${tmp.extra.localThemeFile}`,
|
||||||
|
theme_name: tmp.extra.localThemeName,
|
||||||
|
kv_key: "plugin_state_key",
|
||||||
|
session_id: "ses_test",
|
||||||
|
keybinds: { modal: "ctrl+alt+m", close: "q" },
|
||||||
|
}
|
||||||
|
const invalidOpts = {
|
||||||
|
marker: tmp.extra.invalidMarker,
|
||||||
|
theme_path: `./${tmp.extra.invalidThemeFile}`,
|
||||||
|
theme_name: tmp.extra.invalidThemeName,
|
||||||
|
}
|
||||||
|
const preloadedOpts = {
|
||||||
|
marker: tmp.extra.preloadedMarker,
|
||||||
|
dest: tmp.extra.preloadedDest,
|
||||||
|
theme_path: `./${tmp.extra.preloadedThemeFile}`,
|
||||||
|
theme_name: tmp.extra.preloadedThemeName,
|
||||||
|
}
|
||||||
|
const globalOpts = {
|
||||||
|
marker: tmp.extra.globalMarker,
|
||||||
|
theme_path: `./${tmp.extra.globalThemeFile}`,
|
||||||
|
theme_name: tmp.extra.globalThemeName,
|
||||||
|
}
|
||||||
|
|
||||||
|
const config: TuiConfig.Info = {
|
||||||
|
plugin: [
|
||||||
|
[tmp.extra.localSpec, localOpts],
|
||||||
|
[tmp.extra.invalidSpec, invalidOpts],
|
||||||
|
[tmp.extra.preloadedSpec, preloadedOpts],
|
||||||
|
[tmp.extra.globalSpec, globalOpts],
|
||||||
|
],
|
||||||
|
plugin_origins: [
|
||||||
|
{ spec: [tmp.extra.localSpec, localOpts], scope: "local", source: path.join(tmp.path, "tui.json") },
|
||||||
|
{ spec: [tmp.extra.invalidSpec, invalidOpts], scope: "local", source: path.join(tmp.path, "tui.json") },
|
||||||
|
{ spec: [tmp.extra.preloadedSpec, preloadedOpts], scope: "local", source: path.join(tmp.path, "tui.json") },
|
||||||
|
{
|
||||||
|
spec: [tmp.extra.globalSpec, globalOpts],
|
||||||
|
scope: "global",
|
||||||
|
source: path.join(Global.Path.config, "tui.json"),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
await TuiPluginRuntime.init({
|
||||||
|
api: createTuiPluginApi({
|
||||||
tuiConfig: {
|
tuiConfig: {
|
||||||
theme: "smoke",
|
theme: "smoke",
|
||||||
diff_style: "stacked",
|
diff_style: "stacked",
|
||||||
@@ -366,7 +413,8 @@ export default {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
)
|
config,
|
||||||
|
})
|
||||||
const local = await row(tmp.extra.localMarker)
|
const local = await row(tmp.extra.localMarker)
|
||||||
const global = await row(tmp.extra.globalMarker)
|
const global = await row(tmp.extra.globalMarker)
|
||||||
const invalid = await row(tmp.extra.invalidMarker)
|
const invalid = await row(tmp.extra.invalidMarker)
|
||||||
@@ -459,7 +507,7 @@ test("continues loading when a plugin is missing config metadata", async () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
process.env.OPENCODE_PLUGIN_META_FILE = path.join(tmp.path, "plugin-meta.json")
|
process.env.OPENCODE_PLUGIN_META_FILE = path.join(tmp.path, "plugin-meta.json")
|
||||||
const get = spyOn(TuiConfig, "get").mockResolvedValue({
|
const config: TuiConfig.Info = {
|
||||||
plugin: [
|
plugin: [
|
||||||
[tmp.extra.badSpec, { marker: path.join(tmp.path, "bad.txt") }],
|
[tmp.extra.badSpec, { marker: path.join(tmp.path, "bad.txt") }],
|
||||||
[tmp.extra.goodSpec, { marker: tmp.extra.goodMarker }],
|
[tmp.extra.goodSpec, { marker: tmp.extra.goodMarker }],
|
||||||
@@ -477,12 +525,12 @@ test("continues loading when a plugin is missing config metadata", async () => {
|
|||||||
source: path.join(tmp.path, "tui.json"),
|
source: path.join(tmp.path, "tui.json"),
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
})
|
}
|
||||||
const wait = spyOn(TuiConfig, "waitForDependencies").mockResolvedValue()
|
const wait = spyOn(TuiConfig, "waitForDependencies").mockResolvedValue()
|
||||||
const cwd = spyOn(process, "cwd").mockImplementation(() => tmp.path)
|
const cwd = spyOn(process, "cwd").mockImplementation(() => tmp.path)
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await TuiPluginRuntime.init(createTuiPluginApi())
|
await TuiPluginRuntime.init({ api: createTuiPluginApi(), config })
|
||||||
// bad plugin was skipped (no metadata entry)
|
// bad plugin was skipped (no metadata entry)
|
||||||
await expect(fs.readFile(path.join(tmp.path, "bad.txt"), "utf8")).rejects.toThrow()
|
await expect(fs.readFile(path.join(tmp.path, "bad.txt"), "utf8")).rejects.toThrow()
|
||||||
// good plugin loaded fine
|
// good plugin loaded fine
|
||||||
@@ -492,7 +540,6 @@ test("continues loading when a plugin is missing config metadata", async () => {
|
|||||||
} finally {
|
} finally {
|
||||||
await TuiPluginRuntime.dispose()
|
await TuiPluginRuntime.dispose()
|
||||||
cwd.mockRestore()
|
cwd.mockRestore()
|
||||||
get.mockRestore()
|
|
||||||
wait.mockRestore()
|
wait.mockRestore()
|
||||||
delete process.env.OPENCODE_PLUGIN_META_FILE
|
delete process.env.OPENCODE_PLUGIN_META_FILE
|
||||||
}
|
}
|
||||||
@@ -555,7 +602,18 @@ export default {
|
|||||||
const cwd = spyOn(process, "cwd").mockImplementation(() => tmp.path)
|
const cwd = spyOn(process, "cwd").mockImplementation(() => tmp.path)
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await TuiPluginRuntime.init(createTuiPluginApi())
|
const a = path.join(tmp.path, "order-a.ts")
|
||||||
|
const b = path.join(tmp.path, "order-b.ts")
|
||||||
|
const aSpec = pathToFileURL(a).href
|
||||||
|
const bSpec = pathToFileURL(b).href
|
||||||
|
const config: TuiConfig.Info = {
|
||||||
|
plugin: [aSpec, bSpec],
|
||||||
|
plugin_origins: [
|
||||||
|
{ spec: aSpec, scope: "local", source: path.join(tmp.path, "tui.json") },
|
||||||
|
{ spec: bSpec, scope: "local", source: path.join(tmp.path, "tui.json") },
|
||||||
|
],
|
||||||
|
}
|
||||||
|
await TuiPluginRuntime.init({ api: createTuiPluginApi(), config })
|
||||||
const lines = (await fs.readFile(tmp.extra.marker, "utf8")).trim().split("\n")
|
const lines = (await fs.readFile(tmp.extra.marker, "utf8")).trim().split("\n")
|
||||||
expect(lines).toEqual(["a-start", "a-end", "b"])
|
expect(lines).toEqual(["a-start", "a-end", "b"])
|
||||||
} finally {
|
} finally {
|
||||||
@@ -699,7 +757,7 @@ test("updates installed theme when plugin metadata changes", async () => {
|
|||||||
const cwd = spyOn(process, "cwd").mockImplementation(() => tmp.path)
|
const cwd = spyOn(process, "cwd").mockImplementation(() => tmp.path)
|
||||||
const wait = spyOn(TuiConfig, "waitForDependencies").mockResolvedValue()
|
const wait = spyOn(TuiConfig, "waitForDependencies").mockResolvedValue()
|
||||||
|
|
||||||
const api = () =>
|
const mkApi = () =>
|
||||||
createTuiPluginApi({
|
createTuiPluginApi({
|
||||||
theme: {
|
theme: {
|
||||||
has(name) {
|
has(name) {
|
||||||
@@ -708,8 +766,19 @@ test("updates installed theme when plugin metadata changes", async () => {
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const mkConfig = (): TuiConfig.Info => ({
|
||||||
|
plugin: [[tmp.extra.spec, { theme_path: `./theme-update.json` }]],
|
||||||
|
plugin_origins: [
|
||||||
|
{
|
||||||
|
spec: [tmp.extra.spec, { theme_path: `./theme-update.json` }],
|
||||||
|
scope: "local",
|
||||||
|
source: path.join(tmp.path, "tui.json"),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
})
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await TuiPluginRuntime.init(api())
|
await TuiPluginRuntime.init({ api: mkApi(), config: mkConfig() })
|
||||||
await TuiPluginRuntime.dispose()
|
await TuiPluginRuntime.dispose()
|
||||||
await expect(fs.readFile(tmp.extra.dest, "utf8")).resolves.toContain("#111111")
|
await expect(fs.readFile(tmp.extra.dest, "utf8")).resolves.toContain("#111111")
|
||||||
|
|
||||||
@@ -730,7 +799,7 @@ test("updates installed theme when plugin metadata changes", async () => {
|
|||||||
await fs.utimes(tmp.extra.pluginPath, stamp, stamp)
|
await fs.utimes(tmp.extra.pluginPath, stamp, stamp)
|
||||||
await fs.utimes(tmp.extra.themePath, stamp, stamp)
|
await fs.utimes(tmp.extra.themePath, stamp, stamp)
|
||||||
|
|
||||||
await TuiPluginRuntime.init(api())
|
await TuiPluginRuntime.init({ api: mkApi(), config: mkConfig() })
|
||||||
const text = await fs.readFile(tmp.extra.dest, "utf8")
|
const text = await fs.readFile(tmp.extra.dest, "utf8")
|
||||||
expect(text).toContain("#222222")
|
expect(text).toContain("#222222")
|
||||||
expect(text).not.toContain("#111111")
|
expect(text).not.toContain("#111111")
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import path from "path"
|
|||||||
import { pathToFileURL } from "url"
|
import { pathToFileURL } from "url"
|
||||||
import { tmpdir } from "../../fixture/fixture"
|
import { tmpdir } from "../../fixture/fixture"
|
||||||
import { createTuiPluginApi } from "../../fixture/tui-plugin"
|
import { createTuiPluginApi } from "../../fixture/tui-plugin"
|
||||||
import { TuiConfig } from "../../../src/config"
|
import { TuiConfig } from "../../../src/cli/cmd/tui/config/tui"
|
||||||
|
|
||||||
const { TuiPluginRuntime } = await import("../../../src/cli/cmd/tui/plugin/runtime")
|
const { TuiPluginRuntime } = await import("../../../src/cli/cmd/tui/plugin/runtime")
|
||||||
|
|
||||||
@@ -39,7 +39,7 @@ test("toggles plugin runtime state by exported id", async () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
process.env.OPENCODE_PLUGIN_META_FILE = path.join(tmp.path, "plugin-meta.json")
|
process.env.OPENCODE_PLUGIN_META_FILE = path.join(tmp.path, "plugin-meta.json")
|
||||||
const get = spyOn(TuiConfig, "get").mockResolvedValue({
|
const config: TuiConfig.Info = {
|
||||||
plugin: [[tmp.extra.spec, { marker: tmp.extra.marker }]],
|
plugin: [[tmp.extra.spec, { marker: tmp.extra.marker }]],
|
||||||
plugin_enabled: {
|
plugin_enabled: {
|
||||||
"demo.toggle": false,
|
"demo.toggle": false,
|
||||||
@@ -51,13 +51,13 @@ test("toggles plugin runtime state by exported id", async () => {
|
|||||||
source: path.join(tmp.path, "tui.json"),
|
source: path.join(tmp.path, "tui.json"),
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
})
|
}
|
||||||
const wait = spyOn(TuiConfig, "waitForDependencies").mockResolvedValue()
|
const wait = spyOn(TuiConfig, "waitForDependencies").mockResolvedValue()
|
||||||
const cwd = spyOn(process, "cwd").mockImplementation(() => tmp.path)
|
const cwd = spyOn(process, "cwd").mockImplementation(() => tmp.path)
|
||||||
const api = createTuiPluginApi()
|
const api = createTuiPluginApi()
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await TuiPluginRuntime.init(api)
|
await TuiPluginRuntime.init({ api, config })
|
||||||
|
|
||||||
await expect(fs.readFile(tmp.extra.marker, "utf8")).rejects.toThrow()
|
await expect(fs.readFile(tmp.extra.marker, "utf8")).rejects.toThrow()
|
||||||
expect(TuiPluginRuntime.list().find((item) => item.id === "demo.toggle")).toEqual({
|
expect(TuiPluginRuntime.list().find((item) => item.id === "demo.toggle")).toEqual({
|
||||||
@@ -85,7 +85,6 @@ test("toggles plugin runtime state by exported id", async () => {
|
|||||||
} finally {
|
} finally {
|
||||||
await TuiPluginRuntime.dispose()
|
await TuiPluginRuntime.dispose()
|
||||||
cwd.mockRestore()
|
cwd.mockRestore()
|
||||||
get.mockRestore()
|
|
||||||
wait.mockRestore()
|
wait.mockRestore()
|
||||||
delete process.env.OPENCODE_PLUGIN_META_FILE
|
delete process.env.OPENCODE_PLUGIN_META_FILE
|
||||||
}
|
}
|
||||||
@@ -117,7 +116,7 @@ test("kv plugin_enabled overrides tui config on startup", async () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
process.env.OPENCODE_PLUGIN_META_FILE = path.join(tmp.path, "plugin-meta.json")
|
process.env.OPENCODE_PLUGIN_META_FILE = path.join(tmp.path, "plugin-meta.json")
|
||||||
const get = spyOn(TuiConfig, "get").mockResolvedValue({
|
const config: TuiConfig.Info = {
|
||||||
plugin: [[tmp.extra.spec, { marker: tmp.extra.marker }]],
|
plugin: [[tmp.extra.spec, { marker: tmp.extra.marker }]],
|
||||||
plugin_enabled: {
|
plugin_enabled: {
|
||||||
"demo.startup": false,
|
"demo.startup": false,
|
||||||
@@ -129,7 +128,7 @@ test("kv plugin_enabled overrides tui config on startup", async () => {
|
|||||||
source: path.join(tmp.path, "tui.json"),
|
source: path.join(tmp.path, "tui.json"),
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
})
|
}
|
||||||
const wait = spyOn(TuiConfig, "waitForDependencies").mockResolvedValue()
|
const wait = spyOn(TuiConfig, "waitForDependencies").mockResolvedValue()
|
||||||
const cwd = spyOn(process, "cwd").mockImplementation(() => tmp.path)
|
const cwd = spyOn(process, "cwd").mockImplementation(() => tmp.path)
|
||||||
const api = createTuiPluginApi()
|
const api = createTuiPluginApi()
|
||||||
@@ -138,7 +137,7 @@ test("kv plugin_enabled overrides tui config on startup", async () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await TuiPluginRuntime.init(api)
|
await TuiPluginRuntime.init({ api, config })
|
||||||
|
|
||||||
await expect(fs.readFile(tmp.extra.marker, "utf8")).resolves.toBe("on")
|
await expect(fs.readFile(tmp.extra.marker, "utf8")).resolves.toBe("on")
|
||||||
expect(TuiPluginRuntime.list().find((item) => item.id === "demo.startup")).toEqual({
|
expect(TuiPluginRuntime.list().find((item) => item.id === "demo.startup")).toEqual({
|
||||||
@@ -152,7 +151,6 @@ test("kv plugin_enabled overrides tui config on startup", async () => {
|
|||||||
} finally {
|
} finally {
|
||||||
await TuiPluginRuntime.dispose()
|
await TuiPluginRuntime.dispose()
|
||||||
cwd.mockRestore()
|
cwd.mockRestore()
|
||||||
get.mockRestore()
|
|
||||||
wait.mockRestore()
|
wait.mockRestore()
|
||||||
delete process.env.OPENCODE_PLUGIN_META_FILE
|
delete process.env.OPENCODE_PLUGIN_META_FILE
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,13 +8,11 @@ import { UI } from "../../../src/cli/ui"
|
|||||||
import * as Timeout from "../../../src/util/timeout"
|
import * as Timeout from "../../../src/util/timeout"
|
||||||
import * as Network from "../../../src/cli/network"
|
import * as Network from "../../../src/cli/network"
|
||||||
import * as Win32 from "../../../src/cli/cmd/tui/win32"
|
import * as Win32 from "../../../src/cli/cmd/tui/win32"
|
||||||
import { TuiConfig } from "../../../src/config"
|
import { TuiConfig } from "../../../src/cli/cmd/tui/config/tui"
|
||||||
import { Instance } from "../../../src/project/instance"
|
|
||||||
|
|
||||||
const stop = new Error("stop")
|
const stop = new Error("stop")
|
||||||
const seen = {
|
const seen = {
|
||||||
tui: [] as string[],
|
tui: [] as string[],
|
||||||
inst: [] as string[],
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function setup() {
|
function setup() {
|
||||||
@@ -42,11 +40,6 @@ function setup() {
|
|||||||
})
|
})
|
||||||
spyOn(Win32, "win32DisableProcessedInput").mockImplementation(() => {})
|
spyOn(Win32, "win32DisableProcessedInput").mockImplementation(() => {})
|
||||||
spyOn(Win32, "win32InstallCtrlCGuard").mockReturnValue(undefined)
|
spyOn(Win32, "win32InstallCtrlCGuard").mockReturnValue(undefined)
|
||||||
spyOn(TuiConfig, "get").mockResolvedValue({})
|
|
||||||
spyOn(Instance, "provide").mockImplementation(async (input) => {
|
|
||||||
seen.inst.push(input.directory)
|
|
||||||
return input.fn()
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
describe("tui thread", () => {
|
describe("tui thread", () => {
|
||||||
@@ -86,7 +79,6 @@ describe("tui thread", () => {
|
|||||||
const link = path.join(path.dirname(tmp.path), path.basename(tmp.path) + "-link")
|
const link = path.join(path.dirname(tmp.path), path.basename(tmp.path) + "-link")
|
||||||
const type = process.platform === "win32" ? "junction" : "dir"
|
const type = process.platform === "win32" ? "junction" : "dir"
|
||||||
seen.tui.length = 0
|
seen.tui.length = 0
|
||||||
seen.inst.length = 0
|
|
||||||
await fs.symlink(tmp.path, link, type)
|
await fs.symlink(tmp.path, link, type)
|
||||||
|
|
||||||
Object.defineProperty(process.stdin, "isTTY", {
|
Object.defineProperty(process.stdin, "isTTY", {
|
||||||
@@ -105,7 +97,6 @@ describe("tui thread", () => {
|
|||||||
process.chdir(tmp.path)
|
process.chdir(tmp.path)
|
||||||
process.env.PWD = link
|
process.env.PWD = link
|
||||||
await expect(call(project)).rejects.toBe(stop)
|
await expect(call(project)).rejects.toBe(stop)
|
||||||
expect(seen.inst[0]).toBe(tmp.path)
|
|
||||||
expect(seen.tui[0]).toBe(tmp.path)
|
expect(seen.tui[0]).toBe(tmp.path)
|
||||||
} finally {
|
} finally {
|
||||||
process.chdir(cwd)
|
process.chdir(cwd)
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ import { ProjectID } from "../../src/project/schema"
|
|||||||
import { Filesystem } from "../../src/util"
|
import { Filesystem } from "../../src/util"
|
||||||
import * as Network from "../../src/util/network"
|
import * as Network from "../../src/util/network"
|
||||||
import { Npm } from "../../src/npm"
|
import { Npm } from "../../src/npm"
|
||||||
|
import { ConfigPlugin } from "@/config/plugin"
|
||||||
|
|
||||||
const emptyAccount = Layer.mock(Account.Service)({
|
const emptyAccount = Layer.mock(Account.Service)({
|
||||||
active: () => Effect.succeed(Option.none()),
|
active: () => Effect.succeed(Option.none()),
|
||||||
@@ -1256,7 +1257,7 @@ test("keeps plugin origins aligned with merged plugin list", async () => {
|
|||||||
const cfg = await load()
|
const cfg = await load()
|
||||||
const plugins = cfg.plugin ?? []
|
const plugins = cfg.plugin ?? []
|
||||||
const origins = cfg.plugin_origins ?? []
|
const origins = cfg.plugin_origins ?? []
|
||||||
const names = plugins.map((item) => Config.pluginSpecifier(item))
|
const names = plugins.map((item) => ConfigPlugin.pluginSpecifier(item))
|
||||||
|
|
||||||
expect(names).toContain("shared-plugin@2.0.0")
|
expect(names).toContain("shared-plugin@2.0.0")
|
||||||
expect(names).not.toContain("shared-plugin@1.0.0")
|
expect(names).not.toContain("shared-plugin@1.0.0")
|
||||||
@@ -1264,7 +1265,7 @@ test("keeps plugin origins aligned with merged plugin list", async () => {
|
|||||||
expect(names).toContain("local-only@1.0.0")
|
expect(names).toContain("local-only@1.0.0")
|
||||||
|
|
||||||
expect(origins.map((item) => item.spec)).toEqual(plugins)
|
expect(origins.map((item) => item.spec)).toEqual(plugins)
|
||||||
const hit = origins.find((item) => Config.pluginSpecifier(item.spec) === "shared-plugin@2.0.0")
|
const hit = origins.find((item) => ConfigPlugin.pluginSpecifier(item.spec) === "shared-plugin@2.0.0")
|
||||||
expect(hit?.scope).toBe("local")
|
expect(hit?.scope).toBe("local")
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
@@ -1909,8 +1910,8 @@ describe("resolvePluginSpec", () => {
|
|||||||
test("keeps package specs unchanged", async () => {
|
test("keeps package specs unchanged", async () => {
|
||||||
await using tmp = await tmpdir()
|
await using tmp = await tmpdir()
|
||||||
const file = path.join(tmp.path, "opencode.json")
|
const file = path.join(tmp.path, "opencode.json")
|
||||||
expect(await Config.resolvePluginSpec("oh-my-opencode@2.4.3", file)).toBe("oh-my-opencode@2.4.3")
|
expect(await ConfigPlugin.resolvePluginSpec("oh-my-opencode@2.4.3", file)).toBe("oh-my-opencode@2.4.3")
|
||||||
expect(await Config.resolvePluginSpec("@scope/pkg", file)).toBe("@scope/pkg")
|
expect(await ConfigPlugin.resolvePluginSpec("@scope/pkg", file)).toBe("@scope/pkg")
|
||||||
})
|
})
|
||||||
|
|
||||||
test("resolves windows-style relative plugin directory specs", async () => {
|
test("resolves windows-style relative plugin directory specs", async () => {
|
||||||
@@ -1925,8 +1926,8 @@ describe("resolvePluginSpec", () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
const file = path.join(tmp.path, "opencode.json")
|
const file = path.join(tmp.path, "opencode.json")
|
||||||
const hit = await Config.resolvePluginSpec(".\\plugin", file)
|
const hit = await ConfigPlugin.resolvePluginSpec(".\\plugin", file)
|
||||||
expect(Config.pluginSpecifier(hit)).toBe(pathToFileURL(path.join(tmp.path, "plugin", "index.ts")).href)
|
expect(ConfigPlugin.pluginSpecifier(hit)).toBe(pathToFileURL(path.join(tmp.path, "plugin", "index.ts")).href)
|
||||||
})
|
})
|
||||||
|
|
||||||
test("resolves relative file plugin paths to file urls", async () => {
|
test("resolves relative file plugin paths to file urls", async () => {
|
||||||
@@ -1937,8 +1938,8 @@ describe("resolvePluginSpec", () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
const file = path.join(tmp.path, "opencode.json")
|
const file = path.join(tmp.path, "opencode.json")
|
||||||
const hit = await Config.resolvePluginSpec("./plugin.ts", file)
|
const hit = await ConfigPlugin.resolvePluginSpec("./plugin.ts", file)
|
||||||
expect(Config.pluginSpecifier(hit)).toBe(pathToFileURL(path.join(tmp.path, "plugin.ts")).href)
|
expect(ConfigPlugin.pluginSpecifier(hit)).toBe(pathToFileURL(path.join(tmp.path, "plugin.ts")).href)
|
||||||
})
|
})
|
||||||
|
|
||||||
test("resolves plugin directory paths to directory urls", async () => {
|
test("resolves plugin directory paths to directory urls", async () => {
|
||||||
@@ -1956,8 +1957,8 @@ describe("resolvePluginSpec", () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
const file = path.join(tmp.path, "opencode.json")
|
const file = path.join(tmp.path, "opencode.json")
|
||||||
const hit = await Config.resolvePluginSpec("./plugin", file)
|
const hit = await ConfigPlugin.resolvePluginSpec("./plugin", file)
|
||||||
expect(Config.pluginSpecifier(hit)).toBe(pathToFileURL(path.join(tmp.path, "plugin")).href)
|
expect(ConfigPlugin.pluginSpecifier(hit)).toBe(pathToFileURL(path.join(tmp.path, "plugin")).href)
|
||||||
})
|
})
|
||||||
|
|
||||||
test("resolves plugin directories without package.json to index.ts", async () => {
|
test("resolves plugin directories without package.json to index.ts", async () => {
|
||||||
|
|||||||
@@ -3,13 +3,15 @@ import path from "path"
|
|||||||
import fs from "fs/promises"
|
import fs from "fs/promises"
|
||||||
import { tmpdir } from "../fixture/fixture"
|
import { tmpdir } from "../fixture/fixture"
|
||||||
import { Instance } from "../../src/project/instance"
|
import { Instance } from "../../src/project/instance"
|
||||||
|
import { TuiConfig } from "../../src/cli/cmd/tui/config/tui"
|
||||||
import { Config } from "../../src/config"
|
import { Config } from "../../src/config"
|
||||||
import { TuiConfig } from "../../src/config"
|
|
||||||
import { Global } from "../../src/global"
|
import { Global } from "../../src/global"
|
||||||
import { Filesystem } from "../../src/util"
|
import { Filesystem } from "../../src/util"
|
||||||
import { AppRuntime } from "../../src/effect/app-runtime"
|
import { AppRuntime } from "../../src/effect/app-runtime"
|
||||||
|
import { Effect, Layer } from "effect"
|
||||||
|
import { CurrentWorkingDirectory } from "@/cli/cmd/tui/config/cwd"
|
||||||
|
import { ConfigPlugin } from "@/config/plugin"
|
||||||
|
|
||||||
const managedConfigDir = process.env.OPENCODE_TEST_MANAGED_CONFIG_DIR!
|
|
||||||
const wintest = process.platform === "win32" ? test : test.skip
|
const wintest = process.platform === "win32" ? test : test.skip
|
||||||
const clear = (wait = false) => AppRuntime.runPromise(Config.Service.use((svc) => svc.invalidate(wait)))
|
const clear = (wait = false) => AppRuntime.runPromise(Config.Service.use((svc) => svc.invalidate(wait)))
|
||||||
const load = () => AppRuntime.runPromise(Config.Service.use((svc) => svc.get()))
|
const load = () => AppRuntime.runPromise(Config.Service.use((svc) => svc.get()))
|
||||||
@@ -18,6 +20,13 @@ beforeEach(async () => {
|
|||||||
await clear(true)
|
await clear(true)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const getTuiConfig = async (directory: string) =>
|
||||||
|
Effect.runPromise(
|
||||||
|
TuiConfig.Service.use((svc) => svc.get()).pipe(
|
||||||
|
Effect.provide(TuiConfig.defaultLayer.pipe(Layer.provide(Layer.succeed(CurrentWorkingDirectory, directory)))),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
afterEach(async () => {
|
afterEach(async () => {
|
||||||
delete process.env.OPENCODE_CONFIG
|
delete process.env.OPENCODE_CONFIG
|
||||||
delete process.env.OPENCODE_TUI_CONFIG
|
delete process.env.OPENCODE_TUI_CONFIG
|
||||||
@@ -25,7 +34,6 @@ afterEach(async () => {
|
|||||||
await fs.rm(path.join(Global.Path.config, "opencode.jsonc"), { force: true }).catch(() => {})
|
await fs.rm(path.join(Global.Path.config, "opencode.jsonc"), { force: true }).catch(() => {})
|
||||||
await fs.rm(path.join(Global.Path.config, "tui.json"), { force: true }).catch(() => {})
|
await fs.rm(path.join(Global.Path.config, "tui.json"), { force: true }).catch(() => {})
|
||||||
await fs.rm(path.join(Global.Path.config, "tui.jsonc"), { force: true }).catch(() => {})
|
await fs.rm(path.join(Global.Path.config, "tui.jsonc"), { force: true }).catch(() => {})
|
||||||
await fs.rm(managedConfigDir, { force: true, recursive: true }).catch(() => {})
|
|
||||||
await clear(true)
|
await clear(true)
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -83,9 +91,9 @@ test("keeps server and tui plugin merge semantics aligned", async () => {
|
|||||||
directory: tmp.path,
|
directory: tmp.path,
|
||||||
fn: async () => {
|
fn: async () => {
|
||||||
const server = await load()
|
const server = await load()
|
||||||
const tui = await TuiConfig.get()
|
const tui = await getTuiConfig(tmp.path)
|
||||||
const serverPlugins = (server.plugin ?? []).map((item) => Config.pluginSpecifier(item))
|
const serverPlugins = (server.plugin ?? []).map((item) => ConfigPlugin.pluginSpecifier(item))
|
||||||
const tuiPlugins = (tui.plugin ?? []).map((item) => Config.pluginSpecifier(item))
|
const tuiPlugins = (tui.plugin ?? []).map((item) => ConfigPlugin.pluginSpecifier(item))
|
||||||
|
|
||||||
expect(serverPlugins).toEqual(tuiPlugins)
|
expect(serverPlugins).toEqual(tuiPlugins)
|
||||||
expect(serverPlugins).toContain("shared-plugin@2.0.0")
|
expect(serverPlugins).toContain("shared-plugin@2.0.0")
|
||||||
@@ -93,8 +101,8 @@ test("keeps server and tui plugin merge semantics aligned", async () => {
|
|||||||
|
|
||||||
const serverOrigins = server.plugin_origins ?? []
|
const serverOrigins = server.plugin_origins ?? []
|
||||||
const tuiOrigins = tui.plugin_origins ?? []
|
const tuiOrigins = tui.plugin_origins ?? []
|
||||||
expect(serverOrigins.map((item) => Config.pluginSpecifier(item.spec))).toEqual(serverPlugins)
|
expect(serverOrigins.map((item) => ConfigPlugin.pluginSpecifier(item.spec))).toEqual(serverPlugins)
|
||||||
expect(tuiOrigins.map((item) => Config.pluginSpecifier(item.spec))).toEqual(tuiPlugins)
|
expect(tuiOrigins.map((item) => ConfigPlugin.pluginSpecifier(item.spec))).toEqual(tuiPlugins)
|
||||||
expect(serverOrigins.map((item) => item.scope)).toEqual(tuiOrigins.map((item) => item.scope))
|
expect(serverOrigins.map((item) => item.scope)).toEqual(tuiOrigins.map((item) => item.scope))
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
@@ -113,14 +121,9 @@ test("loads tui config with the same precedence order as server config paths", a
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
await Instance.provide({
|
const config = await getTuiConfig(tmp.path)
|
||||||
directory: tmp.path,
|
expect(config.theme).toBe("local")
|
||||||
fn: async () => {
|
expect(config.diff_style).toBe("stacked")
|
||||||
const config = await TuiConfig.get()
|
|
||||||
expect(config.theme).toBe("local")
|
|
||||||
expect(config.diff_style).toBe("stacked")
|
|
||||||
},
|
|
||||||
})
|
|
||||||
})
|
})
|
||||||
|
|
||||||
test("migrates tui-specific keys from opencode.json when tui.json does not exist", async () => {
|
test("migrates tui-specific keys from opencode.json when tui.json does not exist", async () => {
|
||||||
@@ -141,26 +144,21 @@ test("migrates tui-specific keys from opencode.json when tui.json does not exist
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
await Instance.provide({
|
const config = await getTuiConfig(tmp.path)
|
||||||
directory: tmp.path,
|
expect(config.theme).toBe("migrated-theme")
|
||||||
fn: async () => {
|
expect(config.scroll_speed).toBe(5)
|
||||||
const config = await TuiConfig.get()
|
expect(config.keybinds?.app_exit).toBe("ctrl+q")
|
||||||
expect(config.theme).toBe("migrated-theme")
|
const text = await Filesystem.readText(path.join(tmp.path, "tui.json"))
|
||||||
expect(config.scroll_speed).toBe(5)
|
expect(JSON.parse(text)).toMatchObject({
|
||||||
expect(config.keybinds?.app_exit).toBe("ctrl+q")
|
theme: "migrated-theme",
|
||||||
const text = await Filesystem.readText(path.join(tmp.path, "tui.json"))
|
scroll_speed: 5,
|
||||||
expect(JSON.parse(text)).toMatchObject({
|
|
||||||
theme: "migrated-theme",
|
|
||||||
scroll_speed: 5,
|
|
||||||
})
|
|
||||||
const server = JSON.parse(await Filesystem.readText(path.join(tmp.path, "opencode.json")))
|
|
||||||
expect(server.theme).toBeUndefined()
|
|
||||||
expect(server.keybinds).toBeUndefined()
|
|
||||||
expect(server.tui).toBeUndefined()
|
|
||||||
expect(await Filesystem.exists(path.join(tmp.path, "opencode.json.tui-migration.bak"))).toBe(true)
|
|
||||||
expect(await Filesystem.exists(path.join(tmp.path, "tui.json"))).toBe(true)
|
|
||||||
},
|
|
||||||
})
|
})
|
||||||
|
const server = JSON.parse(await Filesystem.readText(path.join(tmp.path, "opencode.json")))
|
||||||
|
expect(server.theme).toBeUndefined()
|
||||||
|
expect(server.keybinds).toBeUndefined()
|
||||||
|
expect(server.tui).toBeUndefined()
|
||||||
|
expect(await Filesystem.exists(path.join(tmp.path, "opencode.json.tui-migration.bak"))).toBe(true)
|
||||||
|
expect(await Filesystem.exists(path.join(tmp.path, "tui.json"))).toBe(true)
|
||||||
})
|
})
|
||||||
|
|
||||||
test("migrates project legacy tui keys even when global tui.json already exists", async () => {
|
test("migrates project legacy tui keys even when global tui.json already exists", async () => {
|
||||||
@@ -181,19 +179,14 @@ test("migrates project legacy tui keys even when global tui.json already exists"
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
await Instance.provide({
|
const config = await getTuiConfig(tmp.path)
|
||||||
directory: tmp.path,
|
expect(config.theme).toBe("project-migrated")
|
||||||
fn: async () => {
|
expect(config.scroll_speed).toBe(2)
|
||||||
const config = await TuiConfig.get()
|
expect(await Filesystem.exists(path.join(tmp.path, "tui.json"))).toBe(true)
|
||||||
expect(config.theme).toBe("project-migrated")
|
|
||||||
expect(config.scroll_speed).toBe(2)
|
|
||||||
expect(await Filesystem.exists(path.join(tmp.path, "tui.json"))).toBe(true)
|
|
||||||
|
|
||||||
const server = JSON.parse(await Filesystem.readText(path.join(tmp.path, "opencode.json")))
|
const server = JSON.parse(await Filesystem.readText(path.join(tmp.path, "opencode.json")))
|
||||||
expect(server.theme).toBeUndefined()
|
expect(server.theme).toBeUndefined()
|
||||||
expect(server.tui).toBeUndefined()
|
expect(server.tui).toBeUndefined()
|
||||||
},
|
|
||||||
})
|
|
||||||
})
|
})
|
||||||
|
|
||||||
test("drops unknown legacy tui keys during migration", async () => {
|
test("drops unknown legacy tui keys during migration", async () => {
|
||||||
@@ -213,19 +206,14 @@ test("drops unknown legacy tui keys during migration", async () => {
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
await Instance.provide({
|
const config = await getTuiConfig(tmp.path)
|
||||||
directory: tmp.path,
|
expect(config.theme).toBe("migrated-theme")
|
||||||
fn: async () => {
|
expect(config.scroll_speed).toBe(2)
|
||||||
const config = await TuiConfig.get()
|
|
||||||
expect(config.theme).toBe("migrated-theme")
|
|
||||||
expect(config.scroll_speed).toBe(2)
|
|
||||||
|
|
||||||
const text = await Filesystem.readText(path.join(tmp.path, "tui.json"))
|
const text = await Filesystem.readText(path.join(tmp.path, "tui.json"))
|
||||||
const migrated = JSON.parse(text)
|
const migrated = JSON.parse(text)
|
||||||
expect(migrated.scroll_speed).toBe(2)
|
expect(migrated.scroll_speed).toBe(2)
|
||||||
expect(migrated.foo).toBeUndefined()
|
expect(migrated.foo).toBeUndefined()
|
||||||
},
|
|
||||||
})
|
|
||||||
})
|
})
|
||||||
|
|
||||||
test("skips migration when opencode.jsonc is syntactically invalid", async () => {
|
test("skips migration when opencode.jsonc is syntactically invalid", async () => {
|
||||||
@@ -242,19 +230,14 @@ test("skips migration when opencode.jsonc is syntactically invalid", async () =>
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
await Instance.provide({
|
const config = await getTuiConfig(tmp.path)
|
||||||
directory: tmp.path,
|
expect(config.theme).toBeUndefined()
|
||||||
fn: async () => {
|
expect(config.scroll_speed).toBeUndefined()
|
||||||
const config = await TuiConfig.get()
|
expect(await Filesystem.exists(path.join(tmp.path, "tui.json"))).toBe(false)
|
||||||
expect(config.theme).toBeUndefined()
|
expect(await Filesystem.exists(path.join(tmp.path, "opencode.jsonc.tui-migration.bak"))).toBe(false)
|
||||||
expect(config.scroll_speed).toBeUndefined()
|
const source = await Filesystem.readText(path.join(tmp.path, "opencode.jsonc"))
|
||||||
expect(await Filesystem.exists(path.join(tmp.path, "tui.json"))).toBe(false)
|
expect(source).toContain('"theme": "broken-theme"')
|
||||||
expect(await Filesystem.exists(path.join(tmp.path, "opencode.jsonc.tui-migration.bak"))).toBe(false)
|
expect(source).toContain('"tui": { "scroll_speed": 2 }')
|
||||||
const source = await Filesystem.readText(path.join(tmp.path, "opencode.jsonc"))
|
|
||||||
expect(source).toContain('"theme": "broken-theme"')
|
|
||||||
expect(source).toContain('"tui": { "scroll_speed": 2 }')
|
|
||||||
},
|
|
||||||
})
|
|
||||||
})
|
})
|
||||||
|
|
||||||
test("skips migration when tui.json already exists", async () => {
|
test("skips migration when tui.json already exists", async () => {
|
||||||
@@ -265,18 +248,13 @@ test("skips migration when tui.json already exists", async () => {
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
await Instance.provide({
|
const config = await getTuiConfig(tmp.path)
|
||||||
directory: tmp.path,
|
expect(config.diff_style).toBe("stacked")
|
||||||
fn: async () => {
|
expect(config.theme).toBeUndefined()
|
||||||
const config = await TuiConfig.get()
|
|
||||||
expect(config.diff_style).toBe("stacked")
|
|
||||||
expect(config.theme).toBeUndefined()
|
|
||||||
|
|
||||||
const server = JSON.parse(await Filesystem.readText(path.join(tmp.path, "opencode.json")))
|
const server = JSON.parse(await Filesystem.readText(path.join(tmp.path, "opencode.json")))
|
||||||
expect(server.theme).toBe("legacy")
|
expect(server.theme).toBe("legacy")
|
||||||
expect(await Filesystem.exists(path.join(tmp.path, "opencode.json.tui-migration.bak"))).toBe(false)
|
expect(await Filesystem.exists(path.join(tmp.path, "opencode.json.tui-migration.bak"))).toBe(false)
|
||||||
},
|
|
||||||
})
|
|
||||||
})
|
})
|
||||||
|
|
||||||
test("continues loading tui config when legacy source cannot be stripped", async () => {
|
test("continues loading tui config when legacy source cannot be stripped", async () => {
|
||||||
@@ -290,17 +268,12 @@ test("continues loading tui config when legacy source cannot be stripped", async
|
|||||||
await fs.chmod(source, 0o444)
|
await fs.chmod(source, 0o444)
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await Instance.provide({
|
const config = await getTuiConfig(tmp.path)
|
||||||
directory: tmp.path,
|
expect(config.theme).toBe("readonly-theme")
|
||||||
fn: async () => {
|
expect(await Filesystem.exists(path.join(tmp.path, "tui.json"))).toBe(true)
|
||||||
const config = await TuiConfig.get()
|
|
||||||
expect(config.theme).toBe("readonly-theme")
|
|
||||||
expect(await Filesystem.exists(path.join(tmp.path, "tui.json"))).toBe(true)
|
|
||||||
|
|
||||||
const server = JSON.parse(await Filesystem.readText(source))
|
const server = JSON.parse(await Filesystem.readText(source))
|
||||||
expect(server.theme).toBe("readonly-theme")
|
expect(server.theme).toBe("readonly-theme")
|
||||||
},
|
|
||||||
})
|
|
||||||
} finally {
|
} finally {
|
||||||
await fs.chmod(source, 0o644)
|
await fs.chmod(source, 0o644)
|
||||||
}
|
}
|
||||||
@@ -323,17 +296,12 @@ test("migration backup preserves JSONC comments", async () => {
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
await Instance.provide({
|
await getTuiConfig(tmp.path)
|
||||||
directory: tmp.path,
|
const backup = await Filesystem.readText(path.join(tmp.path, "opencode.jsonc.tui-migration.bak"))
|
||||||
fn: async () => {
|
expect(backup).toContain("// top-level comment")
|
||||||
await TuiConfig.get()
|
expect(backup).toContain("// nested comment")
|
||||||
const backup = await Filesystem.readText(path.join(tmp.path, "opencode.jsonc.tui-migration.bak"))
|
expect(backup).toContain('"theme": "jsonc-theme"')
|
||||||
expect(backup).toContain("// top-level comment")
|
expect(backup).toContain('"scroll_speed": 1.5')
|
||||||
expect(backup).toContain("// nested comment")
|
|
||||||
expect(backup).toContain('"theme": "jsonc-theme"')
|
|
||||||
expect(backup).toContain('"scroll_speed": 1.5')
|
|
||||||
},
|
|
||||||
})
|
|
||||||
})
|
})
|
||||||
|
|
||||||
test("migrates legacy tui keys across multiple opencode.json levels", async () => {
|
test("migrates legacy tui keys across multiple opencode.json levels", async () => {
|
||||||
@@ -345,16 +313,10 @@ test("migrates legacy tui keys across multiple opencode.json levels", async () =
|
|||||||
await Bun.write(path.join(nested, "opencode.json"), JSON.stringify({ theme: "nested-theme" }, null, 2))
|
await Bun.write(path.join(nested, "opencode.json"), JSON.stringify({ theme: "nested-theme" }, null, 2))
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
const config = await getTuiConfig(path.join(tmp.path, "apps", "client"))
|
||||||
await Instance.provide({
|
expect(config.theme).toBe("nested-theme")
|
||||||
directory: path.join(tmp.path, "apps", "client"),
|
expect(await Filesystem.exists(path.join(tmp.path, "tui.json"))).toBe(true)
|
||||||
fn: async () => {
|
expect(await Filesystem.exists(path.join(tmp.path, "apps", "client", "tui.json"))).toBe(true)
|
||||||
const config = await TuiConfig.get()
|
|
||||||
expect(config.theme).toBe("nested-theme")
|
|
||||||
expect(await Filesystem.exists(path.join(tmp.path, "tui.json"))).toBe(true)
|
|
||||||
expect(await Filesystem.exists(path.join(tmp.path, "apps", "client", "tui.json"))).toBe(true)
|
|
||||||
},
|
|
||||||
})
|
|
||||||
})
|
})
|
||||||
|
|
||||||
test("flattens nested tui key inside tui.json", async () => {
|
test("flattens nested tui key inside tui.json", async () => {
|
||||||
@@ -370,16 +332,11 @@ test("flattens nested tui key inside tui.json", async () => {
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
await Instance.provide({
|
const config = await getTuiConfig(tmp.path)
|
||||||
directory: tmp.path,
|
expect(config.scroll_speed).toBe(3)
|
||||||
fn: async () => {
|
expect(config.diff_style).toBe("stacked")
|
||||||
const config = await TuiConfig.get()
|
// top-level keys take precedence over nested tui keys
|
||||||
expect(config.scroll_speed).toBe(3)
|
expect(config.theme).toBe("outer")
|
||||||
expect(config.diff_style).toBe("stacked")
|
|
||||||
// top-level keys take precedence over nested tui keys
|
|
||||||
expect(config.theme).toBe("outer")
|
|
||||||
},
|
|
||||||
})
|
|
||||||
})
|
})
|
||||||
|
|
||||||
test("top-level keys in tui.json take precedence over nested tui key", async () => {
|
test("top-level keys in tui.json take precedence over nested tui key", async () => {
|
||||||
@@ -395,14 +352,9 @@ test("top-level keys in tui.json take precedence over nested tui key", async ()
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
await Instance.provide({
|
const config = await getTuiConfig(tmp.path)
|
||||||
directory: tmp.path,
|
expect(config.diff_style).toBe("auto")
|
||||||
fn: async () => {
|
expect(config.scroll_speed).toBe(2)
|
||||||
const config = await TuiConfig.get()
|
|
||||||
expect(config.diff_style).toBe("auto")
|
|
||||||
expect(config.scroll_speed).toBe(2)
|
|
||||||
},
|
|
||||||
})
|
|
||||||
})
|
})
|
||||||
|
|
||||||
test("project config takes precedence over OPENCODE_TUI_CONFIG (matches OPENCODE_CONFIG)", async () => {
|
test("project config takes precedence over OPENCODE_TUI_CONFIG (matches OPENCODE_CONFIG)", async () => {
|
||||||
@@ -415,16 +367,11 @@ test("project config takes precedence over OPENCODE_TUI_CONFIG (matches OPENCODE
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
await Instance.provide({
|
const config = await getTuiConfig(tmp.path)
|
||||||
directory: tmp.path,
|
// project tui.json overrides the custom path, same as server config precedence
|
||||||
fn: async () => {
|
expect(config.theme).toBe("project")
|
||||||
const config = await TuiConfig.get()
|
// project also set diff_style, so that wins
|
||||||
// project tui.json overrides the custom path, same as server config precedence
|
expect(config.diff_style).toBe("auto")
|
||||||
expect(config.theme).toBe("project")
|
|
||||||
// project also set diff_style, so that wins
|
|
||||||
expect(config.diff_style).toBe("auto")
|
|
||||||
},
|
|
||||||
})
|
|
||||||
})
|
})
|
||||||
|
|
||||||
test("merges keybind overrides across precedence layers", async () => {
|
test("merges keybind overrides across precedence layers", async () => {
|
||||||
@@ -434,28 +381,16 @@ test("merges keybind overrides across precedence layers", async () => {
|
|||||||
await Bun.write(path.join(dir, "tui.json"), JSON.stringify({ keybinds: { theme_list: "ctrl+k" } }))
|
await Bun.write(path.join(dir, "tui.json"), JSON.stringify({ keybinds: { theme_list: "ctrl+k" } }))
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
const config = await getTuiConfig(tmp.path)
|
||||||
await Instance.provide({
|
expect(config.keybinds?.app_exit).toBe("ctrl+q")
|
||||||
directory: tmp.path,
|
expect(config.keybinds?.theme_list).toBe("ctrl+k")
|
||||||
fn: async () => {
|
|
||||||
const config = await TuiConfig.get()
|
|
||||||
expect(config.keybinds?.app_exit).toBe("ctrl+q")
|
|
||||||
expect(config.keybinds?.theme_list).toBe("ctrl+k")
|
|
||||||
},
|
|
||||||
})
|
|
||||||
})
|
})
|
||||||
|
|
||||||
wintest("defaults Ctrl+Z to input undo on Windows", async () => {
|
wintest("defaults Ctrl+Z to input undo on Windows", async () => {
|
||||||
await using tmp = await tmpdir()
|
await using tmp = await tmpdir()
|
||||||
|
const config = await getTuiConfig(tmp.path)
|
||||||
await Instance.provide({
|
expect(config.keybinds?.terminal_suspend).toBe("none")
|
||||||
directory: tmp.path,
|
expect(config.keybinds?.input_undo).toBe("ctrl+z,ctrl+-,super+z")
|
||||||
fn: async () => {
|
|
||||||
const config = await TuiConfig.get()
|
|
||||||
expect(config.keybinds?.terminal_suspend).toBe("none")
|
|
||||||
expect(config.keybinds?.input_undo).toBe("ctrl+z,ctrl+-,super+z")
|
|
||||||
},
|
|
||||||
})
|
|
||||||
})
|
})
|
||||||
|
|
||||||
wintest("keeps explicit input undo overrides on Windows", async () => {
|
wintest("keeps explicit input undo overrides on Windows", async () => {
|
||||||
@@ -464,15 +399,9 @@ wintest("keeps explicit input undo overrides on Windows", async () => {
|
|||||||
await Bun.write(path.join(dir, "tui.json"), JSON.stringify({ keybinds: { input_undo: "ctrl+y" } }))
|
await Bun.write(path.join(dir, "tui.json"), JSON.stringify({ keybinds: { input_undo: "ctrl+y" } }))
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
const config = await getTuiConfig(tmp.path)
|
||||||
await Instance.provide({
|
expect(config.keybinds?.terminal_suspend).toBe("none")
|
||||||
directory: tmp.path,
|
expect(config.keybinds?.input_undo).toBe("ctrl+y")
|
||||||
fn: async () => {
|
|
||||||
const config = await TuiConfig.get()
|
|
||||||
expect(config.keybinds?.terminal_suspend).toBe("none")
|
|
||||||
expect(config.keybinds?.input_undo).toBe("ctrl+y")
|
|
||||||
},
|
|
||||||
})
|
|
||||||
})
|
})
|
||||||
|
|
||||||
wintest("ignores terminal suspend bindings on Windows", async () => {
|
wintest("ignores terminal suspend bindings on Windows", async () => {
|
||||||
@@ -482,14 +411,9 @@ wintest("ignores terminal suspend bindings on Windows", async () => {
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
await Instance.provide({
|
const config = await getTuiConfig(tmp.path)
|
||||||
directory: tmp.path,
|
expect(config.keybinds?.terminal_suspend).toBe("none")
|
||||||
fn: async () => {
|
expect(config.keybinds?.input_undo).toBe("ctrl+z,ctrl+-,super+z")
|
||||||
const config = await TuiConfig.get()
|
|
||||||
expect(config.keybinds?.terminal_suspend).toBe("none")
|
|
||||||
expect(config.keybinds?.input_undo).toBe("ctrl+z,ctrl+-,super+z")
|
|
||||||
},
|
|
||||||
})
|
|
||||||
})
|
})
|
||||||
|
|
||||||
test("OPENCODE_TUI_CONFIG provides settings when no project config exists", async () => {
|
test("OPENCODE_TUI_CONFIG provides settings when no project config exists", async () => {
|
||||||
@@ -500,15 +424,9 @@ test("OPENCODE_TUI_CONFIG provides settings when no project config exists", asyn
|
|||||||
process.env.OPENCODE_TUI_CONFIG = custom
|
process.env.OPENCODE_TUI_CONFIG = custom
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
const config = await getTuiConfig(tmp.path)
|
||||||
await Instance.provide({
|
expect(config.theme).toBe("from-env")
|
||||||
directory: tmp.path,
|
expect(config.diff_style).toBe("stacked")
|
||||||
fn: async () => {
|
|
||||||
const config = await TuiConfig.get()
|
|
||||||
expect(config.theme).toBe("from-env")
|
|
||||||
expect(config.diff_style).toBe("stacked")
|
|
||||||
},
|
|
||||||
})
|
|
||||||
})
|
})
|
||||||
|
|
||||||
test("does not derive tui path from OPENCODE_CONFIG", async () => {
|
test("does not derive tui path from OPENCODE_CONFIG", async () => {
|
||||||
@@ -521,14 +439,8 @@ test("does not derive tui path from OPENCODE_CONFIG", async () => {
|
|||||||
process.env.OPENCODE_CONFIG = path.join(customDir, "opencode.json")
|
process.env.OPENCODE_CONFIG = path.join(customDir, "opencode.json")
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
const config = await getTuiConfig(tmp.path)
|
||||||
await Instance.provide({
|
expect(config.theme).toBeUndefined()
|
||||||
directory: tmp.path,
|
|
||||||
fn: async () => {
|
|
||||||
const config = await TuiConfig.get()
|
|
||||||
expect(config.theme).toBeUndefined()
|
|
||||||
},
|
|
||||||
})
|
|
||||||
})
|
})
|
||||||
|
|
||||||
test("applies env and file substitutions in tui.json", async () => {
|
test("applies env and file substitutions in tui.json", async () => {
|
||||||
@@ -547,15 +459,9 @@ test("applies env and file substitutions in tui.json", async () => {
|
|||||||
)
|
)
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
const config = await getTuiConfig(tmp.path)
|
||||||
await Instance.provide({
|
expect(config.theme).toBe("env-theme")
|
||||||
directory: tmp.path,
|
expect(config.keybinds?.app_exit).toBe("ctrl+q")
|
||||||
fn: async () => {
|
|
||||||
const config = await TuiConfig.get()
|
|
||||||
expect(config.theme).toBe("env-theme")
|
|
||||||
expect(config.keybinds?.app_exit).toBe("ctrl+q")
|
|
||||||
},
|
|
||||||
})
|
|
||||||
} finally {
|
} finally {
|
||||||
if (original === undefined) delete process.env.TUI_THEME_TEST
|
if (original === undefined) delete process.env.TUI_THEME_TEST
|
||||||
else process.env.TUI_THEME_TEST = original
|
else process.env.TUI_THEME_TEST = original
|
||||||
@@ -575,46 +481,8 @@ test("applies file substitutions when first identical token is in a commented li
|
|||||||
)
|
)
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
const config = await getTuiConfig(tmp.path)
|
||||||
await Instance.provide({
|
expect(config.theme).toBe("resolved-theme")
|
||||||
directory: tmp.path,
|
|
||||||
fn: async () => {
|
|
||||||
const config = await TuiConfig.get()
|
|
||||||
expect(config.theme).toBe("resolved-theme")
|
|
||||||
},
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
test("loads managed tui config and gives it highest precedence", async () => {
|
|
||||||
await using tmp = await tmpdir({
|
|
||||||
init: async (dir) => {
|
|
||||||
await Bun.write(
|
|
||||||
path.join(dir, "tui.json"),
|
|
||||||
JSON.stringify({ theme: "project-theme", plugin: ["shared-plugin@1.0.0"] }, null, 2),
|
|
||||||
)
|
|
||||||
await fs.mkdir(managedConfigDir, { recursive: true })
|
|
||||||
await Bun.write(
|
|
||||||
path.join(managedConfigDir, "tui.json"),
|
|
||||||
JSON.stringify({ theme: "managed-theme", plugin: ["shared-plugin@2.0.0"] }, null, 2),
|
|
||||||
)
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
await Instance.provide({
|
|
||||||
directory: tmp.path,
|
|
||||||
fn: async () => {
|
|
||||||
const config = await TuiConfig.get()
|
|
||||||
expect(config.theme).toBe("managed-theme")
|
|
||||||
expect(config.plugin).toEqual(["shared-plugin@2.0.0"])
|
|
||||||
expect(config.plugin_origins).toEqual([
|
|
||||||
{
|
|
||||||
spec: "shared-plugin@2.0.0",
|
|
||||||
scope: "global",
|
|
||||||
source: path.join(managedConfigDir, "tui.json"),
|
|
||||||
},
|
|
||||||
])
|
|
||||||
},
|
|
||||||
})
|
|
||||||
})
|
})
|
||||||
|
|
||||||
test("loads .opencode/tui.json", async () => {
|
test("loads .opencode/tui.json", async () => {
|
||||||
@@ -624,33 +492,8 @@ test("loads .opencode/tui.json", async () => {
|
|||||||
await Bun.write(path.join(dir, ".opencode", "tui.json"), JSON.stringify({ diff_style: "stacked" }, null, 2))
|
await Bun.write(path.join(dir, ".opencode", "tui.json"), JSON.stringify({ diff_style: "stacked" }, null, 2))
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
const config = await getTuiConfig(tmp.path)
|
||||||
await Instance.provide({
|
expect(config.diff_style).toBe("stacked")
|
||||||
directory: tmp.path,
|
|
||||||
fn: async () => {
|
|
||||||
const config = await TuiConfig.get()
|
|
||||||
expect(config.diff_style).toBe("stacked")
|
|
||||||
},
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
test("gracefully falls back when tui.json has invalid JSON", async () => {
|
|
||||||
await using tmp = await tmpdir({
|
|
||||||
init: async (dir) => {
|
|
||||||
await Bun.write(path.join(dir, "tui.json"), "{ invalid json }")
|
|
||||||
await fs.mkdir(managedConfigDir, { recursive: true })
|
|
||||||
await Bun.write(path.join(managedConfigDir, "tui.json"), JSON.stringify({ theme: "managed-fallback" }, null, 2))
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
await Instance.provide({
|
|
||||||
directory: tmp.path,
|
|
||||||
fn: async () => {
|
|
||||||
const config = await TuiConfig.get()
|
|
||||||
expect(config.theme).toBe("managed-fallback")
|
|
||||||
expect(config.keybinds).toBeDefined()
|
|
||||||
},
|
|
||||||
})
|
|
||||||
})
|
})
|
||||||
|
|
||||||
test("supports tuple plugin specs with options in tui.json", async () => {
|
test("supports tuple plugin specs with options in tui.json", async () => {
|
||||||
@@ -665,20 +508,15 @@ test("supports tuple plugin specs with options in tui.json", async () => {
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
await Instance.provide({
|
const config = await getTuiConfig(tmp.path)
|
||||||
directory: tmp.path,
|
expect(config.plugin).toEqual([["acme-plugin@1.2.3", { enabled: true, label: "demo" }]])
|
||||||
fn: async () => {
|
expect(config.plugin_origins).toEqual([
|
||||||
const config = await TuiConfig.get()
|
{
|
||||||
expect(config.plugin).toEqual([["acme-plugin@1.2.3", { enabled: true, label: "demo" }]])
|
spec: ["acme-plugin@1.2.3", { enabled: true, label: "demo" }],
|
||||||
expect(config.plugin_origins).toEqual([
|
scope: "local",
|
||||||
{
|
source: path.join(tmp.path, "tui.json"),
|
||||||
spec: ["acme-plugin@1.2.3", { enabled: true, label: "demo" }],
|
|
||||||
scope: "local",
|
|
||||||
source: path.join(tmp.path, "tui.json"),
|
|
||||||
},
|
|
||||||
])
|
|
||||||
},
|
},
|
||||||
})
|
])
|
||||||
})
|
})
|
||||||
|
|
||||||
test("deduplicates tuple plugin specs by name with higher precedence winning", async () => {
|
test("deduplicates tuple plugin specs by name with higher precedence winning", async () => {
|
||||||
@@ -702,28 +540,23 @@ test("deduplicates tuple plugin specs by name with higher precedence winning", a
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
await Instance.provide({
|
const config = await getTuiConfig(tmp.path)
|
||||||
directory: tmp.path,
|
expect(config.plugin).toEqual([
|
||||||
fn: async () => {
|
["acme-plugin@2.0.0", { source: "project" }],
|
||||||
const config = await TuiConfig.get()
|
["second-plugin@3.0.0", { source: "project" }],
|
||||||
expect(config.plugin).toEqual([
|
])
|
||||||
["acme-plugin@2.0.0", { source: "project" }],
|
expect(config.plugin_origins).toEqual([
|
||||||
["second-plugin@3.0.0", { source: "project" }],
|
{
|
||||||
])
|
spec: ["acme-plugin@2.0.0", { source: "project" }],
|
||||||
expect(config.plugin_origins).toEqual([
|
scope: "local",
|
||||||
{
|
source: path.join(tmp.path, "tui.json"),
|
||||||
spec: ["acme-plugin@2.0.0", { source: "project" }],
|
|
||||||
scope: "local",
|
|
||||||
source: path.join(tmp.path, "tui.json"),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
spec: ["second-plugin@3.0.0", { source: "project" }],
|
|
||||||
scope: "local",
|
|
||||||
source: path.join(tmp.path, "tui.json"),
|
|
||||||
},
|
|
||||||
])
|
|
||||||
},
|
},
|
||||||
})
|
{
|
||||||
|
spec: ["second-plugin@3.0.0", { source: "project" }],
|
||||||
|
scope: "local",
|
||||||
|
source: path.join(tmp.path, "tui.json"),
|
||||||
|
},
|
||||||
|
])
|
||||||
})
|
})
|
||||||
|
|
||||||
test("tracks global and local plugin metadata in merged tui config", async () => {
|
test("tracks global and local plugin metadata in merged tui config", async () => {
|
||||||
@@ -744,25 +577,20 @@ test("tracks global and local plugin metadata in merged tui config", async () =>
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
await Instance.provide({
|
const config = await getTuiConfig(tmp.path)
|
||||||
directory: tmp.path,
|
expect(config.plugin).toEqual(["global-plugin@1.0.0", "local-plugin@2.0.0"])
|
||||||
fn: async () => {
|
expect(config.plugin_origins).toEqual([
|
||||||
const config = await TuiConfig.get()
|
{
|
||||||
expect(config.plugin).toEqual(["global-plugin@1.0.0", "local-plugin@2.0.0"])
|
spec: "global-plugin@1.0.0",
|
||||||
expect(config.plugin_origins).toEqual([
|
scope: "global",
|
||||||
{
|
source: path.join(Global.Path.config, "tui.json"),
|
||||||
spec: "global-plugin@1.0.0",
|
|
||||||
scope: "global",
|
|
||||||
source: path.join(Global.Path.config, "tui.json"),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
spec: "local-plugin@2.0.0",
|
|
||||||
scope: "local",
|
|
||||||
source: path.join(tmp.path, "tui.json"),
|
|
||||||
},
|
|
||||||
])
|
|
||||||
},
|
},
|
||||||
})
|
{
|
||||||
|
spec: "local-plugin@2.0.0",
|
||||||
|
scope: "local",
|
||||||
|
source: path.join(tmp.path, "tui.json"),
|
||||||
|
},
|
||||||
|
])
|
||||||
})
|
})
|
||||||
|
|
||||||
test("merges plugin_enabled flags across config layers", async () => {
|
test("merges plugin_enabled flags across config layers", async () => {
|
||||||
@@ -789,15 +617,10 @@ test("merges plugin_enabled flags across config layers", async () => {
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
await Instance.provide({
|
const config = await getTuiConfig(tmp.path)
|
||||||
directory: tmp.path,
|
expect(config.plugin_enabled).toEqual({
|
||||||
fn: async () => {
|
"internal:sidebar-context": false,
|
||||||
const config = await TuiConfig.get()
|
"demo.plugin": false,
|
||||||
expect(config.plugin_enabled).toEqual({
|
"local.plugin": true,
|
||||||
"internal:sidebar-context": false,
|
|
||||||
"demo.plugin": false,
|
|
||||||
"local.plugin": true,
|
|
||||||
})
|
|
||||||
},
|
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -140,7 +140,7 @@ describe("file/index Filesystem patterns", () => {
|
|||||||
await Instance.provide({
|
await Instance.provide({
|
||||||
directory: tmp.path,
|
directory: tmp.path,
|
||||||
fn: async () => {
|
fn: async () => {
|
||||||
expect(Filesystem.mimeType(filepath)).toContain("application/json")
|
expect(await Filesystem.mimeType(filepath)).toContain("application/json")
|
||||||
|
|
||||||
const result = await read("test.json")
|
const result = await read("test.json")
|
||||||
expect(result.type).toBe("text")
|
expect(result.type).toBe("text")
|
||||||
@@ -164,7 +164,7 @@ describe("file/index Filesystem patterns", () => {
|
|||||||
await Instance.provide({
|
await Instance.provide({
|
||||||
directory: tmp.path,
|
directory: tmp.path,
|
||||||
fn: async () => {
|
fn: async () => {
|
||||||
expect(Filesystem.mimeType(filepath)).toContain(mime)
|
expect(await Filesystem.mimeType(filepath)).toContain(mime)
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,27 +1,31 @@
|
|||||||
import { spyOn } from "bun:test"
|
import { spyOn } from "bun:test"
|
||||||
import path from "path"
|
import path from "path"
|
||||||
import { TuiConfig } from "../../src/config"
|
import { TuiConfig } from "../../src/cli/cmd/tui/config/tui"
|
||||||
|
|
||||||
type PluginSpec = string | [string, Record<string, unknown>]
|
type PluginSpec = string | [string, Record<string, unknown>]
|
||||||
|
|
||||||
export function mockTuiRuntime(dir: string, plugin: PluginSpec[]) {
|
export function mockTuiRuntime(dir: string, plugin: PluginSpec[], opts?: { plugin_enabled?: Record<string, boolean> }) {
|
||||||
process.env.OPENCODE_PLUGIN_META_FILE = path.join(dir, "plugin-meta.json")
|
process.env.OPENCODE_PLUGIN_META_FILE = path.join(dir, "plugin-meta.json")
|
||||||
const plugin_origins = plugin.map((spec) => ({
|
const plugin_origins = plugin.map((spec) => ({
|
||||||
spec,
|
spec,
|
||||||
scope: "local" as const,
|
scope: "local" as const,
|
||||||
source: path.join(dir, "tui.json"),
|
source: path.join(dir, "tui.json"),
|
||||||
}))
|
}))
|
||||||
const get = spyOn(TuiConfig, "get").mockResolvedValue({
|
|
||||||
plugin,
|
|
||||||
plugin_origins,
|
|
||||||
})
|
|
||||||
const wait = spyOn(TuiConfig, "waitForDependencies").mockResolvedValue()
|
const wait = spyOn(TuiConfig, "waitForDependencies").mockResolvedValue()
|
||||||
const cwd = spyOn(process, "cwd").mockImplementation(() => dir)
|
const cwd = spyOn(process, "cwd").mockImplementation(() => dir)
|
||||||
|
|
||||||
return () => {
|
const config: TuiConfig.Info = {
|
||||||
cwd.mockRestore()
|
plugin,
|
||||||
get.mockRestore()
|
plugin_origins,
|
||||||
wait.mockRestore()
|
...(opts?.plugin_enabled && { plugin_enabled: opts.plugin_enabled }),
|
||||||
delete process.env.OPENCODE_PLUGIN_META_FILE
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
config,
|
||||||
|
restore: () => {
|
||||||
|
cwd.mockRestore()
|
||||||
|
wait.mockRestore()
|
||||||
|
delete process.env.OPENCODE_PLUGIN_META_FILE
|
||||||
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,14 +1,14 @@
|
|||||||
import { describe, expect, test } from "bun:test"
|
import { describe, expect, test } from "bun:test"
|
||||||
import path from "path"
|
import path from "path"
|
||||||
import { Global } from "../../src/global"
|
import { Global } from "../../src/global"
|
||||||
import { Installation } from "../../src/installation"
|
import { InstallationChannel } from "../../src/installation/version"
|
||||||
import { Database } from "../../src/storage"
|
import { Database } from "../../src/storage"
|
||||||
|
|
||||||
describe("Database.Path", () => {
|
describe("Database.Path", () => {
|
||||||
test("returns database path for the current channel", () => {
|
test("returns database path for the current channel", () => {
|
||||||
const expected = ["latest", "beta"].includes(Installation.CHANNEL)
|
const expected = ["latest", "beta"].includes(InstallationChannel)
|
||||||
? path.join(Global.Path.data, "opencode.db")
|
? path.join(Global.Path.data, "opencode.db")
|
||||||
: path.join(Global.Path.data, `opencode-${Installation.CHANNEL.replace(/[^a-zA-Z0-9._-]/g, "-")}.db`)
|
: path.join(Global.Path.data, `opencode-${InstallationChannel.replace(/[^a-zA-Z0-9._-]/g, "-")}.db`)
|
||||||
expect(Database.getChannelPath()).toBe(expected)
|
expect(Database.getChannelPath()).toBe(expected)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import { Tool } from "../../src/tool"
|
|||||||
import { Filesystem } from "../../src/util"
|
import { Filesystem } from "../../src/util"
|
||||||
import { provideInstance, tmpdirScoped } from "../fixture/fixture"
|
import { provideInstance, tmpdirScoped } from "../fixture/fixture"
|
||||||
import { testEffect } from "../lib/effect"
|
import { testEffect } from "../lib/effect"
|
||||||
|
import { Npm } from "@opencode-ai/shared/npm"
|
||||||
|
|
||||||
const FIXTURES_DIR = path.join(import.meta.dir, "fixtures")
|
const FIXTURES_DIR = path.join(import.meta.dir, "fixtures")
|
||||||
|
|
||||||
|
|||||||
@@ -347,31 +347,31 @@ describe("filesystem", () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
describe("mimeType()", () => {
|
describe("mimeType()", () => {
|
||||||
test("returns correct MIME type for JSON", () => {
|
test("returns correct MIME type for JSON", async () => {
|
||||||
expect(Filesystem.mimeType("test.json")).toContain("application/json")
|
expect(await Filesystem.mimeType("test.json")).toContain("application/json")
|
||||||
})
|
})
|
||||||
|
|
||||||
test("returns correct MIME type for JavaScript", () => {
|
test("returns correct MIME type for JavaScript", async () => {
|
||||||
expect(Filesystem.mimeType("test.js")).toContain("javascript")
|
expect(await Filesystem.mimeType("test.js")).toContain("javascript")
|
||||||
})
|
})
|
||||||
|
|
||||||
test("returns MIME type for TypeScript (or video/mp2t due to extension conflict)", () => {
|
test("returns MIME type for TypeScript (or video/mp2t due to extension conflict)", async () => {
|
||||||
const mime = Filesystem.mimeType("test.ts")
|
const mime = await Filesystem.mimeType("test.ts")
|
||||||
// .ts is ambiguous: TypeScript vs MPEG-2 TS video
|
// .ts is ambiguous: TypeScript vs MPEG-2 TS video
|
||||||
expect(mime === "video/mp2t" || mime === "application/typescript" || mime === "text/typescript").toBe(true)
|
expect(mime === "video/mp2t" || mime === "application/typescript" || mime === "text/typescript").toBe(true)
|
||||||
})
|
})
|
||||||
|
|
||||||
test("returns correct MIME type for images", () => {
|
test("returns correct MIME type for images", async () => {
|
||||||
expect(Filesystem.mimeType("test.png")).toContain("image/png")
|
expect(await Filesystem.mimeType("test.png")).toContain("image/png")
|
||||||
expect(Filesystem.mimeType("test.jpg")).toContain("image/jpeg")
|
expect(await Filesystem.mimeType("test.jpg")).toContain("image/jpeg")
|
||||||
})
|
})
|
||||||
|
|
||||||
test("returns default for unknown extension", () => {
|
test("returns default for unknown extension", async () => {
|
||||||
expect(Filesystem.mimeType("test.unknown")).toBe("application/octet-stream")
|
expect(await Filesystem.mimeType("test.unknown")).toBe("application/octet-stream")
|
||||||
})
|
})
|
||||||
|
|
||||||
test("handles files without extension", () => {
|
test("handles files without extension", async () => {
|
||||||
expect(Filesystem.mimeType("Makefile")).toBe("application/octet-stream")
|
expect(await Filesystem.mimeType("Makefile")).toBe("application/octet-stream")
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
Executable
+4
@@ -0,0 +1,4 @@
|
|||||||
|
import path from "path"
|
||||||
|
const toDynamicallyImport = path.join(process.cwd(), process.argv[2])
|
||||||
|
await import(toDynamicallyImport)
|
||||||
|
console.log(performance.now())
|
||||||
Executable
+153
@@ -0,0 +1,153 @@
|
|||||||
|
#!/usr/bin/env bun
|
||||||
|
import * as path from "path"
|
||||||
|
import * as ts from "typescript"
|
||||||
|
|
||||||
|
const BASE_DIR = "/home/thdxr/dev/projects/anomalyco/opencode/packages/opencode"
|
||||||
|
|
||||||
|
// Get entry file from command line arg or use default
|
||||||
|
const ENTRY_FILE = process.argv[2] || "src/cli/cmd/tui/plugin/index.ts"
|
||||||
|
|
||||||
|
const visited = new Set<string>()
|
||||||
|
|
||||||
|
function resolveImport(importPath: string, fromFile: string): string | null {
|
||||||
|
if (importPath.startsWith("@/")) {
|
||||||
|
return path.join(BASE_DIR, "src", importPath.slice(2))
|
||||||
|
}
|
||||||
|
|
||||||
|
if (importPath.startsWith("./") || importPath.startsWith("../")) {
|
||||||
|
const dir = path.dirname(fromFile)
|
||||||
|
return path.resolve(dir, importPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
function isInternalImport(importPath: string): boolean {
|
||||||
|
return importPath.startsWith("@/") || importPath.startsWith("./") || importPath.startsWith("../")
|
||||||
|
}
|
||||||
|
|
||||||
|
async function tryExtensions(filePath: string): Promise<string | null> {
|
||||||
|
const extensions = [".ts", ".tsx", ".js", ".jsx"]
|
||||||
|
|
||||||
|
try {
|
||||||
|
const file = Bun.file(filePath)
|
||||||
|
const stat = await file.stat()
|
||||||
|
|
||||||
|
if (stat?.isDirectory()) {
|
||||||
|
for (const ext of extensions) {
|
||||||
|
const indexPath = path.join(filePath, "index" + ext)
|
||||||
|
const indexFile = Bun.file(indexPath)
|
||||||
|
if (await indexFile.exists()) return indexPath
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
// It's a file
|
||||||
|
return filePath
|
||||||
|
} catch {
|
||||||
|
// Path doesn't exist, try adding extensions
|
||||||
|
for (const ext of extensions) {
|
||||||
|
const withExt = filePath + ext
|
||||||
|
const extFile = Bun.file(withExt)
|
||||||
|
if (await extFile.exists()) return withExt
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractImports(sourceFile: ts.SourceFile): string[] {
|
||||||
|
const imports: string[] = []
|
||||||
|
|
||||||
|
function visit(node: ts.Node) {
|
||||||
|
// import x from "path" or import { x } from "path"
|
||||||
|
if (ts.isImportDeclaration(node)) {
|
||||||
|
// Skip type-only imports
|
||||||
|
if (node.importClause?.isTypeOnly) return
|
||||||
|
|
||||||
|
const moduleSpec = node.moduleSpecifier
|
||||||
|
if (ts.isStringLiteral(moduleSpec)) {
|
||||||
|
imports.push(moduleSpec.text)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// export { x } from "path"
|
||||||
|
if (ts.isExportDeclaration(node) && node.moduleSpecifier) {
|
||||||
|
if (ts.isStringLiteral(node.moduleSpecifier)) {
|
||||||
|
imports.push(node.moduleSpecifier.text)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Dynamic import: import("path")
|
||||||
|
if (ts.isCallExpression(node) && node.expression.kind === ts.SyntaxKind.ImportKeyword) {
|
||||||
|
const arg = node.arguments[0]
|
||||||
|
if (arg && ts.isStringLiteral(arg)) {
|
||||||
|
imports.push(arg.text)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ts.forEachChild(node, visit)
|
||||||
|
}
|
||||||
|
|
||||||
|
visit(sourceFile)
|
||||||
|
return imports
|
||||||
|
}
|
||||||
|
|
||||||
|
async function traceFile(filePath: string, depth = 0): Promise<void> {
|
||||||
|
const normalizedPath = path.relative(BASE_DIR, filePath)
|
||||||
|
|
||||||
|
if (visited.has(filePath)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only trace TypeScript/JavaScript files
|
||||||
|
if (!filePath.match(/\.(ts|tsx|js|jsx)$/)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
visited.add(filePath)
|
||||||
|
console.log("\t".repeat(depth) + normalizedPath)
|
||||||
|
|
||||||
|
let content: string
|
||||||
|
try {
|
||||||
|
content = await Bun.file(filePath).text()
|
||||||
|
} catch {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const sourceFile = ts.createSourceFile(filePath, content, ts.ScriptTarget.Latest, true)
|
||||||
|
|
||||||
|
const imports = extractImports(sourceFile)
|
||||||
|
const internalImports = imports.filter(isInternalImport)
|
||||||
|
const externalImports = imports.filter((imp) => !isInternalImport(imp))
|
||||||
|
|
||||||
|
// Print external imports
|
||||||
|
for (const imp of externalImports) {
|
||||||
|
console.log("\t".repeat(depth + 1) + `[ext] ${imp}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const imp of internalImports) {
|
||||||
|
const resolved = resolveImport(imp, filePath)
|
||||||
|
if (!resolved) continue
|
||||||
|
|
||||||
|
const actualPath = await tryExtensions(resolved)
|
||||||
|
if (!actualPath) continue
|
||||||
|
|
||||||
|
await traceFile(actualPath, depth + 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const entryPath = path.join(BASE_DIR, ENTRY_FILE)
|
||||||
|
|
||||||
|
// Check if file exists
|
||||||
|
const file = Bun.file(entryPath)
|
||||||
|
if (!(await file.exists())) {
|
||||||
|
console.error(`File not found: ${ENTRY_FILE}`)
|
||||||
|
console.error(`Resolved to: ${entryPath}`)
|
||||||
|
process.exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
await traceFile(entryPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch(console.error)
|
||||||
@@ -10,7 +10,8 @@
|
|||||||
"customConditions": ["browser"],
|
"customConditions": ["browser"],
|
||||||
"paths": {
|
"paths": {
|
||||||
"@/*": ["./src/*"],
|
"@/*": ["./src/*"],
|
||||||
"@tui/*": ["./src/cli/cmd/tui/*"]
|
"@tui/*": ["./src/cli/cmd/tui/*"],
|
||||||
|
"@test/*": ["./test/*"]
|
||||||
},
|
},
|
||||||
"plugins": [
|
"plugins": [
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -6,7 +6,8 @@
|
|||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"test": "bun test"
|
"test": "bun test",
|
||||||
|
"typecheck": "tsgo --noEmit"
|
||||||
},
|
},
|
||||||
"bin": {
|
"bin": {
|
||||||
"opencode": "./bin/opencode"
|
"opencode": "./bin/opencode"
|
||||||
@@ -17,7 +18,9 @@
|
|||||||
"imports": {},
|
"imports": {},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/semver": "catalog:",
|
"@types/semver": "catalog:",
|
||||||
"@types/bun": "catalog:"
|
"@types/bun": "catalog:",
|
||||||
|
"@types/npmcli__arborist": "6.3.3",
|
||||||
|
"@tsconfig/bun": "catalog:"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@effect/platform-node": "catalog:",
|
"@effect/platform-node": "catalog:",
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import path from "path"
|
import path from "path"
|
||||||
import semver from "semver"
|
import semver from "semver"
|
||||||
import { Arborist } from "@npmcli/arborist"
|
|
||||||
import { Effect, Schema, Context, Layer, Option, FileSystem } from "effect"
|
import { Effect, Schema, Context, Layer, Option, FileSystem } from "effect"
|
||||||
import { NodeFileSystem } from "@effect/platform-node"
|
import { NodeFileSystem } from "@effect/platform-node"
|
||||||
import { AppFileSystem } from "./filesystem"
|
import { AppFileSystem } from "./filesystem"
|
||||||
@@ -19,8 +18,8 @@ export namespace Npm {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface Interface {
|
export interface Interface {
|
||||||
readonly add: (pkg: string) => Effect.Effect<EntryPoint, InstallFailedError>
|
readonly add: (pkg: string) => Effect.Effect<EntryPoint, InstallFailedError | EffectFlock.LockError>
|
||||||
readonly install: (dir: string) => Effect.Effect<void>
|
readonly install: (dir: string, input?: { add: string[] }) => Effect.Effect<void, EffectFlock.LockError>
|
||||||
readonly outdated: (pkg: string, cachedVersion: string) => Effect.Effect<boolean>
|
readonly outdated: (pkg: string, cachedVersion: string) => Effect.Effect<boolean>
|
||||||
readonly which: (pkg: string) => Effect.Effect<Option.Option<string>>
|
readonly which: (pkg: string) => Effect.Effect<Option.Option<string>>
|
||||||
}
|
}
|
||||||
@@ -92,6 +91,7 @@ export namespace Npm {
|
|||||||
})
|
})
|
||||||
|
|
||||||
const add = Effect.fn("Npm.add")(function* (pkg: string) {
|
const add = Effect.fn("Npm.add")(function* (pkg: string) {
|
||||||
|
const { Arborist } = yield* Effect.promise(() => import("@npmcli/arborist"))
|
||||||
const dir = directory(pkg)
|
const dir = directory(pkg)
|
||||||
yield* flock.acquire(`npm-install:${dir}`)
|
yield* flock.acquire(`npm-install:${dir}`)
|
||||||
|
|
||||||
@@ -133,10 +133,17 @@ export namespace Npm {
|
|||||||
return resolveEntryPoint(first.name, first.path)
|
return resolveEntryPoint(first.name, first.path)
|
||||||
}, Effect.scoped)
|
}, Effect.scoped)
|
||||||
|
|
||||||
const install = Effect.fn("Npm.install")(function* (dir: string) {
|
const install = Effect.fn("Npm.install")(function* (dir: string, input?: { add: string[] }) {
|
||||||
|
const canWrite = yield* afs.access(dir, { writable: true }).pipe(
|
||||||
|
Effect.as(true),
|
||||||
|
Effect.orElseSucceed(() => false),
|
||||||
|
)
|
||||||
|
if (!canWrite) return
|
||||||
|
|
||||||
yield* flock.acquire(`npm-install:${dir}`)
|
yield* flock.acquire(`npm-install:${dir}`)
|
||||||
|
|
||||||
const reify = Effect.fnUntraced(function* () {
|
const reify = Effect.fnUntraced(function* () {
|
||||||
|
const { Arborist } = yield* Effect.promise(() => import("@npmcli/arborist"))
|
||||||
const arb = new Arborist({
|
const arb = new Arborist({
|
||||||
path: dir,
|
path: dir,
|
||||||
binLinks: true,
|
binLinks: true,
|
||||||
@@ -145,7 +152,14 @@ export namespace Npm {
|
|||||||
ignoreScripts: true,
|
ignoreScripts: true,
|
||||||
})
|
})
|
||||||
yield* Effect.tryPromise({
|
yield* Effect.tryPromise({
|
||||||
try: () => arb.reify().catch(() => {}),
|
try: () =>
|
||||||
|
arb
|
||||||
|
.reify({
|
||||||
|
add: input?.add || [],
|
||||||
|
save: true,
|
||||||
|
saveType: "prod",
|
||||||
|
})
|
||||||
|
.catch(() => {}),
|
||||||
catch: () => {},
|
catch: () => {},
|
||||||
}).pipe(Effect.orElseSucceed(() => {}))
|
}).pipe(Effect.orElseSucceed(() => {}))
|
||||||
})
|
})
|
||||||
@@ -167,6 +181,7 @@ export namespace Npm {
|
|||||||
...Object.keys(pkgAny?.devDependencies || {}),
|
...Object.keys(pkgAny?.devDependencies || {}),
|
||||||
...Object.keys(pkgAny?.peerDependencies || {}),
|
...Object.keys(pkgAny?.peerDependencies || {}),
|
||||||
...Object.keys(pkgAny?.optionalDependencies || {}),
|
...Object.keys(pkgAny?.optionalDependencies || {}),
|
||||||
|
...(input?.add || []),
|
||||||
])
|
])
|
||||||
|
|
||||||
const root = lockAny?.packages?.[""] || {}
|
const root = lockAny?.packages?.[""] || {}
|
||||||
|
|||||||
@@ -4,6 +4,12 @@ export abstract class NamedError extends Error {
|
|||||||
abstract schema(): z.core.$ZodType
|
abstract schema(): z.core.$ZodType
|
||||||
abstract toObject(): { name: string; data: any }
|
abstract toObject(): { name: string; data: any }
|
||||||
|
|
||||||
|
static hasName(error: unknown, name: string): boolean {
|
||||||
|
return (
|
||||||
|
typeof error === "object" && error !== null && "name" in error && (error as Record<string, unknown>).name === name
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
static create<Name extends string, Data extends z.core.$ZodType>(name: Name, data: Data) {
|
static create<Name extends string, Data extends z.core.$ZodType>(name: Name, data: Data) {
|
||||||
const schema = z
|
const schema = z
|
||||||
.object({
|
.object({
|
||||||
|
|||||||
@@ -345,10 +345,14 @@ export namespace Flock {
|
|||||||
return await fn()
|
return await fn()
|
||||||
}
|
}
|
||||||
|
|
||||||
export const effect = Effect.fn("Flock.effect")(function* (key: string) {
|
export const effect = Effect.fn("Flock.effect")(function* (key: string, input: Options = {}) {
|
||||||
return yield* Effect.acquireRelease(
|
return yield* Effect.acquireRelease(
|
||||||
Effect.promise((signal) => Flock.acquire(key, { signal })),
|
Effect.promise((signal) => Flock.acquire(key, { ...input, signal })).pipe(
|
||||||
(foo) => Effect.promise(() => foo.release()),
|
Effect.withSpan("Flock.acquire", {
|
||||||
|
attributes: { key },
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
(lock) => Effect.promise(() => lock.release()).pipe(Effect.withSpan("Flock.release")),
|
||||||
).pipe(Effect.asVoid)
|
).pipe(Effect.asVoid)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,16 +2,7 @@
|
|||||||
"$schema": "https://json.schemastore.org/tsconfig",
|
"$schema": "https://json.schemastore.org/tsconfig",
|
||||||
"extends": "@tsconfig/bun/tsconfig.json",
|
"extends": "@tsconfig/bun/tsconfig.json",
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"jsx": "preserve",
|
|
||||||
"jsxImportSource": "@opentui/solid",
|
|
||||||
"lib": ["ESNext", "DOM", "DOM.Iterable"],
|
|
||||||
"types": [],
|
|
||||||
"noUncheckedIndexedAccess": false,
|
"noUncheckedIndexedAccess": false,
|
||||||
"customConditions": ["browser"],
|
|
||||||
"paths": {
|
|
||||||
"@/*": ["./src/*"],
|
|
||||||
"@tui/*": ["./src/cli/cmd/tui/*"]
|
|
||||||
},
|
|
||||||
"plugins": [
|
"plugins": [
|
||||||
{
|
{
|
||||||
"name": "@effect/language-service",
|
"name": "@effect/language-service",
|
||||||
|
|||||||
Reference in New Issue
Block a user