robot-resources 1.7.1 → 1.7.3

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,124 @@
1
+ import { readFileSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+ import { homedir } from 'node:os';
4
+ import { readConfig } from '@robot-resources/cli-core/config.mjs';
5
+
6
+ const PROBE_TIMEOUT_MS = 5_000;
7
+
8
+ /**
9
+ * Run post-install health checks against all Robot Resources components.
10
+ *
11
+ * Probes:
12
+ * 1. Router — GET http://127.0.0.1:3838/health
13
+ * 2. Scraper — check openclaw.json for scraper MCP registration
14
+ * 3. Platform — GET {platformUrl}/v1/health with api_key
15
+ * 4. MCP — check openclaw.json for openclaw-plugin registration
16
+ *
17
+ * @returns {{ status: 'healthy'|'partial'|'failed', components: Object, summary: string }}
18
+ */
19
+ export async function checkHealth() {
20
+ const config = readConfig();
21
+
22
+ const [router, scraper, platform, mcp] = await Promise.all([
23
+ probeRouter(),
24
+ probeScraper(),
25
+ probePlatform(config),
26
+ probeMcp(),
27
+ ]);
28
+
29
+ const components = { router, scraper, platform, mcp };
30
+ const healthyCount = Object.values(components).filter((c) => c.healthy).length;
31
+ const total = Object.keys(components).length;
32
+
33
+ let status;
34
+ if (healthyCount === total) {
35
+ status = 'healthy';
36
+ } else if (healthyCount === 0) {
37
+ status = 'failed';
38
+ } else {
39
+ status = 'partial';
40
+ }
41
+
42
+ const failing = Object.entries(components)
43
+ .filter(([, c]) => !c.healthy)
44
+ .map(([name, c]) => `${name}: ${c.detail}`);
45
+
46
+ const summary =
47
+ status === 'healthy'
48
+ ? `All ${total} components healthy.`
49
+ : `${healthyCount}/${total} healthy. Issues: ${failing.join('; ')}`;
50
+
51
+ return { status, components, summary };
52
+ }
53
+
54
+ async function probeRouter() {
55
+ try {
56
+ if (typeof fetch === 'undefined') {
57
+ return { healthy: false, detail: 'fetch unavailable' };
58
+ }
59
+ const res = await fetch('http://127.0.0.1:3838/health', {
60
+ signal: AbortSignal.timeout(PROBE_TIMEOUT_MS),
61
+ });
62
+ if (!res.ok) {
63
+ return { healthy: false, detail: `HTTP ${res.status}` };
64
+ }
65
+ const data = await res.json();
66
+ if (data.status === 'healthy' || data.status === 'degraded') {
67
+ return { healthy: true, detail: `running (v${data.version || 'unknown'})` };
68
+ }
69
+ return { healthy: false, detail: `status: ${data.status}` };
70
+ } catch (err) {
71
+ const detail = err.name === 'AbortError' ? 'timeout' : 'unreachable';
72
+ return { healthy: false, detail };
73
+ }
74
+ }
75
+
76
+ function probeScraper() {
77
+ try {
78
+ const ocPath = join(homedir(), '.openclaw', 'openclaw.json');
79
+ const ocConfig = JSON.parse(readFileSync(ocPath, 'utf-8'));
80
+ const hasServer = !!ocConfig?.mcp?.servers?.['robot-resources-scraper'];
81
+ return {
82
+ healthy: hasServer,
83
+ detail: hasServer ? 'MCP registered' : 'scraper MCP not registered',
84
+ };
85
+ } catch {
86
+ return { healthy: false, detail: 'openclaw.json not found' };
87
+ }
88
+ }
89
+
90
+ async function probePlatform(config) {
91
+ if (!config.api_key) {
92
+ return { healthy: false, detail: 'no API key configured' };
93
+ }
94
+ try {
95
+ if (typeof fetch === 'undefined') {
96
+ return { healthy: false, detail: 'fetch unavailable' };
97
+ }
98
+ const platformUrl = process.env.RR_PLATFORM_URL || 'https://api.robotresources.ai';
99
+ const res = await fetch(`${platformUrl}/health`, {
100
+ signal: AbortSignal.timeout(PROBE_TIMEOUT_MS),
101
+ });
102
+ return {
103
+ healthy: res.ok,
104
+ detail: res.ok ? 'reachable' : `HTTP ${res.status}`,
105
+ };
106
+ } catch (err) {
107
+ const detail = err.name === 'AbortError' ? 'timeout' : 'unreachable';
108
+ return { healthy: false, detail };
109
+ }
110
+ }
111
+
112
+ function probeMcp() {
113
+ try {
114
+ const ocPath = join(homedir(), '.openclaw', 'openclaw.json');
115
+ const ocConfig = JSON.parse(readFileSync(ocPath, 'utf-8'));
116
+ const hasPlugin = !!ocConfig?.plugins?.entries?.['openclaw-plugin']?.enabled;
117
+ return {
118
+ healthy: hasPlugin,
119
+ detail: hasPlugin ? 'plugin registered' : 'openclaw-plugin not registered',
120
+ };
121
+ } catch {
122
+ return { healthy: false, detail: 'openclaw.json not found' };
123
+ }
124
+ }
@@ -0,0 +1,31 @@
1
+ import { readFileSync, writeFileSync, mkdirSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+ import { homedir } from 'node:os';
4
+ import { randomUUID } from 'node:crypto';
5
+
6
+ /**
7
+ * Reads or creates a persistent machine identifier.
8
+ * Used for telemetry deduplication across sessions.
9
+ *
10
+ * @param {string} [configDir] - Directory to store .machine-id (defaults to ~/.robot-resources)
11
+ * @returns {string} A UUID v4 machine identifier
12
+ */
13
+ export function getOrCreateMachineId(configDir) {
14
+ const dir = configDir ?? join(homedir(), '.robot-resources');
15
+ const machineIdPath = join(dir, '.machine-id');
16
+
17
+ try {
18
+ const stored = readFileSync(machineIdPath, 'utf-8').trim();
19
+ if (stored) return stored;
20
+ } catch {
21
+ // File doesn't exist or can't be read — fall through to generate
22
+ }
23
+
24
+ const machineId = randomUUID();
25
+ try {
26
+ mkdirSync(dir, { recursive: true });
27
+ writeFileSync(machineIdPath, machineId, 'utf-8');
28
+ } catch { /* non-fatal */ }
29
+
30
+ return machineId;
31
+ }
@@ -1,77 +1,58 @@
1
1
  import { spawn } from 'node:child_process';
2
2
  import { createRequire } from 'node:module';
3
- import { readFileSync, writeFileSync, copyFileSync, mkdirSync } from 'node:fs';
3
+ import { readFileSync, writeFileSync, copyFileSync, mkdirSync, existsSync } from 'node:fs';
4
4
  import { homedir } from 'node:os';
5
5
  import { join, dirname } from 'node:path';
6
6
  import { isOpenClawInstalled, isOpenClawPluginInstalled, getOpenClawAuthMode } from './detect.js';
7
7
  import { stripJson5 } from './json5.js';
8
8
 
9
9
  /**
10
- * Run a command with a heartbeat to keep agent sessions alive.
11
- * OC kills processes after 5s of no output (noOutputTimeoutMs).
12
- * Prints immediately, then every 4s (safely under the 5s threshold).
10
+ * Read openclaw.json, creating it with a minimal structure if it doesn't exist.
11
+ * Returns parsed config object. Throws on malformed JSON (caller handles).
13
12
  */
14
- function spawnWithHeartbeat(cmd, args, { label, timeout = 30_000 } = {}) {
15
- return new Promise((resolve, reject) => {
16
- const proc = spawn(cmd, args, {
17
- stdio: ['ignore', 'pipe', 'pipe'],
18
- timeout,
19
- });
20
-
21
- process.stdout.write(` ${label}...\n`);
22
- let seconds = 0;
23
- const heartbeat = setInterval(() => {
24
- seconds += 4;
25
- process.stdout.write(` ${label}... ${seconds}s\n`);
26
- }, 4000);
27
-
28
- proc.on('close', (code) => {
29
- clearInterval(heartbeat);
30
- if (code === 0) resolve();
31
- else reject(new Error(`${cmd} ${args.join(' ')} exited with code ${code}`));
32
- });
33
-
34
- proc.on('error', (err) => {
35
- clearInterval(heartbeat);
36
- reject(err);
37
- });
38
- });
13
+ function readOrCreateOpenClawConfig() {
14
+ const configDir = join(homedir(), '.openclaw');
15
+ const configPath = join(configDir, 'openclaw.json');
16
+
17
+ if (!existsSync(configPath)) {
18
+ mkdirSync(configDir, { recursive: true });
19
+ const minimal = {};
20
+ writeFileSync(configPath, JSON.stringify(minimal, null, 2) + '\n', 'utf-8');
21
+ return minimal;
22
+ }
23
+
24
+ const raw = readFileSync(configPath, 'utf-8');
25
+ return JSON.parse(stripJson5(raw));
39
26
  }
40
27
 
41
28
  /**
42
- * Set robot-resources/auto as the default model in openclaw.json.
29
+ * Trust the Robot Resources plugin in OpenClaw config.
43
30
  *
44
- * This is required so the plugin's before_model_resolve hook fires.
45
- * Without it, OpenClaw sends requests directly to Anthropic and the
46
- * plugin never gets a chance to route.
31
+ * Adds "openclaw-plugin" to plugins.allow so OpenClaw loads it without
32
+ * provenance warnings. The plugin's before_model_resolve hook intercepts
33
+ * ALL LLM calls regardless of the default model — no need to change the
34
+ * default model (which causes LiveSessionModelSwitchError in OC).
47
35
  *
48
36
  * Returns true if the config was updated, false otherwise.
49
37
  */
50
- function activateRouterModel() {
38
+ function trustPlugin() {
51
39
  const configPath = join(homedir(), '.openclaw', 'openclaw.json');
52
40
 
53
41
  try {
54
- const raw = readFileSync(configPath, 'utf-8');
55
- const config = JSON.parse(stripJson5(raw));
56
-
57
- // Ensure agents.defaults.model exists
58
- if (!config.agents) config.agents = {};
59
- if (!config.agents.defaults) config.agents.defaults = {};
60
- if (!config.agents.defaults.model) config.agents.defaults.model = {};
42
+ const config = readOrCreateOpenClawConfig();
61
43
 
62
- const currentPrimary = config.agents.defaults.model.primary;
44
+ if (!config.plugins) config.plugins = {};
45
+ if (!Array.isArray(config.plugins.allow)) config.plugins.allow = [];
63
46
 
64
- // Only change if not already pointing at robot-resources
65
- if (currentPrimary && currentPrimary.startsWith('robot-resources/')) {
47
+ if (config.plugins.allow.includes('openclaw-plugin')) {
66
48
  return false;
67
49
  }
68
50
 
69
- config.agents.defaults.model.primary = 'robot-resources/auto';
51
+ config.plugins.allow.push('openclaw-plugin');
70
52
 
71
53
  writeFileSync(configPath, JSON.stringify(config, null, 2) + '\n', 'utf-8');
72
54
  return true;
73
55
  } catch {
74
- // Config missing or malformed — non-fatal
75
56
  return false;
76
57
  }
77
58
  }
@@ -89,8 +70,7 @@ function registerScraperMcp() {
89
70
  const configPath = join(homedir(), '.openclaw', 'openclaw.json');
90
71
 
91
72
  try {
92
- const raw = readFileSync(configPath, 'utf-8');
93
- const config = JSON.parse(stripJson5(raw));
73
+ const config = readOrCreateOpenClawConfig();
94
74
 
95
75
  if (!config.mcp) config.mcp = {};
96
76
  if (!config.mcp.servers) config.mcp.servers = {};
@@ -141,8 +121,7 @@ function registerPluginEntry() {
141
121
  const configPath = join(homedir(), '.openclaw', 'openclaw.json');
142
122
 
143
123
  try {
144
- const raw = readFileSync(configPath, 'utf-8');
145
- const config = JSON.parse(stripJson5(raw));
124
+ const config = readOrCreateOpenClawConfig();
146
125
 
147
126
  if (!config.plugins) config.plugins = {};
148
127
  if (!config.plugins.entries) config.plugins.entries = {};
@@ -188,9 +167,8 @@ function configureOpenClaw() {
188
167
  // Register plugin in openclaw.json so OC loads it on gateway start.
189
168
  registerPluginEntry();
190
169
 
191
- // Set robot-resources/auto as the default model so the plugin's
192
- // before_model_resolve hook actually fires for every request.
193
- const configActivated = activateRouterModel();
170
+ // Trust the plugin so OC loads it without provenance warnings.
171
+ const configActivated = trustPlugin();
194
172
 
195
173
  return {
196
174
  name: 'OpenClaw',
@@ -245,13 +223,20 @@ export function configureToolRouting() {
245
223
 
246
224
  /**
247
225
  * Restart the OpenClaw gateway so it picks up new plugin + config.
248
- * Uses heartbeat to keep OC sessions alive during the restart.
226
+ * Spawns a detached process that waits 3s then restarts — lets the wizard
227
+ * finish and return output to OC before the gateway dies.
228
+ *
229
+ * If called from inside an OC agent session (exec: npx robot-resources),
230
+ * a synchronous restart kills the session before output is captured.
231
+ * The deferred approach avoids this: wizard exits → OC captures output →
232
+ * 3s later gateway restarts → next conversation has all tools loaded.
249
233
  */
250
234
  async function restartOpenClawGateway() {
251
- await spawnWithHeartbeat('openclaw', ['gateway', 'restart'], {
252
- label: 'Restarting gateway',
253
- timeout: 15_000,
254
- });
235
+ spawn('sh', ['-c', 'sleep 3 && openclaw gateway restart'], {
236
+ stdio: 'ignore',
237
+ detached: true,
238
+ }).unref();
239
+ process.stdout.write(' Restarting gateway...\n');
255
240
  }
256
241
 
257
242
  // Exported for testing and direct use
package/lib/wizard.js CHANGED
@@ -1,8 +1,13 @@
1
+ import { readFileSync, writeFileSync, mkdirSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+ import { homedir, hostname } from 'node:os';
1
4
  import { readConfig, writeConfig } from '@robot-resources/cli-core/config.mjs';
2
5
  import { findPython, isPortAvailable, isHeadless, isOpenClawInstalled } from './detect.js';
6
+ import { getOrCreateMachineId } from './machine-id.js';
3
7
  import { setupRouter, isRouterInstalled, getVenvPythonPath } from './python-bridge.js';
4
8
  import { installService, isServiceRunning, isServiceInstalled } from './service.js';
5
9
  import { configureToolRouting, registerScraperMcp, restartOpenClawGateway } from './tool-config.js';
10
+ import { checkHealth } from './health-report.js';
6
11
  import { header, step, success, warn, error, info, blank, summary } from './ui.js';
7
12
  /**
8
13
  * Main setup wizard. Handles the full onboarding flow:
@@ -36,24 +41,7 @@ export async function runWizard({ nonInteractive = false } = {}) {
36
41
  const config = readConfig();
37
42
  if (!config.api_key && !process.env.RR_API_KEY) {
38
43
  try {
39
- const { hostname } = await import('node:os');
40
- const { join } = await import('node:path');
41
- const { homedir } = await import('node:os');
42
- const { readFileSync, writeFileSync, mkdirSync } = await import('node:fs');
43
- const { randomUUID } = await import('node:crypto');
44
-
45
- const rrDir = join(homedir(), '.robot-resources');
46
- const machineIdPath = join(rrDir, '.machine-id');
47
- let machineId;
48
- try {
49
- machineId = readFileSync(machineIdPath, 'utf-8').trim();
50
- } catch {
51
- machineId = randomUUID();
52
- try {
53
- mkdirSync(rrDir, { recursive: true });
54
- writeFileSync(machineIdPath, machineId, 'utf-8');
55
- } catch { /* non-fatal */ }
56
- }
44
+ const machineId = getOrCreateMachineId();
57
45
 
58
46
  const platformUrl = process.env.RR_PLATFORM_URL || 'https://api.robotresources.ai';
59
47
  const res = await fetch(`${platformUrl}/v1/auth/signup`, {
@@ -184,7 +172,7 @@ export async function runWizard({ nonInteractive = false } = {}) {
184
172
  success(`${r.name}: already configured`);
185
173
  } else if (r.action === 'installed') {
186
174
  success(`${r.name}: plugin installed`);
187
- if (r.configActivated) success(`${r.name}: default model set to robot-resources/auto`);
175
+ if (r.configActivated) success(`${r.name}: plugin trusted in openclaw.json`);
188
176
  if (r.note) info(` ${r.note}`);
189
177
  } else if (r.action === 'instructions') {
190
178
  warn(`${r.name}: manual configuration needed:`);
@@ -218,10 +206,7 @@ export async function runWizard({ nonInteractive = false } = {}) {
218
206
  } else {
219
207
  // Either already registered, or no openclaw.json
220
208
  try {
221
- const { readFileSync: readFs } = await import('node:fs');
222
- const { join: joinPath } = await import('node:path');
223
- const { homedir: home } = await import('node:os');
224
- const ocConfig = JSON.parse(readFs(joinPath(home(), '.openclaw', 'openclaw.json'), 'utf-8'));
209
+ const ocConfig = JSON.parse(readFileSync(join(homedir(), '.openclaw', 'openclaw.json'), 'utf-8'));
225
210
  if (ocConfig?.mcp?.servers?.['robot-resources-scraper']) {
226
211
  success('Scraper MCP already registered in OpenClaw');
227
212
  results.scraper = true;
@@ -354,17 +339,52 @@ export async function runWizard({ nonInteractive = false } = {}) {
354
339
  blank();
355
340
  }
356
341
 
342
+ // ── Best-effort: Single gateway restart ────────────────────────────────
343
+ //
344
+ // Merged from two previous restarts (after plugin install + after scraper
345
+ // registration). Everything is already installed and registered above.
346
+ // If the session dies during this restart, the gateway picks up changes
347
+ // on its next natural restart.
348
+
349
+ if (isOpenClawInstalled() && (results.tools?.some(r => r.action === 'installed') || scraperRegistered)) {
350
+ try {
351
+ await restartOpenClawGateway();
352
+ success('Gateway restart scheduled — tools available in next conversation');
353
+ } catch {
354
+ warn('Gateway restart failed — tools may not be available until OpenClaw restarts.');
355
+ warn('Run manually: openclaw gateway restart');
356
+ }
357
+ }
358
+
359
+ // ── Post-install health check ──────────────────────────────────────────
360
+
361
+ let healthReport = null;
362
+ if (somethingInstalled) {
363
+ try {
364
+ blank();
365
+ step('Running post-install health check...');
366
+ healthReport = await checkHealth();
367
+
368
+ if (healthReport.status === 'healthy') {
369
+ success(`Health: all components healthy`);
370
+ } else if (healthReport.status === 'partial') {
371
+ warn(`Health: ${healthReport.summary}`);
372
+ } else {
373
+ error(`Health: ${healthReport.summary}`);
374
+ }
375
+ } catch {
376
+ // Non-fatal — health check is informational
377
+ }
378
+ }
379
+
357
380
  // ── Status file (persists results for agents that lose the session) ─────
358
381
 
359
382
  if (somethingInstalled) {
360
383
  try {
361
- const { readFileSync: readFs, writeFileSync: writeFs, mkdirSync: mkFs } = await import('node:fs');
362
- const { join: joinP } = await import('node:path');
363
- const { homedir: homeP } = await import('node:os');
364
- const statusDir = joinP(homeP(), '.robot-resources');
365
- mkFs(statusDir, { recursive: true });
366
- const pkgVersion = JSON.parse(readFs(new URL('../package.json', import.meta.url), 'utf-8')).version;
367
- writeFs(joinP(statusDir, 'wizard-status.json'), JSON.stringify({
384
+ const statusDir = join(homedir(), '.robot-resources');
385
+ mkdirSync(statusDir, { recursive: true });
386
+ const pkgVersion = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf-8')).version;
387
+ writeFileSync(join(statusDir, 'wizard-status.json'), JSON.stringify({
368
388
  completed_at: new Date().toISOString(),
369
389
  version: pkgVersion,
370
390
  router: results.router || false,
@@ -372,25 +392,10 @@ export async function runWizard({ nonInteractive = false } = {}) {
372
392
  scraper: results.scraper || false,
373
393
  plugin: results.tools?.some(r => r.action === 'installed') || false,
374
394
  claim_url: results.claimUrl || readConfig().claim_url || null,
375
- }, null, 2) + '\n', 'utf-8');
395
+ health_report: healthReport || null,
396
+ }, null, 2) + '\n', { mode: 0o600, encoding: 'utf-8' });
376
397
  } catch {
377
398
  // Non-fatal — status file is a convenience, not required
378
399
  }
379
400
  }
380
-
381
- // ── Best-effort: Single gateway restart ────────────────────────────────
382
- //
383
- // Merged from two previous restarts (after plugin install + after scraper
384
- // registration). Everything is already installed and registered above.
385
- // If the session dies during this restart, the gateway picks up changes
386
- // on its next natural restart.
387
-
388
- if (isOpenClawInstalled() && (results.tools?.some(r => r.action === 'installed') || scraperRegistered)) {
389
- try {
390
- await restartOpenClawGateway();
391
- success('OpenClaw gateway restarted');
392
- } catch {
393
- // Best-effort — gateway picks up changes on next restart
394
- }
395
- }
396
401
  }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "robot-resources",
3
- "version": "1.7.1",
4
- "description": "Robot Resources — AI agent runtime tools. One command to install everything.",
3
+ "version": "1.7.3",
4
+ "description": "Robot Resources — AI agent tools. One command to install everything.",
5
5
  "type": "module",
6
6
  "bin": {
7
7
  "robot-resources-setup": "./bin/setup.js"