- authenticated WebSocket link (dependency-free RFC6455 + stream mux), pairing codes, per-device keys (hashed), revoke/remove - /r/<device>/ proxy of the full DSH UI (HTTP, Remote WebSocket, SSE) with per-device cookie minting; slave->slave relay through the master - Devices sidebar panel (all devices' sessions, new session on device), Remote devices settings page, finished-session toasts - plugin + config sync master->slave: registry bundles via pluginManager, link: plugins as tarballs, cordis.patch.yml into a managed bundle layer (!!js preserved), allow/deny, opt-in per-secret credential sync - deploy helpers + e2e probes, unit tests
309 lines
13 KiB
JavaScript
309 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
|
|
}
|
|
|
|
/** 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
|
|
}
|