pool-anything 0.1.0 → 0.1.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.
@@ -37,6 +37,22 @@ sdb.exec(`
37
37
  CREATE INDEX IF NOT EXISTS idx_usage_pool_created ON usage(pool_id, created_at);
38
38
  CREATE INDEX IF NOT EXISTS idx_usage_key_created ON usage(key_id, created_at);
39
39
  CREATE INDEX IF NOT EXISTS idx_pools_provider ON pools(provider);
40
+ CREATE TABLE IF NOT EXISTS calls (
41
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
42
+ pool_id INTEGER NOT NULL REFERENCES pools(id) ON DELETE CASCADE,
43
+ key_id INTEGER NOT NULL REFERENCES pool_keys(id) ON DELETE CASCADE,
44
+ method TEXT NOT NULL DEFAULT 'POST',
45
+ path TEXT NOT NULL DEFAULT '',
46
+ status INTEGER,
47
+ tokens INTEGER NOT NULL DEFAULT 0,
48
+ body TEXT NOT NULL DEFAULT '',
49
+ truncated INTEGER NOT NULL DEFAULT 0,
50
+ encoding TEXT NOT NULL DEFAULT 'text',
51
+ content_type TEXT NOT NULL DEFAULT '',
52
+ error TEXT NOT NULL DEFAULT '',
53
+ created_at TEXT NOT NULL DEFAULT (datetime('now'))
54
+ );
55
+ CREATE INDEX IF NOT EXISTS idx_calls_pool_created ON calls(pool_id, created_at);
40
56
  `);
