Skip to content

Commit 2c3561a

Browse files
Dikevinliyanbowne
andauthored
refactor: split CLI, Desktop, and account store module boundaries (#18)
* refactor(cli): extract desktop and watch orchestration * test(cli): make runner and watcher deterministic * refactor(desktop): extract codex desktop launcher helpers * refactor(accounts): split account store repository helpers * refactor(layout): group account, desktop, and watch modules * test(cli): set launch tests to darwin platform --------- Co-authored-by: liyanbowne <liyanbowne@gmail.com>
1 parent 85482b0 commit 2c3561a

50 files changed

Lines changed: 5163 additions & 4651 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

AGENTS.md

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,14 +8,16 @@ Detailed design notes live in `docs/internal/`.
88
- Do not add a command by querying both Desktop and direct runtime paths unless the command semantics explicitly require it.
99
- Do not spread new platform-specific Desktop process logic outside the Desktop launcher boundary.
1010
- Do not duplicate plan or quota normalization rules outside `src/plan-quota-profile.ts`.
11+
- Before adding legacy interface, data, or code compatibility paths, confirm with the user that backward compatibility is necessary.
1112

1213
## Module Boundaries
1314

1415
- `src/main.ts`: CLI orchestration only.
1516
- `src/commands/*`: command handlers.
16-
- `src/codex-desktop-launch.ts`: managed Desktop lifecycle, DevTools bridge, Desktop runtime reads, and watch stream handling.
17+
- `src/desktop/launcher.ts`: managed Desktop lifecycle, DevTools bridge, Desktop runtime reads, and watch stream handling.
1718
- `src/codex-direct-client.ts`: direct `codex app-server` client for one-shot runtime reads.
18-
- `src/watch-history.ts`: watch history persistence and ETA calculation.
19+
- `src/watch/history.ts`: watch history persistence and ETA calculation.
20+
- `src/account-store/service.ts`: account store orchestration and mutation flows.
1921
- `src/plan-quota-profile.ts`: centralized plan normalization and quota ratio rules.
2022
- `src/cli/quota.ts`: quota presentation, list ordering, and auto-switch candidate formatting.
2123

src/account-store/config.ts

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
import type { AuthSnapshot } from "../auth-snapshot.js";
2+
3+
export function validateConfigSnapshot(
4+
name: string,
5+
snapshot: AuthSnapshot,
6+
rawConfig: string | null,
7+
): void {
8+
if (snapshot.auth_mode !== "apikey") {
9+
return;
10+
}
11+
12+
if (!rawConfig) {
13+
throw new Error(`Current ~/.codex/config.toml is required to save apikey account "${name}".`);
14+
}
15+
16+
if (!/^\s*model_provider\s*=\s*["'][^"']+["']/mu.test(rawConfig)) {
17+
throw new Error(`Current ~/.codex/config.toml is missing model_provider for apikey account "${name}".`);
18+
}
19+
20+
if (!/^\s*base_url\s*=\s*["'][^"']+["']/mu.test(rawConfig)) {
21+
throw new Error(`Current ~/.codex/config.toml is missing base_url for apikey account "${name}".`);
22+
}
23+
}
24+
25+
export function sanitizeConfigForAccountAuth(rawConfig: string): string {
26+
const lines = rawConfig.split(/\r?\n/u);
27+
const result: string[] = [];
28+
let skippingProviderSection = false;
29+
30+
for (const line of lines) {
31+
const trimmed = line.trim();
32+
33+
if (trimmed.startsWith("[") && trimmed.endsWith("]")) {
34+
skippingProviderSection = /^\[model_providers\.[^\]]+\]$/u.test(trimmed);
35+
if (skippingProviderSection) {
36+
continue;
37+
}
38+
}
39+
40+
if (skippingProviderSection) {
41+
continue;
42+
}
43+
44+
if (/^\s*model_provider\s*=/u.test(line)) {
45+
continue;
46+
}
47+
48+
if (/^\s*preferred_auth_method\s*=\s*["']apikey["']\s*$/u.test(line)) {
49+
continue;
50+
}
51+
52+
result.push(line);
53+
}
54+
55+
return `${result.join("\n").replace(/\n{3,}/gu, "\n\n").trimEnd()}\n`;
56+
}

src/account-store/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
export * from "./service.js";
2+
export type * from "./types.js";

src/account-store/repository.ts

Lines changed: 227 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,227 @@
1+
import { join } from "node:path";
2+
3+
import {
4+
AuthSnapshot,
5+
SnapshotMeta,
6+
createSnapshotMeta,
7+
getMetaIdentity,
8+
getSnapshotAccountId,
9+
getSnapshotIdentity,
10+
getSnapshotUserId,
11+
isSupportedChatGPTAuthMode,
12+
parseSnapshotMeta,
13+
readAuthSnapshotFile,
14+
} from "../auth-snapshot.js";
15+
import type { ManagedAccount, StorePaths, StoreState } from "./types.js";
16+
import {
17+
DIRECTORY_MODE,
18+
FILE_MODE,
19+
SCHEMA_VERSION,
20+
atomicWriteFile,
21+
ensureAccountName,
22+
ensureDirectory,
23+
pathExists,
24+
readJsonFile,
25+
stringifyJson,
26+
} from "./storage.js";
27+
28+
function canAutoMigrateLegacyChatGPTMeta(
29+
meta: SnapshotMeta,
30+
snapshot: AuthSnapshot,
31+
): boolean {
32+
if (!isSupportedChatGPTAuthMode(meta.auth_mode) || !isSupportedChatGPTAuthMode(snapshot.auth_mode)) {
33+
return false;
34+
}
35+
36+
if (typeof meta.user_id === "string" && meta.user_id.trim() !== "") {
37+
return false;
38+
}
39+
40+
const snapshotUserId = getSnapshotUserId(snapshot);
41+
if (!snapshotUserId) {
42+
return false;
43+
}
44+
45+
return meta.account_id === getSnapshotAccountId(snapshot);
46+
}
47+
48+
export class AccountStoreRepository {
49+
readonly paths: StorePaths;
50+
51+
constructor(paths: StorePaths) {
52+
this.paths = paths;
53+
}
54+
55+
accountDirectory(name: string): string {
56+
ensureAccountName(name);
57+
return join(this.paths.accountsDir, name);
58+
}
59+
60+
accountAuthPath(name: string): string {
61+
return join(this.accountDirectory(name), "auth.json");
62+
}
63+
64+
accountMetaPath(name: string): string {
65+
return join(this.accountDirectory(name), "meta.json");
66+
}
67+
68+
accountConfigPath(name: string): string {
69+
return join(this.accountDirectory(name), "config.toml");
70+
}
71+
72+
async writeAccountAuthSnapshot(name: string, snapshot: AuthSnapshot): Promise<void> {
73+
await atomicWriteFile(
74+
this.accountAuthPath(name),
75+
stringifyJson(snapshot),
76+
);
77+
}
78+
79+
async writeAccountMeta(name: string, meta: SnapshotMeta): Promise<void> {
80+
await atomicWriteFile(this.accountMetaPath(name), stringifyJson(meta));
81+
}
82+
83+
async ensureEmptyAccountConfigSnapshot(name: string): Promise<string> {
84+
const configPath = this.accountConfigPath(name);
85+
await atomicWriteFile(configPath, "");
86+
return configPath;
87+
}
88+
89+
async syncCurrentAuthIfMatching(snapshot: AuthSnapshot): Promise<void> {
90+
if (!(await pathExists(this.paths.currentAuthPath))) {
91+
return;
92+
}
93+
94+
try {
95+
const currentSnapshot = await readAuthSnapshotFile(this.paths.currentAuthPath);
96+
if (getSnapshotIdentity(currentSnapshot) !== getSnapshotIdentity(snapshot)) {
97+
return;
98+
}
99+
100+
await atomicWriteFile(this.paths.currentAuthPath, stringifyJson(snapshot));
101+
} catch {
102+
// Ignore sync failures here; the stored snapshot is already updated.
103+
}
104+
}
105+
106+
async ensureLayout(): Promise<void> {
107+
await ensureDirectory(this.paths.codexTeamDir, DIRECTORY_MODE);
108+
await ensureDirectory(this.paths.accountsDir, DIRECTORY_MODE);
109+
await ensureDirectory(this.paths.backupsDir, DIRECTORY_MODE);
110+
}
111+
112+
async readState(): Promise<StoreState> {
113+
if (!(await pathExists(this.paths.statePath))) {
114+
return {
115+
schema_version: SCHEMA_VERSION,
116+
last_switched_account: null,
117+
last_backup_path: null,
118+
};
119+
}
120+
121+
const raw = await readJsonFile(this.paths.statePath);
122+
const parsed = JSON.parse(raw) as Partial<StoreState>;
123+
124+
return {
125+
schema_version: parsed.schema_version ?? SCHEMA_VERSION,
126+
last_switched_account: parsed.last_switched_account ?? null,
127+
last_backup_path: parsed.last_backup_path ?? null,
128+
};
129+
}
130+
131+
async writeState(state: StoreState): Promise<void> {
132+
await this.ensureLayout();
133+
await atomicWriteFile(this.paths.statePath, stringifyJson(state));
134+
}
135+
136+
async readManagedAccount(name: string): Promise<ManagedAccount> {
137+
const metaPath = this.accountMetaPath(name);
138+
const authPath = this.accountAuthPath(name);
139+
const [rawMeta, snapshot] = await Promise.all([
140+
readJsonFile(metaPath),
141+
readAuthSnapshotFile(authPath),
142+
]);
143+
let meta = parseSnapshotMeta(rawMeta);
144+
145+
if (meta.name !== name) {
146+
throw new Error(`Account metadata name mismatch for "${name}".`);
147+
}
148+
149+
const snapshotIdentity = getSnapshotIdentity(snapshot);
150+
if (getMetaIdentity(meta) !== snapshotIdentity) {
151+
if (canAutoMigrateLegacyChatGPTMeta(meta, snapshot)) {
152+
meta = {
153+
...meta,
154+
account_id: getSnapshotAccountId(snapshot),
155+
user_id: getSnapshotUserId(snapshot),
156+
};
157+
await this.writeAccountMeta(name, meta);
158+
} else {
159+
throw new Error(`Account metadata account_id mismatch for "${name}".`);
160+
}
161+
}
162+
163+
if (getMetaIdentity(meta) !== snapshotIdentity) {
164+
throw new Error(`Account metadata account_id mismatch for "${name}".`);
165+
}
166+
167+
return {
168+
...meta,
169+
identity: getMetaIdentity(meta),
170+
authPath,
171+
metaPath,
172+
configPath: (await pathExists(this.accountConfigPath(name))) ? this.accountConfigPath(name) : null,
173+
duplicateAccountId: false,
174+
};
175+
}
176+
177+
async listAccounts(): Promise<{ accounts: ManagedAccount[]; warnings: string[] }> {
178+
await this.ensureLayout();
179+
180+
const { readdir } = await import("node:fs/promises");
181+
const entries = await readdir(this.paths.accountsDir, { withFileTypes: true });
182+
const accounts: ManagedAccount[] = [];
183+
const warnings: string[] = [];
184+
185+
for (const entry of entries) {
186+
if (!entry.isDirectory()) {
187+
continue;
188+
}
189+
190+
try {
191+
accounts.push(await this.readManagedAccount(entry.name));
192+
} catch (error) {
193+
warnings.push(
194+
`Account "${entry.name}" is invalid: ${(error as Error).message}`,
195+
);
196+
}
197+
}
198+
199+
const counts = new Map<string, number>();
200+
for (const account of accounts) {
201+
counts.set(account.identity, (counts.get(account.identity) ?? 0) + 1);
202+
}
203+
204+
accounts.sort((left, right) => left.name.localeCompare(right.name));
205+
206+
return {
207+
accounts: accounts.map((account) => ({
208+
...account,
209+
duplicateAccountId: (counts.get(account.identity) ?? 0) > 1,
210+
})),
211+
warnings,
212+
};
213+
}
214+
215+
async readCurrentStatusAccounts() {
216+
return await this.listAccounts();
217+
}
218+
219+
createSnapshotMeta(
220+
name: string,
221+
snapshot: AuthSnapshot,
222+
now: Date,
223+
createdAt?: string | null,
224+
) {
225+
return createSnapshotMeta(name, snapshot, now, createdAt ?? undefined);
226+
}
227+
}

0 commit comments

Comments
 (0)