Skip to content
Merged
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
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
# Speed up account switching and prepare v0.1.20

## Why

Manual npm publish failed because `0.1.19` already exists. The next release should move to `0.1.20` and keep account switching responsive when official `codex login` refreshes an existing saved account.

## What changes

- Reuse registry account metadata to resolve matching relogin snapshots before parsing every saved account file.
- Record direct switch session account and auth fingerprint in one session-map update.
- Bump npm package metadata to `0.1.20`.
- Add `releases/v0.1.20.md` with publish-ready notes.

## Verification

- `npm test --silent`
- `npm pack --dry-run`
- `openspec validate agent-codex-speed-up-account-switching-v0-1-20-2026-04-28-22-23 --strict`
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
# account-switching-speed Spec Delta

## ADDED Requirements

### Requirement: Relogin sync uses registry identity metadata first

External Codex login sync SHALL try saved-account registry identity metadata before falling back to parsing every saved snapshot file.

#### Scenario: registry identifies an alias for refreshed login bytes

- **GIVEN** a saved alias has matching `accountId`, `userId`, or email metadata in the registry
- **AND** `auth.json` contains fresh login bytes for that identity
- **WHEN** external sync runs
- **THEN** the alias snapshot is refreshed in place
- **AND** no duplicate email-named snapshot is created

### Requirement: Direct switch writes session state once

`codex-auth use <account>` SHALL record the active session account and auth fingerprint without requiring a second session-map update after the snapshot copy.

#### Scenario: account switch records fingerprint

- **WHEN** the user runs `codex-auth use team-primary`
- **THEN** `auth.json` is replaced with the saved snapshot
- **AND** the session map records `team-primary` and the copied auth fingerprint in the same session-state update path

### Requirement: v0.1.20 release prep is publishable manually

The next manual npm publish prep SHALL update package metadata and release notes to `0.1.20`.

#### Scenario: prepare next npm publish version

- **GIVEN** npm rejects publishing `0.1.19` because it already exists
- **WHEN** the next patch release is prepared
- **THEN** `package.json` and `package-lock.json` are updated to `0.1.20`
- **AND** `releases/v0.1.20.md` exists with manual publish instructions
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
# Tasks

## 1. Spec

- [x] Define registry-guided relogin matching and v0.1.20 release prep behavior.

## 2. Tests

- [x] Add regression coverage for registry-guided alias refresh.
- [x] Run `npm test --silent`.
- [x] Run `npm pack --dry-run`.

## 3. Implementation

- [x] Use registry metadata before saved snapshot parsing during external login sync.
- [x] Collapse direct account switch session-state updates.
- [x] Bump package metadata to `0.1.20`.
- [x] Add `releases/v0.1.20.md`.

## 4. Verification

- [x] Run `openspec validate agent-codex-speed-up-account-switching-v0-1-20-2026-04-28-22-23 --strict`.

## 5. Cleanup

