@xiaoyuyu6420/dsh-backup 0.14.2 → 0.14.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
@@ -152,6 +152,8 @@ Tried it? Tell us what broke, what's missing, what you liked — it directly sha
152
152
  <details>
153
153
  <summary>Recent releases</summary>
154
154
 
155
+ - **0.14.3** — ships the security and reliability fixes from #139: validates archive/plugin paths, repairs rollback and operation locking, keeps retained credentials private, and preserves healthy backups when corrupt sessions are detected. Also fixes local-plugin migration, preview validation and large-archive rescue verification. tar/ZIP exports exclude a backup directory inside `DSH_HOME`, including its vault; move that entire directory outside `DSH_HOME` before a full restore. Includes 26 regression scenarios and 71/71 real-host checks on DSH `0.2.2-rc.1`. [Release notes](docs/promo/release-0.14.3.md).
156
+
155
157
  - **0.14.2** — review fixes: encryption/decryption now **stream in chunks** and write to a temp file before an atomic rename (no whole-archive buffering, no truncated ciphertext on crash); added the missing client descriptor for `setGithubToken` plus a reverse assertion that every panel method has a descriptor; braced `redactSyncChannel` (s3/gist no longer report a bogus `passwordSet:false`); copy now mentions Codex, the import preview no longer promises per-item override UI, multi-channel sync is documented as push-only, About gains a feedback link, and `rescue --fix --map` requires `--yes`. Verified: smoke 596 / settings 47 / client 62 / real-host e2e 70.
156
158
 
157
159
  - **0.14.1** — follow-up release: npm 0.14.0 shipped before Codex support landed; this version adds Codex chat-history import to the npm track (Claude Code / Codex: scan → preview → import as a new session, function calls paired by call_id into tool call/result, originals untouched) and syncs the verified numbers (smoke 587 / settings 47 / client 60 / real-host e2e 70).
package/README.md CHANGED
@@ -154,6 +154,8 @@ dsh plugin --profile web add github:xiaoyuyu6420/dsh-backup
154
154
  <details>
155
155
  <summary>最近的版本</summary>
156
156
 
157
+ - **0.14.3** —— 发布 #139 的安全与可靠性修复:限制归档与插件导入路径,修复失败回滚、凭据留档权限和操作锁;损坏会话不再触发删除健康备份;修复本地插件迁移、预览结果校验和救援大文件校验。备份目录位于 `DSH_HOME` 内时,tar/ZIP 会排除整个备份目录及 vault,避免重复打包和文件变化错误;整包恢复前需先将整个备份目录移到 `DSH_HOME` 外。新增 26 组回归,真实 DSH `0.2.2-rc.1` e2e 71/71。详情:[发布说明](docs/promo/release-0.14.3.md)。
158
+
157
159
  - **0.14.2** —— 复核修复:加密/解密改**分块流式**并「写临时文件 → 原子替换」(大归档不再整包入内存,崩溃不留半截密文);补 `setGithubToken` 缺失的客户端 descriptor + 「面板方法必有 descriptor」反向断言;`redactSyncChannel` 补花括号(修掉 s3/gist 通道假的 `passwordSet:false`);文案补 Codex、导入预览不再承诺界面逐项覆盖、多通道同步写清为单向推送、About 加反馈入口、`rescue --fix --map` 需 `--yes`;验收 smoke 596 / settings 47 / client 62 / 真机 e2e 70。
158
160
 
159
161
  - **0.14.1** —— 补发:npm 上的 0.14.0 先于 Codex 支持发布,本版把「Codex 聊天记录导入」补齐到 npm 版本(Claude Code / Codex 双来源:扫描 → 预览 → 导入为新会话,函数调用按 call_id 配对成工具调用/结果,原数据零改动),并同步验收数字(smoke 587 / settings 47 / client 60 / 真机 e2e 70)。
