685eb748e8
- The update dialog can be closed while an update applies: it keeps running, the page still reloads itself when the new server is up, and a failure re-opens the dialog with its error. - Account pool card: one tab per AI tool (Claude Code, Codex, Cursor CLI, Oh My Pi, and every other installed tool, which says it has no pool), accounts as cards with 5-hour and weekly quota shown as what is LEFT plus their reset times, and the saved profiles of Settings → AI tools → Profiles as cards with "Share to pool". - Cursor account pool (server/lib/cursor-pool.js): Cursor API keys handed to a cursor-agent pane only through tmux -e CURSOR_API_KEY; `agenthub accounts add <name> --tool cursor`. - Pool groups (server/lib/pool-groups.js, /api/accounts/groups): a group's members run only on its accounts of each tool, a user in no group on the accounts no group lists, a pin still wins. account_pins loses its tool CHECK (rebuilt, pins kept); three new tables. - Oh My Pi login sharing can be switched from its pool tab too.
157 lines
5.3 KiB
JavaScript
157 lines
5.3 KiB
JavaScript
/**
|
||
* @file cursor-pool.js
|
||
* @description The Cursor CLI account POOL, modelled on codex-pool.js's API-key
|
||
* half: Cursor API keys an admin added (`agenthub accounts add <name> --tool
|
||
* cursor`, or the Users page), and the balancer that gives every new
|
||
* `cursor-agent` pane one of them. The key reaches the pane only through tmux
|
||
* `-e CURSOR_API_KEY` (a member's launch file under S3) — `cursor-agent` reads
|
||
* it from there and it outranks the account's own `cursor-agent login` for
|
||
* that process only; nothing is written into anyone's home. An empty pool
|
||
* changes nothing: panes run on their home's own login as before.
|
||
*
|
||
* Records live in `<CLAUDE_HOME>/agenthub-cursor-accounts/<name>.json` (dir
|
||
* 0700, file 0600); no route returns a key.
|
||
*
|
||
* `pick` ranks by fewest live Cursor panes, then name — Cursor sessions carry
|
||
* no token counts here (cursor-usage.js) and the CLI exposes no quota, so
|
||
* there is nothing else to rank by. A user an admin pinned to one account
|
||
* (account-pins.js) skips the ranking.
|
||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||
*/
|
||
|
||
const fs = require("node:fs");
|
||
const path = require("node:path");
|
||
const { getClaudeHome } = require("./claude-home");
|
||
|
||
const NAME_RE = /^[a-z0-9][a-z0-9._-]{0,63}$/i;
|
||
// A Cursor API key is one opaque token; never whitespace, never a newline
|
||
// (it travels as an env value through tmux and a member's launch file).
|
||
const KEY_RE = /^[A-Za-z0-9_.-]{20,500}$/;
|
||
const KEY_ENV = "CURSOR_API_KEY";
|
||
|
||
const err = (code, message) => Object.assign(new Error(message), { code });
|
||
|
||
const poolDir = () => path.join(getClaudeHome(), "agenthub-cursor-accounts");
|
||
const fileOf = (name) => path.join(poolDir(), `${name}.json`);
|
||
|
||
function validName(name) {
|
||
const n = String(name || "").trim();
|
||
if (!NAME_RE.test(n))
|
||
throw err("EBADNAME", "account name: 1–64 letters, digits, '.', '_' or '-'");
|
||
return n;
|
||
}
|
||
|
||
function readAccount(name) {
|
||
try {
|
||
const a = JSON.parse(fs.readFileSync(fileOf(name), "utf8"));
|
||
if (a.kind === "apikey" && typeof a.key === "string") return a;
|
||
} catch {
|
||
/* absent or not ours */
|
||
}
|
||
return null;
|
||
}
|
||
|
||
function allAccounts() {
|
||
let names = [];
|
||
try {
|
||
names = fs.readdirSync(poolDir()).filter((f) => f.endsWith(".json"));
|
||
} catch {
|
||
return [];
|
||
}
|
||
return names
|
||
.map((f) => f.slice(0, -5))
|
||
.filter((n) => NAME_RE.test(n))
|
||
.map(readAccount)
|
||
.filter(Boolean)
|
||
.sort((a, b) => a.name.localeCompare(b.name));
|
||
}
|
||
|
||
/** Add a Cursor API key (Cursor dashboard → Integrations → API keys). */
|
||
function addAccount(name, { apiKey } = {}) {
|
||
const n = validName(name);
|
||
const key = String(apiKey || "").trim();
|
||
if (!KEY_RE.test(key)) {
|
||
throw err("EBADTOKEN", "not a Cursor API key — create one in the Cursor dashboard");
|
||
}
|
||
if (readAccount(n)) throw err("EEXISTS", `account ${n} is already in the pool`);
|
||
const record = { name: n, kind: "apikey", key, addedAt: new Date().toISOString() };
|
||
fs.mkdirSync(poolDir(), { recursive: true, mode: 0o700 });
|
||
fs.chmodSync(poolDir(), 0o700);
|
||
fs.writeFileSync(fileOf(n), JSON.stringify(record, null, 2), { mode: 0o600, flag: "wx" });
|
||
return { name: n, kind: record.kind, addedAt: record.addedAt };
|
||
}
|
||
|
||
function removeAccount(name) {
|
||
const n = validName(name);
|
||
if (!readAccount(n)) throw err("ENOACCOUNT", `no account ${n} in the pool`);
|
||
fs.rmSync(fileOf(n));
|
||
require("./account-pins").dropAccount("cursor", n);
|
||
}
|
||
|
||
function livePanes(account) {
|
||
const { db } = require("../db");
|
||
return db
|
||
.prepare(
|
||
`SELECT COUNT(*) AS n FROM dashboard_runs
|
||
WHERE tool = 'cursor' AND account = ? AND status IN ('running', 'spawning')`
|
||
)
|
||
.get(account).n;
|
||
}
|
||
|
||
/** Every account with what the balancer sees — never a key. */
|
||
function poolStatus() {
|
||
return allAccounts().map((a) => ({
|
||
name: a.name,
|
||
kind: a.kind,
|
||
addedAt: a.addedAt,
|
||
livePanes: livePanes(a.name),
|
||
...require("./pool-usage").accountUsage("cursor", a.name, { wholeSession: true }),
|
||
quota: null,
|
||
quotaPercent: null,
|
||
full: false,
|
||
}));
|
||
}
|
||
|
||
/** The record a new Cursor pane should run on, or null (empty pool). */
|
||
function pick(ownerId = null) {
|
||
// The owner's group's accounts only (pool-groups.js).
|
||
const allowed = require("./pool-groups").allowed(ownerId, "cursor");
|
||
const usable = allAccounts().filter((a) => allowed(a.name));
|
||
if (usable.length === 0) return null;
|
||
const pin = require("./account-pins").pinFor(ownerId, "cursor");
|
||
const pinned = pin ? usable.find((a) => a.name === pin) : null;
|
||
if (pinned) return pinned;
|
||
return usable
|
||
.map((a) => ({ a, n: livePanes(a.name) }))
|
||
.sort((x, y) => x.n - y.n || x.a.name.localeCompare(y.a.name))[0].a;
|
||
}
|
||
|
||
/** `{name, kind}` of the account a new Cursor pane would get, or null. */
|
||
function pickAccount(ownerId = null) {
|
||
const a = pick(ownerId);
|
||
return a ? { name: a.name, kind: a.kind } : null;
|
||
}
|
||
|
||
/**
|
||
* What a new Cursor pane needs from the pool, or null: `{name, env}`. `env`
|
||
* goes through tmux `-e` (a member's launch file), never argv.
|
||
*/
|
||
function forSpawn(ownerId = null) {
|
||
const a = pick(ownerId);
|
||
return a ? { name: a.name, env: { [KEY_ENV]: a.key } } : null;
|
||
}
|
||
|
||
/** Is `name` an account in the pool? */
|
||
const hasAccount = (name) => NAME_RE.test(String(name)) && Boolean(readAccount(String(name)));
|
||
|
||
module.exports = {
|
||
hasAccount,
|
||
addAccount,
|
||
removeAccount,
|
||
poolStatus,
|
||
pickAccount,
|
||
forSpawn,
|
||
poolDir,
|
||
__read: readAccount,
|
||
};
|