@ychris12138/dsh-usage-stats 0.2.8 → 0.2.10
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 +25 -6
- package/lib/accounts.js +13 -2
- package/lib/client.js +5 -2
- package/lib/subscriptions.js +59 -1
- package/package.json +1 -1
- package/scripts/install.mjs +38 -9
package/README.md
CHANGED
|
@@ -53,8 +53,8 @@ dsh plugin --profile web add "github:Ychris12138/dsh-usage-stats"
|
|
|
53
53
|
升级或卸载:
|
|
54
54
|
|
|
55
55
|
```bash
|
|
56
|
-
dsh plugin --profile web update dsh-usage-stats
|
|
57
|
-
dsh plugin --profile web remove dsh-usage-stats
|
|
56
|
+
dsh plugin --profile web update "@ychris12138/dsh-usage-stats"
|
|
57
|
+
dsh plugin --profile web remove "@ychris12138/dsh-usage-stats"
|
|
58
58
|
```
|
|
59
59
|
|
|
60
60
|
<details>
|
|
@@ -66,7 +66,7 @@ PowerShell、命令提示符和 macOS/Linux 终端使用同一条命令:
|
|
|
66
66
|
npx --yes github:Ychris12138/dsh-usage-stats
|
|
67
67
|
```
|
|
68
68
|
|
|
69
|
-
安装器会把运行文件复制到 `~/.dsh/profiles/node_modules/dsh-usage-stats`,并在 `profiles/web/cordis.patch.yml`
|
|
69
|
+
安装器会把运行文件复制到 `~/.dsh/profiles/node_modules/@ychris12138/dsh-usage-stats`,并在 `profiles/web/cordis.patch.yml` 中以带引号的 scoped identity 幂等启用插件。重复运行即可更新,不会重复追加配置;旧版 `name: dsh-usage-stats` 和未加引号的 `name: @ychris12138/dsh-usage-stats` 会自动迁移。设置了 `DSH_HOME` 时使用该目录。
|
|
70
70
|
|
|
71
71
|
`dsh plugin` 与 `npx` 是两条独立安装路径,请选择其中一种;不要同时保留手工 Cordis entry 和 bundle 注册,否则会重复挂载。
|
|
72
72
|
|
|
@@ -98,6 +98,7 @@ npx --yes github:Ychris12138/dsh-usage-stats --no-enable
|
|
|
98
98
|
| Z.ai / 智谱 | 订阅 | `ZAI_API_KEY` | Coding Plan quota/subscription |
|
|
99
99
|
| Kimi For Coding | 订阅 | `KIMI_API_KEY` | `/coding/v1/usages` |
|
|
100
100
|
| MiniMax Coding Plan | 订阅 | `MINIMAX_API_KEY` | `/v1/token_plan/remains` |
|
|
101
|
+
| Ollama 云 | 订阅 | `OLLAMA_API_KEY` | `/api/usage`(5小时 + 周窗口) |
|
|
101
102
|
| New API | 余额 | provider 推理 Token | `/api/usage/token/` |
|
|
102
103
|
| Sub2API / Passion | 自动判别 | provider `apiKeyEnv` | `/v1/usage` |
|
|
103
104
|
| Sub2API 面板(真实) | 余额 | provider 推理 Token | `/user/balance`(复用 apiKey) |
|
|
@@ -139,15 +140,33 @@ KIMI_API_KEY: your-kimi-key
|
|
|
139
140
|
MINIMAX_API_KEY: your-minimax-key
|
|
140
141
|
# 中国区 MiniMax 用户可选;默认 global
|
|
141
142
|
MINIMAX_API_REGION: cn
|
|
143
|
+
OLLAMA_API_KEY: sk-ollama-your-key
|
|
142
144
|
```
|
|
143
145
|
|
|
144
146
|
OpenCode Go 依次尝试 Harness credential、`~/.local/share/opencode/auth.json`,最后才使用显式 `OPENCODE_GO_AUTH_COOKIE + OPENCODE_GO_WORKSPACE_ID` 兼容回退。Bearer usage endpoint 目前不是公开 API,可能随上游变化;Cookie 等同登录凭据,不应进入日志或 issue。
|
|
145
147
|
|
|
148
|
+
Ollama 云读取 `OLLAMA_API_KEY`,调用 `/api/usage` 展示两个订阅窗口(5 小时会话 + 每周),无余额;`usage` 按 0..1 比例换算为进度条。
|
|
149
|
+
|
|
150
|
+
Ollama 适配器只对**已配置的 provider** 生效,不会自动添加账户:当 provider 的 id 为 `ollama`,或其 baseURL 主机为 `ollama.com`(含子域)时自动选用;本地 Ollama(`localhost:11434`)不会被当作云配额账户。特殊代理/自定义端点可用显式 monitor 绑定:
|
|
151
|
+
|
|
152
|
+
```yaml
|
|
153
|
+
# ~/.dsh/profiles/web/cordis.patch.yml
|
|
154
|
+
- insert:
|
|
155
|
+
- id: usage-stats
|
|
156
|
+
name: dsh-usage-stats
|
|
157
|
+
config:
|
|
158
|
+
monitors:
|
|
159
|
+
relay-ollama: # 你配置的 provider id
|
|
160
|
+
adapter: ollama
|
|
161
|
+
usageBaseURL: https://ollama.example.com
|
|
162
|
+
credentialRef: OLLAMA_API_KEY # 非已配置 provider 时必填
|
|
163
|
+
```
|
|
164
|
+
|
|
146
165
|
Z.ai 全球区使用 `api.z.ai`,中国区使用 `open.bigmodel.cn`。MiniMax 优先使用官方 `www.minimax.io` / `www.minimaxi.com` Token Plan 地址,并解析 5 小时与周窗口的剩余比例和重置时间。
|
|
147
166
|
|
|
148
167
|
### New API、Sub2API 与自定义 monitor
|
|
149
168
|
|
|
150
|
-
在现有 `name: dsh-usage-stats` Cordis entry 下合并 `config`,不要追加第二个插件 entry。monitor 键必须是 Harness 中真实存在的 provider id;未知 provider、adapter 或非法映射会在路由和 timer 注册前阻止插件启动。例外:monitor 同时显式提供 `usageBaseURL` 与 `credentialRef` 时视为自包含,会在 provider 注册可见前临时物化为 provider(适用于 Harness 设置页里后加载的 provider),此时不要求该 provider 已出现在注册表中。
|
|
169
|
+
在现有 `name: "@ychris12138/dsh-usage-stats"` Cordis entry 下合并 `config`,不要追加第二个插件 entry。monitor 键必须是 Harness 中真实存在的 provider id;未知 provider、adapter 或非法映射会在路由和 timer 注册前阻止插件启动。例外:monitor 同时显式提供 `usageBaseURL` 与 `credentialRef` 时视为自包含,会在 provider 注册可见前临时物化为 provider(适用于 Harness 设置页里后加载的 provider),此时不要求该 provider 已出现在注册表中。
|
|
151
170
|
|
|
152
171
|
<details>
|
|
153
172
|
<summary><strong>展开 monitor 配置示例</strong></summary>
|
|
@@ -158,7 +177,7 @@ New API 默认用 provider 推理 Token 查询 `/api/usage/token/`,并从 `/ap
|
|
|
158
177
|
# ~/.dsh/profiles/web/cordis.patch.yml
|
|
159
178
|
- insert:
|
|
160
179
|
- id: usage-stats
|
|
161
|
-
name: dsh-usage-stats
|
|
180
|
+
name: "@ychris12138/dsh-usage-stats"
|
|
162
181
|
config:
|
|
163
182
|
monitors:
|
|
164
183
|
relay-a: # Harness provider id
|
|
@@ -264,7 +283,7 @@ Constraints:
|
|
|
264
283
|
|
|
265
284
|
Procedure:
|
|
266
285
|
1. Confirm node, npx, and dsh are available.
|
|
267
|
-
2. Prefer `dsh plugin --profile web update dsh-usage-stats` when already installed; otherwise use `dsh plugin --profile web add "github:Ychris12138/dsh-usage-stats"`.
|
|
286
|
+
2. Prefer `dsh plugin --profile web update "@ychris12138/dsh-usage-stats"` when already installed; otherwise use `dsh plugin --profile web add "github:Ychris12138/dsh-usage-stats"`.
|
|
268
287
|
3. If unavailable, use: npx --yes github:Ychris12138/dsh-usage-stats
|
|
269
288
|
4. Do not combine bundle installation with an existing manual dsh-usage-stats Cordis entry.
|
|
270
289
|
5. For npx, require a verified package and exactly one Cordis entry, then run again with --check.
|
package/lib/accounts.js
CHANGED
|
@@ -52,6 +52,7 @@ const ADAPTERS = new Set([
|
|
|
52
52
|
"zai-token-plan",
|
|
53
53
|
"kimi-token-plan",
|
|
54
54
|
"minimax-token-plan",
|
|
55
|
+
"ollama",
|
|
55
56
|
"declarative"
|
|
56
57
|
]);
|
|
57
58
|
const SENSITIVE_HEADERS = new Set([
|
|
@@ -189,6 +190,15 @@ function defaultAdapter(provider) {
|
|
|
189
190
|
try {
|
|
190
191
|
const hostname = new URL(provider.baseURL).hostname.toLowerCase();
|
|
191
192
|
if (hostname === "passionapi.com" || hostname.endsWith(".passionapi.com")) return "sub2api";
|
|
193
|
+
// Ollama Cloud is detected by its canonical host so a user-configured
|
|
194
|
+
// provider with a custom id still gets the quota adapter; local Ollama
|
|
195
|
+
// (localhost:11434) never matches and stays without an account adapter.
|
|
196
|
+
// The id check is gated on the hostname so a local install that happens
|
|
197
|
+
// to use the canonical "ollama" id is not misread as a cloud account.
|
|
198
|
+
// Subdomains (e.g. api.ollama.com) are recognized for identification
|
|
199
|
+
// only: the usage query always targets the canonical ollama.com host,
|
|
200
|
+
// which is where the /api/usage endpoint is served.
|
|
201
|
+
if ((providerId === "ollama" || hostname === "ollama.com" || hostname.endsWith(".ollama.com")) && !privateHostname(hostname)) return "ollama";
|
|
192
202
|
} catch {
|
|
193
203
|
// A malformed provider URL is handled by the adapter when it is queried.
|
|
194
204
|
}
|
|
@@ -198,7 +208,7 @@ function defaultAdapter(provider) {
|
|
|
198
208
|
|
|
199
209
|
function adapterMode(adapter, monitor) {
|
|
200
210
|
if (adapter === "declarative") return monitor.mode;
|
|
201
|
-
if (["opencode-go", "zai-token-plan", "kimi-token-plan", "minimax-token-plan"].includes(adapter)) return "subscription";
|
|
211
|
+
if (["opencode-go", "zai-token-plan", "kimi-token-plan", "minimax-token-plan", "ollama"].includes(adapter)) return "subscription";
|
|
202
212
|
return "balance";
|
|
203
213
|
}
|
|
204
214
|
|
|
@@ -1118,7 +1128,8 @@ export async function queryAccount(spec, credentials, deps = {}) {
|
|
|
1118
1128
|
const subscriptionId = spec.adapter === "zai-token-plan" ? "zai"
|
|
1119
1129
|
: spec.adapter === "kimi-token-plan" ? "kimi"
|
|
1120
1130
|
: spec.adapter === "minimax-token-plan" ? "minimax"
|
|
1121
|
-
: "
|
|
1131
|
+
: spec.adapter === "ollama" ? "ollama"
|
|
1132
|
+
: "opencode-go";
|
|
1122
1133
|
const provider = await collectSubscription(subscriptionId, credentials, {
|
|
1123
1134
|
apiKeyRef: spec.apiKeyRef,
|
|
1124
1135
|
region: spec.monitor.region
|
package/lib/client.js
CHANGED
|
@@ -62,6 +62,7 @@ window.__ModuleLoader__.load({
|
|
|
62
62
|
".usg_accountCard{--usg-providerAccent:#1f6feb;box-sizing:border-box;border:1px solid var(--dsw-alias-border-l2);background:linear-gradient(135deg,color-mix(in srgb,var(--usg-providerAccent) 8%,transparent),transparent 42%);border-radius:12px;padding:10px 11px;display:flex;flex-direction:column;gap:9px}",
|
|
63
63
|
".usg_accountCard[data-provider=deepseek],.usg_accountCard[data-provider=deepseek-official]{--usg-providerAccent:#1f6feb}",
|
|
64
64
|
".usg_accountCard[data-provider=opencode-go]{--usg-providerAccent:#00a67d}",
|
|
65
|
+
".usg_accountCard[data-provider=ollama],.usg_accountCard[data-adapter=ollama]{--usg-providerAccent:#d97706}",
|
|
65
66
|
".usg_accountCard[data-provider=zai],.usg_accountCard[data-provider=zai-coding-cn]{--usg-providerAccent:#7656e8}",
|
|
66
67
|
".usg_accountCard[data-provider=openrouter]{--usg-providerAccent:#6366f1}",
|
|
67
68
|
".usg_accountCard[data-provider=moonshotai],.usg_accountCard[data-provider=moonshotai-cn],.usg_accountCard[data-provider=kimi],.usg_accountCard[data-provider=kimi-coding]{--usg-providerAccent:#e07a1f}",
|
|
@@ -139,7 +140,7 @@ window.__ModuleLoader__.load({
|
|
|
139
140
|
const tagId = "dsh-usage-stats/UsageStats.module.css";
|
|
140
141
|
if (typeof document !== "undefined" && document.querySelector("style[data-plugin-css=" + JSON.stringify(tagId) + "]") === null) {
|
|
141
142
|
const tag = document.createElement("style");
|
|
142
|
-
tag.dataset.plugin = "dsh-usage-stats";
|
|
143
|
+
tag.dataset.plugin = "@ychris12138/dsh-usage-stats";
|
|
143
144
|
tag.dataset.pluginCss = tagId;
|
|
144
145
|
tag.textContent = css;
|
|
145
146
|
document.head.appendChild(tag);
|
|
@@ -960,6 +961,7 @@ window.__ModuleLoader__.load({
|
|
|
960
961
|
"deepseek-official": "DS",
|
|
961
962
|
deepseek: "DS",
|
|
962
963
|
"opencode-go": "GO",
|
|
964
|
+
ollama: "OL",
|
|
963
965
|
openrouter: "OR",
|
|
964
966
|
moonshotai: "K",
|
|
965
967
|
"moonshotai-cn": "K",
|
|
@@ -968,7 +970,7 @@ window.__ModuleLoader__.load({
|
|
|
968
970
|
zai: "Z",
|
|
969
971
|
"zai-coding-cn": "Z"
|
|
970
972
|
};
|
|
971
|
-
return known[provider.id] ?? String(provider.displayName ?? provider.id).replace(/[^A-Za-z0-9]/g, "").slice(0, 2).toUpperCase();
|
|
973
|
+
return known[provider.id] ?? known[provider.adapter] ?? String(provider.displayName ?? provider.id).replace(/[^A-Za-z0-9]/g, "").slice(0, 2).toUpperCase();
|
|
972
974
|
}
|
|
973
975
|
|
|
974
976
|
/** Balance-mode body rendered inside the shared provider account frame. */
|
|
@@ -1129,6 +1131,7 @@ window.__ModuleLoader__.load({
|
|
|
1129
1131
|
return react_jsx_runtime.jsxs("article", {
|
|
1130
1132
|
className: S.accountCard,
|
|
1131
1133
|
"data-provider": provider.id,
|
|
1134
|
+
"data-adapter": provider.adapter ?? provider.accountMode ?? "",
|
|
1132
1135
|
"data-account-mode": mode,
|
|
1133
1136
|
children: [
|
|
1134
1137
|
react_jsx_runtime.jsxs("div", {
|
package/lib/subscriptions.js
CHANGED
|
@@ -38,6 +38,7 @@ const MINIMAX_LEGACY_HOSTS = {
|
|
|
38
38
|
};
|
|
39
39
|
const MINIMAX_USAGE_PATH = "/v1/api/openplatform/coding_plan/remains";
|
|
40
40
|
const MINIMAX_TOKEN_PLAN_PATH = "/v1/token_plan/remains";
|
|
41
|
+
const OLLAMA_USAGE_URL = "https://ollama.com/api/usage";
|
|
41
42
|
const DEFAULT_TIMEOUT_MS = 15000;
|
|
42
43
|
|
|
43
44
|
const REFS = {
|
|
@@ -48,7 +49,8 @@ const REFS = {
|
|
|
48
49
|
zaiRegion: "ZAI_API_REGION",
|
|
49
50
|
kimiApiKey: "KIMI_API_KEY",
|
|
50
51
|
minimaxApiKey: "MINIMAX_API_KEY",
|
|
51
|
-
minimaxRegion: "MINIMAX_API_REGION"
|
|
52
|
+
minimaxRegion: "MINIMAX_API_REGION",
|
|
53
|
+
ollamaApiKey: "OLLAMA_API_KEY"
|
|
52
54
|
};
|
|
53
55
|
|
|
54
56
|
function numberOrNull(value) {
|
|
@@ -576,6 +578,61 @@ async function collectMiniMax(credentials, deps) {
|
|
|
576
578
|
}
|
|
577
579
|
}
|
|
578
580
|
|
|
581
|
+
/**
|
|
582
|
+
* Ollama Cloud usage monitor.
|
|
583
|
+
*
|
|
584
|
+
* Ollama's cloud /api/usage endpoint reports consumed usage ratios for two
|
|
585
|
+
* limit windows (a 5-hour session window and a weekly window), plus an
|
|
586
|
+
* activity cost. There is no monetary balance, so this adapter presents the
|
|
587
|
+
* two windows as subscription-style progress bars, mirroring OpenCode Go.
|
|
588
|
+
*/
|
|
589
|
+
function ollamaWindowFromObject(limit, kind) {
|
|
590
|
+
if (limit === null || typeof limit !== "object") return null;
|
|
591
|
+
// limits.session.usage / limits.weekly.usage are 0..1 consumed ratios
|
|
592
|
+
// (observed 0.0x..0.3x on live data). clampPercent maps any numeric ratio
|
|
593
|
+
// onto 0..100, so an over-quota window renders as a fully-used bar rather
|
|
594
|
+
// than silently disappearing, mirroring the OpenCode Go adapter.
|
|
595
|
+
const ratio = numberOrNull(limit.usage);
|
|
596
|
+
if (ratio === null) return null;
|
|
597
|
+
const usedPercent = round1(clampPercent(ratio * 100));
|
|
598
|
+
return {
|
|
599
|
+
kind,
|
|
600
|
+
usedPercent,
|
|
601
|
+
remainingPercent: round1(100 - usedPercent)
|
|
602
|
+
};
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
function parseOllama(body) {
|
|
606
|
+
const limits = body?.limits;
|
|
607
|
+
if (limits === null || typeof limits !== "object") return [];
|
|
608
|
+
return [
|
|
609
|
+
ollamaWindowFromObject(limits.session, "session"),
|
|
610
|
+
ollamaWindowFromObject(limits.weekly, "weekly")
|
|
611
|
+
].filter(Boolean);
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
async function collectOllama(credentials, deps) {
|
|
615
|
+
const apiKeyRef = deps.apiKeyRef ?? REFS.ollamaApiKey;
|
|
616
|
+
const apiKey = await resolveCredential(credentials, apiKeyRef);
|
|
617
|
+
if (apiKey === "") return { id: "ollama", displayName: "Ollama", mode: "subscription", status: "not-configured", plan: "Ollama", missingCredentials: [apiKeyRef], windows: [] };
|
|
618
|
+
try {
|
|
619
|
+
const body = await request(nonEmptyUrl(deps.baseURL, "/api/usage") ?? OLLAMA_USAGE_URL, {
|
|
620
|
+
headers: { authorization: `Bearer ${apiKey}`, accept: "application/json" }
|
|
621
|
+
}, deps, "json");
|
|
622
|
+
const windows = parseOllama(body);
|
|
623
|
+
return {
|
|
624
|
+
id: "ollama",
|
|
625
|
+
displayName: "Ollama",
|
|
626
|
+
mode: "subscription",
|
|
627
|
+
status: windows.length > 0 ? "ok" : "invalid-response",
|
|
628
|
+
plan: "Ollama",
|
|
629
|
+
windows
|
|
630
|
+
};
|
|
631
|
+
} catch (error) {
|
|
632
|
+
return { id: "ollama", displayName: "Ollama", mode: "subscription", status: normalizedStatus(error), plan: "Ollama", windows: [] };
|
|
633
|
+
}
|
|
634
|
+
}
|
|
635
|
+
|
|
579
636
|
/** Query one subscription/token-plan adapter. */
|
|
580
637
|
export async function collectSubscription(providerId, credentials, options = {}, deps = {}) {
|
|
581
638
|
const shared = {
|
|
@@ -596,6 +653,7 @@ export async function collectSubscription(providerId, credentials, options = {},
|
|
|
596
653
|
});
|
|
597
654
|
if (providerId === "kimi") return collectKimi(credentials, shared);
|
|
598
655
|
if (providerId === "minimax") return collectMiniMax(credentials, shared);
|
|
656
|
+
if (providerId === "ollama") return collectOllama(credentials, shared);
|
|
599
657
|
return { id: providerId, displayName: providerId, mode: "subscription", status: "unavailable", windows: [] };
|
|
600
658
|
}
|
|
601
659
|
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ychris12138/dsh-usage-stats",
|
|
3
3
|
"description": "Token usage heatmap, provider balances, and subscription quotas for the dsh web GUI",
|
|
4
|
-
"version": "0.2.
|
|
4
|
+
"version": "0.2.10",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
7
7
|
"url": "git+https://github.com/Ychris12138/dsh-usage-stats.git"
|
package/scripts/install.mjs
CHANGED
|
@@ -33,13 +33,24 @@ Set DSH_HOME to override the default ~/.dsh location.`);
|
|
|
33
33
|
const sourceRoot = dirname(dirname(fileURLToPath(import.meta.url)));
|
|
34
34
|
const sourcePackage = JSON.parse(await readFile(join(sourceRoot, "package.json"), "utf8"));
|
|
35
35
|
const dshHome = process.env.DSH_HOME ?? join(homedir(), ".dsh");
|
|
36
|
-
const
|
|
36
|
+
const packagePath = sourcePackage.name.split("/");
|
|
37
|
+
if (packagePath.some((part) => part === "" || part === "." || part === "..")) {
|
|
38
|
+
throw new Error(`invalid package name: ${sourcePackage.name}`);
|
|
39
|
+
}
|
|
40
|
+
const target = join(dshHome, "profiles", "node_modules", ...packagePath);
|
|
37
41
|
const patchPath = join(dshHome, "profiles", "web", "cordis.patch.yml");
|
|
38
|
-
const
|
|
42
|
+
const legacyPackageName = sourcePackage.name.split("/").at(-1);
|
|
43
|
+
const quotedPackageName = JSON.stringify(sourcePackage.name);
|
|
44
|
+
const escapeRegExp = (value) => value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
45
|
+
const scopedNamePattern = escapeRegExp(sourcePackage.name);
|
|
46
|
+
const legacyNamePattern = escapeRegExp(legacyPackageName);
|
|
47
|
+
const pluginLine = new RegExp(`^\\s+name:\\s*(?:"${scopedNamePattern}"|'${scopedNamePattern}')\\s*$`, "gm");
|
|
48
|
+
const unquotedScopedPluginLine = new RegExp(`^(\\s+name:\\s*)${scopedNamePattern}\\s*$`, "gm");
|
|
49
|
+
const legacyPluginLine = new RegExp(`^(\\s+name:\\s*)(?:"${legacyNamePattern}"|'${legacyNamePattern}'|${legacyNamePattern})\\s*$`, "gm");
|
|
39
50
|
const patchBlock = `# dsh-usage-stats: token usage heatmap + DeepSeek balance
|
|
40
51
|
- insert:
|
|
41
52
|
- id: usage-stats
|
|
42
|
-
name:
|
|
53
|
+
name: ${quotedPackageName}
|
|
43
54
|
`;
|
|
44
55
|
const emptySequenceRoot = /^\[\](?:[ \t]+#.*)?$/;
|
|
45
56
|
|
|
@@ -76,10 +87,26 @@ function assertNoEmptyRootConflict(text) {
|
|
|
76
87
|
}
|
|
77
88
|
}
|
|
78
89
|
|
|
90
|
+
function countMatches(text, pattern) {
|
|
91
|
+
return [...String(text).matchAll(pattern)].length;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** Migrate the pre-scope entry and repair an invalid unquoted scoped YAML value. */
|
|
95
|
+
function normalizePluginIdentity(text) {
|
|
96
|
+
const scopedCount = countMatches(text, pluginLine);
|
|
97
|
+
const legacyCount = countMatches(text, legacyPluginLine) + countMatches(text, unquotedScopedPluginLine);
|
|
98
|
+
if (scopedCount + legacyCount > 1) {
|
|
99
|
+
throw new Error(`multiple dsh-usage-stats entries in ${patchPath}; keep one ${quotedPackageName} entry`);
|
|
100
|
+
}
|
|
101
|
+
return String(text)
|
|
102
|
+
.replace(legacyPluginLine, (_, prefix) => `${prefix}${quotedPackageName}`)
|
|
103
|
+
.replace(unquotedScopedPluginLine, (_, prefix) => `${prefix}${quotedPackageName}`);
|
|
104
|
+
}
|
|
105
|
+
|
|
79
106
|
/** Preserve existing YAML/comments while adding exactly one plugin patch entry. */
|
|
80
107
|
function enablePluginInPatch(text) {
|
|
81
|
-
const base = withoutEmptySequenceRoot(text);
|
|
82
|
-
if (
|
|
108
|
+
const base = normalizePluginIdentity(withoutEmptySequenceRoot(text));
|
|
109
|
+
if (countMatches(base, pluginLine) > 0) return base;
|
|
83
110
|
return base.trim() === "" ? patchBlock : `${base.trimEnd()}\n\n${patchBlock}`;
|
|
84
111
|
}
|
|
85
112
|
|
|
@@ -100,10 +127,12 @@ async function verify(expectEnabled) {
|
|
|
100
127
|
throw new Error(`installed package is ${installed.name ?? "unknown"}@${installed.version ?? "unknown"}; expected ${sourcePackage.name}@${sourcePackage.version}`);
|
|
101
128
|
}
|
|
102
129
|
if (expectEnabled) {
|
|
103
|
-
const patch = await readOptional(patchPath);
|
|
104
|
-
const count = patch === null ? 0 : [...patch.matchAll(pluginLine)].length;
|
|
105
|
-
if (count !== 1) throw new Error(`expected exactly one dsh-usage-stats entry in ${patchPath}; found ${count}`);
|
|
130
|
+
const patch = await readOptional(patchPath) ?? "";
|
|
106
131
|
assertNoEmptyRootConflict(patch);
|
|
132
|
+
const count = countMatches(patch, pluginLine);
|
|
133
|
+
if (count !== 1) throw new Error(`expected exactly one dsh-usage-stats entry in ${patchPath}; found ${count}`);
|
|
134
|
+
if (countMatches(patch, legacyPluginLine) > 0) throw new Error(`legacy package name ${legacyPackageName} remains in ${patchPath}`);
|
|
135
|
+
if (countMatches(patch, unquotedScopedPluginLine) > 0) throw new Error(`scoped package name must be quoted in ${patchPath}`);
|
|
107
136
|
}
|
|
108
137
|
console.log(`Verified ${sourcePackage.name}@${sourcePackage.version}`);
|
|
109
138
|
console.log(` package: ${target}`);
|
|
@@ -139,4 +168,4 @@ if (enable) {
|
|
|
139
168
|
|
|
140
169
|
await verify(enable);
|
|
141
170
|
console.log("Installation complete. Restart dsh web, then hard-refresh the browser.");
|
|
142
|
-
console.log("Balance is optional: configure DEEPSEEK_API_KEY in <DSH_HOME>/.credentials.yaml.");
|
|
171
|
+
console.log("Balance is optional: configure DEEPSEEK_API_KEY in <DSH_HOME>/.credentials.yaml.");
|