Skill: prince-cool/claw-janitor (version 1.1.0)

# SKILL: claw-janitor ## Description A safe, zero-dependency, platform-aware system cleanup skill for OpenClaw. Use when disk space is low or cleanup is requested. ## Rules & Constraints - Prefer `--dry-run` first when user confidence is low. - Never force sudo/root; only do elevated/system cleanup when user explicitly wants it. - Respect built-in safety boundaries (blacklists, symlink/mount protections, AI-cache no-auto-delete). ## Command ```bash node /root/.openclaw/workspace/skills/claw-janitor/janitor.js [flags] ``` ## Flags - `--dry-run`: preview only - `--deep`: aggressive cleanup mode - `--json`: structured output - `--no-color`: disable ANSI color - `--report-file <path>`: write JSON summary to file - `--only <group>`: run only one group (`ai-scan|packages|docker|system`) - `--skip <group>`: skip one group (`ai-scan|packages|docker|system`) - `-h, --help`: show usage ## Examples - “Clean up my disk” - `node /root/.openclaw/workspace/skills/claw-janitor/janitor.js` - “Show me first” - `node /root/.openclaw/workspace/skills/claw-janitor/janitor.js --dry-run` - “Only clean package caches” - `node /root/.openclaw/workspace/skills/claw-janitor/janitor.js --dry-run --only packages` - “Generate audit report” - `node /root/.openclaw/workspace/skills/claw-janitor/janitor.js --dry-run --json --report-file /tmp/janitor-report.json`

path: CHANGELOG.md
content: # Changelog

## 1.1.0
- Added strict subtree blacklist sealing (workspace/CWD-sensitive trees)
- Hardened mount-point-safe deletion path
- Added recursive defense-in-depth safety recheck in `safeRemoveDir`
- Added command existence checks before execution (`which/where`)
- Added `--no-color` output mode for CI/log snapshots
- Added stable JSON sorting for deterministic audits
- Added `--only` / `--skip` group control
- Added `--report-file` JSON audit output
- Added `--help`
- Added/expanded automated tests (`test-janitor.js`)
- Added `SECURITY.md`


path: README.md
content: # claw-janitor

A safe, transparent, zero-dependency, multi-platform system cleaner for OpenClaw.

## Why this exists

`claw-janitor` is built for **paranoid-safe cleanup**:
- conservative defaults
- explicit safety boundaries
- no hidden dependencies
- auditable output

## Safety highlights

- **Blacklist sealing** for sensitive paths (`.ssh`, `.aws`, `.gnupg`, `.kube`, `.openclaw/workspace`, CWD tree, system roots)
- **Regex protection** for risky segments (`.git`, `.env`, `node_modules`, etc.)
- **Symlink protection** (never traverses/deletes through symlinks)
- **Mount-point protection** (never crosses filesystem device boundaries)
- **AI cache awareness**: Hugging Face/Ollama/Torch are scanned and alerted, **never auto-deleted**
- **Graceful degradation**: skips locked/denied targets without hard failure

For detailed boundaries, see [`SECURITY.md`](./SECURITY.md).

## Installation

```bash
clawhub install claw-janitor
```

## Usage

```bash
node /path/to/skills/claw-janitor/janitor.js [options]
```

### Common examples

```bash
# Safe cleanup
node janitor.js

# Preview only
node janitor.js --dry-run

# Deep cleanup (more aggressive docker/system pruning)
node janitor.js --deep

# JSON output for automation
node janitor.js --dry-run --json

# Disable color for CI logs
node janitor.js --dry-run --no-color

# Scope by group
node janitor.js --dry-run --only packages
node janitor.js --dry-run --skip docker

# Write audit report to file
node janitor.js --dry-run --json --report-file ./janitor-report.json
```

### Options

- `--dry-run` Preview only, no deletion
- `--deep` More aggressive cleanup
- `--json` Machine-readable output
- `--no-color` Disable ANSI colors
- `--report-file <path>` Write JSON report to file
- `--only <group>` Run only one group: `ai-scan|packages|docker|system`
- `--skip <group>` Skip one group: `ai-scan|packages|docker|system`
- `-h, --help` Show help

## Testing

```bash
npm test
npm run test:dry
```

## License

MIT


path: SECURITY.md
content: # claw-janitor Security Notes

## Safety boundaries

`claw-janitor` is designed to be conservative by default.

