Skip to content

Commit 262ac9e

Browse files
NagyViktNagyViktclaude
authored
refactor(accounts): split account-service.ts into 12 focused modules (N2) (#30)
* refactor(accounts): split account-service.ts into 12 focused modules (N2) Reduce the 1,675-LOC god-file at src/lib/accounts/account-service.ts to a 164-LOC orchestrator that delegates every public method to a focused free-function module. No public API change: every signature on `AccountService` is preserved and the `accountService` singleton in `src/lib/accounts/index.ts` continues to work for `BaseCommand` and the existing direct importers (`commands/check.ts`, `commands/forecast.ts`, `commands/auto-switch.ts`, two test files). Target modules (per `docs/future/01-ARCHITECTURE.md` §2.1): - sync/external-sync.ts (216 LOC) — syncExternalAuthSnapshot... - read/listing.ts (211 LOC) — list/find + getCurrentAccountName - write/save.ts (159 LOC) — saveAccount + safety guard - write/use.ts (128 LOC) — useAccount + activateSnapshot - write/remove.ts (105 LOC) — remove one / by query / all - config/auto-switch-config.ts ( 82 LOC) — status + threshold setters - auto-switch/policy.ts (141 LOC) — runAutoSwitchOnce + daemon - usage/adapter.ts (192 LOC) — usage refresh + proxy shim - safety/snapshot-vault.ts (125 LOC) — backup + clobber recovery - session/pin.ts (243 LOC) — sessions.json + Linux PPID - identity/equality.ts ( 86 LOC) — snapshot identity comparisons - naming.ts ( 40 LOC) — normalizeAccountName + path Shared internal helpers under `_internal/`: - _internal/fs-helpers.ts — pathExists / filesMatch / auth-state read - _internal/auth-state.ts — symlink materialize, current-name file I/O - _internal/registry-ops.ts — persistRegistry + metadata hydration - _internal/name-resolution.ts — login/inferred name resolution Behavior is byte-identical: every existing test in the pre-N2 suite still passes. Wave-1 contracts honored — atomic writes via N1's `persistRegistryAtomic`/`secureWriteFile`, errors thrown via N3's `AuthmuxError` subclasses, paths resolved per-call via N4's `resolve*` getters. New tests (31 cases across 3 files): - src/tests/identity-equality.test.ts - src/tests/naming.test.ts - src/tests/accounts-modules.test.ts (covers listing, session/pin, snapshot-vault, auto-switch-config, auto-switch-policy, usage-adapter, write/use, write/remove, external-sync) Verification: - npm run build: clean - npm test: 129 passing, 0 failing (was 98 pre-extraction) - wc -l src/lib/accounts/account-service.ts → 164 (< 400 ceiling, ~150 goal) - singleton: all 21 public methods still resolve as functions - node dist/index.js --help: CLI boots and lists every command Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(test): make naming.test.ts portable on Windows The accountFilePath test hardcoded "/tmp/authmux-test-accounts" and asserted equality against a forward-slash POSIX path. On Windows the resolved path comes back as "D:\tmp\authmux-test-accounts\..." which fails the strict-equal check across all 3 Node versions in CI. Build the expected path through os.tmpdir() + path.join() so both sides go through the same platform-aware joining and the test passes on Ubuntu, macOS, and Windows. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: NagyVikt <nagy.viktordp@gmail.com> Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 64349b3 commit 262ac9e

20 files changed

Lines changed: 2728 additions & 1619 deletions
Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
// Internal: auth.json read/state helpers — symlink materialization,
2+
// ensure-exists guard, current-name file I/O. Extracted from the
3+
// monolithic AccountService (Theme N2).
4+
5+
import path from "node:path";
6+
import fsp from "node:fs/promises";
7+
import {
8+
resolveAuthPath,
9+
resolveCurrentNamePath,
10+
} from "../../config/paths";
11+
import {
12+
ensureSecureDir,
13+
secureWriteFile,
14+
} from "../../io/secure-fs";
15+
import { AuthFileMissingError } from "../errors";
16+
import {
17+
setSessionAccountName,
18+
} from "../session/pin";
19+
import { pathExists, removeIfExists } from "./fs-helpers";
20+
21+
export async function ensureAuthFileExists(authPath: string): Promise<void> {
22+
if (!(await pathExists(authPath))) {
23+
throw new AuthFileMissingError(authPath);
24+
}
25+
}
26+
27+
export async function materializeAuthSymlink(authPath: string): Promise<void> {
28+
const stat = await fsp.lstat(authPath);
29+
if (!stat.isSymbolicLink()) {
30+
return;
31+
}
32+
33+
const snapshotData = await fsp.readFile(authPath);
34+
await removeIfExists(authPath);
35+
await secureWriteFile(authPath, snapshotData);
36+
}
37+
38+
export async function writeCurrentName(
39+
name: string,
40+
options?: { authFingerprint?: string },
41+
): Promise<void> {
42+
const currentNamePath = resolveCurrentNamePath();
43+
await ensureSecureDir(path.dirname(currentNamePath));
44+
await secureWriteFile(currentNamePath, `${name}\n`);
45+
await setSessionAccountName(name, options?.authFingerprint);
46+
}
47+
48+
export async function readCurrentNameFile(currentNamePath: string): Promise<string | null> {
49+
try {
50+
const contents = await fsp.readFile(currentNamePath, "utf8");
51+
const trimmed = contents.trim();
52+
return trimmed.length ? trimmed : null;
53+
} catch (error) {
54+
const err = error as NodeJS.ErrnoException;
55+
if (err.code === "ENOENT") {
56+
return null;
57+
}
58+
throw error;
59+
}
60+
}
61+
62+
export async function clearActivePointers(
63+
clearSessionAccountName: () => Promise<void>,
64+
): Promise<void> {
65+
const currentPath = resolveCurrentNamePath();
66+
const authPath = resolveAuthPath();
67+
await removeIfExists(currentPath);
68+
await removeIfExists(authPath);
69+
await clearSessionAccountName();
70+
}
Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
// Internal filesystem helpers shared across the extracted clusters.
2+
// Not part of the public API — only modules under `src/lib/accounts/`
3+
// should import from here.
4+
5+
import fs from "node:fs";
6+
import fsp from "node:fs/promises";
7+
8+
export async function pathExists(targetPath: string): Promise<boolean> {
9+
try {
10+
await fsp.access(targetPath, fs.constants.F_OK);
11+
return true;
12+
} catch {
13+
return false;
14+
}
15+
}
16+
17+
export async function filesMatch(firstPath: string, secondPath: string): Promise<boolean> {
18+
try {
19+
const [first, second] = await Promise.all([
20+
fsp.readFile(firstPath),
21+
fsp.readFile(secondPath),
22+
]);
23+
return first.equals(second);
24+
} catch {
25+
return false;
26+
}
27+
}
28+
29+
export async function removeIfExists(target: string): Promise<void> {
30+
try {
31+
await fsp.rm(target, { force: true });
32+
} catch (error) {
33+
const err = error as NodeJS.ErrnoException;
34+
if (err.code !== "ENOENT") {
35+
throw error;
36+
}
37+
}
38+
}
39+
40+
export interface AuthSyncState {
41+
fingerprint: string;
42+
isSymbolicLink: boolean;
43+
}
44+
45+
export async function readAuthSyncState(authPath: string): Promise<AuthSyncState | null> {
46+
try {
47+
const stat = await fsp.lstat(authPath);
48+
return {
49+
fingerprint: createAuthSyncFingerprint(stat),
50+
isSymbolicLink: stat.isSymbolicLink(),
51+
};
52+
} catch {
53+
return null;
54+
}
55+
}
56+
57+
export function createAuthSyncFingerprint(stat: fs.Stats): string {
58+
return [
59+
stat.isSymbolicLink() ? "symlink" : "file",
60+
typeof stat.ino === "number" ? Math.trunc(stat.ino) : 0,
61+
Math.trunc(stat.size),
62+
Math.trunc(stat.mtimeMs),
63+
].join(":");
64+
}
Lines changed: 196 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,196 @@
1+
// Internal: resolve the snapshot file name for a given live auth snapshot.
2+
// Used by save/use/external-sync — kept under _internal/ because the
3+
// public surface only exposes the orchestrator wrappers.
4+
5+
import {
6+
AccountNameInferenceError,
7+
} from "../errors";
8+
import { parseAuthSnapshotFile } from "../auth-parser";
9+
import { loadRegistry } from "../registry";
10+
import { ParsedAuthSnapshot } from "../types";
11+
import { listAccountNames } from "../read/listing";
12+
import { accountFilePath, normalizeAccountName } from "../naming";
13+
import { pathExists } from "./fs-helpers";
14+
import {
15+
registryEntrySharesEmail,
16+
registryEntrySharesIdentity,
17+
snapshotsShareEmail,
18+
snapshotsShareIdentity,
19+
} from "../identity/equality";
20+
21+
export type ResolvedAccountNameSource = "active" | "existing" | "inferred";
22+
23+
export interface ResolvedDefaultAccountName {
24+
name: string;
25+
source: ResolvedAccountNameSource;
26+
forceOverwrite?: boolean;
27+
}
28+
29+
export interface ResolvedLoginAccountName {
30+
name: string;
31+
source: ResolvedAccountNameSource;
32+
forceOverwrite?: boolean;
33+
}
34+
35+
function orderReloginSnapshotCandidates(
36+
accountNames: string[],
37+
incomingSnapshot: ParsedAuthSnapshot,
38+
activeName: string | null,
39+
): string[] {
40+
const ordered: string[] = [];
41+
const add = (name: string | null | undefined): void => {
42+
if (!name || !accountNames.includes(name) || ordered.includes(name)) return;
43+
ordered.push(name);
44+
};
45+
46+
add(activeName);
47+
48+
const incomingEmail = incomingSnapshot.email?.trim().toLowerCase();
49+
if (incomingEmail) {
50+
try {
51+
add(normalizeAccountName(incomingEmail));
52+
} catch {
53+
// Invalid email-shaped snapshot names fall through to identity scan.
54+
}
55+
}
56+
57+
for (const name of accountNames) {
58+
add(name);
59+
}
60+
61+
return ordered;
62+
}
63+
64+
async function resolveRegistryAccountNameForIncomingSnapshot(
65+
incomingSnapshot: ParsedAuthSnapshot,
66+
candidates: string[],
67+
activeName: string | null,
68+
): Promise<ResolvedDefaultAccountName | null> {
69+
const registry = await loadRegistry();
70+
let activeEmailMatch: ResolvedDefaultAccountName | null = null;
71+
72+
for (const name of candidates) {
73+
const entry = registry.accounts[name];
74+
if (!entry || !(await pathExists(accountFilePath(name)))) continue;
75+
76+
if (registryEntrySharesIdentity(entry, incomingSnapshot)) {
77+
return {
78+
name,
79+
source: activeName === name ? "active" : "existing",
80+
};
81+
}
82+
83+
if (!activeEmailMatch && registryEntrySharesEmail(entry, incomingSnapshot)) {
84+
activeEmailMatch = {
85+
name,
86+
source: activeName === name ? "active" : "existing",
87+
forceOverwrite: true,
88+
};
89+
}
90+
}
91+
92+
return activeEmailMatch;
93+
}
94+
95+
export async function resolveExistingAccountNameForIncomingSnapshot(
96+
incomingSnapshot: ParsedAuthSnapshot,
97+
activeName: string | null,
98+
): Promise<ResolvedDefaultAccountName | null> {
99+
let emailMatch: ResolvedDefaultAccountName | null = null;
100+
const accountNames = await listAccountNames();
101+
const candidates = orderReloginSnapshotCandidates(
102+
accountNames,
103+
incomingSnapshot,
104+
activeName,
105+
);
106+
const registryMatch = await resolveRegistryAccountNameForIncomingSnapshot(
107+
incomingSnapshot,
108+
candidates,
109+
activeName,
110+
);
111+
if (registryMatch) {
112+
return registryMatch;
113+
}
114+
115+
for (const name of candidates) {
116+
const snapshotPath = accountFilePath(name);
117+
if (!(await pathExists(snapshotPath))) continue;
118+
119+
const existingSnapshot = await parseAuthSnapshotFile(snapshotPath);
120+
if (snapshotsShareIdentity(existingSnapshot, incomingSnapshot)) {
121+
return {
122+
name,
123+
source: activeName === name ? "active" : "existing",
124+
};
125+
}
126+
127+
if (!emailMatch && snapshotsShareEmail(existingSnapshot, incomingSnapshot)) {
128+
emailMatch = {
129+
name,
130+
source: activeName === name ? "active" : "existing",
131+
forceOverwrite: true,
132+
};
133+
}
134+
}
135+
136+
return emailMatch;
137+
}
138+
139+
export async function resolveUniqueInferredName(
140+
baseName: string,
141+
incomingSnapshot: ParsedAuthSnapshot,
142+
): Promise<string> {
143+
const hasMatchingIdentity = async (name: string): Promise<boolean> => {
144+
const parsed = await parseAuthSnapshotFile(accountFilePath(name));
145+
return snapshotsShareIdentity(parsed, incomingSnapshot);
146+
};
147+
148+
const basePath = accountFilePath(baseName);
149+
if (!(await pathExists(basePath))) {
150+
return baseName;
151+
}
152+
if (await hasMatchingIdentity(baseName)) {
153+
return baseName;
154+
}
155+
156+
for (let i = 2; i <= 99; i += 1) {
157+
const candidate = normalizeAccountName(`${baseName}--dup-${i}`);
158+
const candidatePath = accountFilePath(candidate);
159+
if (!(await pathExists(candidatePath))) {
160+
return candidate;
161+
}
162+
if (await hasMatchingIdentity(candidate)) {
163+
return candidate;
164+
}
165+
}
166+
167+
throw new AccountNameInferenceError();
168+
}
169+
170+
export async function inferAccountNameFromSnapshot(
171+
incomingSnapshot: ParsedAuthSnapshot,
172+
): Promise<string> {
173+
const email = incomingSnapshot.email?.trim().toLowerCase();
174+
if (!email || !email.includes("@")) {
175+
throw new AccountNameInferenceError();
176+
}
177+
178+
const baseCandidate = normalizeAccountName(email);
179+
return resolveUniqueInferredName(baseCandidate, incomingSnapshot);
180+
}
181+
182+
export async function resolveLoginAccountNameForSnapshot(
183+
incomingSnapshot: ParsedAuthSnapshot,
184+
activeName: string | null,
185+
): Promise<ResolvedLoginAccountName> {
186+
const existing = await resolveExistingAccountNameForIncomingSnapshot(
187+
incomingSnapshot,
188+
activeName,
189+
);
190+
if (existing) return existing;
191+
192+
return {
193+
name: await inferAccountNameFromSnapshot(incomingSnapshot),
194+
source: "inferred",
195+
};
196+
}
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
// Internal registry helpers shared across write clusters.
2+
// All durable writes funnel through `persistRegistryAtomic` (Theme N1) —
3+
// this module just adds the reconcile-by-account-name step on top.
4+
5+
import {
6+
persistRegistryAtomic,
7+
reconcileRegistryWithAccounts,
8+
} from "../registry";
9+
import { RegistryData } from "../types";
10+
import { parseAuthSnapshotFile } from "../auth-parser";
11+
import { accountFilePath } from "../naming";
12+
import { listAccountNames } from "../read/listing";
13+
14+
export async function persistRegistry(registry: RegistryData): Promise<void> {
15+
const reconciled = reconcileRegistryWithAccounts(
16+
registry,
17+
await listAccountNames(),
18+
);
19+
await persistRegistryAtomic(reconciled);
20+
}
21+
22+
export async function hydrateSnapshotMetadata(
23+
registry: RegistryData,
24+
accountName: string,
25+
): Promise<void> {
26+
const parsed = await parseAuthSnapshotFile(accountFilePath(accountName));
27+
const entry = registry.accounts[accountName] ?? {
28+
name: accountName,
29+
createdAt: new Date().toISOString(),
30+
};
31+
32+
if (parsed.email) entry.email = parsed.email;
33+
if (parsed.accountId) entry.accountId = parsed.accountId;
34+
if (parsed.userId) entry.userId = parsed.userId;
35+
if (parsed.planType) entry.planType = parsed.planType;
36+
37+
registry.accounts[accountName] = entry;
38+
}
39+
40+
export async function hydrateSnapshotMetadataIfMissing(
41+
registry: RegistryData,
42+
accountName: string,
43+
): Promise<void> {
44+
const entry = registry.accounts[accountName];
45+
if (entry?.email && entry.accountId && entry.userId && entry.planType) {
46+
return;
47+
}
48+
49+
await hydrateSnapshotMetadata(registry, accountName);
50+
}

0 commit comments

Comments
 (0)