Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 9 additions & 6 deletions packages/opencode/src/cli/cmd/plug.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,14 @@
import { intro, log, outro, spinner } from "@clack/prompts"
import type { Argv } from "yargs"
import { AppFileSystem } from "@opencode-ai/shared/filesystem"
import { Effect } from "effect"

import { ConfigPaths } from "../../config/paths"
import { Global } from "../../global"
import { installPlugin, patchPluginConfig, readPluginManifest } from "../../plugin/install"
import { resolvePluginTarget } from "../../plugin/shared"
import { Instance } from "../../project/instance"
import { errorMessage } from "../../util/error"
import { Filesystem } from "../../util/filesystem"
import { Process } from "../../util/process"
import { UI } from "../ui"
import { cmd } from "./cmd"
Expand Down Expand Up @@ -44,6 +45,10 @@ export type PlugCtx = {
directory: string
}

function file<T>(fn: (fs: AppFileSystem.Interface) => Effect.Effect<T, AppFileSystem.Error>) {
return Effect.runPromise(AppFileSystem.Service.use(fn).pipe(Effect.provide(AppFileSystem.defaultLayer)))
}

const defaultPlugDeps: PlugDeps = {
spinner: () => spinner(),
log: {
Expand All @@ -52,11 +57,9 @@ const defaultPlugDeps: PlugDeps = {
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),
readText: (path) => file((fs) => fs.readFileString(path)),
write: (path, text) => file((fs) => fs.writeWithDirs(path, text)),
exists: (path) => file((fs) => fs.existsSafe(path)),
files: (dir, name) => ConfigPaths.fileInDirectory(dir, name),
global: Global.Path.config,
}
Expand Down
23 changes: 14 additions & 9 deletions packages/opencode/src/cli/cmd/tui/plugin/runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ import {
type TuiSlotPlugin,
type TuiTheme,
} from "@opencode-ai/plugin/tui"
import { AppFileSystem } from "@opencode-ai/shared/filesystem"
import { Effect, Option } from "effect"
import path from "path"
import { fileURLToPath } from "url"

Expand All @@ -32,7 +34,6 @@ import { PluginMeta } from "@/plugin/meta"
import { installPlugin as installModulePlugin, patchPluginConfig, readPluginManifest } from "@/plugin/install"
import { hasTheme, upsertTheme } from "../context/theme"
import { Global } from "@/global"
import { Filesystem } from "@/util/filesystem"
import { Process } from "@/util/process"
import { Flock } from "@/util/flock"
import { Flag } from "@/flag/flag"
Expand Down Expand Up @@ -87,6 +88,10 @@ const EMPTY_TUI: TuiPluginModule = {
tui: async () => {},
}

function io<T>(fn: (fs: AppFileSystem.Interface) => Effect.Effect<T, AppFileSystem.Error>) {
return Effect.runPromise(AppFileSystem.Service.use(fn).pipe(Effect.provide(AppFileSystem.defaultLayer)))
}

function fail(message: string, data: Record<string, unknown>) {
if (!("error" in data)) {
log.error(message, data)
Expand Down Expand Up @@ -163,13 +168,13 @@ function createThemeInstaller(
: path.join(source_dir, ".opencode", "themes")
const dest_dir = meta.scope === "local" ? local_dir : path.join(Global.Path.config, "themes")
const dest = path.join(dest_dir, `${name}.json`)
const stat = await Filesystem.statAsync(src)
const mtime = stat ? Math.floor(typeof stat.mtimeMs === "bigint" ? Number(stat.mtimeMs) : stat.mtimeMs) : undefined
const size = stat ? (typeof stat.size === "bigint" ? Number(stat.size) : stat.size) : undefined
const stat = await io((fs) => fs.stat(src)).catch(() => undefined)
const mtime = stat ? Option.getOrUndefined(stat.mtime)?.getTime() : undefined
const size = stat ? Number(stat.size) : undefined
const info = {
src,
dest,
mtime,
mtime: mtime === undefined ? undefined : Math.floor(mtime),
size,
}

Expand All @@ -191,15 +196,15 @@ function createThemeInstaller(
const prev = plugin.themes[name]
if (exists) {
if (plugin.meta.state !== "updated") {
if (!prev && (await Filesystem.exists(dest))) {
if (!prev && (await io((fs) => fs.existsSafe(dest)))) {
await save()
}
return
}
if (prev?.dest === dest && prev.mtime === mtime && prev.size === size) return
}

const text = await Filesystem.readText(src).catch((error) => {
const text = await io((fs) => fs.readFileString(src)).catch((error) => {
log.warn("failed to read tui plugin theme", { path: spec, theme: src, error })
return
})
Expand All @@ -219,8 +224,8 @@ function createThemeInstaller(
return
}

if (exists || !(await Filesystem.exists(dest))) {
await Filesystem.write(dest, text).catch((error) => {
if (exists || !(await io((fs) => fs.existsSafe(dest)))) {
await io((fs) => fs.writeWithDirs(dest, text)).catch((error) => {
log.warn("failed to persist tui plugin theme", { path: spec, theme: src, dest, error })
})
}
Expand Down
35 changes: 27 additions & 8 deletions packages/opencode/src/npm/index.ts
Original file line number Diff line number Diff line change
@@ -1,17 +1,32 @@
import semver from "semver"
import z from "zod"
import { NamedError } from "@opencode-ai/shared/util/error"
import { AppFileSystem } from "@opencode-ai/shared/filesystem"
import { Effect } from "effect"
import { Global } from "../global"
import { Log } from "../util/log"
import path from "path"
import { readdir, rm } from "fs/promises"
import { Filesystem } from "@/util/filesystem"
import { Flock } from "@/util/flock"
import { Arborist } from "@npmcli/arborist"

export namespace Npm {
const log = Log.create({ service: "npm" })
const illegal = process.platform === "win32" ? new Set(["<", ">", ":", '"', "|", "?", "*"]) : undefined
type Deps = Record<string, string>
type Manifest = {
dependencies?: Deps
devDependencies?: Deps
peerDependencies?: Deps
optionalDependencies?: Deps
}
type Lock = {
packages?: Record<string, Manifest>
}

function file<T>(fn: (fs: AppFileSystem.Interface) => Effect.Effect<T, AppFileSystem.Error>) {
return Effect.runPromise(AppFileSystem.Service.use(fn).pipe(Effect.provide(AppFileSystem.defaultLayer)))
}

export const InstallFailedError = NamedError.create(
"NpmInstallFailedError",
Expand Down Expand Up @@ -63,7 +78,7 @@ export namespace Npm {

export async function add(pkg: string) {
const dir = directory(pkg)
await using _ = await Flock.acquire(`npm-install:${Filesystem.resolve(dir)}`)
await using _ = await Flock.acquire(`npm-install:${AppFileSystem.resolve(dir)}`)
log.info("installing package", {
pkg,
})
Expand Down Expand Up @@ -118,14 +133,18 @@ export namespace Npm {
await arb.reify().catch(() => {})
}

if (!(await Filesystem.exists(path.join(dir, "node_modules")))) {
if (!(await file((fs) => fs.existsSafe(path.join(dir, "node_modules"))))) {
log.info("node_modules missing, reifying")
await reify()
return
}

const pkg = await Filesystem.readJson(path.join(dir, "package.json")).catch(() => ({}))
const lock = await Filesystem.readJson(path.join(dir, "package-lock.json")).catch(() => ({}))
const pkg = await file((fs) => fs.readJson(path.join(dir, "package.json")))
.then((item) => item as Manifest)
.catch(() => ({}))
const lock = await file((fs) => fs.readJson(path.join(dir, "package-lock.json")))
.then((item) => item as Lock)
.catch(() => ({}))

const declared = new Set([
...Object.keys(pkg.dependencies || {}),
Expand Down Expand Up @@ -162,9 +181,9 @@ export namespace Npm {
if (files.length === 0) return undefined
if (files.length === 1) return files[0]
// Multiple binaries — resolve from package.json bin field like npx does
const pkgJson = await Filesystem.readJson<{ bin?: string | Record<string, string> }>(
path.join(dir, "node_modules", pkg, "package.json"),
).catch(() => undefined)
const pkgJson = await file((fs) => fs.readJson(path.join(dir, "node_modules", pkg, "package.json")))
.then((item) => item as { bin?: string | Record<string, string> })
.catch(() => undefined)
if (pkgJson?.bin) {
const unscoped = pkg.startsWith("@") ? pkg.split("/")[1] : pkg
const bin = pkgJson.bin
Expand Down
17 changes: 10 additions & 7 deletions packages/opencode/src/plugin/install.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,11 @@ import {
parse as parseJsonc,
printParseErrorCode,
} from "jsonc-parser"
import { AppFileSystem } from "@opencode-ai/shared/filesystem"
import { Effect } from "effect"

import { ConfigPaths } from "@/config/paths"
import { Global } from "@/global"
import { Filesystem } from "@/util/filesystem"
import { Flock } from "@/util/flock"
import { isRecord } from "@/util/record"

Expand Down Expand Up @@ -79,12 +80,14 @@ const defaultInstallDeps: InstallDeps = {
resolve: (spec) => resolvePluginTarget(spec),
}

function file<T>(fn: (fs: AppFileSystem.Interface) => Effect.Effect<T, AppFileSystem.Error>) {
return Effect.runPromise(AppFileSystem.Service.use(fn).pipe(Effect.provide(AppFileSystem.defaultLayer)))
}

const defaultPatchDeps: PatchDeps = {
readText: (file) => Filesystem.readText(file),
write: async (file, text) => {
await Filesystem.write(file, text)
},
exists: (file) => Filesystem.exists(file),
readText: (path) => file((fs) => fs.readFileString(path)),
write: (path, text) => file((fs) => fs.writeWithDirs(path, text)),
exists: (path) => file((fs) => fs.existsSafe(path)),
files: (dir, name) => ConfigPaths.fileInDirectory(dir, name),
}

Expand Down Expand Up @@ -344,7 +347,7 @@ function patchName(kind: Kind): "opencode" | "tui" {

async function patchOne(dir: string, target: Target, spec: string, force: boolean, dep: PatchDeps): Promise<PatchOne> {
const name = patchName(target.kind)
await using _ = await Flock.acquire(`plug-config:${Filesystem.resolve(path.join(dir, name))}`)
await using _ = await Flock.acquire(`plug-config:${AppFileSystem.resolve(path.join(dir, name))}`)

const files = dep.files(dir, name)
let cfg = files[0]
Expand Down
29 changes: 19 additions & 10 deletions packages/opencode/src/plugin/meta.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,19 @@
import path from "path"
import { fileURLToPath } from "url"
import { AppFileSystem } from "@opencode-ai/shared/filesystem"
import { Effect, Option } from "effect"

import { Flag } from "@/flag/flag"
import { Global } from "@/global"
import { Filesystem } from "@/util/filesystem"
import { Flock } from "@/util/flock"

import { parsePluginSpecifier, pluginSource } from "./shared"

export namespace PluginMeta {
function io<T>(fn: (fs: AppFileSystem.Interface) => Effect.Effect<T, AppFileSystem.Error>) {
return Effect.runPromise(AppFileSystem.Service.use(fn).pipe(Effect.provide(AppFileSystem.defaultLayer)))
}

type Source = "file" | "npm"

export type Theme = {
Expand Down Expand Up @@ -61,10 +66,11 @@ export namespace PluginMeta {
}

async function modifiedAt(file: string) {
const stat = await Filesystem.statAsync(file)
const stat = await io((fs) => fs.stat(file)).catch(() => undefined)
if (!stat) return
const mtime = stat.mtimeMs
return Math.floor(typeof mtime === "bigint" ? Number(mtime) : mtime)
const mtime = Option.getOrUndefined(stat.mtime)?.getTime()
if (mtime === undefined) return
return Math.floor(mtime)
}

function resolvedTarget(target: string) {
Expand All @@ -74,9 +80,10 @@ export namespace PluginMeta {

async function npmVersion(target: string) {
const resolved = resolvedTarget(target)
const stat = await Filesystem.statAsync(resolved)
const dir = stat?.isDirectory() ? resolved : path.dirname(resolved)
return Filesystem.readJson<{ version?: string }>(path.join(dir, "package.json"))
const stat = await io((fs) => fs.stat(resolved)).catch(() => undefined)
const dir = stat?.type === "Directory" ? resolved : path.dirname(resolved)
return io((fs) => fs.readJson(path.join(dir, "package.json")))
.then((item) => item as { version?: string })
.then((item) => item.version)
.catch(() => undefined)
}
Expand Down Expand Up @@ -112,7 +119,9 @@ export namespace PluginMeta {
}

async function read(file: string): Promise<Store> {
return Filesystem.readJson<Store>(file).catch(() => ({}) as Store)
return io((fs) => fs.readJson(file))
.then((item) => item as Store)
.catch(() => ({}) as Store)
}

async function row(item: Touch): Promise<Row> {
Expand Down Expand Up @@ -154,7 +163,7 @@ export namespace PluginMeta {
store[item.id] = hit.entry
out.push(hit)
}
await Filesystem.writeJson(file, store)
await io((fs) => fs.writeWithDirs(file, JSON.stringify(store, null, 2)))
return out
})
}
Expand All @@ -177,7 +186,7 @@ export namespace PluginMeta {
...(entry.themes ?? {}),
[name]: theme,
}
await Filesystem.writeJson(file, store)
await io((fs) => fs.writeWithDirs(file, JSON.stringify(store, null, 2)))
})
}

Expand Down
Loading
Loading