@code_tin/dsh-sync 0.0.0-stage → 0.1.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/lib/sync.js ADDED
@@ -0,0 +1,581 @@
1
+ /**
2
+ * Git-backed mirror of the DeepSeek Harness session store.
3
+ *
4
+ * The mirror is a dedicated work tree under `$DSH_HOME/dsh-sync/repo`; the
5
+ * Harness session directory itself (`$DSH_HOME/sessions`) never contains a
6
+ * `.git` entry. Push copies local session logs into the mirror, commits, and
7
+ * pushes. Pull fast-forwards the mirror from the remote and copies only
8
+ * sessions that do not exist locally, so a device never loses a local log.
9
+ *
10
+ * Only append-only session log files (`session.*.jsonl.zstd`) are mirrored.
11
+ * Write locks, `.DS_Store`, and any other bookkeeping files stay local.
12
+ */
13
+ import { execFile } from 'node:child_process'
14
+ import { promises as fs } from 'node:fs'
15
+ import os from 'node:os'
16
+ import path from 'node:path'
17
+ import { promisify } from 'node:util'
18
+
19
+ const execFileAsync = promisify(execFile)
20
+
21
+ const SESSION_FILE_PATTERN = /^session\..+\.jsonl\.zstd$/
22
+ const GIT_TIMEOUT_MS = 120_000
23
+ const TEST_TIMEOUT_MS = 45_000
24
+ const MAX_BUFFER = 16 * 1024 * 1024
25
+ const BRANCH_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._/-]{0,200}$/
26
+
27
+ /** A failed operation carrying the raw git result when one exists. */
28
+ export class SyncFailure extends Error {
29
+ constructor(message, details) {
30
+ super(message)
31
+ this.name = 'SyncFailure'
32
+ this.details = details
33
+ }
34
+ }
35
+
36
+ function gitEnvironment() {
37
+ const env = { ...process.env, GIT_TERMINAL_PROMPT: '0', LC_ALL: 'C', LANG: 'C' }
38
+ // Never let a missing/passphrase-protected key block the host process on an
39
+ // interactive prompt; the failure is reported back to the caller instead.
40
+ if (!env.GIT_SSH_COMMAND) {
41
+ env.GIT_SSH_COMMAND = 'ssh -o BatchMode=yes -o ConnectTimeout=15'
42
+ }
43
+ return env
44
+ }
45
+
46
+ function gitMessage(result) {
47
+ const text = (result.stderr || result.stdout || result.message || '').trim()
48
+ return text || `git exited with code ${String(result.code)}`
49
+ }
50
+
51
+ /**
52
+ * Run one git command.
53
+ *
54
+ * @param {string[]} args
55
+ * @param {{ cwd?: string, allowFailure?: boolean, timeout?: number }} [options]
56
+ * @returns {Promise<{ ok: boolean, stdout: string, stderr: string, code?: unknown, message?: string }>}
57
+ */
58
+ async function runGit(args, options = {}) {
59
+ const { cwd, allowFailure = false, timeout = GIT_TIMEOUT_MS } = options
60
+ try {
61
+ const { stdout, stderr } = await execFileAsync('git', args, {
62
+ cwd,
63
+ env: gitEnvironment(),
64
+ timeout,
65
+ maxBuffer: MAX_BUFFER,
66
+ windowsHide: true,
67
+ })
68
+ return { ok: true, stdout: stdout.trimEnd(), stderr: stderr.trimEnd() }
69
+ } catch (error) {
70
+ const result = {
71
+ ok: false,
72
+ stdout: typeof error?.stdout === 'string' ? error.stdout.trimEnd() : '',
73
+ stderr: typeof error?.stderr === 'string' ? error.stderr.trimEnd() : '',
74
+ code: error?.code,
75
+ message: error?.message ?? String(error),
76
+ }
77
+ if (allowFailure) return result
78
+ throw new SyncFailure(gitMessage(result), result)
79
+ }
80
+ }
81
+
82
+ async function pathExists(target) {
83
+ try {
84
+ await fs.access(target)
85
+ return true
86
+ } catch {
87
+ return false
88
+ }
89
+ }
90
+
91
+ function isRemoteRefMissing(result) {
92
+ return /couldn't find remote ref|could not find remote ref|unknown revision/i.test(
93
+ `${result.stderr}\n${result.stdout}`,
94
+ )
95
+ }
96
+
97
+ /** Reject a URL that git would misread or that spans multiple arguments. */
98
+ export function validateRemote(remote) {
99
+ if (typeof remote !== 'string' || remote.trim() === '') {
100
+ throw new SyncFailure('Set a git repository URL first.')
101
+ }
102
+ const value = remote.trim()
103
+ if (value.length > 2048) throw new SyncFailure('The git URL is too long.')
104
+ if (/\s/.test(value)) throw new SyncFailure('The git URL must not contain whitespace.')
105
+ if (value.startsWith('-')) throw new SyncFailure('The git URL must not start with "-".')
106
+ const looksLikeUrl = /^(https?|ssh|git|file):\/\//i.test(value)
107
+ const looksLikeScp = /^[^@/\s]+@[^:/\s]+:.+$/.test(value)
108
+ const looksLikePath = path.isAbsolute(value) || /^[A-Za-z]:[\\/]/.test(value)
109
+ if (!looksLikeUrl && !looksLikeScp && !looksLikePath) {
110
+ throw new SyncFailure('Use an https/ssh URL, user@host:path, or an absolute path.')
111
+ }
112
+ return value
113
+ }
114
+
115
+ /** Validate a branch name without relying on `git check-ref-format`. */
116
+ export function validateBranch(branch) {
117
+ const value = typeof branch === 'string' && branch.trim() !== '' ? branch.trim() : 'main'
118
+ if (!BRANCH_PATTERN.test(value)
119
+ || value.includes('..')
120
+ || value.endsWith('/')
121
+ || value.endsWith('.lock')
122
+ || value.includes('//')) {
123
+ throw new SyncFailure('Invalid branch name.')
124
+ }
125
+ return value
126
+ }
127
+
128
+ async function writeJsonAtomic(file, value) {
129
+ const temporary = `${file}.${process.pid}.${Date.now()}.tmp`
130
+ await fs.writeFile(temporary, `${JSON.stringify(value, null, 2)}\n`, { encoding: 'utf8', mode: 0o600 })
131
+ await fs.rename(temporary, file)
132
+ }
133
+
134
+ async function readJson(file) {
135
+ try {
136
+ return JSON.parse(await fs.readFile(file, 'utf8'))
137
+ } catch {
138
+ return undefined
139
+ }
140
+ }
141
+
142
+ function summarize(files) {
143
+ let bytes = 0
144
+ const sessions = new Set()
145
+ for (const file of files) {
146
+ sessions.add(`${file.project}/${file.session}`)
147
+ bytes += file.bytes ?? 0
148
+ }
149
+ return { sessions: sessions.size, files: files.length, bytes }
150
+ }
151
+
152
+ /**
153
+ * One settings + mirror owner shared by the `/sync` command and the settings
154
+ * page. Mutating operations are serialized so two clicks cannot race on the
155
+ * same work tree.
156
+ */
157
+ export class SessionGitSync {
158
+ constructor({ home, logger, flush } = {}) {
159
+ this.home = home ?? (process.env.DSH_HOME || path.join(os.homedir(), '.dsh'))
160
+ this.sessionsRoot = path.join(this.home, 'sessions')
161
+ this.dataDir = path.join(this.home, 'dsh-sync')
162
+ this.repoDir = path.join(this.dataDir, 'repo')
163
+ this.settingsPath = path.join(this.dataDir, 'settings.json')
164
+ this.statePath = path.join(this.dataDir, 'state.json')
165
+ this.logger = logger ?? console
166
+ this.flush = typeof flush === 'function' ? flush : async () => {}
167
+ this.queue = Promise.resolve()
168
+ this.gitVersionPromise = undefined
169
+ }
170
+
171
+ /** Serialize mutating operations; reads stay independent. */
172
+ enqueue(task) {
173
+ const run = this.queue.then(task, task)
174
+ this.queue = run.then(() => undefined, () => undefined)
175
+ return run
176
+ }
177
+
178
+ async readSettings() {
179
+ const stored = await readJson(this.settingsPath)
180
+ return {
181
+ remote: typeof stored?.remote === 'string' ? stored.remote : '',
182
+ branch: typeof stored?.branch === 'string' && stored.branch ? stored.branch : 'main',
183
+ }
184
+ }
185
+
186
+ async readState() {
187
+ const stored = await readJson(this.statePath)
188
+ return {
189
+ lastPushAt: typeof stored?.lastPushAt === 'string' ? stored.lastPushAt : null,
190
+ lastPushOk: typeof stored?.lastPushOk === 'boolean' ? stored.lastPushOk : null,
191
+ lastPushSummary: typeof stored?.lastPushSummary === 'string' ? stored.lastPushSummary : null,
192
+ lastPullAt: typeof stored?.lastPullAt === 'string' ? stored.lastPullAt : null,
193
+ lastPullOk: typeof stored?.lastPullOk === 'boolean' ? stored.lastPullOk : null,
194
+ lastPullSummary: typeof stored?.lastPullSummary === 'string' ? stored.lastPullSummary : null,
195
+ lastError: typeof stored?.lastError === 'string' ? stored.lastError : null,
196
+ }
197
+ }
198
+
199
+ async recordState(patch) {
200
+ const next = { ...(await this.readState()), ...patch }
201
+ await fs.mkdir(this.dataDir, { recursive: true })
202
+ await writeJsonAtomic(this.statePath, next)
203
+ }
204
+
205
+ async gitVersion() {
206
+ if (this.gitVersionPromise === undefined) {
207
+ this.gitVersionPromise = (async () => {
208
+ const result = await runGit(['--version'], { cwd: this.home, allowFailure: true, timeout: 10_000 })
209
+ if (!result.ok) return { available: false, version: null }
210
+ return { available: true, version: result.stdout.replace(/^git version\s*/i, '') || result.stdout }
211
+ })()
212
+ }
213
+ return this.gitVersionPromise
214
+ }
215
+
216
+ /** Session log files under one root, sorted for deterministic commits. */
217
+ async collectSessionFiles(root) {
218
+ const files = []
219
+ let projectEntries
220
+ try {
221
+ projectEntries = await fs.readdir(root, { withFileTypes: true })
222
+ } catch {
223
+ return files
224
+ }
225
+ for (const projectEntry of projectEntries.sort((a, b) => a.name.localeCompare(b.name))) {
226
+ if (!projectEntry.isDirectory() || projectEntry.name.startsWith('.')) continue
227
+ const projectDir = path.join(root, projectEntry.name)
228
+ let sessionEntries
229
+ try {
230
+ sessionEntries = await fs.readdir(projectDir, { withFileTypes: true })
231
+ } catch {
232
+ continue
233
+ }
234
+ for (const sessionEntry of sessionEntries.sort((a, b) => a.name.localeCompare(b.name))) {
235
+ if (!sessionEntry.isDirectory() || sessionEntry.name.startsWith('.')) continue
236
+ const sessionDir = path.join(projectDir, sessionEntry.name)
237
+ let fileEntries
238
+ try {
239
+ fileEntries = await fs.readdir(sessionDir, { withFileTypes: true })
240
+ } catch {
241
+ continue
242
+ }
243
+ for (const fileEntry of fileEntries) {
244
+ if (!fileEntry.isFile() || !SESSION_FILE_PATTERN.test(fileEntry.name)) continue
245
+ const abs = path.join(sessionDir, fileEntry.name)
246
+ let bytes = 0
247
+ try {
248
+ bytes = (await fs.stat(abs)).size
249
+ } catch {
250
+ continue
251
+ }
252
+ files.push({ project: projectEntry.name, session: sessionEntry.name, file: fileEntry.name, abs, bytes })
253
+ }
254
+ }
255
+ }
256
+ return files
257
+ }
258
+
259
+ async status() {
260
+ const [settings, state, localFiles, mirrorFiles, git] = await Promise.all([
261
+ this.readSettings(),
262
+ this.readState(),
263
+ this.collectSessionFiles(this.sessionsRoot),
264
+ this.collectSessionFiles(path.join(this.repoDir, 'sessions')),
265
+ this.gitVersion(),
266
+ ])
267
+ const initialized = await pathExists(path.join(this.repoDir, '.git'))
268
+ let lastCommit = null
269
+ if (initialized) {
270
+ const log = await runGit(['log', '-1', '--format=%cI'], { cwd: this.repoDir, allowFailure: true })
271
+ lastCommit = log.ok && log.stdout !== '' ? log.stdout : null
272
+ }
273
+ return {
274
+ configured: settings.remote !== '',
275
+ remote: settings.remote,
276
+ branch: settings.branch,
277
+ repoDir: this.repoDir,
278
+ sessionsRoot: this.sessionsRoot,
279
+ local: summarize(localFiles),
280
+ mirror: { initialized, ...summarize(mirrorFiles), lastCommit },
281
+ state,
282
+ git,
283
+ }
284
+ }
285
+
286
+ async save(remote, branch) {
287
+ const normalizedRemote = typeof remote === 'string' ? remote.trim() : ''
288
+ const normalizedBranch = validateBranch(branch)
289
+ if (normalizedRemote !== '') validateRemote(normalizedRemote)
290
+ return this.enqueue(async () => {
291
+ await fs.mkdir(this.dataDir, { recursive: true })
292
+ await writeJsonAtomic(this.settingsPath, {
293
+ remote: normalizedRemote,
294
+ branch: normalizedBranch,
295
+ })
296
+ return {
297
+ ok: true,
298
+ settings: { remote: normalizedRemote, branch: normalizedBranch },
299
+ }
300
+ })
301
+ }
302
+
303
+ async ensureRepository(settings) {
304
+ await fs.mkdir(this.repoDir, { recursive: true })
305
+ const initialized = await pathExists(path.join(this.repoDir, '.git'))
306
+ if (!initialized) {
307
+ await runGit(['init'], { cwd: this.repoDir })
308
+ // Keep the unborn branch aligned with the configured branch so the first
309
+ // commit lands on the right name.
310
+ await runGit(['symbolic-ref', 'HEAD', `refs/heads/${settings.branch}`], {
311
+ cwd: this.repoDir,
312
+ allowFailure: true,
313
+ })
314
+ }
315
+ await runGit(['config', 'user.name', 'DSH Session Sync'], { cwd: this.repoDir, allowFailure: true })
316
+ await runGit(['config', 'user.email', 'dsh-sync@localhost'], { cwd: this.repoDir, allowFailure: true })
317
+
318
+ const existing = await runGit(['remote', 'get-url', 'origin'], { cwd: this.repoDir, allowFailure: true })
319
+ if (!existing.ok) {
320
+ await runGit(['remote', 'add', 'origin', settings.remote], { cwd: this.repoDir })
321
+ } else if (existing.stdout.trim() !== settings.remote) {
322
+ await runGit(['remote', 'set-url', 'origin', settings.remote], { cwd: this.repoDir })
323
+ }
324
+ }
325
+
326
+ /**
327
+ * Fetch the configured branch.
328
+ *
329
+ * @returns {Promise<{ remoteHasBranch: boolean }>}
330
+ */
331
+ async fetchRemoteBranch(settings) {
332
+ const fetched = await runGit(['fetch', '--prune', 'origin', settings.branch], {
333
+ cwd: this.repoDir,
334
+ allowFailure: true,
335
+ })
336
+ if (!fetched.ok) {
337
+ if (isRemoteRefMissing(fetched)) return { remoteHasBranch: false }
338
+ throw new SyncFailure(gitMessage(fetched), fetched)
339
+ }
340
+ const ref = await runGit(['rev-parse', '--verify', `refs/remotes/origin/${settings.branch}`], {
341
+ cwd: this.repoDir,
342
+ allowFailure: true,
343
+ })
344
+ return { remoteHasBranch: ref.ok }
345
+ }
346
+
347
+ async writeMirrorFiles(files) {
348
+ for (const file of files) {
349
+ const destination = path.join(this.repoDir, 'sessions', file.project, file.session, file.file)
350
+ await fs.mkdir(path.dirname(destination), { recursive: true, mode: 0o700 })
351
+ await fs.copyFile(file.abs, destination)
352
+ await fs.chmod(destination, 0o600).catch(() => {})
353
+ }
354
+ }
355
+
356
+ async writeMirrorDocs() {
357
+ const readme = path.join(this.repoDir, 'README.md')
358
+ if (!(await pathExists(readme))) {
359
+ await fs.writeFile(
360
+ readme,
361
+ [
362
+ '# DSH session mirror',
363
+ '',
364
+ 'This repository is written by the `dsh-sync` plugin for DeepSeek Harness.',
365
+ 'It mirrors `$DSH_HOME/sessions` so sessions can move between devices.',
366
+ '',
367
+ '- `sync push` copies local session logs here and pushes the branch.',
368
+ '- `sync pull` fast-forwards this mirror and copies sessions that are not',
369
+ ' present on the local device (existing local sessions are never replaced).',
370
+ '',
371
+ ].join('\n'),
372
+ 'utf8',
373
+ )
374
+ }
375
+ const ignore = path.join(this.repoDir, '.gitignore')
376
+ if (!(await pathExists(ignore))) {
377
+ await fs.writeFile(ignore, '*.lock\n.DS_Store\n', 'utf8')
378
+ }
379
+ }
380
+
381
+ async flushSessions() {
382
+ try {
383
+ await this.flush()
384
+ return null
385
+ } catch (error) {
386
+ const message = error instanceof Error ? error.message : String(error)
387
+ this.logger?.warn?.(`dsh-sync: session flush before push failed: ${message}`)
388
+ return message
389
+ }
390
+ }
391
+
392
+ /**
393
+ * Copy local session logs into the mirror, commit, and push.
394
+ *
395
+ * A rejected non-fast-forward push is retried once against a freshly fetched
396
+ * remote; local files win for session logs that exist on both sides.
397
+ */
398
+ async push() {
399
+ return this.enqueue(async () => {
400
+ const settings = await this.readSettings()
401
+ if (settings.remote === '') {
402
+ return { ok: false, code: 'not-configured', message: 'Set a git repository URL first.' }
403
+ }
404
+ const flushWarning = await this.flushSessions()
405
+ await this.ensureRepository(settings)
406
+ const localFiles = await this.collectSessionFiles(this.sessionsRoot)
407
+ const local = summarize(localFiles)
408
+ const stamp = new Date().toISOString()
409
+
410
+ for (let attempt = 0; attempt < 2; attempt += 1) {
411
+ const { remoteHasBranch } = await this.fetchRemoteBranch(settings)
412
+
413
+ await runGit(['reset', '--hard'], { cwd: this.repoDir, allowFailure: true })
414
+ await runGit(['clean', '-fd'], { cwd: this.repoDir })
415
+ if (remoteHasBranch) {
416
+ await runGit(['checkout', '-B', settings.branch, `origin/${settings.branch}`], { cwd: this.repoDir })
417
+ } else {
418
+ await runGit(['checkout', '-B', settings.branch], { cwd: this.repoDir })
419
+ }
420
+
421
+ await this.writeMirrorFiles(localFiles)
422
+ await this.writeMirrorDocs()
423
+ await runGit(['add', '-A'], { cwd: this.repoDir })
424
+
425
+ const porcelain = await runGit(['status', '--porcelain'], { cwd: this.repoDir })
426
+ let changed = porcelain.stdout.trim() !== ''
427
+ if (changed) {
428
+ await runGit(
429
+ ['commit', '-m', `sync: ${os.hostname()} ${stamp}`],
430
+ { cwd: this.repoDir },
431
+ )
432
+ } else if (!remoteHasBranch) {
433
+ // An empty session store still needs one commit so the branch exists.
434
+ await runGit(
435
+ ['commit', '--allow-empty', '-m', `init: ${os.hostname()} ${stamp}`],
436
+ { cwd: this.repoDir },
437
+ )
438
+ changed = true
439
+ }
440
+
441
+ if (remoteHasBranch && !changed) {
442
+ const summary = `already up to date (${local.sessions} sessions)`
443
+ await this.recordState({
444
+ lastPushAt: new Date().toISOString(),
445
+ lastPushOk: true,
446
+ lastPushSummary: summary,
447
+ lastError: null,
448
+ })
449
+ return {
450
+ ok: true,
451
+ pushed: false,
452
+ changed: false,
453
+ sessions: local.sessions,
454
+ files: local.files,
455
+ warning: flushWarning,
456
+ }
457
+ }
458
+
459
+ const pushed = await runGit(['push', 'origin', `HEAD:${settings.branch}`], {
460
+ cwd: this.repoDir,
461
+ allowFailure: true,
462
+ })
463
+ if (pushed.ok) {
464
+ const summary = `pushed ${local.sessions} sessions (${local.files} files)`
465
+ await this.recordState({
466
+ lastPushAt: new Date().toISOString(),
467
+ lastPushOk: true,
468
+ lastPushSummary: summary,
469
+ lastError: null,
470
+ })
471
+ return {
472
+ ok: true,
473
+ pushed: true,
474
+ changed,
475
+ sessions: local.sessions,
476
+ files: local.files,
477
+ warning: flushWarning,
478
+ }
479
+ }
480
+
481
+ const race = /non-fast-forward|fetch first|\[rejected\]|remote rejected/i.test(
482
+ `${pushed.stderr}\n${pushed.stdout}`,
483
+ )
484
+ if (attempt === 0 && race) continue
485
+ const message = gitMessage(pushed)
486
+ await this.recordState({
487
+ lastPushAt: new Date().toISOString(),
488
+ lastPushOk: false,
489
+ lastPushSummary: null,
490
+ lastError: message,
491
+ })
492
+ throw new SyncFailure(message, pushed)
493
+ }
494
+
495
+ throw new SyncFailure('git push failed after retrying.')
496
+ })
497
+ }
498
+
499
+ /**
500
+ * Fast-forward the mirror and copy sessions that do not exist locally.
501
+ * Existing local logs always win; the skipped count is reported so the user
502
+ * knows a manual merge may be needed.
503
+ */
504
+ async pull() {
505
+ return this.enqueue(async () => {
506
+ const settings = await this.readSettings()
507
+ if (settings.remote === '') {
508
+ return { ok: false, code: 'not-configured', message: 'Set a git repository URL first.' }
509
+ }
510
+ await this.ensureRepository(settings)
511
+ const { remoteHasBranch } = await this.fetchRemoteBranch(settings)
512
+ if (!remoteHasBranch) {
513
+ return {
514
+ ok: false,
515
+ code: 'empty-remote',
516
+ message: 'The remote repository has no session branch yet. Run push on a device first.',
517
+ }
518
+ }
519
+
520
+ await runGit(['reset', '--hard'], { cwd: this.repoDir, allowFailure: true })
521
+ await runGit(['clean', '-fd'], { cwd: this.repoDir })
522
+ await runGit(['checkout', '-B', settings.branch, `origin/${settings.branch}`], { cwd: this.repoDir })
523
+ await runGit(['reset', '--hard', `origin/${settings.branch}`], { cwd: this.repoDir })
524
+
525
+ const mirrorFiles = await this.collectSessionFiles(path.join(this.repoDir, 'sessions'))
526
+ const addedSessions = new Set()
527
+ let addedFiles = 0
528
+ let skippedFiles = 0
529
+
530
+ for (const file of mirrorFiles) {
531
+ const destination = path.join(this.sessionsRoot, file.project, file.session, file.file)
532
+ if (await pathExists(destination)) {
533
+ skippedFiles += 1
534
+ continue
535
+ }
536
+ await fs.mkdir(path.dirname(destination), { recursive: true, mode: 0o700 })
537
+ await fs.copyFile(file.abs, destination)
538
+ await fs.chmod(destination, 0o600).catch(() => {})
539
+ addedFiles += 1
540
+ addedSessions.add(`${file.project}/${file.session}`)
541
+ }
542
+
543
+ const summary = `pulled ${addedSessions.size} sessions (${addedFiles} files), skipped ${skippedFiles} existing`
544
+ await this.recordState({
545
+ lastPullAt: new Date().toISOString(),
546
+ lastPullOk: true,
547
+ lastPullSummary: summary,
548
+ lastError: null,
549
+ })
550
+ return {
551
+ ok: true,
552
+ addedSessions: addedSessions.size,
553
+ addedFiles,
554
+ skippedFiles,
555
+ }
556
+ })
557
+ }
558
+
559
+ /** Probe a remote without saving it. */
560
+ async test(remote) {
561
+ let normalized
562
+ try {
563
+ normalized = validateRemote(remote)
564
+ } catch (error) {
565
+ return { ok: false, message: error instanceof Error ? error.message : String(error) }
566
+ }
567
+ const result = await runGit(['ls-remote', '--heads', normalized], {
568
+ cwd: this.home,
569
+ allowFailure: true,
570
+ timeout: TEST_TIMEOUT_MS,
571
+ })
572
+ if (!result.ok) return { ok: false, message: gitMessage(result) }
573
+ const branches = result.stdout
574
+ .split('\n')
575
+ .map((line) => line.trim().split(/\s+/)[1])
576
+ .filter((ref) => typeof ref === 'string' && ref.startsWith('refs/heads/'))
577
+ .map((ref) => ref.slice('refs/heads/'.length))
578
+ .filter((name) => name !== '')
579
+ return { ok: true, branches }
580
+ }
581
+ }
package/locale/en.json ADDED
@@ -0,0 +1,6 @@
1
+ {
2
+ "meta": {
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."
5
+ }
6
+ }
package/locale/zh.json ADDED
@@ -0,0 +1,6 @@
1
+ {
2
+ "meta": {
3
+ "title": "会话 Git 同步",
4
+ "description": "把 DSH 会话镜像到 Git 仓库,在多个设备之间共享:/sync、/sync pull、/sync push,并在设置页面中配置仓库地址。"
5
+ }
6
+ }
package/package.json CHANGED
@@ -1,6 +1,62 @@
1
1
  {
2
2
  "name": "@code_tin/dsh-sync",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.0",
4
+ "description": "Sync DeepSeek Harness sessions across devices through a git repository, with a Settings page and /sync, /sync pull, /sync push commands.",
5
+ "license": "MIT",
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "git+https://github.com/codetin/dsh-sync.git"
9
+ },
10
+ "homepage": "https://github.com/codetin/dsh-sync#readme",
11
+ "bugs": {
12
+ "url": "https://github.com/codetin/dsh-sync/issues"
13
+ },
14
+ "type": "module",
15
+ "main": "./index.js",
16
+ "exports": {
17
+ ".": "./index.js",
18
+ "./client": "./client.js",
19
+ "./locale/*.json": "./locale/*.json",
20
+ "./icon": "./icon.svg",
21
+ "./package.json": "./package.json"
22
+ },
23
+ "files": [
24
+ "index.js",
25
+ "client.js",
26
+ "lib/*.js",
27
+ "cordis.patch.yml",
28
+ "locale/*.json",
29
+ "icon.svg",
30
+ "README.md",
31
+ "LICENSE"
32
+ ],
33
+ "keywords": [
34
+ "dsh",
35
+ "dsh-plugin",
36
+ "deepseek-harness",
37
+ "session",
38
+ "sync",
39
+ "git"
40
+ ],
41
+ "dsh": {
42
+ "bundle": {
43
+ "patch": "./cordis.patch.yml"
44
+ },
45
+ "client": {
46
+ "platform": "web",
47
+ "immediately": true,
48
+ "inject": [
49
+ "@deepseek-ai/dsh-client-connection",
50
+ "@deepseek-ai/dsh-client-locale",
51
+ "@deepseek-ai/dsh-client-ui-slots",
52
+ "@deepseek-ai/dsh-client-ui-settings"
53
+ ]
54
+ }
55
+ },
56
+ "scripts": {
57
+ "test": "node --test test/*.test.mjs"
58
+ },
59
+ "publishConfig": {
60
+ "access": "public"
61
+ }
62
+ }