dsh-memory-sync 0.0.0-stage → 0.1.1
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/CHANGELOG.md +42 -0
- package/LICENSE +21 -0
- package/README.en.md +120 -0
- package/README.md +124 -2
- package/cordis.patch.yml +8 -0
- package/icon.svg +9 -0
- package/index.js +93 -0
- package/lib/config.mjs +149 -0
- package/lib/git.mjs +286 -0
- package/lib/lock.mjs +43 -0
- package/lib/notify.mjs +53 -0
- package/lib/state.mjs +59 -0
- package/lib/status.mjs +70 -0
- package/lib/sync.mjs +193 -0
- package/lib/tool.mjs +40 -0
- package/locale/en.json +6 -0
- package/locale/zh.json +6 -0
- package/package.json +56 -4
package/lib/git.mjs
ADDED
|
@@ -0,0 +1,286 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process'
|
|
2
|
+
import { hostname } from 'node:os'
|
|
3
|
+
|
|
4
|
+
function pad2(value) {
|
|
5
|
+
return String(value).padStart(2, '0')
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
/** Local ISO timestamp with a numeric offset, e.g. 2026-11-05T15:53:12+0800. */
|
|
9
|
+
export function localIso(date = new Date()) {
|
|
10
|
+
const offset = -date.getTimezoneOffset()
|
|
11
|
+
const sign = offset >= 0 ? '+' : '-'
|
|
12
|
+
const abs = Math.abs(offset)
|
|
13
|
+
return `${date.getFullYear()}-${pad2(date.getMonth() + 1)}-${pad2(date.getDate())}`
|
|
14
|
+
+ `T${pad2(date.getHours())}:${pad2(date.getMinutes())}:${pad2(date.getSeconds())}`
|
|
15
|
+
+ `${sign}${pad2(Math.floor(abs / 60))}${pad2(abs % 60)}`
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function tail(text, limit = 500) {
|
|
19
|
+
const clean = String(text || '').replace(/\s+/g, ' ').trim()
|
|
20
|
+
if (clean.length <= limit) return clean
|
|
21
|
+
return `…${clean.slice(-limit)}`
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** Kill the whole tree on Windows (cmd/git may spawn children). */
|
|
25
|
+
function killTree(child) {
|
|
26
|
+
if (!child || child.exitCode !== null) return
|
|
27
|
+
if (process.platform === 'win32') {
|
|
28
|
+
try {
|
|
29
|
+
spawn('taskkill', ['/PID', String(child.pid), '/T', '/F'], { windowsHide: true, stdio: 'ignore' })
|
|
30
|
+
return
|
|
31
|
+
} catch {
|
|
32
|
+
// fall through
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
try {
|
|
36
|
+
child.kill('SIGTERM')
|
|
37
|
+
} catch {
|
|
38
|
+
// already gone
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Run a process without a shell, with a timeout and a bounded output buffer.
|
|
44
|
+
*/
|
|
45
|
+
export function runProcess(command, args, {
|
|
46
|
+
cwd,
|
|
47
|
+
env,
|
|
48
|
+
timeoutMs = 60000,
|
|
49
|
+
maxBytes = 4 * 1024 * 1024,
|
|
50
|
+
} = {}) {
|
|
51
|
+
return new Promise((resolve) => {
|
|
52
|
+
const started = Date.now()
|
|
53
|
+
let stdout = ''
|
|
54
|
+
let stderr = ''
|
|
55
|
+
let truncated = false
|
|
56
|
+
let timedOut = false
|
|
57
|
+
let settled = false
|
|
58
|
+
let timer = null
|
|
59
|
+
|
|
60
|
+
const finish = (code, signal, errorMessage = '') => {
|
|
61
|
+
if (settled) return
|
|
62
|
+
settled = true
|
|
63
|
+
if (timer) clearTimeout(timer)
|
|
64
|
+
resolve({
|
|
65
|
+
code: Number.isInteger(code) ? code : -1,
|
|
66
|
+
signal: signal || null,
|
|
67
|
+
stdout,
|
|
68
|
+
stderr: errorMessage && !stderr ? errorMessage : stderr,
|
|
69
|
+
timedOut,
|
|
70
|
+
truncated,
|
|
71
|
+
durationMs: Date.now() - started,
|
|
72
|
+
})
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
let child
|
|
76
|
+
try {
|
|
77
|
+
child = spawn(command, args, {
|
|
78
|
+
cwd,
|
|
79
|
+
env: { ...process.env, ...(env || {}) },
|
|
80
|
+
windowsHide: true,
|
|
81
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
82
|
+
})
|
|
83
|
+
} catch (error) {
|
|
84
|
+
finish(-1, null, String(error?.message || error))
|
|
85
|
+
return
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const take = (chunk, which) => {
|
|
89
|
+
const text = chunk.toString('utf8')
|
|
90
|
+
if (which === 'out') {
|
|
91
|
+
const room = maxBytes - stdout.length
|
|
92
|
+
if (room <= 0) {
|
|
93
|
+
truncated = true
|
|
94
|
+
return
|
|
95
|
+
}
|
|
96
|
+
stdout += room >= text.length ? text : text.slice(0, room)
|
|
97
|
+
if (room < text.length) truncated = true
|
|
98
|
+
return
|
|
99
|
+
}
|
|
100
|
+
const room = maxBytes - stderr.length
|
|
101
|
+
if (room > 0) stderr += room >= text.length ? text : text.slice(0, room)
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
child.stdout?.on('data', (chunk) => take(chunk, 'out'))
|
|
105
|
+
child.stderr?.on('data', (chunk) => take(chunk, 'err'))
|
|
106
|
+
child.on('error', (error) => finish(-1, null, String(error?.message || error)))
|
|
107
|
+
child.on('close', (code, signal) => finish(code, signal))
|
|
108
|
+
|
|
109
|
+
if (timeoutMs > 0) {
|
|
110
|
+
timer = setTimeout(() => {
|
|
111
|
+
timedOut = true
|
|
112
|
+
killTree(child)
|
|
113
|
+
}, Math.max(250, timeoutMs))
|
|
114
|
+
timer.unref?.()
|
|
115
|
+
}
|
|
116
|
+
})
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export async function runGit(cfg, args, { timeoutMs = cfg.localTimeoutMs, cwd = cfg.repoPath } = {}) {
|
|
120
|
+
return runProcess(cfg.gitPath, ['-C', cwd, ...args], { cwd, timeoutMs })
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
export async function resolveRemote(cfg) {
|
|
124
|
+
for (const name of [cfg.remote, 'origin', 'cloud']) {
|
|
125
|
+
if (!name) continue
|
|
126
|
+
const result = await runGit(cfg, ['remote', 'get-url', name], { timeoutMs: 15000 })
|
|
127
|
+
if (result.code === 0 && result.stdout.trim()) return name
|
|
128
|
+
}
|
|
129
|
+
return ''
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
export async function inspectRepo(cfg) {
|
|
133
|
+
const dir = await runGit(cfg, ['rev-parse', '--git-dir'], { timeoutMs: 15000 })
|
|
134
|
+
if (dir.code !== 0) {
|
|
135
|
+
return { ok: false, detail: tail(dir.stderr || dir.stdout) || 'not a git repo' }
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
const branchResult = await runGit(cfg, ['branch', '--show-current'], { timeoutMs: 15000 })
|
|
139
|
+
const branch = branchResult.stdout.trim() || cfg.branch
|
|
140
|
+
const headResult = await runGit(cfg, ['rev-parse', '--short', 'HEAD'], { timeoutMs: 15000 })
|
|
141
|
+
const status = await runGit(cfg, ['status', '--porcelain'], { timeoutMs: 20000 })
|
|
142
|
+
const remote = await resolveRemote(cfg)
|
|
143
|
+
|
|
144
|
+
let ahead = 0
|
|
145
|
+
let behind = 0
|
|
146
|
+
if (remote) {
|
|
147
|
+
const counts = await runGit(
|
|
148
|
+
cfg,
|
|
149
|
+
['rev-list', '--left-right', '--count', `HEAD...${remote}/${cfg.branch}`],
|
|
150
|
+
{ timeoutMs: 20000 },
|
|
151
|
+
)
|
|
152
|
+
if (counts.code === 0) {
|
|
153
|
+
const [a, b] = counts.stdout.trim().split(/\s+/).map(Number)
|
|
154
|
+
ahead = Number.isFinite(a) ? a : 0
|
|
155
|
+
behind = Number.isFinite(b) ? b : 0
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
return {
|
|
160
|
+
ok: true,
|
|
161
|
+
branch,
|
|
162
|
+
head: headResult.code === 0 ? headResult.stdout.trim() : '',
|
|
163
|
+
dirty: status.stdout.trim().length > 0,
|
|
164
|
+
ahead,
|
|
165
|
+
behind,
|
|
166
|
+
remote,
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function failure(phase, started, detail, extra = {}) {
|
|
171
|
+
return {
|
|
172
|
+
name: phase,
|
|
173
|
+
ok: false,
|
|
174
|
+
durationMs: Date.now() - started,
|
|
175
|
+
detail: tail(detail),
|
|
176
|
+
...extra,
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* One local round trip: pull --rebase --autostash, commit dirty files, push.
|
|
182
|
+
* A rejected push is retried once after fetch + rebase (the non-fast-forward case).
|
|
183
|
+
*/
|
|
184
|
+
export async function syncLocal(cfg) {
|
|
185
|
+
const started = Date.now()
|
|
186
|
+
const repo = await inspectRepo(cfg)
|
|
187
|
+
if (!repo.ok) return failure('local', started, repo.detail)
|
|
188
|
+
if (!repo.remote) return failure('local', started, `no git remote named ${cfg.remote} (or origin)`)
|
|
189
|
+
|
|
190
|
+
const pullArgs = ['pull', '--rebase', '--autostash', repo.remote, cfg.branch]
|
|
191
|
+
let pull = await runGit(cfg, pullArgs)
|
|
192
|
+
if (pull.code !== 0) {
|
|
193
|
+
// A conflicted rebase leaves the work tree in a rebase state; abort so the
|
|
194
|
+
// user's files stay usable, then report instead of guessing a resolution.
|
|
195
|
+
await runGit(cfg, ['rebase', '--abort'], { timeoutMs: 15000 })
|
|
196
|
+
pull = await runGit(cfg, pullArgs)
|
|
197
|
+
if (pull.code !== 0) {
|
|
198
|
+
const detail = pull.stderr || pull.stdout
|
|
199
|
+
return failure('local', started, detail, { phase: 'pull', conflict: /conflict/i.test(detail) })
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
await runGit(cfg, ['add', '-A'])
|
|
204
|
+
const status = await runGit(cfg, ['status', '--porcelain'])
|
|
205
|
+
let committed = false
|
|
206
|
+
if (status.stdout.trim()) {
|
|
207
|
+
const nameResult = await runGit(cfg, ['config', 'user.name'], { timeoutMs: 10000 })
|
|
208
|
+
const emailResult = await runGit(cfg, ['config', 'user.email'], { timeoutMs: 10000 })
|
|
209
|
+
const commitArgs = ['commit', '-m', `${cfg.commitPrefix}: ${hostname()} ${localIso()}`]
|
|
210
|
+
if (!nameResult.stdout.trim()) commitArgs.unshift('-c', 'user.name=DSH Memory Sync')
|
|
211
|
+
if (!emailResult.stdout.trim()) commitArgs.unshift('-c', 'user.email=memory-sync@localhost')
|
|
212
|
+
const commit = await runGit(cfg, commitArgs)
|
|
213
|
+
if (commit.code !== 0) return failure('local', started, commit.stderr || commit.stdout, { phase: 'commit' })
|
|
214
|
+
committed = true
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
let push = await runGit(cfg, ['push', repo.remote, `HEAD:${cfg.branch}`])
|
|
218
|
+
let retried = false
|
|
219
|
+
if (push.code !== 0) {
|
|
220
|
+
retried = true
|
|
221
|
+
await runGit(cfg, ['fetch', repo.remote], { timeoutMs: 30000 })
|
|
222
|
+
const rebase = await runGit(cfg, ['rebase', `${repo.remote}/${cfg.branch}`], { timeoutMs: 30000 })
|
|
223
|
+
if (rebase.code === 0) {
|
|
224
|
+
push = await runGit(cfg, ['push', repo.remote, `HEAD:${cfg.branch}`])
|
|
225
|
+
} else {
|
|
226
|
+
await runGit(cfg, ['rebase', '--abort'], { timeoutMs: 15000 })
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
if (push.code !== 0) {
|
|
230
|
+
const detail = push.stderr || push.stdout
|
|
231
|
+
return failure('local', started, detail, { phase: 'push', retried, conflict: /conflict/i.test(detail) })
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
const after = await inspectRepo(cfg)
|
|
235
|
+
return {
|
|
236
|
+
name: 'local',
|
|
237
|
+
ok: true,
|
|
238
|
+
durationMs: Date.now() - started,
|
|
239
|
+
committed,
|
|
240
|
+
retried,
|
|
241
|
+
head: after.ok ? after.head : '',
|
|
242
|
+
dirty: after.ok ? after.dirty : false,
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/** Phase two: ask the peer to commit and push its side. */
|
|
247
|
+
export async function syncPeer(cfg) {
|
|
248
|
+
const started = Date.now()
|
|
249
|
+
if (cfg.mode !== 'peer') {
|
|
250
|
+
return { name: 'peer', ok: true, skipped: true, durationMs: 0, detail: 'local 模式' }
|
|
251
|
+
}
|
|
252
|
+
if (!cfg.peerHost || !cfg.peerUser) {
|
|
253
|
+
return failure('peer', started, 'peerHost / peerUser 未配置,已跳过云端段')
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
const args = [
|
|
257
|
+
'-o', 'BatchMode=yes',
|
|
258
|
+
'-o', 'ConnectTimeout=10',
|
|
259
|
+
'-o', 'ServerAliveInterval=5',
|
|
260
|
+
'-o', 'ServerAliveCountMax=2',
|
|
261
|
+
'-o', 'StrictHostKeyChecking=accept-new',
|
|
262
|
+
]
|
|
263
|
+
if (cfg.peerKeyPath) args.push('-i', cfg.peerKeyPath)
|
|
264
|
+
args.push(`${cfg.peerUser}@${cfg.peerHost}`, `DSH_SYNC_NAME=${cfg.peerEnvName} ${cfg.peerCommand}`)
|
|
265
|
+
|
|
266
|
+
const result = await runProcess('ssh', args, { timeoutMs: cfg.peerTimeoutMs })
|
|
267
|
+
return {
|
|
268
|
+
name: 'peer',
|
|
269
|
+
ok: result.code === 0,
|
|
270
|
+
exitCode: result.code,
|
|
271
|
+
durationMs: Date.now() - started,
|
|
272
|
+
timedOut: result.timedOut,
|
|
273
|
+
detail: result.code === 0 ? '' : tail(result.stderr || result.stdout),
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/** The three-phase startup handshake: local push -> peer commit/push -> local pull. */
|
|
278
|
+
export async function runStartupChain(cfg) {
|
|
279
|
+
const phases = []
|
|
280
|
+
phases.push(await syncLocal(cfg))
|
|
281
|
+
phases.push(await syncPeer(cfg))
|
|
282
|
+
phases.push(await syncLocal(cfg))
|
|
283
|
+
const checked = phases.filter((phase) => !phase.skipped)
|
|
284
|
+
const ok = checked.every((phase) => phase.ok)
|
|
285
|
+
return { ok, phases, head: phases[phases.length - 1]?.head || '' }
|
|
286
|
+
}
|
package/lib/lock.mjs
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { mkdir, readFile, rm, writeFile } from 'node:fs/promises'
|
|
2
|
+
import { hostname } from 'node:os'
|
|
3
|
+
import { join } from 'node:path'
|
|
4
|
+
|
|
5
|
+
const STALE_MS = 15 * 60 * 1000
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Cross-process lock built from a directory (atomic on Windows and POSIX).
|
|
9
|
+
* Returns null when another process holds a fresh lock.
|
|
10
|
+
*/
|
|
11
|
+
export async function acquireLock(lockDir, { staleMs = STALE_MS, now = Date.now() } = {}) {
|
|
12
|
+
await mkdir(lockDir, { recursive: true }).catch(() => {})
|
|
13
|
+
const lockPath = join(lockDir, 'sync.lock')
|
|
14
|
+
|
|
15
|
+
for (let attempt = 0; attempt < 2; attempt += 1) {
|
|
16
|
+
try {
|
|
17
|
+
await mkdir(lockPath)
|
|
18
|
+
await writeFile(
|
|
19
|
+
join(lockPath, 'owner.json'),
|
|
20
|
+
JSON.stringify({ pid: process.pid, host: hostname(), at: now }),
|
|
21
|
+
'utf8',
|
|
22
|
+
).catch(() => {})
|
|
23
|
+
return {
|
|
24
|
+
path: lockPath,
|
|
25
|
+
release: async () => {
|
|
26
|
+
await rm(lockPath, { recursive: true, force: true }).catch(() => {})
|
|
27
|
+
},
|
|
28
|
+
}
|
|
29
|
+
} catch (error) {
|
|
30
|
+
if (error?.code !== 'EEXIST') throw error
|
|
31
|
+
let stale = false
|
|
32
|
+
try {
|
|
33
|
+
const owner = JSON.parse(await readFile(join(lockPath, 'owner.json'), 'utf8'))
|
|
34
|
+
stale = !Number.isFinite(Number(owner?.at)) || now - Number(owner.at) > staleMs
|
|
35
|
+
} catch {
|
|
36
|
+
stale = true
|
|
37
|
+
}
|
|
38
|
+
if (!stale) return null
|
|
39
|
+
await rm(lockPath, { recursive: true, force: true }).catch(() => {})
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
return null
|
|
43
|
+
}
|
package/lib/notify.mjs
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process'
|
|
2
|
+
|
|
3
|
+
function runDetached(command, args) {
|
|
4
|
+
try {
|
|
5
|
+
const child = spawn(command, args, {
|
|
6
|
+
detached: true,
|
|
7
|
+
windowsHide: true,
|
|
8
|
+
stdio: 'ignore',
|
|
9
|
+
})
|
|
10
|
+
child.unref?.()
|
|
11
|
+
return true
|
|
12
|
+
} catch {
|
|
13
|
+
return false
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Best-effort failure popup. There is no host notification service, so this
|
|
19
|
+
* talks to the OS directly. Never throws: a missing notifier must not turn a
|
|
20
|
+
* sync failure into a crash.
|
|
21
|
+
*/
|
|
22
|
+
export async function notifyFailure({ title, message, logger } = {}) {
|
|
23
|
+
const safeTitle = String(title || 'DSH memory sync failed')
|
|
24
|
+
const safeMessage = String(message || 'The startup memory sync did not complete.')
|
|
25
|
+
try {
|
|
26
|
+
if (process.platform === 'win32') {
|
|
27
|
+
const script = [
|
|
28
|
+
'$w = New-Object -ComObject WScript.Shell;',
|
|
29
|
+
`$null = $w.Popup('${escapePowerShell(safeMessage)}', 300, '${escapePowerShell(safeTitle)}', 48)`,
|
|
30
|
+
].join(' ')
|
|
31
|
+
runDetached('powershell.exe', [
|
|
32
|
+
'-NoProfile',
|
|
33
|
+
'-NonInteractive',
|
|
34
|
+
'-WindowStyle', 'Hidden',
|
|
35
|
+
'-Command', script,
|
|
36
|
+
])
|
|
37
|
+
return
|
|
38
|
+
}
|
|
39
|
+
if (process.platform === 'darwin') {
|
|
40
|
+
const escaped = safeMessage.replace(/\\/g, '\\\\').replace(/"/g, '\\"')
|
|
41
|
+
const escapedTitle = safeTitle.replace(/\\/g, '\\\\').replace(/"/g, '\\"')
|
|
42
|
+
runDetached('osascript', ['-e', `display dialog "${escaped}" with title "${escapedTitle}" buttons {"OK"} default button 1 with icon caution`])
|
|
43
|
+
return
|
|
44
|
+
}
|
|
45
|
+
runDetached('notify-send', ['--urgency=critical', safeTitle, safeMessage])
|
|
46
|
+
} catch (error) {
|
|
47
|
+
logger?.warn?.(`notify failed: ${error?.message || error}`)
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function escapePowerShell(text) {
|
|
52
|
+
return String(text).replace(/\r?\n/g, ' ').replace(/'/g, "''")
|
|
53
|
+
}
|
package/lib/state.mjs
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { appendFile, mkdir, readFile, rename, stat, writeFile } from 'node:fs/promises'
|
|
2
|
+
import { dirname, join } from 'node:path'
|
|
3
|
+
|
|
4
|
+
const MAX_LOG_BYTES = 1024 * 1024
|
|
5
|
+
|
|
6
|
+
export async function ensureDir(dir) {
|
|
7
|
+
await mkdir(dir, { recursive: true })
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export async function readJson(file, fallback = null) {
|
|
11
|
+
try {
|
|
12
|
+
return JSON.parse(await readFile(file, 'utf8'))
|
|
13
|
+
} catch {
|
|
14
|
+
return fallback
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** Write JSON via a temp file + rename so a crash never leaves half a file. */
|
|
19
|
+
export async function writeJsonAtomic(file, value) {
|
|
20
|
+
const dir = dirname(file)
|
|
21
|
+
await mkdir(dir, { recursive: true })
|
|
22
|
+
const tmp = join(dir, `.${Date.now()}.${process.pid}.tmp`)
|
|
23
|
+
await writeFile(tmp, JSON.stringify(value, null, 2), 'utf8')
|
|
24
|
+
await rename(tmp, file)
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Keep the log bounded: one previous generation is enough. */
|
|
28
|
+
export async function rotateLog(file, maxBytes = MAX_LOG_BYTES) {
|
|
29
|
+
try {
|
|
30
|
+
const info = await stat(file)
|
|
31
|
+
if (info.size > maxBytes) await rename(file, `${file}.1`)
|
|
32
|
+
} catch {
|
|
33
|
+
// no log yet
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Tiny logger: console + best-effort file. Never throws, never blocks a turn.
|
|
39
|
+
*/
|
|
40
|
+
export function createLogger({ dir, tag = 'memory-sync' }) {
|
|
41
|
+
const file = join(dir, 'sync.log')
|
|
42
|
+
const write = (level, message) => {
|
|
43
|
+
const line = `${new Date().toISOString()} [${tag}] ${level} ${message}\n`
|
|
44
|
+
try {
|
|
45
|
+
if (level === 'error') console.error(line.trimEnd())
|
|
46
|
+
else if (level === 'warn') console.warn(line.trimEnd())
|
|
47
|
+
else console.log(line.trimEnd())
|
|
48
|
+
} catch {
|
|
49
|
+
// console can be closed in a packaged app
|
|
50
|
+
}
|
|
51
|
+
void appendFile(file, line).catch(() => {})
|
|
52
|
+
}
|
|
53
|
+
return {
|
|
54
|
+
file,
|
|
55
|
+
info: (message) => write('info', message),
|
|
56
|
+
warn: (message) => write('warn', message),
|
|
57
|
+
error: (message) => write('error', message),
|
|
58
|
+
}
|
|
59
|
+
}
|
package/lib/status.mjs
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { mkdir, rename, writeFile } from 'node:fs/promises'
|
|
2
|
+
import { hostname, platform, release, userInfo } from 'node:os'
|
|
3
|
+
import { dirname, join } from 'node:path'
|
|
4
|
+
|
|
5
|
+
function pad2(n) {
|
|
6
|
+
return String(n).padStart(2, '0')
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
/** Local wall-clock stamp: `YYYY-MM-DD HH:mm:ss`. */
|
|
10
|
+
export function stampLocal(date = new Date()) {
|
|
11
|
+
const d = date instanceof Date ? date : new Date(date)
|
|
12
|
+
if (Number.isNaN(d.getTime())) return '-'
|
|
13
|
+
return (
|
|
14
|
+
`${d.getFullYear()}-${pad2(d.getMonth() + 1)}-${pad2(d.getDate())} ` +
|
|
15
|
+
`${pad2(d.getHours())}:${pad2(d.getMinutes())}:${pad2(d.getSeconds())}`
|
|
16
|
+
)
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** The process start time, derived from Node's uptime (no extra state needed). */
|
|
20
|
+
export function processStartedAt(now = Date.now()) {
|
|
21
|
+
return new Date(now - process.uptime() * 1000)
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function currentUser() {
|
|
25
|
+
try {
|
|
26
|
+
return userInfo().username || process.env.USERNAME || process.env.USER || 'unknown'
|
|
27
|
+
} catch {
|
|
28
|
+
return process.env.USERNAME || process.env.USER || 'unknown'
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Render the human-readable PC snapshot. Kept to the same shape the old
|
|
34
|
+
* watcher wrote, so the cloud-side AGENTS.md can keep reading `状态刷新`.
|
|
35
|
+
*/
|
|
36
|
+
export function renderStatus({ now = new Date(), startedAt } = {}) {
|
|
37
|
+
const started = startedAt instanceof Date ? startedAt : processStartedAt(now.getTime())
|
|
38
|
+
return [
|
|
39
|
+
'# 电脑侧状态(自动生成,勿手改)',
|
|
40
|
+
'',
|
|
41
|
+
`- 主机名:${hostname()}`,
|
|
42
|
+
`- 用户:${currentUser()}`,
|
|
43
|
+
`- 系统:${platform()} ${release()}`,
|
|
44
|
+
`- DSH 本次启动:${stampLocal(started)}`,
|
|
45
|
+
`- 状态刷新:${stampLocal(now)}`,
|
|
46
|
+
'',
|
|
47
|
+
].join('\n')
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Write the snapshot atomically. Never throws: a failed snapshot must not
|
|
52
|
+
* block the memory sync itself.
|
|
53
|
+
*/
|
|
54
|
+
export async function writeStatusFile({ statusFile, logger, now = new Date(), startedAt } = {}) {
|
|
55
|
+
const file = String(statusFile || '').trim()
|
|
56
|
+
if (!file) return false
|
|
57
|
+
try {
|
|
58
|
+
const dir = dirname(file)
|
|
59
|
+
await mkdir(dir, { recursive: true })
|
|
60
|
+
const text = renderStatus({ now, startedAt })
|
|
61
|
+
const tmp = join(dir, `.${Date.now()}.${process.pid}.status.tmp`)
|
|
62
|
+
await writeFile(tmp, text, 'utf8')
|
|
63
|
+
await rename(tmp, file)
|
|
64
|
+
logger?.info?.(`status snapshot written: ${file}`)
|
|
65
|
+
return true
|
|
66
|
+
} catch (error) {
|
|
67
|
+
logger?.warn?.(`status snapshot failed: ${error?.message || error}`)
|
|
68
|
+
return false
|
|
69
|
+
}
|
|
70
|
+
}
|
package/lib/sync.mjs
ADDED
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
import { join } from 'node:path'
|
|
2
|
+
|
|
3
|
+
import { acquireLock } from './lock.mjs'
|
|
4
|
+
import { inspectRepo, runStartupChain } from './git.mjs'
|
|
5
|
+
import { notifyFailure } from './notify.mjs'
|
|
6
|
+
import { readJson, writeJsonAtomic } from './state.mjs'
|
|
7
|
+
import { writeStatusFile } from './status.mjs'
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Owns the startup timer, the one-at-a-time lock, retry/self-heal timers and
|
|
11
|
+
* the last-run record. Pure Node; no host services besides the injected logger.
|
|
12
|
+
*/
|
|
13
|
+
export function createSyncRuntime({ cfg, logger }) {
|
|
14
|
+
const lastRunFile = join(cfg.stateDir, 'last-run.json')
|
|
15
|
+
const lockDir = join(cfg.stateDir, 'lock')
|
|
16
|
+
|
|
17
|
+
let startupTimer = null
|
|
18
|
+
let retryTimer = null
|
|
19
|
+
let healTimer = null
|
|
20
|
+
let running = null
|
|
21
|
+
let lastRun = null
|
|
22
|
+
let notified = false
|
|
23
|
+
let disposed = false
|
|
24
|
+
|
|
25
|
+
const log = (level, message) => logger?.[level]?.(message)
|
|
26
|
+
|
|
27
|
+
function clearTimers() {
|
|
28
|
+
for (const timer of [startupTimer, retryTimer, healTimer]) {
|
|
29
|
+
if (timer) clearTimeout(timer)
|
|
30
|
+
}
|
|
31
|
+
startupTimer = null
|
|
32
|
+
retryTimer = null
|
|
33
|
+
healTimer = null
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
async function execute(trigger) {
|
|
37
|
+
if (disposed) return { ok: false, skipped: true, trigger, reason: 'disposed' }
|
|
38
|
+
if (running) return running
|
|
39
|
+
|
|
40
|
+
running = (async () => {
|
|
41
|
+
const lock = await acquireLock(lockDir)
|
|
42
|
+
if (!lock) return { ok: false, locked: true, trigger, at: new Date().toISOString() }
|
|
43
|
+
try {
|
|
44
|
+
log('info', `sync start (${trigger}) mode=${cfg.mode}`)
|
|
45
|
+
if (cfg.statusFile) {
|
|
46
|
+
await writeStatusFile({ statusFile: cfg.statusFile, logger })
|
|
47
|
+
}
|
|
48
|
+
const result = await runStartupChain(cfg)
|
|
49
|
+
const run = { ...result, trigger, at: new Date().toISOString() }
|
|
50
|
+
lastRun = run
|
|
51
|
+
await writeJsonAtomic(lastRunFile, run).catch(() => {})
|
|
52
|
+
log(
|
|
53
|
+
result.ok ? 'info' : 'warn',
|
|
54
|
+
`sync ${result.ok ? 'ok' : 'FAILED'} (${trigger}) head=${result.head || '-'}`,
|
|
55
|
+
)
|
|
56
|
+
return run
|
|
57
|
+
} catch (error) {
|
|
58
|
+
const run = {
|
|
59
|
+
ok: false,
|
|
60
|
+
phases: [],
|
|
61
|
+
trigger,
|
|
62
|
+
at: new Date().toISOString(),
|
|
63
|
+
error: String(error?.message || error),
|
|
64
|
+
}
|
|
65
|
+
lastRun = run
|
|
66
|
+
log('error', `sync crashed (${trigger}): ${run.error}`)
|
|
67
|
+
return run
|
|
68
|
+
} finally {
|
|
69
|
+
await lock.release().catch(() => {})
|
|
70
|
+
running = null
|
|
71
|
+
}
|
|
72
|
+
})()
|
|
73
|
+
|
|
74
|
+
return running
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function finalFailure(run) {
|
|
78
|
+
if (cfg.notifyOnFailure && !notified) {
|
|
79
|
+
notified = true
|
|
80
|
+
const failed = (run.phases || [])
|
|
81
|
+
.filter((phase) => !phase.ok && !phase.skipped)
|
|
82
|
+
.map((phase) => phase.name)
|
|
83
|
+
.join('/')
|
|
84
|
+
const detail = failed || run.error || 'unknown'
|
|
85
|
+
void notifyFailure({
|
|
86
|
+
title: 'DSH 记忆同步失败',
|
|
87
|
+
message: `三段同步没有完成(${detail})。详见 ${logger?.file || cfg.stateDir}。`,
|
|
88
|
+
logger,
|
|
89
|
+
})
|
|
90
|
+
}
|
|
91
|
+
armHeal()
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function armRetry(run) {
|
|
95
|
+
if (disposed || cfg.retryDelaySec <= 0) {
|
|
96
|
+
finalFailure(run)
|
|
97
|
+
return
|
|
98
|
+
}
|
|
99
|
+
if (retryTimer) clearTimeout(retryTimer)
|
|
100
|
+
retryTimer = setTimeout(() => {
|
|
101
|
+
retryTimer = null
|
|
102
|
+
void execute('retry').then((next) => afterRun(next, 'retry'))
|
|
103
|
+
}, cfg.retryDelaySec * 1000)
|
|
104
|
+
retryTimer.unref?.()
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function armHeal() {
|
|
108
|
+
if (disposed || cfg.healIntervalSec <= 0) return
|
|
109
|
+
if (healTimer) clearTimeout(healTimer)
|
|
110
|
+
healTimer = setTimeout(() => {
|
|
111
|
+
healTimer = null
|
|
112
|
+
void execute('heal').then((next) => {
|
|
113
|
+
if (next.ok) {
|
|
114
|
+
notified = false
|
|
115
|
+
return
|
|
116
|
+
}
|
|
117
|
+
armHeal()
|
|
118
|
+
})
|
|
119
|
+
}, cfg.healIntervalSec * 1000)
|
|
120
|
+
healTimer.unref?.()
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function afterRun(run, trigger) {
|
|
124
|
+
if (!run || run.skipped) return
|
|
125
|
+
if (run.locked) {
|
|
126
|
+
log('info', 'another sync holds the lock; will retry later')
|
|
127
|
+
armHeal()
|
|
128
|
+
return
|
|
129
|
+
}
|
|
130
|
+
if (run.ok) {
|
|
131
|
+
notified = false
|
|
132
|
+
return
|
|
133
|
+
}
|
|
134
|
+
if (trigger === 'startup') {
|
|
135
|
+
armRetry(run)
|
|
136
|
+
return
|
|
137
|
+
}
|
|
138
|
+
finalFailure(run)
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function scheduleStartup() {
|
|
142
|
+
if (!cfg.enabled || !cfg.runOnStartup) {
|
|
143
|
+
log('info', 'startup sync disabled by config')
|
|
144
|
+
return
|
|
145
|
+
}
|
|
146
|
+
if (process.uptime() > cfg.startupGraceSec) {
|
|
147
|
+
log('info', `plugin mounted after ${Math.round(process.uptime())}s; treating as a reload, not a boot`)
|
|
148
|
+
return
|
|
149
|
+
}
|
|
150
|
+
// Let the app finish booting before touching git or ssh.
|
|
151
|
+
startupTimer = setTimeout(() => {
|
|
152
|
+
startupTimer = null
|
|
153
|
+
void execute('startup').then((run) => afterRun(run, 'startup'))
|
|
154
|
+
}, 2500)
|
|
155
|
+
startupTimer.unref?.()
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
async function run(trigger = 'manual') {
|
|
159
|
+
return execute(trigger)
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
async function status() {
|
|
163
|
+
const stored = lastRun || (await readJson(lastRunFile, null))
|
|
164
|
+
const repo = await inspectRepo(cfg)
|
|
165
|
+
return {
|
|
166
|
+
ok: Boolean(repo.ok),
|
|
167
|
+
repo: repo.ok
|
|
168
|
+
? {
|
|
169
|
+
path: cfg.repoPath,
|
|
170
|
+
branch: repo.branch,
|
|
171
|
+
head: repo.head,
|
|
172
|
+
dirty: repo.dirty,
|
|
173
|
+
ahead: repo.ahead,
|
|
174
|
+
behind: repo.behind,
|
|
175
|
+
remote: repo.remote,
|
|
176
|
+
}
|
|
177
|
+
: { path: cfg.repoPath, error: repo.detail || 'not a git repo' },
|
|
178
|
+
lastRun: stored,
|
|
179
|
+
running: Boolean(running),
|
|
180
|
+
enabled: cfg.enabled,
|
|
181
|
+
mode: cfg.mode,
|
|
182
|
+
peerConfigured: Boolean(cfg.peerHost && cfg.peerUser),
|
|
183
|
+
logFile: logger?.file || '',
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function dispose() {
|
|
188
|
+
disposed = true
|
|
189
|
+
clearTimers()
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
return { scheduleStartup, run, status, dispose }
|
|
193
|
+
}
|