From a9d639f3e2e4c49081ea066157ce17ed92a86bd8 Mon Sep 17 00:00:00 2001 From: NagyVikt Date: Tue, 28 Apr 2026 22:30:19 +0200 Subject: [PATCH] Speed up relogin matching before v0.1.20 publish Manual npm publish is blocked on the already-published 0.1.19 version, so this prepares the next patch while tightening the account-switch hot path. External login sync now uses registry identity metadata before parsing saved snapshots, and direct switching records the copied auth fingerprint during the same session-state update. Constraint: npm refuses republishing 0.1.19 Rejected: Parse every saved snapshot before checking registry metadata | slower for multi-account installs and unnecessary when registry identity is fresh Confidence: high Scope-risk: narrow Directive: Keep codex-auth use on the no-preflight fast path and preserve alias names during relogin refresh Tested: npm test --silent; npm pack --dry-run; openspec validate agent-codex-speed-up-account-switching-v0-1-20-2026-04-28-22-23 --strict; git diff --check Not-tested: npm publish --access public because user wants to publish manually --- .../proposal.md | 18 ++++ .../specs/account-switching-speed/spec.md | 36 ++++++++ .../tasks.md | 27 ++++++ package-lock.json | 4 +- package.json | 2 +- releases/v0.1.20.md | 25 ++++++ src/lib/accounts/account-service.ts | 84 +++++++++++++++++-- src/tests/save-account-safety.test.ts | 79 +++++++++++++++++ 8 files changed, 266 insertions(+), 9 deletions(-) create mode 100644 openspec/changes/agent-codex-speed-up-account-switching-v0-1-20-2026-04-28-22-23/proposal.md create mode 100644 openspec/changes/agent-codex-speed-up-account-switching-v0-1-20-2026-04-28-22-23/specs/account-switching-speed/spec.md create mode 100644 openspec/changes/agent-codex-speed-up-account-switching-v0-1-20-2026-04-28-22-23/tasks.md create mode 100644 releases/v0.1.20.md diff --git a/openspec/changes/agent-codex-speed-up-account-switching-v0-1-20-2026-04-28-22-23/proposal.md b/openspec/changes/agent-codex-speed-up-account-switching-v0-1-20-2026-04-28-22-23/proposal.md new file mode 100644 index 0000000..d4e586c --- /dev/null +++ b/openspec/changes/agent-codex-speed-up-account-switching-v0-1-20-2026-04-28-22-23/proposal.md @@ -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` diff --git a/openspec/changes/agent-codex-speed-up-account-switching-v0-1-20-2026-04-28-22-23/specs/account-switching-speed/spec.md b/openspec/changes/agent-codex-speed-up-account-switching-v0-1-20-2026-04-28-22-23/specs/account-switching-speed/spec.md new file mode 100644 index 0000000..080f50e --- /dev/null +++ b/openspec/changes/agent-codex-speed-up-account-switching-v0-1-20-2026-04-28-22-23/specs/account-switching-speed/spec.md @@ -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 ` 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 diff --git a/openspec/changes/agent-codex-speed-up-account-switching-v0-1-20-2026-04-28-22-23/tasks.md b/openspec/changes/agent-codex-speed-up-account-switching-v0-1-20-2026-04-28-22-23/tasks.md new file mode 100644 index 0000000..32df22e --- /dev/null +++ b/openspec/changes/agent-codex-speed-up-account-switching-v0-1-20-2026-04-28-22-23/tasks.md @@ -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. diff --git a/package-lock.json b/package-lock.json index 743ffdc..0bb5a1d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@imdeadpool/codex-account-switcher", - "version": "0.1.19", + "version": "0.1.20", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@imdeadpool/codex-account-switcher", - "version": "0.1.19", + "version": "0.1.20", "hasInstallScript": true, "license": "MIT", "dependencies": { diff --git a/package.json b/package.json index a69103b..e058172 100644 --- a/package.json +++ b/package.json @@ -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": { diff --git a/releases/v0.1.20.md b/releases/v0.1.20.md new file mode 100644 index 0000000..92cb4ad --- /dev/null +++ b/releases/v0.1.20.md @@ -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 ` 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` diff --git a/src/lib/accounts/account-service.ts b/src/lib/accounts/account-service.ts index 49867c7..175a22b 100644 --- a/src/lib/accounts/account-service.ts +++ b/src/lib/accounts/account-service.ts @@ -31,6 +31,7 @@ import { RegistryData, StatusReport, UsageSnapshot, + AccountRegistryEntry, } from "./types"; import { fetchUsageFromApi, @@ -908,11 +909,11 @@ export class AccountService { } } - private async writeCurrentName(name: string): Promise { + private async writeCurrentName(name: string, options?: { authFingerprint?: string }): Promise { 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 { @@ -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); @@ -1037,6 +1046,41 @@ export class AccountService { return emailMatch; } + private async resolveRegistryAccountNameForIncomingSnapshot( + incomingSnapshot: ParsedAuthSnapshot, + candidates: string[], + activeName: string | null, + ): Promise { + 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, @@ -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 { @@ -1205,7 +1251,7 @@ export class AccountService { return null; } - private async setSessionAccountName(accountName: string): Promise { + private async setSessionAccountName(accountName: string, authFingerprint?: string): Promise { const sessionKey = this.resolveSessionScopeKey(); if (!sessionKey) return; @@ -1213,7 +1259,7 @@ export class AccountService { const existing = sessionMap.sessions[sessionKey]; sessionMap.sessions[sessionKey] = { accountName, - authFingerprint: existing?.authFingerprint, + authFingerprint: authFingerprint ?? existing?.authFingerprint, updatedAt: new Date().toISOString(), }; await this.writeSessionMap(sessionMap); @@ -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(); diff --git a/src/tests/save-account-safety.test.ts b/src/tests/save-account-safety.test.ts index a5e6a78..cd8ac3e 100644 --- a/src/tests/save-account-safety.test.ts +++ b/src/tests/save-account-safety.test.ts @@ -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();