dsh-xdpool 2.1.1 → 2.2.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/CHANGELOG.md +12 -0
- package/README.en.md +4 -4
- package/README.md +5 -5
- package/lib/bin.js +310 -147
- package/lib/client.js +1 -1
- package/lib/index.d.ts +27 -0
- package/lib/index.js +321 -163
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,6 +1,18 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
本项目的所有重要变更都会记录在此文件。
|
|
3
3
|
版本号遵循 [语义化版本](https://semver.org/lang/zh-CN/)。
|
|
4
|
+
## 2.2.0 — 账号池重启不再失忆,CLI 修好,快照真正回读
|
|
5
|
+
> 群友报的三个问题一起收掉:重启 DSH 后账号池只剩当前登录账号;`dsh-xdpool` 命令行任何子命令都报 `ERR_MODULE_NOT_FOUND`;`import` 写进去的快照写了就忘,永远读不回来。
|
|
6
|
+
### 重启后账号还在:账号池持久化
|
|
7
|
+
**现象**:在桌面端来回切换登录,攒了一池账号;重启 DSH,池里只剩当前登录的那一个。
|
|
8
|
+
**根因**:账号池的账号列表是纯内存——`scan()` 只合并不落盘。而上游桌面端在磁盘上只保留**当前登录**的凭据文件(每次登录覆盖同一个文件),所以「重启 = 磁盘上有什么就是什么」,磁盘上永远只有一个。
|
|
9
|
+
**修法**:新增 `account-store.ts`,池的扫描从 `~/.dsh/.xdpool/accounts.json` 播种、每次扫描后原子回写(先写 `.tmp` 再改名,崩溃不会留半个文件)。桌面端只记得最后一次登录,池子自己记账。生产入口(宿主和 CLI 共用的 `createCore()`)默认开启;测试构造的池默认关闭,不会把 fixture 写进真实目录。
|
|
10
|
+
### `import` 的快照终于有人读了
|
|
11
|
+
`dsh-xdpool import` 一直把 `<md5前8>.json` 快照写进 `~/.dsh/.xdpool/`,但**没有任何代码路径读回它**——写了就忘。现在插件数据目录本身就是一个扫描源,快照里的每个凭据都会在下次扫描时收进池子;插件自己的状态文件(`accounts.json`、`ignored.json`、`preferred.json`)明确排除,绝不会被当成凭据解析出幻影账号。
|
|
12
|
+
### CLI 能跑了
|
|
13
|
+
`lib/bin.js` 顶部带着四个打包时从 `index.ts` 一路带入的副作用 import(`@earendil-works/pi-ai` 等 optional peer),绑定里零引用——后果是所有 CLI 子命令直接 `ERR_MODULE_NOT_FOUND`。这四个包改为 external(源码里本来就没有绑定引用),`bin.js` 加载即净,只有命令真正走到 adapter 才会解析。
|
|
14
|
+
### 文档对齐
|
|
15
|
+
README 里「历史登录快照」的四处描述与实际行为不符,一并改写为如实口径:桌面端只保留当前登录,池子自己把攒到的账号持久保存到 `~/.dsh/.xdpool/accounts.json`,重启不丢;`import` 快照同目录,每次扫描收进池。
|
|
4
16
|
## 2.1.1 — 修掉两个用户看得见的 bug
|
|
5
17
|
> 一个让你在插件清单里看到一条失败的红条目,一个让你的账单上「使用端」是空的。都是 2.1.0 之前就存在的,这一版一起收掉。
|
|
6
18
|
### 插件清单里那条 failed 的 billion-context,没有了
|
package/README.en.md
CHANGED
|
@@ -31,7 +31,7 @@ Then it grew into seven pages. Beyond the account pool: sign in to thirteen vend
|
|
|
31
31
|
|
|
32
32
|
**It also throws in a full points-automation suite**: check-ins, task rewards, streak redemption, the buddy trip — all the chores you'd otherwise click through every day, done in the background.
|
|
33
33
|
|
|
34
|
-
> **How this differs from the single-account connectors**: plugins like `dsh-workbuddy-connect` handle one account at a time. XD Pool treats **multiple accounts as the normal case** — it doesn't pick favourites or ask you to import anything. Every
|
|
34
|
+
> **How this differs from the single-account connectors**: plugins like `dsh-workbuddy-connect` handle one account at a time. XD Pool treats **multiple accounts as the normal case** — it doesn't pick favourites or ask you to import anything. Every account you've switched through on the desktop app is remembered across restarts, exposed as a single `xdpool` provider group, with automatic failover inside.
|
|
35
35
|
|
|
36
36
|
> **Upgrading from the old name**: this plugin used to be `dsh-workbuddy-xdpool`. Your data is **carried over automatically** (API keys, ignore list, account pin). The one thing you may need to do by hand is a default model pinned to an old id — see [Upgrading from an older version](#upgrading-from-an-older-version).
|
|
37
37
|
|
|
@@ -214,7 +214,7 @@ dsh plugin --profile desktop exec dsh-xdpool login # guide to adding another
|
|
|
214
214
|
|
|
215
215
|
## How accounts get into the pool
|
|
216
216
|
|
|
217
|
-
|
|
217
|
+
The desktop app keeps only the **currently signed-in** account on disk (each switch overwrites the same file), so XD Pool remembers accounts itself: every time you switch sign-ins in the desktop app, the outgoing account is captured into `~/.dsh/.xdpool/accounts.json` and **survives restarts**. Hit "Detect accounts again" or just start using the pool — every historical account comes back.
|
|
218
218
|
|
|
219
219
|
To snapshot an extra login explicitly (e.g. to pin one account for verification):
|
|
220
220
|
|
|
@@ -224,7 +224,7 @@ dsh plugin --profile desktop exec dsh-xdpool accounts
|
|
|
224
224
|
dsh plugin --profile desktop exec dsh-xdpool remove myKey
|
|
225
225
|
```
|
|
226
226
|
|
|
227
|
-
|
|
227
|
+
Imported snapshots live in the same directory, `~/.dsh/.xdpool/`, and every scan absorbs them into the pool — they are no longer written and never read back. Snapshots are named by the **MD5-8 prefix** of their key (so keys with Chinese, `/`, or spaces are safe). Long-lived use relies on refresh-token auto-renewal; if it lapses, re-sign-in on the desktop and `import <key> --force`.
|
|
228
228
|
|
|
229
229
|
## Configuration
|
|
230
230
|
|
|
@@ -246,7 +246,7 @@ xdpool:
|
|
|
246
246
|
|
|
247
247
|
- **Host side** (`src/`, inside the DSH main process)
|
|
248
248
|
- `index.ts` — registers the `xdpool` provider, the `xdpool` settings section, the same-origin routes (status / re-scan / clear cooldowns / check-in / automation run / credit reserve), plus account discovery and catalog seeding.
|
|
249
|
-
- `accounts.ts` — `WorkBuddyAccountPool`:
|
|
249
|
+
- `accounts.ts` — `WorkBuddyAccountPool`: discovers and durably stores local WorkBuddy accounts (`account-store.ts`), 429 cooldowns, rotation and token refresh; account disabling and credit reserves live here too.
|
|
250
250
|
- `scheduler.ts` — the points-automation scheduler: runs five jobs on local time points (check-in / report / tasks / streak / travel), persists the daily earnings ledger, and backs the card's "Run now" button with the same code path.
|
|
251
251
|
- `task-events.ts` — builds the 13 task event chains. Each chain is plain data plus the fingerprint channel it must go out on (desktop or web); the tasks that need a REAL conversation (skill, experts) open one here to get the server-side id.
|
|
252
252
|
- `catalog.ts` / `upstream.ts` — the upstream client: model catalog (with per-model multipliers and free / image tags), credits, check-in, and every automation endpoint, switching CN/global by credential domain.
|
package/README.md
CHANGED
|
@@ -31,7 +31,7 @@
|
|
|
31
31
|
|
|
32
32
|
**另外附送一整套「积分自动化」**:签到、领任务奖励、连登兑换、开猫猫旅行……这些每天都要手动点一遍的活儿,插件在后台替你干完了。
|
|
33
33
|
|
|
34
|
-
> **和单账号连接插件的区别**:`dsh-workbuddy-connect` 那类插件是「一次一个账号」。XD Pool
|
|
34
|
+
> **和单账号连接插件的区别**:`dsh-workbuddy-connect` 那类插件是「一次一个账号」。XD Pool 把**多账号当默认情况**——不挑号、不手动导入,你在桌面 App 换登过的每个号都被池记下并跨重启保存,对外用一个 `xdpool` provider 分组,池内自动容错。
|
|
35
35
|
|
|
36
36
|
> **从旧名升级**:这个插件以前叫 `dsh-workbuddy-xdpool`。改名后**旧数据会自动接过来**(你的 API key、忽略名单、账号 pin 都还在);唯一需要你动手的是「默认模型」如果指向旧 id,见[从旧版升级](#从旧版升级)。
|
|
37
37
|
|
|
@@ -70,7 +70,7 @@
|
|
|
70
70
|
|
|
71
71
|
### 一、多账号池
|
|
72
72
|
|
|
73
|
-
- **零配置**:装上、打开,就完事了。WorkBuddy 桌面 App
|
|
73
|
+
- **零配置**:装上、打开,就完事了。WorkBuddy 桌面 App 里每个登过的账号都会被自动发现、持久保存并入池(重启不丢),不需要在插件里录任何东西。
|
|
74
74
|
|
|
75
75
|
- **自动容错换号**:池子记着每个账号的限流状态。某个号触发 429 就冷却它,后面的请求自动落到下一个健康的号上;冷却结束自动恢复。所有号都在冷却时,请求才排队等待。
|
|
76
76
|
- **四种账号分配方式**:`priority` 先用完一个号;`round-robin` 按顺序轮换;`balanced` 随机抽、偏向闲置久的;`sticky` 让**一个对话只用一个账号,新对话才换下一个**——上游缓存按账号隔离,中途换号等于把整段对话的缓存作废,`sticky` 就是为这件事准备的。卡片上点一下即可切换,默认仍是 `priority`。
|
|
@@ -221,7 +221,7 @@ dsh plugin --profile desktop exec dsh-xdpool login # 教你怎么在桌面
|
|
|
221
221
|
|
|
222
222
|
## 池里怎么多账号?
|
|
223
223
|
|
|
224
|
-
|
|
224
|
+
桌面 App 同一时间只保留**当前登录账号**的凭据(每次换号都会覆盖同一个文件),所以 XD Pool 自己把账号记下来:你在桌面 App 里换着登几个号,每个号在切走的瞬间就被池收进 `~/.dsh/.xdpool/accounts.json` 持久保存——**重启 DSH 也不会丢**。之后点卡片上的「重新检测账号」,或者直接开始用,池都会带着全部历史账号服务。
|
|
225
225
|
|
|
226
226
|
如果你想给桌面 App 之外的一次登录做个**显式快照**(比如临时固定某个号做验证),也可以手动导入:
|
|
227
227
|
|
|
@@ -233,7 +233,7 @@ dsh plugin --profile desktop exec dsh-xdpool accounts
|
|
|
233
233
|
dsh plugin --profile desktop exec dsh-xdpool remove myKey
|
|
234
234
|
```
|
|
235
235
|
|
|
236
|
-
|
|
236
|
+
导入的快照与自动保存的账号存在同一个目录 `~/.dsh/.xdpool/` 下,每次扫描都会被一并收进池——不再是「导入了但没人读」。快照以 key 的 **MD5 前 8 位**命名(key 本身记在文件里),中文、带 `/`、带空格的 key 都安全。长期使用靠 refresh token 自动续期;失效了就回桌面重新登录,再 `import <key> --force` 覆盖。
|
|
237
237
|
|
|
238
238
|
## 配置
|
|
239
239
|
|
|
@@ -259,7 +259,7 @@ xdpool:
|
|
|
259
259
|
|
|
260
260
|
- **宿主侧**(`src/`,跑在 DSH 主进程里)
|
|
261
261
|
- `index.ts` —— 注册 `xdpool` provider、`xdpool` 设置节、各条同源路由(状态 / 重新检测 / 清除冷却 / 签到 / 自动化运行 / 保留积分),以及账号发现与模型目录播种。
|
|
262
|
-
- `accounts.ts` —— `WorkBuddyAccountPool
|
|
262
|
+
- `accounts.ts` —— `WorkBuddyAccountPool`:发现并持久保存本机 WorkBuddy 账号(`account-store.ts` 落盘)、429 冷却、轮换与 token 刷新;账号停用与积分保留也在这里。
|
|
263
263
|
- `scheduler.ts` —— 积分自动化的调度器:按本地时点跑五个任务(签到 / 上报 / 任务 / 连登 / 旅行),每日收益账本落盘,手动「立即运行」走的是同一套逻辑。
|
|
264
264
|
- `task-events.ts` —— 13 条任务事件链的构造。每条链都是纯数据,外加它该走哪个指纹通道(桌面 / 网页);需要真实会话的任务(技能、专家)在这里先开一次真对话,拿到服务端 id。
|
|
265
265
|
- `catalog.ts` / `upstream.ts` —— 上游模型目录(含每模型积分倍率、免费 / 图片能力标签)、积分查询、签到、自动化各接口的上游客户端(按凭据域名自动切国内外)。
|
package/lib/bin.js
CHANGED
|
@@ -1731,6 +1731,240 @@ var WorkBuddyUpstreamClient = class {
|
|
|
1731
1731
|
}
|
|
1732
1732
|
};
|
|
1733
1733
|
//#endregion
|
|
1734
|
+
//#region src/ignored.ts
|
|
1735
|
+
/**
|
|
1736
|
+
* The permanent ignore list: accounts the user has thrown out of the pool.
|
|
1737
|
+
*
|
|
1738
|
+
* Distinct from `disabledAccountIds`, which is a ROTATION preference. A disabled
|
|
1739
|
+
* account stays on the card, can be switched back on, and its credential keeps
|
|
1740
|
+
* being read on every scan. Ignoring is a statement about the ACCOUNT — "this
|
|
1741
|
+
* one is not mine any more" — so an ignored account is skipped BEFORE its
|
|
1742
|
+
* credential is decrypted, never appears among the pool's accounts, and does not
|
|
1743
|
+
* come back when the desktop app writes a fresh `.info` file for it.
|
|
1744
|
+
*
|
|
1745
|
+
* It lives in a plugin-owned file rather than in the settings document for one
|
|
1746
|
+
* practical reason: the CLI has no settings service. `dsh-xdpool
|
|
1747
|
+
* ignore <id>` runs without a host, so a settings-only list could be written by
|
|
1748
|
+
* the card and never by the CLI. One file, read by both halves, keeps the two
|
|
1749
|
+
* views of "which accounts are ignored" identical by construction.
|
|
1750
|
+
*
|
|
1751
|
+
* @module dsh-xdpool/ignored
|
|
1752
|
+
*/
|
|
1753
|
+
/** Directory holding this plugin's own state (imported snapshots, ignore list). */
|
|
1754
|
+
const PLUGIN_DATA_DIR_NAME = ".xdpool";
|
|
1755
|
+
/** File holding the permanent ignore list, inside {@link pluginDataDir}. */
|
|
1756
|
+
const IGNORED_FILE_NAME = "ignored.json";
|
|
1757
|
+
/**
|
|
1758
|
+
* The DSH home directory, honouring the same override the host uses.
|
|
1759
|
+
*
|
|
1760
|
+
* Shared by the CLI and the host so both halves resolve the same file: an
|
|
1761
|
+
* `ignore` written from the terminal has to be visible to the running plugin,
|
|
1762
|
+
* which is only true if they agree on where "home" is.
|
|
1763
|
+
*/
|
|
1764
|
+
function dshHome$1(env = process.env) {
|
|
1765
|
+
const fromEnv = env["DSH_HOME"];
|
|
1766
|
+
if (typeof fromEnv === "string" && fromEnv.trim() !== "") return fromEnv.trim();
|
|
1767
|
+
return join(homedir(), ".dsh");
|
|
1768
|
+
}
|
|
1769
|
+
/** This plugin's own state directory. */
|
|
1770
|
+
function pluginDataDir(env = process.env) {
|
|
1771
|
+
return join(dshHome$1(env), PLUGIN_DATA_DIR_NAME);
|
|
1772
|
+
}
|
|
1773
|
+
/** Absolute path of the ignore list. */
|
|
1774
|
+
function ignoredIdsPath(env = process.env) {
|
|
1775
|
+
return join(pluginDataDir(env), IGNORED_FILE_NAME);
|
|
1776
|
+
}
|
|
1777
|
+
/**
|
|
1778
|
+
* Normalize whatever the file holds into a clean list.
|
|
1779
|
+
*
|
|
1780
|
+
* Tolerant on purpose: this file is hand-editable and a malformed entry must not
|
|
1781
|
+
* take the pool down. An entry without a usable id is dropped; a missing label
|
|
1782
|
+
* falls back to the id so the card always has something to print.
|
|
1783
|
+
*/
|
|
1784
|
+
function normalize$1(raw) {
|
|
1785
|
+
if (typeof raw !== "object" || raw === null) return [];
|
|
1786
|
+
const entries = raw.accounts;
|
|
1787
|
+
if (!Array.isArray(entries)) return [];
|
|
1788
|
+
const out = [];
|
|
1789
|
+
const seen = /* @__PURE__ */ new Set();
|
|
1790
|
+
for (const entry of entries) {
|
|
1791
|
+
if (typeof entry !== "object" || entry === null) continue;
|
|
1792
|
+
const record = entry;
|
|
1793
|
+
const id = typeof record["id"] === "string" ? record["id"].trim() : "";
|
|
1794
|
+
if (id === "" || seen.has(id)) continue;
|
|
1795
|
+
seen.add(id);
|
|
1796
|
+
const label = typeof record["label"] === "string" && record["label"].trim() !== "" ? record["label"] : id;
|
|
1797
|
+
const ignoredAt = typeof record["ignoredAt"] === "string" ? record["ignoredAt"] : "";
|
|
1798
|
+
out.push({
|
|
1799
|
+
id,
|
|
1800
|
+
label,
|
|
1801
|
+
ignoredAt
|
|
1802
|
+
});
|
|
1803
|
+
}
|
|
1804
|
+
return out;
|
|
1805
|
+
}
|
|
1806
|
+
/**
|
|
1807
|
+
* Read the ignore list, tolerating every "no list yet" shape.
|
|
1808
|
+
*
|
|
1809
|
+
* A missing file, unreadable file, or invalid JSON all mean the same thing to
|
|
1810
|
+
* the caller — nothing is ignored — so none of them throws. The pool must be
|
|
1811
|
+
* able to start on a machine that has never ignored anything.
|
|
1812
|
+
*/
|
|
1813
|
+
async function readIgnoredAccounts(path = ignoredIdsPath()) {
|
|
1814
|
+
try {
|
|
1815
|
+
return normalize$1(JSON.parse(await readFile(path, "utf8")));
|
|
1816
|
+
} catch {
|
|
1817
|
+
return [];
|
|
1818
|
+
}
|
|
1819
|
+
}
|
|
1820
|
+
/**
|
|
1821
|
+
* Replace the ignore list, atomically.
|
|
1822
|
+
*
|
|
1823
|
+
* Written to a sibling temp file and renamed over the target so a crash (or a
|
|
1824
|
+
* concurrent reader) can never observe a half-written document — the ignore
|
|
1825
|
+
* list is the only thing standing between a dead account and the rotation, and
|
|
1826
|
+
* a truncated file reads as "nothing is ignored", which would quietly put every
|
|
1827
|
+
* discarded account back in the pool.
|
|
1828
|
+
*/
|
|
1829
|
+
async function writeIgnoredAccounts(accounts, path = ignoredIdsPath()) {
|
|
1830
|
+
const payload = JSON.stringify({
|
|
1831
|
+
version: 1,
|
|
1832
|
+
accounts
|
|
1833
|
+
}, null, 2);
|
|
1834
|
+
await mkdir(dirname(path), { recursive: true });
|
|
1835
|
+
const temp = `${path}.tmp`;
|
|
1836
|
+
await writeFile$1(temp, `${payload}\n`, "utf8");
|
|
1837
|
+
await rename(temp, path);
|
|
1838
|
+
}
|
|
1839
|
+
/**
|
|
1840
|
+
* Add one account to the ignore list, preserving the rest.
|
|
1841
|
+
*
|
|
1842
|
+
* A read-modify-write rather than a wholesale replace: the card and the CLI can
|
|
1843
|
+
* both be open, and each request names exactly one account, so re-writing the
|
|
1844
|
+
* whole list from a stale view would drop the other side's edits.
|
|
1845
|
+
*/
|
|
1846
|
+
async function ignoreAccount(account, path = ignoredIdsPath()) {
|
|
1847
|
+
const current = await readIgnoredAccounts(path);
|
|
1848
|
+
if (current.some((entry) => entry.id === account.id)) return current;
|
|
1849
|
+
const next = [...current, {
|
|
1850
|
+
id: account.id,
|
|
1851
|
+
label: account.label ?? account.id,
|
|
1852
|
+
ignoredAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
1853
|
+
}];
|
|
1854
|
+
await writeIgnoredAccounts(next, path);
|
|
1855
|
+
return next;
|
|
1856
|
+
}
|
|
1857
|
+
/** Drop one account from the ignore list. Returns the resulting list. */
|
|
1858
|
+
async function unignoreAccount(accountId, path = ignoredIdsPath()) {
|
|
1859
|
+
const current = await readIgnoredAccounts(path);
|
|
1860
|
+
const next = current.filter((entry) => entry.id !== accountId);
|
|
1861
|
+
if (next.length !== current.length) await writeIgnoredAccounts(next, path);
|
|
1862
|
+
return next;
|
|
1863
|
+
}
|
|
1864
|
+
//#endregion
|
|
1865
|
+
//#region src/account-store.ts
|
|
1866
|
+
/**
|
|
1867
|
+
* Durable copy of the discovered accounts, inside the plugin's data directory.
|
|
1868
|
+
*
|
|
1869
|
+
* The pool's account list is otherwise pure memory: the desktop app keeps only
|
|
1870
|
+
* the CURRENT sign-in on disk (each login overwrites the one auth file), so
|
|
1871
|
+
* accounts gathered by switching sign-ins evaporated on every restart. This
|
|
1872
|
+
* store is the fix — `scan()` seeds from it and writes the merged list back, so
|
|
1873
|
+
* the pool now survives restarts.
|
|
1874
|
+
*
|
|
1875
|
+
* JSON only, atomic rename on write: a crash mid-write leaves the previous
|
|
1876
|
+
* generation intact, and the pool never reads a half-written account again.
|
|
1877
|
+
*
|
|
1878
|
+
* @module dsh-xdpool/account-store
|
|
1879
|
+
*/
|
|
1880
|
+
/** File name of the durable account list, inside the plugin data directory. */
|
|
1881
|
+
const ACCOUNTS_FILE_NAME = "accounts.json";
|
|
1882
|
+
/**
|
|
1883
|
+
* State files that live in the same directory but are NOT account snapshots.
|
|
1884
|
+
*
|
|
1885
|
+
* The snapshot importer (`dsh-xdpool import <key>`) writes `<md5prefix>.json`
|
|
1886
|
+
* into this directory, and the pool scans it — so every other `.json` file here
|
|
1887
|
+
* is treated as an imported snapshot. These three are the plugin's own
|
|
1888
|
+
* bookkeeping and must never be parsed as credentials.
|
|
1889
|
+
*/
|
|
1890
|
+
const NON_SNAPSHOT_FILES = [
|
|
1891
|
+
ACCOUNTS_FILE_NAME,
|
|
1892
|
+
"ignored.json",
|
|
1893
|
+
"preferred.json"
|
|
1894
|
+
];
|
|
1895
|
+
/** Absolute path of the durable account list. */
|
|
1896
|
+
function accountStorePath(env = process.env) {
|
|
1897
|
+
return join(dshHome$1(env), PLUGIN_DATA_DIR_NAME, ACCOUNTS_FILE_NAME);
|
|
1898
|
+
}
|
|
1899
|
+
/** The directory the pool scans for imported `<md5prefix>.json` snapshots. */
|
|
1900
|
+
function snapshotDirPath(env = process.env) {
|
|
1901
|
+
return join(dshHome$1(env), PLUGIN_DATA_DIR_NAME);
|
|
1902
|
+
}
|
|
1903
|
+
/**
|
|
1904
|
+
* True when a file name in the snapshot directory is plugin bookkeeping rather
|
|
1905
|
+
* than an imported snapshot.
|
|
1906
|
+
*/
|
|
1907
|
+
function isStateFile(name) {
|
|
1908
|
+
return NON_SNAPSHOT_FILES.includes(name);
|
|
1909
|
+
}
|
|
1910
|
+
/** True when a value has the shape of a stored account. */
|
|
1911
|
+
function isStoredAccount(value) {
|
|
1912
|
+
if (typeof value !== "object" || value === null) return false;
|
|
1913
|
+
const record = value;
|
|
1914
|
+
const credential = record["credential"];
|
|
1915
|
+
return typeof record["id"] === "string" && typeof record["label"] === "string" && typeof credential === "object" && credential !== null && typeof credential["accessToken"] === "string" && typeof credential["refreshToken"] === "string";
|
|
1916
|
+
}
|
|
1917
|
+
/**
|
|
1918
|
+
* Read the durable list. Tolerant on purpose: a malformed or half-written file
|
|
1919
|
+
* yields `[]` and the pool simply rediscovers from disk, rather than a corrupt
|
|
1920
|
+
* store taking the whole pool down.
|
|
1921
|
+
*/
|
|
1922
|
+
async function loadStoredAccounts(path) {
|
|
1923
|
+
let text;
|
|
1924
|
+
try {
|
|
1925
|
+
text = await readFile(path, "utf8");
|
|
1926
|
+
} catch {
|
|
1927
|
+
return [];
|
|
1928
|
+
}
|
|
1929
|
+
let parsed;
|
|
1930
|
+
try {
|
|
1931
|
+
parsed = JSON.parse(text);
|
|
1932
|
+
} catch {
|
|
1933
|
+
return [];
|
|
1934
|
+
}
|
|
1935
|
+
const entries = parsed?.["accounts"];
|
|
1936
|
+
if (!Array.isArray(entries)) return [];
|
|
1937
|
+
const out = [];
|
|
1938
|
+
const seen = /* @__PURE__ */ new Set();
|
|
1939
|
+
for (const entry of entries) {
|
|
1940
|
+
if (!isStoredAccount(entry)) continue;
|
|
1941
|
+
if (seen.has(entry.id)) continue;
|
|
1942
|
+
seen.add(entry.id);
|
|
1943
|
+
out.push(entry);
|
|
1944
|
+
}
|
|
1945
|
+
return out;
|
|
1946
|
+
}
|
|
1947
|
+
/**
|
|
1948
|
+
* Atomically write the durable list: `${path}.tmp` first, then rename. A crash
|
|
1949
|
+
* mid-write leaves the previous generation intact.
|
|
1950
|
+
*/
|
|
1951
|
+
async function saveStoredAccounts(path, accounts) {
|
|
1952
|
+
const stored = accounts.map((account) => ({
|
|
1953
|
+
id: account.id,
|
|
1954
|
+
label: account.label,
|
|
1955
|
+
credential: account.credential,
|
|
1956
|
+
savedAtMs: Date.now()
|
|
1957
|
+
}));
|
|
1958
|
+
const document = JSON.stringify({
|
|
1959
|
+
version: 1,
|
|
1960
|
+
accounts: stored
|
|
1961
|
+
}, null, 2) + "\n";
|
|
1962
|
+
await mkdir(join(path, ".."), { recursive: true });
|
|
1963
|
+
const tmp = `${path}.tmp`;
|
|
1964
|
+
await writeFile$1(tmp, document, "utf8");
|
|
1965
|
+
await rename(tmp, path);
|
|
1966
|
+
}
|
|
1967
|
+
//#endregion
|
|
1734
1968
|
//#region src/at-rest.ts
|
|
1735
1969
|
/**
|
|
1736
1970
|
* WorkBuddy desktop "at-rest" credential decryption.
|
|
@@ -2579,6 +2813,23 @@ async function authFilesIn(dir) {
|
|
|
2579
2813
|
return files.map((name) => join(dir, name));
|
|
2580
2814
|
}
|
|
2581
2815
|
/**
|
|
2816
|
+
* Snapshot files in the plugin data directory that are account snapshots.
|
|
2817
|
+
*
|
|
2818
|
+
* Every `*.info` file there is one (the importer keeps that extension for
|
|
2819
|
+
* uniformity with the auth dirs it copies from); the plugin's own JSON state
|
|
2820
|
+
* files (`accounts.json`, `ignored.json`, `preferred.json`) are NOT and must
|
|
2821
|
+
* never reach the credential parser.
|
|
2822
|
+
*/
|
|
2823
|
+
async function snapshotFilesIn(dir) {
|
|
2824
|
+
let entries;
|
|
2825
|
+
try {
|
|
2826
|
+
entries = await readdir(dir);
|
|
2827
|
+
} catch {
|
|
2828
|
+
return [];
|
|
2829
|
+
}
|
|
2830
|
+
return entries.filter((name) => name.endsWith(".info")).filter((name) => !isStateFile(name)).sort((a, b) => a < b ? 1 : a > b ? -1 : 0).map((name) => join(dir, name));
|
|
2831
|
+
}
|
|
2832
|
+
/**
|
|
2582
2833
|
* One credential file, or undefined when it cannot be used.
|
|
2583
2834
|
*
|
|
2584
2835
|
* Returns undefined — rather than throwing — for every failure mode, so ONE bad
|
|
@@ -2857,6 +3108,18 @@ var WorkBuddyAccountPool = class {
|
|
|
2857
3108
|
*/
|
|
2858
3109
|
lastUsedAt = /* @__PURE__ */ new Map();
|
|
2859
3110
|
refreshInflight = /* @__PURE__ */ new Map();
|
|
3111
|
+
/**
|
|
3112
|
+
* Whether `scan()` seeds from and writes back the durable account store.
|
|
3113
|
+
*
|
|
3114
|
+
* Off by default (tests build fixtures over temp dirs and must not touch the
|
|
3115
|
+
* user's real store); `createCore()` turns it on for production, which is
|
|
3116
|
+
* what makes accounts gathered by switching desktop sign-ins survive a
|
|
3117
|
+
* restart — the desktop app keeps only the current sign-in's credential file
|
|
3118
|
+
* on disk.
|
|
3119
|
+
*/
|
|
3120
|
+
persistent;
|
|
3121
|
+
/** Path of the durable store; computed once so tests can pin it. */
|
|
3122
|
+
storePath;
|
|
2860
3123
|
constructor(options = {}) {
|
|
2861
3124
|
this.logger = options.logger;
|
|
2862
3125
|
this.authDirs = options.authDirs ?? candidateAuthDirs();
|
|
@@ -2866,6 +3129,8 @@ var WorkBuddyAccountPool = class {
|
|
|
2866
3129
|
this.client = options.client;
|
|
2867
3130
|
this.refreshMarginMs = options.refreshMarginMs ?? 3e5;
|
|
2868
3131
|
this.distribution = options.distribution ?? "priority";
|
|
3132
|
+
this.persistent = options.persistent ?? false;
|
|
3133
|
+
this.storePath = accountStorePath();
|
|
2869
3134
|
}
|
|
2870
3135
|
/**
|
|
2871
3136
|
* Re-apply configuration that only affects discovery and cooldown policy,
|
|
@@ -2915,26 +3180,44 @@ var WorkBuddyAccountPool = class {
|
|
|
2915
3180
|
async scan() {
|
|
2916
3181
|
const found = [];
|
|
2917
3182
|
const skipped = [];
|
|
2918
|
-
|
|
2919
|
-
|
|
2920
|
-
|
|
2921
|
-
|
|
2922
|
-
|
|
2923
|
-
|
|
2924
|
-
|
|
2925
|
-
|
|
2926
|
-
|
|
2927
|
-
|
|
2928
|
-
|
|
2929
|
-
|
|
3183
|
+
const scanDirs = this.persistent ? [...this.authDirs, snapshotDirPath()] : this.authDirs;
|
|
3184
|
+
for (const dir of scanDirs) {
|
|
3185
|
+
const files = dir === snapshotDirPath() ? await snapshotFilesIn(dir) : await authFilesIn(dir);
|
|
3186
|
+
for (const file of files) {
|
|
3187
|
+
if (this.ignoredIds.size > 0) {
|
|
3188
|
+
const cheapId = await cheapIdentityIdFromFile(file);
|
|
3189
|
+
if (cheapId !== void 0 && this.ignoredIds.has(cheapId)) continue;
|
|
3190
|
+
}
|
|
3191
|
+
const credential = await readCredential(file);
|
|
3192
|
+
if (credential === void 0) {
|
|
3193
|
+
skipped.push({
|
|
3194
|
+
path: file,
|
|
3195
|
+
reason: await skipReasonFor(file)
|
|
3196
|
+
});
|
|
3197
|
+
continue;
|
|
3198
|
+
}
|
|
3199
|
+
if (this.ignoredIds.size > 0 && this.ignoredIds.has(workbuddyAccountId(credential))) continue;
|
|
3200
|
+
found.push(credential);
|
|
2930
3201
|
}
|
|
2931
|
-
if (this.ignoredIds.size > 0 && this.ignoredIds.has(workbuddyAccountId(credential))) continue;
|
|
2932
|
-
found.push(credential);
|
|
2933
3202
|
}
|
|
2934
3203
|
this.skippedFiles = skipped;
|
|
2935
3204
|
if (skipped.length > 0) this.logger?.warn?.(`dsh-xdpool: ${skipped.length} credential file(s) could not be read; ${found.length} account(s) still loaded. Reasons: ` + [...new Set(skipped.map((s) => s.reason))].join("; "));
|
|
2936
3205
|
const byId = /* @__PURE__ */ new Map();
|
|
2937
3206
|
for (const account of this.accounts) byId.set(account.id, account);
|
|
3207
|
+
if (this.persistent && this.accounts.length === 0) {
|
|
3208
|
+
const stored = await loadStoredAccounts(this.storePath);
|
|
3209
|
+
for (const entry of stored) {
|
|
3210
|
+
if (byId.has(entry.id)) continue;
|
|
3211
|
+
byId.set(entry.id, {
|
|
3212
|
+
id: entry.id,
|
|
3213
|
+
label: entry.label,
|
|
3214
|
+
credential: entry.credential,
|
|
3215
|
+
cooldownUntilMs: 0,
|
|
3216
|
+
modelCooldowns: {},
|
|
3217
|
+
rateLimitHits: 0
|
|
3218
|
+
});
|
|
3219
|
+
}
|
|
3220
|
+
}
|
|
2938
3221
|
for (const credential of found) {
|
|
2939
3222
|
const id = workbuddyAccountId(credential);
|
|
2940
3223
|
const existing = byId.get(id);
|
|
@@ -2963,6 +3246,11 @@ var WorkBuddyAccountPool = class {
|
|
|
2963
3246
|
ordered.sort((a, b) => compareFreshness(a.credential, b.credential));
|
|
2964
3247
|
this.accounts = ordered;
|
|
2965
3248
|
this.lastScanAtMs = Date.now();
|
|
3249
|
+
if (this.persistent) try {
|
|
3250
|
+
await saveStoredAccounts(this.storePath, this.accounts);
|
|
3251
|
+
} catch (error) {
|
|
3252
|
+
this.logger?.warn?.("dsh-xdpool: could not persist the account store", error);
|
|
3253
|
+
}
|
|
2966
3254
|
return this.accounts;
|
|
2967
3255
|
}
|
|
2968
3256
|
/** All accounts, cooldown state included. */
|
|
@@ -10338,137 +10626,6 @@ function formatRates(status) {
|
|
|
10338
10626
|
}
|
|
10339
10627
|
return lines.join("\n");
|
|
10340
10628
|
}
|
|
10341
|
-
//#endregion
|
|
10342
|
-
//#region src/ignored.ts
|
|
10343
|
-
/**
|
|
10344
|
-
* The permanent ignore list: accounts the user has thrown out of the pool.
|
|
10345
|
-
*
|
|
10346
|
-
* Distinct from `disabledAccountIds`, which is a ROTATION preference. A disabled
|
|
10347
|
-
* account stays on the card, can be switched back on, and its credential keeps
|
|
10348
|
-
* being read on every scan. Ignoring is a statement about the ACCOUNT — "this
|
|
10349
|
-
* one is not mine any more" — so an ignored account is skipped BEFORE its
|
|
10350
|
-
* credential is decrypted, never appears among the pool's accounts, and does not
|
|
10351
|
-
* come back when the desktop app writes a fresh `.info` file for it.
|
|
10352
|
-
*
|
|
10353
|
-
* It lives in a plugin-owned file rather than in the settings document for one
|
|
10354
|
-
* practical reason: the CLI has no settings service. `dsh-xdpool
|
|
10355
|
-
* ignore <id>` runs without a host, so a settings-only list could be written by
|
|
10356
|
-
* the card and never by the CLI. One file, read by both halves, keeps the two
|
|
10357
|
-
* views of "which accounts are ignored" identical by construction.
|
|
10358
|
-
*
|
|
10359
|
-
* @module dsh-xdpool/ignored
|
|
10360
|
-
*/
|
|
10361
|
-
/** Directory holding this plugin's own state (imported snapshots, ignore list). */
|
|
10362
|
-
const PLUGIN_DATA_DIR_NAME = ".xdpool";
|
|
10363
|
-
/** File holding the permanent ignore list, inside {@link pluginDataDir}. */
|
|
10364
|
-
const IGNORED_FILE_NAME = "ignored.json";
|
|
10365
|
-
/**
|
|
10366
|
-
* The DSH home directory, honouring the same override the host uses.
|
|
10367
|
-
*
|
|
10368
|
-
* Shared by the CLI and the host so both halves resolve the same file: an
|
|
10369
|
-
* `ignore` written from the terminal has to be visible to the running plugin,
|
|
10370
|
-
* which is only true if they agree on where "home" is.
|
|
10371
|
-
*/
|
|
10372
|
-
function dshHome$1(env = process.env) {
|
|
10373
|
-
const fromEnv = env["DSH_HOME"];
|
|
10374
|
-
if (typeof fromEnv === "string" && fromEnv.trim() !== "") return fromEnv.trim();
|
|
10375
|
-
return join(homedir(), ".dsh");
|
|
10376
|
-
}
|
|
10377
|
-
/** This plugin's own state directory. */
|
|
10378
|
-
function pluginDataDir(env = process.env) {
|
|
10379
|
-
return join(dshHome$1(env), PLUGIN_DATA_DIR_NAME);
|
|
10380
|
-
}
|
|
10381
|
-
/** Absolute path of the ignore list. */
|
|
10382
|
-
function ignoredIdsPath(env = process.env) {
|
|
10383
|
-
return join(pluginDataDir(env), IGNORED_FILE_NAME);
|
|
10384
|
-
}
|
|
10385
|
-
/**
|
|
10386
|
-
* Normalize whatever the file holds into a clean list.
|
|
10387
|
-
*
|
|
10388
|
-
* Tolerant on purpose: this file is hand-editable and a malformed entry must not
|
|
10389
|
-
* take the pool down. An entry without a usable id is dropped; a missing label
|
|
10390
|
-
* falls back to the id so the card always has something to print.
|
|
10391
|
-
*/
|
|
10392
|
-
function normalize$1(raw) {
|
|
10393
|
-
if (typeof raw !== "object" || raw === null) return [];
|
|
10394
|
-
const entries = raw.accounts;
|
|
10395
|
-
if (!Array.isArray(entries)) return [];
|
|
10396
|
-
const out = [];
|
|
10397
|
-
const seen = /* @__PURE__ */ new Set();
|
|
10398
|
-
for (const entry of entries) {
|
|
10399
|
-
if (typeof entry !== "object" || entry === null) continue;
|
|
10400
|
-
const record = entry;
|
|
10401
|
-
const id = typeof record["id"] === "string" ? record["id"].trim() : "";
|
|
10402
|
-
if (id === "" || seen.has(id)) continue;
|
|
10403
|
-
seen.add(id);
|
|
10404
|
-
const label = typeof record["label"] === "string" && record["label"].trim() !== "" ? record["label"] : id;
|
|
10405
|
-
const ignoredAt = typeof record["ignoredAt"] === "string" ? record["ignoredAt"] : "";
|
|
10406
|
-
out.push({
|
|
10407
|
-
id,
|
|
10408
|
-
label,
|
|
10409
|
-
ignoredAt
|
|
10410
|
-
});
|
|
10411
|
-
}
|
|
10412
|
-
return out;
|
|
10413
|
-
}
|
|
10414
|
-
/**
|
|
10415
|
-
* Read the ignore list, tolerating every "no list yet" shape.
|
|
10416
|
-
*
|
|
10417
|
-
* A missing file, unreadable file, or invalid JSON all mean the same thing to
|
|
10418
|
-
* the caller — nothing is ignored — so none of them throws. The pool must be
|
|
10419
|
-
* able to start on a machine that has never ignored anything.
|
|
10420
|
-
*/
|
|
10421
|
-
async function readIgnoredAccounts(path = ignoredIdsPath()) {
|
|
10422
|
-
try {
|
|
10423
|
-
return normalize$1(JSON.parse(await readFile(path, "utf8")));
|
|
10424
|
-
} catch {
|
|
10425
|
-
return [];
|
|
10426
|
-
}
|
|
10427
|
-
}
|
|
10428
|
-
/**
|
|
10429
|
-
* Replace the ignore list, atomically.
|
|
10430
|
-
*
|
|
10431
|
-
* Written to a sibling temp file and renamed over the target so a crash (or a
|
|
10432
|
-
* concurrent reader) can never observe a half-written document — the ignore
|
|
10433
|
-
* list is the only thing standing between a dead account and the rotation, and
|
|
10434
|
-
* a truncated file reads as "nothing is ignored", which would quietly put every
|
|
10435
|
-
* discarded account back in the pool.
|
|
10436
|
-
*/
|
|
10437
|
-
async function writeIgnoredAccounts(accounts, path = ignoredIdsPath()) {
|
|
10438
|
-
const payload = JSON.stringify({
|
|
10439
|
-
version: 1,
|
|
10440
|
-
accounts
|
|
10441
|
-
}, null, 2);
|
|
10442
|
-
await mkdir(dirname(path), { recursive: true });
|
|
10443
|
-
const temp = `${path}.tmp`;
|
|
10444
|
-
await writeFile$1(temp, `${payload}\n`, "utf8");
|
|
10445
|
-
await rename(temp, path);
|
|
10446
|
-
}
|
|
10447
|
-
/**
|
|
10448
|
-
* Add one account to the ignore list, preserving the rest.
|
|
10449
|
-
*
|
|
10450
|
-
* A read-modify-write rather than a wholesale replace: the card and the CLI can
|
|
10451
|
-
* both be open, and each request names exactly one account, so re-writing the
|
|
10452
|
-
* whole list from a stale view would drop the other side's edits.
|
|
10453
|
-
*/
|
|
10454
|
-
async function ignoreAccount(account, path = ignoredIdsPath()) {
|
|
10455
|
-
const current = await readIgnoredAccounts(path);
|
|
10456
|
-
if (current.some((entry) => entry.id === account.id)) return current;
|
|
10457
|
-
const next = [...current, {
|
|
10458
|
-
id: account.id,
|
|
10459
|
-
label: account.label ?? account.id,
|
|
10460
|
-
ignoredAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
10461
|
-
}];
|
|
10462
|
-
await writeIgnoredAccounts(next, path);
|
|
10463
|
-
return next;
|
|
10464
|
-
}
|
|
10465
|
-
/** Drop one account from the ignore list. Returns the resulting list. */
|
|
10466
|
-
async function unignoreAccount(accountId, path = ignoredIdsPath()) {
|
|
10467
|
-
const current = await readIgnoredAccounts(path);
|
|
10468
|
-
const next = current.filter((entry) => entry.id !== accountId);
|
|
10469
|
-
if (next.length !== current.length) await writeIgnoredAccounts(next, path);
|
|
10470
|
-
return next;
|
|
10471
|
-
}
|
|
10472
10629
|
Object.freeze({
|
|
10473
10630
|
"workbuddy-xdpool": "xdpool",
|
|
10474
10631
|
"workbuddy-xdpool-global": "xdpool-global",
|
|
@@ -10647,7 +10804,8 @@ function createCore(logger) {
|
|
|
10647
10804
|
const client = new WorkBuddyUpstreamClient();
|
|
10648
10805
|
const pool = new WorkBuddyAccountPool({
|
|
10649
10806
|
...logger === void 0 ? {} : { logger },
|
|
10650
|
-
client
|
|
10807
|
+
client,
|
|
10808
|
+
persistent: true
|
|
10651
10809
|
});
|
|
10652
10810
|
return {
|
|
10653
10811
|
pool,
|
|
@@ -10666,6 +10824,11 @@ function createCore(logger) {
|
|
|
10666
10824
|
*
|
|
10667
10825
|
* dsh plugin --profile desktop exec dsh-xdpool <command>
|
|
10668
10826
|
*
|
|
10827
|
+
* The CLI is packaged as a separate entry (`lib/bin.js`) and must stay free of
|
|
10828
|
+
* host-injected optional-peer imports: it runs standalone, before any argument
|
|
10829
|
+
* is parsed, so a single side-effect import of `@earendil-works/pi-ai` made
|
|
10830
|
+
* every command die with ERR_MODULE_NOT_FOUND.
|
|
10831
|
+
*
|
|
10669
10832
|
* @module dsh-xdpool/bin
|
|
10670
10833
|
*/
|
|
10671
10834
|
/** Directory holding imported account snapshots. */
|
|
@@ -10686,7 +10849,7 @@ async function cliCore() {
|
|
|
10686
10849
|
}
|
|
10687
10850
|
/** Snapshot files are named by the md5 prefix of their key, so any key is safe. */
|
|
10688
10851
|
function snapshotPath(key, dir) {
|
|
10689
|
-
return join(dir, `${createHash("md5").update(key).digest("hex").slice(0, 8)}.
|
|
10852
|
+
return join(dir, `${createHash("md5").update(key).digest("hex").slice(0, 8)}.info`);
|
|
10690
10853
|
}
|
|
10691
10854
|
function dshHome() {
|
|
10692
10855
|
const fromEnv = process.env["DSH_HOME"];
|
package/lib/client.js
CHANGED
package/lib/index.d.ts
CHANGED
|
@@ -886,6 +886,21 @@ interface AccountPoolOptions {
|
|
|
886
886
|
* behaves like `priority`.
|
|
887
887
|
*/
|
|
888
888
|
distribution?: AccountDistribution;
|
|
889
|
+
/**
|
|
890
|
+
* Enable the durable account store.
|
|
891
|
+
*
|
|
892
|
+
* When on, `scan()` seeds its merge from `~/.dsh/.xdpool/accounts.json` and
|
|
893
|
+
* writes the merged list back, so accounts gathered by switching desktop
|
|
894
|
+
* sign-ins survive a restart, and the plugin's snapshot directory becomes a
|
|
895
|
+
* scan source.
|
|
896
|
+
*
|
|
897
|
+
* DEFAULT OFF, on purpose: the 70+ existing pool tests build fixtures over
|
|
898
|
+
* temp auth dirs, and a store that wrote by default would flush those
|
|
899
|
+
* fixtures into the user's real `~/.dsh/.xdpool/accounts.json`. Production
|
|
900
|
+
* assembles the pool through `createCore()`, which passes `persistent: true`;
|
|
901
|
+
* tests opt in explicitly.
|
|
902
|
+
*/
|
|
903
|
+
persistent?: boolean;
|
|
889
904
|
}
|
|
890
905
|
export declare class WorkBuddyAccountPool {
|
|
891
906
|
private readonly logger;
|
|
@@ -1001,6 +1016,18 @@ export declare class WorkBuddyAccountPool {
|
|
|
1001
1016
|
*/
|
|
1002
1017
|
private lastUsedAt;
|
|
1003
1018
|
private refreshInflight;
|
|
1019
|
+
/**
|
|
1020
|
+
* Whether `scan()` seeds from and writes back the durable account store.
|
|
1021
|
+
*
|
|
1022
|
+
* Off by default (tests build fixtures over temp dirs and must not touch the
|
|
1023
|
+
* user's real store); `createCore()` turns it on for production, which is
|
|
1024
|
+
* what makes accounts gathered by switching desktop sign-ins survive a
|
|
1025
|
+
* restart — the desktop app keeps only the current sign-in's credential file
|
|
1026
|
+
* on disk.
|
|
1027
|
+
*/
|
|
1028
|
+
private readonly persistent;
|
|
1029
|
+
/** Path of the durable store; computed once so tests can pin it. */
|
|
1030
|
+
private readonly storePath;
|
|
1004
1031
|
constructor(options?: AccountPoolOptions);
|
|
1005
1032
|
/**
|
|
1006
1033
|
* Re-apply configuration that only affects discovery and cooldown policy,
|
package/lib/index.js
CHANGED
|
@@ -4,8 +4,8 @@ import z from "@deepseek-ai/schemastery";
|
|
|
4
4
|
import crypto, { createDecipheriv, createHash, randomBytes, randomUUID, timingSafeEqual } from "node:crypto";
|
|
5
5
|
import { mkdir, readFile, readdir, rename, rm, writeFile } from "node:fs/promises";
|
|
6
6
|
import { homedir } from "node:os";
|
|
7
|
-
import { execFile, execFileSync } from "node:child_process";
|
|
8
7
|
import { appendFileSync, cpSync, existsSync, mkdirSync, readFileSync, readdirSync, statSync, truncateSync } from "node:fs";
|
|
8
|
+
import { execFile, execFileSync } from "node:child_process";
|
|
9
9
|
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
10
10
|
import { createProvider } from "@earendil-works/pi-ai";
|
|
11
11
|
import { openAICompletionsApi } from "@earendil-works/pi-ai/api/openai-completions.lazy";
|
|
@@ -1891,6 +1891,256 @@ var WorkBuddyUpstreamClient = class {
|
|
|
1891
1891
|
}
|
|
1892
1892
|
};
|
|
1893
1893
|
//#endregion
|
|
1894
|
+
//#region src/ignored.ts
|
|
1895
|
+
/**
|
|
1896
|
+
* The permanent ignore list: accounts the user has thrown out of the pool.
|
|
1897
|
+
*
|
|
1898
|
+
* Distinct from `disabledAccountIds`, which is a ROTATION preference. A disabled
|
|
1899
|
+
* account stays on the card, can be switched back on, and its credential keeps
|
|
1900
|
+
* being read on every scan. Ignoring is a statement about the ACCOUNT — "this
|
|
1901
|
+
* one is not mine any more" — so an ignored account is skipped BEFORE its
|
|
1902
|
+
* credential is decrypted, never appears among the pool's accounts, and does not
|
|
1903
|
+
* come back when the desktop app writes a fresh `.info` file for it.
|
|
1904
|
+
*
|
|
1905
|
+
* It lives in a plugin-owned file rather than in the settings document for one
|
|
1906
|
+
* practical reason: the CLI has no settings service. `dsh-xdpool
|
|
1907
|
+
* ignore <id>` runs without a host, so a settings-only list could be written by
|
|
1908
|
+
* the card and never by the CLI. One file, read by both halves, keeps the two
|
|
1909
|
+
* views of "which accounts are ignored" identical by construction.
|
|
1910
|
+
*
|
|
1911
|
+
* @module dsh-xdpool/ignored
|
|
1912
|
+
*/
|
|
1913
|
+
/** Directory holding this plugin's own state (imported snapshots, ignore list). */
|
|
1914
|
+
const PLUGIN_DATA_DIR_NAME = ".xdpool";
|
|
1915
|
+
/** File holding the permanent ignore list, inside {@link pluginDataDir}. */
|
|
1916
|
+
const IGNORED_FILE_NAME = "ignored.json";
|
|
1917
|
+
/**
|
|
1918
|
+
* The DSH home directory, honouring the same override the host uses.
|
|
1919
|
+
*
|
|
1920
|
+
* Shared by the CLI and the host so both halves resolve the same file: an
|
|
1921
|
+
* `ignore` written from the terminal has to be visible to the running plugin,
|
|
1922
|
+
* which is only true if they agree on where "home" is.
|
|
1923
|
+
*/
|
|
1924
|
+
function dshHome(env = process.env) {
|
|
1925
|
+
const fromEnv = env["DSH_HOME"];
|
|
1926
|
+
if (typeof fromEnv === "string" && fromEnv.trim() !== "") return fromEnv.trim();
|
|
1927
|
+
return join(homedir(), ".dsh");
|
|
1928
|
+
}
|
|
1929
|
+
/** This plugin's own state directory. */
|
|
1930
|
+
function pluginDataDir(env = process.env) {
|
|
1931
|
+
return join(dshHome(env), PLUGIN_DATA_DIR_NAME);
|
|
1932
|
+
}
|
|
1933
|
+
/** Absolute path of the ignore list. */
|
|
1934
|
+
function ignoredIdsPath(env = process.env) {
|
|
1935
|
+
return join(pluginDataDir(env), IGNORED_FILE_NAME);
|
|
1936
|
+
}
|
|
1937
|
+
/**
|
|
1938
|
+
* Normalize whatever the file holds into a clean list.
|
|
1939
|
+
*
|
|
1940
|
+
* Tolerant on purpose: this file is hand-editable and a malformed entry must not
|
|
1941
|
+
* take the pool down. An entry without a usable id is dropped; a missing label
|
|
1942
|
+
* falls back to the id so the card always has something to print.
|
|
1943
|
+
*/
|
|
1944
|
+
function normalize$1(raw) {
|
|
1945
|
+
if (typeof raw !== "object" || raw === null) return [];
|
|
1946
|
+
const entries = raw.accounts;
|
|
1947
|
+
if (!Array.isArray(entries)) return [];
|
|
1948
|
+
const out = [];
|
|
1949
|
+
const seen = /* @__PURE__ */ new Set();
|
|
1950
|
+
for (const entry of entries) {
|
|
1951
|
+
if (typeof entry !== "object" || entry === null) continue;
|
|
1952
|
+
const record = entry;
|
|
1953
|
+
const id = typeof record["id"] === "string" ? record["id"].trim() : "";
|
|
1954
|
+
if (id === "" || seen.has(id)) continue;
|
|
1955
|
+
seen.add(id);
|
|
1956
|
+
const label = typeof record["label"] === "string" && record["label"].trim() !== "" ? record["label"] : id;
|
|
1957
|
+
const ignoredAt = typeof record["ignoredAt"] === "string" ? record["ignoredAt"] : "";
|
|
1958
|
+
out.push({
|
|
1959
|
+
id,
|
|
1960
|
+
label,
|
|
1961
|
+
ignoredAt
|
|
1962
|
+
});
|
|
1963
|
+
}
|
|
1964
|
+
return out;
|
|
1965
|
+
}
|
|
1966
|
+
/**
|
|
1967
|
+
* Read the ignore list, tolerating every "no list yet" shape.
|
|
1968
|
+
*
|
|
1969
|
+
* A missing file, unreadable file, or invalid JSON all mean the same thing to
|
|
1970
|
+
* the caller — nothing is ignored — so none of them throws. The pool must be
|
|
1971
|
+
* able to start on a machine that has never ignored anything.
|
|
1972
|
+
*/
|
|
1973
|
+
async function readIgnoredAccounts(path = ignoredIdsPath()) {
|
|
1974
|
+
try {
|
|
1975
|
+
return normalize$1(JSON.parse(await readFile(path, "utf8")));
|
|
1976
|
+
} catch {
|
|
1977
|
+
return [];
|
|
1978
|
+
}
|
|
1979
|
+
}
|
|
1980
|
+
/**
|
|
1981
|
+
* Synchronous read, for startup.
|
|
1982
|
+
*
|
|
1983
|
+
* The host applies the ignore list from inside `apply()`, which is synchronous,
|
|
1984
|
+
* and doing it there removes a startup race: an async load could resolve AFTER
|
|
1985
|
+
* the first account scan, which would let an ignored account slip into the pool
|
|
1986
|
+
* once per boot. The file is a few hundred bytes, so a blocking read at startup
|
|
1987
|
+
* costs nothing measurable.
|
|
1988
|
+
*/
|
|
1989
|
+
function readIgnoredAccountsSync(path = ignoredIdsPath()) {
|
|
1990
|
+
try {
|
|
1991
|
+
return normalize$1(JSON.parse(readFileSync(path, "utf8")));
|
|
1992
|
+
} catch {
|
|
1993
|
+
return [];
|
|
1994
|
+
}
|
|
1995
|
+
}
|
|
1996
|
+
/**
|
|
1997
|
+
* Replace the ignore list, atomically.
|
|
1998
|
+
*
|
|
1999
|
+
* Written to a sibling temp file and renamed over the target so a crash (or a
|
|
2000
|
+
* concurrent reader) can never observe a half-written document — the ignore
|
|
2001
|
+
* list is the only thing standing between a dead account and the rotation, and
|
|
2002
|
+
* a truncated file reads as "nothing is ignored", which would quietly put every
|
|
2003
|
+
* discarded account back in the pool.
|
|
2004
|
+
*/
|
|
2005
|
+
async function writeIgnoredAccounts(accounts, path = ignoredIdsPath()) {
|
|
2006
|
+
const payload = JSON.stringify({
|
|
2007
|
+
version: 1,
|
|
2008
|
+
accounts
|
|
2009
|
+
}, null, 2);
|
|
2010
|
+
await mkdir(dirname(path), { recursive: true });
|
|
2011
|
+
const temp = `${path}.tmp`;
|
|
2012
|
+
await writeFile(temp, `${payload}\n`, "utf8");
|
|
2013
|
+
await rename(temp, path);
|
|
2014
|
+
}
|
|
2015
|
+
/**
|
|
2016
|
+
* Add one account to the ignore list, preserving the rest.
|
|
2017
|
+
*
|
|
2018
|
+
* A read-modify-write rather than a wholesale replace: the card and the CLI can
|
|
2019
|
+
* both be open, and each request names exactly one account, so re-writing the
|
|
2020
|
+
* whole list from a stale view would drop the other side's edits.
|
|
2021
|
+
*/
|
|
2022
|
+
async function ignoreAccount(account, path = ignoredIdsPath()) {
|
|
2023
|
+
const current = await readIgnoredAccounts(path);
|
|
2024
|
+
if (current.some((entry) => entry.id === account.id)) return current;
|
|
2025
|
+
const next = [...current, {
|
|
2026
|
+
id: account.id,
|
|
2027
|
+
label: account.label ?? account.id,
|
|
2028
|
+
ignoredAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
2029
|
+
}];
|
|
2030
|
+
await writeIgnoredAccounts(next, path);
|
|
2031
|
+
return next;
|
|
2032
|
+
}
|
|
2033
|
+
/** Drop one account from the ignore list. Returns the resulting list. */
|
|
2034
|
+
async function unignoreAccount(accountId, path = ignoredIdsPath()) {
|
|
2035
|
+
const current = await readIgnoredAccounts(path);
|
|
2036
|
+
const next = current.filter((entry) => entry.id !== accountId);
|
|
2037
|
+
if (next.length !== current.length) await writeIgnoredAccounts(next, path);
|
|
2038
|
+
return next;
|
|
2039
|
+
}
|
|
2040
|
+
//#endregion
|
|
2041
|
+
//#region src/account-store.ts
|
|
2042
|
+
/**
|
|
2043
|
+
* Durable copy of the discovered accounts, inside the plugin's data directory.
|
|
2044
|
+
*
|
|
2045
|
+
* The pool's account list is otherwise pure memory: the desktop app keeps only
|
|
2046
|
+
* the CURRENT sign-in on disk (each login overwrites the one auth file), so
|
|
2047
|
+
* accounts gathered by switching sign-ins evaporated on every restart. This
|
|
2048
|
+
* store is the fix — `scan()` seeds from it and writes the merged list back, so
|
|
2049
|
+
* the pool now survives restarts.
|
|
2050
|
+
*
|
|
2051
|
+
* JSON only, atomic rename on write: a crash mid-write leaves the previous
|
|
2052
|
+
* generation intact, and the pool never reads a half-written account again.
|
|
2053
|
+
*
|
|
2054
|
+
* @module dsh-xdpool/account-store
|
|
2055
|
+
*/
|
|
2056
|
+
/** File name of the durable account list, inside the plugin data directory. */
|
|
2057
|
+
const ACCOUNTS_FILE_NAME = "accounts.json";
|
|
2058
|
+
/**
|
|
2059
|
+
* State files that live in the same directory but are NOT account snapshots.
|
|
2060
|
+
*
|
|
2061
|
+
* The snapshot importer (`dsh-xdpool import <key>`) writes `<md5prefix>.json`
|
|
2062
|
+
* into this directory, and the pool scans it — so every other `.json` file here
|
|
2063
|
+
* is treated as an imported snapshot. These three are the plugin's own
|
|
2064
|
+
* bookkeeping and must never be parsed as credentials.
|
|
2065
|
+
*/
|
|
2066
|
+
const NON_SNAPSHOT_FILES = [
|
|
2067
|
+
ACCOUNTS_FILE_NAME,
|
|
2068
|
+
"ignored.json",
|
|
2069
|
+
"preferred.json"
|
|
2070
|
+
];
|
|
2071
|
+
/** Absolute path of the durable account list. */
|
|
2072
|
+
function accountStorePath(env = process.env) {
|
|
2073
|
+
return join(dshHome(env), PLUGIN_DATA_DIR_NAME, ACCOUNTS_FILE_NAME);
|
|
2074
|
+
}
|
|
2075
|
+
/** The directory the pool scans for imported `<md5prefix>.json` snapshots. */
|
|
2076
|
+
function snapshotDirPath(env = process.env) {
|
|
2077
|
+
return join(dshHome(env), PLUGIN_DATA_DIR_NAME);
|
|
2078
|
+
}
|
|
2079
|
+
/**
|
|
2080
|
+
* True when a file name in the snapshot directory is plugin bookkeeping rather
|
|
2081
|
+
* than an imported snapshot.
|
|
2082
|
+
*/
|
|
2083
|
+
function isStateFile(name) {
|
|
2084
|
+
return NON_SNAPSHOT_FILES.includes(name);
|
|
2085
|
+
}
|
|
2086
|
+
/** True when a value has the shape of a stored account. */
|
|
2087
|
+
function isStoredAccount(value) {
|
|
2088
|
+
if (typeof value !== "object" || value === null) return false;
|
|
2089
|
+
const record = value;
|
|
2090
|
+
const credential = record["credential"];
|
|
2091
|
+
return typeof record["id"] === "string" && typeof record["label"] === "string" && typeof credential === "object" && credential !== null && typeof credential["accessToken"] === "string" && typeof credential["refreshToken"] === "string";
|
|
2092
|
+
}
|
|
2093
|
+
/**
|
|
2094
|
+
* Read the durable list. Tolerant on purpose: a malformed or half-written file
|
|
2095
|
+
* yields `[]` and the pool simply rediscovers from disk, rather than a corrupt
|
|
2096
|
+
* store taking the whole pool down.
|
|
2097
|
+
*/
|
|
2098
|
+
async function loadStoredAccounts(path) {
|
|
2099
|
+
let text;
|
|
2100
|
+
try {
|
|
2101
|
+
text = await readFile(path, "utf8");
|
|
2102
|
+
} catch {
|
|
2103
|
+
return [];
|
|
2104
|
+
}
|
|
2105
|
+
let parsed;
|
|
2106
|
+
try {
|
|
2107
|
+
parsed = JSON.parse(text);
|
|
2108
|
+
} catch {
|
|
2109
|
+
return [];
|
|
2110
|
+
}
|
|
2111
|
+
const entries = parsed?.["accounts"];
|
|
2112
|
+
if (!Array.isArray(entries)) return [];
|
|
2113
|
+
const out = [];
|
|
2114
|
+
const seen = /* @__PURE__ */ new Set();
|
|
2115
|
+
for (const entry of entries) {
|
|
2116
|
+
if (!isStoredAccount(entry)) continue;
|
|
2117
|
+
if (seen.has(entry.id)) continue;
|
|
2118
|
+
seen.add(entry.id);
|
|
2119
|
+
out.push(entry);
|
|
2120
|
+
}
|
|
2121
|
+
return out;
|
|
2122
|
+
}
|
|
2123
|
+
/**
|
|
2124
|
+
* Atomically write the durable list: `${path}.tmp` first, then rename. A crash
|
|
2125
|
+
* mid-write leaves the previous generation intact.
|
|
2126
|
+
*/
|
|
2127
|
+
async function saveStoredAccounts(path, accounts) {
|
|
2128
|
+
const stored = accounts.map((account) => ({
|
|
2129
|
+
id: account.id,
|
|
2130
|
+
label: account.label,
|
|
2131
|
+
credential: account.credential,
|
|
2132
|
+
savedAtMs: Date.now()
|
|
2133
|
+
}));
|
|
2134
|
+
const document = JSON.stringify({
|
|
2135
|
+
version: 1,
|
|
2136
|
+
accounts: stored
|
|
2137
|
+
}, null, 2) + "\n";
|
|
2138
|
+
await mkdir(join(path, ".."), { recursive: true });
|
|
2139
|
+
const tmp = `${path}.tmp`;
|
|
2140
|
+
await writeFile(tmp, document, "utf8");
|
|
2141
|
+
await rename(tmp, path);
|
|
2142
|
+
}
|
|
2143
|
+
//#endregion
|
|
1894
2144
|
//#region src/at-rest.ts
|
|
1895
2145
|
/**
|
|
1896
2146
|
* WorkBuddy desktop "at-rest" credential decryption.
|
|
@@ -2739,6 +2989,23 @@ async function authFilesIn(dir) {
|
|
|
2739
2989
|
return files.map((name) => join(dir, name));
|
|
2740
2990
|
}
|
|
2741
2991
|
/**
|
|
2992
|
+
* Snapshot files in the plugin data directory that are account snapshots.
|
|
2993
|
+
*
|
|
2994
|
+
* Every `*.info` file there is one (the importer keeps that extension for
|
|
2995
|
+
* uniformity with the auth dirs it copies from); the plugin's own JSON state
|
|
2996
|
+
* files (`accounts.json`, `ignored.json`, `preferred.json`) are NOT and must
|
|
2997
|
+
* never reach the credential parser.
|
|
2998
|
+
*/
|
|
2999
|
+
async function snapshotFilesIn(dir) {
|
|
3000
|
+
let entries;
|
|
3001
|
+
try {
|
|
3002
|
+
entries = await readdir(dir);
|
|
3003
|
+
} catch {
|
|
3004
|
+
return [];
|
|
3005
|
+
}
|
|
3006
|
+
return entries.filter((name) => name.endsWith(".info")).filter((name) => !isStateFile(name)).sort((a, b) => a < b ? 1 : a > b ? -1 : 0).map((name) => join(dir, name));
|
|
3007
|
+
}
|
|
3008
|
+
/**
|
|
2742
3009
|
* One credential file, or undefined when it cannot be used.
|
|
2743
3010
|
*
|
|
2744
3011
|
* Returns undefined — rather than throwing — for every failure mode, so ONE bad
|
|
@@ -3017,6 +3284,18 @@ var WorkBuddyAccountPool = class {
|
|
|
3017
3284
|
*/
|
|
3018
3285
|
lastUsedAt = /* @__PURE__ */ new Map();
|
|
3019
3286
|
refreshInflight = /* @__PURE__ */ new Map();
|
|
3287
|
+
/**
|
|
3288
|
+
* Whether `scan()` seeds from and writes back the durable account store.
|
|
3289
|
+
*
|
|
3290
|
+
* Off by default (tests build fixtures over temp dirs and must not touch the
|
|
3291
|
+
* user's real store); `createCore()` turns it on for production, which is
|
|
3292
|
+
* what makes accounts gathered by switching desktop sign-ins survive a
|
|
3293
|
+
* restart — the desktop app keeps only the current sign-in's credential file
|
|
3294
|
+
* on disk.
|
|
3295
|
+
*/
|
|
3296
|
+
persistent;
|
|
3297
|
+
/** Path of the durable store; computed once so tests can pin it. */
|
|
3298
|
+
storePath;
|
|
3020
3299
|
constructor(options = {}) {
|
|
3021
3300
|
this.logger = options.logger;
|
|
3022
3301
|
this.authDirs = options.authDirs ?? candidateAuthDirs();
|
|
@@ -3026,6 +3305,8 @@ var WorkBuddyAccountPool = class {
|
|
|
3026
3305
|
this.client = options.client;
|
|
3027
3306
|
this.refreshMarginMs = options.refreshMarginMs ?? 3e5;
|
|
3028
3307
|
this.distribution = options.distribution ?? "priority";
|
|
3308
|
+
this.persistent = options.persistent ?? false;
|
|
3309
|
+
this.storePath = accountStorePath();
|
|
3029
3310
|
}
|
|
3030
3311
|
/**
|
|
3031
3312
|
* Re-apply configuration that only affects discovery and cooldown policy,
|
|
@@ -3075,26 +3356,44 @@ var WorkBuddyAccountPool = class {
|
|
|
3075
3356
|
async scan() {
|
|
3076
3357
|
const found = [];
|
|
3077
3358
|
const skipped = [];
|
|
3078
|
-
|
|
3079
|
-
|
|
3080
|
-
|
|
3081
|
-
|
|
3082
|
-
|
|
3083
|
-
|
|
3084
|
-
|
|
3085
|
-
|
|
3086
|
-
|
|
3087
|
-
|
|
3088
|
-
|
|
3089
|
-
|
|
3359
|
+
const scanDirs = this.persistent ? [...this.authDirs, snapshotDirPath()] : this.authDirs;
|
|
3360
|
+
for (const dir of scanDirs) {
|
|
3361
|
+
const files = dir === snapshotDirPath() ? await snapshotFilesIn(dir) : await authFilesIn(dir);
|
|
3362
|
+
for (const file of files) {
|
|
3363
|
+
if (this.ignoredIds.size > 0) {
|
|
3364
|
+
const cheapId = await cheapIdentityIdFromFile(file);
|
|
3365
|
+
if (cheapId !== void 0 && this.ignoredIds.has(cheapId)) continue;
|
|
3366
|
+
}
|
|
3367
|
+
const credential = await readCredential(file);
|
|
3368
|
+
if (credential === void 0) {
|
|
3369
|
+
skipped.push({
|
|
3370
|
+
path: file,
|
|
3371
|
+
reason: await skipReasonFor(file)
|
|
3372
|
+
});
|
|
3373
|
+
continue;
|
|
3374
|
+
}
|
|
3375
|
+
if (this.ignoredIds.size > 0 && this.ignoredIds.has(workbuddyAccountId(credential))) continue;
|
|
3376
|
+
found.push(credential);
|
|
3090
3377
|
}
|
|
3091
|
-
if (this.ignoredIds.size > 0 && this.ignoredIds.has(workbuddyAccountId(credential))) continue;
|
|
3092
|
-
found.push(credential);
|
|
3093
3378
|
}
|
|
3094
3379
|
this.skippedFiles = skipped;
|
|
3095
3380
|
if (skipped.length > 0) this.logger?.warn?.(`dsh-xdpool: ${skipped.length} credential file(s) could not be read; ${found.length} account(s) still loaded. Reasons: ` + [...new Set(skipped.map((s) => s.reason))].join("; "));
|
|
3096
3381
|
const byId = /* @__PURE__ */ new Map();
|
|
3097
3382
|
for (const account of this.accounts) byId.set(account.id, account);
|
|
3383
|
+
if (this.persistent && this.accounts.length === 0) {
|
|
3384
|
+
const stored = await loadStoredAccounts(this.storePath);
|
|
3385
|
+
for (const entry of stored) {
|
|
3386
|
+
if (byId.has(entry.id)) continue;
|
|
3387
|
+
byId.set(entry.id, {
|
|
3388
|
+
id: entry.id,
|
|
3389
|
+
label: entry.label,
|
|
3390
|
+
credential: entry.credential,
|
|
3391
|
+
cooldownUntilMs: 0,
|
|
3392
|
+
modelCooldowns: {},
|
|
3393
|
+
rateLimitHits: 0
|
|
3394
|
+
});
|
|
3395
|
+
}
|
|
3396
|
+
}
|
|
3098
3397
|
for (const credential of found) {
|
|
3099
3398
|
const id = workbuddyAccountId(credential);
|
|
3100
3399
|
const existing = byId.get(id);
|
|
@@ -3123,6 +3422,11 @@ var WorkBuddyAccountPool = class {
|
|
|
3123
3422
|
ordered.sort((a, b) => compareFreshness(a.credential, b.credential));
|
|
3124
3423
|
this.accounts = ordered;
|
|
3125
3424
|
this.lastScanAtMs = Date.now();
|
|
3425
|
+
if (this.persistent) try {
|
|
3426
|
+
await saveStoredAccounts(this.storePath, this.accounts);
|
|
3427
|
+
} catch (error) {
|
|
3428
|
+
this.logger?.warn?.("dsh-xdpool: could not persist the account store", error);
|
|
3429
|
+
}
|
|
3126
3430
|
return this.accounts;
|
|
3127
3431
|
}
|
|
3128
3432
|
/** All accounts, cooldown state included. */
|
|
@@ -18048,153 +18352,6 @@ function formatRates(status) {
|
|
|
18048
18352
|
return lines.join("\n");
|
|
18049
18353
|
}
|
|
18050
18354
|
//#endregion
|
|
18051
|
-
//#region src/ignored.ts
|
|
18052
|
-
/**
|
|
18053
|
-
* The permanent ignore list: accounts the user has thrown out of the pool.
|
|
18054
|
-
*
|
|
18055
|
-
* Distinct from `disabledAccountIds`, which is a ROTATION preference. A disabled
|
|
18056
|
-
* account stays on the card, can be switched back on, and its credential keeps
|
|
18057
|
-
* being read on every scan. Ignoring is a statement about the ACCOUNT — "this
|
|
18058
|
-
* one is not mine any more" — so an ignored account is skipped BEFORE its
|
|
18059
|
-
* credential is decrypted, never appears among the pool's accounts, and does not
|
|
18060
|
-
* come back when the desktop app writes a fresh `.info` file for it.
|
|
18061
|
-
*
|
|
18062
|
-
* It lives in a plugin-owned file rather than in the settings document for one
|
|
18063
|
-
* practical reason: the CLI has no settings service. `dsh-xdpool
|
|
18064
|
-
* ignore <id>` runs without a host, so a settings-only list could be written by
|
|
18065
|
-
* the card and never by the CLI. One file, read by both halves, keeps the two
|
|
18066
|
-
* views of "which accounts are ignored" identical by construction.
|
|
18067
|
-
*
|
|
18068
|
-
* @module dsh-xdpool/ignored
|
|
18069
|
-
*/
|
|
18070
|
-
/** Directory holding this plugin's own state (imported snapshots, ignore list). */
|
|
18071
|
-
const PLUGIN_DATA_DIR_NAME = ".xdpool";
|
|
18072
|
-
/** File holding the permanent ignore list, inside {@link pluginDataDir}. */
|
|
18073
|
-
const IGNORED_FILE_NAME = "ignored.json";
|
|
18074
|
-
/**
|
|
18075
|
-
* The DSH home directory, honouring the same override the host uses.
|
|
18076
|
-
*
|
|
18077
|
-
* Shared by the CLI and the host so both halves resolve the same file: an
|
|
18078
|
-
* `ignore` written from the terminal has to be visible to the running plugin,
|
|
18079
|
-
* which is only true if they agree on where "home" is.
|
|
18080
|
-
*/
|
|
18081
|
-
function dshHome(env = process.env) {
|
|
18082
|
-
const fromEnv = env["DSH_HOME"];
|
|
18083
|
-
if (typeof fromEnv === "string" && fromEnv.trim() !== "") return fromEnv.trim();
|
|
18084
|
-
return join(homedir(), ".dsh");
|
|
18085
|
-
}
|
|
18086
|
-
/** This plugin's own state directory. */
|
|
18087
|
-
function pluginDataDir(env = process.env) {
|
|
18088
|
-
return join(dshHome(env), PLUGIN_DATA_DIR_NAME);
|
|
18089
|
-
}
|
|
18090
|
-
/** Absolute path of the ignore list. */
|
|
18091
|
-
function ignoredIdsPath(env = process.env) {
|
|
18092
|
-
return join(pluginDataDir(env), IGNORED_FILE_NAME);
|
|
18093
|
-
}
|
|
18094
|
-
/**
|
|
18095
|
-
* Normalize whatever the file holds into a clean list.
|
|
18096
|
-
*
|
|
18097
|
-
* Tolerant on purpose: this file is hand-editable and a malformed entry must not
|
|
18098
|
-
* take the pool down. An entry without a usable id is dropped; a missing label
|
|
18099
|
-
* falls back to the id so the card always has something to print.
|
|
18100
|
-
*/
|
|
18101
|
-
function normalize$1(raw) {
|
|
18102
|
-
if (typeof raw !== "object" || raw === null) return [];
|
|
18103
|
-
const entries = raw.accounts;
|
|
18104
|
-
if (!Array.isArray(entries)) return [];
|
|
18105
|
-
const out = [];
|
|
18106
|
-
const seen = /* @__PURE__ */ new Set();
|
|
18107
|
-
for (const entry of entries) {
|
|
18108
|
-
if (typeof entry !== "object" || entry === null) continue;
|
|
18109
|
-
const record = entry;
|
|
18110
|
-
const id = typeof record["id"] === "string" ? record["id"].trim() : "";
|
|
18111
|
-
if (id === "" || seen.has(id)) continue;
|
|
18112
|
-
seen.add(id);
|
|
18113
|
-
const label = typeof record["label"] === "string" && record["label"].trim() !== "" ? record["label"] : id;
|
|
18114
|
-
const ignoredAt = typeof record["ignoredAt"] === "string" ? record["ignoredAt"] : "";
|
|
18115
|
-
out.push({
|
|
18116
|
-
id,
|
|
18117
|
-
label,
|
|
18118
|
-
ignoredAt
|
|
18119
|
-
});
|
|
18120
|
-
}
|
|
18121
|
-
return out;
|
|
18122
|
-
}
|
|
18123
|
-
/**
|
|
18124
|
-
* Read the ignore list, tolerating every "no list yet" shape.
|
|
18125
|
-
*
|
|
18126
|
-
* A missing file, unreadable file, or invalid JSON all mean the same thing to
|
|
18127
|
-
* the caller — nothing is ignored — so none of them throws. The pool must be
|
|
18128
|
-
* able to start on a machine that has never ignored anything.
|
|
18129
|
-
*/
|
|
18130
|
-
async function readIgnoredAccounts(path = ignoredIdsPath()) {
|
|
18131
|
-
try {
|
|
18132
|
-
return normalize$1(JSON.parse(await readFile(path, "utf8")));
|
|
18133
|
-
} catch {
|
|
18134
|
-
return [];
|
|
18135
|
-
}
|
|
18136
|
-
}
|
|
18137
|
-
/**
|
|
18138
|
-
* Synchronous read, for startup.
|
|
18139
|
-
*
|
|
18140
|
-
* The host applies the ignore list from inside `apply()`, which is synchronous,
|
|
18141
|
-
* and doing it there removes a startup race: an async load could resolve AFTER
|
|
18142
|
-
* the first account scan, which would let an ignored account slip into the pool
|
|
18143
|
-
* once per boot. The file is a few hundred bytes, so a blocking read at startup
|
|
18144
|
-
* costs nothing measurable.
|
|
18145
|
-
*/
|
|
18146
|
-
function readIgnoredAccountsSync(path = ignoredIdsPath()) {
|
|
18147
|
-
try {
|
|
18148
|
-
return normalize$1(JSON.parse(readFileSync(path, "utf8")));
|
|
18149
|
-
} catch {
|
|
18150
|
-
return [];
|
|
18151
|
-
}
|
|
18152
|
-
}
|
|
18153
|
-
/**
|
|
18154
|
-
* Replace the ignore list, atomically.
|
|
18155
|
-
*
|
|
18156
|
-
* Written to a sibling temp file and renamed over the target so a crash (or a
|
|
18157
|
-
* concurrent reader) can never observe a half-written document — the ignore
|
|
18158
|
-
* list is the only thing standing between a dead account and the rotation, and
|
|
18159
|
-
* a truncated file reads as "nothing is ignored", which would quietly put every
|
|
18160
|
-
* discarded account back in the pool.
|
|
18161
|
-
*/
|
|
18162
|
-
async function writeIgnoredAccounts(accounts, path = ignoredIdsPath()) {
|
|
18163
|
-
const payload = JSON.stringify({
|
|
18164
|
-
version: 1,
|
|
18165
|
-
accounts
|
|
18166
|
-
}, null, 2);
|
|
18167
|
-
await mkdir(dirname(path), { recursive: true });
|
|
18168
|
-
const temp = `${path}.tmp`;
|
|
18169
|
-
await writeFile(temp, `${payload}\n`, "utf8");
|
|
18170
|
-
await rename(temp, path);
|
|
18171
|
-
}
|
|
18172
|
-
/**
|
|
18173
|
-
* Add one account to the ignore list, preserving the rest.
|
|
18174
|
-
*
|
|
18175
|
-
* A read-modify-write rather than a wholesale replace: the card and the CLI can
|
|
18176
|
-
* both be open, and each request names exactly one account, so re-writing the
|
|
18177
|
-
* whole list from a stale view would drop the other side's edits.
|
|
18178
|
-
*/
|
|
18179
|
-
async function ignoreAccount(account, path = ignoredIdsPath()) {
|
|
18180
|
-
const current = await readIgnoredAccounts(path);
|
|
18181
|
-
if (current.some((entry) => entry.id === account.id)) return current;
|
|
18182
|
-
const next = [...current, {
|
|
18183
|
-
id: account.id,
|
|
18184
|
-
label: account.label ?? account.id,
|
|
18185
|
-
ignoredAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
18186
|
-
}];
|
|
18187
|
-
await writeIgnoredAccounts(next, path);
|
|
18188
|
-
return next;
|
|
18189
|
-
}
|
|
18190
|
-
/** Drop one account from the ignore list. Returns the resulting list. */
|
|
18191
|
-
async function unignoreAccount(accountId, path = ignoredIdsPath()) {
|
|
18192
|
-
const current = await readIgnoredAccounts(path);
|
|
18193
|
-
const next = current.filter((entry) => entry.id !== accountId);
|
|
18194
|
-
if (next.length !== current.length) await writeIgnoredAccounts(next, path);
|
|
18195
|
-
return next;
|
|
18196
|
-
}
|
|
18197
|
-
//#endregion
|
|
18198
18355
|
//#region src/migrate.ts
|
|
18199
18356
|
/**
|
|
18200
18357
|
* One-time adoption of state written under the plugin's pre-rename name.
|
|
@@ -18845,7 +19002,8 @@ function createCore(logger) {
|
|
|
18845
19002
|
const client = new WorkBuddyUpstreamClient();
|
|
18846
19003
|
const pool = new WorkBuddyAccountPool({
|
|
18847
19004
|
...logger === void 0 ? {} : { logger },
|
|
18848
|
-
client
|
|
19005
|
+
client,
|
|
19006
|
+
persistent: true
|
|
18849
19007
|
});
|
|
18850
19008
|
return {
|
|
18851
19009
|
pool,
|
package/package.json
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"name": "dsh-xdpool",
|
|
3
3
|
"displayName": "DSH XD Pool",
|
|
4
4
|
"description": "One DSH model pool for every free quota you have: merge all locally signed-in WorkBuddy accounts with auto-failover, sign in to 13 vendors by QR/web, aggregate 76 vendors' free tiers, plus a usage dashboard, model routing, a local OpenAI-compatible gateway and a points-automation suite.",
|
|
5
|
-
"version": "2.
|
|
5
|
+
"version": "2.2.0",
|
|
6
6
|
"license": "MIT",
|
|
7
7
|
"author": "XDTrees",
|
|
8
8
|
"homepage": "https://github.com/XDTrees/dsh-xdpool#readme",
|