- Never follows symlinks during size scan or deletion.
- Never crosses mount/device boundaries in recursive deletion.
- Blocks sensitive paths using exact + tree blacklist rules.
- Blocks dangerous path segments like `.git`, `.env`, `.ssh`, `.kube`, `node_modules`.
- Seals current working directory tree and OpenClaw workspace tree.

## Not cleaned automatically

- Hugging Face / Ollama / Torch model caches are only scanned and reported (not auto-deleted).
- Protected directories and descendants (e.g. workspace, ssh/config trees).

## Principle

If a target is ambiguous or risky, skip it.

## Recommended usage

- Use `--dry-run` first.
- Use `--report-file` for auditing.
- Use `--only` / `--skip` to scope execution.


path: janitor.js
content: /**
 * claw-janitor
 * A safe, multi-platform system cleaner designed for OpenClaw & AI Dev Environments.
 * 
 * Safety & Architecture Rules:
 * 1. ZERO external dependencies. Must run on raw Node.js.
 * 2. Whitelist driven. Hardcoded blacklists for extreme safety.
 * 3. Graceful degradation: If a permission is denied, it skips silently.
 * 4. Symlink protection: Does not traverse or delete through symbolic links.
 * 5. Cross-Platform Root protection: Adapts to Windows (C:\) and POSIX (/).
 * 6. AI Model Awareness: Detects massive AI caches but NEVER deletes them automatically.
 * 7. Workspace Sealing: Will never modify the current working directory or OpenClaw workspace.
 * 8. Env-Aware: Respects XDG_CACHE_HOME, NVM_DIR, HF_HOME, etc.
 * 9. Perf-Optimized: Uses withFileTypes for fast directory traversal.
 * 10. Mount-Point Protection: Stops traversal if it detects crossing into a different mounted device.
 */

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

const args = process.argv.slice(2);

function getArgValue(flag) {
    const idx = args.indexOf(flag);
    if (idx === -1) return null;
    return args[idx + 1] && !args[idx + 1].startsWith('--') ? args[idx + 1] : null;
}

const DRY_RUN = args.includes('--dry-run');
const DEEP_CLEAN = args.includes('--deep');
const JSON_OUT = args.includes('--json');
const NO_COLOR = args.includes('--no-color') || process.env.NO_COLOR === '1';
const REPORT_FILE = getArgValue('--report-file');
const ONLY_GROUP = (getArgValue('--only') || '').trim();
const SKIP_GROUP = (getArgValue('--skip') || '').trim();
const HELP = args.includes('--help') || args.includes('-h');

const HOME = os.homedir();
const PLATFORM = os.platform();
const IS_ROOT = process.getuid ? process.getuid() === 0 : false;
const CWD = process.cwd();

// Environment fallbacks
const NVM_DIR = process.env.NVM_DIR || path.join(HOME, '.nvm');
const HF_HOME = process.env.HF_HOME || path.join(HOME, '.cache', 'huggingface');
const OLLAMA_MODELS = process.env.OLLAMA_MODELS || path.join(HOME, '.ollama', 'models');
const CACHE_HOME = process.env.XDG_CACHE_HOME || path.join(HOME, '.cache');

// Dynamically determine system root
const SYS_ROOT = path.parse(HOME).root;

// Node version compat for fs.rmSync
const nodeMajor = parseInt(process.versions.node.split('.')[0], 10);
const FS_RM_SUPPORTED = nodeMajor >= 14;

// ==========================================
// 🛡️ SECURITY: ABSOLUTE BLACKLIST & SEALING
// ==========================================
const BLACKLIST_TREE = [
    path.join(HOME, '.ssh'),
    path.join(HOME, '.aws'),
    path.join(HOME, '.gnupg'),
    path.join(HOME, '.config'),
    path.join(HOME, '.local', 'share'),
    path.join(HOME, '.kube'),
    path.join(HOME, '.openclaw', 'workspace'), // Seal OpenClaw workspace
    CWD, // Seal current working directory tree
    path.join(SYS_ROOT, 'etc'),
    path.join(SYS_ROOT, 'boot'),
    path.join(SYS_ROOT, 'var'),
    path.join(SYS_ROOT, 'usr'),
    path.join(SYS_ROOT, 'bin'),
    path.join(SYS_ROOT, 'Windows'),
    path.join(SYS_ROOT, 'Program Files'),
    path.join(SYS_ROOT, 'Program Files (x86)')
].map((p) => path.resolve(p));

