@qic-9821/dsh-plugin-sync 0.0.0-stage → 0.1.1

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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 QIC
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,124 @@
1
- # Temporary Holding Version
1
+ # @qic-9821/dsh-plugin-sync
2
+
3
+ 让两台机器上的 **DeepSeek Harness 插件状态** 保持一致:装了什么、开着还是关着、配置是什么、本地插件目录要不要一起搬过去。
4
+
5
+ npm:[`@qic-9821/dsh-plugin-sync`](https://www.npmjs.com/package/@qic-9821/dsh-plugin-sync) · 仓库:<https://github.com/QIC-9821/dsh-plugin-sync> · MIT
6
+
7
+ ![独立面板](docs/panel.png)
8
+
9
+ 配套:`设置 → 插件同步`(系统设置菜单栏里的独立一页)、`设置 → 插件 → 同步`(Plugins 段内的一页)、独立面板 `http://127.0.0.1:<端口>/plugin-sync`、两个 Agent 工具 `plugin_sync` / `plugin_sync_config`。
10
+
11
+ ## 安装
12
+
13
+ 两台机器各装一次(要同步的两端都必须装,否则对端接口不存在):
14
+
15
+ ```bash
16
+ # 已经解出源码时:把目录交给宿主安装(推荐,会自动写 profile 依赖与 lock)
17
+ dsh plugin --profile <profile> add /path/to/dsh-plugin-sync
18
+
19
+ # 或者从 npm 装
20
+ dsh plugin --profile <profile> add @qic-9821/dsh-plugin-sync
21
+ ```
22
+
23
+ 在 App 里也可以走「设置 → 插件 → 安装」给绝对路径。装完刷新一次页面就会多出 `设置 → 插件同步`;宿主进程不用重启(路由和工具会当场挂上)。
24
+
25
+ 安装后两台机器都要做一次:
26
+
27
+ 1. 打开 `设置 → 插件同步`,填对端(地址或 SSH 主机/私钥)和两端相同的令牌。
28
+ 2. 改 `direction`(哪边推、哪边收)和要开的权限。
29
+ 3. 用「只看计划」确认清单再「立即同步」。
30
+
31
+
32
+ ## 能同步什么
33
+
34
+ | 能力 | 开关 | 说明 |
35
+ | --- | --- | --- |
36
+ | 插件清单与启停 | `capPlugins` | 每台机器装了哪些 bundle、每个 loader 条目开着还是关着 |
37
+ | 插件配置 | `capConfig` | `cordis.patch.yml` 里每个 bundle 的 `config:` 段 |
38
+ | 安装 / 升级 / 卸载 | `capInstall` | 通过宿主 `pluginManager` 跑 pnpm、改 `package.json` 与 `node_modules` |
39
+ | 复制插件二进制 | `capFiles` | 把本机 `file:` 插件的源码目录 tar 过去再装(跨机不需要 git/npm 可达) |
40
+ | 允许对端改动本机 | `capRemoteApply` | 关掉后对端连读都读不到 |
41
+ | 同步密钥字段 | `capSecrets` | 默认关;`appSecret` / `token` / `*key*` 一律写成 `[redacted]` |
42
+
43
+ 安装、升级、卸载、启停全部走宿主的 `pluginManager` 服务(`installBundle` / `updateBundle` / `removeBundle` / `setBundleEnabled` / `setPluginEnabled`),不自己拼 pnpm 命令行、不手写 profile 的 `package.json`,失败由宿主回滚它自己的改动。
44
+
45
+ ## 怎么连
46
+
47
+ 两台机器都装本插件,然后在各自设置页填对端:
48
+
49
+ - **HTTP**:直接填对端 Web 地址(要求对端端口可达)。
50
+ - **SSH 隧道**:填 `主机 / 用户 / 端口 / 私钥 / 对端 Web 端口`。本插件按需 `ssh -N -L` 开一条本地转发,再走同一套 HTTP 协议——跨公网、对端只监听 127.0.0.1 的情况用这个(云端的 qqbot profile 就是)。
51
+
52
+ 两端都要填 **同一个令牌**。令牌只用于 `/plugin-sync/peer/*` 这一组接口;同一台机器上的设置页用同源请求,不需要令牌。
53
+
54
+ ## 同步时机
55
+
56
+ - `autoStart`:DSH 启动后约 25 秒跑一轮(等宿主把插件挂完)。
57
+ - `autoRestart`:进程重启后再补一轮(约 2 分钟),处理对端还在启动的情况;服务器重启后的同步就是这条。
58
+ - `intervalMinutes`:固定间隔(0 = 关)。
59
+ - 手动:面板/设置页按钮,或工具 `plugin_sync` `action="run"`。
60
+
61
+ ## 冲突策略
62
+
63
+ - `local`:以发起方为准(默认)
64
+ - `peer`:以接收方为准(不动对端的启停状态)
65
+ - `newest`:版本比较,只升不降
66
+
67
+ `@deepseek-ai/dsh-base`、`@deepseek-ai/dsh-web-app` 和本插件自己永远不参与启停/卸载;`include` / `exclude` 支持精确名字和 `前缀*`。
68
+
69
+ **宿主自带的包默认不同步。** `@deepseek-ai/*` 下的包是 DSH 自己的组件,两台机器的版本和平台本来就可能不一样(Windows 桌面端 vs Linux 服务器端),推过去只会把对端弄坏。要同步其中某个,就在 `include` 里点名(例如 `include: ["@deepseek-ai/dsh-session-log-*"]`)。
70
+
71
+ 同理,某台机器上「故意关掉」的东西要自己加进 `exclude`:比如桌面端的 `@tencent-connect/dsh-qqbot` 平时是停用的(云端在跑同一个 bot,双开会每条消息回两遍),不排除的话一次 `push` 就会把云端的 QQ 机器人关掉。
72
+
73
+ ## 安全边界
74
+
75
+ 1. 对端接口一律要令牌;没配令牌 = 对端接口全关。
76
+ 2. 同步到本机的操作还要再过一遍 `cap*` 权限(对端有权限、本机没开,照样落地不了)。
77
+ 3. 写 `cordis.patch.yml` 前自动备份到 `~/.dsh/plugin-sync/backups/`(带时间戳)。
78
+ 4. 本机永远只改自己的 profile,不写共享目录;状态文件 `~/.dsh/plugin-sync/state.json` 原子写。
79
+
80
+ ## 状态文件
81
+
82
+ ```
83
+ ~/.dsh/plugin-sync/state.json 设置 + 上次运行结果
84
+ ~/.dsh/plugin-sync/backups/ cordis.patch.yml 备份
85
+ ~/.dsh/plugin-sync/incoming/ 从对端收到的插件源码
86
+ ```
87
+
88
+ ## 工具
89
+
90
+ - `plugin_sync` — `status` / `plan` / `run`(可带 `direction`)
91
+ - `plugin_sync_config` — `get` / `set`(`patch` 合并设置;不返回令牌)
92
+
93
+ ## 排查脚本
94
+
95
+ 宿主多半在别的进程里,改完代码不想重启时用这两个脚本验证:
96
+
97
+ ```bash
98
+ node scripts/verify-plan.mjs # 抓两端快照,用当前 merge.js 打印计划
99
+ node scripts/probe-apply.mjs # 过隧道打一次 /peer/apply(默认一个不存在的行,验证鉴权与错误回报)
100
+ ```
101
+
102
+ ## 已知边界
103
+
104
+ - 只同步插件层:workspace、会话、密钥凭据(`.credentials.yaml`)不在范围内。
105
+ - `config:` 段的写出是 flow mapping(`config: {"a":1}`),YAML 合法但和原有块状写法不同;无法解析的写法不会被动。
106
+ - 安装 `file:` 插件时,对端会收到一份源码副本(排除 `node_modules` / `.git`),路径是本机的 `incoming/` 目录。
107
+
108
+ ## 开发
109
+
110
+ ```bash
111
+ node --test test/*.test.js # 25 个用例,纯 Node,无第三方依赖
112
+ node scripts/verify-plan.mjs # 对真实的两台机器跑一次计划,不落地
113
+ ```
114
+
115
+ 设置页在 `client.js` 里注册 `settings.section`(id `plugin-sync`,order 16)与 `settings.plugins.tab`;宿主侧路由在 `lib/http.js`,写操作在 `lib/apply.js`,计划器是纯函数 `lib/merge.js`(好单测)。
116
+
117
+ 设置页在云端实例里的样子:
118
+
119
+ ![设置页](docs/settings-sync.png)
120
+
121
+ ## 许可
122
+
123
+ MIT © 2026 QIC-9821
2
124
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
package/client.js ADDED
@@ -0,0 +1,354 @@
1
+ window.__ModuleLoader__.load({
2
+ id: '@qic-9821/dsh-plugin-sync',
3
+ factory(require) {
4
+ const React = require('react')
5
+ const h = React.createElement
6
+ const { useCallback, useEffect, useMemo, useState } = React
7
+
8
+ const API = '/plugin-sync/api/'
9
+
10
+ const CARD = {
11
+ border: '1px solid var(--dsh-border, rgba(127,127,127,.28))',
12
+ borderRadius: 12,
13
+ padding: '14px 16px',
14
+ marginBottom: 14,
15
+ background: 'var(--dsh-surface, transparent)',
16
+ }
17
+ const LABEL = { fontSize: 12.5, opacity: 0.7, marginBottom: 4 }
18
+ const INPUT = {
19
+ width: '100%',
20
+ boxSizing: 'border-box',
21
+ padding: '6px 9px',
22
+ borderRadius: 8,
23
+ border: '1px solid var(--dsh-border, rgba(127,127,127,.35))',
24
+ background: 'transparent',
25
+ color: 'inherit',
26
+ font: 'inherit',
27
+ }
28
+ const BUTTON = {
29
+ padding: '7px 14px',
30
+ borderRadius: 8,
31
+ border: '1px solid var(--dsh-border, rgba(127,127,127,.35))',
32
+ background: 'transparent',
33
+ color: 'inherit',
34
+ cursor: 'pointer',
35
+ font: 'inherit',
36
+ }
37
+ const BUTTON_PRIMARY = { ...BUTTON, background: 'var(--dsh-accent, #3b82f6)', borderColor: 'var(--dsh-accent, #3b82f6)', color: '#fff' }
38
+ const ROW = { display: 'flex', justifyContent: 'space-between', gap: 12, padding: '4px 0', fontSize: 13 }
39
+ const MUTED = { fontSize: 12.5, opacity: 0.65 }
40
+
41
+ async function api(path, options) {
42
+ const response = await fetch(API + path, options)
43
+ const text = await response.text()
44
+ try {
45
+ return JSON.parse(text)
46
+ } catch {
47
+ return { error: text || `HTTP ${response.status}` }
48
+ }
49
+ }
50
+
51
+ function post(path, body) {
52
+ return api(path, {
53
+ method: 'POST',
54
+ headers: { 'content-type': 'application/json' },
55
+ body: JSON.stringify(body ?? {}),
56
+ })
57
+ }
58
+
59
+ function Card(props) {
60
+ return h('div', { style: CARD }, [
61
+ h('div', { key: 'title', style: { fontSize: 13.5, fontWeight: 600, marginBottom: 10 } }, props.title),
62
+ props.children,
63
+ ])
64
+ }
65
+
66
+ function Field(props) {
67
+ return h('div', { style: { marginTop: 10 } }, [
68
+ props.label ? h('div', { key: 'label', style: LABEL }, props.label) : null,
69
+ props.children,
70
+ ])
71
+ }
72
+
73
+ function Toggle(props) {
74
+ return h(
75
+ 'label',
76
+ {
77
+ key: props.id,
78
+ style: {
79
+ display: 'flex',
80
+ gap: 10,
81
+ alignItems: 'flex-start',
82
+ padding: '6px 0',
83
+ borderBottom: '1px dashed var(--dsh-border, rgba(127,127,127,.25))',
84
+ cursor: 'pointer',
85
+ },
86
+ },
87
+ [
88
+ h('input', {
89
+ key: 'box',
90
+ type: 'checkbox',
91
+ checked: Boolean(props.checked),
92
+ onChange: (event) => props.onChange(event.target.checked),
93
+ style: { marginTop: 2 },
94
+ }),
95
+ h('span', { key: 'text' }, [
96
+ h('b', { key: 'name' }, props.label),
97
+ props.desc ? h('div', { key: 'desc', style: MUTED }, props.desc) : null,
98
+ ]),
99
+ ],
100
+ )
101
+ }
102
+
103
+ function SyncSettings(props) {
104
+ const [state, setState] = useState(null)
105
+ const [error, setError] = useState(null)
106
+ const [busy, setBusy] = useState(null)
107
+ const [out, setOut] = useState('')
108
+ const [draft, setDraft] = useState({})
109
+
110
+ const load = useCallback(async () => {
111
+ try {
112
+ const next = await api('state')
113
+ if (next && next.error) setError(String(next.error))
114
+ else {
115
+ setState(next)
116
+ setError(null)
117
+ }
118
+ } catch (cause) {
119
+ setError(cause?.message || String(cause))
120
+ }
121
+ }, [])
122
+
123
+ useEffect(() => {
124
+ void load()
125
+ }, [load])
126
+
127
+ const config = state?.config || {}
128
+ const value = useCallback((key) => (draft[key] !== undefined ? draft[key] : config[key]), [draft, config])
129
+ const setValue = useCallback((key, next) => setDraft((current) => ({ ...current, [key]: next })), [])
130
+
131
+ const save = useCallback(async () => {
132
+ if (Object.keys(draft).length === 0) return
133
+ setBusy('save')
134
+ try {
135
+ const next = await post('config', { ...draft, capabilities: collectCaps(draft) })
136
+ if (next && next.status) {
137
+ setState(next)
138
+ setDraft({})
139
+ }
140
+ setError(next?.error ? String(next.error) : null)
141
+ } catch (cause) {
142
+ setError(cause?.message || String(cause))
143
+ } finally {
144
+ setBusy(null)
145
+ }
146
+ }, [draft])
147
+
148
+ // capability toggles land in draft under their own keys
149
+ const collectCaps = (patch) => {
150
+ const caps = {}
151
+ for (const cap of state?.capabilityCatalog || []) {
152
+ if (patch[cap.key] !== undefined) caps[cap.key] = patch[cap.key]
153
+ }
154
+ return caps
155
+ }
156
+
157
+ const run = useCallback(
158
+ async (action) => {
159
+ setBusy(action)
160
+ setOut(action === 'sync' ? '同步中…' : '计算中…')
161
+ try {
162
+ const result = action === 'sync' ? await post('sync', {}) : await api('plan')
163
+ setOut(JSON.stringify(result, null, 2).slice(0, 20000))
164
+ if (result?.status) setState((current) => (current ? { ...current, status: result.status } : current))
165
+ } catch (cause) {
166
+ setOut(String(cause?.message || cause))
167
+ } finally {
168
+ setBusy(null)
169
+ }
170
+ },
171
+ [],
172
+ )
173
+
174
+ const status = state?.status
175
+ const statusRows = useMemo(() => {
176
+ if (!status) return []
177
+ return [
178
+ [
179
+ '本机',
180
+ status.local
181
+ ? `${status.local.node || '?'} · ${status.local.profile || '?'} · ${status.local.bundles} 包 / ${status.local.plugins} 条目`
182
+ : status.localError || '读取失败',
183
+ ],
184
+ ['profile 目录', status.profileDir || '?'],
185
+ ['版本', status.package ? `${status.package.name} ${status.package.version || ''}`.trim() : '?'],
186
+ ['对端', status.peer?.describe || '?'],
187
+ [
188
+ '上次同步',
189
+ status.lastRun
190
+ ? `${formatTime(status.lastRun.at)} · ${status.lastRun.ok ? '成功' : `失败:${status.lastRun.error || ''}`}`
191
+ : '还没跑过',
192
+ ],
193
+ ['当前', status.running ? '同步中…' : '空闲'],
194
+ ]
195
+ }, [status])
196
+
197
+ if (!state) {
198
+ return h('div', { style: { padding: 16, fontSize: 13 } }, error ? `读取失败:${error}` : '读取中…')
199
+ }
200
+
201
+ return h(
202
+ 'div',
203
+ { style: { padding: props.compact ? 0 : '4px 2px 24px' } },
204
+ [
205
+ error
206
+ ? h('div', { key: 'err', style: { color: 'var(--dsh-danger, #d9534f)', marginBottom: 10, fontSize: 13 } }, error)
207
+ : null,
208
+
209
+ h(Card, { key: 'status', title: '状态' }, [
210
+ ...statusRows.map(([key, text], index) =>
211
+ h('div', { key: `row-${index}`, style: ROW }, [
212
+ h('span', { key: 'k', style: { opacity: 0.65 } }, key),
213
+ h('span', { key: 'v', style: { textAlign: 'right', wordBreak: 'break-all' } }, String(text)),
214
+ ]),
215
+ ),
216
+ h('div', { key: 'actions', style: { display: 'flex', gap: 8, marginTop: 12, flexWrap: 'wrap' } }, [
217
+ h(
218
+ 'button',
219
+ { key: 'sync', style: BUTTON_PRIMARY, disabled: Boolean(busy), onClick: () => run('sync') },
220
+ busy === 'sync' ? '同步中…' : '立即同步',
221
+ ),
222
+ h('button', { key: 'plan', style: BUTTON, disabled: Boolean(busy), onClick: () => run('plan') }, '只看计划'),
223
+ h('button', { key: 'reload', style: BUTTON, disabled: Boolean(busy), onClick: () => void load() }, '刷新'),
224
+ ]),
225
+ ]),
226
+
227
+ h(Card, { key: 'peer', title: '对端' }, [
228
+ h(Field, { key: 'mode', label: '连接方式' }, select(value('peerMode'), [
229
+ ['off', '未配置'],
230
+ ['http', 'HTTP(对端 Web 端口)'],
231
+ ['ssh', 'SSH 隧道(推荐,跨公网)'],
232
+ ], (next) => setValue('peerMode', next))),
233
+ h(Field, { key: 'url', label: 'HTTP 地址' }, input(value('peerUrl'), 'http://127.0.0.1:18777', (next) => setValue('peerUrl', next))),
234
+ h('div', { key: 'ssh', style: { display: 'grid', gridTemplateColumns: '2fr 1fr 90px', gap: 8 } }, [
235
+ h(Field, { key: 'host', label: 'SSH 主机' }, input(value('peerHost'), '114.55.146.70', (next) => setValue('peerHost', next))),
236
+ h(Field, { key: 'user', label: '用户' }, input(value('peerUser'), 'dsh', (next) => setValue('peerUser', next))),
237
+ h(Field, { key: 'port', label: '端口' }, input(value('peerPort'), '22', (next) => setValue('peerPort', Number(next) || 22))),
238
+ ]),
239
+ h('div', { key: 'tunnel', style: { display: 'grid', gridTemplateColumns: '110px 1fr', gap: 8 } }, [
240
+ h(Field, { key: 'httpPort', label: '对端 Web 端口' }, input(value('peerHttpPort'), '18777', (next) => setValue('peerHttpPort', Number(next) || 18777))),
241
+ h(Field, { key: 'key', label: 'SSH 私钥' }, input(value('peerKey'), 'C:\\Users\\QIC\\.ssh\\dsh_cloud_ed25519', (next) => setValue('peerKey', next))),
242
+ ]),
243
+ h(Field, { key: 'name', label: '对端名称(显示用)' }, input(value('peerName'), 'cloud', (next) => setValue('peerName', next))),
244
+ h(
245
+ Field,
246
+ { key: 'token', label: config.peerTokenSet ? '对端令牌(留空表示不改)' : '对端令牌(两端要一致)' },
247
+ input(draft.peerToken ?? '', '两端填同一串随机字符', (next) => setValue('peerToken', next)),
248
+ ),
249
+ h('div', { key: 'hint', style: { ...MUTED, marginTop: 8 } }, '两台机器都要装本插件;令牌是「对端」接口的凭据,不填则对端接口一律拒绝。'),
250
+ ]),
251
+
252
+ h(Card, { key: 'direction', title: '同步方式与时機' }, [
253
+ h(Field, { key: 'dir', label: '方向' }, select(value('direction'), (state.directionCatalog || []).map((item) => [item.key, item.label]), (next) => setValue('direction', next))),
254
+ h(Field, { key: 'conflict', label: '冲突策略' }, select(value('conflict'), [
255
+ ['local', '以发起方为准'],
256
+ ['peer', '以接收方为准'],
257
+ ['newest', '版本新者优先'],
258
+ ], (next) => setValue('conflict', next))),
259
+ h('div', { key: 'sched', style: { marginTop: 10 } }, [
260
+ h(Toggle, {
261
+ key: 'enable',
262
+ label: '总开关',
263
+ desc: '关掉后「启动时同步」和手动同步都不动作',
264
+ checked: Boolean(value('enabled')),
265
+ onChange: (next) => setValue('enabled', next),
266
+ }),
267
+ h(Toggle, {
268
+ key: 'startup',
269
+ label: '软件启动时同步',
270
+ desc: 'DSH 启动后约 25 秒跑一轮(等宿主把插件都挂上)',
271
+ checked: Boolean(value('autoStart')),
272
+ onChange: (next) => setValue('autoStart', next),
273
+ }),
274
+ h(Toggle, {
275
+ key: 'restart',
276
+ label: '服务器重启后同步',
277
+ desc: '进程重启之后再补一轮,处理对端还在启动的情况',
278
+ checked: Boolean(value('autoRestart')),
279
+ onChange: (next) => setValue('autoRestart', next),
280
+ }),
281
+ h(Field, { key: 'interval', label: '每隔多少分钟同步(0 = 不按间隔)' },
282
+ input(value('intervalMinutes'), '0', (next) => setValue('intervalMinutes', Number(next) || 0))),
283
+ h(Field, { key: 'delay', label: '启动后延迟秒数' },
284
+ input(value('startupDelaySec'), '25', (next) => setValue('startupDelaySec', Number(next) || 0))),
285
+ ]),
286
+ ]),
287
+
288
+ h(Card, { key: 'caps', title: '权限(每一项可以单独关)' },
289
+ (state.capabilityCatalog || []).map((cap) =>
290
+ h(Toggle, {
291
+ key: cap.key,
292
+ label: cap.label,
293
+ desc: cap.desc,
294
+ checked: Boolean(value(cap.key)),
295
+ onChange: (next) => setValue(cap.key, next),
296
+ }),
297
+ ),
298
+ ),
299
+
300
+ h('div', { key: 'save', style: { display: 'flex', gap: 8, alignItems: 'center' } }, [
301
+ h('button', { key: 'save', style: BUTTON_PRIMARY, disabled: Boolean(busy) || Object.keys(draft).length === 0, onClick: () => void save() }, '保存设置'),
302
+ h('span', { key: 'hint', style: MUTED }, Object.keys(draft).length === 0 ? '没有未保存的改动' : `${Object.keys(draft).length} 项待保存`),
303
+ ]),
304
+
305
+ out ? h('pre', { key: 'out', style: { marginTop: 12, whiteSpace: 'pre-wrap', fontSize: 11.5, maxHeight: 260, overflow: 'auto', background: 'rgba(127,127,127,.1)', padding: 10, borderRadius: 8 } }, out) : null,
306
+ ].filter(Boolean),
307
+ )
308
+ }
309
+
310
+ function input(current, placeholder, onChange) {
311
+ return h('input', {
312
+ style: INPUT,
313
+ value: current === undefined || current === null ? '' : String(current),
314
+ placeholder,
315
+ onChange: (event) => onChange(event.target.value),
316
+ })
317
+ }
318
+
319
+ function select(current, options, onChange) {
320
+ return h(
321
+ 'select',
322
+ { style: INPUT, value: current ?? '', onChange: (event) => onChange(event.target.value) },
323
+ options.map(([key, label]) => h('option', { key, value: key }, label)),
324
+ )
325
+ }
326
+
327
+ function formatTime(value) {
328
+ if (!value) return '?'
329
+ try {
330
+ return new Date(value).toLocaleString()
331
+ } catch {
332
+ return String(value)
333
+ }
334
+ }
335
+
336
+ return {
337
+ inject: ['slots'],
338
+ apply(ctx) {
339
+ ctx.slots.inject('settings.section', () =>
340
+ ctx.slots.register(
341
+ { name: 'settings.section', id: 'plugin-sync', order: 16, label: () => '插件同步' },
342
+ SyncSettings,
343
+ ),
344
+ )
345
+ ctx.slots.inject('settings.plugins.tab', () =>
346
+ ctx.slots.register(
347
+ { name: 'settings.plugins.tab', id: 'plugin-sync', order: 60, label: () => '同步' },
348
+ (props) => h(SyncSettings, { ...props, compact: true }),
349
+ ),
350
+ )
351
+ },
352
+ }
353
+ },
354
+ })
@@ -0,0 +1,6 @@
1
+ # dsh-plugin-sync — the row itself. Its settings (peer, capabilities, schedule)
2
+ # are edited from 设置 → 插件同步 and stored in ~/.dsh/plugin-sync/state.json,
3
+ # so this patch stays stable across upgrades.
4
+ - insert:
5
+ - id: plugin-sync
6
+ name: '@qic-9821/dsh-plugin-sync'