dsh-remote 0.8.13 → 0.8.15
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 -21
- package/README.md +236 -234
- package/README.zh.md +195 -190
- package/lib/binding.js +97 -97
- package/lib/client.js +28 -26
- package/lib/credential.js +177 -107
- package/lib/errors.js +78 -78
- package/lib/forwards.js +219 -219
- package/lib/hostkey.js +48 -48
- package/lib/ignore.js +123 -123
- package/lib/index.js +3288 -3230
- package/lib/paths.js +217 -217
- package/lib/registry.js +89 -89
- package/lib/search.js +127 -127
- package/lib/sshconfig.js +57 -57
- package/lib/sync.js +270 -270
- package/lib/tasks.js +95 -95
- package/lib/update.js +196 -196
- package/package.json +74 -66
package/lib/sync.js
CHANGED
|
@@ -1,270 +1,270 @@
|
|
|
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 = 5,
|
|
47
|
-
maxFiles = 500,
|
|
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: [] }
|
|
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) continue
|
|
66
|
-
const rp = joinRemotePath(rDir, name)
|
|
67
|
-
const lp = path.join(lDir, name)
|
|
68
|
-
const rel = relPathUnder(remoteDir, rp) || name
|
|
69
|
-
if (isIgnored(rel, true)) continue
|
|
70
|
-
if (!existsSync(lp)) mkdirSync(lp, { recursive: true })
|
|
71
|
-
await walk(rp, lp, depth - 1)
|
|
72
|
-
stats.dirs++
|
|
73
|
-
if (stats.files >= maxFiles) break
|
|
74
|
-
}
|
|
75
|
-
if (stats.files >= maxFiles) return
|
|
76
|
-
|
|
77
|
-
const files = entries.filter(
|
|
78
|
-
(e) => !(e.attrs && e.attrs.isDirectory && e.attrs.isDirectory()) &&
|
|
79
|
-
String(e.filename) !== '.' && String(e.filename) !== '..',
|
|
80
|
-
)
|
|
81
|
-
await mapLimit(files, 4, async (e) => {
|
|
82
|
-
if (stats.files >= maxFiles) return
|
|
83
|
-
const name = String(e.filename)
|
|
84
|
-
const rp = joinRemotePath(rDir, name)
|
|
85
|
-
const lp = path.join(lDir, name)
|
|
86
|
-
const rel = relPathUnder(remoteDir, rp) || name
|
|
87
|
-
if (isIgnored(rel, false)) return
|
|
88
|
-
let r
|
|
89
|
-
try { r = await sftp.stat(rp) } catch { return }
|
|
90
|
-
if (maxFileBytes > 0 && r.size > maxFileBytes) { stats.skippedLarge++; return }
|
|
91
|
-
const rk = remoteKey(r)
|
|
92
|
-
const sk = state[rel] ? keyOf(state[rel].size, state[rel].mtime) : null
|
|
93
|
-
let lk = null
|
|
94
|
-
if (existsSync(lp)) {
|
|
95
|
-
try { lk = localKey(statSync(lp)) } catch { lk = null }
|
|
96
|
-
}
|
|
97
|
-
if (sk && rk === sk && lk && lk === sk) { stats.skippedUnchanged++; return }
|
|
98
|
-
if (!force && sk && rk !== sk && lk && lk !== sk && rk !== lk) {
|
|
99
|
-
stats.conflicts.push({ path: rp, reason: 'both-modified(远端与本地都改过,跳过不覆盖)' })
|
|
100
|
-
return
|
|
101
|
-
}
|
|
102
|
-
if (!force && sk && rk === sk && lk && lk !== sk) {
|
|
103
|
-
// Pull would clobber a local edit; the remote never changed.
|
|
104
|
-
stats.conflicts.push({ path: rp, reason: 'local-modified(本地改过而远端未变;如需覆盖请用 force / rw_push)' })
|
|
105
|
-
return
|
|
106
|
-
}
|
|
107
|
-
if (dryRun) {
|
|
108
|
-
stats.files++
|
|
109
|
-
stats.touched.push(rp)
|
|
110
|
-
nextState[rel] = { size: r.size, mtime: r.mtime }
|
|
111
|
-
return
|
|
112
|
-
}
|
|
113
|
-
try {
|
|
114
|
-
const buf = await sftp.readFile(rp)
|
|
115
|
-
writeFileSync(lp, buf)
|
|
116
|
-
try { utimesSync(lp, new Date(r.mtime * 1000), new Date(r.mtime * 1000)) } catch {}
|
|
117
|
-
stats.files++
|
|
118
|
-
stats.touched.push(rp)
|
|
119
|
-
nextState[rel] = { size: r.size, mtime: r.mtime }
|
|
120
|
-
} catch { /* skip unreadable */ }
|
|
121
|
-
})
|
|
122
|
-
}
|
|
123
|
-
|
|
124
|
-
await walk(remoteDir, localDir, maxDepth)
|
|
125
|
-
// Informational: remote entries in the snapshot that no longer exist remotely.
|
|
126
|
-
for (const rel of Object.keys(state)) {
|
|
127
|
-
if (state[rel] && !nextState[rel]) stats.staleRemote++
|
|
128
|
-
}
|
|
129
|
-
return { stats, nextState }
|
|
130
|
-
}
|
|
131
|
-
|
|
132
|
-
/** Recursively push local mirror → remote (three-way, ignore-aware). */
|
|
133
|
-
export async function pushTree(sftp, localDir, remoteDir, opts = {}) {
|
|
134
|
-
const {
|
|
135
|
-
maxFiles = 500,
|
|
136
|
-
maxFileBytes = 0,
|
|
137
|
-
isIgnored = () => false,
|
|
138
|
-
dryRun = false,
|
|
139
|
-
force = false,
|
|
140
|
-
state = {},
|
|
141
|
-
} = opts
|
|
142
|
-
const stats = { files: 0, dirs: 0, skippedUnchanged: 0, skippedLarge: 0, conflicts: [], staleLocal: 0, pushed: [] }
|
|
143
|
-
const nextState = { ...state }
|
|
144
|
-
|
|
145
|
-
const walk = async (lDir, rDir) => {
|
|
146
|
-
const entries = readdirSync(lDir, { withFileTypes: true }).filter(
|
|
147
|
-
(e) => e.name !== '.dsh-remote-meta.json' && e.name !== '.dsh-remote-sync-state.json',
|
|
148
|
-
)
|
|
149
|
-
for (const e of entries) {
|
|
150
|
-
if (!e.isDirectory()) continue
|
|
151
|
-
if (stats.files >= maxFiles) break
|
|
152
|
-
const rp = joinRemotePath(rDir, e.name)
|
|
153
|
-
const rel = relPathUnder(remoteDir, rp) || e.name
|
|
154
|
-
if (isIgnored(rel, true)) continue
|
|
155
|
-
try { await sftp.mkdir(rp) } catch { /* already exists */ }
|
|
156
|
-
stats.dirs++
|
|
157
|
-
await walk(path.join(lDir, e.name), rp)
|
|
158
|
-
if (stats.files >= maxFiles) break
|
|
159
|
-
}
|
|
160
|
-
if (stats.files >= maxFiles) return
|
|
161
|
-
|
|
162
|
-
const files = entries.filter((e) => !e.isDirectory())
|
|
163
|
-
await mapLimit(files, 4, async (e) => {
|
|
164
|
-
if (stats.files >= maxFiles) return
|
|
165
|
-
const lp = path.join(lDir, e.name)
|
|
166
|
-
const rp = joinRemotePath(rDir, e.name)
|
|
167
|
-
const rel = relPathUnder(remoteDir, rp) || e.name
|
|
168
|
-
if (isIgnored(rel, false)) return
|
|
169
|
-
let l
|
|
170
|
-
try { l = statSync(lp) } catch { return }
|
|
171
|
-
if (maxFileBytes > 0 && l.size > maxFileBytes) { stats.skippedLarge++; return }
|
|
172
|
-
const lk = localKey(l)
|
|
173
|
-
let r = null
|
|
174
|
-
try { r = await sftp.stat(rp) } catch { /* absent → upload */ }
|
|
175
|
-
const rk = remoteKey(r)
|
|
176
|
-
const sk = state[rel] ? keyOf(state[rel].size, state[rel].mtime) : null
|
|
177
|
-
if (r && sk && lk === sk && rk === sk) { stats.skippedUnchanged++; return }
|
|
178
|
-
if (r && !force && sk && rk !== sk && lk !== sk && rk !== lk) {
|
|
179
|
-
stats.conflicts.push({ path: rp, reason: 'both-modified(远端与本地都改过,跳过不覆盖)' })
|
|
180
|
-
return
|
|
181
|
-
}
|
|
182
|
-
if (r && !force && sk && rk !== sk && lk === sk) {
|
|
183
|
-
stats.conflicts.push({ path: rp, reason: 'remote-modified(远端改过而本地未变;如需覆盖请用 force / 先 rw_sync)' })
|
|
184
|
-
return
|
|
185
|
-
}
|
|
186
|
-
if (r && !force && sk && rk === sk && lk && lk !== sk) {
|
|
187
|
-
// Normal case: local edited since sync, remote untouched → push.
|
|
188
|
-
}
|
|
189
|
-
if (dryRun) {
|
|
190
|
-
stats.files++
|
|
191
|
-
stats.pushed.push(rp)
|
|
192
|
-
return
|
|
193
|
-
}
|
|
194
|
-
try {
|
|
195
|
-
const buf = readFileSync(lp)
|
|
196
|
-
await sftp.writeFile(rp, buf)
|
|
197
|
-
// Align local mtime to whatever the remote server assigned, so the
|
|
198
|
-
// L==R==S invariant holds for the next run.
|
|
199
|
-
let r2 = null
|
|
200
|
-
try { r2 = await sftp.stat(rp) } catch {}
|
|
201
|
-
const rmt = r2 ? r2.mtime : Math.floor(Date.now() / 1000)
|
|
202
|
-
try { utimesSync(lp, new Date(rmt * 1000), new Date(rmt * 1000)) } catch {}
|
|
203
|
-
stats.files++
|
|
204
|
-
stats.pushed.push(rp)
|
|
205
|
-
nextState[rel] = { size: l.size, mtime: rmt }
|
|
206
|
-
} catch { /* skip unwritable */ }
|
|
207
|
-
})
|
|
208
|
-
}
|
|
209
|
-
|
|
210
|
-
await walk(localDir, remoteDir)
|
|
211
|
-
for (const rel of Object.keys(state)) {
|
|
212
|
-
if (state[rel] && !nextState[rel]) stats.staleLocal++
|
|
213
|
-
}
|
|
214
|
-
return { stats, nextState }
|
|
215
|
-
}
|
|
216
|
-
|
|
217
|
-
/** Load the sync-state snapshot from a mirror dir (or {}). */
|
|
218
|
-
export function loadSyncState(localDir) {
|
|
219
|
-
try {
|
|
220
|
-
const j = JSON.parse(readFileSync(path.join(localDir, '.dsh-remote-sync-state.json'), 'utf8'))
|
|
221
|
-
if (j && typeof j === 'object' && !Array.isArray(j)) return j
|
|
222
|
-
} catch {}
|
|
223
|
-
return {}
|
|
224
|
-
}
|
|
225
|
-
|
|
226
|
-
/** Persist the snapshot (atomic-ish: tmp + rename). */
|
|
227
|
-
export function saveSyncState(localDir, state) {
|
|
228
|
-
try {
|
|
229
|
-
mkdirSync(localDir, { recursive: true })
|
|
230
|
-
const tmp = path.join(localDir, '.dsh-remote-sync-state.json.tmp')
|
|
231
|
-
writeFileSync(tmp, JSON.stringify(state, null, 1))
|
|
232
|
-
renameSync(tmp, path.join(localDir, '.dsh-remote-sync-state.json'))
|
|
233
|
-
} catch { /* best effort */ }
|
|
234
|
-
}
|
|
235
|
-
|
|
236
|
-
/** Push ONE local mirror file back to the remote (used by auto-push).
|
|
237
|
-
* Same three-way guard as pushTree: never clobbers a remote change. Returns
|
|
238
|
-
* { status: 'pushed'|'unchanged'|'conflict'|'skipped'|'missing' } and the
|
|
239
|
-
* updated state delta ({rel: {size, mtime}} when pushed). */
|
|
240
|
-
export async function pushOneFile(sftp, localDir, remoteDir, rel, opts = {}) {
|
|
241
|
-
const { maxFileBytes = 0, isIgnored = () => false, force = false, state = {} } = opts
|
|
242
|
-
if (isIgnored(rel, false)) return { status: 'skipped' }
|
|
243
|
-
const lp = path.join(localDir, rel.replace(/\//g, path.sep))
|
|
244
|
-
const rp = joinRemotePath(remoteDir, rel)
|
|
245
|
-
let l
|
|
246
|
-
try { l = statSync(lp) } catch { return { status: 'missing' } }
|
|
247
|
-
if (maxFileBytes > 0 && l.size > maxFileBytes) return { status: 'skipped' }
|
|
248
|
-
const lk = localKey(l)
|
|
249
|
-
let r = null
|
|
250
|
-
try { r = await sftp.stat(rp) } catch { /* absent → upload */ }
|
|
251
|
-
const rk = remoteKey(r)
|
|
252
|
-
const sk = state[rel] ? keyOf(state[rel].size, state[rel].mtime) : null
|
|
253
|
-
if (r && sk && lk === sk && rk === sk) return { status: 'unchanged' }
|
|
254
|
-
if (r && !force && sk && rk !== sk && lk !== sk && rk !== lk) {
|
|
255
|
-
return { status: 'conflict', reason: 'both-modified' }
|
|
256
|
-
}
|
|
257
|
-
if (r && !force && sk && rk !== sk && lk === sk) {
|
|
258
|
-
return { status: 'conflict', reason: 'remote-modified' }
|
|
259
|
-
}
|
|
260
|
-
try {
|
|
261
|
-
await sftp.writeFile(rp, readFileSync(lp))
|
|
262
|
-
let r2 = null
|
|
263
|
-
try { r2 = await sftp.stat(rp) } catch {}
|
|
264
|
-
const rmt = r2 ? r2.mtime : Math.floor(Date.now() / 1000)
|
|
265
|
-
try { utimesSync(lp, new Date(rmt * 1000), new Date(rmt * 1000)) } catch {}
|
|
266
|
-
return { status: 'pushed', state: { [rel]: { size: l.size, mtime: rmt } } }
|
|
267
|
-
} catch (e) {
|
|
268
|
-
return { status: 'skipped', error: String((e && e.message) || e) }
|
|
269
|
-
}
|
|
270
|
-
}
|
|
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 = 5,
|
|
47
|
+
maxFiles = 500,
|
|
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: [] }
|
|
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) continue
|
|
66
|
+
const rp = joinRemotePath(rDir, name)
|
|
67
|
+
const lp = path.join(lDir, name)
|
|
68
|
+
const rel = relPathUnder(remoteDir, rp) || name
|
|
69
|
+
if (isIgnored(rel, true)) continue
|
|
70
|
+
if (!existsSync(lp)) mkdirSync(lp, { recursive: true })
|
|
71
|
+
await walk(rp, lp, depth - 1)
|
|
72
|
+
stats.dirs++
|
|
73
|
+
if (stats.files >= maxFiles) break
|
|
74
|
+
}
|
|
75
|
+
if (stats.files >= maxFiles) return
|
|
76
|
+
|
|
77
|
+
const files = entries.filter(
|
|
78
|
+
(e) => !(e.attrs && e.attrs.isDirectory && e.attrs.isDirectory()) &&
|
|
79
|
+
String(e.filename) !== '.' && String(e.filename) !== '..',
|
|
80
|
+
)
|
|
81
|
+
await mapLimit(files, 4, async (e) => {
|
|
82
|
+
if (stats.files >= maxFiles) return
|
|
83
|
+
const name = String(e.filename)
|
|
84
|
+
const rp = joinRemotePath(rDir, name)
|
|
85
|
+
const lp = path.join(lDir, name)
|
|
86
|
+
const rel = relPathUnder(remoteDir, rp) || name
|
|
87
|
+
if (isIgnored(rel, false)) return
|
|
88
|
+
let r
|
|
89
|
+
try { r = await sftp.stat(rp) } catch { return }
|
|
90
|
+
if (maxFileBytes > 0 && r.size > maxFileBytes) { stats.skippedLarge++; return }
|
|
91
|
+
const rk = remoteKey(r)
|
|
92
|
+
const sk = state[rel] ? keyOf(state[rel].size, state[rel].mtime) : null
|
|
93
|
+
let lk = null
|
|
94
|
+
if (existsSync(lp)) {
|
|
95
|
+
try { lk = localKey(statSync(lp)) } catch { lk = null }
|
|
96
|
+
}
|
|
97
|
+
if (sk && rk === sk && lk && lk === sk) { stats.skippedUnchanged++; return }
|
|
98
|
+
if (!force && sk && rk !== sk && lk && lk !== sk && rk !== lk) {
|
|
99
|
+
stats.conflicts.push({ path: rp, reason: 'both-modified(远端与本地都改过,跳过不覆盖)' })
|
|
100
|
+
return
|
|
101
|
+
}
|
|
102
|
+
if (!force && sk && rk === sk && lk && lk !== sk) {
|
|
103
|
+
// Pull would clobber a local edit; the remote never changed.
|
|
104
|
+
stats.conflicts.push({ path: rp, reason: 'local-modified(本地改过而远端未变;如需覆盖请用 force / rw_push)' })
|
|
105
|
+
return
|
|
106
|
+
}
|
|
107
|
+
if (dryRun) {
|
|
108
|
+
stats.files++
|
|
109
|
+
stats.touched.push(rp)
|
|
110
|
+
nextState[rel] = { size: r.size, mtime: r.mtime }
|
|
111
|
+
return
|
|
112
|
+
}
|
|
113
|
+
try {
|
|
114
|
+
const buf = await sftp.readFile(rp)
|
|
115
|
+
writeFileSync(lp, buf)
|
|
116
|
+
try { utimesSync(lp, new Date(r.mtime * 1000), new Date(r.mtime * 1000)) } catch {}
|
|
117
|
+
stats.files++
|
|
118
|
+
stats.touched.push(rp)
|
|
119
|
+
nextState[rel] = { size: r.size, mtime: r.mtime }
|
|
120
|
+
} catch { /* skip unreadable */ }
|
|
121
|
+
})
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
await walk(remoteDir, localDir, maxDepth)
|
|
125
|
+
// Informational: remote entries in the snapshot that no longer exist remotely.
|
|
126
|
+
for (const rel of Object.keys(state)) {
|
|
127
|
+
if (state[rel] && !nextState[rel]) stats.staleRemote++
|
|
128
|
+
}
|
|
129
|
+
return { stats, nextState }
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** Recursively push local mirror → remote (three-way, ignore-aware). */
|
|
133
|
+
export async function pushTree(sftp, localDir, remoteDir, opts = {}) {
|
|
134
|
+
const {
|
|
135
|
+
maxFiles = 500,
|
|
136
|
+
maxFileBytes = 0,
|
|
137
|
+
isIgnored = () => false,
|
|
138
|
+
dryRun = false,
|
|
139
|
+
force = false,
|
|
140
|
+
state = {},
|
|
141
|
+
} = opts
|
|
142
|
+
const stats = { files: 0, dirs: 0, skippedUnchanged: 0, skippedLarge: 0, conflicts: [], staleLocal: 0, pushed: [] }
|
|
143
|
+
const nextState = { ...state }
|
|
144
|
+
|
|
145
|
+
const walk = async (lDir, rDir) => {
|
|
146
|
+
const entries = readdirSync(lDir, { withFileTypes: true }).filter(
|
|
147
|
+
(e) => e.name !== '.dsh-remote-meta.json' && e.name !== '.dsh-remote-sync-state.json',
|
|
148
|
+
)
|
|
149
|
+
for (const e of entries) {
|
|
150
|
+
if (!e.isDirectory()) continue
|
|
151
|
+
if (stats.files >= maxFiles) break
|
|
152
|
+
const rp = joinRemotePath(rDir, e.name)
|
|
153
|
+
const rel = relPathUnder(remoteDir, rp) || e.name
|
|
154
|
+
if (isIgnored(rel, true)) continue
|
|
155
|
+
try { await sftp.mkdir(rp) } catch { /* already exists */ }
|
|
156
|
+
stats.dirs++
|
|
157
|
+
await walk(path.join(lDir, e.name), rp)
|
|
158
|
+
if (stats.files >= maxFiles) break
|
|
159
|
+
}
|
|
160
|
+
if (stats.files >= maxFiles) return
|
|
161
|
+
|
|
162
|
+
const files = entries.filter((e) => !e.isDirectory())
|
|
163
|
+
await mapLimit(files, 4, async (e) => {
|
|
164
|
+
if (stats.files >= maxFiles) return
|
|
165
|
+
const lp = path.join(lDir, e.name)
|
|
166
|
+
const rp = joinRemotePath(rDir, e.name)
|
|
167
|
+
const rel = relPathUnder(remoteDir, rp) || e.name
|
|
168
|
+
if (isIgnored(rel, false)) return
|
|
169
|
+
let l
|
|
170
|
+
try { l = statSync(lp) } catch { return }
|
|
171
|
+
if (maxFileBytes > 0 && l.size > maxFileBytes) { stats.skippedLarge++; return }
|
|
172
|
+
const lk = localKey(l)
|
|
173
|
+
let r = null
|
|
174
|
+
try { r = await sftp.stat(rp) } catch { /* absent → upload */ }
|
|
175
|
+
const rk = remoteKey(r)
|
|
176
|
+
const sk = state[rel] ? keyOf(state[rel].size, state[rel].mtime) : null
|
|
177
|
+
if (r && sk && lk === sk && rk === sk) { stats.skippedUnchanged++; return }
|
|
178
|
+
if (r && !force && sk && rk !== sk && lk !== sk && rk !== lk) {
|
|
179
|
+
stats.conflicts.push({ path: rp, reason: 'both-modified(远端与本地都改过,跳过不覆盖)' })
|
|
180
|
+
return
|
|
181
|
+
}
|
|
182
|
+
if (r && !force && sk && rk !== sk && lk === sk) {
|
|
183
|
+
stats.conflicts.push({ path: rp, reason: 'remote-modified(远端改过而本地未变;如需覆盖请用 force / 先 rw_sync)' })
|
|
184
|
+
return
|
|
185
|
+
}
|
|
186
|
+
if (r && !force && sk && rk === sk && lk && lk !== sk) {
|
|
187
|
+
// Normal case: local edited since sync, remote untouched → push.
|
|
188
|
+
}
|
|
189
|
+
if (dryRun) {
|
|
190
|
+
stats.files++
|
|
191
|
+
stats.pushed.push(rp)
|
|
192
|
+
return
|
|
193
|
+
}
|
|
194
|
+
try {
|
|
195
|
+
const buf = readFileSync(lp)
|
|
196
|
+
await sftp.writeFile(rp, buf)
|
|
197
|
+
// Align local mtime to whatever the remote server assigned, so the
|
|
198
|
+
// L==R==S invariant holds for the next run.
|
|
199
|
+
let r2 = null
|
|
200
|
+
try { r2 = await sftp.stat(rp) } catch {}
|
|
201
|
+
const rmt = r2 ? r2.mtime : Math.floor(Date.now() / 1000)
|
|
202
|
+
try { utimesSync(lp, new Date(rmt * 1000), new Date(rmt * 1000)) } catch {}
|
|
203
|
+
stats.files++
|
|
204
|
+
stats.pushed.push(rp)
|
|
205
|
+
nextState[rel] = { size: l.size, mtime: rmt }
|
|
206
|
+
} catch { /* skip unwritable */ }
|
|
207
|
+
})
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
await walk(localDir, remoteDir)
|
|
211
|
+
for (const rel of Object.keys(state)) {
|
|
212
|
+
if (state[rel] && !nextState[rel]) stats.staleLocal++
|
|
213
|
+
}
|
|
214
|
+
return { stats, nextState }
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/** Load the sync-state snapshot from a mirror dir (or {}). */
|
|
218
|
+
export function loadSyncState(localDir) {
|
|
219
|
+
try {
|
|
220
|
+
const j = JSON.parse(readFileSync(path.join(localDir, '.dsh-remote-sync-state.json'), 'utf8'))
|
|
221
|
+
if (j && typeof j === 'object' && !Array.isArray(j)) return j
|
|
222
|
+
} catch {}
|
|
223
|
+
return {}
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/** Persist the snapshot (atomic-ish: tmp + rename). */
|
|
227
|
+
export function saveSyncState(localDir, state) {
|
|
228
|
+
try {
|
|
229
|
+
mkdirSync(localDir, { recursive: true })
|
|
230
|
+
const tmp = path.join(localDir, '.dsh-remote-sync-state.json.tmp')
|
|
231
|
+
writeFileSync(tmp, JSON.stringify(state, null, 1))
|
|
232
|
+
renameSync(tmp, path.join(localDir, '.dsh-remote-sync-state.json'))
|
|
233
|
+
} catch { /* best effort */ }
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/** Push ONE local mirror file back to the remote (used by auto-push).
|
|
237
|
+
* Same three-way guard as pushTree: never clobbers a remote change. Returns
|
|
238
|
+
* { status: 'pushed'|'unchanged'|'conflict'|'skipped'|'missing' } and the
|
|
239
|
+
* updated state delta ({rel: {size, mtime}} when pushed). */
|
|
240
|
+
export async function pushOneFile(sftp, localDir, remoteDir, rel, opts = {}) {
|
|
241
|
+
const { maxFileBytes = 0, isIgnored = () => false, force = false, state = {} } = opts
|
|
242
|
+
if (isIgnored(rel, false)) return { status: 'skipped' }
|
|
243
|
+
const lp = path.join(localDir, rel.replace(/\//g, path.sep))
|
|
244
|
+
const rp = joinRemotePath(remoteDir, rel)
|
|
245
|
+
let l
|
|
246
|
+
try { l = statSync(lp) } catch { return { status: 'missing' } }
|
|
247
|
+
if (maxFileBytes > 0 && l.size > maxFileBytes) return { status: 'skipped' }
|
|
248
|
+
const lk = localKey(l)
|
|
249
|
+
let r = null
|
|
250
|
+
try { r = await sftp.stat(rp) } catch { /* absent → upload */ }
|
|
251
|
+
const rk = remoteKey(r)
|
|
252
|
+
const sk = state[rel] ? keyOf(state[rel].size, state[rel].mtime) : null
|
|
253
|
+
if (r && sk && lk === sk && rk === sk) return { status: 'unchanged' }
|
|
254
|
+
if (r && !force && sk && rk !== sk && lk !== sk && rk !== lk) {
|
|
255
|
+
return { status: 'conflict', reason: 'both-modified' }
|
|
256
|
+
}
|
|
257
|
+
if (r && !force && sk && rk !== sk && lk === sk) {
|
|
258
|
+
return { status: 'conflict', reason: 'remote-modified' }
|
|
259
|
+
}
|
|
260
|
+
try {
|
|
261
|
+
await sftp.writeFile(rp, readFileSync(lp))
|
|
262
|
+
let r2 = null
|
|
263
|
+
try { r2 = await sftp.stat(rp) } catch {}
|
|
264
|
+
const rmt = r2 ? r2.mtime : Math.floor(Date.now() / 1000)
|
|
265
|
+
try { utimesSync(lp, new Date(rmt * 1000), new Date(rmt * 1000)) } catch {}
|
|
266
|
+
return { status: 'pushed', state: { [rel]: { size: l.size, mtime: rmt } } }
|
|
267
|
+
} catch (e) {
|
|
268
|
+
return { status: 'skipped', error: String((e && e.message) || e) }
|
|
269
|
+
}
|
|
270
|
+
}
|