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

280 lines (253 loc) • 12.6 kB
#!/usr/bin/env node // secret-drop — one-time localhost form for handing secrets to Claude // without them entering the chat. Values are never printed; only key names. // // File mode (default): writes KEY=value into an env file (mode 600). // node secret-drop.mjs [--file ~/.config/api-keys.env] [--keys A,B] // Hub mode: posts each key to the Awesomate hub's encrypted app env — // the same POST /api/my-apps/apps/:id/env route as awesomate_app_set_env, // authenticated with the user's own PAT from ~/.awesomate/credentials.json. // node secret-drop.mjs --hub-app <appId> [--env dev|staging|prod] [--keys A,B] // Common: [--port 0] [--token <hex>] [--ttl 15] [--stay] import http from 'node:http'; import crypto from 'node:crypto'; import fs from 'node:fs'; import path from 'node:path'; import os from 'node:os'; const args = process.argv.slice(2); const argVal = (name, def) => { const i = args.indexOf(`--${name}`); return i >= 0 && args[i + 1] !== undefined ? args[i + 1] : def; }; const expandHome = (p) => (p === '~' || p.startsWith('~/')) ? path.join(os.homedir(), p.slice(1)) : p; const hubAppId = argVal('hub-app', ''); const hubEnv = argVal('env', 'dev'); const hubMode = hubAppId !== ''; const filePath = path.resolve(expandHome(argVal('file', '~/.config/api-keys.env'))); const ttlMs = Number(argVal('ttl', '15')) * 60_000; const port = Number(argVal('port', '0')); const token = argVal('token', crypto.randomBytes(16).toString('hex')); const stay = args.includes('--stay'); const FILE_KEY_RE = /^[A-Za-z_][A-Za-z0-9_]*$/; const HUB_KEY_RE = /^[A-Z][A-Z0-9_]{0,63}$/; const SAFE_VAL_RE = /^[A-Za-z0-9_@%+=:,.\/-]*$/; const fail = (msg) => { console.error(`secret-drop: ${msg}`); process.exit(1); }; if (hubMode && (!/^[1-9]\d*$/.test(hubAppId))) fail(`--hub-app must be a positive app id, got: ${hubAppId}`); if (hubMode && !['dev', 'staging', 'prod'].includes(hubEnv)) fail(`--env must be dev|staging|prod, got: ${hubEnv}`); const normalizeKey = (name) => (hubMode ? name.toUpperCase() : name); const keyValid = (name) => (hubMode ? HUB_KEY_RE.test(name) : FILE_KEY_RE.test(name)); const expectedKeys = (argVal('keys', '') || '').split(',').map((s) => normalizeKey(s.trim())).filter(Boolean); for (const k of expectedKeys) { if (!keyValid(k)) fail(`invalid key name: ${k}`); } let hubAuth = null; if (hubMode) { let pat = process.env.AWESOMATE_PAT || ''; let base = process.env.AWESOMATE_API_BASE || ''; if (!pat || !base) { try { const raw = JSON.parse(fs.readFileSync(path.join(os.homedir(), '.awesomate', 'credentials.json'), 'utf8')); const prof = raw.profiles ? raw.profiles[process.env.AWESOMATE_ACCOUNT || raw.defaultProfile] || raw : raw; pat = pat || prof.pat || ''; base = base || prof.apiBase || ''; } catch { /* handled below */ } } if (!pat) fail('no Awesomate PAT found — connect your hosting first (~/.awesomate/credentials.json), or set AWESOMATE_PAT'); hubAuth = { pat, base: (base || 'https://hub.awesomate.ai').replace(/\/+$/, '') }; } const destinationLabel = hubMode ? `app #${hubAppId} (${hubEnv} environment) — encrypted in your Awesomate hub` : filePath; const tokenOk = (t) => { if (typeof t !== 'string') return false; const a = Buffer.from(t); const b = Buffer.from(token); return a.length === b.length && crypto.timingSafeEqual(a, b); }; const esc = (s) => s.replace(/[&<>"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c])); const page = (title, body) => `<!doctype html><html><head><meta charset="utf-8"> <meta name="viewport" content="width=device-width,initial-scale=1"> <title>${title}</title><style> body{background:#1c1917;color:#e7e5e4;font:15px/1.5 -apple-system,system-ui,sans-serif; display:flex;justify-content:center;padding:48px 16px} main{width:100%;max-width:560px} h1{font-size:20px;font-weight:600} .card{background:#292524;border:1px solid #44403c;border-radius:12px;padding:24px;margin-top:16px} label{display:block;font-size:13px;color:#a8a29e;margin:14px 0 4px} input.sec,textarea{width:100%;box-sizing:border-box;background:#1c1917; color:#e7e5e4;border:1px solid #44403c;border-radius:8px;padding:10px;font:13px ui-monospace,monospace} textarea{min-height:110px;resize:vertical} button{margin-top:18px;background:#6366f1;color:#fff;border:0;border-radius:8px; padding:10px 18px;font-size:14px;font-weight:600;cursor:pointer} .muted{color:#a8a29e;font-size:13px} code{background:#1c1917;padding:1px 5px;border-radius:4px;font-size:12.5px} ul{padding-left:20px} .ok{color:#4ade80} .bad{color:#f87171} </style></head><body><main>${body}</main></body></html>`; const formPage = () => { const fields = expectedKeys.map((k) => `<label for="k_${k}">${k}</label><input type="password" class="sec" id="k_${k}" name="k_${k}" autocomplete="off" spellcheck="false">` ).join(''); return page('Secret drop', ` <h1>Secret drop</h1> <p class="muted">Values submitted here go straight to <code>${esc(destinationLabel)}</code>. They never enter the Claude chat — Claude only sees the key names.</p> <div class="card"><form method="post" action="/save"> <input type="hidden" name="t" value="${token}"> ${fields} <label for="bulk">${expectedKeys.length ? 'Or paste' : 'Paste'} one or more <code>KEY=value</code> lines</label> <textarea id="bulk" name="bulk" autocomplete="off" spellcheck="false" placeholder="${hubMode ? 'STRIPE_SECRET_KEY=sk_live_...' : 'OPENROUTER_API_KEY=sk-or-...&#10;RESEND_API_KEY=re_...'}"></textarea> ${expectedKeys.length ? `<label style="display:flex;align-items:center;gap:6px;margin-top:10px"> <input type="checkbox" onchange="document.querySelectorAll('.sec').forEach(i=>i.type=this.checked?'text':'password')" style="width:auto"> show values </label>` : ''} <button type="submit">Save securely</button> </form></div>`); }; function quoteVal(v) { if ((v.startsWith('"') && v.endsWith('"') && v.length >= 2) || (v.startsWith("'") && v.endsWith("'") && v.length >= 2)) return v; if (SAFE_VAL_RE.test(v)) return v; return `'${v.replace(/'/g, `'\\''`)}'`; } function writeEnv(file, updates) { fs.mkdirSync(path.dirname(file), { recursive: true }); let lines = fs.existsSync(file) ? fs.readFileSync(file, 'utf8').split('\n') : []; while (lines.length && lines[lines.length - 1] === '') lines.pop(); const replaced = new Set(); lines = lines.map((line) => { const m = line.match(/^(export\s+)?([A-Za-z_][A-Za-z0-9_]*)=/); if (m && updates.has(m[2])) { replaced.add(m[2]); return `${m[1] || ''}${m[2]}=${quoteVal(updates.get(m[2]))}`; } return line; }); for (const [k, v] of updates) { if (!replaced.has(k)) lines.push(`${k}=${quoteVal(v)}`); } fs.writeFileSync(file, lines.join('\n') + '\n', { mode: 0o600 }); fs.chmodSync(file, 0o600); } async function hubSet(key, value) { const res = await fetch(`${hubAuth.base}/api/my-apps/apps/${hubAppId}/env`, { method: 'POST', headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${hubAuth.pat}` }, body: JSON.stringify({ key, value, env: hubEnv }), signal: AbortSignal.timeout(20_000), }); let detail = ''; try { const j = await res.json(); detail = String(j.error || j.detail || '').slice(0, 200); } catch { /* non-JSON body */ } return { ok: res.ok, status: res.status, detail }; } function parseSubmission(params) { const updates = new Map(); const invalid = []; const add = (rawName, rawVal) => { const name = normalizeKey(rawName.trim()); const val = rawVal.replace(/\r/g, '').trim(); if (!val) return; if (keyValid(name)) updates.set(name, val); else invalid.push(rawName.trim()); }; for (const [k, v] of params) { if (k.startsWith('k_')) add(k.slice(2), v); } for (const line of (params.get('bulk') || '').split('\n')) { const s = line.replace(/\r$/, '').trim(); if (!s || s.startsWith('#')) continue; const eq = s.indexOf('='); if (eq <= 0) continue; add(s.slice(0, eq).replace(/^export\s+/, ''), s.slice(eq + 1)); } return { updates, invalid }; } const server = http.createServer((req, res) => { res.setHeader('Cache-Control', 'no-store'); res.setHeader('Referrer-Policy', 'no-referrer'); const url = new URL(req.url, 'http://127.0.0.1'); if (req.method === 'GET' && url.pathname === '/') { if (!tokenOk(url.searchParams.get('t'))) { res.writeHead(403); return res.end('forbidden'); } res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' }); return res.end(formPage()); } if (req.method === 'POST' && url.pathname === '/save') { let body = ''; req.on('data', (c) => { body += c; if (body.length > 1_048_576) req.destroy(); }); req.on('end', async () => { const params = new URLSearchParams(body); if (!tokenOk(params.get('t'))) { res.writeHead(403); return res.end('forbidden'); } const { updates, invalid } = parseSubmission(params); const invalidNote = invalid.length ? `<p class="bad">Skipped invalid key name${invalid.length > 1 ? 's' : ''}: <code>${invalid.map(esc).join(', ')}</code>${hubMode ? ' (letters, digits and underscores only, starting with a letter)' : ''}</p>` : ''; if (!updates.size) { res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' }); return res.end(page('Secret drop', `<h1>Nothing saved</h1>${invalidNote} <p class="muted">No valid <code>KEY=value</code> entries found. Go back and try again.</p>`)); } if (hubMode) { const results = []; for (const [key, value] of updates) { try { results.push({ key, ...(await hubSet(key, value)) }); } catch (e) { results.push({ key, ok: false, status: 0, detail: String((e && e.message) || e).slice(0, 200) }); } } const saved = results.filter((r) => r.ok).map((r) => r.key); const failed = results.filter((r) => !r.ok); if (saved.length) console.log(`SAVED: ${saved.join(',')} -> hub app ${hubAppId} (${hubEnv})`); for (const f of failed) console.log(`FAILED: ${f.key} (status ${f.status}${f.detail ? `: ${f.detail}` : ''})`); const allOk = failed.length === 0; const rows = results.map((r) => r.ok ? `<li class="ok">✓ ${esc(r.key)}</li>` : `<li class="bad">✗ ${esc(r.key)} — ${r.status ? `error ${r.status}` : 'request failed'}${r.detail ? `: ${esc(r.detail)}` : ''}</li>` ).join(''); res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' }); res.end(page('Secret drop', `<h1 class="${allOk ? 'ok' : 'bad'}">${allOk ? 'Saved' : 'Partly saved'}</h1> ${invalidNote}<ul>${rows}</ul> <p class="muted">Destination: <code>${esc(destinationLabel)}</code>. ${allOk && !stay ? 'You can close this tab — the link is now dead.' : 'Go back to retry the failed keys.'}</p>`)); if (allOk && !stay) setTimeout(() => { server.close(); process.exit(0); }, 750); return; } try { writeEnv(filePath, updates); } catch (e) { res.writeHead(500, { 'Content-Type': 'text/html; charset=utf-8' }); return res.end(page('Secret drop', `<h1>Write failed</h1><p class="muted">${esc(String((e && e.message) || e))}</p>`)); } const names = [...updates.keys()]; console.log(`SAVED: ${names.join(',')} -> ${filePath}`); res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' }); res.end(page('Secret drop', `<h1 class="ok">Saved</h1>${invalidNote} <p>Wrote <strong>${names.map(esc).join(', ')}</strong> to <code>${esc(filePath)}</code>.</p> <p class="muted">You can close this tab${stay ? '' : ' — the link is now dead'}.</p>`)); if (!stay) setTimeout(() => { server.close(); process.exit(0); }, 750); }); return; } res.writeHead(404); res.end('not found'); }); server.listen(port, '127.0.0.1', () => { const p = server.address().port; console.log('secret-drop listening (127.0.0.1 only)'); console.log(` dest: ${destinationLabel}`); if (expectedKeys.length) console.log(` keys: ${expectedKeys.join(', ')}`); console.log(` open: http://127.0.0.1:${p}/?t=${token}`); console.log(` expires in ${Math.round(ttlMs / 60000)} min${stay ? '' : '; exits after first successful save'}`); }); setTimeout(() => { console.log('secret-drop: expired'); process.exit(0); }, ttlMs);