openclaw-node-harness 2.0.0 → 2.0.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,733 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * openclaw-node-init.js — Zero-config mesh node provisioner.
5
+ *
6
+ * Tailscale IS the trust layer. No tokens. No secrets. No lead interaction.
7
+ *
8
+ * What it does:
9
+ * 1. Scan Tailscale peers for NATS (port 4222)
10
+ * 2. OS detection (macOS/Linux)
11
+ * 3. Dependency checks (Node.js, git)
12
+ * 4. Directory structure (~/.openclaw/)
13
+ * 5. NATS configuration (auto-discovered)
14
+ * 6. Mesh code installation (git clone)
15
+ * 7. Service installation (launchd/systemd)
16
+ * 8. Health verification (service alive + NATS connectivity)
17
+ *
18
+ * Usage:
19
+ * npx openclaw-node # auto-discover everything
20
+ * node bin/openclaw-node-init.js # same
21
+ * node bin/openclaw-node-init.js --nats nats://x:4222 # explicit NATS URL
22
+ * node bin/openclaw-node-init.js --provider deepseek # set default LLM
23
+ * node bin/openclaw-node-init.js --dry-run # preview only
24
+ */
25
+
26
+ const { execSync, spawnSync } = require('child_process');
27
+ const fs = require('fs');
28
+ const net = require('net');
29
+ const os = require('os');
30
+ const path = require('path');
31
+
32
+ // ── CLI args ──────────────────────────────────────────
33
+
34
+ const args = process.argv.slice(2);
35
+ const DRY_RUN = args.includes('--dry-run');
36
+
37
+ function getArg(flag, defaultVal) {
38
+ const idx = args.indexOf(flag);
39
+ return idx >= 0 && args[idx + 1] ? args[idx + 1] : defaultVal;
40
+ }
41
+
42
+ const NATS_OVERRIDE = getArg('--nats', null);
43
+ const PROVIDER_OVERRIDE = getArg('--provider', 'claude');
44
+ const REPO = getArg('--repo', 'https://github.com/moltyguibros-design/openclaw-node.git');
45
+ const SSH_PUBKEY = getArg('--ssh-key', null);
46
+
47
+ // ── Logging ───────────────────────────────────────────
48
+
49
+ const BOLD = '\x1b[1m';
50
+ const GREEN = '\x1b[32m';
51
+ const RED = '\x1b[31m';
52
+ const YELLOW = '\x1b[33m';
53
+ const CYAN = '\x1b[36m';
54
+ const RESET = '\x1b[0m';
55
+
56
+ function log(msg) { console.log(`${CYAN}[mesh-init]${RESET} ${msg}`); }
57
+ function ok(msg) { console.log(`${GREEN} ✓${RESET} ${msg}`); }
58
+ function warn(msg){ console.log(`${YELLOW} ⚠${RESET} ${msg}`); }
59
+ function fail(msg){ console.error(`${RED} ✗${RESET} ${msg}`); }
60
+ function step(n, msg) { console.log(`\n${BOLD}[${n}]${RESET} ${msg}`); }
61
+
62
+ // ── TCP Port Probe (pure Node, no nc dependency) ────
63
+
64
+ function probePort(ip, port) {
65
+ return new Promise((resolve) => {
66
+ const sock = new net.Socket();
67
+ sock.setTimeout(2000);
68
+ sock.on('connect', () => { sock.destroy(); resolve(true); });
69
+ sock.on('error', () => resolve(false));
70
+ sock.on('timeout', () => { sock.destroy(); resolve(false); });
71
+ sock.connect(port, ip);
72
+ });
73
+ }
74
+
75
+ // ── Tailscale NATS Discovery ─────────────────────────
76
+
77
+ async function discoverNats() {
78
+ if (NATS_OVERRIDE) {
79
+ ok(`Using explicit NATS URL: ${NATS_OVERRIDE}`);
80
+ return NATS_OVERRIDE;
81
+ }
82
+
83
+ // Check existing config first
84
+ const envPath = path.join(os.homedir(), '.openclaw', 'openclaw.env');
85
+ if (fs.existsSync(envPath)) {
86
+ const content = fs.readFileSync(envPath, 'utf8');
87
+ const match = content.match(/^\s*OPENCLAW_NATS\s*=\s*(.+)/m);
88
+ if (match) {
89
+ const existing = match[1].trim();
90
+ ok(`Found existing NATS config: ${existing}`);
91
+ return existing;
92
+ }
93
+ }
94
+
95
+ // Scan Tailscale peers
96
+ log('Scanning Tailscale network for NATS...');
97
+
98
+ let tsStatus;
99
+ try {
100
+ tsStatus = JSON.parse(execSync('tailscale status --json', { encoding: 'utf8', timeout: 10000 }));
101
+ } catch (e) {
102
+ fail('Tailscale not available or not connected.');
103
+ fail('Install Tailscale and join your network, or use --nats nats://host:4222');
104
+ process.exit(1);
105
+ }
106
+
107
+ // Collect all peer IPs (including self)
108
+ const candidates = [];
109
+
110
+ // Add self
111
+ if (tsStatus.Self && tsStatus.Self.TailscaleIPs) {
112
+ for (const ip of tsStatus.Self.TailscaleIPs) {
113
+ if (ip.includes('.')) candidates.push(ip); // IPv4 only
114
+ }
115
+ }
116
+
117
+ // Add peers
118
+ if (tsStatus.Peer) {
119
+ for (const [, peer] of Object.entries(tsStatus.Peer)) {
120
+ if (peer.TailscaleIPs) {
121
+ for (const ip of peer.TailscaleIPs) {
122
+ if (ip.includes('.')) candidates.push(ip); // IPv4 only
123
+ }
124
+ }
125
+ }
126
+ }
127
+
128
+ if (candidates.length === 0) {
129
+ fail('No Tailscale peers found. Is Tailscale connected?');
130
+ fail('Run: tailscale up');
131
+ process.exit(1);
132
+ }
133
+
134
+ log(`Found ${candidates.length} Tailscale IPs. Probing port 4222...`);
135
+
136
+ for (const ip of candidates) {
137
+ if (await probePort(ip, 4222)) {
138
+ const natsUrl = `nats://${ip}:4222`;
139
+ ok(`NATS found at ${natsUrl}`);
140
+ return natsUrl;
141
+ }
142
+ }
143
+
144
+ fail('No NATS server found on any Tailscale peer (port 4222).');
145
+ fail('Ensure NATS is running on your lead node, or use --nats nats://host:4222');
146
+ process.exit(1);
147
+ }
148
+
149
+ // ── OS Detection ──────────────────────────────────────
150
+
151
+ function detectOS() {
152
+ const platform = os.platform();
153
+ const arch = os.arch();
154
+ const release = os.release();
155
+
156
+ if (platform === 'darwin') return { os: 'macos', serviceType: 'launchd', arch, release };
157
+ if (platform === 'linux') return { os: 'linux', serviceType: 'systemd', arch, release };
158
+ fail(`Unsupported platform: ${platform}. OpenClaw mesh requires macOS or Linux.`);
159
+ process.exit(1);
160
+ }
161
+
162
+ // ── Dependency Checks ─────────────────────────────────
163
+
164
+ function checkCommand(cmd) {
165
+ try {
166
+ execSync(`which ${cmd}`, { stdio: 'pipe' });
167
+ return true;
168
+ } catch {
169
+ return false;
170
+ }
171
+ }
172
+
173
+ function getNodeVersion() {
174
+ try {
175
+ const v = execSync('node --version', { encoding: 'utf8' }).trim();
176
+ const major = parseInt(v.replace('v', '').split('.')[0]);
177
+ return { version: v, major };
178
+ } catch {
179
+ return null;
180
+ }
181
+ }
182
+
183
+ function checkDependencies() {
184
+ const deps = [];
185
+ const missing = [];
186
+
187
+ const nodeInfo = getNodeVersion();
188
+ if (nodeInfo && nodeInfo.major >= 18) {
189
+ deps.push({ name: 'Node.js', status: 'ok', detail: nodeInfo.version });
190
+ } else if (nodeInfo) {
191
+ deps.push({ name: 'Node.js', status: 'upgrade', detail: `${nodeInfo.version} (need 18+)` });
192
+ missing.push('node');
193
+ } else {
194
+ deps.push({ name: 'Node.js', status: 'missing', detail: '' });
195
+ missing.push('node');
196
+ }
197
+
198
+ if (checkCommand('git')) {
199
+ const v = execSync('git --version', { encoding: 'utf8' }).trim();
200
+ deps.push({ name: 'Git', status: 'ok', detail: v });
201
+ } else {
202
+ deps.push({ name: 'Git', status: 'missing', detail: '' });
203
+ missing.push('git');
204
+ }
205
+
206
+ if (checkCommand('tailscale')) {
207
+ deps.push({ name: 'Tailscale', status: 'ok', detail: 'installed' });
208
+ } else {
209
+ deps.push({ name: 'Tailscale', status: 'missing', detail: 'required for mesh discovery' });
210
+ if (!NATS_OVERRIDE) missing.push('tailscale');
211
+ }
212
+
213
+ return { deps, missing };
214
+ }
215
+
216
+ function installMissing(missing, osInfo) {
217
+ if (missing.length === 0) return;
218
+
219
+ log(`Installing missing dependencies: ${missing.join(', ')}`);
220
+
221
+ if (DRY_RUN) {
222
+ warn('[DRY RUN] Would install: ' + missing.join(', '));
223
+ return;
224
+ }
225
+
226
+ if (osInfo.os === 'macos') {
227
+ if (!checkCommand('brew')) {
228
+ fail('Homebrew not found. Install it first: https://brew.sh');
229
+ process.exit(1);
230
+ }
231
+ for (const dep of missing) {
232
+ const pkg = dep === 'node' ? 'node@22' : dep;
233
+ log(` brew install ${pkg}`);
234
+ spawnSync('brew', ['install', pkg], { stdio: 'inherit' });
235
+ }
236
+ } else {
237
+ const pm = checkCommand('apt-get') ? 'apt-get'
238
+ : checkCommand('yum') ? 'yum'
239
+ : checkCommand('dnf') ? 'dnf'
240
+ : null;
241
+
242
+ if (!pm) {
243
+ fail('No supported package manager found (need apt, yum, or dnf)');
244
+ process.exit(1);
245
+ }
246
+
247
+ for (const dep of missing) {
248
+ if (dep === 'node') {
249
+ log(' Installing Node.js 22.x via NodeSource...');
250
+ try {
251
+ execSync('curl -fsSL https://deb.nodesource.com/setup_22.x | sudo -E bash -', { stdio: 'inherit' });
252
+ execSync(`sudo ${pm} install -y nodejs`, { stdio: 'inherit' });
253
+ } catch (e) {
254
+ fail(`Node.js installation failed: ${e.message}`);
255
+ process.exit(1);
256
+ }
257
+ } else if (dep === 'tailscale') {
258
+ log(' Installing Tailscale...');
259
+ try {
260
+ execSync('curl -fsSL https://tailscale.com/install.sh | sh', { stdio: 'inherit' });
261
+ } catch (e) {
262
+ fail(`Tailscale installation failed: ${e.message}`);
263
+ fail('Install manually: https://tailscale.com/download');
264
+ process.exit(1);
265
+ }
266
+ } else {
267
+ log(` sudo ${pm} install -y ${dep}`);
268
+ spawnSync('sudo', [pm, 'install', '-y', dep], { stdio: 'inherit' });
269
+ }
270
+ }
271
+ }
272
+ }
273
+
274
+ // ── Directory Setup ───────────────────────────────────
275
+
276
+ function setupDirectories() {
277
+ const home = os.homedir();
278
+ const dirs = [
279
+ path.join(home, '.openclaw'),
280
+ path.join(home, '.openclaw', 'workspace'),
281
+ path.join(home, '.openclaw', 'workspace', '.tmp'),
282
+ path.join(home, '.openclaw', 'workspace', 'memory'),
283
+ path.join(home, '.openclaw', 'worktrees'),
284
+ path.join(home, '.openclaw', 'config'),
285
+ ];
286
+
287
+ for (const dir of dirs) {
288
+ if (!fs.existsSync(dir)) {
289
+ if (DRY_RUN) {
290
+ warn(`[DRY RUN] Would create: ${dir}`);
291
+ } else {
292
+ fs.mkdirSync(dir, { recursive: true });
293
+ ok(`Created: ${dir}`);
294
+ }
295
+ } else {
296
+ ok(`Exists: ${dir}`);
297
+ }
298
+ }
299
+ }
300
+
301
+ // ── SSH Key Provisioning ─────────────────────────────
302
+
303
+ function provisionSSHKey(pubkey) {
304
+ if (!pubkey) return;
305
+
306
+ const sshDir = path.join(os.homedir(), '.ssh');
307
+ const authKeysPath = path.join(sshDir, 'authorized_keys');
308
+
309
+ if (DRY_RUN) {
310
+ warn(`[DRY RUN] Would add SSH key to ${authKeysPath}`);
311
+ return;
312
+ }
313
+
314
+ if (!fs.existsSync(sshDir)) {
315
+ fs.mkdirSync(sshDir, { mode: 0o700 });
316
+ ok(`Created ${sshDir}`);
317
+ } else {
318
+ try { fs.chmodSync(sshDir, 0o700); } catch { /* best effort */ }
319
+ }
320
+
321
+ let existing = '';
322
+ if (fs.existsSync(authKeysPath)) {
323
+ existing = fs.readFileSync(authKeysPath, 'utf8');
324
+ const keyParts = pubkey.trim().split(/\s+/);
325
+ const keyFingerprint = keyParts.length >= 2 ? `${keyParts[0]} ${keyParts[1]}` : pubkey.trim();
326
+ if (existing.includes(keyFingerprint)) {
327
+ ok('SSH key already authorized');
328
+ return;
329
+ }
330
+ }
331
+
332
+ const entry = existing.endsWith('\n') || existing === ''
333
+ ? `${pubkey.trim()}\n`
334
+ : `\n${pubkey.trim()}\n`;
335
+ fs.appendFileSync(authKeysPath, entry, { mode: 0o600 });
336
+ try { fs.chmodSync(authKeysPath, 0o600); } catch { /* best effort */ }
337
+
338
+ ok('SSH key added to authorized_keys');
339
+ }
340
+
341
+ // ── NATS Configuration ───────────────────────────────
342
+
343
+ function configureNats(natsUrl) {
344
+ const envPath = path.join(os.homedir(), '.openclaw', 'openclaw.env');
345
+
346
+ if (DRY_RUN) {
347
+ warn(`[DRY RUN] Would write NATS URL to ${envPath}`);
348
+ return;
349
+ }
350
+
351
+ let content = '';
352
+ if (fs.existsSync(envPath)) {
353
+ content = fs.readFileSync(envPath, 'utf8');
354
+ if (content.match(/^\s*OPENCLAW_NATS\s*=/m)) {
355
+ content = content.replace(/^\s*OPENCLAW_NATS\s*=.*/m, `OPENCLAW_NATS=${natsUrl}`);
356
+ } else {
357
+ content += `\nOPENCLAW_NATS=${natsUrl}\n`;
358
+ }
359
+ } else {
360
+ content = `# OpenClaw Mesh Configuration\n# Generated by openclaw-node-init.js\nOPENCLAW_NATS=${natsUrl}\n`;
361
+ }
362
+
363
+ fs.writeFileSync(envPath, content, { mode: 0o600 });
364
+ ok(`NATS URL configured: ${natsUrl}`);
365
+ }
366
+
367
+ // ── Mesh Code Installation ───────────────────────────
368
+
369
+ function installMeshCode(repoUrl) {
370
+ const meshDir = path.join(os.homedir(), 'openclaw');
371
+
372
+ if (fs.existsSync(path.join(meshDir, 'package.json'))) {
373
+ ok(`Mesh code exists at ${meshDir}`);
374
+ if (!DRY_RUN) {
375
+ log(' Pulling latest + installing deps...');
376
+ spawnSync('git', ['pull', '--ff-only'], { cwd: meshDir, stdio: 'pipe' });
377
+ spawnSync('npm', ['install', '--production'], { cwd: meshDir, stdio: 'pipe' });
378
+ ok('Updated');
379
+ }
380
+ return meshDir;
381
+ }
382
+
383
+ if (DRY_RUN) {
384
+ warn(`[DRY RUN] Would clone ${repoUrl} to ${meshDir}`);
385
+ return meshDir;
386
+ }
387
+
388
+ log(`Cloning mesh code from ${repoUrl}...`);
389
+ try {
390
+ execSync(`git clone "${repoUrl}" "${meshDir}"`, { stdio: 'inherit', timeout: 60000 });
391
+ spawnSync('npm', ['install', '--production'], { cwd: meshDir, stdio: 'pipe' });
392
+ ok('Mesh code installed');
393
+ } catch (e) {
394
+ fail(`Failed to clone mesh code: ${e.message}`);
395
+ process.exit(1);
396
+ }
397
+
398
+ return meshDir;
399
+ }
400
+
401
+ // ── Service Installation ─────────────────────────────
402
+
403
+ function installService(osInfo, meshDir, config) {
404
+ const nodeId = os.hostname().toLowerCase().replace(/[^a-z0-9-]/g, '-');
405
+ const nodeBin = process.execPath;
406
+ const provider = config.provider;
407
+
408
+ if (osInfo.serviceType === 'launchd') {
409
+ return installLaunchdService(meshDir, nodeBin, nodeId, provider, config.nats);
410
+ } else {
411
+ return installSystemdService(meshDir, nodeBin, nodeId, provider, config.nats);
412
+ }
413
+ }
414
+
415
+ function installLaunchdService(meshDir, nodeBin, nodeId, provider, natsUrl) {
416
+ const plistDir = path.join(os.homedir(), 'Library', 'LaunchAgents');
417
+ const plistPath = path.join(plistDir, 'ai.openclaw.mesh-agent.plist');
418
+
419
+ const plist = `<?xml version="1.0" encoding="UTF-8"?>
420
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
421
+ <plist version="1.0">
422
+ <dict>
423
+ <key>Label</key>
424
+ <string>ai.openclaw.mesh-agent</string>
425
+ <key>ProgramArguments</key>
426
+ <array>
427
+ <string>${nodeBin}</string>
428
+ <string>${meshDir}/bin/mesh-agent.js</string>
429
+ </array>
430
+ <key>KeepAlive</key>
431
+ <true/>
432
+ <key>RunAtLoad</key>
433
+ <true/>
434
+ <key>StandardOutPath</key>
435
+ <string>${os.homedir()}/.openclaw/workspace/.tmp/mesh-agent.log</string>
436
+ <key>StandardErrorPath</key>
437
+ <string>${os.homedir()}/.openclaw/workspace/.tmp/mesh-agent.err</string>
438
+ <key>EnvironmentVariables</key>
439
+ <dict>
440
+ <key>OPENCLAW_NATS</key>
441
+ <string>${natsUrl}</string>
442
+ <key>MESH_NODE_ID</key>
443
+ <string>${nodeId}</string>
444
+ <key>MESH_LLM_PROVIDER</key>
445
+ <string>${provider}</string>
446
+ <key>MESH_WORKSPACE</key>
447
+ <string>${os.homedir()}/.openclaw/workspace</string>
448
+ <key>PATH</key>
449
+ <string>/usr/local/bin:/usr/bin:/bin:/opt/homebrew/bin:${os.homedir()}/.npm-global/bin</string>
450
+ <key>NODE_PATH</key>
451
+ <string>${meshDir}/node_modules:${meshDir}/lib</string>
452
+ </dict>
453
+ <key>ThrottleInterval</key>
454
+ <integer>30</integer>
455
+ </dict>
456
+ </plist>`;
457
+
458
+ if (DRY_RUN) {
459
+ warn(`[DRY RUN] Would write launchd plist to ${plistPath}`);
460
+ return;
461
+ }
462
+
463
+ fs.mkdirSync(plistDir, { recursive: true });
464
+ fs.writeFileSync(plistPath, plist);
465
+ ok(`Launchd service written: ${plistPath}`);
466
+
467
+ try {
468
+ execSync(`launchctl unload "${plistPath}" 2>/dev/null || true`, { stdio: 'pipe' });
469
+ execSync(`launchctl load "${plistPath}"`, { stdio: 'pipe' });
470
+ ok('Service loaded and started');
471
+ } catch (e) {
472
+ warn(`Service load warning: ${e.message}`);
473
+ }
474
+ }
475
+
476
+ function installSystemdService(meshDir, nodeBin, nodeId, provider, natsUrl) {
477
+ const serviceDir = path.join(os.homedir(), '.config', 'systemd', 'user');
478
+ const servicePath = path.join(serviceDir, 'openclaw-mesh-agent.service');
479
+
480
+ const service = `[Unit]
481
+ Description=OpenClaw Mesh Agent
482
+ After=network-online.target
483
+ Wants=network-online.target
484
+
485
+ [Service]
486
+ Type=simple
487
+ ExecStart=${nodeBin} ${meshDir}/bin/mesh-agent.js
488
+ Restart=always
489
+ RestartSec=30
490
+ Environment=OPENCLAW_NATS=${natsUrl}
491
+ Environment=MESH_NODE_ID=${nodeId}
492
+ Environment=MESH_LLM_PROVIDER=${provider}
493
+ Environment=MESH_WORKSPACE=${os.homedir()}/.openclaw/workspace
494
+ Environment=NODE_PATH=${meshDir}/node_modules:${meshDir}/lib
495
+ Environment=PATH=/usr/local/bin:/usr/bin:/bin:/usr/local/sbin:/usr/sbin:/sbin:${os.homedir()}/.local/bin:${os.homedir()}/.npm-global/bin
496
+ WorkingDirectory=${meshDir}
497
+
498
+ [Install]
499
+ WantedBy=default.target
500
+ `;
501
+
502
+ if (DRY_RUN) {
503
+ warn(`[DRY RUN] Would write systemd service to ${servicePath}`);
504
+ return;
505
+ }
506
+
507
+ fs.mkdirSync(serviceDir, { recursive: true });
508
+ fs.writeFileSync(servicePath, service);
509
+ ok(`Systemd service written: ${servicePath}`);
510
+
511
+ try {
512
+ execSync('systemctl --user daemon-reload', { stdio: 'pipe' });
513
+ execSync('systemctl --user enable openclaw-mesh-agent', { stdio: 'pipe' });
514
+ execSync('systemctl --user start openclaw-mesh-agent', { stdio: 'pipe' });
515
+ ok('Service enabled and started');
516
+ } catch (e) {
517
+ warn(`Service start warning: ${e.message}`);
518
+ warn('Try manually: systemctl --user start openclaw-mesh-agent');
519
+ }
520
+
521
+ const username = os.userInfo().username;
522
+ try {
523
+ execSync(`loginctl enable-linger ${username}`, { stdio: 'pipe', timeout: 5000 });
524
+ ok(`Linger enabled for ${username} (service survives logout)`);
525
+ } catch {
526
+ try {
527
+ execSync(`sudo loginctl enable-linger ${username}`, { stdio: 'pipe', timeout: 5000 });
528
+ ok(`Linger enabled for ${username} via sudo`);
529
+ } catch {
530
+ warn(`Could not enable linger. Service stops on logout.`);
531
+ warn(`Fix: sudo loginctl enable-linger ${username}`);
532
+ }
533
+ }
534
+ }
535
+
536
+ // ── Service Health Polling ───────────────────────────
537
+
538
+ function verifyServiceRunning(osInfo) {
539
+ if (DRY_RUN) {
540
+ warn('[DRY RUN] Would verify service is running');
541
+ return true;
542
+ }
543
+
544
+ log('Waiting 8s for service to stabilize...');
545
+ const start = Date.now();
546
+ while (Date.now() - start < 8000) {
547
+ spawnSync('sleep', ['1']);
548
+ }
549
+
550
+ if (osInfo.serviceType === 'launchd') {
551
+ try {
552
+ const out = execSync('launchctl list | grep mesh-agent', { encoding: 'utf8', stdio: 'pipe' }).trim();
553
+ if (out) {
554
+ const parts = out.split(/\s+/);
555
+ const pid = parts[0];
556
+ const exitStatus = parts[1];
557
+ if (pid && pid !== '-') {
558
+ ok(`Service running (PID ${pid})`);
559
+ return true;
560
+ } else {
561
+ fail(`Service not running (exit status: ${exitStatus})`);
562
+ fail('Check logs: tail -f ~/.openclaw/workspace/.tmp/mesh-agent.err');
563
+ return false;
564
+ }
565
+ }
566
+ } catch {
567
+ fail('Service not found in launchctl');
568
+ return false;
569
+ }
570
+ } else {
571
+ try {
572
+ const result = spawnSync('systemctl', ['--user', 'is-active', 'openclaw-mesh-agent'], { encoding: 'utf8', stdio: 'pipe' });
573
+ const status = (result.stdout || '').trim();
574
+ if (status === 'active') {
575
+ ok('Service running (systemd active)');
576
+ return true;
577
+ } else {
578
+ fail(`Service not running (systemd status: ${status})`);
579
+ fail('Check logs: journalctl --user -u openclaw-mesh-agent -n 20');
580
+ return false;
581
+ }
582
+ } catch {
583
+ fail('Could not check systemd service status');
584
+ return false;
585
+ }
586
+ }
587
+ return false;
588
+ }
589
+
590
+ // ── NATS Health Verification ─────────────────────────
591
+
592
+ async function verifyNatsHealth(natsUrl, nodeId) {
593
+ log('Verifying NATS connectivity...');
594
+
595
+ if (DRY_RUN) {
596
+ warn('[DRY RUN] Would verify NATS connectivity');
597
+ return true;
598
+ }
599
+
600
+ try {
601
+ const nats = require('nats');
602
+ const nc = await nats.connect({ servers: natsUrl, timeout: 10000 });
603
+
604
+ ok(`NATS connected: ${nc.getServer()}`);
605
+
606
+ const sc = nats.StringCodec();
607
+ nc.publish(`mesh.health.${nodeId}`, sc.encode(JSON.stringify({
608
+ node_id: nodeId,
609
+ status: 'online',
610
+ event: 'node_joined',
611
+ os: os.platform(),
612
+ arch: os.arch(),
613
+ timestamp: new Date().toISOString(),
614
+ })));
615
+ ok('Health announcement published');
616
+
617
+ try {
618
+ const msg = await nc.request('mesh.tasks.list', sc.encode(JSON.stringify({ status: 'queued' })), { timeout: 5000 });
619
+ const resp = JSON.parse(sc.decode(msg.data));
620
+ if (resp.ok) ok(`Task daemon reachable — ${resp.data.length} queued task(s)`);
621
+ } catch {
622
+ warn('Task daemon not reachable (OK for worker-only nodes)');
623
+ }
624
+
625
+ await nc.drain();
626
+ return true;
627
+ } catch (e) {
628
+ fail(`NATS connection failed: ${e.message}`);
629
+ return false;
630
+ }
631
+ }
632
+
633
+ // ── Main ──────────────────────────────────────────────
634
+
635
+ async function main() {
636
+ console.log(`\n${BOLD}${CYAN}╔══════════════════════════════════════╗${RESET}`);
637
+ console.log(`${BOLD}${CYAN}║ OpenClaw Mesh — Join Network ║${RESET}`);
638
+ console.log(`${BOLD}${CYAN}╚══════════════════════════════════════╝${RESET}\n`);
639
+
640
+ if (DRY_RUN) warn('DRY RUN MODE — no changes will be made\n');
641
+
642
+ // ── Step 1: Detect OS ──
643
+ step(1, 'Detecting environment...');
644
+ const osInfo = detectOS();
645
+ const nodeId = os.hostname().toLowerCase().replace(/[^a-z0-9-]/g, '-');
646
+ ok(`OS: ${osInfo.os} (${osInfo.arch})`);
647
+ ok(`Node ID: ${nodeId}`);
648
+ ok(`Service type: ${osInfo.serviceType}`);
649
+
650
+ // ── Step 2: Check & install dependencies (including Tailscale) ──
651
+ step(2, 'Checking dependencies...');
652
+ const { deps, missing } = checkDependencies();
653
+ for (const d of deps) {
654
+ if (d.status === 'ok') ok(`${d.name}: ${d.detail}`);
655
+ else if (d.status === 'optional') warn(`${d.name}: ${d.detail}`);
656
+ else fail(`${d.name}: ${d.status} ${d.detail}`);
657
+ }
658
+
659
+ if (missing.length > 0) {
660
+ step('2b', 'Installing missing dependencies...');
661
+ installMissing(missing, osInfo);
662
+ }
663
+
664
+ // ── Step 3: Discover NATS via Tailscale ──
665
+ step(3, 'Discovering NATS server...');
666
+ const natsUrl = await discoverNats();
667
+
668
+ const config = {
669
+ nats: natsUrl,
670
+ provider: PROVIDER_OVERRIDE,
671
+ repo: REPO,
672
+ };
673
+
674
+ // ── Step 4: Create directory structure ──
675
+ step(4, 'Setting up directories...');
676
+ setupDirectories();
677
+
678
+ // ── Step 4b: Provision SSH key (if provided) ──
679
+ if (SSH_PUBKEY) {
680
+ step('4b', 'Provisioning SSH key...');
681
+ provisionSSHKey(SSH_PUBKEY);
682
+ }
683
+
684
+ // ── Step 5: Configure NATS ──
685
+ step(5, 'Configuring NATS connection...');
686
+ configureNats(config.nats);
687
+
688
+ // ── Step 6: Install mesh code ──
689
+ step(6, 'Installing mesh code...');
690
+ const meshDir = installMeshCode(config.repo);
691
+
692
+ // ── Step 7: Install service ──
693
+ step(7, `Installing ${osInfo.serviceType} service...`);
694
+ installService(osInfo, meshDir, config);
695
+
696
+ // ── Step 8: Verify health ──
697
+ step(8, 'Verifying health...');
698
+ const serviceAlive = verifyServiceRunning(osInfo);
699
+ const natsHealthy = await verifyNatsHealth(config.nats, nodeId);
700
+ const healthy = serviceAlive && natsHealthy;
701
+
702
+ // ── Done ──
703
+ console.log(`\n${BOLD}${GREEN}═══════════════════════════════════════${RESET}`);
704
+ if (healthy) {
705
+ console.log(`${BOLD}${GREEN} Node "${nodeId}" joined the mesh!${RESET}`);
706
+ } else if (serviceAlive && !natsHealthy) {
707
+ console.log(`${BOLD}${YELLOW} Service running but NATS unreachable.${RESET}`);
708
+ console.log(`${YELLOW} The agent will retry automatically.${RESET}`);
709
+ } else if (!serviceAlive) {
710
+ console.log(`${BOLD}${RED} Service failed to start.${RESET}`);
711
+ console.log(`${RED} Provisioning complete but agent is not running.${RESET}`);
712
+ } else {
713
+ console.log(`${BOLD}${YELLOW} Provisioned with warnings.${RESET}`);
714
+ }
715
+ console.log(`${BOLD}${GREEN}═══════════════════════════════════════${RESET}\n`);
716
+
717
+ console.log('Next steps:');
718
+ console.log(` 1. Add API keys to ~/.openclaw/openclaw.env`);
719
+ console.log(` Example: ANTHROPIC_API_KEY=sk-ant-...`);
720
+ if (osInfo.serviceType === 'systemd') {
721
+ console.log(` 2. Check service: systemctl --user status openclaw-mesh-agent`);
722
+ console.log(` 3. View logs: journalctl --user -u openclaw-mesh-agent -f`);
723
+ } else {
724
+ console.log(` 2. Check service: launchctl list | grep mesh-agent`);
725
+ console.log(` 3. View logs: tail -f ~/.openclaw/workspace/.tmp/mesh-agent.log`);
726
+ }
727
+ console.log('');
728
+ }
729
+
730
+ main().catch(err => {
731
+ fail(`Fatal: ${err.message}`);
732
+ process.exit(1);
733
+ });