Files
dsh-remote/lib/sync.mjs
T

316 lines
13 KiB
JavaScript

// Plugin + config sync (master → slaves).
//
// Manifest (built on the master, served over the link):
// {
// version, builtAt, master,
// bundles: [{ name, spec, kind: 'registry'|'local', version, hash }],
// patch: [ ...cordis patch entries from the master's cordis.patch.yml selected for sync ],
// secrets: [ names of credential refs the master offers for opt-in sync ]
// }
//
// Slave application:
// * registry bundles -> ctx.pluginManager.installBundle(`${name}@${version}`) unless already
// installed at that version, or excluded by the slave's deny list.
// * local (link:) bundles -> fetched as a tarball over the link, unpacked to
// <profile>/dsh-remote/plugins/<name>/, installed as `file:` spec.
// * patch entries -> written to <profile>/dsh-remote/managed-bundle/cordis.patch.yml,
// a bundle the slave enables BEFORE its own cordis.patch.yml, so
// anything the slave sets locally keeps precedence.
// * secrets -> only the names the slave explicitly opted into are requested;
// values travel over the authenticated link and are written with
// ctx.credentials.set(ref, value).
import crypto from 'node:crypto'
import { execFile } from 'node:child_process'
import fs from 'node:fs'
import os from 'node:os'
import path from 'node:path'
import { profileDir, stateDir } from './store.mjs'
export const MANAGED_BUNDLE = '@dsh-remote/managed'
/** Patch entry ids never synced: they are machine-local by nature. */
const LOCAL_ONLY_IDS = new Set(['dsh-remote', 'dsh-lanmode', 'host-webserver', 'webserver', 'client-connection'])
function run(cmd, args, opts = {}) {
return new Promise((resolve) => {
execFile(cmd, args, { maxBuffer: 64 * 1024 * 1024, ...opts }, (err, stdout, stderr) => {
resolve({ code: err ? (err.code ?? 1) : 0, stdout: String(stdout), stderr: String(stderr) })
})
})
}
/**
* DSH's own entry-list YAML dialect (JSON schema + `!!js` expressions), loaded from the
* running installation so synced patches keep `!!js` nodes. Over the wire a `!!js` node is
* `{__jsExpr: "<code>"}`; dumping with this schema writes it back as `!!js`.
*/
let yamlCache = null
async function loadYaml() {
if (yamlCache) return yamlCache
// Resolve DSH's own copies (not whatever the plugin dir could see): the dsh install's
// node_modules, found by following the real path of the running `dsh` bin.
const nm = dshNodeModules()
if (!nm) throw new Error('cannot locate the DSH installation (node_modules)')
const incPkg = JSON.parse(fs.readFileSync(path.join(nm, '@deepseek-ai', 'cordis-plugin-include', 'package.json'), 'utf8'))
const incMain = typeof incPkg.exports === 'string' ? incPkg.exports
: incPkg.exports?.['.']?.default || incPkg.exports?.['.']?.import || incPkg.exports?.['.'] || incPkg.main || 'lib/index.js'
const inc = await import(path.join(nm, '@deepseek-ai', 'cordis-plugin-include', String(incMain)))
const yaml = await import(path.join(nm, 'js-yaml', 'dist', 'js-yaml.mjs'))
const y = yaml.default || yaml
yamlCache = {
load: (s) => y.load(s, { schema: inc.entryListSchema }),
dump: (v, o = {}) => y.dump(v, { ...o, schema: inc.entryListSchema }),
}
return yamlCache
}
export { loadYaml }
function readJson(file) {
try { return JSON.parse(fs.readFileSync(file, 'utf8')) } catch { return null }
}
function dirHash(dir) {
const h = crypto.createHash('sha256')
const walk = (d) => {
for (const ent of fs.readdirSync(d, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name))) {
if (ent.name === 'node_modules' || ent.name === '.git' || ent.name.startsWith('.')) continue
const p = path.join(d, ent.name)
if (ent.isDirectory()) walk(p)
else if (ent.isFile()) { h.update(path.relative(dir, p)); h.update(fs.readFileSync(p)) }
}
}
walk(dir)
return h.digest('hex').slice(0, 16)
}
// ---------------------------------------------------------------------------
// master side
// ---------------------------------------------------------------------------
export async function buildManifest({ self, include = {}, offerSecrets = [] }) {
const prof = profileDir()
const pkg = readJson(path.join(prof, 'package.json')) || {}
const deps = pkg.dependencies || {}
const enabled = new Set(pkg.dsh?.profile?.bundles || [])
const excludeBundles = new Set(include.excludeBundles || [])
const bundles = []
for (const [name, spec] of Object.entries(deps)) {
if (!enabled.has(name) || excludeBundles.has(name) || name === '@local/dsh-remote' || name === MANAGED_BUNDLE) continue
const s = String(spec)
if (s.startsWith('link:') || s.startsWith('file:')) {
const dir = path.resolve(prof, s.replace(/^(link|file):/, ''))
const m = readJson(path.join(dir, 'package.json'))
if (!m) continue
bundles.push({ name, kind: 'local', version: m.version || '0.0.0', hash: dirHash(dir), dir })
} else {
const installed = readJson(path.join(prof, 'node_modules', name, 'package.json'))
bundles.push({ name, kind: 'registry', spec: s, version: installed?.version || s.replace(/^[\^~]/, '') })
}
}
// Patch entries: everything in the master's cordis.patch.yml except machine-local ids.
let patch = []
let patchError = null
try {
const yaml = await loadYaml()
const doc = yaml.load(fs.readFileSync(path.join(prof, 'cordis.patch.yml'), 'utf8')) || []
const excludeIds = new Set([...(include.excludePatchIds || []), ...LOCAL_ONLY_IDS])
patch = (Array.isArray(doc) ? doc : []).filter((e) => e && typeof e === 'object' && !excludeIds.has(e.id))
} catch (e) {
patch = []
patchError = String(e?.message ?? e)
}
return {
version: 1,
builtAt: Date.now(),
master: { id: self.id, name: self.name },
bundles: bundles.map(({ dir, ...b }) => b),
patch,
patchError,
secrets: offerSecrets,
_dirs: Object.fromEntries(bundles.filter((b) => b.dir).map((b) => [b.name, b.dir])),
}
}
/** Pack a local plugin dir into a tgz Buffer (excludes .git, node_modules). */
export async function packLocal(dir) {
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'dsh-remote-pack-'))
const out = path.join(tmp, 'pkg.tgz')
const r = await run('tar', ['--exclude=.git', '--exclude=node_modules', '--exclude=*.bak', '-czf', out, '-C', dir, '.'])
if (r.code !== 0) throw new Error(`tar failed: ${r.stderr.trim()}`)
const buf = fs.readFileSync(out)
fs.rmSync(tmp, { recursive: true, force: true })
return buf
}
// ---------------------------------------------------------------------------
// slave side
// ---------------------------------------------------------------------------
export function syncDir() { return path.join(stateDir(), 'sync') }
export function managedBundleDir() { return path.join(stateDir(), 'managed-bundle') }
/** Write the managed bundle (package.json + patch) that carries synced config. */
export async function writeManagedBundle(patch, insertedLocalPlugins = []) {
const dir = managedBundleDir()
fs.mkdirSync(dir, { recursive: true })
const yaml = await loadYaml()
const header = '# Managed by dsh-remote: config synced from the master. Do not edit;\n'
+ '# your own profile cordis.patch.yml is applied after this layer and wins.\n'
const body = patch.length ? yaml.dump(patch, { lineWidth: 200, noRefs: true }) : '[]\n'
fs.writeFileSync(path.join(dir, 'cordis.patch.yml'), header + body)
fs.writeFileSync(path.join(dir, 'package.json'), JSON.stringify({
name: MANAGED_BUNDLE,
version: `0.0.${Date.now()}`,
private: true,
description: 'dsh-remote managed layer: plugins/config synced from the master',
dsh: { bundle: { patch: './cordis.patch.yml' } },
dsh_remote: { localPlugins: insertedLocalPlugins },
}, null, 2))
return dir
}
/**
* Create/refresh <profile>/node_modules/<name> -> dir. DSH resolves bundle packages
* from the profile's node_modules; a `link:` dependency alone is only materialised by a
* pnpm install, so make the link ourselves (idempotent, no network).
*/
export function linkIntoNodeModules(name, dir) {
const link = path.join(profileDir(), 'node_modules', ...name.split('/'))
fs.mkdirSync(path.dirname(link), { recursive: true })
try {
if (fs.readlinkSync(link) === dir) return false
} catch { /* missing or not a link */ }
fs.rmSync(link, { recursive: true, force: true })
fs.symlinkSync(dir, link)
return true
}
/** Ensure the managed bundle is linked and listed last, i.e. right before the user's own patch. */
export function ensureManagedBundleOrder() {
const prof = profileDir()
const file = path.join(prof, 'package.json')
const pkg = readJson(file)
if (!pkg) return false
pkg.dependencies ||= {}
const spec = `link:${managedBundleDir()}`
let changed = linkIntoNodeModules(MANAGED_BUNDLE, managedBundleDir())
let pkgChanged = false
if (pkg.dependencies[MANAGED_BUNDLE] !== spec) { pkg.dependencies[MANAGED_BUNDLE] = spec; pkgChanged = true }
const bundles = (((pkg.dsh ||= {}).profile ||= {}).bundles ||= [])
const idx = bundles.indexOf(MANAGED_BUNDLE)
if (idx !== bundles.length - 1) {
if (idx !== -1) bundles.splice(idx, 1)
bundles.push(MANAGED_BUNDLE)
pkgChanged = true
}
if (pkgChanged) fs.writeFileSync(file, JSON.stringify(pkg, null, 2) + '\n')
return changed || pkgChanged
}
export function unpackLocal(name, version, tgz) {
const safe = name.replace(/[^a-zA-Z0-9._-]+/g, '_')
const dir = path.join(syncDir(), 'plugins', safe)
const tmp = `${dir}.new`
fs.rmSync(tmp, { recursive: true, force: true })
fs.mkdirSync(tmp, { recursive: true })
const tgzFile = path.join(syncDir(), `${safe}.tgz`)
fs.writeFileSync(tgzFile, tgz)
return run('tar', ['-xzf', tgzFile, '-C', tmp]).then((r) => {
fs.rmSync(tgzFile, { force: true })
if (r.code !== 0) throw new Error(`untar ${name}: ${r.stderr.trim()}`)
fs.rmSync(dir, { recursive: true, force: true })
fs.renameSync(tmp, dir)
linkDshDeps(dir)
return dir
})
}
/** DSH installation's own node_modules (where @deepseek-ai/* packages live). */
export function dshNodeModules() {
const bin = process.argv[1] || ''
let real = bin
try { real = fs.realpathSync(bin) } catch { /* keep argv path */ }
const cand = [
path.resolve(path.dirname(real), '..', 'node_modules'),
path.resolve(path.dirname(bin), '..', 'lib', 'node_modules', '@deepseek-ai', 'dsh', 'node_modules'),
]
for (const c of cand) if (fs.existsSync(path.join(c, '@deepseek-ai'))) return c
return null
}
/**
* Synced local plugins import `@deepseek-ai/*` modules that the master resolved through
* a node_modules symlink into its DSH install. Recreate those links against this
* machine's DSH so the plugin loads unchanged.
*/
export function linkDshDeps(pluginDir) {
const nm = dshNodeModules()
if (!nm) return []
const wanted = new Set()
const scan = (d, depth = 0) => {
if (depth > 3) return
for (const ent of fs.readdirSync(d, { withFileTypes: true })) {
if (ent.name === 'node_modules' || ent.name.startsWith('.')) continue
const p = path.join(d, ent.name)
if (ent.isDirectory()) scan(p, depth + 1)
else if (/\.(m?js|cjs)$/.test(ent.name)) {
const src = fs.readFileSync(p, 'utf8')
for (const m of src.matchAll(/(?:from\s+|import\s*\(\s*|require\(\s*)['"](@deepseek-ai\/[a-z0-9-]+)/g)) wanted.add(m[1])
}
}
}
scan(pluginDir)
const linked = []
for (const mod of wanted) {
const target = path.join(nm, mod)
if (!fs.existsSync(target)) continue
const link = path.join(pluginDir, 'node_modules', mod)
fs.mkdirSync(path.dirname(link), { recursive: true })
try { fs.rmSync(link, { recursive: true, force: true }) } catch { /* ignore */ }
fs.symlinkSync(target, link)
linked.push(mod)
}
return linked
}
/** Decide whether a bundle passes the slave's allow/deny lists. */
export function allowed(name, { allow = [], deny = [] } = {}) {
const match = (pat) => pat === name || (pat.endsWith('*') && name.startsWith(pat.slice(0, -1)))
if (deny.some(match)) return false
if (allow.length && !allow.some(match)) return false
return true
}
/** True when the profile still lists `name` as a bundle linked to `dir` (survives manual edits). */
export function isLinked(name, dir) {
const pkg = readJson(path.join(profileDir(), 'package.json'))
if (!pkg || !dir || !fs.existsSync(dir)) return false
return pkg.dependencies?.[name] === `link:${dir}` && (pkg.dsh?.profile?.bundles || []).includes(name)
}
/** Link-spec install of a local plugin dir into the profile (package.json + bundles list). */
export function linkLocalBundle(name, dir) {
const prof = profileDir()
const file = path.join(prof, 'package.json')
const pkg = readJson(file)
if (!pkg) throw new Error('profile package.json unreadable')
pkg.dependencies ||= {}
const spec = `link:${dir}`
let changed = false
if (pkg.dependencies[name] !== spec) { pkg.dependencies[name] = spec; changed = true }
const bundles = (((pkg.dsh ||= {}).profile ||= {}).bundles ||= [])
if (!bundles.includes(name)) {
// Synced plugins go before the managed layer so the master's config for them applies.
const at = bundles.indexOf(MANAGED_BUNDLE)
if (at === -1) bundles.push(name)
else bundles.splice(at, 0, name)
changed = true
}
if (changed) fs.writeFileSync(file, JSON.stringify(pkg, null, 2) + '\n')
return linkIntoNodeModules(name, dir) || changed
}