@xxgxiaoyu/dsh-session-sync 0.0.0-stage → 0.8.3

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/state.js ADDED
@@ -0,0 +1,236 @@
1
+ /**
2
+ * Local state for dsh-session-sync.
3
+ *
4
+ * The state file holds exactly the facts that must survive restarts on THIS
5
+ * machine:
6
+ * - self: this device's stable id and display name
7
+ * - peers: everything learned about other devices, keyed by device id.
8
+ * `customName` lives here only — it never leaves this machine,
9
+ * so renaming a peer is private and cached across reboots,
10
+ * and survives the peer going offline and coming back.
11
+ * - imports: which remote session is stored locally under which id, so
12
+ * the UI can show "已拉取" and skip duplicate pulls.
13
+ * - signatures: content fingerprints of every session imported here. Two
14
+ * devices that each pull the other's copies would otherwise
15
+ * keep re-importing the same conversation under new ids.
16
+ * - settings: auto-pull switches, discovery port, etc.
17
+ *
18
+ * Everything is written atomically (tmp + rename) with 0600 permissions.
19
+ */
20
+ import fs from 'node:fs'
21
+ import os from 'node:os'
22
+ import path from 'node:path'
23
+ import crypto from 'node:crypto'
24
+
25
+ /** Resolve the harness home directory (`$DSH_HOME` or `~/.dsh`). */
26
+ export function dshHome(config) {
27
+ if (typeof config?.home === 'string' && config.home.length > 0) return config.home
28
+ return process.env.DSH_HOME || path.join(os.homedir(), '.dsh')
29
+ }
30
+
31
+ /** Fresh state for a machine that has never run the plugin. */
32
+ export function defaultState() {
33
+ return {
34
+ v: 1,
35
+ self: {
36
+ id: crypto.randomUUID(),
37
+ name: defaultDeviceName(),
38
+ },
39
+ peers: {},
40
+ imports: {},
41
+ signatures: {},
42
+ settings: {
43
+ autoPull: false,
44
+ autoPush: false,
45
+ // UI language: 'auto' follows the DSH locale (which itself falls back to
46
+ // the browser/OS language), 'zh' / 'en' pin the plugin's own copy.
47
+ locale: 'auto',
48
+ discoveryPort: 47801,
49
+ lanPort: 47802,
50
+ seeds: [],
51
+ // Where auto-pull stores what it fetches: an absolute directory
52
+ // (an existing workspace, or any folder the user created). Empty means
53
+ // "未分组" — imports land in the plugin's own unsorted folder, which no
54
+ // workspace owns, so DSH shows them under 未分组.
55
+ syncDir: '',
56
+ // Extra CIDRs / IPs that broadcast cannot reach (other subnets, VPN,
57
+ // routed VLANs). The scanner unicasts a hello to every host in them, which
58
+ // is the only way to find a peer across a router.
59
+ subnets: [],
60
+ // How many sessions transfer at once. 0 = pick from the machine
61
+ // (see resolveConcurrency on the host); 1..16 is an explicit cap.
62
+ concurrency: 0,
63
+ },
64
+ }
65
+ }
66
+
67
+ /** `10.0.0.0/24`, `192.168.5.7` — anything the sweeper can expand. */
68
+ function isCidrOrIp(value) {
69
+ const [address, prefix] = value.trim().split('/')
70
+ if (prefix !== undefined && !/^\d{1,2}$/.test(prefix)) return false
71
+ const parts = address.split('.')
72
+ if (parts.length !== 4) return false
73
+ return parts.every((part) => /^\d{1,3}$/.test(part) && Number(part) <= 255)
74
+ }
75
+
76
+ function defaultDeviceName() {
77
+ const host = os.hostname().replace(/\.local$/i, '')
78
+ return host || 'DSH 设备'
79
+ }
80
+
81
+ function isPlainObject(value) {
82
+ return typeof value === 'object' && value !== null && !Array.isArray(value)
83
+ }
84
+
85
+ /** Merge persisted state over the defaults so new fields appear on upgrade. */
86
+ function normalize(raw) {
87
+ const base = defaultState()
88
+ if (!isPlainObject(raw)) return base
89
+ const self = isPlainObject(raw.self) ? raw.self : {}
90
+ const peers = isPlainObject(raw.peers) ? raw.peers : {}
91
+ const imports = isPlainObject(raw.imports) ? raw.imports : {}
92
+ const signatures = isPlainObject(raw.signatures) ? raw.signatures : {}
93
+ const settings = isPlainObject(raw.settings) ? raw.settings : {}
94
+ return {
95
+ v: 1,
96
+ self: {
97
+ id: typeof self.id === 'string' && self.id.length > 0 ? self.id : base.self.id,
98
+ name: typeof self.name === 'string' && self.name.trim().length > 0 ? self.name.trim() : base.self.name,
99
+ },
100
+ peers: Object.fromEntries(
101
+ Object.entries(peers).filter(([id, rec]) => typeof id === 'string' && isPlainObject(rec)),
102
+ ),
103
+ imports: Object.fromEntries(
104
+ Object.entries(imports).filter(([key, rec]) => typeof key === 'string' && isPlainObject(rec)),
105
+ ),
106
+ signatures: Object.fromEntries(
107
+ Object.entries(signatures).filter(([key, value]) => typeof key === 'string' && typeof value === 'string'),
108
+ ),
109
+ settings: { ...base.settings, ...pickSettings(settings) },
110
+ }
111
+ }
112
+
113
+ function pickSettings(settings) {
114
+ const out = {}
115
+ if (settings.locale === 'auto' || settings.locale === 'zh' || settings.locale === 'en') out.locale = settings.locale
116
+ if (typeof settings.autoPull === 'boolean') out.autoPull = settings.autoPull
117
+ if (typeof settings.autoPush === 'boolean') out.autoPush = settings.autoPush
118
+ if (Number.isInteger(settings.discoveryPort) && settings.discoveryPort > 0 && settings.discoveryPort < 65536) {
119
+ out.discoveryPort = settings.discoveryPort
120
+ }
121
+ if (Number.isInteger(settings.lanPort) && settings.lanPort > 0 && settings.lanPort < 65536) {
122
+ out.lanPort = settings.lanPort
123
+ }
124
+ if (typeof settings.syncDir === 'string' && (settings.syncDir === '' || /^(?:\/|[A-Za-z]:[\\/])/.test(settings.syncDir))) {
125
+ out.syncDir = settings.syncDir
126
+ }
127
+ if (Number.isInteger(settings.concurrency) && settings.concurrency >= 0 && settings.concurrency <= 16) {
128
+ out.concurrency = settings.concurrency
129
+ }
130
+ if (Array.isArray(settings.subnets)) {
131
+ out.subnets = settings.subnets
132
+ .filter((value) => typeof value === 'string' && isCidrOrIp(value))
133
+ .slice(0, 16)
134
+ }
135
+ if (Array.isArray(settings.seeds)) {
136
+ out.seeds = settings.seeds
137
+ .filter((value) => typeof value === 'string' && value.length > 0)
138
+ .slice(0, 32)
139
+ } else {
140
+ out.seeds = []
141
+ }
142
+ return out
143
+ }
144
+
145
+ /**
146
+ * Stores flush on process exit so a rename made less than one debounce window
147
+ * before the app is killed is not lost.
148
+ */
149
+ const liveStores = new Set()
150
+ process.once('exit', () => {
151
+ for (const store of liveStores) {
152
+ try {
153
+ store.saveNow()
154
+ } catch {
155
+ /* best effort during shutdown */
156
+ }
157
+ }
158
+ })
159
+
160
+ export class StateStore {
161
+ /**
162
+ * @param {{ dir: string }} options - `dir` is the plugin's own state directory.
163
+ */
164
+ constructor({ dir }) {
165
+ this.dir = dir
166
+ this.file = path.join(dir, 'state.json')
167
+ this.data = null
168
+ this.dirty = false
169
+ this._timer = null
170
+ this._lastWrite = 0
171
+ liveStores.add(this)
172
+ }
173
+
174
+ /** Drop the exit hook for a disposed plugin instance. */
175
+ dispose() {
176
+ if (this._timer !== null) {
177
+ clearTimeout(this._timer)
178
+ this._timer = null
179
+ }
180
+ liveStores.delete(this)
181
+ this.saveNow()
182
+ }
183
+
184
+ load() {
185
+ if (this.data !== null) return this.data
186
+ let raw
187
+ try {
188
+ raw = JSON.parse(fs.readFileSync(this.file, 'utf8'))
189
+ } catch {
190
+ raw = undefined
191
+ }
192
+ const existed = raw !== undefined
193
+ this.data = normalize(raw)
194
+ if (!existed) this.saveNow()
195
+ return this.data
196
+ }
197
+
198
+ /** Mutate in place, then schedule a debounced atomic write. */
199
+ update(fn) {
200
+ const data = this.load()
201
+ fn(data)
202
+ this.saveSoon()
203
+ return data
204
+ }
205
+
206
+ saveSoon(delay = 400) {
207
+ this.dirty = true
208
+ if (this._timer !== null) return
209
+ this._timer = setTimeout(() => {
210
+ this._timer = null
211
+ this.saveNow()
212
+ }, delay)
213
+ this._timer.unref?.()
214
+ }
215
+
216
+ saveNow() {
217
+ if (this.data === null) return
218
+ const text = `${JSON.stringify(this.data, null, 2)}\n`
219
+ try {
220
+ fs.mkdirSync(this.dir, { recursive: true, mode: 0o700 })
221
+ const tmp = path.join(this.dir, `.state-${process.pid}-${Date.now()}.tmp`)
222
+ fs.writeFileSync(tmp, text, { mode: 0o600 })
223
+ fs.renameSync(tmp, this.file)
224
+ this._lastWrite = Date.now()
225
+ this.dirty = false
226
+ } catch (error) {
227
+ // A failing state write must never take the session bus down; the next
228
+ // successful write retries with the in-memory value intact.
229
+ console.warn('[dsh-session-sync] 状态写入失败:', error?.message ?? error)
230
+ }
231
+ }
232
+
233
+ flush() {
234
+ this.saveNow()
235
+ }
236
+ }
package/package.json CHANGED
@@ -1,6 +1,66 @@
1
1
  {
2
2
  "name": "@xxgxiaoyu/dsh-session-sync",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.8.3",
4
+ "description": "DSH LAN session sync: auto-discover devices running this plugin, browse/pull/push sessions over HTTP, verify them with SHA-256, and keep a local-only nickname per device.",
5
+ "type": "module",
6
+ "main": "lib/index.js",
7
+ "exports": {
8
+ ".": "./lib/index.js",
9
+ "./client": "./lib/client.js",
10
+ "./package.json": "./package.json"
11
+ },
12
+ "files": [
13
+ "lib/",
14
+ "cordis.patch.yml",
15
+ "README.md",
16
+ "README_ZH.md",
17
+ "CHANGELOG.md",
18
+ "CHANGELOG_ZH.md",
19
+ "LICENSE",
20
+ "icon.svg",
21
+ "screenshots.json",
22
+ "scripts/repair-sessions.mjs"
23
+ ],
24
+ "engines": {
25
+ "node": ">=22"
26
+ },
27
+ "keywords": [
28
+ "dsh",
29
+ "dsh-plugin",
30
+ "deepseek-harness",
31
+ "deepseek",
32
+ "session-sync",
33
+ "lan",
34
+ "p2p",
35
+ "session"
36
+ ],
37
+ "dsh": {
38
+ "bundle": {
39
+ "patch": "./cordis.patch.yml"
40
+ },
41
+ "client": {
42
+ "platform": "web",
43
+ "inject": [
44
+ "@deepseek-ai/dsh-client-locale",
45
+ "@deepseek-ai/dsh-client-ui-slots"
46
+ ]
47
+ },
48
+ "manifestVersion": 1
49
+ },
50
+ "license": "MIT",
51
+ "devDependencies": {
52
+ "acorn": "^8.19.0",
53
+ "jsdom": "^30.1.3",
54
+ "react": "^19.3.1",
55
+ "react-dom": "^19.3.1"
56
+ },
57
+ "repository": {
58
+ "type": "git",
59
+ "url": "git+https://github.com/XXGxiaoyu/dsh-session-sync.git"
60
+ },
61
+ "homepage": "https://github.com/XXGxiaoyu/dsh-session-sync#readme",
62
+ "bugs": {
63
+ "url": "https://github.com/XXGxiaoyu/dsh-session-sync/issues"
64
+ },
65
+ "packageManager": "pnpm@11.15.1"
66
+ }
@@ -0,0 +1 @@
1
+ []
@@ -0,0 +1,31 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Repair session logs imported by plugin builds before 0.5.5.
4
+ *
5
+ * Run with DSH closed (or just reload the app afterwards):
6
+ * node scripts/repair-sessions.mjs # repair, keeping *.bak copies
7
+ * node scripts/repair-sessions.mjs --dry-run # only report what would change
8
+ * node scripts/repair-sessions.mjs --home ~/.dsh
9
+ */
10
+ import os from 'node:os'
11
+ import path from 'node:path'
12
+
13
+ import { repairStoredSessions } from '../lib/repair.js'
14
+
15
+ const args = process.argv.slice(2)
16
+ const dryRun = args.includes('--dry-run')
17
+ const homeFlag = args.indexOf('--home')
18
+ const home = homeFlag >= 0 && args[homeFlag + 1] ? args[homeFlag + 1] : process.env.DSH_HOME ?? path.join(os.homedir(), '.dsh')
19
+ const root = path.join(home, 'sessions')
20
+
21
+ const summary = repairStoredSessions(root, {
22
+ dryRun,
23
+ log: (message) => console.log(message),
24
+ })
25
+ console.log(
26
+ `${dryRun ? 'would repair' : 'repaired'} ${summary.repaired} of ${summary.scanned} stored session(s)` +
27
+ (summary.locked > 0 ? ` (${summary.locked} carried a lock marker)` : ''),
28
+ )
29
+ if (!dryRun && summary.repaired > 0) {
30
+ console.log('Restart DSH, then open the conversation again. Originals are kept as *.bak next to each log.')
31
+ }