dsh-remote 0.8.72 → 0.8.74

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.
Files changed (44) hide show
  1. package/LICENSE +21 -21
  2. package/README.en.md +470 -389
  3. package/README.md +529 -462
  4. package/cordis.patch.yml +9 -9
  5. package/lib/binding.js +172 -172
  6. package/lib/client.js +4039 -4022
  7. package/lib/credential.js +263 -263
  8. package/lib/errors.js +141 -141
  9. package/lib/exec-job.js +235 -235
  10. package/lib/file-reference.js +457 -457
  11. package/lib/forwards.js +258 -258
  12. package/lib/host-api.js +808 -0
  13. package/lib/hostkey.js +108 -108
  14. package/lib/http-transport.js +196 -196
  15. package/lib/ignore.js +202 -202
  16. package/lib/index.js +4923 -4541
  17. package/lib/instructions-dynamic.js +415 -415
  18. package/lib/openssh.js +879 -879
  19. package/lib/paths.js +252 -252
  20. package/lib/pool.js +963 -893
  21. package/lib/registry.js +296 -293
  22. package/lib/remote-fs.js +173 -173
  23. package/lib/remote-sandbox.js +183 -183
  24. package/lib/remote-shell-child.cjs +344 -305
  25. package/lib/remote-shell.js +542 -518
  26. package/lib/routes-fs.js +245 -245
  27. package/lib/routes-term.js +169 -169
  28. package/lib/search.js +235 -235
  29. package/lib/sftp-surface.js +119 -119
  30. package/lib/sshconfig.js +779 -705
  31. package/lib/sync.js +275 -275
  32. package/lib/tasks.js +95 -95
  33. package/lib/telemetry.js +213 -213
  34. package/lib/terminal.js +205 -205
  35. package/lib/tui/flow.js +130 -0
  36. package/lib/tui/format.js +332 -0
  37. package/lib/tui/i18n.js +344 -0
  38. package/lib/tui/index.js +322 -0
  39. package/lib/tui/provider.js +327 -0
  40. package/lib/tui/scene.js +996 -0
  41. package/lib/update.js +472 -472
  42. package/lib/web-attach.js +850 -850
  43. package/lib/workspace-cleanup.js +337 -0
  44. package/package.json +100 -99