package/lib/client.js CHANGED
@@ -553,5 +553,5 @@ Set the \`cycles\` parameter to \`"ref"\` to resolve cyclical schemas with defs.
553
553
  [data-dsh-backup] .dsb-item-actions { flex-wrap: wrap; }
554
554
  [data-dsh-backup] .dsb-kv { grid-template-columns: 1fr; }
555
555
  }
556
- `;var ym={name:"@xiaoyuyu6420/dsh-backup",version:"0.14.2",description:"\u4E00\u6761\u547D\u4EE4\u5907\u4EFD\u3001\u6062\u590D\u4E0E\u8FC1\u79FB DeepSeek Harness\uFF08dsh\uFF09\u7684\u5168\u90E8\u6570\u636E\uFF08~/.dsh\uFF1A\u4F1A\u8BDD\u3001\u8BBE\u7F6E\u3001\u63D2\u4EF6\u3001MCP\u3001\u6280\u80FD\uFF09\uFF1A\u5B9A\u65F6\u81EA\u52A8\u5907\u4EFD\u3001\u5347\u7EA7\u524D\u5FEB\u7167\u3001\u6362\u673A\u8FC1\u79FB\u3001\u4F1A\u8BDD\u65E5\u5FD7\u4F53\u68C0\u4FEE\u590D\u3001\u5BBF\u4E3B\u8D77\u4E0D\u6765\u4E5F\u80FD\u7528\u7684\u8FDB\u7A0B\u5916\u6551\u63F4\uFF1B\u51ED\u636E\u9ED8\u8BA4\u8131\u654F\u53EA\u5B58\u672C\u673A\u3002 Backup, restore & migrate all DeepSeek Harness (~/.dsh) data \u2014 sessions, settings, plugins, MCP, skills: scheduled backups, pre-upgrade snapshots, cross-machine migration, session-log doctor, out-of-process rescue console; credentials redacted to a local-only vault.",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","migration","\u8FC1\u79FB","\u6362\u673A","schedule","\u81EA\u52A8\u5907\u4EFD","cross-platform","cross-machine","rescue","doctor","upgrade-snapshot","mcp","skills","\u6280\u80FD","sessions","\u4F1A\u8BDD","workspace","\u5DE5\u4F5C\u533A","vault","\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 hi="settings.backupPanel",xb=ym.name,_b=["slots","locale","remote"],wb=c.object({destination:c.string(),dshHome:c.string(),keepDefault:c.number().int(),autoHours:c.number().int(),lastAuto:c.string().nullable(),backups:c.array(c.object({name:c.string(),size:c.number().int().nullable()})),typedBackups:c.array(c.object({name:c.string(),size:c.number().int().nullable(),types:c.array(c.string())})).optional()}),Sb=c.object({ok:c.boolean(),summary:c.string(),path:c.string(),sha:c.string(),stale:c.number().int(),keep:c.number().int(),encrypted:c.boolean().optional(),types:c.array(c.string()).optional(),hasCredentials:c.boolean().optional()}),zb=c.object({ok:c.boolean(),summary:c.string(),results:c.array(c.object({name:c.string(),ok:c.boolean(),note:c.string()}))}),Ib=c.object({ok:c.boolean(),dryRun:c.boolean(),summary:c.string(),archive:c.string().nullable().optional(),files:c.number().int().nullable().optional(),aside:c.string().nullable().optional(),snapshotPath:c.string().nullable().optional(),sample:c.array(c.string()).optional(),preflight:c.array(c.string()).optional(),targetExists:c.boolean().nullable().optional(),merge:c.boolean().optional(),types:c.array(c.string()).optional(),willOverwrite:c.array(c.string()).optional(),restored:c.number().int().optional(),kept:c.array(c.string()).optional(),encrypted:c.boolean().optional()}),Pb=c.object({ok:c.boolean(),hours:c.number().int(),summary:c.string()}),Nb=c.object({repoRaw:c.string().nullable(),repo:c.string().nullable(),tokenSet:c.boolean(),syncDir:c.string(),lastPush:c.string().nullable(),lastError:c.string().nullable()}),jb=c.object({ok:c.boolean(),summary:c.string(),pushed:c.boolean(),tooBig:c.array(c.string())}),Db=c.object({ok:c.boolean(),summary:c.string(),pulled:c.array(c.string()),corrupt:c.array(c.string()),total:c.number().int()}),Ub=c.object({ok:c.boolean(),summary:c.string()}),Tb=c.object({ok:c.boolean(),repo:c.string().nullable(),summary:c.string()}),Ob=c.object({ok:c.boolean(),current:c.string(),latest:c.string().nullable(),update:c.boolean(),error:c.string().nullable(),summary:c.string()}),Zb=c.object({ok:c.boolean(),updated:c.boolean(),current:c.string().nullable(),latest:c.string().nullable(),summary:c.string()}),Eb=c.object({ok:c.boolean(),summary:c.string(),path:c.string().optional(),name:c.string().optional(),size:c.number().int().optional(),encrypted:c.boolean().optional(),manifest:c.unknown().optional(),redactedFiles:c.array(c.string()).optional()}),Cb=c.object({ok:c.boolean(),summary:c.string(),encrypted:c.boolean().optional(),manifest:c.unknown().optional(),sections:c.array(c.object({key:c.string(),label:c.string().optional(),files:c.number().int().optional(),bytes:c.number().optional(),conflicts:c.array(c.object({name:c.string(),rel:c.string().optional(),exists:c.boolean().optional()})).optional()})).optional(),totalFiles:c.number().int().optional(),totalBytes:c.number().optional(),conflicts:c.array(c.object({name:c.string(),rel:c.string().optional(),section:c.string().optional(),exists:c.boolean().optional()})).optional(),remap:c.array(c.unknown()).optional(),compatibility:c.object({score:c.string().optional(),reasons:c.array(c.string()).optional()}).optional()}),Ab=c.object({ok:c.boolean(),summary:c.string(),encrypted:c.boolean().optional(),applied:c.number().int().optional(),skipped:c.number().int().optional(),overwrote:c.number().int().optional(),snapshot:c.string().nullable().optional(),remapped:c.number().int().optional()}),Rb=c.object({ok:c.boolean(),summary:c.string(),channels:c.object({github:c.unknown().optional(),webdav:c.unknown().optional(),s3:c.unknown().optional(),gist:c.unknown().optional()}).optional()}),Lb=c.object({ok:c.boolean(),summary:c.string(),pushed:c.boolean().optional(),count:c.number().int().optional(),at:c.string().optional(),tooBig:c.array(c.string()).optional(),gistUrl:c.string().nullable().optional()}),Fb=c.object({ok:c.boolean(),summary:c.string(),channel:c.string().optional()}),Vb=c.object({ok:c.boolean(),summary:c.string()}),Jb=c.object({ok:c.boolean(),held:c.boolean().optional(),stale:c.boolean().optional(),pid:c.number().int().nullable().optional(),operation:c.string().nullable().optional(),startedAt:c.string().nullable().optional(),heartbeatAt:c.string().nullable().optional()}),Mb=c.object({ok:c.boolean(),summary:c.string()}),Bb=c.object({ok:c.boolean(),total:c.number().optional(),totalText:c.string().optional(),categories:c.array(c.object({key:c.string(),label:c.string().optional(),bytes:c.number().optional(),files:c.number().int().optional()})).optional(),summary:c.string()}),Wb=c.object({ok:c.boolean(),removed:c.number().int().optional(),names:c.array(c.string()).optional(),summary:c.string()}),Kb=c.object({ok:c.boolean(),weekly:c.array(c.object({day:c.number().int(),time:c.string()})).optional(),summary:c.string()}),Hb=c.object({ok:c.boolean(),version:c.string().optional(),dshVersion:c.string().optional(),platform:c.string().optional(),node:c.string().optional(),profile:c.string().optional(),pluginDir:c.string().optional()}),Gb=c.object({ok:c.boolean(),summary:c.string(),profiles:c.array(c.object({name:c.string(),version:c.string().optional(),appOwned:c.boolean().optional(),readonly:c.boolean().optional(),running:c.boolean().optional(),pid:c.number().int().nullable().optional()})).optional()}),qb=c.object({ok:c.boolean(),summary:c.string(),name:c.string().optional()}),Xb=c.object({ok:c.boolean(),summary:c.string(),from:c.string().optional(),to:c.string().optional()}),Yb=c.object({ok:c.boolean(),summary:c.string(),name:c.string().optional()}),Qb=c.object({ok:c.boolean(),summary:c.string(),pid:c.number().int().nullable().optional(),running:c.boolean().optional()}),eh=c.object({ok:c.boolean(),summary:c.string(),running:c.boolean().optional()}),th=c.object({id:c.string(),name:c.string(),desc:c.string().optional(),sections:c.array(c.string()).optional(),url:c.string().optional(),source:c.string().optional(),risk:c.string().optional(),version:c.string().optional()}),_m=c.object({ok:c.boolean(),enabled:c.boolean().optional(),entries:c.array(th).optional(),summary:c.string()}),rh=_m,nh=c.object({ok:c.boolean(),dryRun:c.boolean().optional(),entry:c.object({id:c.string(),name:c.string().optional()}).optional(),preview:c.unknown().optional(),applied:c.unknown().optional(),supplyChain:c.array(c.string()).optional(),summary:c.string()}),ih=c.object({id:c.string(),label:c.string().optional(),detected:c.boolean().optional(),skills:c.number().int().optional(),instructions:c.string().nullable().optional(),mcpServers:c.array(c.string()).optional()}),oh=c.object({ok:c.boolean(),sources:c.array(ih).optional(),summary:c.string()}),wm=c.object({source:c.string(),label:c.string().optional(),skills:c.number().int().optional(),instructions:c.string().nullable().optional(),mcpServers:c.array(c.string()).optional(),totalFiles:c.number().int().optional(),totalBytes:c.number().optional()}),ah=c.object({ok:c.boolean(),report:wm.optional(),preview:c.unknown().optional(),summary:c.string()}),sh=c.object({ok:c.boolean(),dryRun:c.boolean().optional(),report:wm.optional(),preview:c.unknown().optional(),applied:c.unknown().optional(),summary:c.string()}),ch=c.object({id:c.string(),rel:c.string().optional(),file:c.string().optional(),cwd:c.string().nullable().optional(),version:c.number().int().optional()}),lh=c.object({ok:c.boolean(),sessions:c.array(ch).optional(),summary:c.string()}),uh=c.object({ok:c.boolean(),session:c.string().optional(),targetSeq:c.number().int().optional(),keepRecords:c.number().int().optional(),dropRecords:c.number().int().optional(),newId:c.string().optional(),newPath:c.string().optional(),warnings:c.array(c.string()).optional(),summary:c.string()}),dh=c.object({ok:c.boolean(),dryRun:c.boolean().optional(),session:c.string().optional(),newId:c.string().optional(),newPath:c.string().optional(),keepRecords:c.number().int().optional(),summary:c.string()}),ph=c.object({source:c.string().optional(),id:c.string(),file:c.string().optional(),project:c.string().optional(),messages:c.number().int().optional(),title:c.string().optional()}),mh=c.object({id:c.string(),label:c.string().optional(),detected:c.boolean().optional(),count:c.number().int().optional()}),fh=c.object({ok:c.boolean(),sources:c.array(mh).optional(),detected:c.boolean().optional(),sessions:c.array(ph).optional(),summary:c.string()}),$m=c.object({ok:c.boolean(),dryRun:c.boolean().optional(),source:c.string().optional(),session:c.string().optional(),file:c.string().optional(),messages:c.number().int().optional(),userCount:c.number().int().optional(),asstCount:c.number().int().optional(),toolCount:c.number().int().optional(),skipped:c.number().int().optional(),events:c.number().int().optional(),newId:c.string().optional(),path:c.string().optional(),warnings:c.array(c.string()).optional(),summary:c.string()});function L(t,r){return Object.freeze({mode:"strict",typeSymbol:t,schema:r,create:()=>r})}var gh={name:"keep",wire:"keep",source:"json",codec:L("dsh-backup/types#keep",c.number().int().positive().optional()),acceptsUndefined:!0},Vl={name:"selector",wire:"selector",source:"json",codec:L("dsh-backup/types#selector",c.string().optional()),acceptsUndefined:!0},vh={name:"dryRun",wire:"dryRun",source:"json",codec:L("dsh-backup/types#dryRun",c.boolean().optional()),acceptsUndefined:!0},bh={name:"syncDeps",wire:"syncDeps",source:"json",codec:L("dsh-backup/types#syncDeps",c.boolean().optional()),acceptsUndefined:!0},hh={name:"hours",wire:"hours",source:"json",codec:L("dsh-backup/types#hours",c.number().int().min(0).max(720)),acceptsUndefined:!0},yh={name:"token",wire:"token",source:"json",codec:L("dsh-backup/types#token",c.string()),acceptsUndefined:!1},$h=c.object({ok:c.boolean(),tokenSet:c.boolean().optional(),summary:c.string()}),kh={name:"repo",wire:"repo",source:"json",codec:L("dsh-backup/types#repo",c.string().optional()),acceptsUndefined:!0},km={name:"types",wire:"types",source:"json",codec:L("dsh-backup/types#types",c.array(c.string()).optional()),acceptsUndefined:!0},xm={name:"password",wire:"password",source:"json",codec:L("dsh-backup/types#password",c.string().optional()),acceptsUndefined:!0},$e={name:"options",wire:"options",source:"json",codec:L("dsh-backup/types#options",c.object({}).passthrough())},Jl={name:"channel",wire:"channel",source:"json",codec:L("dsh-backup/types#channel",c.string())},xh={name:"config",wire:"config",source:"json",codec:L("dsh-backup/types#config",c.object({}).passthrough())},_h={name:"force",wire:"force",source:"json",codec:L("dsh-backup/types#force",c.boolean().optional()),acceptsUndefined:!0},wh={name:"scope",wire:"scope",source:"json",codec:L("dsh-backup/types#scope",c.string())},Sh={name:"schedules",wire:"schedules",source:"json",codec:L("dsh-backup/types#schedules",c.array(c.object({day:c.number().int(),time:c.string()})))},bi={name:"name",wire:"name",source:"json",codec:L("dsh-backup/types#name",c.string())},zh={name:"old",wire:"old",source:"json",codec:L("dsh-backup/types#old",c.string())},Ih={name:"new",wire:"new",source:"json",codec:L("dsh-backup/types#new",c.string())},Ph={name:"query",wire:"query",source:"json",codec:L("dsh-backup/types#query",c.string().optional()),acceptsUndefined:!0},Nh={name:"session",wire:"session",source:"json",codec:L("dsh-backup/types#session",c.string())},jh={name:"seq",wire:"seq",source:"json",codec:L("dsh-backup/types#seq",c.number().int().optional()),acceptsUndefined:!0};function N(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:L(`dsh-backup/types#${t}Result`,i)})}var Sm=Object.freeze({package:"dsh-backup",descriptors:Object.freeze([N("status",[],wb,!1),N("backup",[gh,km,xm],Sb,!0),N("verify",[Vl],zb,!0),N("restore",[Vl,vh,km,bh,xm],Ib,!0),N("setAuto",[hh],Pb,!1),N("githubStatus",[],Nb,!1),N("githubSyncNow",[],jb,!0),N("githubPull",[],Db,!0),N("removeEntry",[Vl],Ub,!0),N("setGithubRepo",[kh],Tb,!1),N("setGithubToken",[yh],$h,!1),N("checkUpdate",[],Ob,!0),N("update",[],Zb,!0),N("exportConfig",[$e],Eb,!0),N("importPreview",[$e],Cb,!0),N("importApply",[$e],Ab,!0),N("syncStatus",[],Rb,!1),N("syncNow",[Jl],Lb,!0),N("setSyncChannel",[Jl,xh],Fb,!1),N("removeSyncChannel",[Jl],Vb,!1),N("lockStatus",[],Jb,!1),N("reclaimLock",[_h],Mb,!1),N("diskUsage",[],Bb,!0),N("cleanup",[wh],Wb,!0),N("setWeekly",[Sh],Kb,!1),N("about",[],Hb,!1),N("profilesList",[],Gb,!1),N("profileCreate",[bi],qb,!1),N("profileRename",[zh,Ih],Xb,!1),N("profileDelete",[bi],Yb,!1),N("profileLaunch",[bi],Qb,!1),N("profileStop",[bi],eh,!1),N("marketList",[],_m,!0),N("marketSearch",[Ph],rh,!0),N("marketInstall",[$e],nh,!0),N("foreignSources",[],oh,!1),N("foreignPreview",[$e],ah,!0),N("foreignImport",[$e],sh,!0),N("sessionRewindList",[],lh,!1),N("sessionRewindPreview",[Nh,jh],uh,!0),N("sessionRewindApply",[$e],dh,!0),N("chatSources",[],fh,!1),N("chatPreview",[$e],$m,!0),N("chatApply",[$e],$m,!0)])});function j(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 Dh(t){t.effect(()=>t.locale.register(hi,{zh:vm,en:bm}),"dsh-backup: dictionaries"),t.effect(()=>hm(),"dsh-backup: stylesheet");let r=!1,i=(o,e,n)=>{if(r&&e!==Fl)return;let a=o.locale.bind(hi);o.slots.inject("settings.plugins.tab",()=>o.slots.register({name:"settings.plugins.tab",id:"backup",order:35,label:()=>a("tab"),locale:hi,inject:()=>({panel:n})},e))};t.inject(["slots","locale"],o=>{t.inject(["remote"],e=>{t.effect(()=>{let n=null,a=!0,s=!1;return(async()=>{try{n=await e.remote.$mount(Sm)}catch(m){console.error("dsh-backup: backupPanel mount failed:",m),r=!0,i(o,Fl,{mountError:m})}a=!1,s&&n?.()})(),()=>{s=!0,a||n?.()}},"dsh-backup: remote contribution")}),t.inject(["remote.backupPanel"],e=>{let n=()=>e.remote.backupPanel;i(e,gm,{status:async()=>j(await n().status()),backup:async(s,m,d)=>j(await n().backup(s,m,d)),verify:async s=>j(await n().verify(s)),restore:async(s,m,d,p,b)=>j(await n().restore(s,m,d,p,b)),setAuto:async s=>j(await n().setAuto(s)),githubStatus:async()=>j(await n().githubStatus()),githubSyncNow:async()=>j(await n().githubSyncNow()),githubPull:async()=>j(await n().githubPull()),removeEntry:async s=>j(await n().removeEntry(s)),setGithubRepo:async s=>j(await n().setGithubRepo(s)),setGithubToken:async s=>j(await n().setGithubToken(s)),checkUpdate:async()=>j(await n().checkUpdate()),update:async()=>j(await n().update()),exportConfig:async s=>j(await n().exportConfig(s)),importPreview:async s=>j(await n().importPreview(s)),importApply:async s=>j(await n().importApply(s)),syncStatus:async()=>j(await n().syncStatus()),syncNow:async s=>j(await n().syncNow(s)),setSyncChannel:async(s,m)=>j(await n().setSyncChannel(s,m)),removeSyncChannel:async s=>j(await n().removeSyncChannel(s)),lockStatus:async()=>j(await n().lockStatus()),reclaimLock:async s=>j(await n().reclaimLock(s)),diskUsage:async()=>j(await n().diskUsage()),cleanup:async s=>j(await n().cleanup(s)),setWeekly:async s=>j(await n().setWeekly(s)),about:async()=>j(await n().about()),profilesList:async()=>j(await n().profilesList()),profileCreate:async s=>j(await n().profileCreate(s)),profileRename:async(s,m)=>j(await n().profileRename(s,m)),profileDelete:async s=>j(await n().profileDelete(s)),profileLaunch:async s=>j(await n().profileLaunch(s)),profileStop:async s=>j(await n().profileStop(s)),marketList:async()=>j(await n().marketList()),marketSearch:async s=>j(await n().marketSearch(s)),marketInstall:async s=>j(await n().marketInstall(s)),foreignSources:async()=>j(await n().foreignSources()),foreignPreview:async s=>j(await n().foreignPreview(s)),foreignImport:async s=>j(await n().foreignImport(s)),sessionRewindList:async()=>j(await n().sessionRewindList()),sessionRewindPreview:async(s,m)=>j(await n().sessionRewindPreview(s,m)),sessionRewindApply:async s=>j(await n().sessionRewindApply(s)),chatSources:async()=>j(await n().chatSources()),chatPreview:async s=>j(await n().chatPreview(s)),chatApply:async s=>j(await n().chatApply(s))})})})}
556
+ `;var ym={name:"@xiaoyuyu6420/dsh-backup",version:"0.14.3",description:"\u4E00\u6761\u547D\u4EE4\u5907\u4EFD\u3001\u6062\u590D\u4E0E\u8FC1\u79FB DeepSeek Harness\uFF08dsh\uFF09\u7684\u5168\u90E8\u6570\u636E\uFF08~/.dsh\uFF1A\u4F1A\u8BDD\u3001\u8BBE\u7F6E\u3001\u63D2\u4EF6\u3001MCP\u3001\u6280\u80FD\uFF09\uFF1A\u5B9A\u65F6\u81EA\u52A8\u5907\u4EFD\u3001\u5347\u7EA7\u524D\u5FEB\u7167\u3001\u6362\u673A\u8FC1\u79FB\u3001\u4F1A\u8BDD\u65E5\u5FD7\u4F53\u68C0\u4FEE\u590D\u3001\u5BBF\u4E3B\u8D77\u4E0D\u6765\u4E5F\u80FD\u7528\u7684\u8FDB\u7A0B\u5916\u6551\u63F4\uFF1B\u51ED\u636E\u9ED8\u8BA4\u8131\u654F\u53EA\u5B58\u672C\u673A\u3002 Backup, restore & migrate all DeepSeek Harness (~/.dsh) data \u2014 sessions, settings, plugins, MCP, skills: scheduled backups, pre-upgrade snapshots, cross-machine migration, session-log doctor, out-of-process rescue console; credentials redacted to a local-only vault.",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","migration","\u8FC1\u79FB","\u6362\u673A","schedule","\u81EA\u52A8\u5907\u4EFD","cross-platform","cross-machine","rescue","doctor","upgrade-snapshot","mcp","skills","\u6280\u80FD","sessions","\u4F1A\u8BDD","workspace","\u5DE5\u4F5C\u533A","vault","\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 hi="settings.backupPanel",xb=ym.name,_b=["slots","locale","remote"],wb=c.object({destination:c.string(),dshHome:c.string(),keepDefault:c.number().int(),autoHours:c.number().int(),lastAuto:c.string().nullable(),backups:c.array(c.object({name:c.string(),size:c.number().int().nullable()})),typedBackups:c.array(c.object({name:c.string(),size:c.number().int().nullable(),types:c.array(c.string())})).optional()}),Sb=c.object({ok:c.boolean(),summary:c.string(),path:c.string(),sha:c.string(),stale:c.number().int(),keep:c.number().int(),encrypted:c.boolean().optional(),types:c.array(c.string()).optional(),hasCredentials:c.boolean().optional()}),zb=c.object({ok:c.boolean(),summary:c.string(),results:c.array(c.object({name:c.string(),ok:c.boolean(),note:c.string()}))}),Ib=c.object({ok:c.boolean(),dryRun:c.boolean(),summary:c.string(),archive:c.string().nullable().optional(),files:c.number().int().nullable().optional(),aside:c.string().nullable().optional(),snapshotPath:c.string().nullable().optional(),sample:c.array(c.string()).optional(),preflight:c.array(c.string()).optional(),targetExists:c.boolean().nullable().optional(),merge:c.boolean().optional(),types:c.array(c.string()).optional(),willOverwrite:c.array(c.string()).optional(),restored:c.number().int().optional(),kept:c.array(c.string()).optional(),encrypted:c.boolean().optional()}),Pb=c.object({ok:c.boolean(),hours:c.number().int(),summary:c.string()}),Nb=c.object({repoRaw:c.string().nullable(),repo:c.string().nullable(),tokenSet:c.boolean(),syncDir:c.string(),lastPush:c.string().nullable(),lastError:c.string().nullable()}),jb=c.object({ok:c.boolean(),summary:c.string(),pushed:c.boolean(),tooBig:c.array(c.string())}),Db=c.object({ok:c.boolean(),summary:c.string(),pulled:c.array(c.string()),corrupt:c.array(c.string()),total:c.number().int()}),Ub=c.object({ok:c.boolean(),summary:c.string()}),Tb=c.object({ok:c.boolean(),repo:c.string().nullable(),summary:c.string()}),Ob=c.object({ok:c.boolean(),current:c.string(),latest:c.string().nullable(),update:c.boolean(),error:c.string().nullable(),summary:c.string()}),Zb=c.object({ok:c.boolean(),updated:c.boolean(),current:c.string().nullable(),latest:c.string().nullable(),summary:c.string()}),Eb=c.object({ok:c.boolean(),summary:c.string(),path:c.string().optional(),name:c.string().optional(),size:c.number().int().optional(),encrypted:c.boolean().optional(),manifest:c.unknown().optional(),redactedFiles:c.array(c.string()).optional()}),Cb=c.object({ok:c.boolean(),summary:c.string(),encrypted:c.boolean().optional(),manifest:c.unknown().optional(),sections:c.array(c.object({key:c.string(),label:c.string().optional(),files:c.number().int().optional(),bytes:c.number().optional(),conflicts:c.array(c.object({name:c.string(),rel:c.string().optional(),exists:c.boolean().optional()})).optional()})).optional(),totalFiles:c.number().int().optional(),totalBytes:c.number().optional(),conflicts:c.array(c.object({name:c.string(),rel:c.string().optional(),section:c.string().optional(),exists:c.boolean().optional()})).optional(),remap:c.array(c.unknown()).optional(),compatibility:c.object({score:c.number().min(0).max(100).optional(),reasons:c.array(c.string()).optional()}).optional()}),Ab=c.object({ok:c.boolean(),summary:c.string(),encrypted:c.boolean().optional(),applied:c.number().int().optional(),skipped:c.number().int().optional(),overwrote:c.number().int().optional(),snapshot:c.string().nullable().optional(),remapped:c.number().int().optional()}),Rb=c.object({ok:c.boolean(),summary:c.string(),channels:c.object({github:c.unknown().optional(),webdav:c.unknown().optional(),s3:c.unknown().optional(),gist:c.unknown().optional()}).optional()}),Lb=c.object({ok:c.boolean(),summary:c.string(),pushed:c.boolean().optional(),count:c.number().int().optional(),at:c.string().optional(),tooBig:c.array(c.string()).optional(),gistUrl:c.string().nullable().optional()}),Fb=c.object({ok:c.boolean(),summary:c.string(),channel:c.string().optional()}),Vb=c.object({ok:c.boolean(),summary:c.string()}),Jb=c.object({ok:c.boolean(),held:c.boolean().optional(),stale:c.boolean().optional(),pid:c.number().int().nullable().optional(),operation:c.string().nullable().optional(),startedAt:c.string().nullable().optional(),heartbeatAt:c.string().nullable().optional()}),Mb=c.object({ok:c.boolean(),summary:c.string()}),Bb=c.object({ok:c.boolean(),total:c.number().optional(),totalText:c.string().optional(),categories:c.array(c.object({key:c.string(),label:c.string().optional(),bytes:c.number().optional(),files:c.number().int().optional()})).optional(),summary:c.string()}),Wb=c.object({ok:c.boolean(),removed:c.number().int().optional(),names:c.array(c.string()).optional(),summary:c.string()}),Kb=c.object({ok:c.boolean(),weekly:c.array(c.object({day:c.number().int(),time:c.string()})).optional(),summary:c.string()}),Hb=c.object({ok:c.boolean(),version:c.string().optional(),dshVersion:c.string().optional(),platform:c.string().optional(),node:c.string().optional(),profile:c.string().optional(),pluginDir:c.string().optional()}),Gb=c.object({ok:c.boolean(),summary:c.string(),profiles:c.array(c.object({name:c.string(),version:c.string().optional(),appOwned:c.boolean().optional(),readonly:c.boolean().optional(),running:c.boolean().optional(),pid:c.number().int().nullable().optional()})).optional()}),qb=c.object({ok:c.boolean(),summary:c.string(),name:c.string().optional()}),Xb=c.object({ok:c.boolean(),summary:c.string(),from:c.string().optional(),to:c.string().optional()}),Yb=c.object({ok:c.boolean(),summary:c.string(),name:c.string().optional()}),Qb=c.object({ok:c.boolean(),summary:c.string(),pid:c.number().int().nullable().optional(),running:c.boolean().optional()}),eh=c.object({ok:c.boolean(),summary:c.string(),running:c.boolean().optional()}),th=c.object({id:c.string(),name:c.string(),desc:c.string().optional(),sections:c.array(c.string()).optional(),url:c.string().optional(),source:c.string().optional(),risk:c.string().optional(),version:c.string().optional()}),_m=c.object({ok:c.boolean(),enabled:c.boolean().optional(),entries:c.array(th).optional(),summary:c.string()}),rh=_m,nh=c.object({ok:c.boolean(),dryRun:c.boolean().optional(),entry:c.object({id:c.string(),name:c.string().optional()}).optional(),preview:c.unknown().optional(),applied:c.unknown().optional(),supplyChain:c.array(c.string()).optional(),summary:c.string()}),ih=c.object({id:c.string(),label:c.string().optional(),detected:c.boolean().optional(),skills:c.number().int().optional(),instructions:c.string().nullable().optional(),mcpServers:c.array(c.string()).optional()}),oh=c.object({ok:c.boolean(),sources:c.array(ih).optional(),summary:c.string()}),wm=c.object({source:c.string(),label:c.string().optional(),skills:c.number().int().optional(),instructions:c.string().nullable().optional(),mcpServers:c.array(c.string()).optional(),totalFiles:c.number().int().optional(),totalBytes:c.number().optional()}),ah=c.object({ok:c.boolean(),report:wm.optional(),preview:c.unknown().optional(),summary:c.string()}),sh=c.object({ok:c.boolean(),dryRun:c.boolean().optional(),report:wm.optional(),preview:c.unknown().optional(),applied:c.unknown().optional(),summary:c.string()}),ch=c.object({id:c.string(),rel:c.string().optional(),file:c.string().optional(),cwd:c.string().nullable().optional(),version:c.number().int().optional()}),lh=c.object({ok:c.boolean(),sessions:c.array(ch).optional(),summary:c.string()}),uh=c.object({ok:c.boolean(),session:c.string().optional(),targetSeq:c.number().int().optional(),keepRecords:c.number().int().optional(),dropRecords:c.number().int().optional(),newId:c.string().optional(),newPath:c.string().optional(),warnings:c.array(c.string()).optional(),summary:c.string()}),dh=c.object({ok:c.boolean(),dryRun:c.boolean().optional(),session:c.string().optional(),newId:c.string().optional(),newPath:c.string().optional(),keepRecords:c.number().int().optional(),summary:c.string()}),ph=c.object({source:c.string().optional(),id:c.string(),file:c.string().optional(),project:c.string().optional(),messages:c.number().int().optional(),title:c.string().optional()}),mh=c.object({id:c.string(),label:c.string().optional(),detected:c.boolean().optional(),count:c.number().int().optional()}),fh=c.object({ok:c.boolean(),sources:c.array(mh).optional(),detected:c.boolean().optional(),sessions:c.array(ph).optional(),summary:c.string()}),$m=c.object({ok:c.boolean(),dryRun:c.boolean().optional(),source:c.string().optional(),session:c.string().optional(),file:c.string().optional(),messages:c.number().int().optional(),userCount:c.number().int().optional(),asstCount:c.number().int().optional(),toolCount:c.number().int().optional(),skipped:c.number().int().optional(),events:c.number().int().optional(),newId:c.string().optional(),path:c.string().optional(),warnings:c.array(c.string()).optional(),summary:c.string()});function L(t,r){return Object.freeze({mode:"strict",typeSymbol:t,schema:r,create:()=>r})}var gh={name:"keep",wire:"keep",source:"json",codec:L("dsh-backup/types#keep",c.number().int().positive().optional()),acceptsUndefined:!0},Vl={name:"selector",wire:"selector",source:"json",codec:L("dsh-backup/types#selector",c.string().optional()),acceptsUndefined:!0},vh={name:"dryRun",wire:"dryRun",source:"json",codec:L("dsh-backup/types#dryRun",c.boolean().optional()),acceptsUndefined:!0},bh={name:"syncDeps",wire:"syncDeps",source:"json",codec:L("dsh-backup/types#syncDeps",c.boolean().optional()),acceptsUndefined:!0},hh={name:"hours",wire:"hours",source:"json",codec:L("dsh-backup/types#hours",c.number().int().min(0).max(720)),acceptsUndefined:!0},yh={name:"token",wire:"token",source:"json",codec:L("dsh-backup/types#token",c.string()),acceptsUndefined:!1},$h=c.object({ok:c.boolean(),tokenSet:c.boolean().optional(),summary:c.string()}),kh={name:"repo",wire:"repo",source:"json",codec:L("dsh-backup/types#repo",c.string().optional()),acceptsUndefined:!0},km={name:"types",wire:"types",source:"json",codec:L("dsh-backup/types#types",c.array(c.string()).optional()),acceptsUndefined:!0},xm={name:"password",wire:"password",source:"json",codec:L("dsh-backup/types#password",c.string().optional()),acceptsUndefined:!0},$e={name:"options",wire:"options",source:"json",codec:L("dsh-backup/types#options",c.object({}).passthrough())},Jl={name:"channel",wire:"channel",source:"json",codec:L("dsh-backup/types#channel",c.string())},xh={name:"config",wire:"config",source:"json",codec:L("dsh-backup/types#config",c.object({}).passthrough())},_h={name:"force",wire:"force",source:"json",codec:L("dsh-backup/types#force",c.boolean().optional()),acceptsUndefined:!0},wh={name:"scope",wire:"scope",source:"json",codec:L("dsh-backup/types#scope",c.string())},Sh={name:"schedules",wire:"schedules",source:"json",codec:L("dsh-backup/types#schedules",c.array(c.object({day:c.number().int(),time:c.string()})))},bi={name:"name",wire:"name",source:"json",codec:L("dsh-backup/types#name",c.string())},zh={name:"old",wire:"old",source:"json",codec:L("dsh-backup/types#old",c.string())},Ih={name:"new",wire:"new",source:"json",codec:L("dsh-backup/types#new",c.string())},Ph={name:"query",wire:"query",source:"json",codec:L("dsh-backup/types#query",c.string().optional()),acceptsUndefined:!0},Nh={name:"session",wire:"session",source:"json",codec:L("dsh-backup/types#session",c.string())},jh={name:"seq",wire:"seq",source:"json",codec:L("dsh-backup/types#seq",c.number().int().optional()),acceptsUndefined:!0};function N(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:L(`dsh-backup/types#${t}Result`,i)})}var Sm=Object.freeze({package:"dsh-backup",descriptors:Object.freeze([N("status",[],wb,!1),N("backup",[gh,km,xm],Sb,!0),N("verify",[Vl],zb,!0),N("restore",[Vl,vh,km,bh,xm],Ib,!0),N("setAuto",[hh],Pb,!1),N("githubStatus",[],Nb,!1),N("githubSyncNow",[],jb,!0),N("githubPull",[],Db,!0),N("removeEntry",[Vl],Ub,!0),N("setGithubRepo",[kh],Tb,!1),N("setGithubToken",[yh],$h,!1),N("checkUpdate",[],Ob,!0),N("update",[],Zb,!0),N("exportConfig",[$e],Eb,!0),N("importPreview",[$e],Cb,!0),N("importApply",[$e],Ab,!0),N("syncStatus",[],Rb,!1),N("syncNow",[Jl],Lb,!0),N("setSyncChannel",[Jl,xh],Fb,!1),N("removeSyncChannel",[Jl],Vb,!1),N("lockStatus",[],Jb,!1),N("reclaimLock",[_h],Mb,!1),N("diskUsage",[],Bb,!0),N("cleanup",[wh],Wb,!0),N("setWeekly",[Sh],Kb,!1),N("about",[],Hb,!1),N("profilesList",[],Gb,!1),N("profileCreate",[bi],qb,!1),N("profileRename",[zh,Ih],Xb,!1),N("profileDelete",[bi],Yb,!1),N("profileLaunch",[bi],Qb,!1),N("profileStop",[bi],eh,!1),N("marketList",[],_m,!0),N("marketSearch",[Ph],rh,!0),N("marketInstall",[$e],nh,!0),N("foreignSources",[],oh,!1),N("foreignPreview",[$e],ah,!0),N("foreignImport",[$e],sh,!0),N("sessionRewindList",[],lh,!1),N("sessionRewindPreview",[Nh,jh],uh,!0),N("sessionRewindApply",[$e],dh,!0),N("chatSources",[],fh,!1),N("chatPreview",[$e],$m,!0),N("chatApply",[$e],$m,!0)])});function j(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 Dh(t){t.effect(()=>t.locale.register(hi,{zh:vm,en:bm}),"dsh-backup: dictionaries"),t.effect(()=>hm(),"dsh-backup: stylesheet");let r=!1,i=(o,e,n)=>{if(r&&e!==Fl)return;let a=o.locale.bind(hi);o.slots.inject("settings.plugins.tab",()=>o.slots.register({name:"settings.plugins.tab",id:"backup",order:35,label:()=>a("tab"),locale:hi,inject:()=>({panel:n})},e))};t.inject(["slots","locale"],o=>{t.inject(["remote"],e=>{t.effect(()=>{let n=null,a=!0,s=!1;return(async()=>{try{n=await e.remote.$mount(Sm)}catch(m){console.error("dsh-backup: backupPanel mount failed:",m),r=!0,i(o,Fl,{mountError:m})}a=!1,s&&n?.()})(),()=>{s=!0,a||n?.()}},"dsh-backup: remote contribution")}),t.inject(["remote.backupPanel"],e=>{let n=()=>e.remote.backupPanel;i(e,gm,{status:async()=>j(await n().status()),backup:async(s,m,d)=>j(await n().backup(s,m,d)),verify:async s=>j(await n().verify(s)),restore:async(s,m,d,p,b)=>j(await n().restore(s,m,d,p,b)),setAuto:async s=>j(await n().setAuto(s)),githubStatus:async()=>j(await n().githubStatus()),githubSyncNow:async()=>j(await n().githubSyncNow()),githubPull:async()=>j(await n().githubPull()),removeEntry:async s=>j(await n().removeEntry(s)),setGithubRepo:async s=>j(await n().setGithubRepo(s)),setGithubToken:async s=>j(await n().setGithubToken(s)),checkUpdate:async()=>j(await n().checkUpdate()),update:async()=>j(await n().update()),exportConfig:async s=>j(await n().exportConfig(s)),importPreview:async s=>j(await n().importPreview(s)),importApply:async s=>j(await n().importApply(s)),syncStatus:async()=>j(await n().syncStatus()),syncNow:async s=>j(await n().syncNow(s)),setSyncChannel:async(s,m)=>j(await n().setSyncChannel(s,m)),removeSyncChannel:async s=>j(await n().removeSyncChannel(s)),lockStatus:async()=>j(await n().lockStatus()),reclaimLock:async s=>j(await n().reclaimLock(s)),diskUsage:async()=>j(await n().diskUsage()),cleanup:async s=>j(await n().cleanup(s)),setWeekly:async s=>j(await n().setWeekly(s)),about:async()=>j(await n().about()),profilesList:async()=>j(await n().profilesList()),profileCreate:async s=>j(await n().profileCreate(s)),profileRename:async(s,m)=>j(await n().profileRename(s,m)),profileDelete:async s=>j(await n().profileDelete(s)),profileLaunch:async s=>j(await n().profileLaunch(s)),profileStop:async s=>j(await n().profileStop(s)),marketList:async()=>j(await n().marketList()),marketSearch:async s=>j(await n().marketSearch(s)),marketInstall:async s=>j(await n().marketInstall(s)),foreignSources:async()=>j(await n().foreignSources()),foreignPreview:async s=>j(await n().foreignPreview(s)),foreignImport:async s=>j(await n().foreignImport(s)),sessionRewindList:async()=>j(await n().sessionRewindList()),sessionRewindPreview:async(s,m)=>j(await n().sessionRewindPreview(s,m)),sessionRewindApply:async s=>j(await n().sessionRewindApply(s)),chatSources:async()=>j(await n().chatSources()),chatPreview:async s=>j(await n().chatPreview(s)),chatApply:async s=>j(await n().chatApply(s))})})})}
557
557
  return module.exports; } });
