dsh-webdav-sync 1.0.0 → 1.0.2

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 CHANGED
@@ -10,9 +10,12 @@
10
10
  | `defaults` | `cordis.patch.yml` 的 `agent-default-model` 行 | 默认模型 |
11
11
  | `patch` | `cordis.patch.yml` 里其余的 loader 行(MCP、权限预设、UI 设置、insert 块……) | 其余 profile 配置 |
12
12
  | `plugins` | `package.json` 的 `dependencies` 与 `dsh.profile.bundles` | 已安装插件 |
13
+ | `dirs` | 你在「同步内容」里**选择**的目录(profile 之外的绝对路径) | 附加目录 |
13
14
 
14
15
  每台电脑各改各的,同步时按份做三方合并,冲突时新的那份胜出、旧的那份留档;服务器上永远只有一份「整个 setup」的版本,而不是 profile 的镜像。
15
16
 
17
+ 前四份切自 profile 的两个文件。`dirs` 是让你把 profile 之外的目录(笔记、提示词、脚本)也带上:在「同步内容」里点附加目录那一行名称右边的**选择目录**(一个不带框的文字按钮),挑一个目录 —— 它连里面的文件一起封进同一个备份,在另一台电脑上恢复时**写回同一个路径**。它只增不减:同步会写会覆盖,但永远不会删掉里面的文件;这一行和其余四份一样有自己的开关(关掉只是「这台不带」,谁也删不掉),详见 [0036](docs/decisions/0036-extra-directories-part.md)。
18
+
16
19
  ---
17
20
 
18
21
  ## 参考与来源
@@ -28,7 +31,7 @@
28
31
 
29
32
  适配的部分:
30
33
 
31
- - **Go → TypeScript**:进程内没有常驻服务,一次同步就是一次 HTTP 往返加几次文件读写。
34
+ - **Go → TypeScript**:一次同步按需进行 HTTP 往返与文件读写;可选的本机转发开启后另有一个随 Host 运行的回环监听器。
32
35
  - **同步对象改成 DSH profile 行**:magpie 同步的是它自己的配置面;这里同步的是 `cordis.patch.yml` 的 loader 行和 profile 的 `package.json`,所以多了一层 YAML 文档级合并(保留注释、缩进与引号)。
33
36
  - **`node_modules` 不参与同步**:只同步声明,包在同一台机器上用 pnpm 重新装(见「插件为什么不整目录同步」)。
34
37
 
@@ -74,7 +77,14 @@ pnpm add dsh-webdav-sync
74
77
  | `defaults` | `boolean` | `true` | 是否同步默认模型这一份 |
75
78
  | `plugins` | `boolean` | `true` | 是否同步已安装插件这一份 |
76
79
  | `patch` | `boolean` | `true` | 是否同步其余 profile 配置这一份 |
77
- | `rememberPassword` | `boolean` | `true` | 是否把地址、用户名和密码留到下次。关掉后保存时 `password` 会被清空,密码只在本次运行内使用 |
80
+ | `dirs` | `string[]` | `[]` | 附加目录的绝对路径,按你添加的顺序。列表说「同步哪几个」,开关说「这台带不带」。去掉一个就是「这台不再同步它」(文件不会动)。最多 `DIRS_MAX = 5` 个,每个目录最多 2000 个文件 / 8 MiB,路径必须是绝对的 |
81
+ | `dirsEnabled` | `boolean` | `true` | 附加目录这一份的开关。关掉之后这台电脑不读也不写任何附加目录,列表原样留着,服务器上那份也不动。旧配置里没有这个键,读作开 |
82
+ | `rememberPassword` | `boolean` | `true` | 是否把上游 WebDAV 密码留到下次。关掉后保存到磁盘的 `password` 为空,本次运行仍用内存里的密码;重启 Host 后需重新输入。不影响下面生成的本地转发账号 |
83
+ | `headers` | `string` | `''` | 额外请求头,一行一条「名字: 值」,空行与 `#` 开头的行忽略。先于每个请求自带的头写入,同名时以请求自带的为准;给了自己的 `authorization` 就不再补 Basic。同步与本机转发共用。明文,见下节 |
84
+ | `relayEnabled` | `boolean` | `false` | 本机转发开关。复用本表的 `url`、`user`、`password`、`headers`,与主表单一起保存;关掉只停止监听,保留端口与本地账号 |
85
+ | `relayPort` | `number` | `3426` | 只在 `127.0.0.1` 监听的端口,持久化而非每次启动随机分配;用「换一个端口」显式修改后按同一个「保存」生效 |
86
+ | `relayUser` | `string` | `''` | 首次开启转发并保存时生成的本地 Basic 用户名,之后沿用;保存在本机,不上传 |
87
+ | `relayPassword` | `string` | `''` | 与 `relayUser` 一起生成并保存的本地 Basic 密码,卡片显示且可复制;不是上游 WebDAV 密码,不受 `rememberPassword` 开关影响 |
78
88
 
79
89
  对应 JSON 长这样:
80
90
 
@@ -89,7 +99,14 @@ pnpm add dsh-webdav-sync
89
99
  "defaults": true,
90
100
  "plugins": true,
91
101
  "patch": true,
92
- "rememberPassword": true
102
+ "dirs": [],
103
+ "dirsEnabled": true,
104
+ "rememberPassword": true,
105
+ "headers": "",
106
+ "relayEnabled": false,
107
+ "relayPort": 3426,
108
+ "relayUser": "",
109
+ "relayPassword": ""
93
110
  }
