UNPKG

@awesomate/hosting-mcp

Version:

Awesomate MCP server — lets Claude manage your Awesomate WordPress hosting, plan, limits, n8n automations, and build Node/static apps + databases

262 lines (241 loc) • 14.3 kB
#!/usr/bin/env node /** * Awesomate support report — turn a broken setup into something support can * actually act on. * * Collects a REDACTED diagnostic snapshot of this machine's Awesomate * connection state (versions, profiles, pins, MCP registrations, proxy env, * live connectivity probes, ssh tooling, the last bootstrap log) and writes * it to ~/.awesomate/support-report-<timestamp>.md. * * node support-report.mjs [--note "what went wrong"] [--category connect|tools|ssh|deploy|n8n|other] * node support-report.mjs --submit # also POST it to Awesomate (returns a reference ID) * * Redaction guarantees (safe to email or submit): * - access tokens → first 12 chars + … + last 4 (identifies the row, useless as a credential) * - setup codes → amt_bs_[redacted] * - ssh → fingerprint + paths only, never key material * - proxy URLs → user:pass@ stripped * * Node 18+ built-ins only. */ import { execFileSync } from 'node:child_process'; import { existsSync, readFileSync, writeFileSync, mkdirSync, chmodSync } from 'node:fs'; import { homedir, hostname, platform, release, arch } from 'node:os'; import { join, dirname, resolve } from 'node:path'; function arg(name, fallback) { const i = process.argv.indexOf(`--${name}`); return i >= 0 && process.argv[i + 1] && !process.argv[i + 1].startsWith('--') ? process.argv[i + 1] : fallback; } const SUBMIT = process.argv.includes('--submit'); const NOTE = arg('note', null); const CATEGORY = arg('category', 'connect'); const HOME = homedir(); const AWM_DIR = join(HOME, '.awesomate'); const CRED_PATH = join(AWM_DIR, 'credentials.json'); const PIN_FILENAME = '.awesomate.json'; // --------------------------------------------------------------------------- // Redaction — applied to individual values AND as a final pass over the whole // document, so nothing secret can leak through a path we forgot. // --------------------------------------------------------------------------- function redact(s) { return String(s) .replace(/amt_pat_[A-Za-z0-9_-]{8,}/g, (t) => `${t.slice(0, 12)}…${t.slice(-4)}`) .replace(/amt_bs_[A-Za-z0-9_-]{4,}/g, 'amt_bs_[redacted]') .replace(/(https?:\/\/)[^/@\s]+@/g, '$1[credentials-redacted]@') .replace(/-----BEGIN[\s\S]*?-----END [A-Z ]*KEY-----/g, '[key-material-redacted]'); } function readJson(path) { try { const parsed = JSON.parse(readFileSync(path, 'utf8')); return parsed && typeof parsed === 'object' ? parsed : null; } catch { return null; } } function tryExec(cmd, args) { try { return execFileSync(cmd, args, { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], timeout: 8000 }).trim(); } catch { return null; } } // --------------------------------------------------------------------------- // Account resolution — same precedence as the MCP server / resolve-account.mjs // (duplicated minimally here so the report works even when those are broken). // --------------------------------------------------------------------------- function findPin() { const home = resolve(HOME); let dir = resolve(process.cwd()); for (;;) { const candidate = join(dir, PIN_FILENAME); if (existsSync(candidate)) return candidate; if (dir === home) return null; const parent = dirname(dir); if (parent === dir) return null; dir = parent; } } function profileSummary(key, p) { return { profile: key, slug: p.slug ?? null, contactId: p.contactId ?? null, email: p.email ?? null, plan: p.plan ?? null, apiBase: p.apiBase ?? null, tokenRedacted: p.pat ? redact(p.pat) : null, tokenExpiresAt: p.expiresAt ?? null, ssh: p.ssh ? { host: p.ssh.host, user: p.ssh.user, port: p.ssh.port ?? 22, fingerprint: p.ssh.fingerprint ?? null, keyPath: p.ssh.keyPath ?? null } : null, }; } async function main() { const lines = []; const push = (s = '') => lines.push(s); // ── Environment ────────────────────────────────────────────────────────── push('# Awesomate support report'); push(''); push(`- generated: ${new Date().toISOString()}`); push(`- host: ${hostname()} (${platform()} ${release()}, ${arch()})`); push(`- node: ${process.version}`); push(`- claude CLI: ${tryExec('claude', ['--version']) ?? 'not found on PATH'}`); const installedVersion = (() => { try { return readFileSync(join(HOME, '.claude', 'skills', 'awesomate-hosting', '.installed-version'), 'utf8').trim(); } catch { return null; } })(); push(`- installed skill version: ${installedVersion ?? 'no marker (pre-0.6 install?)'}`); push(`- cwd: ${process.cwd()}`); if (NOTE) { push(''); push(`## What the user reports`); push(''); push(NOTE); } // ── Proxy environment ──────────────────────────────────────────────────── push(''); push('## Proxy environment'); push(''); const proxyVars = ['HTTPS_PROXY', 'https_proxy', 'HTTP_PROXY', 'http_proxy', 'NO_PROXY', 'no_proxy', 'NODE_USE_ENV_PROXY']; const setVars = proxyVars.filter((v) => process.env[v]); if (setVars.length === 0) push('- none set'); for (const v of setVars) push(`- ${v}=${redact(process.env[v])}`); // ── Credentials + account resolution ───────────────────────────────────── push(''); push('## Credentials & account resolution'); push(''); const creds = readJson(CRED_PATH); let profiles = {}; let activeApiBase = 'https://hub.awesomate.ai'; let activePat = null; let activeSlug = null; if (!creds) { push(`- ${CRED_PATH}: ${existsSync(CRED_PATH) ? 'EXISTS BUT UNPARSEABLE' : 'missing (never connected on this machine?)'}`); } else { if (creds.profiles && typeof creds.profiles === 'object') profiles = creds.profiles; else if (creds.pat) profiles = { [creds.slug || 'default']: creds }; push(`- credentials file: v${creds.version ?? 1}, defaultProfile: ${creds.defaultProfile ?? '(none)'}`); for (const [key, p] of Object.entries(profiles)) { push(`- profile ${JSON.stringify(profileSummary(key, p))}`); } } const pinPath = findPin(); const pin = pinPath ? readJson(pinPath) : null; push(`- folder pin: ${pinPath ? `${pinPath} → ${JSON.stringify(pin)}` : 'none found (walk-up from cwd)'}`); const names = Object.keys(profiles); if (process.env.AWESOMATE_PAT) { activePat = process.env.AWESOMATE_PAT; activeSlug = '(env PAT)'; push('- ACTIVE: env AWESOMATE_PAT override'); } else if (process.env.AWESOMATE_ACCOUNT && profiles[process.env.AWESOMATE_ACCOUNT]) { const p = profiles[process.env.AWESOMATE_ACCOUNT]; activePat = p.pat; activeSlug = p.slug; activeApiBase = p.apiBase ?? activeApiBase; push(`- ACTIVE: env AWESOMATE_ACCOUNT=${process.env.AWESOMATE_ACCOUNT}`); } else if (pin?.account && profiles[pin.account]) { const p = profiles[pin.account]; activePat = p.pat; activeSlug = p.slug; activeApiBase = p.apiBase ?? activeApiBase; push(`- ACTIVE: pin → ${pin.account}`); } else if (pin?.account && !profiles[pin.account]) { push(`- ACTIVE: NONE — pin names "${pin.account}" but no such profile exists (this is a hard error by design)`); } else if (names.length === 1) { const p = profiles[names[0]]; activePat = p.pat; activeSlug = p.slug; activeApiBase = p.apiBase ?? activeApiBase; push(`- ACTIVE: sole profile ${names[0]}`); } else if (creds?.defaultProfile && profiles[creds.defaultProfile]) { const p = profiles[creds.defaultProfile]; activePat = p.pat; activeSlug = p.slug; activeApiBase = p.apiBase ?? activeApiBase; push(`- ACTIVE: defaultProfile ${creds.defaultProfile}`); } else { push(`- ACTIVE: NONE (${names.length} profiles, no pin/default)`); } // ── MCP registrations ──────────────────────────────────────────────────── push(''); push('## MCP registrations'); push(''); const claudeJson = readJson(join(HOME, '.claude.json')); const userEntry = claudeJson?.mcpServers?.['awesomate-hosting']; push(`- user scope (~/.claude.json): ${userEntry ? JSON.stringify({ command: userEntry.command, args: userEntry.args, envKeys: Object.keys(userEntry.env ?? {}) }) : 'none'}`); if (userEntry?.env?.AWESOMATE_PAT) push(' ⚠ LEGACY: token baked into env — this pins the registration to one stale account. Re-run Connect to fix.'); for (const [projectDir, project] of Object.entries(claudeJson?.projects ?? {})) { const e = project?.mcpServers?.['awesomate-hosting']; if (e) push(`- local scope (${projectDir}): envKeys=${JSON.stringify(Object.keys(e.env ?? {}))}${e.env?.AWESOMATE_PAT ? ' ⚠ LEGACY env token' : ''}`); } const mcpJson = readJson(join(process.cwd(), '.mcp.json')); const projEntry = mcpJson?.mcpServers?.['awesomate-hosting']; if (projEntry) push(`- project scope (cwd/.mcp.json): envKeys=${JSON.stringify(Object.keys(projEntry.env ?? {}))}${projEntry.env?.AWESOMATE_PAT ? ' ⚠ LEGACY env token' : ''}`); // ── Connectivity probes (proxy-aware) ──────────────────────────────────── push(''); push('## Connectivity probes'); push(''); let doFetch = fetch; if (process.env.HTTPS_PROXY || process.env.https_proxy || process.env.HTTP_PROXY || process.env.http_proxy) { try { const undici = await import('undici'); const dispatcher = new undici.EnvHttpProxyAgent(); doFetch = (url, opts = {}) => undici.fetch(url, { ...opts, dispatcher }); push('- probe transport: undici EnvHttpProxyAgent (proxy honored)'); } catch { push('- probe transport: built-in fetch (undici unavailable — proxy NOT honored unless NODE_USE_ENV_PROXY=1)'); } } else { push('- probe transport: built-in fetch (no proxy configured)'); } async function probe(label, url, headers) { try { const res = await doFetch(url, { headers, signal: AbortSignal.timeout(10_000) }); const body = await res.text(); const hasApiBody = (() => { try { const j = JSON.parse(body); return j && typeof j === 'object'; } catch { return false; } })(); push(`- ${label}: HTTP ${res.status} (${hasApiBody ? 'API response body' : 'NON-API body — likely a gateway/proxy in the way'})`); } catch (err) { push(`- ${label}: FAILED (${err?.cause?.code ?? err?.name ?? err?.message})`); } } await probe('GET /api/hosting-access/context (no auth — 401 proves reachability)', `${activeApiBase}/api/hosting-access/context`); if (activePat) { await probe(`GET /api/hosting-access/context (as ${activeSlug})`, `${activeApiBase}/api/hosting-access/context`, { Authorization: `Bearer ${activePat}` }); } else { push('- authenticated probe skipped: no active profile resolved'); } // ── SSH tooling ────────────────────────────────────────────────────────── push(''); push('## SSH tooling'); push(''); push(`- ssh: ${tryExec('ssh', ['-V']) ?? tryExec('sh', ['-c', 'ssh -V 2>&1']) ?? 'not found'}`); push(`- ssh-keygen: ${tryExec('sh', ['-c', 'command -v ssh-keygen']) ?? 'not found'}`); const keyPath = join(AWM_DIR, 'keys', 'id_ed25519'); push(`- key file: ${existsSync(keyPath) ? `present — ${tryExec('ssh-keygen', ['-lf', keyPath]) ?? 'fingerprint unavailable'}` : 'absent'}`); // ── Last bootstrap log ─────────────────────────────────────────────────── push(''); push('## Last connect attempt (~/.awesomate/last-connect.log)'); push(''); const logPath = join(AWM_DIR, 'last-connect.log'); if (existsSync(logPath)) { push('```'); push(redact(readFileSync(logPath, 'utf8')).trim().split('\n').slice(-60).join('\n')); push('```'); } else { push('- no log (bootstrap predates 0.7.3, or never ran on this machine)'); } // ── Write + optionally submit ──────────────────────────────────────────── const report = redact(lines.join('\n')) + '\n'; mkdirSync(AWM_DIR, { recursive: true, mode: 0o700 }); const stamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19); const outPath = join(AWM_DIR, `support-report-${stamp}.md`); writeFileSync(outPath, report); chmodSync(outPath, 0o600); console.log(`✓ Support report written to ${outPath} (all tokens redacted — safe to send).`); if (!SUBMIT) { console.log('Send it to support@awesomate.ai, or re-run with --submit to deliver it directly and get a reference ID.'); return; } const submitBody = { report, summary: NOTE ? NOTE.slice(0, 255) : `Diagnostic from ${activeSlug ?? 'unconnected machine'} (${hostname()})`, slug: activeSlug && activeSlug !== '(env PAT)' ? activeSlug : null, email: null, category: CATEGORY, clientVersion: installedVersion, }; try { const res = await doFetch(`${activeApiBase}/api/hosting-access/support-report`, { method: 'POST', headers: { 'Content-Type': 'application/json', ...(activePat ? { Authorization: `Bearer ${activePat}` } : {}) }, body: JSON.stringify(submitBody), signal: AbortSignal.timeout(15_000), }); const json = await res.json().catch(() => null); if (res.ok && json?.reference) { console.log(`✓ Submitted to Awesomate support. Reference: ${json.reference}`); console.log(` ${json.next}`); } else { console.error(`Submit failed (HTTP ${res.status}${json?.error ? `: ${json.error}` : ''}) — email the file above to support@awesomate.ai instead.`); process.exitCode = 1; } } catch (err) { console.error(`Submit failed (${err?.cause?.code ?? err?.message}) — email the file above to support@awesomate.ai instead.`); process.exitCode = 1; } } main().catch((err) => { console.error('support-report failed:', err?.stack ?? err?.message ?? String(err)); process.exit(1); });