package/lib/index.js CHANGED
@@ -54,6 +54,7 @@
54
54
  * 插件的自有存储。
55
55
  */
56
56
  import { createHash, randomUUID } from 'node:crypto';
57
+ import { AsyncLocalStorage } from 'node:async_hooks';
57
58
  import { createReadStream, existsSync, readFileSync, realpathSync } from 'node:fs';
58
59
  import { spawn } from 'node:child_process';
59
60
  import { mkdir, open, copyFile, chmod, readdir, readFile, rename, rm, stat as fsStat, lstat, unlink, writeFile, link } from 'node:fs/promises';
@@ -66,6 +67,8 @@ import * as zlib from 'node:zlib';
66
67
  import { decryptFile, encryptFile } from './encrypt.js';
67
68
  import { buildZip, listZip, extractZip, readZipEntry } from './zip.js';
68
69
  import { gfsProtectedSet } from './gfs.js';
70
+ import { safeRelativePath, safeDestination, backupRootRelative, tarLiteralPattern } from './paths.js';
71
+ import { matchesRedaction } from './redact.js';
69
72
  import { acquireLock, releaseLock, heartbeatLock, reclaimLock as reclaimOplock, isLockStale, LOCK_HEARTBEAT_MS } from './oplock.js';
70
73
  import { s3Put, webdavPut, gistSync, gistContent } from './sync-channels.js';
