@awesomate/hosting-mcp 0.7.0 → 0.7.2

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.7.0",
3
+ "version": "0.7.2",
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",
@@ -24,6 +24,7 @@
24
24
  },
25
25
  "dependencies": {
26
26
  "@modelcontextprotocol/sdk": "^1.29.0",
27
+ "undici": "^6.21.0",
27
28
  "zod": "^3.24.1"
28
29
  },
29
30
  "devDependencies": {
@@ -68,8 +68,34 @@ fallback** — that's deliberate.
68
68
  - Connecting a second account never disconnects the first; re-pairing an
69
69
  account just refreshes its profile.
70
70
  - The bootstrap prints a final `AWESOMATE CONNECT: SUCCESS account=<slug>` (or
71
- `FAILED reason=…`) sentinel — when a connect ran as a `!` bang command, check
72
- for that line instead of assuming success from silence.
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).
73
99
 
74
100
  If this skill is loaded but **no `awesomate_*` tools exist in the session at
75
101
  all**, the MCP server was registered after Claude Code started (the bootstrap
@@ -189,7 +215,9 @@ Git skills drive the Git workflow; this skill just deploys the result.
189
215
  effort) generates an SSH keypair and registers the public key. If the SSH
190
216
  step fails (e.g. hosting not fully provisioned), the token + skill + MCP are
191
217
  still set up. The Connect-Claude-Code prompt from the hub runs this via
192
- `npx -p @awesomate/hosting-mcp awesomate-hosting-bootstrap`.
218
+ `npx -y --package=@awesomate/hosting-mcp awesomate-hosting-bootstrap`.
219
+ (Always the `--package=` form — a bare `-p` breaks the claude CLI's argv
220
+ pre-scan if the command is ever wrapped in `claude mcp add`.)
193
221
  - `ssh-connect.sh` — open a jailed SSH session (or run one remote command).
194
222
  - `wp.sh` — run a WP-CLI command against a live site over that SSH.
195
223
  - `deploy.sh` — snapshot-first Studio→live deploy (files, optional DB).
@@ -2,27 +2,33 @@
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 as a PROFILE (multi-account
7
- * safe); pins the current folder to that account; and (on Support Plus+)
8
- * generates an SSH keypair and authorizes the PUBLIC key on the client's own
9
- * cPanel account. The private key never leaves this machine; the setup code is
10
- * the only secret that ever crossed the clipboard, and it's single-use + short-
11
- * lived. The access token itself is never printed.
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.
12
12
  *
13
13
  * Usage (Claude runs this — the user doesn't touch a terminal):
14
- * node bootstrap.mjs --code amt_bs_XXXX [--account <slug>] [--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
15
16
  *
16
- * --account asserts WHICH account the code is expected to connect. If the
17
- * redeemed identity doesn't match, the bootstrap aborts before writing
18
- * anything connecting the wrong account loudly beats connecting it silently.
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.
19
21
  *
20
- * Dependency-free: Node 18+ built-ins only (global fetch, node:fs/os/path,
21
- * child_process for ssh-keygen + the claude CLI).
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.
22
28
  */
23
29
 
24
- import { execFileSync } from 'node:child_process';
25
- 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';
26
32
  import { homedir, hostname } from 'node:os';
27
33
  import { join, dirname, resolve } from 'node:path';
28
34
  import { fileURLToPath } from 'node:url';
@@ -33,13 +39,21 @@ function arg(name, fallback) {
33
39
  }
34
40
 
35
41
  const code = arg('code');
42
+ const resumePat = arg('pat');
36
43
  const expectedAccount = arg('account', null);
44
+ const pinDirArg = arg('pin-dir', null);
37
45
  const apiBase = (arg('api', 'https://hub.awesomate.ai')).replace(/\/$/, '');
38
- if (!code || !code.startsWith('amt_bs_')) {
39
- 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).');
40
49
  console.error('AWESOMATE CONNECT: FAILED reason=bad-code');
41
50
  process.exit(1);
42
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');
55
+ process.exit(1);
56
+ }
43
57
 
44
58
  const dir = join(homedir(), '.awesomate');
45
59
  const keysDir = join(dir, 'keys');
@@ -47,20 +61,81 @@ const credPath = join(dir, 'credentials.json');
47
61
  const keyPath = join(keysDir, 'id_ed25519');
48
62
  const PIN_FILENAME = '.awesomate.json';
49
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
+ : '';
107
+
50
108
  async function request(method, path, { body, token } = {}) {
51
109
  const headers = { 'Content-Type': 'application/json' };
52
110
  if (token) headers.Authorization = `Bearer ${token}`;
53
- const res = await fetch(`${apiBase}${path}`, {
54
- method,
55
- headers,
56
- body: method === 'GET' ? undefined : JSON.stringify(body ?? {}),
57
- });
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
+ }
58
122
  const text = await res.text();
59
123
  let json;
60
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;
61
126
  if (!res.ok) {
62
- const msg = json && typeof json === 'object' && json.error ? json.error : `HTTP ${res.status}`;
63
- 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
+ );
64
139
  }