94
111
  ```
95
112
 
@@ -112,6 +129,15 @@ pnpm add dsh-webdav-sync
112
129
 
113
130
  要清掉存下来的密码,用「关闭同步」:`turnOff()` 保存的是 `{ ...config, url: '', password: '' }`。值得注意的是它**不清口令**,也**不清状态**(不是 `saveState` 而是 `saveConfig`)—— 重新填回同一地址时 `stateKey` 会变回原值,那条基线仍然算数,所以「关掉又打开」不会被当成加入一个新文件夹,也就不会拿服务器上的旧 setup 覆盖本机较新的那份。
114
131
 
132
+ ### 额外请求头
133
+
134
+ 有的 WebDAV 服务器除了 Basic 认证还要别的东西:InfiniCLOUD 之类要 `User-Agent`,套在反向代理后面的要 `X-Api-Key`,也有的直接发 Bearer 令牌。`headers` 就是给这种情况的:
135
+
136
+ - **一行一条 `名字: 值`**,空行与 `#` 开头的行忽略。名字按 HTTP 的 token 规则校验,写坏的行会在**保存时**就被拒(错误里带行号),而不是等到下一次同步才以一个莫名其妙的 400 失败。
137
+ - **先写入,再让每个请求盖过它。** 请求自带的头(`if-match`、`if-none-match`、`content-type`、`depth`、`accept-encoding`)随后写入,同名时以请求自带的为准 —— 否则一个手滑的 `content-type` 就能把上传变成另一种类型,而 412 条件写入也会失效。
138
+ - **给了 `authorization` 就不再补 Basic。** 没给才用上面那个 `password` 拼 `Authorization: Basic …`,所以 Bearer 能用,而填了密码的配置行为不变。
139
+ - **它不算机密。** 与 `password` / `passphrase` 不同,它明文存在 `sync.json` 里,也明文下发到卡片(表单里可直接编辑)。但它也不进 `stateKey`:换请求头只是换了同一把锁的开门方式,那条「上次同步到哪」的基线描述的还是同一个文件。见 [0034](docs/decisions/0034-extra-request-headers.md)。
140
+
115
141
  ---
116
142
 
117
143
  ## 界面
@@ -125,28 +151,32 @@ pnpm add dsh-webdav-sync
125
151
 
126
152
  `order: 22` 是有意的:Settings 导航栏没有滚动条且高度固定,排太后在窗口不高时会被裁掉。
127
153
 
128
- 卡片本身按三节排:**服务器**(地址、账号、密码、口令,每句说明都紧跟在它讲的那个字段的标签下面)、**自动同步**(一排选项卡:关闭同步 / 手动同步 / 3 分钟 / 15 分钟 / 30 分钟 / 1 小时,选中的那个高亮)、**同步内容**(每份一行:前面复选框决定同步不同步,下面一行小字说这份同步的是什么,右端是「已一致 / 仅本机有改动 / 仅服务器有改动 / 两边都有改动」)。同步动作(测试连接/预览/立即同步/服务器覆盖本机/撤销恢复/本机覆盖服务器)在「同步内容」末尾排成一行,「放弃改动/保存」在整张卡片最外层。按钮只有两种样子:每组动作里只有一个实心的主操作(保存、立即同步、确认框的「确定」),其余一律描边。
154
+ 卡片的三个主要设置区依次是:**服务器**(地址、账号、密码、口令、额外请求头 —— 前四个各占一行,最后一个是多行输入框,每句说明都紧跟在它讲的那个字段的标签下面)、**自动同步**(一排选项卡:关闭同步 / 手动同步 / 3 分钟 / 15 分钟 / 30 分钟 / 1 小时,选中的那个高亮)、**同步内容**(四份各一行:前面复选框决定同步不同步,下面一行小字说这份同步的是什么,右端是「已一致 / 仅本机有改动 / 仅服务器有改动 / 两边都有改动」;第五行是**附加目录**,复选框一样在行首,名称右边多一个**选择目录**文字按钮(不带框),选中的路径在下面一行一个、各带「移除」,开关开着而列表为空时不显示状态文字,读不出来的目录在列表下方按「哪条路径、为什么」说出来)。主表单最后还有**本机转发**:一个与其他几行同字号的复选框,开启后是三行同构的值 —— **地址**、**用户名**、**密码**,每行都是名称、等宽文字、紧跟其后的复制图标(复制成功后图标短暂变成对勾),值本身既不套框也不能编辑;端口不出现在地址之外,只有监听出问题时错误旁边才多一个「换一个端口」。同步动作(测试连接/预览/立即同步/服务器覆盖本机/撤销恢复/本机覆盖服务器)在「同步内容」末尾排成一行,「放弃改动/保存」在整张卡片最外层。六个同步动作**全部是不带框的文字按钮**,靠颜色分组:**立即同步**是强调色(这一行唯一的着色项,也就是这一行的主操作),**服务器覆盖本机**与**本机覆盖服务器**是警告色(这两个会用一边整体覆盖另一边),**测试连接**、**预览**、**撤销恢复**是普通文字色;悬停时各自浮起一层底色,键盘聚焦时有一圈焦点环。**保存**与确认框的「确定」仍是实心按钮。附加目录的「选择目录」、转发出问题时的「换一个端口」与地址、账号旁的复制图标也都是不带框的轻量按钮。
155
+
156
+ 附加目录那一份的开关说的是「这台电脑带不带这一份」,不是「带哪几个目录」:关掉之后这台电脑一个附加目录都不读、不写,列表原样留在配置里,服务器上那份一个字节不动 —— 与其余四份「关掉的份原样带走」同一条规则,任何地方都不会因此删文件([0036](docs/decisions/0036-extra-directories-part.md))。「选择目录」在满 5 个之后变灰;开关开着、列表为空时这一行不显示状态文字 —— 「不同步」在其余四份里读作「开关关掉了」,而空列表只是「还没指定」。挑目录用的是这台部署已有的对话框(桌面端走它的原生桥,网页端走 workspace 服务);两者都没弹出来时,会留一个空行让你手打路径。
129
157
 
