@sakupa/mcp 0.7.10 → 0.7.12
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/bin.js +77 -38
- package/dist/index.js +77 -38
- 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.
|
|
132
|
+
var SAKUPA_MCP_VERSION = "0.7.12";
|
|
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
|
|
1200
|
-
let
|
|
1201
|
-
for (; ; ) {
|
|
1202
|
-
|
|
1203
|
-
|
|
1204
|
-
if (parent
|
|
1205
|
-
|
|
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,7 +1358,7 @@ 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
|
|
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
|
}
|
|
@@ -1437,7 +1441,7 @@ var LocalGuidanceError = class extends SakupaError {
|
|
|
1437
1441
|
}
|
|
1438
1442
|
};
|
|
1439
1443
|
var projectDirInput = z2.string().optional().describe(
|
|
1440
|
-
"Absolute path of the
|
|
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."
|
|
1441
1445
|
);
|
|
1442
1446
|
function withProjectDir(ctx, projectDirArg) {
|
|
1443
1447
|
if (projectDirArg === void 0) return ctx;
|
|
@@ -1479,16 +1483,7 @@ function requireSiteFile(ctx) {
|
|
|
1479
1483
|
}
|
|
1480
1484
|
return state.file;
|
|
1481
1485
|
}
|
|
1482
|
-
var
|
|
1483
|
-
not_found: "The required local project binding or resource is unavailable; if this project has no .sakupa/site.json yet, run deploy_site first.",
|
|
1484
|
-
unauthorized: "The server rejected the site credential: the one in .sakupa/site.json no longer matches the server-side verifier. The site itself is intact on the server \u2014 only the local binding file is the problem. Repair the file (restore a backup or undo the local edit). Do NOT delete the .sakupa directory to work around this: the credential is unrecoverable by design, so abandoning it permanently orphans the existing site.",
|
|
1485
|
-
invalid_request: "The request arguments or local project checks did not pass.",
|
|
1486
|
-
validation_failed: "The request arguments or local project checks did not pass.",
|
|
1487
|
-
state_conflict: "The resource state has changed; re-query the current status before deciding the next step.",
|
|
1488
|
-
confirmation_required: "The site or billing state changed, so the previous confirmation is stale; run the preview again and confirm against the fresh snapshot.",
|
|
1489
|
-
payment_required: "This operation requires an active subscription; check billing_status first.",
|
|
1490
|
-
rate_limited: "The server rate limit was reached; retry after the returned wait time."
|
|
1491
|
-
};
|
|
1486
|
+
var UNAUTHORIZED_SUMMARY = "The server rejected the site credential: the one in .sakupa/site.json no longer matches the server-side verifier. The site itself is intact on the server \u2014 only the local binding file is the problem. Repair the file (restore a backup or undo the local edit). Do NOT delete the .sakupa directory to work around this: the credential is unrecoverable by design, so abandoning it permanently orphans the existing site.";
|
|
1492
1487
|
function toolError(e) {
|
|
1493
1488
|
const errorCode = isSakupaError(e) ? e.code : "internal";
|
|
1494
1489
|
const retryable = errorCode === "rate_limited" || errorCode === "internal";
|
|
@@ -1507,7 +1502,8 @@ function toolError(e) {
|
|
|
1507
1502
|
)
|
|
1508
1503
|
) : void 0;
|
|
1509
1504
|
const minimumVersion = rawDetails && typeof rawDetails["minimumVersion"] === "string" ? rawDetails["minimumVersion"] : void 0;
|
|
1510
|
-
const
|
|
1505
|
+
const serverGuidance = isSakupaError(e) && errorCode !== "internal" && errorCode !== "unauthorized" && errorCode !== "upgrade_required" && e.message.trim().length > 0 ? e.message : void 0;
|
|
1506
|
+
const safeSummary = e instanceof LocalGuidanceError ? e.message : errorCode === "upgrade_required" ? `This Sakupa MCP client is v${MCP_VERSION}, older than the server's minimum supported version${minimumVersion !== void 0 ? ` (v${minimumVersion})` : ""}, so the server refused the call. To fix it: ask the user to fully restart their MCP client session \u2014 "npx -y @sakupa/mcp@latest" setups fetch the current version on restart (run "npx clear-npx-cache" first if the old version persists); global installs need "npm install -g @sakupa/mcp@latest". After the restart, retry this exact tool call.` : errorCode === "unauthorized" ? UNAUTHORIZED_SUMMARY : serverGuidance ?? (retryable ? "An upstream service is temporarily unavailable or busy; retry shortly." : "The operation failed; no server-internal details are exposed.");
|
|
1511
1507
|
const result = structuredToolResult({
|
|
1512
1508
|
schemaVersion: 1,
|
|
1513
1509
|
outcome: "failed",
|
|
@@ -1661,6 +1657,20 @@ ${block}`, checklist: toDnsChecklist(diag.checks) };
|
|
|
1661
1657
|
};
|
|
1662
1658
|
}
|
|
1663
1659
|
}
|
|
1660
|
+
function projectRootAbove(projectDir) {
|
|
1661
|
+
if (existsSync3(join4(projectDir, "package.json"))) return null;
|
|
1662
|
+
return findAncestor(projectDir, (dir) => existsSync3(join4(dir, "package.json")), 4);
|
|
1663
|
+
}
|
|
1664
|
+
function findNeighborBinding(projectDir, outputRel) {
|
|
1665
|
+
const bound = (dir) => loadSiteFile(dir).kind !== "absent";
|
|
1666
|
+
const above = findAncestor(projectDir, bound, 3);
|
|
1667
|
+
if (above) return above;
|
|
1668
|
+
if (outputRel && outputRel !== ".") {
|
|
1669
|
+
const outputAbs = resolve3(projectDir, outputRel);
|
|
1670
|
+
if (bound(outputAbs)) return outputAbs;
|
|
1671
|
+
}
|
|
1672
|
+
return null;
|
|
1673
|
+
}
|
|
1664
1674
|
function freeSiteCreationBarrier() {
|
|
1665
1675
|
const recent = listRecentCreations(Date.now());
|
|
1666
1676
|
if (recent.length < FREE_ACTIVE_SITES_PER_IP) return null;
|
|
@@ -1721,6 +1731,9 @@ Next action: ${analysis.suggestedNextAction}`,
|
|
|
1721
1731
|
publicConfirmed: z3.boolean().optional().describe(
|
|
1722
1732
|
"Required only for the first deployment: user explicitly confirmed creation of a public 24-hour URL."
|
|
1723
1733
|
),
|
|
1734
|
+
subprojectConfirmed: z3.boolean().optional().describe(
|
|
1735
|
+
"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."
|
|
1736
|
+
),
|
|
1724
1737
|
lang: z3.string().optional().describe("Site language override (en | ja | zh-CN); defaults to the html lang.")
|
|
1725
1738
|
}
|
|
1726
1739
|
},
|
|
@@ -1749,16 +1762,34 @@ Next action: ${analysis.suggestedNextAction}`,
|
|
|
1749
1762
|
}
|
|
1750
1763
|
const existing = siteFileState.kind === "ok" ? siteFileState.file : null;
|
|
1751
1764
|
if (!existing) {
|
|
1765
|
+
const rootAbove = args.subprojectConfirmed === true ? null : projectRootAbove(ctx.projectDir);
|
|
1766
|
+
if (rootAbove) {
|
|
1767
|
+
return text(
|
|
1768
|
+
"not_project_root",
|
|
1769
|
+
`${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.`,
|
|
1770
|
+
{ projectRoot: rootAbove, confirmationField: "subprojectConfirmed" },
|
|
1771
|
+
"blocked"
|
|
1772
|
+
);
|
|
1773
|
+
}
|
|
1774
|
+
const neighbor = findNeighborBinding(ctx.projectDir, analysis.recommendedOutputDir);
|
|
1775
|
+
if (neighbor) {
|
|
1776
|
+
return text(
|
|
1777
|
+
"neighbor_binding_found",
|
|
1778
|
+
`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.`,
|
|
1779
|
+
{ neighborProjectDir: neighbor },
|
|
1780
|
+
"blocked"
|
|
1781
|
+
);
|
|
1782
|
+
}
|
|
1752
1783
|
const barrier = freeSiteCreationBarrier();
|
|
1753
1784
|
if (barrier) return barrier;
|
|
1754
|
-
|
|
1755
|
-
|
|
1756
|
-
|
|
1757
|
-
|
|
1758
|
-
|
|
1759
|
-
|
|
1760
|
-
|
|
1761
|
-
|
|
1785
|
+
if (args.publicConfirmed !== true) {
|
|
1786
|
+
return text(
|
|
1787
|
+
"public_deployment_confirmation_required",
|
|
1788
|
+
`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.`,
|
|
1789
|
+
{ publicUrlLifetimeHours: FREE_SITE_TTL_HOURS, confirmationField: "publicConfirmed" },
|
|
1790
|
+
"waiting_user"
|
|
1791
|
+
);
|
|
1792
|
+
}
|
|
1762
1793
|
}
|
|
1763
1794
|
ensureUploadSizeWithinLimits(manifest, !existing);
|
|
1764
1795
|
if (!existing) {
|
|
@@ -1982,7 +2013,7 @@ Once payment confirms, the site becomes permanent on its current URL. Binding a
|
|
|
1982
2013
|
server.registerTool(
|
|
1983
2014
|
"bind_domain",
|
|
1984
2015
|
{
|
|
1985
|
-
description: `Bind a custom domain to this subscribed site \u2014 an OPTIONAL extra serving surface; the permanent ${previewHostPattern} URL keeps working alongside it. The binding unit is the APEX domain: binding example.com automatically includes www.example.com (both serve the same content, one apex TXT verification covers both), and one site binds at most ONE apex domain \u2014 a second domain needs a second subscribed site. Requires an ACTIVE subscription (subscribe_site). Ownership is proven ONLY by DNS control of the apex \u2014 payment never grants ownership
|
|
2016
|
+
description: `Bind a custom domain to this subscribed site \u2014 an OPTIONAL extra serving surface; the permanent ${previewHostPattern} URL keeps working alongside it. The binding unit is the APEX domain: binding example.com automatically includes www.example.com (both serve the same content, one apex TXT verification covers both), and one site binds at most ONE apex domain \u2014 a second domain needs a second subscribed site. Requires an ACTIVE subscription (subscribe_site). Ownership is proven ONLY by DNS control of the apex \u2014 payment never grants ownership, and bindings are ALWAYS challengeable: whoever proves CURRENT DNS control takes the domain, even from an existing binding (the displaced site keeps its subscription, content and permanent URL). Unverified requests expire after 72 hours. Call again with action "status" to check progress.`,
|
|
1986
2017
|
outputSchema: STRUCTURED_TOOL_OUTPUT_SCHEMA,
|
|
1987
2018
|
annotations: { readOnlyHint: false, destructiveHint: true, openWorldHint: true },
|
|
1988
2019
|
inputSchema: {
|
|
@@ -2051,17 +2082,19 @@ ${res2.message}
|
|
|
2051
2082
|
"domain_verification_started",
|
|
2052
2083
|
`Domain binding started for ${apex} (includes: ${res.includedHostnames.join(", ")} \u2014 both will serve this site).
|
|
2053
2084
|
|
|
2054
|
-
|
|
2085
|
+
STEP 1 of 2 \u2014 add ALL THREE records NOW:
|
|
2055
2086
|
|
|
2056
2087
|
1) TXT host: ${txtShort} value: ${res.verificationRecord.value}
|
|
2057
2088
|
2) CNAME host: www value: ${res.servingTarget}
|
|
2058
|
-
3) APEX host: @ -> ${res.servingTarget}
|
|
2089
|
+
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.
|
|
2059
2090
|
|
|
2060
2091
|
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.
|
|
2061
2092
|
|
|
2062
2093
|
Ownership comes ONLY from DNS control; paying never grants it. The first verified request wins and this challenge expires after 72 hours.
|
|
2063
2094
|
|
|
2064
|
-
|
|
2095
|
+
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.
|
|
2096
|
+
|
|
2097
|
+
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.`,
|
|
2065
2098
|
{
|
|
2066
2099
|
verificationId: res.verificationId,
|
|
2067
2100
|
apexDomain: apex,
|
|
@@ -2371,9 +2404,10 @@ ${res.archiveUrl}`,
|
|
|
2371
2404
|
...args.contactEmail !== void 0 ? { contactEmail: args.contactEmail } : {}
|
|
2372
2405
|
};
|
|
2373
2406
|
if (args.confirmSubmit !== true) {
|
|
2407
|
+
const contactNote = args.contactEmail !== void 0 ? "note that their contact email is attached for follow-up. " : "ASK THEM ONCE whether they want to attach a contact email for follow-up (optional \u2014 omit if declined; include it as contactEmail when they do). ";
|
|
2374
2408
|
return textJson(
|
|
2375
2409
|
"bug_report_preview_ready",
|
|
2376
|
-
|
|
2410
|
+
`Bug report prepared but NOT submitted. This is the exact payload that would be sent (structured diagnostics only \u2014 no file contents, source code or secrets). Show it to the user, and ${contactNote}Then re-run report_bug with confirmSubmit: true to submit.`,
|
|
2377
2411
|
payload,
|
|
2378
2412
|
"preview"
|
|
2379
2413
|
);
|
|
@@ -2709,13 +2743,18 @@ Workflow:
|
|
|
2709
2743
|
|
|
2710
2744
|
Project directory contract: ONE directory = ONE site (its .sakupa/site.json holds the
|
|
2711
2745
|
binding). Every project-scoped tool accepts projectDir \u2014 ALWAYS pass the absolute path of
|
|
2712
|
-
the
|
|
2713
|
-
|
|
2714
|
-
|
|
2746
|
+
the user's PROJECT ROOT, the SAME directory every time for the same project: the folder
|
|
2747
|
+
the user opened (for framework projects, where package.json lives \u2014 never the dist/out
|
|
2748
|
+
build folder; output is auto-detected). Without it the server falls back to its startup
|
|
2749
|
+
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
|
|
2715
2750
|
Explicit Environment line: TEST vs PRODUCTION). analyze_site, deploy_site, site_status,
|
|
2716
2751
|
refresh_site, delete_site and unbind_domain echo
|
|
2717
2752
|
the directory they acted on \u2014 verify it matches the user's active project.
|
|
2718
2753
|
|
|
2754
|
+
When the same operation fails twice in a row, or the user is clearly stuck or
|
|
2755
|
+
frustrated, proactively offer report_bug: it files the problem into Sakupa's ticket and
|
|
2756
|
+
alert stream, and you should attach your own factual account via agentContext.
|
|
2757
|
+
|
|
2719
2758
|
Safety boundaries:
|
|
2720
2759
|
- Static output only: no SSR, API routes, middleware, server actions, databases or online builds.
|
|
2721
2760
|
- 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.
|
|
127
|
+
var SAKUPA_MCP_VERSION = "0.7.12";
|
|
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
|
|
843
|
-
let
|
|
844
|
-
for (; ; ) {
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
if (parent
|
|
848
|
-
|
|
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
|
|
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;
|
|
@@ -1368,16 +1372,7 @@ function requireSiteFile(ctx) {
|
|
|
1368
1372
|
}
|
|
1369
1373
|
return state.file;
|
|
1370
1374
|
}
|
|
1371
|
-
var
|
|
1372
|
-
not_found: "The required local project binding or resource is unavailable; if this project has no .sakupa/site.json yet, run deploy_site first.",
|
|
1373
|
-
unauthorized: "The server rejected the site credential: the one in .sakupa/site.json no longer matches the server-side verifier. The site itself is intact on the server \u2014 only the local binding file is the problem. Repair the file (restore a backup or undo the local edit). Do NOT delete the .sakupa directory to work around this: the credential is unrecoverable by design, so abandoning it permanently orphans the existing site.",
|
|
1374
|
-
invalid_request: "The request arguments or local project checks did not pass.",
|
|
1375
|
-
validation_failed: "The request arguments or local project checks did not pass.",
|
|
1376
|
-
state_conflict: "The resource state has changed; re-query the current status before deciding the next step.",
|
|
1377
|
-
confirmation_required: "The site or billing state changed, so the previous confirmation is stale; run the preview again and confirm against the fresh snapshot.",
|
|
1378
|
-
payment_required: "This operation requires an active subscription; check billing_status first.",
|
|
1379
|
-
rate_limited: "The server rate limit was reached; retry after the returned wait time."
|
|
1380
|
-
};
|
|
1375
|
+
var UNAUTHORIZED_SUMMARY = "The server rejected the site credential: the one in .sakupa/site.json no longer matches the server-side verifier. The site itself is intact on the server \u2014 only the local binding file is the problem. Repair the file (restore a backup or undo the local edit). Do NOT delete the .sakupa directory to work around this: the credential is unrecoverable by design, so abandoning it permanently orphans the existing site.";
|
|
1381
1376
|
function toolError(e) {
|
|
1382
1377
|
const errorCode = isSakupaError(e) ? e.code : "internal";
|
|
1383
1378
|
const retryable = errorCode === "rate_limited" || errorCode === "internal";
|
|
@@ -1396,7 +1391,8 @@ function toolError(e) {
|
|
|
1396
1391
|
)
|
|
1397
1392
|
) : void 0;
|
|
1398
1393
|
const minimumVersion = rawDetails && typeof rawDetails["minimumVersion"] === "string" ? rawDetails["minimumVersion"] : void 0;
|
|
1399
|
-
const
|
|
1394
|
+
const serverGuidance = isSakupaError(e) && errorCode !== "internal" && errorCode !== "unauthorized" && errorCode !== "upgrade_required" && e.message.trim().length > 0 ? e.message : void 0;
|
|
1395
|
+
const safeSummary = e instanceof LocalGuidanceError ? e.message : errorCode === "upgrade_required" ? `This Sakupa MCP client is v${MCP_VERSION}, older than the server's minimum supported version${minimumVersion !== void 0 ? ` (v${minimumVersion})` : ""}, so the server refused the call. To fix it: ask the user to fully restart their MCP client session \u2014 "npx -y @sakupa/mcp@latest" setups fetch the current version on restart (run "npx clear-npx-cache" first if the old version persists); global installs need "npm install -g @sakupa/mcp@latest". After the restart, retry this exact tool call.` : errorCode === "unauthorized" ? UNAUTHORIZED_SUMMARY : serverGuidance ?? (retryable ? "An upstream service is temporarily unavailable or busy; retry shortly." : "The operation failed; no server-internal details are exposed.");
|
|
1400
1396
|
const result = structuredToolResult({
|
|
1401
1397
|
schemaVersion: 1,
|
|
1402
1398
|
outcome: "failed",
|
|
@@ -1414,7 +1410,7 @@ function toolError(e) {
|
|
|
1414
1410
|
|
|
1415
1411
|
// src/tools/definitions.ts
|
|
1416
1412
|
import { randomUUID } from "node:crypto";
|
|
1417
|
-
import { promises as fs2 } from "node:fs";
|
|
1413
|
+
import { existsSync as existsSync3, promises as fs2 } from "node:fs";
|
|
1418
1414
|
import { join as join4, resolve as resolve3 } from "node:path";
|
|
1419
1415
|
import { z as z3 } from "zod";
|
|
1420
1416
|
|
|
@@ -1562,7 +1558,7 @@ async function diagnoseBinding(input) {
|
|
|
1562
1558
|
const apexResolves = apexAnswers.length > 0;
|
|
1563
1559
|
const allOk = checks.every((c) => c.state === "ok");
|
|
1564
1560
|
const checklist = checks.map(renderCheck).join("\n") + `
|
|
1565
|
-
[${apexResolves ? "OK" : "MISSING"}] APEX ${apex} \u2014 ` + (apexResolves ? "resolves." : `does not resolve yet
|
|
1561
|
+
[${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
1562
|
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
1563
|
return { checks, apexResolves, allOk, checklist, layers };
|
|
1568
1564
|
}
|
|
@@ -1725,6 +1721,20 @@ ${block}`, checklist: toDnsChecklist(diag.checks) };
|
|
|
1725
1721
|
};
|
|
1726
1722
|
}
|
|
1727
1723
|
}
|
|
1724
|
+
function projectRootAbove(projectDir) {
|
|
1725
|
+
if (existsSync3(join4(projectDir, "package.json"))) return null;
|
|
1726
|
+
return findAncestor(projectDir, (dir) => existsSync3(join4(dir, "package.json")), 4);
|
|
1727
|
+
}
|
|
1728
|
+
function findNeighborBinding(projectDir, outputRel) {
|
|
1729
|
+
const bound = (dir) => loadSiteFile(dir).kind !== "absent";
|
|
1730
|
+
const above = findAncestor(projectDir, bound, 3);
|
|
1731
|
+
if (above) return above;
|
|
1732
|
+
if (outputRel && outputRel !== ".") {
|
|
1733
|
+
const outputAbs = resolve3(projectDir, outputRel);
|
|
1734
|
+
if (bound(outputAbs)) return outputAbs;
|
|
1735
|
+
}
|
|
1736
|
+
return null;
|
|
1737
|
+
}
|
|
1728
1738
|
function freeSiteCreationBarrier() {
|
|
1729
1739
|
const recent = listRecentCreations(Date.now());
|
|
1730
1740
|
if (recent.length < FREE_ACTIVE_SITES_PER_IP) return null;
|
|
@@ -1785,6 +1795,9 @@ Next action: ${analysis.suggestedNextAction}`,
|
|
|
1785
1795
|
publicConfirmed: z3.boolean().optional().describe(
|
|
1786
1796
|
"Required only for the first deployment: user explicitly confirmed creation of a public 24-hour URL."
|
|
1787
1797
|
),
|
|
1798
|
+
subprojectConfirmed: z3.boolean().optional().describe(
|
|
1799
|
+
"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."
|
|
1800
|
+
),
|
|
1788
1801
|
lang: z3.string().optional().describe("Site language override (en | ja | zh-CN); defaults to the html lang.")
|
|
1789
1802
|
}
|
|
1790
1803
|
},
|
|
@@ -1813,16 +1826,34 @@ Next action: ${analysis.suggestedNextAction}`,
|
|
|
1813
1826
|
}
|
|
1814
1827
|
const existing = siteFileState.kind === "ok" ? siteFileState.file : null;
|
|
1815
1828
|
if (!existing) {
|
|
1829
|
+
const rootAbove = args.subprojectConfirmed === true ? null : projectRootAbove(ctx.projectDir);
|
|
1830
|
+
if (rootAbove) {
|
|
1831
|
+
return text(
|
|
1832
|
+
"not_project_root",
|
|
1833
|
+
`${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.`,
|
|
1834
|
+
{ projectRoot: rootAbove, confirmationField: "subprojectConfirmed" },
|
|
1835
|
+
"blocked"
|
|
1836
|
+
);
|
|
1837
|
+
}
|
|
1838
|
+
const neighbor = findNeighborBinding(ctx.projectDir, analysis.recommendedOutputDir);
|
|
1839
|
+
if (neighbor) {
|
|
1840
|
+
return text(
|
|
1841
|
+
"neighbor_binding_found",
|
|
1842
|
+
`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.`,
|
|
1843
|
+
{ neighborProjectDir: neighbor },
|
|
1844
|
+
"blocked"
|
|
1845
|
+
);
|
|
1846
|
+
}
|
|
1816
1847
|
const barrier = freeSiteCreationBarrier();
|
|
1817
1848
|
if (barrier) return barrier;
|
|
1818
|
-
|
|
1819
|
-
|
|
1820
|
-
|
|
1821
|
-
|
|
1822
|
-
|
|
1823
|
-
|
|
1824
|
-
|
|
1825
|
-
|
|
1849
|
+
if (args.publicConfirmed !== true) {
|
|
1850
|
+
return text(
|
|
1851
|
+
"public_deployment_confirmation_required",
|
|
1852
|
+
`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.`,
|
|
1853
|
+
{ publicUrlLifetimeHours: FREE_SITE_TTL_HOURS, confirmationField: "publicConfirmed" },
|
|
1854
|
+
"waiting_user"
|
|
1855
|
+
);
|
|
1856
|
+
}
|
|
1826
1857
|
}
|
|
1827
1858
|
ensureUploadSizeWithinLimits(manifest, !existing);
|
|
1828
1859
|
if (!existing) {
|
|
@@ -2046,7 +2077,7 @@ Once payment confirms, the site becomes permanent on its current URL. Binding a
|
|
|
2046
2077
|
server.registerTool(
|
|
2047
2078
|
"bind_domain",
|
|
2048
2079
|
{
|
|
2049
|
-
description: `Bind a custom domain to this subscribed site \u2014 an OPTIONAL extra serving surface; the permanent ${previewHostPattern} URL keeps working alongside it. The binding unit is the APEX domain: binding example.com automatically includes www.example.com (both serve the same content, one apex TXT verification covers both), and one site binds at most ONE apex domain \u2014 a second domain needs a second subscribed site. Requires an ACTIVE subscription (subscribe_site). Ownership is proven ONLY by DNS control of the apex \u2014 payment never grants ownership
|
|
2080
|
+
description: `Bind a custom domain to this subscribed site \u2014 an OPTIONAL extra serving surface; the permanent ${previewHostPattern} URL keeps working alongside it. The binding unit is the APEX domain: binding example.com automatically includes www.example.com (both serve the same content, one apex TXT verification covers both), and one site binds at most ONE apex domain \u2014 a second domain needs a second subscribed site. Requires an ACTIVE subscription (subscribe_site). Ownership is proven ONLY by DNS control of the apex \u2014 payment never grants ownership, and bindings are ALWAYS challengeable: whoever proves CURRENT DNS control takes the domain, even from an existing binding (the displaced site keeps its subscription, content and permanent URL). Unverified requests expire after 72 hours. Call again with action "status" to check progress.`,
|
|
2050
2081
|
outputSchema: STRUCTURED_TOOL_OUTPUT_SCHEMA,
|
|
2051
2082
|
annotations: { readOnlyHint: false, destructiveHint: true, openWorldHint: true },
|
|
2052
2083
|
inputSchema: {
|
|
@@ -2115,17 +2146,19 @@ ${res2.message}
|
|
|
2115
2146
|
"domain_verification_started",
|
|
2116
2147
|
`Domain binding started for ${apex} (includes: ${res.includedHostnames.join(", ")} \u2014 both will serve this site).
|
|
2117
2148
|
|
|
2118
|
-
|
|
2149
|
+
STEP 1 of 2 \u2014 add ALL THREE records NOW:
|
|
2119
2150
|
|
|
2120
2151
|
1) TXT host: ${txtShort} value: ${res.verificationRecord.value}
|
|
2121
2152
|
2) CNAME host: www value: ${res.servingTarget}
|
|
2122
|
-
3) APEX host: @ -> ${res.servingTarget}
|
|
2153
|
+
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.
|
|
2123
2154
|
|
|
2124
2155
|
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.
|
|
2125
2156
|
|
|
2126
2157
|
Ownership comes ONLY from DNS control; paying never grants it. The first verified request wins and this challenge expires after 72 hours.
|
|
2127
2158
|
|
|
2128
|
-
|
|
2159
|
+
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.
|
|
2160
|
+
|
|
2161
|
+
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.`,
|
|
2129
2162
|
{
|
|
2130
2163
|
verificationId: res.verificationId,
|
|
2131
2164
|
apexDomain: apex,
|
|
@@ -2435,9 +2468,10 @@ ${res.archiveUrl}`,
|
|
|
2435
2468
|
...args.contactEmail !== void 0 ? { contactEmail: args.contactEmail } : {}
|
|
2436
2469
|
};
|
|
2437
2470
|
if (args.confirmSubmit !== true) {
|
|
2471
|
+
const contactNote = args.contactEmail !== void 0 ? "note that their contact email is attached for follow-up. " : "ASK THEM ONCE whether they want to attach a contact email for follow-up (optional \u2014 omit if declined; include it as contactEmail when they do). ";
|
|
2438
2472
|
return textJson(
|
|
2439
2473
|
"bug_report_preview_ready",
|
|
2440
|
-
|
|
2474
|
+
`Bug report prepared but NOT submitted. This is the exact payload that would be sent (structured diagnostics only \u2014 no file contents, source code or secrets). Show it to the user, and ${contactNote}Then re-run report_bug with confirmSubmit: true to submit.`,
|
|
2441
2475
|
payload,
|
|
2442
2476
|
"preview"
|
|
2443
2477
|
);
|
|
@@ -2704,13 +2738,18 @@ Workflow:
|
|
|
2704
2738
|
|
|
2705
2739
|
Project directory contract: ONE directory = ONE site (its .sakupa/site.json holds the
|
|
2706
2740
|
binding). Every project-scoped tool accepts projectDir \u2014 ALWAYS pass the absolute path of
|
|
2707
|
-
the
|
|
2708
|
-
|
|
2709
|
-
|
|
2741
|
+
the user's PROJECT ROOT, the SAME directory every time for the same project: the folder
|
|
2742
|
+
the user opened (for framework projects, where package.json lives \u2014 never the dist/out
|
|
2743
|
+
build folder; output is auto-detected). Without it the server falls back to its startup
|
|
2744
|
+
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
|
|
2710
2745
|
Explicit Environment line: TEST vs PRODUCTION). analyze_site, deploy_site, site_status,
|
|
2711
2746
|
refresh_site, delete_site and unbind_domain echo
|
|
2712
2747
|
the directory they acted on \u2014 verify it matches the user's active project.
|
|
2713
2748
|
|
|
2749
|
+
When the same operation fails twice in a row, or the user is clearly stuck or
|
|
2750
|
+
frustrated, proactively offer report_bug: it files the problem into Sakupa's ticket and
|
|
2751
|
+
alert stream, and you should attach your own factual account via agentContext.
|
|
2752
|
+
|
|
2714
2753
|
Safety boundaries:
|
|
2715
2754
|
- Static output only: no SSR, API routes, middleware, server actions, databases or online builds.
|
|
2716
2755
|
- Never upload source projects, secrets, .env files, private keys, archives, videos or audio.
|