@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
JavaScript
/**
* 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);
});