feat(auth): multi-account registry store + migration (slice 3, PR 1/3)#115
Merged
Conversation
Introduces the schema-v2 account registry that backs quick-switch, with no
user-visible behaviour change yet — a single account behaves exactly as
before.
Store (github-token-store.ts):
- AccountRegistry { schemaVersion, activeKey, accounts: Record<login@host> }
+ AccountRecord { login, host, token, tokenType, addedVia, obtainedAt }.
- Pure ops: addAndActivate / setActive / removeAccount / getActiveRecord /
listAccounts. Atomic temp+rename writes (a torn write would lose every
account). RMW shorthands addAccountToDefaultRegistry /
removeActiveFromDefaultRegistry.
- migrateLegacyRecord: one-time lift of the legacy single-record file into
the registry, keyed login@host (resolveLogin injected; offline →
unknown@host so the token is never lost). Gated + leaves the legacy file
as a rollback fallback.
- readDefaultRecord rewritten as a registry-aware back-compat wrapper
(active record → legacy-file fallback), so boot / CLI reuse-check /
setup-status / debug keep working unchanged.
Wiring:
- The three sign-in producers (device-code, CLI, gh-reuse) now persist a
typed AccountRecord (addedVia device-code/gh-cli) instead of the bare
single record. Each resolves login best-effort; host = gh format via a new
dependency-free github-host leaf (kept out of api-config to avoid an
import cycle).
- signOut drops the active account from the registry (behaviour-neutral for
one account) + still removes the legacy file.
- Boot runs the migration before reading the active account.
- uninstall --purge removes the registry too; debug + setup-status read the
active record.
The contentious sign-out-keeps-copy lifecycle decision is deliberately NOT
made here — it is only user-visible with the quick-switch UI (PR 3). PR 1 is
behaviour-neutral. +15 store/migration tests; auth-controller DI seam gains
addAccount/removeActiveAccount.
| ): Promise<void> { | ||
| const json = `${JSON.stringify(value, null, 2)}\n` | ||
| const tmp = `${filePath}.tmp.${process.pid}` | ||
| await fs.writeFile(tmp, json, { mode: 0o600 }) |
This was referenced Jun 11, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What
PR 1 of 3 for slice 3 — multi-account persistence + quick-switch. This lays the persistence foundation with no user-visible behaviour change: a single account behaves exactly as today.
Store (
github-token-store.ts)AccountRegistry { schemaVersion: 2, activeKey, accounts: Record<"login@host", AccountRecord> };AccountRecord { login, host, token, tokenType, addedVia, obtainedAt }. Identity key =login@host(gh's host format, so a maximal device-code account and the same gh-imported account collapse to one entry).addAndActivate/setActive/removeAccount/getActiveRecord/listAccounts. Atomic temp+rename writes (a torn write would lose every account). RMW shorthandsaddAccountToDefaultRegistry/removeActiveFromDefaultRegistry.migrateLegacyRecord: one-time lift of the legacy single-record file → registry, keyedlogin@host(resolveLogininjected; offline →unknown@hostso the token is never lost). Gated on empty-registry; leaves the legacy file as a rollback fallback.readDefaultRecordrewritten as a registry-aware back-compat wrapper (active record → legacy-file fallback), so boot / CLI reuse-check / setup-status / debug keep working unchanged.Wiring
AccountRecord(addedViadevice-code/gh-cli). Host comes from a new dependency-freegithub-hostleaf — deliberately kept out ofapi-configso the token-store writers don't close an import cycle (api-config → state → get-models → error → auth-controller).deps:checkcycle count stays at the baseline (23).signOutdrops the active account from the registry (behaviour-neutral for one account) + still removes the legacy file.uninstall --purgeremoves the registry too;debug+setup-statusread the active record.Scope discipline
The contentious sign-out-keeps-copy lifecycle decision is deliberately not made here — it's only user-visible with the quick-switch UI (PR 3), so PR 1 stays behaviour-neutral and independently releasable. PR 2 =
/accountsroutes (list/switch/remove); PR 3 = the picker UI.Testing
bun test— 840 pass / 0 fail (+15 registry + migration tests: pure ops, persistence round-trip, atomic 0600, corrupt→empty, migration login-keyed / offline-fallback / gated-noop / obtainedAt-preserved).bun run check:fastclean;deps:check0 errors (cycle-neutral);knipno new unused exports; shelltscclean./simplify(2× parallel reviewers) — converged on "design sound, ship"; the one actionable item (a brittle path-replace in setup-status) was extracted intoregistryPathFor().