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

160 lines (145 loc) • 7.72 kB
#!/usr/bin/env node /** * Wire push-to-deploy for an Awesomate app from the client's own machine. * * node deploy-key.mjs <appId> [--path <repo-dir>] [--rotate] * * 1. Generates a fresh ed25519 keypair for THIS app (never the machine key). * 2. Registers the public half on the hosting account through the hub * (POST /api/hosting-access/ssh-keys, Support Plus and above). * 3. Sets SSH_PRIVATE_KEY, SSH_HOST and SSH_USER as GitHub Actions secrets on * the repo in <repo-dir> (gh CLI, already authenticated). * 4. Shreds the private key locally and tells the hub which repo and key * fingerprint the app now uses (PATCH /api/my-apps/apps/<id>). * * Idempotent: if the repo already has the three secrets and --rotate is not * given, it only refreshes the hub's record. --rotate mints a new key AND * revokes the previous one from the hosting account, so a leaked key stops * working. Prints names and fingerprints, never key material. */ import { spawnSync } from 'node:child_process'; import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; import { tmpdir, homedir } from 'node:os'; import { join } from 'node:path'; const args = process.argv.slice(2); const flags = new Set(args.filter((a) => a.startsWith('--'))); const positional = args.filter((a) => !a.startsWith('--')); const appId = Number(positional[0]); const pathIdx = args.indexOf('--path'); const cwd = pathIdx >= 0 && args[pathIdx + 1] ? args[pathIdx + 1] : process.cwd(); const rotate = flags.has('--rotate'); if (!Number.isInteger(appId) || appId <= 0) { console.error('usage: node deploy-key.mjs <appId> [--path <repo-dir>] [--rotate]'); console.error('appId: the number from awesomate_app_list / awesomate_app_get'); process.exit(2); } function run(cmd, cmdArgs, opts = {}) { return spawnSync(cmd, cmdArgs, { cwd, encoding: 'utf8', ...opts }); } function die(msg) { console.error(`\n✗ ${msg}`); process.exit(1); } // --- account: API base, token and SSH block from the hosting skill's resolver -- const resolver = join(homedir(), '.claude', 'skills', 'awesomate-hosting', 'scripts', 'resolve-account.mjs'); if (!existsSync(resolver)) { die('The awesomate-hosting skill is not installed on this machine. Connect Claude Code from hub.awesomate.ai/sites first.'); } const resolved = spawnSync('node', [resolver, '--api', '--ssh'], { encoding: 'utf8' }); if (resolved.status !== 0) { die((resolved.stderr || resolved.stdout || '').trim() || 'Could not resolve the connected Awesomate account.'); } const kv = {}; for (const part of resolved.stdout.trim().split(';')) { const m = part.trim().match(/^([A-Z]+)='(.*)'$/); if (m) kv[m[1]] = m[2]; } const { ACCT, API, PAT, H: sshHost, U: sshUser } = kv; if (!API || !PAT) die('No access token found for this account. Reconnect from hub.awesomate.ai/sites.'); if (!sshHost || !sshUser) die('This plan has no shell access (Support Plus and above), so a deploy key cannot be authorised.'); console.log(`→ account: ${ACCT} hosting user: ${sshUser}@${sshHost}`); async function hub(method, path, body) { const res = await fetch(`${API}${path}`, { method, headers: { Authorization: `Bearer ${PAT}`, 'Content-Type': 'application/json', Accept: 'application/json' }, body: body === undefined ? undefined : JSON.stringify(body), }); const text = await res.text(); let json = {}; try { json = text ? JSON.parse(text) : {}; } catch { json = { raw: text.slice(0, 200) }; } if (!res.ok) { const msg = json.message || json.error || `${res.status}`; throw new Error(`${method} ${path} → ${res.status}: ${msg}`); } return json; } // --- repo ------------------------------------------------------------------ if (run('gh', ['--version']).status !== 0) die("GitHub CLI 'gh' is not installed (macOS: brew install gh)."); if (run('gh', ['auth', 'status']).status !== 0) die('Not connected to GitHub. Run: gh auth login'); const repoView = run('gh', ['repo', 'view', '--json', 'nameWithOwner', '-q', '.nameWithOwner']); if (repoView.status !== 0) die(`No GitHub repo in ${cwd}. Run github-setup.mjs first.`); const repo = repoView.stdout.trim(); console.log(`→ repo: ${repo}`); const existing = run('gh', ['secret', 'list', '--json', 'name', '-q', '.[].name']).stdout.split(/\r?\n/).filter(Boolean); const haveAll = ['SSH_PRIVATE_KEY', 'SSH_HOST', 'SSH_USER'].every((n) => existing.includes(n)); let previousFingerprint = null; try { const current = await hub('GET', `/api/my-apps/apps/${appId}`); previousFingerprint = current?.app?.deploy_key_fingerprint ?? null; } catch { /* the hub record is optional here */ } let fingerprint = null; if (haveAll && !rotate) { console.log('• deploy secrets already present on the repo (pass --rotate to replace the key)'); } else { // --- key ---------------------------------------------------------------- const dir = mkdtempSync(join(tmpdir(), 'awm-deploy-key-')); const keyPath = join(dir, 'key'); try { const gen = spawnSync('ssh-keygen', ['-t', 'ed25519', '-N', '', '-C', `awesomate-deploy:${ACCT}:app${appId}`, '-f', keyPath], { encoding: 'utf8' }); if (gen.status !== 0) die(`ssh-keygen failed: ${(gen.stderr || '').trim()}`); const publicKey = readFileSync(`${keyPath}.pub`, 'utf8').trim(); const reg = await hub('POST', '/api/hosting-access/ssh-keys', { publicKey, label: `deploy:app${appId} (${repo})`, }); fingerprint = reg.fingerprint ?? null; const host = reg.sshHost || sshHost; const user = reg.sshUser || sshUser; console.log(`• deploy key authorised on ${user}@${host}${fingerprint ? ` (${fingerprint})` : ''}`); const priv = readFileSync(keyPath, 'utf8'); for (const [name, value] of [['SSH_PRIVATE_KEY', priv], ['SSH_HOST', host], ['SSH_USER', user]]) { const set = run('gh', ['secret', 'set', name], { input: value }); if (set.status !== 0) die(`gh secret set ${name} failed: ${(set.stderr || '').trim()}`); console.log(`• set repo secret ${name}`); } } finally { // Overwrite before unlinking so the private half does not linger in tmp. try { if (existsSync(keyPath)) writeFileSync(keyPath, '\0'.repeat(4096)); } catch { /* best effort */ } rmSync(dir, { recursive: true, force: true }); } } // --- revoke the previous key on rotate -------------------------------------- if (rotate && fingerprint && previousFingerprint && previousFingerprint !== fingerprint) { try { const list = await hub('GET', '/api/hosting-access/ssh-keys'); const old = (list?.keys ?? []).find((k) => k.fingerprint === previousFingerprint); if (old) { await hub('DELETE', `/api/hosting-access/ssh-keys/${old.id}`); console.log(`• revoked the previous deploy key (${previousFingerprint})`); } else { console.log(`• previous key ${previousFingerprint} is not in the hub registry; revoke it from hub.awesomate.ai/settings?tab=access if it still exists`); } } catch (err) { console.log(`• could not revoke the previous key (${err.message}); revoke it from hub.awesomate.ai/settings?tab=access`); } } // --- tell the hub ----------------------------------------------------------- const patch = { githubRepo: repo }; if (fingerprint) patch.deployKeyFingerprint = fingerprint; try { await hub('PATCH', `/api/my-apps/apps/${appId}`, patch); console.log(`• hub updated: app ${appId} deploys from ${repo}`); } catch (err) { console.log(`• could not update the hub record (${err.message}); the secrets are set, so deploys will work. Ask Claude to run awesomate_app_update.`); } console.log('\n✓ Push-to-deploy is wired. Push the dev branch to deploy dev; gh run watch to follow.');