From 8c2b6e48eef0230320e85458abab9bb5b6dcc35a Mon Sep 17 00:00:00 2001 From: Jean-Baptiste Date: Sat, 3 Oct 2026 11:36:35 +0200 Subject: [PATCH 1/2] (remote): check a host's enrolment state from Settings (#222) Each host row gets a Check host button: one read-only ssh reports ssh, the claude CLI and version, tmux, ~/.claude and the login, and hands the command to run on the host for each missing item. Login is read from the exit status of `claude auth status`, whose output is discarded; no credential is read or copied. Closes #222 --- .ai/contexts/session-cache.md | 13 ++ CHANGELOG.md | 1 + docs/remote-hosts.md | 30 ++++ eslint.config.js | 16 ++ main.js | 9 +- preload.js | 1 + public/index.html | 1 + public/remote-enrol-panel.js | 74 ++++++++ public/settings-panel.js | 7 +- public/style.css | 46 +++++ remote-enrol.js | 93 ++++++++++ remote-transport.js | 68 ++++++- test/dom-remote-enrol-panel.test.js | 122 +++++++++++++ test/main-remote-enrol-wiring.test.js | 36 ++++ test/remote-enrol.test.js | 157 +++++++++++++++++ test/remote-transport-enrol.test.js | 243 ++++++++++++++++++++++++++ 16 files changed, 914 insertions(+), 3 deletions(-) create mode 100644 public/remote-enrol-panel.js create mode 100644 remote-enrol.js create mode 100644 test/dom-remote-enrol-panel.test.js create mode 100644 test/main-remote-enrol-wiring.test.js create mode 100644 test/remote-enrol.test.js create mode 100644 test/remote-transport-enrol.test.js diff --git a/.ai/contexts/session-cache.md b/.ai/contexts/session-cache.md index fa8a1102..cfbcdd66 100644 --- a/.ai/contexts/session-cache.md +++ b/.ai/contexts/session-cache.md @@ -840,6 +840,19 @@ also returns `blocked` (why nothing could be read, or null) and the normalised ` - **A failed attach after a successful launch** leaves the tmux session running on the host; the error names it, and it appears in the sidebar at the next refresh. - Not verified here: a real host (the tests use a fake runner, plus a real `sh` with stubbed `tmux`/`claude`), tmux older than the `-P -F` form, and a `claude` that exits at once (the pane then closes and attach fails with the probe error). +## Remote hosts — enrolment (issue #222) + +`remote-enrol.js` builds the checklist and guards the request; the command and its parser are in `remote-transport.js` (`ENROL_COMMAND`, `parseEnrol`, `checkHost`); the IPC is `remote-host-enrol-check`, the UI is `public/remote-enrol-panel.js` driven from the host rows of `settings-panel.js`. + +- **It reports state, it acquires nothing.** The sensitive-path denylist refuses `.claude/.credentials.json` on purpose (#208), so no check opens, copies, hashes or tests that file, and none reads `ANTHROPIC_*`, a keychain or a token. Pinned by a negative match on `ENROL_COMMAND` in `test/remote-transport-enrol.test.js`. +- **The logged-in signal is the CLI's own exit status.** `claude auth status` (verified locally, CLI 2.1.288, read-only) exits 0 when logged in and 1 when not, and prints JSON or text that includes the email and organisation. The command runs it with stdout and stderr thrown away (`>/dev/null 2>&1 `). +Switchboard never copies or reads credentials. The login check does not open the +credentials file or test that it exists: it asks the CLI, throws away what the +CLI prints (which includes your email), and keeps only the exit status. When the +state cannot be told (an older CLI without `claude auth status`, no `~/.claude` +yet, `claude` missing, an unusual exit status) the line says *unknown — run +`claude` on the host once to log in*, never "not logged in". + +Limits: the host must be saved first (the button checks the hosts in the saved +settings); a Linux host with a POSIX shell is required, so a Windows host is +reported as not checked; only `tmux` is looked for. If the CLI is logged in only +through an environment variable set by an interactive profile, the check, which +runs in a non-interactive shell, reads "not logged in". + ## Requirements on the host - A Linux host (`/proc` is read for liveness, attach and stop), a POSIX shell diff --git a/eslint.config.js b/eslint.config.js index baa02629..bb682ca1 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -118,6 +118,7 @@ const rendererCrossFileGlobals = { openSettingsViewer: 'readonly', wireActivityTraceToggle: 'readonly', wireActivityReportingToggle: 'readonly', + wireRemoteEnrolControls: 'readonly', renderActivityReportingStatus: 'readonly', renderActivityTraceFiles: 'readonly', openActivityTraceFile: 'readonly', @@ -442,6 +443,21 @@ module.exports = [ }, }, + // Producer of the host checklist global that settings-panel.js consumes. + { + files: ['public/remote-enrol-panel.js'], + languageOptions: { + ecmaVersion: 2024, + sourceType: 'script', + globals: { ...globals.browser, wireRemoteEnrolControls: 'off' }, + }, + rules: { + 'no-undef': 'error', + 'no-unused-vars': ['warn', { args: 'none', varsIgnorePattern: '^_' }], + 'no-redeclare': 'warn', + }, + }, + // Dual-mode Settings section (public/activity-reporting-panel.js — see // .ai/contexts/activitywatch.md): classic + diff --git a/public/remote-enrol-panel.js b/public/remote-enrol-panel.js new file mode 100644 index 00000000..903b771a --- /dev/null +++ b/public/remote-enrol-panel.js @@ -0,0 +1,74 @@ +// remote-enrol-panel.js — the host checklist in Settings — see .ai/contexts/session-cache.md ("Remote hosts — enrolment") + +'use strict'; + +const ENROL_WHERE_TEXT = { host: 'Run on the host:', workstation: 'Run on this machine:' }; + +function enrolEl(tag, className, text) { + const el = document.createElement(tag); + if (className) el.className = className; + if (text !== undefined) el.textContent = text; + return el; +} + +function renderEnrolChecklist(containerEl, result) { + if (!containerEl) return; + containerEl.textContent = ''; + if (!result || result.ok !== true || !Array.isArray(result.items)) { + containerEl.appendChild(enrolEl('div', 'enrol-error settings-description', (result && result.error) || 'the check returned no answer')); + return; + } + for (const it of result.items) { + const row = enrolEl('div', 'enrol-item'); + row.dataset.status = String(it.status); + const head = enrolEl('div', 'enrol-head'); + head.appendChild(enrolEl('span', 'enrol-status', String(it.status))); + head.appendChild(enrolEl('span', 'enrol-label', String(it.label))); + row.appendChild(head); + row.appendChild(enrolEl('div', 'enrol-detail settings-description', String(it.detail))); + if (typeof it.command === 'string' && it.command) { + const cmd = enrolEl('div', 'enrol-command'); + cmd.appendChild(enrolEl('span', 'enrol-where', ENROL_WHERE_TEXT[it.where] || ENROL_WHERE_TEXT.host)); + cmd.appendChild(enrolEl('code', '', it.command)); + const copy = enrolEl('button', 'enrol-copy settings-check-updates-btn', 'Copy'); + copy.type = 'button'; + copy.addEventListener('click', async () => { + try { + await window.api.writeClipboard(it.command); + copy.textContent = 'Copied'; + } catch { + copy.textContent = 'Failed'; + } + }); + cmd.appendChild(copy); + row.appendChild(cmd); + } + containerEl.appendChild(row); + } +} + +function wireRemoteEnrolControls(buttonEl, containerEl, getAlias) { + if (!buttonEl) return; + buttonEl.addEventListener('click', async () => { + const alias = String(getAlias() || '').trim(); + if (!alias) { + renderEnrolChecklist(containerEl, { ok: false, error: 'type an ssh alias first' }); + return; + } + buttonEl.disabled = true; + containerEl.textContent = ''; + containerEl.appendChild(enrolEl('div', 'enrol-pending settings-description', `Checking ${alias}…`)); + try { + renderEnrolChecklist(containerEl, await window.api.remoteHostEnrolCheck(alias)); + } catch (err) { + renderEnrolChecklist(containerEl, { ok: false, error: `check failed: ${err.message}` }); + } finally { + buttonEl.disabled = false; + } + }); +} + +if (typeof window !== 'undefined') { + window.renderEnrolChecklist = renderEnrolChecklist; + window.wireRemoteEnrolControls = wireRemoteEnrolControls; +} diff --git a/public/settings-panel.js b/public/settings-panel.js index 88e39d0b..66e433a7 100644 --- a/public/settings-panel.js +++ b/public/settings-panel.js @@ -436,13 +436,18 @@ + - `).join(''); + +
`).join(''); listEl.querySelectorAll('.remote-host-row').forEach(row => { const i = Number(row.dataset.i); row.querySelector('.rh-alias').addEventListener('input', e => { remoteHosts[i].alias = e.target.value; }); row.querySelector('.rh-label').addEventListener('input', e => { remoteHosts[i].label = e.target.value; }); row.querySelector('.rh-enabled').addEventListener('change', e => { remoteHosts[i].enabled = e.target.checked; }); + if (typeof wireRemoteEnrolControls === 'function') { + wireRemoteEnrolControls(row.querySelector('.rh-check'), listEl.querySelector(`.remote-host-enrol[data-i="${i}"]`), () => remoteHosts[i].alias); + } row.querySelector('.rh-remove').addEventListener('click', () => { remoteHosts.splice(i, 1); renderRemoteHosts(); diff --git a/public/style.css b/public/style.css index 16671508..bd31476d 100644 --- a/public/style.css +++ b/public/style.css @@ -4482,6 +4482,52 @@ body { display: flex; flex-direction: column; } min-width: 0; } +.remote-host-enrol:empty { + display: none; +} + +.remote-host-enrol { + margin: 0 0 10px 12px; + padding-left: 10px; + border-left: 2px solid var(--control-surface); +} + +.enrol-item { + margin-bottom: 8px; +} + +.enrol-head { + display: flex; + gap: 8px; + align-items: baseline; +} + +.enrol-status { + font-size: 11px; + text-transform: uppercase; + letter-spacing: 0.04em; +} + +.enrol-item[data-status="ok"] .enrol-status { color: #3ecf5a; } +.enrol-item[data-status="missing"] .enrol-status { color: #e5534b; } +.enrol-item[data-status="unknown"] .enrol-status { color: #d4a72c; } + +.enrol-command { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 8px; + margin-top: 4px; +} + +.enrol-command code { + user-select: all; +} + +.enrol-error { + color: #e5534b; +} + /* Terminal sessions: green status dot */ .session-item.is-terminal .session-icon.running { background: #3ecf5a; diff --git a/remote-enrol.js b/remote-enrol.js new file mode 100644 index 00000000..2c2737df --- /dev/null +++ b/remote-enrol.js @@ -0,0 +1,93 @@ +// remote-enrol.js — see .ai/contexts/session-cache.md ("Remote hosts — enrolment") +'use strict'; + +const { isValidAlias } = require('./remote-hosts'); + +const UNKNOWN_AUTH_TEXT = 'unknown — run `claude` on the host once to log in'; +const INSTALL_CLAUDE_COMMAND = 'curl -fsSL https://claude.ai/install.sh | bash'; +const INSTALL_TMUX_COMMAND = 'sudo apt install tmux'; +const LOGIN_COMMAND = 'claude auth login'; +const running = new Set(); +const NOT_CHECKED_UNREACHABLE = 'not checked: ssh did not connect'; + +function item(id, label, status, detail, command = null, where = 'host', optional = false) { + const out = { id, label, status, detail, command: command || null, where: command ? where : null }; + if (optional) out.optional = true; + return out; +} + +function unreachableItems(alias, detail) { + return [ + item('ssh', 'ssh reachable', 'missing', detail || 'ssh did not connect', `ssh -o BatchMode=yes ${alias} true`, 'workstation'), + item('claude', 'claude CLI', 'unknown', NOT_CHECKED_UNREACHABLE), + item('tmux', 'tmux', 'unknown', NOT_CHECKED_UNREACHABLE, null, 'host', true), + item('claude-dir', '~/.claude', 'unknown', NOT_CHECKED_UNREACHABLE), + item('auth', 'account logged in', 'unknown', NOT_CHECKED_UNREACHABLE), + ]; +} + +function noFactsItems(detail) { + const why = `${detail || 'no answer'} — only Linux hosts are checked`; + return [ + item('ssh', 'ssh reachable', 'ok', 'connected'), + item('claude', 'claude CLI', 'unknown', why), + item('tmux', 'tmux', 'unknown', why, null, 'host', true), + item('claude-dir', '~/.claude', 'unknown', why), + item('auth', 'account logged in', 'unknown', why), + ]; +} + +function authItem(alias, facts) { + if (!facts.claude) return item('auth', 'account logged in', 'unknown', 'needs the claude CLI first'); + if (facts.auth === true) { + return item('auth', 'account logged in', 'ok', 'claude auth status exits 0; Switchboard reads only that exit status, never the credentials'); + } + if (facts.auth === false) { + return item('auth', 'account logged in', 'missing', + `claude auth status exits 1: not logged in. Run this on the host, in a terminal there (for example after ssh -t ${alias}). Switchboard never copies or reads credentials.`, + LOGIN_COMMAND); + } + return item('auth', 'account logged in', 'unknown', UNKNOWN_AUTH_TEXT, 'claude'); +} + +function buildChecklist(alias, result) { + if (!result || result.reachable !== true) return unreachableItems(alias, result && result.detail); + const facts = result.facts; + if (!facts) return noFactsItems(result.detail); + return [ + item('ssh', 'ssh reachable', 'ok', 'connected'), + facts.claude + ? item('claude', 'claude CLI', 'ok', facts.claudeVersion ? `version ${facts.claudeVersion}` : 'present, version unreadable') + : item('claude', 'claude CLI', 'missing', + 'not found on the PATH of an ssh command. Install it on the host, or make it visible to non-interactive shells.', + INSTALL_CLAUDE_COMMAND), + facts.tmux + ? item('tmux', 'tmux', 'ok', 'installed', null, 'host', true) + : item('tmux', 'tmux', 'missing', + 'none: the host can be observed, but its sessions cannot be launched or attached from here. Other multiplexers are not supported.', + INSTALL_TMUX_COMMAND, 'host', true), + facts.claudeDir + ? item('claude-dir', '~/.claude', 'ok', 'present') + : item('claude-dir', '~/.claude', 'missing', 'absent: the CLI has never run for this user on the host.', LOGIN_COMMAND), + authItem(alias, facts), + ]; +} + +async function handleEnrolRequest(payload, deps) { + const alias = payload && payload.alias; + if (typeof alias !== 'string' || !isValidAlias(alias) || !deps.isDeclared(alias)) { + return { ok: false, error: 'not a declared host — save the settings first' }; + } + if (running.has(alias)) return { ok: false, error: `a check of ${alias} is already running` }; + running.add(alias); + try { + const result = await deps.transport.checkHost(alias); + return { ok: true, alias, items: buildChecklist(alias, result) }; + } catch (err) { + return { ok: false, error: `check failed: ${err.message}` }; + } finally { + running.delete(alias); + } +} + +module.exports = { buildChecklist, handleEnrolRequest, UNKNOWN_AUTH_TEXT, LOGIN_COMMAND }; diff --git a/remote-transport.js b/remote-transport.js index 9c4350b2..78366beb 100644 --- a/remote-transport.js +++ b/remote-transport.js @@ -18,7 +18,11 @@ const DEFAULT_CONNECT_TIMEOUT_S = 10; const DEFAULT_LIST_TIMEOUT_MS = 60_000; const DEFAULT_FETCH_TIMEOUT_MS = 120_000; const DEFAULT_PROBE_TIMEOUT_MS = 15_000; +const DEFAULT_ENROL_TIMEOUT_MS = 30_000; const MAX_PROBE_BYTES = 1024; +const MAX_ENROL_BYTES = 2048; +const MAX_ENROL_DETAIL = 200; +const SSH_CONNECT_FAILED_EXIT = 255; const MAX_LIST_BYTES = 8 * 1024 * 1024; const DEFAULT_CONCURRENCY = 4; // see .ai/contexts/session-cache.md ("Remote hosts — incremental fetch") @@ -59,6 +63,46 @@ function parseProbe(stdout) { return out; } +// see .ai/contexts/session-cache.md ("Remote hosts — enrolment") +const ENROL_COMMAND = PROBE_COMMAND + '; ' + + 'if command -v claude >/dev/null 2>&1; then echo claude=1; ' + + 'v=$(claude --version 2>/dev/null | head -n 1 | head -c 64); printf \'claude_version=%s\\n\' "$v"; else echo claude=0; fi; ' + + 'if [ -d "$HOME/.claude" ]; then echo claude_dir=1; else echo claude_dir=0; fi; ' + + 'auth=unknown; ' + + 'if command -v claude >/dev/null 2>&1 && [ -d "$HOME/.claude" ] && claude auth --help 2>/dev/null | grep -q \'^ status\'; then ' + + 'claude auth status >/dev/null 2>&1 l.replace(/\r$/, '')).filter(l => l !== ''); + const flag = (line, name) => (line === name + '=1' ? true : line === name + '=0' ? false : undefined); + let i = 0; + const tmux = flag(lines[i++], 'tmux'); + const inotifywait = flag(lines[i++], 'inotifywait'); + const claude = flag(lines[i++], 'claude'); + if (tmux === undefined || inotifywait === undefined || claude === undefined) return null; + let claudeVersion = null; + if (claude) { + const line = lines[i++]; + if (typeof line !== 'string' || !line.startsWith('claude_version=')) return null; + const value = line.slice('claude_version='.length); + claudeVersion = CLAUDE_VERSION_RE.test(value) ? value : null; + } + const claudeDir = flag(lines[i++], 'claude_dir'); + if (claudeDir === undefined) return null; + const authLine = lines[i++]; + const auth = authLine === 'auth=1' ? true : authLine === 'auth=0' ? false : authLine === 'auth=unknown' ? null : undefined; + if (auth === undefined || i !== lines.length) return null; + return { tmux, inotifywait, claude, claudeVersion, claudeDir, auth }; +} + +function oneLine(text) { + const first = String(text || '').split('\n').map(l => l.trim()).find(Boolean) || ''; + return first.replace(/[\u0000-\u001f\u007f]/g, '').slice(0, MAX_ENROL_DETAIL); +} + function parseInventory(stdout) { const out = []; for (const line of stdout.split('\n')) { @@ -151,6 +195,7 @@ function createSshTransport(opts = {}) { const log = opts.log || { info() {}, warn() {}, error() {} }; const listTimeoutMs = opts.listTimeoutMs || DEFAULT_LIST_TIMEOUT_MS; const probeTimeoutMs = opts.probeTimeoutMs || DEFAULT_PROBE_TIMEOUT_MS; + const enrolTimeoutMs = opts.enrolTimeoutMs || DEFAULT_ENROL_TIMEOUT_MS; const fetchTimeoutMs = opts.fetchTimeoutMs || DEFAULT_FETCH_TIMEOUT_MS; const concurrency = Math.max(1, Math.min(8, opts.concurrency || DEFAULT_CONCURRENCY)); @@ -268,6 +313,25 @@ function createSshTransport(opts = {}) { return tools; } + async function checkHost(alias) { + const res = await run(resolveSshPath(), [...SSH_BASE_OPTS, '-n', alias, ENROL_COMMAND], { + timeoutMs: enrolTimeoutMs, + maxBytes: MAX_ENROL_BYTES, + }); + if (res.timedOut) return { reachable: false, facts: null, detail: `ssh timed out after ${enrolTimeoutMs} ms` }; + if (res.truncated) return { reachable: true, facts: null, detail: 'the answer exceeded the size cap' }; + if (res.code === SSH_CONNECT_FAILED_EXIT || res.code === -1) { + return { reachable: false, facts: null, detail: oneLine(res.stderr) || `ssh failed (exit ${res.code})` }; + } + if (res.truncated) return { reachable: true, facts: null, detail: 'the answer exceeded the size cap' }; + if (res.code !== 0) { + return { reachable: true, facts: null, detail: `the check failed on the host (exit ${res.code}): ${oneLine(res.stderr) || 'no stderr'}` }; + } + const facts = parseEnrol(res.stdout); + if (!facts) return { reachable: true, facts: null, detail: 'the host returned unexpected output' }; + return { reachable: true, facts, detail: '' }; + } + async function fetchOne(alias, rel, destRoot) { const destPath = path.join(destRoot, rel); fs.mkdirSync(path.dirname(destPath), { recursive: true }); @@ -384,7 +448,7 @@ function createSshTransport(opts = {}) { cancelInFlight(); } - return { listFiles, probeTools, fetchFiles, fetchIncremental, cancelInFlight, dispose, liveCount: () => live.size }; + return { listFiles, probeTools, checkHost, fetchFiles, fetchIncremental, cancelInFlight, dispose, liveCount: () => live.size }; } module.exports = { @@ -396,6 +460,8 @@ module.exports = { LIST_COMMAND, PROBE_COMMAND, parseProbe, + ENROL_COMMAND, + parseEnrol, ALIVE_MARKER_PREFIX, REMOTE_PROJECTS_REL, REMOTE_SESSIONS_REL, diff --git a/test/dom-remote-enrol-panel.test.js b/test/dom-remote-enrol-panel.test.js new file mode 100644 index 00000000..83619f51 --- /dev/null +++ b/test/dom-remote-enrol-panel.test.js @@ -0,0 +1,122 @@ +'use strict'; + +// Issue #222: the checklist in the Settings host row. A status per item, and for +// each missing item the command to run, with a copy button. The DOM is built +// with textContent only: what the host answered is never parsed as HTML. + +const test = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); +const vm = require('node:vm'); +const { JSDOM } = require('jsdom'); + +const SRC = path.join(__dirname, '..', 'public', 'remote-enrol-panel.js'); + +function setup({ check, copied = [] } = {}) { + const dom = new JSDOM('
', { + url: 'http://localhost/', runScripts: 'outside-only', pretendToBeVisual: true, + }); + const { window } = dom; + window.api = { + remoteHostEnrolCheck: check || (async () => ({ ok: true, alias: 'vps', items: [] })), + writeClipboard: async (text) => { copied.push(text); return { ok: true }; }, + }; + vm.runInContext(fs.readFileSync(SRC, 'utf8'), dom.getInternalVMContext(), { filename: SRC }); + return { window, document: window.document, copied }; +} + +const ITEMS = [ + { id: 'ssh', label: 'ssh reachable', status: 'ok', detail: 'connected', command: null, where: null }, + { id: 'claude', label: 'claude CLI', status: 'missing', detail: 'not found', command: 'curl -fsSL https://claude.ai/install.sh | bash', where: 'host' }, + { id: 'tmux', label: 'tmux', status: 'unknown', detail: 'not checked', command: null, where: null, optional: true }, + { id: 'auth', label: 'account logged in', status: 'missing', detail: 'log in', command: 'claude auth login', where: 'host' }, + { id: 'ssh2', label: 'ssh again', status: 'missing', detail: 'no', command: 'ssh -o BatchMode=yes vps true', where: 'workstation' }, +]; + +test('renderEnrolChecklist shows a status per item and a copyable command only for items that carry one', async () => { + const { window, document, copied } = setup(); + const box = document.getElementById('row'); + window.renderEnrolChecklist(box, { ok: true, alias: 'vps', items: ITEMS }); + + const rows = [...box.querySelectorAll('.enrol-item')]; + assert.deepEqual(rows.map(r => r.dataset.status), ['ok', 'missing', 'unknown', 'missing', 'missing']); + assert.deepEqual(rows.map(r => r.querySelector('.enrol-status').textContent), ['ok', 'missing', 'unknown', 'missing', 'missing']); + assert.equal(rows[0].querySelector('.enrol-command'), null); + assert.equal(rows[2].querySelector('.enrol-command'), null); + + assert.equal(rows[1].querySelector('.enrol-command code').textContent, 'curl -fsSL https://claude.ai/install.sh | bash'); + assert.match(rows[1].querySelector('.enrol-where').textContent, /on the host/i); + assert.match(rows[4].querySelector('.enrol-where').textContent, /on this machine/i); + + rows[3].querySelector('.enrol-copy').click(); + await Promise.resolve(); + assert.deepEqual(copied, ['claude auth login']); +}); + +test('renderEnrolChecklist treats everything the host answered as text, never as markup', () => { + const { window, document } = setup(); + const box = document.getElementById('row'); + const hostile = ''; + window.renderEnrolChecklist(box, { + ok: true, alias: 'vps', + items: [{ id: 'ssh', label: hostile, status: 'missing', detail: hostile, command: hostile, where: 'host' }], + }); + assert.equal(box.querySelector('img'), null); + assert.ok(box.textContent.includes(hostile)); + assert.equal(window.pwned, undefined); +}); + +test('renderEnrolChecklist shows an error answer as one line and no checklist', () => { + const { window, document } = setup(); + const box = document.getElementById('row'); + window.renderEnrolChecklist(box, { ok: false, error: 'not a declared host — save the settings first' }); + assert.equal(box.querySelectorAll('.enrol-item').length, 0); + assert.match(box.querySelector('.enrol-error').textContent, /save the settings first/); +}); + +test('the Check host button asks main for the row alias, trimmed, and disables itself while it waits', async () => { + let release; + const gate = new Promise((res) => { release = res; }); + const asked = []; + const { window, document } = setup({ check: async (alias) => { asked.push(alias); await gate; return { ok: true, alias, items: ITEMS }; } }); + const row = document.getElementById('row'); + const btn = document.createElement('button'); + const box = document.createElement('div'); + row.append(btn, box); + window.wireRemoteEnrolControls(btn, box, () => ' vps '); + + btn.click(); + btn.click(); + assert.deepEqual(asked, ['vps']); + assert.equal(btn.disabled, true); + release(); + await new Promise(r => setImmediate(r)); + assert.equal(btn.disabled, false); + assert.equal(box.querySelectorAll('.enrol-item').length, ITEMS.length); +}); + +test('the Check host button does not call main for an empty alias', async () => { + const asked = []; + const { window, document } = setup({ check: async (a) => { asked.push(a); return { ok: true, items: [] }; } }); + const btn = document.createElement('button'); + const box = document.createElement('div'); + document.body.append(btn, box); + window.wireRemoteEnrolControls(btn, box, () => ' '); + btn.click(); + await new Promise(r => setImmediate(r)); + assert.deepEqual(asked, []); + assert.match(box.querySelector('.enrol-error').textContent, /alias/); +}); + +test('a rejected IPC call is shown as an error and re-enables the button', async () => { + const { window, document } = setup({ check: async () => { throw new Error('ipc gone'); } }); + const btn = document.createElement('button'); + const box = document.createElement('div'); + document.body.append(btn, box); + window.wireRemoteEnrolControls(btn, box, () => 'vps'); + btn.click(); + await new Promise(r => setImmediate(r)); + assert.equal(btn.disabled, false); + assert.match(box.querySelector('.enrol-error').textContent, /ipc gone/); +}); diff --git a/test/main-remote-enrol-wiring.test.js b/test/main-remote-enrol-wiring.test.js new file mode 100644 index 00000000..4b37120e --- /dev/null +++ b/test/main-remote-enrol-wiring.test.js @@ -0,0 +1,36 @@ +'use strict'; + +// Reads main.js, preload.js and the Settings panel as text: the IPC and the +// button are glue that no test can load. Behaviour lives in +// test/remote-enrol.test.js and test/dom-remote-enrol-panel.test.js. + +const test = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); + +const read = (f) => fs.readFileSync(path.join(__dirname, '..', f), 'utf8').replace(/\r\n/g, '\n'); +const mainSrc = read('main.js'); +const preloadSrc = read('preload.js'); +const settingsSrc = read('public/settings-panel.js'); +const indexSrc = read('public/index.html'); + +const at = mainSrc.indexOf("ipcMain.handle('remote-host-enrol-check'"); +const handler = at === -1 ? '' : mainSrc.slice(at, mainSrc.indexOf('\n});', at)); + +test('remote-host-enrol-check runs the check only for a host declared in the saved settings', () => { + assert.notEqual(at, -1, 'main.js should register remote-host-enrol-check'); + assert.match(handler, /handleEnrolRequest\(\{ alias \}, \{/); + assert.match(handler, /isDeclared: \(a\) => normalizeHosts\(\(getSetting\('global'\) \|\| \{\}\)\.remoteHosts\)\.some\(h => h\.alias === a\)/); + assert.match(handler, /transport: remoteTransport/); +}); + +test('the preload exposes the check with the alias as its only argument', () => { + assert.match(preloadSrc, /remoteHostEnrolCheck: \(alias\) => ipcRenderer\.invoke\('remote-host-enrol-check', alias\)/); +}); + +test('the Settings panel shows the checklist control for each host row and the script is loaded', () => { + assert.match(settingsSrc, /wireRemoteEnrolControls\(/); + assert.match(indexSrc, /