From 9418927162fcd1a19b2e91f6910295a08ce54c57 Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Wed, 1 Apr 2026 18:33:29 -0700 Subject: [PATCH] fix(config): make plugin entry migration atomic with temp-file + rename Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- .../migrate-legacy-plugin-entry.test.ts | 41 ++++++++++++++++++- src/shared/migrate-legacy-plugin-entry.ts | 12 +++++- 2 files changed, 50 insertions(+), 3 deletions(-) diff --git a/src/shared/migrate-legacy-plugin-entry.test.ts b/src/shared/migrate-legacy-plugin-entry.test.ts index 544e245bc..e43cfe809 100644 --- a/src/shared/migrate-legacy-plugin-entry.test.ts +++ b/src/shared/migrate-legacy-plugin-entry.test.ts @@ -1,4 +1,6 @@ -import { afterEach, beforeEach, describe, expect, it } from "bun:test" +/// + +import { afterEach, beforeEach, describe, expect, it, mock } from "bun:test" import { mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs" import { tmpdir } from "node:os" import { join } from "node:path" @@ -53,6 +55,43 @@ describe("migrateLegacyPluginEntry", () => { }) }) + describe("#given renaming the temp file fails after writing the migrated config", () => { + describe("#when migrating the config", () => { + it("#then keeps the original config untouched and writes the migrated content to a sibling temp file", async () => { + const configPath = join(testDir, "opencode.json") + const originalContent = JSON.stringify({ plugin: ["oh-my-opencode@latest"] }, null, 2) + const tempPath = `${configPath}.tmp` + writeFileSync(configPath, originalContent) + + const fs = await import("node:fs") + const originalRenameSync = fs.renameSync + + mock.module("node:fs", () => ({ + ...fs, + renameSync: () => { + throw new Error("simulated rename failure") + }, + })) + + try { + const { migrateLegacyPluginEntry } = await importFreshMigrationModule() + + const result = migrateLegacyPluginEntry(configPath) + + expect(result).toBe(false) + expect(readFileSync(configPath, "utf-8")).toBe(originalContent) + expect(readFileSync(tempPath, "utf-8")).toContain("oh-my-openagent@latest") + expect(readFileSync(tempPath, "utf-8")).not.toContain("oh-my-opencode") + } finally { + mock.module("node:fs", () => ({ + ...fs, + renameSync: originalRenameSync, + })) + } + }) + }) + }) + describe("#given opencode.json contains pinned oh-my-opencode version", () => { describe("#when migrating the config", () => { it("#then preserves the version pin", async () => { diff --git a/src/shared/migrate-legacy-plugin-entry.ts b/src/shared/migrate-legacy-plugin-entry.ts index 1eee6ae2e..21dc5f875 100644 --- a/src/shared/migrate-legacy-plugin-entry.ts +++ b/src/shared/migrate-legacy-plugin-entry.ts @@ -1,4 +1,4 @@ -import { existsSync, readFileSync, writeFileSync } from "node:fs" +import { closeSync, existsSync, fsyncSync, openSync, readFileSync, renameSync, writeFileSync } from "node:fs" import { applyEdits, modify } from "jsonc-parser" import { parseJsoncSafe } from "./jsonc-parser" @@ -66,7 +66,15 @@ export function migrateLegacyPluginEntry(configPath: string): boolean { : JSON.stringify({ ...(parseResult.data as OpenCodeConfig), plugin: updatedPluginEntries }, null, 2) + "\n" if (!updated || updated === content) return false - writeFileSync(configPath, updated, "utf-8") + const tempPath = `${configPath}.tmp` + writeFileSync(tempPath, updated, "utf-8") + const tempFileDescriptor = openSync(tempPath, "r") + try { + fsyncSync(tempFileDescriptor) + } finally { + closeSync(tempFileDescriptor) + } + renameSync(tempPath, configPath) log("[migrateLegacyPluginEntry] Auto-migrated opencode.json plugin entry", { configPath, from: LEGACY_PLUGIN_NAME,