#!/usr/bin/env node /** * Smoke test for ruvnet/ruflo#2132 — Windows-compatible init hook generation. * * Verifies that `ruflo init` generates a .claude/settings.json containing * node-based hook commands (no /bin/bash, no POSIX pipelines), and that the * platform detection correctly distinguishes Windows from POSIX. * * Runs on: ubuntu-latest, macos-latest, windows-latest (CI matrix) * * On windows-latest: asserts settings.json has node-based hooks * On ubuntu/macos: asserts settings.json has POSIX-compatible hooks and that * no Windows-specific cmd.exe / %USERPROFILE% patterns appear */ import { spawnSync } from 'node:child_process'; import { mkdtempSync, readFileSync, existsSync } from 'node:fs'; import { join } from 'node:path'; import { tmpdir } from 'node:os'; import { fileURLToPath } from 'node:url'; import { dirname, resolve } from 'node:path'; const __dirname = dirname(fileURLToPath(import.meta.url)); const REPO_ROOT = resolve(__dirname, '..'); const CLI_BIN = join(REPO_ROOT, 'v3', '@claude-flow', 'cli', 'bin', 'cli.js'); const IS_WINDOWS = process.platform === 'win32'; let passed = 0; let failed = 0; function assert(condition, message) { if (condition) { console.log(` pass: ${message}`); passed++; } else { console.error(` FAIL: ${message}`); failed++; } } function assertNot(condition, message) { assert(!condition, message); } // Run ruflo init in a temporary directory const tmpDir = mkdtempSync(join(tmpdir(), 'ruflo-smoke-win-init-')); console.log(`Running ruflo init in: ${tmpDir}`); const result = spawnSync( process.execPath, [CLI_BIN, 'init', '--yes', '--skip-prompts', '--no-install'], { cwd: tmpDir, env: { ...process.env, // Ensure non-interactive mode regardless of TTY CI: 'true', FORCE_COLOR: '0', }, encoding: 'utf8', // iter 123 → 131 — bumped from 180s to 300s. macos-latest observed // running exactly 180s before the timer fired (CI cold ONNX download // + agentic-flow init + MCP server spawn). 300s leaves room for the // smoke's assertion phase under the job's 10-min cap. timeout: 300_000, // Don't fail if the command exits non-zero — init may partially succeed } ); console.log('init exit code:', result.status); if (result.stderr && result.stderr.trim()) { console.log('init stderr (first 500 chars):', result.stderr.slice(0, 500)); } const settingsPath = join(tmpDir, '.claude', 'settings.json'); if (!existsSync(settingsPath)) { // Try alternate location (some init modes write to cwd/.claude/) console.log('settings.json not found, listing tmp dir:'); try { const { readdirSync } = await import('node:fs'); const entries = readdirSync(tmpDir, { recursive: true }); entries.slice(0, 20).forEach(e => console.log(' ', e)); } catch { /* ignore */ } console.error('FAIL: settings.json not generated by ruflo init'); process.exit(1); } const settingsText = readFileSync(settingsPath, 'utf8'); let settings; try { settings = JSON.parse(settingsText); } catch (e) { console.error('FAIL: settings.json is not valid JSON:', e.message); process.exit(1); } console.log(`\nPlatform: ${process.platform}`); console.log('Verifying settings.json hook commands...\n'); // Collect all hook commands for inspection const allCommands = []; function collectCommands(obj, path) { if (!obj || typeof obj !== 'object') return; if (Array.isArray(obj)) { obj.forEach((item, i) => collectCommands(item, `${path}[${i}]`)); return; } if (typeof obj.command === 'string') { allCommands.push({ command: obj.command, path }); } for (const [k, v] of Object.entries(obj)) { collectCommands(v, `${path}.${k}`); } } if (settings.hooks) { collectCommands(settings.hooks, 'hooks'); } console.log(`Found ${allCommands.length} hook command(s) in settings.json`); allCommands.slice(0, 5).forEach(({ command, path }) => { console.log(` [${path}]: ${command.slice(0, 80)}...`); }); if (IS_WINDOWS) { console.log('\n--- Windows assertions ---'); // On Windows: all hook commands must be node-based (no /bin/bash, no | jq) assert(allCommands.length > 0, 'settings.json has at least one hook command'); for (const { command, path } of allCommands) { assertNot( /\/bin\/bash\b/.test(command), `[${path}] has no /bin/bash literal` ); assertNot( /\/bin\/sh\b/.test(command), `[${path}] has no /bin/sh literal` ); assertNot( /\|\s*jq\b/.test(command), `[${path}] has no pipe-to-jq` ); assertNot( /\.sh\b/.test(command), `[${path}] has no .sh script reference` ); } // Check that hook-handler.cjs was deployed const handlerPath = join(tmpDir, '.claude', 'helpers', 'hook-handler.cjs'); assert(existsSync(handlerPath), '.claude/helpers/hook-handler.cjs exists'); // Check that ruflo-hook.cjs was deployed (new in #2132) const shimPath = join(tmpDir, '.claude', 'helpers', 'ruflo-hook.cjs'); assert(existsSync(shimPath), '.claude/helpers/ruflo-hook.cjs exists (#2132)'); } else { console.log('\n--- POSIX (Mac/Linux) assertions ---'); // On POSIX: settings.json may use sh/node hybrid; must NOT have Windows cmd.exe patterns assert(allCommands.length > 0, 'settings.json has at least one hook command'); for (const { command, path } of allCommands) { assertNot( /cmd\s+\/c\b/.test(command) && /%USERPROFILE%/.test(command), `[${path}] has no Windows-only cmd.exe + %USERPROFILE% pattern` ); } // hook-handler.cjs must still be deployed on POSIX const handlerPath = join(tmpDir, '.claude', 'helpers', 'hook-handler.cjs'); assert(existsSync(handlerPath), '.claude/helpers/hook-handler.cjs exists'); // ruflo-hook.cjs is always deployed now (cross-platform shim always available) const shimPath = join(tmpDir, '.claude', 'helpers', 'ruflo-hook.cjs'); assert(existsSync(shimPath), '.claude/helpers/ruflo-hook.cjs exists (#2132)'); } console.log(`\nResults: ${passed} passed, ${failed} failed`); if (failed > 0) { process.exit(1); } console.log('ok: smoke-windows-init-hooks passed');