Skill: emberdesire/jasper-configguard (version 1.0.0)

--- name: jasper-configguard version: 1.0.0 description: Safe config changes for OpenClaw with automatic rollback. Backs up before patching, health-checks after restart, auto-rolls back on failure. Commands: patch, restore, list, diff, validate, doctor. --- # Jasper ConfigGuard v1.0.0 Safe config changes for OpenClaw with automatic rollback. Never brick your gateway again. ## Setup ```bash npm install -g jasper-configguard ``` ## Usage ### Apply a config change safely ```bash jasper-configguard patch '{"gateway":{"bind":"tailnet"}}' ``` The tool will: 1. Back up your current config 2. Apply the patch (deep merge) 3. Restart the gateway 4. Wait for health check 5. **Auto-rollback** if gateway fails ### Preview changes ```bash jasper-configguard patch --dry-run '{"agents":{"defaults":{"model":{"primary":"opus"}}}}' ``` ### Restore from backup ```bash jasper-configguard restore ``` ### List backups ```bash jasper-configguard list ``` ### Check health ```bash jasper-configguard doctor ``` ## Agent Integration Use from your agent to safely modify OpenClaw config: ```bash # Safe model switch jasper-configguard patch '{"agents":{"defaults":{"model":{"primary":"anthropic/claude-opus-4-5"}}}}' # Enable a plugin safely jasper-configguard patch '{"plugins":{"entries":{"my-plugin":{"enabled":true}}}}' # If something breaks, restore jasper-configguard restore ``` ## API ```javascript const { ConfigGuard } = require('jasper-configguard'); const guard = new ConfigGuard(); // Safe patch const result = await guard.patch({ gateway: { bind: 'tailnet' } }); if (!result.success) console.log('Rolled back:', result.error); // Dry run const preview = guard.dryRun({ agents: { defaults: { model: { primary: 'opus' } } } }); console.log(preview.diff); ```

path: README.md
content: # 🛡️ jasper-configguard