71
74
  import { defineTool } from '@deepseek-ai/dsh-tools';
@@ -151,7 +154,7 @@ const EXPORT_SECTIONS = Object.freeze([
151
154
  { key: 'settings', label: '设置', paths: ['settings.yaml', 'settings.json'] },
152
155
  { key: 'ui', label: '界面设置', paths: ['ui.json', 'ui-state.json', '.ui'] },
153
156
  { key: 'providers', label: '模型供应商', paths: ['providers'] },
154
- { key: 'plugins', label: '插件 profiles', paths: ['profiles'] },
157
+ { key: 'plugins', label: '插件 profiles', paths: ['profiles', 'local-plugins'] },
155
158
  { key: 'mcp', label: 'MCP 配置', paths: ['cordis.patch.yml', 'cordis.yml'] },
156
159
  { key: 'skills', label: '技能', paths: ['skills'] },
157
160
  { key: 'prompts', label: '提示词', paths: ['prompts'] },
@@ -1351,7 +1354,33 @@ export function apply(ctx, pluginConfig) {
1351
1354
  * (rel 本身可含 glob)。
1352
1355
  */
1353
1356
  function redactExcludeFlags(base) {
1354
- return sensitivePaths().flatMap((rel) => [`--exclude=${base}/${rel}`, `--exclude=*/${rel}`]);
1357
+ return sensitivePatterns().flatMap((rel) => [`--exclude=${base}/${rel}`, `--exclude=*/${rel}`]);
1358
+ }
1359
+
1360
+ // Old releases left rollback copies beside live credentials. Exclude these too.
1361
+ function sensitivePatterns() {
1362
+ return sensitivePaths().flatMap((rel) => [rel, `${rel}.pre-merge-*`, `${rel}.pre-import-*`]);
1363
+ }
1364
+
1365
+ function isSensitiveRelative(rel) {
1366
+ return sensitivePatterns().some((pattern) => matchesRedaction(rel, pattern));
1367
+ }
1368
+
1369
+ async function retainBeforeOverwrite(rel, kind, stamp) {
1370
+ const { dshHome, root } = paths();
1371
+ const sensitive = isSensitiveRelative(rel);
1372
+ const keep = sensitive
1373
+ ? await safeDestination(`${root}/${VAULT_DIR}/${PRESERVED_DIR}`, `${kind}-${stamp}/${rel}`)
1374
+ : await safeDestination(dshHome, `${rel}.pre-${kind}-${stamp}`);
1375
+ await mkdir(dirname(keep), { recursive: true, mode: 0o700 });
1376
+ // Create exclusively and privately before copying; never follow a retained
1377
+ // path left by an earlier operation or expose plaintext through the umask.
1378
+ const fh = await open(keep, 'wx', 0o600);
1379
+ // copyFile copies the source mode too, which could turn a private rollback
1380
+ // file back into 0644. Stream through the already-private file descriptor.
1381
+ try { await fh.writeFile(createReadStream(`${dshHome}/${rel}`)); }
1382
+ finally { await fh.close(); }
1383
+ return keep;
1355
1384
  }
1356
1385
 
1357
1386
  /**
@@ -1687,26 +1716,30 @@ export function apply(ctx, pluginConfig) {
1687
1716
  // 备份/恢复/导入/同步/doctor 这类重活会动 ~/.dsh 与备份目录(vault 刷新、
1688
1717
  // .dsh 挪旁、归档轮换),多实例/多 profile 并发会互相踩。锁文件在备份目录根
1689
1718
  // .oplock.json,记 pid+token+心跳;死锁(pid 已死)下次自动回收,活锁绝不误踢。
1690
- // 同进程重入(restore 内部的快照 doBackup 等)经 heldLock 内存标记直接放行。
1719
+ // 同一异步操作链可重入(restore 内部的快照 doBackup 等)。
1691
1720
  let heldLock = null;
1692
- let heartbeatTimer = null;
1721
+ const operationScope = new AsyncLocalStorage();
1693
1722
 
1694
1723
  async function withOperationLock(operation, fn) {
1695
- if (heldLock) return fn(); // 同进程重入:本进程已持锁,直接跑
1724
+ // Only descendants of the current operation may reenter (restore -> snapshot).
1725
+ // An unrelated RPC in the same process must still compete for the lock.
1726
+ if (heldLock && operationScope.getStore() === heldLock.token) return fn();
1696
1727
  const { root } = paths();
1697
1728
  const lockPath = `${root}/${LOCK_FILE}`;
1698
1729
  const r = await acquireLock(lockPath, operation);
1699
1730
  if (!r.acquired) {
1700
- throw new Error(`另一操作正在进行(${r.heldBy?.operation ?? '未知'},pid=${r.heldBy?.pid})——请稍后重试;若确认是残留死锁可用 /backup lock reclaim 回收`);
1731
+ throw new Error(`另一操作正在进行(${r.heldBy?.operation ?? '未知'},pid=${r.heldBy?.pid ?? '未知'}):${r.reason ?? ''}——请稍后重试;若确认是残留死锁可用 /backup lock reclaim 回收`);
1701
1732
  }
1702
1733
  heldLock = r.lock;
1703
- heartbeatTimer = setInterval(() => { heartbeatLock(lockPath, r.lock.token).catch(() => {}); }, LOCK_HEARTBEAT_MS);
1734
+ let heartbeat = Promise.resolve();
1735
+ const heartbeatTimer = setInterval(() => { heartbeat = heartbeat.then(() => heartbeatLock(lockPath, r.lock.token)).catch(() => {}); }, LOCK_HEARTBEAT_MS);
1704
1736
  try {
1705
- return await fn();
1737
+ return await operationScope.run(r.lock.token, fn);
1706
1738
  } finally {
1707
- if (heartbeatTimer) { clearInterval(heartbeatTimer); heartbeatTimer = null; }
1739
+ clearInterval(heartbeatTimer);
1740
+ await heartbeat;
1708
1741
  await releaseLock(lockPath, r.lock.token).catch(() => {});
1709
- heldLock = null;
1742
+ if (heldLock?.token === r.lock.token) heldLock = null;
1710
1743
  }
1711
1744
  }
1712
1745
 
@@ -1716,6 +1749,7 @@ export function apply(ctx, pluginConfig) {
1716
1749
 
1717
1750
  async function doBackupInner(keep, signal, opts = {}) {
1718
1751
  const { home, dshHome, root } = paths();
1752
+ const backupRel = backupRootRelative(dshHome, root);
1719
1753
  const keepN = keep && keep > 0 ? Math.floor(keep) : defaultKeep();
1720
1754
  // 状态文件先写:writeText 会自动创建备份目录,替代 mkdir -p(Windows 无 mkdir.exe)。
1721
1755
  await saveAutoState();
@@ -1735,10 +1769,11 @@ export function apply(ctx, pluginConfig) {
1735
1769
  // 不知情的情况下删掉最后一份好副本。检出项记入 meta.quarantined,恢复侧
1736
1770
  // 如实提示。体检失败不阻断备份(fail-open)。
1737
1771
  let quarantined = [];
1772
+ let scanFailed = false;
1738
1773
  if (!opts?.skipScan) {
1739
1774
  try {
1740
1775
  quarantined = (await runDoctorScan(signal)).corrupt.map((c) => c.rel);
1741
- } catch { /* 扫描异常按无隔离处理 */ }
1776
+ } catch { scanFailed = true; }
1742
1777
  }
1743
1778
  const quarantineFlags = quarantined.map((rel) => `--exclude=${base}/${rel}`);
1744
1779
 
