// 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 // /dsh-remote/plugins//, installed as `file:` spec. // * patch entries -> written to /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: ""}`; 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 /node_modules/ -> 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 }