From 73490d331179cb640828b8625abb75208cd7ae5f Mon Sep 17 00:00:00 2001 From: Christophe Besson Date: Fri, 21 Aug 2026 17:50:08 +0200 Subject: feat: D.6 first-run wizard — detect, start, link, group, gek-init, pair MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Desktop client guides new users through the full onboarding sequence without a terminal: detect local node → start daemon (systemd with direct-start fallback for dev) → link node key to hub → create group → attach directories → gek-init → operator pair. - node:detect returns configured field to distinguish missing vs stopped - node:start tries systemctl first, checks is-active, falls back to spawning the binary directly when the unit crashes (dev mode) - probeNode() extracts the shared config→token→fetch pattern - SetupWelcome on empty HomePage links to /create-group - CreateGroupWizard step 0 handles node detection and start - platform.js exposes node.installed() and node.start() - 12 setup.* i18n keys added to all 10 locales - Integration test for node:start fallback logic Co-Authored-By: Claude Opus 4.6 --- packages/meshbay-client/src/main.js | 114 +++++++++- packages/meshbay-client/src/preload.js | 2 + packages/meshbay-client/test/test-node-start.js | 268 ++++++++++++++++++++++++ 3 files changed, 380 insertions(+), 4 deletions(-) create mode 100644 packages/meshbay-client/test/test-node-start.js (limited to 'packages/meshbay-client') diff --git a/packages/meshbay-client/src/main.js b/packages/meshbay-client/src/main.js index 45e8462..22e0162 100644 --- a/packages/meshbay-client/src/main.js +++ b/packages/meshbay-client/src/main.js @@ -22,6 +22,7 @@ const { app, BrowserWindow, dialog, ipcMain, protocol, safeStorage, shell } = require('electron'); +const { execFile, spawn } = require('node:child_process'); const crypto = require('node:crypto'); const fs = require('node:fs'); const fsp = require('node:fs/promises'); @@ -691,27 +692,132 @@ function registerBridge() { ipcMain.handle('node:detect', async () => { const nc = readNodeConfig(); - if (!nc) return { detected: false }; + if (!nc) return { detected: false, configured: false }; const token = readNodeToken(nc.dataDir); - if (!token) return { detected: false }; + if (!token) return { detected: false, configured: true }; _nodeToken = token; _nodePort = nc.uiPort; try { const r = await fetch( `http://127.0.0.1:${_nodePort}/api/status?t=${_nodeToken}`, { signal: AbortSignal.timeout(3000) }); - if (!r.ok) return { detected: false }; + if (!r.ok) return { detected: false, configured: true }; const status = await r.json(); return { detected: true, + configured: true, status: status.status, pk_node_ed25519: status.pk_node_ed25519 || '', }; } catch { - return { detected: false }; + return { detected: false, configured: true }; } }); + ipcMain.handle('node:installed', async () => { + if (process.platform !== 'linux') return { installed: false }; + return new Promise((resolve) => { + execFile('systemctl', ['--user', 'show', 'meshbay-node.service', + '--property=LoadState'], (err, stdout) => { + if (err) return resolve({ installed: false }); + resolve({ installed: stdout.trim() === 'LoadState=loaded' }); + }); + }); + }); + + async function probeNode() { + const nc = readNodeConfig(); + const dataDir = nc ? nc.dataDir + : path.join(os.homedir(), '.local', 'share', 'meshbay'); + const port = nc ? nc.uiPort : 18000; + const token = readNodeToken(dataDir); + if (!token) return null; + try { + const r = await fetch( + `http://127.0.0.1:${port}/api/status?t=${token}`, + { signal: AbortSignal.timeout(3000) }); + if (!r.ok) return null; + const status = await r.json(); + _nodeToken = token; + _nodePort = port; + return { pk_node_ed25519: status.pk_node_ed25519 || '' }; + } catch { return null; } + } + + ipcMain.handle('node:start', async () => { + const already = await probeNode(); + if (already) return { started: true, ...already }; + + if (process.platform !== 'linux') { + throw new Error('Automatic node start is only supported on Linux'); + } + + const deadline = Date.now() + 15000; + const configFile = nodeConfigPath(); + let launched = false; + + // Try systemctl first — the production path. + try { + await new Promise((resolve, reject) => { + execFile('systemctl', ['--user', 'enable', '--now', 'meshbay-node'], + (err, _stdout, stderr) => { + if (err) return reject(new Error(stderr.trim() || err.message)); + resolve(); + }); + }); + // Give the service a moment, then check if it actually stayed up. + for (let i = 0; i < 6 && Date.now() < deadline; i++) { + await new Promise((r) => setTimeout(r, 500)); + const result = await probeNode(); + if (result) return { started: true, ...result }; + } + // The unit may be stuck in auto-restart (e.g. ExecStart points to + // /usr/bin which doesn't exist in dev). is-active returns 0 only when + // the service is genuinely running. + const isActive = await new Promise((resolve) => { + execFile('systemctl', ['--user', 'is-active', 'meshbay-node'], + (err) => resolve(!err)); + }); + if (isActive) { + launched = true; + } else { + // Stop the broken unit so it doesn't compete with the direct start. + await new Promise((resolve) => { + execFile('systemctl', ['--user', 'stop', 'meshbay-node'], + () => resolve()); + }); + } + } catch { + // systemctl itself failed (e.g. no unit file). + } + + // Fallback: start the binary directly (dev mode, or no unit installed). + if (!launched) { + const binPath = await new Promise((resolve) => { + execFile('which', ['meshbay-node'], (err, stdout) => { + resolve(err ? null : stdout.trim()); + }); + }); + if (!binPath) { + throw new Error( + 'meshbay-node is not installed'); + } + const child = spawn(binPath, ['--config', configFile], { + detached: true, + stdio: 'ignore', + }); + child.unref(); + } + + while (Date.now() < deadline) { + await new Promise((r) => setTimeout(r, 500)); + const result = await probeNode(); + if (result) return { started: true, ...result }; + } + throw new Error( + 'meshbay-node was started but did not become ready within 15 seconds'); + }); + ipcMain.handle('node:call', async (_e, method, apiPath, body) => { if (!_nodeToken) throw new Error('Node not detected'); const sep = apiPath.includes('?') ? '&' : '?'; diff --git a/packages/meshbay-client/src/preload.js b/packages/meshbay-client/src/preload.js index e9fd375..c8553a5 100644 --- a/packages/meshbay-client/src/preload.js +++ b/packages/meshbay-client/src/preload.js @@ -86,6 +86,8 @@ contextBridge.exposeInMainWorld('meshbay', { // pattern as hub:fetch. node: { detect: () => ipcRenderer.invoke('node:detect'), + installed: () => ipcRenderer.invoke('node:installed'), + start: () => ipcRenderer.invoke('node:start'), call: (method, path, body) => ipcRenderer.invoke('node:call', method, path, body), pairingCode: () => ipcRenderer.invoke('node:pairing-code'), setPairingCode: (code) => ipcRenderer.invoke('node:set-pairing-code', code), diff --git a/packages/meshbay-client/test/test-node-start.js b/packages/meshbay-client/test/test-node-start.js new file mode 100644 index 0000000..1e91419 --- /dev/null +++ b/packages/meshbay-client/test/test-node-start.js @@ -0,0 +1,268 @@ +#!/usr/bin/env node +/** + * Integration test for the node:start logic in main.js. + * + * Exercises each step of the startup sequence on the real system: + * 1. probeNode — can we reach a running daemon? + * 2. systemctl enable --now — does the unit start the daemon? + * 3. is-active check — did the unit crash (e.g. wrong ExecStart path)? + * 4. direct start fallback — find binary in PATH, spawn it + * 5. probeNode poll — does the daemon respond after direct start? + * + * Run: node test/test-node-start.js + * + * The test leaves the daemon running on success so subsequent Electron + * testing can pick it up. Pass --cleanup to kill it after the test. + */ + +'use strict'; + +const { execFile, spawn } = require('node:child_process'); +const fs = require('node:fs'); +const os = require('node:os'); +const path = require('node:path'); + +const CLEANUP = process.argv.includes('--cleanup'); + +let passed = 0; +let failed = 0; + +function ok(label) { passed++; console.log(` \x1b[32m✓\x1b[0m ${label}`); } +function fail(label, detail) { + failed++; + console.log(` \x1b[31m✗\x1b[0m ${label}`); + if (detail) console.log(` ${detail}`); +} +function info(msg) { console.log(` \x1b[36mℹ\x1b[0m ${msg}`); } + +// ── helpers (same logic as main.js) ────────────────────────────────────────── + +function nodeConfigPath() { + return path.join(os.homedir(), '.config', 'meshbay', 'node.toml'); +} + +function readNodeConfig() { + try { + const text = fs.readFileSync(nodeConfigPath(), 'utf8'); + let dataDir = path.join(os.homedir(), '.local', 'share', 'meshbay'); + let uiPort = 18000; + const dataMatch = text.match(/^\s*data_dir\s*=\s*"([^"]+)"/m); + if (dataMatch) dataDir = dataMatch[1].replace(/^~/, os.homedir()); + const portMatch = text.match(/^\s*ui_port\s*=\s*(\d+)/m); + if (portMatch) uiPort = parseInt(portMatch[1], 10); + return { dataDir, uiPort }; + } catch { return null; } +} + +function readNodeToken(dataDir) { + try { + return fs.readFileSync(path.join(dataDir, 'ui-token'), 'utf8').trim(); + } catch { return null; } +} + +async function probeNode() { + const nc = readNodeConfig(); + const dataDir = nc ? nc.dataDir + : path.join(os.homedir(), '.local', 'share', 'meshbay'); + const port = nc ? nc.uiPort : 18000; + const token = readNodeToken(dataDir); + if (!token) return null; + try { + const r = await fetch( + `http://127.0.0.1:${port}/api/status?t=${token}`, + { signal: AbortSignal.timeout(3000) }); + if (!r.ok) return null; + const status = await r.json(); + return { port, pk_node_ed25519: status.pk_node_ed25519 || '' }; + } catch { return null; } +} + +function exec(cmd, args) { + return new Promise((resolve) => { + execFile(cmd, args, (err, stdout, stderr) => { + resolve({ err, stdout: stdout?.trim(), stderr: stderr?.trim() }); + }); + }); +} + +async function pollProbe(seconds) { + const deadline = Date.now() + seconds * 1000; + while (Date.now() < deadline) { + await new Promise((r) => setTimeout(r, 500)); + const result = await probeNode(); + if (result) return result; + } + return null; +} + +// ── kill any running daemon ────────────────────────────────────────────────── + +async function killDaemon() { + const { stdout } = await exec('pgrep', ['-f', 'meshbay.node.daemon']); + if (stdout) { + for (const pid of stdout.split('\n').filter(Boolean)) { + try { process.kill(Number(pid), 'SIGTERM'); } catch {} + } + await new Promise((r) => setTimeout(r, 1000)); + } + await exec('systemctl', ['--user', 'stop', 'meshbay-node']); +} + +// ── tests ──────────────────────────────────────────────────────────────────── + +async function main() { + console.log('\nnode:start integration tests\n'); + + // ── Prerequisites ── + console.log('Prerequisites:'); + + const configFile = nodeConfigPath(); + if (fs.existsSync(configFile)) { + ok(`Config exists: ${configFile}`); + } else { + fail(`Config missing: ${configFile}`); + process.exit(1); + } + + const nc = readNodeConfig(); + info(`dataDir = ${nc.dataDir}`); + info(`uiPort = ${nc.uiPort}`); + + const { stdout: binPath } = await exec('which', ['meshbay-node']); + if (binPath) { + ok(`Binary found: ${binPath}`); + } else { + fail('meshbay-node not in PATH'); + process.exit(1); + } + + const unitExists = fs.existsSync( + path.join(os.homedir(), '.config', 'systemd', 'user', 'meshbay-node.service')); + info(`systemd unit installed: ${unitExists}`); + + if (unitExists) { + const { stdout: execStart } = await exec('systemctl', [ + '--user', 'show', 'meshbay-node', '--property=ExecStart']); + const execPath = execStart?.match(/path=([^ ;]+)/)?.[1] || '?'; + const execExists = fs.existsSync(execPath); + if (execExists) { + ok(`Unit ExecStart binary exists: ${execPath}`); + } else { + info(`Unit ExecStart binary MISSING: ${execPath} (fallback will be needed)`); + } + } + + // ── Test 1: probeNode when daemon is stopped ── + console.log('\n1. probeNode with daemon stopped:'); + await killDaemon(); + const probeDown = await probeNode(); + if (!probeDown) { + ok('probeNode returns null when daemon is stopped'); + } else { + fail('probeNode returned a result but daemon should be stopped', JSON.stringify(probeDown)); + } + + // ── Test 2: systemctl enable --now ── + if (unitExists) { + console.log('\n2. systemctl enable --now:'); + const { err: sysErr, stderr: sysStderr } = await exec( + 'systemctl', ['--user', 'enable', '--now', 'meshbay-node']); + if (sysErr) { + info(`systemctl returned error: ${sysStderr || sysErr.message}`); + } else { + ok('systemctl enable --now returned 0'); + } + + // Wait a moment, then check + await new Promise((r) => setTimeout(r, 2000)); + + const { err: activeErr, stdout: activeState } = await exec( + 'systemctl', ['--user', 'is-active', 'meshbay-node']); + info(`is-active: "${activeState}" (exit ${activeErr ? activeErr.code : 0})`); + + const isActive = !activeErr; + if (isActive) { + ok('Service is active'); + const probeUp = await pollProbe(5); + if (probeUp) { + ok(`probeNode succeeds (pk: ${probeUp.pk_node_ed25519?.slice(0, 16)}...)`); + } else { + fail('probeNode returned null despite service being active'); + } + } else { + info('Service is NOT active — this is expected in dev mode (ExecStart mismatch)'); + + // Verify is-failed vs is-active difference (the bug we fixed) + const { err: failedErr, stdout: failedState } = await exec( + 'systemctl', ['--user', 'is-failed', 'meshbay-node']); + info(`is-failed: "${failedState}" (exit ${failedErr ? failedErr.code : 0})`); + const isFailed = !failedErr; + if (!isFailed && activeState === 'activating') { + ok('Confirmed bug scenario: is-failed=false while is-active=activating (auto-restart loop)'); + info('Old code would NOT fall back; new code uses is-active and DOES fall back'); + } + + // Stop the broken unit + await exec('systemctl', ['--user', 'stop', 'meshbay-node']); + await exec('systemctl', ['--user', 'disable', 'meshbay-node']); + ok('Stopped and disabled broken unit'); + } + } else { + console.log('\n2. systemctl (skipped — no unit file)'); + } + + // ── Test 3: Direct start fallback ── + console.log('\n3. Direct start fallback:'); + await killDaemon(); + await new Promise((r) => setTimeout(r, 500)); + + const child = spawn(binPath, ['--config', configFile], { + detached: true, + stdio: 'ignore', + }); + child.unref(); + ok(`Spawned ${binPath} --config ${configFile} (pid ${child.pid})`); + + const probeAfter = await pollProbe(15); + if (probeAfter) { + ok(`probeNode succeeds after direct start (pk: ${probeAfter.pk_node_ed25519?.slice(0, 16)}...)`); + } else { + fail('probeNode returned null after 15 seconds of polling'); + // Debug info + const token = readNodeToken(nc.dataDir); + info(`Token file present: ${!!token}`); + const { stdout: pgrep } = await exec('pgrep', ['-af', 'meshbay']); + info(`meshbay processes: ${pgrep || 'none'}`); + } + + // ── Test 4: probeNode when daemon is already running ── + console.log('\n4. probeNode when daemon already running:'); + if (probeAfter) { + const probeAgain = await probeNode(); + if (probeAgain) { + ok('probeNode returns immediately for running daemon'); + } else { + fail('probeNode returned null for running daemon'); + } + } else { + info('Skipped — daemon did not start'); + } + + // ── Cleanup ── + if (CLEANUP) { + console.log('\nCleanup:'); + await killDaemon(); + ok('Daemon stopped'); + } else { + info('\nDaemon left running for Electron testing. Pass --cleanup to stop it.'); + } + + // ── Summary ── + console.log(`\n${passed} passed, ${failed} failed\n`); + process.exit(failed > 0 ? 1 : 0); +} + +main().catch((err) => { + console.error(err); + process.exit(1); +}); -- cgit v1.2.3