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

605 lines (563 loc) • 28.5 kB
#!/usr/bin/env node /** * 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>] * node bootstrap.mjs --pat amt_pat_XXXX [--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 (no fresh code needed). Setup codes are replayable * for their whole 10-minute life, so re-running the same command is also safe. * * 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 setup prompt from hub.awesomate.ai/claude (or resume with --pat if you already hold a token).'); console.error('AWESOMATE CONNECT: FAILED reason=bad-code'); process.exit(1); } if (resumePat && !resumePat.startsWith('amt_pat_')) { console.error('--pat does not look like an Awesomate access token (expected amt_pat_ prefix).'); 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, }; } 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; } 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, }; 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. This usually means hosting isn\'t fully provisioned — contact Awesomate support, then ask me to "retry SSH setup".'); } } } 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); });