teamclaude-cloud 1.4.1

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,148 @@
1
+ import { readFile, writeFile, mkdir, rm } from 'node:fs/promises';
2
+ import { writeFileSync, mkdirSync } 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.TEAMCLAUDE_CONFIG) return process.env.TEAMCLAUDE_CONFIG;
9
+ const configDir = process.env.XDG_CONFIG_HOME || join(homedir(), '.config');
10
+ return join(configDir, 'teamclaude.json');
11
+ }
12
+
13
+ // Runtime state for the running server (pid/port), written next to the config so
14
+ // `teamclaude status` / `stop` / `restart` can find and signal it without the
15
+ // user hunting for the PID. One file per config, so different configs (and ports)
16
+ // never collide.
17
+ export function getServerStatePath() {
18
+ return getConfigPath().replace(/\.json$/, '') + '.server.json';
19
+ }
20
+
21
+ export async function writeServerState(state) {
22
+ const path = getServerStatePath();
23
+ await mkdir(dirname(path), { recursive: true });
24
+ await writeFile(path, JSON.stringify(state, null, 2) + '\n', { mode: 0o600 });
25
+ }
26
+
27
+ export async function readServerState() {
28
+ try {
29
+ return JSON.parse(await readFile(getServerStatePath(), 'utf-8'));
30
+ } catch {
31
+ return null; // missing or unreadable → treat as "no recorded server"
32
+ }
33
+ }
34
+
35
+ export async function clearServerState() {
36
+ try { await rm(getServerStatePath(), { force: true }); } catch { /* best-effort */ }
37
+ }
38
+
39
+ // Per-account quota snapshot (credential-free), written next to the config so a
40
+ // restarted server starts from the last known quota/throttle state instead of a
41
+ // blank dashboard. Unlike the server-state file this survives exit on purpose.
42
+ export function getQuotaCachePath() {
43
+ return getConfigPath().replace(/\.json$/, '') + '.quota.json';
44
+ }
45
+
46
+ export async function readQuotaCache() {
47
+ try {
48
+ return JSON.parse(await readFile(getQuotaCachePath(), 'utf-8'));
49
+ } catch {
50
+ return null; // missing or unreadable → start unmeasured, exactly as before
51
+ }
52
+ }
53
+
54
+ /**
55
+ * Synchronous on purpose: the last write happens inside a process 'exit'
56
+ * handler, where async I/O never completes. The snapshot is small (a few KB).
57
+ */
58
+ export function writeQuotaCacheSync(data) {
59
+ try {
60
+ const path = getQuotaCachePath();
61
+ mkdirSync(dirname(path), { recursive: true });
62
+ writeFileSync(path, JSON.stringify(data, null, 2) + '\n', { mode: 0o600 });
63
+ } catch { /* best-effort — a failed snapshot must never break the proxy */ }
64
+ }
65
+
66
+ export function createDefaultConfig() {
67
+ return {
68
+ proxy: {
69
+ port: 3456,
70
+ apiKey: 'tc-' + randomBytes(24).toString('base64url'),
71
+ },
72
+ upstream: 'https://api.anthropic.com',
73
+ switchThreshold: 0.98,
74
+ // Max simultaneous in-flight requests per account before load spreads to the
75
+ // next account (per-account `maxConcurrent` overrides this). Tune to just
76
+ // below where one account starts returning rate/concurrency 429s.
77
+ maxConcurrentPerAccount: 3,
78
+ // Keep one client connection's sequential requests on the same account so
79
+ // Anthropic's per-account prompt cache stays warm (a session's turns reuse
80
+ // the keep-alive socket). Soft: concurrent overflow still spreads to other
81
+ // accounts. Set false to route every request purely by use-or-lose priority.
82
+ sessionAffinity: true,
83
+ // How long (ms) a request waits for a free slot when every account is at its
84
+ // cap, before returning 429. 0 = never queue.
85
+ overflowQueueTimeoutMs: 15000,
86
+ // Hard caps that bound proxy memory under a request flood.
87
+ overflowQueueMaxDepth: 256, // max queued requests before 429
88
+ maxRequestBytes: 33554432, // 32 MiB max buffered request body, else 413
89
+ accounts: [],
90
+ };
91
+ }
92
+
93
+ export async function loadConfig() {
94
+ const path = getConfigPath();
95
+ try {
96
+ return JSON.parse(await readFile(path, 'utf-8'));
97
+ } catch (err) {
98
+ if (err.code === 'ENOENT') return null;
99
+ throw err;
100
+ }
101
+ }
102
+
103
+ export async function loadOrCreateConfig() {
104
+ let config = await loadConfig();
105
+ if (!config) {
106
+ config = createDefaultConfig();
107
+ await saveConfig(config);
108
+ console.log(`Created config at ${getConfigPath()}`);
109
+ }
110
+ return config;
111
+ }
112
+
113
+ export async function saveConfig(config) {
114
+ const path = getConfigPath();
115
+ await mkdir(dirname(path), { recursive: true });
116
+ await writeFile(path, JSON.stringify(config, null, 2) + '\n', { mode: 0o600 });
117
+ }
118
+
119
+ /**
120
+ * Atomically update the config: re-reads from disk, calls updater(config),
121
+ * then saves. Returns the updated config. This prevents overwriting changes
122
+ * made by other processes (e.g. `teamclaude import` while the server runs).
123
+ *
124
+ * Calls are SERIALIZED within this process (via the chain below): atomicConfigUpdate
125
+ * re-reads the whole file, mutates, and writes it all back, so two concurrent callers
126
+ * — e.g. a background token refresh and a TUI save/delete — would each read the same
127
+ * snapshot and the later write would clobber the earlier one's change (resurrecting a
128
+ * just-deleted account, or dropping a freshly-refreshed token). Chaining makes each
129
+ * cycle observe the previous cycle's write. Cross-PROCESS races remain (the
130
+ * single-proxy design assumption); a CLI write while the server runs is reconciled on
131
+ * the next reload.
132
+ */
133
+ let _configWriteChain = Promise.resolve();
134
+
135
+ export function atomicConfigUpdate(updater) {
136
+ const run = async () => {
137
+ const config = await loadConfig() || createDefaultConfig();
138
+ await updater(config);
139
+ await saveConfig(config);
140
+ return config;
141
+ };
142
+ // Run after the previous cycle settles (success OR failure) so one failed update
143
+ // can't stall the queue; keep the chain itself non-rejecting and surface the
144
+ // result/error only to this caller.
145
+ const result = _configWriteChain.then(run, run);
146
+ _configWriteChain = result.then(() => {}, () => {});
147
+ return result;
148
+ }