65
140
  return json;
66
141
  }
@@ -74,6 +149,13 @@ function readJsonFile(path) {
74
149
  } catch { return null; }
75
150
  }
76
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
+
77
159
  /**
78
160
  * Merge one account profile into credentials.json (v2 multi-profile shape).
79
161
  * Existing profiles for OTHER accounts are preserved — connecting a second
@@ -122,25 +204,33 @@ function mergeProfile(profile) {
122
204
 
123
205
  mkdirSync(dir, { recursive: true, mode: 0o700 });
124
206
  chmodSync(dir, 0o700);
125
- writeFileSync(credPath, JSON.stringify(creds, null, 2));
207
+ writeJsonFile(credPath, creds);
126
208
  chmodSync(credPath, 0o600);
127
209
  return creds;
128
210
  }
129
211
 
130
- /** Pin the folder bootstrap ran in to this account. Never pins $HOME. */
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
+ */
131
217
  function writePin(slug) {
132
- const cwd = resolve(process.cwd());
133
- if (cwd === resolve(homedir())) {
134
- console.log(`• Running from the home directory — not writing a ${PIN_FILENAME} pin. Re-run Connect from the project folder to pin it.`);
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.`);
135
225
  return null;
136
226
  }
137
- const pinPath = join(cwd, PIN_FILENAME);
227
+ const pinPath = join(target, PIN_FILENAME);
138
228
  const existing = readJsonFile(pinPath);
139
229
  if (existing?.account && existing.account !== slug) {
140
- console.log(`• Re-pinning this folder: ${existing.account} → ${slug}.`);
230
+ console.log(`• Re-pinning ${target}: ${existing.account} → ${slug}.`);
141
231
  }
142
- writeFileSync(pinPath, `${JSON.stringify({ version: 1, account: slug }, null, 2)}\n`);
143
- console.log(`✓ Pinned ${cwd} to account "${slug}" (${PIN_FILENAME}).`);
232
+ writeJsonFile(pinPath, { version: 1, account: slug });
233
+ console.log(`✓ Pinned ${target} to account "${slug}" (${PIN_FILENAME}).`);
144
234
  return pinPath;
145
235
  }
146
236
 
@@ -152,12 +242,6 @@ function writePin(slug) {
152
242
  * failure here never aborts the rest of setup.
153
243
  */
154
244
  function installSkill() {
155
- // bootstrap.mjs lives at <skill>/scripts/bootstrap.mjs → the skill dir is
156
- // `..` and every sibling of it under the package's skill/ dir is another
157
- // bundled skill (e.g. awesomate-n8n). Install them all. When bootstrap is
158
- // re-run from an already-installed copy (~/.claude/skills/...), the parent
159
- // is the user's whole skills folder — in that case only reinstall this one
160
- // skill, never its unrelated siblings.
161
245
  const hostingSkill = dirname(dirname(fileURLToPath(import.meta.url)));
162
246
  const skillRoot = dirname(hostingSkill);
163
247
  // Only the package layout (skill/<name>/) has bundled siblings; from an
@@ -203,100 +287,153 @@ function installSkill() {
203
287
  return true;
204
288
  }
205
289
 
206
- function claudeCli(args) {
207
- return execFileSync('claude', args, { stdio: ['ignore', 'pipe', 'pipe'], encoding: 'utf8' });
208
- }
209
-
210
- const MANUAL_ADD =
211
- 'claude mcp add --scope user awesomate-hosting -- npx -y -p @awesomate/hosting-mcp awesomate-hosting-mcp';
290
+ // --package= (with the =) instead of -p: the claude CLI pre-scans the FULL
291
+ // argv for its own global -p/--print flag even after the `--` separator —
292
+ // so any `claude mcp add … -- npx -y -p pkg bin` flips it into print mode and
293
+ // it rejects the scope flag as "unknown option" (marlins bug report,
294
+ // 2026-07-28). npx treats both forms identically at runtime; the = form keeps
295
+ // the value glued to the token so these args are safe to copy anywhere.
296
+ // The explicit package must stay: this package ships two bins.
297
+ const CLEAN_ENTRY = {
298
+ command: 'npx',
299
+ args: ['-y', '--package=@awesomate/hosting-mcp', 'awesomate-hosting-mcp'],
300
+ };
301
+ const MANUAL_ADD_JSON = JSON.stringify({ mcpServers: { 'awesomate-hosting': CLEAN_ENTRY } }, null, 2);
212
302
 
213
303
  /**
214
- * Converge on exactly one env-free, user-scope MCP registration.
304
+ * Converge on exactly one env-free, user-scope MCP registration by editing the
305
+ * config files DIRECTLY — `claude mcp add` flag syntax has drifted between CLI
306
+ * versions (--scope/-s rejected on some), and a registration is too important
307
+ * to hang off a moving target.
215
308
  *
216
309
  * Legacy bootstraps baked AWESOMATE_PAT into the registration env — env beats
217
310
  * credentials.json inside the server, so those registrations keep serving a
218
- * STALE account forever after a re-pair. Remove every registration that
219
- * carries the env var (user scope, plus this project's .mcp.json / local
220
- * scope), then add the clean one. Registrations in OTHER projects' local
221
- * scopes can't be removed from here — those are reported for manual cleanup.
222
- *
223
- * Throws on failure: a wrong registration is exactly the silent-wrong-account
224
- * bug this bootstrap exists to prevent.
311
+ * STALE account forever after a re-pair. Remove every entry that carries the
312
+ * env var (user scope, this project's local scope + .mcp.json); entries in
313
+ * OTHER projects are reported for manual cleanup, not touched.
225
314
  */
226
315
  function ensureMcpRegistration() {
227
316
  const cwd = resolve(process.cwd());
228
- const claudeConfig = readJsonFile(join(homedir(), '.claude.json')) ?? {};
229
- const warnings = [];
230
-
231
- const scopesToClean = [];
232
- if (claudeConfig.mcpServers?.['awesomate-hosting']?.env?.AWESOMATE_PAT) scopesToClean.push('user');
233
- if (readJsonFile(join(cwd, '.mcp.json'))?.mcpServers?.['awesomate-hosting']?.env?.AWESOMATE_PAT) scopesToClean.push('project');
234
- for (const [projectDir, project] of Object.entries(claudeConfig.projects ?? {})) {
235
- if (!project?.mcpServers?.['awesomate-hosting']?.env?.AWESOMATE_PAT) continue;
236
- if (resolve(projectDir) === cwd) scopesToClean.push('local');
237
- else warnings.push(`cd ${projectDir} && claude mcp remove awesomate-hosting -s local`);
238
- }
317
+ const claudeJsonPath = join(homedir(), '.claude.json');
239
318
 
240
- for (const scope of scopesToClean) {
241
- claudeCli(['mcp', 'remove', 'awesomate-hosting', '-s', scope]);
242
- console.log(`✓ Removed legacy ${scope}-scope registration (had a token baked into its env).`);
319
+ let cfg = {};
320
+ if (existsSync(claudeJsonPath)) {
321
+ cfg = readJsonFile(claudeJsonPath);
322
+ if (!cfg) {
323
+ throw new Error(`${claudeJsonPath} exists but is not valid JSON — refusing to rewrite it. Fix the file, then add:\n${MANUAL_ADD_JSON}`);
324
+ }
243
325
  }
244
326
 
245
- const userEntry = claudeConfig.mcpServers?.['awesomate-hosting'];
246
- const userEntryIsClean =
247
- userEntry && !userEntry.env?.AWESOMATE_PAT && !scopesToClean.includes('user') &&
248
- JSON.stringify(userEntry.args ?? []).includes('awesomate-hosting-mcp');
249
- if (userEntryIsClean) {
250
- console.log('✓ MCP server already registered (user scope, env-free) — account selection comes from the folder pin.');
327
+ const entry = cfg.mcpServers?.['awesomate-hosting'];
328
+ const entryIsClean =
329
+ entry && !entry.env?.AWESOMATE_PAT && JSON.stringify(entry.args ?? []).includes('awesomate-hosting-mcp');
330
+
331
+ let changed = false;
332
+ if (!entryIsClean) {
333
+ cfg.mcpServers = { ...(cfg.mcpServers ?? {}), 'awesomate-hosting': CLEAN_ENTRY };
334
+ changed = true;
335
+ console.log(
336
+ entry?.env?.AWESOMATE_PAT
337
+ ? '✓ Replaced the legacy user-scope registration (had a token baked into its env) with an env-free one.'
338
+ : '✓ Registered the Awesomate Hosting MCP server (user scope, env-free — the folder pin picks the account).',
339
+ );
251
340
  } else {
252
- if (userEntry && !scopesToClean.includes('user')) {
253
- // Exists but is odd (wrong command, partial env) — replace it.
254
- try { claudeCli(['mcp', 'remove', 'awesomate-hosting', '-s', 'user']); } catch { /* may not exist at this scope */ }
341
+ console.log('✓ MCP server already registered (user scope, env-free) — account selection comes from the folder pin.');
342
+ }
343
+
344
+ // This project's local-scope entry (lives under projects[cwd] in ~/.claude.json).
345
+ const localEntry = cfg.projects?.[cwd]?.mcpServers?.['awesomate-hosting'];
346
+ if (localEntry?.env?.AWESOMATE_PAT) {
347
+ delete cfg.projects[cwd].mcpServers['awesomate-hosting'];
348
+ changed = true;
349
+ console.log("✓ Removed this project's legacy local-scope registration (token baked into env).");
350
+ }
351
+ // Other projects: report, don't touch.
352
+ for (const [projectDir, project] of Object.entries(cfg.projects ?? {})) {
353
+ if (resolve(projectDir) === cwd) continue;
354
+ if (project?.mcpServers?.['awesomate-hosting']?.env?.AWESOMATE_PAT) {
355
+ 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.`);
255
356
  }
