@weibaohui/dsh-sync 0.1.0
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/README.md +79 -0
- package/client/bundle.js +528 -0
- package/client/index.js +514 -0
- package/cordis.patch.yml +12 -0
- package/package.json +59 -0
- package/src/index.js +852 -0
package/src/index.js
ADDED
|
@@ -0,0 +1,852 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* dsh-plugin-dsh-sync — Host half
|
|
5
|
+
*
|
|
6
|
+
* A small git-based sync system for multiple dsh replicas. Each instance
|
|
7
|
+
* mirrors its skills / sessions / settings / plugins into a private GitCode
|
|
8
|
+
* repository through a branch → PR → merge flow, so two replicas that both
|
|
9
|
+
* touch the same file surface as a pull request instead of a silent
|
|
10
|
+
* overwrite. Deterministic work (fetch / branch / commit / push) is done by
|
|
11
|
+
* the git CLI directly; only the conflict step — which needs semantic
|
|
12
|
+
* judgement — hands off to an in-process agent (same channel skills-management
|
|
13
|
+
* share uses). Token is write-only through the host settings service and
|
|
14
|
+
* never travels to the client in cleartext.
|
|
15
|
+
*
|
|
16
|
+
* Architecture: a shadow working tree at $DSH_HOME/dsh-sync/repo mirrors
|
|
17
|
+
* selected live roots. Push = fetch origin/main → reset shadow to origin/main
|
|
18
|
+
* → overlay live snapshot → branch → commit → push → create PR → mergeable?
|
|
19
|
+
* merge : surface a conflict action. Pull = fetch → for files remote changed
|
|
20
|
+
* since lastSyncedCommit, write the remote version back to live only when the
|
|
21
|
+
* local copy is untouched (three-way; locally-modified files wait for the
|
|
22
|
+
* next push). Conflicts an agent cannot auto-resolve stay open as PRs.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
const { execFile } = require('node:child_process')
|
|
26
|
+
const { randomUUID } = require('node:crypto')
|
|
27
|
+
const fsP = require('node:fs/promises')
|
|
28
|
+
const { join, relative, resolve, sep } = require('node:path')
|
|
29
|
+
const { homedir, hostname } = require('node:os')
|
|
30
|
+
// settings 服务要求 schemastery schema(可调用 + toJSON;zod 不兼容,register 会抛错被吞)。
|
|
31
|
+
// 宿主沙箱内解析打包依赖可能抛 ERR_INTERNAL_ASSERTION(.pnpm 软链),因此优先沿
|
|
32
|
+
// dsh 全局安装取 settings 服务自用的那份副本,本地开发/测试再退回标准 require。
|
|
33
|
+
function loadSchemastery() {
|
|
34
|
+
const errors = []
|
|
35
|
+
const { createRequire } = require('node:module')
|
|
36
|
+
for (const prefix of [process.env.DSH_GLOBAL_PREFIX, join(homedir(), '.local')].filter(Boolean)) {
|
|
37
|
+
const hostCopy = join(prefix, 'lib', 'node_modules', '@deepseek-ai', 'dsh', 'node_modules', '@deepseek-ai', 'schemastery', 'lib', 'index.cjs')
|
|
38
|
+
try { return createRequire(hostCopy)(hostCopy) } catch (e) { errors.push(String(e && e.code || e)) }
|
|
39
|
+
}
|
|
40
|
+
try { return require('@deepseek-ai/schemastery') } catch (e) { errors.push(String(e && e.code || e)) }
|
|
41
|
+
if (process.env.DSHSYNC_DEBUG) console.warn(`[dsh-sync] schemastery unavailable: ${errors.join(' | ')}`)
|
|
42
|
+
return null
|
|
43
|
+
}
|
|
44
|
+
const Schema = loadSchemastery()
|
|
45
|
+
|
|
46
|
+
const GITCODE_API_BASE = 'https://api.gitcode.com/api/v5'
|
|
47
|
+
const MAX_BODY_BYTES = 64 * 1024
|
|
48
|
+
const SYNC_TIMEOUT_MS = 10 * 60 * 1000
|
|
49
|
+
const CONFLICT_RUN_TIMEOUT_MS = 30 * 60 * 1000
|
|
50
|
+
const CONFLICT_RUN_OUTPUT_CAP = 256 * 1024
|
|
51
|
+
|
|
52
|
+
const DEFAULT_SYNC_SETTINGS = {
|
|
53
|
+
repoUrl: '',
|
|
54
|
+
branch: 'main',
|
|
55
|
+
gitBinary: 'git',
|
|
56
|
+
autoSync: true,
|
|
57
|
+
syncOnStartup: false,
|
|
58
|
+
intervalMinutes: 30,
|
|
59
|
+
conflictMode: 'ai', // 'ai' (action button → in-process agent) | 'manual'
|
|
60
|
+
syncSkills: true,
|
|
61
|
+
syncSessions: false,
|
|
62
|
+
syncSettings: true,
|
|
63
|
+
syncPlugins: true,
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// ── Shared helpers (ported from skills-management so conventions match) ──
|
|
67
|
+
|
|
68
|
+
function dshHome() { return process.env.DSH_HOME ? resolve(process.env.DSH_HOME) : join(homedir(), '.dsh') }
|
|
69
|
+
|
|
70
|
+
function displayPath(p) {
|
|
71
|
+
const home = homedir()
|
|
72
|
+
if (p === home) return '~'
|
|
73
|
+
if (p.startsWith(home + sep)) return '~' + p.slice(home.length)
|
|
74
|
+
return p
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function expandTilde(p) {
|
|
78
|
+
return p === '~' || p.startsWith('~/') || p.startsWith('~\\') ? join(homedir(), p.slice(2)) : p
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function readJsonBody(req) {
|
|
82
|
+
return new Promise((fulfil, reject) => {
|
|
83
|
+
let size = 0, chunks = []
|
|
84
|
+
req.on('data', (chunk) => {
|
|
85
|
+
size += chunk.length
|
|
86
|
+
if (size > MAX_BODY_BYTES) { reject(new Error('request body too large')); req.destroy(); return }
|
|
87
|
+
chunks.push(chunk)
|
|
88
|
+
})
|
|
89
|
+
req.on('end', () => {
|
|
90
|
+
try { fulfil(chunks.length === 0 ? {} : JSON.parse(Buffer.concat(chunks).toString('utf8'))) }
|
|
91
|
+
catch (error) { reject(new Error(`invalid JSON body: ${error && error.message}`)) }
|
|
92
|
+
})
|
|
93
|
+
req.on('error', reject)
|
|
94
|
+
})
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function sendJson(res, status, payload) {
|
|
98
|
+
res.writeHead(status, { 'content-type': 'application/json; charset=utf-8' })
|
|
99
|
+
res.end(JSON.stringify(payload))
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
async function atomicWriteFile(file, content) {
|
|
103
|
+
await fsP.mkdir(join(file, '..'), { recursive: true })
|
|
104
|
+
const temp = join(join(file, '..'), `.${randomUUID()}.tmp`)
|
|
105
|
+
await fsP.writeFile(temp, content)
|
|
106
|
+
await fsP.rename(temp, file)
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// ── Git CLI (token stays out of .git/config — authed URL per command) ──
|
|
110
|
+
|
|
111
|
+
function gitExec(binary, args, cwd) {
|
|
112
|
+
return new Promise((fulfil, reject) => {
|
|
113
|
+
execFile(binary, args, { cwd, timeout: 10 * 60 * 1000, maxBuffer: 16 * 1024 * 1024 }, (error, stdout, stderr) => {
|
|
114
|
+
if (error) {
|
|
115
|
+
const tail = String(stderr || error.message || '').split(/\r?\n/).filter(Boolean).slice(-3).join(' ')
|
|
116
|
+
reject(new Error(`git ${args[0]}: ${tail || error.message}`))
|
|
117
|
+
return
|
|
118
|
+
}
|
|
119
|
+
fulfil(String(stdout))
|
|
120
|
+
})
|
|
121
|
+
})
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function gitShowBuf(binary, rev, cwd) {
|
|
125
|
+
// raw bytes for binary-safe compare/copy (session logs are zstd)
|
|
126
|
+
return new Promise((fulfil, reject) => {
|
|
127
|
+
execFile(binary, ['show', rev], { cwd, maxBuffer: 64 * 1024 * 1024, encoding: 'buffer' }, (error, stdout) => {
|
|
128
|
+
if (error) { reject(new Error(`git show: ${String(error.message || '')}`)); return }
|
|
129
|
+
fulfil(stdout)
|
|
130
|
+
})
|
|
131
|
+
})
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
async function gitAvailable(binary) {
|
|
135
|
+
try { await gitExec(binary, ['--version']); return true } catch { return false }
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
async function gitCurrentCommit(binary, repo) {
|
|
139
|
+
try { return (await gitExec(binary, ['rev-parse', 'HEAD'], repo)).trim() } catch { return undefined }
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/** Embed an access token in an https remote URL (gitcode/oauth2 style).
|
|
143
|
+
* Credentials stay out of .git/config — every remote-touching command
|
|
144
|
+
* receives the authed URL directly and nothing is persisted. */
|
|
145
|
+
function authedUrl(url, token) {
|
|
146
|
+
if (!token) return url
|
|
147
|
+
return String(url).replace(/^(https?:\/\/)([^@/]+@)?/, `$1oauth2:${encodeURIComponent(token)}@`)
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// ── Cross-process lock: tui + web profiles run the same $DSH_HOME, so two
|
|
151
|
+
// sync loops could write the shadow tree at once. O_EXCL atomic create. ──
|
|
152
|
+
|
|
153
|
+
async function acquireLock(lockFile) {
|
|
154
|
+
const fs = require('node:fs')
|
|
155
|
+
try {
|
|
156
|
+
const handle = fs.openSync(lockFile, 'wx')
|
|
157
|
+
fs.writeSync(handle, String(process.pid))
|
|
158
|
+
fs.closeSync(handle)
|
|
159
|
+
return () => { try { fs.unlinkSync(lockFile) } catch {} }
|
|
160
|
+
} catch (e) {
|
|
161
|
+
if (e.code === 'EEXIST') {
|
|
162
|
+
// stale-lock recovery: a crashed process leaves a lock; if its pid is
|
|
163
|
+
// gone, steal it. Otherwise someone else is syncing.
|
|
164
|
+
try {
|
|
165
|
+
const pid = parseInt(String(fs.readFileSync(lockFile, 'utf8')).trim(), 10)
|
|
166
|
+
if (Number.isFinite(pid)) {
|
|
167
|
+
try { process.kill(pid, 0); return null } catch { /* pid dead → steal */ }
|
|
168
|
+
}
|
|
169
|
+
fs.unlinkSync(lockFile)
|
|
170
|
+
const handle = fs.openSync(lockFile, 'wx')
|
|
171
|
+
fs.writeSync(handle, String(process.pid))
|
|
172
|
+
fs.closeSync(handle)
|
|
173
|
+
return () => { try { fs.unlinkSync(lockFile) } catch {} }
|
|
174
|
+
} catch { return null }
|
|
175
|
+
}
|
|
176
|
+
throw e
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// ── GitCode REST: repo privacy check + PR create / detail / merge ──
|
|
181
|
+
|
|
182
|
+
/** Parse `https://gitcode.com/<owner>/<repo>(.git)` → { owner, repo }. */
|
|
183
|
+
function parseRepoUrl(url) {
|
|
184
|
+
const m = String(url || '').match(/gitcode\.com\/([^/]+)\/([^/?.]+?)(?:\.git)?(?:[/?#]|$)/i)
|
|
185
|
+
if (!m) return null
|
|
186
|
+
return { owner: m[1], repo: m[2] }
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
async function gitcodeRequest(token, method, path, body, { apiBase = GITCODE_API_BASE } = {}) {
|
|
190
|
+
const url = apiBase + path
|
|
191
|
+
// 认证必须用 PRIVATE-TOKEN(实测 GitCode 子资源端点 branches/pulls 对
|
|
192
|
+
// Authorization: Bearer 有 bug——带 Bearer 查 project 一律 404 not found,
|
|
193
|
+
// 匿名 / PRIVATE-TOKEN / access_token query 均正常)
|
|
194
|
+
const init = { method, headers: { 'PRIVATE-TOKEN': token, 'Content-Type': 'application/json' } }
|
|
195
|
+
if (body !== undefined) init.body = JSON.stringify(body)
|
|
196
|
+
const r = await fetch(url, init)
|
|
197
|
+
const text = await r.text()
|
|
198
|
+
let json = null
|
|
199
|
+
try { json = text === '' ? null : JSON.parse(text) } catch {}
|
|
200
|
+
return { ok: r.ok, status: r.status, json, text }
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/** Verify the configured repo exists AND is private. Public repos are refused
|
|
204
|
+
* because sync carries credentials (settings.yaml is mirrored wholesale). */
|
|
205
|
+
async function checkRepoPrivate(token, repoUrl) {
|
|
206
|
+
const parsed = parseRepoUrl(repoUrl)
|
|
207
|
+
if (!parsed) return { ok: false, error: '无法解析仓库地址(需要 https://gitcode.com/<owner>/<repo>)' }
|
|
208
|
+
const r = await gitcodeRequest(token, 'GET', `/repos/${parsed.owner}/${parsed.repo}`)
|
|
209
|
+
if (!r.ok) return { ok: false, error: `无法访问仓库(HTTP ${r.status}):${(r.json && r.json.message) || r.text.slice(0, 120)}` }
|
|
210
|
+
const priv = r.json && (r.json.private === true || r.json.private === 'true')
|
|
211
|
+
if (!priv) return { ok: false, error: '检测到公共仓库。dsh-sync 会同步含凭证的 settings.yaml,必须使用私有仓库——请到 gitcode.com 将该仓库设为私有,或新建私有仓库后再填地址。', isPublic: true }
|
|
212
|
+
return { ok: true, owner: parsed.owner, repo: parsed.repo, defaultBranch: r.json && (r.json.default_branch || 'main') }
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
async function createPullRequest(token, owner, repo, { head, base, title, body }) {
|
|
216
|
+
return gitcodeRequest(token, 'POST', `/repos/${owner}/${repo}/pulls`, { head, base, title: title || 'dsh-sync', body: body || '' })
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
async function getPullRequest(token, owner, repo, number) {
|
|
220
|
+
return gitcodeRequest(token, 'GET', `/repos/${owner}/${repo}/pulls/${number}`)
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
async function mergePullRequest(token, owner, repo, number, method) {
|
|
224
|
+
return gitcodeRequest(token, 'PUT', `/repos/${owner}/${repo}/pulls/${number}/merge`, method ? { merge_method: method } : {})
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
// ── Sync spec: which live roots mirror into which shadow paths ──
|
|
228
|
+
// Four toggle groups; a group's sources are only active when its switch
|
|
229
|
+
// is on. Built fresh each cycle from the effective settings. `roots` is
|
|
230
|
+
// injectable so tests never touch the real $HOME.
|
|
231
|
+
|
|
232
|
+
function defaultRoots() {
|
|
233
|
+
const home = homedir()
|
|
234
|
+
const dh = dshHome()
|
|
235
|
+
return {
|
|
236
|
+
dshSkills: join(dh, 'skills'),
|
|
237
|
+
agentsSkills: join(home, '.agents', 'skills'),
|
|
238
|
+
agentsLock: join(home, '.agents', '.skill-lock.json'),
|
|
239
|
+
sessions: join(dh, 'sessions'),
|
|
240
|
+
settingsFile: join(dh, 'settings.yaml'),
|
|
241
|
+
profiles: join(dh, 'profiles'),
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
function syncSpec(eff, roots = defaultRoots()) {
|
|
246
|
+
const groups = []
|
|
247
|
+
if (eff.syncSkills) groups.push({
|
|
248
|
+
name: 'skills',
|
|
249
|
+
sources: [
|
|
250
|
+
{ from: roots.dshSkills, to: 'skills/dsh' },
|
|
251
|
+
// 软链解引用成实文件:跨机不能指望同一个 link target 存在
|
|
252
|
+
{ from: roots.agentsSkills, to: 'skills/agents', followSymlinks: true },
|
|
253
|
+
{ from: roots.agentsLock, to: 'skills/.skill-lock.json', file: true },
|
|
254
|
+
],
|
|
255
|
+
})
|
|
256
|
+
if (eff.syncSessions) groups.push({
|
|
257
|
+
name: 'sessions',
|
|
258
|
+
sources: [{ from: roots.sessions, to: 'sessions', excludeNames: new Set(['session_projcache.json']) }],
|
|
259
|
+
})
|
|
260
|
+
if (eff.syncSettings) groups.push({
|
|
261
|
+
name: 'settings',
|
|
262
|
+
// 整文件同步、不脱敏——前提是私仓校验通过
|
|
263
|
+
sources: [{ from: roots.settingsFile, to: 'settings/settings.yaml', file: true }],
|
|
264
|
+
})
|
|
265
|
+
if (eff.syncPlugins) groups.push({
|
|
266
|
+
name: 'plugins',
|
|
267
|
+
sources: [{
|
|
268
|
+
from: roots.profiles, to: 'plugins',
|
|
269
|
+
// 只存声明:package.json / patch / 锁文件。node_modules 按机重装,
|
|
270
|
+
// .dsh-market 是市场缓存,cordis.yml 是 loader 产物(可重建)
|
|
271
|
+
includeFiles: new Set(['package.json', 'cordis.patch.yml', 'pnpm-lock.yaml', 'pnpm-workspace.yaml']),
|
|
272
|
+
excludeDirs: new Set(['node_modules', '.dsh-market']),
|
|
273
|
+
excludeNames: new Set(['cordis.yml']),
|
|
274
|
+
}],
|
|
275
|
+
})
|
|
276
|
+
return groups
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
async function copyTree(from, to, opts) {
|
|
280
|
+
const { includeFiles, excludeDirs, excludeNames, followSymlinks } = opts || {}
|
|
281
|
+
await fsP.mkdir(to, { recursive: true })
|
|
282
|
+
let entries
|
|
283
|
+
try { entries = await fsP.readdir(from, { withFileTypes: true }) } catch { return }
|
|
284
|
+
for (const ent of entries) {
|
|
285
|
+
if (ent.name === '.git') continue
|
|
286
|
+
if (ent.isDirectory()) {
|
|
287
|
+
if (excludeDirs && excludeDirs.has(ent.name)) continue
|
|
288
|
+
await copyTree(join(from, ent.name), join(to, ent.name), opts)
|
|
289
|
+
} else {
|
|
290
|
+
if (excludeNames && excludeNames.has(ent.name)) continue
|
|
291
|
+
if (includeFiles && !includeFiles.has(ent.name)) continue
|
|
292
|
+
let stat
|
|
293
|
+
try { stat = followSymlinks ? await fsP.stat(join(from, ent.name)) : ent } catch { continue }
|
|
294
|
+
if (!stat || !stat.isFile()) continue
|
|
295
|
+
try { await fsP.copyFile(join(from, ent.name), join(to, ent.name)) } catch {}
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
/** Push a live snapshot into the shadow tree (shadow = live after this). */
|
|
301
|
+
async function mirrorLiveToShadow(spec, shadowDir) {
|
|
302
|
+
for (const group of spec) {
|
|
303
|
+
for (const src of group.sources) {
|
|
304
|
+
const target = join(shadowDir, src.to)
|
|
305
|
+
if (src.file) {
|
|
306
|
+
try {
|
|
307
|
+
await fsP.access(src.from)
|
|
308
|
+
await fsP.mkdir(join(target, '..'), { recursive: true })
|
|
309
|
+
await fsP.copyFile(src.from, target)
|
|
310
|
+
} catch { try { await fsP.unlink(target) } catch {} }
|
|
311
|
+
} else {
|
|
312
|
+
await fsP.rm(target, { recursive: true, force: true }).catch(() => {})
|
|
313
|
+
await copyTree(src.from, target, {
|
|
314
|
+
includeFiles: src.includeFiles,
|
|
315
|
+
excludeDirs: src.excludeDirs,
|
|
316
|
+
excludeNames: src.excludeNames,
|
|
317
|
+
followSymlinks: src.followSymlinks,
|
|
318
|
+
})
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
/** Reverse-resolve a shadow-relative path back to its live absolute path. */
|
|
325
|
+
function resolveLivePath(spec, shadowRel) {
|
|
326
|
+
const norm = shadowRel.split(sep).join('/')
|
|
327
|
+
for (const group of spec) {
|
|
328
|
+
for (const src of group.sources) {
|
|
329
|
+
const to = src.to.split(sep).join('/')
|
|
330
|
+
if (src.file) {
|
|
331
|
+
if (norm === to) return src.from
|
|
332
|
+
} else if (norm === to) {
|
|
333
|
+
return src.from
|
|
334
|
+
} else if (norm.startsWith(to + '/')) {
|
|
335
|
+
return join(src.from, norm.slice(to.length + 1))
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
return undefined
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
// ── Shadow repo lifecycle ──
|
|
343
|
+
|
|
344
|
+
async function ensureShadowRepo(binary, eff, repoDir) {
|
|
345
|
+
const remote = authedUrl(eff.repoUrl, eff.token)
|
|
346
|
+
let exists = false
|
|
347
|
+
try { await fsP.access(join(repoDir, '.git')); exists = true } catch { exists = false }
|
|
348
|
+
if (!exists) {
|
|
349
|
+
await fsP.rm(repoDir, { recursive: true, force: true }).catch(() => {})
|
|
350
|
+
await fsP.mkdir(join(repoDir, '..'), { recursive: true })
|
|
351
|
+
// Try a shallow clone first; an empty repo (first ever sync) fails, in
|
|
352
|
+
// which case init locally and let the first push seed the remote.
|
|
353
|
+
try {
|
|
354
|
+
await gitExec(binary, ['clone', '-b', eff.branch, '--depth', '1', remote, repoDir])
|
|
355
|
+
// clone 会把带 token 的 URL 写进 .git/config——立刻换回干净地址,
|
|
356
|
+
// 后续 fetch/push 一律显式传 authedUrl,凭证不落盘
|
|
357
|
+
await gitExec(binary, ['remote', 'set-url', 'origin', eff.repoUrl], repoDir).catch(() => {})
|
|
358
|
+
} catch {
|
|
359
|
+
await fsP.mkdir(repoDir, { recursive: true })
|
|
360
|
+
await gitExec(binary, ['init', '-b', eff.branch], repoDir)
|
|
361
|
+
// .gitattributes: append-only jsonl logs merge as union, not conflict
|
|
362
|
+
await atomicWriteFile(join(repoDir, '.gitattributes'), '*.jsonl merge=union\n')
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
return remote
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
// ── Three-way push: local deltas → branch → PR → merge | conflict ──
|
|
369
|
+
|
|
370
|
+
async function runPush(binary, eff, { repoDir, instanceId, state, logger, roots }) {
|
|
371
|
+
const remote = await ensureShadowRepo(binary, eff, repoDir)
|
|
372
|
+
const spec = syncSpec(eff, roots)
|
|
373
|
+
|
|
374
|
+
// 1. fetch origin/main → FETCH_HEAD (canonical baseline)
|
|
375
|
+
try { await gitExec(binary, ['fetch', remote, eff.branch], repoDir) } catch (e) {
|
|
376
|
+
// first-ever push to an empty remote: no main yet, skip fetch
|
|
377
|
+
if (!/Could not find|doesn't exist|no such|empty/i.test(String(e && e.message))) throw e
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
// 2. branch off FETCH_HEAD (or HEAD if remote was empty), reset shadow to it
|
|
381
|
+
const hasRemote = (await gitExec(binary, ['rev-parse', '--verify', 'FETCH_HEAD'], repoDir).then(() => true).catch(() => false))
|
|
382
|
+
const baseRef = hasRemote ? 'FETCH_HEAD' : 'HEAD'
|
|
383
|
+
const branch = `sync/${instanceId}/${Date.now()}`
|
|
384
|
+
// detach onto base so the working tree reflects the canonical baseline
|
|
385
|
+
await gitExec(binary, ['checkout', '--detach', baseRef], repoDir).catch(() => {})
|
|
386
|
+
// 3. overlay live snapshot onto the baseline: shadow now = baseline + local deltas
|
|
387
|
+
await mirrorLiveToShadow(spec, repoDir)
|
|
388
|
+
|
|
389
|
+
// 4. commit on a fresh branch
|
|
390
|
+
await gitExec(binary, ['checkout', '-b', branch], repoDir)
|
|
391
|
+
await gitExec(binary, ['add', '-A'], repoDir)
|
|
392
|
+
let commitOk = false
|
|
393
|
+
try { await gitExec(binary, ['-c', 'user.name=dsh-sync', '-c', 'user.email=dsh-sync@local', 'commit', '-m', `sync ${instanceId} ${new Date().toISOString()}`], repoDir); commitOk = true } catch { /* nothing to commit */ }
|
|
394
|
+
if (!commitOk) return { pushed: false, nothingToCommit: true }
|
|
395
|
+
|
|
396
|
+
// 5. push the branch (token in URL, not in config)
|
|
397
|
+
await gitExec(binary, ['push', remote, `HEAD:${branch}`], repoDir)
|
|
398
|
+
|
|
399
|
+
// 6. create PR + mergeable check
|
|
400
|
+
const parsed = parseRepoUrl(eff.repoUrl)
|
|
401
|
+
if (!parsed) {
|
|
402
|
+
// non-GitCode remote (local test, self-hosted git): push the branch only;
|
|
403
|
+
// PR create/merge is GitCode-specific and skipped. Advance shadow onto
|
|
404
|
+
// main as the next cycle's pull baseline.
|
|
405
|
+
await gitExec(binary, ['fetch', remote, eff.branch], repoDir).catch(() => {})
|
|
406
|
+
await gitExec(binary, ['checkout', eff.branch], repoDir).catch(() => {})
|
|
407
|
+
await gitExec(binary, ['reset', '--hard', 'FETCH_HEAD'], repoDir).catch(() => {})
|
|
408
|
+
state.lastSyncedCommit = await gitCurrentCommit(binary, repoDir)
|
|
409
|
+
state.lastPushedBranch = branch
|
|
410
|
+
return { pushed: true, prSkipped: true, branch }
|
|
411
|
+
}
|
|
412
|
+
// 同仓库 PR 的 head 就是分支名(`user:branch` 是 fork PR 语法,GitCode 会 400)
|
|
413
|
+
const prBody = { head: branch, base: eff.branch, title: `dsh-sync ${instanceId}`, body: `Auto sync from ${instanceId}` }
|
|
414
|
+
const prRes = await createPullRequest(eff.token, parsed.owner, parsed.repo, prBody)
|
|
415
|
+
if (!prRes.ok) {
|
|
416
|
+
// 409 = branch already has an open PR (idempotent retry); try to find it
|
|
417
|
+
if (prRes.status === 409) return { pushed: true, prConflict: true, message: '已有进行中的同步 PR' }
|
|
418
|
+
throw new Error(`创建 PR 失败(HTTP ${prRes.status}):${(prRes.json && prRes.json.message) || prRes.text.slice(0, 160)}`)
|
|
419
|
+
}
|
|
420
|
+
const prNumber = prRes.json && (prRes.json.number || prRes.json.id)
|
|
421
|
+
state.lastPushedBranch = branch
|
|
422
|
+
state.lastPrNumber = prNumber
|
|
423
|
+
|
|
424
|
+
// 7. mergeable?
|
|
425
|
+
let mergeable = false, conflict = false
|
|
426
|
+
try {
|
|
427
|
+
const det = await getPullRequest(eff.token, parsed.owner, parsed.repo, prNumber)
|
|
428
|
+
mergeable = det.ok && det.json && det.json.mergeable === true
|
|
429
|
+
conflict = det.ok && det.json && det.json.mergeable === false
|
|
430
|
+
} catch {}
|
|
431
|
+
|
|
432
|
+
if (mergeable) {
|
|
433
|
+
const mr = await mergePullRequest(eff.token, parsed.owner, parsed.repo, prNumber, 'squash')
|
|
434
|
+
if (!mr.ok) throw new Error(`合并 PR 失败(HTTP ${mr.status})`)
|
|
435
|
+
// advance shadow to the merged main
|
|
436
|
+
await gitExec(binary, ['fetch', remote, eff.branch], repoDir).catch(() => {})
|
|
437
|
+
await gitExec(binary, ['checkout', eff.branch], repoDir).catch(() => {})
|
|
438
|
+
await gitExec(binary, ['reset', '--hard', 'FETCH_HEAD'], repoDir).catch(() => {})
|
|
439
|
+
state.lastSyncedCommit = await gitCurrentCommit(binary, repoDir)
|
|
440
|
+
return { pushed: true, merged: true, prNumber }
|
|
441
|
+
}
|
|
442
|
+
// conflict → leave PR open; client shows the "AI 解决冲突" action button
|
|
443
|
+
return { pushed: true, prConflict: true, prNumber, conflict: true }
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
// ── Three-way pull: remote deltas → live, only for untouched files ──
|
|
447
|
+
|
|
448
|
+
async function runPull(binary, eff, { repoDir, state, logger, roots }) {
|
|
449
|
+
const remote = authedUrl(eff.repoUrl, eff.token)
|
|
450
|
+
const spec = syncSpec(eff, roots)
|
|
451
|
+
const lastSynced = state.lastSyncedCommit
|
|
452
|
+
try { await gitExec(binary, ['fetch', remote, eff.branch], repoDir) } catch (e) {
|
|
453
|
+
if (!/Could not find|doesn't exist|empty/i.test(String(e && e.message))) throw e
|
|
454
|
+
return { pulled: false, empty: true }
|
|
455
|
+
}
|
|
456
|
+
const hasFetch = await gitExec(binary, ['rev-parse', '--verify', 'FETCH_HEAD'], repoDir).then(() => true).catch(() => false)
|
|
457
|
+
if (!hasFetch) return { pulled: false, empty: true }
|
|
458
|
+
if (!lastSynced) {
|
|
459
|
+
// never synced before: nothing to diff against; just record baseline
|
|
460
|
+
await gitExec(binary, ['checkout', eff.branch], repoDir).catch(() => {})
|
|
461
|
+
await gitExec(binary, ['reset', '--hard', 'FETCH_HEAD'], repoDir).catch(() => {})
|
|
462
|
+
state.lastSyncedCommit = await gitCurrentCommit(binary, repoDir)
|
|
463
|
+
return { pulled: false, firstBaseline: true }
|
|
464
|
+
}
|
|
465
|
+
// files remote changed since lastSyncedCommit
|
|
466
|
+
let changedRaw = ''
|
|
467
|
+
try { changedRaw = await gitExec(binary, ['diff', '--name-only', lastSynced, 'FETCH_HEAD'], repoDir) } catch {}
|
|
468
|
+
const changed = changedRaw.split(/\r?\n/).map(s => s.trim()).filter(Boolean)
|
|
469
|
+
let applied = 0, skipped = 0
|
|
470
|
+
for (const p of changed) {
|
|
471
|
+
const livePath = resolveLivePath(spec, p)
|
|
472
|
+
if (!livePath) { skipped++; continue }
|
|
473
|
+
let liveBuf = null
|
|
474
|
+
try { liveBuf = await fsP.readFile(livePath) } catch {}
|
|
475
|
+
let lastSyncedBuf = null
|
|
476
|
+
try { lastSyncedBuf = await gitShowBuf(binary, `${lastSynced}:${p}`, repoDir) } catch { lastSyncedBuf = Buffer.alloc(0) }
|
|
477
|
+
const untouched = liveBuf === null ? (lastSyncedBuf.length === 0) : Buffer.compare(liveBuf, lastSyncedBuf) === 0
|
|
478
|
+
if (!untouched) { skipped++; continue } // 本地动过 → 留给下个 push
|
|
479
|
+
try {
|
|
480
|
+
const remoteBuf = await gitShowBuf(binary, `FETCH_HEAD:${p}`, repoDir)
|
|
481
|
+
await atomicWriteFile(livePath, remoteBuf)
|
|
482
|
+
applied++
|
|
483
|
+
} catch { skipped++ }
|
|
484
|
+
}
|
|
485
|
+
// advance shadow baseline to the freshly-pulled main
|
|
486
|
+
await gitExec(binary, ['checkout', eff.branch], repoDir).catch(() => {})
|
|
487
|
+
await gitExec(binary, ['reset', '--hard', 'FETCH_HEAD'], repoDir).catch(() => {})
|
|
488
|
+
state.lastSyncedCommit = await gitCurrentCommit(binary, repoDir)
|
|
489
|
+
return { pulled: true, applied, skipped, changed: changed.length }
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
// ── Conflict-resolution action button: in-process agent (same channel as
|
|
493
|
+
// skills-management share-run). The agent operates the shadow repo's git
|
|
494
|
+
// directly + merges the PR via REST. Only this step needs semantic
|
|
495
|
+
// judgement — everything deterministic stayed in the CLI. ──
|
|
496
|
+
|
|
497
|
+
const CONFLICT_PROMPT_ZH = [
|
|
498
|
+
'请解决 dsh-sync 同步仓库的冲突 PR,使该 PR 可被合并,然后合并它。',
|
|
499
|
+
'',
|
|
500
|
+
'## 关键信息',
|
|
501
|
+
'- 同步仓库:{{repoUrl}}(GitCode,API base = https://api.gitcode.com)',
|
|
502
|
+
'- 本地工作树(影子仓库):{{shadowDir}}(需 checkout 到冲突分支 {{branch}})',
|
|
503
|
+
'- PR 编号:#{{prNumber}}',
|
|
504
|
+
'- 访问令牌:{{token}}(下方步骤直接用此字符串,不要 printenv、不要回显明文)。',
|
|
505
|
+
'',
|
|
506
|
+
'## 工具限制(硬性)',
|
|
507
|
+
'- 只允许使用 bash(git/curl 命令)和 HTTP 请求工具。',
|
|
508
|
+
'- **严禁**使用任何 return / deliver / 投递 / IM 文件类工具(如 dsh_im_return_file)。不要把任何文件“投递”或“返回”出去。',
|
|
509
|
+
'- **不要读取 ~/.dsh/settings.yaml**——token 已在上方给你,别碰配置文件。',
|
|
510
|
+
'- token 是敏感凭据,任何输出、日志、结果里都不要回显其明文。',
|
|
511
|
+
'',
|
|
512
|
+
'## 执行步骤',
|
|
513
|
+
'1. token 已在上方「访问令牌」行给出,后续步骤直接用该字符串(不要 printenv)。',
|
|
514
|
+
'2. 在影子仓库内:`cd {{shadowDir}} && git fetch https://oauth2:{{token}}@gitcode.com/<owner>/<repo>.git main`(token 嵌 URL、不落 .git/config),然后 `git checkout {{branch}}`,再 `git merge FETCH_HEAD` 触发冲突。',
|
|
515
|
+
'3. 查看冲突文件:`git diff --name-only --diff-filter=U` 和 `git status`。对每个冲突文件分析两边版本决定取舍或合并(保留两边有效改动;README 等无语义文件取任一即可)。',
|
|
516
|
+
'4. 解决后:`git add -A && git -c user.name=dsh-sync -c user.email=dsh-sync@local commit --no-edit`,再 `git push https://oauth2:{{token}}@gitcode.com/<owner>/<repo>.git HEAD:{{branch}}`。',
|
|
517
|
+
'5. 查 PR 可合并:`curl -s -H "PRIVATE-TOKEN: {{token}}" https://api.gitcode.com/api/v5/repos/<owner>/<repo>/pulls/{{prNumber}}`,确认 mergeable 为 true。',
|
|
518
|
+
'6. 合并:`curl -s -X PUT -H "PRIVATE-TOKEN: {{token}}" -H "Content-Type: application/json" -d \'{"merge_method":"squash"}\' https://api.gitcode.com/api/v5/repos/<owner>/<repo>/pulls/{{prNumber}}/merge`。',
|
|
519
|
+
'7. 完成后输出 PR 网页链接。',
|
|
520
|
+
'',
|
|
521
|
+
'## 注意',
|
|
522
|
+
'- 认证头必须用 PRIVATE-TOKEN(不要用 Authorization: Bearer,GitCode 子资源端点对 Bearer 有 bug 会 404)。',
|
|
523
|
+
'- 不读 settings.yaml;不回显 token;不用投递类工具。',
|
|
524
|
+
'- 若失败先看错误信息,不盲目重试。全程与最终汇报都使用中文。',
|
|
525
|
+
].join('\n')
|
|
526
|
+
|
|
527
|
+
function substituteParams(template, params) {
|
|
528
|
+
let out = template
|
|
529
|
+
for (const [key, value] of Object.entries(params)) {
|
|
530
|
+
out = out.split(`{{${key}}}`).join(String(value))
|
|
531
|
+
}
|
|
532
|
+
return out
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
// apiproxy client: dsh web 的 /api HTTP RPC(web 客户端同款),创建主对话级 session。
|
|
536
|
+
// 关键区别:apiproxy session.create 建的是 web 主对话级 agent(agentPreset=standard
|
|
537
|
+
// + dsh-base 全工具,含 bash——实测 tool/call=bash + pwd 跑通);而 agents.create 子 agent
|
|
538
|
+
// 是精简 scope(只有 thinking + 插件全局工具,无 bash)。故 conflict 走 apiproxy 不走
|
|
539
|
+
// agents.create。base URL 可由 DSH_WEB_URL 覆盖,默认本地 3080。
|
|
540
|
+
const APIPROXY_BASE = process.env.DSH_WEB_URL || 'http://127.0.0.1:3080'
|
|
541
|
+
async function apiproxy(method, payload) {
|
|
542
|
+
const rpcId = 'dshsync-' + Date.now().toString(36) + Math.random().toString(36).slice(2, 6)
|
|
543
|
+
const r = await fetch(APIPROXY_BASE + '/api/' + method, {
|
|
544
|
+
method: 'POST',
|
|
545
|
+
headers: { 'Content-Type': 'application/json' },
|
|
546
|
+
body: JSON.stringify({ type: 'client-request', rpcId, method, payload }),
|
|
547
|
+
})
|
|
548
|
+
const j = await r.json().catch(() => ({}))
|
|
549
|
+
const res = j.result
|
|
550
|
+
if (!res || !res.ok) throw new Error('apiproxy ' + method + ' 失败: ' + JSON.stringify(j).slice(0, 200))
|
|
551
|
+
return res.value
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
async function runConflictViaApiproxy({ prompt, dir, job, sessions, logger, token }) {
|
|
555
|
+
// token 经 prompt 内联({{token}})--apiproxy 主对话级 session 的 bash 是 host-plane
|
|
556
|
+
// executor,不继承 dsh web 进程的 process.env,故不能像 headless spawn 那样 env 注入
|
|
557
|
+
try {
|
|
558
|
+
// 1. 创建主对话级 session(有 bash)+ 发 prompt
|
|
559
|
+
const created = await apiproxy('session.create', { cwd: dir })
|
|
560
|
+
const sessionId = created && created.sessionId
|
|
561
|
+
if (!sessionId) throw new Error('session.create 未返回 sessionId')
|
|
562
|
+
job.sessionId = sessionId
|
|
563
|
+
await apiproxy('session.prompt', { sessionId, mode: 'queue', content: [{ type: 'text', text: prompt }] })
|
|
564
|
+
// 2. events 泵:ctx.sessions.get(sessionId) 同进程读活会话 events,300ms 取新
|
|
565
|
+
let session
|
|
566
|
+
try { session = sessions.get(sessionId) } catch (e) { throw new Error('ctx.sessions.get(' + sessionId + ') 失败: ' + (e && e.message)) }
|
|
567
|
+
const seen = new Set()
|
|
568
|
+
const liveLine = (text) => { job.output = (job.output + text).slice(-CONFLICT_RUN_OUTPUT_CAP) }
|
|
569
|
+
let finished = false
|
|
570
|
+
const pump = () => {
|
|
571
|
+
const evs = (session && Array.isArray(session.events)) ? session.events : []
|
|
572
|
+
for (const ev of evs) {
|
|
573
|
+
const seq = ev.seq
|
|
574
|
+
if (seq != null && seen.has(seq)) continue
|
|
575
|
+
if (seq != null) seen.add(seq)
|
|
576
|
+
const d = ev.data || ev
|
|
577
|
+
const ty = ev.type
|
|
578
|
+
if (ty === 'assistant/chunk' && d.chunk && d.chunk.type === 'text' && d.chunk.text) liveLine(d.chunk.text)
|
|
579
|
+
else if (ty === 'tool/call') {
|
|
580
|
+
const args = d.arguments || d.input || {}
|
|
581
|
+
const cmd = (args && typeof args === 'object' ? (args.command || JSON.stringify(args)) : String(args))
|
|
582
|
+
liveLine('\n[tool] ' + (d.name || '?') + ' ' + String(cmd).slice(0, 200) + '\n')
|
|
583
|
+
}
|
|
584
|
+
else if (ty === 'tool/result') {
|
|
585
|
+
let rc = ''
|
|
586
|
+
const msg = d.message || d
|
|
587
|
+
const outer = (msg && Array.isArray(msg.content)) ? msg.content : (Array.isArray(d.content) ? d.content : [])
|
|
588
|
+
for (const it of outer) {
|
|
589
|
+
const inner = it && it.content
|
|
590
|
+
if (Array.isArray(inner)) { for (const x of inner) { if (x && x.text) rc += x.text } }
|
|
591
|
+
else if (typeof inner === 'string') rc += inner
|
|
592
|
+
}
|
|
593
|
+
if (rc) liveLine('-> ' + rc.slice(0, 240) + '\n')
|
|
594
|
+
}
|
|
595
|
+
else if (ty === 'turn/end') finished = true
|
|
596
|
+
}
|
|
597
|
+
}
|
|
598
|
+
const timer = setInterval(pump, 300)
|
|
599
|
+
if (typeof timer.unref === 'function') timer.unref()
|
|
600
|
+
// 3. 等跑完(turn/end)或超时
|
|
601
|
+
const deadline = Date.now() + CONFLICT_RUN_TIMEOUT_MS
|
|
602
|
+
await new Promise((resolve) => {
|
|
603
|
+
const wait = setInterval(() => { if (finished || Date.now() > deadline) { clearInterval(wait); resolve() } }, 500)
|
|
604
|
+
if (typeof wait.unref === 'function') wait.unref()
|
|
605
|
+
})
|
|
606
|
+
clearInterval(timer); pump()
|
|
607
|
+
job.status = finished ? 'done' : 'error'
|
|
608
|
+
job.code = finished ? 0 : 1
|
|
609
|
+
if (!finished) job.output += '\n[超时未完成]'
|
|
610
|
+
} finally {}
|
|
611
|
+
return job
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
function createConflictRunJob({ prompt, dir, jobs, logger, sessions, token }) {
|
|
615
|
+
const id = 'cf' + Date.now().toString(36) + Math.random().toString(36).slice(2, 8)
|
|
616
|
+
const job = { id, status: 'running', startedAt: new Date().toISOString(), dir, output: '', code: null }
|
|
617
|
+
jobs.set(id, job)
|
|
618
|
+
// 走 apiproxy 创建主对话级 session(standard preset + dsh-base 全工具,含 bash),
|
|
619
|
+
// 不是 agents.create 子 agent(精简无 bash)。token 注入 env,events 经 ctx.sessions.get 流式读。
|
|
620
|
+
if (!sessions || typeof sessions.get !== 'function') {
|
|
621
|
+
job.status = 'error'
|
|
622
|
+
job.output = 'sessions 服务不可用(动态 ctx.inject 失败)'
|
|
623
|
+
return job
|
|
624
|
+
}
|
|
625
|
+
runConflictViaApiproxy({ prompt, dir, job, sessions, logger, token })
|
|
626
|
+
.catch(e => { job.status = 'error'; job.output = (job.output + '\n' + String(e && e.message)).slice(-CONFLICT_RUN_OUTPUT_CAP) })
|
|
627
|
+
return job
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
module.exports = {
|
|
631
|
+
name: 'dsh-sync',
|
|
632
|
+
inject: ['webServer', 'settings'],
|
|
633
|
+
__internals: { syncSpec, defaultRoots, parseRepoUrl, authedUrl, mirrorLiveToShadow, resolveLivePath, copyTree, gitExec, acquireLock, checkRepoPrivate, gitcodeRequest, ensureShadowRepo, runPush, runPull, gitCurrentCommit, atomicWriteFile, DEFAULT_SYNC_SETTINGS, CONFLICT_PROMPT_ZH, substituteParams },
|
|
634
|
+
|
|
635
|
+
apply(ctx, config = {}) {
|
|
636
|
+
const dh = dshHome()
|
|
637
|
+
const syncDir = join(dh, 'dsh-sync')
|
|
638
|
+
const repoDir = join(syncDir, 'repo')
|
|
639
|
+
const stateFile = join(syncDir, 'state.json')
|
|
640
|
+
const lockFile = join(syncDir, '.lock')
|
|
641
|
+
|
|
642
|
+
// ── Settings namespace (write-only token, hasToken-only on read) ──
|
|
643
|
+
// 命名空间必须匹配 /^[a-z][a-z0-9-]*$/ —— 点号形式会被 settings 写入通道拒绝
|
|
644
|
+
const SYNC_SETTINGS_NS = 'dsh-sync'
|
|
645
|
+
const baseSettings = () => {
|
|
646
|
+
const cfg = (config.sync && typeof config.sync === 'object') ? config.sync : {}
|
|
647
|
+
const base = { ...DEFAULT_SYNC_SETTINGS }
|
|
648
|
+
for (const key of Object.keys(base)) if (cfg[key] !== undefined) base[key] = cfg[key]
|
|
649
|
+
if (config.repoUrl !== undefined) base.repoUrl = config.repoUrl
|
|
650
|
+
return base
|
|
651
|
+
}
|
|
652
|
+
let settingsScope = null
|
|
653
|
+
const settingsOverrides = {}
|
|
654
|
+
if (Schema && ctx.settings && typeof ctx.settings.register === 'function') {
|
|
655
|
+
try {
|
|
656
|
+
settingsScope = ctx.settings.register(SYNC_SETTINGS_NS, Schema.object({
|
|
657
|
+
repoUrl: Schema.string(),
|
|
658
|
+
branch: Schema.string(),
|
|
659
|
+
gitBinary: Schema.string(),
|
|
660
|
+
autoSync: Schema.boolean(),
|
|
661
|
+
syncOnStartup: Schema.boolean(),
|
|
662
|
+
intervalMinutes: Schema.number(),
|
|
663
|
+
conflictMode: Schema.string(),
|
|
664
|
+
syncSkills: Schema.boolean(),
|
|
665
|
+
syncSessions: Schema.boolean(),
|
|
666
|
+
syncSettings: Schema.boolean(),
|
|
667
|
+
syncPlugins: Schema.boolean(),
|
|
668
|
+
token: Schema.string(),
|
|
669
|
+
}), { base: baseSettings() })
|
|
670
|
+
} catch (e) { ctx.logger.warn(`dsh-sync: settings register: ${e && e.message}`) }
|
|
671
|
+
}
|
|
672
|
+
const syncSettings = () => {
|
|
673
|
+
if (settingsScope && typeof settingsScope.get === 'function') {
|
|
674
|
+
const v = settingsScope.get()
|
|
675
|
+
if (v && typeof v === 'object') return { ...baseSettings(), ...v }
|
|
676
|
+
}
|
|
677
|
+
return { ...baseSettings(), ...settingsOverrides }
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
// ── State (instanceId + lastSyncedCommit + lastResult) ──
|
|
681
|
+
let state = { instanceId: undefined, lastSyncedCommit: undefined, lastSyncAt: undefined, lastResult: undefined }
|
|
682
|
+
const stateLoaded = fsP.readFile(stateFile, 'utf8').then(raw => {
|
|
683
|
+
try { Object.assign(state, JSON.parse(raw)) } catch {}
|
|
684
|
+
}).catch(() => {})
|
|
685
|
+
// first boot: mint a stable instance id (hostname + short uuid). Persisted,
|
|
686
|
+
// never synced (it lives outside the shadow tree).
|
|
687
|
+
stateLoaded.then(async () => {
|
|
688
|
+
if (!state.instanceId) {
|
|
689
|
+
state.instanceId = `${String(hostname() || 'host').split('.')[0].slice(0, 16)}-${randomUUID().slice(0, 8)}`
|
|
690
|
+
try { await fsP.mkdir(syncDir, { recursive: true }); await fsP.writeFile(stateFile, JSON.stringify(state, null, 2), { mode: 0o600 }) } catch {}
|
|
691
|
+
}
|
|
692
|
+
})
|
|
693
|
+
const saveState = async () => {
|
|
694
|
+
try { await fsP.mkdir(syncDir, { recursive: true }); await fsP.writeFile(stateFile, JSON.stringify(state, null, 2), { mode: 0o600 }) } catch {}
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
// ── Sync run: lock → push → pull → save ──
|
|
698
|
+
let syncRun = null
|
|
699
|
+
const runSync = async () => {
|
|
700
|
+
if (syncRun !== null) return syncRun
|
|
701
|
+
syncRun = (async () => {
|
|
702
|
+
await stateLoaded
|
|
703
|
+
const eff = syncSettings()
|
|
704
|
+
if (!eff.repoUrl || !eff.token) throw new Error('未配置仓库地址或访问令牌(到 ⚙ 同步设置 中填写)')
|
|
705
|
+
if (!(await gitAvailable(eff.gitBinary))) throw new Error('PATH 上找不到 git')
|
|
706
|
+
const release = await acquireLock(lockFile)
|
|
707
|
+
if (release === null) throw new Error('另一个同步进程正在运行(已跳过)')
|
|
708
|
+
const started = Date.now()
|
|
709
|
+
let result = { pushed: false, pulled: false }
|
|
710
|
+
try {
|
|
711
|
+
const ctx2 = { repoDir, instanceId: state.instanceId, state, logger: ctx.logger }
|
|
712
|
+
result.push = await runPush(eff.gitBinary, eff, ctx2).catch(e => { result.pushError = String(e && e.message); return null })
|
|
713
|
+
result.pull = await runPull(eff.gitBinary, eff, ctx2).catch(e => { result.pullError = String(e && e.message); return null })
|
|
714
|
+
state.lastSyncAt = new Date().toISOString()
|
|
715
|
+
state.lastResult = { ...result, at: state.lastSyncAt, durationMs: Date.now() - started }
|
|
716
|
+
await saveState()
|
|
717
|
+
} finally { release() }
|
|
718
|
+
return result
|
|
719
|
+
})().finally(() => { syncRun = null })
|
|
720
|
+
return syncRun
|
|
721
|
+
}
|
|
722
|
+
|
|
723
|
+
// ── Conflict-resolution jobs (action button → apiproxy 主对话级 session) ──
|
|
724
|
+
const conflictRunJobs = new Map()
|
|
725
|
+
// 动态注入 sessions 服务:conflict 走 apiproxy 创建主对话级 session 后,
|
|
726
|
+
// 用 ctx.sessions.get(sessionId).events 流式读 agent 输出(像 agents.create 事件泵,
|
|
727
|
+
// 但这个 agent 有 bash)
|
|
728
|
+
let sessionsSvc = null
|
|
729
|
+
try {
|
|
730
|
+
if (ctx.inject && typeof ctx.inject === 'function') {
|
|
731
|
+
ctx.inject(['sessions'], (svcs) => { sessionsSvc = svcs && svcs.sessions })
|
|
732
|
+
}
|
|
733
|
+
} catch {}
|
|
734
|
+
|
|
735
|
+
// ── Startup + periodic auto-sync ──
|
|
736
|
+
ctx.effect(() => {
|
|
737
|
+
const fireIfDue = async (reason) => {
|
|
738
|
+
await stateLoaded
|
|
739
|
+
const eff = syncSettings()
|
|
740
|
+
if (!eff.autoSync) return
|
|
741
|
+
if (reason === 'startup' && !eff.syncOnStartup) return
|
|
742
|
+
runSync().catch(e => ctx.logger.warn(`dsh-sync: ${reason} sync: ${e && e.message}`))
|
|
743
|
+
}
|
|
744
|
+
fireIfDue('startup')
|
|
745
|
+
const timer = setInterval(() => fireIfDue('interval'), Math.max(5, (syncSettings().intervalMinutes || 30)) * 60 * 1000)
|
|
746
|
+
if (typeof timer.unref === 'function') timer.unref()
|
|
747
|
+
return () => clearInterval(timer)
|
|
748
|
+
}, 'dsh-sync: auto-sync')
|
|
749
|
+
|
|
750
|
+
// ── HTTP API ──
|
|
751
|
+
ctx.effect(() => ctx.webServer.register({
|
|
752
|
+
kind: 'prefix',
|
|
753
|
+
path: '/dsh-sync/api',
|
|
754
|
+
handler: async (req, res) => {
|
|
755
|
+
try {
|
|
756
|
+
const url = new URL(req.url || '/', 'http://dsh.local')
|
|
757
|
+
const apiPath = url.pathname.replace(/\/+$/, '')
|
|
758
|
+
const query = url.searchParams
|
|
759
|
+
|
|
760
|
+
// GET /dsh-sync/api/status
|
|
761
|
+
if (req.method === 'GET' && apiPath.endsWith('/dsh-sync/api/status')) {
|
|
762
|
+
await stateLoaded
|
|
763
|
+
const eff = syncSettings()
|
|
764
|
+
const { token, ...safe } = eff
|
|
765
|
+
const repoExists = await fsP.access(join(repoDir, '.git')).then(() => true).catch(() => false)
|
|
766
|
+
sendJson(res, 200, {
|
|
767
|
+
repoUrl: eff.repoUrl, branch: eff.branch, dir: displayPath(repoDir), repoExists,
|
|
768
|
+
instanceId: state.instanceId,
|
|
769
|
+
gitAvailable: await gitAvailable(eff.gitBinary),
|
|
770
|
+
lastSyncAt: state.lastSyncAt, lastResult: state.lastResult,
|
|
771
|
+
autoSync: eff.autoSync, syncOnStartup: eff.syncOnStartup,
|
|
772
|
+
intervalMinutes: eff.intervalMinutes, conflictMode: eff.conflictMode,
|
|
773
|
+
syncSkills: eff.syncSkills, syncSessions: eff.syncSessions,
|
|
774
|
+
syncSettings: eff.syncSettings, syncPlugins: eff.syncPlugins,
|
|
775
|
+
hasToken: typeof token === 'string' && token !== '',
|
|
776
|
+
syncing: syncRun !== null,
|
|
777
|
+
pendingConflict: state.lastResult && state.lastResult.push && state.lastResult.push.conflict === true
|
|
778
|
+
? { branch: state.lastPushedBranch, prNumber: state.lastPrNumber } : null,
|
|
779
|
+
})
|
|
780
|
+
return
|
|
781
|
+
}
|
|
782
|
+
|
|
783
|
+
// POST /dsh-sync/api/sync
|
|
784
|
+
if (req.method === 'POST' && apiPath.endsWith('/dsh-sync/api/sync')) {
|
|
785
|
+
try {
|
|
786
|
+
const result = await runSync()
|
|
787
|
+
sendJson(res, 200, result)
|
|
788
|
+
} catch (e) { sendJson(res, 400, { error: String(e && e.message || e) }) }
|
|
789
|
+
return
|
|
790
|
+
}
|
|
791
|
+
|
|
792
|
+
// PUT /dsh-sync/api/settings
|
|
793
|
+
if (req.method === 'PUT' && apiPath.endsWith('/dsh-sync/api/settings')) {
|
|
794
|
+
const body = await readJsonBody(req)
|
|
795
|
+
await stateLoaded
|
|
796
|
+
const patch = {}
|
|
797
|
+
for (const key of ['repoUrl', 'branch', 'gitBinary', 'conflictMode']) {
|
|
798
|
+
if (typeof body[key] === 'string' && body[key] !== '') patch[key] = body[key]
|
|
799
|
+
}
|
|
800
|
+
for (const key of ['autoSync', 'syncOnStartup', 'syncSkills', 'syncSessions', 'syncSettings', 'syncPlugins']) {
|
|
801
|
+
if (typeof body[key] === 'boolean') patch[key] = body[key]
|
|
802
|
+
}
|
|
803
|
+
if (typeof body.intervalMinutes === 'number' && body.intervalMinutes >= 1) patch.intervalMinutes = body.intervalMinutes
|
|
804
|
+
// token: non-empty sets; null/'' clears. Never echoed.
|
|
805
|
+
if (typeof body.token === 'string' && body.token !== '') patch.token = body.token
|
|
806
|
+
if (body.token === null || body.token === '') patch.token = undefined
|
|
807
|
+
// 私仓硬校验:带 repoUrl+token(首次或换仓库)时拒绝公共仓库
|
|
808
|
+
if (patch.token && (patch.repoUrl || syncSettings().repoUrl)) {
|
|
809
|
+
const checkUrl = patch.repoUrl || syncSettings().repoUrl
|
|
810
|
+
const check = await checkRepoPrivate(patch.token, checkUrl)
|
|
811
|
+
if (!check.ok) { sendJson(res, 400, { error: check.error, isPublic: !!check.isPublic }); return }
|
|
812
|
+
}
|
|
813
|
+
if (settingsScope && typeof settingsScope.update === 'function') await settingsScope.update(patch)
|
|
814
|
+
else Object.assign(settingsOverrides, patch)
|
|
815
|
+
const eff = syncSettings()
|
|
816
|
+
const { token, ...safe } = eff
|
|
817
|
+
sendJson(res, 200, { settings: safe, hasToken: typeof token === 'string' && token !== '' })
|
|
818
|
+
return
|
|
819
|
+
}
|
|
820
|
+
|
|
821
|
+
// POST /dsh-sync/api/conflict/run {prNumber?, branch?} → AI resolves
|
|
822
|
+
if (req.method === 'POST' && apiPath.endsWith('/dsh-sync/api/conflict/run')) {
|
|
823
|
+
const body = await readJsonBody(req)
|
|
824
|
+
await stateLoaded
|
|
825
|
+
const eff = syncSettings()
|
|
826
|
+
if (!eff.repoUrl || !eff.token) { sendJson(res, 400, { error: '未配置仓库或令牌' }); return }
|
|
827
|
+
const branch = body.branch || state.lastPushedBranch
|
|
828
|
+
const prNumber = body.prNumber || state.lastPrNumber
|
|
829
|
+
if (!branch || !prNumber) { sendJson(res, 400, { error: '没有待解决的冲突 PR' }); return }
|
|
830
|
+
const prompt = substituteParams(CONFLICT_PROMPT_ZH, {
|
|
831
|
+
repoUrl: eff.repoUrl, shadowDir: repoDir, branch, prNumber, token: eff.token,
|
|
832
|
+
})
|
|
833
|
+
const job = createConflictRunJob({ prompt, dir: repoDir, jobs: conflictRunJobs, logger: ctx.logger, sessions: sessionsSvc, token: eff.token })
|
|
834
|
+
sendJson(res, 202, { jobId: job.id, status: job.status })
|
|
835
|
+
return
|
|
836
|
+
}
|
|
837
|
+
|
|
838
|
+
// GET /dsh-sync/api/conflict/run?id= → job status/output
|
|
839
|
+
if (req.method === 'GET' && apiPath.endsWith('/dsh-sync/api/conflict/run')) {
|
|
840
|
+
const id = query.get('id') || ''
|
|
841
|
+
const job = conflictRunJobs.get(id)
|
|
842
|
+
if (job === undefined) { sendJson(res, 404, { error: 'job not found' }); return }
|
|
843
|
+
sendJson(res, 200, { ...job, output: (job.output || '').slice(-32 * 1024) })
|
|
844
|
+
return
|
|
845
|
+
}
|
|
846
|
+
|
|
847
|
+
sendJson(res, 404, { error: 'not found' })
|
|
848
|
+
} catch (error) { sendJson(res, 400, { error: String(error && error.message || error) }) }
|
|
849
|
+
},
|
|
850
|
+
}), 'dsh-sync: api route')
|
|
851
|
+
},
|
|
852
|
+
}
|