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 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 instead of silently showing nothing. Command
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
- GitHub release feed. A local `link:`/`file:` checkout is fast-forwarded when it
44
- is on `main`, clean, and a strict fast-forward. An npm / market / tarball copy
45
- updates itself: the release tarball is unpacked and each packaged file
46
- (`lib/`, `package.json`, `cordis.patch.yml`, `docs/`) is written to a temp file
47
- and renamed over the target, so pnpm's hardlinked store is never edited in
48
- place. Either way the running Host keeps the old module graph, so the
49
- widget/settings show "updated — restart DSH". The plugin never restarts DSH by
50
- itself.
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 a checkout:
95
+ From npm:
82
96
 
83
97
  ```bash
84
- dsh plugin --profile desktop add /path/to/dsh-provider-usage
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 /path/to/dsh-provider-usage
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/auth.json` (`$XDG_DATA_HOME/opencode/auth.json` when
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 for `opencode.db`), `opencodeConsoleOrgId` supplies the
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`. A missing console credential is
141
- actionable rather than hidden: the balance slot renders a clickable **Set up**
142
- chip that opens this bundle's page in the Plugins panel, whose tooltip names the
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 releases; fast-forward a clean checkout, otherwise replace an installed package |
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.balanceMissing` marks a Command Code credits
289
- response without purchased/free pools. `meta.errors` lists partial Command Code
290
- endpoint failures: the numbers stay, the health dot turns amber, and the tooltip
291
- names the failed calls. Error cards keep the plan they already resolved.
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. 75 tests, all offline.
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
- - **自动更新** — 宿主启动后以及按配置的间隔检查 GitHub Release。本地
37
- `link:`/`file:` 目录检出在「main 分支、工作区干净、可严格 fast-forward」时直接
38
- `git pull`;npm / 市场 / tarball 安装的副本则下载该 Release 的 tarball,先写临时
39
- 文件再 `rename` 覆盖 `lib/`、`package.json`、`cordis.patch.yml` 等打包文件——不会
40
- 就地改写 pnpm store 的硬链接。两种方式完成后,运行中的宿主仍持有旧模块,所以
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 区域,并查看无网络请求的检测状态。保存后立即生效。GLM
54
- 卡片底部还有一条**推广链接**(Z.ai「邀请好友得积分」),可点击打开或一键复制;
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
- 从本地 checkout 安装:
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/auth.json`(设置了 `$XDG_DATA_HOME` 时用
108
- `$XDG_DATA_HOME/opencode/auth.json`)中的 `opencode` provider;
109
- 3. `~/.local/share/opencode/opencode.db` 的 `credential` 表(Node 支持
110
- `node:sqlite` 时读取;当前 OpenCode 将 OAuth grant 存在这里)。
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` | 检查 GitHub Release;干净 checkout 快进,安装包用 Release tarball 原地替换 |
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.balanceMissing` 表示 Command Code 的
256
- credits 响应没有购买/免费额度。`meta.errors` 列出 Command Code 的部分接口失败:
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)与失败隔离、会话头注入、余额变化动画;共 75 个测试。
374
+ auth.json)与失败隔离、会话头注入、余额变化动画;共 98 个测试。
338
375
 
339
376
  ## 许可证
340
377
 
@@ -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): download the GitHub release
10
- * tarball and replace the packaged files in place. Every file is written
11
- * to a temp file inside the package directory first and then renamed over
12
- * the target, which keeps pnpm's hardlinked store untouched.
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
- if (!isNewerVersion(release.version, currentVersion)) {
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. The running Host keeps the old module graph until
504
- // it is restarted, which the UI reports.
505
- const result = await applyPackageUpdate({
506
- packageDir,
507
- tarballUrl: release.tarballUrl,
508
- fetchImpl,
509
- packageName,
510
- expectVersion: release.version,
511
- logger
512
- });
513
- logger.info?.(`dsh-multi-provider-usage: self-updated package to ${result.version}; restart DSH to apply`);
514
- return patch({
515
- status: "updated",
516
- version: result.version,
517
- updatedAt: now(),
518
- restartRequired: true,
519
- message: "package files replaced; restart DSH to apply"
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
  }