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
83 changes: 83 additions & 0 deletions src/plugin/config/loader.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { __testExports, loadConfig } from "./loader";

describe("config loader legacy Windows migration", () => {
let tempRoot: string;
let previousConfigDir: string | undefined;
let previousAppData: string | undefined;

beforeEach(() => {
tempRoot = mkdtempSync(join(tmpdir(), "antigravity-config-"));

previousConfigDir = process.env.OPENCODE_CONFIG_DIR;
previousAppData = process.env.APPDATA;

process.env.OPENCODE_CONFIG_DIR = join(tempRoot, "new-config");
process.env.APPDATA = join(tempRoot, "legacy-appdata");
});

afterEach(() => {
if (previousConfigDir === undefined) {
delete process.env.OPENCODE_CONFIG_DIR;
} else {
process.env.OPENCODE_CONFIG_DIR = previousConfigDir;
}

if (previousAppData === undefined) {
delete process.env.APPDATA;
} else {
process.env.APPDATA = previousAppData;
}

rmSync(tempRoot, { recursive: true, force: true });
});

it("migrates %APPDATA%/opencode/antigravity.json to current config path", () => {
const legacyDir = join(process.env.APPDATA!, "opencode");
const legacyPath = join(legacyDir, "antigravity.json");
const expectedPath = join(process.env.OPENCODE_CONFIG_DIR!, "antigravity.json");

mkdirSync(legacyDir, { recursive: true });
writeFileSync(legacyPath, JSON.stringify({ scheduling_mode: "performance_first" }), "utf-8");

const resolvedPath = __testExports.resolveUserConfigPath("win32");

expect(resolvedPath).toBe(expectedPath);
expect(existsSync(expectedPath)).toBe(true);
expect(existsSync(legacyPath)).toBe(false);
});

it("loads settings from migrated legacy config", () => {
const legacyDir = join(process.env.APPDATA!, "opencode");
const legacyPath = join(legacyDir, "antigravity.json");

mkdirSync(legacyDir, { recursive: true });
writeFileSync(
legacyPath,
JSON.stringify({
account_selection_strategy: "round-robin",
scheduling_mode: "performance_first",
switch_on_first_rate_limit: true,
max_rate_limit_wait_seconds: 5,
}),
"utf-8",
);

// Force legacy -> new path migration even when tests run on non-Windows CI.
__testExports.resolveUserConfigPath("win32");

const loaded = loadConfig(tempRoot);

expect(loaded.account_selection_strategy).toBe("round-robin");
expect(loaded.scheduling_mode).toBe("performance_first");
expect(loaded.switch_on_first_rate_limit).toBe(true);
expect(loaded.max_rate_limit_wait_seconds).toBe(5);

const migratedPath = join(process.env.OPENCODE_CONFIG_DIR!, "antigravity.json");
const persisted = JSON.parse(readFileSync(migratedPath, "utf-8")) as Record<string, unknown>;
expect(persisted.scheduling_mode).toBe("performance_first");
});
});
60 changes: 57 additions & 3 deletions src/plugin/config/loader.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,8 @@
* 4. Environment variables
*/

import { existsSync, readFileSync } from "node:fs";
import { join } from "node:path";
import { copyFileSync, existsSync, mkdirSync, readFileSync, renameSync, unlinkSync } from "node:fs";
import { dirname, join } from "node:path";
import { homedir } from "node:os";
import { AccountSelectionStrategySchema, AntigravityConfigSchema, DEFAULT_CONFIG, type AntigravityConfig } from "./schema";
import { createLogger } from "../logger";
Expand All @@ -37,11 +37,61 @@ function getConfigDir(): string {
return join(xdgConfig, "opencode");
}

/**
* Gets the legacy Windows config directory (%APPDATA%\opencode).
*/
function getLegacyWindowsConfigDir(): string {
return join(
process.env.APPDATA || join(homedir(), "AppData", "Roaming"),
"opencode",
);
}

/**
* Resolves user config path and migrates legacy Windows config if needed.
*
* Legacy path: %APPDATA%\opencode\antigravity.json
* Current path: ~/.config/opencode/antigravity.json (or OPENCODE_CONFIG_DIR override)
*/
function resolveUserConfigPath(platform: NodeJS.Platform = process.platform): string {
const newPath = join(getConfigDir(), "antigravity.json");

if (platform !== "win32") {
return newPath;
}

const legacyPath = join(getLegacyWindowsConfigDir(), "antigravity.json");
if (!existsSync(legacyPath) || existsSync(newPath)) {
return newPath;
}

try {
mkdirSync(dirname(newPath), { recursive: true });
try {
renameSync(legacyPath, newPath);
log.info("Migrated Windows config via rename", { from: legacyPath, to: newPath });
} catch {
copyFileSync(legacyPath, newPath);
unlinkSync(legacyPath);
log.info("Migrated Windows config via copy+delete", { from: legacyPath, to: newPath });
}
return newPath;
} catch (error) {
// Fallback to legacy path if migration failed, so user settings are still respected.
log.warn("Failed to migrate legacy Windows config, falling back to legacy path", {
legacyPath,
newPath,
error: String(error),
});
return legacyPath;
}
Comment on lines +68 to +87
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

If unlinkSync fails after a successful copyFileSync, the log is misleading and the return value is suboptimal.

When copyFileSync (Line 74) succeeds but unlinkSync (Line 75) throws, the exception propagates to the outer catch (Line 79), which logs "Failed to migrate" and returns legacyPath. At that point newPath already contains the correct config, so:

  1. The warning is misleading — migration partially succeeded (copy worked, delete didn't).
  2. Returning legacyPath is harmless this run (same content), but on the next run both files exist and newPath wins (Line 64), so the user silently flips paths between invocations.

Consider catching unlinkSync failures separately so you can still return newPath and log a more accurate warning about the leftover legacy file.

Suggested fix
     } catch {
       copyFileSync(legacyPath, newPath);
-      unlinkSync(legacyPath);
-      log.info("Migrated Windows config via copy+delete", { from: legacyPath, to: newPath });
+      try {
+        unlinkSync(legacyPath);
+      } catch (unlinkError) {
+        log.warn("Migrated config but could not remove legacy file", {
+          legacyPath,
+          error: String(unlinkError),
+        });
+      }
+      log.info("Migrated Windows config via copy+delete", { from: legacyPath, to: newPath });
     }
🤖 Prompt for AI Agents
In `@src/plugin/config/loader.ts` around lines 68 - 87, The migration block
mishandles a failing unlinkSync: if copyFileSync succeeds but unlinkSync throws,
the outer catch logs a full migration failure and returns legacyPath even though
newPath contains the migrated config; fix by wrapping unlinkSync in its own
try/catch (inside the catch that handles renameSync failure), so on unlink
errors you call log.warn with a message like "Copied config but failed to remove
legacy file, leaving both files" including legacyPath/newPath/error, and still
return newPath; keep the existing outer catch for true migration failures (e.g.,
copyFileSync errors) so renameSync, copyFileSync, unlinkSync are referenced
accordingly and the function returns newPath when the config was successfully
copied.

}

/**
* Get the user-level config file path.
*/
export function getUserConfigPath(): string {
return join(getConfigDir(), "antigravity.json");
return resolveUserConfigPath();
}

/**
Expand Down Expand Up @@ -231,3 +281,7 @@ export function initRuntimeConfig(config: AntigravityConfig): void {
export function getKeepThinking(): boolean {
return runtimeConfig?.keep_thinking ?? false;
}

export const __testExports = {
resolveUserConfigPath,
};