@xiaoyuyu6420/dsh-backup 0.5.2 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +41 -7
- package/README.zh.md +37 -7
- package/lib/client.js +1 -1
- package/lib/index.js +193 -44
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -1,6 +1,11 @@
|
|
|
1
1
|
# dsh-backup
|
|
2
2
|
|
|
3
3
|
[](https://github.com/topics/dsh-plugin)
|
|
4
|
+
[](https://www.npmjs.com/package/@xiaoyuyu6420/dsh-backup)
|
|
5
|
+
[](https://github.com/xiaoyuyu6420/dsh-backup/actions/workflows/publish.yml)
|
|
6
|
+
[](LICENSE)
|
|
7
|
+
|
|
8
|
+
English | [简体中文](README.zh.md)
|
|
4
9
|
|
|
5
10
|
One-command backup **and restore** for DeepSeek Harness user data — sessions,
|
|
6
11
|
settings, credentials, skills, and plugin config under `~/.dsh`, excluding
|
|
@@ -14,9 +19,10 @@ macOS, Linux, and Windows.
|
|
|
14
19
|
- **`/backup list`** — list existing backups (name + size) and auto-backup status
|
|
15
20
|
- **`/backup verify [prefix|all]`** — validate archive checksums (default: the newest)
|
|
16
21
|
- **`/backup restore <prefix|latest> [--dry-run]`** — restore `~/.dsh` from an archive
|
|
17
|
-
- **`/backup auto <N>|off|status`** — auto-backup every N hours (1–720;
|
|
22
|
+
- **`/backup auto <N>|off|status`** — auto-backup every N hours (1–720; retains 3 copies below 24h, 7 otherwise, unless `config.keep` overrides; persisted across restarts)
|
|
18
23
|
- **`/backup --keep N`** — override the rotation count (default 7)
|
|
19
|
-
- **`/backup github status|sync
|
|
24
|
+
- **`/backup github status|sync|repo <address|off>`** — GitHub sync status / push now / set the sync repository
|
|
25
|
+
- **`/backup delete|rm <prefix|latest>`** — delete a backup and its checksum sidecar
|
|
20
26
|
- **`backup_dsh` tool** — same capability for the model (`mode=backup|list|verify|restore|auto`)
|
|
21
27
|
|
|
22
28
|
## GitHub sync
|
|
@@ -70,13 +76,13 @@ Plugin `config` in the active cordis profile:
|
|
|
70
76
|
name: 'dsh-backup'
|
|
71
77
|
config:
|
|
72
78
|
destination: '~/Backups/dsh' # default ~/Desktop/dsh-backups
|
|
73
|
-
keep: 10 #
|
|
79
|
+
keep: 10 # manual rotation count (default 7); when set, also the auto-backup retention — auto keeps 3 copies below 24h, 7 otherwise by default
|
|
74
80
|
exclude: # extra tar --exclude patterns
|
|
75
81
|
- '*cache*'
|
|
76
82
|
githubRepo: 'name/dsh-backups' # optional GitHub sync (see below)
|
|
77
83
|
```
|
|
78
84
|
|
|
79
|
-
Auto-backup state lives in `<destination>/auto.json` and resumes after restart.
|
|
85
|
+
Auto-backup state lives in `<destination>/auto.json` and resumes after restart — the next run is scheduled from the last auto-backup time, never reset by the restart.
|
|
80
86
|
|
|
81
87
|
## Security note
|
|
82
88
|
|
|
@@ -93,11 +99,24 @@ does not apply to host-owned storage.
|
|
|
93
99
|
## Install
|
|
94
100
|
|
|
95
101
|
```sh
|
|
96
|
-
dsh plugin --profile web add dsh-backup
|
|
102
|
+
dsh plugin --profile web add @xiaoyuyu6420/dsh-backup
|
|
103
|
+
# or from git:
|
|
104
|
+
dsh plugin --profile web add github:xiaoyuyu6420/dsh-backup
|
|
97
105
|
```
|
|
98
106
|
|
|
99
|
-
|
|
100
|
-
|
|
107
|
+
> ⚠️ The npm package name is the **scoped** `@xiaoyuyu6420/dsh-backup`. The
|
|
108
|
+
> unscoped `dsh-backup` on npm is an unrelated third-party package — don't
|
|
109
|
+
> install it.
|
|
110
|
+
|
|
111
|
+
## Quickstart
|
|
112
|
+
|
|
113
|
+
Your first backup is about 30 seconds away:
|
|
114
|
+
|
|
115
|
+
1. Install the plugin — `dsh plugin --profile web add @xiaoyuyu6420/dsh-backup`
|
|
116
|
+
2. Restart `dsh web` — plugin discovery is cached per process.
|
|
117
|
+
3. Run `/backup` — the archive and its sha256 sidecar land in `~/Desktop/dsh-backups/`.
|
|
118
|
+
|
|
119
|
+
Prefer clicking? The same flow lives in Settings → Plugins → Backup.
|
|
101
120
|
|
|
102
121
|
## Requirements
|
|
103
122
|
|
|
@@ -106,6 +125,12 @@ or open Settings → Plugins → Backup.
|
|
|
106
125
|
and fall back to an in-process hash on Windows)
|
|
107
126
|
- DSH `0.1.0-rc.6` or compatible
|
|
108
127
|
|
|
128
|
+
## Troubleshooting
|
|
129
|
+
|
|
130
|
+
- **Plugin fails to load with `client api: method "backupPanel/remove" conflicts with its namespace service`** — you are on v0.5.0, whose delete-endpoint method name collided with a reserved name in the DSH client (issues [#2](https://github.com/xiaoyuyu6420/dsh-backup/issues/2), [#5](https://github.com/xiaoyuyu6420/dsh-backup/issues/5)). v0.5.1 renamed the method to `removeEntry`; upgrade with `dsh plugin --profile web add @xiaoyuyu6420/dsh-backup@latest` and restart `dsh web`.
|
|
131
|
+
- **Which `tar` on Windows?** Either one works — Windows ships bsdtar in System32, and Git Bash provides GNU tar. Checksums prefer `sha256sum`/`shasum` when present and fall back to an in-process hash, so `/backup verify` works in both shells.
|
|
132
|
+
- **Installed the wrong package** — the npm package is the **scoped** `@xiaoyuyu6420/dsh-backup`; the unscoped `dsh-backup` is an unrelated third-party package. Check your profile's plugin list and reinstall with the scoped name.
|
|
133
|
+
|
|
109
134
|
## Development
|
|
110
135
|
|
|
111
136
|
Zero runtime dependencies — the host plugin is `lib/index.js`. The browser half
|
|
@@ -118,6 +143,15 @@ node scripts/smoke.mjs # host smoke suite (real temp dir, mocked DSH se
|
|
|
118
143
|
node scripts/smoke-client.mjs # client bundle: handshake, schemas, tab registration, SSR
|
|
119
144
|
```
|
|
120
145
|
|
|
146
|
+
## Acknowledgements
|
|
147
|
+
|
|
148
|
+
- [@beastrobin](https://github.com/beastrobin) — the reserved-method-name root
|
|
149
|
+
cause analysis in #1 that directly led to the v0.5.1 fix
|
|
150
|
+
- [@mlosun](https://github.com/mlosun) — the thorough reproduction and root
|
|
151
|
+
cause report in #2
|
|
152
|
+
- [@Choi-Peng](https://github.com/Choi-Peng) — triage help pointing affected
|
|
153
|
+
users to the fix in #5
|
|
154
|
+
|
|
121
155
|
## License
|
|
122
156
|
|
|
123
157
|
MIT
|
package/README.zh.md
CHANGED
|
@@ -1,6 +1,11 @@
|
|
|
1
1
|
# dsh-backup
|
|
2
2
|
|
|
3
3
|
[](https://github.com/topics/dsh-plugin)
|
|
4
|
+
[](https://www.npmjs.com/package/@xiaoyuyu6420/dsh-backup)
|
|
5
|
+
[](https://github.com/xiaoyuyu6420/dsh-backup/actions/workflows/publish.yml)
|
|
6
|
+
[](LICENSE)
|
|
7
|
+
|
|
8
|
+
[English](README.md) | 简体中文
|
|
4
9
|
|
|
5
10
|
一键备份**与恢复** DeepSeek Harness 用户数据——`~/.dsh` 下的会话、设置、凭据、
|
|
6
11
|
技能与插件配置(排除可重装的 node_modules),自动生成 sha256 校验和、完整性
|
|
@@ -12,9 +17,10 @@
|
|
|
12
17
|
- **`/backup list`** —— 列出已有备份(名称 + 大小)与自动备份状态
|
|
13
18
|
- **`/backup verify [前缀|all]`** —— 校验归档完整性(缺省校验最新一份)
|
|
14
19
|
- **`/backup restore <前缀|latest> [--dry-run]`** —— 从归档恢复 `~/.dsh`
|
|
15
|
-
- **`/backup auto <N小时>|off|status`** —— 每 N 小时自动备份(1~720
|
|
20
|
+
- **`/backup auto <N小时>|off|status`** —— 每 N 小时自动备份(1~720;保留份数默认 <24h 3 份、否则 7 份,config.keep 可覆盖;状态持久化,重启续跑)
|
|
16
21
|
- **`/backup --keep N`** —— 覆盖轮换保留份数(默认 7)
|
|
17
|
-
- **`/backup github status|sync
|
|
22
|
+
- **`/backup github status|sync|repo <地址|off>`** —— GitHub 同步状态 / 立即推送 / 设置同步仓库
|
|
23
|
+
- **`/backup delete|rm <前缀|latest>`** —— 删除指定备份(归档 + 校验边车)
|
|
18
24
|
- **`backup_dsh` 工具** —— 模型可调用同一能力(`mode=backup|list|verify|restore|auto`)
|
|
19
25
|
|
|
20
26
|
## GitHub 同步
|
|
@@ -64,13 +70,13 @@ credential 文件(不进进程参数)。推送为 `HEAD:main --force-with-le
|
|
|
64
70
|
name: 'dsh-backup'
|
|
65
71
|
config:
|
|
66
72
|
destination: '~/Backups/dsh' # 默认 ~/Desktop/dsh-backups
|
|
67
|
-
keep: 10 #
|
|
73
|
+
keep: 10 # 手动备份轮换份数(默认 7);配置后同时作为自动备份保留份数(未配置时 auto 默认 <24h 3 份 / 否则 7 份)
|
|
68
74
|
exclude: # 额外的 tar --exclude 模式
|
|
69
75
|
- '*cache*'
|
|
70
76
|
githubRepo: '账号/dsh-backups' # 可选 GitHub 同步(见下文)
|
|
71
77
|
```
|
|
72
78
|
|
|
73
|
-
自动备份状态保存在 `<destination>/auto.json
|
|
79
|
+
自动备份状态保存在 `<destination>/auto.json`,重启后按上次自动执行时间推算下次触发(不重置节奏)。
|
|
74
80
|
|
|
75
81
|
## 安全说明
|
|
76
82
|
|
|
@@ -85,11 +91,23 @@ credential 文件(不进进程参数)。推送为 `HEAD:main --force-with-le
|
|
|
85
91
|
## 安装
|
|
86
92
|
|
|
87
93
|
```sh
|
|
88
|
-
dsh plugin --profile web add dsh-backup
|
|
94
|
+
dsh plugin --profile web add @xiaoyuyu6420/dsh-backup
|
|
95
|
+
# 或从 git 安装:
|
|
96
|
+
dsh plugin --profile web add github:xiaoyuyu6420/dsh-backup
|
|
89
97
|
```
|
|
90
98
|
|
|
91
|
-
|
|
92
|
-
|
|
99
|
+
> ⚠️ npm 包名是 **scoped** 的 `@xiaoyuyu6420/dsh-backup`。npm 上不带 scope
|
|
100
|
+
> 的 `dsh-backup` 是无关的第三方包,请勿安装。
|
|
101
|
+
|
|
102
|
+
## 快速上手
|
|
103
|
+
|
|
104
|
+
约 30 秒拿到第一个备份:
|
|
105
|
+
|
|
106
|
+
1. 安装插件 —— `dsh plugin --profile web add @xiaoyuyu6420/dsh-backup`
|
|
107
|
+
2. 重启 `dsh web` —— 插件发现按进程缓存。
|
|
108
|
+
3. 跑 `/backup` —— 归档连同 sha256 校验文件落到 `~/Desktop/dsh-backups/`。
|
|
109
|
+
|
|
110
|
+
更喜欢点鼠标?同一套流程在 Settings → Plugins → 备份 标签页。
|
|
93
111
|
|
|
94
112
|
## 依赖
|
|
95
113
|
|
|
@@ -98,6 +116,12 @@ Settings → Plugins → 备份。
|
|
|
98
116
|
Windows 上回退进程内哈希)
|
|
99
117
|
- DSH `0.1.0-rc.6` 或兼容版本
|
|
100
118
|
|
|
119
|
+
## 故障排查
|
|
120
|
+
|
|
121
|
+
- **插件加载失败,报 `client api: method "backupPanel/remove" conflicts with its namespace service`** —— 你装的是 v0.5.0:删除接口的方法名撞上了 DSH 客户端的保留方法名(见 [#2](https://github.com/xiaoyuyu6420/dsh-backup/issues/2)、[#5](https://github.com/xiaoyuyu6420/dsh-backup/issues/5))。v0.5.1 已将方法改名 `removeEntry` 修复;执行 `dsh plugin --profile web add @xiaoyuyu6420/dsh-backup@latest` 升级后重启 `dsh web` 即可。
|
|
122
|
+
- **Windows 上用哪个 `tar`?** 都可以——Windows 自带 System32 里的 bsdtar,Git Bash 里则是 GNU tar。校验和优先用 `sha256sum`/`shasum`,缺失时自动回退进程内哈希,两种 shell 下 `/backup verify` 都能正常工作。
|
|
123
|
+
- **装错了包** —— npm 包名是 **scoped** 的 `@xiaoyuyu6420/dsh-backup`;不带 scope 的 `dsh-backup` 是无关的第三方包。检查 profile 的插件列表,改用 scoped 名重装。
|
|
124
|
+
|
|
101
125
|
## 开发
|
|
102
126
|
|
|
103
127
|
运行时零依赖——宿主插件就是 `lib/index.js`。浏览器半边源码在 `src/`,
|
|
@@ -110,6 +134,12 @@ node scripts/smoke.mjs # 宿主冒烟(真实临时目录 + 模拟 DSH
|
|
|
110
134
|
node scripts/smoke-client.mjs # 客户端 bundle:握手/schema/标签页注册/SSR
|
|
111
135
|
```
|
|
112
136
|
|
|
137
|
+
## 致谢
|
|
138
|
+
|
|
139
|
+
- [@beastrobin](https://github.com/beastrobin) —— #1 中对保留方法名的根因分析,直接促成 v0.5.1 修复
|
|
140
|
+
- [@mlosun](https://github.com/mlosun) —— #2 中详尽的复现与根因报告
|
|
141
|
+
- [@Choi-Peng](https://github.com/Choi-Peng) —— #5 中协助把受影响用户指引到修复版本
|
|
142
|
+
|
|
113
143
|
## 许可证
|
|
114
144
|
|
|
115
145
|
MIT
|
package/lib/client.js
CHANGED
|
@@ -330,5 +330,5 @@ Set the \`cycles\` parameter to \`"ref"\` to resolve cyclical schemas with defs.
|
|
|
330
330
|
max-height: 10em;
|
|
331
331
|
overflow: auto;
|
|
332
332
|
}
|
|
333
|
-
`;var jn="settings.backupPanel",Kf="dsh-backup",Bf=["slots","locale","remote"],Wf=x.object({destination:x.string(),dshHome:x.string(),keepDefault:x.number().int(),autoHours:x.number().int(),lastAuto:x.string().nullable(),backups:x.array(x.object({name:x.string(),size:x.number().int().nullable()}))}),Xf=x.object({ok:x.boolean(),summary:x.string(),path:x.string(),sha:x.string(),stale:x.number().int(),keep:x.number().int()}),qf=x.object({ok:x.boolean(),summary:x.string(),results:x.array(x.object({name:x.string(),ok:x.boolean(),note:x.string()}))}),Hf=x.object({ok:x.boolean(),dryRun:x.boolean(),summary:x.string(),archive:x.string().nullable().optional(),files:x.number().int().nullable().optional(),aside:x.string().nullable().optional(),snapshotPath:x.string().nullable().optional(),sample:x.array(x.string()).optional()}),Yf=x.object({ok:x.boolean(),hours:x.number().int(),summary:x.string()}),Qf=x.object({repoRaw:x.string().nullable(),repo:x.string().nullable(),tokenSet:x.boolean(),syncDir:x.string(),lastPush:x.string().nullable(),lastError:x.string().nullable()}),eg=x.object({ok:x.boolean(),summary:x.string(),pushed:x.boolean(),tooBig:x.array(x.string())}),tg=x.object({ok:x.boolean(),summary:x.string()}),rg=x.object({ok:x.boolean(),repo:x.string().nullable(),summary:x.string()}),ng={name:"keep",wire:"keep",source:"json",codec:{mode:"strict",typeSymbol:"dsh-backup/types#keep",schema:x.number().int().positive().optional()},acceptsUndefined:!0},Nu={name:"selector",wire:"selector",source:"json",codec:{mode:"strict",typeSymbol:"dsh-backup/types#selector",schema:x.string().optional()},acceptsUndefined:!0},ig={name:"dryRun",wire:"dryRun",source:"json",codec:{mode:"strict",typeSymbol:"dsh-backup/types#dryRun",schema:x.boolean().optional()},acceptsUndefined:!0},og={name:"hours",wire:"hours",source:"json",codec:{mode:"strict",typeSymbol:"dsh-backup/types#hours",schema:x.number().int().min(0).max(720)},acceptsUndefined:!0},ag={name:"repo",wire:"repo",source:"json",codec:{mode:"strict",typeSymbol:"dsh-backup/types#repo",schema:x.string().optional()},acceptsUndefined:!0};function le(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:Object.freeze({mode:"strict",typeSymbol:`dsh-backup/types#${t}Result`,schema:i})})}var Ld=Object.freeze({package:"dsh-backup",descriptors:Object.freeze([le("status",[],Wf,!1),le("backup",[ng],Xf,!0),le("verify",[Nu],qf,!0),le("restore",[Nu,ig],Hf,!0),le("setAuto",[og],Yf,!1),le("githubStatus",[],Qf,!1),le("githubSyncNow",[],eg,!0),le("removeEntry",[Nu],tg,!0),le("setGithubRepo",[ag],rg,!1)])});function de(t){if(!t.ok){let r=t.error;throw new Error(r&&r.message?`${r.code}: ${r.message}`:"backupPanel \u8C03\u7528\u5931\u8D25")}return t.value}async function cg(t){t.effect(()=>t.locale.register(jn,{zh:Ed,en:Ad}),"dsh-backup: dictionaries"),t.effect(()=>Rd(),"dsh-backup: stylesheet"),await t.remote.$mount(Ld),t.inject(["remote.backupPanel"],r=>{let i=r.locale.bind(jn),o=()=>r.remote.backupPanel,e={status:async()=>de(await o().status()),backup:async n=>de(await o().backup(n)),verify:async n=>de(await o().verify(n)),restore:async(n,a)=>de(await o().restore(n,a)),setAuto:async n=>de(await o().setAuto(n)),githubStatus:async()=>de(await o().githubStatus()),githubSyncNow:async()=>de(await o().githubSyncNow()),removeEntry:async n=>de(await o().removeEntry(n)),setGithubRepo:async n=>de(await o().setGithubRepo(n))};r.slots.inject("settings.plugins.tab",()=>r.slots.register({name:"settings.plugins.tab",id:"backup",order:35,label:()=>i("tab"),locale:jn,inject:()=>({panel:e})},Td))})}
|
|
333
|
+
`;var jn="settings.backupPanel",Kf="dsh-backup",Bf=["slots","locale","remote"],Wf=x.object({destination:x.string(),dshHome:x.string(),keepDefault:x.number().int(),autoHours:x.number().int(),lastAuto:x.string().nullable(),backups:x.array(x.object({name:x.string(),size:x.number().int().nullable()}))}),Xf=x.object({ok:x.boolean(),summary:x.string(),path:x.string(),sha:x.string(),stale:x.number().int(),keep:x.number().int()}),qf=x.object({ok:x.boolean(),summary:x.string(),results:x.array(x.object({name:x.string(),ok:x.boolean(),note:x.string()}))}),Hf=x.object({ok:x.boolean(),dryRun:x.boolean(),summary:x.string(),archive:x.string().nullable().optional(),files:x.number().int().nullable().optional(),aside:x.string().nullable().optional(),snapshotPath:x.string().nullable().optional(),sample:x.array(x.string()).optional()}),Yf=x.object({ok:x.boolean(),hours:x.number().int(),summary:x.string()}),Qf=x.object({repoRaw:x.string().nullable(),repo:x.string().nullable(),tokenSet:x.boolean(),syncDir:x.string(),lastPush:x.string().nullable(),lastError:x.string().nullable()}),eg=x.object({ok:x.boolean(),summary:x.string(),pushed:x.boolean(),tooBig:x.array(x.string())}),tg=x.object({ok:x.boolean(),summary:x.string()}),rg=x.object({ok:x.boolean(),repo:x.string().nullable(),summary:x.string()}),ng={name:"keep",wire:"keep",source:"json",codec:{mode:"strict",typeSymbol:"dsh-backup/types#keep",schema:x.number().int().positive().optional()},acceptsUndefined:!0},Nu={name:"selector",wire:"selector",source:"json",codec:{mode:"strict",typeSymbol:"dsh-backup/types#selector",schema:x.string().optional()},acceptsUndefined:!0},ig={name:"dryRun",wire:"dryRun",source:"json",codec:{mode:"strict",typeSymbol:"dsh-backup/types#dryRun",schema:x.boolean().optional()},acceptsUndefined:!0},og={name:"hours",wire:"hours",source:"json",codec:{mode:"strict",typeSymbol:"dsh-backup/types#hours",schema:x.number().int().min(0).max(720)},acceptsUndefined:!0},ag={name:"repo",wire:"repo",source:"json",codec:{mode:"strict",typeSymbol:"dsh-backup/types#repo",schema:x.string().optional()},acceptsUndefined:!0};function le(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:Object.freeze({mode:"strict",typeSymbol:`dsh-backup/types#${t}Result`,schema:i})})}var Ld=Object.freeze({package:"dsh-backup",descriptors:Object.freeze([le("status",[],Wf,!1),le("backup",[ng],Xf,!0),le("verify",[Nu],qf,!0),le("restore",[Nu,ig],Hf,!0),le("setAuto",[og],Yf,!1),le("githubStatus",[],Qf,!1),le("githubSyncNow",[],eg,!0),le("removeEntry",[Nu],tg,!0),le("setGithubRepo",[ag],rg,!1)])});function de(t){if(!t.ok){let r=t.error;throw new Error(r&&r.message?`${r.code}: ${r.message}`:"backupPanel \u8C03\u7528\u5931\u8D25")}return t.value}async function cg(t){t.effect(()=>t.locale.register(jn,{zh:Ed,en:Ad}),"dsh-backup: dictionaries"),t.effect(()=>Rd(),"dsh-backup: stylesheet"),t.effect(await t.remote.$mount(Ld),"dsh-backup: remote contribution"),t.inject(["remote.backupPanel"],r=>{let i=r.locale.bind(jn),o=()=>r.remote.backupPanel,e={status:async()=>de(await o().status()),backup:async n=>de(await o().backup(n)),verify:async n=>de(await o().verify(n)),restore:async(n,a)=>de(await o().restore(n,a)),setAuto:async n=>de(await o().setAuto(n)),githubStatus:async()=>de(await o().githubStatus()),githubSyncNow:async()=>de(await o().githubSyncNow()),removeEntry:async n=>de(await o().removeEntry(n)),setGithubRepo:async n=>de(await o().setGithubRepo(n))};r.slots.inject("settings.plugins.tab",()=>r.slots.register({name:"settings.plugins.tab",id:"backup",order:35,label:()=>i("tab"),locale:jn,inject:()=>({panel:e})},Td))})}
|
|
334
334
|
return module.exports; } });
|
package/lib/index.js
CHANGED
|
@@ -9,8 +9,11 @@
|
|
|
9
9
|
* - `/backup list` 列出已有备份(含大小)+ 自动备份状态
|
|
10
10
|
* - `/backup verify [前缀|all]` 校验备份完整性(缺省校验最新一份)
|
|
11
11
|
* - `/backup restore <前缀|latest> [--dry-run]` 恢复(先校验 + 自动快照当前数据)
|
|
12
|
-
* - `/backup auto <N小时>|off|status` 定时自动备份(1~720
|
|
13
|
-
*
|
|
12
|
+
* - `/backup auto <N小时>|off|status` 定时自动备份(1~720;保留份数由 config.keep
|
|
13
|
+
* 支配,未配置时 <24h 3 份、否则 7 份;重启按
|
|
14
|
+
* 上次执行时间推算下次,不重置节奏)
|
|
15
|
+
* - `/backup github status|sync|repo <地址|off>` GitHub 同步状态 / 立即同步 / 设置仓库
|
|
16
|
+
* - `/backup delete|rm <前缀|latest>` 删除备份(归档 + 校验边车)
|
|
14
17
|
* - `/backup --keep N` 覆盖本次保留份数
|
|
15
18
|
* - `backup_dsh` 模型工具:mode=backup|list|verify|restore|auto
|
|
16
19
|
* - cordis.yml `config`:destination / keep / exclude / githubRepo(见 README)
|
|
@@ -30,7 +33,7 @@
|
|
|
30
33
|
*/
|
|
31
34
|
import { createHash } from 'node:crypto';
|
|
32
35
|
import { createReadStream } from 'node:fs';
|
|
33
|
-
import { mkdir, copyFile, readdir, readFile, stat as fsStat, writeFile } from 'node:fs/promises';
|
|
36
|
+
import { mkdir, open, copyFile, readdir, readFile, rename, stat as fsStat, unlink, writeFile } from 'node:fs/promises';
|
|
34
37
|
import { dirname, join } from 'node:path';
|
|
35
38
|
import { defineTool } from '@deepseek-ai/dsh-tools';
|
|
36
39
|
import { TypertRemoteService } from '@deepseek-ai/dsh-typert-protocol';
|
|
@@ -38,7 +41,7 @@ import { TypertRemoteService } from '@deepseek-ai/dsh-typert-protocol';
|
|
|
38
41
|
export const name = 'dsh-backup';
|
|
39
42
|
export const inject = ['subprocess', 'commands', 'timer', 'tools'];
|
|
40
43
|
|
|
41
|
-
/** Windows
|
|
44
|
+
/** Windows 无 POSIX chmod 语义(用户目录 ACL 默认私有)。 */
|
|
42
45
|
const IS_WIN = process.platform === 'win32';
|
|
43
46
|
/** sha256 回退路径(node:fs 读取 + node:crypto)的内存上限。 */
|
|
44
47
|
const HASH_MAX_BYTES = 256 * 1024 * 1024;
|
|
@@ -144,6 +147,25 @@ class BackupPanelService extends TypertRemoteService {
|
|
|
144
147
|
|
|
145
148
|
export function apply(ctx, pluginConfig) {
|
|
146
149
|
// ---------- 工具函数 ----------
|
|
150
|
+
/**
|
|
151
|
+
* 读取 subprocess 收集的输出流。dsh-subprocess 的收集契约:输出超过
|
|
152
|
+
* maxBytes(8192)时内存窗口只保留尾部(readFrom 返回 lossy: true),
|
|
153
|
+
* 完整输出 spill 到 spillPath——restore 的 `tar -tvzf` 校验清单必须看
|
|
154
|
+
* 全量,否则大归档的校验可被清单前段的恶意条目绕过(8KB 尾窗看不到)。
|
|
155
|
+
*/
|
|
156
|
+
async function collectOutput(col) {
|
|
157
|
+
const read = col?.readFrom(0);
|
|
158
|
+
if (!read) return '';
|
|
159
|
+
if (read.lossy && read.spillPath) {
|
|
160
|
+
try {
|
|
161
|
+
return await readFile(read.spillPath, 'utf8');
|
|
162
|
+
} catch {
|
|
163
|
+
// spill 文件不可读时降级为内存尾窗文本(截断,但比失败好)
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
return read.text ?? '';
|
|
167
|
+
}
|
|
168
|
+
|
|
147
169
|
async function spawnRun(argv, cwd, signal) {
|
|
148
170
|
const proc = ctx.subprocess.spawn({
|
|
149
171
|
argv,
|
|
@@ -157,8 +179,18 @@ export function apply(ctx, pluginConfig) {
|
|
|
157
179
|
},
|
|
158
180
|
});
|
|
159
181
|
const outcome = await proc.done;
|
|
160
|
-
const out = proc.collected
|
|
161
|
-
const err = proc.collected
|
|
182
|
+
const out = await collectOutput(proc.collected?.stdout);
|
|
183
|
+
const err = await collectOutput(proc.collected?.stderr);
|
|
184
|
+
// 取消优先于一切结果分类:用户取消不是"命令失败"。进程已正常退出后、
|
|
185
|
+
// 结果返回前的毫秒窗口内 abort 同样报"操作已取消"——刻意取舍:真取消
|
|
186
|
+
// 绝不得误报成功,窗口期误报取消(R7 的反向错误)比误报成功危害小。
|
|
187
|
+
if (signal?.aborted) {
|
|
188
|
+
throw new Error('操作已取消');
|
|
189
|
+
}
|
|
190
|
+
// loose 检查:smoke 桩与部分平台下 signal 字段可能为 undefined。
|
|
191
|
+
if (outcome.signal != null) {
|
|
192
|
+
throw new Error(`命令被终止(signal=${outcome.signal})`);
|
|
193
|
+
}
|
|
162
194
|
if (outcome.exitCode !== 0) {
|
|
163
195
|
throw new Error(`命令失败 exit=${outcome.exitCode}: ${err || out}`);
|
|
164
196
|
}
|
|
@@ -199,29 +231,24 @@ export function apply(ctx, pluginConfig) {
|
|
|
199
231
|
}
|
|
200
232
|
|
|
201
233
|
/**
|
|
202
|
-
* 删除 dir
|
|
203
|
-
*
|
|
234
|
+
* 删除 dir 下的文件。纯 Node fs.unlink(零 shell):文件名来自备份目录
|
|
235
|
+
* 内容,拼接进 cmd/rm 命令行会构成命令注入面(Windows 下 cmd 的
|
|
236
|
+
* `del /f /q ${names}` 可被 `dsh-x & calc &x.tar.gz` 之类文件名利用)。
|
|
204
237
|
*/
|
|
205
238
|
async function removeFiles(names, dir, signal) {
|
|
206
239
|
if (!names.length) return;
|
|
207
|
-
if (
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
}
|
|
240
|
+
if (signal?.aborted) throw new Error('操作已取消');
|
|
241
|
+
await Promise.all(names.map((n) => unlink(`${dir}/${n}`).catch((err) => {
|
|
242
|
+
// 目标已不存在等价 rm -f 的静默成功;其余错误照常抛出
|
|
243
|
+
if (err && err.code === 'ENOENT') return;
|
|
244
|
+
throw err;
|
|
245
|
+
})));
|
|
214
246
|
}
|
|
215
247
|
|
|
216
248
|
/** 把 dir 下的文件/目录改名为同目录下的另一个名字(恢复时挪开现有数据)。 */
|
|
217
249
|
async function renameBeside(dir, srcName, dstName, signal) {
|
|
218
|
-
if (
|
|
219
|
-
|
|
220
|
-
await spawnRun([cmd, '/d', '/c', `move /y ${srcName} ${dstName}`], dir, signal);
|
|
221
|
-
} else {
|
|
222
|
-
const mv = await ctx.subprocess.resolveExecutable('mv');
|
|
223
|
-
await spawnRun([mv, `${dir}/${srcName}`, `${dir}/${dstName}`], dir, signal);
|
|
224
|
-
}
|
|
250
|
+
if (signal?.aborted) throw new Error('操作已取消');
|
|
251
|
+
await rename(`${dir}/${srcName}`, `${dir}/${dstName}`);
|
|
225
252
|
}
|
|
226
253
|
|
|
227
254
|
async function listBackups() {
|
|
@@ -230,7 +257,7 @@ export function apply(ctx, pluginConfig) {
|
|
|
230
257
|
try {
|
|
231
258
|
dirents = await readdir(root, { withFileTypes: true });
|
|
232
259
|
} catch {
|
|
233
|
-
//
|
|
260
|
+
// 备份目录缺失或不可读时视为空列表(首次使用前 / EACCES 容错)
|
|
234
261
|
return [];
|
|
235
262
|
}
|
|
236
263
|
const backups = [];
|
|
@@ -240,6 +267,7 @@ export function apply(ctx, pluginConfig) {
|
|
|
240
267
|
try {
|
|
241
268
|
size = (await fsStat(`${root}/${d.name}`)).size;
|
|
242
269
|
} catch {
|
|
270
|
+
// 归档在列表与 stat 之间被删除(轮换/删除竞态)——大小显示为未知而非报错
|
|
243
271
|
size = undefined;
|
|
244
272
|
}
|
|
245
273
|
backups.push({ name: d.name, size });
|
|
@@ -373,6 +401,7 @@ export function apply(ctx, pluginConfig) {
|
|
|
373
401
|
try {
|
|
374
402
|
await spawnRun([git, 'init', '-b', 'main'], syncDir, signal);
|
|
375
403
|
} catch {
|
|
404
|
+
// git 版本不支持 -b 时回退 init + branch -M
|
|
376
405
|
await spawnRun([git, 'init'], syncDir, signal);
|
|
377
406
|
await spawnRun([git, 'branch', '-M', 'main'], syncDir, signal);
|
|
378
407
|
}
|
|
@@ -384,20 +413,33 @@ export function apply(ctx, pluginConfig) {
|
|
|
384
413
|
if (cfg.token) {
|
|
385
414
|
// 正斜杠路径:msys git 会把反斜杠绝对路径当相对路径解析(怪名化)
|
|
386
415
|
const creds = `${syncDir}/.git-credentials`;
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
416
|
+
// 独占创建(0600):凭据文件不留 umask 默认权限窗口;已存在时普通
|
|
417
|
+
// 打开后显式 chmod 收紧——open 的 mode 参数只作用于新建文件,对已
|
|
418
|
+
// 存在文件(如 0644 残留)不生效。
|
|
419
|
+
let fh;
|
|
420
|
+
try {
|
|
421
|
+
fh = await open(creds, 'wx', 0o600);
|
|
422
|
+
} catch (e) {
|
|
423
|
+
if (!(e && e.code === 'EEXIST')) throw e;
|
|
424
|
+
fh = await open(creds, 'w', 0o600);
|
|
425
|
+
if (!IS_WIN) await fh.chmod(0o600);
|
|
426
|
+
}
|
|
427
|
+
try {
|
|
428
|
+
await fh.writeFile(`https://x-access-token:${cfg.token}@github.com\n`, 'utf8');
|
|
429
|
+
} finally {
|
|
430
|
+
await fh.close();
|
|
391
431
|
}
|
|
392
432
|
await spawnRun([git, 'config', 'credential.helper', `store --file=${creds}`], syncDir, signal);
|
|
393
433
|
}
|
|
394
434
|
// token 文件绝不能进仓库:git add -A 会把它当普通文件提交
|
|
395
435
|
await writeOwned(`${syncDir}/.gitignore`, '.git-credentials\n');
|
|
396
436
|
|
|
397
|
-
// 镜像工作树:只保留 .gitignore
|
|
398
|
-
//
|
|
399
|
-
//
|
|
437
|
+
// 镜像工作树:只保留 .gitignore、凭据文件(.git-credentials,token
|
|
438
|
+
// 配置时在 keep 集内保留)与当前备份集(归档+边车),其余文件
|
|
439
|
+
// (旧副本、误入杂物)清理——git add -A 因此只会收录归档;轮换
|
|
440
|
+
// 删除与误入文件一并同步移除。
|
|
400
441
|
const keep = new Set(['.gitignore']);
|
|
442
|
+
if (cfg.token) keep.add('.git-credentials');
|
|
401
443
|
for (const b of await listBackups()) {
|
|
402
444
|
keep.add(b.name);
|
|
403
445
|
keep.add(`${b.name}.sha256`);
|
|
@@ -406,6 +448,7 @@ export function apply(ctx, pluginConfig) {
|
|
|
406
448
|
try {
|
|
407
449
|
entries = await readdir(syncDir, { withFileTypes: true });
|
|
408
450
|
} catch {
|
|
451
|
+
// 同步目录刚创建或不可读时视为空
|
|
409
452
|
entries = [];
|
|
410
453
|
}
|
|
411
454
|
const stale = entries
|
|
@@ -464,6 +507,7 @@ export function apply(ctx, pluginConfig) {
|
|
|
464
507
|
});
|
|
465
508
|
createReadStream(abs).on('error', () => { res.destroy(); }).pipe(res);
|
|
466
509
|
} catch {
|
|
510
|
+
// 任何读取/解析失败按 404 处理(下载是尽力而为的附件通道)
|
|
467
511
|
if (!res.headersSent) res.writeHead(404);
|
|
468
512
|
res.end();
|
|
469
513
|
}
|
|
@@ -506,6 +550,49 @@ export function apply(ctx, pluginConfig) {
|
|
|
506
550
|
return { ok: true, name: picked.name, summary: `已删除备份: ${picked.name}` };
|
|
507
551
|
}
|
|
508
552
|
|
|
553
|
+
/**
|
|
554
|
+
* 解析 `tar -tvzf` 单行输出,返回 { type, name };布局不匹配返回 null。
|
|
555
|
+
* 兼容三种 verbose 布局(locale 无关锚定,不依赖英文月份缩写):
|
|
556
|
+
* 1. GNU tar:mode owner size YYYY-MM-DD 时间|年份 name——以 YYYY-MM-DD
|
|
557
|
+
* 字段为锚,name 起点 = 该字段 + 2(跳过 date 与 time/年份)。
|
|
558
|
+
* 2. bsdtar:mode uid gid size 月 日 时间 name(中文 locale 月份形如
|
|
559
|
+
* `8月`,英文 `Aug`)——以最后一个 HH:MM 字段为锚,name 起点 = 其后 1。
|
|
560
|
+
* 3. bsdtar 跨年条目无时间字段(月 日 年份)——以最后一个 4 位数字为锚
|
|
561
|
+
* (年份;size 恰为 4 位数字时年份更靠右仍正确),name 起点 = 其后 1。
|
|
562
|
+
* name 保留含空格路径并去掉目录尾部的斜杠。
|
|
563
|
+
* 已知边界:POSIX 文件名恰为 HH:MM 形态(如 `12:30`)会被误取为时间锚
|
|
564
|
+
* (Windows 文件名禁 `:`,故 Windows 不受影响)——极端误取,保守接受。
|
|
565
|
+
*/
|
|
566
|
+
function parseTarEntry(line) {
|
|
567
|
+
const f = line.trim().split(/\s+/);
|
|
568
|
+
if (f.length < 6) return null;
|
|
569
|
+
let nameIdx = -1;
|
|
570
|
+
for (let i = 1; i < f.length; i++) {
|
|
571
|
+
if (/^\d{4}-\d{2}-\d{2}$/.test(f[i])) {
|
|
572
|
+
nameIdx = i + 2;
|
|
573
|
+
break;
|
|
574
|
+
}
|
|
575
|
+
}
|
|
576
|
+
if (nameIdx < 0) {
|
|
577
|
+
for (let i = f.length - 1; i >= 1; i--) {
|
|
578
|
+
if (/^\d{1,2}:\d{2}$/.test(f[i])) {
|
|
579
|
+
nameIdx = i + 1;
|
|
580
|
+
break;
|
|
581
|
+
}
|
|
582
|
+
}
|
|
583
|
+
}
|
|
584
|
+
if (nameIdx < 0) {
|
|
585
|
+
for (let i = f.length - 1; i >= 1; i--) {
|
|
586
|
+
if (/^\d{4}$/.test(f[i])) {
|
|
587
|
+
nameIdx = i + 1;
|
|
588
|
+
break;
|
|
589
|
+
}
|
|
590
|
+
}
|
|
591
|
+
}
|
|
592
|
+
if (nameIdx < 0 || nameIdx >= f.length) return null;
|
|
593
|
+
return { type: f[0][0], name: f.slice(nameIdx).join(' ').replace(/\/$/, '') };
|
|
594
|
+
}
|
|
595
|
+
|
|
509
596
|
async function restoreArchive(selector, dryRun, signal) {
|
|
510
597
|
const { home, dshHome, root } = paths();
|
|
511
598
|
const picked = await pickArchive(selector);
|
|
@@ -519,12 +606,33 @@ export function apply(ctx, pluginConfig) {
|
|
|
519
606
|
const parent = dshHome.slice(0, -(base.length + 1)) || '/';
|
|
520
607
|
const tar = await ctx.subprocess.resolveExecutable('tar');
|
|
521
608
|
// 同 doBackup:cwd 为备份目录,-f 用纯文件名,规避 Windows GNU tar 的盘符冒号问题。
|
|
522
|
-
const listed = await spawnRun([tar, '-
|
|
523
|
-
|
|
524
|
-
//
|
|
525
|
-
|
|
609
|
+
const listed = await spawnRun([tar, '-tvzf', picked.name], root, signal);
|
|
610
|
+
// tar 路径穿越防护:逐行解析 -tvzf 输出(双布局,见 parseTarEntry),
|
|
611
|
+
// 条目必须是普通文件/目录、相对路径且位于备份根目录之下;任一违规
|
|
612
|
+
// 即整体拒绝,恢复绝不触碰备份根之外的文件。
|
|
613
|
+
const entries = [];
|
|
614
|
+
const bad = [];
|
|
615
|
+
for (const line of listed.out.split('\n')) {
|
|
616
|
+
const s = line.trim();
|
|
617
|
+
if (!s) continue;
|
|
618
|
+
const parsed = parseTarEntry(s);
|
|
619
|
+
// 反斜杠归一化后再做全部路径检查:`.dsh/..\..\escape.txt` 与
|
|
620
|
+
// `/` 分隔的 `..` 段同样拒绝(Windows 风格路径变体)
|
|
621
|
+
const name = parsed ? parsed.name.replace(/\\/g, '/').replace(/\/$/, '') : null;
|
|
622
|
+
if (
|
|
623
|
+
!parsed || !name
|
|
624
|
+
|| (parsed.type !== '-' && parsed.type !== 'd')
|
|
625
|
+
|| name.startsWith('/') || /^[A-Za-z]:/.test(name)
|
|
626
|
+
|| name.split('/').includes('..')
|
|
627
|
+
|| (name !== base && !name.startsWith(`${base}/`))
|
|
628
|
+
) {
|
|
629
|
+
bad.push(parsed ? name : s);
|
|
630
|
+
} else {
|
|
631
|
+
entries.push(name);
|
|
632
|
+
}
|
|
633
|
+
}
|
|
526
634
|
if (bad.length) {
|
|
527
|
-
throw new Error(
|
|
635
|
+
throw new Error(`归档包含不安全条目(拒绝恢复):${bad.slice(0, 3).join(', ')},恢复已中止`);
|
|
528
636
|
}
|
|
529
637
|
|
|
530
638
|
if (dryRun) return { archive, files: entries.length, sample: entries.slice(0, 12), aside: null, snapshotPath: null, dryRun: true };
|
|
@@ -536,7 +644,10 @@ export function apply(ctx, pluginConfig) {
|
|
|
536
644
|
try {
|
|
537
645
|
await fsStat(dshHome);
|
|
538
646
|
current = true;
|
|
539
|
-
} catch {
|
|
647
|
+
} catch (err) {
|
|
648
|
+
// dshHome 不存在(ENOENT)视为首次恢复、无现有数据可移开;其他 stat
|
|
649
|
+
// 失败(如 EACCES)中止恢复,不跳过 aside 直接覆盖现有数据
|
|
650
|
+
if (!err || err.code !== 'ENOENT') throw err;
|
|
540
651
|
current = false;
|
|
541
652
|
}
|
|
542
653
|
if (current) {
|
|
@@ -552,11 +663,18 @@ export function apply(ctx, pluginConfig) {
|
|
|
552
663
|
let autoDispose = null;
|
|
553
664
|
let autoHours = 0;
|
|
554
665
|
let lastAuto = null;
|
|
666
|
+
let lastAutoAt = null;
|
|
555
667
|
let githubState = { repo: null, lastPush: null, lastError: null };
|
|
556
668
|
|
|
669
|
+
/** 自动备份的保留份数:config.keep 支配(未配置时 <24h 3 份、否则 7 份)。 */
|
|
670
|
+
function autoKeep() {
|
|
671
|
+
const k = Number(pluginConfig?.keep);
|
|
672
|
+
return Number.isFinite(k) && k > 0 ? Math.floor(k) : (autoHours >= 24 ? 7 : 3);
|
|
673
|
+
}
|
|
674
|
+
|
|
557
675
|
async function saveAutoState() {
|
|
558
676
|
const { root } = paths();
|
|
559
|
-
await writeOwned(`${root}/auto.json`, `${JSON.stringify({ hours: autoHours, github: githubState })}\n`);
|
|
677
|
+
await writeOwned(`${root}/auto.json`, `${JSON.stringify({ hours: autoHours, lastAutoAt, github: githubState })}\n`);
|
|
560
678
|
}
|
|
561
679
|
|
|
562
680
|
async function loadAutoState() {
|
|
@@ -571,24 +689,54 @@ export function apply(ctx, pluginConfig) {
|
|
|
571
689
|
lastError: typeof parsed.github.lastError === 'string' ? parsed.github.lastError : null,
|
|
572
690
|
};
|
|
573
691
|
}
|
|
692
|
+
lastAutoAt = typeof parsed?.lastAutoAt === 'string' && !Number.isNaN(Date.parse(parsed.lastAutoAt))
|
|
693
|
+
? parsed.lastAutoAt
|
|
694
|
+
: null;
|
|
574
695
|
return Number.isFinite(h) && h >= 1 && h <= 720 ? Math.floor(h) : 0;
|
|
575
|
-
} catch {
|
|
696
|
+
} catch (err) {
|
|
697
|
+
console.warn(`[dsh-backup] auto.json 无法读取,自动备份计划已重置: ${String(err && err.message ? err.message : err)}`);
|
|
576
698
|
return 0;
|
|
577
699
|
}
|
|
578
700
|
}
|
|
579
701
|
|
|
702
|
+
/** 下次自动备份触发时间(毫秒):上次执行 + 周期;无锚点从当前时间起算。 */
|
|
703
|
+
function nextAutoAt() {
|
|
704
|
+
const cycle = autoHours * 3600 * 1000;
|
|
705
|
+
// 非法/缺失 lastAutoAt 按 0 处理:从当前时间起算,避免 NaN 传染延迟计算
|
|
706
|
+
const anchor = (Number(new Date(lastAutoAt)) || 0) + cycle;
|
|
707
|
+
return Math.max(anchor, Date.now());
|
|
708
|
+
}
|
|
709
|
+
|
|
580
710
|
function autoSummary() {
|
|
581
711
|
if (!autoDispose) return '自动备份未开启(/backup auto <N小时> 开启)';
|
|
582
|
-
|
|
712
|
+
// 绝对节奏:下次 = 上次执行 + 周期;错过(重启间隔超周期)则按现在显示,调度会立即补跑。
|
|
713
|
+
const next = new Date(nextAutoAt()).toLocaleString();
|
|
583
714
|
return `自动备份已开启:每 ${autoHours} 小时一次(已持久化,重启续跑),下次约 ${next}${lastAuto ? `;上次自动备份: ${lastAuto}` : ''}`;
|
|
584
715
|
}
|
|
585
716
|
|
|
717
|
+
/** 链式 timeout 调度:每次触发后按上次执行时间推算下一次,重启后节奏不重置。 */
|
|
718
|
+
function scheduleAuto() {
|
|
719
|
+
// 关闭竞态保护:auto off 时 in-flight 备份完成后的续链会以 autoHours=0
|
|
720
|
+
// 算出 delay=0,形成无限备份循环——关闭后绝不续链。
|
|
721
|
+
if (autoHours <= 0) return;
|
|
722
|
+
const delay = nextAutoAt() - Date.now();
|
|
723
|
+
autoDispose = ctx.timeout(async () => {
|
|
724
|
+
await runAutoBackup();
|
|
725
|
+
scheduleAuto();
|
|
726
|
+
}, delay);
|
|
727
|
+
}
|
|
728
|
+
|
|
586
729
|
async function runAutoBackup() {
|
|
587
730
|
try {
|
|
588
|
-
const r = await doBackup(
|
|
731
|
+
const r = await doBackup(autoKeep());
|
|
589
732
|
lastAuto = r.path.split('/').pop();
|
|
733
|
+
lastAutoAt = new Date().toISOString();
|
|
734
|
+
await saveAutoState();
|
|
590
735
|
console.log(`[dsh-backup] 自动备份完成: ${r.path} (sha ${r.sha.slice(0, 12)}…)`);
|
|
591
736
|
} catch (err) {
|
|
737
|
+
// 失败也推进内存锚点:否则下次 delay=0 立即重试形成热循环。不落盘
|
|
738
|
+
// (失败路径 auto.json 大概率也写失败),下次成功备份会持久化。
|
|
739
|
+
lastAutoAt = new Date().toISOString();
|
|
592
740
|
console.error(`[dsh-backup] 自动备份失败: ${String(err && err.message ? err.message : err)}`);
|
|
593
741
|
}
|
|
594
742
|
}
|
|
@@ -596,7 +744,8 @@ export function apply(ctx, pluginConfig) {
|
|
|
596
744
|
async function setAuto(h) {
|
|
597
745
|
if (autoDispose) { autoDispose(); autoDispose = null; }
|
|
598
746
|
autoHours = h;
|
|
599
|
-
|
|
747
|
+
lastAutoAt = null;
|
|
748
|
+
if (h > 0) scheduleAuto();
|
|
600
749
|
await saveAutoState();
|
|
601
750
|
}
|
|
602
751
|
|
|
@@ -703,7 +852,7 @@ export function apply(ctx, pluginConfig) {
|
|
|
703
852
|
return { kind: 'error', text: '小时数需为 1~720 之间的数字(如 /backup auto 12)' };
|
|
704
853
|
}
|
|
705
854
|
await setAuto(h);
|
|
706
|
-
return { kind: 'success', text: `✅ 自动备份已开启:每 ${h} 小时执行一次(保留 ${
|
|
855
|
+
return { kind: 'success', text: `✅ 自动备份已开启:每 ${h} 小时执行一次(保留 ${autoKeep()} 份,已持久化)。\n${autoSummary()}` };
|
|
707
856
|
}
|
|
708
857
|
|
|
709
858
|
let keep;
|
|
@@ -930,12 +1079,12 @@ export function apply(ctx, pluginConfig) {
|
|
|
930
1079
|
}), 'dsh-backup: download route');
|
|
931
1080
|
});
|
|
932
1081
|
|
|
933
|
-
//
|
|
1082
|
+
// 启动时恢复持久化的定时备份计划(不阻塞插件装配);错过则 delay=0 立即补跑。
|
|
934
1083
|
void (async () => {
|
|
935
1084
|
const h = await loadAutoState();
|
|
936
1085
|
if (h > 0 && !autoDispose) {
|
|
937
1086
|
autoHours = h;
|
|
938
|
-
|
|
1087
|
+
scheduleAuto();
|
|
939
1088
|
}
|
|
940
1089
|
})();
|
|
941
1090
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@xiaoyuyu6420/dsh-backup",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.6.0",
|
|
4
4
|
"description": "Backup, restore, download and GitHub-sync DeepSeek Harness user data (~/.dsh): /backup, scheduled auto-backup that survives restarts, sha256 checksums, integrity verify, rotation and a visual Settings panel. Cross-platform (macOS/Linux/Windows). 一键备份与恢复 DSH 数据:定时自动备份、完整性校验、下载与 GitHub 同步,附 Settings 可视面板。",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "lib/index.js",
|