diff --git a/README.md b/README.md index b233458..ab621cb 100644 --- a/README.md +++ b/README.md @@ -75,9 +75,8 @@ The slave gets its own device key; the master stores only its hash. **Revoke** c ## Use it -- **Sidebar → Devices:** every device, its sessions grouped by workspace, a running indicator, search. Click a session to open it. A local session opens in place. A remote one opens `/r//#/session/`, the full UI of that device, with a "Remote: · back to local" pill in the corner. -- **+ New session** on a device card creates a session in one of that device's workspaces and opens it. -- **Open DSH ↗** opens a device's whole UI. +- **Workspaces list:** the workspaces of every *other* machine appear in the stock Workspaces list, below your own, with a **server icon** instead of the folder icon and the device name on the right. Click one to fold its sessions open (newest first, "Show N more" for the rest); hover for **+** to start a session in that workspace on that device. Clicking a remote session opens it as the usual conversation view, served by that device at `/r//#/session/`. A "Remote: · back to local" pill marks the remote view. While you are viewing a device, its own workspaces are the native rows and are not repeated. +- **Sidebar → Devices:** every device with its state (online / offline, last seen, platform, counts of workspaces, sessions and running ones), then its workspaces and sessions. The same server icon marks other machines' workspaces. **Open DSH ↗** opens a device's whole UI, **+ New session** creates a session in one of its workspaces. - **Toasts:** ": '' finished", with an *Open* action. ## Agent tools: the master's AI drives the slaves diff --git a/client.js b/client.js index 0045949..b028af4 100644 --- a/client.js +++ b/client.js @@ -169,18 +169,55 @@ window.__ModuleLoader__.load({ err && h('span', { style: S.err }, err)); } + // ---- icons (the primitives have no server glyph, so draw it) + function ServerIcon({ size = 16 }) { + return h('svg', { viewBox: '0 0 16 16', width: size, height: size, fill: 'none', stroke: 'currentColor', strokeWidth: 1.2, strokeLinecap: 'round', strokeLinejoin: 'round', 'aria-hidden': true, 'data-dshr-icon': 'server' }, + h('rect', { x: 2, y: 2.5, width: 12, height: 4.2, rx: 1.1 }), + h('rect', { x: 2, y: 9.3, width: 12, height: 4.2, rx: 1.1 }), + h('path', { d: 'M4.6 4.6h.01M4.6 11.4h.01', strokeWidth: 1.8 })); + } + function FolderIcon({ size = 16 }) { + return h('svg', { viewBox: '0 0 16 16', width: size, height: size, fill: 'none', stroke: 'currentColor', strokeWidth: 1.2, strokeLinecap: 'round', strokeLinejoin: 'round', 'aria-hidden': true }, + h('path', { d: 'M2 4.5A1.5 1.5 0 0 1 3.5 3h2.6l1.4 1.5h5A1.5 1.5 0 0 1 14 6v5.5a1.5 1.5 0 0 1-1.5 1.5h-9A1.5 1.5 0 0 1 2 11.5z' })); + } + + /** Workspaces of one device with their sessions (workspace registry + sessions by cwd). */ + function groupDevice(dev, q = '') { + const match = (s) => !q || `${s.title || ''} ${s.cwd || ''}`.toLowerCase().includes(q); + const groups = []; + const byPath = new Map(); + for (const w of dev.workspaces || []) { + const g = { key: `${dev.device}:${w.id}`, id: w.id, title: w.title || base(w.path) || w.path, path: w.path, sessions: [] }; + groups.push(g); byPath.set(w.path, g); + } + for (const s of dev.items || []) { + if (!match(s)) continue; + const p = s.cwd || ''; + let g = byPath.get(p); + if (!g) { + g = { key: `${dev.device}:cwd:${p}`, id: undefined, title: base(p) || 'No workspace', path: p, sessions: [] }; + groups.push(g); byPath.set(p, g); + } + g.sessions.push(s); + } + return q ? groups.filter((g) => g.sessions.length) : groups; + } + + // ------------------------------------------------------------------ Devices page function DevicesPage() { const st = useStore(); const [filter, setFilter] = useState(''); const self = st.state?.self; if (!st.state && !st.error) return h('div', { style: S.page }, 'Loading devices…'); - const devices = st.sessions || []; const q = filter.trim().toLowerCase(); - const meta = Object.fromEntries((st.state?.devices || []).map((d) => [d.id, d])); + const bySessions = Object.fromEntries((st.sessions || []).map((d) => [d.device, d])); + const devices = (st.state?.devices || []).filter((d) => !d.revoked || d.self); + const online = devices.filter((d) => d.online).length; return h('div', { style: S.page, 'data-dsh-plugin': 'dsh-remote' }, h('h1', { style: S.h1 }, 'Devices'), h('p', { style: S.sub }, - st.state ? `This device: ${self?.name} (${st.state.role})` : '', + `${online} of ${devices.length} online`, + st.state ? ` · this device: ${self?.name} (${st.state.role})` : '', st.state?.role === 'slave' && st.state.master ? (st.state.master.linked ? ` · linked to ${st.state.master.info?.name || st.state.master.url}` : ` · master unreachable${st.state.master.error ? ': ' + st.state.master.error : ''}`) : ''), @@ -188,34 +225,133 @@ window.__ModuleLoader__.load({ h('div', { style: { display: 'flex', gap: 8, margin: '0 0 14px' } }, h('input', { style: { ...S.input, flex: 1 }, placeholder: 'Filter sessions…', value: filter, onChange: (e) => setFilter(e.target.value) }), h('button', { style: S.btn, onClick: () => store.refresh() }, 'Refresh')), - devices.map((dev) => { - const m = meta[dev.device] || {}; - const items = (dev.items || []).filter((s) => !q || `${s.title || ''} ${s.cwd || ''}`.toLowerCase().includes(q)); - const byWs = new Map(); - for (const s of items) { - const key = s.cwd || ''; - if (!byWs.has(key)) byWs.set(key, []); - byWs.get(key).push(s); - } - const wsTitle = (cwd) => (dev.workspaces || []).find((w) => w.path === cwd)?.title || base(cwd) || 'No workspace'; - return h('div', { key: dev.device, style: S.card }, + devices.map((m) => { + const dev = bySessions[m.id] || { device: m.id, name: m.name, items: [], workspaces: [], self: m.self }; + const groups = groupDevice(dev, q); + const running = (dev.items || []).filter((s) => s.running).length; + const state = m.online + ? (dev.error ? 'unreachable' : `online${m.connectedAt ? ' · connected ' + ago(m.connectedAt) : ''}`) + : `offline${m.lastSeenAt ? ' · last seen ' + ago(m.lastSeenAt) : ''}`; + return h('div', { key: m.id, style: { ...S.card, opacity: m.online ? 1 : .7 } }, h('div', { style: S.devHead }, - h('span', { style: S.dot(!dev.error) }), - h('strong', null, dev.name || dev.device), - dev.self && h('span', { style: S.badge('accent') }, 'this device'), + h('span', { style: S.dot(m.online && !dev.error) }), + h('strong', null, m.name || m.id), + m.self && h('span', { style: S.badge('accent') }, 'this device'), m.role === 'master' && h('span', { style: S.badge() }, 'master'), m.via && h('span', { style: S.badge() }, 'via master'), + m.revoked && h('span', { style: S.badge('warn') }, 'revoked'), h('span', { style: { flex: 1 } }), - !dev.self && h('a', { style: { ...S.btn, textDecoration: 'none' }, href: `${mount.root}r/${dev.device}/` }, 'Open DSH ↗'), - h(NewSession, { dev, selfId: self?.id })), + m.online && !m.self && h('a', { style: { ...S.btn, textDecoration: 'none' }, href: `${mount.root}r/${m.id}/` }, 'Open DSH ↗'), + m.online && !dev.error && h(NewSession, { dev, selfId: self?.id })), + h('div', { style: { fontSize: 12, opacity: .65, margin: '0 0 6px' } }, + state, + m.platform ? ` · ${m.platform}` : '', + m.online && !dev.error ? ` · ${(dev.workspaces || []).length} workspaces · ${(dev.items || []).length} sessions${running ? ` · ${running} running` : ''}` : ''), dev.error && h('div', { style: S.err }, dev.error), - !dev.error && !items.length && h('div', { style: { opacity: .6, fontSize: 13 } }, q ? 'No matching sessions.' : 'No sessions yet.'), - [...byWs.entries()].map(([cwd, list]) => h('div', { key: cwd }, - h('div', { style: S.ws, title: cwd }, wsTitle(cwd)), - list.map((s) => h(SessionRow, { key: s.sessionId, dev, s, selfId: self?.id }))))); + !dev.error && m.online && !groups.length && h('div', { style: { opacity: .6, fontSize: 13 } }, q ? 'No matching sessions.' : 'No workspaces yet.'), + groups.map((g) => h('div', { key: g.key }, + h('div', { style: { ...S.ws, display: 'flex', alignItems: 'center', gap: 6 }, title: g.path }, + h('span', { style: { display: 'inline-flex', opacity: .8 } }, m.self ? h(FolderIcon, { size: 14 }) : h(ServerIcon, { size: 14 })), + g.title), + g.sessions.length + ? g.sessions.map((s) => h(SessionRow, { key: s.sessionId, dev, s, selfId: self?.id })) + : h('div', { style: { fontSize: 12, opacity: .5, padding: '2px 8px 4px 28px' } }, 'no sessions')))); })); } + // ------------------------------------------------------------------ remote workspaces in the sidebar + // The stock Workspaces list belongs to ui-workspace and has no slot for extra groups, so a + // small React root is mounted as the last child of its tree and styled with the same + // design tokens. Rows use a server glyph where the stock rows use a folder; sessions open + // as the usual conversation view of that device (/r/<device>/#/session/<id>). + const SB_CSS = ` + .dshr-sb{margin-top:2px;display:flex;flex-direction:column;gap:2px} + .dshr-sb-row{box-sizing:border-box;display:flex;align-items:center;gap:6px;height:34px;padding:0 8px;border-radius:var(--dsw-radius-md,8px);cursor:pointer;user-select:none;color:var(--dsw-alias-label-primary,inherit);font-size:14px;text-decoration:none} + .dshr-sb-row:hover{background:var(--dsw-alias-interactive-bg-hover,rgba(127,127,127,.12))} + .dshr-sb-slot{width:16px;height:20px;display:inline-flex;align-items:center;justify-content:center;flex:0 0 auto;color:var(--dsw-alias-label-tertiary,#888)} + .dshr-sb-title{flex:1 1 0;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;line-height:20px} + .dshr-sb-dev{flex:0 0 auto;font-size:10px;line-height:16px;color:var(--dsw-alias-label-tertiary,#888);max-width:64px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap} + .dshr-sb-sess{height:32px;padding-inline-start:30px;gap:0} + .dshr-sb-sess .dshr-sb-title{margin:0 6px 0 4px} + .dshr-sb-time{flex:0 0 auto;font-size:10px;line-height:16px;color:var(--dsw-alias-label-tertiary,#888)} + .dshr-sb-plus{display:none;flex:0 0 auto;width:20px;height:20px;border:0;border-radius:6px;background:none;color:inherit;cursor:pointer;line-height:1;font-size:15px} + .dshr-sb-plus:hover{background:rgba(127,127,127,.2)} + .dshr-sb-row:hover .dshr-sb-plus{display:inline-flex;align-items:center;justify-content:center} + .dshr-sb-more{font-size:12px;color:var(--dsw-alias-label-tertiary,#888);padding:2px 8px 2px 30px;cursor:pointer;border:0;background:none;text-align:left} + .dshr-sb-run{width:8px;height:8px;border-radius:8px;background:#d29922;margin-right:2px;flex:0 0 auto;animation:dshr-pulse 1s infinite} + `; + const SB_SESSIONS = 6; + + function SidebarRemote() { + const st = useStore(); + const [open, setOpen] = useState(() => { try { return JSON.parse(localStorage.getItem('dshr-ws-open') || '{}'); } catch { return {}; } }); + const [more, setMore] = useState({}); + const selfId = st.state?.self?.id; + // The device whose UI this page is showing already has its own native rows. + const viewed = mount.device || selfId; + const devs = (st.sessions || []).filter((d) => d.device !== viewed && !d.error); + if (!st.state || !devs.length) return null; + const toggle = (key) => { + const next = { ...open, [key]: !open[key] }; + setOpen(next); + try { localStorage.setItem('dshr-ws-open', JSON.stringify(next)); } catch { /* ignore */ } + }; + const create = async (e, dev, g) => { + e.stopPropagation(); + try { + const r = await post('new-session', { device: dev.device, workspaceId: g.id, cwd: g.id ? undefined : g.path || undefined }); + location.href = sessionUrl(dev.device, selfId, r.sessionId); + } catch (err) { console.warn('[dsh-remote] new session failed:', err.message); } + }; + return h('div', { className: 'dshr-sb', 'data-dshr-remote-workspaces': '' }, + devs.flatMap((dev) => groupDevice(dev).map((g) => { + const isOpen = !!open[g.key]; + const shown = more[g.key] ? g.sessions : g.sessions.slice(0, SB_SESSIONS); + return h(React.Fragment, { key: g.key }, + h('div', { className: 'dshr-sb-row', role: 'treeitem', 'aria-expanded': isOpen, title: `${dev.name}: ${g.path}`, onClick: () => toggle(g.key) }, + h('span', { className: 'dshr-sb-slot' }, h(ServerIcon)), + h('span', { className: 'dshr-sb-title' }, g.title), + h('button', { className: 'dshr-sb-plus', title: `New session on ${dev.name}`, onClick: (e) => create(e, dev, g) }, '+'), + h('span', { className: 'dshr-sb-dev' }, dev.name || dev.device)), + isOpen && shown.map((s) => h('a', { key: s.sessionId, className: 'dshr-sb-row dshr-sb-sess', role: 'treeitem', href: sessionUrl(dev.device, selfId, s.sessionId), title: `${s.title || s.sessionId}\n${dev.name}` }, + s.running ? h('span', { className: 'dshr-sb-run', title: 'running' }) : null, + h('span', { className: 'dshr-sb-title' }, s.title || 'untitled'), + h('span', { className: 'dshr-sb-time' }, ago(s.updatedAt)))), + isOpen && !more[g.key] && g.sessions.length > SB_SESSIONS && h('button', { className: 'dshr-sb-more', onClick: () => setMore({ ...more, [g.key]: true }) }, `Show ${g.sessions.length - SB_SESSIONS} more`), + isOpen && !g.sessions.length && h('div', { className: 'dshr-sb-more', style: { cursor: 'default' } }, 'no sessions')); + }))); + } + + function mountSidebarRemote() { + if (!document.getElementById('dshr-sb-style')) { + const css = document.createElement('style'); + css.id = 'dshr-sb-style'; + css.textContent = SB_CSS; + document.head.appendChild(css); + } + const host = document.createElement('div'); + host.className = 'dshr-sb-host'; + host.setAttribute('role', 'none'); + const root = ReactDOM.createRoot ? ReactDOM.createRoot(host) : null; + if (root) root.render(h(SidebarRemote)); else ReactDOM.render(h(SidebarRemote), host); + let raf = 0; + const place = () => { + raf = 0; + const tree = document.querySelector('[data-slot="sidebar.workspaces"] [role="tree"]'); + if (!tree) return; + if (host.parentElement !== tree || tree.lastElementChild !== host) tree.appendChild(host); + }; + const schedule = () => { if (!raf) raf = requestAnimationFrame(place); }; + const obs = new MutationObserver(schedule); + obs.observe(document.body, { childList: true, subtree: true }); + schedule(); + return () => { + obs.disconnect(); + try { root ? root.unmount() : ReactDOM.unmountComponentAtNode(host); } catch { /* ignore */ } + host.remove(); + }; + } + // ------------------------------------------------------------------ Settings section function Field({ label, children, hint }) { return h('label', { style: { display: 'grid', gap: 4, margin: '0 0 10px', fontSize: 13 } }, @@ -565,6 +701,8 @@ window.__ModuleLoader__.load({ const disposers = []; if (!mount.device) disposers.push(mountToasts()); disposers.push(mountRemoteBanner()); + // Other machines' workspaces inside the stock Workspaces list (server icon). + disposers.push(mountSidebarRemote()); disposers.push(ctx.slots.inject('sidebar.panellist', () => ctx.slots.register({ name: 'sidebar.panellist', id: PANEL_ID, order: 50, label: 'Devices', diff --git a/deploy/e2e-dom.mjs b/deploy/e2e-dom.mjs new file mode 100644 index 0000000..dbca0c5 --- /dev/null +++ b/deploy/e2e-dom.mjs @@ -0,0 +1,48 @@ +#!/usr/bin/env node +// Dump the sidebar DOM (structure only) of the local DSH, to build UI integrations against. +// node deploy/e2e-dom.mjs <chrome> [port] [selector] +import { spawn } from 'node:child_process' +import fs from 'node:fs' +import os from 'node:os' +import path from 'node:path' +import { cookieName, mintCookie } from '../lib/auth-cookie.mjs' + +const [chrome, port = '3081', selector = 'aside, nav, [class*=sidebar]'] = process.argv.slice(2) +const home = process.env.DSH_HOME || path.join(os.homedir(), '.dsh') +const secret = fs.readFileSync(path.join(home, '.credentials.yaml'), 'utf8').match(/browser-session:[\s\S]*?secret:\s*([A-Za-z0-9_-]{43})/)[1] +const authority = `127.0.0.1:${port}` +const prof = fs.mkdtempSync(path.join(os.tmpdir(), 'dshr-dom-')) +const proc = spawn(chrome, ['--headless=new', '--no-sandbox', '--disable-gpu', `--user-data-dir=${prof}`, '--remote-debugging-port=0', '--window-size=1400,900', 'about:blank'], { stdio: ['ignore', 'ignore', 'pipe'] }) +const wsUrl = await new Promise((resolve, reject) => { + let buf = '' + proc.stderr.on('data', (d) => { buf += d; const m = buf.match(/DevTools listening on (ws:\/\/\S+)/); if (m) resolve(m[1]) }) + setTimeout(() => reject(new Error('chrome did not start')), 15000) +}) +const page = (await (await fetch(wsUrl.replace(/^ws/, 'http').replace(/\/devtools\/browser\/.*/, '/json/list'))).json()).find((t) => t.type === 'page') +const ws = new WebSocket(page.webSocketDebuggerUrl) +await new Promise((r) => ws.addEventListener('open', r)) +let id = 0 +const pending = new Map() +ws.addEventListener('message', (ev) => { const m = JSON.parse(ev.data); if (m.id && pending.has(m.id)) { pending.get(m.id)(m); pending.delete(m.id) } }) +const send = (method, params = {}) => new Promise((resolve) => { const i = ++id; pending.set(i, resolve); ws.send(JSON.stringify({ id: i, method, params })) }) +const sleep = (ms) => new Promise((r) => setTimeout(r, ms)) +await send('Page.enable'); await send('Runtime.enable'); await send('Network.enable') +await send('Network.setCookie', { name: cookieName(authority), value: mintCookie(authority, secret, 1).split('=').slice(1).join('='), domain: '127.0.0.1', path: '/', httpOnly: true }) +await send('Page.navigate', { url: `http://${authority}/` }) +await sleep(10000) +const expr = `(() => { + const root = [...document.querySelectorAll(${JSON.stringify(selector)})].find((e) => /Workspaces/.test(e.innerText || '')) || document.body + const dump = (el, d = 0) => { + if (d > 9) return '' + const attrs = [...el.attributes].filter((a) => /^(class|role|data-|aria-label|href|title|type)/.test(a.name)).map((a) => a.name + '="' + String(a.value).slice(0, 70) + '"').join(' ') + const own = [...el.childNodes].filter((n) => n.nodeType === 3).map((n) => n.textContent.trim()).filter(Boolean).join(' ').slice(0, 40) + const kids = [...el.children].map((c) => dump(c, d + 1)).join('') + return ' '.repeat(d) + '<' + el.tagName.toLowerCase() + (attrs ? ' ' + attrs : '') + '>' + (own ? ' ' + own : '') + '\\n' + kids + } + return dump(root) +})()` +const r = await send('Runtime.evaluate', { expression: expr, returnByValue: true }) +console.log(r.result?.result?.value || JSON.stringify(r.result)) +ws.close(); proc.kill('SIGKILL') +try { fs.rmSync(prof, { recursive: true, force: true }) } catch { /* chrome may still hold files */ } +process.exit(0) diff --git a/deploy/e2e-eval.mjs b/deploy/e2e-eval.mjs new file mode 100644 index 0000000..3f12764 --- /dev/null +++ b/deploy/e2e-eval.mjs @@ -0,0 +1,50 @@ +#!/usr/bin/env node +// Evaluate a JS expression inside the DSH page (headless Chromium) and print the result. +// node deploy/e2e-eval.mjs <chrome> <port> '<expression>' [waitMs] [urlPath] +// Authenticates with the local browser-session secret (DSH_HOME). Handy for UI integration work. +import { spawn } from 'node:child_process' +import fs from 'node:fs' +import os from 'node:os' +import path from 'node:path' +import { cookieName, mintCookie } from '../lib/auth-cookie.mjs' + +const [chrome, port = '3081', expression, waitMs = '9000', urlPath = '/'] = process.argv.slice(2) +const home = process.env.DSH_HOME || path.join(os.homedir(), '.dsh') +const secret = fs.readFileSync(path.join(home, '.credentials.yaml'), 'utf8').match(/browser-session:[\s\S]*?secret:\s*([A-Za-z0-9_-]{43})/)[1] +const authority = `127.0.0.1:${port}` +const prof = fs.mkdtempSync(path.join(os.tmpdir(), 'dshr-eval-')) +const proc = spawn(chrome, ['--headless=new', '--no-sandbox', '--disable-gpu', `--user-data-dir=${prof}`, '--remote-debugging-port=0', '--window-size=1400,1000', 'about:blank'], { stdio: ['ignore', 'ignore', 'pipe'] }) +const wsUrl = await new Promise((resolve, reject) => { + let buf = '' + proc.stderr.on('data', (d) => { buf += d; const m = buf.match(/DevTools listening on (ws:\/\/\S+)/); if (m) resolve(m[1]) }) + setTimeout(() => reject(new Error('chrome did not start')), 15000) +}) +const page = (await (await fetch(wsUrl.replace(/^ws/, 'http').replace(/\/devtools\/browser\/.*/, '/json/list'))).json()).find((t) => t.type === 'page') +const ws = new WebSocket(page.webSocketDebuggerUrl) +await new Promise((r) => ws.addEventListener('open', r)) +let id = 0 +const pending = new Map() +ws.addEventListener('message', (ev) => { const m = JSON.parse(ev.data); if (m.id && pending.has(m.id)) { pending.get(m.id)(m); pending.delete(m.id) } }) +const send = (method, params = {}) => new Promise((resolve) => { const i = ++id; pending.set(i, resolve); ws.send(JSON.stringify({ id: i, method, params })) }) +await send('Page.enable'); await send('Runtime.enable'); await send('Network.enable') +await send('Network.setCookie', { name: cookieName(authority), value: mintCookie(authority, secret, 1).split('=').slice(1).join('='), domain: '127.0.0.1', path: '/', httpOnly: true }) +await send('Page.navigate', { url: `http://${authority}${urlPath}` }) +await new Promise((r) => setTimeout(r, Number(waitMs))) +const r = await send('Runtime.evaluate', { expression, awaitPromise: true, returnByValue: true }) +const v = r.result?.result?.value +console.log(typeof v === 'string' ? v : JSON.stringify(v ?? r.result, null, 1)) +// Optional second expression after the page settled (e.g. after the first one navigated). +if (process.env.THEN) { + await new Promise((res) => setTimeout(res, Number(process.env.THEN_WAIT || 12000))) + const r2 = await send('Runtime.evaluate', { expression: process.env.THEN, awaitPromise: true, returnByValue: true }) + const v2 = r2.result?.result?.value + console.log(typeof v2 === 'string' ? v2 : JSON.stringify(v2 ?? r2.result, null, 1)) +} +if (process.env.SHOT) { + const s = await send('Page.captureScreenshot', { format: 'png' }) + fs.writeFileSync(process.env.SHOT, Buffer.from(s.result.data, 'base64')) + console.log('screenshot', process.env.SHOT) +} +ws.close(); proc.kill('SIGKILL') +try { fs.rmSync(prof, { recursive: true, force: true }) } catch { /* chrome may still hold files */ } +process.exit(0) diff --git a/package.json b/package.json index 30dfa30..ac9fb5c 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@local/dsh-remote", - "version": "0.5.0", + "version": "0.6.0", "description": "Claude-Desktop-style remote control for DSH: master/slave link, every device's sessions on every device (/r/<device>/), plugin + config sync with opt-in secrets.", "private": true, "type": "module",