dsh-connect-trae 3.5.1 → 3.6.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
@@ -1,5 +1,55 @@
1
1
  # Changelog
2
2
 
3
+ ## 3.6.0 (2026-10-16)
4
+
5
+ > **多个账号可以并存了。** 本版新增**账号导入 / 导出**:不再需要每次把某台机器的登录凭据
6
+ > 手工写进默认路径来顶掉上一个账号 —— 导出一份 JSON、在另一处导入即可。同时补上
7
+ > `#39` 那条诊断:上游没下发 function 配置时,报错不再读起来像本地故障。
8
+
9
+ 这是一次 **minor** 升级(新增用户可见的界面与能力)。
10
+
11
+ ### 新增
12
+
13
+ - **账号导入 / 导出(issue #37)**:账号区新增一行「导入 / 导出账号」,
14
+ - **导出**:勾选若干账号 → 下载一个 JSON 文件(`trae-accounts-YYYY-MM-DD.json`);
15
+ - **导入**:选择文件 → **脱敏预览**(只显示账号标识与「是否有 Token」,不显示凭据)→ 勾选 → 导入;
16
+ - 文件格式与同族 `dsh-connect-workbuddy` **互通**(顶层 snake_case:`access_token` / `refresh_token` /
17
+ `uid` / `nickname` / `domain` / `expiresAt`(毫秒) / `refreshExpiresAt` / `auth_raw`),
18
+ 所以两边导出的文件可以互相导入。
19
+ - **顺带把存储升级成多账号**:本插件原先每个区域只保存**一个**凭据,导入的第二个账号无处可放。
20
+ 现改为**每区域一个文件保存凭据数组**(格式版本 1→2),**旧文件仍可读**、在下一次写入时自动升级。
21
+ - 导入按钮在**未登录时也可用** —— 一台从没登录过的机器,正是最需要导入别处导出的文件的时候。
22
+
23
+ > **区域门禁是正向匹配**:导入文件里的 `domain` 是文件作者可控的值,而本插件「认不出就当作国内版」
24
+ > 的规则对**上游下发**的数据是对的、对**导入文件**是危险的(伪造域名会被塞进国内版)。
25
+ > 因此导入走一条**只认正向识别**的通道:空值、认不出的值、以及明确属于另一区域的值一律**跳过并计数**,
26
+ > 绝不静默落库。国内版与国际版是两个并行的供应商栈,跨区导入会导致跨区计费。
27
+
28
+ ### 修复
29
+
30
+ - **修复一个会导致账号丢失的写入缺陷(本次实现导入功能时发现)**:本插件的刷新路径原先把
31
+ 「刚刷新好的那一个凭据」当作**整个文件**写回。在单凭据时代这没问题;一旦一个区域能保存多个账号
32
+ (正是导入带来的),**刷新一个账号就会把同区域的其他账号全部删掉**。
33
+ 已改为**读-改-写**:只替换被刷新账号自己那一条,其余条目原样保留。回归测试专门钉住了这一点。
34
+
35
+ - **上游未下发 function 配置时,报错读起来不再像本地故障(issue #39)**:
36
+ - 报告者遇到的现象是每次调用都返回 `2001 no function config found for appId=…`,
37
+ 他据此排查了一圈本地凭据与插件版本,最后才发现问题在服务端 —— 上游的
38
+ `get_detail_param` 当时对**每个** SOLO function 都返回空的 `config_info_list`,随后**自行恢复**。
39
+ - 现在(a)`2001` 有了明确文案:**「上游尚未下发本通道的 function 配置(这是上游侧的状态,
40
+ 不是本地凭据或配置问题)」**;(b)当**所有** function 都返回 200 却一条配置都没有时,
41
+ 目录读取会抛出专用的 `TraeEmptySoloDirectory`,把「上游没下发」与「所有 function 都请求失败」
42
+ 区分开。两者都不暗示需要重新登录,因为这个条件是**会自行恢复**的。
43
+
44
+ ### 测试
45
+
46
+ - 新增 4 个测试文件、共 34 例:导入导出格式(解析 / 脱敏预览 / 往返 / 区域门禁)、
47
+ 导入路由(HTTP 边界上的伪造区域拒绝、导入前重新解析文件而非信任预览)、
48
+ 存储升级与**刷新不吞账号**、以及端到端的「一处导出 → 另一处导入」。
49
+ - 区域门禁做了**变异验证**:把门禁改成 fail-open(正是同族插件 `#42` 修过的那个缺陷)后,
50
+ 格式层与路由层**各有用例变红**;把毫秒单位改成错误的换算同样被抓住。
51
+ - 全仓 **655** 例测试通过,类型检查通过。
52
+
3
53
  ## 3.5.1 (2026-10-16)
4
54
 
5
55
  > **DeepSeek-V4 四个模型行不再必然报 4001。** 本版修一处 wire 名单合并的方向错误:
package/README.md CHANGED
@@ -42,6 +42,7 @@
42
42
  - **识图能力跟随 Trae 自身的标注** —— Trae 把某个模型标为 `multimodal` 时,插件直接按多模态对外声明,无需你在卡片上逐一手勾(issue #16)。判据是「**你手动勾选的 ∪ Trae 标注多模态的**」:Trae 明确标 `false` 或未标注的模型保持纯文本,你可以照旧手动为它们打开图片输入。卡片上被自动开启的那一行显示为已勾选且只读,并说明原因。详见下方「图片输入(识图)」。
43
43
  - **多账号切换** —— 支持重新读取 Token 列表并选择账号;Token 不写入 DSH 设置。**你的选择会被记住并长期生效**:插件不会因为某个账号额度用尽、或检测到另一个可用账号就自作主张替你切换(换账号意味着换人计费)。所以当绑定的账号额度耗尽时,卡片会**明确告诉你**,并把本机其他还有额度的账号连同其可用积分数列出来,由你一键切换——而不是让面板一直空着、也不自动把你切走。「刷新账号与 Token」每次都会回报结果(找到几个账号、当前仍绑定谁),不会再「点了没反应」。下拉里显示的是产品名(`Trae` / `TRAE SOLO` / 国际版),便于分辨凭据来自哪个安装。
44
44
  - **跟随 Trae 当前登录的账号** —— 你在 Trae 桌面端**退出并换登录**后,卡片会检测到「插件绑定的账号」与「Trae 现在登录的账号」不一致,并给出一行提示与**一键切换**(复用账号下拉的同一次写入,不引入第二条切换路径)。想让插件**自动跟随**,可打开设置卡片里的「跟随 Trae 当前登录的账号」开关:此后每次配置生效都会把 `accounts.<region>` 重绑到 App 当前登录的账号。**该开关默认关闭**——已有配置保持原来的严格绑定,自动换绑意味着换人计费,必须由你主动勾选;未勾选时上面那行提示就是你的手动通道。(issue #29)
45
+ - **账号导入 / 导出** —— 不用再为了用第二个账号而把凭据手工写进默认路径、顶掉上一个。在卡片账号区的「导入 / 导出账号」里:**导出**勾选若干账号下载一份 JSON;**导入**选择文件后先看**脱敏预览**(只显示账号标识与「是否有 Token」,不含凭据本身)再勾选导入。文件格式与同族 `dsh-connect-workbuddy` **互通**,两边导出的文件可以互相导入。导入按账号去重(同一账号再导入即覆盖更新),**认不出所属区域或因缺 Token 的记录会被跳过并计数**——国内版与国际版是两条并行计费通道,跨区导入会导致跨区计费,所以这里只认同能**正向识别**的区域。导出文件里是**有效 Token,请当作密码保管**。(issue #37)
45
46
  - **只读用量与模型管理** —— 国内版查看 Work 积分与通用积分,国际版查看订阅/试用状态;刷新/启用 Trae 模型;只读查询不消耗额度。
46
47
  - **积分临近到期会提醒** —— 剩余额度只回答「还有多少」,回答不了「**哪一笔先到期**」;提醒条按到期紧迫程度逐级加重(**7 天 → 24 小时 → 已过期**),写明金额、套餐数和最早到期时间,避免只盯总额时悄悄丢掉最先到期的额度。国内版在用量页与输入框积分面板两处都显示(国际版是订阅制,没有逐包到期数据)。
47
48
  - **一键测试启用模型** —— 给**已启用**的每个模型发一条最小消息(一句 `ping`、不带工具),逐个告诉你能不能真的应答:**可用** / **需要更高档位**(Trae `1005`,你的套餐不覆盖)/ **上游不提供**(`4001`,SOLO 通道没有这个模型)/ **被限流**(`4011`,Trae 限了这条通道)/ **登录凭据被拒** / **本次未测出**。最后一项是一等公民:超时、断连、代理、5xx **一律不算「不可用」**——只有上游明确的拒绝才算负面结论。**这是插件里唯一消耗额度的操作**(签到改变的是账号状态,不消耗),只在你点击时运行,结果只留在当前会话里不写入设置。
package/lib/client.js CHANGED
@@ -48,6 +48,17 @@ window.__ModuleLoader__.load({
48
48
  * account row.
49
49
  */
50
50
  const TRAE_CHECKIN_PATH = "/plugins/dsh-connect-trae/checkin";
51
+ /**
52
+ * Plugin-owned account transfer endpoint: batch export and import of a region's
53
+ * stored credentials (issue #37).
54
+ *
55
+ * One route with an `action` query parameter (`export` | `preview` | `import`),
56
+ * mirroring the sibling `dsh-connect-workbuddy` plugin's transfer route so the
57
+ * two cards behave the same way for a user who has both installed. The vault
58
+ * document never leaves the Host: the browser receives parsed answers, and a
59
+ * preview carries display fields only — never token material.
60
+ */
61
+ const TRAE_TRANSFER_PATH = "/plugins/dsh-connect-trae/transfer";
51
62
  /** Query parameter naming the region a card request addresses. */
52
63
  const TRAE_REGION_PARAM = "region";
53
64
  /** Every region, in card tab order. */
@@ -60,6 +71,18 @@ window.__ModuleLoader__.load({
60
71
  return `${path}?${TRAE_REGION_PARAM}=${region}`;
61
72
  }
62
73
  /**
74
+ * Address one region's transfer route with its step, e.g.
75
+ * `/plugins/dsh-connect-trae/transfer?region=cn&action=export`.
76
+ *
77
+ * Separate from {@link withTraeRegion} because this route carries a second
78
+ * query parameter: a caller that appended `&action=` by hand would be one typo
79
+ * away from a 400, which is exactly the failure the sibling workbuddy plugin
80
+ * hit (#42) when five requests dropped that parameter.
81
+ */
82
+ function withTraeTransferAction(path, region, action) {
83
+ return `${path}?${TRAE_REGION_PARAM}=${region}&action=${action}`;
84
+ }
85
+ /**
63
86
  * Peel ONE `{get(): T}` live reference, the shape DSH 0.1.7 delivers a
64
87
  * volatile-marked settings field as (see {@link unwrapVolatileDeep}).
65
88
  */
@@ -482,6 +505,32 @@ window.__ModuleLoader__.load({
482
505
  .dsm-trae-expiry-week{border-color:var(--dsw-alias-border-l2,#3a3d45);background:var(--dsw-alias-bg-layer-3,#2c2f36);color:var(--dsw-alias-label-primary,#e6e6e6)}
483
506
  .dsm-trae-expiry-lapsed{border-color:var(--dsw-alias-border-l2,#3a3d45);background:var(--dsw-alias-bg-layer-3,#2c2f36);color:var(--dsw-alias-label-tertiary,#999)}
484
507
  .dsm-trae-expiry-detail{font-size:11px;line-height:16px;opacity:.8}
508
+ /* Account import / export (issue #37). A sub-block of the accounts section:
509
+ the transfer row, plus the two dialogs it opens in place. The file input is
510
+ hidden rather than absent — the button opens the system picker, and the input
511
+ must stay mounted to be clicked. */
512
+ .dsm-trae-transfer{margin-top:12px;padding-top:10px;border-top:1px solid var(--dsw-alias-border-l2,rgba(127,127,127,.2))}
513
+ .dsm-trae-transfer-label{margin:0 0 6px;font-size:11px;line-height:16px;color:var(--dsw-alias-label-secondary,GrayText)}
514
+ .dsm-trae-transfer-actions{display:flex;flex-wrap:wrap;gap:8px}
515
+ .dsm-trae-transfer-file{display:none}
516
+ .dsm-trae-transfer-status{margin:6px 0 0;font-size:11px;line-height:16px}
517
+ .dsm-trae-transfer-ok{color:var(--dsw-alias-state-success-primary,#16a34a)}
518
+ .dsm-trae-transfer-error{color:var(--dsw-alias-state-error-primary,#dc2626)}
519
+ .dsm-trae-transfer-dialog{
520
+ margin-top:8px;padding:10px;border:1px solid var(--dsw-alias-border-l2,rgba(127,127,127,.28));
521
+ border-radius:8px;font-size:12px;line-height:18px;
522
+ }
523
+ .dsm-trae-transfer-dialog>p{margin:0 0 6px}
524
+ .dsm-trae-transfer-list{list-style:none;margin:6px 0;padding:0;max-height:180px;overflow:auto}
525
+ .dsm-trae-transfer-list label{display:flex;align-items:center;gap:6px;padding:2px 0;cursor:pointer}
526
+ .dsm-trae-transfer-list input{flex:none}
527
+ .dsm-trae-transfer-dialog-actions{display:flex;flex-wrap:wrap;gap:8px;margin-top:8px}
528
+ /* A text-button rather than a boxed one: "select all" is a shortcut inside the
529
+ dialog, and a third button would compete with the two that act. */
530
+ .dsm-trae-transfer-link{
531
+ margin-top:6px;padding:0;border:0;background:none;cursor:pointer;
532
+ color:var(--dsw-alias-brand-primary,#2563eb);font-size:11px;line-height:16px;
533
+ }
485
534
  .dsm-trae-models-hint{margin:4px 0 0;color:var(--dsw-alias-label-tertiary,#999);font-size:11px;line-height:16px}
486
535
  /* Verdict badge. Only a real refusal is coloured as a warning; an unknown
487
536
  verdict stays neutral because an inconclusive probe is not evidence against
@@ -785,6 +834,368 @@ window.__ModuleLoader__.load({
785
834
  });
786
835
  }
787
836
  //#endregion
837
+ //#region src/client/AccountTransfer.tsx
838
+ /**
839
+ * The card's account-transfer section: batch export and import of a region's
840
+ * stored credentials, in the file format shared with sibling credential
841
+ * managers for the same upstream (the `dsh-connect-workbuddy` family).
842
+ *
843
+ * Modelled on that plugin's `AccountTransfer`, deliberately: the two plugins
844
+ * share one external-presentation language, and a user who has imported
845
+ * accounts in one should recognise the flow in the other. The shape is a single
846
+ * section owning all of its state (busy / result line / error), with the export
847
+ * dialog (pick accounts → download JSON) and the import dialog (pick file →
848
+ * desensitised preview → tick → import) split the same way.
849
+ *
850
+ * Two properties are load-bearing and must not be relaxed:
851
+ * - The preview carries display fields and a `hasToken` boolean only. Tokens
852
+ * never enter the browser at all until the user explicitly exports a chosen
853
+ * account, and the Host re-parses the file on import rather than trusting
854
+ * anything this component says about it.
855
+ * - Import is offered even when the region has NO account: a machine that has
856
+ * never signed in is exactly where importing someone else's export matters
857
+ * most. Only EXPORT needs an existing account to select.
858
+ *
859
+ * The download uses `<a download>` on a Blob because the card runs inside the
860
+ * settings iframe and has no host save dialog; a same-origin anchor download is
861
+ * the reliable path there.
862
+ *
863
+ * @module dsh-connect-trae/client/AccountTransfer
864
+ */
865
+ /** How long the veil may wait for the card's re-read before it comes down. */
866
+ const REFRESH_SETTLE_TIMEOUT_MS = 5e3;
867
+ /** Build the default export file name: trae-accounts-YYYY-MM-DD.json. */
868
+ function exportFileName() {
869
+ const pad = (value) => String(value).padStart(2, "0");
870
+ const now = /* @__PURE__ */ new Date();
871
+ return `trae-accounts-${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())}.json`;
872
+ }
873
+ /** Download a JSON value as a local file (no host save dialog inside the iframe). */
874
+ function downloadJson(fileName, data) {
875
+ const blob = new Blob([JSON.stringify(data, null, 2)], { type: "application/json" });
876
+ const url = URL.createObjectURL(blob);
877
+ const anchor = document.createElement("a");
878
+ anchor.href = url;
879
+ anchor.download = fileName;
880
+ document.body.appendChild(anchor);
881
+ anchor.click();
882
+ anchor.remove();
883
+ URL.revokeObjectURL(url);
884
+ }
885
+ /** Resolve once `value` settles, or after `timeoutMs` — whichever comes first. */
886
+ async function settlesWithin(value, timeoutMs) {
887
+ await Promise.race([Promise.resolve(value).catch(() => void 0), new Promise((resolve) => {
888
+ setTimeout(resolve, timeoutMs);
889
+ })]);
890
+ }
891
+ function AccountTransfer({ t, region, accounts, onChanged }) {
892
+ const [phase, setPhase] = (0, react.useState)({ kind: "idle" });
893
+ const [dialog, setDialog] = (0, react.useState)(void 0);
894
+ const [selected, setSelected] = (0, react.useState)(/* @__PURE__ */ new Set());
895
+ const [preview, setPreview] = (0, react.useState)(void 0);
896
+ const [importSelected, setImportSelected] = (0, react.useState)(/* @__PURE__ */ new Set());
897
+ const fileInput = (0, react.useRef)(null);
898
+ /**
899
+ * The text of the file the user just picked, held here rather than re-read
900
+ * from the input at import time: the picker is a bare system dialog that
901
+ * closes immediately, so between preview and confirm no dialog holds the
902
+ * File. Re-reading `fileInput.current.files` would also break the moment
903
+ * anything clears the input — and it must be cleared after every pick, or
904
+ * choosing the SAME file twice fires no change event.
905
+ */
906
+ const importText = (0, react.useRef)("");
907
+ const mounted = (0, react.useRef)(true);
908
+ (0, react.useEffect)(() => {
909
+ mounted.current = true;
910
+ return () => {
911
+ mounted.current = false;
912
+ };
913
+ }, []);
914
+ const closeDialog = () => {
915
+ setDialog(void 0);
916
+ setSelected(/* @__PURE__ */ new Set());
917
+ setPreview(void 0);
918
+ setImportSelected(/* @__PURE__ */ new Set());
919
+ };
920
+ const runExport = async () => {
921
+ if (selected.size === 0) return;
922
+ setPhase({ kind: "exporting" });
923
+ try {
924
+ const response = await fetch(withTraeTransferAction(TRAE_TRANSFER_PATH, region, "export"), {
925
+ method: "POST",
926
+ headers: {
927
+ "content-type": "application/json",
928
+ accept: "application/json"
929
+ },
930
+ credentials: "same-origin",
931
+ body: JSON.stringify({ accountIds: [...selected] })
932
+ });
933
+ const body = await response.json();
934
+ if (!mounted.current) return;
935
+ if (!response.ok || !Array.isArray(body.accounts)) {
936
+ setPhase({
937
+ kind: "error",
938
+ message: body.error ?? `HTTP ${response.status}`
939
+ });
940
+ return;
941
+ }
942
+ const fileName = exportFileName();
943
+ downloadJson(fileName, body.accounts);
944
+ closeDialog();
945
+ setPhase({
946
+ kind: "done",
947
+ message: t("row.transferExported", {
948
+ count: body.accounts.length,
949
+ fileName
950
+ })
951
+ });
952
+ } catch (error) {
953
+ if (mounted.current) setPhase({
954
+ kind: "error",
955
+ message: error instanceof Error ? error.message : String(error)
956
+ });
957
+ }
958
+ };
959
+ const readImportFile = async (file) => {
960
+ setPhase({ kind: "importing" });
961
+ try {
962
+ const text = await file.text();
963
+ importText.current = text;
964
+ const response = await fetch(withTraeTransferAction(TRAE_TRANSFER_PATH, region, "preview"), {
965
+ method: "POST",
966
+ headers: {
967
+ "content-type": "application/json",
968
+ accept: "application/json"
969
+ },
970
+ credentials: "same-origin",
971
+ body: JSON.stringify({ text })
972
+ });
973
+ const body = await response.json();
974
+ if (!mounted.current) return;
975
+ if (!response.ok || !Array.isArray(body.accounts)) {
976
+ setPhase({
977
+ kind: "error",
978
+ message: body.error ?? `HTTP ${response.status}`
979
+ });
980
+ return;
981
+ }
982
+ setPreview(body);
983
+ setImportSelected(new Set(body.accounts.filter((entry) => entry.hasToken).map((entry) => entry.index)));
984
+ setPhase({ kind: "idle" });
985
+ } catch (error) {
986
+ if (mounted.current) setPhase({
987
+ kind: "error",
988
+ message: error instanceof Error ? error.message : String(error)
989
+ });
990
+ }
991
+ };
992
+ const runImport = async () => {
993
+ if (preview === void 0 || importSelected.size === 0 || importText.current === "") return;
994
+ setPhase({ kind: "importing" });
995
+ try {
996
+ const response = await fetch(withTraeTransferAction(TRAE_TRANSFER_PATH, region, "import"), {
997
+ method: "POST",
998
+ headers: {
999
+ "content-type": "application/json",
1000
+ accept: "application/json"
1001
+ },
1002
+ credentials: "same-origin",
1003
+ body: JSON.stringify({
1004
+ text: importText.current,
1005
+ indexes: [...importSelected]
1006
+ })
1007
+ });
1008
+ const body = await response.json();
1009
+ if (!mounted.current) return;
1010
+ if (!response.ok || typeof body.imported !== "number") {
1011
+ setPhase({
1012
+ kind: "error",
1013
+ message: body.error ?? `HTTP ${response.status}`
1014
+ });
1015
+ return;
1016
+ }
1017
+ closeDialog();
1018
+ const message = t("row.transferImported", {
1019
+ imported: body.imported,
1020
+ skipped: body.skipped ?? 0
1021
+ });
1022
+ setPhase({
1023
+ kind: "refreshing",
1024
+ message
1025
+ });
1026
+ await settlesWithin(onChanged?.(), REFRESH_SETTLE_TIMEOUT_MS);
1027
+ if (!mounted.current) return;
1028
+ setPhase({
1029
+ kind: "done",
1030
+ message
1031
+ });
1032
+ } catch (error) {
1033
+ if (mounted.current) setPhase({
1034
+ kind: "error",
1035
+ message: error instanceof Error ? error.message : String(error)
1036
+ });
1037
+ }
1038
+ };
1039
+ const busy = phase.kind === "exporting" || phase.kind === "importing" || phase.kind === "refreshing";
1040
+ const veilMessage = phase.kind === "exporting" ? t("row.transferExporting") : phase.kind === "importing" || phase.kind === "refreshing" ? t("row.transferImporting") : "";
1041
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("section", {
1042
+ className: "dsm-trae-transfer",
1043
+ "aria-label": t("row.transferTitle"),
1044
+ children: [
1045
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)(BusyOverlay, {
1046
+ visible: busy,
1047
+ message: veilMessage,
1048
+ ...busy ? { hint: t("row.transferVeilHint") } : {}
1049
+ }),
1050
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
1051
+ className: "dsm-trae-transfer-label",
1052
+ children: t("row.transferTitle")
1053
+ }),
1054
+ phase.kind === "done" ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
1055
+ className: "dsm-trae-transfer-status dsm-trae-transfer-ok",
1056
+ children: phase.message
1057
+ }) : phase.kind === "error" ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
1058
+ className: "dsm-trae-transfer-status dsm-trae-transfer-error",
1059
+ children: t("row.transferError", { message: phase.message })
1060
+ }) : null,
1061
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
1062
+ className: "dsm-trae-transfer-actions",
1063
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
1064
+ type: "button",
1065
+ className: "dsm-btn dsm-btn-outline",
1066
+ disabled: busy || accounts.length === 0,
1067
+ onClick: () => {
1068
+ setSelected(/* @__PURE__ */ new Set());
1069
+ setDialog("export");
1070
+ },
1071
+ children: t("row.transferExport")
1072
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
1073
+ type: "button",
1074
+ className: "dsm-btn dsm-btn-outline",
1075
+ disabled: busy,
1076
+ onClick: () => {
1077
+ fileInput.current?.click();
1078
+ },
1079
+ children: t("row.transferImport")
1080
+ })]
1081
+ }),
1082
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("input", {
1083
+ ref: fileInput,
1084
+ type: "file",
1085
+ accept: "application/json,.json",
1086
+ className: "dsm-trae-transfer-file",
1087
+ onChange: (event) => {
1088
+ const file = event.target.files?.[0];
1089
+ event.target.value = "";
1090
+ if (file !== void 0) {
1091
+ setPreview(void 0);
1092
+ setImportSelected(/* @__PURE__ */ new Set());
1093
+ setDialog("import");
1094
+ readImportFile(file);
1095
+ }
1096
+ }
1097
+ }),
1098
+ dialog === "export" ? /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
1099
+ className: "dsm-trae-transfer-dialog",
1100
+ role: "dialog",
1101
+ "aria-label": t("row.transferExport"),
1102
+ children: [
1103
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", { children: t("row.transferExportPick", { total: accounts.length }) }),
1104
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
1105
+ className: "dsm-trae-hint",
1106
+ children: t("row.transferHint")
1107
+ }),
1108
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("ul", {
1109
+ className: "dsm-trae-transfer-list",
1110
+ children: accounts.map((account) => /* @__PURE__ */ (0, react_jsx_runtime.jsx)("li", { children: /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("label", { children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("input", {
1111
+ type: "checkbox",
1112
+ checked: selected.has(account.id),
1113
+ onChange: (event) => {
1114
+ const next = new Set(selected);
1115
+ if (event.target.checked) next.add(account.id);
1116
+ else next.delete(account.id);
1117
+ setSelected(next);
1118
+ }
1119
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { children: account.accountName })] }) }, account.id))
1120
+ }),
1121
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
1122
+ className: "dsm-trae-transfer-dialog-actions",
1123
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
1124
+ type: "button",
1125
+ className: "dsm-btn",
1126
+ disabled: selected.size === 0,
1127
+ onClick: () => {
1128
+ runExport();
1129
+ },
1130
+ children: t("row.transferExportConfirm", { count: selected.size })
1131
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
1132
+ type: "button",
1133
+ className: "dsm-btn dsm-btn-outline",
1134
+ onClick: closeDialog,
1135
+ children: t("row.transferCancel")
1136
+ })]
1137
+ }),
1138
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
1139
+ type: "button",
1140
+ className: "dsm-trae-transfer-link",
1141
+ onClick: () => setSelected(new Set(accounts.map((account) => account.id))),
1142
+ children: t("row.transferSelectAll")
1143
+ })
1144
+ ]
1145
+ }) : null,
1146
+ dialog === "import" ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
1147
+ className: "dsm-trae-transfer-dialog",
1148
+ role: "dialog",
1149
+ "aria-label": t("row.transferImport"),
1150
+ children: preview === void 0 ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", { children: t("row.transferReading") }) : /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, { children: [
1151
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", { children: t("row.transferImportPick", { total: preview.total }) }),
1152
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("ul", {
1153
+ className: "dsm-trae-transfer-list",
1154
+ children: preview.accounts.map((entry) => /* @__PURE__ */ (0, react_jsx_runtime.jsx)("li", { children: /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("label", { children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("input", {
1155
+ type: "checkbox",
1156
+ checked: importSelected.has(entry.index),
1157
+ disabled: !entry.hasToken,
1158
+ onChange: (event) => {
1159
+ const next = new Set(importSelected);
1160
+ if (event.target.checked) next.add(entry.index);
1161
+ else next.delete(entry.index);
1162
+ setImportSelected(next);
1163
+ }
1164
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", { children: [
1165
+ entry.nickname || entry.uid || t("row.transferUnnamed"),
1166
+ entry.email === "" ? null : /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
1167
+ className: "dsm-trae-hint",
1168
+ children: [" · ", entry.email]
1169
+ }),
1170
+ entry.hasToken ? null : /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
1171
+ className: "dsm-trae-hint",
1172
+ children: [" · ", t("row.transferNoToken")]
1173
+ })
1174
+ ] })] }) }, entry.index))
1175
+ }),
1176
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
1177
+ className: "dsm-trae-transfer-dialog-actions",
1178
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
1179
+ type: "button",
1180
+ className: "dsm-btn",
1181
+ disabled: importSelected.size === 0,
1182
+ onClick: () => {
1183
+ runImport();
1184
+ },
1185
+ children: t("row.transferImportConfirm", { count: importSelected.size })
1186
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
1187
+ type: "button",
1188
+ className: "dsm-btn dsm-btn-outline",
1189
+ onClick: closeDialog,
1190
+ children: t("row.transferCancel")
1191
+ })]
1192
+ })
1193
+ ] })
1194
+ }) : null
1195
+ ]
1196
+ });
1197
+ }
1198
+ //#endregion
788
1199
  //#region src/client/plan-text.ts