const BLACKLIST_EXACT = [
    path.resolve(SYS_ROOT),
    path.resolve(HOME),
    path.resolve(path.join(HOME, '.docker', 'config.json'))
];

const BLACKLIST_REGEX = /([\\/]\.git[\\/]|[\\/]\.env($|[\\/])|[\\/]\.ssh[\\/]|[\\/]\.kube[\\/]|[\\/]node_modules[\\/])/i;

function isSafeToClean(targetPath) {
    if (!targetPath) return false;
    const normalized = path.resolve(targetPath);

    for (const blacklisted of BLACKLIST_EXACT) {
        if (normalized === blacklisted) return false;
    }

    for (const blacklisted of BLACKLIST_TREE) {
        if (normalized === blacklisted || normalized.startsWith(blacklisted + path.sep)) {
            return false;
        }
    }

    if (BLACKLIST_REGEX.test(normalized + path.sep)) return false;
    return true;
}

// ==========================================
// 🛠️ UTILS & PERF & MOUNT PROTECTION
// ==========================================
let totalSavedBytes = 0;
let report = [];
let aiAlerts = [];

const VALID_GROUPS = new Set(['ai-scan', 'packages', 'docker', 'system']);

function shouldRunGroup(group) {
    if (!VALID_GROUPS.has(group)) return false;
    if (ONLY_GROUP && group !== ONLY_GROUP) return false;
    if (SKIP_GROUP && group === SKIP_GROUP) return false;
    return true;
}

function formatBytes(bytes) {
    if (bytes === 0) return '0 B';
    const k = 1024;
    const sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
    const i = Math.floor(Math.log(bytes) / Math.log(k));
    return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
}

// Optimized directory size calculation with Mount Point boundary protection
function getDirSize(dirPath, rootDev = null) {
    let size = 0;
    try {
        if (!fs.existsSync(dirPath)) return 0;
        
        const stat = fs.lstatSync(dirPath);
        if (stat.isSymbolicLink()) return 0; 
        
        // Mount point protection (do not cross device boundaries)
        if (rootDev === null) {
            rootDev = stat.dev;
        } else if (stat.dev !== rootDev) {
            return 0; // Crossed a mount point, stop traversing this branch
        }

        if (stat.isFile()) return stat.size;

        const entries = fs.readdirSync(dirPath, { withFileTypes: true });
        for (const entry of entries) {
            const fullPath = path.join(dirPath, entry.name);
            try {
                if (entry.isDirectory()) {
                    size += getDirSize(fullPath, rootDev);
                } else if (entry.isFile()) {
                    const childStat = fs.statSync(fullPath);
                    if (childStat.dev === rootDev) {
                         size += childStat.size;
                    }
                }
            } catch(subE) {
                // Ignore transient file locks
            }
        }
    } catch (e) {
        return 0; 
    }
    return size;
}

function safeRemoveDir(targetPath, rootDev) {
    try {
        if (!fs.existsSync(targetPath)) return;
        // Defense in depth: re-check safety at every recursion level.
        if (!isSafeToClean(targetPath)) return;

        const stat = fs.lstatSync(targetPath);

        if (stat.isSymbolicLink()) return;
        if (stat.dev !== rootDev) return; // Never cross mount point

        if (stat.isDirectory()) {
            const entries = fs.readdirSync(targetPath, { withFileTypes: true });
            for (const entry of entries) {
                safeRemoveDir(path.join(targetPath, entry.name), rootDev);
            }
            try { fs.rmdirSync(targetPath); } catch (e) {}
        } else {
            try { fs.unlinkSync(targetPath); } catch (e) {}
        }
    } catch (e) {
        // ignore locked/vanished paths
    }
}

function removePath(targetPath, description) {
    if (!fs.existsSync(targetPath)) return;
    
    let rootDev = null;
    try {
        const stat = fs.lstatSync(targetPath);
        if (stat.isSymbolicLink()) {
             logAction('SKIP', `Symlink detected (Protected): ${targetPath}`, 0);
             return;
        }
        rootDev = stat.dev;
    } catch(e) { return; }

    if (!isSafeToClean(targetPath)) {
        logAction('SKIP', `Blacklisted/Workspace path: ${targetPath}`, 0);
        return;
    }

    const size = getDirSize(targetPath, rootDev);
    
    if (DRY_RUN) {
        logAction('DRY-RUN', `Would delete ${description}: ${targetPath}`, size);
        totalSavedBytes += size;
        return;
    }

    try {
        // Always use mount-aware deletion to avoid crossing filesystem boundaries.
        // Keep fs.rmSync support only for non-directory paths.
        if (FS_RM_SUPPORTED && fs.lstatSync(targetPath).isFile()) {
            fs.rmSync(targetPath, { force: true, maxRetries: 3, retryDelay: 100 });
        } else {
            safeRemoveDir(targetPath, rootDev);
        }

        logAction('CLEANED', `${description}: ${targetPath}`, size);
        totalSavedBytes += size;
    } catch (e) {
        logAction('WARN', `Failed to delete ${targetPath} (Locked or Denied)`, 0);
    }
}

