refactor: extract shared util package (#37828)

This commit is contained in:
Dax 2026-07-21 10:34:29 -04:00 committed by GitHub
commit e0810753f2
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
272 changed files with 590 additions and 565 deletions

View file

@ -25,14 +25,15 @@
"dependencies": {
"@effect/platform-node": "catalog:",
"@opencode-ai/client": "workspace:*",
"@opencode-ai/core": "workspace:*",
"@opencode-ai/plugin": "workspace:*",
"@opencode-ai/schema": "workspace:*",
"@opencode-ai/server": "workspace:*",
"@opencode-ai/tui": "workspace:*",
"@opencode-ai/util": "workspace:*",
"@opentui/core": "catalog:",
"@opentui/solid": "catalog:",
"@parcel/watcher": "2.5.1",
"@silvia-odwyer/photon-node": "0.3.4",
"effect": "catalog:",
"immer": "11.1.4",
"jsonc-parser": "3.3.1",

View file

@ -1,6 +1,5 @@
import { createHash } from "node:crypto"
import { copyFile, mkdir, readdir, readFile, stat } from "node:fs/promises"
import { createRequire } from "node:module"
import path from "node:path"
import { fileURLToPath } from "node:url"
import { getNodeAssets } from "@opentui/core/node-assets"
@ -40,7 +39,7 @@ export async function collectNodeAssets(target: NodeTarget) {
{ key: target.parcelWatcherAsset, source: fileURLToPath(import.meta.resolve(target.parcelWatcherPackage)) },
{
key: photonWasmAsset,
source: createRequire(path.resolve(dir, "../core/package.json")).resolve(photonWasmAsset),
source: fileURLToPath(import.meta.resolve(photonWasmAsset)),
},
...attentionSoundAssets.map((key) => ({
key,

View file

@ -1,7 +1,7 @@
import { Cause, Effect, Exit, Option } from "effect"
import { Service } from "@opencode-ai/client/effect/service"
import { OpenCode, type OpenCodeClient } from "@opencode-ai/client/promise"
import { AppProcess } from "@opencode-ai/core/process"
import { AppProcess } from "@opencode-ai/util/process"
import { Commands } from "../../commands"
import { Runtime } from "../../../framework/runtime"
import { ServiceConfig } from "../../../services/service-config"

View file

@ -1,5 +1,5 @@
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
import { Global } from "@opencode-ai/core/global"
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
import { Global } from "@opencode-ai/util/global"
import { run } from "@opencode-ai/tui"
import { Commands } from "../commands"
import { Runtime } from "../../framework/runtime"
@ -8,7 +8,7 @@ import { Context, Effect, FileSystem, Option } from "effect"
import { ServerConnection } from "../../services/server-connection"
import { Updater } from "../../services/updater"
import { UpdatePreflight } from "../../services/update-preflight"
import { Npm } from "@opencode-ai/core/npm"
import { Npm } from "@opencode-ai/util/npm"
export default Runtime.handler(Commands, (input) =>
Effect.gen(function* () {

View file

@ -3,7 +3,7 @@ import path from "node:path"
import { readFile, stat, writeFile } from "node:fs/promises"
import { Effect, Option } from "effect"
import { applyEdits, modify } from "jsonc-parser"
import { Global } from "@opencode-ai/core/global"
import { Global } from "@opencode-ai/util/global"
import { Commands } from "../../commands"
import { Runtime } from "../../../framework/runtime"

View file

@ -1,6 +1,6 @@
export * as Config from "./config"
import { Global } from "@opencode-ai/core/global"
import { Global } from "@opencode-ai/util/global"
import { Context, Effect, FileSystem, Layer, Option, Schema, Semaphore } from "effect"
import { produce, type Draft } from "immer"
import { applyEdits, modify, parse, type ParseError } from "jsonc-parser"

View file

@ -1,10 +1,10 @@
import { Effect, FileSystem, Scope } from "effect"
import { Command } from "effect/unstable/cli"
import { Spec } from "./spec"
import { Global } from "@opencode-ai/core/global"
import { Global } from "@opencode-ai/util/global"
import { Updater } from "../services/updater"
import { Config } from "../config"
import { Npm } from "@opencode-ai/core/npm"
import { Npm } from "@opencode-ai/util/npm"
export type Input<Value> =
Value extends Spec.Node<infer _Name, infer Command, infer _Commands>

View file

@ -4,14 +4,14 @@ import { NodeRuntime, NodeServices } from "@effect/platform-node"
import { Effect } from "effect"
import { Commands } from "./commands/commands"
import { Runtime } from "./framework/runtime"
import { Observability } from "@opencode-ai/core/observability"
import { Observability } from "@opencode-ai/util/observability"
import { Updater } from "./services/updater"
import { InstallationChannel, InstallationVersion, InstallationLocal } from "@opencode-ai/core/installation/version"
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
import { Global } from "@opencode-ai/core/global"
import { AppProcess } from "@opencode-ai/core/process"
import { InstallationChannel, InstallationVersion, InstallationLocal } from "@opencode-ai/util/installation/version"
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
import { Global } from "@opencode-ai/util/global"
import { AppProcess } from "@opencode-ai/util/process"
import { Config } from "./config"
import { Npm } from "@opencode-ai/core/npm"
import { Npm } from "@opencode-ai/util/npm"
const Handlers = Runtime.handlers(Commands, {
$: () => import("./commands/handlers/default"),

View file

@ -1,6 +1,6 @@
import type { MiniFrontendInput } from "@opencode-ai/tui/mini"
import { createModelPreferenceRepository } from "@opencode-ai/tui/model-preference"
import { Global } from "@opencode-ai/core/global"
import { Global } from "@opencode-ai/util/global"
import fs from "node:fs"
import { readFile } from "node:fs/promises"
import path from "node:path"

View file

@ -1,6 +1,6 @@
import { Service, type Endpoint } from "@opencode-ai/client/effect/service"
import { OpenCode, type OpenCodeClient, type SessionMessageAssistantTool } from "@opencode-ai/client/promise"
import { FSUtil } from "@opencode-ai/core/fs-util"
import { FSUtil } from "@opencode-ai/util/fs-util"
import { open } from "node:fs/promises"
import path from "node:path"
import { readStdin } from "../util/io"

View file

@ -2,10 +2,10 @@ export * as ServerProcess from "./server-process"
import { NodeServices } from "@effect/platform-node"
import { Service, type DiscoverOptions, type Info } from "@opencode-ai/client/effect/service"
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
import { Global } from "@opencode-ai/core/global"
import { InstallationVersion } from "@opencode-ai/core/installation/version"
import { AppProcess } from "@opencode-ai/core/process"
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
import { Global } from "@opencode-ai/util/global"
import { InstallationVersion } from "@opencode-ai/util/installation/version"
import { AppProcess } from "@opencode-ai/util/process"
import { randomBytes, randomUUID } from "node:crypto"
import path from "node:path"
import { Effect, FileSystem, Logger, Option, Redacted, Schedule, Schema } from "effect"

View file

@ -1,6 +1,6 @@
import { Service, type Endpoint, type EnsureOptions } from "@opencode-ai/client/effect/service"
import { ClientError, isUnauthorizedError, OpenCode } from "@opencode-ai/client/promise"
import { InstallationVersion } from "@opencode-ai/core/installation/version"
import { InstallationVersion } from "@opencode-ai/util/installation/version"
import { Effect, Redacted } from "effect"
import { Env } from "../env"
import { ServiceConfig } from "./service-config"

View file

@ -1,6 +1,6 @@
import { Global } from "@opencode-ai/core/global"
import { InstallationChannel, InstallationVersion } from "@opencode-ai/core/installation/version"
import { Hash } from "@opencode-ai/core/util/hash"
import { Global } from "@opencode-ai/util/global"
import { InstallationChannel, InstallationVersion } from "@opencode-ai/util/installation/version"
import { Hash } from "@opencode-ai/util/hash"
import { Service } from "@opencode-ai/client/effect/service"
import { Effect, FileSystem, Option, Schema } from "effect"
import { randomBytes } from "crypto"
@ -25,17 +25,21 @@ const decodeInfo = Schema.decodeUnknownEffect(Schema.fromJsonString(Info))
const decodeRegistration = Schema.decodeUnknownEffect(Schema.fromJsonString(Service.Info))
export function filename(channel = InstallationChannel) {
if (channel === "latest") return "service.json"
if (channel === "local") return "service-local.json"
return `service-${Hash.fast(channel)}.json`
if (channel === "latest" || channel === "next") return "service.json"
return `service-${channel.replace(/[^a-zA-Z0-9._-]/g, "-")}.json`
}
export function defaultPort(channel = InstallationChannel) {
if (channel === "latest") return 0xc0de
if (channel === "latest" || channel === "next") return 0xc0de
if (channel === "local") return 0xc0df
return 10_000 + (Number.parseInt(Hash.fast(channel).slice(0, 8), 16) % 50_000)
}
export function legacyFilename(channel = InstallationChannel) {
if (channel === "latest" || channel === "local") return
return `service-${Hash.fast(channel)}.json`
}
export function versionBelongsToChannel(
version: string | undefined,
channel = InstallationChannel,
@ -54,7 +58,6 @@ export const migrateRegistration = Effect.fnUntraced(function* (
channel = InstallationChannel,
installedVersion = InstallationVersion,
) {
if (channel === "latest" || channel === "local") return
const fs = yield* FileSystem.FileSystem
const text = yield* fs.readFileString(legacy).pipe(Effect.option)
if (Option.isNone(text)) return
@ -64,6 +67,14 @@ export const migrateRegistration = Effect.fnUntraced(function* (
yield* fs.writeFileString(file, text.value, { flag: "wx", mode: 0o600 }).pipe(Effect.ignore)
})
export const migrateConfig = Effect.fnUntraced(function* (legacy: string, file: string) {
const fs = yield* FileSystem.FileSystem
const text = yield* fs.readFileString(legacy).pipe(Effect.option)
if (Option.isNone(text)) return
if (Option.isNone(yield* decodeInfo(text.value).pipe(Effect.option))) return
yield* fs.writeFileString(file, text.value, { flag: "wx", mode: 0o600 }).pipe(Effect.ignore)
})
function configKey(key: string): Key {
if (key === "hostname" || key === "port" || key === "password") return key
throw new Error(`Unknown service config key: ${key}`)
@ -73,18 +84,23 @@ const paths = Effect.gen(function* () {
const fs = yield* FileSystem.FileSystem
const global = yield* Global.Service
const name = filename()
const legacy = legacyFilename()
const file = path.join(global.state, name)
return {
fs,
file,
legacyFile: path.join(global.state, "service.json"),
legacyConfigFile: legacy ? path.join(global.config, legacy) : undefined,
legacyRegistrationFiles: [
...(legacy ? [path.join(global.state, legacy)] : []),
...(name !== "service.json" && InstallationChannel !== "local" ? [path.join(global.state, "service.json")] : []),
],
configFile: path.join(global.config, name),
}
})
export const options = Effect.fnUntraced(function* () {
const { file, legacyFile } = yield* paths
yield* migrateRegistration(legacyFile, file)
const { file, legacyRegistrationFiles } = yield* paths
yield* Effect.forEach(legacyRegistrationFiles, (legacy) => migrateRegistration(legacy, file))
return {
file,
version: InstallationVersion,
@ -93,7 +109,8 @@ export const options = Effect.fnUntraced(function* () {
})
export const read = Effect.fn("cli.service-config.read")(function* () {
const { fs, configFile } = yield* paths
const { fs, configFile, legacyConfigFile } = yield* paths
if (legacyConfigFile) yield* migrateConfig(legacyConfigFile, configFile)
return yield* fs.readFileString(configFile).pipe(
Effect.flatMap(decodeInfo),
Effect.catch(() => Effect.succeed({} as Info)),

View file

@ -1,6 +1,6 @@
import { Service, type Endpoint } from "@opencode-ai/client/effect/service"
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
import { CrossSpawnSpawner } from "@opencode-ai/util/cross-spawn-spawner"
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
import { Deferred, Effect, Schema, Stream } from "effect"
import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"
import { randomBytes } from "node:crypto"

View file

@ -3,7 +3,7 @@
// version-mismatched background service before the TUI attaches.
import { createCliRenderer, RGBA, TextAttributes, type CliRenderer, type ThemeMode } from "@opentui/core"
import { render, useTerminalDimensions } from "@opentui/solid"
import { InstallationVersion } from "@opencode-ai/core/installation/version"
import { InstallationVersion } from "@opencode-ai/util/installation/version"
import { registerOpencodeSpinner } from "@opencode-ai/tui/component/register-spinner"
import { SPINNER_FRAMES } from "@opencode-ai/tui/component/spinner"
import { go } from "@opencode-ai/tui/logo"

View file

@ -1,10 +1,10 @@
import { Global } from "@opencode-ai/core/global"
import { AppProcess } from "@opencode-ai/core/process"
import { Global } from "@opencode-ai/util/global"
import { AppProcess } from "@opencode-ai/util/process"
import {
InstallationChannel,
InstallationLocal,
InstallationVersion,
} from "@opencode-ai/core/installation/version"
} from "@opencode-ai/util/installation/version"
import { Context, Duration, Effect, FileSystem, Layer } from "effect"
import { ChildProcess } from "effect/unstable/process"
import { parse, type ParseError } from "jsonc-parser"

View file

@ -1,5 +1,5 @@
import { NodeFileSystem } from "@effect/platform-node"
import { Global } from "@opencode-ai/core/global"
import { Global } from "@opencode-ai/util/global"
import { Effect } from "effect"
import { expect, test } from "bun:test"
import path from "path"

View file

@ -5,6 +5,16 @@ import path from "node:path"
const root = path.resolve(import.meta.dir, "../../..")
describe("CLI frontend import boundaries", () => {
test("does not import Core directly", async () => {
const glob = new Bun.Glob("{src,test}/**/*.{ts,tsx}")
const imports: string[] = []
for await (const file of glob.scan({ cwd: path.join(root, "packages/cli") })) {
const source = await Bun.file(path.join(root, "packages/cli", file)).text()
if (/["']@opencode-ai\/core(?:\/[^"']*)?["']/.test(source)) imports.push(file)
}
expect(imports).toEqual([])
})
test("exposes only the intentional package entrypoints", async () => {
const run = await import("@opencode-ai/cli/run")
const mini = await import("@opencode-ai/tui/mini")

View file

@ -1,6 +1,6 @@
import { describe, expect, test } from "bun:test"
import { ClientError, OpenCode } from "@opencode-ai/client/promise"
import { InstallationVersion } from "@opencode-ai/core/installation/version"
import { InstallationVersion } from "@opencode-ai/util/installation/version"
import path from "node:path"
import { createMiniConnection, mergeInput as mergeInteractiveInput, resolveMiniTarget } from "../src/mini"
import { mergeInput as mergeNonInteractiveInput, parseRunModel } from "../src/run/run"

View file

@ -1,6 +1,6 @@
import { NodeFileSystem } from "@effect/platform-node"
import { Global } from "@opencode-ai/core/global"
import { InstallationVersion } from "@opencode-ai/core/installation/version"
import { Global } from "@opencode-ai/util/global"
import { InstallationVersion } from "@opencode-ai/util/installation/version"
import { expect, test } from "bun:test"
import { Effect, FileSystem, Scope } from "effect"
import fs from "node:fs/promises"

View file

@ -1,18 +1,9 @@
import { NodeFileSystem } from "@effect/platform-node"
import { Service, type Info } from "@opencode-ai/client/effect/service"
import { Database } from "@opencode-ai/core/database/database"
import { EventV2 } from "@opencode-ai/core/event"
import { EventTable } from "@opencode-ai/core/event/sql"
import { Global } from "@opencode-ai/core/global"
import { InstallationVersion } from "@opencode-ai/core/installation/version"
import { Project } from "@opencode-ai/core/project"
import { ProjectTable } from "@opencode-ai/core/project/sql"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { SessionV2 } from "@opencode-ai/core/session"
import { SessionEvent } from "@opencode-ai/core/session/event"
import { SessionTable } from "@opencode-ai/core/session/sql"
import { Global } from "@opencode-ai/util/global"
import { InstallationVersion } from "@opencode-ai/util/installation/version"
import { expect, test } from "bun:test"
import { Effect, Schedule, Schema } from "effect"
import { Effect, Schema } from "effect"
import fs from "node:fs/promises"
import os from "node:os"
import path from "node:path"
@ -20,6 +11,7 @@ import { ServiceConfig } from "../src/services/service-config"
test("managed service ports are stable per installation channel", () => {
expect(ServiceConfig.defaultPort("latest")).toBe(0xc0de)
expect(ServiceConfig.defaultPort("next")).toBe(0xc0de)
expect(ServiceConfig.defaultPort("local")).toBe(0xc0df)
expect(ServiceConfig.defaultPort("preview-a")).toBe(ServiceConfig.defaultPort("preview-a"))
expect(ServiceConfig.defaultPort("preview-a")).not.toBe(ServiceConfig.defaultPort("preview-b"))
@ -43,17 +35,35 @@ test("local channel stores service config with the local service filename", asyn
}
})
test("service filenames isolate installation channels", () => {
test("service filenames share release channels and identify preview channels", () => {
expect(ServiceConfig.filename("latest")).toBe("service.json")
expect(ServiceConfig.filename("next")).toBe("service.json")
expect(ServiceConfig.filename("local")).toBe("service-local.json")
expect(ServiceConfig.filename("preview-a")).not.toBe(ServiceConfig.filename("preview-b"))
expect(ServiceConfig.filename("preview-a")).not.toBe(ServiceConfig.filename("latest"))
expect(ServiceConfig.filename("preview-a")).toBe("service-preview-a.json")
expect(ServiceConfig.filename("preview/a")).toBe("service-preview-a.json")
expect(ServiceConfig.versionBelongsToChannel("0.0.0-preview-a-1234", "preview-a")).toBe(true)
expect(ServiceConfig.versionBelongsToChannel("0.0.0-preview-a-1234.2", "preview-a")).toBe(true)
expect(ServiceConfig.versionBelongsToChannel("0.0.0-preview-a-other-1234", "preview-a")).toBe(false)
expect(ServiceConfig.versionBelongsToChannel("1.2.3", "preview-a")).toBe(false)
})
test("service config migrates from the hashed channel filename", async () => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-service-config-migration-"))
const legacy = path.join(root, ServiceConfig.legacyFilename("preview-a")!)
const target = path.join(root, ServiceConfig.filename("preview-a"))
try {
await fs.writeFile(legacy, JSON.stringify({ hostname: "127.0.0.2", port: 4098 }))
await Effect.runPromise(ServiceConfig.migrateConfig(legacy, target).pipe(Effect.provide(NodeFileSystem.layer)))
expect(await Bun.file(target).json()).toEqual({ hostname: "127.0.0.2", port: 4098 })
await fs.writeFile(target, JSON.stringify({ port: 4099 }))
await Effect.runPromise(ServiceConfig.migrateConfig(legacy, target).pipe(Effect.provide(NodeFileSystem.layer)))
expect(await Bun.file(target).json()).toEqual({ port: 4099 })
} finally {
await fs.rm(root, { recursive: true, force: true })
}
})
test("preview registration migration never moves stable discovery", async () => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-service-migration-"))
const legacy = path.join(root, "service.json")
@ -185,31 +195,6 @@ test("concurrent service processes elect one server", async () => {
XDG_DATA_HOME: path.join(root, "data"),
XDG_STATE_HOME: path.join(root, "state"),
}
const sessionID = SessionV2.ID.make("ses_service_recovery")
await withDatabase(
database,
Effect.gen(function* () {
const { db } = yield* Database.Service
yield* db
.insert(ProjectTable)
.values({ id: Project.ID.global, worktree: AbsolutePath.make(root), sandboxes: [] })
.run()
.pipe(Effect.orDie)
yield* db
.insert(SessionTable)
.values({
id: sessionID,
project_id: Project.ID.global,
slug: "recovery",
directory: root,
title: "recovery",
version: "test",
time_suspended: Date.now(),
})
.run()
.pipe(Effect.orDie)
}),
)
const command = [process.execPath, path.join(import.meta.dir, "../src/index.ts"), "serve", "--service"]
const registration = path.join(root, "state", "opencode", "service-local.json")
const port = await availablePort()
@ -262,20 +247,6 @@ test("concurrent service processes elect one server", async () => {
contender.kill("SIGTERM")
await contender.exited
}
expect(
await withDatabase(
database,
Effect.gen(function* () {
const { db } = yield* Database.Service
return yield* db
.select({ timeSuspended: SessionTable.time_suspended })
.from(SessionTable)
.get()
.pipe(Effect.orDie)
}),
),
).toEqual({ timeSuspended: null })
expect(await waitForExecutionStart(database, sessionID)).toBe(1)
await Effect.runPromise(Service.stop({ file: registration }).pipe(Effect.provide(NodeFileSystem.layer)))
await winner?.exited
expect(await Bun.file(registration).exists()).toBe(false)
@ -510,40 +481,6 @@ test("a failed service stays registered and owns the selected port until stopped
}
}, 30_000)
function withDatabase<A, E>(file: string, effect: Effect.Effect<A, E, Database.Service>) {
return Effect.runPromise(effect.pipe(Effect.provide(Database.layer({ path: file })), Effect.scoped))
}
function waitForExecutionStart(file: string, sessionID: SessionV2.ID) {
return withDatabase(
file,
Effect.gen(function* () {
const { db } = yield* Database.Service
return yield* db
.select({ id: EventTable.id, sessionID: EventTable.aggregate_id, type: EventTable.type })
.from(EventTable)
.all()
.pipe(
Effect.orDie,
Effect.map((rows) =>
rows.filter(
(row) =>
row.sessionID === sessionID &&
row.type ===
EventV2.versionedType(
SessionEvent.Execution.Started.type,
SessionEvent.Execution.Started.durable.version,
),
),
),
Effect.filterOrFail((rows) => rows.length > 0),
Effect.map((rows) => rows.length),
Effect.retry(Schedule.max([Schedule.spaced("50 millis"), Schedule.recurs(200)])),
)
}),
)
}
async function waitForInfo(file: string, accept: (info: Info) => boolean = () => true) {
for (let attempt = 0; attempt < 400; attempt++) {
const value = await Bun.file(file)