@sakupa/mcp 0.7.9 → 0.7.11

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.
Files changed (3) hide show
  1. package/dist/bin.js +143 -44
  2. package/dist/index.js +143 -44
  3. package/package.json +1 -1
package/dist/bin.js CHANGED
@@ -129,7 +129,7 @@ var FORBIDDEN_PATH_SEGMENTS = [
129
129
  var ALLOWED_HIDDEN_PATHS = [".well-known/"];
130
130
 
131
131
  // ../core/dist/domain/version.js
132
- var SAKUPA_MCP_VERSION = "0.7.9";
132
+ var SAKUPA_MCP_VERSION = "0.7.11";
133
133
 
134
134
  // ../core/dist/domain/errors.js
135
135
  var HTTP_STATUS = {
@@ -686,7 +686,7 @@ var HttpApiClient = class {
686
686
 
687
687
  // src/tools/definitions.ts
688
688
  import { randomUUID } from "node:crypto";
689
- import { promises as fs2 } from "node:fs";
689
+ import { existsSync as existsSync3, promises as fs2 } from "node:fs";
690
690
  import { join as join4, resolve as resolve3 } from "node:path";
691
691
  import { z as z3 } from "zod";
692
692
 
@@ -1196,14 +1196,18 @@ function deleteSiteFile(projectDir) {
1196
1196
  rmSync(path, { force: true });
1197
1197
  }
1198
1198
  }
1199
- function isInsideGitRepo(projectDir) {
1200
- let dir = projectDir;
1201
- for (; ; ) {
1202
- if (existsSync(join2(dir, ".git"))) return true;
1203
- const parent = dirname(dir);
1204
- if (parent === dir) return false;
1205
- dir = parent;
1199
+ function findAncestor(startDir, predicate, maxLevels = Number.POSITIVE_INFINITY) {
1200
+ let cursor = startDir;
1201
+ for (let i = 0; i < maxLevels; i += 1) {
1202
+ const parent = dirname(cursor);
1203
+ if (parent === cursor) return null;
1204
+ if (predicate(parent)) return parent;
1205
+ cursor = parent;
1206
1206
  }
1207
+ return null;
1208
+ }
1209
+ function isInsideGitRepo(projectDir) {
1210
+ return existsSync(join2(projectDir, ".git")) || findAncestor(projectDir, (dir) => existsSync(join2(dir, ".git"))) !== null;
1207
1211
  }
1208
1212
  function credentialGitReminder(projectDir) {
1209
1213
  if (!isInsideGitRepo(projectDir)) return "";
@@ -1354,10 +1358,30 @@ async function diagnoseBinding(input) {
1354
1358
  const apexResolves = apexAnswers.length > 0;
1355
1359
  const allOk = checks.every((c) => c.state === "ok");
1356
1360
  const checklist = checks.map(renderCheck).join("\n") + `
1357
- [${apexResolves ? "OK" : "MISSING"}] APEX ${apex} \u2014 ` + (apexResolves ? "resolves." : `does not resolve yet: point it at ${input.servingTarget} using your DNS panel's ALIAS / ANAME / CNAME-flattening feature (an apex cannot use a plain CNAME).`);
1361
+ [${apexResolves ? "OK" : "MISSING"}] APEX ${apex} \u2014 ` + (apexResolves ? "resolves." : `does not resolve yet. Three-step fix, stop at the first that works: (1) try adding a plain record \u2014 type CNAME, host @, value ${input.servingTarget} (most panels and Cloudflare accept this directly; confirm past any MX-conflict warning if the domain sends no email). (2) If rejected, look for ALIAS / ANAME / CNAME-flattening in the record-type list \u2014 same host and value. (3) If the panel has neither, skip the apex: www alone works fine (certificates do not depend on the apex record); optionally add a URL redirect from @ to www.`);
1358
1362
  const layers = `Pipeline: [1] public DNS (checked LIVE above) -> [2] Sakupa ownership verification: ${input.verificationStatus} -> [3] HTTPS certificate & serving: ` + (input.provisioning ? "provisioning (Cloudflare validates and issues within minutes once the records above are all OK; Sakupa retries automatically every ~5 minutes)." : "starts after verification.");
1359
1363
  return { checks, apexResolves, allOk, checklist, layers };
1360
1364
  }
1365
+ var DNS_RETRY_AFTER_SECONDS = 300;
1366
+ var DNS_MAX_ATTEMPTS = 10;
1367
+ function toDnsChecklist(checks) {
1368
+ return checks.map((c) => ({
1369
+ name: c.record.name,
1370
+ type: c.record.type,
1371
+ shortHost: c.shortHost,
1372
+ state: c.state,
1373
+ ...c.fix ? { fix: c.fix } : {}
1374
+ }));
1375
+ }
1376
+ function renderChecklistBlock(diag, fixTail) {
1377
+ const cadence = `Re-check every ${DNS_RETRY_AFTER_SECONDS / 60} minutes, up to ${DNS_MAX_ATTEMPTS} times.`;
1378
+ return `Live DNS checklist (host values are the SHORT panel form):
1379
+ ${diag.checklist}
1380
+
1381
+ ${diag.layers}
1382
+
1383
+ ` + (diag.allOk ? "All records are live; certificate issuance completes automatically \u2014 re-check in a few minutes until the binding is active." : `${fixTail} ${cadence}`);
1384
+ }
1361
1385
 
1362
1386
  // src/version.ts
1363
1387
  var MCP_VERSION = SAKUPA_MCP_VERSION;
@@ -1417,7 +1441,7 @@ var LocalGuidanceError = class extends SakupaError {
1417
1441
  }
1418
1442
  };
1419
1443
  var projectDirInput = z2.string().optional().describe(
1420
- "Absolute path of the project directory the user is CURRENTLY working in (the folder holding the site files and .sakupa). Always pass it explicitly; when omitted the server falls back to its startup directory, which may not be where the user is working now."
1444
+ "Absolute path of the user's PROJECT ROOT \u2014 the folder the user opened/works in (for framework projects: where package.json lives, NEVER the build-output subfolder like dist/out; the analyzer locates the output automatically). .sakupa/site.json lives here, so PASS THE SAME DIRECTORY EVERY TIME for the same project. When omitted the server falls back to its startup directory, which may not be where the user is working now."
1421
1445
  );
1422
1446
  function withProjectDir(ctx, projectDirArg) {
1423
1447
  if (projectDirArg === void 0) return ctx;
@@ -1609,8 +1633,52 @@ async function uploadAll(ctx, targets, files, outputAbs) {
1609
1633
  }
1610
1634
  return targets.length;
1611
1635
  }
1612
- var DNS_RETRY_AFTER_SECONDS = 300;
1613
- var DNS_MAX_ATTEMPTS = 10;
1636
+ async function describePendingBinding(client, credential, pb) {
1637
+ const framing = `
1638
+ Domain binding IN PROGRESS: ${pb.apexDomain} \u2014 ` + (pb.phase === "provisioning" ? "ownership verified; certificates/serving are provisioning." : `awaiting DNS verification (challenge valid until ${pb.verificationExpiresAt}).`);
1639
+ let check;
1640
+ try {
1641
+ check = await client.checkVerification(pb.verificationId, credential);
1642
+ } catch {
1643
+ return {
1644
+ note: framing + "\n(Couldn't refresh binding progress from the server just now \u2014 try site_status again shortly.)"
1645
+ };
1646
+ }
1647
+ try {
1648
+ const diag = await diagnoseBinding({
1649
+ apexDomain: check.apexDomain,
1650
+ servingTarget: check.servingTarget,
1651
+ ...check.verificationRecord ? { verificationRecord: check.verificationRecord } : {},
1652
+ pendingDnsRecords: check.pendingDnsRecords,
1653
+ verificationStatus: check.status,
1654
+ provisioning: pb.phase === "provisioning"
1655
+ });
1656
+ const block = renderChecklistBlock(
1657
+ diag,
1658
+ "Relay every [MISSING]/[FIX] line to the user with its exact fix."
1659
+ );
1660
+ return { note: `${framing}
1661
+ ${block}`, checklist: toDnsChecklist(diag.checks) };
1662
+ } catch {
1663
+ return {
1664
+ note: framing + '\n(Live DNS lookups are unavailable right now \u2014 run bind_domain with action "status" for the per-record checklist.)'
1665
+ };
1666
+ }
1667
+ }
1668
+ function projectRootAbove(projectDir) {
1669
+ if (existsSync3(join4(projectDir, "package.json"))) return null;
1670
+ return findAncestor(projectDir, (dir) => existsSync3(join4(dir, "package.json")), 4);
1671
+ }
1672
+ function findNeighborBinding(projectDir, outputRel) {
1673
+ const bound = (dir) => loadSiteFile(dir).kind !== "absent";
1674
+ const above = findAncestor(projectDir, bound, 3);
1675
+ if (above) return above;
1676
+ if (outputRel && outputRel !== ".") {
1677
+ const outputAbs = resolve3(projectDir, outputRel);
1678
+ if (bound(outputAbs)) return outputAbs;
1679
+ }
1680
+ return null;
1681
+ }
1614
1682
  function freeSiteCreationBarrier() {
1615
1683
  const recent = listRecentCreations(Date.now());
1616
1684
  if (recent.length < FREE_ACTIVE_SITES_PER_IP) return null;
@@ -1671,6 +1739,9 @@ Next action: ${analysis.suggestedNextAction}`,
1671
1739
  publicConfirmed: z3.boolean().optional().describe(
1672
1740
  "Required only for the first deployment: user explicitly confirmed creation of a public 24-hour URL."
1673
1741
  ),
1742
+ subprojectConfirmed: z3.boolean().optional().describe(
1743
+ "Only when creating a NEW site in a subfolder of a package.json project: the user explicitly confirmed this subfolder is an INDEPENDENT site, not the project's build output."
1744
+ ),
1674
1745
  lang: z3.string().optional().describe("Site language override (en | ja | zh-CN); defaults to the html lang.")
1675
1746
  }
1676
1747
  },
@@ -1699,16 +1770,34 @@ Next action: ${analysis.suggestedNextAction}`,
1699
1770
  }
1700
1771
  const existing = siteFileState.kind === "ok" ? siteFileState.file : null;
1701
1772
  if (!existing) {
1773
+ const rootAbove = args.subprojectConfirmed === true ? null : projectRootAbove(ctx.projectDir);
1774
+ if (rootAbove) {
1775
+ return text(
1776
+ "not_project_root",
1777
+ `${ctx.projectDir} has no package.json but ${rootAbove} does \u2014 this directory is a SUBFOLDER of that project (typically its build output), and .sakupa must live at the project ROOT. Re-run deploy_site with projectDir: ${rootAbove}. Only if the user explicitly says this subfolder is an INDEPENDENT site (e.g. a docs/ site inside a repo), re-run with subprojectConfirmed: true. Nothing was deployed and no site was created.`,
1778
+ { projectRoot: rootAbove, confirmationField: "subprojectConfirmed" },
1779
+ "blocked"
1780
+ );
1781
+ }
1782
+ const neighbor = findNeighborBinding(ctx.projectDir, analysis.recommendedOutputDir);
1783
+ if (neighbor) {
1784
+ return text(
1785
+ "neighbor_binding_found",
1786
+ `No .sakupa binding in ${ctx.projectDir}, but one EXISTS at ${neighbor} \u2014 this looks like the same project addressed at a different directory level. To update that existing site, re-run deploy_site with projectDir: ${neighbor}. Only if the user explicitly wants a SEPARATE new site, move this deploy to a directory outside that project. Nothing was deployed and no site was created.`,
1787
+ { neighborProjectDir: neighbor },
1788
+ "blocked"
1789
+ );
1790
+ }
1702
1791
  const barrier = freeSiteCreationBarrier();
1703
1792
  if (barrier) return barrier;
1704
- }
1705
- if (!existing && args.publicConfirmed !== true) {
1706
- return text(
1707
- "public_deployment_confirmation_required",
1708
- `First deployment creates a public URL that anyone with the link can open. The free preview stays live for ${FREE_SITE_TTL_HOURS} hours. Explain this to the user and obtain explicit confirmation before retrying deploy_site with publicConfirmed: true.`,
1709
- { publicUrlLifetimeHours: FREE_SITE_TTL_HOURS, confirmationField: "publicConfirmed" },
1710
- "waiting_user"
1711
- );
1793
+ if (args.publicConfirmed !== true) {
1794
+ return text(
1795
+ "public_deployment_confirmation_required",
1796
+ `First deployment creates a public URL that anyone with the link can open. The free preview stays live for ${FREE_SITE_TTL_HOURS} hours. Explain this to the user and obtain explicit confirmation before retrying deploy_site with publicConfirmed: true.`,
1797
+ { publicUrlLifetimeHours: FREE_SITE_TTL_HOURS, confirmationField: "publicConfirmed" },
1798
+ "waiting_user"
1799
+ );
1800
+ }
1712
1801
  }
1713
1802
  ensureUploadSizeWithinLimits(manifest, !existing);
1714
1803
  if (!existing) {
@@ -1871,9 +1960,11 @@ NO content was uploaded or changed by this call \u2014 to publish new or edited
1871
1960
  const ctx = withProjectDir(baseCtx, args.projectDir);
1872
1961
  const site = requireSiteFile(ctx);
1873
1962
  const res = await ctx.client.getSiteStatus(site.siteId, site.credential);
1874
- return textJson("site_status_returned", "Site status:", {
1963
+ const binding = res.pendingDomainBinding ? await describePendingBinding(ctx.client, site.credential, res.pendingDomainBinding) : void 0;
1964
+ return textJson("site_status_returned", `Site status:${binding?.note ?? ""}`, {
1875
1965
  ...res,
1876
- projectDir: ctx.projectDir
1966
+ projectDir: ctx.projectDir,
1967
+ ...binding?.checklist ? { dnsChecklist: binding.checklist } : {}
1877
1968
  });
1878
1969
  } catch (e) {
1879
1970
  return toolError(e);
@@ -1952,7 +2043,7 @@ Once payment confirms, the site becomes permanent on its current URL. Binding a
1952
2043
  writeSiteFile(ctx.projectDir, { ...site, boundDomain: res2.apexDomain });
1953
2044
  }
1954
2045
  const apex2 = res2.apexDomain;
1955
- const { checks, apexResolves, allOk, checklist, layers } = await diagnoseBinding({
2046
+ const diag = await diagnoseBinding({
1956
2047
  apexDomain: apex2,
1957
2048
  servingTarget: res2.servingTarget,
1958
2049
  ...res2.verificationRecord ? { verificationRecord: res2.verificationRecord } : {},
@@ -1960,30 +2051,23 @@ Once payment confirms, the site becomes permanent on its current URL. Binding a
1960
2051
  verificationStatus: res2.status,
1961
2052
  provisioning: res2.provisioningJobId !== void 0
1962
2053
  });
2054
+ const { apexResolves, allOk } = diag;
1963
2055
  return text(
1964
2056
  res2.status === "verified" ? "domain_verification_succeeded" : "domain_verification_pending",
1965
2057
  `Domain binding status for ${apex2}: ${res2.status}
1966
2058
  ${res2.message}
1967
2059
 
1968
- Live DNS checklist (host values are the SHORT panel form):
1969
- ${checklist}
1970
-
1971
- ${layers}
1972
-
1973
- ` + (allOk && res2.status === "verified" ? "All records are live; certificate issuance completes automatically \u2014 check again in a few minutes until the binding is active." : "Fix any [MISSING]/[FIX] lines above, then re-run bind_domain status. Re-check every 5 minutes, up to 10 times; if still failing after that, show the user this checklist."),
2060
+ ` + renderChecklistBlock(
2061
+ diag,
2062
+ "Fix any [MISSING]/[FIX] lines above, then re-run bind_domain status; if still failing after the attempts below, show the user this checklist."
2063
+ ),
1974
2064
  {
1975
2065
  verificationId: res2.verificationId,
1976
2066
  status: res2.status,
1977
2067
  apexDomain: apex2,
1978
2068
  provisioningJobId: res2.provisioningJobId,
1979
2069
  servingTarget: res2.servingTarget,
1980
- dnsChecklist: checks.map((c) => ({
1981
- name: c.record.name,
1982
- type: c.record.type,
1983
- shortHost: c.shortHost,
1984
- state: c.state,
1985
- fix: c.fix || void 0
1986
- })),
2070
+ dnsChecklist: toDnsChecklist(diag.checks),
1987
2071
  apexResolves,
1988
2072
  retryAfterSeconds: DNS_RETRY_AFTER_SECONDS,
1989
2073
  maxAttempts: DNS_MAX_ATTEMPTS,
@@ -2006,17 +2090,19 @@ ${layers}
2006
2090
  "domain_verification_started",
2007
2091
  `Domain binding started for ${apex} (includes: ${res.includedHostnames.join(", ")} \u2014 both will serve this site).
2008
2092
 
2009
- Add ALL THREE DNS records NOW (adding them together lets verification, certificate issuance and serving complete without further record changes):
2093
+ STEP 1 of 2 \u2014 add ALL THREE records NOW:
2010
2094
 
2011
2095
  1) TXT host: ${txtShort} value: ${res.verificationRecord.value}
2012
2096
  2) CNAME host: www value: ${res.servingTarget}
2013
- 3) APEX host: @ -> ${res.servingTarget} via your DNS panel's ALIAS / ANAME / CNAME-flattening feature (an apex cannot use a plain CNAME).
2097
+ 3) APEX host: @ -> ${res.servingTarget}. Try a plain CNAME at host @ first (most panels accept it); if rejected use ALIAS / ANAME / CNAME-flattening; if the panel has neither, skip it \u2014 www alone works, and a URL redirect from @ to www covers bare-domain visitors.
2014
2098
 
2015
2099
  Host fields above are the SHORT form: most DNS panels append the domain automatically. After saving, the record list must NOT show ${apex} twice in one name \u2014 that means the full name was pasted into an auto-appending field.
2016
2100
 
2017
2101
  Ownership comes ONLY from DNS control; paying never grants it. The first verified request wins and this challenge expires after 72 hours.
2018
2102
 
2019
- Then run bind_domain with action "status" \u2014 it live-checks every record and names the exact fix for anything wrong. Re-check every 5 minutes (up to 10 times). Any later session can resume with action "status" alone; the verificationId is optional.`,
2103
+ STEP 2 (after ownership verifies): Cloudflare issues certificate-validation TXT records \u2014 the verification result and bind_domain "status" list them the moment they exist; relay each to the user and add them too. Everything then completes automatically.
2104
+
2105
+ Drive the whole flow with bind_domain "status": it live-checks every record and names the exact fix for anything wrong. Re-check every 5 minutes (up to 10 times). Any later session can resume with action "status" alone; the verificationId is optional.`,
2020
2106
  {
2021
2107
  verificationId: res.verificationId,
2022
2108
  apexDomain: apex,
@@ -2291,6 +2377,12 @@ ${res.archiveUrl}`,
2291
2377
  deploymentId: z3.string().optional(),
2292
2378
  severity: severityEnum.optional(),
2293
2379
  description: z3.string().optional().describe("What happened, in the user's words (no secrets)."),
2380
+ agentContext: z3.string().optional().describe(
2381
+ "YOUR OWN factual account of the session as the AI: which tools you called, what they returned, expected vs actual. Write it yourself from your observations \u2014 never ask the user to compose it, and do not read it back to them; it travels alongside the user's description as a second witness. No secrets, no file contents."
2382
+ ),
2383
+ contactEmail: z3.string().optional().describe(
2384
+ "OPTIONAL. Before submitting, ask the user ONCE whether they want to leave a contact for follow-up. Omit entirely if they decline \u2014 never require it."
2385
+ ),
2294
2386
  confirmSubmit: z3.boolean().optional().describe("User reviewed the report payload and approved submission.")
2295
2387
  }
2296
2388
  },
@@ -2315,7 +2407,9 @@ ${res.archiveUrl}`,
2315
2407
  ...site ? { siteId: site.siteId } : {},
2316
2408
  ...args.severity !== void 0 ? { severity: args.severity } : {},
2317
2409
  diagnostics,
2318
- ...args.description !== void 0 ? { description: args.description } : {}
2410
+ ...args.description !== void 0 ? { description: args.description } : {},
2411
+ ...args.agentContext !== void 0 ? { agentContext: args.agentContext } : {},
2412
+ ...args.contactEmail !== void 0 ? { contactEmail: args.contactEmail } : {}
2319
2413
  };
2320
2414
  if (args.confirmSubmit !== true) {
2321
2415
  return textJson(
@@ -2656,13 +2750,18 @@ Workflow:
2656
2750
 
2657
2751
  Project directory contract: ONE directory = ONE site (its .sakupa/site.json holds the
2658
2752
  binding). Every project-scoped tool accepts projectDir \u2014 ALWAYS pass the absolute path of
2659
- the directory the user is currently working in, on every call. Without it the server falls
2660
- back to its startup directory, which may be a different project than the one the user is
2661
- looking at. After every deploy, TELL the user which environment it went to (deploy results carry an
2753
+ the user's PROJECT ROOT, the SAME directory every time for the same project: the folder
2754
+ the user opened (for framework projects, where package.json lives \u2014 never the dist/out
2755
+ build folder; output is auto-detected). Without it the server falls back to its startup
2756
+ directory, which may be a different project than the one the user is looking at. After every deploy, TELL the user which environment it went to (deploy results carry an
2662
2757
  Explicit Environment line: TEST vs PRODUCTION). analyze_site, deploy_site, site_status,
2663
2758
  refresh_site, delete_site and unbind_domain echo
2664
2759
  the directory they acted on \u2014 verify it matches the user's active project.
2665
2760
 
2761
+ When the same operation fails twice in a row, or the user is clearly stuck or
2762
+ frustrated, proactively offer report_bug: it files the problem into Sakupa's ticket and
2763
+ alert stream, and you should attach your own factual account via agentContext.
2764
+
2666
2765
  Safety boundaries:
2667
2766
  - Static output only: no SSR, API routes, middleware, server actions, databases or online builds.
2668
2767
  - Never upload source projects, secrets, .env files, private keys, archives, videos or audio.
package/dist/index.js CHANGED
@@ -124,7 +124,7 @@ var FORBIDDEN_PATH_SEGMENTS = [
124
124
  var ALLOWED_HIDDEN_PATHS = [".well-known/"];
125
125
 
126
126
  // ../core/dist/domain/version.js
127
- var SAKUPA_MCP_VERSION = "0.7.9";
127
+ var SAKUPA_MCP_VERSION = "0.7.11";
128
128
 
129
129
  // ../core/dist/domain/errors.js
130
130
  var HTTP_STATUS = {
@@ -839,14 +839,18 @@ function deleteSiteFile(projectDir) {
839
839
  rmSync(path, { force: true });
840
840
  }
841
841
  }
842
- function isInsideGitRepo(projectDir) {
843
- let dir = projectDir;
844
- for (; ; ) {
845
- if (existsSync(join(dir, ".git"))) return true;
846
- const parent = dirname(dir);
847
- if (parent === dir) return false;
848
- dir = parent;
842
+ function findAncestor(startDir, predicate, maxLevels = Number.POSITIVE_INFINITY) {
843
+ let cursor = startDir;
844
+ for (let i = 0; i < maxLevels; i += 1) {
845
+ const parent = dirname(cursor);
846
+ if (parent === cursor) return null;
847
+ if (predicate(parent)) return parent;
848
+ cursor = parent;
849
849
  }
850
+ return null;
851
+ }
852
+ function isInsideGitRepo(projectDir) {
853
+ return existsSync(join(projectDir, ".git")) || findAncestor(projectDir, (dir) => existsSync(join(dir, ".git"))) !== null;
850
854
  }
851
855
  function credentialGitReminder(projectDir) {
852
856
  if (!isInsideGitRepo(projectDir)) return "";
@@ -1326,7 +1330,7 @@ var LocalGuidanceError = class extends SakupaError {
1326
1330
  }
1327
1331
  };
1328
1332
  var projectDirInput = z2.string().optional().describe(
1329
- "Absolute path of the project directory the user is CURRENTLY working in (the folder holding the site files and .sakupa). Always pass it explicitly; when omitted the server falls back to its startup directory, which may not be where the user is working now."
1333
+ "Absolute path of the user's PROJECT ROOT \u2014 the folder the user opened/works in (for framework projects: where package.json lives, NEVER the build-output subfolder like dist/out; the analyzer locates the output automatically). .sakupa/site.json lives here, so PASS THE SAME DIRECTORY EVERY TIME for the same project. When omitted the server falls back to its startup directory, which may not be where the user is working now."
1330
1334
  );
1331
1335
  function withProjectDir(ctx, projectDirArg) {
1332
1336
  if (projectDirArg === void 0) return ctx;
@@ -1414,7 +1418,7 @@ function toolError(e) {
1414
1418
 
1415
1419
  // src/tools/definitions.ts
1416
1420
  import { randomUUID } from "node:crypto";
1417
- import { promises as fs2 } from "node:fs";
1421
+ import { existsSync as existsSync3, promises as fs2 } from "node:fs";
1418
1422
  import { join as join4, resolve as resolve3 } from "node:path";
1419
1423
  import { z as z3 } from "zod";
1420
1424
 
@@ -1562,10 +1566,30 @@ async function diagnoseBinding(input) {
1562
1566
  const apexResolves = apexAnswers.length > 0;
1563
1567
  const allOk = checks.every((c) => c.state === "ok");
1564
1568
  const checklist = checks.map(renderCheck).join("\n") + `
1565
- [${apexResolves ? "OK" : "MISSING"}] APEX ${apex} \u2014 ` + (apexResolves ? "resolves." : `does not resolve yet: point it at ${input.servingTarget} using your DNS panel's ALIAS / ANAME / CNAME-flattening feature (an apex cannot use a plain CNAME).`);
1569
+ [${apexResolves ? "OK" : "MISSING"}] APEX ${apex} \u2014 ` + (apexResolves ? "resolves." : `does not resolve yet. Three-step fix, stop at the first that works: (1) try adding a plain record \u2014 type CNAME, host @, value ${input.servingTarget} (most panels and Cloudflare accept this directly; confirm past any MX-conflict warning if the domain sends no email). (2) If rejected, look for ALIAS / ANAME / CNAME-flattening in the record-type list \u2014 same host and value. (3) If the panel has neither, skip the apex: www alone works fine (certificates do not depend on the apex record); optionally add a URL redirect from @ to www.`);
1566
1570
  const layers = `Pipeline: [1] public DNS (checked LIVE above) -> [2] Sakupa ownership verification: ${input.verificationStatus} -> [3] HTTPS certificate & serving: ` + (input.provisioning ? "provisioning (Cloudflare validates and issues within minutes once the records above are all OK; Sakupa retries automatically every ~5 minutes)." : "starts after verification.");
1567
1571
  return { checks, apexResolves, allOk, checklist, layers };
1568
1572
  }
1573
+ var DNS_RETRY_AFTER_SECONDS = 300;
1574
+ var DNS_MAX_ATTEMPTS = 10;
1575
+ function toDnsChecklist(checks) {
1576
+ return checks.map((c) => ({
1577
+ name: c.record.name,
1578
+ type: c.record.type,
1579
+ shortHost: c.shortHost,
1580
+ state: c.state,
1581
+ ...c.fix ? { fix: c.fix } : {}
1582
+ }));
1583
+ }
1584
+ function renderChecklistBlock(diag, fixTail) {
1585
+ const cadence = `Re-check every ${DNS_RETRY_AFTER_SECONDS / 60} minutes, up to ${DNS_MAX_ATTEMPTS} times.`;
1586
+ return `Live DNS checklist (host values are the SHORT panel form):
1587
+ ${diag.checklist}
1588
+
1589
+ ${diag.layers}
1590
+
1591
+ ` + (diag.allOk ? "All records are live; certificate issuance completes automatically \u2014 re-check in a few minutes until the binding is active." : `${fixTail} ${cadence}`);
1592
+ }
1569
1593
 
1570
1594
  // src/tools/definitions.ts
1571
1595
  function text(resultCode, t, data = {}, outcome = "completed") {
@@ -1673,8 +1697,52 @@ async function uploadAll(ctx, targets, files, outputAbs) {
1673
1697
  }
1674
1698
  return targets.length;
1675
1699
  }
1676
- var DNS_RETRY_AFTER_SECONDS = 300;
1677
- var DNS_MAX_ATTEMPTS = 10;
1700
+ async function describePendingBinding(client, credential, pb) {
1701
+ const framing = `
1702
+ Domain binding IN PROGRESS: ${pb.apexDomain} \u2014 ` + (pb.phase === "provisioning" ? "ownership verified; certificates/serving are provisioning." : `awaiting DNS verification (challenge valid until ${pb.verificationExpiresAt}).`);
1703
+ let check;
1704
+ try {
1705
+ check = await client.checkVerification(pb.verificationId, credential);
1706
+ } catch {
1707
+ return {
1708
+ note: framing + "\n(Couldn't refresh binding progress from the server just now \u2014 try site_status again shortly.)"
1709
+ };
1710
+ }
1711
+ try {
1712
+ const diag = await diagnoseBinding({
1713
+ apexDomain: check.apexDomain,
1714
+ servingTarget: check.servingTarget,
1715
+ ...check.verificationRecord ? { verificationRecord: check.verificationRecord } : {},
1716
+ pendingDnsRecords: check.pendingDnsRecords,
1717
+ verificationStatus: check.status,
1718
+ provisioning: pb.phase === "provisioning"
1719
+ });
1720
+ const block = renderChecklistBlock(
1721
+ diag,
1722
+ "Relay every [MISSING]/[FIX] line to the user with its exact fix."
1723
+ );
1724
+ return { note: `${framing}
1725
+ ${block}`, checklist: toDnsChecklist(diag.checks) };
1726
+ } catch {
1727
+ return {
1728
+ note: framing + '\n(Live DNS lookups are unavailable right now \u2014 run bind_domain with action "status" for the per-record checklist.)'
1729
+ };
1730
+ }
1731
+ }
1732
+ function projectRootAbove(projectDir) {
1733
+ if (existsSync3(join4(projectDir, "package.json"))) return null;
1734
+ return findAncestor(projectDir, (dir) => existsSync3(join4(dir, "package.json")), 4);
1735
+ }
1736
+ function findNeighborBinding(projectDir, outputRel) {
1737
+ const bound = (dir) => loadSiteFile(dir).kind !== "absent";
1738
+ const above = findAncestor(projectDir, bound, 3);
1739
+ if (above) return above;
1740
+ if (outputRel && outputRel !== ".") {
1741
+ const outputAbs = resolve3(projectDir, outputRel);
1742
+ if (bound(outputAbs)) return outputAbs;
1743
+ }
1744
+ return null;
1745
+ }
1678
1746
  function freeSiteCreationBarrier() {
1679
1747
  const recent = listRecentCreations(Date.now());
1680
1748
  if (recent.length < FREE_ACTIVE_SITES_PER_IP) return null;
@@ -1735,6 +1803,9 @@ Next action: ${analysis.suggestedNextAction}`,
1735
1803
  publicConfirmed: z3.boolean().optional().describe(
1736
1804
  "Required only for the first deployment: user explicitly confirmed creation of a public 24-hour URL."
1737
1805
  ),
1806
+ subprojectConfirmed: z3.boolean().optional().describe(
1807
+ "Only when creating a NEW site in a subfolder of a package.json project: the user explicitly confirmed this subfolder is an INDEPENDENT site, not the project's build output."
1808
+ ),
1738
1809
  lang: z3.string().optional().describe("Site language override (en | ja | zh-CN); defaults to the html lang.")
1739
1810
  }
1740
1811
  },
@@ -1763,16 +1834,34 @@ Next action: ${analysis.suggestedNextAction}`,
1763
1834
  }
1764
1835
  const existing = siteFileState.kind === "ok" ? siteFileState.file : null;
1765
1836
  if (!existing) {
1837
+ const rootAbove = args.subprojectConfirmed === true ? null : projectRootAbove(ctx.projectDir);
1838
+ if (rootAbove) {
1839
+ return text(
1840
+ "not_project_root",
1841
+ `${ctx.projectDir} has no package.json but ${rootAbove} does \u2014 this directory is a SUBFOLDER of that project (typically its build output), and .sakupa must live at the project ROOT. Re-run deploy_site with projectDir: ${rootAbove}. Only if the user explicitly says this subfolder is an INDEPENDENT site (e.g. a docs/ site inside a repo), re-run with subprojectConfirmed: true. Nothing was deployed and no site was created.`,
1842
+ { projectRoot: rootAbove, confirmationField: "subprojectConfirmed" },
1843
+ "blocked"
1844
+ );
1845
+ }
1846
+ const neighbor = findNeighborBinding(ctx.projectDir, analysis.recommendedOutputDir);
1847
+ if (neighbor) {
1848
+ return text(
1849
+ "neighbor_binding_found",
1850
+ `No .sakupa binding in ${ctx.projectDir}, but one EXISTS at ${neighbor} \u2014 this looks like the same project addressed at a different directory level. To update that existing site, re-run deploy_site with projectDir: ${neighbor}. Only if the user explicitly wants a SEPARATE new site, move this deploy to a directory outside that project. Nothing was deployed and no site was created.`,
1851
+ { neighborProjectDir: neighbor },
1852
+ "blocked"
1853
+ );
1854
+ }
1766
1855
  const barrier = freeSiteCreationBarrier();
1767
1856
  if (barrier) return barrier;
1768
- }
1769
- if (!existing && args.publicConfirmed !== true) {
1770
- return text(
1771
- "public_deployment_confirmation_required",
1772
- `First deployment creates a public URL that anyone with the link can open. The free preview stays live for ${FREE_SITE_TTL_HOURS} hours. Explain this to the user and obtain explicit confirmation before retrying deploy_site with publicConfirmed: true.`,
1773
- { publicUrlLifetimeHours: FREE_SITE_TTL_HOURS, confirmationField: "publicConfirmed" },
1774
- "waiting_user"
1775
- );
1857
+ if (args.publicConfirmed !== true) {
1858
+ return text(
1859
+ "public_deployment_confirmation_required",
1860
+ `First deployment creates a public URL that anyone with the link can open. The free preview stays live for ${FREE_SITE_TTL_HOURS} hours. Explain this to the user and obtain explicit confirmation before retrying deploy_site with publicConfirmed: true.`,
1861
+ { publicUrlLifetimeHours: FREE_SITE_TTL_HOURS, confirmationField: "publicConfirmed" },
1862
+ "waiting_user"
1863
+ );
1864
+ }
1776
1865
  }
1777
1866
  ensureUploadSizeWithinLimits(manifest, !existing);
1778
1867
  if (!existing) {
@@ -1935,9 +2024,11 @@ NO content was uploaded or changed by this call \u2014 to publish new or edited
1935
2024
  const ctx = withProjectDir(baseCtx, args.projectDir);
1936
2025
  const site = requireSiteFile(ctx);
1937
2026
  const res = await ctx.client.getSiteStatus(site.siteId, site.credential);
1938
- return textJson("site_status_returned", "Site status:", {
2027
+ const binding = res.pendingDomainBinding ? await describePendingBinding(ctx.client, site.credential, res.pendingDomainBinding) : void 0;
2028
+ return textJson("site_status_returned", `Site status:${binding?.note ?? ""}`, {
1939
2029
  ...res,
1940
- projectDir: ctx.projectDir
2030
+ projectDir: ctx.projectDir,
2031
+ ...binding?.checklist ? { dnsChecklist: binding.checklist } : {}
1941
2032
  });
1942
2033
  } catch (e) {
1943
2034
  return toolError(e);
@@ -2016,7 +2107,7 @@ Once payment confirms, the site becomes permanent on its current URL. Binding a
2016
2107
  writeSiteFile(ctx.projectDir, { ...site, boundDomain: res2.apexDomain });
2017
2108
  }
2018
2109
  const apex2 = res2.apexDomain;
2019
- const { checks, apexResolves, allOk, checklist, layers } = await diagnoseBinding({
2110
+ const diag = await diagnoseBinding({
2020
2111
  apexDomain: apex2,
2021
2112
  servingTarget: res2.servingTarget,
2022
2113
  ...res2.verificationRecord ? { verificationRecord: res2.verificationRecord } : {},
@@ -2024,30 +2115,23 @@ Once payment confirms, the site becomes permanent on its current URL. Binding a
2024
2115
  verificationStatus: res2.status,
2025
2116
  provisioning: res2.provisioningJobId !== void 0
2026
2117
  });
2118
+ const { apexResolves, allOk } = diag;
2027
2119
  return text(
2028
2120
  res2.status === "verified" ? "domain_verification_succeeded" : "domain_verification_pending",
2029
2121
  `Domain binding status for ${apex2}: ${res2.status}
2030
2122
  ${res2.message}
2031
2123
 
2032
- Live DNS checklist (host values are the SHORT panel form):
2033
- ${checklist}
2034
-
2035
- ${layers}
2036
-
2037
- ` + (allOk && res2.status === "verified" ? "All records are live; certificate issuance completes automatically \u2014 check again in a few minutes until the binding is active." : "Fix any [MISSING]/[FIX] lines above, then re-run bind_domain status. Re-check every 5 minutes, up to 10 times; if still failing after that, show the user this checklist."),
2124
+ ` + renderChecklistBlock(
2125
+ diag,
2126
+ "Fix any [MISSING]/[FIX] lines above, then re-run bind_domain status; if still failing after the attempts below, show the user this checklist."
2127
+ ),
2038
2128
  {
2039
2129
  verificationId: res2.verificationId,
2040
2130
  status: res2.status,
2041
2131
  apexDomain: apex2,
2042
2132
  provisioningJobId: res2.provisioningJobId,
2043
2133
  servingTarget: res2.servingTarget,
2044
- dnsChecklist: checks.map((c) => ({
2045
- name: c.record.name,
2046
- type: c.record.type,
2047
- shortHost: c.shortHost,
2048
- state: c.state,
2049
- fix: c.fix || void 0
2050
- })),
2134
+ dnsChecklist: toDnsChecklist(diag.checks),
2051
2135
  apexResolves,
2052
2136
  retryAfterSeconds: DNS_RETRY_AFTER_SECONDS,
2053
2137
  maxAttempts: DNS_MAX_ATTEMPTS,
@@ -2070,17 +2154,19 @@ ${layers}
2070
2154
  "domain_verification_started",
2071
2155
  `Domain binding started for ${apex} (includes: ${res.includedHostnames.join(", ")} \u2014 both will serve this site).
2072
2156
 
2073
- Add ALL THREE DNS records NOW (adding them together lets verification, certificate issuance and serving complete without further record changes):
2157
+ STEP 1 of 2 \u2014 add ALL THREE records NOW:
2074
2158
 
2075
2159
  1) TXT host: ${txtShort} value: ${res.verificationRecord.value}
2076
2160
  2) CNAME host: www value: ${res.servingTarget}
2077
- 3) APEX host: @ -> ${res.servingTarget} via your DNS panel's ALIAS / ANAME / CNAME-flattening feature (an apex cannot use a plain CNAME).
2161
+ 3) APEX host: @ -> ${res.servingTarget}. Try a plain CNAME at host @ first (most panels accept it); if rejected use ALIAS / ANAME / CNAME-flattening; if the panel has neither, skip it \u2014 www alone works, and a URL redirect from @ to www covers bare-domain visitors.
2078
2162
 
2079
2163
  Host fields above are the SHORT form: most DNS panels append the domain automatically. After saving, the record list must NOT show ${apex} twice in one name \u2014 that means the full name was pasted into an auto-appending field.
2080
2164
 
2081
2165
  Ownership comes ONLY from DNS control; paying never grants it. The first verified request wins and this challenge expires after 72 hours.
2082
2166
 
2083
- Then run bind_domain with action "status" \u2014 it live-checks every record and names the exact fix for anything wrong. Re-check every 5 minutes (up to 10 times). Any later session can resume with action "status" alone; the verificationId is optional.`,
2167
+ STEP 2 (after ownership verifies): Cloudflare issues certificate-validation TXT records \u2014 the verification result and bind_domain "status" list them the moment they exist; relay each to the user and add them too. Everything then completes automatically.
2168
+
2169
+ Drive the whole flow with bind_domain "status": it live-checks every record and names the exact fix for anything wrong. Re-check every 5 minutes (up to 10 times). Any later session can resume with action "status" alone; the verificationId is optional.`,
2084
2170
  {
2085
2171
  verificationId: res.verificationId,
2086
2172
  apexDomain: apex,
@@ -2355,6 +2441,12 @@ ${res.archiveUrl}`,
2355
2441
  deploymentId: z3.string().optional(),
2356
2442
  severity: severityEnum.optional(),
2357
2443
  description: z3.string().optional().describe("What happened, in the user's words (no secrets)."),
2444
+ agentContext: z3.string().optional().describe(
2445
+ "YOUR OWN factual account of the session as the AI: which tools you called, what they returned, expected vs actual. Write it yourself from your observations \u2014 never ask the user to compose it, and do not read it back to them; it travels alongside the user's description as a second witness. No secrets, no file contents."
2446
+ ),
2447
+ contactEmail: z3.string().optional().describe(
2448
+ "OPTIONAL. Before submitting, ask the user ONCE whether they want to leave a contact for follow-up. Omit entirely if they decline \u2014 never require it."
2449
+ ),
2358
2450
  confirmSubmit: z3.boolean().optional().describe("User reviewed the report payload and approved submission.")
2359
2451
  }
2360
2452
  },