130
- 浏览器半边不读 profile 文件、也不说 WebDAV,它只 `fetch` Host 半边的路由(全部在 `ROUTE_PREFIX = '/plugin/webdav-sync'` 之下):
158
+ 浏览器半边不读 profile 文件、也不说 WebDAV,它只 `fetch` Host 半边在 `ROUTE_PREFIX = '/plugin/webdav-sync'` 之下的路由:
131
159
 
132
160
  `/status`、`/config`、`/check`、`/sync`、`/preview`、`/restore`、`/undo`、`/upload`、`/dismiss`、`/off`、`/install`。
133
161
 
162
+ 主表单最末尾是**本机转发**复选框,开启后用三行同构的文字显示地址与生成的本地 Basic 用户名、密码:每行都是名称(地址 / 用户名 / 密码)、等宽的值、紧跟其后的复制图标。它复用同一份草稿、同一个「放弃改动 / 保存」:`/status` 同时返回 `relay` 的监听状态与本地账号,`/config` 同时保存转发开关和端口并立即应用。没有 `/plugin/webdav-relay/*` 独立路由,也没有独立保存,见下节。
163
+
134
164
  ---
135
165
 
136
166
  ## 卡片上的动作
137
167
 
138
168
  | 按钮 | 路由 | 做什么 | 写哪里 |
139
169
  | --- | --- | --- | --- |
140
- | **保存** | `/config` | 存下地址、账号与口令、自动同步间隔、同步哪几份 | `sync.json`(口令与密码按「记住密码」决定写到哪) |
170
+ | **保存** | `/config` | 存下地址、账号与口令、额外请求头、自动同步间隔、同步内容与附加目录列表、本机转发开关与端口;首次开启转发时生成本地账号,保存后立即应用监听状态 | `sync.json`(上游密码按「记住密码」决定是否落盘;加密口令与本地转发账号仍保存在本机) |
141
171
  | **放弃改动** | 无(只在浏览器半边) | 把表单恢复成上次从 Host 取到的状态 | 不写任何东西 |
142
172
  | **立即同步** | `/sync`(`force: true`) | 跑一次三方分片合并 | 总会写服务器(`force` 覆盖「没什么可说就不写」);本机只在合并结论需要改它时才写,且被替换的副本先封存进 `sync/` |
143
173
  | **预览** | `/preview` | 用和真正合并同一套判断,说出每一份会取哪边 | 不写 profile、不写服务器(但会读一次服务器;若那份服务器文件是截断的、且本机有基线,会先留档一份 `-server-damaged`) |
144
174
  | **测试连接** | `/check` | 到达服务器、报告账号能否读写、服务器上有没有我们的备份、多大、什么时候写的 | 服务器上缺 `dsh-webdav` 文件夹时会创建它(首次同步本来也要建) |
145
- | **服务器覆盖本机** | `/restore` | 不比较谁改了什么,服务器上有的每一份都拿过来 | 本机文件;替换前的本机 setup 先封存,供撤销 |
175
+ | **服务器覆盖本机** | `/restore` | 不比较谁改了什么,服务器上有的每一份都拿过来(附加目录写回它被指定时的那个绝对路径) | 本机文件;替换前的本机 setup 先封存,供撤销 |
146
176
  | **本机覆盖服务器** | `/upload` | 覆盖一个**不是我们备份**的服务器文件 | 服务器文件;原文件先留档到 `sync/` |
147
177
  | **撤销恢复** | `/undo` | 把上次 Restore 换掉的本机 setup 放回去 | 本机文件 |
148
178
  | **安装缺少的插件** | `/install` | 在 profile 目录跑 `pnpm install` | `node_modules`(同步声明后由本机拉包) |
149
- | **关闭同步**(自动同步选项卡的第一个) | `/off` | 清空本机配置(清 `url` 与 `password`),服务器上的文件保持原样 | `sync.json`(`sync-state.json` 刻意保留) |
179
+ | **关闭同步**(自动同步选项卡的第一个) | `/off` | 清 `url` 与上游 `password` 并立即停止本机转发监听;保留转发开关、端口及生成的本地账号,服务器上的文件保持原样 | `sync.json`(`sync-state.json` 刻意保留) |
150
180
  | **知道了** | `/dismiss` | 收掉上次同步的「替换了内容」通知 | `sync-state.json` |
151
181
 
152
182
  「服务器覆盖本机」「本机覆盖服务器」「关闭同步」三个动作都要先在卡片上确认一次(「关闭同步」从「自动同步」选项卡里选,选完弹确认框);「撤销恢复」只在真的存在一次可撤销的 Restore 时才可用。
@@ -157,6 +187,49 @@ pnpm add dsh-webdav-sync
157
187
 
158
188
  ---
159
189
 
