maxpool 1.0.0

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.
package/src/config.js ADDED
@@ -0,0 +1,162 @@
1
+ import { readFile, writeFile, mkdir, rename, chmod, unlink } from 'node:fs/promises';
2
+ import { existsSync } from 'node:fs';
3
+ import { join, dirname } from 'node:path';
4
+ import { homedir } from 'node:os';
5
+ import { randomBytes } from 'node:crypto';
6
+
7
+ export function getConfigPath() {
8
+ if (process.env.MAXPOOL_CONFIG) return process.env.MAXPOOL_CONFIG;
9
+ if (process.env.TEAMCLAUDE_CONFIG) return process.env.TEAMCLAUDE_CONFIG; // legacy env
10
+ const configDir = process.env.XDG_CONFIG_HOME || join(homedir(), '.config');
11
+ const current = join(configDir, 'maxpool.json');
12
+ if (existsSync(current)) return current;
13
+ // Seamless upgrade from the project's former name: keep using an existing
14
+ // teamclaude.json in place rather than starting empty.
15
+ const legacy = join(configDir, 'teamclaude.json');
16
+ if (existsSync(legacy)) return legacy;
17
+ return current;
18
+ }
19
+
20
+ /**
21
+ * Path to the runtime state file (a sibling of the config). Holds volatile data
22
+ * learned at runtime — quota utilization observed passively from traffic — kept
23
+ * out of the hand-editable config so config stays clean and isn't rewritten on
24
+ * every state save.
25
+ */
26
+ export function getStatePath() {
27
+ const cfg = getConfigPath();
28
+ return cfg.endsWith('.json') ? cfg.replace(/\.json$/, '.state.json') : cfg + '.state';
29
+ }
30
+
31
+ export function createDefaultConfig() {
32
+ return {
33
+ proxy: {
34
+ port: 3456,
35
+ host: '127.0.0.1',
36
+ apiKey: 'mp-' + randomBytes(24).toString('base64url'),
37
+ },
38
+ upstream: 'https://api.anthropic.com',
39
+ switchThreshold: 0.90,
40
+ quotaProbeSeconds: 0, // background quota probe; 0 = off (opt-in)
41
+ routing: {
42
+ mode: 'automatic',
43
+ preferredAccount: null,
44
+ },
45
+ scheduler: {
46
+ mode: 'adaptive-least-loaded',
47
+ safetyMaxActivePerAccount: 50,
48
+ safetyMaxGlobalActive: 150,
49
+ cooldownMs: 30_000,
50
+ maxCooldownMs: 15 * 60_000,
51
+ },
52
+ retry: {
53
+ maxAttemptsPerRequest: 0,
54
+ maxRetryBufferBytes: 10 * 1024 * 1024,
55
+ },
56
+ queue: {
57
+ enabled: true,
58
+ maxWaitMs: 24 * 60 * 60 * 1000,
59
+ autoMaxWaitMs: null,
60
+ capacityMaxWaitMs: 15 * 60 * 1000,
61
+ maxQueuedBodyBytes: 256 * 1024 * 1024,
62
+ weeklyMaxWaitMs: 0,
63
+ pollMs: 1000,
64
+ heartbeatMs: 10_000,
65
+ },
66
+ accounts: [],
67
+ };
68
+ }
69
+
70
+ export async function loadConfig() {
71
+ const path = getConfigPath();
72
+ let raw;
73
+ try {
74
+ raw = await readFile(path, 'utf-8');
75
+ } catch (err) {
76
+ if (err.code === 'ENOENT') return null;
77
+ throw err;
78
+ }
79
+ try {
80
+ return JSON.parse(raw);
81
+ } catch (err) {
82
+ // With atomic temp+rename writes a torn read is impossible, so a parse
83
+ // failure means genuine corruption. Surface it clearly rather than as a
84
+ // cryptic crash, and do NOT return null — that would let a caller overwrite
85
+ // the file with defaults and lose recoverable OAuth credentials.
86
+ throw new Error(`config at ${path} is not valid JSON (corrupt?): ${err.message}`);
87
+ }
88
+ }
89
+
90
+ export async function loadOrCreateConfig() {
91
+ let config = await loadConfig();
92
+ if (!config) {
93
+ config = createDefaultConfig();
94
+ await saveConfig(config);
95
+ console.log(`Created config at ${getConfigPath()}`);
96
+ }
97
+ return config;
98
+ }
99
+
100
+ /**
101
+ * Atomic 0600 write: a crash/power-loss mid-write must never truncate the file
102
+ * (config holds every account's OAuth tokens). Write a sibling temp file, force
103
+ * 0600 (writeFile's mode only applies on create, not when overwriting an
104
+ * existing 0644 file), then rename — atomic within a filesystem on POSIX.
105
+ */
106
+ async function atomicWrite(path, content) {
107
+ await mkdir(dirname(path), { recursive: true });
108
+ const tmp = `${path}.${process.pid}.${randomBytes(6).toString('hex')}.tmp`;
109
+ try {
110
+ await writeFile(tmp, content, { mode: 0o600 });
111
+ await chmod(tmp, 0o600);
112
+ await rename(tmp, path);
113
+ } catch (err) {
114
+ await unlink(tmp).catch(() => {});
115
+ throw err;
116
+ }
117
+ }
118
+
119
+ export async function saveConfig(config) {
120
+ await atomicWrite(getConfigPath(), JSON.stringify(config, null, 2) + '\n');
121
+ }
122
+
123
+ /** Load runtime state (regenerable). Returns null if missing or unreadable —
124
+ * never throws, since stale/corrupt learned state must not crash startup. */
125
+ export async function loadState() {
126
+ try {
127
+ return JSON.parse(await readFile(getStatePath(), 'utf-8'));
128
+ } catch {
129
+ return null;
130
+ }
131
+ }
132
+
133
+ export async function saveState(state) {
134
+ await atomicWrite(getStatePath(), JSON.stringify(state, null, 2) + '\n');
135
+ }
136
+
137
+ let _configWriteChain = Promise.resolve();
138
+
139
+ /**
140
+ * Serialize an in-process read-modify-write of the config so concurrent updates
141
+ * cannot lose each other's writes — e.g. a background OAuth token refresh
142
+ * (fire-and-forget) racing a TUI account add/delete. Each update re-reads the
143
+ * latest config, applies updater(config), then saves atomically.
144
+ *
145
+ * NOTE: this serializes writes within THIS process only. A separate
146
+ * `maxpool import`/`login` process writing concurrently is not coordinated
147
+ * (that would require a lockfile) — but those are short, rare, human-driven.
148
+ */
149
+ export function atomicConfigUpdate(updater) {
150
+ const run = async () => {
151
+ const config = await loadConfig() || createDefaultConfig();
152
+ await updater(config);
153
+ await saveConfig(config);
154
+ return config;
155
+ };
156
+ // Run after any in-flight update regardless of whether it resolved or
157
+ // rejected, so one failure doesn't poison the chain; the caller still sees
158
+ // this update's real result/error.
159
+ const result = _configWriteChain.then(run, run);
160
+ _configWriteChain = result.then(() => {}, () => {});
161
+ return result;
162
+ }