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
JavaScript
/**
* 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