function hasCommand(bin) {
    try {
        if (PLATFORM === 'win32') {
            execSync(`where ${bin}`, { stdio: 'ignore', timeout: 5000 });
        } else {
            execSync(`command -v ${bin}`, { stdio: 'ignore', timeout: 5000 });
        }
        return true;
    } catch {
        return false;
    }
}

function execCommand(cmd, description, fallbackSize = 0) {
    const bin = cmd.split(' ')[0];
    if (!hasCommand(bin)) {
        logAction('SKIP', `Command not installed: ${bin}`, 0);
        return;
    }

    try {
        if (DRY_RUN) {
            logAction('DRY-RUN', `Would execute: ${cmd} (${description})`, 0);
            return;
        }

        const output = execSync(cmd, { stdio: ['ignore', 'pipe', 'ignore'], encoding: 'utf-8', timeout: 60000 });

        let estimatedSize = fallbackSize;
        if (cmd.includes('docker') && output.includes('Total reclaimed space:')) {
            const match = output.match(/Total reclaimed space: ([\d\.]+) ([A-Z]+)/);
            if (match) {
                const val = parseFloat(match[1]);
                const unit = match[2];
                if (unit === 'GB') estimatedSize = val * 1024 * 1024 * 1024;
                if (unit === 'MB') estimatedSize = val * 1024 * 1024;
                if (unit === 'KB') estimatedSize = val * 1024;
            }
        }

        logAction('EXEC', `${description}`, estimatedSize);
        totalSavedBytes += estimatedSize;
    } catch (e) {
        logAction('SKIP', `Command unavailable or failed: ${bin}`, 0);
    }
}

function colorize(text, colorCode) {
    if (NO_COLOR) return text;
    return `${colorCode}${text}\x1b[0m`;
}

function logAction(status, message, size) {
    const sizeStr = size > 0 ? ` [${formatBytes(size)}]` : '';
    report.push({ status, message, size });
    if (!JSON_OUT) {
        let color = '\x1b[0m';
        if (status === 'CLEANED' || status === 'EXEC') color = '\x1b[32m';
        if (status === 'DRY-RUN') color = '\x1b[36m';
        if (status === 'WARN') color = '\x1b[33m';
        if (status === 'SKIP') color = '\x1b[90m';
        console.log(`${colorize(`[${status}]`, color)} ${message}${sizeStr}`);
    }
}

// ==========================================
// 🤖 AI AWARENESS: PASSIVE SCAN ONLY
// ==========================================
function scanAITitans() {
    const aiPaths = [
        { path: HF_HOME, name: 'Hugging Face Cache' },
        { path: OLLAMA_MODELS, name: 'Ollama Models' },
        { path: path.join(CACHE_HOME, 'torch'), name: 'PyTorch Cache' }
    ];

    for (const item of aiPaths) {
        if (fs.existsSync(item.path)) {
            const stat = fs.statSync(item.path);
            const size = getDirSize(item.path, stat.dev); // Safe scan
            if (size > 1024 * 1024 * 500) { // Alert if > 500MB
                aiAlerts.push(`Found massive AI cache: ${item.name} (${formatBytes(size)}) at ${item.path}. Untouched for safety. Handle manually if needed.`);
            }
        }
    }
}

