@code_tin/dsh-sync 0.0.0-stage → 0.2.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/LICENSE +21 -0
- package/README.md +91 -2
- package/client.js +567 -0
- package/cordis.patch.yml +8 -0
- package/icon.svg +12 -0
- package/index.js +197 -0
- package/lib/auto.js +115 -0
- package/lib/remote.js +62 -0
- package/lib/sync.js +645 -0
- package/locale/en.json +6 -0
- package/locale/zh.json +6 -0
- package/package.json +60 -4
package/index.js
ADDED
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Host half of `dsh-sync`.
|
|
3
|
+
*
|
|
4
|
+
* Responsibilities:
|
|
5
|
+
* - own the git mirror engine (`lib/sync.js`);
|
|
6
|
+
* - register the human `/sync` command;
|
|
7
|
+
* - expose the same operations to the Settings page through a Remote service.
|
|
8
|
+
*
|
|
9
|
+
* The browser half only renders the settings page; every operation lives here
|
|
10
|
+
* so the command line and the page cannot disagree.
|
|
11
|
+
*/
|
|
12
|
+
import os from 'node:os'
|
|
13
|
+
import path from 'node:path'
|
|
14
|
+
import { SessionGitSync, validateBranch } from './lib/sync.js'
|
|
15
|
+
import { AutoPushScheduler } from './lib/auto.js'
|
|
16
|
+
import { registerRemoteService } from './lib/remote.js'
|
|
17
|
+
|
|
18
|
+
const SERVICE_KEY = 'dshSync'
|
|
19
|
+
const NAMESPACE = 'dsh-sync'
|
|
20
|
+
|
|
21
|
+
/** The command needs the registry; without it the plugin has nothing to do. */
|
|
22
|
+
export const inject = ['commands']
|
|
23
|
+
|
|
24
|
+
function formatTime(value) {
|
|
25
|
+
if (!value) return '无'
|
|
26
|
+
const date = new Date(value)
|
|
27
|
+
if (Number.isNaN(date.getTime())) return value
|
|
28
|
+
return date.toLocaleString('zh-CN', { hour12: false })
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function formatStatus(status) {
|
|
32
|
+
const lines = [
|
|
33
|
+
'DSH 会话 Git 同步',
|
|
34
|
+
`远程仓库: ${status.remote || '(未配置,在 设置 → 插件 → 会话 Git 同步 中填写)'}`,
|
|
35
|
+
`分支: ${status.branch}`,
|
|
36
|
+
`本地会话: ${status.local.sessions} 个会话 / ${status.local.files} 个文件`,
|
|
37
|
+
`镜像仓库: ${status.mirror.initialized
|
|
38
|
+
? `${status.mirror.sessions} 个会话 / ${status.mirror.files} 个文件`
|
|
39
|
+
: '尚未初始化(首次 /sync push 时创建)'}`,
|
|
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
|
+
}
|
|
53
|
+
if (status.state.lastPushAt) {
|
|
54
|
+
lines.push(`上次推送: ${formatTime(status.state.lastPushAt)} ${status.state.lastPushOk === false ? '失败' : '成功'}`)
|
|
55
|
+
}
|
|
56
|
+
if (status.state.lastAutoPushAt) {
|
|
57
|
+
lines.push(`上次自动推送: ${formatTime(status.state.lastAutoPushAt)} ${status.state.lastAutoPushOk === false ? '失败' : '成功'}`)
|
|
58
|
+
}
|
|
59
|
+
if (status.state.lastPullAt) {
|
|
60
|
+
lines.push(`上次拉取: ${formatTime(status.state.lastPullAt)} ${status.state.lastPullOk === false ? '失败' : '成功'}`)
|
|
61
|
+
}
|
|
62
|
+
if (status.state.lastError) lines.push(`最近错误: ${status.state.lastError}`)
|
|
63
|
+
lines.push('用法: /sync | /sync pull | /sync push')
|
|
64
|
+
return lines.join('\n')
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function success(text) {
|
|
68
|
+
return { kind: 'success', text }
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function failure(text) {
|
|
72
|
+
return { kind: 'error', text }
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export function apply(ctx) {
|
|
76
|
+
const home = process.env.DSH_HOME || path.join(os.homedir(), '.dsh')
|
|
77
|
+
const sync = new SessionGitSync({
|
|
78
|
+
home,
|
|
79
|
+
logger: ctx.logger,
|
|
80
|
+
flush: () => ctx.get('sessionPersistence')?.flush(),
|
|
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')
|
|
95
|
+
|
|
96
|
+
ctx.effect(() => ctx.commands.register({
|
|
97
|
+
name: 'sync',
|
|
98
|
+
description: '在设备之间通过 Git 同步 DSH 会话:status / pull / push',
|
|
99
|
+
input: { hint: 'status | pull | push' },
|
|
100
|
+
async handler(invocation) {
|
|
101
|
+
const argument = String(invocation.rawInput ?? '').trim().toLowerCase()
|
|
102
|
+
try {
|
|
103
|
+
if (argument === '' || argument === 'status') {
|
|
104
|
+
return success(formatStatus(await readStatus()))
|
|
105
|
+
}
|
|
106
|
+
if (argument === 'help') {
|
|
107
|
+
return success([
|
|
108
|
+
'/sync 查看同步状态',
|
|
109
|
+
'/sync pull 拉取远程会话到本地(不覆盖本地已有会话)',
|
|
110
|
+
'/sync push 把本地会话推送到 Git 远程仓库',
|
|
111
|
+
'/sync test 测试已保存的 Git 地址',
|
|
112
|
+
].join('\n'))
|
|
113
|
+
}
|
|
114
|
+
if (argument === 'pull') {
|
|
115
|
+
const result = await sync.pull()
|
|
116
|
+
if (result.ok === false) return failure(`拉取失败:${result.message}`)
|
|
117
|
+
return success(
|
|
118
|
+
`拉取完成:新增 ${result.addedSessions} 个会话(${result.addedFiles} 个文件),`
|
|
119
|
+
+ `跳过 ${result.skippedFiles} 个本地已存在的文件。`,
|
|
120
|
+
)
|
|
121
|
+
}
|
|
122
|
+
if (argument === 'push') {
|
|
123
|
+
const result = await sync.push()
|
|
124
|
+
if (result.ok === false) return failure(`推送失败:${result.message}`)
|
|
125
|
+
const text = result.pushed
|
|
126
|
+
? `推送完成:${result.sessions} 个会话(${result.files} 个文件)。`
|
|
127
|
+
: '推送完成:远程已是最新,无需更新。'
|
|
128
|
+
return success(result.warning ? `${text}\n注意:${result.warning}` : text)
|
|
129
|
+
}
|
|
130
|
+
if (argument === 'test') {
|
|
131
|
+
const settings = await sync.readSettings()
|
|
132
|
+
if (!settings.remote) return failure('尚未配置 Git 地址,请先在设置页面填写。')
|
|
133
|
+
const result = await sync.test(settings.remote)
|
|
134
|
+
if (result.ok === false) return failure(`连接失败:${result.message}`)
|
|
135
|
+
return success(`连接成功:远程有 ${result.branches.length} 个分支。`)
|
|
136
|
+
}
|
|
137
|
+
return failure(`未知参数 “${argument}”。用法: /sync | /sync pull | /sync push`)
|
|
138
|
+
} catch (error) {
|
|
139
|
+
return failure(`同步失败:${error instanceof Error ? error.message : String(error)}`)
|
|
140
|
+
}
|
|
141
|
+
},
|
|
142
|
+
}), 'dsh-sync: /sync command')
|
|
143
|
+
|
|
144
|
+
const withStatus = async (result) => {
|
|
145
|
+
let status
|
|
146
|
+
try {
|
|
147
|
+
status = await readStatus()
|
|
148
|
+
} catch {
|
|
149
|
+
status = undefined
|
|
150
|
+
}
|
|
151
|
+
return status === undefined ? result : { ...result, status }
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
const dispose = registerRemoteService({
|
|
155
|
+
ctx,
|
|
156
|
+
serviceKey: SERVICE_KEY,
|
|
157
|
+
namespace: NAMESPACE,
|
|
158
|
+
methods: [
|
|
159
|
+
{
|
|
160
|
+
method: 'status',
|
|
161
|
+
implementation: function status() {
|
|
162
|
+
return readStatus()
|
|
163
|
+
},
|
|
164
|
+
},
|
|
165
|
+
{
|
|
166
|
+
method: 'save',
|
|
167
|
+
implementation: async function save(remote, branch, autoPush, autoPushIntervalMinutes) {
|
|
168
|
+
const normalizedBranch = validateBranch(branch)
|
|
169
|
+
const result = await sync.save(remote, normalizedBranch, autoPush, autoPushIntervalMinutes)
|
|
170
|
+
await scheduler.refresh()
|
|
171
|
+
return withStatus(result)
|
|
172
|
+
},
|
|
173
|
+
},
|
|
174
|
+
{
|
|
175
|
+
method: 'test',
|
|
176
|
+
implementation: async function test(remote) {
|
|
177
|
+
return sync.test(remote)
|
|
178
|
+
},
|
|
179
|
+
},
|
|
180
|
+
{
|
|
181
|
+
method: 'pull',
|
|
182
|
+
implementation: async function pull() {
|
|
183
|
+
return withStatus(await sync.pull())
|
|
184
|
+
},
|
|
185
|
+
},
|
|
186
|
+
{
|
|
187
|
+
method: 'push',
|
|
188
|
+
implementation: async function push() {
|
|
189
|
+
return withStatus(await sync.push())
|
|
190
|
+
},
|
|
191
|
+
},
|
|
192
|
+
],
|
|
193
|
+
})
|
|
194
|
+
ctx.effect(() => dispose, 'dsh-sync: remote service')
|
|
195
|
+
|
|
196
|
+
ctx.logger?.info?.(`dsh-sync ready: sessions=${sync.sessionsRoot}`)
|
|
197
|
+
}
|
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/remote.js
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Minimal host-side Remote service for the browser half.
|
|
3
|
+
*
|
|
4
|
+
* The Harness Web client reaches a plugin's host code through the Typert
|
|
5
|
+
* gateway (`ctx.connection.rpc.call('/api', '<namespace>/<method>', { args })`).
|
|
6
|
+
* The gateway only routes to a registered Cordis service that carries a
|
|
7
|
+
* `typertRemote` binding and the source-mode method descriptor on its
|
|
8
|
+
* prototype. Building that by hand keeps this plugin dependency-free: it uses
|
|
9
|
+
* only `ctx.reflect.provide` (a Cordis core service) and the documented
|
|
10
|
+
* descriptor key.
|
|
11
|
+
*
|
|
12
|
+
* A method is addressed as `<namespace>/<method>`, and the gateway spreads the
|
|
13
|
+
* request's `args` object across the implementation's parameters, matching
|
|
14
|
+
* names exactly. Keep one parameter per wire field.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
/** Descriptor key the gateway reads off the service prototype. */
|
|
18
|
+
const REMOTE_METHODS = '@deepseek-ai/dsh-typert-protocol/remote-methods'
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Register one Remote service.
|
|
22
|
+
*
|
|
23
|
+
* @param {object} deps
|
|
24
|
+
* @param {import('@deepseek-ai/cordis').Context} deps.ctx owning context
|
|
25
|
+
* @param {string} deps.serviceKey Cordis service key
|
|
26
|
+
* @param {string} deps.namespace wire namespace used in the endpoint
|
|
27
|
+
* @param {Array<{ method: string, implementation: Function }>} deps.methods
|
|
28
|
+
* @returns {() => void} disposer that unregisters the service
|
|
29
|
+
*/
|
|
30
|
+
export function registerRemoteService({ ctx, serviceKey, namespace, methods }) {
|
|
31
|
+
const service = {}
|
|
32
|
+
Object.defineProperty(service, 'ctx', { value: ctx, enumerable: false, writable: true })
|
|
33
|
+
Object.defineProperty(service, 'name', { value: serviceKey, enumerable: false, writable: true })
|
|
34
|
+
Object.defineProperty(service, 'typertRemote', {
|
|
35
|
+
value: Object.freeze({ service, serviceKey, namespace }),
|
|
36
|
+
enumerable: false,
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
const prototype = {}
|
|
40
|
+
for (const { method, implementation } of methods) {
|
|
41
|
+
Object.defineProperty(prototype, method, {
|
|
42
|
+
value: implementation,
|
|
43
|
+
enumerable: false,
|
|
44
|
+
writable: false,
|
|
45
|
+
})
|
|
46
|
+
}
|
|
47
|
+
Object.defineProperty(prototype, REMOTE_METHODS, {
|
|
48
|
+
value: Object.freeze({
|
|
49
|
+
version: 1,
|
|
50
|
+
methods: Object.freeze(
|
|
51
|
+
methods.map(({ method }) => Object.freeze({
|
|
52
|
+
method,
|
|
53
|
+
invocation: Object.freeze({ kind: 'direct' }),
|
|
54
|
+
})),
|
|
55
|
+
),
|
|
56
|
+
}),
|
|
57
|
+
enumerable: false,
|
|
58
|
+
})
|
|
59
|
+
Object.setPrototypeOf(service, prototype)
|
|
60
|
+
|
|
61
|
+
return ctx.reflect.provide(serviceKey, service)
|
|
62
|
+
}
|