190
+ ## 本机转发(给别的 WebDAV 客户端用)
191
+
192
+ **这是主表单最后的一个开关,不是第二套 WebDAV 配置。** 它让本机上的其他 WebDAV 客户端复用上面已经填好的 `url`、`user`、`password` 与 `headers`。有些客户端没有「额外请求头」这个字段,而服务器恰恰需要 `User-Agent`、`X-Api-Key` 等(见 [0034](docs/decisions/0034-extra-request-headers.md));例如 [magpie](https://github.com/yetone/magpie) 的 WebDAV 表单只有地址、账号与密码。
193
+
194
+ ### 开启与复制
195
+
196
+ 1. 在上面的服务器表单里填好 WebDAV 地址、上游账号密码与所需的额外请求头。
197
+ 2. 勾选主表单末尾的**本机转发**,点整张卡片唯一的**保存**。首次开启并保存时会生成一组本地 Basic 用户名、密码;之后保存、重启或关闭再开启都沿用它们。
198
+ 3. 点开关下面**地址**那行右边的复制图标,把它复制走,再点**用户名**、**密码**各自的复制图标,把它们填进另一个客户端。这里的账号是本地转发账号,**不是**上游 WebDAV 账号,不需要把真实 WebDAV 密码交给那个客户端。地址与账号都是只读的:它们由配置推出来,不在这里编辑。三个值都排成「名称 + 等宽文字 + 复制图标」(地址 / 用户名 / 密码),都直接排在那里(不是输入框,也不套框),可以像普通文字一样选中复制;复制被系统拒绝时,图标旁边会说一句「复制不了,请手动选中复制」。
199
+
200
+ 地址保留上游基址的路径。例如上游是 `https://dav.example.com/dav/` 时,默认显示:
201
+
202
+ ```
203
+ http://127.0.0.1:3426/dav
204
+ ```
205
+
206
+ 上游没有路径时就是 `http://127.0.0.1:3426`。以卡片实际显示为准,地址中**没有 token**。地址跟随表单草稿预览;改动后要点主「保存」才会应用到监听器。首次保存前还没有本地账号,卡片会提示保存后生成。
207
+
208
+ ### 固定端口与同一个保存
209
+
210
+ 默认端口是 `3426`,保存在 `<profileDir>/.dsh-webdav-sync/sync.json`,不会每次启动随机换一个,也不会在占用时自动挑其他端口。端口出现在地址里,卡片上不再单独列一行:监听有冲突时会显示错误,错误旁边才出现**换一个端口**,按同一个主「保存」生效。改开关或端口都会成为主表单的未保存改动,没有独立转发表单、独立保存或「换一个地址/token」操作。
211
+
212
+ 端口与上游路径共同决定客户端看到的地址;随意更改它们可能让客户端把它当作另一台服务器、重新开始加入流程。显式换端口后要同步更新客户端里的地址。关闭转发只停止监听,保存的端口与本地账号仍保留。
213
+
214
+ ### 认证与转发边界
215
+
216
+ - **只绑 `127.0.0.1`,且必须通过本地 Basic 认证。** 缺少、错误或不是 Basic 的认证都会得到 `401`(带 Basic challenge),请求到此结束,**不向上游转发**。回环地址不是权限边界,本机其他进程也能访问;本地账号密码才是这扇门的钥匙。
217
+ - **客户端的 `Authorization` 只用于进门,绝不透传。** 发往上游的认证复用同步配置的 `user` / `password` 拼 Basic;若额外请求头里显式配置了 `Authorization`,则优先用那一条,不再补 Basic。
218
+ - **其余头先配置、后客户端。** 同名时客户端的协议头(`If-Match`、`Depth`、`Content-Type` 等)优先;连接级的逐跳头不透传,`Host` 使用上游地址。配置的 `headers`(如 `User-Agent`、`X-Api-Key`)会随请求发送。
219
+ - **请求与响应按流转发,不转换备份内容。** 方法、请求体、条件头、状态码与 ETag 透传;路径已含上游基路径时不重复拼接,否则补上基路径,查询串保留;指向上游基路径的响应 `Location` 改回本机地址。没有 `.zip` 或 `.prop` 后缀的文件资源路径会统一补 `.prop`,已有这两种后缀则保留;`MKCOL` 与集合型 `PROPFIND` 路径不改名(省略 Depth 按集合处理,Depth 0 的无扩展名未知路径也保持原样)。成功访问产生的别名保存在 profile stateDir,目录型 `PROPFIND` 仅在收到未压缩的 207 响应时,将已知别名的 `href` 还原为客户端原始名称;同一转发器内的 `MOVE` / `COPY` 也会映射 Destination 并维护目标别名。其余 XML 内容与原生 `.prop` 文件名不变,详见 [0050](docs/decisions/0050-relay-propfind-aliases.md) 和 [0049](docs/decisions/0049-relay-collection-and-destination-mapping.md)。
220
+
221
+ ### 本机保存与旧配置
222
+
223
+ 本机开关、端口与生成的账号写在主配置 `<profileDir>/.dsh-webdav-sync/sync.json`(`0600`)的 `relayEnabled` / `relayPort` / `relayUser` / `relayPassword` 字段里,**不上传、不进备份**。本地用户名与密码为了能填进另一个程序会明文显示在卡片中;上游 WebDAV 密码与加密口令仍只以是否已设置下发。能够读取本机配置文件的进程仍可能取得其中的凭据。
224
+
225
+ **DSH 必须保持运行。** 转发器在 Host 进程里,Host 退出或插件卸载就停止。若上游 `rememberPassword: false`,上游密码只留在本次 Host 内存里,重启后必须在主表单重新输入并保存;本地转发账号虽然仍在,也不能替代上游密码。
226
+
227
+ 旧版 `<profileDir>/.dsh-webdav-relay/relay.json` **不自动读取、不迁移、不删除**,旧 token 地址也不再适用。升级后确认主表单的上游配置,开启转发并保存,再把新的地址与本地账号填入客户端。迁移不需要改动上游数据;开启、关闭、保存转发配置本身不会发起同步,也不会重写或转换服务器上的文件,只有通过认证后的客户端请求才会访问上游。
228
+
229
+ 当前决策见 [0041](docs/decisions/0041-relay-reuses-sync-config.md);[0040](docs/decisions/0040-local-relay-for-other-clients.md) 保留为历史,其独立配置、token、路由与保存的设计已被取代。
230
+
231
+ ---
232
+
160
233
  ## 合并是怎么做的
161
234
 
162
235
  一次同步是**每份一次的三方合并**,三方是:本机现在、服务器现在、以及上次同步留下的基线。
@@ -167,9 +240,9 @@ pnpm add dsh-webdav-sync
167
240
  - `changedThere = first || remote.hashes[part] !== state.remote?.[part]`
168
241
  - 比的是哈希不是「在不在」—— 只比存在与否会把另一台电脑做的编辑读成「没变」,然后悄悄留下本机更旧的那份。
169
242
  3. **只本机改** → 推上去。**只服务器改** → 取回来。**两边都没改** → 本机有的就以本机为准,避免只在一侧存在的份被误删。
170
- 4. **两边都改** → 比时间:服务器那侧用 `Bundle.created`,本机这侧用文件的 mtime(`patch` 份用 `cordis.patch.yml` 的 `mtimeMs`,`plugins` 份用 `package.json` 的 `mtimeMs`)。本机不早于服务器则本机胜,否则服务器胜。**冲突一旦发生,两边都留档到 `sync/`**(`-this-computer` 与 `-server`)。
171
- 5. **删除会传播**:服务器上少了某份,本机也把它去掉(`applied[part] = null`)。**两个例外**:`plugins` 份永不删除(它是加载 shell 的东西),以及首次同步(本机没有基线)不删任何东西。关掉的份(`syncsPart` 为 false)也不删本机的东西,只是不再参与。
172
- 6. **每次覆盖前先留档**:本机文件将被改写前,当前的整个 setup 会被封成 `sync/<时间戳>-before-apply.dshsync`。**第一次同步(以及状态文件丢了之后的第一次)也留这一份**:那种情况下服务器整套配置会被套到本机上,哈希上与「刚加入这台服务器」没法区分,所以插件不猜哪边该赢,而是照常让服务器赢、同时把本机那份封存下来并在通知里**点名它的路径**(`sync/this-computer`),Restore 和 Undo 都能把它放回去。两边本来就一样时不会多出这份副本。见 [0024](docs/decisions/0024-adopting-server-keeps-local.md)。
243
+ 4. **两边都改** → 比时间:服务器那侧用 `Bundle.created`,本机这侧用文件的 mtime(`patch` 份用 `cordis.patch.yml` 的 `mtimeMs`,`plugins` 份用 `package.json` 的 `mtimeMs`,`dirs` 份用它读到的文件里最新的那个 `mtimeMs`)。本机不早于服务器则本机胜,否则服务器胜。**冲突一旦发生,两边都留档到 `sync/`**(`-this-computer` 与 `-server`)。
244
+ 5. **删除会传播**:服务器上少了某份,本机也把它去掉(`applied[part] = null`)。**三个例外**:`plugins` 份永不删除(它是加载 shell 的东西),`dirs` 份永不删除(见下节),以及首次同步(本机没有基线)不删任何东西。关掉的份(`syncsPart` 为 false)也不删本机的东西,只是不再参与。
245
+ 6. **每次覆盖前先留档**:本机文件将被改写前,当前的整个 setup 会被封成 `sync/<时间戳>-before-apply.prop`。**第一次同步(以及状态文件丢了之后的第一次)也留这一份**:那种情况下服务器整套配置会被套到本机上,哈希上与「刚加入这台服务器」没法区分,所以插件不猜哪边该赢,而是照常让服务器赢、同时把本机那份封存下来并在通知里**点名它的路径**(`sync/this-computer`),Restore 和 Undo 都能把它放回去。两边本来就一样时不会多出这份副本。见 [0024](docs/decisions/0024-adopting-server-keeps-local.md)。
173
246
  7. **本机文件只在服务器写成功之后才写**。上传失败的话,这台电脑不会既拿着别人的 setup、又说不清自己那份去哪儿了。
174
247
  8. **关掉的份会被原样带过上传**:把一份关掉,只是不再动本机的它、也不再从服务器取它,但服务器上那份的存在会被保留 —— 不能因为一台机器关了它,就替还开着它的机器把它删掉。恢复和撤销也一样:一份被关掉,恢复就不会把它写进来,撤销也不会去动它。
175
248
 
@@ -179,6 +252,8 @@ pnpm add dsh-webdav-sync
179
252
 
180
253
  要说清楚的是:**卡片上的状态不代表服务器现在的真相**。要真知道服务器那边是什么,按「预览」—— 它用同一套合并判断去真读一次服务器,逐份说出会取哪边(见 [0029](docs/decisions/0029-status-says-only-what-it-knows.md))。
181
254
 
255
+ 卡片底部那一行左右两个时间是**两件事**:**上次检查**(左边)是宿主**试过**同步(自动同步的每次 tick 都算,哪怕两边一致、一个字节都没写),**上次同步**(右边)只在真换过东西的时候才前进(推上去、从服务器取回某一份、恢复、上传)。所以「上次检查」在动就说明定时器活着,而「上次同步」停在很久以前只说明这段时间两边一直是一份;升级前的老状态文件没有这个时间,会显示「尚未同步过」。卡片打开着时自己每 15 秒重读一次状态(只读本机文件,不向服务器发请求),否则这两个时间永远停在打开那一刻。见 [0039](docs/decisions/0039-the-card-says-check-and-sync-apart.md)。
256
+
182
257
  写到本机的 YAML 走 `yaml` 的 Document API 而不是「解析成对象再序列化」,所以一个人的注释、缩进、引号风格会在只改了一个 provider 的同步里活下来。合并结论与原文件相同时,`applyPatch` 直接把读到的原始字节还回去 —— YAML 重新序列化不是字节稳定的(`[text, image]` 会被写成 `[ text, image ]`),改写一个什么都没变的文件等于撒谎。
183
258
 
184
259
  本机写入用「写同目录临时文件再 rename」的方式(`writeAtomic`),断电或崩溃只会留下旧文件或新文件,不会是半个。
@@ -189,6 +264,18 @@ pnpm add dsh-webdav-sync
189
264
 
190
265
  顺带一提,`applyManifest` 是**镜像**而不是合并这份声明:别的电脑删掉的插件在这里也会从声明里去掉,否则两边永远收敛不了。但这只是文件里少一行,并不会卸载任何包。
191
266
 
267
+ ### 附加目录:只增不减
268
+
269
+ `dirs` 这一份和上面四份有三处不同,都是因为**里面是用户自己的文件**:
270
+
271
+ - **写入只增不减。** 同步会写、会覆盖,但永远不为这条路删掉任何东西。在一台电脑上删掉的笔记会从别处回来;要在所有电脑上删掉,手动删。服务器上这一份整个消失时也一样:本机仍然指定着目录,就把自己的副本推回去,而不是照着删。
272
+ - **读不出来不等于空目录。** 目录被删了、盘没挂上、没权限,读取会把这条路径记进 `dirProblems`,并在这一份里**用服务器上的副本原样顶替**这次读取 —— 否则「读不出来」会被读成「这个目录里什么也没有」,下一次推上去就等于替所有人删了整个目录。卡片上会直说哪条路径没读成、为什么。`missing` 再区分「只是不在这台电脑上」(恢复可以建它)与「读不了」(绝不盲写)。
273
+ - **有上限,超了整目录不同步。** 最多 5 个目录、每个目录 2000 个文件 / 8 MiB(base64 之前)。`node_modules`、`.git` 与符号链接静默跳过。带半个目录上去比不带更危险,所以超限是拒绝整个目录,而不是截断着带。
274
+
275
+ 这一份在卡片上和其余四份一样有开关(`dirsEnabled`):关掉之后这台电脑一个附加目录都不读、不写,列表原样留着,服务器上那份也不动 —— 只是「这台不带」,不是「删掉它」。
276
+
277
+ 路径是这一份的数据:`writeDirs` 按备份里的 `path` 写回**同一个目录**,而不是在另一台电脑上另造一个。相对路径、盘符不存在的路径不读也不写,但条目留在这一份里(丢掉它等于替那台电脑删了它)。写回时只动绝对路径;条目里用 `..` 或开头带 `/` 去指到目录以外的文件(`under` 检查)直接不写。见 [0036](docs/decisions/0036-extra-directories-part.md)。
278
+
192
279
  ---
193
280
 
194
281
  ## 服务器上的文件
@@ -196,12 +283,14 @@ pnpm add dsh-webdav-sync
196
283
  ```
197
284
  <你填的 WebDAV 地址>
198
285
  └── dsh-webdav/
199
- └── dsh-profile.dshsync
286
+ └── dsh-profile.prop
200
287
  ```
201
288
 
202
- 常量在 `src/wire.ts`:`REMOTE_FOLDER = 'dsh-webdav'`、`REMOTE_FILE = 'dsh-profile.dshsync'`。这个文件夹由插件自己 MKCOL 创建。
289
+ 常量在 `src/wire.ts`:`REMOTE_FOLDER = 'dsh-webdav'`、`REMOTE_FILE = 'dsh-profile.prop'`、`BACKUP_EXT = '.prop'`。这个文件夹由插件自己 MKCOL 创建。
290
+
291
+ 改后缀之前这个文件叫 `dsh-profile.dshsync`。老文件夹里的那个名字仍会被读(`LEGACY_REMOTE_FILE`),所以升级后的第一次同步看到的是自己上次留下的备份,而不是「服务器上什么都没有」——后者会让这台电脑把自己的配置推上去,盖掉另一台电脑留下的那份。新名字找不到时才会去看旧名字,此后只写新名字:旧文件不再更新,也不会被自动删除,可以手动删。见 [0035](docs/decisions/0035-backup-suffix-prop.md)。
203
292
 
204
- `dsh-profile.dshsync` 是一段 JSON 信封,长这样:
293
+ `dsh-profile.prop` 是一段 JSON 信封,长这样:
205
294
 
206
295
  ```json
207
296
  {
@@ -227,8 +316,9 @@ pnpm add dsh-webdav-sync
227
316
 
228
317
  - **不是我们备份的文件,绝不静默覆盖。** 服务器上那个文件如果打不开成我们的格式(`NotBackupError`),同步会报错并指向「本机覆盖服务器」这个显式动作,而不是顺手把它换掉。那可能是别人的东西。
229
318
  - **别的口令封的文件,也绝不覆盖。** 如果文件是我们的格式但口令打不开(`PassphraseError`),同步会说你该用另一台电脑上设的口令;连「本机覆盖服务器」也会拒绝 —— 那是一份别人的备份,这台电脑没有资格替它。
230
- - **损坏/截断的备份只在「本机曾同步过」时才在其上重建。** 判断条件是 `state.local !== undefined`:本机有基线,说明这个文件夹确实是我们的,被截断的字节会先留档成 `sync/<时间戳>-server-damaged.dshsync`,再写一份完整的覆盖上去。只有开头看起来像我们文件的中断正文才算损坏(`looksSealed` 检查前 256 字节),门户登录页之类的东西不会被当成我们的残片。
231
- - **能解开、但内容不像自己写的文件,按损坏处理,不按「别人的」处理。** 分片形状不对(比如 `patch.entries` 不是数组)的包会让合并在半路炸出 `TypeError`,或者把一份空配置当成本机设置写下去。所以解封之后先校验四份的形状,不对就报错,并且**说清是哪一份**:`服务器上的文件是一份读不了的 dsh 同步备份:其中「plugins」这一份的形状不是本插件写出来的`。本机有基线时走重建路径(本机编辑不会被清空),没有基线时明确拒绝,同时告诉用户可以用「本机覆盖服务器」。见 [0022](docs/decisions/0022-validate-part-shapes.md)。
319
+ - **损坏/截断的备份只在「本机曾同步过」时才在其上重建。** 判断条件是 `state.local !== undefined`:本机有基线,说明这个文件夹确实是我们的,被截断的字节会先留档成 `sync/<时间戳>-server-damaged.prop`,再写一份完整的覆盖上去。只有开头看起来像我们文件的中断正文才算损坏(`looksSealed` 检查前 256 字节),门户登录页之类的东西不会被当成我们的残片。
320
+ - **能解开、但内容不像自己写的文件,按损坏处理,不按「别人的」处理。** 分片形状不对(比如 `patch.entries` 不是数组)的包会让合并在半路炸出 `TypeError`,或者把一份空配置当成本机设置写下去。所以解封之后先校验每一份的形状,不对就报错,并且**说清是哪一份**:`服务器上的文件是一份读不了的 dsh 同步备份:其中「plugins」这一份的形状不是本插件写出来的`。本机有基线时走重建路径(本机编辑不会被清空),没有基线时明确拒绝,同时告诉用户可以用「本机覆盖服务器」。见 [0022](docs/decisions/0022-validate-part-shapes.md)。
321
+ - **附加目录只写不删,且只写回它被指定时的那个路径。** 同步会往这些目录里写文件、覆盖文件,但永远不会删掉里面的任何东西:镜像删除是唯一一种事后说不清也补不回来的操作,而「删了又回来」只是烦人。写回时只动本机上的绝对路径,条目里想用 `..` 或绝对路径走出目录的文件直接不写;一个目录读不出来时(盘没挂上、没权限)这一份取服务器上的副本,绝不当成空目录推上去。见 [0036](docs/decisions/0036-extra-directories-part.md)。
232
322
  - **状态文件读不出来时不当成没有状态。** 只有文件**真的不在**才算第一次同步;它在却解不开,会说出是哪个文件、解析错在哪,而不是悄悄退回「这是一台新电脑」然后让服务器上的 setup 覆盖本机。(本机自己的 `cordis.patch.yml` / `package.json` 坏了也一样报文件名。)见 [0027](docs/decisions/0027-damaged-is-not-missing.md)。
233
323
  - **口令不得等于 WebDAV 密码。** 服务器会被发送密码(Basic 认证),所以用它加密的文件,等于让服务器的运营者能解开。保存时这条会被拒绝:`口令不能和 WebDAV 密码相同:密码是要发给服务器的,服务器可以拿它打开文件。请另设一个口令`。比较用的是常量时间实现(`sameSecret`)。
234
324
  - **密码不会被带到另一个账号上。** 空密码框的含义是「沿用已存的那个」,而这只在同一个服务器 + 同一个用户名下成立;地址或用户名一变,沿用的密码就被丢掉(`sameAccount`),免得 A 服务的凭据被发到 B 服务。这台机器上的自动同步定时器因此也没有这条路径 —— 它不需要任何人为动作就会发请求。见 [0015](docs/decisions/0015-password-not-carried-across-accounts.md)。
@@ -242,18 +332,18 @@ pnpm add dsh-webdav-sync
242
332
 
243
333
  ## 本机文件
244
334
 
245
- 全部在 profile 的 `.dsh-webdav-sync/` 之下:
335
+ 同步的全部在 profile 的 `.dsh-webdav-sync/` 之下:
246
336
 
247
337
  | 路径 | 权限 | 内容 |
248
338
  | --- | --- | --- |
249
- | `<profileDir>/.dsh-webdav-sync/sync.json` | `0600` | 同步配置,**含 WebDAV 密码与加密口令** |
339
+ | `<profileDir>/.dsh-webdav-sync/sync.json` | `0600` | 同步与本机转发的主配置,**含加密口令、选择记住的上游 WebDAV 密码、转发开关/端口与生成的本地 Basic 账号密码**;整个配置文件不上传 |
250
340
  | `<profileDir>/.dsh-webdav-sync/sync-state.json` | `0600` | 同步状态:服务器版本、各份哈希、上次通知、可撤销的 Restore、上次失败原因 |
251
- | `<profileDir>/.dsh-webdav-sync/sync-server.dshsync` | `0600` | 上次读/写的服务器正文缓存(让「只本机变过」的同步不必下载它正要覆盖的文件) |
341
+ | `<profileDir>/.dsh-webdav-sync/sync-server.prop` | `0600` | 上次读/写的服务器正文缓存(让「只本机变过」的同步不必下载它正要覆盖的文件) |
252
342
  | `<profileDir>/.dsh-webdav-sync/sync/` | `0700` | 被替换掉的副本。每个 tag 保留最新 5 份 |
253
343
 
254
344
  profile 目录的解析顺序(与 `dsh-git-branch` 一致):`profileDir` 配置项(必须是绝对路径)→ `$DSH_PROFILE_DIR` → `<home>/profiles/<name>`,其中 `<home>` 取 `$DSH_HOME`(须是绝对路径)否则 `~/.dsh`,`<name>` 取 `$DSH_PROFILE` 否则 `desktop`。空字符串一律当作没设。
255
345
 
256
- 同步的文件一共就两个:profile 的 `cordis.patch.yml` 与 `package.json`。
346
+ 同步的前四份切自 profile 的两个文件:`cordis.patch.yml` 与 `package.json`。第五份(`dirs`)是你在卡片上指定的附加目录,内容跟着备份走,落在同一个路径。
257
347
 
258
348
  ---
259
349
 
@@ -261,7 +351,7 @@ profile 目录的解析顺序(与 `dsh-git-branch` 一致):`profileDir`
261
351
 
262
352
  ```bash
263
353
  pnpm install
264
- pnpm test # vitest run —— 207 个测试
354
+ pnpm test # vitest run —— 360 个测试
265
355
  pnpm run typecheck # tsc -p tsconfig.client.json --noEmit
266
356
  pnpm run build:all # pnpm run build && pnpm run build:client
267
357
  ```
@@ -272,7 +362,7 @@ pnpm run build:all # pnpm run build && pnpm run build:client
272
362
 
273
363
  环境要求:`engines.node >= 22.19`,包管理器 `pnpm@11.8.0`(`packageManager` 字段)。运行时依赖只有 `yaml`;Host 半边依赖 `@deepseek-ai/cordis`、`@deepseek-ai/dsh-host-webserver`、`@deepseek-ai/dsh-settings`、`@deepseek-ai/schemastery` 作为 peerDependencies。
274
364
 
275
- 测试分布:`tests/engine.spec.ts`(54)、`tests/dav.spec.ts`(37)、`tests/store.spec.ts`(30)、`tests/state.spec.ts`(23)、`tests/client-card.spec.ts`(22)、`tests/crypto.spec.ts`(22)、`tests/index.spec.ts`(13)、`tests/install.spec.ts`(6),共 207 例。
365
+ 测试分布:`tests/relay-host.spec.ts`(8)、`tests/relay-panel.spec.tsx`(6)、`tests/engine.spec.ts`(67)、`tests/dav.spec.ts`(49)、`tests/client-card.spec.ts`(45)、`tests/state.spec.ts`(40)、`tests/relay.spec.ts`(38)、`tests/index.spec.ts`(32)、`tests/store.spec.ts`(30)、`tests/crypto.spec.ts`(22)、`tests/dirs.spec.ts`(16)、`tests/install.spec.ts`(7),共 360 例。
276
366
 
277
367
  ---
278
368
 
@@ -290,10 +380,14 @@ MIT © GaoHu1997(奋斗的小高)
290
380
 
291
381
  ## English
292
382
 
293
- `dsh-webdav-sync` keeps a DSH profile the same across computers through a WebDAV folder. It syncs four parts — the model catalog (the `llm-pi-ai` row of `cordis.patch.yml`), the default model (`agent-default-model`), the remaining profile-patch rows, and the installed-plugin list from `package.json`.
383
+ `dsh-webdav-sync` keeps a DSH profile the same across computers through a WebDAV folder. It syncs five parts — the model catalog (the `llm-pi-ai` row of `cordis.patch.yml`), the default model (`agent-default-model`), the remaining profile-patch rows, the installed-plugin list from `package.json`, and the extra directories you pick in the card: their files travel inside the same sealed backup and a restore writes them back to the very same absolute path, add-only, with a switch of their own in the 同步内容 list.
294
384
 
295
- The whole backup is sealed on this computer with a passphrase (AES-256-GCM under a PBKDF2-SHA256 key, 600000 iterations) before it is uploaded, so the server only ever holds ciphertext at `<address>/dsh-webdav/dsh-profile.dshsync`. Each part is hashed on its own and merged three-way against the last synced baseline: one side changed wins, both changed keeps the newer and files the loser under `<profileDir>/.dsh-webdav-sync/sync/`, and every copy that gets replaced is sealed aside first. Removals propagate — except the plugin list, which is never removed.
385
+ The whole backup is sealed on this computer with a passphrase (AES-256-GCM under a PBKDF2-SHA256 key, 600000 iterations) before it is uploaded, so the server only ever holds ciphertext at `<address>/dsh-webdav/dsh-profile.prop`. Each part is hashed on its own and merged three-way against the last synced baseline: one side changed wins, both changed keeps the newer and files the loser under `<profileDir>/.dsh-webdav-sync/sync/`, and every copy that gets replaced is sealed aside first. Removals propagate — except the plugin list and the extra directories, which are never removed.
296
386
 
297
387
  The design is ported from [magpie](https://github.com/yetone/magpie)'s `internal/davsync` (Go → TypeScript, syncing DSH profile rows instead of magpie's own configuration). The WebDAV password and the passphrase live in `<profileDir>/.dsh-webdav-sync/sync.json` at mode 0600 and are never written into `cordis.patch.yml` — the patch is itself one of the things that travels — and never sent to the browser, which only receives `passwordSet` / `passphraseSet` booleans.
298
388
 
299
- A file on the server that is not a backup of ours is never silently overwritten; restoring from it or writing over it are explicit actions. License: MIT.
389
+ A file on the server that is not a backup of ours is never silently overwritten; restoring from it or writing over it are explicit actions.
390
+
391
+ The optional loopback forwarder is the final checkbox in the same form, with the same Save button — not another WebDAV configuration. It reuses the sync's `url`, `user`, `password`, and extra `headers`. Once enabled and saved, small text shows the local address, persisted port (default `3426`), and a generated local Basic username/password; address and credentials can each be copied. For an upstream address such as `https://dav.example.com/dav/`, use `http://127.0.0.1:3426/dav` and the local credentials in the other client. There is no path token. The port is never randomized automatically: use the explicit change-port button, save with the main form, then update the client's address. The generated account is kept across saves and restarts in `<profileDir>/.dsh-webdav-sync/sync.json`, never uploaded.
392
+
393
+ The relay binds only `127.0.0.1`. Missing or incorrect local Basic authentication gets `401` and is not forwarded. Client `Authorization` is consumed locally and never sent upstream: upstream authentication uses the sync's Basic credentials unless an explicit extra `Authorization` header overrides them. Other client protocol headers take precedence over configured headers; methods, request bodies, conditional headers and status codes stream through without interpreting WebDAV, and upstream `Location` headers are mapped back to the relay. DSH must remain running. With upstream `rememberPassword: false`, the upstream password must be entered again after a Host restart even though the local account remains saved. Old `<profileDir>/.dsh-webdav-relay/relay.json` is not read, migrated or deleted automatically; reconfigure clients with the new displayed address and local account. Saving or toggling the relay does not sync, rewrite or convert upstream data; authenticated client requests still perform their requested WebDAV operations. [0041](docs/decisions/0041-relay-reuses-sync-config.md) supersedes the separate configuration, token, routes and Save design in historical [0040](docs/decisions/0040-local-relay-for-other-clients.md). License: MIT.