openclaw-node-harness 2.0.0 → 2.0.1

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,674 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * openclaw-node-init.js — One-click mesh node provisioner.
5
+ *
6
+ * Takes a join token (from mesh-join-token.js) and bootstraps a fully
7
+ * functional mesh worker node. Handles:
8
+ * 1. Token validation (signature + expiry)
9
+ * 2. OS detection (macOS/Linux)
10
+ * 3. Dependency checks (Node.js, git, nats-server optional)
11
+ * 4. Directory structure (~/.openclaw/)
12
+ * 5. NATS configuration (from token)
13
+ * 6. Mesh code installation (repo URL from token)
14
+ * 7. Service installation (launchd/systemd)
15
+ * 8. Health verification (service alive + NATS connectivity)
16
+ *
17
+ * Usage:
18
+ * MESH_JOIN_TOKEN=<token> node bin/openclaw-node-init.js
19
+ * node bin/openclaw-node-init.js --token <token>
20
+ * node bin/openclaw-node-init.js --token <token> --dry-run
21
+ * node bin/openclaw-node-init.js --token <token> --provider deepseek
22
+ *
23
+ * The token contains NATS URL, repo URL, role, and default provider.
24
+ * API keys must be set separately in ~/.openclaw/openclaw.env after provisioning.
25
+ */
26
+
27
+ const { execSync, spawnSync } = require('child_process');
28
+ const crypto = require('crypto');
29
+ const fs = require('fs');
30
+ const os = require('os');
31
+ const path = require('path');
32
+
33
+ // ── CLI args ──────────────────────────────────────────
34
+
35
+ const args = process.argv.slice(2);
36
+ const DRY_RUN = args.includes('--dry-run');
37
+
38
+ function getArg(flag, defaultVal) {
39
+ const idx = args.indexOf(flag);
40
+ return idx >= 0 && args[idx + 1] ? args[idx + 1] : defaultVal;
41
+ }
42
+
43
+ const TOKEN_RAW = getArg('--token', null) || process.env.MESH_JOIN_TOKEN;
44
+ const PROVIDER_OVERRIDE = getArg('--provider', null);
45
+
46
+ // Default repo URL — used when token is v1 (no repo field)
47
+ const DEFAULT_REPO = 'https://github.com/moltyguibros-design/openclaw-node.git';
48
+
49
+ // ── Logging ───────────────────────────────────────────
50
+
51
+ const BOLD = '\x1b[1m';
52
+ const GREEN = '\x1b[32m';
53
+ const RED = '\x1b[31m';
54
+ const YELLOW = '\x1b[33m';
55
+ const CYAN = '\x1b[36m';
56
+ const RESET = '\x1b[0m';
57
+
58
+ function log(msg) { console.log(`${CYAN}[mesh-init]${RESET} ${msg}`); }
59
+ function ok(msg) { console.log(`${GREEN} ✓${RESET} ${msg}`); }
60
+ function warn(msg){ console.log(`${YELLOW} ⚠${RESET} ${msg}`); }
61
+ function fail(msg){ console.error(`${RED} ✗${RESET} ${msg}`); }
62
+ function step(n, msg) { console.log(`\n${BOLD}[${n}]${RESET} ${msg}`); }
63
+
64
+ // ── Token Parsing ─────────────────────────────────────
65
+
66
+ function parseToken(raw) {
67
+ if (!raw) {
68
+ fail('No join token provided. Use --token <token> or set MESH_JOIN_TOKEN env var.');
69
+ fail('Generate a token on the lead node: node bin/mesh-join-token.js');
70
+ process.exit(1);
71
+ }
72
+
73
+ try {
74
+ const decoded = JSON.parse(Buffer.from(raw, 'base64url').toString('utf8'));
75
+ return { payload: decoded.p, signature: decoded.s };
76
+ } catch (e) {
77
+ fail(`Invalid token format: ${e.message}`);
78
+ process.exit(1);
79
+ }
80
+ }
81
+
82
+ function validateToken(payload) {
83
+ // Accept v1 (no repo) and v2 (with repo)
84
+ if (payload.v !== 1 && payload.v !== 2) {
85
+ fail(`Unsupported token version: ${payload.v}. This provisioner supports v1 and v2.`);
86
+ process.exit(1);
87
+ }
88
+ if (payload.expires && Date.now() > payload.expires) {
89
+ fail(`Token expired at ${new Date(payload.expires).toISOString()}`);
90
+ fail('Generate a new token on the lead node: node bin/mesh-join-token.js');
91
+ process.exit(1);
92
+ }
93
+ if (!payload.nats) {
94
+ fail('Token missing NATS URL');
95
+ process.exit(1);
96
+ }
97
+ }
98
+
99
+ // ── OS Detection ──────────────────────────────────────
100
+
101
+ function detectOS() {
102
+ const platform = os.platform();
103
+ const arch = os.arch();
104
+ const release = os.release();
105
+
106
+ if (platform === 'darwin') return { os: 'macos', serviceType: 'launchd', arch, release };
107
+ if (platform === 'linux') return { os: 'linux', serviceType: 'systemd', arch, release };
108
+ fail(`Unsupported platform: ${platform}. OpenClaw mesh requires macOS or Linux.`);
109
+ process.exit(1);
110
+ }
111
+
112
+ // ── Dependency Checks ─────────────────────────────────
113
+
114
+ function checkCommand(cmd) {
115
+ try {
116
+ execSync(`which ${cmd}`, { stdio: 'pipe' });
117
+ return true;
118
+ } catch {
119
+ return false;
120
+ }
121
+ }
122
+
123
+ function getNodeVersion() {
124
+ try {
125
+ const v = execSync('node --version', { encoding: 'utf8' }).trim();
126
+ const major = parseInt(v.replace('v', '').split('.')[0]);
127
+ return { version: v, major };
128
+ } catch {
129
+ return null;
130
+ }
131
+ }
132
+
133
+ function checkDependencies(osInfo) {
134
+ const deps = [];
135
+ const missing = [];
136
+
137
+ // Node.js 18+
138
+ const nodeInfo = getNodeVersion();
139
+ if (nodeInfo && nodeInfo.major >= 18) {
140
+ deps.push({ name: 'Node.js', status: 'ok', detail: nodeInfo.version });
141
+ } else if (nodeInfo) {
142
+ deps.push({ name: 'Node.js', status: 'upgrade', detail: `${nodeInfo.version} (need 18+)` });
143
+ missing.push('node');
144
+ } else {
145
+ deps.push({ name: 'Node.js', status: 'missing', detail: '' });
146
+ missing.push('node');
147
+ }
148
+
149
+ // Git
150
+ if (checkCommand('git')) {
151
+ const v = execSync('git --version', { encoding: 'utf8' }).trim();
152
+ deps.push({ name: 'Git', status: 'ok', detail: v });
153
+ } else {
154
+ deps.push({ name: 'Git', status: 'missing', detail: '' });
155
+ missing.push('git');
156
+ }
157
+
158
+ // Tailscale (optional but recommended)
159
+ if (checkCommand('tailscale')) {
160
+ deps.push({ name: 'Tailscale', status: 'ok', detail: 'installed' });
161
+ } else {
162
+ deps.push({ name: 'Tailscale', status: 'optional', detail: 'not installed (recommended for secure mesh)' });
163
+ }
164
+
165
+ return { deps, missing };
166
+ }
167
+
168
+ function installMissing(missing, osInfo) {
169
+ if (missing.length === 0) return;
170
+
171
+ log(`Installing missing dependencies: ${missing.join(', ')}`);
172
+
173
+ if (DRY_RUN) {
174
+ warn('[DRY RUN] Would install: ' + missing.join(', '));
175
+ return;
176
+ }
177
+
178
+ if (osInfo.os === 'macos') {
179
+ if (!checkCommand('brew')) {
180
+ fail('Homebrew not found. Install it first: https://brew.sh');
181
+ process.exit(1);
182
+ }
183
+ for (const dep of missing) {
184
+ const pkg = dep === 'node' ? 'node@22' : dep;
185
+ log(` brew install ${pkg}`);
186
+ spawnSync('brew', ['install', pkg], { stdio: 'inherit' });
187
+ }
188
+ } else {
189
+ // Linux — try apt, then yum, then dnf
190
+ const pm = checkCommand('apt-get') ? 'apt-get'
191
+ : checkCommand('yum') ? 'yum'
192
+ : checkCommand('dnf') ? 'dnf'
193
+ : null;
194
+
195
+ if (!pm) {
196
+ fail('No supported package manager found (need apt, yum, or dnf)');
197
+ process.exit(1);
198
+ }
199
+
200
+ for (const dep of missing) {
201
+ if (dep === 'node') {
202
+ // Use NodeSource for recent Node.js
203
+ log(' Installing Node.js 22.x via NodeSource...');
204
+ try {
205
+ execSync('curl -fsSL https://deb.nodesource.com/setup_22.x | sudo -E bash -', { stdio: 'inherit' });
206
+ execSync(`sudo ${pm} install -y nodejs`, { stdio: 'inherit' });
207
+ } catch (e) {
208
+ fail(`Node.js installation failed: ${e.message}`);
209
+ process.exit(1);
210
+ }
211
+ } else {
212
+ log(` sudo ${pm} install -y ${dep}`);
213
+ spawnSync('sudo', [pm, 'install', '-y', dep], { stdio: 'inherit' });
214
+ }
215
+ }
216
+ }
217
+ }
218
+
219
+ // ── Directory Setup ───────────────────────────────────
220
+
221
+ function setupDirectories() {
222
+ const home = os.homedir();
223
+ const dirs = [
224
+ path.join(home, '.openclaw'),
225
+ path.join(home, '.openclaw', 'workspace'),
226
+ path.join(home, '.openclaw', 'workspace', '.tmp'),
227
+ path.join(home, '.openclaw', 'workspace', 'memory'),
228
+ path.join(home, '.openclaw', 'worktrees'),
229
+ path.join(home, '.openclaw', 'config'),
230
+ ];
231
+
232
+ for (const dir of dirs) {
233
+ if (!fs.existsSync(dir)) {
234
+ if (DRY_RUN) {
235
+ warn(`[DRY RUN] Would create: ${dir}`);
236
+ } else {
237
+ fs.mkdirSync(dir, { recursive: true });
238
+ ok(`Created: ${dir}`);
239
+ }
240
+ } else {
241
+ ok(`Exists: ${dir}`);
242
+ }
243
+ }
244
+ }
245
+
246
+ // ── NATS Configuration ───────────────────────────────
247
+
248
+ function configureNats(natsUrl) {
249
+ const envPath = path.join(os.homedir(), '.openclaw', 'openclaw.env');
250
+
251
+ if (DRY_RUN) {
252
+ warn(`[DRY RUN] Would write NATS URL to ${envPath}`);
253
+ return;
254
+ }
255
+
256
+ let content = '';
257
+ if (fs.existsSync(envPath)) {
258
+ content = fs.readFileSync(envPath, 'utf8');
259
+ // Update existing OPENCLAW_NATS line or append
260
+ if (content.match(/^\s*OPENCLAW_NATS\s*=/m)) {
261
+ content = content.replace(/^\s*OPENCLAW_NATS\s*=.*/m, `OPENCLAW_NATS=${natsUrl}`);
262
+ } else {
263
+ content += `\nOPENCLAW_NATS=${natsUrl}\n`;
264
+ }
265
+ } else {
266
+ content = `# OpenClaw Mesh Configuration\n# Generated by openclaw-node-init.js\nOPENCLAW_NATS=${natsUrl}\n`;
267
+ }
268
+
269
+ fs.writeFileSync(envPath, content, { mode: 0o600 });
270
+ ok(`NATS URL configured: ${natsUrl}`);
271
+ }
272
+
273
+ // ── Mesh Code Installation ───────────────────────────
274
+
275
+ function installMeshCode(repoUrl) {
276
+ const meshDir = path.join(os.homedir(), 'openclaw');
277
+
278
+ if (fs.existsSync(path.join(meshDir, 'package.json'))) {
279
+ ok(`Mesh code already installed at ${meshDir}`);
280
+ // npm install to ensure deps are current
281
+ if (!DRY_RUN) {
282
+ log(' Updating dependencies...');
283
+ spawnSync('npm', ['install', '--production'], { cwd: meshDir, stdio: 'pipe' });
284
+ ok('Dependencies updated');
285
+ }
286
+ return meshDir;
287
+ }
288
+
289
+ if (DRY_RUN) {
290
+ warn(`[DRY RUN] Would clone ${repoUrl} to ${meshDir}`);
291
+ return meshDir;
292
+ }
293
+
294
+ log(`Cloning mesh code from ${repoUrl}...`);
295
+ try {
296
+ execSync(
297
+ `git clone "${repoUrl}" "${meshDir}"`,
298
+ { stdio: 'inherit', timeout: 60000 }
299
+ );
300
+ spawnSync('npm', ['install', '--production'], { cwd: meshDir, stdio: 'pipe' });
301
+ ok('Mesh code installed');
302
+ } catch (e) {
303
+ fail(`Failed to clone mesh code: ${e.message}`);
304
+ fail(`Repo URL: ${repoUrl}`);
305
+ process.exit(1);
306
+ }
307
+
308
+ return meshDir;
309
+ }
310
+
311
+ // ── Service Installation ─────────────────────────────
312
+
313
+ function installService(osInfo, meshDir, config) {
314
+ const nodeId = os.hostname().toLowerCase().replace(/[^a-z0-9-]/g, '-');
315
+ const nodeBin = process.execPath; // path to current node binary
316
+ const provider = config.provider;
317
+
318
+ if (osInfo.serviceType === 'launchd') {
319
+ return installLaunchdService(meshDir, nodeBin, nodeId, provider, config.nats);
320
+ } else {
321
+ return installSystemdService(meshDir, nodeBin, nodeId, provider, config.nats);
322
+ }
323
+ }
324
+
325
+ function installLaunchdService(meshDir, nodeBin, nodeId, provider, natsUrl) {
326
+ const plistDir = path.join(os.homedir(), 'Library', 'LaunchAgents');
327
+ const plistPath = path.join(plistDir, 'ai.openclaw.mesh-agent.plist');
328
+
329
+ const plist = `<?xml version="1.0" encoding="UTF-8"?>
330
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
331
+ <plist version="1.0">
332
+ <dict>
333
+ <key>Label</key>
334
+ <string>ai.openclaw.mesh-agent</string>
335
+ <key>ProgramArguments</key>
336
+ <array>
337
+ <string>${nodeBin}</string>
338
+ <string>${meshDir}/bin/mesh-agent.js</string>
339
+ </array>
340
+ <key>KeepAlive</key>
341
+ <true/>
342
+ <key>RunAtLoad</key>
343
+ <true/>
344
+ <key>StandardOutPath</key>
345
+ <string>${os.homedir()}/.openclaw/workspace/.tmp/mesh-agent.log</string>
346
+ <key>StandardErrorPath</key>
347
+ <string>${os.homedir()}/.openclaw/workspace/.tmp/mesh-agent.err</string>
348
+ <key>EnvironmentVariables</key>
349
+ <dict>
350
+ <key>OPENCLAW_NATS</key>
351
+ <string>${natsUrl}</string>
352
+ <key>MESH_NODE_ID</key>
353
+ <string>${nodeId}</string>
354
+ <key>MESH_LLM_PROVIDER</key>
355
+ <string>${provider}</string>
356
+ <key>MESH_WORKSPACE</key>
357
+ <string>${os.homedir()}/.openclaw/workspace</string>
358
+ <key>PATH</key>
359
+ <string>/usr/local/bin:/usr/bin:/bin:/opt/homebrew/bin:${os.homedir()}/.npm-global/bin</string>
360
+ <key>NODE_PATH</key>
361
+ <string>${meshDir}/node_modules:${meshDir}/lib</string>
362
+ </dict>
363
+ <key>ThrottleInterval</key>
364
+ <integer>30</integer>
365
+ </dict>
366
+ </plist>`;
367
+
368
+ if (DRY_RUN) {
369
+ warn(`[DRY RUN] Would write launchd plist to ${plistPath}`);
370
+ return;
371
+ }
372
+
373
+ fs.mkdirSync(plistDir, { recursive: true });
374
+ fs.writeFileSync(plistPath, plist);
375
+ ok(`Launchd service written: ${plistPath}`);
376
+
377
+ // Load the service
378
+ try {
379
+ execSync(`launchctl unload "${plistPath}" 2>/dev/null || true`, { stdio: 'pipe' });
380
+ execSync(`launchctl load "${plistPath}"`, { stdio: 'pipe' });
381
+ ok('Service loaded and started');
382
+ } catch (e) {
383
+ warn(`Service load warning: ${e.message}`);
384
+ }
385
+ }
386
+
387
+ function installSystemdService(meshDir, nodeBin, nodeId, provider, natsUrl) {
388
+ const serviceDir = path.join(os.homedir(), '.config', 'systemd', 'user');
389
+ const servicePath = path.join(serviceDir, 'openclaw-mesh-agent.service');
390
+
391
+ const service = `[Unit]
392
+ Description=OpenClaw Mesh Agent
393
+ After=network-online.target
394
+ Wants=network-online.target
395
+
396
+ [Service]
397
+ Type=simple
398
+ ExecStart=${nodeBin} ${meshDir}/bin/mesh-agent.js
399
+ Restart=always
400
+ RestartSec=30
401
+ Environment=OPENCLAW_NATS=${natsUrl}
402
+ Environment=MESH_NODE_ID=${nodeId}
403
+ Environment=MESH_LLM_PROVIDER=${provider}
404
+ Environment=MESH_WORKSPACE=${os.homedir()}/.openclaw/workspace
405
+ Environment=NODE_PATH=${meshDir}/node_modules:${meshDir}/lib
406
+ WorkingDirectory=${meshDir}
407
+
408
+ [Install]
409
+ WantedBy=default.target
410
+ `;
411
+
412
+ if (DRY_RUN) {
413
+ warn(`[DRY RUN] Would write systemd service to ${servicePath}`);
414
+ return;
415
+ }
416
+
417
+ fs.mkdirSync(serviceDir, { recursive: true });
418
+ fs.writeFileSync(servicePath, service);
419
+ ok(`Systemd service written: ${servicePath}`);
420
+
421
+ // Enable and start
422
+ try {
423
+ execSync('systemctl --user daemon-reload', { stdio: 'pipe' });
424
+ execSync('systemctl --user enable openclaw-mesh-agent', { stdio: 'pipe' });
425
+ execSync('systemctl --user start openclaw-mesh-agent', { stdio: 'pipe' });
426
+ ok('Service enabled and started');
427
+ } catch (e) {
428
+ warn(`Service start warning: ${e.message}`);
429
+ warn('Try manually: systemctl --user start openclaw-mesh-agent');
430
+ }
431
+
432
+ // Enable lingering — requires either sudo or polkit permission.
433
+ // Without linger, the service dies when the user logs out.
434
+ const username = os.userInfo().username;
435
+ try {
436
+ // Try without sudo first (works if polkit allows it)
437
+ execSync(`loginctl enable-linger ${username}`, { stdio: 'pipe', timeout: 5000 });
438
+ ok(`Linger enabled for ${username} (service survives logout)`);
439
+ } catch {
440
+ try {
441
+ // Try with sudo
442
+ execSync(`sudo loginctl enable-linger ${username}`, { stdio: 'pipe', timeout: 5000 });
443
+ ok(`Linger enabled for ${username} via sudo (service survives logout)`);
444
+ } catch (e2) {
445
+ warn(`Could not enable linger for ${username}: ${e2.message}`);
446
+ warn('Without linger, the mesh-agent service will stop when you log out.');
447
+ warn(`Fix manually: sudo loginctl enable-linger ${username}`);
448
+ }
449
+ }
450
+ }
451
+
452
+ // ── Service Health Polling ───────────────────────────
453
+
454
+ function verifyServiceRunning(osInfo) {
455
+ if (DRY_RUN) {
456
+ warn('[DRY RUN] Would verify service is running');
457
+ return true;
458
+ }
459
+
460
+ log('Waiting 8s for service to stabilize...');
461
+
462
+ // Wait for the service to either stabilize or crash
463
+ const waitMs = 8000;
464
+ const start = Date.now();
465
+ while (Date.now() - start < waitMs) {
466
+ spawnSync('sleep', ['1']); // cross-platform 1s sleep
467
+ }
468
+
469
+ if (osInfo.serviceType === 'launchd') {
470
+ try {
471
+ const out = execSync('launchctl list | grep mesh-agent', { encoding: 'utf8', stdio: 'pipe' }).trim();
472
+ if (out) {
473
+ // launchctl list format: PID\tStatus\tLabel
474
+ const parts = out.split(/\s+/);
475
+ const pid = parts[0];
476
+ const exitStatus = parts[1];
477
+ if (pid && pid !== '-') {
478
+ ok(`Service running (PID ${pid})`);
479
+ return true;
480
+ } else {
481
+ fail(`Service not running (exit status: ${exitStatus})`);
482
+ fail('Check logs: tail -f ~/.openclaw/workspace/.tmp/mesh-agent.err');
483
+ return false;
484
+ }
485
+ }
486
+ } catch {
487
+ fail('Service not found in launchctl');
488
+ return false;
489
+ }
490
+ } else {
491
+ // systemd
492
+ try {
493
+ const result = spawnSync('systemctl', ['--user', 'is-active', 'openclaw-mesh-agent'], { encoding: 'utf8', stdio: 'pipe' });
494
+ const status = (result.stdout || '').trim();
495
+ if (status === 'active') {
496
+ ok('Service running (systemd active)');
497
+ return true;
498
+ } else {
499
+ fail(`Service not running (systemd status: ${status})`);
500
+ fail('Check logs: journalctl --user -u openclaw-mesh-agent -n 20');
501
+ return false;
502
+ }
503
+ } catch {
504
+ fail('Could not check systemd service status');
505
+ return false;
506
+ }
507
+ }
508
+ return false;
509
+ }
510
+
511
+ // ── NATS Health Verification ─────────────────────────
512
+
513
+ async function verifyNatsHealth(natsUrl, nodeId) {
514
+ log('Verifying NATS connectivity...');
515
+
516
+ if (DRY_RUN) {
517
+ warn('[DRY RUN] Would verify NATS connectivity');
518
+ return true;
519
+ }
520
+
521
+ try {
522
+ // Dynamic require — nats module should be installed by now
523
+ const nats = require('nats');
524
+ const nc = await nats.connect({ servers: natsUrl, timeout: 10000 });
525
+
526
+ ok(`NATS connected: ${nc.getServer()}`);
527
+
528
+ // Publish a health announcement
529
+ const sc = nats.StringCodec();
530
+ nc.publish(`mesh.health.${nodeId}`, sc.encode(JSON.stringify({
531
+ node_id: nodeId,
532
+ status: 'online',
533
+ event: 'node_joined',
534
+ os: os.platform(),
535
+ arch: os.arch(),
536
+ timestamp: new Date().toISOString(),
537
+ })));
538
+
539
+ ok('Health announcement published');
540
+
541
+ // Try to reach the task daemon
542
+ try {
543
+ const msg = await nc.request('mesh.tasks.list', sc.encode(JSON.stringify({ status: 'queued' })), { timeout: 5000 });
544
+ const resp = JSON.parse(sc.decode(msg.data));
545
+ if (resp.ok) {
546
+ ok(`Task daemon reachable — ${resp.data.length} queued task(s)`);
547
+ } else {
548
+ warn(`Task daemon responded with error: ${resp.error}`);
549
+ }
550
+ } catch {
551
+ warn('Task daemon not reachable (may not be running yet — this is OK for worker-only nodes)');
552
+ }
553
+
554
+ await nc.drain();
555
+ return true;
556
+ } catch (e) {
557
+ fail(`NATS connection failed: ${e.message}`);
558
+ fail('Check that the NATS server is running and the node has network access');
559
+ return false;
560
+ }
561
+ }
562
+
563
+ // ── Main ──────────────────────────────────────────────
564
+
565
+ async function main() {
566
+ console.log(`\n${BOLD}${CYAN}╔══════════════════════════════════════╗${RESET}`);
567
+ console.log(`${BOLD}${CYAN}║ OpenClaw Mesh Node Provisioner ║${RESET}`);
568
+ console.log(`${BOLD}${CYAN}╚══════════════════════════════════════╝${RESET}\n`);
569
+
570
+ if (DRY_RUN) warn('DRY RUN MODE — no changes will be made\n');
571
+
572
+ // ── Step 1: Parse and validate token ──
573
+ step(1, 'Validating join token...');
574
+ const { payload } = parseToken(TOKEN_RAW);
575
+ validateToken(payload);
576
+ ok(`Token valid (v${payload.v}, role: ${payload.role}, provider: ${payload.provider}, lead: ${payload.lead})`);
577
+ ok(`Expires: ${new Date(payload.expires).toISOString()}`);
578
+
579
+ // Extract config from token (with defaults for v1 tokens)
580
+ const repoUrl = payload.repo || DEFAULT_REPO;
581
+ const config = {
582
+ nats: payload.nats,
583
+ role: payload.role,
584
+ provider: PROVIDER_OVERRIDE || payload.provider,
585
+ repo: repoUrl,
586
+ };
587
+
588
+ if (payload.repo) {
589
+ ok(`Repo: ${repoUrl}`);
590
+ } else {
591
+ warn(`Token v1 (no repo field) — using default: ${DEFAULT_REPO}`);
592
+ }
593
+
594
+ // ── Step 2: Detect OS ──
595
+ step(2, 'Detecting environment...');
596
+ const osInfo = detectOS();
597
+ const nodeId = os.hostname().toLowerCase().replace(/[^a-z0-9-]/g, '-');
598
+ ok(`OS: ${osInfo.os} (${osInfo.arch})`);
599
+ ok(`Node ID: ${nodeId}`);
600
+ ok(`Service type: ${osInfo.serviceType}`);
601
+
602
+ // ── Step 3: Check dependencies ──
603
+ step(3, 'Checking dependencies...');
604
+ const { deps, missing } = checkDependencies(osInfo);
605
+ for (const d of deps) {
606
+ if (d.status === 'ok') ok(`${d.name}: ${d.detail}`);
607
+ else if (d.status === 'optional') warn(`${d.name}: ${d.detail}`);
608
+ else fail(`${d.name}: ${d.status} ${d.detail}`);
609
+ }
610
+
611
+ if (missing.length > 0) {
612
+ step('3b', 'Installing missing dependencies...');
613
+ installMissing(missing, osInfo);
614
+ }
615
+
616
+ // ── Step 4: Create directory structure ──
617
+ step(4, 'Setting up directories...');
618
+ setupDirectories();
619
+
620
+ // ── Step 5: Configure NATS ──
621
+ step(5, 'Configuring NATS connection...');
622
+ configureNats(config.nats);
623
+
624
+ // ── Step 6: Install mesh code ──
625
+ step(6, 'Installing mesh code...');
626
+ const meshDir = installMeshCode(config.repo);
627
+
628
+ // ── Step 7: Install service ──
629
+ step(7, `Installing ${osInfo.serviceType} service...`);
630
+ installService(osInfo, meshDir, config);
631
+
632
+ // ── Step 8: Verify health (service + NATS) ──
633
+ step(8, 'Verifying health...');
634
+ const serviceAlive = verifyServiceRunning(osInfo);
635
+ const natsHealthy = await verifyNatsHealth(config.nats, nodeId);
636
+ const healthy = serviceAlive && natsHealthy;
637
+
638
+ // ── Done ──
639
+ console.log(`\n${BOLD}${GREEN}═══════════════════════════════════════${RESET}`);
640
+ if (healthy) {
641
+ console.log(`${BOLD}${GREEN} Node "${nodeId}" joined the mesh!${RESET}`);
642
+ } else if (serviceAlive && !natsHealthy) {
643
+ console.log(`${BOLD}${YELLOW} Service running but NATS unreachable.${RESET}`);
644
+ console.log(`${YELLOW} The agent will retry automatically.${RESET}`);
645
+ } else if (!serviceAlive) {
646
+ console.log(`${BOLD}${RED} Service failed to start.${RESET}`);
647
+ console.log(`${RED} Provisioning complete but agent is not running.${RESET}`);
648
+ } else {
649
+ console.log(`${BOLD}${YELLOW} Node provisioned but health check failed.${RESET}`);
650
+ console.log(`${YELLOW} The service is installed and will retry automatically.${RESET}`);
651
+ }
652
+ console.log(`${BOLD}${GREEN}═══════════════════════════════════════${RESET}\n`);
653
+
654
+ console.log('Next steps:');
655
+ console.log(` 1. Add API keys to ~/.openclaw/openclaw.env`);
656
+ console.log(` Example: ANTHROPIC_API_KEY=sk-ant-...`);
657
+ if (osInfo.serviceType === 'systemd') {
658
+ console.log(` 2. Check service: systemctl --user status openclaw-mesh-agent`);
659
+ console.log(` 3. View logs: journalctl --user -u openclaw-mesh-agent -f`);
660
+ } else {
661
+ console.log(` 2. Check service: launchctl list | grep mesh-agent`);
662
+ console.log(` 3. View logs: tail -f ~/.openclaw/workspace/.tmp/mesh-agent.log`);
663
+ }
664
+ console.log(` 4. Submit a test task from lead: node bin/mesh.js submit --title "hello" --provider shell`);
665
+ if (!serviceAlive) {
666
+ console.log(`\n ${RED}IMPORTANT: Fix the service error above before proceeding.${RESET}`);
667
+ }
668
+ console.log('');
669
+ }
670
+
671
+ main().catch(err => {
672
+ fail(`Fatal: ${err.message}`);
673
+ process.exit(1);
674
+ });