@@ -2379,7 +2471,9 @@ ${res.archiveUrl}`,
2379
2471
  ...site ? { siteId: site.siteId } : {},
2380
2472
  ...args.severity !== void 0 ? { severity: args.severity } : {},
2381
2473
  diagnostics,
2382
- ...args.description !== void 0 ? { description: args.description } : {}
2474
+ ...args.description !== void 0 ? { description: args.description } : {},
2475
+ ...args.agentContext !== void 0 ? { agentContext: args.agentContext } : {},
2476
+ ...args.contactEmail !== void 0 ? { contactEmail: args.contactEmail } : {}
2383
2477
  };
2384
2478
  if (args.confirmSubmit !== true) {
2385
2479
  return textJson(
@@ -2651,13 +2745,18 @@ Workflow:
2651
2745
 
2652
2746
  Project directory contract: ONE directory = ONE site (its .sakupa/site.json holds the
2653
2747
  binding). Every project-scoped tool accepts projectDir \u2014 ALWAYS pass the absolute path of
2654
- the directory the user is currently working in, on every call. Without it the server falls
2655
- back to its startup directory, which may be a different project than the one the user is
2656
- looking at. After every deploy, TELL the user which environment it went to (deploy results carry an
2748
+ the user's PROJECT ROOT, the SAME directory every time for the same project: the folder
2749
+ the user opened (for framework projects, where package.json lives \u2014 never the dist/out
2750
+ build folder; output is auto-detected). Without it the server falls back to its startup
2751
+ directory, which may be a different project than the one the user is looking at. After every deploy, TELL the user which environment it went to (deploy results carry an
2657
2752
  Explicit Environment line: TEST vs PRODUCTION). analyze_site, deploy_site, site_status,
2658
2753
  refresh_site, delete_site and unbind_domain echo
2659
2754
  the directory they acted on \u2014 verify it matches the user's active project.
2660
2755
 
2756
+ When the same operation fails twice in a row, or the user is clearly stuck or
2757
+ frustrated, proactively offer report_bug: it files the problem into Sakupa's ticket and
2758
+ alert stream, and you should attach your own factual account via agentContext.
2759
+
2661
2760
  Safety boundaries:
2662
2761
  - Static output only: no SSR, API routes, middleware, server actions, databases or online builds.
2663
2762
  - Never upload source projects, secrets, .env files, private keys, archives, videos or audio.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sakupa/mcp",
3
- "version": "0.7.9",
3
+ "version": "0.7.11",
4
4
  "description": "Sakupa MCP server: publish AI-made static sites from your AI tool. AI-made pages, live in seconds.",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",