// ==========================================
// 🚀 CLEANUP ROUTINES
// ==========================================
function cleanPackageManagers() {
    execCommand('npm cache clean --force', 'NPM Cache');
    execCommand('yarn cache clean', 'Yarn Cache');
    execCommand('pnpm store prune', 'PNPM Store');
    execCommand('bun pm cache rm', 'Bun Cache');
    
    removePath(path.join(NVM_DIR, '.cache'), 'NVM Version Zip Cache');
    execCommand('pip cache purge', 'Python PIP Cache');
    execCommand('go clean -cache -modcache', 'Go Module Cache');
    removePath(path.join(HOME, '.cargo', 'registry', 'cache'), 'Rust Cargo Cache');
    
    if (PLATFORM === 'win32') {
        removePath(path.join(process.env.LOCALAPPDATA || '', 'ms-playwright'), 'Playwright Browser Binaries (Win)');
        removePath(path.join(process.env.LOCALAPPDATA || '', 'puppeteer'), 'Puppeteer Browser Binaries (Win)');
    } else {
        removePath(path.join(CACHE_HOME, 'ms-playwright'), 'Playwright Browser Binaries');
        removePath(path.join(CACHE_HOME, 'puppeteer'), 'Puppeteer Browser Binaries');
    }
    removePath(path.join(os.tmpdir(), 'openclaw-tmp'), 'OpenClaw Temp Artifacts');
}

function cleanDocker() {
    try {
        execSync('docker ps', { stdio: 'ignore', timeout: 5000 });
        if (DEEP_CLEAN) {
            execCommand('docker system prune -a -f', 'Docker System Prune (All Unused Images & Containers)');
            execCommand('docker builder prune -a -f', 'Docker Buildx/BuildKit Cache (Deep)');
        } else {
            execCommand('docker system prune -f', 'Docker System Prune (Dangling Images Only)');
            execCommand('docker builder prune -f', 'Docker Buildx/BuildKit Cache (Dangling)');
        }
    } catch (e) {
        logAction('SKIP', 'Docker not found or daemon not running', 0);
    }
}

function cleanSystem() {
    if (PLATFORM === 'linux') {
        if (!IS_ROOT) {
            logAction('SKIP', 'Linux System-level cleanup skipped (Run with sudo/root to enable)', 0);
            return;
        }
        if (fs.existsSync('/usr/bin/apt-get')) {
            execCommand('apt-get clean', 'APT Package Cache');
            execCommand('apt-get autoremove -y', 'APT Autoremove Unused Dependencies');
        }
        if (fs.existsSync('/usr/bin/yum')) {
            execCommand('yum clean all', 'YUM Package Cache');
        }
        if (fs.existsSync('/bin/journalctl')) {
            execCommand('journalctl --vacuum-time=3d', 'Systemd Journal Cleanup (Keep 3 days)');
        }
    } else if (PLATFORM === 'darwin') {
        removePath(path.join(HOME, 'Library', 'Caches', 'CocoaPods'), 'macOS CocoaPods Cache');
        try {
            execSync('which brew', { stdio: 'ignore' });
            execCommand('brew cleanup', 'Homebrew Cache');
            execCommand('brew autoremove', 'Homebrew Unused Dependencies');
        } catch(e) {
            logAction('SKIP', 'Homebrew not found on macOS', 0);
        }
    }
}

function printHelp() {
    console.log(`claw-janitor\n\nUsage:\n  node janitor.js [options]\n\nOptions:\n  --dry-run               Preview only, do not delete\n  --deep                  More aggressive cleanup (docker/images etc.)\n  --json                  Output machine-readable JSON\n  --no-color              Disable ANSI colors\n  --report-file <path>    Write JSON report to file\n  --only <group>          Run only one group: ai-scan|packages|docker|system\n  --skip <group>          Skip one group: ai-scan|packages|docker|system\n  -h, --help              Show this help\n`);
}

