dsh-home-sync 0.2.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/LICENSE +21 -0
- package/README.md +139 -0
- package/README.zh-CN.md +110 -0
- package/cordis.patch.yml +5 -0
- package/lib/index.js +107 -0
- package/lib/sync.js +374 -0
- package/lib/ui.js +841 -0
- package/package.json +23 -0
package/lib/sync.js
ADDED
|
@@ -0,0 +1,374 @@
|
|
|
1
|
+
import fs from 'node:fs'
|
|
2
|
+
import os from 'node:os'
|
|
3
|
+
import path from 'node:path'
|
|
4
|
+
import crypto from 'node:crypto'
|
|
5
|
+
import { execFile } from 'node:child_process'
|
|
6
|
+
|
|
7
|
+
export const DEFAULT_CONFIG = Object.freeze({ branch: 'main', autoPullOnStartup: true,
|
|
8
|
+
autoSync: false, syncIntervalSeconds: 60, commitMessage: 'sync: home config & memory', sshBatch: true })
|
|
9
|
+
export class SyncError extends Error {
|
|
10
|
+
constructor(reason, message, status = 409) { super(message); this.reason = reason; this.status = status }
|
|
11
|
+
}
|
|
12
|
+
const fail = (reason, message, status) => { throw new SyncError(reason, message, status) }
|
|
13
|
+
const exists = file => { try { return fs.lstatSync(file) } catch (e) { if (e.code === 'ENOENT') return null; throw e } }
|
|
14
|
+
const hash = file => crypto.createHash('sha256').update(fs.readFileSync(file)).digest('hex')
|
|
15
|
+
const split0 = value => value.split('\0').filter(Boolean)
|
|
16
|
+
|
|
17
|
+
export function validateBranch(value) {
|
|
18
|
+
if (typeof value !== 'string' || !value || value.length > 200 || value === 'HEAD' || value === '@' ||
|
|
19
|
+
/[\s\x00-\x1f\x7f~^:?*\[\\]/.test(value) || value.startsWith('-') || value.includes('..') || value.includes('@{') ||
|
|
20
|
+
value.split('/').some(s => !s || s.startsWith('.') || s.endsWith('.') || s.endsWith('.lock'))) fail('invalid-branch', '分支名称无效。', 400)
|
|
21
|
+
return value
|
|
22
|
+
}
|
|
23
|
+
export function validateConfig(input, base = DEFAULT_CONFIG) {
|
|
24
|
+
if (!input || typeof input !== 'object' || Array.isArray(input)) fail('invalid-config', '配置必须是 JSON 对象。', 400)
|
|
25
|
+
const allowed = [...Object.keys(DEFAULT_CONFIG), 'autoSyncOnStartup']
|
|
26
|
+
for (const key of Object.keys(input)) if (!allowed.includes(key)) fail('invalid-config', `未知配置项:${key}`, 400)
|
|
27
|
+
const cfg = { ...base, ...input }
|
|
28
|
+
if (!Object.hasOwn(input, 'autoSync') && Object.hasOwn(input, 'autoSyncOnStartup')) cfg.autoSync = input.autoSyncOnStartup
|
|
29
|
+
for (const key of ['autoSync', 'autoPullOnStartup', 'sshBatch']) if (typeof cfg[key] !== 'boolean') fail('invalid-config', `${key} 必须是布尔值。`, 400)
|
|
30
|
+
if (Object.hasOwn(input, 'autoSyncOnStartup') && typeof input.autoSyncOnStartup !== 'boolean') fail('invalid-config', 'autoSyncOnStartup 必须是布尔值。', 400)
|
|
31
|
+
delete cfg.autoSyncOnStartup
|
|
32
|
+
validateBranch(cfg.branch)
|
|
33
|
+
if (!Number.isInteger(cfg.syncIntervalSeconds) || cfg.syncIntervalSeconds < 15 || cfg.syncIntervalSeconds > 86400) fail('invalid-config', '同步间隔必须为 15–86400 秒的整数。', 400)
|
|
34
|
+
if (typeof cfg.commitMessage !== 'string' || !cfg.commitMessage.trim() || cfg.commitMessage.length > 1000 || cfg.commitMessage.includes('\0')) fail('invalid-config', '提交信息必须为 1–1000 个字符。', 400)
|
|
35
|
+
return cfg
|
|
36
|
+
}
|
|
37
|
+
export function validateRemote(value) {
|
|
38
|
+
if (typeof value !== 'string' || !value || value.length > 2048 || /[\x00-\x1f\x7f]/.test(value) || value.startsWith('-') || (!path.isAbsolute(value) && /\s/.test(value))) fail('invalid-remote', '远端地址无效。', 400)
|
|
39
|
+
if (/^(https|ssh):\/\//i.test(value)) {
|
|
40
|
+
let url
|
|
41
|
+
try { url = new URL(value) } catch { fail('invalid-remote', '远端 URL 无效。', 400) }
|
|
42
|
+
if (!url.hostname || url.password || (url.protocol === 'https:' && url.username)) fail('invalid-remote', '请使用凭据管理器,不要在 URL 中放置口令。', 400)
|
|
43
|
+
} else if (!path.isAbsolute(value) && !/^(?:[\w.-]+@)?[\w.-]+:[\w./-]+$/.test(value)) fail('invalid-remote', '仅支持 HTTPS、SSH、SCP 格式或绝对本地仓库路径。', 400)
|
|
44
|
+
return value
|
|
45
|
+
}
|
|
46
|
+
const exactFiles = new Set(['.gitignore', 'settings.yaml', 'cordis.patch.yml',
|
|
47
|
+
'profiles/web/package.json', 'profiles/web/pnpm-lock.yaml', 'profiles/web/pnpm-workspace.yaml',
|
|
48
|
+
'profiles/web/cordis.yml', 'profiles/web/cordis.patch.yml', 'profiles/web/.dsh-market/state.json'])
|
|
49
|
+
export function allowedFile(rel) {
|
|
50
|
+
if (typeof rel !== 'string' || /[\\:\x00-\x1f\x7f]/.test(rel)) return false
|
|
51
|
+
const parts = rel.split('/')
|
|
52
|
+
if (parts.some(s => !s || s === '.' || s === '..' || /[. ]$/.test(s) || /^(con|prn|aux|nul|com[1-9]|lpt[1-9])(?:\.|$)/i.test(s))) return false
|
|
53
|
+
if (exactFiles.has(rel)) return true
|
|
54
|
+
return parts[0] === 'mnemon' && parts.length > 1 && !parts.slice(1).some(s => s.startsWith('.') || /^(data|state|credentials?|secrets?)$/i.test(s)) && !/\.(log|wal|shm)$/i.test(rel)
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function createHomeSync(directory = process.env.DSH_HOME || path.join(os.homedir(), '.dsh')) {
|
|
58
|
+
const home = exists(path.resolve(directory)) ? fs.realpathSync.native(path.resolve(directory)) : path.resolve(directory)
|
|
59
|
+
if (home === path.parse(home).root) fail('invalid-home', 'DSH_HOME 不能是磁盘根目录。', 400)
|
|
60
|
+
const configFile = path.join(home, 'dsh-home-sync.json'), lockDir = home + '.home-sync-lock'
|
|
61
|
+
const historyFile = home + '.home-sync-history.json'
|
|
62
|
+
const trackingRef = cfg => 'refs/dsh-home-sync/branches/' + cfg.branch
|
|
63
|
+
let disposed = false, active = null, lastResult = null, historyWarning = null
|
|
64
|
+
function readHistory() {
|
|
65
|
+
try {
|
|
66
|
+
const stat = exists(historyFile)
|
|
67
|
+
if (!stat) return []
|
|
68
|
+
if (!stat.isFile() || stat.isSymbolicLink() || stat.size > 1024 * 1024) throw new Error('操作记录文件类型或大小无效')
|
|
69
|
+
const rows = JSON.parse(fs.readFileSync(historyFile, 'utf8'))
|
|
70
|
+
if (!Array.isArray(rows) || rows.some(r => !r || typeof r !== 'object' || typeof r.message !== 'string')) throw new Error('操作记录格式无效')
|
|
71
|
+
return rows.slice(0, 100)
|
|
72
|
+
} catch (error) { historyWarning = '操作记录读取失败:' + error.message; return [] }
|
|
73
|
+
}
|
|
74
|
+
function recordResult(result) {
|
|
75
|
+
historyWarning = null
|
|
76
|
+
const temp = historyFile + '.' + crypto.randomUUID() + '.tmp'
|
|
77
|
+
try {
|
|
78
|
+
const rows = readHistory()
|
|
79
|
+
if (historyWarning) return
|
|
80
|
+
const row = { id: active.id, at: result.at, kind: result.kind, ok: result.ok,
|
|
81
|
+
message: String(result.message || (result.ok ? '操作完成。' : result.reason)).slice(0, 6000), backupDir: result.backupDir }
|
|
82
|
+
const saved = [row, ...rows].slice(0, 100)
|
|
83
|
+
let serialized = JSON.stringify(saved)
|
|
84
|
+
while (Buffer.byteLength(serialized) > 900 * 1024 && saved.length > 1) { saved.pop(); serialized = JSON.stringify(saved) }
|
|
85
|
+
const fd = fs.openSync(temp, 'wx', 0o600)
|
|
86
|
+
try { fs.writeFileSync(fd, serialized); fs.fsyncSync(fd) } finally { fs.closeSync(fd) }
|
|
87
|
+
fs.renameSync(temp, historyFile)
|
|
88
|
+
} catch (error) { historyWarning = '操作记录保存失败:' + error.message }
|
|
89
|
+
finally { try { if (exists(temp)) fs.unlinkSync(temp) } catch (error) { historyWarning = '操作记录临时文件清理失败:' + error.message } }
|
|
90
|
+
}
|
|
91
|
+
function checkActive() { if (disposed) fail('stopped', '插件已停止。', 503) }
|
|
92
|
+
function readConfig() {
|
|
93
|
+
try { return validateConfig(JSON.parse(fs.readFileSync(configFile, 'utf8'))) }
|
|
94
|
+
catch (e) { if (e.code === 'ENOENT') return { ...DEFAULT_CONFIG }; fail('config-corrupt', `配置读取失败,自动任务已暂停:${e.message}`, 503) }
|
|
95
|
+
}
|
|
96
|
+
function atomicConfig(cfg) {
|
|
97
|
+
fs.mkdirSync(home, { recursive: true })
|
|
98
|
+
const temp = configFile + '.' + crypto.randomUUID() + '.tmp'
|
|
99
|
+
try {
|
|
100
|
+
const fd = fs.openSync(temp, 'wx', 0o600)
|
|
101
|
+
try { fs.writeFileSync(fd, JSON.stringify(cfg, null, 2) + '\n'); fs.fsyncSync(fd) } finally { fs.closeSync(fd) }
|
|
102
|
+
fs.renameSync(temp, configFile)
|
|
103
|
+
} finally { if (exists(temp)) fs.unlinkSync(temp) }
|
|
104
|
+
}
|
|
105
|
+
function git(args, { cwd = home, cfg = DEFAULT_CONFIG, input, cleanup = false } = {}) {
|
|
106
|
+
if (!cleanup) checkActive()
|
|
107
|
+
const env = { ...process.env }
|
|
108
|
+
for (const key of Object.keys(env)) if (/^GIT_(DIR|WORK_TREE|COMMON_DIR|INDEX_FILE|OBJECT_DIRECTORY|ALTERNATE_OBJECT_DIRECTORIES|NAMESPACE|CONFIG_COUNT|CONFIG_KEY_.*|CONFIG_VALUE_.*|CONFIG_PARAMETERS|CEILING_DIRECTORIES|DISCOVERY_ACROSS_FILESYSTEM|PREFIX|SSH|SSH_COMMAND)$/.test(key)) delete env[key]
|
|
109
|
+
env.GIT_TERMINAL_PROMPT = '0'; env.GCM_INTERACTIVE = 'never'; env.GIT_OPTIONAL_LOCKS = '0'; env.GIT_NO_REPLACE_OBJECTS = '1'
|
|
110
|
+
if (cfg.sshBatch) env.GIT_SSH_COMMAND = 'ssh -o BatchMode=yes -o StrictHostKeyChecking=accept-new -o ConnectTimeout=15'
|
|
111
|
+
return new Promise(resolve => {
|
|
112
|
+
const child = execFile('git', ['--literal-pathspecs', '-C', cwd, ...args],
|
|
113
|
+
{ env, timeout: 90000, windowsHide: true, maxBuffer: 16 * 1024 * 1024 }, (error, stdout, stderr) => {
|
|
114
|
+
resolve({ ok: !error, code: error?.code ?? 0, stdout: String(stdout || ''), stderr: String(stderr || ''), timedOut: !!error?.killed })
|
|
115
|
+
})
|
|
116
|
+
child.stdin.on('error', () => {}); child.stdin.end(input)
|
|
117
|
+
})
|
|
118
|
+
}
|
|
119
|
+
async function must(args, options) {
|
|
120
|
+
const r = await git(args, options)
|
|
121
|
+
if (!r.ok) fail('git-failed', (r.stderr || r.stdout || `Git 失败:${r.code}`).trim())
|
|
122
|
+
return r.stdout.trim()
|
|
123
|
+
}
|
|
124
|
+
function safeHomePath(rel) {
|
|
125
|
+
if (!allowedFile(rel)) fail('unsafe-path', `禁止同步此路径:${rel}`)
|
|
126
|
+
const target = path.resolve(home, ...rel.split('/'))
|
|
127
|
+
if (!target.startsWith(home + path.sep)) fail('unsafe-path', '路径超出主目录。')
|
|
128
|
+
const parts = rel.split('/')
|
|
129
|
+
for (let i = 1; i <= parts.length; i++) {
|
|
130
|
+
const entry = exists(path.join(home, ...parts.slice(0, i)))
|
|
131
|
+
if (entry && (entry.isSymbolicLink() || (i < parts.length ? !entry.isDirectory() : !entry.isFile() || entry.nlink > 1))) fail('path-collision', `路径不是独立的普通文件或目录:${rel}`)
|
|
132
|
+
}
|
|
133
|
+
return target
|
|
134
|
+
}
|
|
135
|
+
async function tree(ref, cwd = home) {
|
|
136
|
+
const r = await git(['ls-tree', '-r', '-z', ref], { cwd })
|
|
137
|
+
if (!r.ok) fail('tree-failed', r.stderr.trim())
|
|
138
|
+
const files = []
|
|
139
|
+
for (const row of split0(r.stdout)) {
|
|
140
|
+
const tab = row.indexOf('\t'), rel = row.slice(tab + 1), mode = row.slice(0, 6)
|
|
141
|
+
if (tab < 0 || !['100644', '100755'].includes(mode) || !allowedFile(rel)) fail('unsafe-tree', `仓库含禁止同步的路径或文件类型:${rel}`)
|
|
142
|
+
files.push(rel)
|
|
143
|
+
}
|
|
144
|
+
if (new Set(files.map(f => f.toLowerCase())).size !== files.length) fail('path-collision', '仓库含大小写冲突的文件路径。')
|
|
145
|
+
return files
|
|
146
|
+
}
|
|
147
|
+
async function ensureRepo(cfg, requireBranch = true, allowUnborn = false) {
|
|
148
|
+
if (!exists(path.join(home, '.git'))) fail('no-repo', '尚未初始化同步仓库。')
|
|
149
|
+
const root = await must(['rev-parse', '--show-toplevel'])
|
|
150
|
+
const canonical = file => { const value = fs.realpathSync.native(file); return process.platform === 'win32' ? value.toLowerCase() : value }
|
|
151
|
+
if (canonical(root) !== canonical(home)) fail('wrong-repo', 'Git 仓库根目录与 DSH_HOME 不一致。')
|
|
152
|
+
const branch = await must(['symbolic-ref', '--quiet', '--short', 'HEAD'])
|
|
153
|
+
if (requireBranch && branch !== cfg.branch) fail('branch-mismatch', `当前分支 ${branch} 与同步分支 ${cfg.branch} 不一致。`)
|
|
154
|
+
for (const marker of ['MERGE_HEAD', 'rebase-merge', 'rebase-apply', 'CHERRY_PICK_HEAD', 'REVERT_HEAD']) {
|
|
155
|
+
const location = await must(['rev-parse', '--git-path', marker])
|
|
156
|
+
if (exists(path.resolve(home, location))) fail('unfinished-operation', '请先完成或撤销现有 Git 合并/变基操作。')
|
|
157
|
+
}
|
|
158
|
+
const head = await git(['rev-parse', '--verify', 'HEAD'])
|
|
159
|
+
if (!head.ok) {
|
|
160
|
+
const ref = await git(['show-ref', '--verify', '--quiet', 'refs/heads/' + branch])
|
|
161
|
+
if (!allowUnborn || ref.code !== 1) fail('invalid-head', head.stderr.trim())
|
|
162
|
+
}
|
|
163
|
+
return branch
|
|
164
|
+
}
|
|
165
|
+
async function inspectIndex() {
|
|
166
|
+
const r = await git(['ls-files', '--stage', '-z'])
|
|
167
|
+
if (!r.ok) fail('index-failed', r.stderr.trim())
|
|
168
|
+
const files = []
|
|
169
|
+
for (const row of split0(r.stdout)) {
|
|
170
|
+
const tab = row.indexOf('\t'), rel = row.slice(tab + 1), meta = row.slice(0, tab).split(' ')
|
|
171
|
+
if (!['100644', '100755'].includes(meta[0]) || meta[2] !== '0' || !allowedFile(rel)) fail('unsafe-index', `索引包含禁止路径或未解决冲突:${rel}`)
|
|
172
|
+
safeHomePath(rel); files.push(rel)
|
|
173
|
+
}
|
|
174
|
+
return files
|
|
175
|
+
}
|
|
176
|
+
async function fetchRemote(cfg) {
|
|
177
|
+
const remote = validateRemote(await must(['remote', 'get-url', 'origin']))
|
|
178
|
+
await must(['fetch', '--no-tags', '--no-recurse-submodules', remote, '+refs/heads/' + cfg.branch + ':' + trackingRef(cfg)], { cfg })
|
|
179
|
+
await tree(trackingRef(cfg))
|
|
180
|
+
return await must(['rev-parse', trackingRef(cfg)])
|
|
181
|
+
}
|
|
182
|
+
async function aheadBehind(ref) {
|
|
183
|
+
const [ahead, behind] = (await must(['rev-list', '--left-right', '--count', 'HEAD...' + ref])).split(/\s+/).map(Number)
|
|
184
|
+
return { ahead, behind }
|
|
185
|
+
}
|
|
186
|
+
async function stageChanges() {
|
|
187
|
+
const tracked = await inspectIndex(), r = await git(['ls-files', '--others', '--exclude-standard', '-z'])
|
|
188
|
+
if (!r.ok) fail('status-failed', r.stderr.trim())
|
|
189
|
+
const files = [...new Set([...tracked, ...split0(r.stdout).filter(allowedFile)])]
|
|
190
|
+
for (const rel of files) safeHomePath(rel)
|
|
191
|
+
if (files.length) await must(['add', '-A', '--pathspec-from-file=-', '--pathspec-file-nul'], { input: files.join('\0') + '\0' })
|
|
192
|
+
await inspectIndex()
|
|
193
|
+
}
|
|
194
|
+
async function sync(pullOnly = false) {
|
|
195
|
+
const cfg = readConfig()
|
|
196
|
+
await ensureRepo(cfg); await tree('HEAD'); await inspectIndex()
|
|
197
|
+
const remoteRef = await fetchRemote(cfg), targetFiles = await tree(remoteRef)
|
|
198
|
+
for (const rel of targetFiles) safeHomePath(rel)
|
|
199
|
+
const currentFiles = new Set(await tree('HEAD'))
|
|
200
|
+
for (const rel of targetFiles) if (!currentFiles.has(rel) && exists(safeHomePath(rel))) fail('local-collision', `远端新增文件与本机未跟踪文件同名:${rel}`)
|
|
201
|
+
if (!pullOnly) {
|
|
202
|
+
await stageChanges()
|
|
203
|
+
const changed = await git(['diff', '--cached', '--quiet'])
|
|
204
|
+
if (changed.code === 1) await must(['commit', '-m', cfg.commitMessage], { cfg })
|
|
205
|
+
else if (!changed.ok) fail('status-failed', changed.stderr.trim())
|
|
206
|
+
}
|
|
207
|
+
const counts = await aheadBehind(remoteRef)
|
|
208
|
+
if (counts.behind) {
|
|
209
|
+
const r = await git(['merge', '--no-autostash', pullOnly ? '--ff-only' : '--no-edit', remoteRef], { cfg })
|
|
210
|
+
if (!r.ok) {
|
|
211
|
+
const merging = await git(['rev-parse', '--verify', 'MERGE_HEAD'], { cleanup: true })
|
|
212
|
+
if (merging.ok) {
|
|
213
|
+
const abort = await git(['merge', '--abort'], { cleanup: true })
|
|
214
|
+
if (!abort.ok) fail('recovery-required', '合并中止失败,请手动恢复;本地提交已保留。' + abort.stderr)
|
|
215
|
+
}
|
|
216
|
+
fail('sync-conflict', '未推送:无法自动整合远端。已保留本地内容,请手动解决冲突后重试。\n' + r.stderr.trim())
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
await inspectIndex()
|
|
220
|
+
if (!pullOnly) {
|
|
221
|
+
// Every push destination receives reachable history, including ancestors already at the fetch remote.
|
|
222
|
+
const commits = (await must(['rev-list', '--max-count=1001', 'HEAD'])).split('\n').filter(Boolean)
|
|
223
|
+
if (commits.length > 1000) fail('history-too-large', '可达历史超过 1000 个提交,请先人工检查并建立干净的同步历史。')
|
|
224
|
+
for (const ref of commits) await tree(ref)
|
|
225
|
+
const pushRemotes = (await must(['remote', 'get-url', '--push', '--all', 'origin'])).split('\n').map(validateRemote)
|
|
226
|
+
await must(['-c', 'remote.origin.mirror=false', 'push', '--no-follow-tags', 'origin', 'HEAD:refs/heads/' + cfg.branch], { cfg })
|
|
227
|
+
if (pushRemotes.includes(await must(['remote', 'get-url', 'origin']))) await must(['update-ref', trackingRef(cfg), await must(['rev-parse', 'HEAD'])])
|
|
228
|
+
}
|
|
229
|
+
return { ok: true, message: pullOnly ? '已拉取远端更新。' : '同步完成。', ...(await aheadBehind(trackingRef(cfg))) }
|
|
230
|
+
}
|
|
231
|
+
async function initialize(body) {
|
|
232
|
+
if (!body || typeof body !== 'object' || Array.isArray(body)) fail('invalid-body', '初始化参数必须是对象。', 400)
|
|
233
|
+
for (const key of Object.keys(body)) if (!['remote', 'branch', 'confirm', 'mode'].includes(key)) fail('invalid-body', '未知初始化参数。', 400)
|
|
234
|
+
const cfg = readConfig(), branch = validateBranch(body.branch ?? cfg.branch), remote = validateRemote(body.remote)
|
|
235
|
+
if (body.confirm !== true) fail('needs-confirm', '请确认将覆盖同步文件;执行前会创建完整恢复备份。', 400)
|
|
236
|
+
if (!['reset', 'merge'].includes(body.mode)) fail('invalid-mode', '初始化模式无效。', 400)
|
|
237
|
+
const gitDir = path.join(home, '.git'), hadRepo = !!exists(gitDir)
|
|
238
|
+
if (hadRepo && (!exists(gitDir).isDirectory() || exists(gitDir).isSymbolicLink())) fail('unsupported-repo', '初始化暂不支持链接仓库或 Git worktree。')
|
|
239
|
+
if (hadRepo && (exists(path.join(gitDir, 'commondir')) || exists(path.join(gitDir, 'objects/info/alternates')))) fail('unsupported-repo', '初始化暂不支持共享对象库,请先建立独立完整仓库。')
|
|
240
|
+
let oldFiles = []
|
|
241
|
+
if (hadRepo) {
|
|
242
|
+
await ensureRepo(cfg, false, true)
|
|
243
|
+
const head = await git(['rev-parse', '--verify', 'HEAD'])
|
|
244
|
+
oldFiles = [...new Set([...await inspectIndex(), ...(head.ok ? await tree('HEAD') : [])])]
|
|
245
|
+
}
|
|
246
|
+
const temp = fs.mkdtempSync(path.join(os.tmpdir(), 'dsh-home-sync-init-'))
|
|
247
|
+
let backupDir = null, manifest = null, touched = false
|
|
248
|
+
try {
|
|
249
|
+
await must(['init', '-b', branch], { cwd: temp })
|
|
250
|
+
await must(['fetch', '--no-tags', '--no-recurse-submodules', remote, 'refs/heads/' + branch + ':refs/dsh-home-sync/target'], { cwd: temp, cfg })
|
|
251
|
+
const target = await must(['rev-parse', 'refs/dsh-home-sync/target'], { cwd: temp }), files = await tree(target, temp)
|
|
252
|
+
const affected = [...new Set([...oldFiles, ...files])]
|
|
253
|
+
if (new Set(affected.map(f => f.toLowerCase())).size !== affected.length) fail('path-collision', '本机与远端存在大小写路径冲突。')
|
|
254
|
+
for (const rel of affected) safeHomePath(rel)
|
|
255
|
+
// Hard reset is confined to this newly created temporary repository.
|
|
256
|
+
await must(['reset', '--hard', target], { cwd: temp })
|
|
257
|
+
const backupRoot = home + '.home-sync-backups'
|
|
258
|
+
if (exists(backupRoot)?.isSymbolicLink()) fail('unsafe-backup', '备份目录不能是符号链接。')
|
|
259
|
+
fs.mkdirSync(backupRoot, { recursive: true, mode: 0o700 })
|
|
260
|
+
backupDir = fs.mkdtempSync(path.join(backupRoot, 'init-'))
|
|
261
|
+
manifest = { home, target, branch, createdAt: new Date().toISOString(), hadRepo, state: 'prepared', files: [] }
|
|
262
|
+
for (const rel of affected) {
|
|
263
|
+
const src = safeHomePath(rel), present = !!exists(src)
|
|
264
|
+
if (present) {
|
|
265
|
+
const dest = path.join(backupDir, 'files', ...rel.split('/'))
|
|
266
|
+
fs.mkdirSync(path.dirname(dest), { recursive: true }); fs.copyFileSync(src, dest)
|
|
267
|
+
manifest.files.push({ path: rel, existed: true, sha256: hash(dest) })
|
|
268
|
+
} else manifest.files.push({ path: rel, existed: false })
|
|
269
|
+
}
|
|
270
|
+
if (hadRepo) fs.cpSync(gitDir, path.join(backupDir, 'repository'), { recursive: true })
|
|
271
|
+
const oldConfig = exists(configFile) ? fs.readFileSync(configFile) : null
|
|
272
|
+
if (oldConfig) fs.writeFileSync(path.join(backupDir, 'config.json'), oldConfig)
|
|
273
|
+
manifest.hadConfig = oldConfig !== null
|
|
274
|
+
fs.writeFileSync(path.join(backupDir, 'manifest.json'), JSON.stringify(manifest, null, 2))
|
|
275
|
+
fs.writeFileSync(path.join(backupDir, 'MERGE.md'), '# 初始化恢复备份\n\nfiles/ 保存所有被覆盖或删除的原始文件,repository/ 保存原 Git 元数据。manifest.json 记录原文件是否存在。\n\n迁移合并需手动比较 files/ 与主目录内容后再开启自动同步;不要整份覆盖记忆。\n\n恢复时先停止 DSH,按 manifest 恢复文件(删除原先不存在的文件),用 repository/ 替换 .git,并恢复 config.json;若原先没有仓库/配置则移除新建项。请勿在 DSH 运行中恢复。\n')
|
|
276
|
+
for (const file of manifest.files) {
|
|
277
|
+
const src = safeHomePath(file.path)
|
|
278
|
+
if (!!exists(src) !== file.existed || (file.existed && hash(src) !== file.sha256)) fail('home-changed', '备份期间文件发生变化,初始化已取消,请重试。')
|
|
279
|
+
}
|
|
280
|
+
checkActive(); touched = true
|
|
281
|
+
if (!hadRepo) { fs.mkdirSync(home, { recursive: true }); await must(['init', '-b', branch]) }
|
|
282
|
+
await must(['fetch', '--no-tags', '--no-recurse-submodules', temp, target])
|
|
283
|
+
for (const rel of affected) {
|
|
284
|
+
checkActive()
|
|
285
|
+
const dest = safeHomePath(rel)
|
|
286
|
+
if (files.includes(rel)) { fs.mkdirSync(path.dirname(dest), { recursive: true }); fs.copyFileSync(path.join(temp, ...rel.split('/')), dest) }
|
|
287
|
+
else if (exists(dest)) fs.unlinkSync(dest)
|
|
288
|
+
}
|
|
289
|
+
await must(['symbolic-ref', 'HEAD', 'refs/heads/' + branch]); await must(['reset', '--mixed', target])
|
|
290
|
+
const origin = await git(['remote', 'get-url', 'origin'])
|
|
291
|
+
await must(['remote', origin.ok ? 'set-url' : 'add', 'origin', remote])
|
|
292
|
+
const unset = await git(['config', '--unset-all', 'remote.origin.pushurl'])
|
|
293
|
+
if (!unset.ok && unset.code !== 5) fail('config-failed', unset.stderr.trim())
|
|
294
|
+
await must(['config', '--replace-all', 'remote.origin.fetch', '+refs/heads/*:refs/remotes/origin/*'])
|
|
295
|
+
await must(['update-ref', 'refs/remotes/origin/' + branch, target])
|
|
296
|
+
await must(['update-ref', trackingRef({ branch }), target])
|
|
297
|
+
await must(['branch', '--set-upstream-to=origin/' + branch, branch])
|
|
298
|
+
atomicConfig({ ...cfg, branch, autoSync: false, autoPullOnStartup: false })
|
|
299
|
+
manifest.state = 'complete'; fs.writeFileSync(path.join(backupDir, 'manifest.json'), JSON.stringify(manifest, null, 2))
|
|
300
|
+
return { ok: true, config: readConfig(), mode: body.mode, backupDir, backed: manifest.files.filter(f => f.existed).map(f => f.path), message: '初始化完成,自动任务已暂停。请检查备份并手动合并需要保留的内容。' }
|
|
301
|
+
} catch (error) {
|
|
302
|
+
if (touched && manifest) {
|
|
303
|
+
try {
|
|
304
|
+
for (const file of manifest.files) {
|
|
305
|
+
const dest = safeHomePath(file.path)
|
|
306
|
+
if (file.existed) { fs.mkdirSync(path.dirname(dest), { recursive: true }); fs.copyFileSync(path.join(backupDir, 'files', ...file.path.split('/')), dest) }
|
|
307
|
+
else if (exists(dest)) fs.unlinkSync(dest)
|
|
308
|
+
}
|
|
309
|
+
// Fixed paths under the checked home and this operation's backup.
|
|
310
|
+
if (path.resolve(gitDir) !== path.join(home, '.git')) throw new Error('Invalid recovery path')
|
|
311
|
+
if (exists(gitDir)) fs.renameSync(gitDir, path.join(backupDir, 'failed-repository'))
|
|
312
|
+
if (manifest.hadRepo) fs.cpSync(path.join(backupDir, 'repository'), gitDir, { recursive: true })
|
|
313
|
+
if (manifest.hadConfig) fs.copyFileSync(path.join(backupDir, 'config.json'), configFile)
|
|
314
|
+
else if (exists(configFile)) fs.unlinkSync(configFile)
|
|
315
|
+
manifest.state = 'rolled-back'; fs.writeFileSync(path.join(backupDir, 'manifest.json'), JSON.stringify(manifest, null, 2))
|
|
316
|
+
} catch (recovery) { fail('recovery-required', `恢复未完成,请停止 DSH 并从 ${backupDir} 恢复:${recovery.message}`, 500) }
|
|
317
|
+
}
|
|
318
|
+
if (backupDir) error.message += `\n备份:${backupDir}`
|
|
319
|
+
throw error
|
|
320
|
+
} finally {
|
|
321
|
+
// Only the directory created by mkdtemp above is deleted.
|
|
322
|
+
if (path.dirname(temp) === path.resolve(os.tmpdir()) && path.basename(temp).startsWith('dsh-home-sync-init-')) {
|
|
323
|
+
try { fs.rmSync(temp, { recursive: true, force: true, maxRetries: 3, retryDelay: 100 }) }
|
|
324
|
+
catch (cleanupError) { console.warn('[dsh-home-sync] 临时目录清理失败,可稍后删除:', temp, cleanupError.message) }
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
async function operation(label, fn) {
|
|
329
|
+
checkActive()
|
|
330
|
+
if (active) fail('busy', '另一个同步操作正在执行,请稍后重试。')
|
|
331
|
+
fs.mkdirSync(path.dirname(home), { recursive: true })
|
|
332
|
+
try { fs.mkdirSync(lockDir) } catch (e) { if (e.code === 'EEXIST') fail('busy', `同步锁已存在:${lockDir}。若上次进程异常退出,请确认没有任务运行后移除该锁目录。`); throw e }
|
|
333
|
+
active = { id: crypto.randomUUID(), kind: label, startedAt: new Date().toISOString() }
|
|
334
|
+
try {
|
|
335
|
+
fs.writeFileSync(path.join(lockDir, 'owner.json'), JSON.stringify({ pid: process.pid, ...active }))
|
|
336
|
+
const result = await fn(); lastResult = { ...result, kind: label, at: new Date().toISOString() }; recordResult(lastResult); return { ...result, historyWarning }
|
|
337
|
+
} catch (error) {
|
|
338
|
+
lastResult = { ok: false, kind: label, at: new Date().toISOString(), reason: error.reason || 'error', message: error.message }; recordResult(lastResult); throw error
|
|
339
|
+
} finally {
|
|
340
|
+
active = null
|
|
341
|
+
if (exists(path.join(lockDir, 'owner.json'))) fs.unlinkSync(path.join(lockDir, 'owner.json'))
|
|
342
|
+
fs.rmdirSync(lockDir)
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
async function status() {
|
|
346
|
+
const history = readHistory()
|
|
347
|
+
const base = { home, hasRepo: !!exists(path.join(home, '.git')), branch: null, remote: null, dirty: null, ahead: null, behind: null, operation: active, lastResult: lastResult || history[0] || null, history, historyWarning }
|
|
348
|
+
try {
|
|
349
|
+
const cfg = readConfig(); base.config = cfg
|
|
350
|
+
if (!base.hasRepo) return { ...base, ok: true }
|
|
351
|
+
base.branch = await ensureRepo(cfg, false)
|
|
352
|
+
const remote = await git(['remote', 'get-url', 'origin'])
|
|
353
|
+
if (remote.ok) base.remote = remote.stdout.trim().replace(/(https?:\/\/)[^/@]+@/gi, '$1[redacted]@')
|
|
354
|
+
const st = await git(['status', '--porcelain', '--untracked-files=all', '-z'])
|
|
355
|
+
if (!st.ok) fail('status-failed', st.stderr.trim())
|
|
356
|
+
const records = split0(st.stdout), dirty = []
|
|
357
|
+
let excludedCount = 0
|
|
358
|
+
for (let i = 0; i < records.length; i++) {
|
|
359
|
+
if (allowedFile(records[i].slice(3))) dirty.push(records[i]); else excludedCount++
|
|
360
|
+
if (/^[RC]|^.[RC]/.test(records[i])) i++
|
|
361
|
+
}
|
|
362
|
+
base.dirty = dirty
|
|
363
|
+
base.excludedCount = excludedCount
|
|
364
|
+
base.branchMatches = base.branch === cfg.branch
|
|
365
|
+
const ref = await git(['rev-parse', '--verify', trackingRef(cfg)])
|
|
366
|
+
if (ref.ok) Object.assign(base, await aheadBehind(ref.stdout.trim()))
|
|
367
|
+
return { ...base, ok: true }
|
|
368
|
+
} catch (error) { return { ...base, ok: false, reason: error.reason || 'error', error: error.message } }
|
|
369
|
+
}
|
|
370
|
+
return { home, readConfig, status, get active() { return active },
|
|
371
|
+
saveConfig: patch => operation('config', async () => { const cfg = validateConfig(patch, readConfig()); atomicConfig(cfg); return { ok: true, config: cfg } }),
|
|
372
|
+
pull: () => operation('pull', () => sync(true)), push: () => operation('sync', () => sync()),
|
|
373
|
+
init: body => operation('init', () => initialize(body)), dispose() { disposed = true } }
|
|
374
|
+
}
|