@awesomate/hosting-mcp 0.6.2 → 0.7.0

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 CHANGED
@@ -2980,7 +2980,7 @@ var require_compile = __commonJS({
2980
2980
  const schOrFunc = root.refs[ref];
2981
2981
  if (schOrFunc)
2982
2982
  return schOrFunc;
2983
- let _sch = resolve.call(this, root, ref);
2983
+ let _sch = resolve2.call(this, root, ref);
2984
2984
  if (_sch === void 0) {
2985
2985
  const schema = (_a = root.localRefs) === null || _a === void 0 ? void 0 : _a[ref];
2986
2986
  const { schemaId } = this.opts;
@@ -3007,7 +3007,7 @@ var require_compile = __commonJS({
3007
3007
  function sameSchemaEnv(s1, s2) {
3008
3008
  return s1.schema === s2.schema && s1.root === s2.root && s1.baseId === s2.baseId;
3009
3009
  }
3010
- function resolve(root, ref) {
3010
+ function resolve2(root, ref) {
3011
3011
  let sch;
3012
3012
  while (typeof (sch = this.refs[ref]) == "string")
3013
3013
  ref = sch;
@@ -3638,7 +3638,7 @@ var require_fast_uri = __commonJS({
3638
3638
  }
3639
3639
  return uri;
3640
3640
  }
3641
- function resolve(baseURI, relativeURI, options) {
3641
+ function resolve2(baseURI, relativeURI, options) {
3642
3642
  const schemelessOptions = options ? Object.assign({ scheme: "null" }, options) : { scheme: "null" };
3643
3643
  const resolved = resolveComponent(parse3(baseURI, schemelessOptions), parse3(relativeURI, schemelessOptions), schemelessOptions, true);
3644
3644
  schemelessOptions.skipEscape = true;
@@ -3902,7 +3902,7 @@ var require_fast_uri = __commonJS({
3902
3902
  var fastUri = {
3903
3903
  SCHEMES,
3904
3904
  normalize,
3905
- resolve,
3905
+ resolve: resolve2,
3906
3906
  resolveComponent,
3907
3907
  equal,
3908
3908
  serialize,
@@ -18985,7 +18985,7 @@ var Protocol = class {
18985
18985
  return;
18986
18986
  }
18987
18987
  const pollInterval = task2.pollInterval ?? this._options?.defaultTaskPollInterval ?? 1e3;
18988
- await new Promise((resolve) => setTimeout(resolve, pollInterval));
18988
+ await new Promise((resolve2) => setTimeout(resolve2, pollInterval));
18989
18989
  options?.signal?.throwIfAborted();
18990
18990
  }
18991
18991
  } catch (error2) {
@@ -19002,7 +19002,7 @@ var Protocol = class {
19002
19002
  */
19003
19003
  request(request, resultSchema, options) {
19004
19004
  const { relatedRequestId, resumptionToken, onresumptiontoken, task, relatedTask } = options ?? {};
19005
- return new Promise((resolve, reject) => {
19005
+ return new Promise((resolve2, reject) => {
19006
19006
  const earlyReject = (error2) => {
19007
19007
  reject(error2);
19008
19008
  };
@@ -19080,7 +19080,7 @@ var Protocol = class {
19080
19080
  if (!parseResult.success) {
19081
19081
  reject(parseResult.error);
19082
19082
  } else {
19083
- resolve(parseResult.data);
19083
+ resolve2(parseResult.data);
19084
19084
  }
19085
19085
  } catch (error2) {
19086
19086
  reject(error2);
@@ -19341,12 +19341,12 @@ var Protocol = class {
19341
19341
  }
19342
19342
  } catch {
19343
19343
  }
19344
- return new Promise((resolve, reject) => {
19344
+ return new Promise((resolve2, reject) => {
19345
19345
  if (signal.aborted) {
19346
19346
  reject(new McpError(ErrorCode.InvalidRequest, "Request cancelled"));
19347
19347
  return;
19348
19348
  }
19349
- const timeoutId = setTimeout(resolve, interval);
19349
+ const timeoutId = setTimeout(resolve2, interval);
19350
19350
  signal.addEventListener("abort", () => {
19351
19351
  clearTimeout(timeoutId);
19352
19352
  reject(new McpError(ErrorCode.InvalidRequest, "Request cancelled"));
@@ -20446,7 +20446,7 @@ var McpServer = class {
20446
20446
  let task = createTaskResult.task;
20447
20447
  const pollInterval = task.pollInterval ?? 5e3;
20448
20448
  while (task.status !== "completed" && task.status !== "failed" && task.status !== "cancelled") {
20449
- await new Promise((resolve) => setTimeout(resolve, pollInterval));
20449
+ await new Promise((resolve2) => setTimeout(resolve2, pollInterval));
20450
20450
  const updatedTask = await extra.taskStore.getTask(taskId);
20451
20451
  if (!updatedTask) {
20452
20452
  throw new McpError(ErrorCode.InternalError, `Task ${taskId} not found during polling`);
@@ -21095,12 +21095,12 @@ var StdioServerTransport = class {
21095
21095
  this.onclose?.();
21096
21096
  }
21097
21097
  send(message) {
21098
- return new Promise((resolve) => {
21098
+ return new Promise((resolve2) => {
21099
21099
  const json = serializeMessage(message);
21100
21100
  if (this._stdout.write(json)) {
21101
- resolve();
21101
+ resolve2();
21102
21102
  } else {
21103
- this._stdout.once("drain", resolve);
21103
+ this._stdout.once("drain", resolve2);
21104
21104
  }
21105
21105
  });
21106
21106
  }
@@ -21109,43 +21109,168 @@ var StdioServerTransport = class {
21109
21109
  // src/index.ts
21110
21110
  import { readFileSync as readFileSync2 } from "node:fs";
21111
21111
  import { homedir as homedir2 } from "node:os";
21112
- import { join as join2, dirname } from "node:path";
21112
+ import { join as join2, dirname as dirname2 } from "node:path";
21113
21113
  import { fileURLToPath } from "node:url";
21114
21114
 
21115
21115
  // src/config.ts
21116
- import { readFileSync } from "node:fs";
21116
+ import { existsSync, readFileSync } from "node:fs";
21117
21117
  import { homedir } from "node:os";
21118
- import { join } from "node:path";
21118
+ import { dirname, isAbsolute, join, resolve } from "node:path";
21119
21119
  var DEFAULT_API_BASE = "https://hub.awesomate.ai";
21120
- function loadConfig() {
21121
- let fileCreds = {};
21122
- const credPath = join(homedir(), ".awesomate", "credentials.json");
21120
+ var PIN_FILENAME = ".awesomate.json";
21121
+ function credentialsPath() {
21122
+ return join(homedir(), ".awesomate", "credentials.json");
21123
+ }
21124
+ function readJsonObject(path, label) {
21125
+ let raw;
21126
+ try {
21127
+ raw = readFileSync(path, "utf8");
21128
+ } catch (err) {
21129
+ if (err?.code !== "ENOENT") {
21130
+ console.error(`[awesomate-hosting-mcp] could not read ${label} at ${path}: ${err.message}`);
21131
+ }
21132
+ return null;
21133
+ }
21123
21134
  try {
21124
- const raw = readFileSync(credPath, "utf8");
21125
21135
  const parsed = JSON.parse(raw);
21126
- if (parsed && typeof parsed === "object") {
21127
- fileCreds = parsed;
21128
- } else {
21129
- console.error(`[awesomate-hosting-mcp] ${credPath} is not a JSON object \u2014 ignoring it.`);
21136
+ if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
21137
+ return parsed;
21130
21138
  }
21139
+ console.error(`[awesomate-hosting-mcp] ${path} is not a JSON object \u2014 ignoring it.`);
21131
21140
  } catch (err) {
21132
- if (err?.code && err.code !== "ENOENT") {
21133
- console.error(
21134
- `[awesomate-hosting-mcp] could not read ${credPath} (${err.message}) \u2014 falling back to env vars.`
21141
+ console.error(`[awesomate-hosting-mcp] ${path} is not valid JSON (${err.message}) \u2014 ignoring it.`);
21142
+ }
21143
+ return null;
21144
+ }
21145
+ function readProfiles(credPath = credentialsPath()) {
21146
+ const file = readJsonObject(credPath, "credentials") ?? {};
21147
+ const profiles = {};
21148
+ if (file.profiles && typeof file.profiles === "object") {
21149
+ for (const [key, profile] of Object.entries(file.profiles)) {
21150
+ if (profile && typeof profile === "object" && typeof profile.pat === "string") {
21151
+ profiles[key] = profile;
21152
+ }
21153
+ }
21154
+ } else if (typeof file.pat === "string" && file.pat) {
21155
+ profiles[file.slug || "default"] = {
21156
+ apiBase: file.apiBase,
21157
+ pat: file.pat,
21158
+ contactId: file.contactId,
21159
+ slug: file.slug,
21160
+ email: file.email,
21161
+ plan: file.plan,
21162
+ expiresAt: file.expiresAt,
21163
+ cpanel: file.cpanel,
21164
+ ssh: file.ssh
21165
+ };
21166
+ }
21167
+ const defaultProfile = typeof file.defaultProfile === "string" && profiles[file.defaultProfile] ? file.defaultProfile : null;
21168
+ return { profiles, defaultProfile };
21169
+ }
21170
+ function findPinFile(startDir = process.cwd()) {
21171
+ const explicit = process.env.AWESOMATE_PIN_FILE;
21172
+ if (explicit) {
21173
+ const path = isAbsolute(explicit) ? explicit : resolve(startDir, explicit);
21174
+ return existsSync(path) ? path : null;
21175
+ }
21176
+ const home = resolve(homedir());
21177
+ let dir = resolve(startDir);
21178
+ for (; ; ) {
21179
+ const candidate = join(dir, PIN_FILENAME);
21180
+ if (existsSync(candidate)) return candidate;
21181
+ if (dir === home) return null;
21182
+ const parent = dirname(dir);
21183
+ if (parent === dir) return null;
21184
+ dir = parent;
21185
+ }
21186
+ }
21187
+ function profileList(profiles) {
21188
+ const names = Object.keys(profiles);
21189
+ return names.length ? names.join(", ") : "(none \u2014 run Connect Claude Code from hub.awesomate.ai/sites)";
21190
+ }
21191
+ function fromProfile(key, profile, source, credPath, pinPath, availableProfiles) {
21192
+ const pat = profile.pat ?? "";
21193
+ if (!pat.startsWith("amt_pat_")) {
21194
+ throw new Error(
21195
+ `Profile "${key}" in ${credPath} has a malformed access token \u2014 re-run Connect Claude Code from hub.awesomate.ai/sites for this account.`
21196
+ );
21197
+ }
21198
+ return {
21199
+ apiBase: (process.env.AWESOMATE_API_BASE || profile.apiBase || DEFAULT_API_BASE).replace(/\/$/, ""),
21200
+ pat,
21201
+ account: profile.slug ?? key,
21202
+ email: profile.email ?? null,
21203
+ plan: profile.plan ?? null,
21204
+ expiresAt: profile.expiresAt ?? null,
21205
+ source,
21206
+ credPath,
21207
+ pinPath,
21208
+ availableProfiles
21209
+ };
21210
+ }
21211
+ function loadConfig() {
21212
+ const credPath = credentialsPath();
21213
+ const { profiles, defaultProfile } = readProfiles(credPath);
21214
+ const available = Object.keys(profiles);
21215
+ const envPat = process.env.AWESOMATE_PAT;
21216
+ if (envPat) {
21217
+ if (!envPat.startsWith("amt_pat_")) {
21218
+ throw new Error("AWESOMATE_PAT does not look like an Awesomate access token (expected amt_pat_ prefix).");
21219
+ }
21220
+ const matched = Object.entries(profiles).find(([, p]) => p.pat === envPat);
21221
+ return {
21222
+ apiBase: (process.env.AWESOMATE_API_BASE || matched?.[1].apiBase || DEFAULT_API_BASE).replace(/\/$/, ""),
21223
+ pat: envPat,
21224
+ account: matched ? matched[1].slug ?? matched[0] : null,
21225
+ email: matched?.[1].email ?? null,
21226
+ plan: matched?.[1].plan ?? null,
21227
+ expiresAt: matched?.[1].expiresAt ?? null,
21228
+ source: "env-pat",
21229
+ credPath,
21230
+ pinPath: null,
21231
+ availableProfiles: available
21232
+ };
21233
+ }
21234
+ const envAccount = process.env.AWESOMATE_ACCOUNT;
21235
+ if (envAccount) {
21236
+ const profile = profiles[envAccount];
21237
+ if (!profile) {
21238
+ throw new Error(
21239
+ `AWESOMATE_ACCOUNT="${envAccount}" has no matching profile in ${credPath}. Available profiles: ${profileList(profiles)}. Connect that account from hub.awesomate.ai/sites (logged in as it) to add its profile.`
21135
21240
  );
21136
21241
  }
21242
+ return fromProfile(envAccount, profile, "env-account", credPath, null, available);
21137
21243
  }
21138
- const pat = process.env.AWESOMATE_PAT || fileCreds.pat || "";
21139
- const apiBase = (process.env.AWESOMATE_API_BASE || fileCreds.apiBase || DEFAULT_API_BASE).replace(/\/$/, "");
21140
- if (!pat) {
21244
+ const pinPath = findPinFile();
21245
+ if (pinPath) {
21246
+ const pin = readJsonObject(pinPath, "project pin");
21247
+ const account = pin && typeof pin.account === "string" ? pin.account : null;
21248
+ if (!account) {
21249
+ throw new Error(`${pinPath} exists but has no "account" field. Expected: {"account": "<slug>"}.`);
21250
+ }
21251
+ const profile = profiles[account];
21252
+ if (!profile) {
21253
+ throw new Error(
21254
+ `This folder is pinned to account "${account}" (${pinPath}) but no matching profile exists in ${credPath}. Available profiles: ${profileList(profiles)}. Fix the pin, or connect "${account}" from hub.awesomate.ai/sites while logged into that account.`
21255
+ );
21256
+ }
21257
+ return fromProfile(account, profile, "pin", credPath, pinPath, available);
21258
+ }
21259
+ if (available.length === 1) {
21260
+ const key = available[0];
21261
+ return fromProfile(key, profiles[key], "sole-profile", credPath, null, available);
21262
+ }
21263
+ if (defaultProfile) {
21264
+ return fromProfile(defaultProfile, profiles[defaultProfile], "default-profile", credPath, null, available);
21265
+ }
21266
+ if (available.length === 0) {
21141
21267
  throw new Error(
21142
- "No Awesomate access token found. Set AWESOMATE_PAT or run the bootstrap from the hub Sites page (it writes ~/.awesomate/credentials.json)."
21268
+ `No Awesomate access token found. Set AWESOMATE_PAT or run Connect Claude Code from hub.awesomate.ai/sites (it writes ${credPath}).`
21143
21269
  );
21144
21270
  }
21145
- if (!pat.startsWith("amt_pat_")) {
21146
- throw new Error("AWESOMATE_PAT does not look like an Awesomate access token (expected amt_pat_ prefix).");
21147
- }
21148
- return { apiBase, pat };
21271
+ throw new Error(
21272
+ `Multiple Awesomate accounts are connected (${profileList(profiles)}) but this folder isn't pinned to one. Create a ${PIN_FILENAME} in the project root with {"account": "<slug>"}, set AWESOMATE_ACCOUNT, or set defaultProfile in credentials.json.`
21273
+ );
21149
21274
  }
21150
21275
 
21151
21276
  // src/http.ts
@@ -21219,11 +21344,22 @@ function hubDelete(config3, path) {
21219
21344
  }
21220
21345
 
21221
21346
  // src/index.ts
21222
- var config2;
21347
+ var config2 = null;
21348
+ var configError = null;
21349
+ function requireConfig() {
21350
+ if (!config2) {
21351
+ throw new Error(configError ?? "Awesomate account not configured.");
21352
+ }
21353
+ return config2;
21354
+ }
21355
+ function accountStamp() {
21356
+ if (!config2) return `account: NOT CONNECTED \u2014 ${configError ?? "unknown error"}`;
21357
+ return `account: ${config2.account ?? "unknown (env PAT not matched to a profile)"} (source: ${config2.source})`;
21358
+ }
21223
21359
  var SERVER_VERSION = (() => {
21224
21360
  try {
21225
21361
  const pkg = JSON.parse(
21226
- readFileSync2(join2(dirname(fileURLToPath(import.meta.url)), "..", "package.json"), "utf8")
21362
+ readFileSync2(join2(dirname2(fileURLToPath(import.meta.url)), "..", "package.json"), "utf8")
21227
21363
  );
21228
21364
  return pkg.version ?? "0.0.0";
21229
21365
  } catch {
@@ -21250,21 +21386,48 @@ var server = new McpServer({
21250
21386
  version: SERVER_VERSION
21251
21387
  });
21252
21388
  function textResult(data) {
21253
- return { content: [{ type: "text", text: JSON.stringify(data, null, 2) }] };
21389
+ return {
21390
+ content: [{ type: "text", text: `${accountStamp()}
21391
+ ${JSON.stringify(data, null, 2)}` }]
21392
+ };
21254
21393
  }
21255
21394
  function errorResult(err) {
21256
21395
  const message = err instanceof Error ? err.message : String(err);
21257
- return { content: [{ type: "text", text: message }], isError: true };
21396
+ return { content: [{ type: "text", text: `${accountStamp()}
21397
+ ${message}` }], isError: true };
21258
21398
  }
21259
21399
  function readTool(name, description, path) {
21260
21400
  server.registerTool(name, { description, inputSchema: {} }, async () => {
21261
21401
  try {
21262
- return textResult(await hubGet(config2, path));
21402
+ return textResult(await hubGet(requireConfig(), path));
21263
21403
  } catch (err) {
21264
21404
  return errorResult(err);
21265
21405
  }
21266
21406
  });
21267
21407
  }
21408
+ server.registerTool(
21409
+ "awesomate_whoami",
21410
+ {
21411
+ description: "Zero-network identity check: which Awesomate account this session is connected to (slug, plan, token expiry) and WHY (env var, project pin file, sole profile, or default). Call it after connecting, after switching folders, and any time the account in play matters \u2014 if the slug is not the account the user expects, STOP and fix the pin/connection before doing any work.",
21412
+ inputSchema: {}
21413
+ },
21414
+ async () => {
21415
+ if (!config2) {
21416
+ return errorResult(new Error(configError ?? "Awesomate account not configured."));
21417
+ }
21418
+ return textResult({
21419
+ account: config2.account,
21420
+ email: config2.email,
21421
+ plan: config2.plan,
21422
+ tokenExpiresAt: config2.expiresAt,
21423
+ apiBase: config2.apiBase,
21424
+ source: config2.source,
21425
+ credentialsFile: config2.credPath,
21426
+ pinFile: config2.pinPath,
21427
+ availableProfiles: config2.availableProfiles
21428
+ });
21429
+ }
21430
+ );
21268
21431
  server.registerTool(
21269
21432
  "awesomate_get_context",
21270
21433
  {
@@ -21273,7 +21436,7 @@ server.registerTool(
21273
21436
  },
21274
21437
  async () => {
21275
21438
  try {
21276
- const ctx = await hubGet(config2, "/api/hosting-access/context");
21439
+ const ctx = await hubGet(requireConfig(), "/api/hosting-access/context");
21277
21440
  return textResult({ ...ctx, skill: skillUpdateInfo() });
21278
21441
  } catch (err) {
21279
21442
  return errorResult(err);
@@ -21300,21 +21463,21 @@ server.registerTool(
21300
21463
  async ({ action, workflowJson, workflowId, draftId, operationId }) => {
21301
21464
  try {
21302
21465
  if (action === "validate") {
21303
- return textResult(await hubPost(config2, "/api/my-n8n/machine/workflows/validate", { workflow: workflowJson }));
21466
+ return textResult(await hubPost(requireConfig(), "/api/my-n8n/machine/workflows/validate", { workflow: workflowJson }));
21304
21467
  }
21305
21468
  if (action === "create_draft") {
21306
- return textResult(await hubPost(config2, "/api/my-n8n/machine/workflows/draft", workflowJson ?? {}));
21469
+ return textResult(await hubPost(requireConfig(), "/api/my-n8n/machine/workflows/draft", workflowJson ?? {}));
21307
21470
  }
21308
21471
  if (action === "rollback") {
21309
21472
  if (!operationId) return errorResult(new Error("operationId is required for rollback"));
21310
21473
  return textResult(
21311
- await hubPost(config2, `/api/my-n8n/machine/operations/${encodeURIComponent(operationId)}/rollback`, {})
21474
+ await hubPost(requireConfig(), `/api/my-n8n/machine/operations/${encodeURIComponent(operationId)}/rollback`, {})
21312
21475
  );
21313
21476
  }
21314
21477
  if (!workflowId) return errorResult(new Error(`workflowId is required for ${action}`));
21315
21478
  if (action === "activate" || action === "deactivate") {
21316
21479
  return textResult(
21317
- await hubPost(config2, `/api/my-n8n/machine/workflows/${encodeURIComponent(workflowId)}/activate`, {
21480
+ await hubPost(requireConfig(), `/api/my-n8n/machine/workflows/${encodeURIComponent(workflowId)}/activate`, {
21318
21481
  active: action === "activate"
21319
21482
  })
21320
21483
  );
@@ -21322,11 +21485,11 @@ server.registerTool(
21322
21485
  if (action === "promote") {
21323
21486
  if (!draftId) return errorResult(new Error("draftId is required for promote"));
21324
21487
  return textResult(
21325
- await hubPost(config2, `/api/my-n8n/machine/workflows/${encodeURIComponent(workflowId)}/promote`, { draftId })
21488
+ await hubPost(requireConfig(), `/api/my-n8n/machine/workflows/${encodeURIComponent(workflowId)}/promote`, { draftId })
21326
21489
  );
21327
21490
  }
21328
21491
  return textResult(
21329
- await hubDelete(config2, `/api/my-n8n/machine/workflows/${encodeURIComponent(workflowId)}`)
21492
+ await hubDelete(requireConfig(), `/api/my-n8n/machine/workflows/${encodeURIComponent(workflowId)}`)
21330
21493
  );
21331
21494
  } catch (err) {
21332
21495
  return errorResult(err);
@@ -21345,7 +21508,7 @@ server.registerTool(
21345
21508
  async ({ workflowId, payload }) => {
21346
21509
  try {
21347
21510
  return textResult(
21348
- await hubPost(config2, `/api/my-n8n/machine/workflows/${encodeURIComponent(workflowId)}/test-fire`, { payload })
21511
+ await hubPost(requireConfig(), `/api/my-n8n/machine/workflows/${encodeURIComponent(workflowId)}/test-fire`, { payload })
21349
21512
  );
21350
21513
  } catch (err) {
21351
21514
  return errorResult(err);
@@ -21406,7 +21569,7 @@ server.registerTool(
21406
21569
  async ({ domain, reason }) => {
21407
21570
  try {
21408
21571
  return textResult(
21409
- await hubPost(config2, `/api/hosting-access/sites/${encodeURIComponent(domain)}/snapshot`, { reason })
21572
+ await hubPost(requireConfig(), `/api/hosting-access/sites/${encodeURIComponent(domain)}/snapshot`, { reason })
21410
21573
  );
21411
21574
  } catch (err) {
21412
21575
  return errorResult(err);
@@ -21421,7 +21584,7 @@ server.registerTool(
21421
21584
  },
21422
21585
  async ({ domain }) => {
21423
21586
  try {
21424
- return textResult(await hubGet(config2, `/api/hosting-access/sites/${encodeURIComponent(domain)}/snapshots`));
21587
+ return textResult(await hubGet(requireConfig(), `/api/hosting-access/sites/${encodeURIComponent(domain)}/snapshots`));
21425
21588
  } catch (err) {
21426
21589
  return errorResult(err);
21427
21590
  }
@@ -21439,7 +21602,7 @@ server.registerTool(
21439
21602
  async ({ domain, snapshotId }) => {
21440
21603
  try {
21441
21604
  return textResult(
21442
- await hubPost(config2, `/api/hosting-access/sites/${encodeURIComponent(domain)}/rollback`, { snapshotId })
21605
+ await hubPost(requireConfig(), `/api/hosting-access/sites/${encodeURIComponent(domain)}/rollback`, { snapshotId })
21443
21606
  );
21444
21607
  } catch (err) {
21445
21608
  return errorResult(err);
@@ -21447,10 +21610,15 @@ server.registerTool(
21447
21610
  }
21448
21611
  );
21449
21612
  async function main() {
21450
- config2 = loadConfig();
21613
+ try {
21614
+ config2 = loadConfig();
21615
+ } catch (err) {
21616
+ configError = err instanceof Error ? err.message : String(err);
21617
+ console.error(`[awesomate-hosting-mcp] account not resolved: ${configError}`);
21618
+ }
21451
21619
  const transport = new StdioServerTransport();
21452
21620
  await server.connect(transport);
21453
- console.error("[awesomate-hosting-mcp] connected (stdio)");
21621
+ console.error(`[awesomate-hosting-mcp] connected (stdio) \u2014 ${accountStamp()}`);
21454
21622
  }
21455
21623
  main().catch((err) => {
21456
21624
  console.error(
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.0",
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,7 +19,8 @@
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",
@@ -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,39 @@ 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
+ `FAILED reason=…`) sentinel — when a connect ran as a `!` bang command, check
72
+ for that line instead of assuming success from silence.
73
+
44
74
  If this skill is loaded but **no `awesomate_*` tools exist in the session at
45
75
  all**, the MCP server was registered after Claude Code started (the bootstrap
46
76
  just ran). Don't investigate settings files or reinstall anything — and don't
47
77
  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>`:
78
+ skill works right now over REST resolve the ACTIVE profile first (run
79
+ `node ~/.claude/skills/awesomate-hosting/scripts/resolve-account.mjs --api` to
80
+ get `API`/`PAT`/`ACCT`; never read top-level `pat` directly when a `profiles`
81
+ map exists, and never echo the pat into the conversation) and GET with
82
+ `Authorization: Bearer <pat>`:
51
83
 
52
84
  | MCP tool | REST equivalent |
53
85
  |---|---|
@@ -3,23 +3,28 @@
3
3
  * Awesomate Hosting — one-time bootstrap.
4
4
  *
5
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+)
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+)
7
8
  * generates an SSH keypair and authorizes the PUBLIC key on the client's own
8
9
  * cPanel account. The private key never leaves this machine; the setup code is
9
10
  * the only secret that ever crossed the clipboard, and it's single-use + short-
10
- * lived.
11
+ * lived. The 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>] [--api https://hub.awesomate.ai]
15
+ *
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.
14
19
  *
15
20
  * Dependency-free: Node 18+ built-ins only (global fetch, node:fs/os/path,
16
- * child_process for ssh-keygen).
21
+ * child_process for ssh-keygen + the claude CLI).
17
22
  */
18
23
 
19
24
  import { execFileSync } from 'node:child_process';
20
25
  import { existsSync, mkdirSync, writeFileSync, chmodSync, readFileSync, cpSync, readdirSync } from 'node:fs';
21
26
  import { homedir, hostname } from 'node:os';
22
- import { join, dirname } from 'node:path';
27
+ import { join, dirname, resolve } from 'node:path';
23
28
  import { fileURLToPath } from 'node:url';
24
29
 
25
30
  function arg(name, fallback) {
@@ -28,9 +33,11 @@ function arg(name, fallback) {
28
33
  }
29
34
 
30
35
  const code = arg('code');
36
+ const expectedAccount = arg('account', null);
31
37
  const apiBase = (arg('api', 'https://hub.awesomate.ai')).replace(/\/$/, '');
32
38
  if (!code || !code.startsWith('amt_bs_')) {
33
39
  console.error('Missing/invalid --code. Copy a fresh setup prompt from hub.awesomate.ai/sites.');
40
+ console.error('AWESOMATE CONNECT: FAILED reason=bad-code');
34
41
  process.exit(1);
35
42
  }
36
43
 
@@ -38,11 +45,16 @@ const dir = join(homedir(), '.awesomate');
38
45
  const keysDir = join(dir, 'keys');
39
46
  const credPath = join(dir, 'credentials.json');
40
47
  const keyPath = join(keysDir, 'id_ed25519');
48
+ const PIN_FILENAME = '.awesomate.json';
41
49
 
42
- async function post(path, { body, token } = {}) {
50
+ async function request(method, path, { body, token } = {}) {
43
51
  const headers = { 'Content-Type': 'application/json' };
44
52
  if (token) headers.Authorization = `Bearer ${token}`;
45
- const res = await fetch(`${apiBase}${path}`, { method: 'POST', headers, body: JSON.stringify(body ?? {}) });
53
+ const res = await fetch(`${apiBase}${path}`, {
54
+ method,
55
+ headers,
56
+ body: method === 'GET' ? undefined : JSON.stringify(body ?? {}),
57
+ });
46
58
  const text = await res.text();
47
59
  let json;
48
60
  try { json = text ? JSON.parse(text) : null; } catch { json = text; }
@@ -52,12 +64,84 @@ async function post(path, { body, token } = {}) {
52
64
  }
53
65
  return json;
54
66
  }
67
+ const post = (path, opts) => request('POST', path, opts);
68
+ const get = (path, opts) => request('GET', path, opts);
69
+
70
+ function readJsonFile(path) {
71
+ try {
72
+ const parsed = JSON.parse(readFileSync(path, 'utf8'));
73
+ return parsed && typeof parsed === 'object' ? parsed : null;
74
+ } catch { return null; }
75
+ }
76
+
77
+ /**
78
+ * Merge one account profile into credentials.json (v2 multi-profile shape).
79
+ * Existing profiles for OTHER accounts are preserved — connecting a second
80
+ * account must never disconnect the first. Top-level v1 fields are kept as a
81
+ * mirror of the default profile because already-installed skill scripts
82
+ * (ssh-connect.sh / deploy.sh / pull-live.sh copies from older versions) read
83
+ * them directly until the skill is refreshed.
84
+ */
85
+ function mergeProfile(profile) {
86
+ const existing = readJsonFile(credPath) ?? {};
87
+ const profiles = { ...(existing.profiles ?? {}) };
88
+
89
+ // v1 file: lift the old top-level account into the profiles map first so it
90
+ // survives this connect.
91
+ if (!existing.profiles && typeof existing.pat === 'string' && existing.pat) {
92
+ const legacyKey = existing.slug || 'default';
93
+ profiles[legacyKey] = {
94
+ apiBase: existing.apiBase, pat: existing.pat, contactId: existing.contactId,
95
+ slug: existing.slug, email: existing.email, plan: existing.plan,
96
+ expiresAt: existing.expiresAt, cpanel: existing.cpanel, ssh: existing.ssh,
97
+ };
98
+ }
99
+
100
+ profiles[profile.slug] = { ...(profiles[profile.slug] ?? {}), ...profile };
101
+
102
+ let defaultProfile = existing.defaultProfile;
103
+ if (defaultProfile && !profiles[defaultProfile]) defaultProfile = undefined;
104
+ if (!defaultProfile && Object.keys(profiles).length === 1) defaultProfile = profile.slug;
105
+ if (existing.defaultProfile && existing.defaultProfile !== profile.slug && profiles[existing.defaultProfile]) {
106
+ console.log(`• defaultProfile stays "${existing.defaultProfile}" — this folder is pinned to "${profile.slug}" instead.`);
107
+ }
108
+
109
+ // The v1 mirror follows the default profile when one is set; otherwise the
110
+ // account just connected (matching the old last-connect-wins behavior for
111
+ // legacy readers, without losing the other profiles).
112
+ const mirrorSource = (defaultProfile && profiles[defaultProfile]) || profile;
113
+ const creds = {
114
+ version: 2,
115
+ ...(defaultProfile ? { defaultProfile } : {}),
116
+ profiles,
117
+ apiBase: mirrorSource.apiBase, pat: mirrorSource.pat, contactId: mirrorSource.contactId,
118
+ slug: mirrorSource.slug, email: mirrorSource.email ?? null, plan: mirrorSource.plan,
119
+ cpanel: mirrorSource.cpanel ?? null, expiresAt: mirrorSource.expiresAt ?? null,
120
+ ...(mirrorSource.ssh ? { ssh: mirrorSource.ssh } : {}),
121
+ };
55
122
 
56
- function writeCreds(creds) {
57
123
  mkdirSync(dir, { recursive: true, mode: 0o700 });
58
124
  chmodSync(dir, 0o700);
59
125
  writeFileSync(credPath, JSON.stringify(creds, null, 2));
60
126
  chmodSync(credPath, 0o600);
127
+ return creds;
128
+ }
129
+
130
+ /** Pin the folder bootstrap ran in to this account. Never pins $HOME. */
131
+ 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.`);
135
+ return null;
136
+ }
137
+ const pinPath = join(cwd, PIN_FILENAME);
138
+ const existing = readJsonFile(pinPath);
139
+ if (existing?.account && existing.account !== slug) {
140
+ console.log(`• Re-pinning this folder: ${existing.account} → ${slug}.`);
141
+ }
142
+ writeFileSync(pinPath, `${JSON.stringify({ version: 1, account: slug }, null, 2)}\n`);
143
+ console.log(`✓ Pinned ${cwd} to account "${slug}" (${PIN_FILENAME}).`);
144
+ return pinPath;
61
145
  }
62
146
 
63
147
  /**
@@ -119,23 +203,116 @@ function installSkill() {
119
203
  return true;
120
204
  }
121
205
 
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';
212
+
213
+ /**
214
+ * Converge on exactly one env-free, user-scope MCP registration.
215
+ *
216
+ * Legacy bootstraps baked AWESOMATE_PAT into the registration env — env beats
217
+ * 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.
225
+ */
226
+ function ensureMcpRegistration() {
227
+ 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
+ }
239
+
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).`);
243
+ }
244
+
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.');
251
+ } 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 */ }
255
+ }
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
+ }
260
+
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}`);
263
+ }
264
+ }
265
+
122
266
  async function main() {
123
267
  console.log(`Redeeming setup code against ${apiBase} …`);
124
268
  const redeemed = await post('/api/hosting-access/redeem', {
125
269
  body: { code, machineLabel: `Claude Code on ${hostname()}` },
126
270
  });
127
271
 
128
- const creds = {
272
+ const slug = redeemed.slug ?? null;
273
+ if (!slug) {
274
+ console.error('The hub did not return an account slug for this code — cannot safely store a profile. Contact Awesomate support.');
275
+ console.error('AWESOMATE CONNECT: FAILED reason=no-slug');
276
+ process.exit(1);
277
+ }
278
+ // Wrong-account guard: abort BEFORE writing anything.
279
+ if (expectedAccount && expectedAccount !== slug) {
280
+ console.error(`This setup code belongs to account "${slug}" but this connect was for "${expectedAccount}".`);
281
+ console.error(`You are probably logged into the wrong hub account — log into ${expectedAccount}'s hub account and copy a fresh Connect prompt.`);
282
+ console.error(`AWESOMATE CONNECT: FAILED reason=account-mismatch expected=${expectedAccount} got=${slug}`);
283
+ process.exit(1);
284
+ }
285
+
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);
300
+ }
301
+
302
+ const profile = {
129
303
  apiBase,
130
304
  pat: redeemed.pat,
131
305
  contactId: redeemed.contactId,
132
- slug: redeemed.slug ?? null,
306
+ slug,
307
+ email: redeemed.email ?? context?.email ?? context?.account?.email ?? null,
133
308
  plan: redeemed.plan,
134
309
  cpanel: redeemed.cpanel ?? null,
135
310
  expiresAt: redeemed.expiresAt ?? null,
136
311
  };
137
- writeCreds(creds);
138
- console.log(`✓ Access token stored at ${credPath} (chmod 600). Plan: ${redeemed.plan}.`);
312
+ mergeProfile(profile);
313
+ console.log(`✓ Access token stored as profile "${slug}" in ${credPath} (chmod 600). Plan: ${redeemed.plan}.`);
314
+
315
+ const pinPath = writePin(slug);
139
316
 
140
317
  // Install the skill + register the MCP server FIRST — these always work and
141
318
  // don't depend on hosting being fully provisioned. SSH comes last so that a
@@ -144,19 +321,13 @@ async function main() {
144
321
  console.log(`• Could not auto-install the skill (${err.message}) — continuing; you can copy skill/ manually.`);
145
322
  }
146
323
 
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
324
  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 project — run:');
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));
325
+ ensureMcpRegistration();
326
+ } 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);
160
331
  }
161
332
 
162
333
  // SSH key (Support Plus+): best-effort. If it fails (e.g. hosting not fully
@@ -166,7 +337,7 @@ async function main() {
166
337
  try {
167
338
  if (!existsSync(keyPath)) {
168
339
  mkdirSync(keysDir, { recursive: true, mode: 0o700 });
169
- execFileSync('ssh-keygen', ['-t', 'ed25519', '-f', keyPath, '-N', '', '-C', `awesomate:${redeemed.slug ?? 'site'}`], { stdio: 'ignore' });
340
+ execFileSync('ssh-keygen', ['-t', 'ed25519', '-f', keyPath, '-N', '', '-C', `awesomate:${slug}`], { stdio: 'pipe' });
170
341
  chmodSync(keyPath, 0o600);
171
342
  }
172
343
  const publicKey = readFileSync(`${keyPath}.pub`, 'utf8').trim();
@@ -174,8 +345,8 @@ async function main() {
174
345
  token: redeemed.pat,
175
346
  body: { publicKey, label: `Claude Code on ${hostname()}` },
176
347
  });
177
- creds.ssh = { host: reg.sshHost, user: reg.sshUser, port: reg.sshPort ?? 22, keyPath, fingerprint: reg.fingerprint };
178
- writeCreds(creds);
348
+ profile.ssh = { host: reg.sshHost, user: reg.sshUser, port: reg.sshPort ?? 22, keyPath, fingerprint: reg.fingerprint };
349
+ mergeProfile(profile);
179
350
  console.log(`✓ SSH key authorized on ${reg.sshUser}@${reg.sshHost} (jailed to your account).`);
180
351
  } catch (err) {
181
352
  console.log(`• SSH key not registered yet: ${err.message}`);
@@ -185,12 +356,16 @@ async function main() {
185
356
  console.log('• Plan has no shell access — read/status mode. Upgrade to Support Plus for SSH + deploy.');
186
357
  }
187
358
 
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.)');
359
+ const who = profile.email ? `${slug} (${profile.email}, plan ${redeemed.plan})` : `${slug} (plan ${redeemed.plan})`;
360
+ console.log(`\n✓ Connected: ${who}${pinPath ? `pinned ${dirname(pinPath)}` : ''}`);
361
+ console.log('Setup complete. Next: run awesomate_whoami to confirm the account, then ask me to read your plan and list your sites.');
362
+ 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.)');
363
+ console.log(`AWESOMATE CONNECT: SUCCESS account=${slug} pin=${pinPath ?? 'none'}`);
190
364
  }
191
365
 
192
366
  main().catch((err) => {
193
367
  console.error('Bootstrap failed:', err.message);
194
368
  console.error('If the code expired or was already used, grab a fresh one from hub.awesomate.ai/sites.');
369
+ console.error('AWESOMATE CONNECT: FAILED reason=error');
195
370
  process.exit(1);
196
371
  });
@@ -52,18 +52,17 @@ done
52
52
  echo "--from wp-content looks empty (no themes/ or plugins/). Refusing to --delete against live."; exit 1; }
53
53
  case "$DOMAIN" in *[!a-z0-9.-]*|"") echo "Invalid --domain."; exit 1;; esac
54
54
 
55
- # Read connection details + PAT (node is present wherever the MCP runs).
56
- # Capture first, THEN eval so a no-shell plan (node exits 2) aborts here with
57
- # its own message instead of leaving $U/$P/… unset for `set -u` to trip on later.
58
- if ! CREDS_EVAL="$(node -e '
59
- const c = require(process.env.HOME + "/.awesomate/credentials.json");
60
- if (!c.ssh) { console.error("Your plan does not include shell access (Support Plus+ required)."); process.exit(2); }
61
- const q = (s) => "'"'"'" + String(s == null ? "" : s).replace(/'"'"'/g, "") + "'"'"'";
62
- console.log(`H=${q(c.ssh.host)}; U=${q(c.ssh.user)}; P=${q(c.ssh.port||22)}; K=${q(c.ssh.keyPath)}; API=${q(c.apiBase)}; PAT=${q(c.pat)}`);
63
- ')"; then
55
+ # Resolve the account (folder pin / AWESOMATE_ACCOUNT / sole profile) and its
56
+ # connection details + PAT via the shared resolver (node is present wherever
57
+ # the MCP runs). Capture first, THEN eval so a resolver failure (no-shell
58
+ # plan exits 2, unpinned multi-account exits 1) aborts here with its own
59
+ # message instead of leaving $U/$P/… unset for `set -u` to trip on later.
60
+ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
61
+ if ! CREDS_EVAL="$(node "$SCRIPT_DIR/resolve-account.mjs" --ssh --api)"; then
64
62
  exit 1
65
63
  fi
66
64
  eval "$CREDS_EVAL"
65
+ echo "→ account: $ACCT"
67
66
 
68
67
  # Harden every credentials-derived field BEFORE it reaches ssh/rsync/scp. rsync
69
68
  # re-tokenizes its -e remote-shell string on whitespace (ignoring shell quotes),
@@ -25,15 +25,13 @@ done
25
25
  [ -n "$DOMAIN" ] && [ -n "$TO" ] || { echo "Usage: pull-live.sh --domain <live-domain> --to <local-dir> [--docroot PATH]"; exit 1; }
26
26
  case "$DOMAIN" in *[!a-z0-9.-]*|"") echo "Invalid --domain."; exit 1;; esac
27
27
 
28
- if ! CREDS_EVAL="$(node -e '
29
- const c = require(process.env.HOME + "/.awesomate/credentials.json");
30
- if (!c.ssh) { console.error("Your plan does not include shell access (Support Plus+ required)."); process.exit(2); }
31
- const q = (s) => "'"'"'" + String(s == null ? "" : s).replace(/'"'"'/g, "") + "'"'"'";
32
- console.log(`H=${q(c.ssh.host)}; U=${q(c.ssh.user)}; P=${q(c.ssh.port||22)}; K=${q(c.ssh.keyPath)}`);
33
- ')"; then
28
+ # Shared resolver: folder pin / AWESOMATE_ACCOUNT / sole profile → ssh block.
29
+ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
30
+ if ! CREDS_EVAL="$(node "$SCRIPT_DIR/resolve-account.mjs" --ssh)"; then
34
31
  exit 1
35
32
  fi
36
33
  eval "$CREDS_EVAL"
34
+ echo "→ account: $ACCT"
37
35
 
38
36
  # Harden credentials-derived fields before they reach rsync/scp/ssh — rsync
39
37
  # word-splits its -e remote-shell string, so whitespace here could inject ssh
@@ -0,0 +1,100 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Shared account resolver for the awesomate-hosting skill scripts.
4
+ *
5
+ * Mirrors the MCP server's precedence exactly (mcp/src/config.ts):
6
+ * AWESOMATE_ACCOUNT env → .awesomate.json pin (walked up from cwd, stopping
7
+ * at $HOME) → sole profile → defaultProfile → legacy v1 top-level fields.
8
+ * A pin naming a missing profile is a hard error — never a silent fallback.
9
+ *
10
+ * Prints shell-eval assignments (single-quoted, quote-stripped values — same
11
+ * hardening contract the callers' charclass gates assume):
12
+ * node resolve-account.mjs --ssh # ACCT + H/U/P/K
13
+ * node resolve-account.mjs --api # ACCT + API/PAT
14
+ * Flags combine. Exit 1 = no resolvable account, exit 2 = no shell access.
15
+ */
16
+
17
+ import { existsSync, readFileSync } from 'node:fs';
18
+ import { homedir } from 'node:os';
19
+ import { join, dirname, resolve } from 'node:path';
20
+
21
+ function readJson(path) {
22
+ try {
23
+ const parsed = JSON.parse(readFileSync(path, 'utf8'));
24
+ return parsed && typeof parsed === 'object' ? parsed : null;
25
+ } catch { return null; }
26
+ }
27
+
28
+ function fail(msg, code = 1) { console.error(msg); process.exit(code); }
29
+
30
+ const credPath = join(homedir(), '.awesomate', 'credentials.json');
31
+ const file = readJson(credPath) ?? {};
32
+
33
+ const profiles = {};
34
+ if (file.profiles && typeof file.profiles === 'object') {
35
+ for (const [key, profile] of Object.entries(file.profiles)) {
36
+ if (profile && typeof profile.pat === 'string') profiles[key] = profile;
37
+ }
38
+ } else if (typeof file.pat === 'string' && file.pat) {
39
+ profiles[file.slug || 'default'] = file; // legacy v1 single-account file
40
+ }
41
+ const names = Object.keys(profiles);
42
+
43
+ function findPin() {
44
+ const explicit = process.env.AWESOMATE_PIN_FILE;
45
+ if (explicit) return existsSync(explicit) ? explicit : null;
46
+ const home = resolve(homedir());
47
+ let dir = resolve(process.cwd());
48
+ for (;;) {
49
+ const candidate = join(dir, '.awesomate.json');
50
+ if (existsSync(candidate)) return candidate;
51
+ if (dir === home) return null;
52
+ const parent = dirname(dir);
53
+ if (parent === dir) return null;
54
+ dir = parent;
55
+ }
56
+ }
57
+
58
+ let key = null;
59
+ if (process.env.AWESOMATE_ACCOUNT) {
60
+ key = process.env.AWESOMATE_ACCOUNT;
61
+ if (!profiles[key]) {
62
+ fail(`AWESOMATE_ACCOUNT="${key}" has no matching profile in ${credPath}. Available: ${names.join(', ') || '(none)'}.`);
63
+ }
64
+ } else {
65
+ const pinPath = findPin();
66
+ if (pinPath) {
67
+ const pin = readJson(pinPath);
68
+ key = pin && typeof pin.account === 'string' ? pin.account : null;
69
+ if (!key) fail(`${pinPath} exists but has no "account" field. Expected: {"account": "<slug>"}.`);
70
+ if (!profiles[key]) {
71
+ fail(
72
+ `This folder is pinned to account "${key}" (${pinPath}) but no matching profile exists in ${credPath}. ` +
73
+ `Available: ${names.join(', ') || '(none)'}. Connect "${key}" from hub.awesomate.ai/sites (logged in as it) or fix the pin.`,
74
+ );
75
+ }
76
+ } else if (names.length === 1) {
77
+ key = names[0];
78
+ } else if (file.defaultProfile && profiles[file.defaultProfile]) {
79
+ key = file.defaultProfile;
80
+ } else if (names.length === 0) {
81
+ fail('Not connected. Run the Connect Claude Code setup from hub.awesomate.ai/sites.');
82
+ } else {
83
+ fail(
84
+ `Multiple accounts are connected (${names.join(', ')}) and this folder isn't pinned to one. ` +
85
+ 'Create .awesomate.json {"account": "<slug>"} in the project root or set AWESOMATE_ACCOUNT.',
86
+ );
87
+ }
88
+ }
89
+
90
+ const profile = profiles[key];
91
+ const q = (s) => "'" + String(s == null ? '' : s).replace(/'/g, '') + "'";
92
+ const out = [`ACCT=${q(profile.slug ?? key)}`];
93
+ if (process.argv.includes('--ssh')) {
94
+ if (!profile.ssh) fail('Your plan does not include shell access (Support Plus+ required).', 2);
95
+ out.push(`H=${q(profile.ssh.host)}`, `U=${q(profile.ssh.user)}`, `P=${q(profile.ssh.port || 22)}`, `K=${q(profile.ssh.keyPath)}`);
96
+ }
97
+ if (process.argv.includes('--api')) {
98
+ out.push(`API=${q(profile.apiBase || 'https://hub.awesomate.ai')}`, `PAT=${q(profile.pat)}`);
99
+ }
100
+ console.log(out.join('; '));
@@ -8,12 +8,12 @@ set -euo pipefail
8
8
  CRED="$HOME/.awesomate/credentials.json"
9
9
  [ -f "$CRED" ] || { echo "Not connected. Run bootstrap first (Connect Claude Code on hub.awesomate.ai/sites)."; exit 1; }
10
10
 
11
- # Parse the ssh block with node (present wherever the MCP runs via npx).
12
- eval "$(node -e '
13
- const c = require(process.env.HOME + "/.awesomate/credentials.json");
14
- if (!c.ssh) { console.error("Your plan does not include shell access (Support Plus+ required)."); process.exit(2); }
15
- const q = (s) => "'"'"'" + String(s).replace(/'"'"'/g, "") + "'"'"'";
16
- console.log(`H=${q(c.ssh.host)}; U=${q(c.ssh.user)}; P=${q(c.ssh.port||22)}; K=${q(c.ssh.keyPath)}`);
17
- ')"
11
+ # Resolve WHICH account (folder pin / AWESOMATE_ACCOUNT / sole profile) and its
12
+ # ssh block via the shared resolver. Capture then eval so a resolver failure
13
+ # aborts with its own message instead of tripping set -u later.
14
+ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
15
+ if ! CREDS_EVAL="$(node "$SCRIPT_DIR/resolve-account.mjs" --ssh)"; then exit 1; fi
16
+ eval "$CREDS_EVAL"
17
+ echo "→ account: $ACCT" >&2
18
18
 
19
19
  exec ssh -i "$K" -p "$P" -o StrictHostKeyChecking=accept-new "$U@$H" "$@"
@@ -20,7 +20,13 @@ promote back, and delete drafts — via the `awesomate_n8n_deploy` and
20
20
 
21
21
  ## 0. First run (every session)
22
22
 
23
- Call `awesomate_n8n_context` once before any n8n work and cache the result:
23
+ Run `awesomate_whoami` first n8n tools act on whichever ACCOUNT this folder
24
+ resolves to (folder `.awesomate.json` pin / `AWESOMATE_ACCOUNT` / sole
25
+ profile; see the awesomate-hosting skill's multi-account section). If the slug
26
+ isn't the instance the user means, stop and fix the pin/connection before any
27
+ n8n work. Every tool response is stamped `account: <slug>` — watch it.
28
+
29
+ Then call `awesomate_n8n_context` once before any n8n work and cache the result:
24
30
 
25
31
  - `consented: false` → give the user the `settingsUrl` link (Settings →
26
32
  Privacy → "Allow Claude Code to Build n8n Workflows"), wait for them to
@@ -40,8 +46,11 @@ $env, `$json.body`, task-runner limits, activation semantics).
40
46
 
41
47
  ## 1. Reads
42
48
 
43
- PAT as `Authorization: Bearer <pat>` (from `~/.awesomate/credentials.json`)
44
- against `apiBase`. Never echo the PAT into the conversation.
49
+ PAT as `Authorization: Bearer <pat>` against `apiBase`. Resolve the ACTIVE
50
+ profile's pat `node ~/.claude/skills/awesomate-hosting/scripts/resolve-account.mjs --api`
51
+ — never read `credentials.json`'s top-level `pat` directly when a `profiles`
52
+ map exists (it mirrors the default profile, not necessarily this folder's
53
+ account). Never echo the PAT into the conversation.
45
54
 
46
55
  | What | Endpoint |
47
56
  |---|---|