@sakupa/mcp 0.7.8 → 0.7.9
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 +187 -30
- package/dist/index.js +187 -30
- 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.9";
|
|
133
133
|
|
|
134
134
|
// ../core/dist/domain/errors.js
|
|
135
135
|
var HTTP_STATUS = {
|
|
@@ -458,10 +458,10 @@ var WEBHOOK_PROCESSING_LEASE_MS = 5 * 60 * 1e3;
|
|
|
458
458
|
// src/config.ts
|
|
459
459
|
var TEST_API_BASE_URL = "https://api-test.sakupa.com";
|
|
460
460
|
function previewHostPatternFor(apiBaseUrl) {
|
|
461
|
-
return apiBaseUrl ===
|
|
461
|
+
return environmentFor(apiBaseUrl) === "test" ? "{shortId}-test.sakupa.com" : "{shortId}.sakupa.com";
|
|
462
462
|
}
|
|
463
463
|
function loadMcpRuntimeConfig(env = process.env, cwd = process.cwd()) {
|
|
464
|
-
const apiBaseUrl = (env["SAKUPA_API_URL"] ?? DEFAULT_API_BASE_URL).replace(/\/+$/, "");
|
|
464
|
+
const apiBaseUrl = (env["SAKUPA_API_URL"] ?? env["SAKUPA_API_BASE_URL"] ?? DEFAULT_API_BASE_URL).replace(/\/+$/, "");
|
|
465
465
|
const projectDir = env["SAKUPA_PROJECT_DIR"] ?? cwd;
|
|
466
466
|
const testAccessToken = env["SAKUPA_TEST_ACCESS_TOKEN"]?.trim() ?? "";
|
|
467
467
|
if (apiBaseUrl === TEST_API_BASE_URL) {
|
|
@@ -479,6 +479,9 @@ function loadMcpRuntimeConfig(env = process.env, cwd = process.cwd()) {
|
|
|
479
479
|
}
|
|
480
480
|
return { apiBaseUrl, projectDir };
|
|
481
481
|
}
|
|
482
|
+
function environmentFor(apiBaseUrl) {
|
|
483
|
+
return apiBaseUrl === TEST_API_BASE_URL ? "test" : "production";
|
|
484
|
+
}
|
|
482
485
|
|
|
483
486
|
// src/server.ts
|
|
484
487
|
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
@@ -591,11 +594,14 @@ var HttpApiClient = class {
|
|
|
591
594
|
async bindDomain(credential, req) {
|
|
592
595
|
return this.call("POST", "/v1/domains/bind", { credential, body: req });
|
|
593
596
|
}
|
|
594
|
-
async checkVerification(verificationId, credential) {
|
|
597
|
+
async checkVerification(verificationId, credential, latestForSiteId) {
|
|
595
598
|
return this.call(
|
|
596
599
|
"POST",
|
|
597
600
|
`/v1/domains/verifications/${encodeURIComponent(verificationId)}/check`,
|
|
598
|
-
{
|
|
601
|
+
{
|
|
602
|
+
credential,
|
|
603
|
+
...latestForSiteId !== void 0 ? { body: { siteId: latestForSiteId } } : {}
|
|
604
|
+
}
|
|
599
605
|
);
|
|
600
606
|
}
|
|
601
607
|
async unbindDomain(siteId, credential, req) {
|
|
@@ -1248,6 +1254,111 @@ function removeCreation(siteId) {
|
|
|
1248
1254
|
if (rest.length !== all.length) writeAll(rest);
|
|
1249
1255
|
}
|
|
1250
1256
|
|
|
1257
|
+
// src/dns-doh.ts
|
|
1258
|
+
var dohFetch = (input, init) => fetch(input, init);
|
|
1259
|
+
var TYPE_CODES = { TXT: 16, CNAME: 5, A: 1 };
|
|
1260
|
+
async function resolveDns(name, type) {
|
|
1261
|
+
const endpoints = [
|
|
1262
|
+
`https://cloudflare-dns.com/dns-query?name=${encodeURIComponent(name)}&type=${type}`,
|
|
1263
|
+
`https://dns.google/resolve?name=${encodeURIComponent(name)}&type=${type}`
|
|
1264
|
+
];
|
|
1265
|
+
for (const url of endpoints) {
|
|
1266
|
+
try {
|
|
1267
|
+
const res = await dohFetch(url, { headers: { accept: "application/dns-json" } });
|
|
1268
|
+
if (!res.ok) continue;
|
|
1269
|
+
const body = await res.json();
|
|
1270
|
+
return (body.Answer ?? []).filter((a) => a.type === TYPE_CODES[type]).map((a) => a.data.replace(/^"|"$/g, "").replace(/"\s+"/g, "")).map((v) => type === "CNAME" ? v.replace(/\.$/, "").toLowerCase() : v);
|
|
1271
|
+
} catch {
|
|
1272
|
+
}
|
|
1273
|
+
}
|
|
1274
|
+
return [];
|
|
1275
|
+
}
|
|
1276
|
+
function shortHostFor(fullName, apexDomain) {
|
|
1277
|
+
const suffix = `.${apexDomain}`;
|
|
1278
|
+
if (fullName === apexDomain) return "@";
|
|
1279
|
+
return fullName.endsWith(suffix) ? fullName.slice(0, -suffix.length) : fullName;
|
|
1280
|
+
}
|
|
1281
|
+
function matches(record, values) {
|
|
1282
|
+
if (record.type === "CNAME") {
|
|
1283
|
+
const want = record.value.replace(/\.$/, "").toLowerCase();
|
|
1284
|
+
return values.some((v) => v === want);
|
|
1285
|
+
}
|
|
1286
|
+
return values.includes(record.value);
|
|
1287
|
+
}
|
|
1288
|
+
async function checkRecord(record, apexDomain) {
|
|
1289
|
+
const shortHost = shortHostFor(record.name, apexDomain);
|
|
1290
|
+
const found = await resolveDns(record.name, record.type);
|
|
1291
|
+
if (matches(record, found)) {
|
|
1292
|
+
return { record, shortHost, state: "ok", found, fix: "" };
|
|
1293
|
+
}
|
|
1294
|
+
const doubled = await resolveDns(`${record.name}.${apexDomain}`, record.type);
|
|
1295
|
+
if (matches(record, doubled)) {
|
|
1296
|
+
return {
|
|
1297
|
+
record,
|
|
1298
|
+
shortHost,
|
|
1299
|
+
state: "double_domain",
|
|
1300
|
+
found: doubled,
|
|
1301
|
+
fix: `The record exists at ${record.name}.${apexDomain} \u2014 the host field was filled with the full name and your DNS panel appended ${apexDomain} again. Edit that record's host to exactly: ${shortHost}`
|
|
1302
|
+
};
|
|
1303
|
+
}
|
|
1304
|
+
if (found.length > 0) {
|
|
1305
|
+
return {
|
|
1306
|
+
record,
|
|
1307
|
+
shortHost,
|
|
1308
|
+
state: "wrong_value",
|
|
1309
|
+
found,
|
|
1310
|
+
fix: `A ${record.type} record exists at ${record.name} but its value is ${JSON.stringify(found)} instead of "${record.value}". Update the value exactly.`
|
|
1311
|
+
};
|
|
1312
|
+
}
|
|
1313
|
+
return {
|
|
1314
|
+
record,
|
|
1315
|
+
shortHost,
|
|
1316
|
+
state: "missing",
|
|
1317
|
+
found,
|
|
1318
|
+
fix: `Create it now \u2014 type: ${record.type}, host: ${shortHost} (most panels append .${apexDomain} automatically; if yours wants the full name use ${record.name}), value: ${record.value}`
|
|
1319
|
+
};
|
|
1320
|
+
}
|
|
1321
|
+
function renderCheck(c) {
|
|
1322
|
+
const label = `${c.record.type} ${c.shortHost}`;
|
|
1323
|
+
switch (c.state) {
|
|
1324
|
+
case "ok":
|
|
1325
|
+
return ` [OK] ${label} \u2014 live on public DNS.`;
|
|
1326
|
+
case "double_domain":
|
|
1327
|
+
return ` [FIX] ${label} \u2014 ${c.fix}`;
|
|
1328
|
+
case "wrong_value":
|
|
1329
|
+
return ` [FIX] ${label} \u2014 ${c.fix}`;
|
|
1330
|
+
case "missing":
|
|
1331
|
+
return ` [MISSING] ${label} \u2014 ${c.fix}`;
|
|
1332
|
+
}
|
|
1333
|
+
}
|
|
1334
|
+
async function diagnoseBinding(input) {
|
|
1335
|
+
const apex = input.apexDomain;
|
|
1336
|
+
const byKey = /* @__PURE__ */ new Map();
|
|
1337
|
+
if (input.verificationRecord) {
|
|
1338
|
+
byKey.set(`TXT:${input.verificationRecord.name}`, input.verificationRecord);
|
|
1339
|
+
}
|
|
1340
|
+
const www = {
|
|
1341
|
+
name: `www.${apex}`,
|
|
1342
|
+
type: "CNAME",
|
|
1343
|
+
value: input.servingTarget
|
|
1344
|
+
};
|
|
1345
|
+
byKey.set(`CNAME:${www.name}`, www);
|
|
1346
|
+
for (const rec of input.pendingDnsRecords) {
|
|
1347
|
+
const key = `${rec.type}:${rec.name}`;
|
|
1348
|
+
if (!byKey.has(key)) byKey.set(key, rec);
|
|
1349
|
+
}
|
|
1350
|
+
const [checks, apexAnswers] = await Promise.all([
|
|
1351
|
+
Promise.all([...byKey.values()].map((rec) => checkRecord(rec, apex))),
|
|
1352
|
+
resolveDns(apex, "A")
|
|
1353
|
+
]);
|
|
1354
|
+
const apexResolves = apexAnswers.length > 0;
|
|
1355
|
+
const allOk = checks.every((c) => c.state === "ok");
|
|
1356
|
+
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).`);
|
|
1358
|
+
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
|
+
return { checks, apexResolves, allOk, checklist, layers };
|
|
1360
|
+
}
|
|
1361
|
+
|
|
1251
1362
|
// src/version.ts
|
|
1252
1363
|
var MCP_VERSION = SAKUPA_MCP_VERSION;
|
|
1253
1364
|
var CLIENT_TYPE = "sakupa-mcp";
|
|
@@ -1498,6 +1609,8 @@ async function uploadAll(ctx, targets, files, outputAbs) {
|
|
|
1498
1609
|
}
|
|
1499
1610
|
return targets.length;
|
|
1500
1611
|
}
|
|
1612
|
+
var DNS_RETRY_AFTER_SECONDS = 300;
|
|
1613
|
+
var DNS_MAX_ATTEMPTS = 10;
|
|
1501
1614
|
function freeSiteCreationBarrier() {
|
|
1502
1615
|
const recent = listRecentCreations(Date.now());
|
|
1503
1616
|
if (recent.length < FREE_ACTIVE_SITES_PER_IP) return null;
|
|
@@ -1628,6 +1741,7 @@ Next action: ${analysis.suggestedNextAction}`,
|
|
|
1628
1741
|
return text(
|
|
1629
1742
|
"site_published",
|
|
1630
1743
|
`Site published: ${finalized2.url}
|
|
1744
|
+
Environment: ${environmentFor(ctx.apiBaseUrl).toUpperCase()} (${ctx.apiBaseUrl})
|
|
1631
1745
|
Project directory: ${ctx.projectDir}
|
|
1632
1746
|
Files uploaded: ${uploaded2} (${finalized2.totalBytes} bytes)
|
|
1633
1747
|
` + (finalized2.expiresAt ? `Expires at: ${finalized2.expiresAt}
|
|
@@ -1647,7 +1761,8 @@ ${JSON.stringify(finalized2.warnings, null, 2)}` : ""),
|
|
|
1647
1761
|
totalBytes: finalized2.totalBytes,
|
|
1648
1762
|
warnings: finalized2.warnings,
|
|
1649
1763
|
credentialStoredLocally: true,
|
|
1650
|
-
projectDir: ctx.projectDir
|
|
1764
|
+
projectDir: ctx.projectDir,
|
|
1765
|
+
environment: environmentFor(ctx.apiBaseUrl)
|
|
1651
1766
|
}
|
|
1652
1767
|
);
|
|
1653
1768
|
}
|
|
@@ -1693,6 +1808,7 @@ ${JSON.stringify(finalized2.warnings, null, 2)}` : ""),
|
|
|
1693
1808
|
return text(
|
|
1694
1809
|
"site_updated",
|
|
1695
1810
|
`Site updated: ${finalized.url}
|
|
1811
|
+
Environment: ${environmentFor(ctx.apiBaseUrl).toUpperCase()} (${ctx.apiBaseUrl})
|
|
1696
1812
|
Project directory: ${ctx.projectDir}
|
|
1697
1813
|
Files uploaded: ${uploaded} (${finalized.totalBytes} bytes)
|
|
1698
1814
|
` + (finalized.expiresAt ? `Validity refreshed \u2014 expires at: ${finalized.expiresAt}
|
|
@@ -1706,6 +1822,7 @@ ${JSON.stringify(finalized.warnings, null, 2)}` : ""),
|
|
|
1706
1822
|
url: finalized.url,
|
|
1707
1823
|
mode: finalized.mode,
|
|
1708
1824
|
projectDir: ctx.projectDir,
|
|
1825
|
+
environment: environmentFor(ctx.apiBaseUrl),
|
|
1709
1826
|
expiresAt: finalized.expiresAt,
|
|
1710
1827
|
filesUploaded: uploaded,
|
|
1711
1828
|
totalBytes: finalized.totalBytes,
|
|
@@ -1820,7 +1937,9 @@ Once payment confirms, the site becomes permanent on its current URL. Binding a
|
|
|
1820
1937
|
projectDir: projectDirInput,
|
|
1821
1938
|
action: z3.enum(["start", "status"]),
|
|
1822
1939
|
hostname: z3.string().optional().describe("Required for start."),
|
|
1823
|
-
verificationId: z3.string().optional().describe(
|
|
1940
|
+
verificationId: z3.string().optional().describe(
|
|
1941
|
+
"Optional for status: when omitted, the server finds this site's latest binding verification \u2014 a NEW session can resume without it."
|
|
1942
|
+
)
|
|
1824
1943
|
}
|
|
1825
1944
|
},
|
|
1826
1945
|
async (args) => {
|
|
@@ -1828,30 +1947,49 @@ Once payment confirms, the site becomes permanent on its current URL. Binding a
|
|
|
1828
1947
|
const ctx = withProjectDir(baseCtx, args.projectDir);
|
|
1829
1948
|
const site = requireSiteFile(ctx);
|
|
1830
1949
|
if (args.action === "status") {
|
|
1831
|
-
|
|
1832
|
-
throw new SakupaError("invalid_request", "verificationId is required for status");
|
|
1833
|
-
}
|
|
1834
|
-
const res2 = await ctx.client.checkVerification(args.verificationId, site.credential);
|
|
1950
|
+
const res2 = args.verificationId ? await ctx.client.checkVerification(args.verificationId, site.credential) : await ctx.client.checkVerification("latest", site.credential, site.siteId);
|
|
1835
1951
|
if (res2.status === "verified") {
|
|
1836
1952
|
writeSiteFile(ctx.projectDir, { ...site, boundDomain: res2.apexDomain });
|
|
1837
1953
|
}
|
|
1954
|
+
const apex2 = res2.apexDomain;
|
|
1955
|
+
const { checks, apexResolves, allOk, checklist, layers } = await diagnoseBinding({
|
|
1956
|
+
apexDomain: apex2,
|
|
1957
|
+
servingTarget: res2.servingTarget,
|
|
1958
|
+
...res2.verificationRecord ? { verificationRecord: res2.verificationRecord } : {},
|
|
1959
|
+
pendingDnsRecords: res2.pendingDnsRecords,
|
|
1960
|
+
verificationStatus: res2.status,
|
|
1961
|
+
provisioning: res2.provisioningJobId !== void 0
|
|
1962
|
+
});
|
|
1838
1963
|
return text(
|
|
1839
1964
|
res2.status === "verified" ? "domain_verification_succeeded" : "domain_verification_pending",
|
|
1840
|
-
`
|
|
1965
|
+
`Domain binding status for ${apex2}: ${res2.status}
|
|
1841
1966
|
${res2.message}
|
|
1842
|
-
|
|
1843
|
-
|
|
1844
|
-
|
|
1845
|
-
|
|
1967
|
+
|
|
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."),
|
|
1846
1974
|
{
|
|
1847
1975
|
verificationId: res2.verificationId,
|
|
1848
1976
|
status: res2.status,
|
|
1849
|
-
apexDomain:
|
|
1977
|
+
apexDomain: apex2,
|
|
1850
1978
|
provisioningJobId: res2.provisioningJobId,
|
|
1851
|
-
|
|
1979
|
+
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
|
+
})),
|
|
1987
|
+
apexResolves,
|
|
1988
|
+
retryAfterSeconds: DNS_RETRY_AFTER_SECONDS,
|
|
1989
|
+
maxAttempts: DNS_MAX_ATTEMPTS,
|
|
1852
1990
|
message: res2.message
|
|
1853
1991
|
},
|
|
1854
|
-
res2.status === "verified" ? "
|
|
1992
|
+
res2.status === "verified" ? "pending_provider" : "waiting_user"
|
|
1855
1993
|
);
|
|
1856
1994
|
}
|
|
1857
1995
|
if (!args.hostname) {
|
|
@@ -1862,25 +2000,42 @@ ${JSON.stringify(res2.pendingDnsRecords, null, 2)}` : ""),
|
|
|
1862
2000
|
hostname: args.hostname
|
|
1863
2001
|
};
|
|
1864
2002
|
const res = await ctx.client.bindDomain(site.credential, req);
|
|
2003
|
+
const apex = res.apexDomain;
|
|
2004
|
+
const txtShort = shortHostFor(res.verificationRecord.name, apex);
|
|
1865
2005
|
return text(
|
|
1866
2006
|
"domain_verification_started",
|
|
1867
|
-
`Domain binding started for ${
|
|
2007
|
+
`Domain binding started for ${apex} (includes: ${res.includedHostnames.join(", ")} \u2014 both will serve this site).
|
|
2008
|
+
|
|
2009
|
+
Add ALL THREE DNS records NOW (adding them together lets verification, certificate issuance and serving complete without further record changes):
|
|
1868
2010
|
|
|
1869
|
-
1
|
|
1870
|
-
|
|
1871
|
-
|
|
1872
|
-
value: ${res.verificationRecord.value}
|
|
1873
|
-
Ownership comes ONLY from DNS control; paying never grants it. This request does not reserve the domain \u2014 the first verified request wins, and this challenge expires after 72 hours.
|
|
2011
|
+
1) TXT host: ${txtShort} value: ${res.verificationRecord.value}
|
|
2012
|
+
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).
|
|
1874
2014
|
|
|
1875
|
-
|
|
2015
|
+
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.
|
|
1876
2016
|
|
|
1877
|
-
|
|
2017
|
+
Ownership comes ONLY from DNS control; paying never grants it. The first verified request wins and this challenge expires after 72 hours.
|
|
2018
|
+
|
|
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.`,
|
|
1878
2020
|
{
|
|
1879
2021
|
verificationId: res.verificationId,
|
|
1880
|
-
apexDomain:
|
|
2022
|
+
apexDomain: apex,
|
|
1881
2023
|
includedHostnames: res.includedHostnames,
|
|
1882
2024
|
verificationRecord: res.verificationRecord,
|
|
1883
|
-
|
|
2025
|
+
servingTarget: res.servingTarget,
|
|
2026
|
+
requiredRecords: [
|
|
2027
|
+
{
|
|
2028
|
+
type: "TXT",
|
|
2029
|
+
shortHost: txtShort,
|
|
2030
|
+
name: res.verificationRecord.name,
|
|
2031
|
+
value: res.verificationRecord.value
|
|
2032
|
+
},
|
|
2033
|
+
{ type: "CNAME", shortHost: "www", name: `www.${apex}`, value: res.servingTarget },
|
|
2034
|
+
{ type: "ALIAS", shortHost: "@", name: apex, value: res.servingTarget }
|
|
2035
|
+
],
|
|
2036
|
+
servingInstructions: res.servingInstructions,
|
|
2037
|
+
retryAfterSeconds: DNS_RETRY_AFTER_SECONDS,
|
|
2038
|
+
maxAttempts: DNS_MAX_ATTEMPTS
|
|
1884
2039
|
},
|
|
1885
2040
|
"waiting_user"
|
|
1886
2041
|
);
|
|
@@ -2503,7 +2658,9 @@ Project directory contract: ONE directory = ONE site (its .sakupa/site.json hold
|
|
|
2503
2658
|
binding). Every project-scoped tool accepts projectDir \u2014 ALWAYS pass the absolute path of
|
|
2504
2659
|
the directory the user is currently working in, on every call. Without it the server falls
|
|
2505
2660
|
back to its startup directory, which may be a different project than the one the user is
|
|
2506
|
-
looking at.
|
|
2661
|
+
looking at. After every deploy, TELL the user which environment it went to (deploy results carry an
|
|
2662
|
+
Explicit Environment line: TEST vs PRODUCTION). analyze_site, deploy_site, site_status,
|
|
2663
|
+
refresh_site, delete_site and unbind_domain echo
|
|
2507
2664
|
the directory they acted on \u2014 verify it matches the user's active project.
|
|
2508
2665
|
|
|
2509
2666
|
Safety boundaries:
|
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.9";
|
|
128
128
|
|
|
129
129
|
// ../core/dist/domain/errors.js
|
|
130
130
|
var HTTP_STATUS = {
|
|
@@ -457,10 +457,10 @@ var CLIENT_TYPE = "sakupa-mcp";
|
|
|
457
457
|
// src/config.ts
|
|
458
458
|
var TEST_API_BASE_URL = "https://api-test.sakupa.com";
|
|
459
459
|
function previewHostPatternFor(apiBaseUrl) {
|
|
460
|
-
return apiBaseUrl ===
|
|
460
|
+
return environmentFor(apiBaseUrl) === "test" ? "{shortId}-test.sakupa.com" : "{shortId}.sakupa.com";
|
|
461
461
|
}
|
|
462
462
|
function loadMcpRuntimeConfig(env = process.env, cwd = process.cwd()) {
|
|
463
|
-
const apiBaseUrl = (env["SAKUPA_API_URL"] ?? DEFAULT_API_BASE_URL).replace(/\/+$/, "");
|
|
463
|
+
const apiBaseUrl = (env["SAKUPA_API_URL"] ?? env["SAKUPA_API_BASE_URL"] ?? DEFAULT_API_BASE_URL).replace(/\/+$/, "");
|
|
464
464
|
const projectDir = env["SAKUPA_PROJECT_DIR"] ?? cwd;
|
|
465
465
|
const testAccessToken = env["SAKUPA_TEST_ACCESS_TOKEN"]?.trim() ?? "";
|
|
466
466
|
if (apiBaseUrl === TEST_API_BASE_URL) {
|
|
@@ -478,6 +478,9 @@ function loadMcpRuntimeConfig(env = process.env, cwd = process.cwd()) {
|
|
|
478
478
|
}
|
|
479
479
|
return { apiBaseUrl, projectDir };
|
|
480
480
|
}
|
|
481
|
+
function environmentFor(apiBaseUrl) {
|
|
482
|
+
return apiBaseUrl === TEST_API_BASE_URL ? "test" : "production";
|
|
483
|
+
}
|
|
481
484
|
|
|
482
485
|
// src/transport.ts
|
|
483
486
|
var FetchTransport = class {
|
|
@@ -659,11 +662,14 @@ var HttpApiClient = class {
|
|
|
659
662
|
async bindDomain(credential, req) {
|
|
660
663
|
return this.call("POST", "/v1/domains/bind", { credential, body: req });
|
|
661
664
|
}
|
|
662
|
-
async checkVerification(verificationId, credential) {
|
|
665
|
+
async checkVerification(verificationId, credential, latestForSiteId) {
|
|
663
666
|
return this.call(
|
|
664
667
|
"POST",
|
|
665
668
|
`/v1/domains/verifications/${encodeURIComponent(verificationId)}/check`,
|
|
666
|
-
{
|
|
669
|
+
{
|
|
670
|
+
credential,
|
|
671
|
+
...latestForSiteId !== void 0 ? { body: { siteId: latestForSiteId } } : {}
|
|
672
|
+
}
|
|
667
673
|
);
|
|
668
674
|
}
|
|
669
675
|
async unbindDomain(siteId, credential, req) {
|
|
@@ -1456,6 +1462,111 @@ function removeCreation(siteId) {
|
|
|
1456
1462
|
if (rest.length !== all.length) writeAll(rest);
|
|
1457
1463
|
}
|
|
1458
1464
|
|
|
1465
|
+
// src/dns-doh.ts
|
|
1466
|
+
var dohFetch = (input, init) => fetch(input, init);
|
|
1467
|
+
var TYPE_CODES = { TXT: 16, CNAME: 5, A: 1 };
|
|
1468
|
+
async function resolveDns(name, type) {
|
|
1469
|
+
const endpoints = [
|
|
1470
|
+
`https://cloudflare-dns.com/dns-query?name=${encodeURIComponent(name)}&type=${type}`,
|
|
1471
|
+
`https://dns.google/resolve?name=${encodeURIComponent(name)}&type=${type}`
|
|
1472
|
+
];
|
|
1473
|
+
for (const url of endpoints) {
|
|
1474
|
+
try {
|
|
1475
|
+
const res = await dohFetch(url, { headers: { accept: "application/dns-json" } });
|
|
1476
|
+
if (!res.ok) continue;
|
|
1477
|
+
const body = await res.json();
|
|
1478
|
+
return (body.Answer ?? []).filter((a) => a.type === TYPE_CODES[type]).map((a) => a.data.replace(/^"|"$/g, "").replace(/"\s+"/g, "")).map((v) => type === "CNAME" ? v.replace(/\.$/, "").toLowerCase() : v);
|
|
1479
|
+
} catch {
|
|
1480
|
+
}
|
|
1481
|
+
}
|
|
1482
|
+
return [];
|
|
1483
|
+
}
|
|
1484
|
+
function shortHostFor(fullName, apexDomain) {
|
|
1485
|
+
const suffix = `.${apexDomain}`;
|
|
1486
|
+
if (fullName === apexDomain) return "@";
|
|
1487
|
+
return fullName.endsWith(suffix) ? fullName.slice(0, -suffix.length) : fullName;
|
|
1488
|
+
}
|
|
1489
|
+
function matches(record, values) {
|
|
1490
|
+
if (record.type === "CNAME") {
|
|
1491
|
+
const want = record.value.replace(/\.$/, "").toLowerCase();
|
|
1492
|
+
return values.some((v) => v === want);
|
|
1493
|
+
}
|
|
1494
|
+
return values.includes(record.value);
|
|
1495
|
+
}
|
|
1496
|
+
async function checkRecord(record, apexDomain) {
|
|
1497
|
+
const shortHost = shortHostFor(record.name, apexDomain);
|
|
1498
|
+
const found = await resolveDns(record.name, record.type);
|
|
1499
|
+
if (matches(record, found)) {
|
|
1500
|
+
return { record, shortHost, state: "ok", found, fix: "" };
|
|
1501
|
+
}
|
|
1502
|
+
const doubled = await resolveDns(`${record.name}.${apexDomain}`, record.type);
|
|
1503
|
+
if (matches(record, doubled)) {
|
|
1504
|
+
return {
|
|
1505
|
+
record,
|
|
1506
|
+
shortHost,
|
|
1507
|
+
state: "double_domain",
|
|
1508
|
+
found: doubled,
|
|
1509
|
+
fix: `The record exists at ${record.name}.${apexDomain} \u2014 the host field was filled with the full name and your DNS panel appended ${apexDomain} again. Edit that record's host to exactly: ${shortHost}`
|
|
1510
|
+
};
|
|
1511
|
+
}
|
|
1512
|
+
if (found.length > 0) {
|
|
1513
|
+
return {
|
|
1514
|
+
record,
|
|
1515
|
+
shortHost,
|
|
1516
|
+
state: "wrong_value",
|
|
1517
|
+
found,
|
|
1518
|
+
fix: `A ${record.type} record exists at ${record.name} but its value is ${JSON.stringify(found)} instead of "${record.value}". Update the value exactly.`
|
|
1519
|
+
};
|
|
1520
|
+
}
|
|
1521
|
+
return {
|
|
1522
|
+
record,
|
|
1523
|
+
shortHost,
|
|
1524
|
+
state: "missing",
|
|
1525
|
+
found,
|
|
1526
|
+
fix: `Create it now \u2014 type: ${record.type}, host: ${shortHost} (most panels append .${apexDomain} automatically; if yours wants the full name use ${record.name}), value: ${record.value}`
|
|
1527
|
+
};
|
|
1528
|
+
}
|
|
1529
|
+
function renderCheck(c) {
|
|
1530
|
+
const label = `${c.record.type} ${c.shortHost}`;
|
|
1531
|
+
switch (c.state) {
|
|
1532
|
+
case "ok":
|
|
1533
|
+
return ` [OK] ${label} \u2014 live on public DNS.`;
|
|
1534
|
+
case "double_domain":
|
|
1535
|
+
return ` [FIX] ${label} \u2014 ${c.fix}`;
|
|
1536
|
+
case "wrong_value":
|
|
1537
|
+
return ` [FIX] ${label} \u2014 ${c.fix}`;
|
|
1538
|
+
case "missing":
|
|
1539
|
+
return ` [MISSING] ${label} \u2014 ${c.fix}`;
|
|
1540
|
+
}
|
|
1541
|
+
}
|
|
1542
|
+
async function diagnoseBinding(input) {
|
|
1543
|
+
const apex = input.apexDomain;
|
|
1544
|
+
const byKey = /* @__PURE__ */ new Map();
|
|
1545
|
+
if (input.verificationRecord) {
|
|
1546
|
+
byKey.set(`TXT:${input.verificationRecord.name}`, input.verificationRecord);
|
|
1547
|
+
}
|
|
1548
|
+
const www = {
|
|
1549
|
+
name: `www.${apex}`,
|
|
1550
|
+
type: "CNAME",
|
|
1551
|
+
value: input.servingTarget
|
|
1552
|
+
};
|
|
1553
|
+
byKey.set(`CNAME:${www.name}`, www);
|
|
1554
|
+
for (const rec of input.pendingDnsRecords) {
|
|
1555
|
+
const key = `${rec.type}:${rec.name}`;
|
|
1556
|
+
if (!byKey.has(key)) byKey.set(key, rec);
|
|
1557
|
+
}
|
|
1558
|
+
const [checks, apexAnswers] = await Promise.all([
|
|
1559
|
+
Promise.all([...byKey.values()].map((rec) => checkRecord(rec, apex))),
|
|
1560
|
+
resolveDns(apex, "A")
|
|
1561
|
+
]);
|
|
1562
|
+
const apexResolves = apexAnswers.length > 0;
|
|
1563
|
+
const allOk = checks.every((c) => c.state === "ok");
|
|
1564
|
+
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).`);
|
|
1566
|
+
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
|
+
return { checks, apexResolves, allOk, checklist, layers };
|
|
1568
|
+
}
|
|
1569
|
+
|
|
1459
1570
|
// src/tools/definitions.ts
|
|
1460
1571
|
function text(resultCode, t, data = {}, outcome = "completed") {
|
|
1461
1572
|
return structuredToolResult({
|
|
@@ -1562,6 +1673,8 @@ async function uploadAll(ctx, targets, files, outputAbs) {
|
|
|
1562
1673
|
}
|
|
1563
1674
|
return targets.length;
|
|
1564
1675
|
}
|
|
1676
|
+
var DNS_RETRY_AFTER_SECONDS = 300;
|
|
1677
|
+
var DNS_MAX_ATTEMPTS = 10;
|
|
1565
1678
|
function freeSiteCreationBarrier() {
|
|
1566
1679
|
const recent = listRecentCreations(Date.now());
|
|
1567
1680
|
if (recent.length < FREE_ACTIVE_SITES_PER_IP) return null;
|
|
@@ -1692,6 +1805,7 @@ Next action: ${analysis.suggestedNextAction}`,
|
|
|
1692
1805
|
return text(
|
|
1693
1806
|
"site_published",
|
|
1694
1807
|
`Site published: ${finalized2.url}
|
|
1808
|
+
Environment: ${environmentFor(ctx.apiBaseUrl).toUpperCase()} (${ctx.apiBaseUrl})
|
|
1695
1809
|
Project directory: ${ctx.projectDir}
|
|
1696
1810
|
Files uploaded: ${uploaded2} (${finalized2.totalBytes} bytes)
|
|
1697
1811
|
` + (finalized2.expiresAt ? `Expires at: ${finalized2.expiresAt}
|
|
@@ -1711,7 +1825,8 @@ ${JSON.stringify(finalized2.warnings, null, 2)}` : ""),
|
|
|
1711
1825
|
totalBytes: finalized2.totalBytes,
|
|
1712
1826
|
warnings: finalized2.warnings,
|
|
1713
1827
|
credentialStoredLocally: true,
|
|
1714
|
-
projectDir: ctx.projectDir
|
|
1828
|
+
projectDir: ctx.projectDir,
|
|
1829
|
+
environment: environmentFor(ctx.apiBaseUrl)
|
|
1715
1830
|
}
|
|
1716
1831
|
);
|
|
1717
1832
|
}
|
|
@@ -1757,6 +1872,7 @@ ${JSON.stringify(finalized2.warnings, null, 2)}` : ""),
|
|
|
1757
1872
|
return text(
|
|
1758
1873
|
"site_updated",
|
|
1759
1874
|
`Site updated: ${finalized.url}
|
|
1875
|
+
Environment: ${environmentFor(ctx.apiBaseUrl).toUpperCase()} (${ctx.apiBaseUrl})
|
|
1760
1876
|
Project directory: ${ctx.projectDir}
|
|
1761
1877
|
Files uploaded: ${uploaded} (${finalized.totalBytes} bytes)
|
|
1762
1878
|
` + (finalized.expiresAt ? `Validity refreshed \u2014 expires at: ${finalized.expiresAt}
|
|
@@ -1770,6 +1886,7 @@ ${JSON.stringify(finalized.warnings, null, 2)}` : ""),
|
|
|
1770
1886
|
url: finalized.url,
|
|
1771
1887
|
mode: finalized.mode,
|
|
1772
1888
|
projectDir: ctx.projectDir,
|
|
1889
|
+
environment: environmentFor(ctx.apiBaseUrl),
|
|
1773
1890
|
expiresAt: finalized.expiresAt,
|
|
1774
1891
|
filesUploaded: uploaded,
|
|
1775
1892
|
totalBytes: finalized.totalBytes,
|
|
@@ -1884,7 +2001,9 @@ Once payment confirms, the site becomes permanent on its current URL. Binding a
|
|
|
1884
2001
|
projectDir: projectDirInput,
|
|
1885
2002
|
action: z3.enum(["start", "status"]),
|
|
1886
2003
|
hostname: z3.string().optional().describe("Required for start."),
|
|
1887
|
-
verificationId: z3.string().optional().describe(
|
|
2004
|
+
verificationId: z3.string().optional().describe(
|
|
2005
|
+
"Optional for status: when omitted, the server finds this site's latest binding verification \u2014 a NEW session can resume without it."
|
|
2006
|
+
)
|
|
1888
2007
|
}
|
|
1889
2008
|
},
|
|
1890
2009
|
async (args) => {
|
|
@@ -1892,30 +2011,49 @@ Once payment confirms, the site becomes permanent on its current URL. Binding a
|
|
|
1892
2011
|
const ctx = withProjectDir(baseCtx, args.projectDir);
|
|
1893
2012
|
const site = requireSiteFile(ctx);
|
|
1894
2013
|
if (args.action === "status") {
|
|
1895
|
-
|
|
1896
|
-
throw new SakupaError("invalid_request", "verificationId is required for status");
|
|
1897
|
-
}
|
|
1898
|
-
const res2 = await ctx.client.checkVerification(args.verificationId, site.credential);
|
|
2014
|
+
const res2 = args.verificationId ? await ctx.client.checkVerification(args.verificationId, site.credential) : await ctx.client.checkVerification("latest", site.credential, site.siteId);
|
|
1899
2015
|
if (res2.status === "verified") {
|
|
1900
2016
|
writeSiteFile(ctx.projectDir, { ...site, boundDomain: res2.apexDomain });
|
|
1901
2017
|
}
|
|
2018
|
+
const apex2 = res2.apexDomain;
|
|
2019
|
+
const { checks, apexResolves, allOk, checklist, layers } = await diagnoseBinding({
|
|
2020
|
+
apexDomain: apex2,
|
|
2021
|
+
servingTarget: res2.servingTarget,
|
|
2022
|
+
...res2.verificationRecord ? { verificationRecord: res2.verificationRecord } : {},
|
|
2023
|
+
pendingDnsRecords: res2.pendingDnsRecords,
|
|
2024
|
+
verificationStatus: res2.status,
|
|
2025
|
+
provisioning: res2.provisioningJobId !== void 0
|
|
2026
|
+
});
|
|
1902
2027
|
return text(
|
|
1903
2028
|
res2.status === "verified" ? "domain_verification_succeeded" : "domain_verification_pending",
|
|
1904
|
-
`
|
|
2029
|
+
`Domain binding status for ${apex2}: ${res2.status}
|
|
1905
2030
|
${res2.message}
|
|
1906
|
-
|
|
1907
|
-
|
|
1908
|
-
|
|
1909
|
-
|
|
2031
|
+
|
|
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."),
|
|
1910
2038
|
{
|
|
1911
2039
|
verificationId: res2.verificationId,
|
|
1912
2040
|
status: res2.status,
|
|
1913
|
-
apexDomain:
|
|
2041
|
+
apexDomain: apex2,
|
|
1914
2042
|
provisioningJobId: res2.provisioningJobId,
|
|
1915
|
-
|
|
2043
|
+
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
|
+
})),
|
|
2051
|
+
apexResolves,
|
|
2052
|
+
retryAfterSeconds: DNS_RETRY_AFTER_SECONDS,
|
|
2053
|
+
maxAttempts: DNS_MAX_ATTEMPTS,
|
|
1916
2054
|
message: res2.message
|
|
1917
2055
|
},
|
|
1918
|
-
res2.status === "verified" ? "
|
|
2056
|
+
res2.status === "verified" ? "pending_provider" : "waiting_user"
|
|
1919
2057
|
);
|
|
1920
2058
|
}
|
|
1921
2059
|
if (!args.hostname) {
|
|
@@ -1926,25 +2064,42 @@ ${JSON.stringify(res2.pendingDnsRecords, null, 2)}` : ""),
|
|
|
1926
2064
|
hostname: args.hostname
|
|
1927
2065
|
};
|
|
1928
2066
|
const res = await ctx.client.bindDomain(site.credential, req);
|
|
2067
|
+
const apex = res.apexDomain;
|
|
2068
|
+
const txtShort = shortHostFor(res.verificationRecord.name, apex);
|
|
1929
2069
|
return text(
|
|
1930
2070
|
"domain_verification_started",
|
|
1931
|
-
`Domain binding started for ${
|
|
2071
|
+
`Domain binding started for ${apex} (includes: ${res.includedHostnames.join(", ")} \u2014 both will serve this site).
|
|
2072
|
+
|
|
2073
|
+
Add ALL THREE DNS records NOW (adding them together lets verification, certificate issuance and serving complete without further record changes):
|
|
1932
2074
|
|
|
1933
|
-
1
|
|
1934
|
-
|
|
1935
|
-
|
|
1936
|
-
value: ${res.verificationRecord.value}
|
|
1937
|
-
Ownership comes ONLY from DNS control; paying never grants it. This request does not reserve the domain \u2014 the first verified request wins, and this challenge expires after 72 hours.
|
|
2075
|
+
1) TXT host: ${txtShort} value: ${res.verificationRecord.value}
|
|
2076
|
+
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).
|
|
1938
2078
|
|
|
1939
|
-
|
|
2079
|
+
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.
|
|
1940
2080
|
|
|
1941
|
-
|
|
2081
|
+
Ownership comes ONLY from DNS control; paying never grants it. The first verified request wins and this challenge expires after 72 hours.
|
|
2082
|
+
|
|
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.`,
|
|
1942
2084
|
{
|
|
1943
2085
|
verificationId: res.verificationId,
|
|
1944
|
-
apexDomain:
|
|
2086
|
+
apexDomain: apex,
|
|
1945
2087
|
includedHostnames: res.includedHostnames,
|
|
1946
2088
|
verificationRecord: res.verificationRecord,
|
|
1947
|
-
|
|
2089
|
+
servingTarget: res.servingTarget,
|
|
2090
|
+
requiredRecords: [
|
|
2091
|
+
{
|
|
2092
|
+
type: "TXT",
|
|
2093
|
+
shortHost: txtShort,
|
|
2094
|
+
name: res.verificationRecord.name,
|
|
2095
|
+
value: res.verificationRecord.value
|
|
2096
|
+
},
|
|
2097
|
+
{ type: "CNAME", shortHost: "www", name: `www.${apex}`, value: res.servingTarget },
|
|
2098
|
+
{ type: "ALIAS", shortHost: "@", name: apex, value: res.servingTarget }
|
|
2099
|
+
],
|
|
2100
|
+
servingInstructions: res.servingInstructions,
|
|
2101
|
+
retryAfterSeconds: DNS_RETRY_AFTER_SECONDS,
|
|
2102
|
+
maxAttempts: DNS_MAX_ATTEMPTS
|
|
1948
2103
|
},
|
|
1949
2104
|
"waiting_user"
|
|
1950
2105
|
);
|
|
@@ -2498,7 +2653,9 @@ Project directory contract: ONE directory = ONE site (its .sakupa/site.json hold
|
|
|
2498
2653
|
binding). Every project-scoped tool accepts projectDir \u2014 ALWAYS pass the absolute path of
|
|
2499
2654
|
the directory the user is currently working in, on every call. Without it the server falls
|
|
2500
2655
|
back to its startup directory, which may be a different project than the one the user is
|
|
2501
|
-
looking at.
|
|
2656
|
+
looking at. After every deploy, TELL the user which environment it went to (deploy results carry an
|
|
2657
|
+
Explicit Environment line: TEST vs PRODUCTION). analyze_site, deploy_site, site_status,
|
|
2658
|
+
refresh_site, delete_site and unbind_domain echo
|
|
2502
2659
|
the directory they acted on \u2014 verify it matches the user's active project.
|
|
2503
2660
|
|
|
2504
2661
|
Safety boundaries:
|