@imdeadpool/codex-account-switcher 0.1.5 → 0.1.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (45) hide show
  1. package/README.md +79 -5
  2. package/dist/commands/config.d.ts +15 -0
  3. package/dist/commands/config.js +81 -0
  4. package/dist/commands/daemon.d.ts +9 -0
  5. package/dist/commands/daemon.js +39 -0
  6. package/dist/commands/list.d.ts +3 -0
  7. package/dist/commands/list.js +30 -5
  8. package/dist/commands/login.d.ts +15 -0
  9. package/dist/commands/login.js +97 -0
  10. package/dist/commands/remove.d.ts +14 -0
  11. package/dist/commands/remove.js +104 -0
  12. package/dist/commands/save.d.ts +4 -1
  13. package/dist/commands/save.js +24 -6
  14. package/dist/commands/status.d.ts +5 -0
  15. package/dist/commands/status.js +16 -0
  16. package/dist/lib/accounts/account-service.d.ts +59 -2
  17. package/dist/lib/accounts/account-service.js +551 -36
  18. package/dist/lib/accounts/auth-parser.d.ts +3 -0
  19. package/dist/lib/accounts/auth-parser.js +83 -0
  20. package/dist/lib/accounts/errors.d.ts +15 -0
  21. package/dist/lib/accounts/errors.js +34 -2
  22. package/dist/lib/accounts/index.d.ts +3 -1
  23. package/dist/lib/accounts/index.js +5 -1
  24. package/dist/lib/accounts/registry.d.ts +6 -0
  25. package/dist/lib/accounts/registry.js +166 -0
  26. package/dist/lib/accounts/service-manager.d.ts +4 -0
  27. package/dist/lib/accounts/service-manager.js +204 -0
  28. package/dist/lib/accounts/types.d.ts +71 -0
  29. package/dist/lib/accounts/types.js +5 -0
  30. package/dist/lib/accounts/usage.d.ts +10 -0
  31. package/dist/lib/accounts/usage.js +246 -0
  32. package/dist/lib/base-command.d.ts +1 -0
  33. package/dist/lib/base-command.js +4 -0
  34. package/dist/lib/config/paths.d.ts +6 -0
  35. package/dist/lib/config/paths.js +46 -5
  36. package/dist/tests/auth-parser.test.d.ts +1 -0
  37. package/dist/tests/auth-parser.test.js +65 -0
  38. package/dist/tests/registry.test.d.ts +1 -0
  39. package/dist/tests/registry.test.js +37 -0
  40. package/dist/tests/save-account-safety.test.d.ts +1 -0
  41. package/dist/tests/save-account-safety.test.js +399 -0
  42. package/dist/tests/usage.test.d.ts +1 -0
  43. package/dist/tests/usage.test.js +29 -0
  44. package/package.json +9 -6
  45. package/scripts/postinstall-login-hook.cjs +90 -0
@@ -9,64 +9,451 @@ const promises_1 = __importDefault(require("node:fs/promises"));
9
9
  const node_path_1 = __importDefault(require("node:path"));
10
10
  const paths_1 = require("../config/paths");
11
11
  const errors_1 = require("./errors");