789
1200
  /**
790
1201
  * The international region's "what plan is this account on" text.
@@ -2246,6 +2657,21 @@ window.__ModuleLoader__.load({
2246
2657
  className: "dsm-trae-usage-note",
2247
2658
  role: "status",
2248
2659
  children: rescanNote
2660
+ }),
2661
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)(AccountTransfer, {
2662
+ t,
2663
+ region: activeRegion,
2664
+ accounts: status.status === "signed-in" ? status.accounts.map((account) => ({
2665
+ id: account.id,
2666
+ accountName: account.accountName
2667
+ })) : [],
2668
+ onChanged: async () => {
2669
+ const fresh = await refreshUsage(activeRegion);
2670
+ if (fresh !== void 0) setStatusByRegion((current) => ({
2671
+ ...current,
2672
+ [activeRegion]: fresh
2673
+ }));
2674
+ }
2249
2675
  })
2250
2676
  ]
2251
2677
  }),
@@ -3225,6 +3651,25 @@ window.__ModuleLoader__.load({
3225
3651
  "row.refreshing": "Refreshing…",
3226
3652
  "row.accountsTitle": "Trae account",
3227
3653
  "row.accountsHint": "Sign-ins detected on this machine. Tokens are never shown or saved.",
3654
+ "row.transferTitle": "Import & export accounts",
3655
+ "row.transferExport": "Export selected",
3656
+ "row.transferImport": "Import from file",
3657
+ "row.transferCancel": "Cancel",
3658
+ "row.transferSelectAll": "Select all",
3659
+ "row.transferExportPick": "Choose which of the {total} accounts to export.",
3660
+ "row.transferExportConfirm": "Export {count}",
3661
+ "row.transferExported": "Exported {count} account(s) to {fileName}.",
3662
+ "row.transferExporting": "Preparing the export…",
3663
+ "row.transferImportPick": "This file holds {total} record(s). Untick anything you do not want to add.",
3664
+ "row.transferImportConfirm": "Import {count}",
3665
+ "row.transferImported": "Imported {imported} account(s), skipped {skipped}.",
3666
+ "row.transferImporting": "Importing…",
3667
+ "row.transferReading": "Reading the file…",
3668
+ "row.transferHint": "The exported file contains live tokens — treat it as a password. Records belonging to the other region are skipped.",
3669
+ "row.transferError": "Import/export failed: {message}",
3670
+ "row.transferVeilHint": "The account list refreshes when this finishes.",
3671
+ "row.transferUnnamed": "Unnamed account",
3672
+ "row.transferNoToken": "no token, will be skipped",
3228
3673
  "row.accountsRescan": "Detect accounts again",
3229
3674
  "row.accountsScanning": "Detecting…",
3230
3675
  "row.accountsNone": "This region reports no usable account.",
@@ -3372,6 +3817,25 @@ window.__ModuleLoader__.load({
3372
3817
  "row.refreshing": "正在刷新…",
3373
3818
  "row.accountsTitle": "Trae 账号",
3374
3819
  "row.accountsHint": "本机检测到的登录账号;Token 不会显示或保存。",
3820
+ "row.transferTitle": "导入 / 导出账号",
3821
+ "row.transferExport": "导出所选",
3822
+ "row.transferImport": "从文件导入",
3823
+ "row.transferCancel": "取消",
3824
+ "row.transferSelectAll": "全选",
3825
+ "row.transferExportPick": "从这 {total} 个账号中选择要导出的。",
3826
+ "row.transferExportConfirm": "导出 {count} 个",
3827
+ "row.transferExported": "已导出 {count} 个账号到 {fileName}。",
3828
+ "row.transferExporting": "正在准备导出…",
3829
+ "row.transferImportPick": "该文件包含 {total} 条记录,取消勾选不想添加的。",
3830
+ "row.transferImportConfirm": "导入 {count} 个",
3831
+ "row.transferImported": "已导入 {imported} 个账号,跳过 {skipped} 个。",
3832
+ "row.transferImporting": "正在导入…",
3833
+ "row.transferReading": "正在读取文件…",
3834
+ "row.transferHint": "导出的文件里是有效 Token,请当作密码保管。属于另一区域的记录会被跳过。",
3835
+ "row.transferError": "导入/导出失败:{message}",
3836
+ "row.transferVeilHint": "完成后账号列表会自动刷新。",
3837
+ "row.transferUnnamed": "未命名账号",
3838
+ "row.transferNoToken": "无 Token,将被跳过",
3375
3839
  "row.accountsRescan": "重新检测账号",
3376
3840
  "row.accountsScanning": "正在检测…",
3377
3841
  "row.accountsNone": "该区域没有可列出的账号。",
package/lib/index.d.ts CHANGED
@@ -698,6 +698,22 @@ declare class TraeCredentialStore {
698
698
  * fallback, for the same reason {@link current} refuses to fall back.
699
699
  */
