@awesomate/hosting-mcp
Version:
Awesomate MCP server — lets Claude manage your Awesomate WordPress hosting, plan, limits, n8n automations, and build Node/static apps + databases
628 lines (585 loc) • 30.5 kB
JavaScript
/**
* Awesomate Hosting — one-time bootstrap.
*
* Redeems the setup code from the hub's "Connect Claude Code" prompt for a
* durable, scoped access token; stores it as a PROFILE (multi-account safe);
* pins the current folder to that account; and (on Support Plus+) generates an
* SSH keypair and authorizes the PUBLIC key on the client's own cPanel
* account. The private key never leaves this machine; the setup code is the
* only secret that ever crossed the clipboard, and it's short-lived. The
* access token itself is never printed.
*
* Usage (Claude runs this — the user doesn't touch a terminal):
* node bootstrap.mjs --code amt_bs_XXXX [--account <slug>] [--pin-dir <dir>] [--replace]
* node bootstrap.mjs --pat <token> [--account <slug>] # resume: skip redeem
*
* --account asserts WHICH account this connect is for — mismatch aborts before
* anything is written. --pat resumes a half-finished bootstrap with an
* already-redeemed token. It exists for support on the user's own machine and
* is never suggested in chat: a token on a command line lands in the
* transcript and shell history. Setup codes are replayable for their whole
* 10-minute life, and a fresh Connect command is always the advice.
*
* Proxied environments (cloud agent sandboxes): Node's built-in fetch ignores
* HTTP(S)_PROXY, so this script routes through undici's EnvHttpProxyAgent when
* a proxy is configured (falling back to a one-shot re-exec with
* NODE_USE_ENV_PROXY=1 when undici isn't resolvable).
*
* Node 18+ built-ins only, plus undici when running from the npm package.
*/
import { execFileSync, spawnSync } from 'node:child_process';
import { existsSync, mkdirSync, writeFileSync, chmodSync, readFileSync, cpSync, readdirSync, renameSync, statSync, realpathSync } from 'node:fs';
import { homedir, hostname } from 'node:os';
import { join, dirname, resolve, basename } from 'node:path';
import { fileURLToPath } from 'node:url';
function arg(name, fallback) {
const i = process.argv.indexOf(`--${name}`);
return i >= 0 && process.argv[i + 1] ? process.argv[i + 1] : fallback;
}
const code = arg('code');
const resumePat = arg('pat');
const expectedAccount = arg('account', null);
const pinDirArg = arg('pin-dir', null);
const apiBase = (arg('api', 'https://hub.awesomate.ai')).replace(/\/$/, '');
// --update: refresh the installed skills from this package and exit — no
// setup code, no registration, no token. The no-MCP fallback for the
// awesomate_skill_update tool.
if (process.argv.includes('--update')) {
try {
installSkill();
// Also converge the MCP registration: --update is the no-token repair
// path, and a broken registration (e.g. bare-npx on Windows) is exactly
// what people run it to fix.
try {
ensureMcpRegistration();
} catch (regErr) {
console.error('Skill files refreshed, but the MCP registration could not be updated:', regErr?.message ?? regErr);
}
console.log('Skills refreshed. New content applies in your next Claude Code session.');
process.exit(0);
} catch (err) {
console.error('Skill refresh failed:', err?.message ?? err);
process.exit(1);
}
}
if (!resumePat && (!code || !code.startsWith('amt_bs_'))) {
console.error('Missing/invalid --code. Copy a fresh Connect command from hub.awesomate.ai/claude and run it again.');
console.error('AWESOMATE CONNECT: FAILED reason=bad-code');
process.exit(1);
}
if (resumePat && !resumePat.startsWith('amt_pat_')) {
console.error('That is not an Awesomate access token. Copy a fresh Connect command from hub.awesomate.ai/claude and run it again.');
console.error('AWESOMATE CONNECT: FAILED reason=bad-pat');
process.exit(1);
}
const dir = join(homedir(), '.awesomate');
const keysDir = join(dir, 'keys');
const credPath = join(dir, 'credentials.json');
const keyPath = join(keysDir, 'id_ed25519');
const PIN_FILENAME = '.awesomate.json';
// ---------------------------------------------------------------------------
// Connect log: every run's output (redacted) lands in ~/.awesomate/
// last-connect.log so a failure is diagnosable AFTER the fact — the
// support-report script attaches it automatically. Tokens/codes never reach
// the log: bootstrap output doesn't print them and the argv line is redacted.
// ---------------------------------------------------------------------------
const CONNECT_LOG = [`--- awesomate connect ${new Date().toISOString()} ---`];
function redactSecrets(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]');
}
CONNECT_LOG.push(`argv: ${process.argv.slice(2).map(redactSecrets).join(' ')}`);
for (const method of ['log', 'error']) {
const original = console[method].bind(console);
console[method] = (...args) => {
CONNECT_LOG.push(redactSecrets(args.join(' ')));
original(...args);
};
}
process.on('exit', (code) => {
try {
CONNECT_LOG.push(`exit: ${code}`);
mkdirSync(dir, { recursive: true, mode: 0o700 });
writeFileSync(join(dir, 'last-connect.log'), `${CONNECT_LOG.join('\n')}\n`, { mode: 0o600 });
} catch { /* logging must never block exit */ }
});
const SUPPORT_HINT =
'Stuck? Generate a support report: node ~/.claude/skills/awesomate-hosting/scripts/support-report.mjs --note "<what happened>" (add --submit to send it to Awesomate and get a reference ID).';
// ---------------------------------------------------------------------------
// Proxy-aware fetch. Node's built-in fetch does NOT honor HTTP(S)_PROXY —
// in sandboxes that force egress through a proxy, requests bypass it and the
// gateway answers 403, which looks exactly like a credential failure. Route
// through undici's EnvHttpProxyAgent when a proxy env var is set.
// ---------------------------------------------------------------------------
const PROXY_URL =
process.env.HTTPS_PROXY || process.env.https_proxy || process.env.HTTP_PROXY || process.env.http_proxy || '';
let doFetch = fetch;
async function initProxySupport() {
if (!PROXY_URL) return;
// undici tags EnvHttpProxyAgent experimental and warns on construction —
// noise in the middle of connect output. Keep every other warning.
process.removeAllListeners('warning');
process.on('warning', (w) => {
if (!/EnvHttpProxyAgent/.test(String(w.message))) console.error(w.stack ?? String(w.message));
});
try {
// Resolves when running from the npm package (undici is a dependency).
const undici = await import('undici');
const dispatcher = new undici.EnvHttpProxyAgent();
doFetch = (url, opts = {}) => undici.fetch(url, { ...opts, dispatcher });
console.log(`• Egress proxy detected — routing hub requests through ${PROXY_URL}.`);
} catch {
// Running from an installed skill copy with no node_modules. Re-exec once
// with NODE_USE_ENV_PROXY=1 (built-in fetch honors proxies on newer Node;
// the env var is ignored where unsupported, so this is at worst a no-op).
if (process.env.AWESOMATE_PROXY_REEXEC !== '1') {
const r = spawnSync(process.execPath, process.argv.slice(1), {
stdio: 'inherit',
env: { ...process.env, NODE_USE_ENV_PROXY: '1', AWESOMATE_PROXY_REEXEC: '1' },
});
process.exit(r.status ?? 1);
}
console.log(`• Egress proxy detected (${PROXY_URL}) but proxy support is unavailable in this runtime — requests may be blocked by the gateway.`);
}
}
const proxyAdvice = () =>
PROXY_URL
? ` An egress proxy is configured (${PROXY_URL}); if this persists, re-run the command prefixed with NODE_USE_ENV_PROXY=1.`
: '';
async function request(method, path, { body, token } = {}) {
const headers = { 'Content-Type': 'application/json' };
if (token) headers.Authorization = `Bearer ${token}`;
let res;
try {
res = await doFetch(`${apiBase}${path}`, {
method,
headers,
body: method === 'GET' ? undefined : JSON.stringify(body ?? {}),
});
} catch (err) {
const detail = err?.cause?.code ?? err?.code ?? err?.message ?? 'network error';
throw new Error(`Could not reach ${apiBase} (${detail}). This is a NETWORK problem, not a code/token problem.${proxyAdvice()}`);
}
const text = await res.text();
let json;
try { json = text ? JSON.parse(text) : null; } catch { json = text; }
const apiError = json && typeof json === 'object' && typeof json.error === 'string' ? json.error : null;
if (!res.ok) {
if (apiError) {
const e = new Error(`${path} failed: ${apiError}`);
e.isApiError = true;
e.status = res.status;
throw e;
}
// No structured error body → the response almost certainly came from an
// egress gateway / firewall, not from the hub. Say so instead of blaming
// the code — that misdirection is expensive.
throw new Error(
`${path} returned HTTP ${res.status} without an API error body — the request likely never reached hub.awesomate.ai (egress proxy or firewall in the way).${proxyAdvice()}`,
);
}
return json;
}
const post = (path, opts) => request('POST', path, opts);
const get = (path, opts) => request('GET', path, opts);
function readJsonFile(path) {
try {
const parsed = JSON.parse(readFileSync(path, 'utf8'));
return parsed && typeof parsed === 'object' ? parsed : null;
} catch { return null; }
}
/** Atomic JSON write (tmp + rename) so a crash can't leave a torn file. */
function writeJsonFile(path, value) {
const tmp = `${path}.tmp-${process.pid}`;
writeFileSync(tmp, `${JSON.stringify(value, null, 2)}\n`);
renameSync(tmp, path);
}
/**
* Merge one account profile into credentials.json (v2 multi-profile shape).
* Existing profiles for OTHER accounts are preserved — connecting a second
* account must never disconnect the first. Top-level v1 fields are kept as a
* mirror of the default profile because already-installed skill scripts
* (ssh-connect.sh / deploy.sh / pull-live.sh copies from older versions) read
* them directly until the skill is refreshed.
*/
function mergeProfile(profile) {
const existing = readJsonFile(credPath) ?? {};
const profiles = { ...(existing.profiles ?? {}) };
// v1 file: lift the old top-level account into the profiles map first so it
// survives this connect.
if (!existing.profiles && typeof existing.pat === 'string' && existing.pat) {
const legacyKey = existing.slug || 'default';
profiles[legacyKey] = {
apiBase: existing.apiBase, pat: existing.pat, contactId: existing.contactId,
slug: existing.slug, email: existing.email, plan: existing.plan,
expiresAt: existing.expiresAt, cpanel: existing.cpanel, ssh: existing.ssh,
};
}
profiles[profile.slug] = { ...(profiles[profile.slug] ?? {}), ...profile };
let defaultProfile = existing.defaultProfile;
if (defaultProfile && !profiles[defaultProfile]) defaultProfile = undefined;
if (!defaultProfile && Object.keys(profiles).length === 1) defaultProfile = profile.slug;
if (existing.defaultProfile && existing.defaultProfile !== profile.slug && profiles[existing.defaultProfile]) {
console.log(`• defaultProfile stays "${existing.defaultProfile}" — this folder is pinned to "${profile.slug}" instead.`);
}
// The v1 mirror follows the default profile when one is set; otherwise the
// account just connected (matching the old last-connect-wins behavior for
// legacy readers, without losing the other profiles).
const mirrorSource = (defaultProfile && profiles[defaultProfile]) || profile;
const creds = {
version: 2,
...(defaultProfile ? { defaultProfile } : {}),
profiles,
apiBase: mirrorSource.apiBase, pat: mirrorSource.pat, contactId: mirrorSource.contactId,
slug: mirrorSource.slug, email: mirrorSource.email ?? null, plan: mirrorSource.plan,
cpanel: mirrorSource.cpanel ?? null, expiresAt: mirrorSource.expiresAt ?? null,
...(mirrorSource.ssh ? { ssh: mirrorSource.ssh } : {}),
};
mkdirSync(dir, { recursive: true, mode: 0o700 });
chmodSync(dir, 0o700);
writeJsonFile(credPath, creds);
chmodSync(credPath, 0o600);
return creds;
}
/**
* Pin a folder to this account. --pin-dir overrides the implicit cwd (useful
* when the bootstrap is run from somewhere other than the project root).
* Never pins $HOME.
*/
function writePin(slug) {
const target = resolve(pinDirArg || process.cwd());
if (pinDirArg && !(existsSync(target) && statSync(target).isDirectory())) {
console.log(`• --pin-dir ${pinDirArg} is not an existing directory — skipping the pin. Create it and add ${PIN_FILENAME} with {"account": "${slug}"}.`);
return null;
}
if (target === resolve(homedir())) {
console.log(`• Target is the home directory — not writing a ${PIN_FILENAME} pin. Re-run from the project folder (or pass --pin-dir) to pin it.`);
return null;
}
const pinPath = join(target, PIN_FILENAME);
const existing = readJsonFile(pinPath);
if (existing?.account && existing.account !== slug) {
console.log(`• Re-pinning ${target}: ${existing.account} → ${slug}.`);
}
writeJsonFile(pinPath, { version: 1, account: slug });
console.log(`✓ Pinned ${target} to account "${slug}" (${PIN_FILENAME}).`);
return pinPath;
}
/**
* Copy the skill bundled alongside this script into ~/.claude/skills so Claude
* Code loads it. This runs INSIDE the already-approved bootstrap invocation, so
* there's no separate `cp` step for a permission classifier to block. Idempotent
* (overwrites an older/hand-made copy with the canonical one). Best-effort: a
* failure here never aborts the rest of setup.
*/
function installSkill() {
// realpathSync: npx runs the bin through a symlink (or a shim on Windows) —
// resolve to the file's true location inside the package before walking up,
// or the walk lands in the npx cache's .bin dir. basename() (never
// endsWith('/skill')): Windows paths use '\', which broke the layout check
// and made every Windows bootstrap report "Skill files not found".
let selfPath = fileURLToPath(import.meta.url);
try { selfPath = realpathSync(selfPath); } catch { /* keep unresolved path */ }
const hostingSkill = dirname(dirname(selfPath));
const skillRoot = dirname(hostingSkill);
const isPackageLayout = basename(skillRoot) === 'skill';
if (!isPackageLayout) {
// Running from an INSTALLED copy (~/.claude/skills/...): the siblings here
// are the user's other skills, not the bundle, and this skill's src IS its
// dest — so an "update" from here silently refreshed nothing while
// reporting success. Say so honestly and point at the two real paths.
console.log('• This is the installed copy of the skill — it cannot refresh the bundle from here.');
console.log(' Update instead with the awesomate_skill_update tool in Claude Code, or re-run the');
console.log(' full bootstrap: npx -y --package=@awesomate/hosting-mcp awesomate-hosting-bootstrap --update');
return false;
}
const bundled = readdirSync(skillRoot);
// Package version, stamped into each installed skill as .installed-version.
// The MCP server (always latest via unpinned npx) compares it against its own
// version and reports skill.updateAvailable in awesomate_get_context.
let pkgVersion = null;
if (isPackageLayout) {
try {
pkgVersion = JSON.parse(readFileSync(join(dirname(skillRoot), 'package.json'), 'utf8')).version ?? null;
} catch { /* non-fatal — marker just won't be written */ }
}
let installed = 0;
for (const name of bundled) {
const src = join(skillRoot, name);
if (!existsSync(join(src, 'SKILL.md'))) continue;
const dest = join(homedir(), '.claude', 'skills', name);
if (src === dest) { installed += 1; continue; } // already running from the installed copy
mkdirSync(dirname(dest), { recursive: true });
cpSync(src, dest, { recursive: true, force: true });
// Make the shell/mjs helpers executable (npm can publish them non-+x).
const scriptsDir = join(dest, 'scripts');
if (existsSync(scriptsDir)) {
for (const f of readdirSync(scriptsDir)) {
if (/\.(sh|mjs)$/.test(f)) { try { chmodSync(join(scriptsDir, f), 0o755); } catch { /* non-fatal */ } }
}
}
if (pkgVersion) {
try { writeFileSync(join(dest, '.installed-version'), `${pkgVersion}\n`); } catch { /* non-fatal */ }
}
console.log(`✓ Installed the "${name}" skill to ${dest}.`);
installed += 1;
}
if (installed === 0) {
console.log('• Skill files not found next to bootstrap — skipping skill install (MCP still works).');
return false;
}
return true;
}
// --package= (with the =) instead of -p: the claude CLI pre-scans the FULL
// argv for its own global -p/--print flag — even after the `--` separator —
// so any `claude mcp add … -- npx -y -p pkg bin` flips it into print mode and
// it rejects the scope flag as "unknown option" (marlins bug report,
// 2026-07-28). npx treats both forms identically at runtime; the = form keeps
// the value glued to the token so these args are safe to copy anywhere.
// The explicit package must stay: this package ships two bins.
// Windows: a bare `npx` spawn fails silently (it's npx.cmd there), so the
// server never starts and the tools never appear — even after a restart.
// First observed on the first Windows client, 2026-08-19. cmd /c resolves it.
const CLEAN_ENTRY = process.platform === 'win32'
? {
command: 'cmd',
args: ['/c', 'npx', '-y', '--package=@awesomate/hosting-mcp', 'awesomate-hosting-mcp'],
}
: {
command: 'npx',
args: ['-y', '--package=@awesomate/hosting-mcp', 'awesomate-hosting-mcp'],
};
const MANUAL_ADD_JSON = JSON.stringify({ mcpServers: { 'awesomate-hosting': CLEAN_ENTRY } }, null, 2);
/**
* Converge on exactly one env-free, user-scope MCP registration by editing the
* config files DIRECTLY — `claude mcp add` flag syntax has drifted between CLI
* versions (--scope/-s rejected on some), and a registration is too important
* to hang off a moving target.
*
* Legacy bootstraps baked AWESOMATE_PAT into the registration env — env beats
* credentials.json inside the server, so those registrations keep serving a
* STALE account forever after a re-pair. Remove every entry that carries the
* env var (user scope, this project's local scope + .mcp.json); entries in
* OTHER projects are reported for manual cleanup, not touched.
*/
function ensureMcpRegistration() {
const cwd = resolve(process.cwd());
const claudeJsonPath = join(homedir(), '.claude.json');
let cfg = {};
if (existsSync(claudeJsonPath)) {
cfg = readJsonFile(claudeJsonPath);
if (!cfg) {
throw new Error(`${claudeJsonPath} exists but is not valid JSON — refusing to rewrite it. Fix the file, then add:\n${MANUAL_ADD_JSON}`);
}
}
const entry = cfg.mcpServers?.['awesomate-hosting'];
const entryIsClean =
entry &&
!entry.env?.AWESOMATE_PAT &&
JSON.stringify(entry.args ?? []).includes('awesomate-hosting-mcp') &&
// A bare-npx registration is broken on Windows — rewrite it.
(process.platform !== 'win32' || entry.command === 'cmd');
let changed = false;
if (!entryIsClean) {
cfg.mcpServers = { ...(cfg.mcpServers ?? {}), 'awesomate-hosting': CLEAN_ENTRY };
changed = true;
console.log(
entry?.env?.AWESOMATE_PAT
? '✓ Replaced the legacy user-scope registration (had a token baked into its env) with an env-free one.'
: '✓ Registered the Awesomate Hosting MCP server (user scope, env-free — the folder pin picks the account).',
);
} else {
console.log('✓ MCP server already registered (user scope, env-free) — account selection comes from the folder pin.');
}
// This project's local-scope entry (lives under projects[cwd] in ~/.claude.json).
const localEntry = cfg.projects?.[cwd]?.mcpServers?.['awesomate-hosting'];
if (localEntry?.env?.AWESOMATE_PAT) {
delete cfg.projects[cwd].mcpServers['awesomate-hosting'];
changed = true;
console.log("✓ Removed this project's legacy local-scope registration (token baked into env).");
}
// Other projects: report, don't touch.
for (const [projectDir, project] of Object.entries(cfg.projects ?? {})) {
if (resolve(projectDir) === cwd) continue;
if (project?.mcpServers?.['awesomate-hosting']?.env?.AWESOMATE_PAT) {
console.log(`• ${projectDir} still has a legacy registration with a baked-in token — from that folder run the Connect command again, or remove the awesomate-hosting entry from its local MCP config.`);
}
}
if (changed) {
writeJsonFile(claudeJsonPath, cfg);
// Read back and verify — never trust a write (or a CLI exit code) alone.
const verify = readJsonFile(claudeJsonPath);
const written = verify?.mcpServers?.['awesomate-hosting'];
if (!written || written.command !== CLEAN_ENTRY.command || !JSON.stringify(written.args ?? []).includes('awesomate-hosting-mcp')) {
throw new Error(`wrote ${claudeJsonPath} but the awesomate-hosting entry did not verify on read-back`);
}
}
// Project-scope .mcp.json in cwd.
const mcpJsonPath = join(cwd, '.mcp.json');
const mcpJson = readJsonFile(mcpJsonPath);
if (mcpJson?.mcpServers?.['awesomate-hosting']?.env?.AWESOMATE_PAT) {
delete mcpJson.mcpServers['awesomate-hosting'];
writeJsonFile(mcpJsonPath, mcpJson);
console.log('✓ Removed the legacy project-scope (.mcp.json) registration (token baked into env).');
}
}
async function main() {
await initProxySupport();
const issues = [];
// ── Identity: redeem a code, or verify a resumed PAT ─────────────────────
let redeemed;
if (resumePat) {
console.log(`Resuming with an existing access token — verifying against ${apiBase} …`);
const ctx = await get('/api/hosting-access/context', { token: resumePat });
redeemed = {
pat: resumePat,
contactId: ctx.contactId,
slug: ctx.slug ?? null,
email: ctx.email ?? null,
plan: ctx.plan,
shellAccess: Boolean(ctx.capabilities?.shell),
cpanel: ctx.cpanel ?? null,
expiresAt: ctx.patExpiresAt ?? null,
...(ctx.key?.holder === 'person' ? { keyKind: 'person_device' } : {}),
};
} else {
console.log(`Redeeming setup code against ${apiBase} …`);
redeemed = await post('/api/hosting-access/redeem', {
body: { code, machineLabel: `Claude Code on ${hostname()}` },
});
}
const slug = redeemed.slug ?? null;
if (!slug) {
console.error('The hub did not return an account slug — cannot safely store a profile. Contact Awesomate support.');
console.error('AWESOMATE CONNECT: FAILED reason=no-slug');
process.exit(1);
}
// Wrong-account guard: abort BEFORE writing anything.
if (expectedAccount && expectedAccount !== slug) {
console.error(`This ${resumePat ? 'token' : 'setup code'} belongs to account "${slug}" but this connect was for "${expectedAccount}".`);
console.error(`You are probably logged into the wrong hub account — log into ${expectedAccount}'s hub account and copy a fresh Connect prompt.`);
console.error(`AWESOMATE CONNECT: FAILED reason=account-mismatch expected=${expectedAccount} got=${slug}`);
process.exit(1);
}
// Server-side identity check with the NEW token before trusting it (the
// resume path already did exactly this to build `redeemed`).
if (!resumePat) {
let context = null;
try {
context = await get('/api/hosting-access/context', { token: redeemed.pat });
} catch (err) {
console.error(`Token verification failed (${err.message}) — not storing it. Re-run the same command (setup codes stay valid for 10 minutes).`);
console.error('AWESOMATE CONNECT: FAILED reason=verify-failed');
process.exit(1);
}
const ctxSlug = context?.slug ?? null;
if (ctxSlug && ctxSlug !== slug) {
console.error(`Identity mismatch: redeem said "${slug}" but the token resolves to "${ctxSlug}". Not storing it — contact Awesomate support.`);
console.error('AWESOMATE CONNECT: FAILED reason=identity-mismatch');
process.exit(1);
}
redeemed.email = redeemed.email ?? context?.email ?? null;
}
// One computer, two people (per-person keys design §6.1): never silently swap whose key this
// account uses here. An owner and a team member on a shared computer each keep their own.
const saved = readJsonFile(credPath) ?? {};
const before = (saved.profiles ?? {})[slug] ?? (saved.slug === slug ? saved : null);
const beforeEmail = typeof before?.email === 'string' ? before.email.trim().toLowerCase() : null;
const newEmail = typeof redeemed.email === 'string' ? redeemed.email.trim().toLowerCase() : null;
const replacing = Boolean(beforeEmail && newEmail && beforeEmail !== newEmail);
if (replacing && !process.argv.includes('--replace')) {
console.error(`This computer is already connected to "${slug}" as ${beforeEmail}. Connecting as ${newEmail} would replace that key here.`);
console.error('Ask the person at this computer which key should stay. Only on their clear yes, run the same command again with --replace; otherwise connect from your own user account on this computer.');
console.error(`The new key for ${newEmail} was made but not saved here. If it will not be used, remove it in the hub: Settings, Account, Your Claude Code.`);
console.error('AWESOMATE CONNECT: FAILED reason=profile-belongs-to-someone-else');
process.exit(1);
}
const profile = {
apiBase,
pat: redeemed.pat,
contactId: redeemed.contactId,
slug,
email: redeemed.email ?? null,
plan: redeemed.plan,
cpanel: redeemed.cpanel ?? null,
expiresAt: redeemed.expiresAt ?? null,
// Someone else's SSH key never carries over to this person's profile.
...(replacing ? { ssh: undefined } : {}),
};
mergeProfile(profile);
console.log(`✓ Access token stored as profile "${slug}" in ${credPath} (chmod 600). Plan: ${redeemed.plan}.`);
const pinPath = writePin(slug);
// Install the skill + register the MCP server FIRST — these always work and
// don't depend on hosting being fully provisioned. Neither is allowed to
// strand the other or SSH: every step from here is collected, not fatal.
try { installSkill(); } catch (err) {
console.log(`• Could not auto-install the skill (${err.message}) — continuing; you can copy skill/ manually.`);
}
try {
ensureMcpRegistration();
} catch (err) {
issues.push('mcp-registration');
console.error(`• MCP registration failed: ${err.message}`);
console.error(` Add it manually (no token goes in the config — the folder pin picks the account):\n${MANUAL_ADD_JSON}`);
}
// SSH key (Support Plus+): best-effort. If it fails (e.g. hosting not fully
// provisioned yet, or no ssh tooling in this environment), keep the token +
// skill + MCP — deploy/shell just aren't ready until it's registered, which
// the skill can retry later.
if (redeemed.shellAccess) {
try {
if (!existsSync(keyPath)) {
mkdirSync(keysDir, { recursive: true, mode: 0o700 });
execFileSync('ssh-keygen', ['-t', 'ed25519', '-f', keyPath, '-N', '', '-C', `awesomate:${slug}`], { stdio: 'pipe' });
chmodSync(keyPath, 0o600);
}
const publicKey = readFileSync(`${keyPath}.pub`, 'utf8').trim();
const reg = await post('/api/hosting-access/ssh-keys', {
token: redeemed.pat,
body: { publicKey, label: `Claude Code on ${hostname()}` },
});
profile.ssh = { host: reg.sshHost, user: reg.sshUser, port: reg.sshPort ?? 22, keyPath, fingerprint: reg.fingerprint };
mergeProfile(profile);
console.log(`✓ SSH key authorized on ${reg.sshUser}@${reg.sshHost} (jailed to your account).`);
} catch (err) {
if (err?.code === 'ENOENT') {
console.log('• No ssh-keygen in this environment — skipping SSH setup. Deploy and shell need a real workstation; reads and management calls work fine here.');
} else {
console.log(`• SSH key not registered yet: ${err.message}`);
console.log(' Everything else is set up. Once your hosting is ready, copy a fresh Connect command from hub.awesomate.ai/claude and run it again: that adds the SSH key. Never paste an access token into the chat.');
console.log(' If your hosting never finishes setting up, contact Awesomate support.');
}
}
} else if (redeemed.keyKind === 'person_device') {
console.log('• This is your own key: it does what you can do in the hub. SSH and deploys stay with the account owner.');
} else {
console.log('• Plan has no shell access — read/status mode. Upgrade to Support Plus for SSH + deploy.');
}
const who = profile.email ? `${slug} (${profile.email}, plan ${redeemed.plan})` : `${slug} (plan ${redeemed.plan})`;
console.log(`\n✓ Connected: ${who}${pinPath ? ` — pinned ${dirname(pinPath)}` : ''}`);
console.log('Setup complete.');
console.log('');
console.log('IMPORTANT — restart Claude Code now: the MCP tools and skills load only at startup.');
console.log('Until the restart, builds still work — the assistant should follow');
console.log('~/.claude/skills/awesomate-n8n/references/rest-fallback.md (n8n) or');
console.log('~/.claude/skills/awesomate-hosting/references/rest-fallback.md (hosting).');
console.log('After restarting: run awesomate_whoami to confirm the account, then ask for your first build.');
console.log('(The skill works in your current Claude session right away via its REST fallback — restart Claude Code when convenient to load the MCP tools.)');
if (issues.length) {
console.log(SUPPORT_HINT);
console.log(`AWESOMATE CONNECT: PARTIAL account=${slug} pin=${pinPath ?? 'none'} issues=${issues.join(',')}`);
process.exit(1);
}
console.log(`AWESOMATE CONNECT: SUCCESS account=${slug} pin=${pinPath ?? 'none'}`);
}
main().catch((err) => {
console.error('Bootstrap failed:', err.message);
if (err.isApiError && err.status === 401) {
console.error('Grab a fresh code from hub.awesomate.ai/claude — codes last 10 minutes (and stay re-runnable within that window).');
}
console.error(SUPPORT_HINT);
console.error('AWESOMATE CONNECT: FAILED reason=error');
process.exit(1);
});