12
- const ACCOUNT_NAME_PATTERN = /^[a-zA-Z0-9][a-zA-Z0-9._-]*$/;
12
+ const auth_parser_1 = require("./auth-parser");
13
+ const registry_1 = require("./registry");
14
+ const usage_1 = require("./usage");
15
+ const service_manager_1 = require("./service-manager");
16
+ const ACCOUNT_NAME_PATTERN = /^[a-zA-Z0-9][a-zA-Z0-9._@+-]*$/;
13
17
  class AccountService {
18
+ async syncExternalAuthSnapshotIfNeeded() {
19
+ const authPath = (0, paths_1.resolveAuthPath)();
20
+ if (!(await this.pathExists(authPath))) {
21
+ return {
22
+ synchronized: false,
23
+ autoSwitchDisabled: false,
24
+ };
25
+ }
26
+ await this.materializeAuthSymlink(authPath);
27
+ const incomingSnapshot = await (0, auth_parser_1.parseAuthSnapshotFile)(authPath);
28
+ if (incomingSnapshot.authMode !== "chatgpt") {
29
+ return {
30
+ synchronized: false,
31
+ autoSwitchDisabled: false,
32
+ };
33
+ }
34
+ const activeName = await this.getCurrentAccountName();
35
+ if (activeName) {
36
+ const activeSnapshotPath = this.accountFilePath(activeName);
37
+ if (await this.pathExists(activeSnapshotPath)) {
38
+ const activeSnapshot = await (0, auth_parser_1.parseAuthSnapshotFile)(activeSnapshotPath);
39
+ if (this.snapshotsShareIdentity(activeSnapshot, incomingSnapshot)) {
40
+ return {
41
+ synchronized: false,
42
+ autoSwitchDisabled: false,
43
+ };
44
+ }
45
+ }
46
+ }
47
+ const status = await this.getStatus();
48
+ const autoSwitchDisabled = status.autoSwitchEnabled;
49
+ if (autoSwitchDisabled) {
50
+ await this.setAutoSwitchEnabled(false);
51
+ }
52
+ const resolvedName = await this.resolveLoginAccountNameFromCurrentAuth();
53
+ const savedName = await this.saveAccount(resolvedName.name);
54
+ return {
55
+ synchronized: true,
56
+ savedName,
57
+ autoSwitchDisabled,
58
+ };
59
+ }
14
60
  async listAccountNames() {
15
- if (!(await this.pathExists(paths_1.accountsDir))) {
61
+ const accountsDir = (0, paths_1.resolveAccountsDir)();
62
+ if (!(await this.pathExists(accountsDir))) {
16
63
  return [];
17
64
  }
18
- const entries = await promises_1.default.readdir(paths_1.accountsDir, { withFileTypes: true });
65
+ const entries = await promises_1.default.readdir(accountsDir, { withFileTypes: true });
19
66
  return entries
20
- .filter((entry) => entry.isFile() && entry.name.endsWith(".json"))
67
+ .filter((entry) => entry.isFile() && entry.name.endsWith(".json") && entry.name !== "registry.json")
21
68
  .map((entry) => entry.name.replace(/\.json$/i, ""))
22
69
  .sort((a, b) => a.localeCompare(b, undefined, { sensitivity: "base" }));
23
70
  }
71
+ async listAccountChoices() {
72
+ const [accounts, current, registry] = await Promise.all([
73
+ this.listAccountNames(),
74
+ this.getCurrentAccountName(),
75
+ this.loadReconciledRegistry(),
76
+ ]);
77
+ return accounts.map((name) => {
78
+ var _a;
79
+ return ({
80
+ name,
81
+ email: (_a = registry.accounts[name]) === null || _a === void 0 ? void 0 : _a.email,
82
+ active: current === name,
83
+ });
84
+ });
85
+ }
86
+ async listAccountMappings() {
87
+ const [accounts, current, registry] = await Promise.all([
88
+ this.listAccountNames(),
89
+ this.getCurrentAccountName(),
90
+ this.loadReconciledRegistry(),
91
+ ]);
92
+ return Promise.all(accounts.map(async (name) => {
93
+ var _a, _b, _c, _d, _e;
94
+ const entry = registry.accounts[name];
95
+ let fallbackSnapshot;
96
+ if (!(entry === null || entry === void 0 ? void 0 : entry.email) || !(entry === null || entry === void 0 ? void 0 : entry.accountId) || !(entry === null || entry === void 0 ? void 0 : entry.userId) || !(entry === null || entry === void 0 ? void 0 : entry.planType)) {
97
+ fallbackSnapshot = await (0, auth_parser_1.parseAuthSnapshotFile)(this.accountFilePath(name));
98
+ }
99
+ return {
100
+ name,
101
+ active: current === name,
102
+ email: (_a = entry === null || entry === void 0 ? void 0 : entry.email) !== null && _a !== void 0 ? _a : fallbackSnapshot === null || fallbackSnapshot === void 0 ? void 0 : fallbackSnapshot.email,
103
+ accountId: (_b = entry === null || entry === void 0 ? void 0 : entry.accountId) !== null && _b !== void 0 ? _b : fallbackSnapshot === null || fallbackSnapshot === void 0 ? void 0 : fallbackSnapshot.accountId,
104
+ userId: (_c = entry === null || entry === void 0 ? void 0 : entry.userId) !== null && _c !== void 0 ? _c : fallbackSnapshot === null || fallbackSnapshot === void 0 ? void 0 : fallbackSnapshot.userId,
105
+ planType: (_d = entry === null || entry === void 0 ? void 0 : entry.planType) !== null && _d !== void 0 ? _d : fallbackSnapshot === null || fallbackSnapshot === void 0 ? void 0 : fallbackSnapshot.planType,
106
+ lastUsageAt: entry === null || entry === void 0 ? void 0 : entry.lastUsageAt,
107
+ usageSource: (_e = entry === null || entry === void 0 ? void 0 : entry.lastUsage) === null || _e === void 0 ? void 0 : _e.source,
108
+ };
109
+ }));
110
+ }
111
+ async findMatchingAccounts(query) {
112
+ const normalized = query.trim().toLowerCase();
113
+ if (!normalized)
114
+ return [];
115
+ const choices = await this.listAccountChoices();
116
+ const registry = await this.loadReconciledRegistry();
117
+ return choices.filter((choice) => {
118
+ var _a, _b;
119
+ if (choice.name.toLowerCase().includes(normalized))
120
+ return true;
121
+ if (choice.email && choice.email.toLowerCase().includes(normalized))
122
+ return true;
123
+ const meta = registry.accounts[choice.name];
124
+ if ((_a = meta === null || meta === void 0 ? void 0 : meta.accountId) === null || _a === void 0 ? void 0 : _a.toLowerCase().includes(normalized))
125
+ return true;
126
+ if ((_b = meta === null || meta === void 0 ? void 0 : meta.userId) === null || _b === void 0 ? void 0 : _b.toLowerCase().includes(normalized))
127
+ return true;
128
+ return false;
129
+ });
130
+ }
24
131
  async getCurrentAccountName() {
25
- const currentName = await this.readCurrentNameFile();
132
+ const currentNamePath = (0, paths_1.resolveCurrentNamePath)();
133
+ const currentName = await this.readCurrentNameFile(currentNamePath);
26
134
  if (currentName)
27
135
  return currentName;
28
- if (!(await this.pathExists(paths_1.authPath)))
136
+ const authPath = (0, paths_1.resolveAuthPath)();
137
+ if (!(await this.pathExists(authPath)))
29
138
  return null;
30
- const stat = await promises_1.default.lstat(paths_1.authPath);
139
+ const stat = await promises_1.default.lstat(authPath);
31
140
  if (!stat.isSymbolicLink())
32
141
  return null;
33
- const rawTarget = await promises_1.default.readlink(paths_1.authPath);
34
- const resolvedTarget = node_path_1.default.resolve(node_path_1.default.dirname(paths_1.authPath), rawTarget);
35
- const accountsRoot = node_path_1.default.resolve(paths_1.accountsDir);
142
+ const rawTarget = await promises_1.default.readlink(authPath);
143
+ const resolvedTarget = node_path_1.default.resolve(node_path_1.default.dirname(authPath), rawTarget);
144
+ const accountsRoot = node_path_1.default.resolve((0, paths_1.resolveAccountsDir)());
36
145
  const relative = node_path_1.default.relative(accountsRoot, resolvedTarget);
37
146
  if (relative.startsWith(".."))
38
147
  return null;
39
148
  const base = node_path_1.default.basename(resolvedTarget);
149
+ if (!base.endsWith(".json") || base === "registry.json")
150
+ return null;
40
151
  return base.replace(/\.json$/i, "");
41
152
  }
42
- async saveAccount(rawName) {
153
+ async saveAccount(rawName, options) {
43
154
  const name = this.normalizeAccountName(rawName);
44
- await this.ensureAuthFileExists();
45
- await this.ensureDir(paths_1.accountsDir);
155
+ const authPath = (0, paths_1.resolveAuthPath)();
156
+ const accountsDir = (0, paths_1.resolveAccountsDir)();
157
+ await this.ensureAuthFileExists(authPath);
158
+ await this.ensureDir(accountsDir);
46
159
  const destination = this.accountFilePath(name);
47
- await promises_1.default.copyFile(paths_1.authPath, destination);
160
+ await this.assertSafeSnapshotOverwrite({
161
+ authPath,
162
+ destinationPath: destination,
163
+ accountName: name,
164
+ force: Boolean(options === null || options === void 0 ? void 0 : options.force),
165
+ });
166
+ await promises_1.default.copyFile(authPath, destination);
48
167
  await this.writeCurrentName(name);
168
+ const registry = await this.loadReconciledRegistry();
169
+ await this.hydrateSnapshotMetadata(registry, name);
170
+ registry.activeAccountName = name;
171
+ await this.persistRegistry(registry);
49
172
  return name;
50
173
  }
174
+ async inferAccountNameFromCurrentAuth() {
175
+ var _a;
176
+ const authPath = (0, paths_1.resolveAuthPath)();
177
+ await this.ensureAuthFileExists(authPath);
178
+ const parsed = await (0, auth_parser_1.parseAuthSnapshotFile)(authPath);
179
+ const email = (_a = parsed.email) === null || _a === void 0 ? void 0 : _a.trim().toLowerCase();
180
+ if (!email || !email.includes("@")) {
181
+ throw new errors_1.AccountNameInferenceError();
182
+ }
183
+ const baseCandidate = this.normalizeAccountName(email);
184
+ const uniqueName = await this.resolveUniqueInferredName(baseCandidate, parsed);
185
+ return uniqueName;
186
+ }
187
+ async resolveDefaultAccountNameFromCurrentAuth() {
188
+ const authPath = (0, paths_1.resolveAuthPath)();
189
+ await this.ensureAuthFileExists(authPath);
190
+ const activeName = await this.getCurrentAccountName();
191
+ if (activeName) {
192
+ const activeSnapshotPath = this.accountFilePath(activeName);
193
+ if (await this.pathExists(activeSnapshotPath)) {
194
+ const [activeSnapshot, incomingSnapshot] = await Promise.all([
195
+ (0, auth_parser_1.parseAuthSnapshotFile)(activeSnapshotPath),
196
+ (0, auth_parser_1.parseAuthSnapshotFile)(authPath),
197
+ ]);
198
+ if (this.snapshotsShareIdentity(activeSnapshot, incomingSnapshot)) {
199
+ return {
200
+ name: activeName,
201
+ source: "active",
202
+ };
203
+ }
204
+ }
205
+ }
206
+ return {
207
+ name: await this.inferAccountNameFromCurrentAuth(),
208
+ source: "inferred",
209
+ };
210
+ }
211
+ async resolveLoginAccountNameFromCurrentAuth() {
212
+ return {
213
+ name: await this.inferAccountNameFromCurrentAuth(),
214
+ source: "inferred",
215
+ };
216
+ }
51
217
  async useAccount(rawName) {
52
218
  const name = this.normalizeAccountName(rawName);
53
- const source = this.accountFilePath(name);
54
- if (!(await this.pathExists(source))) {
55
- throw new errors_1.AccountNotFoundError(name);
219
+ await this.activateSnapshot(name);
220
+ const registry = await this.loadReconciledRegistry();
221
+ await this.hydrateSnapshotMetadata(registry, name);
222
+ registry.activeAccountName = name;
223
+ await this.persistRegistry(registry);
224
+ return name;
225
+ }
226
+ async removeAccounts(accountNames) {
227
+ const uniqueNames = [...new Set(accountNames.map((name) => this.normalizeAccountName(name)))];
228
+ if (uniqueNames.length === 0) {
229
+ return { removed: [] };
56
230
  }
57
- await this.ensureDir(paths_1.accountsDir);
58
- await this.ensureDir(paths_1.codexDir);
59
- if (process.platform === "win32") {
60
- await promises_1.default.copyFile(source, paths_1.authPath);
231
+ const current = await this.getCurrentAccountName();
232
+ const registry = await this.loadReconciledRegistry();
233
+ const removed = [];
234
+ for (const name of uniqueNames) {
235
+ const snapshotPath = this.accountFilePath(name);
236
+ if (!(await this.pathExists(snapshotPath))) {
237
+ throw new errors_1.AccountNotFoundError(name);
238
+ }
239
+ await promises_1.default.rm(snapshotPath, { force: true });
240
+ delete registry.accounts[name];
241
+ removed.push(name);
242
+ }
243
+ const removedSet = new Set(removed);
244
+ let activated;
245
+ if (current && removedSet.has(current)) {
246
+ const remaining = (await this.listAccountNames()).filter((name) => !removedSet.has(name));
247
+ if (remaining.length > 0) {
248
+ const best = this.selectBestCandidateFromRegistry(remaining, registry);
249
+ await this.activateSnapshot(best);
250
+ activated = best;
251
+ registry.activeAccountName = best;
252
+ }
253
+ else {
254
+ await this.clearActivePointers();
255
+ delete registry.activeAccountName;
256
+ }
257
+ }
258
+ else if (registry.activeAccountName && removedSet.has(registry.activeAccountName)) {
259
+ delete registry.activeAccountName;
260
+ }
261
+ await this.persistRegistry(registry);
262
+ return {
263
+ removed,
264
+ activated,
265
+ };
266
+ }
267
+ async removeByQuery(query) {
268
+ const matches = await this.findMatchingAccounts(query);
269
+ if (matches.length === 0) {
270
+ throw new errors_1.AccountNotFoundError(query);
271
+ }
272
+ if (matches.length > 1) {
273
+ throw new errors_1.AmbiguousAccountQueryError(query);
274
+ }
275
+ return this.removeAccounts([matches[0].name]);
276
+ }
277
+ async removeAllAccounts() {
278
+ const all = await this.listAccountNames();
279
+ return this.removeAccounts(all);
280
+ }
281
+ async getStatus() {
282
+ const registry = await this.loadReconciledRegistry();
283
+ return {
284
+ autoSwitchEnabled: registry.autoSwitch.enabled,
285
+ serviceState: (0, service_manager_1.getManagedServiceState)(),
286
+ threshold5hPercent: registry.autoSwitch.threshold5hPercent,
287
+ thresholdWeeklyPercent: registry.autoSwitch.thresholdWeeklyPercent,
288
+ usageMode: registry.api.usage ? "api" : "local",
289
+ };
290
+ }
291
+ async setAutoSwitchEnabled(enabled) {
292
+ const registry = await this.loadReconciledRegistry();
293
+ registry.autoSwitch.enabled = enabled;
294
+ if (enabled) {
295
+ try {
296
+ await (0, service_manager_1.enableManagedService)();
297
+ }
298
+ catch (error) {
299
+ registry.autoSwitch.enabled = false;
300
+ await this.persistRegistry(registry);
301
+ throw new errors_1.AutoSwitchConfigError(`Failed to enable managed auto-switch service: ${error.message}`);
302
+ }
61
303
  }
62
304
  else {
63
- await this.replaceSymlink(source, paths_1.authPath);
305
+ await (0, service_manager_1.disableManagedService)();
64
306
  }
65
- await this.writeCurrentName(name);
66
- return name;
307
+ await this.persistRegistry(registry);
308
+ return this.getStatus();
309
+ }
310
+ async setApiUsageEnabled(enabled) {
311
+ const registry = await this.loadReconciledRegistry();
312
+ registry.api.usage = enabled;
313
+ await this.persistRegistry(registry);
314
+ return this.getStatus();
315
+ }
316
+ async configureAutoSwitchThresholds(input) {
317
+ const registry = await this.loadReconciledRegistry();
318
+ if (typeof input.threshold5hPercent === "number") {
319
+ if (!this.isValidPercent(input.threshold5hPercent)) {
320
+ throw new errors_1.AutoSwitchConfigError("`--5h` must be an integer from 1 to 100.");
321
+ }
322
+ registry.autoSwitch.threshold5hPercent = Math.round(input.threshold5hPercent);
323
+ }
324
+ if (typeof input.thresholdWeeklyPercent === "number") {
325
+ if (!this.isValidPercent(input.thresholdWeeklyPercent)) {
326
+ throw new errors_1.AutoSwitchConfigError("`--weekly` must be an integer from 1 to 100.");
327
+ }
328
+ registry.autoSwitch.thresholdWeeklyPercent = Math.round(input.thresholdWeeklyPercent);
329
+ }
330
+ await this.persistRegistry(registry);
331
+ return this.getStatus();
332
+ }
333
+ async runAutoSwitchOnce() {
334
+ var _a, _b, _c;
335
+ const registry = await this.loadReconciledRegistry();
336
+ if (!registry.autoSwitch.enabled) {
337
+ return { switched: false, reason: "auto-switch is disabled" };
338
+ }
339
+ const accountNames = await this.listAccountNames();
340
+ if (accountNames.length === 0) {
341
+ return { switched: false, reason: "no saved accounts" };
342
+ }
343
+ const active = (_a = (await this.getCurrentAccountName())) !== null && _a !== void 0 ? _a : registry.activeAccountName;
344
+ if (!active || !accountNames.includes(active)) {
345
+ return { switched: false, reason: "no active account" };
346
+ }
347
+ const nowSeconds = Math.floor(Date.now() / 1000);
348
+ const activeUsage = await this.refreshAccountUsage(registry, active, {
349
+ preferApi: registry.api.usage,
350
+ allowLocalFallback: true,
351
+ });
352
+ if (!(0, usage_1.shouldSwitchCurrent)(activeUsage, {
353
+ threshold5hPercent: registry.autoSwitch.threshold5hPercent,
354
+ thresholdWeeklyPercent: registry.autoSwitch.thresholdWeeklyPercent,
355
+ }, nowSeconds)) {
356
+ await this.persistRegistry(registry);
357
+ return { switched: false, reason: "active account is above configured thresholds" };
358
+ }
359
+ const currentScore = (_b = (0, usage_1.usageScore)(activeUsage, nowSeconds)) !== null && _b !== void 0 ? _b : 0;
360
+ let bestCandidate;
361
+ let bestScore = currentScore;
362
+ for (const candidate of accountNames) {
363
+ if (candidate === active)
364
+ continue;
365
+ const usage = await this.refreshAccountUsage(registry, candidate, {
366
+ preferApi: registry.api.usage,
367
+ allowLocalFallback: false,
368
+ });
369
+ const score = (_c = (0, usage_1.usageScore)(usage, nowSeconds)) !== null && _c !== void 0 ? _c : 100;
370
+ if (!bestCandidate || score > bestScore) {
371
+ bestCandidate = candidate;
372
+ bestScore = score;
373
+ }
374
+ }
375
+ if (!bestCandidate || bestScore <= currentScore) {
376
+ await this.persistRegistry(registry);
377
+ return {
378
+ switched: false,
379
+ reason: "no candidate has better remaining quota",
380
+ };
381
+ }
382
+ await this.activateSnapshot(bestCandidate);
383
+ registry.activeAccountName = bestCandidate;
384
+ await this.hydrateSnapshotMetadata(registry, bestCandidate);
385
+ await this.persistRegistry(registry);
386
+ return {
387
+ switched: true,
388
+ fromAccount: active,
389
+ toAccount: bestCandidate,
390
+ reason: "switched due to low credits on active account",
391
+ };
392
+ }
393
+ async runDaemon(mode) {
394
+ if (mode === "once") {
395
+ await this.runAutoSwitchOnce();
396
+ return;
397
+ }
398
+ for (;;) {
399
+ try {
400
+ await this.runAutoSwitchOnce();
401
+ }
402
+ catch {
403
+ // keep daemon alive
404
+ }
405
+ await new Promise((resolve) => setTimeout(resolve, 30000));
406
+ }
407
+ }
408
+ selectBestCandidateFromRegistry(candidates, registry) {
409
+ var _a, _b, _c, _d;
410
+ const nowSeconds = Math.floor(Date.now() / 1000);
411
+ let best = candidates[0];
412
+ let bestScore = (_b = (0, usage_1.usageScore)((_a = registry.accounts[best]) === null || _a === void 0 ? void 0 : _a.lastUsage, nowSeconds)) !== null && _b !== void 0 ? _b : -1;
413
+ for (const candidate of candidates.slice(1)) {
414
+ const score = (_d = (0, usage_1.usageScore)((_c = registry.accounts[candidate]) === null || _c === void 0 ? void 0 : _c.lastUsage, nowSeconds)) !== null && _d !== void 0 ? _d : -1;
415
+ if (score > bestScore) {
416
+ best = candidate;
417
+ bestScore = score;
418
+ }
419
+ }
420
+ return best;
421
+ }
422
+ async refreshAccountUsage(registry, accountName, options) {
423
+ var _a;
424
+ const snapshotPath = this.accountFilePath(accountName);
425
+ const parsed = await (0, auth_parser_1.parseAuthSnapshotFile)(snapshotPath);
426
+ const entry = (_a = registry.accounts[accountName]) !== null && _a !== void 0 ? _a : {
427
+ name: accountName,
428
+ createdAt: new Date().toISOString(),
429
+ };
430
+ if (parsed.email)
431
+ entry.email = parsed.email;
432
+ if (parsed.accountId)
433
+ entry.accountId = parsed.accountId;
434
+ if (parsed.userId)
435
+ entry.userId = parsed.userId;
436
+ if (parsed.planType)
437
+ entry.planType = parsed.planType;
438
+ let usage = null;
439
+ if (options.preferApi) {
440
+ usage = await (0, usage_1.fetchUsageFromApi)(parsed);
441
+ }
442
+ if (!usage && options.allowLocalFallback) {
443
+ usage = await (0, usage_1.fetchUsageFromLocal)((0, paths_1.resolveCodexDir)());
444
+ }
445
+ if (usage) {
446
+ entry.lastUsage = usage;
447
+ entry.lastUsageAt = usage.fetchedAt;
448
+ if (usage.planType) {
449
+ entry.planType = usage.planType;
450
+ }
451
+ }
452
+ registry.accounts[accountName] = entry;
453
+ return entry.lastUsage;
67
454
  }
68
455
  accountFilePath(name) {
69
- return node_path_1.default.join(paths_1.accountsDir, `${name}.json`);
456
+ return node_path_1.default.join((0, paths_1.resolveAccountsDir)(), `${name}.json`);
70
457
  }
71
458
  normalizeAccountName(rawName) {
72
459
  if (typeof rawName !== "string") {
@@ -82,18 +469,47 @@ class AccountService {
82
469
  }
83
470
  return withoutExtension;
84
471
  }
85
- async ensureAuthFileExists() {
86
- if (!(await this.pathExists(paths_1.authPath))) {
87
- throw new errors_1.AuthFileMissingError(paths_1.authPath);
472
+ isValidPercent(value) {
473
+ return Number.isFinite(value) && Number.isInteger(value) && value >= 1 && value <= 100;
474
+ }
475
+ async ensureAuthFileExists(authPath) {
476
+ if (!(await this.pathExists(authPath))) {
477
+ throw new errors_1.AuthFileMissingError(authPath);
88
478
  }
89
479
  }
90
480
  async ensureDir(dirPath) {
91
481
  await promises_1.default.mkdir(dirPath, { recursive: true });
92
482
  }
93
- async replaceSymlink(target, linkPath) {
94
- await this.removeIfExists(linkPath);
95
- const absoluteTarget = node_path_1.default.resolve(target);
96
- await promises_1.default.symlink(absoluteTarget, linkPath);
483
+ async materializeAuthSymlink(authPath) {
484
+ const stat = await promises_1.default.lstat(authPath);
485
+ if (!stat.isSymbolicLink()) {
486
+ return;
487
+ }
488
+ const snapshotData = await promises_1.default.readFile(authPath);
489
+ await this.removeIfExists(authPath);
490
+ await promises_1.default.writeFile(authPath, snapshotData);
491
+ }
492
+ async assertSafeSnapshotOverwrite(input) {
493
+ var _a, _b;
494
+ if (input.force || !(await this.pathExists(input.destinationPath))) {
495
+ return;
496
+ }
497
+ const [existingSnapshot, incomingSnapshot] = await Promise.all([
498
+ (0, auth_parser_1.parseAuthSnapshotFile)(input.destinationPath),
499
+ (0, auth_parser_1.parseAuthSnapshotFile)(input.authPath),
500
+ ]);
501
+ const existingEmail = (_a = existingSnapshot.email) === null || _a === void 0 ? void 0 : _a.trim().toLowerCase();
502
+ const incomingEmail = (_b = incomingSnapshot.email) === null || _b === void 0 ? void 0 : _b.trim().toLowerCase();
503
+ if (existingEmail && incomingEmail && existingEmail !== incomingEmail) {
504
+ throw new errors_1.SnapshotEmailMismatchError(input.accountName, existingEmail, incomingEmail);
505
+ }
506
+ if (this.snapshotsShareIdentity(existingSnapshot, incomingSnapshot))
507
+ return;
508
+ if (!existingEmail || !incomingEmail)
509
+ return;
510
+ const existingIdentity = this.renderSnapshotIdentity(existingSnapshot, existingEmail);
511
+ const incomingIdentity = this.renderSnapshotIdentity(incomingSnapshot, incomingEmail);
512
+ throw new errors_1.SnapshotEmailMismatchError(input.accountName, existingIdentity, incomingIdentity);
97
513
  }
98
514
  async removeIfExists(target) {
99
515
  try {
@@ -107,12 +523,13 @@ class AccountService {
107
523
  }
108
524
  }
109
525
  async writeCurrentName(name) {
110
- await this.ensureDir(paths_1.codexDir);
111
- await promises_1.default.writeFile(paths_1.currentNamePath, `${name}\n`, "utf8");
526
+ const currentNamePath = (0, paths_1.resolveCurrentNamePath)();
527
+ await this.ensureDir(node_path_1.default.dirname(currentNamePath));
528
+ await promises_1.default.writeFile(currentNamePath, `${name}\n`, "utf8");
112
529
  }
113
- async readCurrentNameFile() {
530
+ async readCurrentNameFile(currentNamePath) {
114
531
  try {
115
- const contents = await promises_1.default.readFile(paths_1.currentNamePath, "utf8");
532
+ const contents = await promises_1.default.readFile(currentNamePath, "utf8");
116
533
  const trimmed = contents.trim();
117
534
  return trimmed.length ? trimmed : null;
118
535
  }
@@ -133,5 +550,103 @@ class AccountService {
133
550
  return false;
134
551
  }
135
552
  }
553
+ async hydrateSnapshotMetadata(registry, accountName) {
554
+ var _a;
555
+ const parsed = await (0, auth_parser_1.parseAuthSnapshotFile)(this.accountFilePath(accountName));
556
+ const entry = (_a = registry.accounts[accountName]) !== null && _a !== void 0 ? _a : {
557
+ name: accountName,
558
+ createdAt: new Date().toISOString(),
559
+ };
560
+ if (parsed.email)
561
+ entry.email = parsed.email;
562
+ if (parsed.accountId)
563
+ entry.accountId = parsed.accountId;
564
+ if (parsed.userId)
565
+ entry.userId = parsed.userId;
566
+ if (parsed.planType)
567
+ entry.planType = parsed.planType;
568
+ registry.accounts[accountName] = entry;
569
+ }
570
+ async resolveUniqueInferredName(baseName, incomingSnapshot) {
571
+ const accountPathFor = (name) => this.accountFilePath(name);
572
+ const hasMatchingIdentity = async (name) => {
573
+ const parsed = await (0, auth_parser_1.parseAuthSnapshotFile)(accountPathFor(name));
574
+ return this.snapshotsShareIdentity(parsed, incomingSnapshot);
575
+ };
576
+ const basePath = accountPathFor(baseName);
577
+ if (!(await this.pathExists(basePath))) {
578
+ return baseName;
579
+ }
580
+ if (await hasMatchingIdentity(baseName)) {
581
+ return baseName;
582
+ }
583
+ for (let i = 2; i <= 99; i += 1) {
584
+ const candidate = this.normalizeAccountName(`${baseName}--dup-${i}`);
585
+ const candidatePath = accountPathFor(candidate);
586
+ if (!(await this.pathExists(candidatePath))) {
587
+ return candidate;
588
+ }
589
+ if (await hasMatchingIdentity(candidate)) {
590
+ return candidate;
591
+ }
592
+ }
593
+ throw new errors_1.AccountNameInferenceError();
594
+ }
595
+ async loadReconciledRegistry() {
596
+ const accountNames = await this.listAccountNames();
597
+ const loaded = await (0, registry_1.loadRegistry)();
598
+ const base = loaded.version === 1 ? loaded : (0, registry_1.createDefaultRegistry)();
599
+ return (0, registry_1.reconcileRegistryWithAccounts)(base, accountNames);
600
+ }
601
+ async persistRegistry(registry) {
602
+ const reconciled = (0, registry_1.reconcileRegistryWithAccounts)(registry, await this.listAccountNames());
603
+ await (0, registry_1.saveRegistry)(reconciled);
604
+ }
605
+ async activateSnapshot(accountName) {
606
+ const name = this.normalizeAccountName(accountName);
607
+ const source = this.accountFilePath(name);
608
+ if (!(await this.pathExists(source))) {
609
+ throw new errors_1.AccountNotFoundError(name);
610
+ }
611
+ const authPath = (0, paths_1.resolveAuthPath)();
612
+ await this.ensureDir(node_path_1.default.dirname(authPath));
613
+ await promises_1.default.copyFile(source, authPath);
614
+ await this.writeCurrentName(name);
615
+ }
616
+ async clearActivePointers() {
617
+ const currentPath = (0, paths_1.resolveCurrentNamePath)();
618
+ const authPath = (0, paths_1.resolveAuthPath)();
619
+ await this.removeIfExists(currentPath);
620
+ await this.removeIfExists(authPath);
621
+ }
622
+ snapshotsShareIdentity(a, b) {
623
+ var _a, _b;
624
+ if (a.authMode !== "chatgpt" || b.authMode !== "chatgpt") {
625
+ return false;
626
+ }
627
+ if (a.userId && b.userId && a.accountId && b.accountId) {
628
+ return a.userId === b.userId && a.accountId === b.accountId;
629
+ }
630
+ if (a.accountId && b.accountId) {
631
+ return a.accountId === b.accountId;
632
+ }
633
+ if (a.userId && b.userId) {
634
+ return a.userId === b.userId;
635
+ }
636
+ const aEmail = (_a = a.email) === null || _a === void 0 ? void 0 : _a.trim().toLowerCase();
637
+ const bEmail = (_b = b.email) === null || _b === void 0 ? void 0 : _b.trim().toLowerCase();
638
+ if (aEmail && bEmail) {
639
+ return aEmail === bEmail;
640
+ }
641
+ return false;
642
+ }
643
+ renderSnapshotIdentity(snapshot, fallbackEmail) {
644
+ const parts = [fallbackEmail];
645
+ if (snapshot.accountId)
646
+ parts.push(`account:${snapshot.accountId}`);
647
+ if (snapshot.userId)
648
+ parts.push(`user:${snapshot.userId}`);
649
+ return parts.join(" | ");
650
+ }
136
651
  }
137
652
  exports.AccountService = AccountService;
@@ -0,0 +1,3 @@
1
+ import { ParsedAuthSnapshot } from "./types";
2
+ export declare function parseAuthSnapshotData(data: unknown): ParsedAuthSnapshot;
3
+ export declare function parseAuthSnapshotFile(snapshotPath: string): Promise<ParsedAuthSnapshot>;