feat(settings): move config/state to owner buckets
Add generic /api/storage config/state endpoints with merge-patch, migrate legacy YAML/JSON layout, and update UI/server to read and write owner-scoped settings. Replace config SSE events and drop /api/config routes.
This commit is contained in:
55
packages/server/src/settings/binaries.ts
Normal file
55
packages/server/src/settings/binaries.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
import type { SettingsService } from "./service"
|
||||
|
||||
export interface OpenCodeBinaryEntry {
|
||||
path: string
|
||||
version?: string
|
||||
lastUsed?: number
|
||||
label?: string
|
||||
}
|
||||
|
||||
export interface ResolvedBinary {
|
||||
path: string
|
||||
label: string
|
||||
version?: string
|
||||
}
|
||||
|
||||
function prettyLabel(p: string): string {
|
||||
const parts = p.split(/[\\/]/)
|
||||
const last = parts[parts.length - 1] || p
|
||||
return last || p
|
||||
}
|
||||
|
||||
function readUiBinaries(settings: SettingsService): OpenCodeBinaryEntry[] {
|
||||
const ui = settings.getOwner("state", "ui")
|
||||
const list = (ui as any)?.opencodeBinaries
|
||||
if (!Array.isArray(list)) return []
|
||||
return list.filter((item) => item && typeof item === "object" && typeof (item as any).path === "string") as any
|
||||
}
|
||||
|
||||
function readDefaultBinaryPath(settings: SettingsService): string | undefined {
|
||||
const server = settings.getOwner("config", "server")
|
||||
const value = (server as any)?.opencodeBinary
|
||||
return typeof value === "string" && value.trim().length > 0 ? value.trim() : undefined
|
||||
}
|
||||
|
||||
export class BinaryResolver {
|
||||
constructor(private readonly settings: SettingsService) {}
|
||||
|
||||
list(): OpenCodeBinaryEntry[] {
|
||||
return readUiBinaries(this.settings)
|
||||
}
|
||||
|
||||
resolveDefault(): ResolvedBinary {
|
||||
const binaries = this.list()
|
||||
const configuredDefault = readDefaultBinaryPath(this.settings)
|
||||
const fallback = binaries[0]?.path
|
||||
const path = configuredDefault ?? fallback ?? "opencode"
|
||||
|
||||
const entry = binaries.find((b) => b.path === path)
|
||||
return {
|
||||
path,
|
||||
label: entry?.label ?? prettyLabel(path),
|
||||
version: entry?.version,
|
||||
}
|
||||
}
|
||||
}
|
||||
39
packages/server/src/settings/merge-patch.ts
Normal file
39
packages/server/src/settings/merge-patch.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
type PlainObject = Record<string, unknown>
|
||||
|
||||
export function isPlainObject(value: unknown): value is PlainObject {
|
||||
if (!value || typeof value !== "object") return false
|
||||
if (Array.isArray(value)) return false
|
||||
const proto = Object.getPrototypeOf(value)
|
||||
return proto === Object.prototype || proto === null
|
||||
}
|
||||
|
||||
/**
|
||||
* RFC 7396-ish merge patch with explicit null deletes.
|
||||
* - Objects merge recursively
|
||||
* - Arrays/scalars replace
|
||||
* - null deletes keys
|
||||
*/
|
||||
export function applyMergePatch(current: unknown, patch: unknown): unknown {
|
||||
if (!isPlainObject(patch)) {
|
||||
return patch
|
||||
}
|
||||
|
||||
const base: PlainObject = isPlainObject(current) ? { ...(current as PlainObject) } : {}
|
||||
|
||||
for (const [key, value] of Object.entries(patch)) {
|
||||
if (value === null) {
|
||||
delete base[key]
|
||||
continue
|
||||
}
|
||||
|
||||
const existing = base[key]
|
||||
if (isPlainObject(value) && isPlainObject(existing)) {
|
||||
base[key] = applyMergePatch(existing, value)
|
||||
continue
|
||||
}
|
||||
|
||||
base[key] = value
|
||||
}
|
||||
|
||||
return base
|
||||
}
|
||||
269
packages/server/src/settings/migrate.ts
Normal file
269
packages/server/src/settings/migrate.ts
Normal file
@@ -0,0 +1,269 @@
|
||||
import fs from "fs"
|
||||
import path from "path"
|
||||
import { parse as parseYaml, stringify as stringifyYaml } from "yaml"
|
||||
import type { Logger } from "../logger"
|
||||
import type { ConfigLocation } from "../config/location"
|
||||
import { isPlainObject } from "./merge-patch"
|
||||
|
||||
type Doc = Record<string, unknown>
|
||||
|
||||
function ensureTrailingNewline(content: string): string {
|
||||
if (!content) return "\n"
|
||||
return content.endsWith("\n") ? content : `${content}\n`
|
||||
}
|
||||
|
||||
function safeReadYaml(filePath: string, logger: Logger): unknown {
|
||||
try {
|
||||
const content = fs.readFileSync(filePath, "utf-8")
|
||||
return parseYaml(content)
|
||||
} catch (error) {
|
||||
logger.warn({ err: error, filePath }, "Failed to read YAML file during migration")
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function safeReadJson(filePath: string, logger: Logger): unknown {
|
||||
try {
|
||||
const content = fs.readFileSync(filePath, "utf-8")
|
||||
return JSON.parse(content)
|
||||
} catch (error) {
|
||||
logger.warn({ err: error, filePath }, "Failed to read JSON file during migration")
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function writeYaml(filePath: string, doc: Doc, logger: Logger) {
|
||||
try {
|
||||
fs.mkdirSync(path.dirname(filePath), { recursive: true })
|
||||
const yaml = stringifyYaml(doc as any)
|
||||
fs.writeFileSync(filePath, ensureTrailingNewline(yaml), "utf-8")
|
||||
} catch (error) {
|
||||
logger.warn({ err: error, filePath }, "Failed to write YAML file during migration")
|
||||
}
|
||||
}
|
||||
|
||||
function pickBackupPath(filePath: string): string {
|
||||
const preferred = `${filePath}.bak`
|
||||
if (!fs.existsSync(preferred)) {
|
||||
return preferred
|
||||
}
|
||||
return `${filePath}.bak.${Date.now()}`
|
||||
}
|
||||
|
||||
function normalizeDoc(value: unknown): Doc {
|
||||
return isPlainObject(value) ? (value as Doc) : {}
|
||||
}
|
||||
|
||||
function looksLikeNewOwnerDoc(value: unknown): boolean {
|
||||
const doc = normalizeDoc(value)
|
||||
// Heuristic: owner-bucket docs have at least one of these roots.
|
||||
return Boolean(doc.ui || doc.server || doc.app || doc.legacy)
|
||||
}
|
||||
|
||||
function looksLikeLegacyConfig(value: unknown): boolean {
|
||||
const doc = normalizeDoc(value)
|
||||
return Boolean(doc.preferences || doc.opencodeBinaries || doc.theme || doc.recentFolders)
|
||||
}
|
||||
|
||||
function looksLikeLegacyState(value: unknown): boolean {
|
||||
const doc = normalizeDoc(value)
|
||||
return Boolean(doc.recentFolders)
|
||||
}
|
||||
|
||||
function omitKeys(source: Doc, keys: Set<string>): Doc {
|
||||
const out: Doc = {}
|
||||
for (const [k, v] of Object.entries(source)) {
|
||||
if (keys.has(k)) continue
|
||||
out[k] = v
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
function mapLegacyToOwnerDocs(legacyConfig: unknown, legacyState: unknown): { config: Doc; state: Doc } {
|
||||
const cfg = normalizeDoc(legacyConfig)
|
||||
const st = normalizeDoc(legacyState)
|
||||
|
||||
const outConfig: Doc = {}
|
||||
const outState: Doc = {}
|
||||
|
||||
const uiConfig: Doc = {}
|
||||
const uiSettings: Doc = {}
|
||||
const serverConfig: Doc = {}
|
||||
const uiState: Doc = {}
|
||||
|
||||
// theme -> config.ui.theme
|
||||
if (typeof cfg.theme === "string") {
|
||||
uiConfig.theme = cfg.theme
|
||||
}
|
||||
|
||||
const preferences = normalizeDoc(cfg.preferences)
|
||||
if (Object.keys(preferences).length > 0) {
|
||||
// Server-owned stable keys
|
||||
const envVars = preferences.environmentVariables
|
||||
if (isPlainObject(envVars)) {
|
||||
serverConfig.environmentVariables = envVars
|
||||
}
|
||||
const listeningMode = preferences.listeningMode
|
||||
if (typeof listeningMode === "string") {
|
||||
serverConfig.listeningMode = listeningMode
|
||||
}
|
||||
const lastUsedBinary = preferences.lastUsedBinary
|
||||
if (typeof lastUsedBinary === "string") {
|
||||
serverConfig.opencodeBinary = lastUsedBinary
|
||||
}
|
||||
|
||||
// UI-owned state keys (drop preferences)
|
||||
const modelRecents = preferences.modelRecents
|
||||
const modelFavorites = preferences.modelFavorites
|
||||
const modelThinkingSelections = preferences.modelThinkingSelections
|
||||
|
||||
const models: Doc = {}
|
||||
if (Array.isArray(modelRecents)) {
|
||||
models.recents = modelRecents
|
||||
}
|
||||
if (Array.isArray(modelFavorites)) {
|
||||
models.favorites = modelFavorites
|
||||
}
|
||||
if (isPlainObject(modelThinkingSelections)) {
|
||||
models.thinkingSelections = modelThinkingSelections
|
||||
}
|
||||
if (Object.keys(models).length > 0) {
|
||||
uiState.models = models
|
||||
}
|
||||
|
||||
// Remaining preferences are treated as stable UI settings.
|
||||
const moved = new Set([
|
||||
"environmentVariables",
|
||||
"listeningMode",
|
||||
"lastUsedBinary",
|
||||
"modelRecents",
|
||||
"modelFavorites",
|
||||
"modelThinkingSelections",
|
||||
])
|
||||
Object.assign(uiSettings, omitKeys(preferences, moved))
|
||||
}
|
||||
|
||||
// recentFolders lives in legacy state (yaml) or legacy config.json
|
||||
const recentFolders = (st.recentFolders ?? cfg.recentFolders) as unknown
|
||||
if (Array.isArray(recentFolders)) {
|
||||
uiState.recentFolders = recentFolders
|
||||
}
|
||||
|
||||
// opencodeBinaries -> state.ui.opencodeBinaries
|
||||
if (Array.isArray(cfg.opencodeBinaries)) {
|
||||
uiState.opencodeBinaries = cfg.opencodeBinaries
|
||||
}
|
||||
|
||||
if (Object.keys(uiSettings).length > 0) {
|
||||
uiConfig.settings = uiSettings
|
||||
}
|
||||
|
||||
if (Object.keys(uiConfig).length > 0) {
|
||||
outConfig.ui = uiConfig
|
||||
}
|
||||
if (Object.keys(serverConfig).length > 0) {
|
||||
outConfig.server = serverConfig
|
||||
}
|
||||
if (Object.keys(uiState).length > 0) {
|
||||
outState.ui = uiState
|
||||
}
|
||||
|
||||
// Unknown top-level keys -> legacy.unknown
|
||||
const knownConfigKeys = new Set(["preferences", "opencodeBinaries", "theme", "recentFolders"])
|
||||
const unknownConfig = omitKeys(cfg, knownConfigKeys)
|
||||
if (Object.keys(unknownConfig).length > 0) {
|
||||
outConfig.legacy = { unknown: unknownConfig }
|
||||
}
|
||||
|
||||
const knownStateKeys = new Set(["recentFolders"])
|
||||
const unknownState = omitKeys(st, knownStateKeys)
|
||||
if (Object.keys(unknownState).length > 0) {
|
||||
outState.legacy = { unknown: unknownState }
|
||||
}
|
||||
|
||||
return { config: outConfig, state: outState }
|
||||
}
|
||||
|
||||
/**
|
||||
* Migrate older config/state layouts into owner-bucket YAML docs.
|
||||
*
|
||||
* Legacy inputs supported:
|
||||
* - config.yaml with { preferences, opencodeBinaries, theme }
|
||||
* - state.yaml with { recentFolders }
|
||||
* - legacy config.json with full ConfigFile schema
|
||||
*/
|
||||
export function migrateSettingsLayout(location: ConfigLocation, logger: Logger) {
|
||||
const configYamlPath = location.configYamlPath
|
||||
const stateYamlPath = location.stateYamlPath
|
||||
const legacyJsonPath = location.legacyJsonPath
|
||||
|
||||
const configExists = fs.existsSync(configYamlPath)
|
||||
const stateExists = fs.existsSync(stateYamlPath)
|
||||
|
||||
const configDoc = configExists ? safeReadYaml(configYamlPath, logger) : null
|
||||
const stateDoc = stateExists ? safeReadYaml(stateYamlPath, logger) : null
|
||||
|
||||
const configIsNew = configExists && looksLikeNewOwnerDoc(configDoc) && !looksLikeLegacyConfig(configDoc)
|
||||
const stateIsNew = stateExists && looksLikeNewOwnerDoc(stateDoc) && !looksLikeLegacyState(stateDoc)
|
||||
|
||||
if (configIsNew && stateIsNew) {
|
||||
return
|
||||
}
|
||||
|
||||
const legacyJsonExists = fs.existsSync(legacyJsonPath)
|
||||
|
||||
const hasLegacyYaml = (configExists && looksLikeLegacyConfig(configDoc)) || (stateExists && looksLikeLegacyState(stateDoc))
|
||||
const shouldMigrateFromJson = !configExists && legacyJsonExists
|
||||
|
||||
if (!hasLegacyYaml && !shouldMigrateFromJson) {
|
||||
// Either fresh install or partially written docs; let stores create on first write.
|
||||
return
|
||||
}
|
||||
|
||||
const sourceConfig = shouldMigrateFromJson ? safeReadJson(legacyJsonPath, logger) : configDoc
|
||||
const sourceState = shouldMigrateFromJson ? sourceConfig : stateDoc
|
||||
|
||||
const { config, state } = mapLegacyToOwnerDocs(sourceConfig, sourceState)
|
||||
|
||||
try {
|
||||
fs.mkdirSync(location.baseDir, { recursive: true })
|
||||
} catch (error) {
|
||||
logger.warn({ err: error, baseDir: location.baseDir }, "Failed to create base directory during migration")
|
||||
}
|
||||
|
||||
// Backup legacy files before rewriting.
|
||||
if (configExists) {
|
||||
try {
|
||||
const bak = pickBackupPath(configYamlPath)
|
||||
fs.renameSync(configYamlPath, bak)
|
||||
logger.info({ configYamlPath, bak }, "Backed up legacy config.yaml")
|
||||
} catch (error) {
|
||||
logger.warn({ err: error, configYamlPath }, "Failed to backup legacy config.yaml")
|
||||
}
|
||||
}
|
||||
|
||||
if (stateExists) {
|
||||
try {
|
||||
const bak = pickBackupPath(stateYamlPath)
|
||||
fs.renameSync(stateYamlPath, bak)
|
||||
logger.info({ stateYamlPath, bak }, "Backed up legacy state.yaml")
|
||||
} catch (error) {
|
||||
logger.warn({ err: error, stateYamlPath }, "Failed to backup legacy state.yaml")
|
||||
}
|
||||
}
|
||||
|
||||
if (shouldMigrateFromJson) {
|
||||
try {
|
||||
const bak = pickBackupPath(legacyJsonPath)
|
||||
fs.renameSync(legacyJsonPath, bak)
|
||||
logger.info({ legacyJsonPath, bak }, "Moved legacy config.json to backup")
|
||||
} catch (error) {
|
||||
logger.warn({ err: error, legacyJsonPath }, "Failed to move legacy config.json to backup")
|
||||
}
|
||||
}
|
||||
|
||||
writeYaml(configYamlPath, config, logger)
|
||||
writeYaml(stateYamlPath, state, logger)
|
||||
|
||||
logger.info({ configYamlPath, stateYamlPath }, "Migrated settings docs to owner-bucket layout")
|
||||
}
|
||||
55
packages/server/src/settings/service.ts
Normal file
55
packages/server/src/settings/service.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
import type { Logger } from "../logger"
|
||||
import type { EventBus } from "../events/bus"
|
||||
import type { ConfigLocation } from "../config/location"
|
||||
import { YamlDocStore, type SettingsDoc } from "./yaml-doc-store"
|
||||
import { migrateSettingsLayout } from "./migrate"
|
||||
import type { WorkspaceEventPayload } from "../api-types"
|
||||
|
||||
export type DocKind = "config" | "state"
|
||||
|
||||
export class SettingsService {
|
||||
private readonly configStore: YamlDocStore
|
||||
private readonly stateStore: YamlDocStore
|
||||
|
||||
constructor(
|
||||
private readonly location: ConfigLocation,
|
||||
private readonly eventBus: EventBus | undefined,
|
||||
private readonly logger: Logger,
|
||||
) {
|
||||
migrateSettingsLayout(location, logger)
|
||||
this.configStore = new YamlDocStore(location.configYamlPath, logger.child({ component: "settings-config" }))
|
||||
this.stateStore = new YamlDocStore(location.stateYamlPath, logger.child({ component: "settings-state" }))
|
||||
}
|
||||
|
||||
getDoc(kind: DocKind): SettingsDoc {
|
||||
return kind === "config" ? this.configStore.get() : this.stateStore.get()
|
||||
}
|
||||
|
||||
mergePatchDoc(kind: DocKind, patch: unknown): SettingsDoc {
|
||||
const updated = kind === "config" ? this.configStore.mergePatch(patch) : this.stateStore.mergePatch(patch)
|
||||
this.publish(kind, "*")
|
||||
return updated
|
||||
}
|
||||
|
||||
getOwner(kind: DocKind, owner: string): SettingsDoc {
|
||||
return kind === "config" ? this.configStore.getOwner(owner) : this.stateStore.getOwner(owner)
|
||||
}
|
||||
|
||||
mergePatchOwner(kind: DocKind, owner: string, patch: unknown): SettingsDoc {
|
||||
const updated =
|
||||
kind === "config" ? this.configStore.mergePatchOwner(owner, patch) : this.stateStore.mergePatchOwner(owner, patch)
|
||||
this.publish(kind, owner, updated)
|
||||
return updated
|
||||
}
|
||||
|
||||
private publish(kind: DocKind, owner: string, value?: SettingsDoc) {
|
||||
if (!this.eventBus) return
|
||||
const type = kind === "config" ? "storage.configChanged" : "storage.stateChanged"
|
||||
const payload: WorkspaceEventPayload = {
|
||||
type,
|
||||
owner,
|
||||
value: value ?? this.getOwner(kind, owner),
|
||||
} as any
|
||||
this.eventBus.publish(payload)
|
||||
}
|
||||
}
|
||||
110
packages/server/src/settings/yaml-doc-store.ts
Normal file
110
packages/server/src/settings/yaml-doc-store.ts
Normal file
@@ -0,0 +1,110 @@
|
||||
import fs from "fs"
|
||||
import path from "path"
|
||||
import { parse as parseYaml, stringify as stringifyYaml } from "yaml"
|
||||
import type { Logger } from "../logger"
|
||||
import { applyMergePatch, isPlainObject } from "./merge-patch"
|
||||
|
||||
export type SettingsDoc = Record<string, unknown>
|
||||
|
||||
function ensureTrailingNewline(content: string): string {
|
||||
if (!content) return "\n"
|
||||
return content.endsWith("\n") ? content : `${content}\n`
|
||||
}
|
||||
|
||||
function normalizeDoc(input: unknown): SettingsDoc {
|
||||
if (!isPlainObject(input)) {
|
||||
return {}
|
||||
}
|
||||
return input
|
||||
}
|
||||
|
||||
export class YamlDocStore {
|
||||
private cache: SettingsDoc = {}
|
||||
private loaded = false
|
||||
|
||||
constructor(
|
||||
private readonly filePath: string,
|
||||
private readonly logger: Logger,
|
||||
) {}
|
||||
|
||||
load(): SettingsDoc {
|
||||
if (this.loaded) {
|
||||
return this.cache
|
||||
}
|
||||
|
||||
try {
|
||||
if (!fs.existsSync(this.filePath)) {
|
||||
this.cache = {}
|
||||
this.loaded = true
|
||||
return this.cache
|
||||
}
|
||||
|
||||
const content = fs.readFileSync(this.filePath, "utf-8")
|
||||
const parsed = parseYaml(content)
|
||||
this.cache = normalizeDoc(parsed)
|
||||
this.loaded = true
|
||||
return this.cache
|
||||
} catch (error) {
|
||||
this.logger.warn({ err: error, filePath: this.filePath }, "Failed to read YAML doc; using empty object")
|
||||
this.cache = {}
|
||||
this.loaded = true
|
||||
return this.cache
|
||||
}
|
||||
}
|
||||
|
||||
get(): SettingsDoc {
|
||||
return this.load()
|
||||
}
|
||||
|
||||
replace(next: unknown): SettingsDoc {
|
||||
const normalized = normalizeDoc(next)
|
||||
this.cache = normalized
|
||||
this.loaded = true
|
||||
this.persist()
|
||||
return this.cache
|
||||
}
|
||||
|
||||
mergePatch(patch: unknown): SettingsDoc {
|
||||
if (!isPlainObject(patch)) {
|
||||
throw new Error("Patch must be a JSON object")
|
||||
}
|
||||
const current = this.get()
|
||||
const next = applyMergePatch(current, patch)
|
||||
return this.replace(next)
|
||||
}
|
||||
|
||||
getOwner(owner: string): SettingsDoc {
|
||||
const doc = this.get()
|
||||
const value = (doc as any)?.[owner]
|
||||
return normalizeDoc(value)
|
||||
}
|
||||
|
||||
replaceOwner(owner: string, value: unknown): SettingsDoc {
|
||||
const doc = this.get()
|
||||
const nextDoc: SettingsDoc = { ...doc, [owner]: normalizeDoc(value) }
|
||||
this.replace(nextDoc)
|
||||
return nextDoc[owner] as SettingsDoc
|
||||
}
|
||||
|
||||
mergePatchOwner(owner: string, patch: unknown): SettingsDoc {
|
||||
if (!isPlainObject(patch)) {
|
||||
throw new Error("Patch must be a JSON object")
|
||||
}
|
||||
const doc = this.get()
|
||||
const currentOwner = normalizeDoc((doc as any)?.[owner])
|
||||
const nextOwner = normalizeDoc(applyMergePatch(currentOwner, patch))
|
||||
const nextDoc: SettingsDoc = { ...doc, [owner]: nextOwner }
|
||||
this.replace(nextDoc)
|
||||
return nextOwner
|
||||
}
|
||||
|
||||
private persist() {
|
||||
try {
|
||||
fs.mkdirSync(path.dirname(this.filePath), { recursive: true })
|
||||
const yaml = stringifyYaml(this.cache as any)
|
||||
fs.writeFileSync(this.filePath, ensureTrailingNewline(yaml), "utf-8")
|
||||
} catch (error) {
|
||||
this.logger.warn({ err: error, filePath: this.filePath }, "Failed to persist YAML doc")
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user