256
- claudeCli(['mcp', 'add', '--scope', 'user', 'awesomate-hosting', '--',
257
- 'npx', '-y', '-p', '@awesomate/hosting-mcp', 'awesomate-hosting-mcp']);
258
- console.log('✓ Registered the Awesomate Hosting MCP server (user scope, env-free — the folder pin picks the account).');
259
357
  }
260
358
 
261
- for (const w of warnings) {
262
- console.log(`• Another project still has a legacy registration with a baked-in token. From that project run:\n ${w}`);
359
+ if (changed) {
360
+ writeJsonFile(claudeJsonPath, cfg);
361
+ // Read back and verify — never trust a write (or a CLI exit code) alone.
362
+ const verify = readJsonFile(claudeJsonPath);
363
+ const written = verify?.mcpServers?.['awesomate-hosting'];
364
+ if (!written || written.command !== CLEAN_ENTRY.command || !JSON.stringify(written.args ?? []).includes('awesomate-hosting-mcp')) {
365
+ throw new Error(`wrote ${claudeJsonPath} but the awesomate-hosting entry did not verify on read-back`);
366
+ }
367
+ }
368
+
369
+ // Project-scope .mcp.json in cwd.
370
+ const mcpJsonPath = join(cwd, '.mcp.json');
371
+ const mcpJson = readJsonFile(mcpJsonPath);
372
+ if (mcpJson?.mcpServers?.['awesomate-hosting']?.env?.AWESOMATE_PAT) {
373
+ delete mcpJson.mcpServers['awesomate-hosting'];
374
+ writeJsonFile(mcpJsonPath, mcpJson);
375
+ console.log('✓ Removed the legacy project-scope (.mcp.json) registration (token baked into env).');
263
376
  }
264
377
  }
