UNPKG

aiwg

Version:

Deployment tool and support utility for AI context. Copies agents, skills, commands, rules, and behaviors into the paths each AI platform reads (Claude Code, Codex, Copilot, Cursor, Warp, OpenClaw, and 6 more) so one source of truth works across 10 platfo

297 lines 11.9 kB
/** * Diagnose Command Handler * * `aiwg diagnose` — produces a shareable support bundle for bug reports. * * Bundle contents: * - manifest.json environment fingerprint + collection timestamp * - logs/aiwg-YYYY-MM-DD.jsonl last 7 days of JSONL logs (or last 1000 lines, smaller) * - config.json sanitized copy of .aiwg/aiwg.config (if present) * - git.txt `git log -10 --oneline` + branch + remote (if in a repo) * - doctor.txt output of `aiwg doctor` (best-effort) * * Sanitization (unless --include-secrets): * - Strip environment variables matching *SECRET*, *TOKEN*, *PASS*, *KEY* * - Replace any absolute path containing ~/.ssh with <redacted> * - Remove API tokens from config.json (any field name ending in token/secret/key) * * Output modes: * --tarball (default) write aiwg-diagnose-YYYYMMDDHHMMSS.tar.gz in cwd * --stdout write a single-file JSON manifest to stdout (for pasting into issues) * * @implements #925 */ import { getVersionInfo } from '../../channel/manager.mjs'; import { getLoggerInfo } from '../log.js'; import { readAiwgConfig, getProjectDir } from '../../config/aiwg-config.js'; import { getLogger } from '../log.js'; import { spawnSync } from 'child_process'; import { readFileSync, writeFileSync, existsSync, statSync, readdirSync, mkdtempSync, rmSync } from 'fs'; import path from 'path'; import os from 'os'; import * as ui from '../ui.js'; const SECRET_ENV_PATTERNS = [/SECRET/i, /TOKEN/i, /PASS/i, /_KEY$/i, /API_KEY/i, /PRIVATE/i]; const SECRET_FIELD_PATTERNS = [/token/i, /secret/i, /key$/i, /password/i, /api[-_]?key/i]; export const diagnoseHandler = { id: 'diagnose', name: 'Diagnose', description: 'Produce a shareable support bundle (logs + env + config) for bug reports', category: 'maintenance', aliases: [], async execute(ctx) { const stdoutMode = ctx.args.includes('--stdout'); const includeSecrets = ctx.args.includes('--include-secrets'); const cwd = getProjectDir(ctx, ctx.args); const log = getLogger('cli:diagnose'); const span = log.span('diagnose', { stdoutMode, includeSecrets }); const bundle = await collectBundle({ cwd, includeSecrets }); span.end('bundle-collected', { logs: bundle.logs.length, has_config: bundle.config !== null, has_git: bundle.git !== null, }); if (stdoutMode) { console.log(JSON.stringify(bundle, null, 2)); return { exitCode: 0 }; } // Tarball mode: write each file into a tmpdir, tar it up, clean up. const outName = `aiwg-diagnose-${new Date().toISOString().replace(/[-:.]/g, '').slice(0, 15)}.tar.gz`; const outPath = path.resolve(cwd, outName); const stage = mkdtempSync(path.join(os.tmpdir(), 'aiwg-diagnose-')); try { // manifest.json writeFileSync(path.join(stage, 'manifest.json'), JSON.stringify(bundle.manifest, null, 2) + '\n'); // config.json if (bundle.config) writeFileSync(path.join(stage, 'config.json'), JSON.stringify(bundle.config, null, 2) + '\n'); // git.txt if (bundle.git) writeFileSync(path.join(stage, 'git.txt'), bundle.git); // doctor.txt if (bundle.doctor) writeFileSync(path.join(stage, 'doctor.txt'), bundle.doctor); // env.txt (sanitized) writeFileSync(path.join(stage, 'env.txt'), bundle.manifest.env.map((kv) => `${kv[0]}=${kv[1]}`).join('\n') + '\n'); // logs/*.jsonl if (bundle.logs.length > 0) { const logsDir = path.join(stage, 'logs'); require('fs').mkdirSync(logsDir, { recursive: true }); for (const f of bundle.logs) { writeFileSync(path.join(logsDir, f.name), f.content); } } // tar it up via system tar. We rely on GNU/BSD tar being present; // degrade gracefully if not. const tar = spawnSync('tar', ['-czf', outPath, '-C', stage, '.'], { stdio: 'pipe' }); if (tar.status !== 0) { ui.error(`tar failed: ${tar.stderr?.toString() ?? '(no stderr)'}`); ui.info(`Bundle contents staged at: ${stage}`); ui.info(`You can tar it manually: tar -czf ${outName} -C ${stage} .`); return { exitCode: 1, error: new Error('tar failed') }; } ui.success(`Diagnose bundle written to: ${outPath}`); const size = statSync(outPath).size; ui.dim(` Size: ${(size / 1024).toFixed(1)} KB`); ui.dim(` Attach to bug reports at https://git.integrolabs.net/roctinam/aiwg/issues`); if (!includeSecrets) { ui.dim(` Sanitized: env vars matching SECRET/TOKEN/PASS/KEY stripped, config secrets redacted`); ui.dim(` (pass --include-secrets to skip sanitization, but please inspect the bundle first)`); } } finally { rmSync(stage, { recursive: true, force: true }); } return { exitCode: 0 }; }, }; async function collectBundle(opts) { const versionInfo = await getVersionInfo(); const loggerInfo = getLoggerInfo(); // Sanitized env — strip secret-looking values unless opts.includeSecrets. const env = Object.entries(process.env) .filter(([k]) => k.startsWith('AIWG_') || k === 'NODE_ENV' || k === 'CI' || k === 'TERM' || k === 'LANG' || k === 'TZ' || k === 'PATH') .map(([k, v]) => { if (!opts.includeSecrets && SECRET_ENV_PATTERNS.some(r => r.test(k))) { return [k, '<redacted>']; } return [k, redactPath(v ?? '', opts.includeSecrets)]; }) .sort(([a], [b]) => a.localeCompare(b)); // Project config — stripped of secret-looking fields. let config = null; try { const raw = await readAiwgConfig(opts.cwd); if (raw) { const asRecord = raw; config = opts.includeSecrets ? asRecord : redactSecretsInObject(asRecord); } } catch { /* ignore */ } // Git state — best-effort. const git = collectGitInfo(opts.cwd); // Doctor output — best-effort, spawn aiwg doctor locally. const doctor = collectDoctorOutput(); // Logs — last 7 days of JSONL files, capped at 1 MB total. const logs = collectRecentLogs(loggerInfo.logFile, 7, 1_000_000, opts.includeSecrets); return { manifest: { collected_at: new Date().toISOString(), version: versionInfo.version, channel: versionInfo.devMode ? 'dev' : versionInfo.channel, packageRoot: versionInfo.packageRoot, ...(versionInfo.gitHash ? { git: { sha: versionInfo.gitHash, branch: versionInfo.gitBranch ?? '(unknown)', path: versionInfo.edgePath ?? versionInfo.packageRoot, }, } : {}), node: process.version, platform: { os: process.platform, arch: process.arch, release: loggerInfo.provenance.os_release, }, tty: { stdin: !!process.stdin.isTTY, stdout: !!process.stdout.isTTY, stderr: !!process.stderr.isTTY, }, logger: { level: loggerInfo.level, logFile: loggerInfo.logFile, disabled: loggerInfo.disabled, }, invocation_id: loggerInfo.provenance.invocation_id, env, sanitized: !opts.includeSecrets, }, config, git, doctor, logs, }; } function redactPath(v, includeSecrets) { if (includeSecrets) return v; // Hide paths under ~/.ssh (common leak source for token files). return v.replace(new RegExp(path.join(os.homedir(), '\\.ssh', '[^:\\s]*'), 'g'), '<redacted>'); } function redactSecretsInObject(obj) { if (typeof obj !== 'object' || obj === null) return obj; const out = Array.isArray(obj) ? [] : {}; for (const [k, v] of Object.entries(obj)) { if (SECRET_FIELD_PATTERNS.some(r => r.test(k))) { out[k] = '<redacted>'; } else if (typeof v === 'object' && v !== null) { out[k] = redactSecretsInObject(v); } else { out[k] = v; } } return out; } function collectGitInfo(cwd) { try { const branch = spawnSync('git', ['-C', cwd, 'rev-parse', '--abbrev-ref', 'HEAD'], { stdio: 'pipe', timeout: 5000 }); const log = spawnSync('git', ['-C', cwd, 'log', '-10', '--oneline'], { stdio: 'pipe', timeout: 5000 }); const remote = spawnSync('git', ['-C', cwd, 'remote', '-v'], { stdio: 'pipe', timeout: 5000 }); if (branch.status !== 0) return null; return [ `branch: ${branch.stdout.toString().trim()}`, '', 'recent commits:', log.stdout.toString().trim(), '', 'remotes:', remote.stdout.toString().trim(), ].join('\n'); } catch { return null; } } function collectDoctorOutput() { try { const r = spawnSync(process.execPath, [process.argv[1], 'doctor'], { stdio: 'pipe', timeout: 30_000, env: { ...process.env, AIWG_LOG_DISABLE: '1', NO_COLOR: '1' }, }); const out = r.stdout?.toString() ?? ''; const err = r.stderr?.toString() ?? ''; return (out + (err ? '\n\n-- stderr --\n' + err : '')).trim(); } catch { return null; } } function collectRecentLogs(logFile, days, maxBytes, includeSecrets) { if (!logFile) return []; const dir = path.dirname(logFile); if (!existsSync(dir)) return []; const cutoff = Date.now() - days * 24 * 60 * 60 * 1000; let entries = []; try { entries = readdirSync(dir) .filter(f => /^aiwg-\d{4}-\d{2}-\d{2}\.jsonl$/.test(f)) .sort() .reverse(); // newest first } catch { return []; } const out = []; let total = 0; for (const name of entries) { const full = path.join(dir, name); try { const s = statSync(full); if (s.mtimeMs < cutoff) break; let content = readFileSync(full, 'utf-8'); if (!includeSecrets) content = redactLogContent(content); // Cap per-file size so a runaway log doesn't dominate the bundle. if (total + content.length > maxBytes) { const remaining = Math.max(0, maxBytes - total); if (remaining > 0) { // Take the last `remaining` bytes — most recent records. content = content.slice(-remaining); out.push({ name, content }); } break; } out.push({ name, content }); total += content.length; } catch { /* ignore per-file errors */ } } return out; } function redactLogContent(content) { // Line-by-line JSONL redaction. Parse each line, redact secret fields, // re-emit. Fall back to raw on parse failure. return content .split('\n') .map(line => { if (!line.trim()) return line; try { const obj = JSON.parse(line); return JSON.stringify(redactSecretsInObject(obj)); } catch { return line; } }) .join('\n'); } //# sourceMappingURL=diagnose.js.map