Safe config changes for [OpenClaw](https://openclaw.ai) with automatic rollback. Never brick your gateway again.

## The Problem

One bad config change can take down your OpenClaw gateway. Missing a comma, wrong model name, invalid plugin path — and suddenly your AI agent is unreachable. You're SSH-ing in at 3am to manually restore a backup you hope exists.

## The Solution

`jasper-configguard` wraps every config change in a safety net:

1. **Backup** current config
2. **Apply** your patch (deep merge)
3. **Restart** the gateway
4. **Health check** (waits up to 30s)
5. **Auto-rollback** if gateway fails to come up

## Install

```bash
npm install -g jasper-configguard
```

Or use directly:

```bash
npx jasper-configguard patch '{"gateway":{"bind":"tailnet"}}'
```

## Usage

### Apply a config change safely

```bash
jasper-configguard patch '{"gateway":{"controlUi":{"enabled":true}}}'
```

### Preview changes (dry run)

```bash
jasper-configguard patch --dry-run '{"agents":{"defaults":{"model":{"primary":"anthropic/claude-sonnet-4-5"}}}}'
```

### From a file

```bash
jasper-configguard patch --file my-changes.json
```

### Restore last backup

```bash
jasper-configguard restore
```

### List backups

```bash
jasper-configguard list
```

### Show config diff

```bash
jasper-configguard diff
```

### Validate config

```bash
jasper-configguard validate
```

### Health check

```bash
jasper-configguard doctor
```

## Programmatic Usage

```javascript
const { ConfigGuard } = require('jasper-configguard');

const guard = new ConfigGuard({
  configPath: '~/.openclaw/openclaw.json',  // auto-detected
  timeout: 30,  // health check timeout in seconds
});

// Safe patch with rollback
const result = await guard.patch({
  gateway: { bind: 'tailnet' }
});

if (result.success) {
  console.log('Applied! Backup:', result.backupId);
} else {
  console.log('Rolled back:', result.error);
}

// Dry run
const preview = guard.dryRun({ agents: { defaults: { model: { primary: 'opus' } } } });
console.log(preview.diff);

// List backups
const backups = guard.listBackups();

// Restore
await guard.restore(backups[0].id);
```

## How It Works

### Deep Merge

Patches are deep-merged, not replaced. This means:

```bash
# This ONLY changes bind, preserving port, auth, etc.
jasper-configguard patch '{"gateway":{"bind":"tailnet"}}'
```

### Arrays Replace

Arrays are **replaced entirely** (not merged). This matches OpenClaw's `config.patch` behavior:

```bash
# This replaces the ENTIRE agents.list — include all agents!
jasper-configguard patch '{"agents":{"list":[{"id":"main"},{"id":"worker"}]}}'
```

### Auto-Rollback

If the gateway doesn't respond within 30 seconds after restart:

1. Previous config is restored from backup
2. Gateway is restarted again
3. Script reports the failure

### Backups

- Stored in `~/.openclaw/config-backups/`
- Timestamped: `openclaw.json.<unix-timestamp>`
- Last 20 kept (auto-pruned)
- Manual restore: `jasper-configguard restore <timestamp>`

## Options

| Flag | Description |
|------|-------------|
| `--config <path>` | Path to openclaw.json (auto-detected) |
| `--timeout <secs>` | Health check timeout (default: 30) |
| `--dry-run` | Preview changes without applying |
| `--no-restart` | Apply without restarting gateway |
| `--verbose` | Detailed output |

## Requirements

- Node.js 18+
- OpenClaw gateway running on localhost:18789
- Write access to `~/.openclaw/`

## License

MIT — [E.x.O. Entertainment Studios](https://exohaven.online)


path: cli/configguard.js
content: #!/usr/bin/env node

/**
 * jasper-configguard — Safe OpenClaw config changes with automatic rollback
 * 
 * Usage:
 *   jasper-configguard patch '{"gateway":{"controlUi":{"enabled":true}}}'
 *   jasper-configguard patch --file patch.json
 *   jasper-configguard restore [backup-id]
 *   jasper-configguard list
 *   jasper-configguard diff [backup-id]
 *   jasper-configguard validate [config-path]
 *   jasper-configguard setup
 *   jasper-configguard --version
 */

const { ConfigGuard } = require('../src/index.js');
const fs = require('fs');
const path = require('path');

const VERSION = require('../package.json').version;

const HELP = `
🛡️  jasper-configguard v${VERSION}
Safe config changes for OpenClaw with automatic rollback.

USAGE:
  jasper-configguard <command> [options]

COMMANDS:
  patch <json|--file path>   Apply a config patch with safety net
  restore [id]               Restore a backup (latest if no id)
  list                       List available backups
  diff [id]                  Show diff between current and backup
  validate [path]            Validate a config file
  setup                      Initialize backup directory
  doctor                     Check gateway health + config validity

OPTIONS:
  --config <path>     Path to openclaw.json (auto-detected)
  --timeout <secs>    Health check timeout (default: 30)
  --dry-run           Show what would change without applying
  --no-restart        Apply config without restarting gateway
  --verbose           Show detailed output
  --version           Show version
  --help              Show this help

EXAMPLES:
  # Apply a config change safely
  jasper-configguard patch '{"gateway":{"bind":"lan"}}'

  # Preview changes without applying
  jasper-configguard patch --dry-run '{"agents":{"defaults":{"model":{"primary":"anthropic/claude-sonnet-4-5"}}}}'

  # Restore last known good config
  jasper-configguard restore

  # Show what changed since last backup
  jasper-configguard diff
`;

async function main() {
  const args = process.argv.slice(2);
  
  if (args.includes('--version') || args.includes('-v')) {
    console.log(VERSION);
    return;
  }
  
  if (args.length === 0 || args.includes('--help') || args.includes('-h')) {
    console.log(HELP);
    return;
  }

  const command = args[0];
  const flags = parseFlags(args.slice(1));
  const guard = new ConfigGuard({
    configPath: flags.config,
    timeout: parseInt(flags.timeout || '30', 10),
    verbose: flags.verbose || false,
  });

  try {
    switch (command) {
      case 'patch':
        await handlePatch(guard, args.slice(1), flags);
        break;
      case 'restore':
        await handleRestore(guard, args[1], flags);
        break;
      case 'list':
        await handleList(guard);
        break;
      case 'diff':
        await handleDiff(guard, args[1]);
        break;
      case 'validate':
        await handleValidate(guard, args[1]);
        break;
      case 'setup':
        await handleSetup(guard);
        break;
      case 'doctor':
        await handleDoctor(guard);
        break;
      default:
        // Maybe it's inline JSON
        if (command.startsWith('{')) {
          await handlePatch(guard, args, flags);
        } else {
          console.error(`Unknown command: ${command}\nRun 'jasper-configguard --help' for usage.`);
          process.exit(1);
        }
    }
  } catch (err) {
    console.error(`\n❌ ${err.message}`);
    if (flags.verbose) console.error(err.stack);
    process.exit(1);
  }
}

function parseFlags(args) {
  const flags = {};
  for (let i = 0; i < args.length; i++) {
    if (args[i] === '--config' && args[i + 1]) { flags.config = args[++i]; }
    else if (args[i] === '--timeout' && args[i + 1]) { flags.timeout = args[++i]; }
    else if (args[i] === '--file' && args[i + 1]) { flags.file = args[++i]; }
    else if (args[i] === '--dry-run') { flags.dryRun = true; }
    else if (args[i] === '--no-restart') { flags.noRestart = true; }
    else if (args[i] === '--verbose') { flags.verbose = true; }
  }
  return flags;
}

async function handlePatch(guard, args, flags) {
  let patch;
  
  if (flags.file) {
    const filePath = path.resolve(flags.file);
    if (!fs.existsSync(filePath)) throw new Error(`File not found: ${filePath}`);
    patch = JSON.parse(fs.readFileSync(filePath, 'utf8'));
  } else {
    // Find the JSON argument
    const jsonArg = args.find(a => a.startsWith('{') || a.startsWith('"'));
    if (!jsonArg) throw new Error('No JSON patch provided. Use: patch \'{"key":"value"}\' or --file path.json');
    patch = JSON.parse(jsonArg);
  }

  if (flags.dryRun) {
    const result = await guard.dryRun(patch);
    console.log('\n🔍 Dry run — changes that would be applied:\n');
    console.log(result.diff);
    console.log('\nNo changes made. Remove --dry-run to apply.');
    return;
  }

  const result = await guard.patch(patch, { 
    restart: !flags.noRestart,
    verbose: flags.verbose 
  });
  
  if (result.success) {
    console.log(`\n✅ Config updated successfully`);
    console.log(`   Backup: ${result.backupId}`);
    console.log(`   Rollback: jasper-configguard restore ${result.backupId}`);
  } else {
    console.log(`\n⚠️  Config rolled back — gateway failed health check`);
    console.log(`   Error: ${result.error}`);
    process.exit(1);
  }
}

async function handleRestore(guard, backupId) {
  const result = await guard.restore(backupId);
  if (result.success) {
    console.log(`\n✅ Restored from backup: ${result.backupId}`);
    console.log(`   Gateway health: ${result.healthy ? 'healthy' : 'check manually'}`);
  } else {
    console.error(`\n❌ Restore failed: ${result.error}`);
    process.exit(1);
  }
}

async function handleList(guard) {
  const backups = guard.listBackups();
  if (backups.length === 0) {
    console.log('\nNo backups found. Run a patch to create one.');
    return;
  }
  console.log(`\n📦 Config backups (${backups.length}):\n`);
  for (const b of backups) {
    const age = timeSince(b.timestamp);
    console.log(`  ${b.id}  ${b.date}  (${age} ago)  ${b.size}`);
  }
  console.log(`\nRestore: jasper-configguard restore <id>`);
}

async function handleDiff(guard, backupId) {
  const diff = guard.diff(backupId);
  if (!diff) {
    console.log('\nNo differences found (or no backup to compare).');
    return;
  }
  console.log('\n📋 Config diff:\n');
  console.log(diff);
}

async function handleValidate(guard, configPath) {
  const result = guard.validate(configPath);
  if (result.valid) {
    console.log('\n✅ Config is valid JSON');
    if (result.warnings.length > 0) {
      console.log('\n⚠️  Warnings:');
      result.warnings.forEach(w => console.log(`   - ${w}`));
    }
  } else {
    console.error(`\n❌ Invalid config: ${result.error}`);
    process.exit(1);
  }
}

async function handleSetup(guard) {
  guard.setup();
  console.log('\n✅ ConfigGuard initialized');
  console.log(`   Config: ${guard.configPath}`);
  console.log(`   Backups: ${guard.backupDir}`);
  console.log(`   Max backups: ${guard.maxBackups}`);
}

async function handleDoctor(guard) {
  console.log('\n🏥 ConfigGuard Doctor\n');
  
  // Check config exists
  const configExists = fs.existsSync(guard.configPath);
  console.log(`  Config file: ${configExists ? '✅' : '❌'} ${guard.configPath}`);
  
  // Validate JSON
  if (configExists) {
    const result = guard.validate();
    console.log(`  Valid JSON: ${result.valid ? '✅' : '❌'}`);
  }
  
  // Check backups
  const backups = guard.listBackups();
  console.log(`  Backups: ${backups.length > 0 ? '✅' : '⚠️ '} ${backups.length} available`);
  
  // Check gateway health
  const healthy = await guard.healthCheck();
  console.log(`  Gateway: ${healthy ? '✅ healthy' : '❌ not responding'}`);
  
  // Check gateway process
  const running = guard.isGatewayRunning();
  console.log(`  Process: ${running ? '✅ running' : '❌ not found'}`);
}

function timeSince(timestamp) {
  // timestamp is unix seconds, Date.now() is ms
  const seconds = Math.floor((Date.now() / 1000) - timestamp);
  if (seconds < 60) return `${seconds}s`;
  if (seconds < 3600) return `${Math.floor(seconds / 60)}m`;
  if (seconds < 86400) return `${Math.floor(seconds / 3600)}h`;
  return `${Math.floor(seconds / 86400)}d`;
}

main().catch(err => {
  console.error(`Fatal: ${err.message}`);
  process.exit(1);
});


path: package.json
content: {
  "name": "jasper-configguard",
  "version": "1.0.0",
  "description": "🛡️ Safe config changes for OpenClaw with automatic rollback. Never brick your gateway again.",
  "main": "src/index.js",
  "bin": {
    "jasper-configguard": "./cli/configguard.js",
    "safe-config-patch": "./cli/configguard.js"
  },
  "scripts": {
    "test": "node test/configguard.test.js",
    "cli": "node cli/configguard.js"
  },
  "keywords": [
    "openclaw",
    "config",
    "safety",
    "rollback",
    "gateway",
    "ai-agent",
    "devops"
  ],
  "author": "E.x.O. Entertainment Studios <[REDACTED_SECRET]>",
  "license": "MIT",
  "repository": {
    "type": "git",
    "url": "https://github.com/E-x-O-Entertainment-Studios-Inc/jasper-configguard"
  },
  "homepage": "https://exohaven.online/products/jasper-configguard",
  "engines": {
    "node": ">=18.0.0"
  }
}


path: src/index.js
content: /**
 * jasper-configguard — Safe OpenClaw config management
 * 
 * Core library for config patching with automatic rollback.
 */

const fs = require('fs');
const path = require('path');
const { execSync } = require('child_process');
const http = require('http');

const DEFAULT_CONFIG_PATHS = [
  path.join(process.env.HOME || '', '.openclaw', 'openclaw.json'),
  path.join(process.cwd(), 'openclaw.json'),
];

const DEFAULT_BACKUP_DIR = path.join(process.env.HOME || '', '.openclaw', 'config-backups');
const DEFAULT_GATEWAY_URL = 'http://localhost:18789';
const MAX_BACKUPS = 20;

class ConfigGuard {
  constructor(options = {}) {
    this.configPath = options.configPath || this._findConfig();
    this.backupDir = options.backupDir || DEFAULT_BACKUP_DIR;
    this.gatewayUrl = options.gatewayUrl || DEFAULT_GATEWAY_URL;
    this.timeout = options.timeout || 30;
    this.maxBackups = options.maxBackups || MAX_BACKUPS;
    this.verbose = options.verbose || false;
    
    // Ensure backup dir exists
    if (!fs.existsSync(this.backupDir)) {
      fs.mkdirSync(this.backupDir, { recursive: true });
    }
  }

  /**
   * Apply a config patch with automatic rollback on failure.
   * 
   * @param {object} patch - JSON object to deep merge into config
   * @param {object} opts - { restart: true, verbose: false }
   * @returns {{ success: boolean, backupId?: string, error?: string }}
   */
  async patch(patch, opts = {}) {
    const { restart = true, verbose = false } = opts;
    
    // 1. Read current config
    const currentConfig = this._readConfig();
    
    // 2. Create backup
    const backupId = this._createBackup(currentConfig);
    this._log(`Backed up to: ${backupId}`, verbose);
    
    // 3. Deep merge
    const newConfig = deepMerge(currentConfig, patch);
    
    // 4. Validate result
    try {
      JSON.stringify(newConfig);
    } catch (e) {
      return { success: false, error: 'Merged config is not valid JSON', backupId };
    }
    
    // 5. Write new config
    this._writeConfig(newConfig);
    this._log('Config written', verbose);
    
    if (!restart) {
      return { success: true, backupId };
    }
    
    // 6. Restart gateway
    this._log('Restarting gateway...', verbose);
    this._restartGateway();
    
    // 7. Health check
    this._log(`Waiting for health (${this.timeout}s timeout)...`, verbose);
    const healthy = await this._waitForHealth();
    
    if (healthy) {
      this._pruneBackups();
      return { success: true, backupId };
    }
    
    // 8. ROLLBACK
    this._log('❌ Gateway unhealthy — rolling back!', true);
    this._restoreBackup(backupId);
    this._restartGateway();
    
    // Wait for restored config to come up
    const recoveredHealth = await this._waitForHealth();
    
    return {
      success: false,
      backupId,
      error: `Gateway failed health check. Rolled back to ${backupId}. Recovery: ${recoveredHealth ? 'OK' : 'MANUAL INTERVENTION NEEDED'}`,
      rolledBack: true,
      recoveryHealthy: recoveredHealth,
    };
  }

  /**
   * Preview what a patch would change (dry run).
   */
  dryRun(patch) {
    const current = this._readConfig();
    const merged = deepMerge(current, patch);
    const diff = jsonDiff(current, merged);
    return { diff, current, merged };
  }

  /**
   * Restore a specific backup (or latest).
   */
  async restore(backupId) {
    try {
      if (!backupId) {
        const backups = this.listBackups();
        if (backups.length === 0) return { success: false, error: 'No backups available' };
        backupId = backups[0].id;
      }
      
      this._restoreBackup(backupId);
      this._restartGateway();
      const healthy = await this._waitForHealth();
      
      return { success: true, backupId, healthy };
    } catch (err) {
      return { success: false, error: err.message };
    }
  }

  /**
   * List available backups, newest first.
   */
  listBackups() {
    if (!fs.existsSync(this.backupDir)) return [];
    
    return fs.readdirSync(this.backupDir)
      .filter(f => f.startsWith('openclaw.json.'))
      .map(f => {
        const filePath = path.join(this.backupDir, f);
        const stat = fs.statSync(filePath);
        const timestamp = parseInt(f.split('.').pop(), 10);
        return {
          id: f.replace('openclaw.json.', ''),
          file: f,
          path: filePath,
          timestamp,
          date: new Date(timestamp * 1000).toISOString().replace('T', ' ').slice(0, 19),
          size: formatBytes(stat.size),
        };
      })
      .sort((a, b) => b.timestamp - a.timestamp);
  }

  /**
   * Show diff between current config and a backup.
   */
  diff(backupId) {
    const current = this._readConfig();
    
    if (!backupId) {
      const backups = this.listBackups();
      if (backups.length === 0) return null;
      backupId = backups[0].id;
    }
    
    const backupPath = path.join(this.backupDir, `openclaw.json.${backupId}`);
    if (!fs.existsSync(backupPath)) return null;
    
    const backup = JSON.parse(fs.readFileSync(backupPath, 'utf8'));
    return jsonDiff(backup, current);
  }

  /**
   * Validate a config file.
   */
  validate(configPath) {
    const filePath = configPath || this.configPath;
    const warnings = [];
    
    try {
      const content = fs.readFileSync(filePath, 'utf8');
      const config = JSON.parse(content);
      
      // Basic structure checks
      if (!config.gateway) warnings.push('Missing "gateway" section');
      if (!config.agents) warnings.push('Missing "agents" section');
      if (!config.agents?.defaults?.model) warnings.push('No default model configured');
      if (config.agents?.list && !Array.isArray(config.agents.list)) {
        return { valid: false, error: '"agents.list" must be an array' };
      }
      
      return { valid: true, warnings };
    } catch (err) {
      if (err.code === 'ENOENT') return { valid: false, error: `File not found: ${filePath}` };
      return { valid: false, error: `Invalid JSON: ${err.message}` };
    }
  }

  /**
   * Initialize backup directory.
   */
  setup() {
    if (!fs.existsSync(this.backupDir)) {
      fs.mkdirSync(this.backupDir, { recursive: true });
    }
    // Create initial backup
    if (fs.existsSync(this.configPath)) {
      const config = this._readConfig();
      this._createBackup(config);
    }
  }

  /**
   * Check if gateway is responding.
   */
  async healthCheck() {
    return this._checkHealth();
  }

  /**
   * Check if gateway process is running.
   */
  isGatewayRunning() {
    try {
      execSync('pgrep -f openclaw-gateway', { stdio: 'pipe' });
      return true;
    } catch {
      return false;
    }
  }

  // --- Internal methods ---

  _findConfig() {
    for (const p of DEFAULT_CONFIG_PATHS) {
      if (fs.existsSync(p)) return p;
    }
    return DEFAULT_CONFIG_PATHS[0]; // default even if doesn't exist yet
  }

  _readConfig() {
    const content = fs.readFileSync(this.configPath, 'utf8');
    return JSON.parse(content);
  }

  _writeConfig(config) {
    fs.writeFileSync(this.configPath, JSON.stringify(config, null, 2) + '\n');
  }

  _createBackup(config) {
    const timestamp = Math.floor(Date.now() / 1000);
    const backupFile = `openclaw.json.${timestamp}`;
    const backupPath = path.join(this.backupDir, backupFile);
    fs.writeFileSync(backupPath, JSON.stringify(config, null, 2) + '\n');
    return String(timestamp);
  }

  _restoreBackup(backupId) {
    const backupPath = path.join(this.backupDir, `openclaw.json.${backupId}`);
    if (!fs.existsSync(backupPath)) throw new Error(`Backup not found: ${backupId}`);
    const content = fs.readFileSync(backupPath, 'utf8');
    JSON.parse(content); // validate
    fs.writeFileSync(this.configPath, content);
  }

  _restartGateway() {
    try {
      execSync('kill -USR1 $(pgrep -f openclaw-gateway)', { stdio: 'pipe', timeout: 5000 });
    } catch {
      // Process might not exist, that's fine
    }
  }

  async _waitForHealth() {
    const start = Date.now();
    const deadline = start + (this.timeout * 1000);
    
    // Initial delay for restart
    await sleep(3000);
    
    while (Date.now() < deadline) {
      if (await this._checkHealth()) return true;
      await sleep(2000);
    }
    return false;
  }

  _checkHealth() {
    return new Promise((resolve) => {
      const req = http.get(`${this.gatewayUrl}/`, { timeout: 3000 }, (res) => {
        resolve(res.statusCode === 200);
      });
      req.on('error', () => resolve(false));
      req.on('timeout', () => { req.destroy(); resolve(false); });
    });
  }

  _pruneBackups() {
    const backups = this.listBackups();
    if (backups.length > this.maxBackups) {
      for (const old of backups.slice(this.maxBackups)) {
        try { fs.unlinkSync(old.path); } catch {}
      }
    }
  }

  _log(msg, force = false) {
    if (this.verbose || force) console.log(`[configguard] ${msg}`);
  }
}

// --- Utilities ---

function deepMerge(target, source) {
  const result = { ...target };
  for (const key of Object.keys(source)) {
    if (source[key] && typeof source[key] === 'object' && !Array.isArray(source[key])) {
      result[key] = deepMerge(result[key] || {}, source[key]);
    } else {
      result[key] = source[key];
    }
  }
  return result;
}

function jsonDiff(a, b, prefix = '', _topLevel = true) {
  const lines = [];
  const allKeys = new Set([...Object.keys(a || {}), ...Object.keys(b || {})]);
  
  for (const key of allKeys) {
    const keyPath = prefix ? `${prefix}.${key}` : key;
    const aVal = a?.[key];
    const bVal = b?.[key];
    
    if (aVal === undefined && bVal !== undefined) {
      lines.push(`+ ${keyPath}: ${JSON.stringify(bVal)}`);
    } else if (aVal !== undefined && bVal === undefined) {
      lines.push(`- ${keyPath}: ${JSON.stringify(aVal)}`);
    } else if (aVal && bVal && typeof aVal === 'object' && typeof bVal === 'object' && !Array.isArray(aVal) && !Array.isArray(bVal)) {
      const nested = jsonDiff(aVal, bVal, keyPath, false);
      lines.push(...nested);
    } else if (JSON.stringify(aVal) !== JSON.stringify(bVal)) {
      lines.push(`- ${keyPath}: ${JSON.stringify(aVal)}`);
      lines.push(`+ ${keyPath}: ${JSON.stringify(bVal)}`);
    }
  }
  return _topLevel ? lines.join('\n') : lines;
}

function formatBytes(bytes) {
  if (bytes < 1024) return `${bytes}B`;
  if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)}KB`;
  return `${(bytes / (1024 * 1024)).toFixed(1)}MB`;
}

function sleep(ms) {
  return new Promise(resolve => setTimeout(resolve, ms));
}

module.exports = { ConfigGuard, deepMerge, jsonDiff };


path: test/configguard.test.js
content: #!/usr/bin/env node

/**
 * Tests for jasper-configguard
 */

const { ConfigGuard, deepMerge, jsonDiff } = require('../src/index.js');
const fs = require('fs');
const path = require('path');
const os = require('os');

let passed = 0;
let failed = 0;

function test(name, fn) {
  try {
    fn();
    console.log(`  ✅ ${name}`);
    passed++;
  } catch (err) {
    console.error(`  ❌ ${name}: ${err.message}`);
    failed++;
  }
}

function assert(condition, msg) {
  if (!condition) throw new Error(msg || 'Assertion failed');
}

function assertEqual(a, b, msg) {
  const aStr = JSON.stringify(a);
  const bStr = JSON.stringify(b);
  if (aStr !== bStr) throw new Error(msg || `Expected ${bStr}, got ${aStr}`);
}

// --- Setup temp dir ---
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'configguard-test-'));
const configPath = path.join(tmpDir, 'openclaw.json');
const backupDir = path.join(tmpDir, 'backups');

const baseConfig = {
  gateway: { port: 18789, bind: 'lan' },
  agents: { defaults: { model: { primary: 'anthropic/claude-sonnet-4-5' } }, list: [{ id: 'main' }] },
};

fs.writeFileSync(configPath, JSON.stringify(baseConfig, null, 2));

console.log('\n🛡️  jasper-configguard tests\n');

// --- deepMerge tests ---
console.log('deepMerge:');

test('merges flat objects', () => {
  const result = deepMerge({ a: 1, b: 2 }, { b: 3, c: 4 });
  assertEqual(result, { a: 1, b: 3, c: 4 });
});

test('deep merges nested objects', () => {
  const result = deepMerge(
    { gateway: { port: 18789, bind: 'lan' } },
    { gateway: { bind: 'tailnet' } }
  );
  assertEqual(result, { gateway: { port: 18789, bind: 'tailnet' } });
});

test('replaces arrays (does not merge)', () => {
  const result = deepMerge(
    { agents: { list: [{ id: 'main' }] } },
    { agents: { list: [{ id: 'main' }, { id: 'worker' }] } }
  );
  assertEqual(result.agents.list.length, 2);
});

test('adds new nested keys', () => {
  const result = deepMerge(
    { gateway: { port: 18789 } },
    { gateway: { controlUi: { enabled: true } } }
  );
  assertEqual(result.gateway.controlUi.enabled, true);
  assertEqual(result.gateway.port, 18789);
});

test('handles null source values', () => {
  const result = deepMerge({ a: 1 }, { a: null });
  assertEqual(result.a, null);
});

// --- jsonDiff tests ---
console.log('\njsonDiff:');

test('detects added keys', () => {
  const diff = jsonDiff({ a: 1 }, { a: 1, b: 2 });
  assert(diff.includes('+ b: 2'), 'Should show added key');
});

test('detects removed keys', () => {
  const diff = jsonDiff({ a: 1, b: 2 }, { a: 1 });
  assert(diff.includes('- b: 2'), 'Should show removed key');
});

test('detects changed values', () => {
  const diff = jsonDiff({ a: 1 }, { a: 2 });
  assert(diff.includes('- a: 1'), 'Should show old value');
  assert(diff.includes('+ a: 2'), 'Should show new value');
});

test('no diff for identical objects', () => {
  const diff = jsonDiff({ a: 1 }, { a: 1 });
  assertEqual(diff, '');
});

// --- ConfigGuard tests ---
console.log('\nConfigGuard:');

test('initializes with auto-detected paths', () => {
  const guard = new ConfigGuard({ configPath, backupDir });
  assert(guard.configPath === configPath);
  assert(guard.backupDir === backupDir);
});

test('setup creates backup dir', () => {
  const guard = new ConfigGuard({ configPath, backupDir });
  guard.setup();
  assert(fs.existsSync(backupDir));
});

test('validate catches valid config', () => {
  const guard = new ConfigGuard({ configPath, backupDir });
  const result = guard.validate();
  assert(result.valid, 'Should be valid');
});

test('validate catches invalid JSON', () => {
  const badPath = path.join(tmpDir, 'bad.json');
  fs.writeFileSync(badPath, 'not json{{{');
  const guard = new ConfigGuard({ configPath: badPath, backupDir });
  const result = guard.validate();
  assert(!result.valid, 'Should be invalid');
});

test('validate catches missing file', () => {
  const guard = new ConfigGuard({ configPath: '/nonexistent/path.json', backupDir });
  const result = guard.validate();
  assert(!result.valid, 'Should be invalid');
});

test('listBackups returns empty initially', () => {
  const emptyBackupDir = path.join(tmpDir, 'empty-backups');
  const guard = new ConfigGuard({ configPath, backupDir: emptyBackupDir });
  const backups = guard.listBackups();
  assertEqual(backups.length, 0);
});

test('dryRun shows changes without writing', () => {
  const guard = new ConfigGuard({ configPath, backupDir });
  const result = guard.dryRun({ gateway: { bind: 'tailnet' } });
  assert(result.diff.includes('tailnet'), 'Should show new value');
  // Config should be unchanged
  const config = JSON.parse(fs.readFileSync(configPath, 'utf8'));
  assertEqual(config.gateway.bind, 'lan');
});

test('patch without restart writes config', async () => {
  const guard = new ConfigGuard({ configPath, backupDir });
  const result = await guard.patch({ gateway: { newKey: 'test' } }, { restart: false });
  assert(result.success, 'Should succeed');
  assert(result.backupId, 'Should have backup ID');
  const config = JSON.parse(fs.readFileSync(configPath, 'utf8'));
  assertEqual(config.gateway.newKey, 'test');
  assertEqual(config.gateway.port, 18789); // preserved
});

test('restore reverts to backup', async () => {
  const guard = new ConfigGuard({ configPath, backupDir });
  // Patch something
  await guard.patch({ gateway: { changed: true } }, { restart: false });
  // Get latest backup
  const backups = guard.listBackups();
  assert(backups.length > 0, 'Should have backups');
  // Restore
  fs.writeFileSync(configPath, JSON.stringify(baseConfig, null, 2)); // reset
  guard._restoreBackup(backups[0].id);
  // Verify
  const config = JSON.parse(fs.readFileSync(configPath, 'utf8'));
  assert(config.gateway, 'Should have gateway');
});

test('diff shows changes between backup and current', () => {
  const guard = new ConfigGuard({ configPath, backupDir });
  // Modify config
  const config = JSON.parse(fs.readFileSync(configPath, 'utf8'));
  config.gateway.bind = 'modified';
  fs.writeFileSync(configPath, JSON.stringify(config, null, 2));
  // Check diff
  const diff = guard.diff();
  // Should show something (we have backups from previous tests)
  assert(typeof diff === 'string');
});

// --- Cleanup ---
fs.rmSync(tmpDir, { recursive: true, force: true });

// --- Results ---
console.log(`\n────────────────────────────────────────`);
console.log(`Results: ${passed} passed, ${failed} failed`);
console.log(`────────────────────────────────────────\n`);

process.exit(failed > 0 ? 1 : 0);


Security scan verdicts — ClawScan: clean | static analysis: suspicious | VirusTotal: clean | SkillSpector: clean (LOW)