dsh-multi-router 0.3.8 → 0.4.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 CHANGED
@@ -2,6 +2,14 @@
2
2
 
3
3
  本项目遵循 [语义化版本](https://semver.org/lang/zh-CN/)。版本号从 `0.1` 起以功能迭代为主,接口在 `1.0` 前可能变化。
4
4
 
5
+ ## 0.4.0
6
+
7
+ - 首次启用自动获取并保存 WorkBuddy / Trae 本机登录,首页明确显示检测与保存结果,增加一键重试和下一步引导。
8
+ - 内置所有已安装插件的更新面板:npm 版本 / 频道、GitHub 分支提交检查,支持其它插件、停用插件、搜索筛选与批量更新。
9
+ - 通过 DSH 官方安装服务执行更新和新插件安装,保留启用状态,提供取消、失败提示、npm 来源选择及重启引导。
10
+ - 同时存在 npm / pnpm 锁文件时读取最近更新的锁文件;固定 Git 提交不解除,npm 安装完成后核验实际版本。
11
+
12
+
5
13
  ## 0.3.8
6
14
 
7
15
  - 恢复 WorkBuddy / Trae 本机登录自动获取:启动、每分钟检测,以及手动刷新均会扫描;沿用原插件的 WorkBuddy 多路径和加密格式读取器,Trae 自动识别 CN / Global / SOLO。
package/README.md CHANGED
@@ -6,7 +6,7 @@
6
6
 
7
7
  一个插件内置 WorkBuddy、Trae、Qoder 的认证、模型发现和聊天传输,提供三家的多账号池及集中路由。运行不依赖原来三个 DSH 插件;三家代码随安装包一起打包。基于 MIT 上游,固定版本见 `upstream-lock.json`,许可见 `THIRD_PARTY_NOTICES.md`。
8
8
 
9
- 版本:0.3.8。适配 DSH Desktop / Web 0.2.0-rc.2,Node.js ^22.19.0 或 >=24。
9
+ 版本:0.4.0。适配 DSH Desktop / Web 0.2.0-rc.2,Node.js ^22.19.0 或 >=24。
10
10
 
11
11
  ## 在 DSH 里管理
12
12
 
@@ -89,9 +89,59 @@ Qoder 的 `dfmodel` 在真实目录里显示为 DeepSeek-Flash,未声明具体
89
89
 
90
90
  账号获取采用官方客户端已有登录文件的只读解析,不修改客户端登录、不复制参考项目代码、不自动签到。手动凭证支持 WorkBuddy / Trae 字段表单、三家凭证 JSON 和 Qoder PAT;凭证格式、区域、Trae 设备身份会校验。已有账号可在详情中「补充 / 更新凭证」,旧副本保留。Qoder 普通网页 Cookie 无法替代 PAT;官方 PAT 指引见 https://docs.qoder.com/cli/authentication 。
91
91
 
92
+ ## 首次使用与账号自动获取
93
+
94
+ 第一次启用插件时会执行 WorkBuddy / WorkBuddy AI 与 Trae CN / Global / SOLO 的本机登录检测,将找到的有效登录保存为独立账号副本。首页会展示「本机账号自动获取」结果,区分已保存、未登录和无法读取,并提供「一键获取并保存」及手动添加入口;无需先逐个点击添加账号。之后每分钟检测新增登录,重复检测不重复添加,已停用和已删除的账号保持用户选择。Qoder 仍需添加 PAT。
95
+
96
+ ## 所有已安装插件的更新与安装
97
+
98
+ 在本插件配置面板打开 **插件更新**,管理当前 DSH 配置中的全部已安装插件,包括其它插件和已停用插件。首次打开自动检查,后续超过 24 小时重新打开时再检查,也可随时点击「检查全部更新」。
99
+
100
+ - **npm 来源**:比较 SemVer,保留 `latest` / `next` 等频道;固定版本和版本范围在用户确认更新后升级到最新正式版本。查询沿用 DSH 的 registry、网络、认证与镜像回退设置。
101
+ - **GitHub 来源**:比较原分支 / 标签的 Git 提交,版本号未变化也能发现更新。支持 `github:用户/仓库`、GitHub HTTPS / SSH 地址及锁文件中的提交;固定 Git 提交不自动解除。GitHub 查询需要能够访问仓库公开元数据,私有仓库或限流会显示原因,不会猜测最新提交。
102
+ - 可按名称、来源和可更新状态筛选;支持单个更新、勾选批量更新、取消以及失败后重试。更新前显示目标,并保留每个插件原有启用 / 停用状态。
103
+ - 可直接输入 npm 包名 / 指定版本,或 `github:用户/仓库` 安装新插件。实际安装由 DSH 官方插件管理服务执行兼容检查、镜像重试、配置锁及失败回滚;依赖构建脚本仍在 DSH 原安装页授权,不自动绕过。
104
+ - 结果显示实际安装是否成功及是否需要重启。npm 镜像尚未同步、网络超时、版本不兼容会明确提示。GitHub 下载不会由 npm 镜像代替。
105
+ - DSH 自带组件也在清单中展示,标记为随 DSH 应用升级;本地文件 / 开发链接标记为原方式更新。不同 DSH 配置之间相互独立,只操作当前配置。
106
+
92
107
  ## 安装
93
108
 
94
- ### 方式一:GitHub 链接安装(首选)
109
+ ### 方式一:npm 安装(首选)
110
+
111
+ 在 DSH 的 **插件页 → 安装插件** 输入包名:
112
+
113
+ ```text
114
+ dsh-multi-router
115
+ ```
116
+
117
+ CLI 安装最新正式版:
118
+
119
+ ```bash
120
+ # Desktop profile
121
+ dsh plugin --profile desktop add dsh-multi-router
122
+
123
+ # Web profile
124
+ dsh plugin --profile web add dsh-multi-router
125
+ ```
126
+
127
+ 包地址:[dsh-multi-router · npm](https://www.npmjs.com/package/dsh-multi-router)。正式版本由 GitHub Release 自动测试、打包并发布到 npm `latest`,包中已包含编译产物,用户无需自行构建。需要固定版本时可安装 `dsh-multi-router@0.4.0`。
128
+
129
+ 已使用 npm 来源的用户更新:
130
+
131
+ ```bash
132
+ dsh plugin --profile desktop update dsh-multi-router
133
+ ```
134
+
135
+ 原先通过 GitHub 或本地 `.tgz` 安装的用户,运行下面命令切换到 npm 正式版本:
136
+
137
+ ```bash
138
+ dsh plugin --profile desktop add dsh-multi-router
139
+ ```
140
+
141
+ 这是同名插件的来源切换;账号、路由和用量保存在包目录之外,会保留。更新完成后**完全退出并重新打开 DeepSeek Harness**;关闭窗口或点击「刷新面板」不会重载后台。插件面板检测到前后台版本不一致时会显示重启步骤。
142
+
143
+ ### 方式二:GitHub 链接安装
144
+
95
145
 
96
146
  在 DSH 的 **插件页 → 安装插件** 输入 GitHub 仓库地址:
97
147
 
@@ -123,7 +173,7 @@ Desktop 若提示 CLI 仅安装依赖、未自动启用 bundle:完全退出 De
123
173
 
124
174
  > 首次安装请先在 DSH 中**关闭**原 WorkBuddy / Trae / Qoder 三个独立连接器(仅关闭,不必卸载,不影响其凭证),避免同名 provider 冲突。
125
175
 
126
- ### 方式二:本地构建 / 离线包
176
+ ### 方式三:本地构建 / 离线包
127
177
 
128
178
  ```bash
129
179
  git clone https://github.com/AndroidSix/dsh-multi-router.git
@@ -131,12 +181,12 @@ cd dsh-multi-router
131
181
  npm ci
132
182
  npm run check
133
183
  npm run pack:release
134
- dsh plugin --profile desktop add ./dsh-multi-router-0.3.8.tgz
184
+ dsh plugin --profile desktop add ./dsh-multi-router-0.4.0.tgz
135
185
  ```
136
186
 
137
187
  ### 配置账号
138
188
 
139
- 安装后直接在配置面板「账号与登录」添加;也可先用 CLI:
189
+ 安装并启动插件后,已登录的 WorkBuddy / Trae 账号会自动进入账号池;Qoder PAT、多账号凭证和其它设备配置可在面板中添加或导入。也可用 CLI:
140
190
 
141
191
  ```bash
142
192
  node lib/cli.js add --id wb1 --provider workbuddy --source '/path/workbuddy-desktop.info'
@@ -34,12 +34,12 @@ import {
34
34
  import { createHash, randomBytes as randomBytes2, timingSafeEqual } from "node:crypto";
35
35
 
36
36
  // package.json
37
- var version = "0.3.8";
37
+ var version = "0.4.0";
38
38
 
39
39
  // src/manager.mjs
40
- import { readFile, mkdir as mkdir2, mkdtemp as mkdtemp2, rm as rm2 } from "node:fs/promises";
40
+ import { readFile as readFile2, mkdir as mkdir2, mkdtemp as mkdtemp2, rm as rm2 } from "node:fs/promises";
41
41
  import { watch } from "node:fs";
42
- import { dirname as dirname3, join as join3, relative as relative2, resolve } from "node:path";
42
+ import { dirname as dirname3, join as join4, relative as relative2, resolve } from "node:path";
43
43
  import { withFileLock } from "@deepseek-ai/dsh-atomic-write";
44
44
 
45
45
  // upstream/qoder/src/status-paths.ts
@@ -233,6 +233,334 @@ async function prepareMigration(manager, raw, portable, mode) {
233
233
  }
234
234
  }
235
235
 
236
+ // src/plugin-updates.mjs
237
+ import { readFile, stat } from "node:fs/promises";
238
+ import { join as join3 } from "node:path";
239
+ import { randomUUID } from "node:crypto";
240
+ import { createRequire } from "node:module";
241
+ import { pathToFileURL } from "node:url";
242
+ import { parse as parseYaml } from "yaml";
243
+
244
+ // upstream/workbuddy/src/update.ts
245
+ var WORKBUDDY_REPOSITORY_URL = "https://github.com/corrinehu/dsh-workbuddy-connect";
246
+ var WORKBUDDY_UPDATE_PACKAGE_NAME = "dsh-workbuddy-connect";
247
+ var WORKBUDDY_UPDATE_NPM_METADATA_URL = `https://registry.npmjs.org/-/package/${WORKBUDDY_UPDATE_PACKAGE_NAME}/dist-tags`;
248
+ var WORKBUDDY_UPDATE_RELEASES_API_URL = `${WORKBUDDY_REPOSITORY_URL.replace("https://github.com", "https://api.github.com/repos")}/releases?per_page=100`;
249
+ var WORKBUDDY_RELEASE_PAGE_BASE = `${WORKBUDDY_REPOSITORY_URL}/releases/tag/`;
250
+ var WORKBUDDY_UPDATE_MAX_METADATA_BYTES = 64 * 1024;
251
+ var WORKBUDDY_UPDATE_MAX_RELEASES_BYTES = 1024 * 1024;
252
+ function parseWorkBuddyVersion(raw) {
253
+ if (typeof raw !== "string") return void 0;
254
+ const normalized = raw.startsWith("v") ? raw.slice(1) : raw;
255
+ const match = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-([0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$/u.exec(normalized);
256
+ if (match === null) return void 0;
257
+ const rawPrerelease = match[4] === void 0 ? [] : match[4].split(".");
258
+ if (rawPrerelease.some((identifier) => /^\d+$/u.test(identifier) && !/^(0|[1-9]\d*)$/u.test(identifier))) return void 0;
259
+ const prerelease = rawPrerelease.map((identifier) => /^(0|[1-9]\d*)$/u.test(identifier) ? Number(identifier) : identifier);
260
+ if (prerelease.some((identifier) => typeof identifier === "number" && !Number.isSafeInteger(identifier))) return void 0;
261
+ const parsed = {
262
+ major: Number(match[1]),
263
+ minor: Number(match[2]),
264
+ patch: Number(match[3]),
265
+ prerelease
266
+ };
267
+ return [parsed.major, parsed.minor, parsed.patch].every(Number.isSafeInteger) ? parsed : void 0;
268
+ }
269
+ function compareIdentifiers(left, right) {
270
+ if (typeof left === "number" && typeof right === "number") return left < right ? -1 : left > right ? 1 : 0;
271
+ if (typeof left === "number") return -1;
272
+ if (typeof right === "number") return 1;
273
+ return left < right ? -1 : left > right ? 1 : 0;
274
+ }
275
+ function compareWorkBuddyVersions(left, right) {
276
+ const a = parseWorkBuddyVersion(left);
277
+ const b = parseWorkBuddyVersion(right);
278
+ if (a === void 0 || b === void 0) throw new TypeError("invalid WorkBuddy Connect version");
279
+ for (const [aPart, bPart] of [[a.major, b.major], [a.minor, b.minor], [a.patch, b.patch]]) {
280
+ if (aPart !== bPart) return aPart < bPart ? -1 : 1;
281
+ }
282
+ if (a.prerelease.length === 0 && b.prerelease.length !== 0) return 1;
283
+ if (a.prerelease.length !== 0 && b.prerelease.length === 0) return -1;
284
+ for (let index = 0; index < Math.max(a.prerelease.length, b.prerelease.length); index += 1) {
285
+ const aPart = a.prerelease[index];
286
+ const bPart = b.prerelease[index];
287
+ if (aPart === void 0) return -1;
288
+ if (bPart === void 0) return 1;
289
+ const comparison = compareIdentifiers(aPart, bPart);
290
+ if (comparison !== 0) return comparison;
291
+ }
292
+ return 0;
293
+ }
294
+
295
+ // src/plugin-updates.mjs
296
+ var packageName = /^(?:@[a-z0-9][a-z0-9._-]*\/)?[a-z0-9][a-z0-9._-]*$/i;
297
+ var safeRef = /^[a-zA-Z0-9._/-]{1,200}$/;
298
+ function parsePluginSource(name, spec) {
299
+ if (!packageName.test(name) || typeof spec !== "string" || spec.length > 600 || spec.startsWith("-") || spec.trim() !== spec || /[\x00-\x1f]/.test(spec)) return { kind: "unsupported" };
300
+ const git = /^(?:github:|git\+https:\/\/github\.com\/|https:\/\/github\.com\/|git\+ssh:\/\/git@github\.com\/|git@github\.com:)?([\w.-]+)\/([\w.-]+?)(?:\.git)?(?:#(.+))?$/.exec(spec);
301
+ if (git && !spec.startsWith("@") && !spec.startsWith("file:") && !spec.startsWith("link:")) {
302
+ const [, owner, repo, ref] = git;
303
+ if (owner === "." || owner === ".." || repo === "." || repo === ".." || ref && (!safeRef.test(ref) || ref.includes(".."))) return { kind: "unsupported" };
304
+ return { kind: "github", owner, repo, ref, source: `github:${owner}/${repo}${ref ? "#" + ref : ""}` };
305
+ }
306
+ if (/^(?:file:|link:|workspace:|https?:|git|\/|\.)/.test(spec)) return { kind: "unsupported" };
307
+ const alias = /^npm:((?:@[^/]+\/)?[^@]+)(?:@(.+))?$/.exec(spec);
308
+ const actual = alias?.[1] ?? name, range = alias?.[2] ?? spec;
309
+ if (!packageName.test(actual) || !/^[a-zA-Z0-9.*^~<>=|+ -]{1,160}$/.test(range)) return { kind: "unsupported" };
310
+ const channel = /^[a-zA-Z][\w.-]*$/.test(range);
311
+ return { kind: "npm", package: actual, alias: !!alias, channel, tag: channel ? range : "latest", source: spec };
312
+ }
313
+ function githubCommit(value) {
314
+ if (typeof value !== "string") return void 0;
315
+ return /(?:\/tar\.gz\/|\/tarball\/|#)([a-f0-9]{40})(?:\b|\()/i.exec(value)?.[1]?.toLowerCase();
316
+ }
317
+ async function jsonFile(file) {
318
+ try {
319
+ return JSON.parse(await readFile(file, "utf8"));
320
+ } catch {
321
+ return void 0;
322
+ }
323
+ }
324
+ async function locks(dir) {
325
+ let pnpm;
326
+ try {
327
+ pnpm = parseYaml(await readFile(join3(dir, "pnpm-lock.yaml"), "utf8"), { maxAliasCount: 20 });
328
+ } catch {
329
+ }
330
+ const npm = await jsonFile(join3(dir, "package-lock.json"));
331
+ const times = await Promise.all(["pnpm-lock.yaml", "package-lock.json"].map((name) => stat(join3(dir, name)).then((s) => s.mtimeMs).catch(() => 0)));
332
+ return { pnpm, npm, preferNpm: times[1] > times[0] };
333
+ }
334
+ async function publicJson(url, fetchImpl, signal) {
335
+ const res = await fetchImpl(url, {
336
+ signal: AbortSignal.any([signal, AbortSignal.timeout(12e3)]),
337
+ headers: { Accept: "application/json", "User-Agent": "dsh-multi-router-plugin-updates" },
338
+ redirect: "error"
339
+ });
340
+ if (!res.ok) throw Error(res.status === 403 || res.status === 429 ? "\u8FDC\u7AEF\u9650\u6D41\uFF0C\u8BF7\u7A0D\u540E\u91CD\u8BD5" : res.status === 404 ? "\u4ED3\u5E93 / \u7248\u672C\u4E0D\u5B58\u5728\uFF0C\u6216\u9700\u8981\u79C1\u6709\u4ED3\u5E93\u6388\u6743" : `\u7F51\u7EDC\u8BF7\u6C42\u5931\u8D25\uFF08HTTP ${res.status}\uFF09`);
341
+ let size = 0, chunks = [];
342
+ for await (const chunk of res.body) {
343
+ size += chunk.byteLength;
344
+ if (size > 256 * 1024) throw Error("\u66F4\u65B0\u5143\u6570\u636E\u8FC7\u5927");
345
+ chunks.push(Buffer.from(chunk));
346
+ }
347
+ return JSON.parse(Buffer.concat(chunks).toString("utf8"));
348
+ }
349
+ var short = (sha) => sha?.slice(0, 8);
350
+ async function registryVersion(service, profile, spec, signal) {
351
+ const require2 = createRequire(profile.installAnchor);
352
+ const { viewProfilePackage } = await import(pathToFileURL(require2.resolve("@deepseek-ai/dsh-plugin-manager/operations")).href);
353
+ const { registryPlan } = await import(pathToFileURL(require2.resolve("@deepseek-ai/dsh-plugin-manager/registry")).href);
354
+ for (const registry of registryPlan(void 0, await service.registries())) {
355
+ const answer = await viewProfilePackage(profile.dir, spec, { ...profile.packageManager, registry, timeoutMs: 12e3, signal });
356
+ if (signal.aborted) throw Error("\u68C0\u67E5\u5DF2\u53D6\u6D88");
357
+ if (answer.exitCode !== 0) continue;
358
+ const parsed = JSON.parse(answer.stdout), metadata = Array.isArray(parsed) ? parsed.at(-1) : parsed;
359
+ if (metadata?.version) return metadata;
360
+ }
361
+ throw Error("npm \u67E5\u8BE2\u5931\u8D25\uFF1B\u8BF7\u68C0\u67E5 DSH \u7F51\u7EDC\u3001registry \u8BBE\u7F6E\u6216\u5305\u7684\u8BBF\u95EE\u6743\u9650");
362
+ }
363
+ var PluginUpdates = class {
364
+ constructor({ service = () => void 0, profile = () => void 0, fetchImpl = (...args) => fetch(...args), lookup = registryVersion } = {}) {
365
+ this.service = service;
366
+ this.profile = profile;
367
+ this.fetchImpl = fetchImpl;
368
+ this.rows = [];
369
+ this.abort = new AbortController();
370
+ this.lookup = lookup;
371
+ }
372
+ state() {
373
+ return {
374
+ available: !!this.service()?.listBundles && !!this.profile()?.dir,
375
+ checkedAt: this.checkedAt,
376
+ rows: this.rows.map(({ spec, ...row }) => ({ ...row, source: row.source.kind === "unsupported" ? { kind: "unsupported" } : row.source })),
377
+ job: this.job,
378
+ error: this.error
379
+ };
380
+ }
381
+ async list() {
382
+ const service = this.service(), profile = this.profile();
383
+ if (!service?.listBundles || !profile?.dir) {
384
+ this.rows = [];
385
+ return;
386
+ }
387
+ const [manifest, bundles, lock] = await Promise.all([jsonFile(join3(profile.dir, "package.json")), service.listBundles(), locks(profile.dir)]);
388
+ if (!manifest) throw Error("\u5F53\u524D DSH \u914D\u7F6E\u7684\u63D2\u4EF6\u6E05\u5355\u65E0\u6CD5\u8BFB\u53D6");
389
+ this.rows = bundles.filter((b) => b.installed || b.version).map((b) => {
390
+ const spec = manifest.dependencies?.[b.name];
391
+ const source = !spec || b.removable === false && !b.readOnlyReason ? { kind: "builtin" } : parsePluginSource(b.name, spec);
392
+ const npm = lock.npm?.packages?.[`node_modules/${b.name}`]?.resolved;
393
+ const pnpm = lock.pnpm?.importers?.["."]?.dependencies?.[b.name]?.version;
394
+ const installed = lock.preferNpm ? npm ?? pnpm : pnpm ?? npm;
395
+ const previous = this.rows.find((r) => r.name === b.name && r.spec === spec && r.version === b.version && r.commit === githubCommit(installed));
396
+ return {
397
+ ...previous,
398
+ name: b.name,
399
+ version: b.version,
400
+ enabled: b.enabled,
401
+ spec,
402
+ source,
403
+ commit: githubCommit(installed),
404
+ status: previous?.status ?? (source.kind === "builtin" ? "builtin" : source.kind === "unsupported" ? "unsupported" : "unchecked"),
405
+ ...source.kind === "builtin" ? { message: "DSH \u81EA\u5E26 / \u53D7\u4FDD\u62A4\u7EC4\u4EF6\uFF0C\u8BF7\u901A\u8FC7\u66F4\u65B0 DeepSeek Harness \u5347\u7EA7" } : {},
406
+ ...source.kind === "unsupported" ? { message: "\u672C\u5730\u6587\u4EF6 / \u94FE\u63A5\u6765\u6E90\uFF0C\u8BF7\u901A\u8FC7\u539F\u5B89\u88C5\u65B9\u5F0F\u66F4\u65B0" } : {}
407
+ };
408
+ });
409
+ }
410
+ async checkRow(row) {
411
+ try {
412
+ if (row.source.kind === "npm") {
413
+ const spec = `${row.source.package}@${row.source.tag}`;
414
+ const found = await this.lookup(this.service(), this.profile(), spec, this.abort.signal);
415
+ if (!found.version || found.problem && found.problem !== "already-installed") throw Error(found.problem === "not-found" ? "npm \u4E2D\u672A\u627E\u5230\u8BE5\u5305\u6216\u9891\u9053" : "npm \u67E5\u8BE2\u5931\u8D25\uFF1B\u8BF7\u68C0\u67E5 DSH \u7684\u7F51\u7EDC\u4E0E registry \u8BBE\u7F6E");
416
+ if (!parseWorkBuddyVersion(found.version) || row.version && !parseWorkBuddyVersion(row.version)) throw Error("\u65E0\u6CD5\u6BD4\u8F83\u7248\u672C\u53F7");
417
+ row.latestVersion = found.version;
418
+ row.target = `${row.name}@${row.source.alias ? `npm:${row.source.package}@` : ""}${row.source.channel ? row.source.tag : found.version}`;
419
+ row.status = !row.version || compareWorkBuddyVersions(found.version, row.version) > 0 ? "available" : "current";
420
+ row.message = !row.version ? "\u5B89\u88C5\u6587\u4EF6\u4E0D\u53EF\u8BFB\uFF0C\u53EF\u91CD\u65B0\u5B89\u88C5\u8BE5\u6765\u6E90\u4FEE\u590D" : row.status === "current" ? "\u5F53\u524D\u7248\u672C\u5DF2\u662F\u8BE5\u9891\u9053\u6700\u65B0\uFF0C\u6216\u9AD8\u4E8E\u8BE5\u9891\u9053" : void 0;
421
+ } else if (row.source.kind === "github") {
422
+ const { owner, repo, ref } = row.source;
423
+ const url = `https://api.github.com/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/commits/${encodeURIComponent(ref ?? "HEAD")}`;
424
+ const found = await publicJson(url, this.fetchImpl, this.abort.signal);
425
+ if (!/^[a-f0-9]{40}$/i.test(found.sha)) throw Error("GitHub \u8FD4\u56DE\u65E0\u6548\u63D0\u4EA4\u4FE1\u606F");
426
+ row.latestCommit = found.sha.toLowerCase();
427
+ row.target = `${row.name}@${row.spec}`;
428
+ row.status = row.commit === row.latestCommit ? "current" : "available";
429
+ row.message = row.commit ? row.status === "available" ? `\u63D0\u4EA4 ${short(row.commit)} \u2192 ${short(row.latestCommit)}` : "\u8BE5\u5206\u652F / \u6807\u7B7E\u7684\u63D0\u4EA4\u5DF2\u540C\u6B65" : "\u65E0\u6CD5\u8BFB\u53D6\u672C\u5730 Git \u63D0\u4EA4\uFF0C\u53EF\u91CD\u65B0\u5B89\u88C5\u5DF2\u68C0\u67E5\u7684\u6765\u6E90";
430
+ if (ref && /^[a-f0-9]{7,40}$/i.test(ref)) {
431
+ row.status = "pinned";
432
+ row.message = "\u5B89\u88C5\u6765\u6E90\u56FA\u5B9A\u5728 Git \u63D0\u4EA4\uFF1B\u4FDD\u7559\u6B64\u63D0\u4EA4\uFF0C\u4E0D\u81EA\u52A8\u5207\u6362\u5206\u652F";
433
+ }
434
+ }
435
+ row.checkedAt = Date.now();
436
+ row.checkedEnabled = row.enabled;
437
+ } catch (error) {
438
+ row.status = "failed";
439
+ row.message = error.message;
440
+ delete row.target;
441
+ }
442
+ }
443
+ start(kind, task) {
444
+ if (this.flight) throw Error("\u5DF2\u6709\u63D2\u4EF6\u64CD\u4F5C\u6B63\u5728\u6267\u884C\uFF0C\u8BF7\u7B49\u5F85\u5B8C\u6210");
445
+ this.job = { id: randomUUID(), kind, state: "running", startedAt: Date.now(), results: [], total: 0 };
446
+ this.error = void 0;
447
+ this.flight = Promise.resolve().then(() => task(this.job)).then(() => {
448
+ this.job.state = this.job.cancelRequested ? "cancelled" : "done";
449
+ }, (e) => {
450
+ this.job.state = this.job.cancelRequested ? "cancelled" : "failed";
451
+ this.job.message = e.message;
452
+ }).finally(() => {
453
+ this.job.finishedAt = Date.now();
454
+ this.flight = void 0;
455
+ });
456
+ }
457
+ check() {
458
+ this.start("check", async (job) => {
459
+ await this.list();
460
+ const rows = this.rows.filter((r) => ["npm", "github"].includes(r.source.kind));
461
+ job.total = rows.length;
462
+ let next = 0;
463
+ await Promise.all(Array.from({ length: Math.min(3, rows.length) }, async () => {
464
+ while (next < rows.length && !job.cancelRequested) {
465
+ const row = rows[next++];
466
+ await this.checkRow(row);
467
+ job.results.push({ name: row.name, status: row.status, message: row.message });
468
+ }
469
+ }));
470
+ this.checkedAt = Date.now();
471
+ });
472
+ }
473
+ async installRow(row, registry) {
474
+ const original = await jsonFile(join3(this.profile().dir, "package.json"));
475
+ if (original?.dependencies?.[row.name] !== row.spec) throw Error("\u5B89\u88C5\u6765\u6E90\u5DF2\u6539\u53D8\uFF0C\u8BF7\u91CD\u65B0\u68C0\u67E5");
476
+ const current = (await this.service().listBundles()).find((b) => b.name === row.name);
477
+ if (!current || current.enabled !== row.checkedEnabled || current.version !== row.version) throw Error("\u63D2\u4EF6\u7248\u672C\u6216\u542F\u7528\u72B6\u6001\u5DF2\u6539\u53D8\uFF0C\u8BF7\u91CD\u65B0\u68C0\u67E5");
478
+ const result = await this.service().installBundle(row.target, {
479
+ enabled: row.enabled,
480
+ requestId: this.job.id,
481
+ ...registry ? { registry } : {}
482
+ });
483
+ if (result.packageResult && result.packageResult.exitCode !== 0 || ["failed", "cancelled"].includes(result.application) || result.error) {
484
+ return {
485
+ name: row.name,
486
+ status: "failed",
487
+ application: result.application,
488
+ message: result.pendingBuilds?.length ? "\u4F9D\u8D56\u6784\u5EFA\u811A\u672C\u9700\u8981\u6388\u6743\uFF0C\u8BF7\u5230 DSH \u63D2\u4EF6\u5B89\u88C5\u9875\u5141\u8BB8\u8FD9\u4E9B\u811A\u672C\u540E\u91CD\u8BD5" : result.error?.code === "incompatible-version" ? "\u63D2\u4EF6\u4E0E\u5F53\u524D DSH \u4E0D\u517C\u5BB9\uFF0C\u8BF7\u5148\u5347\u7EA7 DSH \u6216\u9009\u62E9\u517C\u5BB9\u7248\u672C" : `\u5B89\u88C5\u672A\u5B8C\u6210\uFF08${result.error?.code ?? result.packageResult?.kind ?? "unknown"}\uFF09\uFF1B\u8BF7\u5230 DSH \u63D2\u4EF6\u9875\u67E5\u770B\u5B89\u88C5\u65E5\u5FD7`,
489
+ pendingBuilds: result.pendingBuilds ?? [],
490
+ logPath: result.packageResult?.logPath
491
+ };
492
+ }
493
+ const installed = (await this.service().listBundles()).find((b) => b.name === row.name);
494
+ if (row.source.kind === "npm" && installed?.version !== row.latestVersion) return {
495
+ name: row.name,
496
+ status: "failed",
497
+ message: `\u5B9E\u9645\u5B89\u88C5\u7248\u672C ${installed?.version ?? "\u672A\u77E5"} \u4E0E\u68C0\u67E5\u7248\u672C ${row.latestVersion} \u4E0D\u540C\uFF1B\u8BF7\u7A0D\u540E\u91CD\u65B0\u68C0\u67E5\uFF0C\u53EF\u80FD\u662F\u955C\u50CF\u5C1A\u672A\u540C\u6B65\u6216\u53D1\u884C\u65F6\u95F4\u9650\u5236`
498
+ };
499
+ return { name: row.name, status: "installed", application: result.application, message: result.application === "restart-required" ? "\u5DF2\u5B89\u88C5\uFF0C\u5B8C\u5168\u9000\u51FA\u5E76\u91CD\u65B0\u6253\u5F00 DSH \u540E\u751F\u6548" : "\u5B89\u88C5\u5B8C\u6210", restartRequired: result.application === "restart-required" };
500
+ }
501
+ update(names, registry) {
502
+ if (!Array.isArray(names) || !names.length || names.length > 200 || names.some((n) => typeof n !== "string") || new Set(names).size !== names.length) throw Error("\u8BF7\u9009\u62E9\u8981\u66F4\u65B0\u7684\u63D2\u4EF6");
503
+ const selected = names.map((name) => this.rows.find((r) => r.name === name));
504
+ if (selected.some((r) => !r || !["available", "failed"].includes(r.status) || !r.target || Date.now() - r.checkedAt > 6e5)) throw Error("\u8BF7\u5148\u68C0\u67E5\u66F4\u65B0\uFF0C\u518D\u9009\u62E9\u53EF\u66F4\u65B0\u7684\u63D2\u4EF6\uFF08\u68C0\u67E5\u7ED3\u679C\u6709\u6548\u671F 10 \u5206\u949F\uFF09");
505
+ selected.sort((a, b) => Number(a.name === "dsh-multi-router") - Number(b.name === "dsh-multi-router"));
506
+ this.start("update", async (job) => {
507
+ job.total = selected.length;
508
+ for (const row of selected) {
509
+ if (job.cancelRequested) break;
510
+ job.current = row.name;
511
+ let result;
512
+ try {
513
+ result = await this.installRow(row, registry);
514
+ } catch (e) {
515
+ result = { name: row.name, status: "failed", message: e.message };
516
+ }
517
+ job.results.push(result);
518
+ row.status = result.status;
519
+ row.message = result.message;
520
+ }
521
+ delete job.current;
522
+ await this.list();
523
+ });
524
+ }
525
+ install(spec, registry) {
526
+ if (typeof spec !== "string" || spec.length > 600 || spec.startsWith("-") || /[\s\x00-\x1f]/.test(spec)) throw Error("\u8BF7\u8F93\u5165\u4E00\u4E2A npm \u5305\u540D\u6216 GitHub \u5B89\u88C5\u5730\u5740");
527
+ const named = /^((?:@[^/]+\/)?[^@]+)(?:@(.+))?$/.exec(spec);
528
+ const github = parsePluginSource("placeholder", spec);
529
+ const npm = named && parsePluginSource(named[1], named[2] || "latest");
530
+ if (github.kind !== "github" && npm?.kind !== "npm") throw Error("\u4EC5\u652F\u6301 npm \u5305\u540D / \u7248\u672C\u6216 github:\u7528\u6237/\u4ED3\u5E93");
531
+ this.start("install", async (job) => {
532
+ job.total = 1;
533
+ job.current = spec;
534
+ const result = await this.service().installBundle(spec, { requestId: job.id, ...registry ? { registry } : {} });
535
+ job.results.push({
536
+ name: result.bundle ?? spec,
537
+ status: result.error || ["failed", "cancelled"].includes(result.application) || result.packageResult && result.packageResult.exitCode !== 0 ? "failed" : "installed",
538
+ message: result.error ? `\u5B89\u88C5\u672A\u5B8C\u6210\uFF08${result.error.code}\uFF09\uFF0C\u8BF7\u67E5\u770B DSH \u63D2\u4EF6\u5B89\u88C5\u65E5\u5FD7` : result.application === "restart-required" ? "\u5DF2\u5B89\u88C5\uFF0C\u8BF7\u5B8C\u5168\u9000\u51FA\u5E76\u91CD\u542F DSH" : "\u5B89\u88C5\u5B8C\u6210",
539
+ application: result.application,
540
+ pendingBuilds: result.pendingBuilds ?? [],
541
+ restartRequired: result.application === "restart-required"
542
+ });
543
+ delete job.current;
544
+ await this.list();
545
+ });
546
+ }
547
+ async action(input) {
548
+ if (!this.state().available) throw Error("\u5F53\u524D DSH \u672A\u63D0\u4F9B\u63D2\u4EF6\u7BA1\u7406\u63A5\u53E3\uFF0C\u8BF7\u5347\u7EA7 DSH \u540E\u91CD\u8BD5");
549
+ if (input.registry && !["https://registry.npmjs.org/", "https://registry.npmmirror.com/"].includes(input.registry)) throw Error("\u65E0\u6548\u7684 npm \u6765\u6E90");
550
+ if (input.action === "plugins.list") {
551
+ if (!this.flight) await this.list();
552
+ } else if (input.action === "plugins.check") this.check();
553
+ else if (input.action === "plugins.update") this.update(input.names, input.registry);
554
+ else if (input.action === "plugins.install") this.install(input.spec, input.registry);
555
+ else if (input.action === "plugins.cancel") {
556
+ if (this.job?.state === "running") {
557
+ this.job.cancelRequested = true;
558
+ if (this.job.kind !== "check") await this.service().cancelInstall?.(this.job.id);
559
+ }
560
+ } else throw Error("\u672A\u77E5\u63D2\u4EF6\u64CD\u4F5C");
561
+ }
562
+ };
563
+
236
564
  // src/manager.mjs
237
565
  var MANAGEMENT_PATH = "/.well-known/dsh-multi-router";
238
566
  var revisionOf = (raw) => createHash("sha256").update(JSON.stringify(raw)).digest("hex");
@@ -272,28 +600,35 @@ function modelInfo(row) {
272
600
  }
273
601
  var Manager = class {
274
602
  constructor(file, router, onChange = () => {
275
- }) {
603
+ }, pluginAccess = {}) {
276
604
  this.file = file;
277
605
  this.router = router;
278
606
  this.onChange = onChange;
279
- this.catalogFile = join3(dirname3(file), "catalogs.json");
607
+ this.catalogFile = join4(dirname3(file), "catalogs.json");
280
608
  this.catalogs = /* @__PURE__ */ new Map();
281
609
  this.catalogErrors = /* @__PURE__ */ new Map();
282
610
  this.refreshes = /* @__PURE__ */ new Map();
283
611
  this.closed = false;
284
612
  this.monitorFlights = /* @__PURE__ */ new Map();
285
613
  this.loginInfo = /* @__PURE__ */ new Map();
286
- this.telemetry = new Telemetry(join3(dirname3(file), "usage.json"));
614
+ this.telemetry = new Telemetry(join4(dirname3(file), "usage.json"));
615
+ this.plugins = new PluginUpdates(pluginAccess);
616
+ this.setupFile = join4(dirname3(file), "initial-setup.json");
287
617
  router.telemetry = this.telemetry;
288
618
  router.monitor = (a) => this.monitorAccount(a);
289
619
  router.prepareFree = () => this.checkFreeModels(true);
290
620
  router.freeModels = (account) => this.freeModels(account);
291
621
  }
292
622
  async load() {
293
- this.raw = JSON.parse(await readFile(this.file, "utf8"));
623
+ this.raw = JSON.parse(await readFile2(this.file, "utf8"));
624
+ try {
625
+ this.setup = JSON.parse(await readFile2(this.setupFile, "utf8"));
626
+ } catch {
627
+ this.setup = void 0;
628
+ }
294
629
  this.config = validateConfig(this.raw, this.file);
295
630
  try {
296
- const saved = JSON.parse(await readFile(this.catalogFile, "utf8"));
631
+ const saved = JSON.parse(await readFile2(this.catalogFile, "utf8"));
297
632
  for (const account of this.config.accounts) {
298
633
  const item = saved[account.id];
299
634
  if (item?.credentialFile === account.credentialFile && Array.isArray(item.models)) {
@@ -335,8 +670,9 @@ var Manager = class {
335
670
  if (this.closed || !discovered.accounts.length) return;
336
671
  await withFileLock(this.file, async () => {
337
672
  if (this.closed) return;
338
- let raw = JSON.parse(await readFile(this.file, "utf8"));
673
+ let raw = JSON.parse(await readFile2(this.file, "utf8"));
339
674
  let changed = false;
675
+ const saved = /* @__PURE__ */ new Map();
340
676
  for (const item of discovered.accounts) {
341
677
  const key = localAccountKey(item.provider, item.document);
342
678
  if (!force && raw.ignoredLocalAccounts?.includes(key)) continue;
@@ -353,6 +689,11 @@ var Manager = class {
353
689
  } catch {
354
690
  }
355
691
  }
692
+ const id = existing?.id ?? `local_${createHash("sha256").update(key).digest("hex").slice(0, 20)}`;
693
+ const recordSaved = () => {
694
+ if (!saved.has(item.provider)) saved.set(item.provider, /* @__PURE__ */ new Set());
695
+ saved.get(item.provider).add(id);
696
+ };
356
697
  let sameDevice = true;
357
698
  if (existing && item.identityDocument) {
358
699
  try {
@@ -362,13 +703,15 @@ var Manager = class {
362
703
  sameDevice = false;
363
704
  }
364
705
  }
365
- if (existing && sameDevice && current.credential.expiresAtMs >= item.document.credential.expiresAtMs) continue;
366
- const id = existing?.id ?? `local_${createHash("sha256").update(key).digest("hex").slice(0, 20)}`;
367
- const base = join3(dirname3(this.file), "accounts");
706
+ if (existing && sameDevice && current.credential.expiresAtMs >= item.document.credential.expiresAtMs) {
707
+ recordSaved();
708
+ continue;
709
+ }
710
+ const base = join4(dirname3(this.file), "accounts");
368
711
  await mkdir2(base, { recursive: true, mode: 448 });
369
- const staging = await mkdtemp2(join3(base, `${id}-`));
712
+ const staging = await mkdtemp2(join4(base, `${id}-`));
370
713
  try {
371
- const credentialFile = join3(staging, "credential.json"), identityFile = join3(staging, "identity.json");
714
+ const credentialFile = join4(staging, "credential.json"), identityFile = join4(staging, "identity.json");
372
715
  await importAccount({ ...item, credentialFile, identityFile });
373
716
  const paths = {
374
717
  credentialFile: relative2(dirname3(this.file), credentialFile),
@@ -387,6 +730,7 @@ var Manager = class {
387
730
  await writePrivateJson(this.file, next);
388
731
  raw = next;
389
732
  changed = true;
733
+ recordSaved();
390
734
  this.router.clients.delete(id);
391
735
  this.catalogs.delete(id);
392
736
  this.catalogErrors.delete(id);
@@ -401,12 +745,25 @@ var Manager = class {
401
745
  await Promise.all(this.config.accounts.map((a) => this.readLogin(a)));
402
746
  this.publish();
403
747
  }
748
+ this.localDiscovery = this.localDiscovery.map((s) => discovered.status.some((d) => d.provider === s.provider) ? { ...s, savedCount: saved.get(s.provider)?.size ?? 0 } : s);
404
749
  }, { waitMs: 3e4 });
405
750
  })().finally(() => {
406
751
  this.localScan = void 0;
407
752
  });
408
753
  return this.localScan;
409
754
  }
755
+ async finishInitialSetup() {
756
+ if (this.setup?.completedAt) return;
757
+ await withFileLock(this.setupFile, async () => {
758
+ try {
759
+ this.setup = JSON.parse(await readFile2(this.setupFile, "utf8"));
760
+ } catch {
761
+ }
762
+ if (this.setup?.completedAt) return;
763
+ this.setup = { completedAt: Date.now(), dismissed: false };
764
+ await writePrivateJson(this.setupFile, this.setup);
765
+ });
766
+ }
410
767
  async monitorAccount(account) {
411
768
  if (this.monitorFlights.has(account.id)) return this.monitorFlights.get(account.id);
412
769
  const promise = (async () => {
@@ -597,6 +954,8 @@ var Manager = class {
597
954
  pluginVersion: version,
598
955
  revision: this.revision,
599
956
  localDiscovery: this.localDiscovery ?? [],
957
+ setup: this.setup,
958
+ pluginUpdates: this.plugins.state(),
600
959
  telemetry: this.telemetry.state(),
601
960
  accounts: this.config.accounts.map((a) => ({
602
961
  id: a.id,
@@ -631,8 +990,23 @@ var Manager = class {
631
990
  }
632
991
  async action(input) {
633
992
  if (!input || typeof input !== "object" || Array.isArray(input)) bad("\u8BF7\u6C42\u683C\u5F0F\u65E0\u6548");
993
+ if (typeof input.action === "string" && input.action.startsWith("plugins.")) {
994
+ try {
995
+ await this.plugins.action(input);
996
+ } catch (e) {
997
+ bad(e.message);
998
+ }
999
+ return this.state();
1000
+ }
1001
+ if (input.action === "setup.dismiss") {
1002
+ await withFileLock(this.setupFile, async () => {
1003
+ this.setup = { ...this.setup, completedAt: this.setup?.completedAt ?? Date.now(), dismissed: true };
1004
+ await writePrivateJson(this.setupFile, this.setup);
1005
+ });
1006
+ return this.state();
1007
+ }
634
1008
  if (input.action === "account.export") {
635
- if (input.revision !== revisionOf(JSON.parse(await readFile(this.file, "utf8")))) throw new ManagementError(409, "\u914D\u7F6E\u5DF2\u66F4\u65B0\uFF0C\u8BF7\u5237\u65B0\u540E\u63D0\u53D6\u51ED\u8BC1");
1009
+ if (input.revision !== revisionOf(JSON.parse(await readFile2(this.file, "utf8")))) throw new ManagementError(409, "\u914D\u7F6E\u5DF2\u66F4\u65B0\uFF0C\u8BF7\u5237\u65B0\u540E\u63D0\u53D6\u51ED\u8BC1");
636
1010
  const account = this.config.accounts.find((a) => a.id === input.id);
637
1011
  if (!account) bad("\u8D26\u53F7\u4E0D\u5B58\u5728");
638
1012
  let credential;
@@ -654,7 +1028,7 @@ var Manager = class {
654
1028
  } };
655
1029
  }
656
1030
  if (input.action === "config.export") {
657
- if (input.revision !== revisionOf(JSON.parse(await readFile(this.file, "utf8")))) throw new ManagementError(409, "\u914D\u7F6E\u5DF2\u66F4\u65B0\uFF0C\u8BF7\u5237\u65B0\u540E\u5BFC\u51FA");
1031
+ if (input.revision !== revisionOf(JSON.parse(await readFile2(this.file, "utf8")))) throw new ManagementError(409, "\u914D\u7F6E\u5DF2\u66F4\u65B0\uFF0C\u8BF7\u5237\u65B0\u540E\u5BFC\u51FA");
658
1032
  try {
659
1033
  return { ...this.state(), exported: { filename: `dsh-multi-router-${input.includeCredentials ? "encrypted" : "settings"}-${(/* @__PURE__ */ new Date()).toISOString().slice(0, 10)}.json`, document: await exportConfiguration(this, input) } };
660
1034
  } catch (e) {
@@ -702,7 +1076,7 @@ var Manager = class {
702
1076
  return this.state();
703
1077
  }
704
1078
  return withFileLock(this.file, async () => {
705
- let raw = JSON.parse(await readFile(this.file, "utf8"));
1079
+ let raw = JSON.parse(await readFile2(this.file, "utf8"));
706
1080
  if (input.revision !== revisionOf(raw)) throw new ManagementError(409, "\u914D\u7F6E\u5DF2\u66F4\u65B0\uFF0C\u8BF7\u5237\u65B0\u540E\u518D\u4FDD\u5B58");
707
1081
  raw.routes ??= structuredClone(this.config.routes);
708
1082
  let staging;
@@ -725,10 +1099,10 @@ var Manager = class {
725
1099
  }
726
1100
  if (!/^[a-zA-Z0-9_-]{1,64}$/.test(input.id ?? "") || !PROVIDERS.includes(input.provider)) bad("\u8D26\u53F7 ID \u6216\u4F9B\u5E94\u5546\u65E0\u6548");
727
1101
  if (account && !replacing) bad("\u8D26\u53F7 ID \u5DF2\u5B58\u5728\uFF0C\u8BF7\u4F7F\u7528\u4E0D\u540C ID");
728
- const base = join3(dirname3(this.file), "accounts");
1102
+ const base = join4(dirname3(this.file), "accounts");
729
1103
  await mkdir2(base, { recursive: true, mode: 448 });
730
- staging = await mkdtemp2(join3(base, `${input.id}-`));
731
- const credentialFile = join3(staging, "credential.json"), identityFile = join3(staging, "identity.json");
1104
+ staging = await mkdtemp2(join4(base, `${input.id}-`));
1105
+ const credentialFile = join4(staging, "credential.json"), identityFile = join4(staging, "identity.json");
732
1106
  const added = {
733
1107
  id: input.id,
734
1108
  name: input.name || account?.name || input.id,
@@ -917,7 +1291,7 @@ var Manager = class {
917
1291
  clearTimeout(timer);
918
1292
  timer = setTimeout(() => {
919
1293
  void withFileLock(this.file, async () => {
920
- const raw = JSON.parse(await readFile(this.file, "utf8"));
1294
+ const raw = JSON.parse(await readFile2(this.file, "utf8"));
921
1295
  if (this.closed || revisionOf(raw) === this.revision) return;
922
1296
  const config = validateConfig(raw, this.file);
923
1297
  this.raw = raw;
@@ -984,4 +1358,4 @@ export {
984
1358
  Manager,
985
1359
  managementHandler
986
1360
  };
987
- //# sourceMappingURL=chunk-WIDXMBQ2.js.map
1361
+ //# sourceMappingURL=chunk-UQS7DNOQ.js.map