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