@xiaoyuyu6420/dsh-backup 0.13.2 → 0.13.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.en.md CHANGED
@@ -48,7 +48,7 @@ Prefer clicking? There's a visual panel in `dsh web` → Settings → Plugins
48
48
 
49
49
  ## Install
50
50
 
51
- Requires macOS / Linux / Windows 10+ (ships `tar`) and DSH `0.1.1-rc.2`+ (the 0.1.1 / 0.1.2 / 0.1.5 / 0.1.6 / 0.1.7 trains are all tested; the 0.2.0 train is adapted and pending release — see the [compatibility matrix](docs/compatibility.md) for the current status).
51
+ Requires macOS / Linux / Windows 10+ (ships `tar`) and DSH `0.1.1-rc.2`+ (the 0.1.1 / 0.1.2 / 0.1.5 / 0.1.6 / 0.1.7 trains are all tested; the 0.2.0 train is supported since 0.13.2 — see the [compatibility matrix](docs/compatibility.md) for the current status).
52
52
 
53
53
  ```sh
54
54
  dsh plugin --profile web add @xiaoyuyu6420/dsh-backup
@@ -138,6 +138,8 @@ Tried it? Tell us what broke, what's missing, what you liked — it directly sha
138
138
  <details>
139
139
  <summary>Recent releases</summary>
140
140
 
141
+ - **0.13.3** — Three fixes from real user reports plus two self-found defects, all re-verified on four host trains (0.1.5-rc.3 / 0.1.7-rc.2 / 0.2.0-rc.2 / 0.2.1-alpha.1) with e2e 41/41 each: ① **archives containing attachments could not be restored at all** (#112, @zevs281): DSH's attachment store uses hard links (tar typeflag `h`) while the restore validator only allowed `-`/`d` — full restore, dry-run, typed merge and doctor repair all refused on 0.13.2 and earlier. Hard links are now allowed when both the entry and its target stay inside the backup root (symlinks/devices still rejected). ② **large archives were falsely rejected on restore** (#119, @Yoshino-JF): when the `tar -tvzf` listing exceeds the host subprocess spill cap the host discards the spill file and only the last 8 KB remain; the validator then treats a mid-filename fragment as an unsafe entry and refuses the whole archive (backup succeeds, verify passes, only restore fails). The listing is now consumed as a raw stream line by line (no cap; terminate on the first violation). ③ **doctor/migrate were blind to v4 session logs** (#117, @xiuwufangoc-design): the host moved to format v4 (`session.v4.jsonl.zstd`) in 0.1.7 while filenames and the calibrated generation were hardcoded to generation 0/v3 — scans found nothing. Filenames are now matched by the host's own pattern, the calibrated generation moved to v4, and newer generations only warn. ④ **one-click update could not cross minors** (#115): pnpm `update` only upgrades within the declared range and the receipt claimed success regardless — now `update --latest`, with the actually-installed version read back and verified. ⑤ **migration precheck false alarms** (#113, @zevs281): unknown `source.kind` values are hints now instead of "will not open" verdicts, and the known set is aligned with the host's authoritative tables.
142
+ - **0.13.2** — dsh **0.2.0 train** adaptation: peers/engines gain `^0.2.0-rc.1` (the host rejects incompatible peers at install time since 0.1.7-rc.1); **settings form fixed for 0.1.7+** (the host derives forms from the entry's Config schema and only honors `volatile` fields — the plugin exports `Config` with per-field `.volatile()`); Windows scheduler boot hang fixed (#103: streaming hash + boot grace + persisted attempt anchor); review P2 debt cleared and an in-product Star entry.
141
143
  - **0.13.1** — Two panel fixes, both from user reports: ① **the panel tab silently disappears on host 0.1.6+** (#94, @jackovibe): since 0.1.6 the host requires a `create()` lazy factory on strict codecs in client Remote contributions (older hosts only read `schema`); without it `$mount` throws during validation with only a console log, so the tab is never registered. Fix: `strictCodec()` now provides both `schema` and `create()` sharing one zod instance; `$mount` waits declaratively for the `remote` service (0.1.6 settles client modules by dependency graph); and a new `BackupTabFallback` **degraded tab** shows a visible hint plus command pointers if the mount ever fails — no more silent disappearance. peers/engines gain `^0.1.6-alpha.1 || ^0.1.7-alpha.1` (verified on real hosts 0.1.6-alpha.2 / 0.1.7-alpha.2 / 0.1.7-rc.1 / 0.1.7-rc.2). ② **the "GitHub Token" save button errored** (#97, @CheeseTJ): the client `panel` proxy never re-exported `setGithubToken` (that RPC landed in the host half in 0.12.0 and the omission lay dormant while no UI called it). Fix: wire it up, plus a new smoke-client assertion that the panel surface must mirror the contribution descriptors one-for-one.
142
144
  - **0.13.0** — Update awareness + one-command self-update: ① `/backup check-update` and a new "Plugin updates" card in the panel — checks the npm registry for a newer version (8s timeout, degrades silently offline, never blocks the plugin); when a newer version exists an "Update to x.y.z" button appears; the `updateCheck` setting (default off) silently checks on panel open. ② `/backup update [--profile <name>]` — takes a `dsh-pre-upgrade-` snapshot automatically before updating (roll back with `/backup restore`), then runs the official `dsh plugin --profile <name> update` (pnpm semantics; the plugin never rewrites its own dependency files), and tells you to restart dsh web afterwards. Note: the host settles the plugin Loader tree at startup and plugins are pnpm dependencies of the profile, so true hot-swap (no restart) is not possible in the current host architecture — one-command update + restart is the best form it can take.
143
145
  - **0.12.2** — Fix a Windows data-safety hazard (#88, thanks @Liuyeyuyangy0 for the outstanding report): when `DSH_HOME` sits directly under a drive root (e.g. `D:\dsh_data`), the string-slice parent computation degrades to the bare drive letter `D:`, and `tar -C D:` fails on both bsdtar and GNU tar — worse, tar creates the `-f` target before writing, leaving a 29-byte empty shell that the panel lists as a normal backup (no `.sha256`/`.meta.json`; silently zero usable backups, and the restore / doctor repair / rescue channels are hit by the same bug). All 7 tar `-C` call sites (backup, typed & full restore, doctor point-repair, rescue snapshot/restore/repair) now receive a separator-completed parent; `paths()` normalizes trailing slashes off `DSH_HOME` like `HOME` (a trailing slash used to empty the tar positional and produce 0-byte archives); failed tar runs now delete the empty shell before rethrowing, so failures are visible. Smoke gains scenario 28 (four path-layout assertions + a trailing-slash real-backup e2e). Archive format, settings and RPC unchanged.
package/README.md CHANGED
@@ -50,7 +50,7 @@ sha256: 8f9ae6322ef782d21554981cf4547220d5bb3e64d7964a883317415ad54e3cbb
50
50
 
51
51
  ## 安装
52
52
 
53
- 要求:macOS / Linux / Windows 10+(自带 `tar`),DSH `0.1.1-rc.2`+(0.1.1 / 0.1.2 / 0.1.5 / 0.1.6 / 0.1.7 列车均已实测,0.2.0 列车已适配待发版;当前兼容状态见 [兼容性矩阵](docs/compatibility.md))。
53
+ 要求:macOS / Linux / Windows 10+(自带 `tar`),DSH `0.1.1-rc.2`+(0.1.1 / 0.1.2 / 0.1.5 / 0.1.6 / 0.1.7 列车均已实测,0.2.0 列车自 0.13.2 起支持;当前兼容状态见 [兼容性矩阵](docs/compatibility.md))。
54
54
 
55
55
  ```sh
56
56
  dsh plugin --profile web add @xiaoyuyu6420/dsh-backup
@@ -140,6 +140,8 @@ dsh plugin --profile web add github:xiaoyuyu6420/dsh-backup
140
140
  <details>
141
141
  <summary>最近的版本</summary>
142
142
 