@@ -1761,8 +1796,9 @@ export function apply(ctx, pluginConfig) {
1761
1796
  const redacted = skipVault ? await existingSensitive(dshHome) : await refreshVault(dshHome, home, signal);
1762
1797
  const tar = await ctx.subprocess.resolveExecutable('tar');
1763
1798
  const redactFlags = isTyped ? [] : redactExcludeFlags(base);
1799
+ const destinationFlags = backupRel === null ? [] : [`--exclude=${tarLiteralPattern(`${base}/${backupRel}`)}`];
1764
1800
  try {
1765
- await spawnRun([tar, '--exclude=*node_modules*', '--exclude=.system', ...quarantineFlags, ...redactFlags, ...extraExcludes(), '-czf', name, '-C', parentTar(parent), ...(isTyped ? typePaths : [base])], root, signal);
1801
+ await spawnRun([tar, '--exclude=*node_modules*', '--exclude=.system', ...destinationFlags, ...quarantineFlags, ...redactFlags, ...extraExcludes(), '-czf', name, '-C', parentTar(parent), ...(isTyped ? typePaths : [base])], root, signal);
1766
1802
  } catch (err) {
1767
1803
  // tar 失败前已创建 -f 目标——空壳归档留在备份目录会被面板当正常备份
1768
1804
  // 列出来(#88:29 字节静默空包)。删掉空壳再抛,让失败可见。
@@ -1848,7 +1884,8 @@ export function apply(ctx, pluginConfig) {
1848
1884
  monthly: Number(gfsCfg.gfsMonthly) > 0 ? Math.floor(Number(gfsCfg.gfsMonthly)) : 0,
1849
1885
  yearly: Number(gfsCfg.gfsYearly) > 0 ? Math.floor(Number(gfsCfg.gfsYearly)) : 0,
1850
1886
  });
1851
- const rotateStale = opts.skipRotate
1887
+ const retentionPaused = quarantined.length > 0 || scanFailed;
1888
+ const rotateStale = opts.skipRotate || retentionPaused || SNAPSHOT_PREFIXES.some((p) => name.startsWith(p))
1852
1889
  ? []
1853
1890
  : rotateTail.filter((b) => !tierProtectedSet(rotateTail, rotateBase[0]?.name).has(b.name) && !gfsProtect.has(b.name)).map((b) => b.name);
1854
1891
  if (rotateStale.length) await removeFiles(rotateStale.flatMap((n) => sidecarsFor(n)), root, signal);
@@ -1871,14 +1908,14 @@ export function apply(ctx, pluginConfig) {
1871
1908
  await saveAutoState();
1872
1909
  }
1873
1910
 
1874
- return { path: out, name, sha: shaText, total: rotateBase.length, stale: rotateStale.length, keep: keepN, quarantined, sync, ...(encrypted ? { encrypted: true } : {}), ...(isTyped ? { types, hasCredentials } : {}) };
1911
+ return { path: out, name, sha: shaText, total: rotateBase.length, stale: rotateStale.length, keep: keepN, quarantined, retentionPaused, sync, ...(encrypted ? { encrypted: true } : {}), ...(isTyped ? { types, hasCredentials } : {}) };
1875
1912
  }
1876
1913
 
1877
1914
  /** 备份回执的隔离提示行:体检发现坏会话文件未入档时如实告知 + 指路修复。 */
1878
1915
  function quarantineNote(r) {
1879
1916
  return Array.isArray(r?.quarantined) && r.quarantined.length
1880
- ? `\n⚠️ ${r.quarantined.length} 个损坏会话文件未入档(防轮换毁掉好副本);/backup doctor --repair 修复后可正常备份`
1881
- : '';
1917
+ ? `\n⚠️ ${r.quarantined.length} 个损坏会话文件未入档,已暂停轮换以保留健康副本;/backup doctor --repair 修复后恢复轮换`
1918
+ : r?.retentionPaused ? '\n⚠️ 会话体检未完成,已暂停轮换;体检恢复后再清理旧备份' : '';
1882
1919
  }
1883
1920
 
1884
1921
  // ---------- GitHub 同步 ----------
@@ -2016,7 +2053,8 @@ export function apply(ctx, pluginConfig) {
2016
2053
  } finally {
2017
2054
  await fh.close();
2018
2055
  }
