@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/dist/index.js +18893 -147
- package/package.json +4 -2
- package/skill/awesomate-hosting/SKILL.md +63 -5
- package/skill/awesomate-hosting/scripts/bootstrap.mjs +365 -58
- package/skill/awesomate-hosting/scripts/deploy.sh +8 -9
- package/skill/awesomate-hosting/scripts/pull-live.sh +4 -6
- package/skill/awesomate-hosting/scripts/resolve-account.mjs +100 -0
- package/skill/awesomate-hosting/scripts/ssh-connect.sh +7 -7
- package/skill/awesomate-n8n/SKILL.md +12 -3
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@awesomate/hosting-mcp",
|
|
3
|
-
"version": "0.
|
|
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
|
-
"
|
|
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 **`
|
|
28
|
-
|
|
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
|
|
49
|
-
|
|
50
|
-
|
|
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
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
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 [--
|
|
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
|
-
*
|
|
16
|
-
*
|
|
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
|
-
|
|
33
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
51
|
-
|
|
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
|
-
|
|
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
|
-
|
|
124
|
-
const
|
|
125
|
-
body: { code, machineLabel: `Claude Code on ${hostname()}` },
|
|
126
|
-
});
|
|
365
|
+
await initProxySupport();
|
|
366
|
+
const issues = [];
|
|
127
367
|
|
|
128
|
-
|
|
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
|
|
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
|
-
|
|
138
|
-
console.log(`✓ Access token stored
|
|
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.
|
|
142
|
-
//
|
|
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
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
console.
|
|
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 config — the 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
|
|
164
|
-
// ready until it's registered, which
|
|
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:${
|
|
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
|
-
|
|
178
|
-
|
|
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
|
-
|
|
182
|
-
|
|
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
|
-
|
|
189
|
-
|
|
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
|
-
|
|
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
|
});
|