package/lib/sync.js CHANGED
@@ -1,275 +1,275 @@
1
- // dsh-remote — three-way conflict-aware mirror sync (remote ⇄ local mirror).
2
- //
3
- // Why three-way: plain rsync-style pull/push silently clobbers whichever side
4
- // changed while the other side was edited. We keep a per-mirror snapshot of the
5
- // last-synced remote state (`.dsh-remote-sync-state.json`: relPath →
6
- // {mtime,size}) and compare remote (R), local (L) and last-synced (S):
7
- //
8
- // R==S && L==S → unchanged, skip
9
- // R!=S && L==S → remote changed → pull it (or push when pushing)
10
- // R==S && L!=S → local changed, remote untouched
11
- // • pull (rw_sync): would clobber local edit → conflict
12
- // • push (rw_push): normal upload
13
- // R!=S && L!=S && R==L → both changed to the same bytes → skip
14
- // R!=S && L!=S && R!=L → BOTH changed differently → conflict (never clobber)
15
- //
16
- // `force: true` downgrades every conflict to a plain overwrite (rsync behavior).
17
- // `dryRun: true` computes the full plan without writing anything.
18
- // After a successful pull the local mtime is aligned to the remote mtime, and
19
- // after a successful push the local mtime is aligned to the remote mtime the
20
- // server assigned — so the invariant L==R==S holds and the next run is cheap.
21
- import { readdirSync, statSync, mkdirSync, existsSync, readFileSync, writeFileSync, utimesSync, renameSync } from 'node:fs'
22
- import path from 'node:path'
23
- import { joinRemotePath, relPathUnder } from './paths.js'
24
-
25
- function mapLimit(items, limit, fn) {
26
- const limitN = Math.max(1, Math.min(Math.floor(limit) || 1, items.length))
27
- let next = 0
28
- const worker = async () => {
29
- while (next < items.length) {
30
- const i = next++
31
- try { await fn(items[i], i) } catch { /* per-item errors are the fn's concern */ }
32
- }
33
- }
34
- const workers = []
35
- for (let w = 0; w < limitN; w++) workers.push(worker())
36
- return Promise.all(workers)
37
- }
38
-
39
- const keyOf = (size, mtime) => `${size}:${Math.floor(mtime || 0)}`
40
- const localKey = (st) => (st ? keyOf(st.size, st.mtimeMs / 1000) : null)
41
- const remoteKey = (st) => (st ? keyOf(st.size, st.mtime) : null)
42
-
43
- /** Recursively pull remote → local mirror (three-way, ignore-aware). */
44
- export async function syncTree(sftp, remoteDir, localDir, opts = {}) {
45
- const {
46
- maxDepth = 8,
47
- maxFiles = 2000,
48
- maxFileBytes = 0,
49
- isIgnored = () => false,
50
- dryRun = false,
51
- force = false,
52
- state = {},
53
- } = opts
54
- const stats = { files: 0, dirs: 0, skippedUnchanged: 0, skippedLarge: 0, conflicts: [], staleRemote: 0, touched: [], truncated: false }
55
- const nextState = { ...state }
56
-
57
- const walk = async (rDir, lDir, depth) => {
58
- const entries = await sftp.readdir(rDir).then((l) => l || [], () => [])
59
- // Directories first (sequential, depth-bounded) so every file has a home.
60
- for (const e of entries) {
61
- const name = String(e.filename)
62
- if (name === '.' || name === '..') continue
63
- const isDir = !!(e.attrs && e.attrs.isDirectory && e.attrs.isDirectory())
64
- if (!isDir) continue
65
- if (depth <= 0 || stats.files >= maxFiles) {
66
- stats.truncated = true
67
- continue
68
- }
69
- const rp = joinRemotePath(rDir, name)
70
- const lp = path.join(lDir, name)
71
- const rel = relPathUnder(remoteDir, rp) || name
72
- if (isIgnored(rel, true)) continue
73
- if (!existsSync(lp)) mkdirSync(lp, { recursive: true })
74
- await walk(rp, lp, depth - 1)
75
- stats.dirs++
76
- if (stats.files >= maxFiles) break
77
- }
78
- if (stats.files >= maxFiles) { stats.truncated = true; return }
79
-
80
- const files = entries.filter(
81
- (e) => !(e.attrs && e.attrs.isDirectory && e.attrs.isDirectory()) &&
82
- String(e.filename) !== '.' && String(e.filename) !== '..',
83
- )
84
- await mapLimit(files, 4, async (e) => {
85
- if (stats.files >= maxFiles) { stats.truncated = true; return }
86
- const name = String(e.filename)
87
- const rp = joinRemotePath(rDir, name)
88
- const lp = path.join(lDir, name)
89
- const rel = relPathUnder(remoteDir, rp) || name
90
- if (isIgnored(rel, false)) return
91
- let r
92
- try { r = await sftp.stat(rp) } catch { return }
93
- if (maxFileBytes > 0 && r.size > maxFileBytes) { stats.skippedLarge++; return }
94
- const rk = remoteKey(r)
95
- const sk = state[rel] ? keyOf(state[rel].size, state[rel].mtime) : null
96
- let lk = null
97
- if (existsSync(lp)) {
98
- try { lk = localKey(statSync(lp)) } catch { lk = null }
99
- }
100
- if (sk && rk === sk && lk && lk === sk) { stats.skippedUnchanged++; return }
101
- if (!force && sk && rk !== sk && lk && lk !== sk && rk !== lk) {
102
- stats.conflicts.push({ path: rp, reason: 'both-modified(远端与本地都改过,跳过不覆盖)' })
103
- return
104
- }
105
- if (!force && sk && rk === sk && lk && lk !== sk) {
106
- // Pull would clobber a local edit; the remote never changed.
107
- stats.conflicts.push({ path: rp, reason: 'local-modified(本地改过而远端未变;如需覆盖请用 force / rw_push)' })
108
- return
109
- }
110
- if (dryRun) {
111
- stats.files++
112
- stats.touched.push(rp)
113
- nextState[rel] = { size: r.size, mtime: r.mtime }
114
- return
115
- }
116
- try {
117
- const buf = await sftp.readFile(rp)
118
- writeFileSync(lp, buf)
119
- try { utimesSync(lp, new Date(r.mtime * 1000), new Date(r.mtime * 1000)) } catch {}
120
- stats.files++
121
- stats.touched.push(rp)
122
- nextState[rel] = { size: r.size, mtime: r.mtime }
123
- } catch { /* skip unreadable */ }
124
- })
125
- }
126
-
127
- await walk(remoteDir, localDir, maxDepth)
128
- if (stats.files >= maxFiles) stats.truncated = true
129
- // Informational: remote entries in the snapshot that no longer exist remotely.
130
- for (const rel of Object.keys(state)) {
131
- if (state[rel] && !nextState[rel]) stats.staleRemote++
132
- }
133
- return { stats, nextState }
134
- }
135
-
136
- /** Recursively push local mirror → remote (three-way, ignore-aware). */
137
- export async function pushTree(sftp, localDir, remoteDir, opts = {}) {
138
- const {
139
- maxFiles = 2000,
140
- maxFileBytes = 0,
141
- isIgnored = () => false,
142
- dryRun = false,
143
- force = false,
144
- state = {},
145
- } = opts
146
- const stats = { files: 0, dirs: 0, skippedUnchanged: 0, skippedLarge: 0, conflicts: [], staleLocal: 0, pushed: [], truncated: false }
147
- const nextState = { ...state }
148
-
149
- const walk = async (lDir, rDir) => {
150
- const entries = readdirSync(lDir, { withFileTypes: true }).filter(
151
- (e) => e.name !== '.dsh-remote-meta.json' && e.name !== '.dsh-remote-sync-state.json',
152
- )
153
- for (const e of entries) {
154
- if (!e.isDirectory()) continue
155
- if (stats.files >= maxFiles) break
156
- const rp = joinRemotePath(rDir, e.name)
157
- const rel = relPathUnder(remoteDir, rp) || e.name
158
- if (isIgnored(rel, true)) continue
159
- try { await sftp.mkdir(rp) } catch { /* already exists */ }
160
- stats.dirs++
161
- await walk(path.join(lDir, e.name), rp)
162
- if (stats.files >= maxFiles) break
163
- }
164
- if (stats.files >= maxFiles) { stats.truncated = true; return }
165
-
166
- const files = entries.filter((e) => !e.isDirectory())
167
- await mapLimit(files, 4, async (e) => {
168
- if (stats.files >= maxFiles) { stats.truncated = true; return }
169
- const lp = path.join(lDir, e.name)
170
- const rp = joinRemotePath(rDir, e.name)
171
- const rel = relPathUnder(remoteDir, rp) || e.name
172
- if (isIgnored(rel, false)) return
173
- let l
174
- try { l = statSync(lp) } catch { return }
175
- if (maxFileBytes > 0 && l.size > maxFileBytes) { stats.skippedLarge++; return }
176
- const lk = localKey(l)
177
- let r = null
178
- try { r = await sftp.stat(rp) } catch { /* absent → upload */ }
179
- const rk = remoteKey(r)
180
- const sk = state[rel] ? keyOf(state[rel].size, state[rel].mtime) : null
181
- if (r && sk && lk === sk && rk === sk) { stats.skippedUnchanged++; return }
182
- if (r && !force && sk && rk !== sk && lk !== sk && rk !== lk) {
183
- stats.conflicts.push({ path: rp, reason: 'both-modified(远端与本地都改过,跳过不覆盖)' })
184
- return
185
- }
186
- if (r && !force && sk && rk !== sk && lk === sk) {
187
- stats.conflicts.push({ path: rp, reason: 'remote-modified(远端改过而本地未变;如需覆盖请用 force / 先 rw_sync)' })
188
- return
189
- }
190
- if (r && !force && sk && rk === sk && lk && lk !== sk) {
191
- // Normal case: local edited since sync, remote untouched → push.
192
- }
193
- if (dryRun) {
194
- stats.files++
195
- stats.pushed.push(rp)
196
- return
197
- }
198
- try {
199
- const buf = readFileSync(lp)
200
- await sftp.writeFile(rp, buf)
201
- // Align local mtime to whatever the remote server assigned, so the
202
- // L==R==S invariant holds for the next run.
203
- let r2 = null
204
- try { r2 = await sftp.stat(rp) } catch {}
205
- const rmt = r2 ? r2.mtime : Math.floor(Date.now() / 1000)
206
- try { utimesSync(lp, new Date(rmt * 1000), new Date(rmt * 1000)) } catch {}
207
- stats.files++
208
- stats.pushed.push(rp)
209
- nextState[rel] = { size: l.size, mtime: rmt }
210
- } catch { /* skip unwritable */ }
211
- })
212
- }
213
-
214
- await walk(localDir, remoteDir)
215
- if (stats.files >= maxFiles) stats.truncated = true
216
- for (const rel of Object.keys(state)) {
217
- if (state[rel] && !nextState[rel]) stats.staleLocal++
218
- }
219
- return { stats, nextState }
220
- }
221
-
222
- /** Load the sync-state snapshot from a mirror dir (or {}). */
223
- export function loadSyncState(localDir) {
224
- try {
225
- const j = JSON.parse(readFileSync(path.join(localDir, '.dsh-remote-sync-state.json'), 'utf8'))
226
- if (j && typeof j === 'object' && !Array.isArray(j)) return j
227
- } catch {}
228
- return {}
229
- }
230
-
231
- /** Persist the snapshot (atomic-ish: tmp + rename). */
232
- export function saveSyncState(localDir, state) {
233
- try {
234
- mkdirSync(localDir, { recursive: true })
235
- const tmp = path.join(localDir, '.dsh-remote-sync-state.json.tmp')
236
- writeFileSync(tmp, JSON.stringify(state, null, 1))
237
- renameSync(tmp, path.join(localDir, '.dsh-remote-sync-state.json'))
238
- } catch { /* best effort */ }
239
- }
240
-
241
- /** Push ONE local mirror file back to the remote (used by auto-push).
242
- * Same three-way guard as pushTree: never clobbers a remote change. Returns
243
- * { status: 'pushed'|'unchanged'|'conflict'|'skipped'|'missing' } and the
244
- * updated state delta ({rel: {size, mtime}} when pushed). */
245
- export async function pushOneFile(sftp, localDir, remoteDir, rel, opts = {}) {
246
- const { maxFileBytes = 0, isIgnored = () => false, force = false, state = {} } = opts
247
- if (isIgnored(rel, false)) return { status: 'skipped' }
248
- const lp = path.join(localDir, rel.replace(/\//g, path.sep))
249
- const rp = joinRemotePath(remoteDir, rel)
250
- let l
251
- try { l = statSync(lp) } catch { return { status: 'missing' } }
252
- if (maxFileBytes > 0 && l.size > maxFileBytes) return { status: 'skipped' }
253
- const lk = localKey(l)
254
- let r = null
255
- try { r = await sftp.stat(rp) } catch { /* absent → upload */ }
256
- const rk = remoteKey(r)
257
- const sk = state[rel] ? keyOf(state[rel].size, state[rel].mtime) : null
258
- if (r && sk && lk === sk && rk === sk) return { status: 'unchanged' }
259
- if (r && !force && sk && rk !== sk && lk !== sk && rk !== lk) {
260
- return { status: 'conflict', reason: 'both-modified' }
261
- }
262
- if (r && !force && sk && rk !== sk && lk === sk) {
263
- return { status: 'conflict', reason: 'remote-modified' }
264
- }
265
- try {
266
- await sftp.writeFile(rp, readFileSync(lp))
267
- let r2 = null
268
- try { r2 = await sftp.stat(rp) } catch {}
269
- const rmt = r2 ? r2.mtime : Math.floor(Date.now() / 1000)
270
- try { utimesSync(lp, new Date(rmt * 1000), new Date(rmt * 1000)) } catch {}
271
- return { status: 'pushed', state: { [rel]: { size: l.size, mtime: rmt } } }
272
- } catch (e) {
273
- return { status: 'skipped', error: String((e && e.message) || e) }
274
- }
275
- }
1
+ // dsh-remote — three-way conflict-aware mirror sync (remote ⇄ local mirror).
2
+ //
3
+ // Why three-way: plain rsync-style pull/push silently clobbers whichever side
4
+ // changed while the other side was edited. We keep a per-mirror snapshot of the
5
+ // last-synced remote state (`.dsh-remote-sync-state.json`: relPath →
6
+ // {mtime,size}) and compare remote (R), local (L) and last-synced (S):
7
+ //
8
+ // R==S && L==S → unchanged, skip
9
+ // R!=S && L==S → remote changed → pull it (or push when pushing)
10
+ // R==S && L!=S → local changed, remote untouched
11
+ // • pull (rw_sync): would clobber local edit → conflict
12
+ // • push (rw_push): normal upload
13
+ // R!=S && L!=S && R==L → both changed to the same bytes → skip
14
+ // R!=S && L!=S && R!=L → BOTH changed differently → conflict (never clobber)
15
+ //
16
+ // `force: true` downgrades every conflict to a plain overwrite (rsync behavior).
17
+ // `dryRun: true` computes the full plan without writing anything.
18
+ // After a successful pull the local mtime is aligned to the remote mtime, and
19
+ // after a successful push the local mtime is aligned to the remote mtime the
20
+ // server assigned — so the invariant L==R==S holds and the next run is cheap.
21
+ import { readdirSync, statSync, mkdirSync, existsSync, readFileSync, writeFileSync, utimesSync, renameSync } from 'node:fs'
22
+ import path from 'node:path'
23
+ import { joinRemotePath, relPathUnder } from './paths.js'
24
+
25
+ function mapLimit(items, limit, fn) {
26
+ const limitN = Math.max(1, Math.min(Math.floor(limit) || 1, items.length))
27
+ let next = 0
28
+ const worker = async () => {
29
+ while (next < items.length) {
30
+ const i = next++
31
+ try { await fn(items[i], i) } catch { /* per-item errors are the fn's concern */ }
32
+ }
33
+ }
34
+ const workers = []
35
+ for (let w = 0; w < limitN; w++) workers.push(worker())
36
+ return Promise.all(workers)
37
+ }
38
+
39
+ const keyOf = (size, mtime) => `${size}:${Math.floor(mtime || 0)}`
40
+ const localKey = (st) => (st ? keyOf(st.size, st.mtimeMs / 1000) : null)
41
+ const remoteKey = (st) => (st ? keyOf(st.size, st.mtime) : null)
42
+
43
+ /** Recursively pull remote → local mirror (three-way, ignore-aware). */
44
+ export async function syncTree(sftp, remoteDir, localDir, opts = {}) {
45
+ const {
46
+ maxDepth = 8,
47
+ maxFiles = 2000,
48
+ maxFileBytes = 0,
49
+ isIgnored = () => false,
50
+ dryRun = false,
51
+ force = false,
52
+ state = {},
53
+ } = opts
54
+ const stats = { files: 0, dirs: 0, skippedUnchanged: 0, skippedLarge: 0, conflicts: [], staleRemote: 0, touched: [], truncated: false }
55
+ const nextState = { ...state }
56
+
57
+ const walk = async (rDir, lDir, depth) => {
58
+ const entries = await sftp.readdir(rDir).then((l) => l || [], () => [])
59
+ // Directories first (sequential, depth-bounded) so every file has a home.
60
+ for (const e of entries) {
61
+ const name = String(e.filename)
62
+ if (name === '.' || name === '..') continue
63
+ const isDir = !!(e.attrs && e.attrs.isDirectory && e.attrs.isDirectory())
64
+ if (!isDir) continue
65
+ if (depth <= 0 || stats.files >= maxFiles) {
66
+ stats.truncated = true
67
+ continue
68
+ }
69
+ const rp = joinRemotePath(rDir, name)
70
+ const lp = path.join(lDir, name)
71
+ const rel = relPathUnder(remoteDir, rp) || name
72
+ if (isIgnored(rel, true)) continue
73
+ if (!existsSync(lp)) mkdirSync(lp, { recursive: true })
74
+ await walk(rp, lp, depth - 1)
75
+ stats.dirs++
76
+ if (stats.files >= maxFiles) break
77
+ }
78
+ if (stats.files >= maxFiles) { stats.truncated = true; return }
79
+
80
+ const files = entries.filter(
81
+ (e) => !(e.attrs && e.attrs.isDirectory && e.attrs.isDirectory()) &&
82
+ String(e.filename) !== '.' && String(e.filename) !== '..',
83
+ )
84
+ await mapLimit(files, 4, async (e) => {
85
+ if (stats.files >= maxFiles) { stats.truncated = true; return }
86
+ const name = String(e.filename)
87
+ const rp = joinRemotePath(rDir, name)
88
+ const lp = path.join(lDir, name)
89
+ const rel = relPathUnder(remoteDir, rp) || name
90
+ if (isIgnored(rel, false)) return
91
+ let r
92
+ try { r = await sftp.stat(rp) } catch { return }
93
+ if (maxFileBytes > 0 && r.size > maxFileBytes) { stats.skippedLarge++; return }
94
+ const rk = remoteKey(r)
95
+ const sk = state[rel] ? keyOf(state[rel].size, state[rel].mtime) : null
96
+ let lk = null
97
+ if (existsSync(lp)) {
98
+ try { lk = localKey(statSync(lp)) } catch { lk = null }
99
+ }
100
+ if (sk && rk === sk && lk && lk === sk) { stats.skippedUnchanged++; return }
101
+ if (!force && sk && rk !== sk && lk && lk !== sk && rk !== lk) {
102
+ stats.conflicts.push({ path: rp, reason: 'both-modified(远端与本地都改过,跳过不覆盖)' })
103
+ return
104
+ }
105
+ if (!force && sk && rk === sk && lk && lk !== sk) {
106
+ // Pull would clobber a local edit; the remote never changed.
107
+ stats.conflicts.push({ path: rp, reason: 'local-modified(本地改过而远端未变;如需覆盖请用 force / rw_push)' })
108
+ return
109
+ }
110
+ if (dryRun) {
111
+ stats.files++
112
+ stats.touched.push(rp)
113
+ nextState[rel] = { size: r.size, mtime: r.mtime }
114
+ return
115
+ }
116
+ try {
117
+ const buf = await sftp.readFile(rp)
118
+ writeFileSync(lp, buf)
119
+ try { utimesSync(lp, new Date(r.mtime * 1000), new Date(r.mtime * 1000)) } catch {}
120
+ stats.files++
121
+ stats.touched.push(rp)
122
+ nextState[rel] = { size: r.size, mtime: r.mtime }
123
+ } catch { /* skip unreadable */ }
124
+ })
125
+ }
126
+
127
+ await walk(remoteDir, localDir, maxDepth)
128
+ if (stats.files >= maxFiles) stats.truncated = true
129
+ // Informational: remote entries in the snapshot that no longer exist remotely.
130
+ for (const rel of Object.keys(state)) {
131
+ if (state[rel] && !nextState[rel]) stats.staleRemote++
132
+ }
133
+ return { stats, nextState }
134
+ }
135
+
136
+ /** Recursively push local mirror → remote (three-way, ignore-aware). */
137
+ export async function pushTree(sftp, localDir, remoteDir, opts = {}) {
138
+ const {
139
+ maxFiles = 2000,
140
+ maxFileBytes = 0,
141
+ isIgnored = () => false,
142
+ dryRun = false,
143
+ force = false,
144
+ state = {},
145
+ } = opts
146
+ const stats = { files: 0, dirs: 0, skippedUnchanged: 0, skippedLarge: 0, conflicts: [], staleLocal: 0, pushed: [], truncated: false }
147
+ const nextState = { ...state }
148
+
149
+ const walk = async (lDir, rDir) => {
150
+ const entries = readdirSync(lDir, { withFileTypes: true }).filter(
151
+ (e) => e.name !== '.dsh-remote-meta.json' && e.name !== '.dsh-remote-sync-state.json',
152
+ )
153
+ for (const e of entries) {
154
+ if (!e.isDirectory()) continue
155
+ if (stats.files >= maxFiles) break
156
+ const rp = joinRemotePath(rDir, e.name)
157
+ const rel = relPathUnder(remoteDir, rp) || e.name
158
+ if (isIgnored(rel, true)) continue
159
+ try { await sftp.mkdir(rp) } catch { /* already exists */ }
160
+ stats.dirs++
161
+ await walk(path.join(lDir, e.name), rp)
162
+ if (stats.files >= maxFiles) break
163
+ }
164
+ if (stats.files >= maxFiles) { stats.truncated = true; return }
165
+
166
+ const files = entries.filter((e) => !e.isDirectory())
167
+ await mapLimit(files, 4, async (e) => {
168
+ if (stats.files >= maxFiles) { stats.truncated = true; return }
169
+ const lp = path.join(lDir, e.name)
170
+ const rp = joinRemotePath(rDir, e.name)
171
+ const rel = relPathUnder(remoteDir, rp) || e.name
172
+ if (isIgnored(rel, false)) return
173
+ let l
174
+ try { l = statSync(lp) } catch { return }
175
+ if (maxFileBytes > 0 && l.size > maxFileBytes) { stats.skippedLarge++; return }
176
+ const lk = localKey(l)
177
+ let r = null
178
+ try { r = await sftp.stat(rp) } catch { /* absent → upload */ }
179
+ const rk = remoteKey(r)
180
+ const sk = state[rel] ? keyOf(state[rel].size, state[rel].mtime) : null
181
+ if (r && sk && lk === sk && rk === sk) { stats.skippedUnchanged++; return }
182
+ if (r && !force && sk && rk !== sk && lk !== sk && rk !== lk) {
183
+ stats.conflicts.push({ path: rp, reason: 'both-modified(远端与本地都改过,跳过不覆盖)' })
184
+ return
185
+ }
186
+ if (r && !force && sk && rk !== sk && lk === sk) {
187
+ stats.conflicts.push({ path: rp, reason: 'remote-modified(远端改过而本地未变;如需覆盖请用 force / 先 rw_sync)' })
188
+ return
189
+ }
190
+ if (r && !force && sk && rk === sk && lk && lk !== sk) {
191
+ // Normal case: local edited since sync, remote untouched → push.
192
+ }
193
+ if (dryRun) {
194
+ stats.files++
195
+ stats.pushed.push(rp)
196
+ return
197
+ }
198
+ try {
199
+ const buf = readFileSync(lp)
200
+ await sftp.writeFile(rp, buf)
201
+ // Align local mtime to whatever the remote server assigned, so the
202
+ // L==R==S invariant holds for the next run.
203
+ let r2 = null
204
+ try { r2 = await sftp.stat(rp) } catch {}
205
+ const rmt = r2 ? r2.mtime : Math.floor(Date.now() / 1000)
206
+ try { utimesSync(lp, new Date(rmt * 1000), new Date(rmt * 1000)) } catch {}
207
+ stats.files++
208
+ stats.pushed.push(rp)
209
+ nextState[rel] = { size: l.size, mtime: rmt }
210
+ } catch { /* skip unwritable */ }
211
+ })
212
+ }
213
+
214
+ await walk(localDir, remoteDir)
215
+ if (stats.files >= maxFiles) stats.truncated = true
216
+ for (const rel of Object.keys(state)) {
217
+ if (state[rel] && !nextState[rel]) stats.staleLocal++
218
+ }
219
+ return { stats, nextState }
220
+ }
221
+
222
+ /** Load the sync-state snapshot from a mirror dir (or {}). */
223
+ export function loadSyncState(localDir) {
224
+ try {
225
+ const j = JSON.parse(readFileSync(path.join(localDir, '.dsh-remote-sync-state.json'), 'utf8'))
226
+ if (j && typeof j === 'object' && !Array.isArray(j)) return j
227
+ } catch {}
228
+ return {}
229
+ }
230
+
231
+ /** Persist the snapshot (atomic-ish: tmp + rename). */
232
+ export function saveSyncState(localDir, state) {
233
+ try {
234
+ mkdirSync(localDir, { recursive: true })
235
+ const tmp = path.join(localDir, '.dsh-remote-sync-state.json.tmp')
236
+ writeFileSync(tmp, JSON.stringify(state, null, 1))
237
+ renameSync(tmp, path.join(localDir, '.dsh-remote-sync-state.json'))
238
+ } catch { /* best effort */ }
239
+ }
240
+
241
+ /** Push ONE local mirror file back to the remote (used by auto-push).
242
+ * Same three-way guard as pushTree: never clobbers a remote change. Returns
243
+ * { status: 'pushed'|'unchanged'|'conflict'|'skipped'|'missing' } and the
244
+ * updated state delta ({rel: {size, mtime}} when pushed). */
245
+ export async function pushOneFile(sftp, localDir, remoteDir, rel, opts = {}) {
246
+ const { maxFileBytes = 0, isIgnored = () => false, force = false, state = {} } = opts
247
+ if (isIgnored(rel, false)) return { status: 'skipped' }
248
+ const lp = path.join(localDir, rel.replace(/\//g, path.sep))
249
+ const rp = joinRemotePath(remoteDir, rel)
250
+ let l
251
+ try { l = statSync(lp) } catch { return { status: 'missing' } }
252
+ if (maxFileBytes > 0 && l.size > maxFileBytes) return { status: 'skipped' }
253
+ const lk = localKey(l)
254
+ let r = null
255
+ try { r = await sftp.stat(rp) } catch { /* absent → upload */ }
256
+ const rk = remoteKey(r)
257
+ const sk = state[rel] ? keyOf(state[rel].size, state[rel].mtime) : null
258
+ if (r && sk && lk === sk && rk === sk) return { status: 'unchanged' }
259
+ if (r && !force && sk && rk !== sk && lk !== sk && rk !== lk) {
260
+ return { status: 'conflict', reason: 'both-modified' }
261
+ }
262
+ if (r && !force && sk && rk !== sk && lk === sk) {
263
+ return { status: 'conflict', reason: 'remote-modified' }
264
+ }
265
+ try {
266
+ await sftp.writeFile(rp, readFileSync(lp))
267
+ let r2 = null
268
+ try { r2 = await sftp.stat(rp) } catch {}
269
+ const rmt = r2 ? r2.mtime : Math.floor(Date.now() / 1000)
270
+ try { utimesSync(lp, new Date(rmt * 1000), new Date(rmt * 1000)) } catch {}
271
+ return { status: 'pushed', state: { [rel]: { size: l.size, mtime: rmt } } }
272
+ } catch (e) {
273
+ return { status: 'skipped', error: String((e && e.message) || e) }
274
+ }
275
+ }