@qic-9821/dsh-plugin-sync 0.0.0-stage → 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +123 -2
- package/client.js +354 -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 +462 -0
- package/lib/http.js +379 -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/index.js
ADDED
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
import { Engine, readVersion } from './lib/engine.js'
|
|
2
|
+
import { createRoutes } from './lib/http.js'
|
|
3
|
+
|
|
4
|
+
export const name = '@qic-9821/dsh-plugin-sync'
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Config is optional: hosts without @deepseek-ai/schemastery must still load
|
|
8
|
+
* the plugin (they just lose the generated settings form).
|
|
9
|
+
*/
|
|
10
|
+
let z = null
|
|
11
|
+
try {
|
|
12
|
+
const mod = await import('@deepseek-ai/schemastery')
|
|
13
|
+
z = mod?.default ?? mod ?? null
|
|
14
|
+
} catch {
|
|
15
|
+
z = null
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const field = (schema) => (typeof schema?.extra === 'function' ? schema.extra('volatile', true) : schema)
|
|
19
|
+
|
|
20
|
+
export const Config = z
|
|
21
|
+
? z.object({
|
|
22
|
+
enabled: field(z.boolean().default(true)),
|
|
23
|
+
autoStart: field(z.boolean().default(true)),
|
|
24
|
+
autoRestart: field(z.boolean().default(true)),
|
|
25
|
+
intervalMinutes: field(z.natural().default(0)),
|
|
26
|
+
startupDelaySec: field(z.natural().default(25)),
|
|
27
|
+
direction: field(z.string().default('push')),
|
|
28
|
+
conflict: field(z.string().default('local')),
|
|
29
|
+
peerMode: field(z.string().default('off')),
|
|
30
|
+
peerUrl: field(z.string().default('')),
|
|
31
|
+
peerToken: field(z.string().default('')),
|
|
32
|
+
peerName: field(z.string().default('')),
|
|
33
|
+
localName: field(z.string().default('')),
|
|
34
|
+
peerHost: field(z.string().default('')),
|
|
35
|
+
peerUser: field(z.string().default('')),
|
|
36
|
+
peerPort: field(z.natural().default(22)),
|
|
37
|
+
peerKey: field(z.string().default('')),
|
|
38
|
+
peerHttpPort: field(z.natural().default(18777)),
|
|
39
|
+
capPlugins: field(z.boolean().default(true)),
|
|
40
|
+
capConfig: field(z.boolean().default(false)),
|
|
41
|
+
capInstall: field(z.boolean().default(false)),
|
|
42
|
+
capFiles: field(z.boolean().default(false)),
|
|
43
|
+
capRemoteApply: field(z.boolean().default(true)),
|
|
44
|
+
capSecrets: field(z.boolean().default(false)),
|
|
45
|
+
include: field(z.array(z.string()).default([])),
|
|
46
|
+
exclude: field(z.array(z.string()).default([])),
|
|
47
|
+
profileDir: field(z.string().default('')),
|
|
48
|
+
maxBundleBytes: field(z.natural().default(48 * 1024 * 1024)),
|
|
49
|
+
timeoutMs: field(z.natural().default(60000)),
|
|
50
|
+
})
|
|
51
|
+
: undefined
|
|
52
|
+
|
|
53
|
+
export const inject = ['tools']
|
|
54
|
+
|
|
55
|
+
function pickLogger(logger) {
|
|
56
|
+
if (typeof logger?.info === 'function') return logger
|
|
57
|
+
return {
|
|
58
|
+
info: (...args) => console.log('[plugin-sync]', ...args),
|
|
59
|
+
warn: (...args) => console.warn('[plugin-sync]', ...args),
|
|
60
|
+
error: (...args) => console.error('[plugin-sync]', ...args),
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export async function apply(ctx, config = {}) {
|
|
65
|
+
const logger = pickLogger(ctx?.logger)
|
|
66
|
+
const engine = new Engine({ ctx, config, env: process.env, logger })
|
|
67
|
+
engine.version = readVersion(new URL('./package.json', import.meta.url))
|
|
68
|
+
|
|
69
|
+
const disposers = []
|
|
70
|
+
|
|
71
|
+
if (typeof ctx?.tools?.register === 'function') {
|
|
72
|
+
try {
|
|
73
|
+
disposers.push(ctx.tools.register(pluginSyncToolSpec(engine)))
|
|
74
|
+
disposers.push(ctx.tools.register(pluginSyncConfigToolSpec(engine)))
|
|
75
|
+
} catch (error) {
|
|
76
|
+
logger.warn?.(`tool registration failed: ${error?.message || error}`)
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const mountRoutes = (hostCtx) => {
|
|
81
|
+
const webServer = hostCtx?.webServer ?? hostCtx?.get?.('webServer')
|
|
82
|
+
if (!webServer || typeof webServer.register !== 'function') return
|
|
83
|
+
try {
|
|
84
|
+
const origin = typeof webServer.getOrigin === 'function' ? webServer.getOrigin() : ''
|
|
85
|
+
const routes = createRoutes({ engine, ctx, origin })
|
|
86
|
+
disposers.push(webServer.register({ kind: 'prefix', path: '/plugin-sync', handler: routes.handle }))
|
|
87
|
+
} catch (error) {
|
|
88
|
+
logger.warn?.(`web route registration failed: ${error?.message || error}`)
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// On a cold boot a bundle row can be instantiated before the web server
|
|
93
|
+
// exists (the server rides a later row); acquiring it lazily keeps the row
|
|
94
|
+
// loading on profiles without a web UI too.
|
|
95
|
+
if (typeof ctx?.inject === 'function') {
|
|
96
|
+
disposers.push(ctx.inject(['webServer'], (hostCtx) => mountRoutes(hostCtx)))
|
|
97
|
+
} else {
|
|
98
|
+
mountRoutes(ctx)
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
try {
|
|
102
|
+
engine.schedule()
|
|
103
|
+
} catch (error) {
|
|
104
|
+
logger.warn?.(`schedule failed: ${error?.message || error}`)
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const cfg = engine.cfg()
|
|
108
|
+
engine.log(`ready: profile=${engine.profileDir(cfg) || '?'} peer=${cfg.peerMode} caps=${['capPlugins', 'capConfig', 'capInstall', 'capFiles'].filter((key) => cfg[key]).join('+') || 'none'}`)
|
|
109
|
+
|
|
110
|
+
const dispose = async () => {
|
|
111
|
+
engine.dispose()
|
|
112
|
+
for (const disposer of disposers.reverse()) {
|
|
113
|
+
try {
|
|
114
|
+
disposer?.()
|
|
115
|
+
} catch {
|
|
116
|
+
// ignore teardown errors
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
ctx.effect?.(() => () => dispose(), 'dsh-plugin-sync: teardown')
|
|
122
|
+
return dispose
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function pluginSyncToolSpec(engine) {
|
|
126
|
+
return {
|
|
127
|
+
name: 'plugin_sync',
|
|
128
|
+
description: [
|
|
129
|
+
'Inspect or run the cross-machine DSH plugin sync (inventory, configs, enable state, package installs, local plugin folders).',
|
|
130
|
+
'action="status" reports this machine, the peer and the last run.',
|
|
131
|
+
'action="plan" shows what a sync would change, without changing anything.',
|
|
132
|
+
'action="run" performs the sync now (respects the capability switches and the direction setting).',
|
|
133
|
+
'Use it when the user asks whether plugins are in sync, what would change, or asks to sync now.',
|
|
134
|
+
].join('\n'),
|
|
135
|
+
parameters: {
|
|
136
|
+
type: 'object',
|
|
137
|
+
properties: {
|
|
138
|
+
action: { type: 'string', enum: ['status', 'plan', 'run'], description: 'status | plan | run' },
|
|
139
|
+
direction: {
|
|
140
|
+
type: 'string',
|
|
141
|
+
enum: ['push', 'pull', 'both', 'off'],
|
|
142
|
+
description: 'Override the configured direction for this call.',
|
|
143
|
+
},
|
|
144
|
+
},
|
|
145
|
+
required: ['action'],
|
|
146
|
+
additionalProperties: false,
|
|
147
|
+
},
|
|
148
|
+
timeoutMs: 600000,
|
|
149
|
+
output: {
|
|
150
|
+
schema: { type: 'object', additionalProperties: true },
|
|
151
|
+
render(_args, value) {
|
|
152
|
+
return [{ type: 'text', text: typeof value === 'string' ? value : JSON.stringify(value, null, 2) }]
|
|
153
|
+
},
|
|
154
|
+
},
|
|
155
|
+
async execute(args) {
|
|
156
|
+
const action = String(args?.action || 'status')
|
|
157
|
+
const direction = typeof args?.direction === 'string' ? args.direction : null
|
|
158
|
+
if (action === 'status') return engine.status()
|
|
159
|
+
if (action === 'plan') return engine.plan({ direction })
|
|
160
|
+
if (action === 'run') return engine.sync({ trigger: 'tool', direction })
|
|
161
|
+
return { ok: false, error: `unknown action: ${action}` }
|
|
162
|
+
},
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function pluginSyncConfigToolSpec(engine) {
|
|
167
|
+
return {
|
|
168
|
+
name: 'plugin_sync_config',
|
|
169
|
+
description: [
|
|
170
|
+
'Read or update the plugin-sync settings: peer (http/ssh), capability switches, direction/conflict and schedule.',
|
|
171
|
+
'action="get" returns the current effective settings (the peer token is never returned).',
|
|
172
|
+
'action="set" merges a patch (any of: enabled, direction, conflict, peerMode, peerUrl, peerHost, peerUser, peerPort, peerKey, peerHttpPort, peerToken, capPlugins, capConfig, capInstall, capFiles, capRemoteApply, capSecrets, autoStart, autoRestart, intervalMinutes, startupDelaySec, include, exclude).',
|
|
173
|
+
].join('\n'),
|
|
174
|
+
parameters: {
|
|
175
|
+
type: 'object',
|
|
176
|
+
properties: {
|
|
177
|
+
action: { type: 'string', enum: ['get', 'set'] },
|
|
178
|
+
patch: { type: 'object', additionalProperties: true, description: 'Settings to merge (action="set").' },
|
|
179
|
+
},
|
|
180
|
+
required: ['action'],
|
|
181
|
+
additionalProperties: false,
|
|
182
|
+
},
|
|
183
|
+
timeoutMs: 60000,
|
|
184
|
+
output: {
|
|
185
|
+
schema: { type: 'object', additionalProperties: true },
|
|
186
|
+
render(_args, value) {
|
|
187
|
+
return [{ type: 'text', text: typeof value === 'string' ? value : JSON.stringify(value, null, 2) }]
|
|
188
|
+
},
|
|
189
|
+
},
|
|
190
|
+
async execute(args) {
|
|
191
|
+
const action = String(args?.action || 'get')
|
|
192
|
+
if (action === 'get') {
|
|
193
|
+
const cfg = engine.cfg()
|
|
194
|
+
return {
|
|
195
|
+
...cfg,
|
|
196
|
+
peerToken: cfg.peerToken ? '[set]' : '',
|
|
197
|
+
profileDir: engine.profileDir(cfg),
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
if (action === 'set') {
|
|
201
|
+
const patch = args?.patch && typeof args.patch === 'object' ? args.patch : {}
|
|
202
|
+
engine.updateState(patch)
|
|
203
|
+
engine.schedule()
|
|
204
|
+
const cfg = engine.cfg()
|
|
205
|
+
return { ok: true, ...cfg, peerToken: cfg.peerToken ? '[set]' : '' }
|
|
206
|
+
}
|
|
207
|
+
return { ok: false, error: `unknown action: ${action}` }
|
|
208
|
+
},
|
|
209
|
+
}
|
|
210
|
+
}
|
package/lib/apply.js
ADDED
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
import { backupPatch, readPatchText, setRowConfig, writePatchText } from './patch.js'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Executes merge ops against the *local* machine through the pluginManager
|
|
5
|
+
* service, so pnpm, the profile manifest and the loader stay authoritative.
|
|
6
|
+
* One failing op never aborts the rest; every result is reported.
|
|
7
|
+
*/
|
|
8
|
+
export async function applyPlan({ ctx, ops = [], cfg = {}, profileDir = null, log = () => {} }) {
|
|
9
|
+
const results = []
|
|
10
|
+
const pm = ctx?.get?.('pluginManager')
|
|
11
|
+
let patchBackedUp = false
|
|
12
|
+
|
|
13
|
+
for (const op of ops) {
|
|
14
|
+
const started = Date.now()
|
|
15
|
+
try {
|
|
16
|
+
if (op.kind === 'config') {
|
|
17
|
+
if (!cfg.capConfig) throw new Error('配置同步权限未开启')
|
|
18
|
+
if (!profileDir) throw new Error('找不到 profile 目录')
|
|
19
|
+
if (!patchBackedUp) {
|
|
20
|
+
const backup = backupPatch(profileDir, `${cfg.__backupDir || ''}`)
|
|
21
|
+
patchBackedUp = true
|
|
22
|
+
if (backup) log(`patch backup: ${backup}`)
|
|
23
|
+
}
|
|
24
|
+
const text = readPatchText(profileDir)
|
|
25
|
+
const rowId = op.rowId || op.name
|
|
26
|
+
const next = setRowConfig(text, rowId, op.config)
|
|
27
|
+
if (!next.changed) throw new Error(`cordis.patch.yml 里没有 ${rowId} 这一行`)
|
|
28
|
+
writePatchText(profileDir, next.text)
|
|
29
|
+
results.push(ok(op, '重启后生效'))
|
|
30
|
+
continue
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
if (!pm) throw new Error('pluginManager 服务不可用')
|
|
34
|
+
|
|
35
|
+
if (op.kind === 'install') {
|
|
36
|
+
if (!cfg.capInstall) throw new Error('安装/升级权限未开启')
|
|
37
|
+
const result = await pm.installBundle(op.spec, { enabled: op.enabled !== false })
|
|
38
|
+
results.push(fromChange(op, result))
|
|
39
|
+
continue
|
|
40
|
+
}
|
|
41
|
+
if (op.kind === 'upgrade') {
|
|
42
|
+
if (!cfg.capInstall) throw new Error('安装/升级权限未开启')
|
|
43
|
+
const result = await pm.updateBundle(op.name, op.version || undefined)
|
|
44
|
+
results.push(fromChange(op, result))
|
|
45
|
+
continue
|
|
46
|
+
}
|
|
47
|
+
if (op.kind === 'remove') {
|
|
48
|
+
if (!cfg.capInstall) throw new Error('卸载权限未开启')
|
|
49
|
+
const result = await pm.removeBundle(op.name)
|
|
50
|
+
results.push(fromChange(op, result))
|
|
51
|
+
continue
|
|
52
|
+
}
|
|
53
|
+
if (op.kind === 'enable' || op.kind === 'disable') {
|
|
54
|
+
if (!cfg.capPlugins) throw new Error('插件启停权限未开启')
|
|
55
|
+
const result = await pm.setBundleEnabled(op.name, op.kind === 'enable')
|
|
56
|
+
results.push(fromChange(op, result))
|
|
57
|
+
continue
|
|
58
|
+
}
|
|
59
|
+
if (op.kind === 'plugin-enable' || op.kind === 'plugin-disable') {
|
|
60
|
+
if (!cfg.capPlugins) throw new Error('插件启停权限未开启')
|
|
61
|
+
const result = await pm.setPluginEnabled(op.entryId, op.kind === 'plugin-enable')
|
|
62
|
+
results.push(fromChange(op, result))
|
|
63
|
+
continue
|
|
64
|
+
}
|
|
65
|
+
results.push({ ...fail(op, `未知操作: ${op.kind}`), ms: Date.now() - started })
|
|
66
|
+
} catch (error) {
|
|
67
|
+
results.push({ ...fail(op, describeError(error)), ms: Date.now() - started })
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const failed = results.filter((item) => !item.ok)
|
|
72
|
+
return {
|
|
73
|
+
ok: failed.length === 0,
|
|
74
|
+
applied: results.filter((item) => item.ok).length,
|
|
75
|
+
failed: failed.length,
|
|
76
|
+
results,
|
|
77
|
+
restartRequired: results.some((item) => item.application === 'restart-required'),
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function ok(op, note = null) {
|
|
82
|
+
return { ok: true, op: pick(op), application: 'applied', note }
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function fail(op, error) {
|
|
86
|
+
return { ok: false, op: pick(op), error }
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function pick(op) {
|
|
90
|
+
return { kind: op.kind, name: op.name ?? null, entryId: op.entryId ?? null, version: op.version ?? null }
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** pluginManager rejects with plain `{ code }` objects, so String() is useless. */
|
|
94
|
+
export function describeError(error) {
|
|
95
|
+
if (!error) return '未知错误'
|
|
96
|
+
if (typeof error === 'string') return error
|
|
97
|
+
if (error instanceof Error) return error.message || error.name
|
|
98
|
+
if (typeof error.message === 'string' && error.message) return error.message
|
|
99
|
+
if (typeof error.code === 'string') return error.code
|
|
100
|
+
try {
|
|
101
|
+
const text = JSON.stringify(error)
|
|
102
|
+
return text && text !== '{}' ? (text.length > 300 ? `${text.slice(0, 300)}…` : text) : String(error)
|
|
103
|
+
} catch {
|
|
104
|
+
return String(error)
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function fromChange(op, result) {
|
|
109
|
+
const r = result && typeof result === 'object' ? result : {}
|
|
110
|
+
return {
|
|
111
|
+
ok: r.application !== 'failed' && r.application !== 'cancelled',
|
|
112
|
+
op: pick(op),
|
|
113
|
+
application: r.application ?? 'applied',
|
|
114
|
+
restartReason: r.restartReason ?? null,
|
|
115
|
+
error: r.error ? describeError(r.error) : null,
|
|
116
|
+
warnings: Array.isArray(r.warnings) ? r.warnings.map(String) : [],
|
|
117
|
+
pendingBuilds: Array.isArray(r.pendingBuilds) ? r.pendingBuilds : [],
|
|
118
|
+
changed: r.changed !== false,
|
|
119
|
+
}
|
|
120
|
+
}
|
package/lib/config.js
ADDED
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
import { homedir } from 'node:os'
|
|
2
|
+
import { existsSync, readdirSync, readFileSync, mkdirSync, renameSync, writeFileSync } from 'node:fs'
|
|
3
|
+
import { join } from 'node:path'
|
|
4
|
+
|
|
5
|
+
export const PACKAGE_NAME = '@qic-9821/dsh-plugin-sync'
|
|
6
|
+
export const LEGACY_PACKAGE_NAME = 'dsh-plugin-sync'
|
|
7
|
+
|
|
8
|
+
const SECRET_KEY = /(secret|token|passw|apikey|api_key|credential|cookie|private_key|privatekey|authorization|access_key)/i
|
|
9
|
+
|
|
10
|
+
export function dshHome(env = process.env) {
|
|
11
|
+
const explicit = env.DSH_HOME?.trim()
|
|
12
|
+
return explicit || join(homedir(), '.dsh')
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function stateDir(env = process.env) {
|
|
16
|
+
return join(dshHome(env), 'plugin-sync')
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function statePath(env = process.env) {
|
|
20
|
+
return join(stateDir(env), 'state.json')
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function backupDir(env = process.env) {
|
|
24
|
+
return join(stateDir(env), 'backups')
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* The running profile is the one whose package.json depends on this package.
|
|
29
|
+
* (A `file:` install keeps the module inside D:\dsh-plugins, so the module path
|
|
30
|
+
* says nothing about the profile.)
|
|
31
|
+
*/
|
|
32
|
+
export function resolveProfileDir(config = {}, env = process.env) {
|
|
33
|
+
if (typeof config.profileDir === 'string' && config.profileDir.trim()) return config.profileDir.trim()
|
|
34
|
+
if (env.DSH_PROFILE_DIR?.trim()) return env.DSH_PROFILE_DIR.trim()
|
|
35
|
+
const root = join(dshHome(env), 'profiles')
|
|
36
|
+
if (!existsSync(root)) return null
|
|
37
|
+
const candidates = []
|
|
38
|
+
for (const name of readdirSync(root)) {
|
|
39
|
+
const dir = join(root, name)
|
|
40
|
+
const manifestPath = join(dir, 'package.json')
|
|
41
|
+
if (!existsSync(manifestPath)) continue
|
|
42
|
+
try {
|
|
43
|
+
const manifest = JSON.parse(readFileSync(manifestPath, 'utf8'))
|
|
44
|
+
const deps = { ...(manifest.dependencies || {}), ...(manifest.devDependencies || {}) }
|
|
45
|
+
const hit = Boolean(deps[PACKAGE_NAME] || deps[PACKAGE_NAME] === '' || deps[LEGACY_PACKAGE_NAME] || deps[LEGACY_PACKAGE_NAME] === '')
|
|
46
|
+
candidates.push({ dir, name, hit, mtime: 0 })
|
|
47
|
+
} catch {
|
|
48
|
+
// ignore unreadable manifests
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
const hit = candidates.find((entry) => entry.hit)
|
|
52
|
+
if (hit) return hit.dir
|
|
53
|
+
if (config !== null && candidates.length === 1) return candidates[0].dir
|
|
54
|
+
return candidates.length > 0 ? candidates[candidates.length - 1].dir : null
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export const DEFAULTS = {
|
|
58
|
+
enabled: true,
|
|
59
|
+
// schedule
|
|
60
|
+
autoStart: true,
|
|
61
|
+
autoRestart: true,
|
|
62
|
+
intervalMinutes: 0,
|
|
63
|
+
startupDelaySec: 25,
|
|
64
|
+
// direction / conflict
|
|
65
|
+
direction: 'push',
|
|
66
|
+
conflict: 'local',
|
|
67
|
+
// peer
|
|
68
|
+
peerMode: 'off',
|
|
69
|
+
peerUrl: '',
|
|
70
|
+
peerToken: '',
|
|
71
|
+
peerName: '',
|
|
72
|
+
localName: '',
|
|
73
|
+
peerHost: '',
|
|
74
|
+
peerUser: '',
|
|
75
|
+
peerPort: 22,
|
|
76
|
+
peerKey: '',
|
|
77
|
+
peerHttpPort: 18777,
|
|
78
|
+
// capabilities
|
|
79
|
+
capPlugins: true,
|
|
80
|
+
capConfig: false,
|
|
81
|
+
capInstall: false,
|
|
82
|
+
capFiles: false,
|
|
83
|
+
capRemoteApply: true,
|
|
84
|
+
capSecrets: false,
|
|
85
|
+
// scope
|
|
86
|
+
include: [],
|
|
87
|
+
exclude: [],
|
|
88
|
+
profileDir: '',
|
|
89
|
+
maxBundleBytes: 48 * 1024 * 1024,
|
|
90
|
+
timeoutMs: 60000,
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export const CAPABILITY_KEYS = [
|
|
94
|
+
'capPlugins',
|
|
95
|
+
'capConfig',
|
|
96
|
+
'capInstall',
|
|
97
|
+
'capFiles',
|
|
98
|
+
'capRemoteApply',
|
|
99
|
+
'capSecrets',
|
|
100
|
+
]
|
|
101
|
+
|
|
102
|
+
export const CAPABILITY_LABELS = {
|
|
103
|
+
capPlugins: ['插件清单与启停', '同步安装了哪些插件、哪些开着', 'Plugin inventory and enable state'],
|
|
104
|
+
capConfig: ['插件配置', '同步每个插件在 cordis.patch.yml 里的配置项', 'Plugin config in cordis.patch.yml'],
|
|
105
|
+
capInstall: ['安装 / 升级 / 卸载', '在另一端跑 pnpm、改 node_modules、增删插件包', 'Install, upgrade and remove packages (pnpm)'],
|
|
106
|
+
capFiles: ['复制插件二进制', '把本机 file: 插件的源码目录打包传过去', 'Copy local plugin source folders'],
|
|
107
|
+
capRemoteApply: ['允许对端改动本机', '对端推过来的变更可以在本机落地', 'Allow the peer to change this machine'],
|
|
108
|
+
capSecrets: ['同步密钥字段', '(默认关)同步 appSecret / token 之类字段', 'Include secret-looking config fields'],
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export const DIRECTION_LABELS = {
|
|
112
|
+
push: ['推送到对端', 'Push this machine to the peer'],
|
|
113
|
+
pull: ['从对端拉取', 'Pull the peer to this machine'],
|
|
114
|
+
both: ['双向合并', 'Merge both ways'],
|
|
115
|
+
off: ['只比较不改', 'Compare only'],
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export function normalizeConfig(config = {}, env = process.env) {
|
|
119
|
+
const cfg = { ...DEFAULTS }
|
|
120
|
+
for (const [key, value] of Object.entries(config || {})) {
|
|
121
|
+
if (value === undefined || value === null) continue
|
|
122
|
+
if (key === 'peer' && typeof value === 'object') {
|
|
123
|
+
Object.assign(cfg, normalizePeer(value))
|
|
124
|
+
continue
|
|
125
|
+
}
|
|
126
|
+
cfg[key] = value
|
|
127
|
+
}
|
|
128
|
+
cfg.direction = ['push', 'pull', 'both', 'off'].includes(cfg.direction) ? cfg.direction : 'push'
|
|
129
|
+
cfg.conflict = ['local', 'peer', 'newest'].includes(cfg.conflict) ? cfg.conflict : 'peer'
|
|
130
|
+
cfg.peerMode = ['off', 'http', 'ssh'].includes(cfg.peerMode) ? cfg.peerMode : 'off'
|
|
131
|
+
cfg.intervalMinutes = clampNumber(cfg.intervalMinutes, 0, 24 * 60, 0)
|
|
132
|
+
cfg.startupDelaySec = clampNumber(cfg.startupDelaySec, 0, 600, 25)
|
|
133
|
+
cfg.peerPort = clampNumber(cfg.peerPort, 1, 65535, 22)
|
|
134
|
+
cfg.peerHttpPort = clampNumber(cfg.peerHttpPort, 1, 65535, 18777)
|
|
135
|
+
cfg.maxBundleBytes = clampNumber(cfg.maxBundleBytes, 1024 * 1024, 512 * 1024 * 1024, 48 * 1024 * 1024)
|
|
136
|
+
cfg.timeoutMs = clampNumber(cfg.timeoutMs, 5000, 600000, 60000)
|
|
137
|
+
cfg.include = Array.isArray(cfg.include) ? cfg.include.map(String).filter(Boolean) : []
|
|
138
|
+
cfg.exclude = Array.isArray(cfg.exclude) ? cfg.exclude.map(String).filter(Boolean) : []
|
|
139
|
+
return cfg
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function normalizePeer(peer) {
|
|
143
|
+
const out = {}
|
|
144
|
+
if (typeof peer.mode === 'string') out.peerMode = peer.mode
|
|
145
|
+
if (typeof peer.url === 'string') out.peerUrl = peer.url
|
|
146
|
+
if (typeof peer.token === 'string') out.peerToken = peer.token
|
|
147
|
+
if (typeof peer.name === 'string') out.peerName = peer.name
|
|
148
|
+
if (typeof peer.host === 'string') out.peerHost = peer.host
|
|
149
|
+
if (typeof peer.user === 'string') out.peerUser = peer.user
|
|
150
|
+
if (peer.port !== undefined) out.peerPort = peer.port
|
|
151
|
+
if (typeof peer.key === 'string') out.peerKey = peer.key
|
|
152
|
+
if (peer.httpPort !== undefined) out.peerHttpPort = peer.httpPort
|
|
153
|
+
return out
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
export function clampNumber(value, min, max, fallback) {
|
|
157
|
+
const n = Number(value)
|
|
158
|
+
if (!Number.isFinite(n)) return fallback
|
|
159
|
+
return Math.min(max, Math.max(min, Math.trunc(n)))
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
export function isSecretKey(key) {
|
|
163
|
+
return SECRET_KEY.test(String(key))
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/** Deep-clone a config object, replacing secret-looking leaves unless allowed. */
|
|
167
|
+
export function filterConfig(value, { includeSecrets = false } = {}) {
|
|
168
|
+
if (Array.isArray(value)) return value.map((item) => filterConfig(item, { includeSecrets }))
|
|
169
|
+
if (!value || typeof value !== 'object') return value
|
|
170
|
+
const out = {}
|
|
171
|
+
for (const [key, item] of Object.entries(value)) {
|
|
172
|
+
if (!includeSecrets && isSecretKey(key)) {
|
|
173
|
+
out[key] = '[redacted]'
|
|
174
|
+
continue
|
|
175
|
+
}
|
|
176
|
+
out[key] = filterConfig(item, { includeSecrets })
|
|
177
|
+
}
|
|
178
|
+
return out
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
export function redactDeep(value) {
|
|
182
|
+
return filterConfig(value, { includeSecrets: false })
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
export function readState(env = process.env) {
|
|
186
|
+
const path = statePath(env)
|
|
187
|
+
try {
|
|
188
|
+
const parsed = JSON.parse(readFileSync(path, 'utf8'))
|
|
189
|
+
return parsed && typeof parsed === 'object' ? parsed : {}
|
|
190
|
+
} catch {
|
|
191
|
+
return {}
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
export function writeState(state, env = process.env) {
|
|
196
|
+
const dir = stateDir(env)
|
|
197
|
+
mkdirSync(dir, { recursive: true })
|
|
198
|
+
const path = statePath(env)
|
|
199
|
+
const tmp = `${path}.tmp`
|
|
200
|
+
writeFileSync(tmp, `${JSON.stringify(state, null, 2)}\n`, 'utf8')
|
|
201
|
+
renameSync(tmp, path)
|
|
202
|
+
return path
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
export function saveStatePatch(patch, env = process.env) {
|
|
206
|
+
const next = { ...readState(env), ...(patch || {}) }
|
|
207
|
+
writeState(next, env)
|
|
208
|
+
return next
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
export function effectiveConfig(config = {}, env = process.env) {
|
|
212
|
+
const state = readState(env)
|
|
213
|
+
const merged = { ...normalizeConfig(config, env) }
|
|
214
|
+
const allowed = new Set(Object.keys(DEFAULTS))
|
|
215
|
+
for (const [key, value] of Object.entries(state)) {
|
|
216
|
+
if (!allowed.has(key)) continue
|
|
217
|
+
if (typeof value === 'function') continue
|
|
218
|
+
merged[key] = value
|
|
219
|
+
}
|
|
220
|
+
return normalizeConfig(merged, env)
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
export function ensureStateDirs(env = process.env) {
|
|
224
|
+
const dir = stateDir(env)
|
|
225
|
+
mkdirSync(dir, { recursive: true })
|
|
226
|
+
mkdirSync(backupDir(env), { recursive: true })
|
|
227
|
+
return dir
|
|
228
|
+
}
|