- [ ] Commit, push, create/update PR, wait for `MERGED`, and prune sandbox with `gx branch finish --branch agent/codex/speed-up-account-switching-v0-1-20-2026-04-28-22-23 --base main --via-pr --wait-for-merge --cleanup`.
- [ ] Record PR URL and final `MERGED` evidence.
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@imdeadpool/codex-account-switcher",
"version": "0.1.19",
"version": "0.1.20",
"description": "A command-line tool that lets you manage and switch between multiple Codex accounts instantly, no more constant logins and logouts.",
"license": "MIT",
"bin": {
Expand Down
25 changes: 25 additions & 0 deletions releases/v0.1.20.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
# codex-account-switcher v0.1.20

## Summary
This patch keeps `codex-auth use` and official `codex login` snapshot sync on faster paths, then bumps the package to the next publishable npm version.

## What's changed
- Updated package metadata from `0.1.19` to `0.1.20`.
- Reused registry identity metadata during external login sync before falling back to parsing saved snapshots.
- Collapsed direct account switching session-state updates so `codex-auth use <account>` records the active account and auth fingerprint in one session-map write.
- Added regression coverage for registry-guided alias refresh when a saved snapshot needs to be overwritten from fresh login bytes.

## Verification
- `npm test`
- `npm pack --dry-run`

## Upgrade notes
- No breaking CLI changes.
- Manual publish should use `npm publish --access public` after verification.

## Publish checklist
1. Review package metadata and release notes.
2. `npm test`
3. `npm pack --dry-run`
4. `npm publish --access public`
5. Create GitHub release `v0.1.20` from `releases/v0.1.20.md`
84 changes: 78 additions & 6 deletions src/lib/accounts/account-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ import {
RegistryData,
StatusReport,
UsageSnapshot,
AccountRegistryEntry,
} from "./types";
import {
fetchUsageFromApi,
Expand Down Expand Up @@ -908,11 +909,11 @@ export class AccountService {
}
}

private async writeCurrentName(name: string): Promise<void> {
private async writeCurrentName(name: string, options?: { authFingerprint?: string }): Promise<void> {
const currentNamePath = resolveCurrentNamePath();
await this.ensureDir(path.dirname(currentNamePath));
await fsp.writeFile(currentNamePath, `${name}\n`, "utf8");
await this.setSessionAccountName(name);
await this.setSessionAccountName(name, options?.authFingerprint);
}

private async readCurrentNameFile(currentNamePath: string): Promise<string | null> {
Expand Down Expand Up @@ -1012,6 +1013,14 @@ export class AccountService {
let emailMatch: ResolvedDefaultAccountName | null = null;
const accountNames = await this.listAccountNames();
const candidates = this.orderReloginSnapshotCandidates(accountNames, incomingSnapshot, activeName);
const registryMatch = await this.resolveRegistryAccountNameForIncomingSnapshot(
incomingSnapshot,
candidates,
activeName,
);
if (registryMatch) {
return registryMatch;
}

for (const name of candidates) {
const snapshotPath = this.accountFilePath(name);
Expand All @@ -1037,6 +1046,41 @@ export class AccountService {
return emailMatch;
}

private async resolveRegistryAccountNameForIncomingSnapshot(
incomingSnapshot: ParsedAuthSnapshot,
candidates: string[],
activeName: string | null,
): Promise<ResolvedDefaultAccountName | null> {
const registry = await loadRegistry();
let activeEmailMatch: ResolvedDefaultAccountName | null = null;

for (const name of candidates) {
const entry = registry.accounts[name];
if (!entry || !(await this.pathExists(this.accountFilePath(name)))) continue;

if (this.registryEntrySharesIdentity(entry, incomingSnapshot)) {
return {
name,
source: activeName === name ? "active" : "existing",
};
}

if (
!activeEmailMatch &&
activeName === name &&
this.registryEntrySharesEmail(entry, incomingSnapshot)
) {
activeEmailMatch = {
name,
source: "active",
forceOverwrite: true,
};
}
}

return activeEmailMatch;
}

private orderReloginSnapshotCandidates(
accountNames: string[],
incomingSnapshot: ParsedAuthSnapshot,
Expand Down Expand Up @@ -1132,8 +1176,10 @@ export class AccountService {
await this.ensureDir(path.dirname(authPath));
await fsp.copyFile(source, authPath);

await this.writeCurrentName(name);
await this.rememberSessionAuthFingerprint(authPath);
const authState = await this.readAuthSyncState(authPath);
await this.writeCurrentName(name, {
authFingerprint: authState && !authState.isSymbolicLink ? authState.fingerprint : undefined,
});
}

private async clearActivePointers(): Promise<void> {
Expand Down Expand Up @@ -1205,15 +1251,15 @@ export class AccountService {
return null;
}

private async setSessionAccountName(accountName: string): Promise<void> {
private async setSessionAccountName(accountName: string, authFingerprint?: string): Promise<void> {
const sessionKey = this.resolveSessionScopeKey();
if (!sessionKey) return;

const sessionMap = await this.readSessionMap();
const existing = sessionMap.sessions[sessionKey];
sessionMap.sessions[sessionKey] = {
accountName,
authFingerprint: existing?.authFingerprint,
authFingerprint: authFingerprint ?? existing?.authFingerprint,
updatedAt: new Date().toISOString(),
};
await this.writeSessionMap(sessionMap);
Expand Down Expand Up @@ -1393,6 +1439,32 @@ export class AccountService {
return false;
}

private registryEntrySharesIdentity(entry: AccountRegistryEntry, snapshot: ParsedAuthSnapshot): boolean {
if (snapshot.authMode !== "chatgpt") {
return false;
}

if (entry.userId && snapshot.userId && entry.accountId && snapshot.accountId) {
return entry.userId === snapshot.userId && entry.accountId === snapshot.accountId;
}

if (entry.accountId && snapshot.accountId) {
return entry.accountId === snapshot.accountId;
}

if (entry.userId && snapshot.userId) {
return entry.userId === snapshot.userId;
}

return this.registryEntrySharesEmail(entry, snapshot);
}

private registryEntrySharesEmail(entry: AccountRegistryEntry, snapshot: ParsedAuthSnapshot): boolean {
const entryEmail = entry.email?.trim().toLowerCase();
const snapshotEmail = snapshot.email?.trim().toLowerCase();
return Boolean(entryEmail && snapshotEmail && entryEmail === snapshotEmail);
}

private snapshotsShareEmail(a: ParsedAuthSnapshot, b: ParsedAuthSnapshot): boolean {
const aEmail = a.email?.trim().toLowerCase();
const bEmail = b.email?.trim().toLowerCase();
Expand Down
79 changes: 79 additions & 0 deletions src/tests/save-account-safety.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -855,6 +855,85 @@ test("syncExternalAuthSnapshotIfNeeded reuses a saved alias that matches relogin
});
});

test("syncExternalAuthSnapshotIfNeeded uses registry metadata before parsing every saved snapshot", async (t) => {
await withIsolatedCodexDir(t, async ({ codexDir, accountsDir, authPath }) => {
const service = new AccountService();
const activeName = "primary@edixai.com";
const savedAlias = "team-primary";
const incomingEmail = "admin@kozpontihusbolt.hu";
const currentPath = path.join(codexDir, "current");
const registryPath = path.join(accountsDir, "registry.json");

await fsp.writeFile(
path.join(accountsDir, `${activeName}.json`),
buildAuthPayload(activeName, {
accountId: "acct-primary",
userId: "user-primary",
}),
"utf8",
);
await fsp.writeFile(path.join(accountsDir, `${savedAlias}.json`), "{broken", "utf8");
await fsp.writeFile(currentPath, `${activeName}\n`, "utf8");
await fsp.writeFile(
registryPath,
`${JSON.stringify(
{
version: 1,
autoSwitch: {
enabled: false,
threshold5hPercent: 10,
thresholdWeeklyPercent: 5,
},
api: {
usage: true,
},
activeAccountName: activeName,
accounts: {
[activeName]: {
name: activeName,
createdAt: new Date().toISOString(),
email: activeName,
accountId: "acct-primary",
userId: "user-primary",
},
[savedAlias]: {
name: savedAlias,
createdAt: new Date().toISOString(),
email: incomingEmail,
accountId: "acct-team",
userId: "user-team",
},
},
},
null,
2,
)}\n`,
"utf8",
);
await fsp.writeFile(
authPath,
buildAuthPayload(incomingEmail, {
accountId: "acct-team",
userId: "user-team",
tokenSeed: "post-login",
}),
"utf8",
);

const result = await service.syncExternalAuthSnapshotIfNeeded();
assert.deepEqual(result, {
synchronized: true,
savedName: savedAlias,
autoSwitchDisabled: false,
});

assert.equal((await fsp.readFile(currentPath, "utf8")).trim(), savedAlias);
const aliasSnapshotRaw = await fsp.readFile(path.join(accountsDir, `${savedAlias}.json`), "utf8");
assert.match(aliasSnapshotRaw, /token-post-login/);
await assert.rejects(() => fsp.access(path.join(accountsDir, `${incomingEmail}.json`)));
});
});

test("syncExternalAuthSnapshotIfNeeded refreshes active canonical email snapshot instead of creating a duplicate", async (t) => {
await withIsolatedCodexDir(t, async ({ codexDir, accountsDir, authPath }) => {
const service = new AccountService();
Expand Down