@code_tin/dsh-sync 0.1.0 → 0.2.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/README.md +10 -6
- package/client.js +125 -16
- package/index.js +35 -5
- package/lib/auto.js +115 -0
- package/lib/sync.js +66 -2
- package/locale/en.json +1 -1
- package/locale/zh.json +1 -1
- package/package.json +2 -2
- package/icon.svg +0 -12
package/README.md
CHANGED
|
@@ -2,8 +2,8 @@
|
|
|
2
2
|
|
|
3
3
|
通过 Git 在多个设备之间同步 DeepSeek Harness(DSH)的会话。
|
|
4
4
|
|
|
5
|
-
- npm:`@code_tin/dsh-sync`;安装:`plugin_manager install_bundle { "target": "@code_tin/dsh-sync@0.
|
|
6
|
-
- 设置页:**设置 → 会话 Git 同步**,填写 Git
|
|
5
|
+
- npm:`@code_tin/dsh-sync`;安装:`plugin_manager install_bundle { "target": "@code_tin/dsh-sync@0.2.0" }`
|
|
6
|
+
- 设置页:**设置 → 会话 Git 同步**,填写 Git 仓库地址与分支,查看本地/镜像状态,直接拉取或推送;可勾选**自动同步**并设置间隔(分钟)。
|
|
7
7
|
- 命令:`/sync`、`/sync pull`、`/sync push`、`/sync test`。
|
|
8
8
|
- 数据:本机会话仍在 `~/.dsh/sessions`;插件在 `~/.dsh/dsh-sync/repo` 维护一个专用 Git 镜像仓库,不往会话目录里写 `.git`。
|
|
9
9
|
|
|
@@ -16,6 +16,7 @@
|
|
|
16
16
|
|
|
17
17
|
- **push**:先 flush 活跃会话,把本地 `session.*.jsonl.zstd` 复制进镜像,提交并推送。推送被拒绝(其他设备刚推过)时会自动 fetch 重试一次;同一会话冲突时本地文件优先。
|
|
18
18
|
- **pull**:把镜像快进到远程分支,只复制本地**不存在**的会话文件。本地已有的会话一律保留,并计入“跳过”数量。
|
|
19
|
+
- **自动同步**:勾选后,宿主进程每隔设定的分钟数执行一次 push(首次在保存后一个间隔触发),不需要手动点按钮。开关、间隔改动保存后立即重新计时;关闭后不再调度。
|
|
19
20
|
- 只同步会话日志文件;`session.lock`、`.DS_Store` 等不会进入仓库。
|
|
20
21
|
|
|
21
22
|
## 使用
|
|
@@ -23,8 +24,9 @@
|
|
|
23
24
|
1. 在设置页填写仓库地址,例如 `https://github.com/me/dsh-sessions.git` 或 `git@github.com:me/dsh-sessions.git`,保存。
|
|
24
25
|
2. 在设备 A 执行 `/sync push`(或点“推送到远程”)。
|
|
25
26
|
3. 在设备 B 保存同一地址后执行 `/sync pull`,刷新侧边栏(或重新打开窗口)即可看到新会话。
|
|
27
|
+
4. 需要持续同步时勾选“自动同步”,填入间隔分钟数(1–1440,默认 10),保存。状态区会显示下次自动推送时间。
|
|
26
28
|
|
|
27
|
-
`/sync`
|
|
29
|
+
`/sync` 显示状态(含自动同步开关、下次时间和上次自动推送结果);`/sync test` 测试连接。Git 认证沿用本机的 SSH agent / credential helper,插件不会保存凭据。若 HTTPS 需要账号密码,请先配置好 credential helper,否则命令会快速失败而不是弹出交互提示。
|
|
28
30
|
|
|
29
31
|
## 安装(开发目录)
|
|
30
32
|
|
|
@@ -41,6 +43,8 @@ plugin_manager install_bundle { "target": "/path/to/dsh-sync" }
|
|
|
41
43
|
- 同一个会话如果在两台设备上继续写入,pull 不会合并日志:保留本地版本并跳过该文件。需要时请在镜像仓库里手动取出远端版本。
|
|
42
44
|
- 首次 pull 之前远程必须至少有一次 push(远程分支不存在时 pull 会明确报错)。
|
|
43
45
|
- 镜像仓库会保留历史上推送过的会话;本机删除会话不会删除镜像中的副本。
|
|
46
|
+
- 自动同步只做 push,不自动 pull;需要把其他设备的会话取回时仍要手动 pull。
|
|
47
|
+
- 自动同步依赖 DSH 宿主进程持续运行;宿主退出期间不会推送,重启后从新间隔重新计时;系统休眠唤醒后会在计时到期时补触发一次。失败只记录到状态与日志,不会重试。
|
|
44
48
|
- Git 地址保存在 `~/.dsh/dsh-sync/settings.json`(权限 0600)。不要把带 token 的 URL 提交到任何地方。
|
|
45
49
|
|
|
46
50
|
## 分发给其他设备
|
|
@@ -51,13 +55,13 @@ plugin_manager install_bundle { "target": "/path/to/dsh-sync" }
|
|
|
51
55
|
|
|
52
56
|
```bash
|
|
53
57
|
npm pack
|
|
54
|
-
# 生成 code_tin-dsh-sync-0.
|
|
58
|
+
# 生成 code_tin-dsh-sync-0.2.0.tgz
|
|
55
59
|
```
|
|
56
60
|
|
|
57
61
|
把 tgz 拷到目标机器,在 DSH 里安装:
|
|
58
62
|
|
|
59
63
|
```
|
|
60
|
-
plugin_manager install_bundle { "target": "/绝对路径/code_tin-dsh-sync-0.
|
|
64
|
+
plugin_manager install_bundle { "target": "/绝对路径/code_tin-dsh-sync-0.2.0.tgz" }
|
|
61
65
|
```
|
|
62
66
|
|
|
63
67
|
Plugin Manager 支持目录、tarball、git 地址和 npm 包名这几类安装 spec。
|
|
@@ -74,7 +78,7 @@ npm publish --access public
|
|
|
74
78
|
发布后其他机器直接安装:
|
|
75
79
|
|
|
76
80
|
```
|
|
77
|
-
plugin_manager install_bundle { "target": "@code_tin/dsh-sync@0.
|
|
81
|
+
plugin_manager install_bundle { "target": "@code_tin/dsh-sync@0.2.0" }
|
|
78
82
|
```
|
|
79
83
|
|
|
80
84
|
注意:发布到 npm 只解决“可安装”,不会自动让插件出现在 DSH 插件市场的 Discover 列表;市场收录通常还需要公开仓库和索引源。
|
package/client.js
CHANGED
|
@@ -2,9 +2,10 @@
|
|
|
2
2
|
* Browser half of `dsh-sync`.
|
|
3
3
|
*
|
|
4
4
|
* Adds one page under Settings → 会话 Git 同步 (Settings → Session Git Sync).
|
|
5
|
-
* The page writes the repository URL
|
|
6
|
-
* the host Remote service registered by `index.js`.
|
|
7
|
-
* work stay on the host; this module only renders
|
|
5
|
+
* The page writes the repository URL, branch, and auto-sync schedule, and
|
|
6
|
+
* triggers pull/push through the host Remote service registered by `index.js`.
|
|
7
|
+
* All session bytes and git work stay on the host; this module only renders
|
|
8
|
+
* state and calls endpoints.
|
|
8
9
|
*/
|
|
9
10
|
window.__ModuleLoader__.load({
|
|
10
11
|
id: '@code_tin/dsh-sync',
|
|
@@ -17,18 +18,23 @@ window.__ModuleLoader__.load({
|
|
|
17
18
|
const ZH = {
|
|
18
19
|
nav: '会话 Git 同步',
|
|
19
20
|
title: '会话 Git 同步',
|
|
20
|
-
subtitle: '把本机的 DSH 会话镜像到一个 Git
|
|
21
|
+
subtitle: '把本机的 DSH 会话镜像到一个 Git 仓库,在多个设备之间共享。拉取只新增本地不存在的会话,不会覆盖本地已有会话;也可以开启定时自动推送。',
|
|
21
22
|
remoteLabel: 'Git 仓库地址',
|
|
22
23
|
remotePlaceholder: 'https://example.com/user/dsh-sessions.git',
|
|
23
24
|
branchLabel: '分支',
|
|
24
25
|
branchPlaceholder: 'main',
|
|
26
|
+
autoLabel: '自动同步',
|
|
27
|
+
autoHint: '开启后每隔下面的时间自动把本地会话推送到 Git(等同 /sync push),DSH 宿主进程需要保持运行。',
|
|
28
|
+
intervalLabel: '自动同步间隔(分钟)',
|
|
29
|
+
intervalUnit: '分钟',
|
|
30
|
+
intervalInvalid: '自动同步间隔需要是 1 到 1440 之间的整数分钟。',
|
|
25
31
|
save: '保存',
|
|
26
32
|
test: '测试连接',
|
|
27
33
|
pull: '从远程拉取',
|
|
28
34
|
push: '推送到远程',
|
|
29
35
|
refresh: '刷新状态',
|
|
30
36
|
unsaved: '有未保存的修改;拉取或推送前会自动保存。',
|
|
31
|
-
saved: '
|
|
37
|
+
saved: '已保存设置。',
|
|
32
38
|
statusTitle: '同步状态',
|
|
33
39
|
commandsTitle: '命令',
|
|
34
40
|
cmdStatus: '查看同步状态',
|
|
@@ -39,8 +45,14 @@ window.__ModuleLoader__.load({
|
|
|
39
45
|
branchStatus: '分支',
|
|
40
46
|
localStatus: '本地会话 / 文件',
|
|
41
47
|
mirrorStatus: '镜像仓库',
|
|
48
|
+
autoStatus: '自动同步',
|
|
49
|
+
autoOff: '已关闭',
|
|
50
|
+
autoOn: '每 {minutes} 分钟',
|
|
51
|
+
autoNext: '每 {minutes} 分钟,下次 {time}',
|
|
52
|
+
autoNoRemote: '已开启,尚未配置远程仓库',
|
|
42
53
|
lastPush: '上次推送',
|
|
43
54
|
lastPull: '上次拉取',
|
|
55
|
+
lastAutoPush: '上次自动推送',
|
|
44
56
|
dataDir: '本地会话目录',
|
|
45
57
|
repoDir: '镜像仓库目录',
|
|
46
58
|
notConfigured: '未配置',
|
|
@@ -61,18 +73,23 @@ window.__ModuleLoader__.load({
|
|
|
61
73
|
const EN = {
|
|
62
74
|
nav: 'Session Git Sync',
|
|
63
75
|
title: 'Session Git Sync',
|
|
64
|
-
subtitle: 'Mirror this device’s DSH sessions into a git repository and share them across devices. Pull only adds sessions missing locally; it never overwrites an existing local session.',
|
|
76
|
+
subtitle: 'Mirror this device’s DSH sessions into a git repository and share them across devices. Pull only adds sessions missing locally; it never overwrites an existing local session. Optionally push on a timer.',
|
|
65
77
|
remoteLabel: 'Git repository URL',
|
|
66
78
|
remotePlaceholder: 'https://example.com/user/dsh-sessions.git',
|
|
67
79
|
branchLabel: 'Branch',
|
|
68
80
|
branchPlaceholder: 'main',
|
|
81
|
+
autoLabel: 'Auto sync',
|
|
82
|
+
autoHint: 'When enabled, local sessions are pushed to git every interval (same as /sync push). The DSH host process must stay running.',
|
|
83
|
+
intervalLabel: 'Auto sync interval (minutes)',
|
|
84
|
+
intervalUnit: 'min',
|
|
85
|
+
intervalInvalid: 'The auto sync interval must be a whole number between 1 and 1440 minutes.',
|
|
69
86
|
save: 'Save',
|
|
70
87
|
test: 'Test connection',
|
|
71
88
|
pull: 'Pull from remote',
|
|
72
89
|
push: 'Push to remote',
|
|
73
90
|
refresh: 'Refresh status',
|
|
74
91
|
unsaved: 'Unsaved changes; pull or push saves them first.',
|
|
75
|
-
saved: '
|
|
92
|
+
saved: 'Settings saved.',
|
|
76
93
|
statusTitle: 'Sync status',
|
|
77
94
|
commandsTitle: 'Commands',
|
|
78
95
|
cmdStatus: 'show sync status',
|
|
@@ -83,8 +100,14 @@ window.__ModuleLoader__.load({
|
|
|
83
100
|
branchStatus: 'Branch',
|
|
84
101
|
localStatus: 'Local sessions / files',
|
|
85
102
|
mirrorStatus: 'Mirror',
|
|
103
|
+
autoStatus: 'Auto sync',
|
|
104
|
+
autoOff: 'Off',
|
|
105
|
+
autoOn: 'Every {minutes} min',
|
|
106
|
+
autoNext: 'Every {minutes} min, next {time}',
|
|
107
|
+
autoNoRemote: 'On, but no remote repository is configured',
|
|
86
108
|
lastPush: 'Last push',
|
|
87
109
|
lastPull: 'Last pull',
|
|
110
|
+
lastAutoPush: 'Last auto push',
|
|
88
111
|
dataDir: 'Local session directory',
|
|
89
112
|
repoDir: 'Mirror directory',
|
|
90
113
|
notConfigured: 'Not configured',
|
|
@@ -112,6 +135,12 @@ window.__ModuleLoader__.load({
|
|
|
112
135
|
.dshsync-row { display: flex; gap: 10px; }
|
|
113
136
|
.dshsync-grow { flex: 1 1 auto; min-width: 0; }
|
|
114
137
|
.dshsync-branch { width: 170px; flex: 0 0 auto; }
|
|
138
|
+
.dshsync-check { display: flex; align-items: center; gap: 8px; font-size: 13px; color: var(--dsw-alias-label-primary); cursor: pointer; }
|
|
139
|
+
.dshsync-check input { width: 15px; height: 15px; margin: 0; accent-color: var(--dsw-alias-brand-primary); }
|
|
140
|
+
.dshsync-field-hint { margin: 0; font-size: 12px; line-height: 1.5; color: var(--dsw-alias-label-secondary); }
|
|
141
|
+
.dshsync-interval-row { display: flex; align-items: center; gap: 8px; }
|
|
142
|
+
.dshsync-interval-input { width: 120px; }
|
|
143
|
+
.dshsync-unit { font-size: 12px; color: var(--dsw-alias-label-secondary); }
|
|
115
144
|
.dshsync-label { font-size: 12px; font-weight: 500; color: var(--dsw-alias-label-secondary); }
|
|
116
145
|
.dshsync-input { box-sizing: border-box; width: 100%; height: 34px; padding: 0 10px; border: 1px solid var(--dsw-alias-border-l1); border-radius: 8px; background: var(--dsw-alias-bg-base); color: var(--dsw-alias-label-primary); font-size: 13px; outline: none; }
|
|
117
146
|
.dshsync-input::placeholder { color: var(--dsw-alias-state-idle-primary); }
|
|
@@ -184,7 +213,12 @@ window.__ModuleLoader__.load({
|
|
|
184
213
|
}
|
|
185
214
|
return {
|
|
186
215
|
status: () => call('status'),
|
|
187
|
-
save: (remote, branch) => call('save', {
|
|
216
|
+
save: (remote, branch, autoPush, autoPushIntervalMinutes) => call('save', {
|
|
217
|
+
remote,
|
|
218
|
+
branch,
|
|
219
|
+
autoPush,
|
|
220
|
+
autoPushIntervalMinutes,
|
|
221
|
+
}),
|
|
188
222
|
test: (remote) => call('test', { remote }),
|
|
189
223
|
pull: () => call('pull'),
|
|
190
224
|
push: () => call('push'),
|
|
@@ -196,6 +230,8 @@ window.__ModuleLoader__.load({
|
|
|
196
230
|
const [snapshot, setSnapshot] = React.useState(null)
|
|
197
231
|
const [remote, setRemote] = React.useState('')
|
|
198
232
|
const [branch, setBranch] = React.useState('main')
|
|
233
|
+
const [autoPush, setAutoPush] = React.useState(false)
|
|
234
|
+
const [intervalText, setIntervalText] = React.useState('10')
|
|
199
235
|
const [dirty, setDirty] = React.useState(false)
|
|
200
236
|
const [busy, setBusy] = React.useState('')
|
|
201
237
|
const [notice, setNotice] = React.useState(null)
|
|
@@ -212,6 +248,8 @@ window.__ModuleLoader__.load({
|
|
|
212
248
|
setSnapshot(value)
|
|
213
249
|
setRemote(value.remote || '')
|
|
214
250
|
setBranch(value.branch || 'main')
|
|
251
|
+
setAutoPush(value.autoPush === true)
|
|
252
|
+
setIntervalText(String(value.autoPushIntervalMinutes ?? 10))
|
|
215
253
|
})
|
|
216
254
|
.catch((error) => {
|
|
217
255
|
if (alive) setNotice({ kind: 'error', text: `${t('loadFailed')}: ${errorText(error)}` })
|
|
@@ -241,19 +279,38 @@ window.__ModuleLoader__.load({
|
|
|
241
279
|
}
|
|
242
280
|
}
|
|
243
281
|
|
|
282
|
+
const parseInterval = () => {
|
|
283
|
+
const value = Number(intervalText)
|
|
284
|
+
return Number.isInteger(value) && value >= 1 && value <= 1440 ? value : null
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
const applySettings = (settings) => {
|
|
288
|
+
if (!settings) return
|
|
289
|
+
if (typeof settings.remote === 'string') setRemote(settings.remote)
|
|
290
|
+
if (typeof settings.branch === 'string') setBranch(settings.branch)
|
|
291
|
+
if (typeof settings.autoPush === 'boolean') setAutoPush(settings.autoPush)
|
|
292
|
+
if (settings.autoPushIntervalMinutes !== undefined) {
|
|
293
|
+
setIntervalText(String(settings.autoPushIntervalMinutes))
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
|
|
244
297
|
const saveFirst = async () => {
|
|
245
298
|
if (!dirty) return true
|
|
299
|
+
const interval = parseInterval()
|
|
300
|
+
if (interval === null) {
|
|
301
|
+
setNotice({ kind: 'error', text: t('intervalInvalid') })
|
|
302
|
+
return false
|
|
303
|
+
}
|
|
246
304
|
setBusy('save')
|
|
247
305
|
try {
|
|
248
|
-
const saved = await rpc.save(remote.trim(), branch.trim())
|
|
306
|
+
const saved = await rpc.save(remote.trim(), branch.trim(), autoPush, interval)
|
|
249
307
|
if (saved && saved.status) setSnapshot(saved.status)
|
|
250
308
|
if (saved && saved.ok === false) {
|
|
251
309
|
setNotice({ kind: 'error', text: saved.message || t('failed') })
|
|
252
310
|
return false
|
|
253
311
|
}
|
|
254
312
|
setDirty(false)
|
|
255
|
-
|
|
256
|
-
setBranch(saved && saved.settings ? saved.settings.branch : branch.trim())
|
|
313
|
+
applySettings(saved && saved.settings)
|
|
257
314
|
return true
|
|
258
315
|
} catch (error) {
|
|
259
316
|
setNotice({ kind: 'error', text: errorText(error) })
|
|
@@ -264,13 +321,15 @@ window.__ModuleLoader__.load({
|
|
|
264
321
|
}
|
|
265
322
|
|
|
266
323
|
const onSave = async () => {
|
|
267
|
-
const
|
|
324
|
+
const interval = parseInterval()
|
|
325
|
+
if (interval === null) {
|
|
326
|
+
setNotice({ kind: 'error', text: t('intervalInvalid') })
|
|
327
|
+
return
|
|
328
|
+
}
|
|
329
|
+
const result = await perform('save', () => rpc.save(remote.trim(), branch.trim(), autoPush, interval))
|
|
268
330
|
if (!result || result.ok === false) return
|
|
269
331
|
setDirty(false)
|
|
270
|
-
|
|
271
|
-
setRemote(result.settings.remote)
|
|
272
|
-
setBranch(result.settings.branch)
|
|
273
|
-
}
|
|
332
|
+
applySettings(result.settings)
|
|
274
333
|
setNotice({ kind: 'ok', text: t('saved') })
|
|
275
334
|
}
|
|
276
335
|
|
|
@@ -321,6 +380,8 @@ window.__ModuleLoader__.load({
|
|
|
321
380
|
if (!dirty) {
|
|
322
381
|
setRemote(value.remote || '')
|
|
323
382
|
setBranch(value.branch || 'main')
|
|
383
|
+
setAutoPush(value.autoPush === true)
|
|
384
|
+
setIntervalText(String(value.autoPushIntervalMinutes ?? 10))
|
|
324
385
|
}
|
|
325
386
|
setNotice({ kind: 'info', text: t('refreshed') })
|
|
326
387
|
} catch (error) {
|
|
@@ -334,6 +395,14 @@ window.__ModuleLoader__.load({
|
|
|
334
395
|
const state = snapshot && snapshot.state ? snapshot.state : {}
|
|
335
396
|
const local = snapshot && snapshot.local ? snapshot.local : { sessions: 0, files: 0 }
|
|
336
397
|
const mirror = snapshot && snapshot.mirror ? snapshot.mirror : { initialized: false, sessions: 0, files: 0 }
|
|
398
|
+
const autoText = (() => {
|
|
399
|
+
if (!snapshot || snapshot.autoPush !== true) return t('autoOff')
|
|
400
|
+
if (!snapshot.remote) return t('autoNoRemote')
|
|
401
|
+
const minutes = snapshot.autoPushIntervalMinutes ?? 10
|
|
402
|
+
return snapshot.autoPushNextAt
|
|
403
|
+
? format(t('autoNext'), { minutes, time: timeText(snapshot.autoPushNextAt) })
|
|
404
|
+
: format(t('autoOn'), { minutes })
|
|
405
|
+
})()
|
|
337
406
|
|
|
338
407
|
const row = (key, label, value, mono) => [
|
|
339
408
|
h('dt', { className: 'dshsync-k', key: `${key}-k` }, label),
|
|
@@ -385,6 +454,42 @@ window.__ModuleLoader__.load({
|
|
|
385
454
|
}),
|
|
386
455
|
),
|
|
387
456
|
),
|
|
457
|
+
h('div', { className: 'dshsync-field' },
|
|
458
|
+
h('label', { className: 'dshsync-check', htmlFor: 'dshsync-auto' },
|
|
459
|
+
h('input', {
|
|
460
|
+
id: 'dshsync-auto',
|
|
461
|
+
type: 'checkbox',
|
|
462
|
+
checked: autoPush,
|
|
463
|
+
disabled: busy === 'save',
|
|
464
|
+
onChange: (event) => {
|
|
465
|
+
setAutoPush(event.target.checked)
|
|
466
|
+
setDirty(true)
|
|
467
|
+
},
|
|
468
|
+
}),
|
|
469
|
+
h('span', null, t('autoLabel')),
|
|
470
|
+
),
|
|
471
|
+
h('p', { className: 'dshsync-field-hint' }, t('autoHint')),
|
|
472
|
+
),
|
|
473
|
+
h('div', { className: 'dshsync-field dshsync-interval' },
|
|
474
|
+
h('label', { className: 'dshsync-label', htmlFor: 'dshsync-interval' }, t('intervalLabel')),
|
|
475
|
+
h('div', { className: 'dshsync-interval-row' },
|
|
476
|
+
h('input', {
|
|
477
|
+
id: 'dshsync-interval',
|
|
478
|
+
className: 'dshsync-input dshsync-interval-input',
|
|
479
|
+
type: 'number',
|
|
480
|
+
min: 1,
|
|
481
|
+
max: 1440,
|
|
482
|
+
step: 1,
|
|
483
|
+
value: intervalText,
|
|
484
|
+
disabled: busy === 'save',
|
|
485
|
+
onChange: (event) => {
|
|
486
|
+
setIntervalText(event.target.value)
|
|
487
|
+
setDirty(true)
|
|
488
|
+
},
|
|
489
|
+
}),
|
|
490
|
+
h('span', { className: 'dshsync-unit' }, t('intervalUnit')),
|
|
491
|
+
),
|
|
492
|
+
),
|
|
388
493
|
h('div', { className: 'dshsync-actions' },
|
|
389
494
|
h('button', { type: 'button', className: 'dshsync-btn dshsync-btn-primary', onClick: onSave, disabled }, t('save')),
|
|
390
495
|
h('button', { type: 'button', className: 'dshsync-btn', onClick: onTest, disabled }, t('test')),
|
|
@@ -406,6 +511,7 @@ window.__ModuleLoader__.load({
|
|
|
406
511
|
: h('dl', { className: 'dshsync-grid' },
|
|
407
512
|
row('remote', t('remoteStatus'), snapshot.remote || t('notConfigured')),
|
|
408
513
|
row('branch', t('branchStatus'), snapshot.branch),
|
|
514
|
+
row('auto', t('autoStatus'), autoText),
|
|
409
515
|
row('local', t('localStatus'), `${local.sessions} / ${local.files}`),
|
|
410
516
|
row('mirror', t('mirrorStatus'), mirror.initialized
|
|
411
517
|
? `${mirror.sessions} / ${mirror.files}`
|
|
@@ -413,6 +519,9 @@ window.__ModuleLoader__.load({
|
|
|
413
519
|
row('push', t('lastPush'), state.lastPushAt
|
|
414
520
|
? `${timeText(state.lastPushAt)}${state.lastPushOk === false ? ' ✕' : ''}`
|
|
415
521
|
: t('never')),
|
|
522
|
+
row('autoPush', t('lastAutoPush'), state.lastAutoPushAt
|
|
523
|
+
? `${timeText(state.lastAutoPushAt)}${state.lastAutoPushOk === false ? ' ✕' : ''}`
|
|
524
|
+
: t('never')),
|
|
416
525
|
row('pull', t('lastPull'), state.lastPullAt
|
|
417
526
|
? `${timeText(state.lastPullAt)}${state.lastPullOk === false ? ' ✕' : ''}`
|
|
418
527
|
: t('never')),
|
package/index.js
CHANGED
|
@@ -12,6 +12,7 @@
|
|
|
12
12
|
import os from 'node:os'
|
|
13
13
|
import path from 'node:path'
|
|
14
14
|
import { SessionGitSync, validateBranch } from './lib/sync.js'
|
|
15
|
+
import { AutoPushScheduler } from './lib/auto.js'
|
|
15
16
|
import { registerRemoteService } from './lib/remote.js'
|
|
16
17
|
|
|
17
18
|
const SERVICE_KEY = 'dshSync'
|
|
@@ -37,9 +38,24 @@ function formatStatus(status) {
|
|
|
37
38
|
? `${status.mirror.sessions} 个会话 / ${status.mirror.files} 个文件`
|
|
38
39
|
: '尚未初始化(首次 /sync push 时创建)'}`,
|
|
39
40
|
]
|
|
41
|
+
if (status.autoPush) {
|
|
42
|
+
if (!status.remote) {
|
|
43
|
+
lines.push(`自动同步: 已开启(每 ${status.autoPushIntervalMinutes} 分钟),但尚未配置远程仓库`)
|
|
44
|
+
} else {
|
|
45
|
+
const next = status.autoPushNextAt
|
|
46
|
+
? `,下次 ${formatTime(new Date(status.autoPushNextAt).toISOString())}`
|
|
47
|
+
: ''
|
|
48
|
+
lines.push(`自动同步: 每 ${status.autoPushIntervalMinutes} 分钟推送一次${next}`)
|
|
49
|
+
}
|
|
50
|
+
} else {
|
|
51
|
+
lines.push('自动同步: 已关闭(在设置页开启)')
|
|
52
|
+
}
|
|
40
53
|
if (status.state.lastPushAt) {
|
|
41
54
|
lines.push(`上次推送: ${formatTime(status.state.lastPushAt)} ${status.state.lastPushOk === false ? '失败' : '成功'}`)
|
|
42
55
|
}
|
|
56
|
+
if (status.state.lastAutoPushAt) {
|
|
57
|
+
lines.push(`上次自动推送: ${formatTime(status.state.lastAutoPushAt)} ${status.state.lastAutoPushOk === false ? '失败' : '成功'}`)
|
|
58
|
+
}
|
|
43
59
|
if (status.state.lastPullAt) {
|
|
44
60
|
lines.push(`上次拉取: ${formatTime(status.state.lastPullAt)} ${status.state.lastPullOk === false ? '失败' : '成功'}`)
|
|
45
61
|
}
|
|
@@ -63,6 +79,19 @@ export function apply(ctx) {
|
|
|
63
79
|
logger: ctx.logger,
|
|
64
80
|
flush: () => ctx.get('sessionPersistence')?.flush(),
|
|
65
81
|
})
|
|
82
|
+
const scheduler = new AutoPushScheduler({ sync, logger: ctx.logger })
|
|
83
|
+
|
|
84
|
+
/** Status plus the live timer state the page and command both display. */
|
|
85
|
+
const readStatus = async () => ({
|
|
86
|
+
...(await sync.status()),
|
|
87
|
+
autoPushNextAt: scheduler.nextAt,
|
|
88
|
+
autoPushRunning: scheduler.running,
|
|
89
|
+
})
|
|
90
|
+
|
|
91
|
+
ctx.effect(() => {
|
|
92
|
+
scheduler.start()
|
|
93
|
+
return () => scheduler.stop()
|
|
94
|
+
}, 'dsh-sync: auto push timer')
|
|
66
95
|
|
|
67
96
|
ctx.effect(() => ctx.commands.register({
|
|
68
97
|
name: 'sync',
|
|
@@ -72,7 +101,7 @@ export function apply(ctx) {
|
|
|
72
101
|
const argument = String(invocation.rawInput ?? '').trim().toLowerCase()
|
|
73
102
|
try {
|
|
74
103
|
if (argument === '' || argument === 'status') {
|
|
75
|
-
return success(formatStatus(await
|
|
104
|
+
return success(formatStatus(await readStatus()))
|
|
76
105
|
}
|
|
77
106
|
if (argument === 'help') {
|
|
78
107
|
return success([
|
|
@@ -115,7 +144,7 @@ export function apply(ctx) {
|
|
|
115
144
|
const withStatus = async (result) => {
|
|
116
145
|
let status
|
|
117
146
|
try {
|
|
118
|
-
status = await
|
|
147
|
+
status = await readStatus()
|
|
119
148
|
} catch {
|
|
120
149
|
status = undefined
|
|
121
150
|
}
|
|
@@ -130,14 +159,15 @@ export function apply(ctx) {
|
|
|
130
159
|
{
|
|
131
160
|
method: 'status',
|
|
132
161
|
implementation: function status() {
|
|
133
|
-
return
|
|
162
|
+
return readStatus()
|
|
134
163
|
},
|
|
135
164
|
},
|
|
136
165
|
{
|
|
137
166
|
method: 'save',
|
|
138
|
-
implementation: async function save(remote, branch) {
|
|
167
|
+
implementation: async function save(remote, branch, autoPush, autoPushIntervalMinutes) {
|
|
139
168
|
const normalizedBranch = validateBranch(branch)
|
|
140
|
-
const result = await sync.save(remote, normalizedBranch)
|
|
169
|
+
const result = await sync.save(remote, normalizedBranch, autoPush, autoPushIntervalMinutes)
|
|
170
|
+
await scheduler.refresh()
|
|
141
171
|
return withStatus(result)
|
|
142
172
|
},
|
|
143
173
|
},
|
package/lib/auto.js
ADDED
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Periodic auto-push for `dsh-sync`.
|
|
3
|
+
*
|
|
4
|
+
* The host owns one scheduler; it re-reads settings before every run so the
|
|
5
|
+
* checkbox and interval in the Settings page take effect without a restart.
|
|
6
|
+
* Each run is a normal `push`, so it shares the engine's queue and state
|
|
7
|
+
* recording with manual pushes.
|
|
8
|
+
*/
|
|
9
|
+
import { DEFAULT_AUTO_PUSH_INTERVAL_MINUTES } from './sync.js'
|
|
10
|
+
|
|
11
|
+
export class AutoPushScheduler {
|
|
12
|
+
constructor({ sync, logger, setTimer, clearTimer } = {}) {
|
|
13
|
+
if (!sync) throw new Error('AutoPushScheduler needs a sync engine.')
|
|
14
|
+
this.sync = sync
|
|
15
|
+
this.logger = logger
|
|
16
|
+
this.setTimer = setTimer ?? ((fn, ms) => setTimeout(fn, ms))
|
|
17
|
+
this.clearTimer = clearTimer ?? ((id) => clearTimeout(id))
|
|
18
|
+
this.stopped = true
|
|
19
|
+
this.timer = undefined
|
|
20
|
+
this.nextAt = null
|
|
21
|
+
this.running = false
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** Begin watching settings; the first run happens one interval from now. */
|
|
25
|
+
start() {
|
|
26
|
+
if (!this.stopped) return
|
|
27
|
+
this.stopped = false
|
|
28
|
+
void this.arm()
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
stop() {
|
|
32
|
+
this.stopped = true
|
|
33
|
+
this.cancel()
|
|
34
|
+
this.nextAt = null
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Re-arm after settings changed (enable/disable/interval). */
|
|
38
|
+
refresh() {
|
|
39
|
+
if (this.stopped) return Promise.resolve()
|
|
40
|
+
return this.arm()
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
cancel() {
|
|
44
|
+
if (this.timer !== undefined) {
|
|
45
|
+
this.clearTimer(this.timer)
|
|
46
|
+
this.timer = undefined
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
async arm() {
|
|
51
|
+
this.cancel()
|
|
52
|
+
this.nextAt = null
|
|
53
|
+
if (this.stopped) return
|
|
54
|
+
let settings
|
|
55
|
+
try {
|
|
56
|
+
settings = await this.sync.readSettings()
|
|
57
|
+
} catch (error) {
|
|
58
|
+
this.logger?.warn?.(`dsh-sync: cannot read settings for auto-sync: ${describeError(error)}`)
|
|
59
|
+
return
|
|
60
|
+
}
|
|
61
|
+
if (this.stopped) return
|
|
62
|
+
if (!settings.autoPush || settings.remote === '') return
|
|
63
|
+
const minutes = settings.autoPushIntervalMinutes || DEFAULT_AUTO_PUSH_INTERVAL_MINUTES
|
|
64
|
+
const delay = minutes * 60_000
|
|
65
|
+
this.nextAt = Date.now() + delay
|
|
66
|
+
this.timer = this.setTimer(() => {
|
|
67
|
+
void this.tick()
|
|
68
|
+
}, delay)
|
|
69
|
+
// A pending auto-push must never keep the host process alive.
|
|
70
|
+
this.timer?.unref?.()
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
async tick() {
|
|
74
|
+
this.timer = undefined
|
|
75
|
+
this.nextAt = null
|
|
76
|
+
if (this.stopped) return
|
|
77
|
+
if (this.running) {
|
|
78
|
+
void this.arm()
|
|
79
|
+
return
|
|
80
|
+
}
|
|
81
|
+
let settings
|
|
82
|
+
try {
|
|
83
|
+
settings = await this.sync.readSettings()
|
|
84
|
+
} catch (error) {
|
|
85
|
+
this.logger?.warn?.(`dsh-sync: cannot read settings for auto-sync: ${describeError(error)}`)
|
|
86
|
+
void this.arm()
|
|
87
|
+
return
|
|
88
|
+
}
|
|
89
|
+
if (!settings.autoPush || settings.remote === '') return
|
|
90
|
+
|
|
91
|
+
this.running = true
|
|
92
|
+
let outcome = { ok: false }
|
|
93
|
+
try {
|
|
94
|
+
const result = await this.sync.push()
|
|
95
|
+
outcome = result
|
|
96
|
+
if (result?.ok === false) {
|
|
97
|
+
this.logger?.warn?.(`dsh-sync: auto push skipped: ${result.message ?? 'not configured'}`)
|
|
98
|
+
}
|
|
99
|
+
} catch (error) {
|
|
100
|
+
this.logger?.warn?.(`dsh-sync: auto push failed: ${describeError(error)}`)
|
|
101
|
+
} finally {
|
|
102
|
+
this.running = false
|
|
103
|
+
if (!this.stopped) void this.arm()
|
|
104
|
+
}
|
|
105
|
+
try {
|
|
106
|
+
await this.sync.recordAutoPush(outcome)
|
|
107
|
+
} catch (error) {
|
|
108
|
+
this.logger?.warn?.(`dsh-sync: cannot record auto push: ${describeError(error)}`)
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function describeError(error) {
|
|
114
|
+
return error instanceof Error ? error.message : String(error)
|
|
115
|
+
}
|
package/lib/sync.js
CHANGED
|
@@ -24,6 +24,11 @@ const TEST_TIMEOUT_MS = 45_000
|
|
|
24
24
|
const MAX_BUFFER = 16 * 1024 * 1024
|
|
25
25
|
const BRANCH_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._/-]{0,200}$/
|
|
26
26
|
|
|
27
|
+
/** Auto-sync defaults and bounds, in minutes. */
|
|
28
|
+
export const DEFAULT_AUTO_PUSH_INTERVAL_MINUTES = 10
|
|
29
|
+
export const MIN_AUTO_PUSH_INTERVAL_MINUTES = 1
|
|
30
|
+
export const MAX_AUTO_PUSH_INTERVAL_MINUTES = 1440
|
|
31
|
+
|
|
27
32
|
/** A failed operation carrying the raw git result when one exists. */
|
|
28
33
|
export class SyncFailure extends Error {
|
|
29
34
|
constructor(message, details) {
|
|
@@ -125,6 +130,37 @@ export function validateBranch(branch) {
|
|
|
125
130
|
return value
|
|
126
131
|
}
|
|
127
132
|
|
|
133
|
+
/**
|
|
134
|
+
* Validate the auto-sync interval (whole minutes).
|
|
135
|
+
*
|
|
136
|
+
* @param {unknown} value
|
|
137
|
+
* @returns {number}
|
|
138
|
+
*/
|
|
139
|
+
export function normalizeAutoPushInterval(value) {
|
|
140
|
+
if (value === undefined || value === null || value === '') {
|
|
141
|
+
return DEFAULT_AUTO_PUSH_INTERVAL_MINUTES
|
|
142
|
+
}
|
|
143
|
+
const number = Number(value)
|
|
144
|
+
if (!Number.isInteger(number)
|
|
145
|
+
|| number < MIN_AUTO_PUSH_INTERVAL_MINUTES
|
|
146
|
+
|| number > MAX_AUTO_PUSH_INTERVAL_MINUTES) {
|
|
147
|
+
throw new SyncFailure(
|
|
148
|
+
`Auto-sync interval must be a whole number between ${MIN_AUTO_PUSH_INTERVAL_MINUTES} and ${MAX_AUTO_PUSH_INTERVAL_MINUTES} minutes.`,
|
|
149
|
+
)
|
|
150
|
+
}
|
|
151
|
+
return number
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/** Interval for an already persisted settings file; bad values fall back. */
|
|
155
|
+
function storedAutoPushInterval(value) {
|
|
156
|
+
const number = Number(value)
|
|
157
|
+
return Number.isInteger(number)
|
|
158
|
+
&& number >= MIN_AUTO_PUSH_INTERVAL_MINUTES
|
|
159
|
+
&& number <= MAX_AUTO_PUSH_INTERVAL_MINUTES
|
|
160
|
+
? number
|
|
161
|
+
: DEFAULT_AUTO_PUSH_INTERVAL_MINUTES
|
|
162
|
+
}
|
|
163
|
+
|
|
128
164
|
async function writeJsonAtomic(file, value) {
|
|
129
165
|
const temporary = `${file}.${process.pid}.${Date.now()}.tmp`
|
|
130
166
|
await fs.writeFile(temporary, `${JSON.stringify(value, null, 2)}\n`, { encoding: 'utf8', mode: 0o600 })
|
|
@@ -180,6 +216,8 @@ export class SessionGitSync {
|
|
|
180
216
|
return {
|
|
181
217
|
remote: typeof stored?.remote === 'string' ? stored.remote : '',
|
|
182
218
|
branch: typeof stored?.branch === 'string' && stored.branch ? stored.branch : 'main',
|
|
219
|
+
autoPush: stored?.autoPush === true,
|
|
220
|
+
autoPushIntervalMinutes: storedAutoPushInterval(stored?.autoPushIntervalMinutes),
|
|
183
221
|
}
|
|
184
222
|
}
|
|
185
223
|
|
|
@@ -192,6 +230,8 @@ export class SessionGitSync {
|
|
|
192
230
|
lastPullAt: typeof stored?.lastPullAt === 'string' ? stored.lastPullAt : null,
|
|
193
231
|
lastPullOk: typeof stored?.lastPullOk === 'boolean' ? stored.lastPullOk : null,
|
|
194
232
|
lastPullSummary: typeof stored?.lastPullSummary === 'string' ? stored.lastPullSummary : null,
|
|
233
|
+
lastAutoPushAt: typeof stored?.lastAutoPushAt === 'string' ? stored.lastAutoPushAt : null,
|
|
234
|
+
lastAutoPushOk: typeof stored?.lastAutoPushOk === 'boolean' ? stored.lastAutoPushOk : null,
|
|
195
235
|
lastError: typeof stored?.lastError === 'string' ? stored.lastError : null,
|
|
196
236
|
}
|
|
197
237
|
}
|
|
@@ -202,6 +242,14 @@ export class SessionGitSync {
|
|
|
202
242
|
await writeJsonAtomic(this.statePath, next)
|
|
203
243
|
}
|
|
204
244
|
|
|
245
|
+
/** Mark the outcome of one scheduled auto-sync run. */
|
|
246
|
+
async recordAutoPush(result) {
|
|
247
|
+
await this.recordState({
|
|
248
|
+
lastAutoPushAt: new Date().toISOString(),
|
|
249
|
+
lastAutoPushOk: result?.ok !== false,
|
|
250
|
+
})
|
|
251
|
+
}
|
|
252
|
+
|
|
205
253
|
async gitVersion() {
|
|
206
254
|
if (this.gitVersionPromise === undefined) {
|
|
207
255
|
this.gitVersionPromise = (async () => {
|
|
@@ -274,6 +322,8 @@ export class SessionGitSync {
|
|
|
274
322
|
configured: settings.remote !== '',
|
|
275
323
|
remote: settings.remote,
|
|
276
324
|
branch: settings.branch,
|
|
325
|
+
autoPush: settings.autoPush,
|
|
326
|
+
autoPushIntervalMinutes: settings.autoPushIntervalMinutes,
|
|
277
327
|
repoDir: this.repoDir,
|
|
278
328
|
sessionsRoot: this.sessionsRoot,
|
|
279
329
|
local: summarize(localFiles),
|
|
@@ -283,19 +333,33 @@ export class SessionGitSync {
|
|
|
283
333
|
}
|
|
284
334
|
}
|
|
285
335
|
|
|
286
|
-
async save(remote, branch) {
|
|
336
|
+
async save(remote, branch, autoPush, autoPushIntervalMinutes) {
|
|
287
337
|
const normalizedRemote = typeof remote === 'string' ? remote.trim() : ''
|
|
288
338
|
const normalizedBranch = validateBranch(branch)
|
|
339
|
+
// Omitted auto-sync fields keep their stored values, so an older client
|
|
340
|
+
// that only sends remote/branch cannot silently disable a running schedule.
|
|
341
|
+
const current = await this.readSettings()
|
|
342
|
+
const normalizedAutoPush = autoPush === undefined ? current.autoPush : autoPush === true
|
|
343
|
+
const normalizedInterval = autoPushIntervalMinutes === undefined
|
|
344
|
+
? current.autoPushIntervalMinutes
|
|
345
|
+
: normalizeAutoPushInterval(autoPushIntervalMinutes)
|
|
289
346
|
if (normalizedRemote !== '') validateRemote(normalizedRemote)
|
|
290
347
|
return this.enqueue(async () => {
|
|
291
348
|
await fs.mkdir(this.dataDir, { recursive: true })
|
|
292
349
|
await writeJsonAtomic(this.settingsPath, {
|
|
293
350
|
remote: normalizedRemote,
|
|
294
351
|
branch: normalizedBranch,
|
|
352
|
+
autoPush: normalizedAutoPush,
|
|
353
|
+
autoPushIntervalMinutes: normalizedInterval,
|
|
295
354
|
})
|
|
296
355
|
return {
|
|
297
356
|
ok: true,
|
|
298
|
-
settings: {
|
|
357
|
+
settings: {
|
|
358
|
+
remote: normalizedRemote,
|
|
359
|
+
branch: normalizedBranch,
|
|
360
|
+
autoPush: normalizedAutoPush,
|
|
361
|
+
autoPushIntervalMinutes: normalizedInterval,
|
|
362
|
+
},
|
|
299
363
|
}
|
|
300
364
|
})
|
|
301
365
|
}
|
package/locale/en.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"meta": {
|
|
3
3
|
"title": "Session Git Sync",
|
|
4
|
-
"description": "Mirror DSH sessions into a git repository and share them across devices: /sync status, /sync pull and /sync push, plus a Settings page for the repository URL."
|
|
4
|
+
"description": "Mirror DSH sessions into a git repository and share them across devices: /sync status, /sync pull and /sync push, plus a Settings page for the repository URL and timed auto-push."
|
|
5
5
|
}
|
|
6
6
|
}
|
package/locale/zh.json
CHANGED
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@code_tin/dsh-sync",
|
|
3
|
-
"version": "0.1
|
|
4
|
-
"description": "Sync DeepSeek Harness sessions across devices through a git repository, with a Settings page and /sync
|
|
3
|
+
"version": "0.2.1",
|
|
4
|
+
"description": "Sync DeepSeek Harness sessions across devices through a git repository, with optional timed auto-push, a Settings page, and /sync commands.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
7
7
|
"type": "git",
|
package/icon.svg
DELETED
|
@@ -1,12 +0,0 @@
|
|
|
1
|
-
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="64" height="64" role="img" aria-label="Session Git Sync">
|
|
2
|
-
<rect width="64" height="64" rx="14" fill="#1f6feb"/>
|
|
3
|
-
<g fill="none" stroke="#ffffff" stroke-width="3" stroke-linecap="round" stroke-linejoin="round">
|
|
4
|
-
<path d="M21 16v23a6 6 0 0 0 6 6h14"/>
|
|
5
|
-
<path d="M43 16v8"/>
|
|
6
|
-
</g>
|
|
7
|
-
<circle cx="21" cy="13" r="5" fill="#ffffff"/>
|
|
8
|
-
<circle cx="43" cy="13" r="5" fill="#ffffff"/>
|
|
9
|
-
<circle cx="15" cy="47" r="5" fill="#ffffff"/>
|
|
10
|
-
<path d="M44 36a9 9 0 0 1 6 15" fill="none" stroke="#bcd7ff" stroke-width="3" stroke-linecap="round"/>
|
|
11
|
-
<path d="M52 44v7h-7" fill="none" stroke="#bcd7ff" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"/>
|
|
12
|
-
</svg>
|