Files
oh-my-openagent/src/shared/migration/config-migration.ts
YeonGyu-Kim 441fda9177 fix: migrate config on deep copy, apply to rawConfig only on successful file write (#1660)
Previously, migrateConfigFile() mutated rawConfig directly. If the file
write failed (e.g. read-only file, permissions), the in-memory config was
already changed to the migrated values, causing the plugin to use migrated
models even though the user's file was untouched. On the next run, the
migration would fire again since _migrations was never persisted.

Now all mutations happen on a structuredClone copy. The original rawConfig
is only updated after the file write succeeds. If the write fails,
rawConfig stays untouched and the function returns false.
2026-02-08 19:33:26 +09:00

127 lines
3.9 KiB
TypeScript

import * as fs from "fs"
import { log } from "../logger"
import { AGENT_NAME_MAP, migrateAgentNames } from "./agent-names"
import { migrateHookNames } from "./hook-names"
import { migrateModelVersions } from "./model-versions"
export function migrateConfigFile(
configPath: string,
rawConfig: Record<string, unknown>
): boolean {
// Work on a deep copy — only apply changes to rawConfig if file write succeeds
const copy = structuredClone(rawConfig)
let needsWrite = false
// Load previously applied migrations
const existingMigrations = Array.isArray(copy._migrations)
? new Set(copy._migrations as string[])
: new Set<string>()
const allNewMigrations: string[] = []
if (copy.agents && typeof copy.agents === "object") {
const { migrated, changed } = migrateAgentNames(copy.agents as Record<string, unknown>)
if (changed) {
copy.agents = migrated
needsWrite = true
}
}
// Migrate model versions in agents (skip already-applied migrations)
if (copy.agents && typeof copy.agents === "object") {
const { migrated, changed, newMigrations } = migrateModelVersions(
copy.agents as Record<string, unknown>,
existingMigrations
)
if (changed) {
copy.agents = migrated
needsWrite = true
log("Migrated model versions in agents config")
}
allNewMigrations.push(...newMigrations)
}
// Migrate model versions in categories (skip already-applied migrations)
if (copy.categories && typeof copy.categories === "object") {
const { migrated, changed, newMigrations } = migrateModelVersions(
copy.categories as Record<string, unknown>,
existingMigrations
)
if (changed) {
copy.categories = migrated
needsWrite = true
log("Migrated model versions in categories config")
}
allNewMigrations.push(...newMigrations)
}
// Record newly applied migrations
if (allNewMigrations.length > 0) {
const updatedMigrations = Array.from(existingMigrations)
updatedMigrations.push(...allNewMigrations)
copy._migrations = updatedMigrations
needsWrite = true
}
if (copy.omo_agent) {
copy.sisyphus_agent = copy.omo_agent
delete copy.omo_agent
needsWrite = true
}
if (copy.disabled_agents && Array.isArray(copy.disabled_agents)) {
const migrated: string[] = []
let changed = false
for (const agent of copy.disabled_agents as string[]) {
const newAgent = AGENT_NAME_MAP[agent.toLowerCase()] ?? AGENT_NAME_MAP[agent] ?? agent
if (newAgent !== agent) {
changed = true
}
migrated.push(newAgent)
}
if (changed) {
copy.disabled_agents = migrated
needsWrite = true
}
}
if (copy.disabled_hooks && Array.isArray(copy.disabled_hooks)) {
const { migrated, changed, removed } = migrateHookNames(copy.disabled_hooks as string[])
if (changed) {
copy.disabled_hooks = migrated
needsWrite = true
}
if (removed.length > 0) {
log(
`Removed obsolete hooks from disabled_hooks: ${removed.join(", ")} (these hooks no longer exist in v3.0.0)`
)
}
}
if (needsWrite) {
try {
const timestamp = new Date().toISOString().replace(/[:.]/g, "-")
const backupPath = `${configPath}.bak.${timestamp}`
try {
fs.copyFileSync(configPath, backupPath)
} catch {
// Original file may not exist yet — skip backup
}
fs.writeFileSync(configPath, JSON.stringify(copy, null, 2) + "\n", "utf-8")
log(`Migrated config file: ${configPath} (backup: ${backupPath})`)
} catch (err) {
log(`Failed to write migrated config to ${configPath}:`, err)
// File write failed — rawConfig is untouched, preserving user's original values
return false
}
// File write succeeded — apply changes to the original rawConfig
for (const key of Object.keys(rawConfig)) {
delete rawConfig[key]
}
Object.assign(rawConfig, copy)
}
return needsWrite
}