@awesomate/hosting-mcp 0.6.2 → 0.7.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@awesomate/hosting-mcp",
3
- "version": "0.6.2",
3
+ "version": "0.7.1",
4
4
  "description": "Awesomate MCP server — lets Claude manage your Awesomate WordPress hosting, plan, limits, and n8n automations",
5
5
  "license": "UNLICENSED",
6
6
  "type": "module",
@@ -19,10 +19,12 @@
19
19
  "scripts": {
20
20
  "build": "esbuild src/index.ts --bundle --platform=node --target=node18 --format=esm --outfile=dist/index.js --banner:js='#!/usr/bin/env node' --external:node:*",
21
21
  "typecheck": "tsc --noEmit",
22
- "prepublishOnly": "npm run typecheck && npm run build"
22
+ "test": "esbuild src/config.ts --bundle --platform=node --target=node18 --format=esm --outfile=test/.build/config.mjs --external:node:* && node --test test/",
23
+ "prepublishOnly": "npm run typecheck && npm run test && npm run build"
23
24
  },
24
25
  "dependencies": {
25
26
  "@modelcontextprotocol/sdk": "^1.29.0",
27
+ "undici": "^6.21.0",
26
28
  "zod": "^3.24.1"
27
29
  },
28
30
  "devDependencies": {
@@ -24,8 +24,14 @@ generation, defer to WordPress Studio and any installed WordPress skills
24
24
 
25
25
  ## 0. First run — orient before acting
26
26
 
27
- On the first hosting request in a session, call **`awesomate_get_context`** once
28
- (then `awesomate_get_limits` if you're about to create/change anything). From the
27
+ On the first hosting request in a session, call **`awesomate_whoami`** — it's
28
+ zero-network and tells you WHICH account this folder is connected to and why
29
+ (`source`: a `.awesomate.json` folder pin, `AWESOMATE_ACCOUNT`, the sole
30
+ profile, or the default profile). **If the slug isn't the account the user is
31
+ talking about, stop and fix the connection before doing any work** — every
32
+ subsequent tool response is stamped with `account: <slug> (source: …)`; treat a
33
+ mismatch there the same way. Then call **`awesomate_get_context`** once (then
34
+ `awesomate_get_limits` if you're about to create/change anything). From the
29
35
  context note three things and cache them for the session:
30
36
 
31
37
  - **`plan`** and **`capabilities.shell`** — this decides which branch below you're on.
@@ -41,13 +47,65 @@ context note three things and cache them for the session:
41
47
  If `awesomate_get_context` fails with a connectivity error, it's the user's
42
48
  network or the API base — not an auth problem; say so.
43
49
 
50
+ ### Multiple accounts on one machine
51
+
52
+ `~/.awesomate/credentials.json` can hold several account **profiles** (one per
53
+ slug), and each project folder is bound to one via a committable
54
+ `.awesomate.json` pin (`{"account": "<slug>"}`) written by the bootstrap in the
55
+ folder it ran in. Resolution order: `AWESOMATE_PAT` env → `AWESOMATE_ACCOUNT`
56
+ env → nearest folder pin (walking up, stopping at `$HOME`) → sole profile →
57
+ `defaultProfile`. A pin naming a missing profile is a **hard error, never a
58
+ fallback** — that's deliberate.
59
+
60
+ - **Wrong account connected?** Compare `awesomate_whoami` against the folder's
61
+ `.awesomate.json`. Fix by editing the pin (if the profile exists) or by
62
+ running Connect Claude Code from the hub **while logged into the right hub
63
+ account, from the right folder** — the connect command carries
64
+ `--account <slug>` and the bootstrap aborts on a mismatch instead of storing
65
+ anything.
66
+ - **"Multiple accounts … folder isn't pinned" error?** Create the pin file or
67
+ set `AWESOMATE_ACCOUNT`. Don't guess an account for the user.
68
+ - Connecting a second account never disconnects the first; re-pairing an
69
+ account just refreshes its profile.
70
+ - The bootstrap prints a final `AWESOMATE CONNECT: SUCCESS account=<slug>` (or
71
+ `PARTIAL … issues=…` / `FAILED reason=…`) sentinel — when a connect ran as a
72
+ `!` bang command, check for that line instead of assuming success from
73
+ silence. `PARTIAL` means the token/pin are stored but a step (listed in
74
+ `issues=`) needs the printed manual fix.
75
+
76
+ ### Sandboxed / ephemeral environments (cloud agent containers)
77
+
78
+ - **Egress proxy**: if `HTTPS_PROXY`/`HTTP_PROXY` is set, the environment
79
+ forces traffic through a proxy that Node's built-in fetch ignores — requests
80
+ bypass it and the gateway answers 403, which looks like a bad code/token but
81
+ is a transport failure. The bootstrap and MCP server handle this themselves
82
+ (undici env-proxy agent); if an older version is in play, prefix commands
83
+ with `NODE_USE_ENV_PROXY=1`. Diagnostic: `curl` works but `node -e "fetch(…)"`
84
+ 403s → proxy bypass, not credentials.
85
+ - **Interrupted connect?** Setup codes are re-runnable for their whole
86
+ 10-minute life (each run mints a fresh token), and `--pat amt_pat_…` resumes
87
+ with an already-redeemed token — never tell the user their code is spent
88
+ unless the API literally said so.
89
+ - **Nothing persists**: `~/.awesomate`, the skill copy, and the MCP
90
+ registration all vanish with the container. Treat a container connect as a
91
+ diagnosis/test, and tell the user a real workstation is needed for a lasting
92
+ install.
93
+ - **No ssh client**: containers usually lack `ssh-keygen`/`ssh`, so deploy and
94
+ shell are unavailable — reads and management calls still work. The bootstrap
95
+ skips SSH with a note rather than failing.
96
+ - Never probe connectivity with a POST to `/redeem` — it's state-changing. Use
97
+ `GET {apiBase}/api/hosting-access/context` (401 without a token still proves
98
+ reachability).
99
+
44
100
  If this skill is loaded but **no `awesomate_*` tools exist in the session at
45
101
  all**, the MCP server was registered after Claude Code started (the bootstrap
46
102
  just ran). Don't investigate settings files or reinstall anything — and don't
47
103
  make a restart the precondition for showing the user their own data. The
48
- skill works right now over REST: read `apiBase` and `pat` from
49
- `~/.awesomate/credentials.json` (never echo the pat into the conversation)
50
- and GET with `Authorization: Bearer <pat>`:
104
+ skill works right now over REST resolve the ACTIVE profile first (run
105
+ `node ~/.claude/skills/awesomate-hosting/scripts/resolve-account.mjs --api` to
106
+ get `API`/`PAT`/`ACCT`; never read top-level `pat` directly when a `profiles`
107
+ map exists, and never echo the pat into the conversation) and GET with
108
+ `Authorization: Bearer <pat>`:
51
109
 
52
110
  | MCP tool | REST equivalent |
53
111
  |---|---|
@@ -2,24 +2,35 @@
2
2
  /**
3
3
  * Awesomate Hosting — one-time bootstrap.
4
4
  *
5
- * Redeems the single-use setup code from the hub's "Connect Claude Code" prompt
6
- * for a durable, scoped access token; stores it locally; and (on Support Plus+)
7
- * generates an SSH keypair and authorizes the PUBLIC key on the client's own
8
- * cPanel account. The private key never leaves this machine; the setup code is
9
- * the only secret that ever crossed the clipboard, and it's single-use + short-
10
- * lived.
5
+ * Redeems the setup code from the hub's "Connect Claude Code" prompt for a
6
+ * durable, scoped access token; stores it as a PROFILE (multi-account safe);
7
+ * pins the current folder to that account; and (on Support Plus+) generates an
8
+ * SSH keypair and authorizes the PUBLIC key on the client's own cPanel
9
+ * account. The private key never leaves this machine; the setup code is the
10
+ * only secret that ever crossed the clipboard, and it's short-lived. The
11
+ * access token itself is never printed.
11
12
  *
12
13
  * Usage (Claude runs this — the user doesn't touch a terminal):
13
- * node bootstrap.mjs --code amt_bs_XXXX [--api https://hub.awesomate.ai]
14
+ * node bootstrap.mjs --code amt_bs_XXXX [--account <slug>] [--pin-dir <dir>]
15
+ * node bootstrap.mjs --pat amt_pat_XXXX [--account <slug>] # resume: skip redeem
14
16
  *
15
- * Dependency-free: Node 18+ built-ins only (global fetch, node:fs/os/path,
16
- * child_process for ssh-keygen).
17
+ * --account asserts WHICH account this connect is for — mismatch aborts before
18
+ * anything is written. --pat resumes a half-finished bootstrap with an
19
+ * already-redeemed token (no fresh code needed). Setup codes are replayable
20
+ * for their whole 10-minute life, so re-running the same command is also safe.
21
+ *
22
+ * Proxied environments (cloud agent sandboxes): Node's built-in fetch ignores
23
+ * HTTP(S)_PROXY, so this script routes through undici's EnvHttpProxyAgent when
24
+ * a proxy is configured (falling back to a one-shot re-exec with
25
+ * NODE_USE_ENV_PROXY=1 when undici isn't resolvable).
26
+ *
27
+ * Node 18+ built-ins only, plus undici when running from the npm package.
17
28
  */
18
29
 
19
- import { execFileSync } from 'node:child_process';
20
- import { existsSync, mkdirSync, writeFileSync, chmodSync, readFileSync, cpSync, readdirSync } from 'node:fs';
30
+ import { execFileSync, spawnSync } from 'node:child_process';
31
+ import { existsSync, mkdirSync, writeFileSync, chmodSync, readFileSync, cpSync, readdirSync, renameSync, statSync } from 'node:fs';
21
32
  import { homedir, hostname } from 'node:os';
22
- import { join, dirname } from 'node:path';
33
+ import { join, dirname, resolve } from 'node:path';
23
34
  import { fileURLToPath } from 'node:url';
24
35
 
25
36
  function arg(name, fallback) {
@@ -28,9 +39,19 @@ function arg(name, fallback) {
28
39
  }
29
40
 
30
41
  const code = arg('code');
42
+ const resumePat = arg('pat');
43
+ const expectedAccount = arg('account', null);
44
+ const pinDirArg = arg('pin-dir', null);
31
45
  const apiBase = (arg('api', 'https://hub.awesomate.ai')).replace(/\/$/, '');
32
- if (!code || !code.startsWith('amt_bs_')) {
33
- console.error('Missing/invalid --code. Copy a fresh setup prompt from hub.awesomate.ai/sites.');
46
+
47
+ if (!resumePat && (!code || !code.startsWith('amt_bs_'))) {
48
+ console.error('Missing/invalid --code. Copy a fresh setup prompt from hub.awesomate.ai/sites (or resume with --pat if you already hold a token).');
49
+ console.error('AWESOMATE CONNECT: FAILED reason=bad-code');
50
+ process.exit(1);
51
+ }
52
+ if (resumePat && !resumePat.startsWith('amt_pat_')) {
53
+ console.error('--pat does not look like an Awesomate access token (expected amt_pat_ prefix).');
54
+ console.error('AWESOMATE CONNECT: FAILED reason=bad-pat');
34
55
  process.exit(1);
35
56
  }
36
57
 
@@ -38,26 +59,179 @@ const dir = join(homedir(), '.awesomate');
38
59
  const keysDir = join(dir, 'keys');
39
60
  const credPath = join(dir, 'credentials.json');
40
61
  const keyPath = join(keysDir, 'id_ed25519');
62
+ const PIN_FILENAME = '.awesomate.json';
63
+
64
+ // ---------------------------------------------------------------------------
65
+ // Proxy-aware fetch. Node's built-in fetch does NOT honor HTTP(S)_PROXY —
66
+ // in sandboxes that force egress through a proxy, requests bypass it and the
67
+ // gateway answers 403, which looks exactly like a credential failure. Route
68
+ // through undici's EnvHttpProxyAgent when a proxy env var is set.
69
+ // ---------------------------------------------------------------------------
70
+ const PROXY_URL =
71
+ process.env.HTTPS_PROXY || process.env.https_proxy || process.env.HTTP_PROXY || process.env.http_proxy || '';
72
+ let doFetch = fetch;
73
+
74
+ async function initProxySupport() {
75
+ if (!PROXY_URL) return;
76
+ // undici tags EnvHttpProxyAgent experimental and warns on construction —
77
+ // noise in the middle of connect output. Keep every other warning.
78
+ process.removeAllListeners('warning');
79
+ process.on('warning', (w) => {
80
+ if (!/EnvHttpProxyAgent/.test(String(w.message))) console.error(w.stack ?? String(w.message));
81
+ });
82
+ try {
83
+ // Resolves when running from the npm package (undici is a dependency).
84
+ const undici = await import('undici');
85
+ const dispatcher = new undici.EnvHttpProxyAgent();
86
+ doFetch = (url, opts = {}) => undici.fetch(url, { ...opts, dispatcher });
87
+ console.log(`• Egress proxy detected — routing hub requests through ${PROXY_URL}.`);
88
+ } catch {
89
+ // Running from an installed skill copy with no node_modules. Re-exec once
90
+ // with NODE_USE_ENV_PROXY=1 (built-in fetch honors proxies on newer Node;
91
+ // the env var is ignored where unsupported, so this is at worst a no-op).
92
+ if (process.env.AWESOMATE_PROXY_REEXEC !== '1') {
93
+ const r = spawnSync(process.execPath, process.argv.slice(1), {
94
+ stdio: 'inherit',
95
+ env: { ...process.env, NODE_USE_ENV_PROXY: '1', AWESOMATE_PROXY_REEXEC: '1' },
96
+ });
97
+ process.exit(r.status ?? 1);
98
+ }
99
+ console.log(`• Egress proxy detected (${PROXY_URL}) but proxy support is unavailable in this runtime — requests may be blocked by the gateway.`);
100
+ }
101
+ }
102
+
103
+ const proxyAdvice = () =>
104
+ PROXY_URL
105
+ ? ` An egress proxy is configured (${PROXY_URL}); if this persists, re-run the command prefixed with NODE_USE_ENV_PROXY=1.`
106
+ : '';
41
107
 
42
- async function post(path, { body, token } = {}) {
108
+ async function request(method, path, { body, token } = {}) {
43
109
  const headers = { 'Content-Type': 'application/json' };
44
110
  if (token) headers.Authorization = `Bearer ${token}`;
45
- const res = await fetch(`${apiBase}${path}`, { method: 'POST', headers, body: JSON.stringify(body ?? {}) });
111
+ let res;
112
+ try {
113
+ res = await doFetch(`${apiBase}${path}`, {
114
+ method,
115
+ headers,
116
+ body: method === 'GET' ? undefined : JSON.stringify(body ?? {}),
117
+ });
118
+ } catch (err) {
119
+ const detail = err?.cause?.code ?? err?.code ?? err?.message ?? 'network error';
120
+ throw new Error(`Could not reach ${apiBase} (${detail}). This is a NETWORK problem, not a code/token problem.${proxyAdvice()}`);
121
+ }
46
122
  const text = await res.text();
47
123
  let json;
48
124
  try { json = text ? JSON.parse(text) : null; } catch { json = text; }
125
+ const apiError = json && typeof json === 'object' && typeof json.error === 'string' ? json.error : null;
49
126
  if (!res.ok) {
50
- const msg = json && typeof json === 'object' && json.error ? json.error : `HTTP ${res.status}`;
51
- throw new Error(`${path} failed: ${msg}`);
127
+ if (apiError) {
128
+ const e = new Error(`${path} failed: ${apiError}`);
129
+ e.isApiError = true;
130
+ e.status = res.status;
131
+ throw e;
132
+ }
133
+ // No structured error body → the response almost certainly came from an
134
+ // egress gateway / firewall, not from the hub. Say so instead of blaming
135
+ // the code — that misdirection is expensive.
136
+ throw new Error(
137
+ `${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()}`,
138
+ );
52
139
  }
53
140
  return json;
54
141
  }
142
+ const post = (path, opts) => request('POST', path, opts);
143
+ const get = (path, opts) => request('GET', path, opts);
144
+
145
+ function readJsonFile(path) {
146
+ try {
147
+ const parsed = JSON.parse(readFileSync(path, 'utf8'));
148
+ return parsed && typeof parsed === 'object' ? parsed : null;
149
+ } catch { return null; }
150
+ }
151
+
152
+ /** Atomic JSON write (tmp + rename) so a crash can't leave a torn file. */
153
+ function writeJsonFile(path, value) {
154
+ const tmp = `${path}.tmp-${process.pid}`;
155
+ writeFileSync(tmp, `${JSON.stringify(value, null, 2)}\n`);
156
+ renameSync(tmp, path);
157
+ }
158
+
159
+ /**
160
+ * Merge one account profile into credentials.json (v2 multi-profile shape).
161
+ * Existing profiles for OTHER accounts are preserved — connecting a second
162
+ * account must never disconnect the first. Top-level v1 fields are kept as a
163
+ * mirror of the default profile because already-installed skill scripts
164
+ * (ssh-connect.sh / deploy.sh / pull-live.sh copies from older versions) read
165
+ * them directly until the skill is refreshed.
166
+ */
167
+ function mergeProfile(profile) {
168
+ const existing = readJsonFile(credPath) ?? {};
169
+ const profiles = { ...(existing.profiles ?? {}) };
170
+
171
+ // v1 file: lift the old top-level account into the profiles map first so it
172
+ // survives this connect.
173
+ if (!existing.profiles && typeof existing.pat === 'string' && existing.pat) {
174
+ const legacyKey = existing.slug || 'default';
175
+ profiles[legacyKey] = {
176
+ apiBase: existing.apiBase, pat: existing.pat, contactId: existing.contactId,
177
+ slug: existing.slug, email: existing.email, plan: existing.plan,
178
+ expiresAt: existing.expiresAt, cpanel: existing.cpanel, ssh: existing.ssh,
179
+ };
180
+ }
181
+
182
+ profiles[profile.slug] = { ...(profiles[profile.slug] ?? {}), ...profile };
183
+
184
+ let defaultProfile = existing.defaultProfile;
185
+ if (defaultProfile && !profiles[defaultProfile]) defaultProfile = undefined;
186
+ if (!defaultProfile && Object.keys(profiles).length === 1) defaultProfile = profile.slug;
187
+ if (existing.defaultProfile && existing.defaultProfile !== profile.slug && profiles[existing.defaultProfile]) {
188
+ console.log(`• defaultProfile stays "${existing.defaultProfile}" — this folder is pinned to "${profile.slug}" instead.`);
189
+ }
190
+
191
+ // The v1 mirror follows the default profile when one is set; otherwise the
192
+ // account just connected (matching the old last-connect-wins behavior for
193
+ // legacy readers, without losing the other profiles).
194
+ const mirrorSource = (defaultProfile && profiles[defaultProfile]) || profile;
195
+ const creds = {
196
+ version: 2,
197
+ ...(defaultProfile ? { defaultProfile } : {}),
198
+ profiles,
199
+ apiBase: mirrorSource.apiBase, pat: mirrorSource.pat, contactId: mirrorSource.contactId,
200
+ slug: mirrorSource.slug, email: mirrorSource.email ?? null, plan: mirrorSource.plan,
201
+ cpanel: mirrorSource.cpanel ?? null, expiresAt: mirrorSource.expiresAt ?? null,
202
+ ...(mirrorSource.ssh ? { ssh: mirrorSource.ssh } : {}),
203
+ };
55
204
 
56
- function writeCreds(creds) {
57
205
  mkdirSync(dir, { recursive: true, mode: 0o700 });
58
206
  chmodSync(dir, 0o700);
59
- writeFileSync(credPath, JSON.stringify(creds, null, 2));
207
+ writeJsonFile(credPath, creds);
60
208
  chmodSync(credPath, 0o600);
209
+ return creds;
210
+ }
211
+
212
+ /**
213
+ * Pin a folder to this account. --pin-dir overrides the implicit cwd (useful
214
+ * when the bootstrap is run from somewhere other than the project root).
215
+ * Never pins $HOME.
216
+ */
217
+ function writePin(slug) {
218
+ const target = resolve(pinDirArg || process.cwd());
219
+ if (pinDirArg && !(existsSync(target) && statSync(target).isDirectory())) {
220
+ console.log(`• --pin-dir ${pinDirArg} is not an existing directory — skipping the pin. Create it and add ${PIN_FILENAME} with {"account": "${slug}"}.`);
221
+ return null;
222
+ }
223
+ if (target === resolve(homedir())) {
224
+ 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.`);
225
+ return null;
226
+ }
227
+ const pinPath = join(target, PIN_FILENAME);
228
+ const existing = readJsonFile(pinPath);
229
+ if (existing?.account && existing.account !== slug) {
230
+ console.log(`• Re-pinning ${target}: ${existing.account} → ${slug}.`);
231
+ }
232
+ writeJsonFile(pinPath, { version: 1, account: slug });
233
+ console.log(`✓ Pinned ${target} to account "${slug}" (${PIN_FILENAME}).`);
234
+ return pinPath;
61
235
  }
62
236
 
63
237
  /**
@@ -68,12 +242,6 @@ function writeCreds(creds) {
68
242
  * failure here never aborts the rest of setup.
69
243
  */
70
244
  function installSkill() {
71
- // bootstrap.mjs lives at <skill>/scripts/bootstrap.mjs → the skill dir is
72
- // `..` and every sibling of it under the package's skill/ dir is another
73
- // bundled skill (e.g. awesomate-n8n). Install them all. When bootstrap is
74
- // re-run from an already-installed copy (~/.claude/skills/...), the parent
75
- // is the user's whole skills folder — in that case only reinstall this one
76
- // skill, never its unrelated siblings.
77
245
  const hostingSkill = dirname(dirname(fileURLToPath(import.meta.url)));
78
246
  const skillRoot = dirname(hostingSkill);
79
247
  // Only the package layout (skill/<name>/) has bundled siblings; from an
@@ -119,54 +287,179 @@ function installSkill() {
119
287
  return true;
120
288
  }
121
289
 
290
+ const CLEAN_ENTRY = {
291
+ command: 'npx',
292
+ args: ['-y', '-p', '@awesomate/hosting-mcp', 'awesomate-hosting-mcp'],
293
+ };
294
+ const MANUAL_ADD_JSON = JSON.stringify({ mcpServers: { 'awesomate-hosting': CLEAN_ENTRY } }, null, 2);
295
+
296
+ /**
297
+ * Converge on exactly one env-free, user-scope MCP registration by editing the
298
+ * config files DIRECTLY — `claude mcp add` flag syntax has drifted between CLI
299
+ * versions (--scope/-s rejected on some), and a registration is too important
300
+ * to hang off a moving target.
301
+ *
302
+ * Legacy bootstraps baked AWESOMATE_PAT into the registration env — env beats
303
+ * credentials.json inside the server, so those registrations keep serving a
304
+ * STALE account forever after a re-pair. Remove every entry that carries the
305
+ * env var (user scope, this project's local scope + .mcp.json); entries in
306
+ * OTHER projects are reported for manual cleanup, not touched.
307
+ */
308
+ function ensureMcpRegistration() {
309
+ const cwd = resolve(process.cwd());
310
+ const claudeJsonPath = join(homedir(), '.claude.json');
311
+
312
+ let cfg = {};
313
+ if (existsSync(claudeJsonPath)) {
314
+ cfg = readJsonFile(claudeJsonPath);
315
+ if (!cfg) {
316
+ throw new Error(`${claudeJsonPath} exists but is not valid JSON — refusing to rewrite it. Fix the file, then add:\n${MANUAL_ADD_JSON}`);
317
+ }
318
+ }
319
+
320
+ const entry = cfg.mcpServers?.['awesomate-hosting'];
321
+ const entryIsClean =
322
+ entry && !entry.env?.AWESOMATE_PAT && JSON.stringify(entry.args ?? []).includes('awesomate-hosting-mcp');
323
+
324
+ let changed = false;
325
+ if (!entryIsClean) {
326
+ cfg.mcpServers = { ...(cfg.mcpServers ?? {}), 'awesomate-hosting': CLEAN_ENTRY };
327
+ changed = true;
328
+ console.log(
329
+ entry?.env?.AWESOMATE_PAT
330
+ ? '✓ Replaced the legacy user-scope registration (had a token baked into its env) with an env-free one.'
331
+ : '✓ Registered the Awesomate Hosting MCP server (user scope, env-free — the folder pin picks the account).',
332
+ );
333
+ } else {
334
+ console.log('✓ MCP server already registered (user scope, env-free) — account selection comes from the folder pin.');
335
+ }
336
+
337
+ // This project's local-scope entry (lives under projects[cwd] in ~/.claude.json).
338
+ const localEntry = cfg.projects?.[cwd]?.mcpServers?.['awesomate-hosting'];
339
+ if (localEntry?.env?.AWESOMATE_PAT) {
340
+ delete cfg.projects[cwd].mcpServers['awesomate-hosting'];
341
+ changed = true;
342
+ console.log("✓ Removed this project's legacy local-scope registration (token baked into env).");
343
+ }
344
+ // Other projects: report, don't touch.
345
+ for (const [projectDir, project] of Object.entries(cfg.projects ?? {})) {
346
+ if (resolve(projectDir) === cwd) continue;
347
+ if (project?.mcpServers?.['awesomate-hosting']?.env?.AWESOMATE_PAT) {
348
+ 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.`);
349
+ }
350
+ }
351
+
352
+ if (changed) writeJsonFile(claudeJsonPath, cfg);
353
+
354
+ // Project-scope .mcp.json in cwd.
355
+ const mcpJsonPath = join(cwd, '.mcp.json');
356
+ const mcpJson = readJsonFile(mcpJsonPath);
357
+ if (mcpJson?.mcpServers?.['awesomate-hosting']?.env?.AWESOMATE_PAT) {
358
+ delete mcpJson.mcpServers['awesomate-hosting'];
359
+ writeJsonFile(mcpJsonPath, mcpJson);
360
+ console.log('✓ Removed the legacy project-scope (.mcp.json) registration (token baked into env).');
361
+ }
362
+ }
363
+
122
364
  async function main() {
123
- console.log(`Redeeming setup code against ${apiBase} …`);
124
- const redeemed = await post('/api/hosting-access/redeem', {
125
- body: { code, machineLabel: `Claude Code on ${hostname()}` },
126
- });
365
+ await initProxySupport();
366
+ const issues = [];
127
367
 
128
- const creds = {
368
+ // ── Identity: redeem a code, or verify a resumed PAT ─────────────────────
369
+ let redeemed;
370
+ if (resumePat) {
371
+ console.log(`Resuming with an existing access token — verifying against ${apiBase} …`);
372
+ const ctx = await get('/api/hosting-access/context', { token: resumePat });
373
+ redeemed = {
374
+ pat: resumePat,
375
+ contactId: ctx.contactId,
376
+ slug: ctx.slug ?? null,
377
+ email: ctx.email ?? null,
378
+ plan: ctx.plan,
379
+ shellAccess: Boolean(ctx.capabilities?.shell),
380
+ cpanel: ctx.cpanel ?? null,
381
+ expiresAt: ctx.patExpiresAt ?? null,
382
+ };
383
+ } else {
384
+ console.log(`Redeeming setup code against ${apiBase} …`);
385
+ redeemed = await post('/api/hosting-access/redeem', {
386
+ body: { code, machineLabel: `Claude Code on ${hostname()}` },
387
+ });
388
+ }
389
+
390
+ const slug = redeemed.slug ?? null;
391
+ if (!slug) {
392
+ console.error('The hub did not return an account slug — cannot safely store a profile. Contact Awesomate support.');
393
+ console.error('AWESOMATE CONNECT: FAILED reason=no-slug');
394
+ process.exit(1);
395
+ }
396
+ // Wrong-account guard: abort BEFORE writing anything.
397
+ if (expectedAccount && expectedAccount !== slug) {
398
+ console.error(`This ${resumePat ? 'token' : 'setup code'} belongs to account "${slug}" but this connect was for "${expectedAccount}".`);
399
+ console.error(`You are probably logged into the wrong hub account — log into ${expectedAccount}'s hub account and copy a fresh Connect prompt.`);
400
+ console.error(`AWESOMATE CONNECT: FAILED reason=account-mismatch expected=${expectedAccount} got=${slug}`);
401
+ process.exit(1);
402
+ }
403
+
404
+ // Server-side identity check with the NEW token before trusting it (the
405
+ // resume path already did exactly this to build `redeemed`).
406
+ if (!resumePat) {
407
+ let context = null;
408
+ try {
409
+ context = await get('/api/hosting-access/context', { token: redeemed.pat });
410
+ } catch (err) {
411
+ console.error(`Token verification failed (${err.message}) — not storing it. Re-run the same command (setup codes stay valid for 10 minutes).`);
412
+ console.error('AWESOMATE CONNECT: FAILED reason=verify-failed');
413
+ process.exit(1);
414
+ }
415
+ const ctxSlug = context?.slug ?? null;
416
+ if (ctxSlug && ctxSlug !== slug) {
417
+ console.error(`Identity mismatch: redeem said "${slug}" but the token resolves to "${ctxSlug}". Not storing it — contact Awesomate support.`);
418
+ console.error('AWESOMATE CONNECT: FAILED reason=identity-mismatch');
419
+ process.exit(1);
420
+ }
421
+ redeemed.email = redeemed.email ?? context?.email ?? null;
422
+ }
423
+
424
+ const profile = {
129
425
  apiBase,
130
426
  pat: redeemed.pat,
131
427
  contactId: redeemed.contactId,
132
- slug: redeemed.slug ?? null,
428
+ slug,
429
+ email: redeemed.email ?? null,
133
430
  plan: redeemed.plan,
134
431
  cpanel: redeemed.cpanel ?? null,
135
432
  expiresAt: redeemed.expiresAt ?? null,
136
433
  };
137
- writeCreds(creds);
138
- console.log(`✓ Access token stored at ${credPath} (chmod 600). Plan: ${redeemed.plan}.`);
434
+ mergeProfile(profile);
435
+ console.log(`✓ Access token stored as profile "${slug}" in ${credPath} (chmod 600). Plan: ${redeemed.plan}.`);
436
+
437
+ const pinPath = writePin(slug);
139
438
 
140
439
  // Install the skill + register the MCP server FIRST — these always work and
141
- // don't depend on hosting being fully provisioned. SSH comes last so that a
142
- // shell/provisioning hiccup can't strand the skill or MCP.
440
+ // don't depend on hosting being fully provisioned. Neither is allowed to
441
+ // strand the other or SSH: every step from here is collected, not fatal.
143
442
  try { installSkill(); } catch (err) {
144
443
  console.log(`• Could not auto-install the skill (${err.message}) — continuing; you can copy skill/ manually.`);
145
444
  }
146
445
 
147
- // Register the MCP server. Prefer the Claude CLI if present; otherwise print
148
- // the config for Claude to apply.
149
- const cmd = `claude mcp add awesomate-hosting --env AWESOMATE_PAT=${redeemed.pat} --env AWESOMATE_API_BASE=${apiBase} -- npx -y -p @awesomate/hosting-mcp awesomate-hosting-mcp`;
150
- let registered = false;
151
446
  try {
152
- execFileSync('claude', ['mcp', 'add', 'awesomate-hosting', '--env', `AWESOMATE_PAT=${redeemed.pat}`, '--env', `AWESOMATE_API_BASE=${apiBase}`, '--', 'npx', '-y', '-p', '@awesomate/hosting-mcp', 'awesomate-hosting-mcp'], { stdio: 'ignore' });
153
- registered = true;
154
- console.log('✓ Registered the Awesomate Hosting MCP server with Claude Code.');
155
- } catch {
156
- console.log('\nAdd the Awesomate Hosting MCP server to this projectrun:');
157
- console.log(` ${cmd}`);
158
- console.log('…or add to .mcp.json:');
159
- console.log(JSON.stringify({ mcpServers: { 'awesomate-hosting': { command: 'npx', args: ['-y', '-p', '@awesomate/hosting-mcp', 'awesomate-hosting-mcp'], env: { AWESOMATE_PAT: redeemed.pat, AWESOMATE_API_BASE: apiBase } } } }, null, 2));
447
+ ensureMcpRegistration();
448
+ } catch (err) {
449
+ issues.push('mcp-registration');
450
+ console.error(`• MCP registration failed: ${err.message}`);
451
+ console.error(` Add it manually (no token goes in the configthe folder pin picks the account):\n${MANUAL_ADD_JSON}`);
160
452
  }
161
453
 
162
454
  // SSH key (Support Plus+): best-effort. If it fails (e.g. hosting not fully
163
- // provisioned yet), keep the token + skill + MCP deploy/shell just aren't
164
- // ready until it's registered, which the skill can retry later.
455
+ // provisioned yet, or no ssh tooling in this environment), keep the token +
456
+ // skill + MCP — deploy/shell just aren't ready until it's registered, which
457
+ // the skill can retry later.
165
458
  if (redeemed.shellAccess) {
166
459
  try {
167
460
  if (!existsSync(keyPath)) {
168
461
  mkdirSync(keysDir, { recursive: true, mode: 0o700 });
169
- execFileSync('ssh-keygen', ['-t', 'ed25519', '-f', keyPath, '-N', '', '-C', `awesomate:${redeemed.slug ?? 'site'}`], { stdio: 'ignore' });
462
+ execFileSync('ssh-keygen', ['-t', 'ed25519', '-f', keyPath, '-N', '', '-C', `awesomate:${slug}`], { stdio: 'pipe' });
170
463
  chmodSync(keyPath, 0o600);
171
464
  }
172
465
  const publicKey = readFileSync(`${keyPath}.pub`, 'utf8').trim();
@@ -174,23 +467,37 @@ async function main() {
174
467
  token: redeemed.pat,
175
468
  body: { publicKey, label: `Claude Code on ${hostname()}` },
176
469
  });
177
- creds.ssh = { host: reg.sshHost, user: reg.sshUser, port: reg.sshPort ?? 22, keyPath, fingerprint: reg.fingerprint };
178
- writeCreds(creds);
470
+ profile.ssh = { host: reg.sshHost, user: reg.sshUser, port: reg.sshPort ?? 22, keyPath, fingerprint: reg.fingerprint };
471
+ mergeProfile(profile);
179
472
  console.log(`✓ SSH key authorized on ${reg.sshUser}@${reg.sshHost} (jailed to your account).`);
180
473
  } catch (err) {
181
- console.log(`• SSH key not registered yet: ${err.message}`);
182
- 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".');
474
+ if (err?.code === 'ENOENT') {
475
+ 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.');
476
+ } else {
477
+ console.log(`• SSH key not registered yet: ${err.message}`);
478
+ 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".');
479
+ }
183
480
  }
184
481
  } else {
185
482
  console.log('• Plan has no shell access — read/status mode. Upgrade to Support Plus for SSH + deploy.');
186
483
  }
187
484
 
188
- console.log('\nSetup complete. Next: ask me to read your plan, list your sites, and tell you what we can do.');
189
- if (!registered) 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.)');
485
+ const who = profile.email ? `${slug} (${profile.email}, plan ${redeemed.plan})` : `${slug} (plan ${redeemed.plan})`;
486
+ console.log(`\n✓ Connected: ${who}${pinPath ? `pinned ${dirname(pinPath)}` : ''}`);
487
+ console.log('Setup complete. Next: run awesomate_whoami to confirm the account, then ask me to read your plan and list your sites.');
488
+ 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.)');
489
+ if (issues.length) {
490
+ console.log(`AWESOMATE CONNECT: PARTIAL account=${slug} pin=${pinPath ?? 'none'} issues=${issues.join(',')}`);
491
+ process.exit(1);
492
+ }
493
+ console.log(`AWESOMATE CONNECT: SUCCESS account=${slug} pin=${pinPath ?? 'none'}`);
190
494
  }
191
495
 
192
496
  main().catch((err) => {
193
497
  console.error('Bootstrap failed:', err.message);
194
- console.error('If the code expired or was already used, grab a fresh one from hub.awesomate.ai/sites.');
498
+ if (err.isApiError && err.status === 401) {
499
+ console.error('Grab a fresh code from hub.awesomate.ai/sites — codes last 10 minutes (and stay re-runnable within that window).');
500
+ }
501
+ console.error('AWESOMATE CONNECT: FAILED reason=error');
195
502
  process.exit(1);
196
503
  });