231 lines
6.8 KiB
TypeScript
231 lines
6.8 KiB
TypeScript
import { intro, log, outro, spinner } from "@clack/prompts"
|
|
import { Effect } from "effect"
|
|
|
|
import { ConfigPaths } from "@/config/paths"
|
|
import { Global } from "@opencode-ai/core/global"
|
|
import { installPlugin, patchPluginConfig, readPluginManifest } from "../../plugin/install"
|
|
import { resolvePluginTarget } from "../../plugin/shared"
|
|
import { errorMessage } from "../../util/error"
|
|
import { Filesystem } from "@/util/filesystem"
|
|
import { Process } from "@/util/process"
|
|
import { UI } from "../ui"
|
|
import { effectCmd } from "../effect-cmd"
|
|
import { InstanceRef } from "@/effect/instance-ref"
|
|
|
|
type Spin = {
|
|
start: (msg: string) => void
|
|
stop: (msg: string, code?: number) => void
|
|
}
|
|
|
|
export type PlugDeps = {
|
|
spinner: () => Spin
|
|
log: {
|
|
error: (msg: string) => void
|
|
info: (msg: string) => void
|
|
success: (msg: string) => void
|
|
}
|
|
resolve: (spec: string) => Promise<string>
|
|
readText: (file: string) => Promise<string>
|
|
write: (file: string, text: string) => Promise<void>
|
|
exists: (file: string) => Promise<boolean>
|
|
files: (dir: string, name: "opencode" | "tui") => string[]
|
|
global: string
|
|
}
|
|
|
|
export type PlugInput = {
|
|
mod: string
|
|
global?: boolean
|
|
force?: boolean
|
|
}
|
|
|
|
export type PlugCtx = {
|
|
vcs?: string
|
|
worktree: string
|
|
directory: string
|
|
}
|
|
|
|
const defaultPlugDeps: PlugDeps = {
|
|
spinner: () => spinner(),
|
|
log: {
|
|
error: (msg) => log.error(msg),
|
|
info: (msg) => log.info(msg),
|
|
success: (msg) => log.success(msg),
|
|
},
|
|
resolve: (spec) => resolvePluginTarget(spec),
|
|
readText: (file) => Filesystem.readText(file),
|
|
write: async (file, text) => {
|
|
await Filesystem.write(file, text)
|
|
},
|
|
exists: (file) => Filesystem.exists(file),
|
|
files: (dir, name) => ConfigPaths.fileInDirectory(dir, name),
|
|
global: Global.Path.config,
|
|
}
|
|
|
|
function cause(err: unknown) {
|
|
if (!err || typeof err !== "object") return
|
|
if (!("cause" in err)) return
|
|
return (err as { cause?: unknown }).cause
|
|
}
|
|
|
|
export function createPlugTask(input: PlugInput, dep: PlugDeps = defaultPlugDeps) {
|
|
const mod = input.mod
|
|
const force = Boolean(input.force)
|
|
const global = Boolean(input.global)
|
|
|
|
return async (ctx: PlugCtx) => {
|
|
const install = dep.spinner()
|
|
install.start("Installing plugin package...")
|
|
const target = await installPlugin(mod, dep)
|
|
if (!target.ok) {
|
|
install.stop("Install failed", 1)
|
|
dep.log.error(`Could not install "${mod}"`)
|
|
const hit = cause(target.error) ?? target.error
|
|
if (hit instanceof Process.RunFailedError) {
|
|
const lines = hit.stderr
|
|
.toString()
|
|
.split(/\r?\n/)
|
|
.map((line) => line.trim())
|
|
.filter(Boolean)
|
|
const errs = lines.filter((line) => line.startsWith("error:")).map((line) => line.replace(/^error:\s*/, ""))
|
|
const detail = errs[0] ?? lines.at(-1)
|
|
if (detail) dep.log.error(detail)
|
|
if (lines.some((line) => line.includes("No version matching"))) {
|
|
dep.log.info("This package depends on a version that is not available in your npm registry.")
|
|
dep.log.info("Check npm registry/auth settings and try again.")
|
|
}
|
|
}
|
|
if (!(hit instanceof Process.RunFailedError)) {
|
|
dep.log.error(errorMessage(hit))
|
|
}
|
|
return false
|
|
}
|
|
install.stop("Plugin package ready")
|
|
|
|
const inspect = dep.spinner()
|
|
inspect.start("Reading plugin manifest...")
|
|
const manifest = await readPluginManifest(target.target)
|
|
if (!manifest.ok) {
|
|
if (manifest.code === "manifest_read_failed") {
|
|
inspect.stop("Manifest read failed", 1)
|
|
dep.log.error(`Installed "${mod}" but failed to read ${manifest.file}`)
|
|
dep.log.error(errorMessage(cause(manifest.error) ?? manifest.error))
|
|
return false
|
|
}
|
|
|
|
if (manifest.code === "manifest_no_targets") {
|
|
inspect.stop("No plugin targets found", 1)
|
|
dep.log.error(`"${mod}" does not expose plugin entrypoints in package.json`)
|
|
dep.log.info(
|
|
'Expected one of: exports["./tui"], exports["./server"], package.json main for server, or package.json["oc-themes"] for tui themes.',
|
|
)
|
|
return false
|
|
}
|
|
|
|
inspect.stop("Manifest read failed", 1)
|
|
return false
|
|
}
|
|
|
|
inspect.stop(
|
|
`Detected ${manifest.targets.map((item) => item.kind).join(" + ")} target${manifest.targets.length === 1 ? "" : "s"}`,
|
|
)
|
|
|
|
const patch = dep.spinner()
|
|
patch.start("Updating plugin config...")
|
|
const out = await patchPluginConfig(
|
|
{
|
|
spec: mod,
|
|
targets: manifest.targets,
|
|
force,
|
|
global,
|
|
vcs: ctx.vcs,
|
|
worktree: ctx.worktree,
|
|
directory: ctx.directory,
|
|
config: dep.global,
|
|
},
|
|
dep,
|
|
)
|
|
if (!out.ok) {
|
|
if (out.code === "invalid_json") {
|
|
patch.stop(`Failed updating ${out.kind} config`, 1)
|
|
dep.log.error(`Invalid JSON in ${out.file} (${out.parse} at line ${out.line}, column ${out.col})`)
|
|
dep.log.info("Fix the config file and run the command again.")
|
|
return false
|
|
}
|
|
|
|
patch.stop("Failed updating plugin config", 1)
|
|
dep.log.error(errorMessage(out.error))
|
|
return false
|
|
}
|
|
patch.stop("Plugin config updated")
|
|
for (const item of out.items) {
|
|
if (item.mode === "noop") {
|
|
dep.log.info(`Already configured in ${item.file}`)
|
|
continue
|
|
}
|
|
if (item.mode === "replace") {
|
|
dep.log.info(`Replaced in ${item.file}`)
|
|
continue
|
|
}
|
|
dep.log.info(`Added to ${item.file}`)
|
|
}
|
|
|
|
dep.log.success(`Installed ${mod}`)
|
|
dep.log.info(global ? `Scope: global (${out.dir})` : `Scope: local (${out.dir})`)
|
|
return true
|
|
}
|
|
}
|
|
|
|
export const PluginCommand = effectCmd({
|
|
command: "plugin <module>",
|
|
aliases: ["plug"],
|
|
describe: "install plugin and update config",
|
|
builder: (yargs) =>
|
|
yargs
|
|
.positional("module", {
|
|
type: "string",
|
|
describe: "npm module name",
|
|
})
|
|
.option("global", {
|
|
alias: ["g"],
|
|
type: "boolean",
|
|
default: false,
|
|
describe: "install in global config",
|
|
})
|
|
.option("force", {
|
|
alias: ["f"],
|
|
type: "boolean",
|
|
default: false,
|
|
describe: "replace existing plugin version",
|
|
}),
|
|
handler: Effect.fn("Cli.plug")(function* (args) {
|
|
const mod = String(args.module ?? "").trim()
|
|
if (!mod) {
|
|
UI.error("module is required")
|
|
process.exitCode = 1
|
|
return
|
|
}
|
|
|
|
UI.empty()
|
|
intro(`Install plugin ${mod}`)
|
|
|
|
const run = createPlugTask({
|
|
mod,
|
|
global: Boolean(args.global),
|
|
force: Boolean(args.force),
|
|
})
|
|
|
|
const ctx = yield* InstanceRef
|
|
if (!ctx) return
|
|
const ok = yield* Effect.promise(() =>
|
|
run({
|
|
vcs: ctx.project.vcs,
|
|
worktree: ctx.worktree,
|
|
directory: ctx.directory,
|
|
}),
|
|
)
|
|
|
|
outro("Done")
|
|
if (!ok) process.exitCode = 1
|
|
}),
|
|
})
|