265
378
 
266
379
  async function main() {
267
- console.log(`Redeeming setup code against ${apiBase} …`);
268
- const redeemed = await post('/api/hosting-access/redeem', {
269
- body: { code, machineLabel: `Claude Code on ${hostname()}` },
270
- });
380
+ await initProxySupport();
381
+ const issues = [];
382
+
383
+ // ── Identity: redeem a code, or verify a resumed PAT ─────────────────────
384
+ let redeemed;
385
+ if (resumePat) {
386
+ console.log(`Resuming with an existing access token — verifying against ${apiBase} …`);
387
+ const ctx = await get('/api/hosting-access/context', { token: resumePat });
388
+ redeemed = {
389
+ pat: resumePat,
390
+ contactId: ctx.contactId,
391
+ slug: ctx.slug ?? null,
392
+ email: ctx.email ?? null,
393
+ plan: ctx.plan,
394
+ shellAccess: Boolean(ctx.capabilities?.shell),
395
+ cpanel: ctx.cpanel ?? null,
396
+ expiresAt: ctx.patExpiresAt ?? null,
397
+ };
398
+ } else {
399
+ console.log(`Redeeming setup code against ${apiBase} …`);
400
+ redeemed = await post('/api/hosting-access/redeem', {
401
+ body: { code, machineLabel: `Claude Code on ${hostname()}` },
402
+ });
403
+ }
271
404
 
272
405
  const slug = redeemed.slug ?? null;
273
406
  if (!slug) {
274
- console.error('The hub did not return an account slug for this code — cannot safely store a profile. Contact Awesomate support.');
407
+ console.error('The hub did not return an account slug — cannot safely store a profile. Contact Awesomate support.');
275
408
  console.error('AWESOMATE CONNECT: FAILED reason=no-slug');
276
409
  process.exit(1);
277
410
  }
278
411
  // Wrong-account guard: abort BEFORE writing anything.
279
412
  if (expectedAccount && expectedAccount !== slug) {
280
- console.error(`This setup code belongs to account "${slug}" but this connect was for "${expectedAccount}".`);
413
+ console.error(`This ${resumePat ? 'token' : 'setup code'} belongs to account "${slug}" but this connect was for "${expectedAccount}".`);
281
414
  console.error(`You are probably logged into the wrong hub account — log into ${expectedAccount}'s hub account and copy a fresh Connect prompt.`);
282
415
  console.error(`AWESOMATE CONNECT: FAILED reason=account-mismatch expected=${expectedAccount} got=${slug}`);
283
416
  process.exit(1);
284
417
  }
285
418
 
286
- // Server-side identity check with the NEW token before trusting it.
287
- let context = null;
288
- try {
289
- context = await get('/api/hosting-access/context', { token: redeemed.pat });
290
- } catch (err) {
291
- console.error(`Token verification failed (${err.message}) not storing it. Re-run with a fresh code from hub.awesomate.ai/sites.`);
292
- console.error('AWESOMATE CONNECT: FAILED reason=verify-failed');
293
- process.exit(1);
294
- }
295
- const ctxSlug = context?.slug ?? context?.account?.slug ?? null;
296
- if (ctxSlug && ctxSlug !== slug) {
297
- console.error(`Identity mismatch: redeem said "${slug}" but the token resolves to "${ctxSlug}". Not storing it — contact Awesomate support.`);
298
- console.error('AWESOMATE CONNECT: FAILED reason=identity-mismatch');
299
- process.exit(1);
419
+ // Server-side identity check with the NEW token before trusting it (the
420
+ // resume path already did exactly this to build `redeemed`).
421
+ if (!resumePat) {
422
+ let context = null;
423
+ try {
424
+ context = await get('/api/hosting-access/context', { token: redeemed.pat });
425
+ } catch (err) {
426
+ console.error(`Token verification failed (${err.message}) — not storing it. Re-run the same command (setup codes stay valid for 10 minutes).`);
427
+ console.error('AWESOMATE CONNECT: FAILED reason=verify-failed');
428
+ process.exit(1);
429
+ }
430
+ const ctxSlug = context?.slug ?? null;
431
+ if (ctxSlug && ctxSlug !== slug) {
432
+ console.error(`Identity mismatch: redeem said "${slug}" but the token resolves to "${ctxSlug}". Not storing it — contact Awesomate support.`);
433
+ console.error('AWESOMATE CONNECT: FAILED reason=identity-mismatch');
434
+ process.exit(1);
435
+ }
436
+ redeemed.email = redeemed.email ?? context?.email ?? null;
300
437
  }
301
438
 
302
439
  const profile = {
@@ -304,7 +441,7 @@ async function main() {
304
441
  pat: redeemed.pat,
305
442
  contactId: redeemed.contactId,
306
443
  slug,
307
- email: redeemed.email ?? context?.email ?? context?.account?.email ?? null,
444
+ email: redeemed.email ?? null,
308
445
  plan: redeemed.plan,
309
446
  cpanel: redeemed.cpanel ?? null,
310
447
  expiresAt: redeemed.expiresAt ?? null,
@@ -315,8 +452,8 @@ async function main() {
315
452
  const pinPath = writePin(slug);
316
453
 
317
454
  // Install the skill + register the MCP server FIRST — these always work and
318
- // don't depend on hosting being fully provisioned. SSH comes last so that a
319
- // shell/provisioning hiccup can't strand the skill or MCP.
455
+ // don't depend on hosting being fully provisioned. Neither is allowed to
456
+ // strand the other or SSH: every step from here is collected, not fatal.
320
457
  try { installSkill(); } catch (err) {
321
458
  console.log(`• Could not auto-install the skill (${err.message}) — continuing; you can copy skill/ manually.`);
322
459
  }
@@ -324,15 +461,15 @@ async function main() {
324
461
  try {
325
462
  ensureMcpRegistration();
326
463
  } catch (err) {
327
- console.error(`MCP registration failed: ${err.message}`);
328
- console.error(`Register it manually (no token goes in the command — the folder pin picks the account):\n ${MANUAL_ADD}`);
329
- console.error(`AWESOMATE CONNECT: FAILED reason=mcp-registration account=${slug}`);
330
- process.exit(1);
464
+ issues.push('mcp-registration');
465
+ console.error(`• MCP registration failed: ${err.message}`);
466
+ console.error(` Add it manually (no token goes in the config — the folder pin picks the account):\n${MANUAL_ADD_JSON}`);
331
467
  }
332
468
 
333
469
  // SSH key (Support Plus+): best-effort. If it fails (e.g. hosting not fully
334
- // provisioned yet), keep the token + skill + MCP deploy/shell just aren't
335
- // ready until it's registered, which the skill can retry later.
470
+ // provisioned yet, or no ssh tooling in this environment), keep the token +
471
+ // skill + MCP — deploy/shell just aren't ready until it's registered, which
472
+ // the skill can retry later.
336
473
  if (redeemed.shellAccess) {
337
474
  try {
338
475
  if (!existsSync(keyPath)) {
@@ -349,8 +486,12 @@ async function main() {
349
486
  mergeProfile(profile);
350
487
  console.log(`✓ SSH key authorized on ${reg.sshUser}@${reg.sshHost} (jailed to your account).`);
351
488
  } catch (err) {
352
- console.log(`• SSH key not registered yet: ${err.message}`);
353
- 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".');
489
+ if (err?.code === 'ENOENT') {
490
+ 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.');
491
+ } else {
492
+ console.log(`• SSH key not registered yet: ${err.message}`);
493
+ 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".');
494
+ }
354
495
  }
355
496
  } else {
356
497
  console.log('• Plan has no shell access — read/status mode. Upgrade to Support Plus for SSH + deploy.');
@@ -360,12 +501,18 @@ async function main() {
360
501
  console.log(`\n✓ Connected: ${who}${pinPath ? ` — pinned ${dirname(pinPath)}` : ''}`);
361
502
  console.log('Setup complete. Next: run awesomate_whoami to confirm the account, then ask me to read your plan and list your sites.');
362
503
  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.)');
504
+ if (issues.length) {
505
+ console.log(`AWESOMATE CONNECT: PARTIAL account=${slug} pin=${pinPath ?? 'none'} issues=${issues.join(',')}`);
506
+ process.exit(1);
507
+ }
363
508
  console.log(`AWESOMATE CONNECT: SUCCESS account=${slug} pin=${pinPath ?? 'none'}`);
364
509
  }
365
510
 
366
511
  main().catch((err) => {
367
512
  console.error('Bootstrap failed:', err.message);
368
- console.error('If the code expired or was already used, grab a fresh one from hub.awesomate.ai/sites.');
513
+ if (err.isApiError && err.status === 401) {
514
+ console.error('Grab a fresh code from hub.awesomate.ai/sites — codes last 10 minutes (and stay re-runnable within that window).');
515
+ }
369
516
  console.error('AWESOMATE CONNECT: FAILED reason=error');
370
517
  process.exit(1);
371
518
  });