This commit is contained in:
Noe
2026-02-10 16:14:55 +00:00
parent a14c5293f8
commit c4c8922db2
9 changed files with 269 additions and 225 deletions

View File

@@ -118,7 +118,7 @@ Tool schemas are cleaned via allowlist. Unsupported fields (`const`, `$ref`, `$d
Accounts rotate on rate limits. Gemini has dual quota pools (Antigravity headers + Gemini CLI headers). Fingerprints are per-account and regenerated on capacity exhaustion.
### 6. Fingerprint System
Per-account device fingerprints stored in `antigravity-accounts.json`. Each fingerprint includes deviceId, sessionToken, userAgent, apiClient, clientMetadata. Headers: `User-Agent`, `X-Goog-Api-Client`, `Client-Metadata`, `X-Goog-QuotaUser`, `X-Client-Device-Id`. History tracked (max 5), restorable.
Per-account device fingerprints stored in `antigravity-accounts.json`. Each fingerprint includes deviceId, sessionToken, userAgent, and a reduced clientMetadata (ideType, platform, pluginType — no osVersion, arch, or sqmId). The only header composed is `User-Agent`, built by `buildFingerprintHeaders()` in `fingerprint.ts` and applied on the antigravity request path in `request.ts`. History tracked (max 5), restorable.
## Dependencies

View File

@@ -847,7 +847,7 @@ async function persistAccountPool(
: (typeof stored?.activeIndex === "number" && Number.isFinite(stored.activeIndex) ? stored.activeIndex : 0);
await saveAccounts({
version: 3,
version: 4,
accounts,
activeIndex: clampInt(activeIndex, 0, accounts.length - 1),
activeIndexByFamily: {
@@ -2929,7 +2929,7 @@ export const createAntigravityPlugin = (providerId: string) => async (
);
// Use saveAccountsReplace to bypass merge (otherwise deleted account gets merged back)
await saveAccountsReplace({
version: 3,
version: 4,
accounts: updatedAccounts,
activeIndex: 0,
activeIndexByFamily: { claude: 0, gemini: 0 },
@@ -3145,7 +3145,7 @@ export const createAntigravityPlugin = (providerId: string) => async (
lastUsed: Date.now(),
};
await saveAccounts({
version: 3,
version: 4,
accounts: updatedAccounts,
activeIndex: currentStorage.activeIndex,
activeIndexByFamily: currentStorage.activeIndexByFamily,

View File

@@ -1,7 +1,7 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { AccountManager, type ModelFamily, type HeaderStyle, parseRateLimitReason, calculateBackoffMs, type RateLimitReason, resolveQuotaGroup } from "./accounts";
import type { AccountStorageV3 } from "./storage";
import type { AccountStorageV4 } from "./storage";
import type { OAuthAuthDetails } from "./types";
// Mock storage to prevent test data from leaking to real config files
@@ -28,8 +28,8 @@ describe("AccountManager", () => {
expires: 123,
};
const stored: AccountStorageV3 = {
version: 3,
const stored: AccountStorageV4 = {
version: 4,
accounts: [],
activeIndex: 0,
};
@@ -39,8 +39,8 @@ describe("AccountManager", () => {
});
it("returns current account when not rate-limited for family", () => {
const stored: AccountStorageV3 = {
version: 3,
const stored: AccountStorageV4 = {
version: 4,
accounts: [
{ refreshToken: "r1", projectId: "p1", addedAt: 1, lastUsed: 0 },
{ refreshToken: "r2", projectId: "p2", addedAt: 1, lastUsed: 0 },
@@ -58,8 +58,8 @@ describe("AccountManager", () => {
});
it("switches to next account when current is rate-limited for family", () => {
const stored: AccountStorageV3 = {
version: 3,
const stored: AccountStorageV4 = {
version: 4,
accounts: [
{ refreshToken: "r1", projectId: "p1", addedAt: 1, lastUsed: 0 },
{ refreshToken: "r2", projectId: "p2", addedAt: 1, lastUsed: 0 },
@@ -78,8 +78,8 @@ describe("AccountManager", () => {
});
it("returns null when all accounts are rate-limited for family", () => {
const stored: AccountStorageV3 = {
version: 3,
const stored: AccountStorageV4 = {
version: 4,
accounts: [
{ refreshToken: "r1", projectId: "p1", addedAt: 1, lastUsed: 0 },
{ refreshToken: "r2", projectId: "p2", addedAt: 1, lastUsed: 0 },
@@ -101,8 +101,8 @@ describe("AccountManager", () => {
vi.useFakeTimers();
vi.setSystemTime(new Date(0));
const stored: AccountStorageV3 = {
version: 3,
const stored: AccountStorageV4 = {
version: 4,
accounts: [
{ refreshToken: "r1", projectId: "p1", addedAt: 1, lastUsed: 0 },
],
@@ -123,8 +123,8 @@ describe("AccountManager", () => {
vi.useFakeTimers();
vi.setSystemTime(new Date(0));
const stored: AccountStorageV3 = {
version: 3,
const stored: AccountStorageV4 = {
version: 4,
accounts: [
{ refreshToken: "r1", projectId: "p1", addedAt: 1, lastUsed: 0 },
{ refreshToken: "r2", projectId: "p2", addedAt: 1, lastUsed: 0 },
@@ -143,8 +143,8 @@ describe("AccountManager", () => {
});
it("tracks rate limits per model family independently", () => {
const stored: AccountStorageV3 = {
version: 3,
const stored: AccountStorageV4 = {
version: 4,
accounts: [
{ refreshToken: "r1", projectId: "p1", addedAt: 1, lastUsed: 0 },
],
@@ -172,8 +172,8 @@ describe("AccountManager", () => {
vi.useFakeTimers();
vi.setSystemTime(new Date(0));
const stored: AccountStorageV3 = {
version: 3,
const stored: AccountStorageV4 = {
version: 4,
accounts: [
{ refreshToken: "r1", projectId: "p1", addedAt: 1, lastUsed: 0 },
{ refreshToken: "r2", projectId: "p2", addedAt: 1, lastUsed: 0 },
@@ -206,8 +206,8 @@ describe("AccountManager", () => {
vi.useFakeTimers();
vi.setSystemTime(new Date(0));
const stored: AccountStorageV3 = {
version: 3,
const stored: AccountStorageV4 = {
version: 4,
accounts: [
{ refreshToken: "r1", projectId: "p1", addedAt: 1, lastUsed: 0 },
{ refreshToken: "r2", projectId: "p2", addedAt: 1, lastUsed: 0 },
@@ -237,8 +237,8 @@ describe("AccountManager", () => {
expires: 123,
};
const stored: AccountStorageV3 = {
version: 3,
const stored: AccountStorageV4 = {
version: 4,
accounts: [
{ refreshToken: "r1", projectId: "p1", addedAt: 1, lastUsed: 0 },
{ refreshToken: "r2", projectId: "p2", addedAt: 1, lastUsed: 0 },
@@ -259,8 +259,8 @@ describe("AccountManager", () => {
vi.useFakeTimers();
vi.setSystemTime(new Date(0));
const stored: AccountStorageV3 = {
version: 3,
const stored: AccountStorageV4 = {
version: 4,
accounts: [
{ refreshToken: "r1", projectId: "p1", addedAt: 1, lastUsed: 0 },
],
@@ -282,8 +282,8 @@ describe("AccountManager", () => {
describe("header style fallback for Gemini", () => {
it("tracks rate limits separately for each header style", () => {
const stored: AccountStorageV3 = {
version: 3,
const stored: AccountStorageV4 = {
version: 4,
accounts: [
{ refreshToken: "r1", projectId: "p1", addedAt: 1, lastUsed: 0 },
],
@@ -300,8 +300,8 @@ describe("AccountManager", () => {
});
it("getAvailableHeaderStyle returns antigravity first for Gemini", () => {
const stored: AccountStorageV3 = {
version: 3,
const stored: AccountStorageV4 = {
version: 4,
accounts: [
{ refreshToken: "r1", projectId: "p1", addedAt: 1, lastUsed: 0 },
],
@@ -315,8 +315,8 @@ describe("AccountManager", () => {
});
it("getAvailableHeaderStyle returns gemini-cli when antigravity is rate-limited", () => {
const stored: AccountStorageV3 = {
version: 3,
const stored: AccountStorageV4 = {
version: 4,
accounts: [
{ refreshToken: "r1", projectId: "p1", addedAt: 1, lastUsed: 0 },
],
@@ -332,8 +332,8 @@ describe("AccountManager", () => {
});
it("getAvailableHeaderStyle returns null when both header styles are rate-limited", () => {
const stored: AccountStorageV3 = {
version: 3,
const stored: AccountStorageV4 = {
version: 4,
accounts: [
{ refreshToken: "r1", projectId: "p1", addedAt: 1, lastUsed: 0 },
],
@@ -350,8 +350,8 @@ describe("AccountManager", () => {
});
it("getAvailableHeaderStyle always returns antigravity for Claude", () => {
const stored: AccountStorageV3 = {
version: 3,
const stored: AccountStorageV4 = {
version: 4,
accounts: [
{ refreshToken: "r1", projectId: "p1", addedAt: 1, lastUsed: 0 },
],
@@ -365,8 +365,8 @@ describe("AccountManager", () => {
});
it("getAvailableHeaderStyle returns null for Claude when rate-limited", () => {
const stored: AccountStorageV3 = {
version: 3,
const stored: AccountStorageV4 = {
version: 4,
accounts: [
{ refreshToken: "r1", projectId: "p1", addedAt: 1, lastUsed: 0 },
],
@@ -385,8 +385,8 @@ describe("AccountManager", () => {
vi.useFakeTimers();
vi.setSystemTime(new Date(0));
const stored: AccountStorageV3 = {
version: 3,
const stored: AccountStorageV4 = {
version: 4,
accounts: [
{ refreshToken: "r1", projectId: "p1", addedAt: 1, lastUsed: 0 },
],
@@ -411,8 +411,8 @@ describe("AccountManager", () => {
vi.useFakeTimers();
vi.setSystemTime(new Date(0));
const stored: AccountStorageV3 = {
version: 3,
const stored: AccountStorageV4 = {
version: 4,
accounts: [
{ refreshToken: "r1", projectId: "p1", addedAt: 1, lastUsed: 0 },
],
@@ -434,8 +434,8 @@ describe("AccountManager", () => {
describe("per-family account tracking", () => {
it("tracks current account independently per model family", () => {
const stored: AccountStorageV3 = {
version: 3,
const stored: AccountStorageV4 = {
version: 4,
accounts: [
{ refreshToken: "r1", projectId: "p1", addedAt: 1, lastUsed: 0 },
{ refreshToken: "r2", projectId: "p2", addedAt: 1, lastUsed: 0 },
@@ -458,8 +458,8 @@ describe("AccountManager", () => {
});
it("switching Claude account does not affect Gemini account selection", () => {
const stored: AccountStorageV3 = {
version: 3,
const stored: AccountStorageV4 = {
version: 4,
accounts: [
{ refreshToken: "r1", projectId: "p1", addedAt: 1, lastUsed: 0 },
{ refreshToken: "r2", projectId: "p2", addedAt: 1, lastUsed: 0 },
@@ -486,8 +486,8 @@ describe("AccountManager", () => {
});
it("persists per-family indices to storage", async () => {
const stored: AccountStorageV3 = {
version: 3,
const stored: AccountStorageV4 = {
version: 4,
accounts: [
{ refreshToken: "r1", projectId: "p1", addedAt: 1, lastUsed: 0 },
{ refreshToken: "r2", projectId: "p2", addedAt: 1, lastUsed: 0 },
@@ -506,8 +506,8 @@ describe("AccountManager", () => {
});
it("loads per-family indices from storage", () => {
const stored: AccountStorageV3 = {
version: 3,
const stored: AccountStorageV4 = {
version: 4,
accounts: [
{ refreshToken: "r1", projectId: "p1", addedAt: 1, lastUsed: 0 },
{ refreshToken: "r2", projectId: "p2", addedAt: 1, lastUsed: 0 },
@@ -527,8 +527,8 @@ describe("AccountManager", () => {
});
it("falls back to activeIndex when activeIndexByFamily is not present", () => {
const stored: AccountStorageV3 = {
version: 3,
const stored: AccountStorageV4 = {
version: 4,
accounts: [
{ refreshToken: "r1", projectId: "p1", addedAt: 1, lastUsed: 0 },
{ refreshToken: "r2", projectId: "p2", addedAt: 1, lastUsed: 0 },
@@ -545,8 +545,8 @@ describe("AccountManager", () => {
describe("account cooldown (non-429 errors)", () => {
it("marks account as cooling down with reason", () => {
const stored: AccountStorageV3 = {
version: 3,
const stored: AccountStorageV4 = {
version: 4,
accounts: [
{ refreshToken: "r1", projectId: "p1", addedAt: 1, lastUsed: 0 },
],
@@ -565,8 +565,8 @@ describe("AccountManager", () => {
vi.useFakeTimers();
vi.setSystemTime(new Date(0));
const stored: AccountStorageV3 = {
version: 3,
const stored: AccountStorageV4 = {
version: 4,
accounts: [
{ refreshToken: "r1", projectId: "p1", addedAt: 1, lastUsed: 0 },
],
@@ -586,8 +586,8 @@ describe("AccountManager", () => {
});
it("clearAccountCooldown removes cooldown state", () => {
const stored: AccountStorageV3 = {
version: 3,
const stored: AccountStorageV4 = {
version: 4,
accounts: [
{ refreshToken: "r1", projectId: "p1", addedAt: 1, lastUsed: 0 },
],
@@ -605,8 +605,8 @@ describe("AccountManager", () => {
});
it("cooling down account is skipped in getCurrentOrNextForFamily", () => {
const stored: AccountStorageV3 = {
version: 3,
const stored: AccountStorageV4 = {
version: 4,
accounts: [
{ refreshToken: "r1", projectId: "p1", addedAt: 1, lastUsed: 0 },
{ refreshToken: "r2", projectId: "p2", addedAt: 1, lastUsed: 0 },
@@ -624,8 +624,8 @@ describe("AccountManager", () => {
});
it("cooldown is independent from rate limits", () => {
const stored: AccountStorageV3 = {
version: 3,
const stored: AccountStorageV4 = {
version: 4,
accounts: [
{ refreshToken: "r1", projectId: "p1", addedAt: 1, lastUsed: 0 },
],
@@ -646,8 +646,8 @@ describe("AccountManager", () => {
describe("account selection strategies", () => {
describe("sticky strategy (default)", () => {
it("returns same account on consecutive calls", () => {
const stored: AccountStorageV3 = {
version: 3,
const stored: AccountStorageV4 = {
version: 4,
accounts: [
{ refreshToken: "r1", projectId: "p1", addedAt: 1, lastUsed: 0 },
{ refreshToken: "r2", projectId: "p2", addedAt: 1, lastUsed: 0 },
@@ -667,8 +667,8 @@ describe("AccountManager", () => {
});
it("switches account only when current is rate-limited", () => {
const stored: AccountStorageV3 = {
version: 3,
const stored: AccountStorageV4 = {
version: 4,
accounts: [
{ refreshToken: "r1", projectId: "p1", addedAt: 1, lastUsed: 0 },
{ refreshToken: "r2", projectId: "p2", addedAt: 1, lastUsed: 0 },
@@ -690,8 +690,8 @@ describe("AccountManager", () => {
describe("round-robin strategy", () => {
it("rotates to next account on each call", () => {
const stored: AccountStorageV3 = {
version: 3,
const stored: AccountStorageV4 = {
version: 4,
accounts: [
{ refreshToken: "r1", projectId: "p1", addedAt: 1, lastUsed: 0 },
{ refreshToken: "r2", projectId: "p2", addedAt: 1, lastUsed: 0 },
@@ -712,8 +712,8 @@ describe("AccountManager", () => {
});
it("skips rate-limited accounts", () => {
const stored: AccountStorageV3 = {
version: 3,
const stored: AccountStorageV4 = {
version: 4,
accounts: [
{ refreshToken: "r1", projectId: "p1", addedAt: 1, lastUsed: 0 },
{ refreshToken: "r2", projectId: "p2", addedAt: 1, lastUsed: 0 },
@@ -736,8 +736,8 @@ describe("AccountManager", () => {
describe("hybrid strategy", () => {
it("returns fresh (untouched) accounts first", () => {
const stored: AccountStorageV3 = {
version: 3,
const stored: AccountStorageV4 = {
version: 4,
accounts: [
{ refreshToken: "r1", projectId: "p1", addedAt: 1, lastUsed: 0 },
{ refreshToken: "r2", projectId: "p2", addedAt: 1, lastUsed: 0 },
@@ -759,8 +759,8 @@ describe("AccountManager", () => {
});
it("continues to return valid accounts after all touched", () => {
const stored: AccountStorageV3 = {
version: 3,
const stored: AccountStorageV4 = {
version: 4,
accounts: [
{ refreshToken: "r1", projectId: "p1", addedAt: 1, lastUsed: 0 },
{ refreshToken: "r2", projectId: "p2", addedAt: 1, lastUsed: 0 },
@@ -785,8 +785,8 @@ describe("AccountManager", () => {
describe("hybrid strategy with token bucket", () => {
it("returns account based on health and token availability", () => {
const stored: AccountStorageV3 = {
version: 3,
const stored: AccountStorageV4 = {
version: 4,
accounts: [
{ refreshToken: "r1", projectId: "p1", addedAt: 1, lastUsed: 0 },
{ refreshToken: "r2", projectId: "p2", addedAt: 1, lastUsed: 0 },
@@ -803,8 +803,8 @@ describe("AccountManager", () => {
});
it("skips rate-limited accounts", () => {
const stored: AccountStorageV3 = {
version: 3,
const stored: AccountStorageV4 = {
version: 4,
accounts: [
{ refreshToken: "r1", projectId: "p1", addedAt: 1, lastUsed: 0 },
{ refreshToken: "r2", projectId: "p2", addedAt: 1, lastUsed: 0 },
@@ -821,8 +821,8 @@ describe("AccountManager", () => {
});
it("skips cooling down accounts", () => {
const stored: AccountStorageV3 = {
version: 3,
const stored: AccountStorageV4 = {
version: 4,
accounts: [
{ refreshToken: "r1", projectId: "p1", addedAt: 1, lastUsed: 0 },
{ refreshToken: "r2", projectId: "p2", addedAt: 1, lastUsed: 0 },
@@ -842,8 +842,8 @@ describe("AccountManager", () => {
vi.useFakeTimers();
vi.setSystemTime(new Date(0));
const stored: AccountStorageV3 = {
version: 3,
const stored: AccountStorageV4 = {
version: 4,
accounts: [
{ refreshToken: "r1", projectId: "p1", addedAt: 1, lastUsed: 0 },
],
@@ -860,8 +860,8 @@ describe("AccountManager", () => {
vi.useFakeTimers();
vi.setSystemTime(new Date(5000));
const stored: AccountStorageV3 = {
version: 3,
const stored: AccountStorageV4 = {
version: 4,
accounts: [
{ refreshToken: "r1", projectId: "p1", addedAt: 1, lastUsed: 0 },
{ refreshToken: "r2", projectId: "p2", addedAt: 1, lastUsed: 0 },
@@ -884,8 +884,8 @@ describe("AccountManager", () => {
vi.useFakeTimers();
vi.setSystemTime(new Date(1000));
const stored: AccountStorageV3 = {
version: 3,
const stored: AccountStorageV4 = {
version: 4,
accounts: [
{ refreshToken: "r1", projectId: "p1", addedAt: 1, lastUsed: 0 },
],
@@ -901,8 +901,8 @@ describe("AccountManager", () => {
});
it("isFreshForQuota returns true for untouched accounts", () => {
const stored: AccountStorageV3 = {
version: 3,
const stored: AccountStorageV4 = {
version: 4,
accounts: [
{ refreshToken: "r1", projectId: "p1", addedAt: 1, lastUsed: 0 },
],
@@ -919,8 +919,8 @@ describe("AccountManager", () => {
vi.useFakeTimers();
vi.setSystemTime(new Date(1000));
const stored: AccountStorageV3 = {
version: 3,
const stored: AccountStorageV4 = {
version: 4,
accounts: [
{ refreshToken: "r1", projectId: "p1", addedAt: 1, lastUsed: 0 },
],
@@ -939,8 +939,8 @@ describe("AccountManager", () => {
vi.useFakeTimers();
vi.setSystemTime(new Date(1000));
const stored: AccountStorageV3 = {
version: 3,
const stored: AccountStorageV4 = {
version: 4,
accounts: [
{ refreshToken: "r1", projectId: "p1", addedAt: 1, lastUsed: 0 },
],
@@ -962,8 +962,8 @@ describe("AccountManager", () => {
describe("consecutiveFailures tracking", () => {
it("initializes consecutiveFailures as undefined", () => {
const stored: AccountStorageV3 = {
version: 3,
const stored: AccountStorageV4 = {
version: 4,
accounts: [
{ refreshToken: "r1", projectId: "p1", addedAt: 1, lastUsed: 0 },
],
@@ -977,8 +977,8 @@ describe("AccountManager", () => {
});
it("can increment and reset consecutiveFailures", () => {
const stored: AccountStorageV3 = {
version: 3,
const stored: AccountStorageV4 = {
version: 4,
accounts: [
{ refreshToken: "r1", projectId: "p1", addedAt: 1, lastUsed: 0 },
],
@@ -1001,8 +1001,8 @@ describe("AccountManager", () => {
describe("Issue #147: headerStyle-aware account selection", () => {
it("skips account when requested headerStyle is rate-limited even if other style is available", () => {
const stored: AccountStorageV3 = {
version: 3,
const stored: AccountStorageV4 = {
version: 4,
accounts: [
{ refreshToken: "r1", projectId: "p1", addedAt: 1, lastUsed: 0 },
{ refreshToken: "r2", projectId: "p2", addedAt: 1, lastUsed: 0 },
@@ -1037,8 +1037,8 @@ describe("AccountManager", () => {
});
it("returns same account when a different headerStyle is rate-limited", () => {
const stored: AccountStorageV3 = {
version: 3,
const stored: AccountStorageV4 = {
version: 4,
accounts: [
{ refreshToken: "r1", projectId: "p1", addedAt: 1, lastUsed: 0 },
{ refreshToken: "r2", projectId: "p2", addedAt: 1, lastUsed: 0 },
@@ -1070,8 +1070,8 @@ describe("AccountManager", () => {
it("requestSaveToDisk coalesces multiple calls into one write", async () => {
vi.useFakeTimers();
const stored: AccountStorageV3 = {
version: 3,
const stored: AccountStorageV4 = {
version: 4,
accounts: [
{ refreshToken: "r1", projectId: "p1", addedAt: 1, lastUsed: 0 },
],
@@ -1097,8 +1097,8 @@ describe("AccountManager", () => {
it("flushSaveToDisk waits for pending save to complete", async () => {
vi.useFakeTimers();
const stored: AccountStorageV3 = {
version: 3,
const stored: AccountStorageV4 = {
version: 4,
accounts: [
{ refreshToken: "r1", projectId: "p1", addedAt: 1, lastUsed: 0 },
],
@@ -1123,8 +1123,8 @@ describe("AccountManager", () => {
it("does not save again if no new requestSaveToDisk after flush", async () => {
vi.useFakeTimers();
const stored: AccountStorageV3 = {
version: 3,
const stored: AccountStorageV4 = {
version: 4,
accounts: [
{ refreshToken: "r1", projectId: "p1", addedAt: 1, lastUsed: 0 },
],
@@ -1152,8 +1152,8 @@ describe("AccountManager", () => {
vi.useFakeTimers();
vi.setSystemTime(new Date(0));
const stored: AccountStorageV3 = {
version: 3,
const stored: AccountStorageV4 = {
version: 4,
accounts: [
{ refreshToken: "r1", projectId: "p1", addedAt: 1, lastUsed: 0 },
],
@@ -1248,8 +1248,8 @@ describe("AccountManager", () => {
vi.useFakeTimers();
vi.setSystemTime(1000);
const stored: AccountStorageV3 = {
version: 3,
const stored: AccountStorageV4 = {
version: 4,
accounts: [
{ refreshToken: "r1", projectId: "p1", addedAt: 1, lastUsed: 0 },
],
@@ -1284,8 +1284,8 @@ describe("AccountManager", () => {
vi.useFakeTimers();
vi.setSystemTime(1000);
const stored: AccountStorageV3 = {
version: 3,
const stored: AccountStorageV4 = {
version: 4,
accounts: [
{ refreshToken: "r1", projectId: "p1", addedAt: 1, lastUsed: 0 },
],
@@ -1306,8 +1306,8 @@ describe("AccountManager", () => {
describe("markRequestSuccess", () => {
it("resets consecutive failure counter", () => {
const stored: AccountStorageV3 = {
version: 3,
const stored: AccountStorageV4 = {
version: 4,
accounts: [
{ refreshToken: "r1", projectId: "p1", addedAt: 1, lastUsed: 0 },
],
@@ -1328,8 +1328,8 @@ describe("AccountManager", () => {
vi.useFakeTimers();
vi.setSystemTime(10_000);
const stored: AccountStorageV3 = {
version: 3,
const stored: AccountStorageV4 = {
version: 4,
accounts: [
{ refreshToken: "r1", projectId: "p1", addedAt: 1, lastUsed: 0, rateLimitResetTimes: { "gemini-antigravity": 11_500, "gemini-cli": 11_500 } },
],
@@ -1346,8 +1346,8 @@ describe("AccountManager", () => {
vi.useFakeTimers();
vi.setSystemTime(10_000);
const stored: AccountStorageV3 = {
version: 3,
const stored: AccountStorageV4 = {
version: 4,
accounts: [
{ refreshToken: "r1", projectId: "p1", addedAt: 1, lastUsed: 0, rateLimitResetTimes: { "gemini-antigravity": 15_000, "gemini-cli": 15_000 } },
],
@@ -1361,8 +1361,8 @@ describe("AccountManager", () => {
});
it("shouldTryOptimisticReset returns false when accounts are available", () => {
const stored: AccountStorageV3 = {
version: 3,
const stored: AccountStorageV4 = {
version: 4,
accounts: [
{ refreshToken: "r1", projectId: "p1", addedAt: 1, lastUsed: 0 },
],
@@ -1377,8 +1377,8 @@ describe("AccountManager", () => {
vi.useFakeTimers();
vi.setSystemTime(10_000);
const stored: AccountStorageV3 = {
version: 3,
const stored: AccountStorageV4 = {
version: 4,
accounts: [
{ refreshToken: "r1", projectId: "p1", addedAt: 1, lastUsed: 0, rateLimitResetTimes: { "gemini-antigravity": 70_000, "gemini-cli": 80_000 } },
{ refreshToken: "r2", projectId: "p2", addedAt: 2, lastUsed: 0, rateLimitResetTimes: { "gemini-antigravity": 90_000 } },
@@ -1409,8 +1409,8 @@ describe("AccountManager", () => {
vi.useFakeTimers();
vi.setSystemTime(new Date(0));
const stored: AccountStorageV3 = {
version: 3,
const stored: AccountStorageV4 = {
version: 4,
accounts: [
{ refreshToken: "r1", projectId: "p1", addedAt: 1, lastUsed: 0 },
],
@@ -1439,8 +1439,8 @@ describe("AccountManager", () => {
vi.useFakeTimers();
vi.setSystemTime(new Date(0));
const stored: AccountStorageV3 = {
version: 3,
const stored: AccountStorageV4 = {
version: 4,
accounts: [
{ refreshToken: "r1", projectId: "p1", addedAt: 1, lastUsed: 0 },
],
@@ -1467,8 +1467,8 @@ describe("AccountManager", () => {
describe("Fingerprint History", () => {
it("regenerateAccountFingerprint saves old fingerprint to history", () => {
const stored: AccountStorageV3 = {
version: 3,
const stored: AccountStorageV4 = {
version: 4,
accounts: [
{ refreshToken: "r1", projectId: "p1", addedAt: 1, lastUsed: 0 },
],
@@ -1493,8 +1493,8 @@ describe("AccountManager", () => {
vi.useFakeTimers();
vi.setSystemTime(new Date(1000)); // Start at 1000 to avoid 0 being falsy
const stored: AccountStorageV3 = {
version: 3,
const stored: AccountStorageV4 = {
version: 4,
accounts: [
{ refreshToken: "r1", projectId: "p1", addedAt: 1, lastUsed: 0 },
],
@@ -1533,8 +1533,8 @@ describe("AccountManager", () => {
});
it("getAccountFingerprintHistory returns empty array for new account", () => {
const stored: AccountStorageV3 = {
version: 3,
const stored: AccountStorageV4 = {
version: 4,
accounts: [
{ refreshToken: "r1", projectId: "p1", addedAt: 1, lastUsed: 0 },
],
@@ -1548,8 +1548,8 @@ describe("AccountManager", () => {
});
it("limits fingerprint history to MAX_FINGERPRINT_HISTORY", () => {
const stored: AccountStorageV3 = {
version: 3,
const stored: AccountStorageV4 = {
version: 4,
accounts: [
{ refreshToken: "r1", projectId: "p1", addedAt: 1, lastUsed: 0 },
],
@@ -1570,8 +1570,8 @@ describe("AccountManager", () => {
describe("soft quota threshold", () => {
it("skips account over soft quota threshold in sticky mode", () => {
const stored: AccountStorageV3 = {
version: 3,
const stored: AccountStorageV4 = {
version: 4,
accounts: [
{ refreshToken: "r1", projectId: "p1", addedAt: 1, lastUsed: 0 },
{ refreshToken: "r2", projectId: "p2", addedAt: 2, lastUsed: 0 },
@@ -1587,8 +1587,8 @@ describe("AccountManager", () => {
});
it("allows account under soft quota threshold", () => {
const stored: AccountStorageV3 = {
version: 3,
const stored: AccountStorageV4 = {
version: 4,
accounts: [
{ refreshToken: "r1", projectId: "p1", addedAt: 1, lastUsed: 0 },
],
@@ -1603,8 +1603,8 @@ describe("AccountManager", () => {
});
it("threshold of 100 disables soft quota protection", () => {
const stored: AccountStorageV3 = {
version: 3,
const stored: AccountStorageV4 = {
version: 4,
accounts: [
{ refreshToken: "r1", projectId: "p1", addedAt: 1, lastUsed: 0 },
],
@@ -1619,8 +1619,8 @@ describe("AccountManager", () => {
});
it("returns null when all accounts over threshold", () => {
const stored: AccountStorageV3 = {
version: 3,
const stored: AccountStorageV4 = {
version: 4,
accounts: [
{ refreshToken: "r1", projectId: "p1", addedAt: 1, lastUsed: 0 },
{ refreshToken: "r2", projectId: "p2", addedAt: 2, lastUsed: 0 },
@@ -1637,8 +1637,8 @@ describe("AccountManager", () => {
});
it("skips account over threshold in round-robin mode", () => {
const stored: AccountStorageV3 = {
version: 3,
const stored: AccountStorageV4 = {
version: 4,
accounts: [
{ refreshToken: "r1", projectId: "p1", addedAt: 1, lastUsed: 0 },
{ refreshToken: "r2", projectId: "p2", addedAt: 2, lastUsed: 0 },
@@ -1654,8 +1654,8 @@ describe("AccountManager", () => {
});
it("account without cached quota is not skipped", () => {
const stored: AccountStorageV3 = {
version: 3,
const stored: AccountStorageV4 = {
version: 4,
accounts: [
{ refreshToken: "r1", projectId: "p1", addedAt: 1, lastUsed: 0 },
],
@@ -1669,8 +1669,8 @@ describe("AccountManager", () => {
});
it("handles remainingFraction of 0 (fully exhausted)", () => {
const stored: AccountStorageV3 = {
version: 3,
const stored: AccountStorageV4 = {
version: 4,
accounts: [
{ refreshToken: "r1", projectId: "p1", addedAt: 1, lastUsed: 0 },
{ refreshToken: "r2", projectId: "p2", addedAt: 2, lastUsed: 0 },
@@ -1689,8 +1689,8 @@ describe("AccountManager", () => {
vi.useFakeTimers();
vi.setSystemTime(new Date(0));
const stored: AccountStorageV3 = {
version: 3,
const stored: AccountStorageV4 = {
version: 4,
accounts: [
{ refreshToken: "r1", projectId: "p1", addedAt: 1, lastUsed: 0 },
],
@@ -1709,8 +1709,8 @@ describe("AccountManager", () => {
});
it("fails open when cachedQuotaUpdatedAt is missing", () => {
const stored: AccountStorageV3 = {
version: 3,
const stored: AccountStorageV4 = {
version: 4,
accounts: [
{ refreshToken: "r1", projectId: "p1", addedAt: 1, lastUsed: 0 },
],
@@ -1729,8 +1729,8 @@ describe("AccountManager", () => {
describe("getMinWaitTimeForSoftQuota", () => {
it("returns 0 when accounts are available (under threshold)", () => {
const stored: AccountStorageV3 = {
version: 3,
const stored: AccountStorageV4 = {
version: 4,
accounts: [
{ refreshToken: "r1", projectId: "p1", addedAt: 1, lastUsed: 0 },
],
@@ -1745,8 +1745,8 @@ describe("AccountManager", () => {
});
it("returns null when no resetTime available", () => {
const stored: AccountStorageV3 = {
version: 3,
const stored: AccountStorageV4 = {
version: 4,
accounts: [
{ refreshToken: "r1", projectId: "p1", addedAt: 1, lastUsed: 0 },
],
@@ -1764,8 +1764,8 @@ describe("AccountManager", () => {
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-01-28T10:00:00Z"));
const stored: AccountStorageV3 = {
version: 3,
const stored: AccountStorageV4 = {
version: 4,
accounts: [
{ refreshToken: "r1", projectId: "p1", addedAt: 1, lastUsed: 0 },
],
@@ -1791,8 +1791,8 @@ describe("AccountManager", () => {
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-01-28T16:00:00Z"));
const stored: AccountStorageV3 = {
version: 3,
const stored: AccountStorageV4 = {
version: 4,
accounts: [
{ refreshToken: "r1", projectId: "p1", addedAt: 1, lastUsed: 0 },
],
@@ -1818,8 +1818,8 @@ describe("AccountManager", () => {
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-01-28T10:00:00Z"));
const stored: AccountStorageV3 = {
version: 3,
const stored: AccountStorageV4 = {
version: 4,
accounts: [
{ refreshToken: "r1", projectId: "p1", addedAt: 1, lastUsed: 0 },
{ refreshToken: "r2", projectId: "p2", addedAt: 2, lastUsed: 0 },

View File

@@ -1,5 +1,5 @@
import { formatRefreshParts, parseRefreshParts } from "./auth";
import { loadAccounts, saveAccounts, type AccountStorageV3, type AccountMetadataV3, type RateLimitStateV3, type ModelFamily, type HeaderStyle, type CooldownReason } from "./storage";
import { loadAccounts, saveAccounts, type AccountStorageV4, type AccountMetadataV3, type RateLimitStateV3, type ModelFamily, type HeaderStyle, type CooldownReason } from "./storage";
import type { OAuthAuthDetails, RefreshParts } from "./types";
import type { AccountSelectionStrategy } from "./config/schema";
import { getHealthTracker, getTokenTracker, selectHybridAccount, type AccountWithMetrics } from "./rotation";
@@ -318,7 +318,7 @@ export class AccountManager {
return new AccountManager(authFallback, stored);
}
constructor(authFallback?: OAuthAuthDetails, stored?: AccountStorageV3 | null) {
constructor(authFallback?: OAuthAuthDetails, stored?: AccountStorageV4 | null) {
const authParts = authFallback ? parseRefreshParts(authFallback.refresh) : null;
if (stored && stored.accounts.length === 0) {
@@ -359,8 +359,8 @@ export class AccountManager {
coolingDownUntil: acc.coolingDownUntil,
cooldownReason: acc.cooldownReason,
touchedForQuota: {},
// Always regenerate fingerprint on load to pick up alignment changes
fingerprint: generateFingerprint(),
fingerprint: acc.fingerprint ?? generateFingerprint(),
fingerprintHistory: acc.fingerprintHistory ?? [],
cachedQuota: acc.cachedQuota as Partial<Record<QuotaGroup, QuotaGroupSummary>> | undefined,
cachedQuotaUpdatedAt: acc.cachedQuotaUpdatedAt,
verificationRequired: acc.verificationRequired,
@@ -977,8 +977,8 @@ export class AccountManager {
const claudeIndex = Math.max(0, this.currentAccountIndexByFamily.claude);
const geminiIndex = Math.max(0, this.currentAccountIndexByFamily.gemini);
const storage: AccountStorageV3 = {
version: 3,
const storage: AccountStorageV4 = {
version: 4,
accounts: this.accounts.map((a) => ({
email: a.email,
refreshToken: a.parts.refreshToken,

View File

@@ -1,7 +1,7 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { AccountManager, type ModelFamily, type HeaderStyle } from "./accounts";
import type { AccountStorageV3 } from "./storage";
import type { AccountStorageV4 } from "./storage";
/**
* Test: Antigravity-first fallback logic
@@ -21,8 +21,8 @@ describe("Antigravity-first fallback", () => {
describe("hasOtherAccountWithAntigravityAvailable", () => {
it("returns true when another account has antigravity available", () => {
const stored: AccountStorageV3 = {
version: 3,
const stored: AccountStorageV4 = {
version: 4,
accounts: [
{ refreshToken: "r1", projectId: "p1", addedAt: 1, lastUsed: 0 },
{ refreshToken: "r2", projectId: "p2", addedAt: 1, lastUsed: 0 },
@@ -47,8 +47,8 @@ describe("Antigravity-first fallback", () => {
});
it("returns false when all other accounts are also rate-limited for antigravity", () => {
const stored: AccountStorageV3 = {
version: 3,
const stored: AccountStorageV4 = {
version: 4,
accounts: [
{ refreshToken: "r1", projectId: "p1", addedAt: 1, lastUsed: 0 },
{ refreshToken: "r2", projectId: "p2", addedAt: 1, lastUsed: 0 },
@@ -73,8 +73,8 @@ describe("Antigravity-first fallback", () => {
});
it("skips disabled accounts", () => {
const stored: AccountStorageV3 = {
version: 3,
const stored: AccountStorageV4 = {
version: 4,
accounts: [
{ refreshToken: "r1", projectId: "p1", addedAt: 1, lastUsed: 0 },
{ refreshToken: "r2", projectId: "p2", addedAt: 1, lastUsed: 0, enabled: false },
@@ -99,8 +99,8 @@ describe("Antigravity-first fallback", () => {
});
it("skips cooling down accounts", () => {
const stored: AccountStorageV3 = {
version: 3,
const stored: AccountStorageV4 = {
version: 4,
accounts: [
{ refreshToken: "r1", projectId: "p1", addedAt: 1, lastUsed: 0 },
{ refreshToken: "r2", projectId: "p2", addedAt: 1, lastUsed: 0 },
@@ -126,8 +126,8 @@ describe("Antigravity-first fallback", () => {
});
it("works with model-specific rate limits", () => {
const stored: AccountStorageV3 = {
version: 3,
const stored: AccountStorageV4 = {
version: 4,
accounts: [
{ refreshToken: "r1", projectId: "p1", addedAt: 1, lastUsed: 0 },
{ refreshToken: "r2", projectId: "p2", addedAt: 1, lastUsed: 0 },
@@ -152,8 +152,8 @@ describe("Antigravity-first fallback", () => {
});
it("returns false for Claude family (no gemini-cli fallback)", () => {
const stored: AccountStorageV3 = {
version: 3,
const stored: AccountStorageV4 = {
version: 4,
accounts: [
{ refreshToken: "r1", projectId: "p1", addedAt: 1, lastUsed: 0 },
{ refreshToken: "r2", projectId: "p2", addedAt: 1, lastUsed: 0 },
@@ -177,8 +177,8 @@ describe("Antigravity-first fallback", () => {
describe("Pre-check fallback logic", () => {
it("should switch to account with antigravity rather than fall back to gemini-cli", () => {
const stored: AccountStorageV3 = {
version: 3,
const stored: AccountStorageV4 = {
version: 4,
accounts: [
{ refreshToken: "r1", projectId: "p1", addedAt: 1, lastUsed: 0 },
{ refreshToken: "r2", projectId: "p2", addedAt: 1, lastUsed: 0 },
@@ -211,8 +211,8 @@ describe("Antigravity-first fallback", () => {
});
it("should only fall back to gemini-cli when ALL accounts exhausted antigravity", () => {
const stored: AccountStorageV3 = {
version: 3,
const stored: AccountStorageV4 = {
version: 4,
accounts: [
{ refreshToken: "r1", projectId: "p1", addedAt: 1, lastUsed: 0 },
{ refreshToken: "r2", projectId: "p2", addedAt: 1, lastUsed: 0 },

View File

@@ -11,7 +11,7 @@
import { describe, expect, it, vi, beforeEach, afterEach } from "vitest";
import { promises as fs } from "node:fs";
import * as storageModule from "./storage";
import type { AccountStorageV3, AccountMetadataV3 } from "./storage";
import type { AccountStorageV4, AccountMetadataV3 } from "./storage";
vi.mock("proper-lockfile", () => ({
default: {
@@ -46,9 +46,9 @@ function createMockAccount(overrides: Partial<AccountMetadataV3> = {}): AccountM
};
}
function createMockStorage(accounts: AccountMetadataV3[], activeIndex = 0): AccountStorageV3 {
function createMockStorage(accounts: AccountMetadataV3[], activeIndex = 0): AccountStorageV4 {
return {
version: 3,
version: 4,
accounts,
activeIndex,
};
@@ -83,7 +83,7 @@ describe("loadAccounts", () => {
const result = await storageModule.loadAccounts();
expect(result).not.toBeNull();
expect(result?.version).toBe(3);
expect(result?.version).toBe(4);
expect(result?.accounts).toHaveLength(1);
});
@@ -141,7 +141,7 @@ describe("loadAccounts", () => {
});
it("returns null on invalid storage format", async () => {
vi.mocked(fs.readFile).mockResolvedValue(JSON.stringify({ version: 3, notAccounts: [] }));
vi.mocked(fs.readFile).mockResolvedValue(JSON.stringify({ version: 4, notAccounts: [] }));
const result = await storageModule.loadAccounts();
@@ -175,7 +175,7 @@ describe("loadAccounts", () => {
const result = await storageModule.loadAccounts();
expect(result?.version).toBe(3);
expect(result?.version).toBe(4);
expect(result?.accounts).toHaveLength(1);
});
});
@@ -197,7 +197,7 @@ describe("saveAccounts", () => {
const writtenContent = vi.mocked(fs.writeFile).mock.calls[0]?.[1];
expect(writtenContent).toBeDefined();
const parsed = JSON.parse(writtenContent as string);
expect(parsed.version).toBe(3);
expect(parsed.version).toBe(4);
expect(parsed.accounts).toHaveLength(1);
});
});

View File

@@ -1,7 +1,7 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { ProactiveRefreshQueue } from "./refresh-queue";
import { AccountManager } from "./accounts";
import type { AccountStorageV3 } from "./storage";
import type { AccountStorageV4 } from "./storage";
import type { PluginClient } from "./types";
// Mock PluginClient
@@ -22,8 +22,8 @@ describe("ProactiveRefreshQueue", () => {
describe("getAccountsNeedingRefresh", () => {
it("skips disabled accounts", () => {
const now = Date.now();
const stored: AccountStorageV3 = {
version: 3,
const stored: AccountStorageV4 = {
version: 4,
accounts: [
{
refreshToken: "r1",
@@ -75,8 +75,8 @@ describe("ProactiveRefreshQueue", () => {
it("includes accounts with undefined enabled (default to enabled)", () => {
const now = Date.now();
const stored: AccountStorageV3 = {
version: 3,
const stored: AccountStorageV4 = {
version: 4,
accounts: [
{
refreshToken: "r1",
@@ -109,8 +109,8 @@ describe("ProactiveRefreshQueue", () => {
it("skips expired accounts", () => {
const now = Date.now();
const stored: AccountStorageV3 = {
version: 3,
const stored: AccountStorageV4 = {
version: 4,
accounts: [
{
refreshToken: "r1",
@@ -142,8 +142,8 @@ describe("ProactiveRefreshQueue", () => {
it("skips accounts that don't need refresh yet", () => {
const now = Date.now();
const stored: AccountStorageV3 = {
version: 3,
const stored: AccountStorageV4 = {
version: 4,
accounts: [
{
refreshToken: "r1",

View File

@@ -373,7 +373,7 @@ describe("Storage Migration", () => {
vi.clearAllMocks();
});
it("migrates V2 storage on load and persists V3", async () => {
it("migrates V2 storage on load and persists V4", async () => {
const v2Data = {
version: 2,
accounts: [
@@ -402,7 +402,7 @@ describe("Storage Migration", () => {
const result = await loadAccounts();
expect(result).not.toBeNull();
expect(result?.version).toBe(3);
expect(result?.version).toBe(4);
const account = result?.accounts[0];
if (!account) throw new Error("Account not found");
@@ -419,7 +419,7 @@ describe("Storage Migration", () => {
if (!saveCall) throw new Error("saveAccounts was not called (tmp file not found)");
const savedContent = JSON.parse(saveCall[1] as string);
expect(savedContent.version).toBe(3);
expect(savedContent.version).toBe(4);
expect(savedContent.accounts[0].rateLimitResetTimes).toEqual({
"gemini-antigravity": future,
});

View File

@@ -193,6 +193,7 @@ export interface AccountMetadataV3 {
cooldownReason?: CooldownReason;
/** Per-account device fingerprint for rate limit mitigation */
fingerprint?: import("./fingerprint").Fingerprint;
fingerprintHistory?: import("./fingerprint").FingerprintVersion[];
/** Set when Google asks the user to verify this account before requests can continue. */
verificationRequired?: boolean;
verificationRequiredAt?: number;
@@ -213,7 +214,21 @@ export interface AccountStorageV3 {
};
}
type AnyAccountStorage = AccountStorageV1 | AccountStorage | AccountStorageV3;
export interface AccountStorageV4 {
version: 4;
accounts: AccountMetadataV3[];
activeIndex: number;
activeIndexByFamily?: {
claude?: number;
gemini?: number;
};
}
type AnyAccountStorage =
| AccountStorageV1
| AccountStorage
| AccountStorageV3
| AccountStorageV4;
/**
* Gets the legacy Windows config directory (%APPDATA%\opencode).
@@ -361,7 +376,7 @@ async function ensureFileExists(path: string): Promise<void> {
await fs.mkdir(dirname(path), { recursive: true });
await fs.writeFile(
path,
JSON.stringify({ version: 3, accounts: [], activeIndex: 0 }, null, 2),
JSON.stringify({ version: 4, accounts: [], activeIndex: 0 }, null, 2),
{ encoding: "utf-8", mode: 0o600 },
);
}
@@ -385,9 +400,9 @@ async function withFileLock<T>(path: string, fn: () => Promise<T>): Promise<T> {
}
function mergeAccountStorage(
existing: AccountStorageV3,
incoming: AccountStorageV3,
): AccountStorageV3 {
existing: AccountStorageV4,
incoming: AccountStorageV4,
): AccountStorageV4 {
const accountMap = new Map<string, AccountMetadataV3>();
for (const acc of existing.accounts) {
@@ -419,7 +434,7 @@ function mergeAccountStorage(
}
return {
version: 3,
version: 4,
accounts: Array.from(accountMap.values()),
activeIndex: incoming.activeIndex,
activeIndexByFamily: incoming.activeIndexByFamily,
@@ -558,7 +573,20 @@ export function migrateV2ToV3(v2: AccountStorage): AccountStorageV3 {
};
}
export async function loadAccounts(): Promise<AccountStorageV3 | null> {
export function migrateV3ToV4(v3: AccountStorageV3): AccountStorageV4 {
return {
version: 4,
accounts: v3.accounts.map((acc) => ({
...acc,
fingerprint: undefined,
fingerprintHistory: undefined,
})),
activeIndex: v3.activeIndex,
activeIndexByFamily: v3.activeIndexByFamily,
};
}
export async function loadAccounts(): Promise<AccountStorageV4 | null> {
try {
const path = getStoragePath();
// Ensure permissions are correct on load (fixes existing files)
@@ -572,32 +600,45 @@ export async function loadAccounts(): Promise<AccountStorageV3 | null> {
return null;
}
let storage: AccountStorageV3;
let storage: AccountStorageV4;
if (data.version === 1) {
log.info("Migrating account storage from v1 to v3");
log.info("Migrating account storage from v1 to v4");
const v2 = migrateV1ToV2(data);
storage = migrateV2ToV3(v2);
const v3 = migrateV2ToV3(v2);
storage = migrateV3ToV4(v3);
try {
await saveAccounts(storage);
log.info("Migration to v3 complete");
log.info("Migration to v4 complete");
} catch (saveError) {
log.warn("Failed to persist migrated storage", {
error: String(saveError),
});
}
} else if (data.version === 2) {
log.info("Migrating account storage from v2 to v3");
storage = migrateV2ToV3(data);
log.info("Migrating account storage from v2 to v4");
const v3 = migrateV2ToV3(data);
storage = migrateV3ToV4(v3);
try {
await saveAccounts(storage);
log.info("Migration to v3 complete");
log.info("Migration to v4 complete");
} catch (saveError) {
log.warn("Failed to persist migrated storage", {
error: String(saveError),
});
}
} else if (data.version === 3) {
log.info("Migrating account storage from v3 to v4");
storage = migrateV3ToV4(data);
try {
await saveAccounts(storage);
log.info("Migration to v4 complete");
} catch (saveError) {
log.warn("Failed to persist migrated storage", {
error: String(saveError),
});
}
} else if (data.version === 4) {
storage = data;
} else {
log.warn("Unknown storage version, ignoring", {
@@ -634,7 +675,7 @@ export async function loadAccounts(): Promise<AccountStorageV3 | null> {
}
return {
version: 3,
version: 4,
accounts: deduplicatedAccounts,
activeIndex,
};
@@ -648,7 +689,7 @@ export async function loadAccounts(): Promise<AccountStorageV3 | null> {
}
}
export async function saveAccounts(storage: AccountStorageV3): Promise<void> {
export async function saveAccounts(storage: AccountStorageV4): Promise<void> {
const path = getStoragePath();
const configDir = dirname(path);
await fs.mkdir(configDir, { recursive: true });
@@ -681,7 +722,7 @@ export async function saveAccounts(storage: AccountStorageV3): Promise<void> {
* Use this for destructive operations like delete where we need to
* remove accounts that would otherwise be merged back from existing storage.
*/
export async function saveAccountsReplace(storage: AccountStorageV3): Promise<void> {
export async function saveAccountsReplace(storage: AccountStorageV4): Promise<void> {
const path = getStoragePath();
const configDir = dirname(path);
await fs.mkdir(configDir, { recursive: true });
@@ -705,7 +746,7 @@ export async function saveAccountsReplace(storage: AccountStorageV3): Promise<vo
});
}
async function loadAccountsUnsafe(): Promise<AccountStorageV3 | null> {
async function loadAccountsUnsafe(): Promise<AccountStorageV4 | null> {
try {
const path = getStoragePath();
// Ensure permissions are correct on load (fixes existing files)
@@ -715,10 +756,13 @@ async function loadAccountsUnsafe(): Promise<AccountStorageV3 | null> {
const parsed = JSON.parse(content);
if (parsed.version === 1) {
return migrateV2ToV3(migrateV1ToV2(parsed));
return migrateV3ToV4(migrateV2ToV3(migrateV1ToV2(parsed)));
}
if (parsed.version === 2) {
return migrateV2ToV3(parsed);
return migrateV3ToV4(migrateV2ToV3(parsed));
}
if (parsed.version === 3) {
return migrateV3ToV4(parsed);
}
return {