700
700
  credentialOf(accountId: string): Promise<TraeCredential | undefined>;
701
+ /**
702
+ * Store imported credentials (issue #37), replacing any entry with the same
703
+ * account id and leaving every other entry alone.
704
+ *
705
+ * Why an additive write rather than the refresh path: an imported credential
706
+ * has NO desktop file behind it, so unlike a refresh result it cannot be
707
+ * rediscovered from the machine. If it were written the way a refresh is —
708
+ * as the whole document — importing account B would delete account A, and a
709
+ * later refresh would delete both. `readAll`'s desktop-first preference is
710
+ * therefore also not enough on its own: these entries survive only because
711
+ * `readOwns` reads them back and the dedupe keeps the longest-lived copy.
712
+ *
713
+ * Returns the account ids actually written, so the caller can report how many
714
+ * landed without re-reading the store.
715
+ */
716
+ importAccounts(credentials: readonly Omit<TraeCredential, 'source'>[]): Promise<string[]>;
701
717
  resolve(): Promise<TraeCredential>;
702
718
  status(): Promise<{
703
719
  state: 'signed-in' | 'signed-out';
package/lib/index.js CHANGED
@@ -829,6 +829,24 @@ const TRAE_ACCOUNT_STATUS_PATH = "/plugins/dsh-connect-trae/account-status";
829
829
  * account row.
830
830
  */
831
831
  const TRAE_CHECKIN_PATH = "/plugins/dsh-connect-trae/checkin";
832
+ /**
833
+ * Plugin-owned account transfer endpoint: batch export and import of a region's
834
+ * stored credentials (issue #37).
835
+ *
836
+ * One route with an `action` query parameter (`export` | `preview` | `import`),
837
+ * mirroring the sibling `dsh-connect-workbuddy` plugin's transfer route so the
838
+ * two cards behave the same way for a user who has both installed. The vault
839
+ * document never leaves the Host: the browser receives parsed answers, and a
840
+ * preview carries display fields only — never token material.
841
+ */
842
+ const TRAE_TRANSFER_PATH = "/plugins/dsh-connect-trae/transfer";
843
+ /** Read the transfer action off a request URL; unknown/absent means undefined. */
844
+ function transferActionOf(url) {
845
+ const at = url.indexOf("?");
846
+ if (at === -1) return void 0;
847
+ const value = new URLSearchParams(url.slice(at + 1)).get("action");
848
+ return value === "export" || value === "preview" || value === "import" ? value : void 0;
849
+ }
832
850
  /** Query parameter naming the region a card request addresses. */
833
851
  const TRAE_REGION_PARAM = "region";
834
852
  /** Every region, in card tab order. */
@@ -1581,7 +1599,24 @@ function regionOfCredential(credential) {
1581
1599
  //#region src/auth.ts
1582
1600
  /** Host used for CLI tokens, which carry no host claim of their own. */
1583
1601
  const CLI_DEFAULT_HOST = "https://api.trae.cn";
1584
- const OWN_VERSION = 1;
1602
+ /**
1603
+ * On-disk format of the plugin-owned per-region file.
1604
+ *
1605
+ * Version 1 held exactly ONE credential (`{ version, credential }`), which was
1606
+ * enough while every account came from a Trae install the plugin can discover
1607
+ * itself. Import (issue #37) breaks that assumption: a user can hold several
1608
+ * accounts as transferable files, and they have to live somewhere. Version 2
1609
+ * keeps the same per-region file but stores `{ version, credentials: [...] }`
1610
+ * instead, so the file stays the unit of region isolation (see
1611
+ * {@link traeOwnAuthPath}) while holding as many accounts as were imported.
1612
+ *
1613
+ * Version 1 remains READ (see {@link parseOwn}), so an existing install keeps
1614
+ * working and upgrades itself the next time a credential is written; it is
1615
+ * never written again.
1616
+ */
1617
+ const OWN_VERSION = 2;
1618
+ /** The superseded single-credential layout, still read for migration. */
1619
+ const OWN_VERSION_SINGLE = 1;
1585
1620
  const TRAE_AUTH_FILENAME = ".trae-auth.json";
1586
1621
  /** Prefix of the plugin-owned per-region credential copies. */
1587
1622
  const TRAE_OWN_PREFIX = ".trae-auth";
@@ -1644,27 +1679,68 @@ function traeAccountId(credential) {
1644
1679
  const stable = `${credential.edition}\0${credential.userId || credential.accountName || "unknown"}`;
1645
1680
  return createHash("sha256").update(stable).digest("hex").slice(0, 24);
1646
1681
  }
1682
+ /** One stored credential, validated. Returns undefined for a malformed row. */
1683
+ function credentialOfStored(stored) {
1684
+ if (typeof stored !== "object" || stored === null || Array.isArray(stored)) return void 0;
1685
+ const record = stored;
1686
+ const edition = record["edition"];
1687
+ if (edition !== "cn" && edition !== "sg" && edition !== "solo" && edition !== "solo-sg") return void 0;
1688
+ return normalizeTraeCredential({
1689
+ token: record["accessToken"],
1690
+ refreshToken: record["refreshToken"],
1691
+ userId: record["userId"],
1692
+ host: record["host"],
1693
+ userRegion: record["userRegion"],
1694
+ account: record["accountName"] === void 0 ? void 0 : { username: record["accountName"] },
1695
+ expiredAt: record["expiresAtMs"],
1696
+ refreshExpiredAt: record["refreshExpiredAtMs"]
1697
+ }, edition, "dsh");
1698
+ }
1699
+ /**
1700
+ * Read the plugin-owned file into its stored credentials.
1701
+ *
1702
+ * Accepts BOTH layouts: version 2 (`credentials: [...]`, the import-capable
1703
+ * shape) and the superseded version 1 (`credential: {...}`). A version-1 file
1704
+ * keeps working until the next write upgrades it, so no migration step is
1705
+ * needed and an older install's file is never rejected. A malformed row is
1706
+ * dropped rather than failing the whole file — one bad imported account must
1707
+ * not hide the rest.
1708
+ */
1647
1709
  function parseOwn(text) {
1648
1710
  try {
1649
1711
  const document = JSON.parse(text);
1650
- if (document.version !== OWN_VERSION || typeof document.credential !== "object" || document.credential === null) return void 0;
1651
- const stored = document.credential;
1652
- const edition = stored["edition"];
1653
- if (edition !== "cn" && edition !== "sg" && edition !== "solo" && edition !== "solo-sg") return void 0;
1654
- return normalizeTraeCredential({
1655
- token: stored["accessToken"],
1656
- refreshToken: stored["refreshToken"],
1657
- userId: stored["userId"],
1658
- host: stored["host"],
1659
- userRegion: stored["userRegion"],
1660
- account: stored["accountName"] === void 0 ? void 0 : { username: stored["accountName"] },
1661
- expiredAt: stored["expiresAtMs"],
1662
- refreshExpiredAt: stored["refreshExpiresAtMs"]
1663
- }, edition, "dsh");
1712
+ if (document.version === OWN_VERSION) {
1713
+ if (!Array.isArray(document.credentials)) return [];
1714
+ return document.credentials.map(credentialOfStored).filter((value) => value !== void 0);
1715
+ }
1716
+ if (document.version === OWN_VERSION_SINGLE) {
1717
+ const single = credentialOfStored(document.credential);
1718
+ return single === void 0 ? [] : [single];
1719
+ }
1720
+ return [];
1664
1721
  } catch {
1665
- return;
1722
+ return [];
1666
1723
  }
1667
1724
  }
1725
+ /**
1726
+ * Write the plugin-owned file: every credential of the region, in the version-2
1727
+ * array layout.
1728
+ *
1729
+ * One writer for both paths that persist to this file (a refresh, and an
1730
+ * import) so the read-modify-write in `refreshNow` and the additive write in
1731
+ * {@link TraeCredentialStore.importAccounts} cannot drift apart. Always writes
1732
+ * `OWN_VERSION`, which is what upgrades a version-1 file on its first write.
1733
+ */
1734
+ async function writeOwn(path, credentials) {
1735
+ const document = {
1736
+ version: OWN_VERSION,
1737
+ credentials: credentials.map((credential) => ({ ...credential }))
1738
+ };
1739
+ await writeFileAtomic(path, `${JSON.stringify(document, null, 2)}\n`, {
1740
+ mode: 384,
1741
+ dirMode: 448
1742
+ });
1743
+ }
1668
1744
  var TraeCredentialStore = class {
1669
1745
  storagePathOverride;
1670
1746
  edition;
@@ -1832,6 +1908,46 @@ var TraeCredentialStore = class {
1832
1908
  if (accountId === "") return void 0;
1833
1909
  return (await this.readAll()).find((credential) => traeAccountId(credential) === accountId);
1834
1910
  }
1911
+ /**
1912
+ * Store imported credentials (issue #37), replacing any entry with the same
1913
+ * account id and leaving every other entry alone.
1914
+ *
1915
+ * Why an additive write rather than the refresh path: an imported credential
1916
+ * has NO desktop file behind it, so unlike a refresh result it cannot be
1917
+ * rediscovered from the machine. If it were written the way a refresh is —
1918
+ * as the whole document — importing account B would delete account A, and a
1919
+ * later refresh would delete both. `readAll`'s desktop-first preference is
1920
+ * therefore also not enough on its own: these entries survive only because
1921
+ * `readOwns` reads them back and the dedupe keeps the longest-lived copy.
1922
+ *
1923
+ * Returns the account ids actually written, so the caller can report how many
1924
+ * landed without re-reading the store.
1925
+ */
1926
+ async importAccounts(credentials) {
1927
+ if (credentials.length === 0) return [];
1928
+ const ownPath = this.ownAuthPath();
1929
+ const written = [];
1930
+ await withFileLock(ownPath, async () => {
1931
+ const existing = await readFile(ownPath, "utf8").then(parseOwn, () => []);
1932
+ const byId = /* @__PURE__ */ new Map();
1933
+ for (const item of existing) byId.set(traeAccountId(item), item);
1934
+ for (const credential of credentials) {
1935
+ const stored = {
1936
+ ...credential,
1937
+ source: "dsh"
1938
+ };
1939
+ const id = traeAccountId(stored);
1940
+ byId.set(id, stored);
1941
+ written.push(id);
1942
+ }
1943
+ await writeOwn(ownPath, [...byId.values()]);
1944
+ });
1945
+ if (written.length > 0) {
1946
+ this.accountId = written[written.length - 1];
1947
+ this.inflight = void 0;
1948
+ }
1949
+ return written;
1950
+ }
1835
1951
  async resolve() {
1836
1952
  const credential = await this.current();
1837
1953
  if (credential === void 0) throw new Error(`trae: no signed-in account found (${this.candidates().map((item) => item.path).join(" or ")})`);
@@ -1976,8 +2092,7 @@ var TraeCredentialStore = class {
1976
2092
  async readOwns() {
1977
2093
  const copies = [];
1978
2094
  for (const path of this.ownCandidates()) try {
1979
- const parsed = parseOwn(await readFile(path, "utf8"));
1980
- if (parsed !== void 0) copies.push(parsed);
2095
+ copies.push(...parseOwn(await readFile(path, "utf8")));
1981
2096
  } catch {}
1982
2097
  return copies;
1983
2098
  }
@@ -1999,13 +2114,11 @@ var TraeCredentialStore = class {
1999
2114
  };
2000
2115
  const ownPath = this.ownAuthPath();
2001
2116
  await withFileLock(ownPath, async () => {
2002
- await writeFileAtomic(ownPath, `${JSON.stringify({
2003
- version: OWN_VERSION,
2004
- credential: refreshed
2005
- }, null, 2)}\n`, {
2006
- mode: 384,
2007
- dirMode: 448
2008
- });
2117
+ const existing = await readFile(ownPath, "utf8").then(parseOwn, () => []);
2118
+ const refreshedId = traeAccountId(refreshed);
2119
+ const entries = existing.filter((item) => traeAccountId(item) !== refreshedId);
2120
+ entries.push(refreshed);
2121
+ await writeOwn(ownPath, entries);
2009
2122
  });
2010
2123
  return refreshed;
2011
2124
  } catch (error) {
@@ -2926,6 +3039,23 @@ function wireCreditMultiplier(config) {
2926
3039
  if (typeof data !== "object" || data === null) return void 0;
2927
3040
  return finitePositive$1(data["rate"]);
2928
3041
  }
3042
+ /**
3043
+ * Thrown by {@link TraeSoloUpstreamClient.fetchModels} when every requested
3044
+ * directory function answered HTTP 200 but with an EMPTY `config_info_list`
3045
+ * (no rows at all), so the aggregate roster is empty. This is issue #39's root
3046
+ * signal: the upstream has published NO SOLO function config for this account /
3047
+ * route. It is a server-side, usually transient gap — the reporter's roster
3048
+ * returned empty and later self-resolved with rows per function — NOT a local
3049
+ * credential, plugin or account fault, and not something to re-login for. The
3050
+ * caller can catch this type to tell "the upstream published no config" apart
3051
+ * from "all directory functions errored" (which surfaces as a generic failure).
3052
+ */
3053
+ var TraeEmptySoloDirectory = class extends Error {
3054
+ name = "TraeEmptySoloDirectory";
3055
+ constructor() {
3056
+ super("Trae’s server returned NO SOLO function config (an empty directory for every requested function) — an upstream-side gap that usually clears on its own; retry later instead of re-checking credentials or the plugin");
3057
+ }
3058
+ };
2929
3059
  var TraeSoloUpstreamClient = class {
2930
3060
  options;
2931
3061
  fetchImpl;
@@ -2982,7 +3112,10 @@ var TraeSoloUpstreamClient = class {
2982
3112
  this.collectModels(list, directoryFunction, byId);
2983
3113
  }
2984
3114
  const models = [...byId.values()];
2985
- if (models.length === 0) throw new Error(`Trae SOLO models response contained no models (${failures.join("; ") || "empty directory"})`);
3115
+ if (models.length === 0) {
3116
+ if (failures.length === 0) throw new TraeEmptySoloDirectory();
3117
+ throw new Error(`Trae SOLO models response contained no models (${failures.join("; ")})`);
3118
+ }
2986
3119
  return models;
2987
3120
  }
2988
3121
  /** Merge one function's config list into the shared catalogue (first wins). */
@@ -3208,9 +3341,20 @@ function decodeTraeEvent(event) {
3208
3341
  * two different statements, and a user hits the second without touching the
3209
3342
  * first (issue #33).
3210
3343
  * - `4023` — the model name is unknown on the route that was asked.
3344
+ * - `2001` — the upstream has not published its function config for the route
3345
+ * that was asked. Evidence is issue #39: on a live CN account every SOLO
3346
+ * function's `get_detail_param` answered HTTP 200 with `config_info_list:
3347
+ * []` (an empty directory), and each chat call failed with
3348
+ * `{"code":2001,"message":"no function config found for appId=...,
3349
+ * function=solo_work_remote"}` (and the variant `[LLMUtilsChat.resolveByUsage]
3350
+ * function is empty, cannot resolve model by usage=`). The roster came back
3351
+ * empty and then self-resolved later with rows per function, so this is a
3352
+ * TRANSIENT server-side gap the user can wait out or retry — never a local
3353
+ * credential, plugin or account fault, and not something to re-login for.
3211
3354
  */
3212
3355
  const TRAE_ERROR_HINTS = {
3213
3356
  1005: "Trae requires a paid plan for this model; the current account's plan does not cover it",
3357
+ 2001: "Trae has not published its function config for this route (an upstream condition, not a local credential or config problem)",
3214
3358
  4001: "Trae does not serve this model under the SOLO function the request used (config_name rejected)",
3215
3359
  4008: "Trae reports the account's SOLO quota is exhausted for this request",
3216
3360
  4011: "Trae throttled the SOLO channel for this account (a rate limit, not the credit balance)",
@@ -4796,6 +4940,268 @@ var TraeUsageClient = class {
4796
4940
  }
4797
4941
  };
4798
4942
  //#endregion
4943
+ //#region src/transfer.ts
4944
+ /**
4945
+ * Account transfer: the export / import file format shared with sibling
4946
+ * credential managers for the same upstream (dsh-connect-workbuddy ships the
4947
+ * identical contract so export files interoperate).
4948
+ *
4949
+ * 参考:通行实现 —— 文件是一个 JSON 数组,每项为一条凭据记录,顶层字段
4950
+ * snake_case(`access_token` / `refresh_token` / `uid` / `nickname` /
4951
+ * `email` / `domain` / `expiresAt`(ms) / `refreshExpiresAt`(ms) /
4952
+ * `auth_raw`),导入按 account id 去重(同 id 覆盖),缺 `access_token`
4953
+ * 的记录跳过;预览只回脱敏字段(含 `hasToken` 布尔量),不回明文。解析
4954
+ * 函数不依赖文件系统,便于无 UI 环境单测;落库由 Host 侧的 store 完成。
4955
+ *
4956
+ * 互通 / 单位边界:`expiresAt` 一族按契约是**毫秒**。workbuddy 的 vault 文档
4957
+ * 存的是秒,需要在导出/导入边界换算;trae 的 `TraeCredential.expiresAtMs`
4958
+ * (见 auth.ts 的 `timeToMs`)**本身就是毫秒**,所以这里**不做任何
4959
+ * 乘除**——刻意与 workbuddy 不同,读者对比两文件时不要误以为换算被漏掉了。
4960
+ * 导出时 `auth_raw` 直接携带原始认证文档(桌面 storage 文档或 `{auth,
4961
+ * account}` 双键块),供写入桌面认证文件的工具原样消费。
4962
+ *
4963
+ * @module dsh-connect-trae/transfer
4964
+ */
4965
+ /**
4966
+ * Parse transfer-file text: a JSON array, every element an object.
4967
+ *
4968
+ * The error strings carry the position (1-based item number) because a file
4969
+ * from another tool can be big, and "第 N 项" is what lets the user find the
4970
+ * broken record in their editor.
4971
+ */
4972
+ function parseTransferFile(text) {
4973
+ if (text.trim() === "") throw new Error("transfer file is empty");
4974
+ let parsed;
4975
+ try {
4976
+ parsed = JSON.parse(text);
4977
+ } catch (error) {
4978
+ throw new Error("transfer file is not valid JSON: " + (error instanceof Error ? error.message : String(error)));
4979
+ }
4980
+ if (!Array.isArray(parsed)) throw new Error("transfer file must be a JSON array of account records");
4981
+ for (const [index, item] of parsed.entries()) if (typeof item !== "object" || item === null || Array.isArray(item)) throw new Error("transfer file item " + (index + 1) + " is not an account object");
4982
+ return parsed;
4983
+ }
4984
+ /** One string field of a record, tolerating the sibling format's odd shapes. */
4985
+ function displayString(value) {
4986
+ if (typeof value === "string") return value;
4987
+ if (typeof value === "number" || typeof value === "boolean") return String(value);
4988
+ return "";
4989
+ }
4990
+ /** Whether a record carries a usable token (a non-empty string). */
4991
+ function hasToken(record) {
4992
+ const token = record["access_token"];
4993
+ return typeof token === "string" && token !== "";
4994
+ }
4995
+ /**
4996
+ * The transfer file's desensitized preview: display fields only.
4997
+ *
4998
+ * Fields come from EXTERNAL files, so a display field may hold an object or an
4999
+ * array (a sibling tool can store the desktop app's encrypted-wrapper nickname
5000
+ * verbatim); every preview field is normalized to a string or emptied rather
5001
+ * than letting a non-renderable value reach the card.
5002
+ */
5003
+ function previewTransferFile(text) {
5004
+ const records = parseTransferFile(text);
5005
+ return {
5006
+ accounts: records.map((record, index) => ({
5007
+ index,
5008
+ uid: displayString(record["uid"]),
5009
+ nickname: displayString(record["nickname"]),
5010
+ email: displayString(record["email"]),
5011
+ hasToken: hasToken(record)
5012
+ })),
5013
+ total: records.length
5014
+ };
5015
+ }
5016
+ /**
5017
+ * Positive-match region claim for an IMPORTED record: the record's own
5018
+ * `userRegion` claim wins, the host suffix is the fallback, and anything
5019
+ * unrecognised (empty, misspelt, or other-region) yields `undefined` — never a
5020
+ * default.
5021
+ *
5022
+ * This is the security-critical difference from `regionOfCredential`, which
5023
+ * classifies anything unrecognised as `cn` as a last resort (via the edition
5024
+ * label). That fallback is right for upstream-sourced data, whose host/region
5025
+ * came out of the real Trae install, but wrong for an import whose values are
5026
+ * simply whatever the file's author wrote: a spoofed or misspelt `domain`
5027
+ * would otherwise be admitted into the CN vault and cross-bill the CN provider
5028
+ * stack with a credential that belongs to no real CN account. So this helper
5029
+ * reuses `regionOfUserRegion` / `regionOfHost` — both of which already return
5030
+ * `undefined` on unrecognised input — and deliberately does NOT consult the
5031
+ * edition fallback.
5032
+ */
5033
+ function knownRegionOfCredential(credential) {
5034
+ return regionOfUserRegion(credential.userRegion) ?? regionOfHost(credential.host);
5035
+ }
5036
+ /**
5037
+ * Project one credential into the shared transfer record shape.
5038
+ *
5039
+ * `expiresAt` / `refreshExpiresAt` are emitted in epoch MILLISECONDS — the
5040
+ * transfer contract's unit. Unlike workbuddy — whose vault stored seconds and
5041
+ * had to convert here — trae's `expiresAtMs` is already milliseconds, so the
5042
+ * value is carried through verbatim (documented above; do not rescale).
5043
+ * `auth_raw` carries the original auth document so a tool that writes desktop
5044
+ * auth files can consume it verbatim.
5045
+ */
5046
+ function traeTransferRecordOf(credential, authRawDocument) {
5047
+ return {
5048
+ access_token: credential.accessToken,
5049
+ ...credential.refreshToken !== void 0 && credential.refreshToken !== "" ? { refresh_token: credential.refreshToken } : {},
5050
+ ...credential.userId !== "" ? { uid: credential.userId } : {},
5051
+ ...credential.accountName !== void 0 ? { nickname: credential.accountName } : {},
5052
+ ...credential.host !== "" ? { domain: credential.host } : {},
5053
+ ...credential.expiresAtMs > 0 ? { expiresAt: credential.expiresAtMs } : {},
5054
+ ...credential.refreshExpiresAtMs === void 0 ? {} : { refreshExpiresAt: credential.refreshExpiresAtMs },
5055
+ auth_raw: authRawDocument
5056
+ };
5057
+ }
5058
+ /** The `userId` claim from a record: the flat `uid`, else recovered from `auth_raw`. */
5059
+ function userIdOfRaw(record) {
5060
+ const flat = displayString(record["uid"]);
5061
+ if (flat !== "") return flat;
5062
+ const root = rawRoot(record["auth_raw"]);
5063
+ if (root === void 0) return "";
5064
+ return displayString(root["userId"]) ?? "";
5065
+ }
5066
+ /** The `accountName` claim: the flat `nickname`, else recovered from `auth_raw`. */
5067
+ function accountNameOfRaw(record) {
5068
+ const flat = displayString(record["nickname"]);
5069
+ if (flat !== "") return flat;
5070
+ const root = rawRoot(record["auth_raw"]);
5071
+ if (root === void 0) return void 0;
5072
+ const fromAccount = displayString((typeof root["account"] === "object" && root["account"] !== null ? root["account"] : void 0)?.["username"]);
5073
+ if (fromAccount !== "") return fromAccount;
5074
+ return displayString(root["username"]);
5075
+ }
5076
+ /** The raw `userRegion` claim string from `auth_raw` (object `{region}` or bare value). */
5077
+ function userRegionOfRaw(raw) {
5078
+ const root = rawRoot(raw);
5079
+ if (root === void 0) return void 0;
5080
+ const value = root["userRegion"];
5081
+ const claim = typeof value === "object" && value !== null && !Array.isArray(value) ? value["region"] : value;
5082
+ return typeof claim === "string" && claim.trim() !== "" ? claim.trim() : void 0;
5083
+ }
5084
+ /**
5085
+ * A record's `auth_raw` root, tolerating both the nested `{auth, account}` block
5086
+ * this plugin exports and a raw desktop storage document: `account` may be a
5087
+ * sub-object (the block shape) or absent (flat document shape).
5088
+ */
5089
+ function rawRoot(raw) {
5090
+ if (typeof raw !== "object" || raw === null || Array.isArray(raw)) return void 0;
5091
+ return raw;
5092
+ }
5093
+ /**
5094
+ * Edition derived from an imported record's `domain` host, positively.
5095
+ *
5096
+ * The shared transfer format has no edition field (only `domain`), so a
5097
+ * re-import must re-derive the routing label from the host. Only the two base
5098
+ * editions are ever assigned — a flat `domain` + `uid` cannot distinguish the
5099
+ * SOLO variants — so this is inherently lossy for SOLO accounts; that is
5100
+ * acceptable because the region gate (see `mergeTransferRecords`) never
5101
+ * consults edition, and the account id's edition component only affects whether
5102
+ * a re-imported account converges on the same stored id (which it does as long
5103
+ * as the host is positively classified).
5104
+ */
5105
+ function editionOfDomain(host) {
5106
+ const region = regionOfHost(host);
5107
+ if (region === "ai") return "sg";
5108
+ if (region === "cn") return "cn";
5109
+ }
5110
+ /**
5111
+ * Project one transfer record into a credential, or undefined when the record
5112
+ * carries no token (the importer's skip rule).
5113
+ *
5114
+ * `userId` empty is tolerated at THIS layer because a token-only record still
5115
+ * describes a usable credential; the Host's region gate decides whether it is
5116
+ * representable, and the store's account id (`edition \0 userId || accountName`)
5117
+ * decides whether it is addressable. An unopenable `auth_raw` is ignored — the
5118
+ * flat fields are the contract, `auth_raw` is extra fidelity for the write-back
5119
+ * path.
5120
+ *
5121
+ * `edition` is restored positively from the domain host (see `editionOfDomain`),
5122
+ * defaulting to `cn` ONLY as a neutral placeholder when the host is not
5123
+ * classifiable — this is safe because the region gate never consults edition,
5124
+ * and such a record is always `skipped` there before edition could matter.
5125
+ */
5126
+ function credentialOfTransferRecord(record) {
5127
+ const accessToken = record["access_token"];
5128
+ if (typeof accessToken !== "string" || accessToken === "") return void 0;
5129
+ const refreshToken = typeof record["refresh_token"] === "string" ? record["refresh_token"] : "";
5130
+ const expiresAtMs = typeof record["expiresAt"] === "number" && Number.isFinite(record["expiresAt"]) && record["expiresAt"] > 0 ? record["expiresAt"] : 0;
5131
+ const refreshExpiresAtMs = typeof record["refreshExpiresAt"] === "number" && Number.isFinite(record["refreshExpiresAt"]) && record["refreshExpiresAt"] > 0 ? record["refreshExpiresAt"] : void 0;
5132
+ const userId = userIdOfRaw(record);
5133
+ const accountName = accountNameOfRaw(record);
5134
+ const host = typeof record["domain"] === "string" ? record["domain"] : "";
5135
+ const edition = editionOfDomain(host) ?? "cn";
5136
+ const userRegion = userRegionOfRaw(record["auth_raw"]);
5137
+ return {
5138
+ accessToken,
5139
+ ...refreshToken === "" ? {} : { refreshToken },
5140
+ userId,
5141
+ ...accountName === void 0 ? {} : { accountName },
5142
+ host,
5143
+ ...userRegion === void 0 ? {} : { userRegion },
5144
+ expiresAtMs,
5145
+ ...refreshExpiresAtMs === void 0 ? {} : { refreshExpiresAtMs },
5146
+ edition
5147
+ };
5148
+ }
5149
+ /** Default gate so the merge is secure even when the caller omits the judge. */
5150
+ const defaultRegionJudge = knownRegionOfCredential;
5151
+ /**
5152
+ * Pure merge: fold selected records into a CHANGES map keyed by account id.
5153
+ *
5154
+ * Mirrors the sibling merger's contract: a record with the same account id
5155
+ * OVERWRITES the stored entry (the imported tokens replace what is stored —
5156
+ * re-importing a re-login must refresh it; the write layer stores the record
5157
+ * atomically, so "overwrite" needs no knowledge of the incumbent), and a
5158
+ * record without a token is skipped. The region gate lives HERE rather than in
5159
+ * the route so it is testable without a Host.
5160
+ *
5161
+ * The region gate is a POSITIVE match: a record is kept only when its OWN
5162
+ * region claim is positively known to equal the target region. `regionOfCredential`
5163
+ * classifies anything unrecognised as `cn`, which is fine for upstream-sourced
5164
+ * data but WRONG for an imported file whose host/region are author-controlled —
5165
+ * a spoofed or misspelt value would otherwise be admitted into the CN vault.
5166
+ * Here the injected `knownRegionOfCredential` (defaulting to the exported
5167
+ * positive-match helper, which never consults the edition fallback) returns
5168
+ * `undefined` for an empty, unrecognised, or other-region claim, and such a
5169
+ * record counts as `skipped` rather than being silently imported cross-region.
5170
+ *
5171
+ * The caller writes one store file per map entry, so a selection that is
5172
+ * already stored rewrites those files with identical content — idempotent,
5173
+ * and the steady state of importing the same file twice.
5174
+ */
5175
+ function mergeTransferRecords(records, indexes, region, knownRegionOfCredential = defaultRegionJudge) {
5176
+ const changes = /* @__PURE__ */ new Map();
5177
+ let imported = 0;
5178
+ let skipped = 0;
5179
+ for (const index of indexes) {
5180
+ const record = records[index];
5181
+ if (record === void 0) {
5182
+ skipped += 1;
5183
+ continue;
5184
+ }
5185
+ const credential = credentialOfTransferRecord(record);
5186
+ if (credential === void 0) {
5187
+ skipped += 1;
5188
+ continue;
5189
+ }
5190
+ if (knownRegionOfCredential(credential) !== region) {
5191
+ skipped += 1;
5192
+ continue;
5193
+ }
5194
+ const id = traeAccountId(credential);
5195
+ changes.set(id, credential);
5196
+ imported += 1;
5197
+ }
5198
+ return {
5199
+ changes,
5200
+ imported,
5201
+ skipped
5202
+ };
5203
+ }
5204
+ //#endregion
4799
5205
  //#region src/web-status.ts
4800
5206
  /**
4801
5207
  * The upstream's business code for "该设备今日已参与签到" — this DEVICE already
@@ -4880,6 +5286,35 @@ async function readAccountBody(req) {
4880
5286
  if (typeof accountId !== "string" || accountId.trim() === "") throw new Error("body must carry an accountId string");
4881
5287
  return accountId.trim();
4882
5288
  }
5289
+ /**
5290
+ * Read a transfer-file POST body: the raw file TEXT plus the record indexes the
5291
+ * user selected, or (for `export`) the account ids to include.
5292
+ *
5293
+ * The ceiling is much larger than the other readers' because this body IS a
5294
+ * user-chosen file rather than a couple of ids — a credential export from a
5295
+ * sibling tool can legitimately be hundreds of kilobytes. It is still bounded so
5296
+ * a malformed or hostile request cannot make the Host buffer without limit.
5297
+ */
5298
+ async function readTransferBody(req) {
5299
+ const chunks = [];
5300
+ let size = 0;
5301
+ for await (const chunk of req) {
5302
+ const buffer = chunk;
5303
+ size += buffer.byteLength;
5304
+ if (size > 4194304) throw new Error("request body too large");
5305
+ chunks.push(buffer);
5306
+ }
5307
+ const text = Buffer.concat(chunks).toString("utf8").trim();
5308
+ if (text === "") return {};
5309
+ let parsed;
5310
+ try {
5311
+ parsed = JSON.parse(text);
5312
+ } catch {
5313
+ throw new Error("invalid JSON body");
5314
+ }
5315
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) throw new Error("body must be a JSON object");
5316
+ return parsed;
5317
+ }
4883
5318
  /** Loopback browser origins only; other devices are refused until trusted origins exist. */
4884
5319
  function loopbackOrigin(req) {
4885
5320
  const origin = req.headers.origin;
@@ -5250,6 +5685,67 @@ function registerTraeUsageRoute(ctx, deps) {
5250
5685
  }
5251
5686
  }
5252
5687
  });
5688
+ /**
5689
+ * Batch credential export / import (issue #37).
5690
+ *
5691
+ * The Host owns every byte of the credential store; the browser only receives
5692
+ * parsed answers. `preview` necessarily returns identifiers the card shows
5693
+ * (uid / nickname / email) plus a `hasToken` boolean — never a token — and
5694
+ * `import` re-parses the file server-side rather than trusting the preview,
5695
+ * so a tampered browser cannot smuggle in a record the preview never showed.
5696
+ */
5697
+ const disposeTransfer = ctx.webServer.register({
5698
+ kind: "exact",
5699
+ path: TRAE_TRANSFER_PATH,
5700
+ handler: async (req, res) => {
5701
+ if (req.method !== "POST") return json(res, 405, { error: "method not allowed" });
5702
+ if (!loopbackOrigin(req)) return json(res, 403, { error: "origin-not-trusted" });
5703
+ const region = requestRegion(req, res);
5704
+ if (region === void 0) return;
5705
+ const action = transferActionOf(req.url ?? "");
5706
+ if (action === void 0) return json(res, 400, { error: "unknown transfer action" });
5707
+ try {
5708
+ const body = await readTransferBody(req);
5709
+ if (action === "export") {
5710
+ const wanted = new Set(Array.isArray(body["accountIds"]) ? body["accountIds"].filter((id) => typeof id === "string") : []);
5711
+ const store = deps.store(region);
5712
+ const records = [];
5713
+ for (const account of await store.accounts()) {
5714
+ if (!wanted.has(account.id)) continue;
5715
+ const credential = await store.credentialOf(account.id).catch(() => void 0);
5716
+ if (credential === void 0) continue;
5717
+ records.push(traeTransferRecordOf(credential, {
5718
+ auth: {
5719
+ accessToken: credential.accessToken,
5720
+ ...credential.refreshToken === void 0 ? {} : { refreshToken: credential.refreshToken },
5721
+ expiresAt: credential.expiresAtMs > 0 ? Math.floor(credential.expiresAtMs / 1e3) : 0,
5722
+ ...credential.refreshExpiresAtMs === void 0 ? {} : { refreshExpiresAt: Math.floor(credential.refreshExpiresAtMs / 1e3) },
5723
+ host: credential.host,
5724
+ ...credential.userRegion === void 0 ? {} : { userRegion: credential.userRegion }
5725
+ },
5726
+ account: {
5727
+ userId: credential.userId,
5728
+ ...credential.accountName === void 0 ? {} : { username: credential.accountName },
5729
+ edition: credential.edition
5730
+ }
5731
+ }));
5732
+ }
5733
+ return json(res, 200, { accounts: records });
5734
+ }
5735
+ const text = typeof body["text"] === "string" ? body["text"] : "";
5736
+ if (action === "preview") return json(res, 200, previewTransferFile(text));
5737
+ const indexes = Array.isArray(body["indexes"]) ? body["indexes"].filter((index) => typeof index === "number" && Number.isInteger(index)) : [];
5738
+ const { changes, imported, skipped } = mergeTransferRecords(parseTransferFile(text), indexes, region);
5739
+ if (changes.size > 0) await deps.store(region).importAccounts([...changes.values()]);
5740
+ json(res, 200, {
5741
+ imported,
5742
+ skipped
5743
+ });
5744
+ } catch (error) {
5745
+ json(res, 400, { error: safeMessage(error) });
5746
+ }
5747
+ }
5748
+ });
5253
5749
  const disposeRefresh = ctx.webServer.register({
5254
5750
  kind: "exact",
5255
5751
  path: TRAE_MODELS_REFRESH_PATH,
@@ -5387,6 +5883,7 @@ function registerTraeUsageRoute(ctx, deps) {
5387
5883
  return () => {
5388
5884
  disposeCheckin();
5389
5885
  disposeRefresh();
5886
+ disposeTransfer();
5390
5887
  disposeModelsTest();
5391
5888
  disposeAccounts();
5392
5889
  disposeAccountCredits();
package/package.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "name": "dsh-connect-trae",
3
3
  "displayName": "DSH Connect Trae",
4
4
  "description": "把本机登录的 Trae 模型接入 DeepSeek Harness:国内版与国际版双供应商并行,提供用量/积分概览与每日签到领取。",
5
- "version": "3.5.1",
5
+ "version": "3.6.0",
6
6
  "author": "dingminhua",
7
7
  "copyright": "Copyright (c) 2026 LaoDing",
8
8
  "license": "MIT",