function run() {
    if (HELP) {
        printHelp();
        return;
    }

    if ((ONLY_GROUP && !VALID_GROUPS.has(ONLY_GROUP)) || (SKIP_GROUP && !VALID_GROUPS.has(SKIP_GROUP))) {
        const bad = ONLY_GROUP && !VALID_GROUPS.has(ONLY_GROUP) ? ONLY_GROUP : SKIP_GROUP;
        throw new Error(`Invalid group: ${bad}. Use one of: ${Array.from(VALID_GROUPS).join(', ')}`);
    }

    if (!JSON_OUT) {
        console.log(`\n🧹 Starting ${colorize('claw-janitor', '\x1b[36m')}...`);
        if (DRY_RUN) console.log(`⚠️  ${colorize('DRY RUN MODE', '\x1b[33m')} - No files will be modified.`);
        if (ONLY_GROUP) console.log(`ℹ️  Running only group: ${ONLY_GROUP}`);
        if (SKIP_GROUP) console.log(`ℹ️  Skipping group: ${SKIP_GROUP}`);
        if (PLATFORM === 'linux' && !IS_ROOT) console.log(`ℹ️  Running as non-root. Deep system files will be skipped safely.\n`);
        else console.log();
    }

    if (shouldRunGroup('ai-scan')) scanAITitans();
    if (shouldRunGroup('packages')) cleanPackageManagers();
    if (shouldRunGroup('docker')) cleanDocker();
    if (shouldRunGroup('system')) cleanSystem();

    const sortedActions = report
        .map((a) => ({ message: a.message, size: a.size, status: a.status }))
        .sort((a, b) => (a.status + a.message).localeCompare(b.status + b.message));

    const summary = {
        actions: sortedActions,
        aiCacheAlerts: [...aiAlerts].sort((a, b) => a.localeCompare(b)),
        deepClean: DEEP_CLEAN,
        dryRun: DRY_RUN,
        isRoot: IS_ROOT,
        onlyGroup: ONLY_GROUP || null,
        platform: PLATFORM,
        reclaimedBytes: totalSavedBytes,
        reclaimedFormatted: formatBytes(totalSavedBytes),
        skipGroup: SKIP_GROUP || null
    };

    if (REPORT_FILE) {
        try {
            fs.writeFileSync(path.resolve(REPORT_FILE), JSON.stringify(summary, null, 2));
            if (!JSON_OUT) console.log(`📝 Report written to: ${path.resolve(REPORT_FILE)}`);
        } catch (e) {
            logAction('WARN', `Failed to write report file: ${REPORT_FILE}`, 0);
        }
    }

    if (!JSON_OUT) {
        if (aiAlerts.length > 0) {
            console.log(`\n🤖 ${colorize('AI CACHE RADAR', '\x1b[35m')}`);
            aiAlerts.forEach(alert => console.log(`   ${colorize(alert, '\x1b[33m')}`));
        }

        console.log(`\n=================================================`);
        console.log(`✨ Total Space ${DRY_RUN ? 'Can Be Reclaimed' : 'Reclaimed'}: ${colorize(formatBytes(totalSavedBytes), '\x1b[32m')}`);
        console.log(`=================================================\n`);
    } else {
        console.log(JSON.stringify(summary, null, 2));
    }
}

if (require.main === module) {
    run();
} else {
    module.exports = {
        cleanPackageManagers,
        cleanDocker,
        cleanSystem,
        run,
        __private: {
            isSafeToClean,
            getDirSize,
            safeRemoveDir,
            shouldRunGroup,
            BLACKLIST_EXACT,
            BLACKLIST_TREE,
            BLACKLIST_REGEX,
            VALID_GROUPS
        }
    };
}

path: package.json
content: {
  "name": "claw-janitor",
  "version": "1.1.0",
  "description": "A safe, transparent, and multi-platform system cleaner designed for OpenClaw.",
  "main": "janitor.js",
  "scripts": {
    "start": "node janitor.js",
    "test": "node test-janitor.js",
    "test:dry": "node janitor.js --dry-run"
  },
  "keywords": [
    "openclaw",
    "agent-skill",
    "system-cleaner",
    "disk-space",
    "docker-prune",
    "janitor",
    "transparent",
    "open-source"
  ],
  "author": "Prince & Pi",
  "license": "MIT"
}

path: test-janitor.js
content: const assert = require('assert');
const fs = require('fs');
const os = require('os');
const path = require('path');
const { execFileSync } = require('child_process');

const janitor = require('./janitor.js');
const { isSafeToClean, getDirSize, safeRemoveDir, VALID_GROUPS } = janitor.__private;

function mkTmp(name) {
  return fs.mkdtempSync(path.join(os.tmpdir(), `cj-${name}-`));
}

