@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/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
+ }