dsh-multi-provider-usage 0.7.1 → 0.7.3
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/README.md +77 -28
- package/README.zh.md +60 -23
- package/lib/auto-update.js +188 -26
- package/lib/client.js +544 -69
- package/lib/index.js +13 -1
- package/lib/opencode-console.js +329 -94
- package/lib/opencode-session.js +5 -1
- package/lib/providers.js +24 -7
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -36,23 +36,31 @@ panel settings page — extended from one provider to three.
|
|
|
36
36
|
console token is auto-discovered from `OPENCODE_CONSOLE_TOKEN`, the OpenCode
|
|
37
37
|
CLI auth file, or its local `opencode.db`; the token never leaves the host.
|
|
38
38
|
When that token is missing, the balance slot becomes a clickable "Set up" chip
|
|
39
|
-
that opens the plugin settings
|
|
39
|
+
that opens the plugin settings, where the OpenCode GO card renders a
|
|
40
|
+
step-by-step setup guide, instead of silently showing nothing. Command
|
|
40
41
|
Code's tooltip breaks its amount into purchased + free credits; a credits
|
|
41
42
|
response without either pool renders a muted **No top-up** badge.
|
|
42
43
|
- **Auto-update** — on boot and every configured interval the host checks the
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
44
|
+
npm registry, the npmmirror mirror and the GitHub release feed together and
|
|
45
|
+
trusts the newest version any of them reports. A local `link:`/`file:` checkout
|
|
46
|
+
is fast-forwarded when it is on `main`, clean, and a strict fast-forward. An
|
|
47
|
+
npm / market / tarball copy updates itself: the release tarball is downloaded
|
|
48
|
+
from the first source that serves it (registry → mirror → GitHub, in turn) and
|
|
49
|
+
each packaged file (`lib/`, `package.json`, `cordis.patch.yml`, `docs/`) is
|
|
50
|
+
written to a temp file and renamed over the target, so pnpm's hardlinked store
|
|
51
|
+
is never edited in place. Either way the running Host keeps the old module
|
|
52
|
+
graph, so the widget/settings show "updated — restart DSH". The plugin never
|
|
53
|
+
restarts DSH by itself.
|
|
51
54
|
- **Balance-change animation** — the widget polls on its normal interval; when
|
|
52
55
|
a provider's balance differs from the previous poll, the number counts from
|
|
53
56
|
the old value to the refreshed one while a red/green delta chip (for example
|
|
54
57
|
`-0.1`) floats up and fades away. There is no popup and no turn-event
|
|
55
58
|
tracking, so the animation may appear up to one poll interval later.
|
|
59
|
+
- **Low-balance tone** — a balance at or below 5 renders amber and at or below 1
|
|
60
|
+
renders red (thresholds are in the currency unit and apply to USD and CNY
|
|
61
|
+
plans alike), in both the sidebar and the settings page's live-usage sections.
|
|
62
|
+
- **Click-to-retry failures** — an error line in the widget is itself clickable
|
|
63
|
+
(and keyboard-activatable) and forces one fresh upstream read.
|
|
56
64
|
- **Same-origin proxy** — the browser only talks to `/provider-usage/usage` and
|
|
57
65
|
`/provider-usage/settings`; API keys stay in the host process.
|
|
58
66
|
- **Failure isolation** — one broken provider never hides the other two. The
|
|
@@ -65,6 +73,12 @@ panel settings page — extended from one provider to three.
|
|
|
65
73
|
- **Bundle settings page** — sidebar **Plugins** panel → this bundle's card:
|
|
66
74
|
enable/disable each provider, edit credential refs and endpoints, choose the
|
|
67
75
|
GLM region, and see non-network detection status. Save applies immediately.
|
|
76
|
+
Each provider card embeds a **live usage** section — quota windows with
|
|
77
|
+
percentages, time-to-reset and the top-up balance, from the same canonical
|
|
78
|
+
snapshot the widget renders. While the page is open it follows the widget:
|
|
79
|
+
a 30-second poll (when the tab is visible) plus an "Updated HH:MM" stamp,
|
|
80
|
+
a **Refresh usage** control forces a re-read, and every save re-reads with
|
|
81
|
+
`refresh=1`, so a changed credential ref shows its own numbers immediately.
|
|
68
82
|
The GLM card also carries the **referral link** (Z.ai "Invite Friends, Get
|
|
69
83
|
Credits") as a clickable, copyable row. The URL is a bundle constant: it never
|
|
70
84
|
goes through the settings route and a settings save cannot overwrite it.
|
|
@@ -78,16 +92,22 @@ panel settings page — extended from one provider to three.
|
|
|
78
92
|
|
|
79
93
|
## Install
|
|
80
94
|
|
|
81
|
-
From
|
|
95
|
+
From npm:
|
|
82
96
|
|
|
83
97
|
```bash
|
|
84
|
-
dsh plugin --profile desktop add
|
|
98
|
+
dsh plugin --profile desktop add dsh-multi-provider-usage
|
|
85
99
|
```
|
|
86
100
|
|
|
87
101
|
or, for the web profile:
|
|
88
102
|
|
|
89
103
|
```bash
|
|
90
|
-
dsh plugin --profile web add
|
|
104
|
+
dsh plugin --profile web add dsh-multi-provider-usage
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
From a checkout (for development):
|
|
108
|
+
|
|
109
|
+
```bash
|
|
110
|
+
dsh plugin --profile desktop add /path/to/dsh-provider-usage
|
|
91
111
|
```
|
|
92
112
|
|
|
93
113
|
The package declares `dsh.bundle.patch`, so the CLI reconciles it into the
|
|
@@ -127,21 +147,48 @@ in this order:
|
|
|
127
147
|
|
|
128
148
|
1. `opencodeConsoleApiKeyEnv` (`OPENCODE_CONSOLE_TOKEN` by default), resolved
|
|
129
149
|
through DSH credentials;
|
|
130
|
-
2. `~/.local/share/opencode/
|
|
131
|
-
set), provider `opencode`;
|
|
132
|
-
3. the `credential` table of `~/.local/share/opencode/opencode.db` (read via
|
|
150
|
+
2. the `credential` table of `~/.local/share/opencode/opencode.db` (read via
|
|
133
151
|
`node:sqlite` when available; this is where current OpenCode releases keep
|
|
134
|
-
the OAuth grant)
|
|
152
|
+
the OAuth grant and rotate it);
|
|
153
|
+
3. `~/.local/share/opencode/auth.json` (`$XDG_DATA_HOME/opencode/auth.json` when
|
|
154
|
+
set), provider `opencode` — the legacy location kept by older releases.
|
|
155
|
+
|
|
156
|
+
The database is preferred deliberately: OpenCode migrated its grant there but
|
|
157
|
+
often leaves the old `auth.json` behind, and that stale token returns 401. Every
|
|
158
|
+
usable OAuth candidate is tried in turn (at most four), so one expired store
|
|
159
|
+
cannot hide the live one. Channel builds (`opencode-beta.db`, `opencode-dev.db`,
|
|
160
|
+
…) and `$OPENCODE_DB` are discovered too. The billing host comes from the grant's
|
|
161
|
+
`metadata.server` (so a self-hosted console works), falling back to the public
|
|
162
|
+
origin; `opencodeConsoleBaseUrl` overrides it.
|
|
163
|
+
|
|
164
|
+
An OpenCode **API-key** login (`oc_sk_…`, the service-account method) carries no
|
|
165
|
+
console session and is never sent to the billing endpoint. Such a login, a
|
|
166
|
+
missing token, or an expired/revoked grant renders a clickable **Set up** chip
|
|
167
|
+
(instead of a mute dash) that opens this bundle's page in the Plugins panel and
|
|
168
|
+
names the fix: run `opencode auth login` and choose the **OpenCode Console
|
|
169
|
+
account**, or store a console token as `OPENCODE_CONSOLE_TOKEN`.
|
|
170
|
+
|
|
171
|
+
When the balance is not configured (or the console login is fixable), the
|
|
172
|
+
OpenCode GO card in the plugin settings renders a step-by-step **setup guide**
|
|
173
|
+
in place of the static hint. The lead-in names the state-specific fix (nothing
|
|
174
|
+
configured, an API-key login, or an expired sign-in). Step ① shows the
|
|
175
|
+
`opencode auth login` CLI command with a **Copy command** button; step ② is the
|
|
176
|
+
manual path — copy the console token and store it as the `OPENCODE_CONSOLE_TOKEN`
|
|
177
|
+
credential — with a **Copy credential ref** button and a link to the OpenCode
|
|
178
|
+
console website (derived from `opencodeConsoleBaseUrl`, ending in `/console`);
|
|
179
|
+
step ③ offers a **Re-check** button that re-reads the sign-in state and forces
|
|
180
|
+
one upstream usage read, so a fresh login shows the balance without restarting
|
|
181
|
+
DSH (unsaved form edits are never discarded by the re-check). The
|
|
182
|
+
`/provider-usage` command gives the same state-specific guidance as text for
|
|
183
|
+
all three fixable states.
|
|
135
184
|
|
|
136
185
|
The optional `opencodeConsoleAuthFile` overrides the auth file (and its
|
|
137
|
-
directory is used
|
|
186
|
+
directory is used to locate `opencode.db`), `opencodeConsoleOrgId` supplies the
|
|
138
187
|
`x-org-id` header, and `opencodeConsoleEnabled: false` turns the whole balance
|
|
139
188
|
read off. Balance failures never hide the GO quota: the provider stays `ok` with
|
|
140
|
-
`balance: null` and a `meta.balanceError
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
fix (`opencode auth login` or `OPENCODE_CONSOLE_TOKEN`). Any other balance
|
|
144
|
-
failure renders a muted **Balance —** badge whose tooltip carries the error.
|
|
189
|
+
`balance: null` and a `meta.balanceError` (plus `meta.balanceSetup` when the
|
|
190
|
+
user can fix it). Any other balance failure renders a muted **Balance —** badge
|
|
191
|
+
whose tooltip carries the error.
|
|
145
192
|
|
|
146
193
|
### Command Code balance
|
|
147
194
|
|
|
@@ -222,7 +269,7 @@ the settings page and the auto-generated form hot-apply.
|
|
|
222
269
|
| `glmRegion` | `"auto"` | `auto`, `zai`, `bigmodel`, `custom` |
|
|
223
270
|
| `cacheMs` | `30000` | Per-provider host cache TTL |
|
|
224
271
|
| `updateCheck` | `true` | npm update check (24 h) |
|
|
225
|
-
| `autoUpdate` | `true` | Check GitHub
|
|
272
|
+
| `autoUpdate` | `true` | Check npm, npmmirror and GitHub together; fast-forward a clean checkout, otherwise replace an installed package from the first source that serves the tarball |
|
|
226
273
|
| `autoUpdateCheckHours` | `6` | Hours between auto-update checks (1–168) |
|
|
227
274
|
| `injectSessionHeader` | `true` | OpenCode GO session-header fix |
|
|
228
275
|
| `providerOrder` | all three ids | Provider display order in the widget; drag the cards in the plugin settings to reorder |
|
|
@@ -285,10 +332,12 @@ the settings page and the auto-generated form hot-apply.
|
|
|
285
332
|
Failed providers have `"status": "error"`, `code`, `error` and `windows: []`;
|
|
286
333
|
their siblings are still returned. A body with `providers: []` means no provider
|
|
287
334
|
is enabled. `meta.balanceError` carries a tolerated balance failure (the
|
|
288
|
-
provider still answers `ok`); `meta.
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
335
|
+
provider still answers `ok`); `meta.balanceSetup` (`no-credential`, `api-key` or
|
|
336
|
+
`expired`) marks a failure the user can fix by signing in again;
|
|
337
|
+
`meta.balanceMissing` marks a Command Code credits response without
|
|
338
|
+
purchased/free pools. `meta.errors` lists partial Command Code endpoint
|
|
339
|
+
failures: the numbers stay, the health dot turns amber, and the tooltip names
|
|
340
|
+
the failed calls. Error cards keep the plan they already resolved.
|
|
292
341
|
|
|
293
342
|
### `GET /provider-usage/settings`
|
|
294
343
|
|
|
@@ -374,7 +423,7 @@ network and no real keys. They cover the schema, all three adapters, OpenCode
|
|
|
374
423
|
GO service integration/fallback, aggregation and error isolation, caching and
|
|
375
424
|
forced refresh, settings conflicts/whitelisting, the client bundle contract,
|
|
376
425
|
top-up balance discovery (credential and local auth.json) and failure
|
|
377
|
-
isolation, and the session-header injection.
|
|
426
|
+
isolation, and the session-header injection. 98 tests, all offline.
|
|
378
427
|
|
|
379
428
|
## License
|
|
380
429
|
|
package/README.zh.md
CHANGED
|
@@ -30,18 +30,24 @@
|
|
|
30
30
|
购买额度 + 免费额度的 “Extra Credits” 余额;两者都显示在账号/套餐旁,并出现在
|
|
31
31
|
`/provider-usage` 文本中。OpenCode 控制台令牌依次从 `OPENCODE_CONSOLE_TOKEN`、
|
|
32
32
|
OpenCode CLI 的 auth.json 或本机 `opencode.db` 自动发现,令牌不会离开宿主进程。
|
|
33
|
-
|
|
33
|
+
没找到控制台令牌时,余额位置显示可点击的「配置余额」,点击直接打开本插件设置页;
|
|
34
|
+
设置页内为分步引导:可复制的登录命令、控制台网页链接与「重新检测」按钮。
|
|
34
35
|
Command Code 的悬停提示会拆成「购买 + 免费」;credits 响应没有这两个池时显示
|
|
35
36
|
灰色「无充值额度」。
|
|
36
|
-
- **自动更新** —
|
|
37
|
-
`link:`/`file:`
|
|
38
|
-
`git pull`;npm / 市场 / tarball
|
|
39
|
-
|
|
40
|
-
|
|
37
|
+
- **自动更新** — 宿主启动后以及按配置的间隔,同时向 npm registry、npmmirror 镜像与
|
|
38
|
+
GitHub Release 查询,以其中报告的最新版本为准。本地 `link:`/`file:` 目录检出在
|
|
39
|
+
「main 分支、工作区干净、可严格 fast-forward」时直接 `git pull`;npm / 市场 / tarball
|
|
40
|
+
安装的副本按「npm → npmmirror → GitHub」依次尝试下载 tarball,先写临时文件再
|
|
41
|
+
`rename` 覆盖 `lib/`、`package.json`、`cordis.patch.yml` 等打包文件——不会就地
|
|
42
|
+
改写 pnpm store 的硬链接。两种方式完成后,运行中的宿主仍持有旧模块,所以
|
|
41
43
|
组件/设置页显示“已更新到 vX,重启 DSH 生效”;插件不会自行重启 DSH。
|
|
42
44
|
- **余额变化动画** — 组件按正常轮询周期刷新;某个服务商的余额相比上一次轮询
|
|
43
45
|
发生变化时,数字会从旧值滚动到新值,同时一个红色/绿色差值(例如 `-0.1`)
|
|
44
46
|
向上飘走并淡出。没有弹窗,也不依赖对话轮次事件;动画最多可能晚一个轮询周期。
|
|
47
|
+
- **余额低额预警** — 余额 ≤ 5 时余额徽章转黄、≤ 1 转红(货币单位通用),侧边栏
|
|
48
|
+
和设置页的「实时用量」一致生效,不用点开卡片也能一眼看到余额告急。
|
|
49
|
+
- **失败可一键重试** — 读取失败时,组件里的错误文字本身可点击(也支持键盘
|
|
50
|
+
Enter/空格),立即强制重新读取。
|
|
45
51
|
- **同源代理** — 浏览器只访问 `/provider-usage/usage` 和
|
|
46
52
|
`/provider-usage/settings`,API Key 始终留在宿主进程。
|
|
47
53
|
- **故障隔离** — 一个服务商失败不会影响另外两个;只要还有服务商成功,路由就返回
|
|
@@ -50,8 +56,12 @@
|
|
|
50
56
|
- **设置快捷入口** — 组件标题栏的「设置」按钮直接打开侧边栏「插件」中的本插件
|
|
51
57
|
页面,并定位到下面的配置页。
|
|
52
58
|
- **插件设置页** — 侧边栏「插件」→ 本插件卡片:启用/禁用服务商、编辑凭据引用和
|
|
53
|
-
接口地址、选择 GLM
|
|
54
|
-
|
|
59
|
+
接口地址、选择 GLM 区域,并查看无网络请求的检测状态。保存后立即生效。每个服务商
|
|
60
|
+
卡片内嵌「实时用量」:显示各额度窗口的百分比、剩余重置时间与订阅外充值余额(与
|
|
61
|
+
侧边栏组件同一份数据);页面打开期间每 30 秒自动跟随刷新,并显示「更新于 HH:MM」
|
|
62
|
+
时间戳;「刷新用量」按钮强制读取;保存设置(例如换了 apiKeyEnv 凭据引用)后会以
|
|
63
|
+
`refresh=1` 立即重新读取,马上反映新凭据的用量与余额。
|
|
64
|
+
GLM 卡片底部还有一条**推广链接**(Z.ai「邀请好友得积分」),可点击打开或一键复制;
|
|
55
65
|
链接是写死的常量,不经过设置接口,也不会被保存的表单覆盖。
|
|
56
66
|
- **`/provider-usage` 聊天命令** — 在对话中打印所有启用服务商的用量文本。
|
|
57
67
|
- **时间条** — 额度条下方,灰色是窗口已流逝时间,蓝色是剩余时间,几何尺寸与参考
|
|
@@ -62,7 +72,13 @@
|
|
|
62
72
|
|
|
63
73
|
## 安装
|
|
64
74
|
|
|
65
|
-
|
|
75
|
+
从 npm 安装:
|
|
76
|
+
|
|
77
|
+
```bash
|
|
78
|
+
dsh plugin --profile desktop add dsh-multi-provider-usage
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
开发时也可以直接从本地 checkout 安装:
|
|
66
82
|
|
|
67
83
|
```bash
|
|
68
84
|
dsh plugin --profile desktop add /path/to/dsh-provider-usage
|
|
@@ -104,18 +120,38 @@ GO 额度接口不包含账户预付费余额。余额来自
|
|
|
104
120
|
OAuth 令牌(GO API Key 访问会返回 403)。宿主按以下顺序解析:
|
|
105
121
|
|
|
106
122
|
1. `opencodeConsoleApiKeyEnv`(默认 `OPENCODE_CONSOLE_TOKEN`),通过 DSH 凭据解析;
|
|
107
|
-
2. `~/.local/share/opencode/
|
|
108
|
-
|
|
109
|
-
3. `~/.local/share/opencode/
|
|
110
|
-
`
|
|
123
|
+
2. `~/.local/share/opencode/opencode.db` 的 `credential` 表(Node 支持
|
|
124
|
+
`node:sqlite` 时读取;当前 OpenCode 将 OAuth grant 存在这里并在运行时轮换);
|
|
125
|
+
3. `~/.local/share/opencode/auth.json`(设置了 `$XDG_DATA_HOME` 时用
|
|
126
|
+
`$XDG_DATA_HOME/opencode/auth.json`)中的 `opencode` provider——旧版本的位置。
|
|
127
|
+
|
|
128
|
+
数据库优先是刻意的:OpenCode 已把登录态迁到数据库,但经常把旧的 `auth.json`
|
|
129
|
+
留在磁盘上,里面的过期令牌会返回 401。宿主会依次尝试每个可用的 OAuth 登录(最多
|
|
130
|
+
四个),所以一份过期数据不会遮住仍然有效的登录。渠道版数据库(`opencode-beta.db`、
|
|
131
|
+
`opencode-dev.db` 等)和 `$OPENCODE_DB` 也会被发现。余额请求使用 grant 里的
|
|
132
|
+
`metadata.server`(自建控制台可用),缺省回落到公开域名;
|
|
133
|
+
`opencodeConsoleBaseUrl` 可覆盖。
|
|
134
|
+
|
|
135
|
+
OpenCode 的 **API Key** 登录(`oc_sk_…`,service-account 方式)不含控制台会话,
|
|
136
|
+
不会被发到只接受控制台令牌的 billing 接口。API Key 登录、缺少令牌或登录已过期/
|
|
137
|
+
被吊销时,余额位置显示可点击的「配置余额」而不是灰色横杠;点击打开本插件在
|
|
138
|
+
Plugins 面板中的设置页,悬停提示修复方式:运行 `opencode auth login` 并选择
|
|
139
|
+
**OpenCode Console account**,或把控制台令牌保存为 `OPENCODE_CONSOLE_TOKEN`。
|
|
140
|
+
|
|
141
|
+
余额未配置或控制台登录可修复时,插件设置页的 OpenCode GO 卡片不再给一句静态
|
|
142
|
+
提示,而是渲染分步**引导**:引言按当前状态指明修复方式(未配置 / API Key 登录 /
|
|
143
|
+
登录已过期)。第①步给出 `opencode auth login` 命令与「复制命令」按钮;第②步是
|
|
144
|
+
手动路径——从控制台网页复制令牌、保存为 `OPENCODE_CONSOLE_TOKEN` 凭据——带
|
|
145
|
+
「复制凭据名」按钮和打开 OpenCode 控制台网页的链接(由 `opencodeConsoleBaseUrl`
|
|
146
|
+
推导,以 `/console` 结尾);第③步提供「重新检测」按钮,重读登录状态并强制刷新
|
|
147
|
+
一次用量,新登录无需重启 DSH 即可显示余额(重新检测不会丢弃表单里未保存的修改)。
|
|
148
|
+
`/provider-usage` 命令对三种可修复状态都会给出同样的文字引导。
|
|
111
149
|
|
|
112
150
|
可用 `opencodeConsoleAuthFile` 覆盖 auth.json 路径(其目录也用于定位
|
|
113
151
|
`opencode.db`),`opencodeConsoleOrgId` 提供 `x-org-id`,
|
|
114
152
|
`opencodeConsoleEnabled: false` 可关闭余额读取。余额失败不会影响 GO 额度:
|
|
115
|
-
provider 仍为 `ok`,`balance` 为 `null` 并带 `meta.balanceError
|
|
116
|
-
|
|
117
|
-
Plugins 面板中的设置页,悬停提示修复方式(`opencode auth login` 或
|
|
118
|
-
`OPENCODE_CONSOLE_TOKEN`)。其它余额失败显示灰色「余额 —」,悬停给出原因。
|
|
153
|
+
provider 仍为 `ok`,`balance` 为 `null` 并带 `meta.balanceError`(可由用户修复时
|
|
154
|
+
另带 `meta.balanceSetup`)。其它余额失败显示灰色「余额 —」,悬停给出原因。
|
|
119
155
|
|
|
120
156
|
### Command Code 余额
|
|
121
157
|
|
|
@@ -190,7 +226,7 @@ Loader 直接读取导出的 `Config` schema;所有字段都是 `.volatile()`
|
|
|
190
226
|
| `glmRegion` | `"auto"` | `auto`、`zai`、`bigmodel`、`custom` |
|
|
191
227
|
| `cacheMs` | `30000` | 单个服务商的宿主缓存 TTL |
|
|
192
228
|
| `updateCheck` | `true` | npm 新版本检查(24 小时) |
|
|
193
|
-
| `autoUpdate` | `true` |
|
|
229
|
+
| `autoUpdate` | `true` | 同时查询 npm / npmmirror / GitHub Release;干净 checkout 快进,安装包按可达的源依次下载 tarball 原地替换 |
|
|
194
230
|
| `autoUpdateCheckHours` | `6` | 自动更新检查间隔(1–168 小时) |
|
|
195
231
|
| `injectSessionHeader` | `true` | OpenCode GO 会话头修复 |
|
|
196
232
|
| `providerOrder` | 三个 id 全集 | 组件中的显示顺序;在插件设置里拖动卡片调整 |
|
|
@@ -252,10 +288,11 @@ Loader 直接读取导出的 `Config` schema;所有字段都是 `.volatile()`
|
|
|
252
288
|
|
|
253
289
|
失败的服务商为 `"status": "error"`,带 `code`、`error` 和空 `windows`,其余服务商
|
|
254
290
|
照常返回。`providers: []` 表示没有启用任何服务商。`meta.balanceError` 表示被容忍
|
|
255
|
-
的余额失败(provider 仍为 `ok`);`meta.
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
291
|
+
的余额失败(provider 仍为 `ok`);`meta.balanceSetup`(`no-credential`、`api-key`
|
|
292
|
+
或 `expired`)表示用户重新登录即可修复的余额状态;`meta.balanceMissing` 表示
|
|
293
|
+
Command Code 的 credits 响应没有购买/免费额度。`meta.errors` 列出 Command Code
|
|
294
|
+
的部分接口失败:数字照常显示,但健康点变黄,悬停可看到失败的接口。错误卡片仍
|
|
295
|
+
保留已经解析出的套餐信息。
|
|
259
296
|
|
|
260
297
|
### `GET /provider-usage/settings`
|
|
261
298
|
|
|
@@ -334,7 +371,7 @@ npm test
|
|
|
334
371
|
测试是封闭的:伪造 `globalThis.fetch` + stub Cordis 上下文,不访问网络、不使用真实
|
|
335
372
|
密钥。覆盖 schema、三个适配器、OpenCode GO 服务集成/回退、聚合与故障隔离、缓存与
|
|
336
373
|
强制刷新、设置冲突/白名单、客户端 bundle 契约、充值余额发现(凭据 / 本地
|
|
337
|
-
auth.json)与失败隔离、会话头注入、余额变化动画;共
|
|
374
|
+
auth.json)与失败隔离、会话头注入、余额变化动画;共 98 个测试。
|
|
338
375
|
|
|
339
376
|
## 许可证
|
|
340
377
|
|
package/lib/auto-update.js
CHANGED
|
@@ -6,10 +6,12 @@
|
|
|
6
6
|
*
|
|
7
7
|
* - `checkout` (`link:` / `file:` directory): fast-forward the git checkout,
|
|
8
8
|
* but only when it is on `main`, clean, and can fast-forward `origin/main`.
|
|
9
|
-
* - `package` (npm / market / tarball install):
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
* the
|
|
9
|
+
* - `package` (npm / market / tarball install): pick the newest release
|
|
10
|
+
* known to npm, the npmmirror mirror and GitHub together, then download
|
|
11
|
+
* the release tarball from the first source that serves it and replace
|
|
12
|
+
* the packaged files in place. Every file is written to a temp file
|
|
13
|
+
* inside the package directory first and then renamed over the target,
|
|
14
|
+
* which keeps pnpm's hardlinked store untouched.
|
|
13
15
|
*
|
|
14
16
|
* Neither strategy restarts the Host: the running module graph is stale once
|
|
15
17
|
* the files land, so the UI reports `restartRequired`. The updater never
|
|
@@ -28,6 +30,10 @@ import { gunzipSync } from "node:zlib";
|
|
|
28
30
|
|
|
29
31
|
export const AUTO_UPDATE_REPO = "wjw1-Evan/dsh-provider-usage";
|
|
30
32
|
export const AUTO_UPDATE_RELEASE_URL = `https://api.github.com/repos/${AUTO_UPDATE_REPO}/releases/latest`;
|
|
33
|
+
/** npm registry — the canonical published package (also the `updateCheck` source). */
|
|
34
|
+
export const NPM_REGISTRY_URL = "https://registry.npmjs.org";
|
|
35
|
+
/** npmmirror mirror of the registry; reachable where npm/GitHub are not. */
|
|
36
|
+
export const NPM_MIRROR_URL = "https://registry.npmmirror.com";
|
|
31
37
|
/** Delay before the first background check, so activation is never blocked. */
|
|
32
38
|
const FIRST_CHECK_DELAY_MS = 15_000;
|
|
33
39
|
/** Hourly tick; the configured check interval gates actual network calls. */
|
|
@@ -390,6 +396,124 @@ async function fetchLatestRelease(fetchImpl) {
|
|
|
390
396
|
};
|
|
391
397
|
}
|
|
392
398
|
|
|
399
|
+
// ── package release sources ─────────────────────────────────────────────
|
|
400
|
+
// An installed package updates by downloading a release tarball. GitHub is
|
|
401
|
+
// the canonical source, but the registry serves the identical packaged
|
|
402
|
+
// surface and is reachable where GitHub is not (and vice versa), so the
|
|
403
|
+
// checker asks npm, the npmmirror mirror and GitHub together and trusts the
|
|
404
|
+
// newest version any of them reports.
|
|
405
|
+
|
|
406
|
+
/** Build the registry tarball URL for a version. */
|
|
407
|
+
function registryTarballUrl(baseUrl, packageName, version) {
|
|
408
|
+
return `${baseUrl}/${packageName}/-/${packageName}-${version}.tgz`;
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
/** Fetch the latest published version from one npm registry endpoint. */
|
|
412
|
+
async function fetchLatestFromRegistry(baseUrl, packageName, fetchImpl) {
|
|
413
|
+
const res = await fetchImpl(`${baseUrl}/${packageName}/latest`, {
|
|
414
|
+
headers: {
|
|
415
|
+
Accept: "application/vnd.npm.install-v1+json",
|
|
416
|
+
"User-Agent": "dsh-multi-provider-usage"
|
|
417
|
+
},
|
|
418
|
+
signal: AbortSignal.timeout(10_000)
|
|
419
|
+
});
|
|
420
|
+
if (!res.ok) throw new Error(`npm registry check ${res.status}`);
|
|
421
|
+
const doc = await res.json();
|
|
422
|
+
const version = typeof doc?.version === "string" ? doc.version : "";
|
|
423
|
+
if (parseVersion(version) === null) throw new Error("npm registry has no usable version");
|
|
424
|
+
const dist = doc?.dist?.tarball;
|
|
425
|
+
return {
|
|
426
|
+
version,
|
|
427
|
+
tarballUrl: typeof dist === "string" && dist.length > 0 ? dist : registryTarballUrl(baseUrl, packageName, version)
|
|
428
|
+
};
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
/**
|
|
432
|
+
* Pick the newest release known to any package source: npm registry, the
|
|
433
|
+
* npmmirror mirror and the GitHub release. One source lagging or failing is
|
|
434
|
+
* tolerated while any other answers; only when every source fails does the
|
|
435
|
+
* check itself fail. The winning source's tarball URL is the first download
|
|
436
|
+
* candidate.
|
|
437
|
+
*/
|
|
438
|
+
async function resolvePackageRelease({ packageName, fetchImpl }) {
|
|
439
|
+
const candidates = [
|
|
440
|
+
{ label: "npm", load: () => fetchLatestFromRegistry(NPM_REGISTRY_URL, packageName, fetchImpl) },
|
|
441
|
+
{ label: "npmmirror", load: () => fetchLatestFromRegistry(NPM_MIRROR_URL, packageName, fetchImpl) },
|
|
442
|
+
{ label: "GitHub", load: () => fetchLatestRelease(fetchImpl) }
|
|
443
|
+
];
|
|
444
|
+
const results = await Promise.allSettled(candidates.map((candidate) => candidate.load()));
|
|
445
|
+
/** @type {{ version: string, tarballUrl: string }[]} */
|
|
446
|
+
const successes = [];
|
|
447
|
+
/** @type {{ label: string, error: unknown }[]} */
|
|
448
|
+
const failures = [];
|
|
449
|
+
results.forEach((result, index) => {
|
|
450
|
+
if (result.status === "fulfilled") successes.push(result.value);
|
|
451
|
+
else failures.push({ label: candidates[index].label, error: result.reason });
|
|
452
|
+
});
|
|
453
|
+
if (successes.length === 0) {
|
|
454
|
+
throw new Error(`release check failed from all sources — ${describeFailures(failures)}`);
|
|
455
|
+
}
|
|
456
|
+
let best = successes[0];
|
|
457
|
+
for (const candidate of successes.slice(1)) {
|
|
458
|
+
if (isNewerVersion(candidate.version, best.version)) best = candidate;
|
|
459
|
+
}
|
|
460
|
+
return {
|
|
461
|
+
version: best.version,
|
|
462
|
+
// Each answering source's tarball URL, newest-version source first: a
|
|
463
|
+
// source that answered the check is also the likeliest download.
|
|
464
|
+
tarballUrls: [
|
|
465
|
+
best.tarballUrl,
|
|
466
|
+
...successes.filter((candidate) => candidate !== best).map((candidate) => candidate.tarballUrl)
|
|
467
|
+
]
|
|
468
|
+
};
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
/** Tarball URLs to try for a resolved release: answering sources first, then
|
|
472
|
+
* the canonical URLs for each source in case the check was partial. */
|
|
473
|
+
function downloadCandidates(release, packageName) {
|
|
474
|
+
const urls = [];
|
|
475
|
+
const add = (url) => {
|
|
476
|
+
if (typeof url === "string" && url.length > 0 && !urls.includes(url)) urls.push(url);
|
|
477
|
+
};
|
|
478
|
+
for (const url of release.tarballUrls ?? []) add(url);
|
|
479
|
+
add(registryTarballUrl(NPM_REGISTRY_URL, packageName, release.version));
|
|
480
|
+
add(registryTarballUrl(NPM_MIRROR_URL, packageName, release.version));
|
|
481
|
+
add(`https://github.com/${AUTO_UPDATE_REPO}/archive/refs/tags/v${release.version}.tar.gz`);
|
|
482
|
+
return urls;
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
/** Short source label for a tarball URL, used in failure messages. */
|
|
486
|
+
function sourceLabelOf(url) {
|
|
487
|
+
const text = String(url);
|
|
488
|
+
if (text.includes("registry.npmjs.org")) return "npm";
|
|
489
|
+
if (text.includes("registry.npmmirror.com")) return "npmmirror";
|
|
490
|
+
if (text.includes("github.com") || text.includes("githubusercontent.com") || text.includes("codeload")) return "GitHub";
|
|
491
|
+
return "release";
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
/** True when the failure is local, so another download source cannot help. */
|
|
495
|
+
function isLocalWriteError(error) {
|
|
496
|
+
const code = typeof error?.code === "string" ? error.code : "";
|
|
497
|
+
if (/^(EACCES|EPERM|EROFS|EXDEV|EBUSY|ENOENT|ENOTDIR|EISDIR)$/.test(code)) return true;
|
|
498
|
+
return /is a symlink|permission denied|not writable/i.test(String(error?.message ?? error));
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
/** Attach an actionable hint to a filesystem-permission failure. */
|
|
502
|
+
function withWriteHint(error) {
|
|
503
|
+
const message = String(error?.message ?? error);
|
|
504
|
+
if (!/^(EACCES|EPERM|EROFS)$/.test(typeof error?.code === "string" ? error.code : "")) return message;
|
|
505
|
+
return `${message} — the package directory is not writable; fix permissions or reinstall the package`;
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
/** Compact, bounded "source: error" summary for combined failure messages.
|
|
509
|
+
* Per-error bounds stay loose enough that an appended hint survives. */
|
|
510
|
+
function describeFailures(failures) {
|
|
511
|
+
return failures
|
|
512
|
+
.map(({ label, error }) => `${label}: ${String(error?.message ?? error).slice(0, 240)}`)
|
|
513
|
+
.join("; ")
|
|
514
|
+
.slice(0, 800);
|
|
515
|
+
}
|
|
516
|
+
|
|
393
517
|
/**
|
|
394
518
|
* Start the background checker.
|
|
395
519
|
*
|
|
@@ -462,16 +586,33 @@ export function startAutoUpdate(options) {
|
|
|
462
586
|
lastCheckAt = now();
|
|
463
587
|
patch({ status: "checking", message: null });
|
|
464
588
|
inFlight = (async () => {
|
|
589
|
+
// A failed release check is an "error" (nothing new is known); a failed
|
|
590
|
+
// apply is "available" (the release is known, the download/write is
|
|
591
|
+
// not). The UI toasts only the latter.
|
|
592
|
+
let phase = "check";
|
|
465
593
|
try {
|
|
466
|
-
const release = await fetchLatestRelease(fetchImpl);
|
|
467
594
|
const install = detect({ packageDir, profileDir, packageName });
|
|
595
|
+
const release = install.kind === "checkout"
|
|
596
|
+
? await fetchLatestRelease(fetchImpl)
|
|
597
|
+
: await resolvePackageRelease({ packageName, fetchImpl });
|
|
468
598
|
patch({
|
|
469
599
|
latestVersion: release.version,
|
|
470
600
|
checkedAt: now(),
|
|
471
601
|
installKind: install.kind,
|
|
472
|
-
releaseUrl: release.url
|
|
602
|
+
releaseUrl: release.url ?? null
|
|
473
603
|
});
|
|
474
|
-
|
|
604
|
+
// `currentVersion` never changes in a running host, so a release
|
|
605
|
+
// this session already applied would be re-downloaded every cycle
|
|
606
|
+
// until restart. Compare against what this session applied.
|
|
607
|
+
const appliedVersion = typeof state.version === "string" && parseVersion(state.version) !== null
|
|
608
|
+
? state.version
|
|
609
|
+
: currentVersion;
|
|
610
|
+
if (!isNewerVersion(release.version, appliedVersion)) {
|
|
611
|
+
if (appliedVersion !== currentVersion) {
|
|
612
|
+
// Already self-updated to this release; restore the earlier
|
|
613
|
+
// "restart required" notice instead of re-applying.
|
|
614
|
+
return patch({ status: "updated" });
|
|
615
|
+
}
|
|
475
616
|
return patch({ status: "up-to-date", message: null, restartRequired: false });
|
|
476
617
|
}
|
|
477
618
|
if (install.kind === "checkout" && typeof install.checkout === "string") {
|
|
@@ -499,29 +640,50 @@ export function startAutoUpdate(options) {
|
|
|
499
640
|
message: "checkout updated; restart DSH to apply"
|
|
500
641
|
});
|
|
501
642
|
}
|
|
643
|
+
phase = "apply";
|
|
502
644
|
// Installed package (npm / market / tarball): replace the packaged
|
|
503
|
-
// files in place
|
|
504
|
-
// it is restarted, which the
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
645
|
+
// files in place, trying each download source in turn. The running
|
|
646
|
+
// Host keeps the old module graph until it is restarted, which the
|
|
647
|
+
// UI reports.
|
|
648
|
+
/** @type {{ label: string, error: unknown }[]} */
|
|
649
|
+
const failures = [];
|
|
650
|
+
const candidates = downloadCandidates(release, packageName);
|
|
651
|
+
for (const url of candidates) {
|
|
652
|
+
const label = sourceLabelOf(url);
|
|
653
|
+
try {
|
|
654
|
+
const result = await applyPackageUpdate({
|
|
655
|
+
packageDir,
|
|
656
|
+
tarballUrl: url,
|
|
657
|
+
fetchImpl,
|
|
658
|
+
packageName,
|
|
659
|
+
expectVersion: release.version,
|
|
660
|
+
logger
|
|
661
|
+
});
|
|
662
|
+
logger.info?.(`dsh-multi-provider-usage: self-updated package to ${result.version} from ${label}; restart DSH to apply`);
|
|
663
|
+
return patch({
|
|
664
|
+
status: "updated",
|
|
665
|
+
version: result.version,
|
|
666
|
+
updatedAt: now(),
|
|
667
|
+
restartRequired: true,
|
|
668
|
+
message: `package files replaced from ${label}; restart DSH to apply`
|
|
669
|
+
});
|
|
670
|
+
} catch (error) {
|
|
671
|
+
if (isLocalWriteError(error)) {
|
|
672
|
+
// No other download source can fix a local filesystem problem.
|
|
673
|
+
failures.push({ label, error: withWriteHint(error) });
|
|
674
|
+
break;
|
|
675
|
+
}
|
|
676
|
+
failures.push({ label, error });
|
|
677
|
+
}
|
|
678
|
+
}
|
|
679
|
+
const prefix = failures.length === candidates.length
|
|
680
|
+
? "update download failed from all sources"
|
|
681
|
+
: "update failed";
|
|
682
|
+
throw new Error(`${prefix} — ${describeFailures(failures)}`);
|
|
521
683
|
} catch (error) {
|
|
522
684
|
const message = error instanceof Error ? error.message : String(error);
|
|
523
685
|
logger.warn?.(`dsh-multi-provider-usage: auto-update failed: ${message}`);
|
|
524
|
-
return patch({ status: "available", message, restartRequired: false });
|
|
686
|
+
return patch({ status: phase === "check" ? "error" : "available", message, restartRequired: false });
|
|
525
687
|
} finally {
|
|
526
688
|
inFlight = null;
|
|
527
689
|
}
|