41
57
  const FALLBACK_PROVIDERS = [
42
58
  {
@@ -108,6 +124,29 @@ export function mask(key) {
108
124
  return "****";
109
125
  return `${key.slice(0, 4)}…${key.slice(-2)}`;
110
126
  }
127
+ /** Max response chars kept per call row — bounds DB growth (media relays are MBs). */
128
+ export const CALLS_BODY_CHARS = 4000;
129
+ /** Higher cap for binary media (audio/video/image base64) so generated files
130
+ * stay playable/renderable from History. Still bounded: ~2MB per row worst case. */
131
+ export const CALLS_MEDIA_CHARS = 2_000_000;
132
+ /** Pick the storage slice for a relay body: text stays small, binary media
133
+ * keeps enough bytes to stay playable/renderable. Pure (testable). */
134
+ export function callBodyForStorage(relayBody, encoding, truncated) {
135
+ const cap = encoding === "base64" ? CALLS_MEDIA_CHARS : CALLS_BODY_CHARS;
136
+ if (relayBody.length <= cap)
137
+ return { body: relayBody, truncated };
138
+ return { body: relayBody.slice(0, cap), truncated: true };
139
+ }
140
+ export function logCall(c) {
141
+ try {
142
+ sdb
143
+ .prepare("INSERT INTO calls (pool_id, key_id, method, path, status, tokens, body, truncated, encoding, content_type, error) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)")
144
+ .run(c.pool_id, c.key_id, c.method.slice(0, 16), c.path.slice(0, 2000), c.status, c.tokens, c.body.slice(0, CALLS_MEDIA_CHARS), c.truncated ? 1 : 0, c.encoding.slice(0, 16), c.contentType.slice(0, 256), c.error.slice(0, 500));
145
+ }
146
+ catch {
147
+ // Call logging must never break proxying.
148
+ }
149
+ }
111
150
  export function providerKeyFields(provider) {
112
151
  return PROVIDERS.find((x) => x.id === provider)?.keyFields ?? ["api_key"];
113
152
  }
@@ -1,4 +1,4 @@
1
- import { sdb, getPool } from "../pool/index.js";
1
+ import { sdb, getPool, logCall, callBodyForStorage } from "../pool/index.js";
2
2
  import { nextKeyRaw, markSuccess, markFailure } from "../pool/rotation.js";
3
3
  import { resolveTarget, resolveTargetForKey, effectiveApiKey, buildUpstreamRequest, validateQueryParams } from "../upstream/index.js";
4
4
  import { allowPrivateUpstream, proxyTimeoutMs } from "../config/env.js";
@@ -168,6 +168,25 @@ export function validateProxyOpts(opts) {
168
168
  if (!q.ok)
169
169
  return { ok: false, error: q.error };
170
170
  }
171
+ if (opts.body !== undefined && opts.form !== undefined)
172
+ return { ok: false, error: "body and form are mutually exclusive" };
173
+ if (opts.form !== undefined) {
174
+ if (typeof opts.form !== "object" || opts.form === null || Array.isArray(opts.form))
175
+ return { ok: false, error: "form must be an object" };
176
+ const entries = Object.entries(opts.form);
177
+ if (entries.length > 50)
178
+ return { ok: false, error: "too many form fields" };
179
+ let bytes = 0;
180
+ for (const [k, v] of entries) {
181
+ if (typeof k !== "string" || k.length === 0 || k.length > 256 || /[\r\n\x00]/.test(k))
182
+ return { ok: false, error: "invalid form field name" };
183
+ if (typeof v !== "string" || v.length > 8000 || /[\r\n\x00]/.test(v))
184
+ return { ok: false, error: "invalid form field value" };
185
+ bytes += k.length + v.length;
186
+ }
187
+ if (bytes > MAX_PROXY_BODY_BYTES)
188
+ return { ok: false, error: `proxy form too large (max ${Math.round(MAX_PROXY_BODY_BYTES / 1_000_000)}MB)` };
189
+ }
171
190
  if (opts.body !== undefined) {
172
191
  try {
173
192
  const s = JSON.stringify(opts.body);
@@ -224,6 +243,14 @@ async function readCappedBody(res, cap) {
224
243
  export function timeoutMs() {
225
244
  return proxyTimeoutMs();
226
245
  }
246
+ /** POOL_DEBUG=1: per-request proxy trace on stderr (method/path/pool and
247
+ * upstream statuses). Never logs bodies or key material. */
248
+ function dlog(poolId, opts, tried, extra = "") {
249
+ if (process.env.POOL_DEBUG !== "1")
250
+ return;
251
+ const st = tried.map((t) => (t.status ?? "?")).join(",");
252
+ console.error(`[proxy] ${opts.method || "POST"} ${opts.path || "/"} pool=${poolId} tried=[${st}]${extra ? " " + extra : ""}`);
253
+ }
227
254
  /**
228
255
  * Forward one request through the pool: rotate, send, fail over on
229
256
  * 429/5xx/network errors (key cools down, next key tried). Returns the
@@ -247,8 +274,10 @@ export async function forward(poolId, opts) {
247
274
  }
248
275
  }
249
276
  const valid = validateProxyOpts(opts);
250
- if (!valid.ok)
277
+ if (!valid.ok) {
278
+ dlog(poolId, opts, [], `invalid: ${valid.error}`);
251
279
  return { status: 400, error: valid.error };
280
+ }
252
281
  // Bound failover: one client request must not fan out to every key in a
253
282
  // large pool (100 keys x timeout each would starve the single-thread server).
254
283
  const keyTotal = sdb.prepare("SELECT COUNT(*) AS n FROM pool_keys WHERE pool_id = ?").get(poolId).n;
@@ -257,6 +286,7 @@ export async function forward(poolId, opts) {
257
286
  for (let attempt = 0; attempt < maxAttempts; attempt++) {
258
287
  const sel = nextKeyRaw(poolId);
259
288
  if ("error" in sel) {
289
+ dlog(poolId, opts, tried, `no-key: ${sel.error}`);
260
290
  return { status: 400, error: sel.error, tried };
261
291
  }
262
292
  let req;
@@ -293,13 +323,16 @@ export async function forward(poolId, opts) {
293
323
  }
294
324
  catch {
295
325
  markFailure(sel.key_id);
326
+ logCall({ pool_id: poolId, key_id: sel.key_id, method: opts.method || "POST", path: opts.path || "/", status: null, tokens: 0, body: "", truncated: false, encoding: "text", contentType: "", error: "upstream network error" });
296
327
  tried.push({ key_id: sel.key_id, label: sel.label, detail: "upstream network error" });
297
328
  continue;
298
329
  }
299
330
  if (upstream.status >= 300 && upstream.status < 400) {
300
331
  // Redirects are not followed and are not the key's fault: report without cooling.
301
332
  await upstream.body?.cancel().catch(() => undefined);
333
+ logCall({ pool_id: poolId, key_id: sel.key_id, method: opts.method || "POST", path: opts.path || "/", status: upstream.status, tokens: 0, body: "", truncated: false, encoding: "text", contentType: "", error: "redirect not followed" });
302
334
  tried.push({ key_id: sel.key_id, label: sel.label, status: upstream.status });
335
+ dlog(poolId, opts, tried, "redirect");
303
336
  return { status: 400, error: `upstream redirected (HTTP ${upstream.status}, location not followed)`, tried };
304
337
  }
305
338
  let text = "";
@@ -315,6 +348,7 @@ export async function forward(poolId, opts) {
315
348
  }
316
349
  catch {
317
350
  markFailure(sel.key_id);
351
+ logCall({ pool_id: poolId, key_id: sel.key_id, method: opts.method || "POST", path: opts.path || "/", status: upstream.status, tokens: 0, body: "", truncated: false, encoding: "text", contentType: "", error: "upstream body error" });
318
352
  tried.push({ key_id: sel.key_id, label: sel.label, status: upstream.status, detail: "upstream body error" });
319
353
  await upstream.body?.cancel().catch(() => undefined);
320
354
  continue;
@@ -325,10 +359,13 @@ export async function forward(poolId, opts) {
325
359
  const relayBody = b64 !== undefined ? b64.slice(0, MAX_RELAY_CHARS) : text.slice(0, MAX_RELAY_CHARS);
326
360
  truncated = truncated || rawLen > MAX_RELAY_CHARS;
327
361
  const encoding = b64 !== undefined ? "base64" : "text";
362
+ const stored = callBodyForStorage(relayBody, encoding, truncated);
363
+ const callBase = { pool_id: poolId, key_id: sel.key_id, method: opts.method || "POST", path: opts.path || "/", body: stored.body, truncated: stored.truncated, encoding, contentType };
328
364
  if (upstream.status === 429 || upstream.status >= 500 || upstream.status === 401 || upstream.status === 403) {
329
365
  // 401/403 = invalid/revoked key: cool it so rotation skips it instead of
330
366
  // paying one dead upstream call on every subsequent request.
331
367
  markFailure(sel.key_id);
368
+ logCall({ ...callBase, status: upstream.status, tokens: 0, error: "" });
332
369
  tried.push({ key_id: sel.key_id, label: sel.label, status: upstream.status });
333
370
  await upstream.body?.cancel().catch(() => undefined);
334
371
  continue;
@@ -337,6 +374,8 @@ export async function forward(poolId, opts) {
337
374
  // Other 4xx: client/upstream error, not the key's fault. Passthrough
338
375
  // without cooling and without charging quota.
339
376
  tried.push({ key_id: sel.key_id, label: sel.label, status: upstream.status });
377
+ logCall({ ...callBase, status: upstream.status, tokens: 0, error: "" });
378
+ dlog(poolId, opts, tried, "passthrough");
340
379
  return { status: 200, key_id: sel.key_id, label: sel.label, masked: sel.masked, upstreamStatus: upstream.status, body: relayBody, encoding, contentType, truncated, tried };
341
380
  }
342
381
  markSuccess(sel.key_id, Date.now() - started);
@@ -344,7 +383,10 @@ export async function forward(poolId, opts) {
344
383
  if (Number.isInteger(tok) && tok > 0) {
345
384
  sdb.prepare("INSERT INTO usage (pool_id, key_id, tokens) VALUES (?, ?, ?)").run(poolId, sel.key_id, tok);
346
385
  }
386
+ logCall({ ...callBase, status: upstream.status, tokens: Number.isInteger(tok) && tok > 0 ? tok : 0, error: "" });
387
+ dlog(poolId, opts, tried, `ok upstream=${upstream.status}`);
347
388
  return { status: 200, key_id: sel.key_id, label: sel.label, masked: sel.masked, upstreamStatus: upstream.status, body: relayBody, encoding, contentType, truncated, tried };
348
389
  }
390
+ dlog(poolId, opts, tried, "all-failed");
349
391
  return { status: 400, error: "all keys failed or cooling down", tried };
350
392
  }
@@ -2,11 +2,11 @@
2
2
  export const MAX_POOLS = 1000;
3
3
  export const MAX_KEYS_PER_POOL = 100;
4
4
  export const MAX_API_KEY_CHARS = 4096;
5
- /** Match /api/pools/:id[/next|/consume|/usage|/proxy] (never /keys). */
5
+ /** Match /api/pools/:id[/next|/consume|/usage|/proxy|/calls] (never /keys). */
6
6
  export function matchPoolRoute(pathname) {
7
7
  if (pathname.includes("/keys"))
8
8
  return null;
9
- const m = pathname.match(/^\/api\/pools\/(\d+)(\/next|\/consume|\/usage|\/proxy)?$/);
9
+ const m = pathname.match(/^\/api\/pools\/(\d+)(\/next|\/consume|\/usage|\/proxy|\/calls)?$/);
10
10
  if (!m)
11
11
  return null;
12
12
  return { poolId: Number(m[1]), sub: (m[2] ?? "") };
package/dist/server.js CHANGED
@@ -10,9 +10,11 @@ import { sdb, PROVIDERS, mask, maskCredentials, normalizeCredentials, parseCrede
10
10
  import { nextKey, recordUsage } from "./pool/rotation.js";
11
11
  import { forward, MAX_TOKENS_PER_REQUEST, checkTarget } from "./proxy/forward.js";
12
12
  import { DOCS_URL } from "./admin/branding.js";
13
- import { page, keysPage, poolsPage, analyticsPage, playgroundPage, providerPage } from "./admin/pages.js";
13
+ import { robotsTxt, sitemapXml } from "./admin/seo.js";
14
+ import { page, keysPage, poolsPage, analyticsPage, historyPage, playgroundPage, providerPage } from "./admin/pages.js";
14
15
  import { MAX_POOLS, MAX_KEYS_PER_POOL, MAX_API_KEY_CHARS, matchPoolRoute, matchKeyRoute } from "./router/api.js";
15
16
  import { health, readiness, analyticsSnapshot } from "./observability/health.js";
17
+ import { getVersionInfo, performUpdate } from "./version.js";
16
18
  const server = http.createServer(async (req, res) => {
17
19
  const url = new URL(req.url ?? "/", "http://localhost");
18
20
  // CORS: only the hosted Pages tools UI (plus http loopback dev and
@@ -62,6 +64,15 @@ const server = http.createServer(async (req, res) => {
62
64
  send(res, 200, PROVIDERS);
63
65
  return;
64
66
  }
67
+ if (req.method === "GET" && url.pathname === "/api/version") {
68
+ send(res, 200, await getVersionInfo());
69
+ return;
70
+ }
71
+ if (req.method === "POST" && url.pathname === "/api/update") {
72
+ const r = await performUpdate();
73
+ send(res, r.ok ? 200 : 500, r);
74
+ return;
75
+ }
65
76
  if (req.method === "GET" && url.pathname === "/api/analytics") {
66
77
  const r = analyticsSnapshot();
67
78
  send(res, r.status, r.body);
@@ -120,6 +131,7 @@ const server = http.createServer(async (req, res) => {
120
131
  if (req.method === "DELETE" && !sub) {
121
132
  sdb.exec("BEGIN IMMEDIATE");
122
133
  try {
134
+ sdb.prepare("DELETE FROM calls WHERE pool_id = ?").run(poolId);
123
135
  sdb.prepare("DELETE FROM usage WHERE pool_id = ?").run(poolId);
124
136
  sdb.prepare("DELETE FROM pool_keys WHERE pool_id = ?").run(poolId);
125
137
  const r = sdb.prepare("DELETE FROM pools WHERE id = ?").run(poolId);
@@ -168,6 +180,14 @@ const server = http.createServer(async (req, res) => {
168
180
  send(res, 200, { pool_id: s.id, used: s.used, usedInWindow: s.usedInWindow, quota: s.quota, quotaWindow: s.quotaWindow, remaining: s.remaining, perKey: s.perKey });
169
181
  return;
170
182
  }
183
+ if (req.method === "GET" && sub === "/calls") {
184
+ const pool = getPool(poolId);
185
+ if (!pool)
186
+ return send(res, 404, { error: "pool not found" });
187
+ const limit = Math.min(Math.max(Number(url.searchParams.get("limit")) || 50, 1), 200);
188
+ send(res, 200, sdb.prepare("SELECT id, pool_id, key_id, method, path, status, tokens, body, truncated, encoding, content_type, error, created_at FROM calls WHERE pool_id = ? ORDER BY id DESC LIMIT ?").all(poolId, limit));
189
+ return;
190
+ }
171
191
  if (req.method === "POST" && sub === "/proxy") {
172
192
  // Media-aware limit: base64 image/audio/video payloads ride inside JSON.
173
193
  const b = (await readJson(req, MAX_PROXY_BODY_BYTES));
@@ -189,13 +209,15 @@ const server = http.createServer(async (req, res) => {
189
209
  if (!pool)
190
210
  return send(res, 404, { error: "pool not found" });
191
211
  const fields = providerKeyFields(pool.provider);
212
+ const now = Date.now();
192
213
  const rows = sdb
193
- .prepare("SELECT id, label, api_key, COALESCE(credentials,'{}') AS credentials, info, created_at FROM pool_keys WHERE pool_id = ? ORDER BY id")
214
+ .prepare("SELECT id, label, api_key, COALESCE(credentials,'{}') AS credentials, info, created_at, cooldown_until FROM pool_keys WHERE pool_id = ? ORDER BY id")
194
215
  .all(poolId);
195
216
  send(res, 200, rows.map((k) => {
196
217
  const creds = parseCredentials(k.credentials);
197
218
  return {
198
219
  id: k.id, label: k.label, masked: mask(k.api_key), info: k.info, created_at: k.created_at,
220
+ cooling: k.cooldown_until > now,
199
221
  keyFields: fields,
200
222
  credentials: Object.keys(creds).length > 0 ? maskCredentials(creds) : undefined,
201
223
  };
@@ -268,12 +290,12 @@ const server = http.createServer(async (req, res) => {
268
290
  }
269
291
  if (req.method === "GET" && keyId !== undefined) {
270
292
  const row = sdb
271
- .prepare("SELECT id, label, api_key, COALESCE(credentials,'{}') AS credentials, info, created_at FROM pool_keys WHERE id = ? AND pool_id = ?")
293
+ .prepare("SELECT id, label, api_key, COALESCE(credentials,'{}') AS credentials, info, created_at, cooldown_until FROM pool_keys WHERE id = ? AND pool_id = ?")
272
294
  .get(Number(keyId), poolId);
273
295
  if (!row)
274
296
  return send(res, 404, { error: "key not found" });
275
297
  const creds = parseCredentials(row.credentials);
276
- send(res, 200, { ...row, credentials: Object.keys(creds).length > 0 ? creds : undefined, masked: mask(row.api_key), maskedCredentials: Object.keys(creds).length > 0 ? maskCredentials(creds) : undefined });
298
+ send(res, 200, { ...row, cooling: row.cooldown_until > Date.now(), credentials: Object.keys(creds).length > 0 ? creds : undefined, masked: mask(row.api_key), maskedCredentials: Object.keys(creds).length > 0 ? maskCredentials(creds) : undefined });
277
299
  return;
278
300
  }
279
301
  if (req.method === "PATCH" && keyId !== undefined) {
@@ -326,6 +348,7 @@ const server = http.createServer(async (req, res) => {
326
348
  return;
327
349
  }
328
350
  if (req.method === "DELETE" && keyId !== undefined) {
351
+ sdb.prepare("DELETE FROM calls WHERE key_id = ?").run(Number(keyId));
329
352
  sdb.prepare("DELETE FROM usage WHERE key_id = ?").run(Number(keyId));
330
353
  const r = sdb.prepare("DELETE FROM pool_keys WHERE id = ? AND pool_id = ?").run(Number(keyId), poolId);
331
354
  if (r.changes === 0)
@@ -370,6 +393,11 @@ const server = http.createServer(async (req, res) => {
370
393
  res.end(analyticsPage());
371
394
  return;
372
395
  }
396
+ if (req.method === "GET" && url.pathname === "/history") {
397
+ res.writeHead(200, { "content-type": "text/html; charset=utf-8" });
398
+ res.end(historyPage());
399
+ return;
400
+ }
373
401
  if (req.method === "GET" && url.pathname.startsWith("/provider/")) {
374
402
  const pid = url.pathname.slice("/provider/".length);
375
403
  if (!/^[a-z0-9-]+$/.test(pid)) {
@@ -402,6 +430,17 @@ const server = http.createServer(async (req, res) => {
402
430
  res.end(h.body);
403
431
  return;
404
432
  }
433
+ if (req.method === "GET" && url.pathname === "/robots.txt") {
434
+ res.writeHead(200, { "content-type": "text/plain; charset=utf-8", "cache-control": "public, max-age=3600" });
435
+ res.end(robotsTxt());
436
+ return;
437
+ }
438
+ if (req.method === "GET" && url.pathname === "/sitemap.xml") {
439
+ const ids = PROVIDERS.map((p) => p.id).filter((id) => typeof id === "string");
440
+ res.writeHead(200, { "content-type": "application/xml; charset=utf-8", "cache-control": "public, max-age=3600" });
441
+ res.end(sitemapXml(ids));
442
+ return;
443
+ }
405
444
  res.writeHead(404, { "content-type": "text/plain" });
406
445
  res.end("not found\n");
407
446
  }
@@ -133,5 +133,15 @@ export function buildUpstreamRequest(target, apiKey, opts) {
133
133
  headers[target.keyHeader] = target.keyPrefix + apiKey;
134
134
  }
135
135
  const base = target.baseUrl.endsWith("/") ? target.baseUrl.slice(0, -1) : target.baseUrl;
136
+ if (opts.form !== undefined) {
137
+ // Form-only upstreams: urlencode fields and override the JSON content type.
138
+ // Caller content-type is dropped so the header can't lie about the body.
139
+ for (const k of Object.keys(headers)) {
140
+ if (k.toLowerCase() === "content-type")
141
+ delete headers[k];
142
+ }
143
+ headers["content-type"] = "application/x-www-form-urlencoded";
144
+ return { url: base + p, headers, body: new URLSearchParams(opts.form).toString() };
145
+ }
136
146
  return { url: base + p, headers, body: opts.body === undefined ? undefined : JSON.stringify(opts.body) };
137
147
  }
@@ -0,0 +1,160 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import { spawnSync } from "node:child_process";
4
+ import { PACKAGE_ROOT } from "./config/paths.js";
5
+ export const PACKAGE_NAME = "pool-anything";
6
+ const REGISTRY_URL = `https://registry.npmjs.org/${PACKAGE_NAME}/latest`;
7
+ const LATEST_TTL_MS = 5 * 60 * 1000;
8
+ export function readCurrentVersion() {
9
+ try {
10
+ const raw = fs.readFileSync(path.join(PACKAGE_ROOT, "package.json"), "utf8");
11
+ const pkg = JSON.parse(raw);
12
+ return typeof pkg.version === "string" ? pkg.version : "0.0.0";
13
+ }
14
+ catch {
15
+ return "0.0.0";
16
+ }
17
+ }
18
+ function npmCmd() {
19
+ return process.platform === "win32" ? "npm.cmd" : "npm";
20
+ }
21
+ /** Numeric semver compare: >0 when a is newer, 0 when equal, <0 when older. */
22
+ export function compareVersions(a, b) {
23
+ const norm = (v) => v
24
+ .trim()
25
+ .replace(/^v/i, "")
26
+ .split("-")[0]
27
+ .split(".")
28
+ .map((p) => {
29
+ const n = Number(p);
30
+ return Number.isFinite(n) ? n : 0;
31
+ });
32
+ const pa = norm(a);
33
+ const pb = norm(b);
34
+ for (let i = 0; i < Math.max(pa.length, pb.length); i++) {
35
+ const d = (pa[i] ?? 0) - (pb[i] ?? 0);
36
+ if (d !== 0)
37
+ return d;
38
+ }
39
+ return 0;
40
+ }
41
+ export async function fetchLatestVersion(timeoutMs = 6000) {
42
+ try {
43
+ const ctrl = new AbortController();
44
+ const t = setTimeout(() => ctrl.abort(), timeoutMs);
45
+ try {
46
+ const r = await fetch(REGISTRY_URL, { signal: ctrl.signal });
47
+ if (!r.ok)
48
+ return null;
49
+ const j = (await r.json());
50
+ return typeof j.version === "string" ? j.version : null;
51
+ }
52
+ finally {
53
+ clearTimeout(t);
54
+ }
55
+ }
56
+ catch {
57
+ return null;
58
+ }
59
+ }
60
+ let cachedLatest = null;
61
+ export async function getVersionInfo() {
62
+ const current = readCurrentVersion();
63
+ const now = Date.now();
64
+ if (cachedLatest && now - cachedLatest.at < LATEST_TTL_MS) {
65
+ const latest = cachedLatest.version;
66
+ return {
67
+ current,
68
+ latest,
69
+ updateAvailable: latest !== null && compareVersions(latest, current) > 0,
70
+ };
71
+ }
72
+ const latest = await fetchLatestVersion();
73
+ cachedLatest = { version: latest, at: now };
74
+ return {
75
+ current,
76
+ latest,
77
+ updateAvailable: latest !== null && compareVersions(latest, current) > 0,
78
+ };
79
+ }
80
+ function runNpm(args) {
81
+ try {
82
+ const r = spawnSync(npmCmd(), args, { encoding: "utf8", timeout: 120_000 });
83
+ const out = `${r.stdout ?? ""}${r.stderr ?? ""}`.trim();
84
+ const err = r.error;
85
+ if (err)
86
+ return { ok: false, output: `npm ${args.join(" ")} failed: ${err.message}` };
87
+ return {
88
+ ok: r.status === 0,
89
+ output: out.slice(-2000) || (r.status === 0 ? "ok" : `npm exited with code ${r.status}`),
90
+ };
91
+ }
92
+ catch (e) {
93
+ return { ok: false, output: `npm ${args.join(" ")} failed: ${e.message}` };
94
+ }
95
+ }
96
+ export async function performUpdate() {
97
+ const previous = readCurrentVersion();
98
+ const latest = await fetchLatestVersion();
99
+ if (latest !== null && compareVersions(latest, previous) <= 0) {
100
+ return {
101
+ ok: true,
102
+ previous,
103
+ current: previous,
104
+ latest,
105
+ updated: false,
106
+ text: `Already on latest v${previous}.`,
107
+ };
108
+ }
109
+ const r = runNpm(["install", "-g", `${PACKAGE_NAME}@latest`]);
110
+ if (!r.ok) {
111
+ return {
112
+ ok: false,
113
+ previous,
114
+ current: previous,
115
+ latest,
116
+ updated: false,
117
+ text: `Update failed: ${r.output}`,
118
+ output: r.output,
119
+ };
120
+ }
121
+ // Bust the cached latest — the registry state is now what we run (or will
122
+ // run after restart).
123
+ cachedLatest = { version: latest, at: Date.now() };
124
+ const current = readCurrentVersion();
125
+ const target = latest ?? (current !== previous ? current : null);
126
+ if (target !== null && current === target && current !== previous) {
127
+ return {
128
+ ok: true,
129
+ previous,
130
+ current,
131
+ latest,
132
+ updated: true,
133
+ text: `Updated v${previous} → v${current}. Restart the shell/server to use it.`,
134
+ output: r.output,
135
+ };
136
+ }
137
+ // Source checkout (npm -g updates the global copy, not this tree) or a
138
+ // registry check that failed: the install succeeded, report what we know.
139
+ const suffix = target !== null ? ` (latest v${target}). Restart to use it.` : `. Restart to use it.`;
140
+ return {
141
+ ok: true,
142
+ previous,
143
+ current,
144
+ latest,
145
+ updated: true,
146
+ text: `Update installed${latest !== null ? ` v${previous} → v${latest}` : ""}${suffix} ${r.output}`.trim(),
147
+ output: r.output,
148
+ };
149
+ }
150
+ export function performUninstall() {
151
+ const r = runNpm(["uninstall", "-g", PACKAGE_NAME]);
152
+ if (!r.ok) {
153
+ return { ok: false, text: `Uninstall failed: ${r.output}`, output: r.output };
154
+ }
155
+ return {
156
+ ok: true,
157
+ text: `Uninstalled ${PACKAGE_NAME}. Your pools/keys in SQLite are untouched (remove ~/.pool-anything to wipe data).`,
158
+ output: r.output,
159
+ };
160
+ }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "pool-anything",
3
- "version": "0.1.0",
4
- "description": "Gather free-tier API keys from any provider into one pool and rotate through them — round-robin key selection, usage/quota tracking, and a drop-in HTTP proxy with a built-in web UI. Zero dependencies, TypeScript + SQLite.",
3
+ "version": "0.1.1",
4
+ "description": "Gather free-tier API keys from any provider into one pool and rotate through them — round-robin key selection, usage/quota tracking, and a drop-in HTTP proxy with a built-in web UI. Dependency-free server core, TypeScript + SQLite.",
5
5
  "keywords": [
6
6
  "api-key-pool",
7
7
  "key-rotation",
@@ -15,7 +15,7 @@
15
15
  "quota",
16
16
  "sqlite",
17
17
  "typescript",
18
- "zero-dependency",
18
+ "ink-tui",
19
19
  "groq",
20
20
  "openrouter",
21
21
  "gemini",
@@ -62,7 +62,12 @@
62
62
  },
63
63
  "devDependencies": {
64
64
  "@types/node": "^22.7.4",
65
+ "@types/react": "^19.3.0",
65
66
  "tsx": "^4.19.1",
66
67
  "typescript": "^5.6.2"
68
+ },
69
+ "dependencies": {
70
+ "ink": "^7.1.1",
71
+ "react": "^19.3.0"
67
72
  }
68
73
  }