@qic-9821/dsh-plugin-sync 0.0.0-stage → 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/LICENSE +21 -0
- package/README.md +123 -2
- package/client.js +353 -0
- package/cordis.patch.yml +6 -0
- package/index.js +210 -0
- package/lib/apply.js +120 -0
- package/lib/config.js +228 -0
- package/lib/engine.js +461 -0
- package/lib/http.js +378 -0
- package/lib/merge.js +192 -0
- package/lib/patch.js +192 -0
- package/lib/snapshot.js +117 -0
- package/lib/transfer.js +85 -0
- package/lib/transport.js +168 -0
- package/package.json +57 -3
package/lib/patch.js
ADDED
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
import { copyFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
|
|
2
|
+
import { dirname } from 'node:path'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Minimal reader/writer for the top-level rows of a DSH `cordis.patch.yml`.
|
|
6
|
+
*
|
|
7
|
+
* Only the top-level `- id: <row>` rows are touched; nested `- insert:` blocks
|
|
8
|
+
* and comments are carried through byte for byte. Values are written as YAML
|
|
9
|
+
* flow mappings (`config: {"a":1}`), which is valid YAML and keeps the writer
|
|
10
|
+
* free of a yaml dependency.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
const ROW_START = /^-\s+id:\s*(.+?)\s*$/
|
|
14
|
+
const NAME_LINE = /^\s+name:\s*(.+?)\s*$/
|
|
15
|
+
const DISABLED_LINE = /^\s+disabled:\s*(true|false)\s*$/
|
|
16
|
+
const CONFIG_LINE = /^\s+config:\s*(.*)$/
|
|
17
|
+
|
|
18
|
+
export function parseRows(text) {
|
|
19
|
+
const lines = String(text ?? '').split('\n')
|
|
20
|
+
const rows = []
|
|
21
|
+
let current = null
|
|
22
|
+
for (let index = 0; index < lines.length; index += 1) {
|
|
23
|
+
const line = lines[index]
|
|
24
|
+
const start = ROW_START.exec(line)
|
|
25
|
+
if (start) {
|
|
26
|
+
current = { id: unquote(start[1]), name: null, disabled: null, startLine: index, endLine: index, configLines: null }
|
|
27
|
+
rows.push(current)
|
|
28
|
+
continue
|
|
29
|
+
}
|
|
30
|
+
if (!current) continue
|
|
31
|
+
if (line.trim().length === 0) {
|
|
32
|
+
// blank lines inside a row block: keep the block alive only if the next
|
|
33
|
+
// non-blank line is still indented
|
|
34
|
+
current.endLine = index
|
|
35
|
+
continue
|
|
36
|
+
}
|
|
37
|
+
if (!/^\s/.test(line)) {
|
|
38
|
+
current.endLine = index - 1
|
|
39
|
+
current = null
|
|
40
|
+
continue
|
|
41
|
+
}
|
|
42
|
+
const name = NAME_LINE.exec(line)
|
|
43
|
+
if (name && current.name === null) current.name = unquote(name[1])
|
|
44
|
+
const disabled = DISABLED_LINE.exec(line)
|
|
45
|
+
if (disabled) current.disabled = disabled[1] === 'true'
|
|
46
|
+
current.endLine = index
|
|
47
|
+
}
|
|
48
|
+
return rows
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function findRow(rows, { id, moduleName }) {
|
|
52
|
+
if (id) {
|
|
53
|
+
const byId = rows.find((row) => row.id === id)
|
|
54
|
+
if (byId) return byId
|
|
55
|
+
}
|
|
56
|
+
if (moduleName) return rows.find((row) => row.name === moduleName) || null
|
|
57
|
+
return null
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function rowConfig(text, row) {
|
|
61
|
+
const lines = String(text ?? '').split('\n')
|
|
62
|
+
const match = CONFIG_LINE.exec(lines[row.startLine] ?? '')
|
|
63
|
+
void match
|
|
64
|
+
for (let index = row.startLine; index <= row.endLine; index += 1) {
|
|
65
|
+
const hit = CONFIG_LINE.exec(lines[index])
|
|
66
|
+
if (!hit) continue
|
|
67
|
+
const inline = hit[1].trim()
|
|
68
|
+
if (inline && inline !== '|' && inline !== '>') {
|
|
69
|
+
try {
|
|
70
|
+
return JSON.parse(inline)
|
|
71
|
+
} catch {
|
|
72
|
+
const relaxed = parseFlowMapping(inline)
|
|
73
|
+
if (relaxed !== null) return relaxed
|
|
74
|
+
// Anything we cannot read stays opaque: config sync must never invent a value.
|
|
75
|
+
return { __raw: inline }
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
const block = []
|
|
79
|
+
for (let cursor = index + 1; cursor <= row.endLine; cursor += 1) {
|
|
80
|
+
const line = lines[cursor]
|
|
81
|
+
if (!/^\s{3,}/.test(line)) break
|
|
82
|
+
block.push(line.replace(/^\s{0,4}/, ''))
|
|
83
|
+
}
|
|
84
|
+
return block.length > 0 ? { __raw: block.join('\n') } : null
|
|
85
|
+
}
|
|
86
|
+
return null
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export function setRowDisabled(text, rowId, disabled) {
|
|
90
|
+
const source = String(text ?? '')
|
|
91
|
+
const rows = parseRows(source)
|
|
92
|
+
const row = findRow(rows, { id: rowId })
|
|
93
|
+
const lines = source.split('\n')
|
|
94
|
+
if (!row) {
|
|
95
|
+
if (!disabled) return source
|
|
96
|
+
const tail = source.endsWith('\n') || source.length === 0 ? '' : '\n'
|
|
97
|
+
return `${source}${tail}- id: ${rowId}\n disabled: true\n`
|
|
98
|
+
}
|
|
99
|
+
const value = disabled ? 'true' : 'false'
|
|
100
|
+
for (let index = row.startLine; index <= row.endLine; index += 1) {
|
|
101
|
+
if (DISABLED_LINE.test(lines[index])) {
|
|
102
|
+
lines[index] = ` disabled: ${value}`
|
|
103
|
+
return lines.join('\n')
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
// insert after the name line, else right after the id line
|
|
107
|
+
let anchor = row.startLine
|
|
108
|
+
for (let index = row.startLine; index <= row.endLine; index += 1) {
|
|
109
|
+
if (NAME_LINE.test(lines[index])) {
|
|
110
|
+
anchor = index
|
|
111
|
+
break
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
lines.splice(anchor + 1, 0, ` disabled: ${value}`)
|
|
115
|
+
return lines.join('\n')
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export function setRowConfig(text, rowId, config) {
|
|
119
|
+
const source = String(text ?? '')
|
|
120
|
+
const rows = parseRows(source)
|
|
121
|
+
const row = findRow(rows, { id: rowId })
|
|
122
|
+
if (!row) return { text: source, changed: false, reason: 'row-not-found' }
|
|
123
|
+
const lines = source.split('\n')
|
|
124
|
+
let configStart = -1
|
|
125
|
+
for (let index = row.startLine; index <= row.endLine; index += 1) {
|
|
126
|
+
if (CONFIG_LINE.test(lines[index])) {
|
|
127
|
+
configStart = index
|
|
128
|
+
break
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
const block = ` config: ${JSON.stringify(config ?? {})}`
|
|
132
|
+
if (configStart === -1) {
|
|
133
|
+
lines.splice(row.endLine + 1, 0, block)
|
|
134
|
+
return { text: lines.join('\n'), changed: true }
|
|
135
|
+
}
|
|
136
|
+
let configEnd = configStart
|
|
137
|
+
for (let cursor = configStart + 1; cursor <= row.endLine; cursor += 1) {
|
|
138
|
+
if (!/^\s{3,}/.test(lines[cursor])) break
|
|
139
|
+
configEnd = cursor
|
|
140
|
+
}
|
|
141
|
+
lines.splice(configStart, configEnd - configStart + 1, block)
|
|
142
|
+
return { text: lines.join('\n'), changed: true }
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
export function readPatchText(profileDir) {
|
|
146
|
+
if (!profileDir) return ''
|
|
147
|
+
const path = `${profileDir}/cordis.patch.yml`
|
|
148
|
+
try {
|
|
149
|
+
return readFileSync(path, 'utf8')
|
|
150
|
+
} catch {
|
|
151
|
+
return ''
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
export function backupPatch(profileDir, backupRoot, stamp = new Date()) {
|
|
156
|
+
if (!profileDir) return null
|
|
157
|
+
const path = `${profileDir}/cordis.patch.yml`
|
|
158
|
+
if (!existsSync(path)) return null
|
|
159
|
+
mkdirSync(backupRoot, { recursive: true })
|
|
160
|
+
const tag = stamp.toISOString().replace(/[-:]/g, '').replace(/\..*$/, '')
|
|
161
|
+
const target = `${backupRoot}/cordis.patch.yml.${tag}.bak`
|
|
162
|
+
copyFileSync(path, target)
|
|
163
|
+
return target
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
export function writePatchText(profileDir, text) {
|
|
167
|
+
const path = `${profileDir}/cordis.patch.yml`
|
|
168
|
+
mkdirSync(dirname(path), { recursive: true })
|
|
169
|
+
writeFileSync(path, text, 'utf8')
|
|
170
|
+
return path
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/** Read `{ appId: 1, nested: { a: 'b' } }` — JSON once bare keys are quoted. */
|
|
174
|
+
function parseFlowMapping(text) {
|
|
175
|
+
const quotedKeys = String(text).replace(/([{,]\s*)([A-Za-z_$][\w$-]*)\s*:/g, '$1"$2":')
|
|
176
|
+
for (const candidate of [quotedKeys, String(text).replace(/'/g, '"')]) {
|
|
177
|
+
try {
|
|
178
|
+
const value = JSON.parse(candidate)
|
|
179
|
+
return value && typeof value === 'object' ? value : null
|
|
180
|
+
} catch {
|
|
181
|
+
// try next form
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
return null
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function unquote(value) { const trimmed = String(value ?? '').trim()
|
|
188
|
+
if ((trimmed.startsWith('"') && trimmed.endsWith('"')) || (trimmed.startsWith("'") && trimmed.endsWith("'"))) {
|
|
189
|
+
return trimmed.slice(1, -1)
|
|
190
|
+
}
|
|
191
|
+
return trimmed
|
|
192
|
+
}
|
package/lib/snapshot.js
ADDED
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import { existsSync } from 'node:fs'
|
|
2
|
+
import { hostname, platform } from 'node:os'
|
|
3
|
+
import { join } from 'node:path'
|
|
4
|
+
|
|
5
|
+
import { PACKAGE_NAME, filterConfig } from './config.js'
|
|
6
|
+
import { parseRows, readPatchText, rowConfig } from './patch.js'
|
|
7
|
+
|
|
8
|
+
export const SNAPSHOT_SCHEMA = 1
|
|
9
|
+
|
|
10
|
+
export function pluginManagerOf(ctx) {
|
|
11
|
+
const pm = ctx?.get?.('pluginManager')
|
|
12
|
+
if (!pm || typeof pm.listBundles !== 'function' || typeof pm.listPlugins !== 'function') {
|
|
13
|
+
throw new Error('pluginManager service unavailable (host too old or plugin not loaded in the app scope)')
|
|
14
|
+
}
|
|
15
|
+
return pm
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function localSpecOf(bundle) {
|
|
19
|
+
const spec = bundle?.installTarget?.spec || bundle?.source || bundle?.spec || null
|
|
20
|
+
if (typeof spec !== 'string' || !spec.trim()) return null
|
|
21
|
+
return spec.trim()
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** Turn `file:D:/x/y`, `file:///D:/x/y` or a bare absolute path into a directory. */
|
|
25
|
+
export function localDirOf(spec) {
|
|
26
|
+
if (!spec) return null
|
|
27
|
+
let value = spec.trim()
|
|
28
|
+
if (value.startsWith('file:')) value = value.slice(5)
|
|
29
|
+
if (value.startsWith('//')) value = value.slice(2)
|
|
30
|
+
if (/^\/[A-Za-z]:\//.test(value)) value = value.slice(1)
|
|
31
|
+
if (!/^([A-Za-z]:[\\/]|\/)/.test(value)) return null
|
|
32
|
+
return value.replace(/\\/g, '/')
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export async function buildSnapshot({ ctx, cfg, profileDir, includeConfig = false, includeSources = false }) {
|
|
36
|
+
const pm = pluginManagerOf(ctx)
|
|
37
|
+
const rawBundles = await pm.listBundles()
|
|
38
|
+
const rawPlugins = await pm.listPlugins()
|
|
39
|
+
const bundles = Array.isArray(rawBundles) ? rawBundles : []
|
|
40
|
+
const plugins = Array.isArray(rawPlugins) ? rawPlugins : []
|
|
41
|
+
const patchText = readPatchText(profileDir)
|
|
42
|
+
const rows = parseRows(patchText)
|
|
43
|
+
|
|
44
|
+
const bundleViews = bundles.map((bundle) => {
|
|
45
|
+
const spec = localSpecOf(bundle)
|
|
46
|
+
return {
|
|
47
|
+
name: String(bundle.name),
|
|
48
|
+
version: bundle.version ?? null,
|
|
49
|
+
spec,
|
|
50
|
+
enabled: bundle.enabled !== false,
|
|
51
|
+
official: Boolean(bundle.official),
|
|
52
|
+
optional: Boolean(bundle.optional),
|
|
53
|
+
availability: bundle.availability ?? null,
|
|
54
|
+
updatable: Boolean(bundle.updatable),
|
|
55
|
+
}
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
const configs = {}
|
|
59
|
+
const sources = {}
|
|
60
|
+
if (includeConfig) {
|
|
61
|
+
for (const row of rows) {
|
|
62
|
+
if (!row.name) continue
|
|
63
|
+
const value = rowConfig(patchText, row)
|
|
64
|
+
if (!value || typeof value !== 'object' || '__raw' in value) continue
|
|
65
|
+
configs[row.name] = {
|
|
66
|
+
rowId: row.id,
|
|
67
|
+
config: filterConfig(value, { includeSecrets: Boolean(cfg.capSecrets) }),
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
if (includeSources) {
|
|
72
|
+
for (const bundle of bundleViews) {
|
|
73
|
+
const dir = localDirOf(bundle.spec)
|
|
74
|
+
if (dir && existsSync(join(dir, 'package.json'))) {
|
|
75
|
+
sources[bundle.name] = { kind: 'dir', path: dir }
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
return {
|
|
81
|
+
schema: SNAPSHOT_SCHEMA,
|
|
82
|
+
at: new Date().toISOString(),
|
|
83
|
+
node: {
|
|
84
|
+
name: cfg.localName || hostname(),
|
|
85
|
+
hostname: hostname(),
|
|
86
|
+
platform: platform(),
|
|
87
|
+
profile: profileDir ? profileDir.replace(/\\/g, '/').split('/').pop() : null,
|
|
88
|
+
plugin: PACKAGE_NAME,
|
|
89
|
+
version: cfg.__version || null,
|
|
90
|
+
},
|
|
91
|
+
bundles: bundleViews,
|
|
92
|
+
plugins: plugins.map((plugin) => ({
|
|
93
|
+
entryId: String(plugin.entryId ?? ''),
|
|
94
|
+
moduleName: String(plugin.moduleName ?? ''),
|
|
95
|
+
enabled: plugin.enabled !== false,
|
|
96
|
+
phase: plugin.fiberPhase ?? null,
|
|
97
|
+
})),
|
|
98
|
+
configs,
|
|
99
|
+
sources,
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export function summarizeSnapshot(snapshot) {
|
|
104
|
+
if (!snapshot || typeof snapshot !== 'object') {
|
|
105
|
+
return { node: null, bundles: 0, plugins: 0, enabled: 0, configs: 0 }
|
|
106
|
+
}
|
|
107
|
+
const bundles = Array.isArray(snapshot.bundles) ? snapshot.bundles : []
|
|
108
|
+
return {
|
|
109
|
+
node: snapshot.node?.name ?? null,
|
|
110
|
+
profile: snapshot.node?.profile ?? null,
|
|
111
|
+
at: snapshot.at ?? null,
|
|
112
|
+
bundles: bundles.length,
|
|
113
|
+
plugins: Array.isArray(snapshot.plugins) ? snapshot.plugins.length : 0,
|
|
114
|
+
enabled: bundles.filter((bundle) => bundle.enabled).length,
|
|
115
|
+
configs: Object.keys(snapshot.configs || {}).length,
|
|
116
|
+
}
|
|
117
|
+
}
|
package/lib/transfer.js
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process'
|
|
2
|
+
import { existsSync, mkdirSync, rmSync, writeFileSync } from 'node:fs'
|
|
3
|
+
import { basename, dirname, join } from 'node:path'
|
|
4
|
+
|
|
5
|
+
/** Tar-based copy of a local `file:` bundle so the peer can install it too. */
|
|
6
|
+
|
|
7
|
+
export function packDirectory(dir, { maxBytes = 48 * 1024 * 1024, excludes = ['node_modules', '.git'] } = {}) {
|
|
8
|
+
return new Promise((resolve, reject) => {
|
|
9
|
+
if (!existsSync(dir)) {
|
|
10
|
+
reject(new Error(`目录不存在: ${dir}`))
|
|
11
|
+
return
|
|
12
|
+
}
|
|
13
|
+
const parent = dirname(dir)
|
|
14
|
+
const base = basename(dir)
|
|
15
|
+
const args = ['-cf', '-', '-C', parent]
|
|
16
|
+
for (const item of excludes) args.push(`--exclude=${item}`)
|
|
17
|
+
args.push(base)
|
|
18
|
+
const child = spawn(tarExecutable(), args, { stdio: ['ignore', 'pipe', 'pipe'], windowsHide: true })
|
|
19
|
+
const chunks = []
|
|
20
|
+
let size = 0
|
|
21
|
+
let stderr = ''
|
|
22
|
+
child.stdout.on('data', (chunk) => {
|
|
23
|
+
size += chunk.length
|
|
24
|
+
if (size > maxBytes) {
|
|
25
|
+
try {
|
|
26
|
+
child.kill()
|
|
27
|
+
} catch {
|
|
28
|
+
// ignore
|
|
29
|
+
}
|
|
30
|
+
reject(new Error(`目录太大(>${Math.round(maxBytes / 1024 / 1024)}MB),已跳过: ${dir}`))
|
|
31
|
+
return
|
|
32
|
+
}
|
|
33
|
+
chunks.push(chunk)
|
|
34
|
+
})
|
|
35
|
+
child.stderr.on('data', (chunk) => {
|
|
36
|
+
stderr += String(chunk)
|
|
37
|
+
})
|
|
38
|
+
child.on('error', (error) => reject(new Error(`tar 不可用: ${error.message}`)))
|
|
39
|
+
child.on('exit', (code) => {
|
|
40
|
+
if (size > maxBytes) return
|
|
41
|
+
if (code !== 0) {
|
|
42
|
+
reject(new Error(`tar 打包失败 (${code}): ${stderr.trim().slice(-300)}`))
|
|
43
|
+
return
|
|
44
|
+
}
|
|
45
|
+
resolve(Buffer.concat(chunks))
|
|
46
|
+
})
|
|
47
|
+
})
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function unpackDirectory(buffer, destRoot, name) {
|
|
51
|
+
return new Promise((resolve, reject) => {
|
|
52
|
+
const safe = String(name).replace(/[^A-Za-z0-9._@-]/g, '_')
|
|
53
|
+
const dest = join(destRoot, safe)
|
|
54
|
+
mkdirSync(destRoot, { recursive: true })
|
|
55
|
+
rmSync(dest, { recursive: true, force: true })
|
|
56
|
+
mkdirSync(dest, { recursive: true })
|
|
57
|
+
const archive = join(destRoot, `${safe}.tar`)
|
|
58
|
+
writeFileSync(archive, buffer)
|
|
59
|
+
const child = spawn(tarExecutable(), ['-xf', archive, '-C', destRoot], {
|
|
60
|
+
stdio: ['ignore', 'ignore', 'pipe'],
|
|
61
|
+
windowsHide: true,
|
|
62
|
+
})
|
|
63
|
+
let stderr = ''
|
|
64
|
+
child.stderr.on('data', (chunk) => {
|
|
65
|
+
stderr += String(chunk)
|
|
66
|
+
})
|
|
67
|
+
child.on('error', (error) => reject(new Error(`tar 不可用: ${error.message}`)))
|
|
68
|
+
child.on('exit', (code) => {
|
|
69
|
+
try {
|
|
70
|
+
rmSync(archive, { force: true })
|
|
71
|
+
} catch {
|
|
72
|
+
// ignore
|
|
73
|
+
}
|
|
74
|
+
if (code !== 0) {
|
|
75
|
+
reject(new Error(`解包失败 (${code}): ${stderr.trim().slice(-300)}`))
|
|
76
|
+
return
|
|
77
|
+
}
|
|
78
|
+
resolve(dest)
|
|
79
|
+
})
|
|
80
|
+
})
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function tarExecutable() {
|
|
84
|
+
return process.platform === 'win32' ? 'tar' : 'tar'
|
|
85
|
+
}
|
package/lib/transport.js
ADDED
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process'
|
|
2
|
+
import { createServer } from 'node:net'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Talks to the peer's `/plugin-sync/peer/*` namespace.
|
|
6
|
+
* Two drivers, one wire protocol:
|
|
7
|
+
* - http: talk to the peer's web server directly
|
|
8
|
+
* - ssh: open a local forward (`ssh -N -L`) and then talk HTTP through it
|
|
9
|
+
* That keeps merge/apply logic identical on both machines.
|
|
10
|
+
*/
|
|
11
|
+
export class PeerLink {
|
|
12
|
+
constructor(cfg, { log = () => {} } = {}) {
|
|
13
|
+
// Callers normally hand over a normalized config; stand-alone callers
|
|
14
|
+
// (scripts, tests) may hand over a raw state file, so fill the two ports.
|
|
15
|
+
this.cfg = {
|
|
16
|
+
...cfg,
|
|
17
|
+
peerPort: Number(cfg?.peerPort) || 22,
|
|
18
|
+
peerHttpPort: Number(cfg?.peerHttpPort) || 18777,
|
|
19
|
+
}
|
|
20
|
+
this.log = log
|
|
21
|
+
this.child = null
|
|
22
|
+
this.baseUrl = null
|
|
23
|
+
this.localPort = null
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
get configured() {
|
|
27
|
+
if (this.cfg.peerMode === 'http') return Boolean(this.cfg.peerUrl)
|
|
28
|
+
if (this.cfg.peerMode === 'ssh') return Boolean(this.cfg.peerHost && this.cfg.peerUser)
|
|
29
|
+
return false
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
describe() {
|
|
33
|
+
if (this.cfg.peerMode === 'http') return this.cfg.peerUrl || '(未设置地址)'
|
|
34
|
+
if (this.cfg.peerMode === 'ssh') return `${this.cfg.peerUser}@${this.cfg.peerHost}:${this.cfg.peerPort} → 127.0.0.1:${this.cfg.peerHttpPort}`
|
|
35
|
+
return '(未配置对端)'
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
async ensure() {
|
|
39
|
+
if (this.baseUrl) return this.baseUrl
|
|
40
|
+
if (this.cfg.peerMode === 'http') {
|
|
41
|
+
if (!this.cfg.peerUrl) throw new Error('对端地址为空')
|
|
42
|
+
this.baseUrl = this.cfg.peerUrl.replace(/\/+$/, '')
|
|
43
|
+
return this.baseUrl
|
|
44
|
+
}
|
|
45
|
+
if (this.cfg.peerMode === 'ssh') return this.ensureTunnel()
|
|
46
|
+
throw new Error('未配置对端(设置里选择 http 或 ssh)')
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
async ensureTunnel() {
|
|
50
|
+
if (this.child && this.baseUrl) return this.baseUrl
|
|
51
|
+
const port = await freePort()
|
|
52
|
+
const args = [
|
|
53
|
+
'-N',
|
|
54
|
+
'-L',
|
|
55
|
+
`127.0.0.1:${port}:127.0.0.1:${this.cfg.peerHttpPort}`,
|
|
56
|
+
`${this.cfg.peerUser}@${this.cfg.peerHost}`,
|
|
57
|
+
'-p',
|
|
58
|
+
String(this.cfg.peerPort),
|
|
59
|
+
'-o',
|
|
60
|
+
'BatchMode=yes',
|
|
61
|
+
'-o',
|
|
62
|
+
'ExitOnForwardFailure=yes',
|
|
63
|
+
'-o',
|
|
64
|
+
'ServerAliveInterval=15',
|
|
65
|
+
'-o',
|
|
66
|
+
'ServerAliveCountMax=3',
|
|
67
|
+
'-o',
|
|
68
|
+
'StrictHostKeyChecking=accept-new',
|
|
69
|
+
'-o',
|
|
70
|
+
'ConnectTimeout=10',
|
|
71
|
+
]
|
|
72
|
+
if (this.cfg.peerKey) args.push('-i', this.cfg.peerKey)
|
|
73
|
+
this.log(`ssh tunnel: 127.0.0.1:${port} → ${this.cfg.peerHost}:${this.cfg.peerHttpPort}`)
|
|
74
|
+
const child = spawn('ssh', args, { stdio: ['ignore', 'ignore', 'pipe'], windowsHide: true })
|
|
75
|
+
this.child = child
|
|
76
|
+
this.localPort = port
|
|
77
|
+
let stderr = ''
|
|
78
|
+
child.stderr?.on('data', (chunk) => {
|
|
79
|
+
stderr += String(chunk)
|
|
80
|
+
})
|
|
81
|
+
child.on('exit', (code) => {
|
|
82
|
+
if (this.child === child && code !== 0) {
|
|
83
|
+
this.log(`ssh tunnel exited (${code}): ${stderr.trim().slice(-400)}`)
|
|
84
|
+
}
|
|
85
|
+
if (this.child === child) {
|
|
86
|
+
this.child = null
|
|
87
|
+
this.baseUrl = null
|
|
88
|
+
}
|
|
89
|
+
})
|
|
90
|
+
const base = `http://127.0.0.1:${port}`
|
|
91
|
+
const deadline = Date.now() + 25000
|
|
92
|
+
while (Date.now() < deadline) {
|
|
93
|
+
await sleep(500)
|
|
94
|
+
try {
|
|
95
|
+
await this.call('/plugin-sync/peer/ping', { timeoutMs: 3000, baseUrl: base })
|
|
96
|
+
this.baseUrl = base
|
|
97
|
+
return base
|
|
98
|
+
} catch {
|
|
99
|
+
if (!this.child) break
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
this.dispose()
|
|
103
|
+
throw new Error(`ssh 隧道未就绪${stderr ? `:${stderr.trim().slice(-300)}` : ''}`)
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
async call(path, options = {}) {
|
|
107
|
+
const baseUrl = options.baseUrl || (await this.ensure())
|
|
108
|
+
const headers = { accept: 'application/json', ...(options.headers || {}) }
|
|
109
|
+
if (this.cfg.peerToken) headers['x-plugin-sync-token'] = this.cfg.peerToken
|
|
110
|
+
let body
|
|
111
|
+
if (options.raw !== undefined) {
|
|
112
|
+
body = options.raw
|
|
113
|
+
headers['content-type'] = headers['content-type'] || 'application/octet-stream'
|
|
114
|
+
} else if (options.body !== undefined) {
|
|
115
|
+
body = JSON.stringify(options.body)
|
|
116
|
+
headers['content-type'] = 'application/json'
|
|
117
|
+
}
|
|
118
|
+
const timeout = options.timeoutMs ?? this.cfg.timeoutMs
|
|
119
|
+
const response = await fetch(`${baseUrl}${path}`, {
|
|
120
|
+
method: options.method || 'GET',
|
|
121
|
+
headers,
|
|
122
|
+
body,
|
|
123
|
+
signal: AbortSignal.timeout(Math.max(1000, timeout)),
|
|
124
|
+
})
|
|
125
|
+
const text = await response.text()
|
|
126
|
+
if (!response.ok) {
|
|
127
|
+
throw new Error(`${path} → HTTP ${response.status}: ${text.slice(0, 300)}`)
|
|
128
|
+
}
|
|
129
|
+
if (!text) return null
|
|
130
|
+
try {
|
|
131
|
+
return JSON.parse(text)
|
|
132
|
+
} catch {
|
|
133
|
+
const error = new Error(`${path} 返回的不是 JSON`)
|
|
134
|
+
error.raw = text.slice(0, 300)
|
|
135
|
+
throw error
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
dispose() {
|
|
140
|
+
if (this.child) {
|
|
141
|
+
try {
|
|
142
|
+
this.child.kill()
|
|
143
|
+
} catch {
|
|
144
|
+
// ignore
|
|
145
|
+
}
|
|
146
|
+
this.child = null
|
|
147
|
+
}
|
|
148
|
+
this.baseUrl = null
|
|
149
|
+
this.localPort = null
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
export function freePort() {
|
|
154
|
+
return new Promise((resolve, reject) => {
|
|
155
|
+
const server = createServer()
|
|
156
|
+
server.unref()
|
|
157
|
+
server.on('error', reject)
|
|
158
|
+
server.listen(0, '127.0.0.1', () => {
|
|
159
|
+
const address = server.address()
|
|
160
|
+
const port = typeof address === 'object' && address ? address.port : 0
|
|
161
|
+
server.close(() => (port ? resolve(port) : reject(new Error('no free port'))))
|
|
162
|
+
})
|
|
163
|
+
})
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
export function sleep(ms) {
|
|
167
|
+
return new Promise((resolve) => setTimeout(resolve, ms))
|
|
168
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,60 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@qic-9821/dsh-plugin-sync",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Keep the DSH plugin set (inventory, configs, enable state, package installs, local bundle binaries) in sync between two machines, with per-capability switches, schedule controls and a settings/panel UI.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"author": "QIC-9821",
|
|
7
|
+
"type": "module",
|
|
8
|
+
"private": false,
|
|
9
|
+
"main": "./index.js",
|
|
10
|
+
"engines": {
|
|
11
|
+
"node": ">=22"
|
|
12
|
+
},
|
|
13
|
+
"exports": {
|
|
14
|
+
".": "./index.js",
|
|
15
|
+
"./client": "./client.js",
|
|
16
|
+
"./package.json": "./package.json"
|
|
17
|
+
},
|
|
18
|
+
"files": [
|
|
19
|
+
"index.js",
|
|
20
|
+
"client.js",
|
|
21
|
+
"lib/",
|
|
22
|
+
"README.md",
|
|
23
|
+
"cordis.patch.yml",
|
|
24
|
+
"LICENSE"
|
|
25
|
+
],
|
|
26
|
+
"keywords": [
|
|
27
|
+
"dsh",
|
|
28
|
+
"deepseek-harness",
|
|
29
|
+
"cordis",
|
|
30
|
+
"plugin",
|
|
31
|
+
"sync",
|
|
32
|
+
"profile"
|
|
33
|
+
],
|
|
34
|
+
"repository": {
|
|
35
|
+
"type": "git",
|
|
36
|
+
"url": "git+https://github.com/QIC-9821/dsh-plugin-sync.git"
|
|
37
|
+
},
|
|
38
|
+
"homepage": "https://github.com/QIC-9821/dsh-plugin-sync#readme",
|
|
39
|
+
"bugs": {
|
|
40
|
+
"url": "https://github.com/QIC-9821/dsh-plugin-sync/issues"
|
|
41
|
+
},
|
|
42
|
+
"publishConfig": {
|
|
43
|
+
"access": "public"
|
|
44
|
+
},
|
|
45
|
+
"dsh": {
|
|
46
|
+
"bundle": {
|
|
47
|
+
"patch": "./cordis.patch.yml"
|
|
48
|
+
},
|
|
49
|
+
"client": {
|
|
50
|
+
"platform": "web",
|
|
51
|
+
"immediately": true,
|
|
52
|
+
"inject": [
|
|
53
|
+
"@deepseek-ai/dsh-client-ui-settings"
|
|
54
|
+
]
|
|
55
|
+
}
|
|
56
|
+
},
|
|
57
|
+
"scripts": {
|
|
58
|
+
"test": "node --test test/*.test.js"
|
|
59
|
+
}
|
|
6
60
|
}
|