function runTests() {
  // 1) Blacklist tree protection
  {
    const blocked = path.resolve(path.join(os.homedir(), '.openclaw', 'workspace', 'x'));
    assert.strictEqual(isSafeToClean(blocked), false, 'workspace subtree must be blocked');
  }

  // 2) Regex protection (.env/.git)
  {
    const envPath = path.resolve('/tmp/project/.env');
    const gitPath = path.resolve('/tmp/project/.git/config');
    assert.strictEqual(isSafeToClean(envPath), false, '.env path must be blocked');
    assert.strictEqual(isSafeToClean(gitPath), false, '.git path must be blocked');
  }

  // 3) Symlink safety in getDirSize
  {
    const base = mkTmp('symlink');
    const target = path.join(base, 'target.txt');
    const link = path.join(base, 'link.txt');
    fs.writeFileSync(target, '1234567890');
    fs.symlinkSync(target, link);
    const size = getDirSize(base);
    assert(size >= 10, 'real file should count');
    assert(size < 1000, 'symlink should not explode size');
    fs.rmSync(base, { recursive: true, force: true });
  }

  // 4) safeRemoveDir basic behavior
  {
    const base = mkTmp('remove');
    const child = path.join(base, 'a', 'b');
    fs.mkdirSync(child, { recursive: true });
    fs.writeFileSync(path.join(child, 'f.txt'), 'ok');
    const dev = fs.lstatSync(base).dev;
    safeRemoveDir(base, dev);
    assert.strictEqual(fs.existsSync(base), false, 'safeRemoveDir should remove tree on same device');
  }

  // 5) CWD sealing
  {
    const cwdChild = path.join(process.cwd(), 'tmp-test-child');
    assert.strictEqual(isSafeToClean(cwdChild), false, 'cwd subtree must be blocked');
  }

  // 6) Dry-run snapshot sanity
  {
    const out = execFileSync(process.execPath, [path.join(__dirname, 'janitor.js'), '--dry-run'], {
      encoding: 'utf8',
      stdio: ['ignore', 'pipe', 'pipe']
    });
    assert(out.includes('DRY RUN MODE'), 'dry-run banner missing');
    assert(out.includes('Total Space Can Be Reclaimed'), 'dry-run summary missing');
  }

  // 7) no-color output should not contain ANSI escapes
  {
    const out = execFileSync(process.execPath, [path.join(__dirname, 'janitor.js'), '--dry-run', '--no-color'], {
      encoding: 'utf8',
      stdio: ['ignore', 'pipe', 'pipe']
    });
    assert(!/\x1b\[[0-9;]*m/.test(out), 'no-color output contains ANSI escape codes');
  }

  // 8) json mode should be parseable and stable keys present
  {
    const out = execFileSync(process.execPath, [path.join(__dirname, 'janitor.js'), '--dry-run', '--json'], {
      encoding: 'utf8',
      stdio: ['ignore', 'pipe', 'pipe']
    });
    const parsed = JSON.parse(out);
    assert(Array.isArray(parsed.actions), 'json actions must be an array');
    assert(Object.prototype.hasOwnProperty.call(parsed, 'reclaimedBytes'), 'json reclaimedBytes missing');
  }

  // 9) only/skip groups and report file
  {
    assert(VALID_GROUPS.has('packages'), 'packages group missing');
    const reportPath = path.join(mkTmp('report'), 'out.json');
    execFileSync(process.execPath, [
      path.join(__dirname, 'janitor.js'),
      '--dry-run',
      '--json',
      '--only', 'packages',
      '--skip', 'docker',
      '--report-file', reportPath
    ], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] });

    const report = JSON.parse(fs.readFileSync(reportPath, 'utf8'));
    assert.strictEqual(report.onlyGroup, 'packages', 'onlyGroup not set in report');
    assert.strictEqual(report.skipGroup, 'docker', 'skipGroup not set in report');
  }

  // 10) safeRemoveDir should re-check blacklist during recursion
  {
    const base = mkTmp('nested-blacklist');
    const safeDir = path.join(base, 'cache');
    const blockedDir = path.join(safeDir, '.git');
    fs.mkdirSync(blockedDir, { recursive: true });
    fs.writeFileSync(path.join(blockedDir, 'config'), 'protected');
    fs.writeFileSync(path.join(safeDir, 'normal.txt'), 'normal');

    const dev = fs.lstatSync(base).dev;
    safeRemoveDir(safeDir, dev);

    assert.strictEqual(fs.existsSync(path.join(blockedDir, 'config')), true, '.git subtree must remain protected');
    // cleanup tmp root to avoid leftovers
    fs.rmSync(base, { recursive: true, force: true });
  }

  // 11) help output
  {
    const out = execFileSync(process.execPath, [path.join(__dirname, 'janitor.js'), '--help'], {
      encoding: 'utf8',
      stdio: ['ignore', 'pipe', 'pipe']
    });
    assert(out.includes('Usage:'), 'help usage missing');
    assert(out.includes('--report-file'), 'help options missing');
  }

  console.log('claw-janitor tests: PASS');
}

runTests();


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