2019
- await spawnRun([git, 'config', 'credential.helper', `store --file=${creds}`], syncDir, signal);
2056
+ const quotedCreds = `'${creds.replace(/'/g, `'\\''`)}'`;
2057
+ await spawnRun([git, 'config', 'credential.helper', `store --file=${quotedCreds}`], syncDir, signal);
2020
2058
  }
2021
2059
  // token 文件绝不能进仓库:git add -A 会把它当普通文件提交
2022
2060
  await writeOwned(`${syncDir}/.gitignore`, '.git-credentials\n');
@@ -2030,6 +2068,10 @@ export function apply(ctx, pluginConfig) {
2030
2068
  * 的 .git-credentials(credential helper),不进进程参数。
2031
2069
  */
2032
2070
  async function githubSync(signal) {
2071
+ return withOperationLock('github-sync', () => githubSyncInner(signal));
2072
+ }
2073
+
2074
+ async function githubSyncInner(signal) {
2033
2075
  const { root } = paths();
2034
2076
  const wt = await ensureSyncWorktree(signal);
2035
2077
  if (wt.reason) return { skipped: wt.reason };
@@ -2106,6 +2148,10 @@ export function apply(ctx, pluginConfig) {
2106
2148
  * 拉回的归档先做 sha256 校验(边车随仓库走),损坏即报且不落列表。
2107
2149
  */
2108
2150
  async function githubPull(signal) {
2151
+ return withOperationLock('github-pull', () => githubPullInner(signal));
2152
+ }
2153
+
2154
+ async function githubPullInner(signal) {
2109
2155
  const { root, home } = paths();
2110
2156
  const wt = await ensureSyncWorktree(signal);
2111
2157
  if (wt.reason) throw new Error(wt.reason);
@@ -2281,6 +2327,10 @@ export function apply(ctx, pluginConfig) {
2281
2327
  * error?, skipped?, gistUrl? },凭据绝不进返回。
2282
2328
  */
2283
2329
  async function syncNow(channel, signal) {
2330
+ return withOperationLock('sync', () => syncNowInner(channel, signal));
2331
+ }
2332
+
2333
+ async function syncNowInner(channel, signal) {
2284
2334
  if (channel === 'github') return githubSync(signal);
2285
2335
  const ch = syncChannels[channel];
2286
2336
  if (!ch) return { error: `未配置同步通道 ${channel}(/backup sync ${channel} set … 或面板配置)` };
@@ -2619,9 +2669,10 @@ export function apply(ctx, pluginConfig) {
2619
2669
  * cwd 为备份目录、-f 用纯文件名(规避 Windows GNU tar 盘符冒号问题),
2620
2670
  * 逐行解析 -tvzf 输出并拒绝备份根之外的任何条目——含不安全条目直接抛错。
2621
2671
  */
2622
- async function safeArchiveEntries(pickedName, signal) {
2623
- const { root, dshHome } = paths();
2624
- const base = dshHome.split('/').pop();
2672
+ async function safeArchiveEntries(pickedName, signal, options = {}) {
2673
+ const env = paths();
2674
+ const root = options.root ?? env.root;
2675
+ const base = options.base ?? env.dshHome.split('/').pop();
2625
2676
  const tar = await ctx.subprocess.resolveExecutable('tar');
2626
2677
  // 清单流式读取(#119):不用有界收集——大归档的清单超过 spill 上限时会被截断,
2627
2678
  // 碎片会被误判成"不安全条目"而拒绝整包(详见 streamListingLines 注释)。
@@ -2646,7 +2697,7 @@ export function apply(ctx, pluginConfig) {
2646
2697
  // 救援台全链路失效(#112)。硬链接不能跨文件系统、也不能指向目录,目标同为
2647
2698
  // 归档内相对路径时解压不会逃出备份根;符号链接(l)、设备等仍一律拒绝。
2648
2699
  const plainOk = Boolean(parsed) && Boolean(name) && (parsed.type === '-' || parsed.type === 'd') && insideBase(name);
2649
- const hardOk = Boolean(parsed) && parsed.type === 'h' && insideBase(name) && insideBase(link);
2700
+ const hardOk = options.allowHardlinks !== false && Boolean(parsed) && parsed.type === 'h' && insideBase(name) && insideBase(link);
2650
2701
  if (!plainOk && !hardOk) {
2651
2702
  bad.push(parsed ? name : s);
2652
2703
  } else {
@@ -2763,13 +2814,17 @@ export function apply(ctx, pluginConfig) {
2763
2814
  targetExists, preflight: preflightLines(preflight, profileDirs),
2764
2815
  };
2765
2816
  }
2766
- // 留档将覆盖的现有文件(.pre-merge-<时间戳>),失败可逐个还原。
2817
+ const newTargets = [];
2818
+ for (const entry of fileTargets) {
2819
+ const rel = entry.slice(`${base}/`.length);
2820
+ await safeDestination(dshHome, rel);
2821
+ if (!(await existsStat(`${dshHome}/${rel}`))) newTargets.push(rel);
2822
+ }
2823
+ // Validate every target before creating rollback copies or changing data.
2767
2824
  const stamp = stampNow();
2768
2825
  const kept = [];
2769
2826
  for (const w of willOverwrite) {
2770
- const keep = `${dshHome}/${w.rel}.pre-merge-${stamp}`;
2771
- await mkdir(dirname(keep), { recursive: true });
2772
- await copyFile(`${dshHome}/${w.rel}`, keep);
2827
+ const keep = await retainBeforeOverwrite(w.rel, 'merge', stamp);
2773
2828
  kept.push({ ...w, keep });
2774
2829
  }
2775
2830
  try {
@@ -2780,14 +2835,18 @@ export function apply(ctx, pluginConfig) {
2780
2835
  // 时把留档路径交代清楚,绝不静默停在半截状态。
2781
2836
  const reason = err && err.message ? err.message : String(err);
2782
2837
  const restored = new Set();
2783
- for (const w of willOverwrite) {
2838
+ for (const w of kept) {
2784
2839
  try { await copyFile(w.keep, `${dshHome}/${w.rel}`); restored.add(w.rel); } catch { /* 逐个失败落入下方报告 */ }
2785
2840
  }
2786
- if (restored.size === willOverwrite.length) {
2841
+ const cleanupFailed = [];
2842
+ for (const rel of newTargets) {
2843
+ try { await rm(`${dshHome}/${rel}`, { recursive: true, force: true }); } catch { cleanupFailed.push(rel); }
2844
+ }
2845
+ if (restored.size === willOverwrite.length && !cleanupFailed.length) {
2787
2846
  throw new Error(`分类型恢复失败(${reason})。已从留档还原全部覆盖现场,数据保持恢复前状态;排查后可重试`);
2788
2847
  }
2789
- const missing = willOverwrite.filter((w) => !restored.has(w.rel)).map((w) => w.rel);
2790
- throw new Error(`分类型恢复失败(${reason}),且部分现场未自动还原: ${missing.join(', ')}。对应的 *.pre-merge-${stamp} 留档仍在原目录旁,手工拷回即可`);
2848
+ const missing = kept.filter((w) => !restored.has(w.rel)).map((w) => `${w.rel}(留档: ${w.keep})`);
2849
+ throw new Error(`分类型恢复失败(${reason}),且部分现场未自动还原: ${[...missing, ...cleanupFailed].join(', ')}。请从上述留档手工还原`);
2791
2850
  }
2792
2851
  // 可选依赖重装:merge 含 profiles 时对新解出的 profile 跑 pnpm install。
2793
2852
  const deps = { installed: [], failed: [], note: null };
@@ -2833,6 +2892,12 @@ export function apply(ctx, pluginConfig) {
2833
2892
  };
2834
2893
  }
2835
2894
 
2895
+ // Moving DSH_HOME would also move the selected archive, vault and operation
2896
+ // lock. Require relocating the complete backup directory before replacement.
2897
+ if (backupRootRelative(dshHome, root) !== null) {
2898
+ throw new Error('整包恢复前请把整个备份目录(含 vault)移到 DSH_HOME 外,并更新备份路径;当前备份目录位于待替换的数据目录内');
2899
+ }
2900
+
2836
2901
  // 恢复前自动快照当前数据,并把当前数据移到旁边(而非合并覆盖)。
2837
2902
  // 快照用 dsh-pre-restore- 前缀与用户常规备份区分,经 listBackups 过滤
2838
2903
  // 后不进列表/轮换/latest 选择;每次恢复清掉上一次的快照防累积。
@@ -2943,7 +3008,7 @@ export function apply(ctx, pluginConfig) {
2943
3008
  '✅ 分类型恢复完成',
2944
3009
  ` 已合并写入: ${r.restored} 项 ← 来自 ${r.archive.split('/').pop()}(类型: ${types})`,
2945
3010
  ];
2946
- if (r.kept?.length) lines.push(` 覆盖的旧文件已留档: ${r.kept.length} 个(.pre-merge-*,确认后可自行删除)`);
3011
+ if (r.kept?.length) lines.push(` 覆盖的旧文件已留档: ${r.kept.length} 个(敏感文件在本机 vault/preserved,其余为 .pre-merge-*,确认后可自行删除)`);
2947
3012
  for (const p of r.preflight ?? []) if (p.startsWith('⚠️')) lines.push(` ${p}`);
2948
3013
  if (r.deps?.note) lines.push(` 插件依赖: ${r.deps.note}`);
2949
3014
  else if (r.deps?.installed?.length) lines.push(` 插件依赖: 已装好 ${r.deps.installed.join(', ')}`);
@@ -3030,10 +3095,12 @@ export function apply(ctx, pluginConfig) {
3030
3095
  * .git/.system(任意深度);凭据节段按 includeCredentials 决定入包或记入脱敏清单。
3031
3096
  */
3032
3097
  async function walkExportFiles(dshHome, includeCredentials, signal) {
3098
+ const backupRel = backupRootRelative(dshHome, paths().root);
3033
3099
  const files = [];
3034
3100
  const redacted = [];
3035
3101
  const skipDirs = new Set(['node_modules', '.git', '.system']);
3036
3102
  async function walk(abs, rel) {
3103
+ if (rel === backupRel) return;
3037
3104
  if (signal?.aborted) throw new Error('操作已取消');
3038
3105
  if (files.length >= EXPORT_MAX_FILES) throw new Error(`导出文件数超过上限(${EXPORT_MAX_FILES}),请检查 ~/.dsh 是否有异常目录树`);
3039
3106
  let st;
@@ -3051,7 +3118,7 @@ export function apply(ctx, pluginConfig) {
3051
3118
  }
3052
3119
  if (!st.isFile()) return;
3053
3120
  const section = sectionKeyForTop(rel.split('/')[0]);
3054
- if (section === 'credentials' && !includeCredentials) { redacted.push(rel); return; }
3121
+ if (!includeCredentials && (section === 'credentials' || isSensitiveRelative(rel))) { redacted.push(rel); return; }
3055
3122
  files.push({ rel, abs, bytes: st.size, section });
3056
3123
  }
3057
3124
  await walk(dshHome, '');
@@ -3076,6 +3143,10 @@ export function apply(ctx, pluginConfig) {
3076
3143
 
3077
3144
  /** 导出:把 ~/.dsh 打成单文件 ZIP(可选加密)。 */
3078
3145
  async function exportConfig(opts, signal) {
3146
+ return withOperationLock('export', () => exportConfigInner(opts, signal));
3147
+ }
3148
+
3149
+ async function exportConfigInner(opts, signal) {
3079
3150
  const { home, dshHome, root } = paths();
3080
3151
  const includeCredentials = opts?.includeCredentials === true;
3081
3152
  const password = typeof opts?.password === 'string' && opts.password ? opts.password : null;
@@ -3093,9 +3164,11 @@ export function apply(ctx, pluginConfig) {
3093
3164
  // 本地 link:/file: 插件迁移:npm pack 成 tarball 打进导出,导入侧解包改 file:
3094
3165
  const localPlugins = await scanLocalPlugins(signal);
3095
3166
  const packedPlugins = localPlugins.filter((p) => p.status === 'packed');
3167
+ // Different profiles may reference distinct packages with the same npm name/version.
3168
+ for (const p of packedPlugins) p.exportName = `${randomUUID()}-${p.tarballName}`;
3096
3169
  const encrypted = Boolean(password);
3097
3170
  const manifest = buildManifest(home, encrypted, redacted, sectionStats);
3098
- manifest.localPlugins = localPlugins.map((p) => ({ profile: p.profile, name: p.name, spec: p.spec, status: p.status, tarball: p.tarballName ?? null, warning: p.warning ?? null }));
3171
+ manifest.localPlugins = localPlugins.map((p) => ({ profile: p.profile, name: p.name, spec: p.spec, status: p.status, tarball: p.exportName ?? null, warning: p.warning ?? null }));
3099
3172
  const finalName = `dsh-config-${stampNow()}.zip`;
3100
3173
  const finalPath = `${root}/${finalName}`;
3101
3174
  const tmpPath = `${root}/.export-${stampNow()}.tmp.zip`;
@@ -3105,7 +3178,7 @@ export function apply(ctx, pluginConfig) {
3105
3178
  try {
3106
3179
  const entryList = [{ name: 'manifest.json', data: Buffer.from(JSON.stringify(manifest)) }];
3107
3180
  for (const f of files) entryList.push({ name: f.rel, sourcePath: f.abs });
3108
- for (const p of packedPlugins) entryList.push({ name: `local-plugins/${p.tarballName}`, sourcePath: p.tarballPath });
3181
+ for (const p of packedPlugins) entryList.push({ name: `local-plugins/${p.exportName}`, sourcePath: p.tarballPath });
3109
3182
  await buildZip(tmpPath, entryList, { signal });
3110
3183
  if (encrypted) {
3111
3184
  const tmpEnc = `${finalPath}.enc-${stampNow()}.tmp`;
@@ -4100,6 +4173,28 @@ export function apply(ctx, pluginConfig) {
4100
4173
  if (Number(manifest?.version) > EXPORT_FORMAT_VERSION) {
4101
4174
  throw new Error(`导出包版本过新(version=${manifest.version},本机支持 ${EXPORT_FORMAT_VERSION})——请升级插件后导入`);
4102
4175
  }
4176
+ if (manifest.localPlugins !== undefined) {
4177
+ if (!Array.isArray(manifest.localPlugins)) throw new Error('manifest.localPlugins 必须是数组');
4178
+ const seen = new Set();
4179
+ manifest.localPlugins = manifest.localPlugins.map((p) => {
4180
+ if (!p || typeof p !== 'object') throw new Error('本地插件元数据无效');
4181
+ safeRelativePath(p.name);
4182
+ if (!/^(?:@[A-Za-z0-9][A-Za-z0-9._-]*\/)?[A-Za-z0-9][A-Za-z0-9._-]*$/.test(p.name)) throw new Error('本地插件名称无效');
4183
+ safeRelativePath(p.profile);
4184
+ if (!/^[A-Za-z0-9._-]+$/.test(p.profile)) throw new Error('本地插件 profile 无效');
4185
+ const key = `${p.profile}/${p.name}`;
4186
+ if (seen.has(key)) throw new Error(`重复的本地插件: ${key}`);
4187
+ seen.add(key);
4188
+ // Canonical export field is tarball; accept the older import-side spelling too.
4189
+ const tarball = p.tarball ?? p.tarballName;
4190
+ if (p.tarball && p.tarballName && p.tarball !== p.tarballName) throw new Error('本地插件 tarball 字段冲突');
4191
+ if (p.status === 'packed') {
4192
+ safeRelativePath(tarball);
4193
+ if (tarball.includes('/') || !tarball.endsWith('.tgz')) throw new Error('本地插件 tarball 路径无效');
4194
+ }
4195
+ return { ...p, tarball };
4196
+ });
4197
+ }
4103
4198
  return manifest;
4104
4199
  }
4105
4200
 
@@ -4212,7 +4307,7 @@ export function apply(ctx, pluginConfig) {
4212
4307
  const willWrite = [];
4213
4308
  const willSkip = [];
4214
4309
  for (const e of selected) {
4215
- const targetAbs = `${dshHome}/${e.name}`;
4310
+ const targetAbs = await safeDestination(dshHome, e.name);
4216
4311
  const exists = await existsStat(targetAbs);
4217
4312
  if (exists && decisions[e.name] !== 'overwrite') { willSkip.push(e.name); continue; }
4218
4313
  willWrite.push({ ...e, targetAbs, conflict: exists });
@@ -4240,73 +4335,75 @@ export function apply(ctx, pluginConfig) {
4240
4335
  await prunePrefixedSnapshots(IMPORT_SNAPSHOT_PREFIX, 1, snap.name);
4241
4336
  } catch { /* 快照失败不阻断导入 */ }
4242
4337
 
4243
-
4338
+
4244
4339
  // 解压到暂存目录(extractZip 内 Zip Slip/符号链接/zip bomb/CRC 全套防线),再拷入 ~/.dsh
4245
- staging = `${root}/.import-stage-${stampNow()}`;
4340
+ staging = `${root}/.import-stage-${randomUUID()}`;
4246
4341
  await extractZip(zipPath, staging, { names: new Set(willWrite.map((e) => e.name)), signal });
4247
4342
  const stamp = stampNow();
4248
4343
  const backups = [];
4249
4344
  const applied = [];
4250
4345
  const overwrote = [];
4346
+ const createdPluginDirs = [];
4347
+ let remapped = 0;
4348
+ let pluginsRestored = { restored: [], skipped: [] };
4251
4349
  try {
4252
4350
  for (const e of willWrite) {
4253
4351
  if (signal?.aborted) throw new Error('操作已取消');
4352
+ await safeDestination(dshHome, e.name);
4254
4353
  await mkdir(dirname(e.targetAbs), { recursive: true });
4255
4354
  if (e.conflict) {
4256
- const keep = `${e.targetAbs}.pre-import-${stamp}`;
4257
- await copyFile(e.targetAbs, keep);
4355
+ const keep = await retainBeforeOverwrite(e.name, 'import', stamp);
4258
4356
  backups.push({ name: e.name, targetAbs: e.targetAbs, keep });
4259
4357
  }
4260
- await copyFile(`${staging}/${e.name}`, e.targetAbs);
4358
+ // Record before copyFile: even a failed copy may leave partial new bytes.
4261
4359
  applied.push(e.name);
4360
+ await copyFile(`${staging}/${e.name}`, e.targetAbs);
4262
4361
  if (e.conflict) overwrote.push(e.name);
4263
4362
  }
4363
+ // 校验:写入的文件必须存在(防半截写入被误判成功)
4364
+ const missing = [];
4365
+ for (const e of willWrite) if (!(await existsStat(e.targetAbs))) missing.push(e.name);
4366
+ if (missing.length) throw new Error(`导入校验失败,部分文件未落盘: ${missing.slice(0, 5).join(', ')}(快照: ${snapshotName ?? '未生成'})`);
4367
+
4368
+ // 跨机 remap:对写入的文本配置做 from→to 前缀替换(settings / cordis)
4369
+ if (Array.isArray(opts?.remap) && opts.remap.length) {
4370
+ for (const m of opts.remap) {
4371
+ if (!m || typeof m.from !== 'string' || !m.from || typeof m.to !== 'string') continue;
4372
+ for (const rel of ['settings.yaml', 'settings.json', 'cordis.yml', 'cordis.patch.yml']) {
4373
+ if (!willWrite.some((e) => e.name === rel)) continue;
4374
+ const p = `${dshHome}/${rel}`;
4375
+ if (!(await existsStat(p))) continue;
4376
+ const text = await readFile(p, 'utf8');
4377
+ const next = text.split(m.from).join(m.to);
4378
+ if (next !== text) { await writeFile(p, next, 'utf8'); remapped += 1; }
4379
+ }
4380
+ }
4381
+ }
4382
+
4383
+ // Only selected, actually written tarballs and profile manifests may be changed.
4384
+ pluginsRestored = await restoreLocalPlugins(manifest.localPlugins, signal, { willWrite, createdPluginDirs });
4264
4385
  } catch (err) {
4265
4386
  // 回滚:覆盖的先还原留档,新增的删除;回滚再失败则给出快照/留档指引,绝不静默停在半截。
4266
4387
  const failed = [];
4388
+ for (const dir of createdPluginDirs.reverse()) {
4389
+ try { await rm(dir, { recursive: true, force: true }); } catch { failed.push(dir); }
4390
+ }
4267
4391
  for (const b of backups) {
4268
- try { await copyFile(b.keep, b.targetAbs); } catch { failed.push(b.name); }
4392
+ try { await copyFile(b.keep, b.targetAbs); } catch { failed.push(`${b.name}(留档: ${b.keep})`); }
4269
4393
  }
4270
4394
  for (const n of applied) {
4271
- if (!backups.some((b) => b.name === n)) await rm(`${dshHome}/${n}`, { force: true }).catch(() => {});
4395
+ if (!backups.some((b) => b.name === n)) {
4396
+ try { await rm(`${dshHome}/${n}`, { force: true }); } catch { failed.push(n); }
4397
+ }
4272
4398
  }
4273
4399
  const snapNote = snapshotName ? `\n已自动快照: ${snapshotName},可 /backup restore ${snapshotName} 整体回滚。` : '';
4274
4400
  if (failed.length) {
4275
- throw new Error(`导入失败并已尽力回滚:${friendlyReason(err)}。部分留档未还原: ${failed.join(', ')}(对应 *.pre-import-${stamp} 留档在原目录旁)。${snapNote}`);
4401
+ throw new Error(`导入失败并已尽力回滚:${friendlyReason(err)}。部分留档未还原: ${failed.join(', ')}。${snapNote}`);
4276
4402
  }
4277
4403
  throw new Error(`导入失败,已自动回滚到导入前状态:${friendlyReason(err)}。${snapNote}`);
4278
4404
  }
4279
4405
 
4280
- // 校验:写入的文件必须存在(防半截写入被误判成功)
4281
- const missing = [];
4282
- for (const e of willWrite) if (!(await existsStat(e.targetAbs))) missing.push(e.name);
4283
- if (missing.length) throw new Error(`导入校验失败,部分文件未落盘: ${missing.slice(0, 5).join(', ')}(快照: ${snapshotName ?? '未生成'})`);
4284
-
4285
- // 跨机 remap:对写入的文本配置做 from→to 前缀替换(settings / cordis)
4286
- let remapped = 0;
4287
- if (Array.isArray(opts?.remap) && opts.remap.length) {
4288
- for (const m of opts.remap) {
4289
- if (!m || typeof m.from !== 'string' || !m.from || typeof m.to !== 'string') continue;
4290
- for (const rel of ['settings.yaml', 'settings.json', 'cordis.yml', 'cordis.patch.yml']) {
4291
- if (!willWrite.some((e) => e.name === rel)) continue;
4292
- const p = `${dshHome}/${rel}`;
4293
- if (!(await existsStat(p))) continue;
4294
- const text = await readFile(p, 'utf8');
4295
- const next = text.split(m.from).join(m.to);
4296
- if (next !== text) { await writeFile(p, next, 'utf8'); remapped += 1; }
4297
- }
4298
- }
4299
- }
4300
-
4301
- // 本地 link:/file: 插件迁移:解包 local-plugins tarball 到 ~/.dsh/local-plugins/ 并改回 file:
4302
- const localPluginEntries = entries.filter((e) => !e.isDirectory && e.name.startsWith('local-plugins/'));
4303
- let pluginsRestored = { restored: [], skipped: [] };
4304
- if (localPluginEntries.length && Array.isArray(manifest?.localPlugins)) {
4305
- await extractZip(zipPath, dshHome, { names: new Set(localPluginEntries.map((e) => e.name)), signal });
4306
- pluginsRestored = await restoreLocalPlugins(manifest.localPlugins, signal);
4307
- }
4308
-
4309
- const summary = `✅ 导入完成\n 已写入: ${applied.length} 个文件(覆盖 ${overwrote.length}、新增 ${applied.length - overwrote.length}、跳过已存在 ${willSkip.length})${remapped ? `\n 已 remap: ${remapped} 个配置文件` : ''}${pluginsRestored.restored.length ? `\n 本地插件已装回 file:: ${pluginsRestored.restored.map((p) => p.name).join(', ')}` : ''}${pluginsRestored.skipped.length ? `\n ⚠️ 本地插件需手动处理: ${pluginsRestored.skipped.map((p) => `${p.name}(${p.reason})`).join(', ')}` : ''}${snapshotName ? `\n 导入前快照: ${snapshotName}` : '\n ⚠️ 导入前快照未生成(建议确认无误后手动备份一次)'}${backups.length ? `\n 覆盖的旧文件已留档 *.pre-import-${stamp}(确认后可删)` : ''}\n ⏸️ 待办: 重启 dsh 生效`;
4406
+ const summary = `✅ 导入完成\n 已写入: ${applied.length} 个文件(覆盖 ${overwrote.length}、新增 ${applied.length - overwrote.length}、跳过已存在 ${willSkip.length})${remapped ? `\n 已 remap: ${remapped} 个配置文件` : ''}${pluginsRestored.restored.length ? `\n 本地插件已装回 file:: ${pluginsRestored.restored.map((p) => p.name).join(', ')}` : ''}${pluginsRestored.skipped.length ? `\n ⚠️ 本地插件需手动处理: ${pluginsRestored.skipped.map((p) => `${p.name}(${p.reason})`).join(', ')}` : ''}${snapshotName ? `\n 导入前快照: ${snapshotName}` : '\n ⚠️ 导入前快照未生成(建议确认无误后手动备份一次)'}${backups.length ? `\n 覆盖的旧文件已留档(敏感文件在本机 vault/preserved,其余在原目录旁;确认后可删)` : ''}\n ⏸️ 待办: 重启 dsh 生效`;
4310
4407
  return { ok: true, encrypted, applied: applied.length, skipped: willSkip.length, overwrote: overwrote.length, snapshot: snapshotName, remapped, pluginsRestored, summary };
4311
4408
  } finally {
4312
4409
  if (staging) await rm(staging, { recursive: true, force: true }).catch(() => {});
@@ -5342,6 +5439,10 @@ export function apply(ctx, pluginConfig) {
5342
5439
 
5343
5440
  /** 分级清理:'cache' 只删可再生缓存/暂存;'expired' 再删过期备份(快照/同步/vault 永不删)。 */
5344
5441
  async function cleanup(scope, signal) {
5442
+ return withOperationLock('cleanup', () => cleanupInner(scope, signal));
5443
+ }
5444
+
5445
+ async function cleanupInner(scope, signal) {
5345
5446
  const { root } = paths();
5346
5447
  const removed = [];
5347
5448
  const rmdir = (dir) => rm(`${root}/${dir}`, { recursive: true, force: true });
@@ -5356,6 +5457,10 @@ export function apply(ctx, pluginConfig) {
5356
5457
  }
5357
5458
  }
5358
5459
  if (scope === 'expired') {
5460
+ const scan = await runDoctorScan(signal);
5461
+ if (scan.corruptCount) {
5462
+ return { ok: true, removed: removed.length, names: removed, summary: `检测到 ${scan.corruptCount} 个损坏会话,已暂停过期备份清理,保留健康副本;请先修复会话` };
5463
+ }
5359
5464
  // 过期备份:keep 窗口外且不在日/周/月/年分层保护内的全量归档才删
5360
5465
  const keepN = defaultKeep();
5361
5466
  const all = await listBackups();
@@ -5408,8 +5513,9 @@ export function apply(ctx, pluginConfig) {
5408
5513
  /** 校验 profile 名(拒绝空/路径段/非法字符/保留名)。 */
5409
5514
  function validateProfileName(name) {
5410
5515
  if (typeof name !== 'string' || !name.trim()) return 'profile 名不能为空';
5411
- if (!/^[A-Za-z0-9._-]+$/.test(name)) return `profile 名只能含字母数字 . _ -(收到 ${name})`;
5412
- if (APP_OWNED_PROFILES.has(name)) return `profile ${name} 是应用内置 profile,只读不可改`;
5516
+ if (name === '.' || name === '..' || !/^[A-Za-z0-9._-]+$/.test(name)) return `profile 名只能含字母数字 . _ -,且不能是 . 或 ..(收到 ${name})`;
5517
+ try { safeRelativePath(name); } catch { return `不安全的 profile 名: ${name}`; }
5518
+ if (APP_OWNED_PROFILES.has(name.toLowerCase())) return `profile ${name} 是应用内置 profile,只读不可改`;
5413
5519
  return null;
5414
5520
  }
5415
5521
 
@@ -5568,32 +5674,54 @@ export function apply(ctx, pluginConfig) {
5568
5674
  return result;
5569
5675
  }
5570
5676
 
5571
- /** 导入侧:把打包的本地插件解包到 `<dshHome>/local-plugins/<name>/` 并改回 file:。 */
5572
- async function restoreLocalPlugins(localPlugins, signal) {
5573
- const { dshHome, root } = paths();
5677
+ /** 导入侧:解包到本机独立目录并把选中的 profile 依赖改回 file:。 */
5678
+ async function restoreLocalPlugins(localPlugins, signal, { willWrite, createdPluginDirs }) {
5679
+ const { dshHome } = paths();
5574
5680
  const restored = [];
5575
5681
  const skipped = [];
5682
+ const written = new Set(willWrite.map((e) => e.name));
5576
5683
  for (const p of Array.isArray(localPlugins) ? localPlugins : []) {
5577
- if (p.status !== 'packed' || !p.tarballName) { skipped.push({ name: p.name, reason: p.warning || p.status }); continue; }
5578
- // 导入包内 tarball 已随 export 打进了 ZIP(importApply 解压到暂存后经 names 落盘到 dshHome 下)
5579
- const src = `${dshHome}/local-plugins/${p.tarballName}`;
5580
- if (!(await existsStat(src))) { skipped.push({ name: p.name, reason: 'tarball 不在导入包内' }); continue; }
5581
- const targetDir = `${dshHome}/local-plugins/${p.name}`;
5582
- await mkdir(targetDir, { recursive: true });
5583
- const tar = await ctx.subprocess.resolveExecutable('tar');
5584
- await spawnRun([tar, '-xzf', src, '-C', parentTar(targetDir)], dshHome, signal);
5585
- // 把 profile 的 package.json 里 link:/file: 改回 file: 指向解包目录
5586
- const pkgPath = `${dshHome}/profiles/${p.profile}/package.json`;
5684
+ if (p.status !== 'packed') { skipped.push({ name: p.name, reason: p.warning || p.status }); continue; }
5685
+ const tarRel = `local-plugins/${p.tarball}`;
5686
+ const pkgRel = `profiles/${p.profile}/package.json`;
5687
+ if (!written.has(tarRel) || !written.has(pkgRel)) {
5688
+ skipped.push({ name: p.name, reason: '插件包或 profile 未选中,或已选择保留本机' });
5689
+ continue;
5690
+ }
5691
+ const src = await safeDestination(dshHome, tarRel);
5692
+ const pkgPath = await safeDestination(dshHome, pkgRel);
5693
+ const pkg = JSON.parse(await readFile(pkgPath, 'utf8'));
5694
+ const fields = ['dependencies', 'devDependencies'].filter((field) =>
5695
+ pkg[field] && Object.hasOwn(pkg[field], p.name) && /^(?:link|file):/.test(pkg[field][p.name]));
5696
+ if (!fields.length) { skipped.push({ name: p.name, reason: 'profile 没有对应的本地插件依赖' }); continue; }
5697
+ // npm pack always wraps files in package/. Reject traversal, links, and special
5698
+ // entries before extracting into a fresh staging directory, never into live data.
5699
+ await safeArchiveEntries(basename(src), signal, { root: dirname(src), base: 'package', allowHardlinks: false });
5700
+ const installRel = `local-plugins/installed-${randomUUID()}`;
5701
+ const installRoot = await safeDestination(dshHome, installRel);
5702
+ await mkdir(dirname(installRoot), { recursive: true });
5703
+ await mkdir(installRoot, { mode: 0o700 });
5704
+ createdPluginDirs.push(installRoot);
5705
+ // Stage on the target filesystem so external-disk backup destinations do
5706
+ // not make the final rename fail with EXDEV.
5707
+ const stage = `${installRoot}/.unpack`;
5708
+ await mkdir(stage, { mode: 0o700 });
5587
5709
  try {
5588
- const pkg = JSON.parse(await readFile(pkgPath, 'utf8'));
5589
- for (const field of ['dependencies', 'devDependencies']) {
5590
- if (pkg[field] && typeof pkg[field] === 'object' && pkg[field][p.name]) {
5591
- pkg[field][p.name] = `file:${targetDir}`;
5592
- }
5593
- }
5710
+ const tar = await ctx.subprocess.resolveExecutable('tar');
5711
+ await spawnRun([tar, '-xzf', basename(src), '-C', stage], dirname(src), signal);
5712
+ const packageJson = await safeDestination(stage, 'package/package.json');
5713
+ const packed = JSON.parse(await readFile(packageJson, 'utf8'));
5714
+ if (!packed || typeof packed !== 'object' || Array.isArray(packed)) throw new Error('本地插件 package.json 无效');
5715
+ const targetDir = await safeDestination(installRoot, p.name);
5716
+ await mkdir(dirname(targetDir), { recursive: true });
5717
+ // Point file: at the actual package root, not the tar's parent directory.
5718
+ await rename(`${stage}/package`, targetDir);
5719
+ for (const field of fields) pkg[field][p.name] = `file:${targetDir}`;
5594
5720
  await writeOwned(pkgPath, `${JSON.stringify(pkg, null, 2)}\n`);
5595
- } catch { /* package.json 改不了就跳过,如实标注 */ }
5596
- restored.push({ name: p.name, profile: p.profile, dir: targetDir });
5721
+ restored.push({ name: p.name, profile: p.profile, dir: targetDir });
5722
+ } finally {
5723
+ await rm(stage, { recursive: true, force: true }).catch(() => {});
5724
+ }
5597
5725
  }
5598
5726
  return { restored, skipped };
5599
5727
  }
package/lib/oplock.js CHANGED
@@ -31,7 +31,7 @@ export function isPidAlive(pid) {
31
31
  process.kill(pid, 0);
32
32
  return true;
33
33
  } catch (err) {
34
- return err && err.code === 'EPERM'; // 存在但无权限 → 视为活着,绝不误踢
34
+ return err?.code !== 'ESRCH'; // Only a confirmed missing process may be reclaimed
35
35
  }
36
36
  }
37
37
 
@@ -53,71 +53,95 @@ async function readLockFile(lockPath) {
53
53
  }
54
54
  }
55
55
 
56
- async function writeLockFile(lockPath, operation) {
56
+ /**
57
+ * A short filesystem transaction serializes all lock mutations across processes.
58
+ * mkdir is exclusive; atomic rename of JSON alone is not mutual exclusion.
59
+ * An unreadable/orphaned guard fails closed (never steal a potentially live writer).
60
+ * The owner record identifies the process for manual recovery after an interrupted
61
+ * metadata transaction. Normal crash recovery uses the main lock's dead PID.
62
+ */
63
+ export async function withLockGuard(lockPath, fn, { wait = false } = {}) {
64
+ const guard = `${lockPath}.guard`;
57
65
  await mkdir(dirname(lockPath), { recursive: true });
58
- const lock = {
59
- pid: process.pid,
60
- token: randomUUID(),
61
- startedAt: new Date().toISOString(),
62
- heartbeatAt: new Date().toISOString(),
63
- operation: operation || 'unknown',
64
- };
65
- // 原子写:临时文件 + rename,避免并发读锁文件读一半
66
+ for (let attempt = 0; ; attempt++) {
67
+ try {
68
+ await mkdir(guard, { mode: 0o700 });
69
+ break;
70
+ } catch (err) {
71
+ if (err.code !== 'EEXIST') throw err;
72
+ if (!wait || attempt >= 100) return { busy: true, guard };
73
+ await new Promise((resolve) => setTimeout(resolve, 10));
74
+ }
75
+ }
76
+ try {
77
+ const fh = await open(`${guard}/owner.json`, 'wx', 0o600);
78
+ try { await fh.writeFile(JSON.stringify({ pid: process.pid, startedAt: new Date().toISOString() })); }
79
+ finally { await fh.close(); }
80
+ return { value: await fn() };
81
+ } finally {
82
+ await rm(guard, { recursive: true, force: true });
83
+ }
84
+ }
85
+
86
+ async function writeLockFile(lockPath, lock) {
66
87
  const tmp = `${lockPath}.${randomUUID()}.tmp`;
67
- await open(tmp, 'wx', 0o600).then(async (fh) => { await fh.writeFile(`${JSON.stringify(lock)}\n`, 'utf8'); await fh.close(); });
68
- await rename(tmp, lockPath).catch(async (err) => { await rm(tmp, { force: true }).catch(() => {}); throw err; });
69
- return lock;
88
+ try {
89
+ const fh = await open(tmp, 'wx', 0o600);
90
+ try { await fh.writeFile(`${JSON.stringify(lock)}\n`, 'utf8'); }
91
+ finally { await fh.close(); }
92
+ await rename(tmp, lockPath);
93
+ } finally {
94
+ await rm(tmp, { force: true }).catch(() => {});
95
+ }
70
96
  }
71
97
 
72
- /**
73
- * 尝试获取操作锁。
74
- * @returns {Promise<{acquired:boolean, lock?:object, reason?:string, heldBy?:object}>}
75
- * acquired=true 时 lock 是本次写下的锁(含 token,供 release/heartbeat 使用)。
76
- */
98
+ const heldBy = (lock) => ({ pid: lock.pid, operation: lock.operation, startedAt: lock.startedAt, heartbeatAt: lock.heartbeatAt });
99
+ const busyReason = (guard) => `锁元数据正在更新(${guard});若进程异常退出,请先确认 guard/owner.json 中的进程已结束再清理该目录`;
100
+
77
101
  export async function acquireLock(lockPath, operation) {
78
- const existing = await readLockFile(lockPath);
79
- if (existing && !existing.unreadable && isLockStale(existing)) {
80
- // 死锁:pid 已死,安全回收后重试
81
- await rm(lockPath, { force: true }).catch(() => {});
82
- } else if (existing) {
83
- return { acquired: false, reason: '另一操作正在运行', heldBy: { pid: existing.pid, operation: existing.operation, startedAt: existing.startedAt, heartbeatAt: existing.heartbeatAt } };
84
- }
85
- const lock = await writeLockFile(lockPath, operation);
86
- return { acquired: true, lock };
102
+ const result = await withLockGuard(lockPath, async () => {
103
+ const existing = await readLockFile(lockPath);
104
+ if (existing && (existing.unreadable || !isLockStale(existing))) {
105
+ return { acquired: false, reason: '另一操作正在运行', heldBy: heldBy(existing) };
106
+ }
107
+ const now = new Date().toISOString();
108
+ const lock = { pid: process.pid, token: randomUUID(), startedAt: now, heartbeatAt: now, operation: operation || 'unknown' };
109
+ await writeLockFile(lockPath, lock);
110
+ return { acquired: true, lock };
111
+ });
112
+ return result.busy ? { acquired: false, reason: busyReason(result.guard) } : result.value;
87
113
  }
88
114
 
89
- /** 刷新心跳(仅当 token 匹配、且 pid 仍是本进程时写)。 */
90
115
  export async function heartbeatLock(lockPath, token) {
91
- const existing = await readLockFile(lockPath);
92
- if (!existing || existing.token !== token) return false;
93
- const lock = { ...existing, heartbeatAt: new Date().toISOString() };
94
- const tmp = `${lockPath}.${randomUUID()}.tmp`;
95
- await open(tmp, 'wx', 0o600).then(async (fh) => { await fh.writeFile(`${JSON.stringify(lock)}\n`, 'utf8'); await fh.close(); });
96
- await rename(tmp, lockPath).catch(async (err) => { await rm(tmp, { force: true }).catch(() => {}); throw err; });
97
- return true;
116
+ const result = await withLockGuard(lockPath, async () => {
117
+ const existing = await readLockFile(lockPath);
118
+ if (!existing || existing.token !== token || existing.pid !== process.pid) return false;
119
+ await writeLockFile(lockPath, { ...existing, heartbeatAt: new Date().toISOString() });
120
+ return true;
121
+ }, { wait: true });
122
+ return result.busy ? false : result.value;
98
123
  }
99
124
 
100
- /** 释放锁(仅 token 匹配才删,绝不删别人的锁)。 */
101
125
  export async function releaseLock(lockPath, token) {
102
- const existing = await readLockFile(lockPath);
103
- if (existing && existing.token === token) {
104
- await rm(lockPath, { force: true }).catch(() => {});
126
+ const result = await withLockGuard(lockPath, async () => {
127
+ const existing = await readLockFile(lockPath);
128
+ if (!existing || existing.token !== token) return false;
129
+ await rm(lockPath, { force: true });
105
130
  return true;
106
- }
107
- return false;
131
+ }, { wait: true });
132
+ if (result.busy) throw new Error(busyReason(result.guard));
133
+ return result.value;
108
134
  }
109
135
 
110
- /**
111
- * 回收入口:死锁直接回收;活锁默认拒绝,force=true 时按用户显式确认强删。
112
- * @returns {Promise<{reclaimed:boolean, reason?:string, heldBy?:object}>}
113
- */
114
136
  export async function reclaimLock(lockPath, { force = false } = {}) {
115
- const existing = await readLockFile(lockPath);
116
- if (!existing) return { reclaimed: false, reason: '没有锁' };
117
- const heldBy = { pid: existing.pid, operation: existing.operation, startedAt: existing.startedAt, heartbeatAt: existing.heartbeatAt };
118
- if (isLockStale(existing) || force) {
119
- await rm(lockPath, { force: true }).catch(() => {});
120
- return { reclaimed: true, heldBy };
121
- }
122
- return { reclaimed: false, reason: '锁持有进程仍在运行(未回收,避免误踢活锁)', heldBy };
137
+ const result = await withLockGuard(lockPath, async () => {
138
+ const existing = await readLockFile(lockPath);
139
+ if (!existing) return { reclaimed: false, reason: '没有锁' };
140
+ if (isLockStale(existing) || force) {
141
+ await rm(lockPath, { force: true });
142
+ return { reclaimed: true, heldBy: heldBy(existing) };
143
+ }
144
+ return { reclaimed: false, reason: '锁持有进程仍在运行(未回收,避免误踢活锁)', heldBy: heldBy(existing) };
145
+ });
146
+ return result.busy ? { reclaimed: false, reason: busyReason(result.guard) } : result.value;
123
147
  }
package/lib/paths.js ADDED
@@ -0,0 +1,56 @@
1
+ import { lstat } from 'node:fs/promises';
2
+ import { realpathSync } from 'node:fs';
3
+ import { basename, dirname, isAbsolute, relative, resolve, sep } from 'node:path';
4
+
5
+ // Resolve existing ancestors too: a destination may not exist yet or may be
6
+ // reached through a symlink outside DSH_HOME.
7
+ function canonicalPath(value) {
8
+ const abs = resolve(value);
9
+ try { return realpathSync(abs); } catch (err) {
10
+ if (err.code !== 'ENOENT' || dirname(abs) === abs) throw err;
11
+ return resolve(canonicalPath(dirname(abs)), basename(abs));
12
+ }
13
+ }
14
+
15
+ /** Relative backup subtree to exclude, or null for an external destination. */
16
+ export function backupRootRelative(dshHome, root) {
17
+ const rel = relative(canonicalPath(dshHome), canonicalPath(root));
18
+ if (!rel) throw new Error('备份目录不能等于 DSH_HOME,请选择独立的备份目录');
19
+ return rel === '..' || rel.startsWith(`..${sep}`) || isAbsolute(rel) ? null : rel.split(sep).join('/');
20
+ }
21
+
22
+ /** tar --exclude is a glob, even when the user configured a literal directory. */
23
+ export function tarLiteralPattern(value) {
24
+ return value.replace(/[\\*?\[\]]/g, '\\$&');
25
+ }
26
+
27
+ /** Portable relative paths only; metadata is untrusted even when ZIP names are safe. */
28
+ export function safeRelativePath(value) {
29
+ if (typeof value !== 'string' || !value || /[\\\u0000-\u001f\u007f:]/.test(value)
30
+ || value.startsWith('/') || value.split('/').some((part) => !part || part === '.' || part === '..'
31
+ || /[. ]$/.test(part) || /^(?:con|prn|aux|nul|com[1-9]|lpt[1-9])(?:\.|$)/i.test(part))) {
32
+ throw new Error(`不安全的相对路径: ${String(value)}`);
33
+ }
34
+ return value;
35
+ }
36
+
37
+ /** Reject existing symlinks as well as lexical traversal before writing into a tree. */
38
+ export async function safeDestination(root, rel) {
39
+ safeRelativePath(rel);
40
+ const base = resolve(root);
41
+ const target = resolve(base, rel);
42
+ const inside = relative(base, target);
43
+ if (!inside || inside === '..' || inside.startsWith(`..${sep}`) || isAbsolute(inside)) {
44
+ throw new Error(`写入路径越界: ${rel}`);
45
+ }
46
+ let current = base;
47
+ for (const part of ['', ...rel.split('/')]) {
48
+ if (part) current = resolve(current, part);
49
+ const info = await lstat(current).catch((err) => {
50
+ if (err.code === 'ENOENT') return null;
51
+ throw err;
52
+ });
53
+ if (info?.isSymbolicLink()) throw new Error(`写入路径含符号链接: ${rel}`);
54
+ }
55
+ return target;
56
+ }
package/lib/redact.js ADDED
@@ -0,0 +1,37 @@
1
+ const literal = (c) => c.replace(/[\\^$.*+?()[\]{}|]/g, '\\$&');
2
+
3
+ // tar excludes are unanchored globs and '*' may cross directory separators.
4
+ // POSIX character classes depend on the tar process locale; conservatively match
5
+ // any character for those classes instead of risking a plaintext ZIP disclosure.
6
+ export function matchesRedaction(rel, pattern) {
7
+ let source = '';
8
+ for (let i = 0; i < pattern.length; i++) {
9
+ const c = pattern[i];
10
+ if (c === '*') source += '[\\s\\S]*';
11
+ else if (c === '?') source += '[\\s\\S]';
12
+ else if (c === '\\' && i + 1 < pattern.length) source += literal(pattern[++i]);
13
+ else if (c === '[') {
14
+ let end = i + 1;
15
+ if (pattern[end] === '!' || pattern[end] === '^') end++;
16
+ if (pattern[end] === ']') end++;
17
+ while (end < pattern.length && pattern[end] !== ']') {
18
+ if (pattern[end] === '[' && ':=.'.includes(pattern[end + 1] ?? ' ')) {
19
+ const innerEnd = pattern.indexOf(`${pattern[end + 1]}]`, end + 2);
20
+ if (innerEnd !== -1) { end = innerEnd + 2; continue; }
21
+ }
22
+ end++;
23
+ }
24
+ if (end === pattern.length) { source += '\\['; continue; }
25
+ const body = pattern.slice(i + 1, end);
26
+ const negated = body.startsWith('!') || body.startsWith('^');
27
+ const chars = (negated ? body.slice(1) : body).replace(/\\/g, '\\\\').replace(/\]/g, '\\]');
28
+ const bracket = `[${negated ? '^' : ''}${chars}]`;
29
+ try {
30
+ new RegExp(bracket);
31
+ source += body.includes('[') ? '[\\s\\S]' : bracket;
32
+ } catch { source += '[\\s\\S]'; }
33
+ i = end;
34
+ } else source += literal(c);
35
+ }
36
+ return new RegExp(`(?:^|/)${source}(?:/|$)`).test(rel);
37
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xiaoyuyu6420/dsh-backup",
3
- "version": "0.14.2",
3
+ "version": "0.14.3",
4
4
  "description": "一条命令备份、恢复与迁移 DeepSeek Harness(dsh)的全部数据(~/.dsh:会话、设置、插件、MCP、技能):定时自动备份、升级前快照、换机迁移、会话日志体检修复、宿主起不来也能用的进程外救援;凭据默认脱敏只存本机。 Backup, restore & migrate all DeepSeek Harness (~/.dsh) data — sessions, settings, plugins, MCP, skills: scheduled backups, pre-upgrade snapshots, cross-machine migration, session-log doctor, out-of-process rescue console; credentials redacted to a local-only vault.",
5
5
  "type": "module",
6
6
  "packageManager": "pnpm@11.24.0",
package/rescue/rescue.mjs CHANGED
@@ -93,8 +93,6 @@ function run(argv, cwd) {
93
93
  const parentTar = (p) => (p.endsWith('/') ? p : `${p}/`);
94
94
 
95
95
  async function sha256File(absPath) {
96
- const info = await fs.stat(absPath);
97
- if (info.size > HASH_MAX_BYTES) throw new Error(`文件 ${Math.floor(info.size / 1048576)}MB 超过哈希上限`);
98
96
  return new Promise((resolve, reject) => {
99
97
  const h = createHash('sha256');
100
98
  createReadStream(absPath).on('data', (d) => h.update(d)).on('error', reject).on('end', () => resolve(h.digest('hex')));
@@ -510,6 +508,17 @@ async function restoreArchive(root, selector, apply) {
510
508
  return { dryRun: true, archive: picked.name, files: entries.length, sample: entries.slice(0, 12), preflight, profileDirs };
511
509
  }
512
510
 
511
+ // Keep this standalone rescue script dependency-free. A nested backup root
512
+ // would disappear when the source tree is moved aside during replacement.
513
+ const sourceReal = await fs.realpath(dshHome).catch((err) => {
514
+ if (err.code === 'ENOENT') return path.resolve(dshHome);
515
+ throw err;
516
+ });
517
+ const backupRel = path.relative(sourceReal, await fs.realpath(root));
518
+ if (!backupRel || (backupRel !== '..' && !backupRel.startsWith(`..${path.sep}`) && !path.isAbsolute(backupRel))) {
519
+ throw new Error('整包恢复前请把整个备份目录(含 vault)移到 DSH_HOME 外,并更新备份路径;当前备份目录位于待替换的数据目录内');
520
+ }
521
+
513
522
  // 现有数据:快照 + 挪旁;缺失(新机/全失)则直接解压
514
523
  let targetExists = true;
515
524
  try {
@@ -755,12 +764,30 @@ function isPidAlive(pid) {
755
764
  process.kill(pid, 0);
756
765
  return true;
757
766
  } catch (err) {
758
- return err && err.code === 'EPERM';
767
+ return err?.code !== 'ESRCH';
759
768
  }
760
769
  }
761
770
 
762
771
  /** 回收残留死锁(owner 证明已死才回收)。 */
763
772
  async function recoverStaleLock(root) {
773
+ // Use the host's same exclusive transaction guard: never remove a lock while
774
+ // a live host is publishing a new owner or heartbeat.
775
+ const guard = `${root}/${LOCK_FILE}.guard`;
776
+ await fs.mkdir(root, { recursive: true });
777
+ try { await fs.mkdir(guard, { mode: 0o700 }); }
778
+ catch (err) {
779
+ if (err.code === 'EEXIST') throw new Error(`锁元数据正在更新(${guard}),请稍后重试`);
780
+ throw err;
781
+ }
782
+ try {
783
+ await fs.writeFile(`${guard}/owner.json`, JSON.stringify({ pid: process.pid }), { mode: 0o600 });
784
+ return await recoverStaleLockInner(root);
785
+ } finally {
786
+ await fs.rm(guard, { recursive: true, force: true });
787
+ }
788
+ }
789
+
790
+ async function recoverStaleLockInner(root) {
764
791
  const lockPath = `${root}/${LOCK_FILE}`;
765
792
  let lock;
766
793
  try {