143
+ - **0.13.3** —— 三条真实用户报障的修复 + 两个自建缺陷修复,全部在四条宿主列车(0.1.5-rc.3 / 0.1.7-rc.2 / 0.2.0-rc.2 / 0.2.1-alpha.1)真机 e2e 各 41/41 复验:① **含附件的归档恢复全链路不可用**(#112,@zevs281):DSH 附件库用硬链接(tar 记为 `h` 条目),而恢复校验器只放行 `-`/`d`——0.13.2 及更早对这类归档的完整恢复/dry-run/分类型合并/doctor 修复一律拒绝。修法:条目与目标都在备份根内的硬链接放行(符号链接、设备仍拒)。② **大归档恢复被误拒**(#119,@Yoshino-JF):归档清单超过宿主 subprocess 的 spill 上限时宿主会**丢弃 spill 文件**、只留 8 KB 尾窗,校验器把"断在文件名中间的碎片"当成不安全条目 → 整包拒绝(备份成功、verify 报完整、只在恢复时炸)。修法:清单改走原始流逐行校验(无上限,发现违规立即终止)。③ **doctor/migrate 看不见 v4 会话日志**(#117,@xiuwufangoc-design):宿主 0.1.7 起会话格式为 v4(`session.v4.jsonl.zstd`),而文件名与校准代写死到第 0 代/v3——体检与迁移预检一份都扫不到。修法:按宿主同源形态匹配文件名、校准代推进到 v4、更新代只提示。④ **一键更新跨不过 minor**(#115):pnpm `update` 只在声明范围内升级,跨 minor 停在旧版且回执谎报成功。修法:`update --latest` + 跑完读回 profile 内真实版本核对。⑤ **迁移预检误报**(#113,@zevs281):未知 `source.kind` 从"判打不开"降级为提示,名单对齐宿主权威集合(真机曾把 81/82 份健康日志误报)。
144
+ - **0.13.2** —— 适配 dsh **0.2.0 列车**:peers/engines 追加 `^0.2.0-rc.1`(宿主自 0.1.7-rc.1 起有安装期兼容闸门,不覆盖会被拒绝安装);**修复 0.1.7 起失效的设置表单**(宿主改从条目 Config schema 派生表单且只认 `volatile` 字段——本插件导出 `Config` 并逐字段 `.volatile()`,设置卡片恢复可读可写);修复 Windows 调度器启动卡死(#103:流式哈希 + 启动宽限 + 尝试锚点落盘);review P2 债清偿与产品内 Star 入口。
143
145
  - **0.13.1** —— 修复两个面板问题(均感谢用户报告):① **0.1.6+ 宿主上面板标签静默消失**(#94,@jackovibe):宿主 0.1.6 起要求客户端 Remote 贡献的 strict codec 带 `create()` 惰性工厂(旧版只读 `schema`),缺失时 `$mount` 在校验阶段抛错且只有 console 日志——标签页因此永不注册。修法:`strictCodec()` 同时提供 `schema` 与 `create()`,两代宿主共用同一 zod 实例;`$mount` 改为声明式等待 `remote` 服务就绪(0.1.6 客户端模块按依赖图结算);并新增 `BackupTabFallback` **降级标签页**——万一挂载失败会在面板位置给出可见提示与命令指路,不再静默消失。peers/engines 追加 `^0.1.6-alpha.1 || ^0.1.7-alpha.1`(0.1.6-alpha.2 / 0.1.7-alpha.2 / 0.1.7-rc.1 / 0.1.7-rc.2 实机验证通过)。② **「GitHub Token」保存按钮报错**(#97,@CheeseTJ):客户端 `panel` 代理漏接了 `setGithubToken`(该 RPC 0.12.0 加入宿主半时即漏,因当时面板无入口而潜伏两版)。修法:补接该行,并给 smoke-client 加「panel 面必须与贡献 descriptors 一一对应」的防漏断言。
144
146
  - **0.13.0** —— 更新感知 + 一键更新(#A+B):① `/backup check-update` 与面板「插件更新」卡——查 npm 最新版(8s 超时、断网静默降级,绝不阻断运行),发现新版即出现「更新到 x.y.z」按钮;设置项 `updateCheck`(默认关)开启后打开面板自动静默检查一次(本插件不联网上报,检查只读 registry)。② `/backup update [--profile 名]` 一键更新——更新前自动留 `dsh-pre-upgrade-` 升级前快照(失败可 `/backup restore` 回滚),再走官方 `dsh plugin --profile <名> update`(pnpm 语义,插件不自己覆写依赖文件),完成提示重启 dsh web 生效。说明:宿主在启动时结算插件 Loader 树、插件是 profile 的 pnpm 依赖,**运行中热替换(不重启生效)在宿主现行架构下不可行**——一键更新 + 重启是此架构下的最佳形态。
145
147
  - **0.12.2** —— 修复 Windows 数据安全隐患(#88,感谢 @Liuyeyuyangy0 出色的报告):`DSH_HOME` 位于盘符根目录(如 `D:\dsh_data`)时,求父目录的字符串截取退化成裸盘符 `D:`,`tar -C D:` 在 bsdtar/GNU tar 下必然失败——而 tar 失败前已创建归档文件,留下 29 字节空壳被面板当正常备份列出(无 `.sha256`/`.meta.json`,静默零可用备份;恢复、doctor 修复、rescue 救援通道同样中招)。备份/恢复/修复/救援共 7 处 tar `-C` 统一改传补过分隔符的父目录;`paths()` 对 `DSH_HOME` 补上与 HOME 一致的尾斜杠归一化(尾斜杠曾让 tar 位置参数变空串、产出 0 字节坏包);tar 失败路径删除空壳归档再抛错,失败不再静默。smoke 新增场景 28(四种路径布局断言 + 尾斜杠真实备份 e2e)。归档格式、设置项、RPC 均不变。
package/lib/client.js CHANGED
@@ -450,5 +450,5 @@ Set the \`cycles\` parameter to \`"ref"\` to resolve cyclical schemas with defs.
450
450
  color: var(--dsw-alias-state-error-primary, #e53e3e);
451
451
  line-height: 1.5;
452
452
  }
453
- `;var _p={name:"@xiaoyuyu6420/dsh-backup",version:"0.13.2",description:"\u4E00\u952E\u5907\u4EFD\u4E0E\u6062\u590D DeepSeek Harness\uFF08dsh\uFF09\u7684\u5168\u90E8\u7528\u6237\u6570\u636E\uFF08~/.dsh\uFF09\uFF1A\u5B9A\u65F6\u81EA\u52A8\u5907\u4EFD\u3001\u5347\u7EA7\u5FEB\u7167\u3001\u4F1A\u8BDD\u65E5\u5FD7\u4F53\u68C0\u4FEE\u590D\u3001\u8FC1\u79FB\u9884\u68C0\u3001\u5BBF\u4E3B\u8D77\u4E0D\u6765\u4E5F\u80FD\u7528\u7684\u6551\u63F4\u901A\u9053\u3001\u51ED\u636E\u8131\u654F\u3001GitHub \u540C\u6B65\u8DE8\u673A\u8FC1\u79FB\uFF0C\u9644\u53EF\u89C6\u5316\u9762\u677F\u3002 One command to back up & restore all of ~/.dsh: /backup, scheduled auto-backup, upgrade snapshots, session doctor & repair, migrate precheck, out-of-process rescue console, credential redaction, GitHub sync.",type:"module",packageManager:"pnpm@11.24.0",main:"lib/index.js",exports:{".":"./lib/index.js","./client":"./lib/client.js","./cordis.patch.yml":"./cordis.patch.yml","./package.json":"./package.json"},files:["lib","rescue","cordis.patch.yml","README.md","README.en.md","LICENSE"],bin:{"dsh-rescue":"./rescue/rescue.mjs"},dsh:{bundle:{patch:"./cordis.patch.yml"},client:{platform:"web",inject:["@deepseek-ai/dsh-client-runtime","@deepseek-ai/dsh-client-locale","@deepseek-ai/dsh-client-ui-settings"]}},keywords:["dsh","dsh-plugin","deepseek-harness","backup","\u5907\u4EFD","restore","\u6062\u590D","schedule","\u81EA\u52A8\u5907\u4EFD","cross-platform","rescue","doctor","upgrade-snapshot","\u6570\u636E\u5B89\u5168"],peerDependencies:{"@deepseek-ai/cordis":"^4.0.1","@deepseek-ai/dsh-commands":"^0.1.1-rc.2 || ^0.1.2-rc.1 || ^0.1.5-rc.1 || ^0.1.6-alpha.1 || ^0.1.7-alpha.1 || ^0.2.0-rc.1","@deepseek-ai/dsh-fs":"^0.1.1-rc.2 || ^0.1.2-rc.1 || ^0.1.5-rc.1 || ^0.1.6-alpha.1 || ^0.1.7-alpha.1 || ^0.2.0-rc.1","@deepseek-ai/dsh-settings":"^0.1.1-rc.2 || ^0.1.2-rc.1 || ^0.1.5-rc.1 || ^0.1.6-alpha.1 || ^0.1.7-alpha.1 || ^0.2.0-rc.1","@deepseek-ai/dsh-subprocess":"^0.1.1-rc.2 || ^0.1.2-rc.1 || ^0.1.5-rc.1 || ^0.1.6-alpha.1 || ^0.1.7-alpha.1 || ^0.2.0-rc.1","@deepseek-ai/dsh-tools":"^0.1.1-rc.2 || ^0.1.2-rc.1 || ^0.1.5-rc.1 || ^0.1.6-alpha.1 || ^0.1.7-alpha.1 || ^0.2.0-rc.1","@deepseek-ai/dsh-typert-protocol":"^0.1.1-rc.2 || ^0.1.2-rc.1 || ^0.1.5-rc.1 || ^0.1.6-alpha.1 || ^0.1.7-alpha.1 || ^0.2.0-rc.1","@deepseek-ai/schemastery":"^3.18.1"},devDependencies:{esbuild:"^0.25.0",react:"^19.0.0","react-dom":"^19.0.0",zod:"^4.4.3"},engines:{node:">=20",dsh:"^0.1.1-rc.2 || ^0.1.2-rc.1 || ^0.1.5-rc.1 || ^0.1.6-alpha.1 || ^0.1.7-alpha.1 || ^0.2.0-rc.1"},license:"MIT",repository:{type:"git",url:"git+https://github.com/xiaoyuyu6420/dsh-backup.git"},homepage:"https://github.com/xiaoyuyu6420/dsh-backup#readme",bugs:{url:"https://github.com/xiaoyuyu6420/dsh-backup/issues"},publishConfig:{access:"public"}};var Vn="settings.backupPanel",Cg=_p.name,Lg=["slots","locale","remote"],Rg=v.object({destination:v.string(),dshHome:v.string(),keepDefault:v.number().int(),autoHours:v.number().int(),lastAuto:v.string().nullable(),backups:v.array(v.object({name:v.string(),size:v.number().int().nullable()})),typedBackups:v.array(v.object({name:v.string(),size:v.number().int().nullable(),types:v.array(v.string())})).optional()}),Fg=v.object({ok:v.boolean(),summary:v.string(),path:v.string(),sha:v.string(),stale:v.number().int(),keep:v.number().int(),types:v.array(v.string()).optional(),hasCredentials:v.boolean().optional()}),Jg=v.object({ok:v.boolean(),summary:v.string(),results:v.array(v.object({name:v.string(),ok:v.boolean(),note:v.string()}))}),Vg=v.object({ok:v.boolean(),dryRun:v.boolean(),summary:v.string(),archive:v.string().nullable().optional(),files:v.number().int().nullable().optional(),aside:v.string().nullable().optional(),snapshotPath:v.string().nullable().optional(),sample:v.array(v.string()).optional(),preflight:v.array(v.string()).optional(),targetExists:v.boolean().nullable().optional(),merge:v.boolean().optional(),types:v.array(v.string()).optional(),willOverwrite:v.array(v.string()).optional(),restored:v.number().int().optional(),kept:v.array(v.string()).optional()}),Mg=v.object({ok:v.boolean(),hours:v.number().int(),summary:v.string()}),Wg=v.object({repoRaw:v.string().nullable(),repo:v.string().nullable(),tokenSet:v.boolean(),syncDir:v.string(),lastPush:v.string().nullable(),lastError:v.string().nullable()}),Bg=v.object({ok:v.boolean(),summary:v.string(),pushed:v.boolean(),tooBig:v.array(v.string())}),Kg=v.object({ok:v.boolean(),summary:v.string(),pulled:v.array(v.string()),corrupt:v.array(v.string()),total:v.number().int()}),Gg=v.object({ok:v.boolean(),summary:v.string()}),Xg=v.object({ok:v.boolean(),repo:v.string().nullable(),summary:v.string()}),Hg=v.object({ok:v.boolean(),current:v.string(),latest:v.string().nullable(),update:v.boolean(),error:v.string().nullable(),summary:v.string()}),qg=v.object({ok:v.boolean(),updated:v.boolean(),current:v.string().nullable(),latest:v.string().nullable(),summary:v.string()});function Pe(t,r){return Object.freeze({mode:"strict",typeSymbol:t,schema:r,create:()=>r})}var Yg={name:"keep",wire:"keep",source:"json",codec:Pe("dsh-backup/types#keep",v.number().int().positive().optional()),acceptsUndefined:!0},ru={name:"selector",wire:"selector",source:"json",codec:Pe("dsh-backup/types#selector",v.string().optional()),acceptsUndefined:!0},Qg={name:"dryRun",wire:"dryRun",source:"json",codec:Pe("dsh-backup/types#dryRun",v.boolean().optional()),acceptsUndefined:!0},ev={name:"syncDeps",wire:"syncDeps",source:"json",codec:Pe("dsh-backup/types#syncDeps",v.boolean().optional()),acceptsUndefined:!0},tv={name:"hours",wire:"hours",source:"json",codec:Pe("dsh-backup/types#hours",v.number().int().min(0).max(720)),acceptsUndefined:!0},rv={name:"repo",wire:"repo",source:"json",codec:Pe("dsh-backup/types#repo",v.string().optional()),acceptsUndefined:!0},xp={name:"types",wire:"types",source:"json",codec:Pe("dsh-backup/types#types",v.array(v.string()).optional()),acceptsUndefined:!0};function re(t,r,i,o){return Object.freeze({id:`dsh-backup#backupPanel/${t}`,service:"backupPanel",namespace:"backupPanel",method:t,invocation:Object.freeze({kind:"direct"}),parameters:Object.freeze(r.map(e=>Object.freeze({...e,codec:Object.freeze(e.codec)}))),...o?{cancellation:Object.freeze({parameter:"signal"})}:{},result:Pe(`dsh-backup/types#${t}Result`,i)})}var kp=Object.freeze({package:"dsh-backup",descriptors:Object.freeze([re("status",[],Rg,!1),re("backup",[Yg,xp],Fg,!0),re("verify",[ru],Jg,!0),re("restore",[ru,Qg,xp,ev],Vg,!0),re("setAuto",[tv],Mg,!1),re("githubStatus",[],Wg,!1),re("githubSyncNow",[],Bg,!0),re("githubPull",[],Kg,!0),re("removeEntry",[ru],Gg,!0),re("setGithubRepo",[rv],Xg,!1),re("checkUpdate",[],Hg,!0),re("update",[],qg,!0)])});function Q(t){if(!t.ok){let r=t.error,i=r&&r.message?`${r.code}: ${r.message}`:"backupPanel \u8C03\u7528\u5931\u8D25";throw new Error(`\u64CD\u4F5C\u6CA1\u6709\u5B8C\u6210\uFF1A\u9762\u677F\u6682\u65F6\u8FDE\u4E0D\u4E0A\u540E\u53F0\uFF0C\u8BF7\u91CD\u8BD5\uFF1B\u82E5\u53CD\u590D\u51FA\u73B0\u8BF7\u91CD\u542F dsh web\uFF08\u6280\u672F\u7EC6\u8282: ${i}\uFF09`)}return t.value}function nv(t){t.effect(()=>t.locale.register(Vn,{zh:bp,en:$p}),"dsh-backup: dictionaries"),t.effect(()=>yp(),"dsh-backup: stylesheet");let r=!1,i=(o,e,n)=>{if(r&&e!==tu)return;let a=o.locale.bind(Vn);o.slots.inject("settings.plugins.tab",()=>o.slots.register({name:"settings.plugins.tab",id:"backup",order:35,label:()=>a("tab"),locale:Vn,inject:()=>({panel:n})},e))};t.inject(["slots","locale"],o=>{t.inject(["remote"],e=>{t.effect(()=>{let n=null,a=!0,c=!1;return(async()=>{try{n=await e.remote.$mount(kp)}catch(u){console.error("dsh-backup: backupPanel mount failed:",u),r=!0,i(o,tu,{mountError:u})}a=!1,c&&n?.()})(),()=>{c=!0,a||n?.()}},"dsh-backup: remote contribution")}),t.inject(["remote.backupPanel"],e=>{let n=()=>e.remote.backupPanel;i(e,hp,{status:async()=>Q(await n().status()),backup:async(c,u)=>Q(await n().backup(c,u)),verify:async c=>Q(await n().verify(c)),restore:async(c,u,s,l)=>Q(await n().restore(c,u,s,l)),setAuto:async c=>Q(await n().setAuto(c)),githubStatus:async()=>Q(await n().githubStatus()),githubSyncNow:async()=>Q(await n().githubSyncNow()),githubPull:async()=>Q(await n().githubPull()),removeEntry:async c=>Q(await n().removeEntry(c)),setGithubRepo:async c=>Q(await n().setGithubRepo(c)),setGithubToken:async c=>Q(await n().setGithubToken(c)),checkUpdate:async()=>Q(await n().checkUpdate()),update:async()=>Q(await n().update())})})})}
453
+ `;var _p={name:"@xiaoyuyu6420/dsh-backup",version:"0.13.3",description:"\u4E00\u952E\u5907\u4EFD\u4E0E\u6062\u590D DeepSeek Harness\uFF08dsh\uFF09\u7684\u5168\u90E8\u7528\u6237\u6570\u636E\uFF08~/.dsh\uFF09\uFF1A\u5B9A\u65F6\u81EA\u52A8\u5907\u4EFD\u3001\u5347\u7EA7\u5FEB\u7167\u3001\u4F1A\u8BDD\u65E5\u5FD7\u4F53\u68C0\u4FEE\u590D\u3001\u8FC1\u79FB\u9884\u68C0\u3001\u5BBF\u4E3B\u8D77\u4E0D\u6765\u4E5F\u80FD\u7528\u7684\u6551\u63F4\u901A\u9053\u3001\u51ED\u636E\u8131\u654F\u3001GitHub \u540C\u6B65\u8DE8\u673A\u8FC1\u79FB\uFF0C\u9644\u53EF\u89C6\u5316\u9762\u677F\u3002 One command to back up & restore all of ~/.dsh: /backup, scheduled auto-backup, upgrade snapshots, session doctor & repair, migrate precheck, out-of-process rescue console, credential redaction, GitHub sync.",type:"module",packageManager:"pnpm@11.24.0",main:"lib/index.js",exports:{".":"./lib/index.js","./client":"./lib/client.js","./cordis.patch.yml":"./cordis.patch.yml","./package.json":"./package.json"},files:["lib","rescue","cordis.patch.yml","README.md","README.en.md","LICENSE"],bin:{"dsh-rescue":"./rescue/rescue.mjs"},dsh:{bundle:{patch:"./cordis.patch.yml"},client:{platform:"web",inject:["@deepseek-ai/dsh-client-runtime","@deepseek-ai/dsh-client-locale","@deepseek-ai/dsh-client-ui-settings"]}},keywords:["dsh","dsh-plugin","deepseek-harness","backup","\u5907\u4EFD","restore","\u6062\u590D","schedule","\u81EA\u52A8\u5907\u4EFD","cross-platform","rescue","doctor","upgrade-snapshot","\u6570\u636E\u5B89\u5168"],peerDependencies:{"@deepseek-ai/cordis":"^4.0.1","@deepseek-ai/dsh-commands":"^0.1.1-rc.2 || ^0.1.2-rc.1 || ^0.1.5-rc.1 || ^0.1.6-alpha.1 || ^0.1.7-alpha.1 || ^0.2.0-rc.1","@deepseek-ai/dsh-fs":"^0.1.1-rc.2 || ^0.1.2-rc.1 || ^0.1.5-rc.1 || ^0.1.6-alpha.1 || ^0.1.7-alpha.1 || ^0.2.0-rc.1","@deepseek-ai/dsh-settings":"^0.1.1-rc.2 || ^0.1.2-rc.1 || ^0.1.5-rc.1 || ^0.1.6-alpha.1 || ^0.1.7-alpha.1 || ^0.2.0-rc.1","@deepseek-ai/dsh-subprocess":"^0.1.1-rc.2 || ^0.1.2-rc.1 || ^0.1.5-rc.1 || ^0.1.6-alpha.1 || ^0.1.7-alpha.1 || ^0.2.0-rc.1","@deepseek-ai/dsh-tools":"^0.1.1-rc.2 || ^0.1.2-rc.1 || ^0.1.5-rc.1 || ^0.1.6-alpha.1 || ^0.1.7-alpha.1 || ^0.2.0-rc.1","@deepseek-ai/dsh-typert-protocol":"^0.1.1-rc.2 || ^0.1.2-rc.1 || ^0.1.5-rc.1 || ^0.1.6-alpha.1 || ^0.1.7-alpha.1 || ^0.2.0-rc.1","@deepseek-ai/schemastery":"^3.18.1"},devDependencies:{esbuild:"^0.25.0",react:"^19.0.0","react-dom":"^19.0.0",zod:"^4.4.3"},engines:{node:">=20",dsh:"^0.1.1-rc.2 || ^0.1.2-rc.1 || ^0.1.5-rc.1 || ^0.1.6-alpha.1 || ^0.1.7-alpha.1 || ^0.2.0-rc.1"},license:"MIT",repository:{type:"git",url:"git+https://github.com/xiaoyuyu6420/dsh-backup.git"},homepage:"https://github.com/xiaoyuyu6420/dsh-backup#readme",bugs:{url:"https://github.com/xiaoyuyu6420/dsh-backup/issues"},publishConfig:{access:"public"}};var Vn="settings.backupPanel",Cg=_p.name,Lg=["slots","locale","remote"],Rg=v.object({destination:v.string(),dshHome:v.string(),keepDefault:v.number().int(),autoHours:v.number().int(),lastAuto:v.string().nullable(),backups:v.array(v.object({name:v.string(),size:v.number().int().nullable()})),typedBackups:v.array(v.object({name:v.string(),size:v.number().int().nullable(),types:v.array(v.string())})).optional()}),Fg=v.object({ok:v.boolean(),summary:v.string(),path:v.string(),sha:v.string(),stale:v.number().int(),keep:v.number().int(),types:v.array(v.string()).optional(),hasCredentials:v.boolean().optional()}),Jg=v.object({ok:v.boolean(),summary:v.string(),results:v.array(v.object({name:v.string(),ok:v.boolean(),note:v.string()}))}),Vg=v.object({ok:v.boolean(),dryRun:v.boolean(),summary:v.string(),archive:v.string().nullable().optional(),files:v.number().int().nullable().optional(),aside:v.string().nullable().optional(),snapshotPath:v.string().nullable().optional(),sample:v.array(v.string()).optional(),preflight:v.array(v.string()).optional(),targetExists:v.boolean().nullable().optional(),merge:v.boolean().optional(),types:v.array(v.string()).optional(),willOverwrite:v.array(v.string()).optional(),restored:v.number().int().optional(),kept:v.array(v.string()).optional()}),Mg=v.object({ok:v.boolean(),hours:v.number().int(),summary:v.string()}),Wg=v.object({repoRaw:v.string().nullable(),repo:v.string().nullable(),tokenSet:v.boolean(),syncDir:v.string(),lastPush:v.string().nullable(),lastError:v.string().nullable()}),Bg=v.object({ok:v.boolean(),summary:v.string(),pushed:v.boolean(),tooBig:v.array(v.string())}),Kg=v.object({ok:v.boolean(),summary:v.string(),pulled:v.array(v.string()),corrupt:v.array(v.string()),total:v.number().int()}),Gg=v.object({ok:v.boolean(),summary:v.string()}),Xg=v.object({ok:v.boolean(),repo:v.string().nullable(),summary:v.string()}),Hg=v.object({ok:v.boolean(),current:v.string(),latest:v.string().nullable(),update:v.boolean(),error:v.string().nullable(),summary:v.string()}),qg=v.object({ok:v.boolean(),updated:v.boolean(),current:v.string().nullable(),latest:v.string().nullable(),summary:v.string()});function Pe(t,r){return Object.freeze({mode:"strict",typeSymbol:t,schema:r,create:()=>r})}var Yg={name:"keep",wire:"keep",source:"json",codec:Pe("dsh-backup/types#keep",v.number().int().positive().optional()),acceptsUndefined:!0},ru={name:"selector",wire:"selector",source:"json",codec:Pe("dsh-backup/types#selector",v.string().optional()),acceptsUndefined:!0},Qg={name:"dryRun",wire:"dryRun",source:"json",codec:Pe("dsh-backup/types#dryRun",v.boolean().optional()),acceptsUndefined:!0},ev={name:"syncDeps",wire:"syncDeps",source:"json",codec:Pe("dsh-backup/types#syncDeps",v.boolean().optional()),acceptsUndefined:!0},tv={name:"hours",wire:"hours",source:"json",codec:Pe("dsh-backup/types#hours",v.number().int().min(0).max(720)),acceptsUndefined:!0},rv={name:"repo",wire:"repo",source:"json",codec:Pe("dsh-backup/types#repo",v.string().optional()),acceptsUndefined:!0},xp={name:"types",wire:"types",source:"json",codec:Pe("dsh-backup/types#types",v.array(v.string()).optional()),acceptsUndefined:!0};function re(t,r,i,o){return Object.freeze({id:`dsh-backup#backupPanel/${t}`,service:"backupPanel",namespace:"backupPanel",method:t,invocation:Object.freeze({kind:"direct"}),parameters:Object.freeze(r.map(e=>Object.freeze({...e,codec:Object.freeze(e.codec)}))),...o?{cancellation:Object.freeze({parameter:"signal"})}:{},result:Pe(`dsh-backup/types#${t}Result`,i)})}var kp=Object.freeze({package:"dsh-backup",descriptors:Object.freeze([re("status",[],Rg,!1),re("backup",[Yg,xp],Fg,!0),re("verify",[ru],Jg,!0),re("restore",[ru,Qg,xp,ev],Vg,!0),re("setAuto",[tv],Mg,!1),re("githubStatus",[],Wg,!1),re("githubSyncNow",[],Bg,!0),re("githubPull",[],Kg,!0),re("removeEntry",[ru],Gg,!0),re("setGithubRepo",[rv],Xg,!1),re("checkUpdate",[],Hg,!0),re("update",[],qg,!0)])});function Q(t){if(!t.ok){let r=t.error,i=r&&r.message?`${r.code}: ${r.message}`:"backupPanel \u8C03\u7528\u5931\u8D25";throw new Error(`\u64CD\u4F5C\u6CA1\u6709\u5B8C\u6210\uFF1A\u9762\u677F\u6682\u65F6\u8FDE\u4E0D\u4E0A\u540E\u53F0\uFF0C\u8BF7\u91CD\u8BD5\uFF1B\u82E5\u53CD\u590D\u51FA\u73B0\u8BF7\u91CD\u542F dsh web\uFF08\u6280\u672F\u7EC6\u8282: ${i}\uFF09`)}return t.value}function nv(t){t.effect(()=>t.locale.register(Vn,{zh:bp,en:$p}),"dsh-backup: dictionaries"),t.effect(()=>yp(),"dsh-backup: stylesheet");let r=!1,i=(o,e,n)=>{if(r&&e!==tu)return;let a=o.locale.bind(Vn);o.slots.inject("settings.plugins.tab",()=>o.slots.register({name:"settings.plugins.tab",id:"backup",order:35,label:()=>a("tab"),locale:Vn,inject:()=>({panel:n})},e))};t.inject(["slots","locale"],o=>{t.inject(["remote"],e=>{t.effect(()=>{let n=null,a=!0,c=!1;return(async()=>{try{n=await e.remote.$mount(kp)}catch(u){console.error("dsh-backup: backupPanel mount failed:",u),r=!0,i(o,tu,{mountError:u})}a=!1,c&&n?.()})(),()=>{c=!0,a||n?.()}},"dsh-backup: remote contribution")}),t.inject(["remote.backupPanel"],e=>{let n=()=>e.remote.backupPanel;i(e,hp,{status:async()=>Q(await n().status()),backup:async(c,u)=>Q(await n().backup(c,u)),verify:async c=>Q(await n().verify(c)),restore:async(c,u,s,l)=>Q(await n().restore(c,u,s,l)),setAuto:async c=>Q(await n().setAuto(c)),githubStatus:async()=>Q(await n().githubStatus()),githubSyncNow:async()=>Q(await n().githubSyncNow()),githubPull:async()=>Q(await n().githubPull()),removeEntry:async c=>Q(await n().removeEntry(c)),setGithubRepo:async c=>Q(await n().setGithubRepo(c)),setGithubToken:async c=>Q(await n().setGithubToken(c)),checkUpdate:async()=>Q(await n().checkUpdate()),update:async()=>Q(await n().update())})})})}
454
454
  return module.exports; } });
package/lib/index.js CHANGED
@@ -47,6 +47,7 @@ import { mkdir, open, copyFile, chmod, readdir, readFile, rename, rm, stat as fs
47
47
  import { createRequire } from 'node:module';
48
48
  import { hostname } from 'node:os';
49
49
  import { basename, dirname, join } from 'node:path';
50
+ import { StringDecoder } from 'node:string_decoder';
50
51
  import { fileURLToPath } from 'node:url';
51
52
  import * as zlib from 'node:zlib';
52
53
  import { defineTool } from '@deepseek-ai/dsh-tools';
@@ -518,7 +519,7 @@ class BackupPanelService extends TypertRemoteService {
518
519
  return this.ops.doctorRepair(selector, signal);
519
520
  }
520
521
 
521
- /** 迁移预检:只读扫描全部会话日志(含 v1/v2/v3 各代),预测升级后哪些会话打不开。 */
522
+ /** 迁移预检:只读扫描全部会话日志(v0 起各代,按文件名代判定),预测升级后哪些会话打不开。 */
522
523
  migrateCheck(signal) {
523
524
  return this.ops.migrateCheck(signal);
524
525
  }
@@ -610,10 +611,16 @@ export function apply(ctx, pluginConfig) {
610
611
 
611
612
  // ---------- 工具函数 ----------
612
613
  /**
613
- * 读取 subprocess 收集的输出流。dsh-subprocess 的收集契约:输出超过
614
- * maxBytes(8192)时内存窗口只保留尾部(readFrom 返回 lossy: true),
615
- * 完整输出 spill 到 spillPath——restore 的 `tar -tvzf` 校验清单必须看
616
- * 全量,否则大归档的校验可被清单前段的恶意条目绕过(8KB 尾窗看不到)。
614
+ * 读取 subprocess 收集的输出流(诊断/小输出用)。dsh-subprocess 的收集契约:
615
+ * 输出超过 maxBytes(8192)时内存窗口只保留尾部(readFrom 返回 lossy: true),
616
+ * 完整输出 spill 到 spillPath。
617
+ *
618
+ * ⚠️ **不要用它读体积不可控、且结果参与安全判定的输出**(#119):spill 自身还有
619
+ * 上限,宿主对超过上限的流会**丢弃 spill 文件**(`discardSpill()`),此时
620
+ * `readFrom` 只给内存尾窗、`lossy: true` 且**没有 spillPath**——旧实现把这段
621
+ * 截断文本原样返回,`tar -tvzf` 校验清单因此把"断在文件名中间的碎片"当成不安全
622
+ * 条目,整包被拒绝恢复(备份成功、verify 报完整、面板正常列出,只在恢复时炸)。
623
+ * 这类输出请走 {@link streamListingLines}(原始流逐行消费,无上限)。
617
624
  */
618
625
  async function collectOutput(col) {
619
626
  const read = col?.readFrom(0);
@@ -628,6 +635,65 @@ export function apply(ctx, pluginConfig) {
628
635
  return read.text ?? '';
629
636
  }
630
637
 
638
+ /**
639
+ * 逐行消费一条命令的 stdout(`stdio: 'pipe'` 原始流形态),把每一行交给
640
+ * `onLine`;`onLine` 返回 false 时立即终止子进程并提前返回("发现违规就不必读完")。
641
+ *
642
+ * 为什么不用有界收集(#119):见 {@link collectOutput} 的 ⚠️。这里内存只保留
643
+ * "尚未收尾的一行",清单多大都不截断,也不会把碎片当成一行;stderr 仍走有界
644
+ * 收集(只作诊断,几 KB 足够)。
645
+ */
646
+ async function streamListingLines(argv, cwd, signal, onLine) {
647
+ const proc = ctx.subprocess.spawn({
648
+ argv,
649
+ cwd,
650
+ graceMs: 5000,
651
+ signal,
652
+ stdio: { stdin: 'ignore', stdout: 'pipe', stderr: { maxBytes: 8192, spill: { maxBytes: 1 << 20 } } },
653
+ });
654
+ const stream = proc.stdout;
655
+ if (!stream) throw new Error('宿主未提供原始输出流(subprocess stdout:"pipe" 不可用)——请升级宿主后重试');
656
+ // 单行长度的护栏:tar -tv 的一行正常不过几百字节;给足余量后仍设上限,
657
+ // 避免被构造出的超长名字把 carry 撑成内存炸弹。
658
+ const MAX_LINE_BYTES = 16 * 1024 * 1024;
659
+ const decoder = new StringDecoder('utf8');
660
+ let bytes = 0;
661
+ let lines = 0;
662
+ let carry = '';
663
+ let stopped = false;
664
+ for await (const chunk of stream) {
665
+ bytes += chunk.length;
666
+ carry += decoder.write(chunk);
667
+ let idx;
668
+ while ((idx = carry.indexOf('\n')) >= 0) {
669
+ const line = carry.slice(0, idx);
670
+ carry = carry.slice(idx + 1);
671
+ lines += 1;
672
+ if (onLine(line) === false) { stopped = true; break; }
673
+ }
674
+ if (stopped) break;
675
+ if (carry.length > MAX_LINE_BYTES) throw new Error(`输出单行超过 ${MAX_LINE_BYTES} 字节上限——拒绝继续解析`);
676
+ }
677
+ if (stopped) {
678
+ proc.terminate();
679
+ } else {
680
+ carry += decoder.end();
681
+ if (carry.length) { lines += 1; onLine(carry); }
682
+ }
683
+ let err = '';
684
+ try {
685
+ err = await collectOutput(proc.collected?.stderr);
686
+ } catch { /* 诊断文本尽力而为 */ }
687
+ const outcome = await proc.done;
688
+ // 取消优先于一切结果分类(与 spawnRun 同款取舍)
689
+ if (signal?.aborted) throw new Error('操作已取消');
690
+ if (!stopped) {
691
+ if (outcome.signal != null) throw new Error(`命令被终止(signal=${outcome.signal})`);
692
+ if (outcome.exitCode !== 0) throw new Error(`命令失败 exit=${outcome.exitCode}: ${err}`);
693
+ }
694
+ return { bytes, lines, stopped };
695
+ }
696
+
631
697
  async function spawnRun(argv, cwd, signal) {
632
698
  const proc = ctx.subprocess.spawn({
633
699
  argv,
@@ -762,17 +828,33 @@ export function apply(ctx, pluginConfig) {
762
828
  }
763
829
  }
764
830
 
831
+ /** 读回 profile 内实际解析到的本插件版本(pnpm 把直接依赖链接在 profile 的
832
+ * node_modules 下);路径异常或读不到时返回 null,调用方按「无法核对」处理。 */
833
+ async function installedVersion(profile) {
834
+ try {
835
+ const meta = JSON.parse(await readFile(join(paths().dshHome, 'profiles', profile, 'node_modules', SELF_META.name, 'package.json'), 'utf8'));
836
+ return typeof meta?.version === 'string' && meta.version ? meta.version : null;
837
+ } catch {
838
+ return null;
839
+ }
840
+ }
841
+
765
842
  /**
766
843
  * 一键更新(B):先拍升级前快照(未过期的退路),再走官方 `dsh plugin
767
- * --profile <p> update <pkg>`(pnpm 语义——profile 是 pnpm 项目,插件绝不
768
- * 自己覆写依赖文件,否则 lockfile 失配)。完成后提示重启生效:宿主在启动
769
- * 时结算 Loader 树,Node 半不可在运行中热替换。
844
+ * --profile <p> update --latest <pkg>`(pnpm 语义——profile 是 pnpm 项目,
845
+ * 插件绝不自己覆写依赖文件,否则 lockfile 失配)。完成后提示重启生效:宿主
846
+ * 在启动时结算 Loader 树,Node 半不可在运行中热替换。
847
+ *
848
+ * `--latest` 不可省:pnpm update <pkg> 只在 profile 声明的版本范围内升级
849
+ * (安装时写入的是 ^x.y.z),跨 minor 会停在旧 minor 上——而升级按钮承诺的
850
+ * 目标通常正是新 minor,少了它就是「点了更新、版本没动」。命令跑完再读回
851
+ * profile 内的真实版本核对,不拿 registry 的 latest 冒充结果。
770
852
  */
771
853
  async function runUpdate(signal, profileArg) {
772
854
  const profile = profileArg || 'web';
773
855
  const chk = await checkUpdate(signal);
774
856
  if (chk.error) {
775
- return { ok: false, updated: false, summary: `检查更新失败:${chk.error}\n可手动执行: dsh plugin --profile ${profile} update ${SELF_META.name}` };
857
+ return { ok: false, updated: false, summary: `检查更新失败:${chk.error}\n可手动执行: dsh plugin --profile ${profile} update --latest ${SELF_META.name}` };
776
858
  }
777
859
  if (!chk.update) {
778
860
  return { ok: true, updated: false, current: chk.current, latest: chk.latest, summary: `已是最新版本 ${chk.current}` };
@@ -788,12 +870,12 @@ export function apply(ctx, pluginConfig) {
788
870
  // detectHostTrain 同款定位——`node <入口> plugin ...` 与运行中的宿主同源)
789
871
  let argv = null;
790
872
  try {
791
- argv = [await ctx.subprocess.resolveExecutable('dsh'), 'plugin', '--profile', profile, 'update', SELF_META.name];
873
+ argv = [await ctx.subprocess.resolveExecutable('dsh'), 'plugin', '--profile', profile, 'update', '--latest', SELF_META.name];
792
874
  } catch {
793
875
  try {
794
- argv = [process.execPath, realpathSync(process.argv[1]), 'plugin', '--profile', profile, 'update', SELF_META.name];
876
+ argv = [process.execPath, realpathSync(process.argv[1]), 'plugin', '--profile', profile, 'update', '--latest', SELF_META.name];
795
877
  } catch {
796
- return { ok: false, updated: false, summary: `找不到 dsh 命令,请手动执行: dsh plugin --profile ${profile} update ${SELF_META.name}${snap ? `\n更新前快照已留: ${snap}` : ''}` };
878
+ return { ok: false, updated: false, summary: `找不到 dsh 命令,请手动执行: dsh plugin --profile ${profile} update --latest ${SELF_META.name}${snap ? `\n更新前快照已留: ${snap}` : ''}` };
797
879
  }
798
880
  }
799
881
  try {
@@ -802,12 +884,26 @@ export function apply(ctx, pluginConfig) {
802
884
  } catch (err) {
803
885
  return { ok: false, updated: false, summary: `更新失败:${friendlyReason(err)}${snap ? `\n已有升级前快照 ${snap},可 /backup restore ${snap} 回滚。` : ''}` };
804
886
  }
887
+ // 命令跑完 ≠ 版本已变:profile 里锁着精确版本、pnpm 配置忽略 --latest 等情况
888
+ // 都会让结果停在旧版,读回真实版本再回执(否则用户重启后仍是旧版,无从解释)。
889
+ const after = await installedVersion(profile);
890
+ if (after && after !== chk.latest) {
891
+ return {
892
+ ok: false,
893
+ updated: false,
894
+ current: after,
895
+ latest: chk.latest,
896
+ summary: `更新命令已执行,但 profile 内解析到的仍是 ${after}(目标 ${chk.latest})。`
897
+ + `\n请手动执行: dsh plugin --profile ${profile} update --latest ${SELF_META.name}`
898
+ + (snap ? `\n更新前已留升级前快照 ${snap}。` : ''),
899
+ };
900
+ }
805
901
  return {
806
902
  ok: true,
807
903
  updated: true,
808
- current: chk.latest,
904
+ current: after || chk.latest,
809
905
  latest: chk.latest,
810
- summary: `已更新到 ${chk.latest}。${snap ? `\n更新前已留升级前快照 ${snap}(想回退:/backup restore ${snap})。` : '\n更新前快照未生成(不影响本次更新)。'}\n请重启 dsh web 使新版本生效。`,
906
+ summary: `已更新到 ${after || chk.latest}。${snap ? `\n更新前已留升级前快照 ${snap}(想回退:/backup restore ${snap})。` : '\n更新前快照未生成(不影响本次更新)。'}\n请重启 dsh web 使新版本生效。`,
811
907
  };
812
908
  }
813
909
 
@@ -1711,7 +1807,29 @@ export function apply(ctx, pluginConfig) {
1711
1807
  }
1712
1808
  }
1713
1809
  if (nameIdx < 0 || nameIdx >= f.length) return null;
1714
- return { type: f[0][0], name: f.slice(nameIdx).join(' ').replace(/\/$/, '') };
1810
+ const rest = f.slice(nameIdx).join(' ');
1811
+ // tar -tv 的链接形态:硬链接 `<名> link to <目标>`、符号链接 `<名> -> <目标>`。
1812
+ // 必须把目标摘出来单独校验:旧实现把它并进 name,于是硬链接条目的"名字"
1813
+ // 天然含空格与目标路径,必然被判非法而整体拒绝(#112),错误信息里也把
1814
+ // 目标一起印出来。
1815
+ let name = rest;
1816
+ let link = null;
1817
+ const hardAt = rest.indexOf(' link to ');
1818
+ if (hardAt >= 0) {
1819
+ name = rest.slice(0, hardAt);
1820
+ link = rest.slice(hardAt + ' link to '.length);
1821
+ } else {
1822
+ const symAt = rest.lastIndexOf(' -> ');
1823
+ if (symAt >= 0) {
1824
+ name = rest.slice(0, symAt);
1825
+ link = rest.slice(symAt + 4);
1826
+ }
1827
+ }
1828
+ return {
1829
+ type: f[0][0],
1830
+ name: name.replace(/\/$/, ''),
1831
+ link: link === null ? null : link.replace(/\/$/, ''),
1832
+ };
1715
1833
  }
1716
1834
 
1717
1835
  /**
@@ -1772,33 +1890,40 @@ export function apply(ctx, pluginConfig) {
1772
1890
  const { root, dshHome } = paths();
1773
1891
  const base = dshHome.split('/').pop();
1774
1892
  const tar = await ctx.subprocess.resolveExecutable('tar');
1775
- const listed = await spawnRun([tar, '-tvzf', pickedName], root, signal);
1776
- // tar 路径穿越防护:逐行解析 -tvzf 输出(双布局,见 parseTarEntry),
1777
- // 条目必须是普通文件/目录、相对路径且位于备份根目录之下;任一违规
1778
- // 即整体拒绝,恢复绝不触碰备份根之外的文件。
1893
+ // 清单流式读取(#119):不用有界收集——大归档的清单超过 spill 上限时会被截断,
1894
+ // 碎片会被误判成"不安全条目"而拒绝整包(详见 streamListingLines 注释)。
1779
1895
  const entries = [];
1780
1896
  const bad = [];
1781
- for (const line of listed.out.split('\n')) {
1897
+ // 路径检查(反斜杠归一化后再判):`.dsh/..\..\escape.txt` 与 `/` 分隔的
1898
+ // `..` 段同样拒绝(Windows 风格路径变体)。
1899
+ const insideBase = (p) => Boolean(p)
1900
+ && !p.startsWith('/') && !/^[A-Za-z]:/.test(p)
1901
+ && !p.split('/').includes('..')
1902
+ && (p === base || p.startsWith(`${base}/`));
1903
+ const onLine = (line) => {
1782
1904
  const s = line.trim();
1783
- if (!s) continue;
1905
+ if (!s) return true;
1784
1906
  const parsed = parseTarEntry(s);
1785
- // 反斜杠归一化后再做全部路径检查:`.dsh/..\..\escape.txt` 与
1786
- // `/` 分隔的 `..` 段同样拒绝(Windows 风格路径变体)
1787
1907
  const name = parsed ? parsed.name.replace(/\\/g, '/').replace(/\/$/, '') : null;
1788
- if (
1789
- !parsed || !name
1790
- || (parsed.type !== '-' && parsed.type !== 'd')
1791
- || name.startsWith('/') || /^[A-Za-z]:/.test(name)
1792
- || name.split('/').includes('..')
1793
- || (name !== base && !name.startsWith(`${base}/`))
1794
- ) {
1908
+ const link = parsed && parsed.link ? parsed.link.replace(/\\/g, '/').replace(/\/$/, '') : null;
1909
+ // 允许的类型:普通文件 '-'、目录 'd',以及**目标也在备份根内**的硬链接 'h'。
1910
+ // 硬链接是 DSH 附件库(attachments/v1:files/ 与 file-objects/ 同一 inode)的
1911
+ // 正常形态,tar 会把第二个路径写成 'h' 条目——旧实现只放行 -/d,于是**带附件的
1912
+ // 归档一律被判"不安全条目"整体拒绝**,完整恢复/dry-run/分类型合并/doctor 修复/
1913
+ // 救援台全链路失效(#112)。硬链接不能跨文件系统、也不能指向目录,目标同为
1914
+ // 归档内相对路径时解压不会逃出备份根;符号链接(l)、设备等仍一律拒绝。
1915
+ const plainOk = Boolean(parsed) && Boolean(name) && (parsed.type === '-' || parsed.type === 'd') && insideBase(name);
1916
+ const hardOk = Boolean(parsed) && parsed.type === 'h' && insideBase(name) && insideBase(link);
1917
+ if (!plainOk && !hardOk) {
1795
1918
  bad.push(parsed ? name : s);
1796
1919
  } else {
1797
1920
  entries.push(name);
1798
1921
  }
1799
- }
1922
+ return bad.length === 0; // 发现违规立即停读并终止 tar,不必读完
1923
+ };
1924
+ const listed = await streamListingLines([tar, '-tvzf', pickedName], root, signal, onLine);
1800
1925
  if (bad.length) {
1801
- throw new Error(`归档包含不安全条目(拒绝恢复):${bad.slice(0, 3).join(', ')},恢复已中止`);
1926
+ throw new Error(`归档包含不安全条目(拒绝恢复):${bad.slice(0, 3).join(', ')}(已读清单 ${listed.lines} 行 / ${listed.bytes} 字节),恢复已中止`);
1802
1927
  }
1803
1928
  return entries;
1804
1929
  }
@@ -2128,7 +2253,17 @@ export function apply(ctx, pluginConfig) {
2128
2253
  // (魔数/帧头/块序列/校验和),再逐帧解出逻辑行。检测目标正是社区高频的
2129
2254
  // "corrupt session log"灾难:并发写 seq 撞号、坏帧、尾部截断、单帧重写。
2130
2255
  const ZSTD_MAGIC = 0xfd2fb528;
2131
- const SESSION_LOG_NAMES = new Set(['session.jsonl.zstd', 'session.jsonl']);
2256
+ // 会话日志文件名 = 宿主 sessionFormatLogFilename 的形态:session[.v<N>].jsonl[.zstd]
2257
+ // (N≥1 可多位;无 .vN 后缀即第 0 代)。**代数是文件名的一部分、会随格式换代前进**,
2258
+ // 写死具体名字会在换代当天整体失明(#117:v4 机器上 doctor/migrate 一份都看不见),
2259
+ // 故按形态匹配,让新代文件至少进入校验/预检流程(代的判定另见迁移预检)。
2260
+ const SESSION_LOG_RE = /^session(?:\.v([1-9][0-9]*))?\.jsonl(?:\.zstd)?$/;
2261
+ const isSessionLogName = (name) => SESSION_LOG_RE.test(name);
2262
+ /** 从会话日志文件名取格式代;非会话日志名返回 null(无 .vN 后缀即 v0)。 */
2263
+ function sessionLogVersion(name) {
2264
+ const m = SESSION_LOG_RE.exec(name);
2265
+ return m ? Number(m[1] ?? 0) : null;
2266
+ }
2132
2267
  const SCAN_SKIP_DIRS = new Set(['node_modules', '.system', '.git', 'vault']);
2133
2268
  const SCAN_MAX_FILES = 4000;
2134
2269
  const SCAN_MAX_DEPTH = 8;
@@ -2273,7 +2408,7 @@ export function apply(ctx, pluginConfig) {
2273
2408
  }
2274
2409
 
2275
2410
  /** 有界递归收集会话日志文件(相对 ~/.dsh 路径);跳过依赖/系统目录。 */
2276
- async function collectSessionLogs(dir, relPrefix, depth, out, names = SESSION_LOG_NAMES) {
2411
+ async function collectSessionLogs(dir, relPrefix, depth, out, isLogName = isSessionLogName) {
2277
2412
  if (depth > SCAN_MAX_DEPTH || out.length >= SCAN_MAX_FILES) return;
2278
2413
  let dirents;
2279
2414
  try {
@@ -2286,8 +2421,8 @@ export function apply(ctx, pluginConfig) {
2286
2421
  const rel = relPrefix ? `${relPrefix}/${d.name}` : d.name;
2287
2422
  if (d.isDirectory()) {
2288
2423
  if (SCAN_SKIP_DIRS.has(d.name)) continue;
2289
- await collectSessionLogs(`${dir}/${d.name}`, rel, depth + 1, out, names);
2290
- } else if (names.has(d.name)) {
2424
+ await collectSessionLogs(`${dir}/${d.name}`, rel, depth + 1, out, isLogName);
2425
+ } else if (isLogName(d.name)) {
2291
2426
  out.push({ abs: `${dir}/${d.name}`, rel });
2292
2427
  }
2293
2428
  }
@@ -2371,16 +2506,13 @@ export function apply(ctx, pluginConfig) {
2371
2506
  // (0.1.5-rc.2 校准,与 0.11.2 对齐 isHeaderLine 同一套路),覆盖"解析即判"
2372
2507
  // 的规则;需要重放迁移过程的规则(chunk 折叠溯源、引用重映射、worker 复核)
2373
2508
  // 不在覆盖范围,结果里如实标注。三层判定:fail=会话将打不开;migratable=
2374
- // 旧代但静态规则全过(首次写开会发布 v3 新代);ok=已是当前代。
2375
- const MIGRATE_CURRENT_VERSION = 3;
2376
- /** 物理代文件名 ↔ 格式代(宿主 sessionFormatLogFilename;文件名与 header 不一致宿主拒载)。 */
2377
- const MIGRATE_LOG_NAMES = [
2378
- { name: 'session.jsonl', version: 0 },
2379
- { name: 'session.v1.jsonl', version: 1 },
2380
- { name: 'session.v2.jsonl', version: 2 },
2381
- { name: 'session.v3.jsonl', version: 3 },
2382
- ];
2383
- const MIGRATE_LOG_SET = new Set(MIGRATE_LOG_NAMES.flatMap((g) => [g.name, `${g.name}.zstd`]));
2509
+ // 旧代但静态规则全过(首次写开会发布当前代新文件);ok=已是当前代。
2510
+ //
2511
+ // **校准代(#117)**:宿主会随格式换代推进文件名与 header 的 version(0.1.7 起
2512
+ // 为 v4——迁移包只到 `v3-to-v4`、无 `v4-to-v5`)。此常数是本插件**规则**覆盖到的
2513
+ // 最高代,换代时须同步更新;比它更新的日志只提示、不判"打不开"(见
2514
+ // newer-generation)——"宿主读不读得了"取决于宿主版本,不是我们这张注定滞后的表。
2515
+ const MIGRATE_CALIBRATED_VERSION = 4;
2384
2516
  // 冻结事件清单(RELEASED_V0_EVENT_TYPES / RELEASED_V2_EVENT_TYPES,宿主
2385
2517
  // 按契约不增长旧代清单);migration 对清单外类型连 ignorable 也拒(#6355)。
2386
2518
  const MIGRATE_V0_EVENT_TYPES = new Set(['agent-preset/selected', 'agent/inbox/spliced', 'approval/asked', 'approval/decided', 'approval/policy', 'assistant/chunk', 'assistant/message', 'command/done', 'command/run', 'compaction/end', 'compaction/prune', 'compaction/start', 'compaction/summary', 'feedback/record', 'goal/change', 'hook/invoked', 'hook/result', 'llm/retry', 'llm/retry-started', 'model/selection', 'permission/preset', 'plan/mode', 'request/context', 'request/header', 'sandbox/mode', 'schedule/change', 'session-log-deepseek/delivery-accepted', 'session/end-seed', 'session/title', 'session/title-llm-request', 'step/end', 'step/start', 'subagent/descriptor', 'subagent/model-selection-policy', 'team/member', 'team/message/delivered', 'team/message/queued', 'team/task', 'todo/write', 'tool-workflow/agent-end', 'tool-workflow/agent-start', 'tool-workflow/run-end', 'tool-workflow/run-start', 'tool/call', 'tool/code-dispatch', 'tool/code-dispatch-start', 'tool/result', 'turn/end', 'turn/start', 'user/message', 'web/deepseek-search-llm-request']);
@@ -2388,14 +2520,26 @@ export function apply(ctx, pluginConfig) {
2388
2520
  /** v0 侧退役类型(pre-react-loop 预发布形态;宿主点名拒绝)。 */
2389
2521
  const MIGRATE_V0_RETIRED_TYPES = new Set(['request/header-delta', 'mode/set']);
2390
2522
  /** 消息来源 kind 白名单(v3 侧 15 类;未知 kind 在 v2→v3 边被拒——#6355 主症状)。 */
2391
- const MIGRATE_SOURCE_KINDS = new Set(['user', 'plugin', 'model', 'tool', 'agent-instructions', 'session-reference', 'team-message', 'goal', 'skill-invocation', 'skill-catalog', 'coordinator', 'subagent-report', 'subagent-settled', 'webhook', 'agent-message']);
2523
+ /**
2524
+ * 「消息来源 kind」的**已知**集合(#113)。注意语义:宿主的 `source.kind` 是
2525
+ * **可合并扩展的联合类型**——每个生产者在自己的模块里声明自己的 kind,没有
2526
+ * 统一的兜底值,所以任何硬编码清单都**不可能完整**。这里的集合因此只当"已知
2527
+ * 名单"用:命中的静默通过,没命中的记一条**提示**(不再判为"打不开",见
2528
+ * 下方 unknown-source-kind)——真正会拒载的是旧代**事件类型**的冻结清单。
2529
+ *
2530
+ * 内容来自宿主 `@deepseek-ai/dsh-session-format-v3-to-v4` 自带的
2531
+ * `RENAMED_PRODUCERS` 取值与 `RELEASED_SAME_NAME_PRODUCERS` 名单
2532
+ * (2026-09-29 从 0.2.0-rc.1 实装包逐行核对),外加 `producerKind()` 的兜底
2533
+ * 形态 `plugin:<插件名>`(用前缀匹配,见 MIGRATE_KIND_KNOWN)。
2534
+ */
2535
+ const MIGRATE_SOURCE_KINDS = new Set(['user', 'plugin', 'model', 'tool', 'agent-instructions', 'session-reference', 'team-message', 'goal', 'skill-invocation', 'skill-catalog', 'coordinator', 'subagent-report', 'subagent-settled', 'webhook', 'agent-message', 'compact-checkpoint', 'ptc-mode', 'compact-basic', 'runtime-context', 'system-prompt', 'model-selection', 'plan-mode', 'time-context', 'tmux-context', 'user-approval', 'repeat-tool-reminder', 'tool-cordis', 'cordis-host-runner', 'tool-goal', 'tool-jobs', 'hooks-codex', 'hooks-claude-code', 'schedule', 'dsh-session-title-llm', 'fallback', 'provider']);
2392
2536
  const MIGRATE_MAX_FINDINGS = 3;
2537
+ /** kind 是否在已知名单内:精确命中,或宿主兜底形态 `plugin:<插件名>`(可任意长尾,故用前缀判定)。 */
2538
+ const MIGRATE_KIND_KNOWN = (kind) => MIGRATE_SOURCE_KINDS.has(kind) || kind.startsWith('plugin:');
2393
2539
 
2394
- /** 从物理文件名推格式代;非代文件名(未来扩展)返回 null。 */
2540
+ /** 非代文件名(未来扩展)返回 null;代的判定与 doctor 共用 SESSION_LOG_RE。 */
2395
2541
  function migrateFilenameVersion(fileName) {
2396
- const base = fileName.replace(/\.zstd$/, '');
2397
- const g = MIGRATE_LOG_NAMES.find((x) => x.name === base);
2398
- return g ? g.version : null;
2542
+ return sessionLogVersion(fileName);
2399
2543
  }
2400
2544
 
2401
2545
  /**
@@ -2413,18 +2557,26 @@ export function apply(ctx, pluginConfig) {
2413
2557
  if (!header || typeof header !== 'object' || typeof header.version !== 'number') return null;
2414
2558
  const sourceVersion = header.version;
2415
2559
  const findings = [];
2560
+ const warnings = [];
2416
2561
  let total = 0;
2417
2562
  const fail = (rule, detail) => {
2418
2563
  total += 1;
2419
2564
  if (findings.length < MIGRATE_MAX_FINDINGS) findings.push({ rule, detail });
2420
2565
  };
2421
- if (sourceVersion > MIGRATE_CURRENT_VERSION) {
2422
- fail('newer-than-host', `header version=${sourceVersion} 高于本宿主可读的 v${MIGRATE_CURRENT_VERSION}——日志由更新版本的宿主写出,读/写都会被拒(提示语会要求升级宿主)`);
2423
- return { sourceVersion, findings, total };
2566
+ // 提示(不判"打不开"):用于**开放集合**上的命中失败——最典型的是
2567
+ // source.kind(可合并扩展的联合类型,硬编码清单天生补不全,#113)。
2568
+ const warn = (rule, detail) => {
2569
+ if (warnings.length < MIGRATE_MAX_FINDINGS) warnings.push({ rule, detail });
2570
+ };
2571
+ if (sourceVersion > MIGRATE_CALIBRATED_VERSION) {
2572
+ // 更新的格式代:本插件的规则表对它是盲区,但"能不能读"由宿主决定(同代/更新可读、
2573
+ // 降级运行被拒)——按开放集合纪律只提示、不替宿主下"打不开"的判决(#117/#113)。
2574
+ warn('newer-generation', `header version=${sourceVersion} 高于本插件校准到的 v${MIGRATE_CALIBRATED_VERSION}——日志由更新的格式代写出。能否打开取决于宿主:宿主同代或更新即可读,**降级运行会被拒**;若你的宿主已是这一代,说明本插件校准滞后,欢迎反馈。仅提示,不判为打不开`);
2575
+ return { sourceVersion, findings, warnings, total };
2424
2576
  }
2425
2577
  if (fileVersion !== null && fileVersion !== sourceVersion) {
2426
2578
  fail('name-version-mismatch', `文件名标 v${fileVersion},header 标 v${sourceVersion}——宿主按不一致直接拒载`);
2427
- return { sourceVersion, findings, total };
2579
+ return { sourceVersion, findings, warnings, total };
2428
2580
  }
2429
2581
  // 事件行静态规则。seq 游标语义与 validateSessionLines 一致:v0 有 packed 行
2430
2582
  //(一条行多个 chunk),v1 起一行一事件。
@@ -2462,14 +2614,22 @@ export function apply(ctx, pluginConfig) {
2462
2614
  if (sourceVersion === 2 && (type === 'tool/code-dispatch' || type === 'tool/code-dispatch-start')) fail('ptc-reserved-v2', `第 ${i + 1} 行 ${type} 是 v3 保留 PTC 标签,v2→v3 拒绝(ignorable 也拒)`);
2463
2615
  if ((sourceVersion === 2 || sourceVersion === 3) && type === 'request/header' && rec.data?.header?.system !== undefined) fail('retired-header-system', `第 ${i + 1} 行 request/header 携带退役的 header.system——v2→v3 无法保留时序地提升它${sourceVersion === 3 ? ',当前代读取同样拒绝' : ''}`);
2464
2616
  const kind = rec?.data?.source?.kind;
2465
- if (typeof kind === 'string' && !MIGRATE_SOURCE_KINDS.has(kind)) fail('unknown-source-kind', `第 ${i + 1} 行消息来源 kind="${kind}" 不在白名单——v2→v3 边拒绝(社区插件自定义 kind,#6355)`);
2617
+ // kind 只作提示,不作判据(#113):宿主把它设计成可合并扩展的联合类型,
2618
+ // 每个生产者(含任意社区插件)都能加自己的 kind,且生产端对未知 kind 照读
2619
+ // ——旧实现把"不在白名单"直接判成"打不开",在一台真实机器上把 81/82 份
2620
+ // 完全健康的日志报成不可打开。
2621
+ if (typeof kind === 'string' && !MIGRATE_KIND_KNOWN(kind)) {
2622
+ warn('unknown-source-kind', `第 ${i + 1} 行消息来源 kind="${kind}" 不在已知名单(宿主为可合并扩展的联合类型,官方/社区插件都能新增)——仅提示,不判为打不开;真正会拒载的是旧代事件类型不在冻结清单内`);
2623
+ }
2466
2624
  }
2467
- return { sourceVersion, findings, total };
2625
+ return { sourceVersion, findings, warnings, total };
2468
2626
  }
2469
2627
 
2470
2628
  /**
2471
2629
  * 单文件迁移预检:解码(复用 doctor 的帧走查)→ 判代 → 静态规则。
2472
- * 返回 { verdict: 'fail'|'migratable'|'ok'|'skipped', version, findings, total, reason }。
2630
+ * 返回 { verdict: 'fail'|'migratable'|'ok'|'skipped', version, findings, warnings, total, reason }——
2631
+ * findings 是"判为打不开"的硬伤,warnings 是开放集合上的命中失败(如未知
2632
+ * source.kind),只提示不改变 verdict(#113)。
2473
2633
  */
2474
2634
  async function migrateAnalyzeFile(f) {
2475
2635
  const fileVersion = migrateFilenameVersion(f.abs.split('/').pop());
@@ -2484,8 +2644,8 @@ export function apply(ctx, pluginConfig) {
2484
2644
  if (!lines.length) return { verdict: 'fail', version: fileVersion, findings: [{ rule: 'empty', detail: '解出的逻辑行为空' }], total: 1 };
2485
2645
  const r = migrateAnalyzeLines(lines, fileVersion);
2486
2646
  if (!r) return { verdict: 'skipped', version: fileVersion, findings: [], total: 0, reason: 'header 行不可解析——按损坏处理(doctor 体检覆盖),迁移预检跳过' };
2487
- if (r.total > 0) return { verdict: 'fail', version: r.sourceVersion, findings: r.findings, total: r.total };
2488
- return { verdict: r.sourceVersion < MIGRATE_CURRENT_VERSION ? 'migratable' : 'ok', version: r.sourceVersion, findings: [], total: 0 };
2647
+ if (r.total > 0) return { verdict: 'fail', version: r.sourceVersion, findings: r.findings, warnings: r.warnings, total: r.total };
2648
+ return { verdict: r.sourceVersion < MIGRATE_CALIBRATED_VERSION ? 'migratable' : 'ok', version: r.sourceVersion, findings: [], warnings: r.warnings, total: 0 };
2489
2649
  } catch (err) {
2490
2650
  return { verdict: 'skipped', version: fileVersion, findings: [], total: 0, reason: `解码失败(${String(err && err.message ? err.message : err)})——doctor 体检会按损坏处理` };
2491
2651
  }
@@ -2551,7 +2711,7 @@ export function apply(ctx, pluginConfig) {
2551
2711
  const { dshHome, root } = paths();
2552
2712
  if (signal?.aborted) throw new Error('操作已取消');
2553
2713
  const found = [];
2554
- await collectSessionLogs(dshHome, '', 0, found, MIGRATE_LOG_SET);
2714
+ await collectSessionLogs(dshHome, '', 0, found);
2555
2715
  const sessions = [];
2556
2716
  for (const f of found) {
2557
2717
  if (signal?.aborted) throw new Error('操作已取消');
@@ -2561,8 +2721,9 @@ export function apply(ctx, pluginConfig) {
2561
2721
  const fails = sessions.filter((s) => s.verdict === 'fail');
2562
2722
  const migratable = sessions.filter((s) => s.verdict === 'migratable');
2563
2723
  const skipped = sessions.filter((s) => s.verdict === 'skipped');
2724
+ const warned = sessions.filter((s) => (s.warnings?.length ?? 0) > 0);
2564
2725
  const lines = [];
2565
- lines.push(`扫描 ${sessions.length} 个会话日志(含 v1/v2/v3 代):${fails.length} 个升级后将打不开、${migratable.length} 个旧代待迁移(静态规则全过)、${skipped.length} 个未能判定`);
2726
+ lines.push(`扫描 ${sessions.length} 个会话日志(v0 起各代,按文件名代判定):${fails.length} 个升级后将打不开、${migratable.length} 个旧代待迁移(静态规则全过)、${skipped.length} 个未能判定${warned.length ? `、${warned.length} 个带提示(不影响打开)` : ''}`);
2566
2727
  if (fails.length) {
2567
2728
  lines.push('');
2568
2729
  for (const s of fails.slice(0, 15)) {
@@ -2574,24 +2735,33 @@ export function apply(ctx, pluginConfig) {
2574
2735
  lines.push('');
2575
2736
  lines.push('升级前建议:先拍一份升级前快照(/backup),坏的会话可从更早归档定点修复(/backup doctor --repair)。');
2576
2737
  }
2738
+ if (warned.length) {
2739
+ lines.push('');
2740
+ lines.push(`ℹ️ ${warned.length} 个日志带提示(**不计入"打不开"**,逐条看原因):`);
2741
+ for (const s of warned.slice(0, 5)) {
2742
+ for (const w of s.warnings) lines.push(` · ${s.rel}:${w.detail}`);
2743
+ }
2744
+ if (warned.length > 5) lines.push(` …另有 ${warned.length - 5} 个文件`);
2745
+ }
2577
2746
  if (!env.hardlink) {
2578
2747
  lines.push('');
2579
- lines.push(`⚠️ 文件系统不支持硬链接(${env.hardlinkNote})——宿主发布 v3 新代文件会失败(官方 #6358 场景):旧会话读得了、写开就报错,且升级无法继续。建议把 ~/.dsh 挪回 APFS/ext4 等本地文件系统。`);
2748
+ lines.push(`⚠️ 文件系统不支持硬链接(${env.hardlinkNote})——宿主发布新代文件会失败(官方 #6358 场景):旧会话读得了、写开就报错,且升级无法继续。建议把 ~/.dsh 挪回 APFS/ext4 等本地文件系统。`);
2580
2749
  }
2581
2750
  if (env.credentialsFlat) {
2582
2751
  lines.push('');
2583
2752
  lines.push(`⚠️ .credentials.yaml 还是旧扁平布局——宿主下次读档会把它原子替换成 version:1(不可逆,旧宿主将读不回)。${env.credentialsPreserved ? `已自动存底:${env.credentialsPreserved.replace(`${root}/`, '')}` : '自动存底失败(vault 不可写?),建议立即手动 /backup 一次。'}`);
2584
2753
  }
2585
2754
  lines.push('');
2586
- lines.push(`覆盖说明:以上为静态规则判定(宿主 ${detectHostTrain() || '未知列车'} 校准)。需要重放迁移过程的检查(chunk 折叠溯源、引用重映射、发布前 worker 复核)不在本版覆盖内——静态全过仍可能在新代发布时被宿主拒载。`);
2755
+ lines.push(`覆盖说明:以上为静态规则判定(宿主 ${detectHostTrain() || '未知列车'} 校准;格式代校准到 v${MIGRATE_CALIBRATED_VERSION})。需要重放迁移过程的检查(chunk 折叠溯源、引用重映射、发布前 worker 复核)不在本版覆盖内——静态全过仍可能在新代发布时被宿主拒载。`);
2587
2756
  const head = fails.length || !env.hardlink || env.credentialsFlat ? '⚠️' : '✅';
2588
2757
  return {
2589
2758
  scanned: sessions.length,
2590
- sessions: sessions.map((s) => ({ rel: s.rel, verdict: s.verdict, version: s.version, total: s.total, findings: s.findings, ...(s.reason ? { reason: s.reason } : {}) })),
2759
+ sessions: sessions.map((s) => ({ rel: s.rel, verdict: s.verdict, version: s.version, total: s.total, findings: s.findings, ...(s.warnings?.length ? { warnings: s.warnings } : {}), ...(s.reason ? { reason: s.reason } : {}) })),
2591
2760
  env,
2592
2761
  failCount: fails.length,
2593
2762
  migratableCount: migratable.length,
2594
2763
  skippedCount: skipped.length,
2764
+ warningCount: warned.length,
2595
2765
  summary: `${head} ${lines.join('\n')}`,
2596
2766
  };
2597
2767
  }
@@ -3123,7 +3293,7 @@ export function apply(ctx, pluginConfig) {
3123
3293
  // ---------- backup_dsh 模型工具 ----------
3124
3294
  ctx.tools.register(defineTool({
3125
3295
  name: 'backup_dsh',
3126
- description: '备份、校验、恢复或体检 DSH 用户数据(~/.dsh 的会话、配置、技能)。凭据文件默认脱敏:不进归档、不进 GitHub 同步,明文只存本机 vault,恢复时自动还原。mode=backup 立即备份(keep 指定保留份数);mode=list 列出备份;mode=verify 校验完整性(selector=前缀或 all,缺省最新一份);mode=restore 恢复(selector=前缀或 latest,syncDeps 恢复后重装 profile 依赖;恢复前自动校验并快照当前数据,解压失败自动还原;真实恢复前必须先 dryRun=true 预览并向用户展示确认);mode=auto 设置定时备份(hours 间隔小时数,0=关闭,缺省查询);mode=doctor 会话日志体检——检测损坏的会话历史(seq 撞号/坏帧/截断),repair=true 时从 selector 归档定点修复;mode=migrate 迁移预检——静态扫描全部代会话日志(含 v1/v2/v3 代),预测宿主升级后哪些会话会打不开、挂在哪条规则,并探测文件系统硬链接支持与凭据文件旧布局',
3296
+ description: '备份、校验、恢复或体检 DSH 用户数据(~/.dsh 的会话、配置、技能)。凭据文件默认脱敏:不进归档、不进 GitHub 同步,明文只存本机 vault,恢复时自动还原。mode=backup 立即备份(keep 指定保留份数);mode=list 列出备份;mode=verify 校验完整性(selector=前缀或 all,缺省最新一份);mode=restore 恢复(selector=前缀或 latest,syncDeps 恢复后重装 profile 依赖;恢复前自动校验并快照当前数据,解压失败自动还原;真实恢复前必须先 dryRun=true 预览并向用户展示确认);mode=auto 设置定时备份(hours 间隔小时数,0=关闭,缺省查询);mode=doctor 会话日志体检——检测损坏的会话历史(seq 撞号/坏帧/截断),repair=true 时从 selector 归档定点修复;mode=migrate 迁移预检——静态扫描全部代会话日志(v0 起各代),预测宿主升级后哪些会话会打不开、挂在哪条规则,并探测文件系统硬链接支持与凭据文件旧布局',
3127
3297
  parameters: {
3128
3298
  mode: { type: 'string', required: true, enum: ['backup', 'list', 'verify', 'restore', 'auto', 'doctor', 'migrate'], description: 'backup=执行备份,list=列出备份,verify=校验完整性,restore=恢复,auto=定时备份,doctor=会话日志体检,migrate=升级迁移预检' },
3129
3299
  keep: { type: 'number', description: '保留的备份份数(mode=backup,默认 7)' },
@@ -3188,8 +3358,11 @@ export function apply(ctx, pluginConfig) {
3188
3358
  failCount: r.failCount,
3189
3359
  migratableCount: r.migratableCount,
3190
3360
  skippedCount: r.skippedCount,
3361
+ warningCount: r.warningCount ?? 0,
3191
3362
  summary: r.summary,
3192
- sessions: r.sessions.filter((s) => s.verdict === 'fail').map((s) => ({ path: s.rel, version: s.version, findings: s.findings })),
3363
+ // fail 项照旧全带;带提示(不判打不开)的条目也带上 warnings——否则
3364
+ // UI 只能看到"没问题",用户无从知道有哪些开放集合上的命中失败(#113)。
3365
+ sessions: r.sessions.filter((s) => s.verdict === 'fail' || s.warnings?.length).map((s) => ({ path: s.rel, version: s.version, findings: s.findings, ...(s.warnings?.length ? { warnings: s.warnings } : {}) })),
3193
3366
  env: r.env,
3194
3367
  };
3195
3368
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xiaoyuyu6420/dsh-backup",
3
- "version": "0.13.2",
3
+ "version": "0.13.3",
4
4
  "description": "一键备份与恢复 DeepSeek Harness(dsh)的全部用户数据(~/.dsh):定时自动备份、升级快照、会话日志体检修复、迁移预检、宿主起不来也能用的救援通道、凭据脱敏、GitHub 同步跨机迁移,附可视化面板。 One command to back up & restore all of ~/.dsh: /backup, scheduled auto-backup, upgrade snapshots, session doctor & repair, migrate precheck, out-of-process rescue console, credential redaction, GitHub sync.",
5
5
  "type": "module",
6
6
  "packageManager": "pnpm@11.24.0",
package/rescue/rescue.mjs CHANGED
@@ -37,7 +37,11 @@ const HERE = path.dirname(fileURLToPath(import.meta.url));
37
37
  const SENSITIVE_DEFAULTS = ['.credentials.yaml', '.env', 'qq-bridge/config.json'];
38
38
  const VAULT_DIR = 'vault';
39
39
  const ZSTD_MAGIC = 0xfd2fb528;
40
- const SESSION_LOG_NAMES = new Set(['session.jsonl.zstd', 'session.jsonl']);
40
+ // 与 lib/index.js 同源:会话日志名形如 session[.v<N>].jsonl[.zstd](宿主
41
+ // sessionFormatLogFilename 的形态)。写死具体名字会在格式换代当天整体失明
42
+ // (#117)——救援台是宿主起不来时的唯一通道,更不能看不见会话。
43
+ const SESSION_LOG_RE = /^session(?:\.v([1-9][0-9]*))?\.jsonl(?:\.zstd)?$/;
44
+ const isSessionLogName = (name) => SESSION_LOG_RE.test(name);
41
45
  const SCAN_SKIP_DIRS = new Set(['node_modules', '.system', '.git', 'vault']);
42
46
  const SCAN_MAX_FILES = 4000;
43
47
  const SCAN_MAX_DEPTH = 8;
@@ -173,24 +177,48 @@ function parseTarEntry(line) {
173
177
  }
174
178
  }
175
179
  if (nameIdx < 0 || nameIdx >= f.length) return null;
176
- return { type: f[0][0], name: f.slice(nameIdx).join(' ').replace(/\/$/, '') };
180
+ const rest = f.slice(nameIdx).join(' ');
181
+ // 链接目标单独摘出(硬链接 `<名> link to <目标>`、符号链接 `<名> -> <目标>`):
182
+ // 与插件侧 parseTarEntry 同语义,别把目标混进名字(#112)。
183
+ let name = rest;
184
+ let link = null;
185
+ const hardAt = rest.indexOf(' link to ');
186
+ if (hardAt >= 0) {
187
+ name = rest.slice(0, hardAt);
188
+ link = rest.slice(hardAt + ' link to '.length);
189
+ } else {
190
+ const symAt = rest.lastIndexOf(' -> ');
191
+ if (symAt >= 0) {
192
+ name = rest.slice(0, symAt);
193
+ link = rest.slice(symAt + 4);
194
+ }
195
+ }
196
+ return {
197
+ type: f[0][0],
198
+ name: name.replace(/\/$/, ''),
199
+ link: link === null ? null : link.replace(/\/$/, ''),
200
+ };
177
201
  }
178
202
 
179
203
  function safeEntries(listedText, base) {
180
204
  const entries = [];
181
205
  const bad = [];
206
+ const insideBase = (p) => Boolean(p)
207
+ && !p.startsWith('/') && !/^[A-Za-z]:/.test(p)
208
+ && !p.split('/').includes('..')
209
+ && (p === base || p.startsWith(`${base}/`));
182
210
  for (const line of listedText.split('\n')) {
183
211
  const s = line.trim();
184
212
  if (!s) continue;
185
213
  const parsed = parseTarEntry(s);
186
214
  const name = parsed ? parsed.name.replace(/\\/g, '/').replace(/\/$/, '') : null;
187
- if (
188
- !parsed || !name
189
- || (parsed.type !== '-' && parsed.type !== 'd')
190
- || name.startsWith('/') || /^[A-Za-z]:/.test(name)
191
- || name.split('/').includes('..')
192
- || (name !== base && !name.startsWith(`${base}/`))
193
- ) {
215
+ const link = parsed && parsed.link ? parsed.link.replace(/\\/g, '/').replace(/\/$/, '') : null;
216
+ // 与插件侧同语义:放行目标也在备份根内的硬链接 'h'(DSH 附件库的正常形态,
217
+ // 见 issue #112),符号链接/设备等仍拒绝。救援台是宿主起不来时的唯一通路,
218
+ // 这里漏判会让"插件写出的归档自己都恢复不了"。
219
+ const plainOk = Boolean(parsed) && Boolean(name) && (parsed.type === '-' || parsed.type === 'd') && insideBase(name);
220
+ const hardOk = Boolean(parsed) && parsed.type === 'h' && insideBase(name) && insideBase(link);
221
+ if (!plainOk && !hardOk) {
194
222
  bad.push(parsed ? name : s);
195
223
  } else {
196
224
  entries.push(name);
@@ -338,7 +366,7 @@ async function collectSessionLogs(dir, relPrefix, depth, out) {
338
366
  if (d.isDirectory()) {
339
367
  if (SCAN_SKIP_DIRS.has(d.name)) continue;
340
368
  await collectSessionLogs(`${dir}/${d.name}`, rel, depth + 1, out);
341
- } else if (SESSION_LOG_NAMES.has(d.name)) {
369
+ } else if (isSessionLogName(d.name)) {
342
370
  out.push({ abs: `${dir}/${d.name}`, rel });
343
371
  }
344
372
  }