@sakupa/mcp 0.7.7 → 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 +429 -112
- package/dist/index.js +430 -111
- package/package.json +1 -1
package/dist/index.js
CHANGED
|
@@ -5,6 +5,7 @@ var FREE_SITE_URL_SUFFIX = `.${SERVICE_DOMAIN}`;
|
|
|
5
5
|
var TEST_ACCESS_HEADER = "x-sakupa-test-token";
|
|
6
6
|
var FREE_SITE_TTL_HOURS = 24;
|
|
7
7
|
var FREE_SITE_MAX_TOTAL_BYTES = 10 * 1024 * 1024;
|
|
8
|
+
var FREE_ACTIVE_SITES_PER_IP = 3;
|
|
8
9
|
var PAID_SITE_MAX_TOTAL_BYTES = 2 * 1024 * 1024 * 1024;
|
|
9
10
|
var MAX_FILE_COUNT = 5e3;
|
|
10
11
|
var MAX_SINGLE_FILE_BYTES = 25 * 1024 * 1024;
|
|
@@ -123,7 +124,7 @@ var FORBIDDEN_PATH_SEGMENTS = [
|
|
|
123
124
|
var ALLOWED_HIDDEN_PATHS = [".well-known/"];
|
|
124
125
|
|
|
125
126
|
// ../core/dist/domain/version.js
|
|
126
|
-
var SAKUPA_MCP_VERSION = "0.7.
|
|
127
|
+
var SAKUPA_MCP_VERSION = "0.7.9";
|
|
127
128
|
|
|
128
129
|
// ../core/dist/domain/errors.js
|
|
129
130
|
var HTTP_STATUS = {
|
|
@@ -456,10 +457,10 @@ var CLIENT_TYPE = "sakupa-mcp";
|
|
|
456
457
|
// src/config.ts
|
|
457
458
|
var TEST_API_BASE_URL = "https://api-test.sakupa.com";
|
|
458
459
|
function previewHostPatternFor(apiBaseUrl) {
|
|
459
|
-
return apiBaseUrl ===
|
|
460
|
+
return environmentFor(apiBaseUrl) === "test" ? "{shortId}-test.sakupa.com" : "{shortId}.sakupa.com";
|
|
460
461
|
}
|
|
461
462
|
function loadMcpRuntimeConfig(env = process.env, cwd = process.cwd()) {
|
|
462
|
-
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(/\/+$/, "");
|
|
463
464
|
const projectDir = env["SAKUPA_PROJECT_DIR"] ?? cwd;
|
|
464
465
|
const testAccessToken = env["SAKUPA_TEST_ACCESS_TOKEN"]?.trim() ?? "";
|
|
465
466
|
if (apiBaseUrl === TEST_API_BASE_URL) {
|
|
@@ -477,6 +478,9 @@ function loadMcpRuntimeConfig(env = process.env, cwd = process.cwd()) {
|
|
|
477
478
|
}
|
|
478
479
|
return { apiBaseUrl, projectDir };
|
|
479
480
|
}
|
|
481
|
+
function environmentFor(apiBaseUrl) {
|
|
482
|
+
return apiBaseUrl === TEST_API_BASE_URL ? "test" : "production";
|
|
483
|
+
}
|
|
480
484
|
|
|
481
485
|
// src/transport.ts
|
|
482
486
|
var FetchTransport = class {
|
|
@@ -658,11 +662,14 @@ var HttpApiClient = class {
|
|
|
658
662
|
async bindDomain(credential, req) {
|
|
659
663
|
return this.call("POST", "/v1/domains/bind", { credential, body: req });
|
|
660
664
|
}
|
|
661
|
-
async checkVerification(verificationId, credential) {
|
|
665
|
+
async checkVerification(verificationId, credential, latestForSiteId) {
|
|
662
666
|
return this.call(
|
|
663
667
|
"POST",
|
|
664
668
|
`/v1/domains/verifications/${encodeURIComponent(verificationId)}/check`,
|
|
665
|
-
{
|
|
669
|
+
{
|
|
670
|
+
credential,
|
|
671
|
+
...latestForSiteId !== void 0 ? { body: { siteId: latestForSiteId } } : {}
|
|
672
|
+
}
|
|
666
673
|
);
|
|
667
674
|
}
|
|
668
675
|
async unbindDomain(siteId, credential, req) {
|
|
@@ -1265,6 +1272,12 @@ async function analyzeProject(projectDir, opts = {}) {
|
|
|
1265
1272
|
};
|
|
1266
1273
|
}
|
|
1267
1274
|
|
|
1275
|
+
// src/tools/context.ts
|
|
1276
|
+
import { z as z2 } from "zod";
|
|
1277
|
+
import { statSync } from "node:fs";
|
|
1278
|
+
import { homedir } from "node:os";
|
|
1279
|
+
import { isAbsolute, parse, resolve as resolve2 } from "node:path";
|
|
1280
|
+
|
|
1268
1281
|
// src/tools/result.ts
|
|
1269
1282
|
import { z } from "zod";
|
|
1270
1283
|
var STRUCTURED_TOOL_OUTPUT_SCHEMA = {
|
|
@@ -1307,22 +1320,64 @@ function structuredToolResult(envelope) {
|
|
|
1307
1320
|
}
|
|
1308
1321
|
|
|
1309
1322
|
// src/tools/context.ts
|
|
1323
|
+
var LocalGuidanceError = class extends SakupaError {
|
|
1324
|
+
constructor(code, message) {
|
|
1325
|
+
super(code, message);
|
|
1326
|
+
}
|
|
1327
|
+
};
|
|
1328
|
+
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."
|
|
1330
|
+
);
|
|
1331
|
+
function withProjectDir(ctx, projectDirArg) {
|
|
1332
|
+
if (projectDirArg === void 0) return ctx;
|
|
1333
|
+
if (!isAbsolute(projectDirArg)) {
|
|
1334
|
+
throw new LocalGuidanceError(
|
|
1335
|
+
"invalid_request",
|
|
1336
|
+
`projectDir must be an ABSOLUTE path (got "${projectDirArg}"). Pass the full path of the directory the user is currently working in.`
|
|
1337
|
+
);
|
|
1338
|
+
}
|
|
1339
|
+
const dir = resolve2(projectDirArg);
|
|
1340
|
+
if (parse(dir).root === dir || dir === homedir()) {
|
|
1341
|
+
throw new LocalGuidanceError(
|
|
1342
|
+
"invalid_request",
|
|
1343
|
+
`projectDir "${dir}" is a filesystem root or the home directory. Pass the specific project folder that holds the site's files, not a top-level directory.`
|
|
1344
|
+
);
|
|
1345
|
+
}
|
|
1346
|
+
const stat = statSync(dir, { throwIfNoEntry: false });
|
|
1347
|
+
if (!stat?.isDirectory()) {
|
|
1348
|
+
throw new LocalGuidanceError(
|
|
1349
|
+
"invalid_request",
|
|
1350
|
+
`projectDir "${dir}" does not exist or is not a directory. Pass the absolute path of the directory the user is currently working in.`
|
|
1351
|
+
);
|
|
1352
|
+
}
|
|
1353
|
+
return { ...ctx, projectDir: dir };
|
|
1354
|
+
}
|
|
1310
1355
|
function requireSiteFile(ctx) {
|
|
1311
1356
|
const state = loadSiteFile(ctx.projectDir);
|
|
1312
1357
|
if (state.kind === "corrupted") {
|
|
1313
|
-
throw new
|
|
1358
|
+
throw new LocalGuidanceError(
|
|
1314
1359
|
"invalid_request",
|
|
1315
1360
|
`.sakupa/site.json in ${ctx.projectDir} is damaged: ${state.problem}. ` + siteFileRecoveryGuidance(ctx.projectDir)
|
|
1316
1361
|
);
|
|
1317
1362
|
}
|
|
1318
1363
|
if (state.kind === "absent") {
|
|
1319
|
-
throw new
|
|
1364
|
+
throw new LocalGuidanceError(
|
|
1320
1365
|
"not_found",
|
|
1321
1366
|
`No .sakupa/site.json found in ${ctx.projectDir}. This project has no Sakupa site binding yet \u2014 run deploy_site first to publish it (the management credential will be stored locally in .sakupa/site.json). If this was a paid custom-domain site whose project file was lost, use recover_domain_site instead.`
|
|
1322
1367
|
);
|
|
1323
1368
|
}
|
|
1324
1369
|
return state.file;
|
|
1325
1370
|
}
|
|
1371
|
+
var STATIC_SUMMARY = {
|
|
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
|
+
};
|
|
1326
1381
|
function toolError(e) {
|
|
1327
1382
|
const errorCode = isSakupaError(e) ? e.code : "internal";
|
|
1328
1383
|
const retryable = errorCode === "rate_limited" || errorCode === "internal";
|
|
@@ -1341,7 +1396,7 @@ function toolError(e) {
|
|
|
1341
1396
|
)
|
|
1342
1397
|
) : void 0;
|
|
1343
1398
|
const minimumVersion = rawDetails && typeof rawDetails["minimumVersion"] === "string" ? rawDetails["minimumVersion"] : void 0;
|
|
1344
|
-
const safeSummary = 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
|
|
1399
|
+
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.` : STATIC_SUMMARY[errorCode] ?? (retryable ? "An upstream service is temporarily unavailable or busy; retry shortly." : "The operation failed; no server-internal details are exposed.");
|
|
1345
1400
|
const result = structuredToolResult({
|
|
1346
1401
|
schemaVersion: 1,
|
|
1347
1402
|
outcome: "failed",
|
|
@@ -1360,8 +1415,159 @@ function toolError(e) {
|
|
|
1360
1415
|
// src/tools/definitions.ts
|
|
1361
1416
|
import { randomUUID } from "node:crypto";
|
|
1362
1417
|
import { promises as fs2 } from "node:fs";
|
|
1363
|
-
import { join as
|
|
1364
|
-
import { z as
|
|
1418
|
+
import { join as join4, resolve as resolve3 } from "node:path";
|
|
1419
|
+
import { z as z3 } from "zod";
|
|
1420
|
+
|
|
1421
|
+
// src/creation-registry.ts
|
|
1422
|
+
import { existsSync as existsSync2, mkdirSync as mkdirSync2, readFileSync as readFileSync2, writeFileSync as writeFileSync2 } from "node:fs";
|
|
1423
|
+
import { homedir as homedir2 } from "node:os";
|
|
1424
|
+
import { dirname as dirname2, join as join3 } from "node:path";
|
|
1425
|
+
var RECENT_WINDOW_MS = FREE_SITE_TTL_HOURS * 60 * 60 * 1e3;
|
|
1426
|
+
function creationRegistryPath() {
|
|
1427
|
+
const base = process.env["SAKUPA_STATE_DIR"] ?? homedir2();
|
|
1428
|
+
return join3(base, ".sakupa", "created-sites.json");
|
|
1429
|
+
}
|
|
1430
|
+
function readAll() {
|
|
1431
|
+
const path = creationRegistryPath();
|
|
1432
|
+
if (!existsSync2(path)) return [];
|
|
1433
|
+
try {
|
|
1434
|
+
const parsed = JSON.parse(readFileSync2(path, "utf-8"));
|
|
1435
|
+
if (!Array.isArray(parsed)) return [];
|
|
1436
|
+
return parsed.filter(
|
|
1437
|
+
(e) => typeof e === "object" && e !== null && typeof e.siteId === "string" && typeof e.createdAt === "string"
|
|
1438
|
+
);
|
|
1439
|
+
} catch {
|
|
1440
|
+
return [];
|
|
1441
|
+
}
|
|
1442
|
+
}
|
|
1443
|
+
function writeAll(records) {
|
|
1444
|
+
const path = creationRegistryPath();
|
|
1445
|
+
mkdirSync2(dirname2(path), { recursive: true });
|
|
1446
|
+
writeFileSync2(path, `${JSON.stringify(records, null, 2)}
|
|
1447
|
+
`, "utf-8");
|
|
1448
|
+
}
|
|
1449
|
+
function listRecentCreations(nowMs) {
|
|
1450
|
+
return readAll().filter((e) => {
|
|
1451
|
+
const t = Date.parse(e.createdAt);
|
|
1452
|
+
return Number.isFinite(t) && nowMs - t < RECENT_WINDOW_MS;
|
|
1453
|
+
});
|
|
1454
|
+
}
|
|
1455
|
+
function recordCreation(record) {
|
|
1456
|
+
const rest = readAll().filter((e) => e.siteId !== record.siteId);
|
|
1457
|
+
writeAll([...rest, record]);
|
|
1458
|
+
}
|
|
1459
|
+
function removeCreation(siteId) {
|
|
1460
|
+
const all = readAll();
|
|
1461
|
+
const rest = all.filter((e) => e.siteId !== siteId);
|
|
1462
|
+
if (rest.length !== all.length) writeAll(rest);
|
|
1463
|
+
}
|
|
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
|
+
|
|
1570
|
+
// src/tools/definitions.ts
|
|
1365
1571
|
function text(resultCode, t, data = {}, outcome = "completed") {
|
|
1366
1572
|
return structuredToolResult({
|
|
1367
1573
|
schemaVersion: 1,
|
|
@@ -1384,12 +1590,12 @@ ${JSON.stringify(obj, null, 2)}`;
|
|
|
1384
1590
|
nextActions: []
|
|
1385
1591
|
});
|
|
1386
1592
|
}
|
|
1387
|
-
var planEnum =
|
|
1388
|
-
var severityEnum =
|
|
1593
|
+
var planEnum = z3.enum(["water", "personal", "share", "business"]);
|
|
1594
|
+
var severityEnum = z3.enum(["low", "medium", "high", "critical"]);
|
|
1389
1595
|
function planCatalog() {
|
|
1390
1596
|
return TIER_ORDER.map((p) => `${p} JPY ${tierPriceJpy(p)}/month`).join(", ");
|
|
1391
1597
|
}
|
|
1392
|
-
var ticketCategoryEnum =
|
|
1598
|
+
var ticketCategoryEnum = z3.enum([
|
|
1393
1599
|
"billing",
|
|
1394
1600
|
"payment",
|
|
1395
1601
|
"refund_review",
|
|
@@ -1437,7 +1643,7 @@ function ensureUploadSizeWithinLimits(manifest, isFirstFreeDeploy) {
|
|
|
1437
1643
|
async function buildHashedManifest(files, outputAbs) {
|
|
1438
1644
|
const manifest = [];
|
|
1439
1645
|
for (const file of files) {
|
|
1440
|
-
const bytes = new Uint8Array(await fs2.readFile(
|
|
1646
|
+
const bytes = new Uint8Array(await fs2.readFile(join4(outputAbs, file.path)));
|
|
1441
1647
|
manifest.push({ path: file.path, size: file.size, contentHash: await sha256Hex(bytes) });
|
|
1442
1648
|
}
|
|
1443
1649
|
return manifest;
|
|
@@ -1456,7 +1662,7 @@ async function uploadAll(ctx, targets, files, outputAbs) {
|
|
|
1456
1662
|
`No local file matches upload target "${target.path}"; aborting upload.`
|
|
1457
1663
|
);
|
|
1458
1664
|
}
|
|
1459
|
-
const bytes = new Uint8Array(await fs2.readFile(
|
|
1665
|
+
const bytes = new Uint8Array(await fs2.readFile(join4(outputAbs, match.path)));
|
|
1460
1666
|
if (bytes.byteLength !== match.size) {
|
|
1461
1667
|
throw new SakupaError(
|
|
1462
1668
|
"validation_failed",
|
|
@@ -1467,8 +1673,25 @@ async function uploadAll(ctx, targets, files, outputAbs) {
|
|
|
1467
1673
|
}
|
|
1468
1674
|
return targets.length;
|
|
1469
1675
|
}
|
|
1470
|
-
|
|
1471
|
-
|
|
1676
|
+
var DNS_RETRY_AFTER_SECONDS = 300;
|
|
1677
|
+
var DNS_MAX_ATTEMPTS = 10;
|
|
1678
|
+
function freeSiteCreationBarrier() {
|
|
1679
|
+
const recent = listRecentCreations(Date.now());
|
|
1680
|
+
if (recent.length < FREE_ACTIVE_SITES_PER_IP) return null;
|
|
1681
|
+
const registryPath = creationRegistryPath();
|
|
1682
|
+
return text(
|
|
1683
|
+
"local_site_limit_reached",
|
|
1684
|
+
`This machine already created ${recent.length} free sites in the last 24 hours (the server also enforces ${FREE_ACTIVE_SITES_PER_IP} active free sites per IP). No new site was created.
|
|
1685
|
+
|
|
1686
|
+
` + recent.map((r) => `- ${r.url} (project: ${r.projectDir}, created: ${r.createdAt})`).join("\n") + `
|
|
1687
|
+
|
|
1688
|
+
Options: delete one of these sites (run delete_site with its projectDir), wait for a free site to expire, or \u2014 if this list is stale because sites were deleted or subscribed elsewhere \u2014 remove the local registry file at ${registryPath} and retry.`,
|
|
1689
|
+
{ recentCreations: recent, limit: FREE_ACTIVE_SITES_PER_IP, registryPath },
|
|
1690
|
+
"blocked"
|
|
1691
|
+
);
|
|
1692
|
+
}
|
|
1693
|
+
function registerTools(server, baseCtx) {
|
|
1694
|
+
const previewHostPattern = previewHostPatternFor(baseCtx.apiBaseUrl);
|
|
1472
1695
|
server.registerTool(
|
|
1473
1696
|
"analyze_site",
|
|
1474
1697
|
{
|
|
@@ -1476,11 +1699,13 @@ function registerTools(server, ctx) {
|
|
|
1476
1699
|
outputSchema: STRUCTURED_TOOL_OUTPUT_SCHEMA,
|
|
1477
1700
|
annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: false },
|
|
1478
1701
|
inputSchema: {
|
|
1479
|
-
|
|
1702
|
+
projectDir: projectDirInput,
|
|
1703
|
+
outputDir: z3.string().optional().describe("Output directory relative to the project root (overrides detection).")
|
|
1480
1704
|
}
|
|
1481
1705
|
},
|
|
1482
1706
|
async (args) => {
|
|
1483
1707
|
try {
|
|
1708
|
+
const ctx = withProjectDir(baseCtx, args.projectDir);
|
|
1484
1709
|
const analysis = await analyzeProject(ctx.projectDir, {
|
|
1485
1710
|
...args.outputDir !== void 0 ? { outputDir: args.outputDir } : {}
|
|
1486
1711
|
});
|
|
@@ -1502,18 +1727,20 @@ Next action: ${analysis.suggestedNextAction}`,
|
|
|
1502
1727
|
outputSchema: STRUCTURED_TOOL_OUTPUT_SCHEMA,
|
|
1503
1728
|
annotations: { readOnlyHint: false, destructiveHint: true, openWorldHint: true },
|
|
1504
1729
|
inputSchema: {
|
|
1505
|
-
|
|
1506
|
-
|
|
1730
|
+
projectDir: projectDirInput,
|
|
1731
|
+
outputDir: z3.string().optional().describe("Output directory relative to the project root (overrides detection)."),
|
|
1732
|
+
spaFallback: z3.boolean().optional().describe(
|
|
1507
1733
|
"Override automatic SPA-fallback detection (single index.html + JS auto-enables rewriting unknown paths to index.html; multiple HTML pages auto-disable it). Pass only to force the behavior against the detected structure."
|
|
1508
1734
|
),
|
|
1509
|
-
publicConfirmed:
|
|
1735
|
+
publicConfirmed: z3.boolean().optional().describe(
|
|
1510
1736
|
"Required only for the first deployment: user explicitly confirmed creation of a public 24-hour URL."
|
|
1511
1737
|
),
|
|
1512
|
-
lang:
|
|
1738
|
+
lang: z3.string().optional().describe("Site language override (en | ja | zh-CN); defaults to the html lang.")
|
|
1513
1739
|
}
|
|
1514
1740
|
},
|
|
1515
1741
|
async (args) => {
|
|
1516
1742
|
try {
|
|
1743
|
+
const ctx = withProjectDir(baseCtx, args.projectDir);
|
|
1517
1744
|
const analysis = await analyzeProject(ctx.projectDir, {
|
|
1518
1745
|
...args.outputDir !== void 0 ? { outputDir: args.outputDir } : {}
|
|
1519
1746
|
});
|
|
@@ -1521,7 +1748,7 @@ Next action: ${analysis.suggestedNextAction}`,
|
|
|
1521
1748
|
return notDeployableResult(analysis);
|
|
1522
1749
|
}
|
|
1523
1750
|
const files = analysis.files;
|
|
1524
|
-
const outputAbs =
|
|
1751
|
+
const outputAbs = resolve3(ctx.projectDir, analysis.recommendedOutputDir ?? ".");
|
|
1525
1752
|
const manifest = await buildHashedManifest(files, outputAbs);
|
|
1526
1753
|
const siteFileState = loadSiteFile(ctx.projectDir);
|
|
1527
1754
|
if (siteFileState.kind === "corrupted") {
|
|
@@ -1535,6 +1762,10 @@ Next action: ${analysis.suggestedNextAction}`,
|
|
|
1535
1762
|
);
|
|
1536
1763
|
}
|
|
1537
1764
|
const existing = siteFileState.kind === "ok" ? siteFileState.file : null;
|
|
1765
|
+
if (!existing) {
|
|
1766
|
+
const barrier = freeSiteCreationBarrier();
|
|
1767
|
+
if (barrier) return barrier;
|
|
1768
|
+
}
|
|
1538
1769
|
if (!existing && args.publicConfirmed !== true) {
|
|
1539
1770
|
return text(
|
|
1540
1771
|
"public_deployment_confirmation_required",
|
|
@@ -1556,17 +1787,26 @@ Next action: ${analysis.suggestedNextAction}`,
|
|
|
1556
1787
|
created.siteId,
|
|
1557
1788
|
created.credential
|
|
1558
1789
|
);
|
|
1790
|
+
const createdAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
1559
1791
|
writeSiteFile(ctx.projectDir, {
|
|
1560
1792
|
siteId: created.siteId,
|
|
1561
1793
|
shortId: created.shortId,
|
|
1562
1794
|
url: finalized2.url,
|
|
1563
1795
|
credential: created.credential,
|
|
1564
|
-
createdAt
|
|
1796
|
+
createdAt,
|
|
1565
1797
|
apiBaseUrl: ctx.apiBaseUrl
|
|
1566
1798
|
});
|
|
1799
|
+
recordCreation({
|
|
1800
|
+
siteId: created.siteId,
|
|
1801
|
+
projectDir: ctx.projectDir,
|
|
1802
|
+
url: finalized2.url,
|
|
1803
|
+
createdAt
|
|
1804
|
+
});
|
|
1567
1805
|
return text(
|
|
1568
1806
|
"site_published",
|
|
1569
1807
|
`Site published: ${finalized2.url}
|
|
1808
|
+
Environment: ${environmentFor(ctx.apiBaseUrl).toUpperCase()} (${ctx.apiBaseUrl})
|
|
1809
|
+
Project directory: ${ctx.projectDir}
|
|
1570
1810
|
Files uploaded: ${uploaded2} (${finalized2.totalBytes} bytes)
|
|
1571
1811
|
` + (finalized2.expiresAt ? `Expires at: ${finalized2.expiresAt}
|
|
1572
1812
|
` : "") + `
|
|
@@ -1584,7 +1824,9 @@ ${JSON.stringify(finalized2.warnings, null, 2)}` : ""),
|
|
|
1584
1824
|
filesUploaded: uploaded2,
|
|
1585
1825
|
totalBytes: finalized2.totalBytes,
|
|
1586
1826
|
warnings: finalized2.warnings,
|
|
1587
|
-
credentialStoredLocally: true
|
|
1827
|
+
credentialStoredLocally: true,
|
|
1828
|
+
projectDir: ctx.projectDir,
|
|
1829
|
+
environment: environmentFor(ctx.apiBaseUrl)
|
|
1588
1830
|
}
|
|
1589
1831
|
);
|
|
1590
1832
|
}
|
|
@@ -1630,6 +1872,8 @@ ${JSON.stringify(finalized2.warnings, null, 2)}` : ""),
|
|
|
1630
1872
|
return text(
|
|
1631
1873
|
"site_updated",
|
|
1632
1874
|
`Site updated: ${finalized.url}
|
|
1875
|
+
Environment: ${environmentFor(ctx.apiBaseUrl).toUpperCase()} (${ctx.apiBaseUrl})
|
|
1876
|
+
Project directory: ${ctx.projectDir}
|
|
1633
1877
|
Files uploaded: ${uploaded} (${finalized.totalBytes} bytes)
|
|
1634
1878
|
` + (finalized.expiresAt ? `Validity refreshed \u2014 expires at: ${finalized.expiresAt}
|
|
1635
1879
|
` : "") + (finalized.mode === "free" ? `
|
|
@@ -1641,6 +1885,8 @@ ${JSON.stringify(finalized.warnings, null, 2)}` : ""),
|
|
|
1641
1885
|
siteId: existing.siteId,
|
|
1642
1886
|
url: finalized.url,
|
|
1643
1887
|
mode: finalized.mode,
|
|
1888
|
+
projectDir: ctx.projectDir,
|
|
1889
|
+
environment: environmentFor(ctx.apiBaseUrl),
|
|
1644
1890
|
expiresAt: finalized.expiresAt,
|
|
1645
1891
|
filesUploaded: uploaded,
|
|
1646
1892
|
totalBytes: finalized.totalBytes,
|
|
@@ -1658,17 +1904,18 @@ ${JSON.stringify(finalized.warnings, null, 2)}` : ""),
|
|
|
1658
1904
|
description: "Refresh the validity of the free temporary site WITHOUT uploading content. Uses the local credential in .sakupa/site.json. Subscribed sites are permanent and need no refresh.",
|
|
1659
1905
|
outputSchema: STRUCTURED_TOOL_OUTPUT_SCHEMA,
|
|
1660
1906
|
annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: true },
|
|
1661
|
-
inputSchema: {}
|
|
1907
|
+
inputSchema: { projectDir: projectDirInput }
|
|
1662
1908
|
},
|
|
1663
|
-
async () => {
|
|
1909
|
+
async (args) => {
|
|
1664
1910
|
try {
|
|
1911
|
+
const ctx = withProjectDir(baseCtx, args.projectDir);
|
|
1665
1912
|
const site = requireSiteFile(ctx);
|
|
1666
1913
|
const res = await ctx.client.refreshSite(site.siteId, site.credential);
|
|
1667
1914
|
return text(
|
|
1668
1915
|
"site_refreshed",
|
|
1669
|
-
`Site validity refreshed. New expiry: ${res.expiresAt}
|
|
1916
|
+
`Site validity refreshed (project: ${ctx.projectDir}). New expiry: ${res.expiresAt}
|
|
1670
1917
|
NO content was uploaded or changed by this call \u2014 to publish new or edited files, run deploy_site. Free sites stay live for ${FREE_SITE_TTL_HOURS} hours after each deploy or refresh.`,
|
|
1671
|
-
{ siteId: site.siteId, expiresAt: res.expiresAt }
|
|
1918
|
+
{ siteId: site.siteId, expiresAt: res.expiresAt, projectDir: ctx.projectDir }
|
|
1672
1919
|
);
|
|
1673
1920
|
} catch (e) {
|
|
1674
1921
|
return toolError(e);
|
|
@@ -1681,13 +1928,17 @@ NO content was uploaded or changed by this call \u2014 to publish new or edited
|
|
|
1681
1928
|
description: "Show the current status of this project's Sakupa site: URL, mode (free/paid), expiry, subscription state, custom domains, size, last deployment and warnings.",
|
|
1682
1929
|
outputSchema: STRUCTURED_TOOL_OUTPUT_SCHEMA,
|
|
1683
1930
|
annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: true },
|
|
1684
|
-
inputSchema: {}
|
|
1931
|
+
inputSchema: { projectDir: projectDirInput }
|
|
1685
1932
|
},
|
|
1686
|
-
async () => {
|
|
1933
|
+
async (args) => {
|
|
1687
1934
|
try {
|
|
1935
|
+
const ctx = withProjectDir(baseCtx, args.projectDir);
|
|
1688
1936
|
const site = requireSiteFile(ctx);
|
|
1689
1937
|
const res = await ctx.client.getSiteStatus(site.siteId, site.credential);
|
|
1690
|
-
return textJson("site_status_returned", "Site status:",
|
|
1938
|
+
return textJson("site_status_returned", "Site status:", {
|
|
1939
|
+
...res,
|
|
1940
|
+
projectDir: ctx.projectDir
|
|
1941
|
+
});
|
|
1691
1942
|
} catch (e) {
|
|
1692
1943
|
return toolError(e);
|
|
1693
1944
|
}
|
|
@@ -1700,6 +1951,7 @@ NO content was uploaded or changed by this call \u2014 to publish new or edited
|
|
|
1700
1951
|
outputSchema: STRUCTURED_TOOL_OUTPUT_SCHEMA,
|
|
1701
1952
|
annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: true },
|
|
1702
1953
|
inputSchema: {
|
|
1954
|
+
projectDir: projectDirInput,
|
|
1703
1955
|
plan: planEnum.describe(
|
|
1704
1956
|
"Monthly plan: water (very light personal pages), personal (personal brand / small shop), share (small-business site), business (steadier traffic, more headroom)."
|
|
1705
1957
|
)
|
|
@@ -1707,6 +1959,7 @@ NO content was uploaded or changed by this call \u2014 to publish new or edited
|
|
|
1707
1959
|
},
|
|
1708
1960
|
async (args) => {
|
|
1709
1961
|
try {
|
|
1962
|
+
const ctx = withProjectDir(baseCtx, args.projectDir);
|
|
1710
1963
|
const site = requireSiteFile(ctx);
|
|
1711
1964
|
const res = await ctx.client.createPlanCheckout(
|
|
1712
1965
|
{
|
|
@@ -1745,39 +1998,62 @@ Once payment confirms, the site becomes permanent on its current URL. Binding a
|
|
|
1745
1998
|
outputSchema: STRUCTURED_TOOL_OUTPUT_SCHEMA,
|
|
1746
1999
|
annotations: { readOnlyHint: false, destructiveHint: true, openWorldHint: true },
|
|
1747
2000
|
inputSchema: {
|
|
1748
|
-
|
|
1749
|
-
|
|
1750
|
-
|
|
2001
|
+
projectDir: projectDirInput,
|
|
2002
|
+
action: z3.enum(["start", "status"]),
|
|
2003
|
+
hostname: z3.string().optional().describe("Required for start."),
|
|
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
|
+
)
|
|
1751
2007
|
}
|
|
1752
2008
|
},
|
|
1753
2009
|
async (args) => {
|
|
1754
2010
|
try {
|
|
2011
|
+
const ctx = withProjectDir(baseCtx, args.projectDir);
|
|
1755
2012
|
const site = requireSiteFile(ctx);
|
|
1756
2013
|
if (args.action === "status") {
|
|
1757
|
-
|
|
1758
|
-
throw new SakupaError("invalid_request", "verificationId is required for status");
|
|
1759
|
-
}
|
|
1760
|
-
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);
|
|
1761
2015
|
if (res2.status === "verified") {
|
|
1762
2016
|
writeSiteFile(ctx.projectDir, { ...site, boundDomain: res2.apexDomain });
|
|
1763
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
|
+
});
|
|
1764
2027
|
return text(
|
|
1765
2028
|
res2.status === "verified" ? "domain_verification_succeeded" : "domain_verification_pending",
|
|
1766
|
-
`
|
|
2029
|
+
`Domain binding status for ${apex2}: ${res2.status}
|
|
1767
2030
|
${res2.message}
|
|
1768
|
-
|
|
1769
|
-
|
|
1770
|
-
|
|
1771
|
-
|
|
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."),
|
|
1772
2038
|
{
|
|
1773
2039
|
verificationId: res2.verificationId,
|
|
1774
2040
|
status: res2.status,
|
|
1775
|
-
apexDomain:
|
|
2041
|
+
apexDomain: apex2,
|
|
1776
2042
|
provisioningJobId: res2.provisioningJobId,
|
|
1777
|
-
|
|
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,
|
|
1778
2054
|
message: res2.message
|
|
1779
2055
|
},
|
|
1780
|
-
res2.status === "verified" ? "
|
|
2056
|
+
res2.status === "verified" ? "pending_provider" : "waiting_user"
|
|
1781
2057
|
);
|
|
1782
2058
|
}
|
|
1783
2059
|
if (!args.hostname) {
|
|
@@ -1788,25 +2064,42 @@ ${JSON.stringify(res2.pendingDnsRecords, null, 2)}` : ""),
|
|
|
1788
2064
|
hostname: args.hostname
|
|
1789
2065
|
};
|
|
1790
2066
|
const res = await ctx.client.bindDomain(site.credential, req);
|
|
2067
|
+
const apex = res.apexDomain;
|
|
2068
|
+
const txtShort = shortHostFor(res.verificationRecord.name, apex);
|
|
1791
2069
|
return text(
|
|
1792
2070
|
"domain_verification_started",
|
|
1793
|
-
`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):
|
|
2074
|
+
|
|
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).
|
|
1794
2078
|
|
|
1795
|
-
|
|
1796
|
-
name: ${res.verificationRecord.name}
|
|
1797
|
-
type: ${res.verificationRecord.type}
|
|
1798
|
-
value: ${res.verificationRecord.value}
|
|
1799
|
-
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.
|
|
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.
|
|
1800
2080
|
|
|
1801
|
-
|
|
2081
|
+
Ownership comes ONLY from DNS control; paying never grants it. The first verified request wins and this challenge expires after 72 hours.
|
|
1802
2082
|
|
|
1803
|
-
Then run bind_domain
|
|
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.`,
|
|
1804
2084
|
{
|
|
1805
2085
|
verificationId: res.verificationId,
|
|
1806
|
-
apexDomain:
|
|
2086
|
+
apexDomain: apex,
|
|
1807
2087
|
includedHostnames: res.includedHostnames,
|
|
1808
2088
|
verificationRecord: res.verificationRecord,
|
|
1809
|
-
|
|
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
|
|
1810
2103
|
},
|
|
1811
2104
|
"waiting_user"
|
|
1812
2105
|
);
|
|
@@ -1821,10 +2114,11 @@ Then run bind_domain again with verificationId: "${res.verificationId}" to check
|
|
|
1821
2114
|
description: "Show this site's hosting subscription: plan, payment state, current paid period, reconciled usage, estimated usage tier, bound custom domains and risks. Owner-only (uses the credential in .sakupa/site.json).",
|
|
1822
2115
|
outputSchema: STRUCTURED_TOOL_OUTPUT_SCHEMA,
|
|
1823
2116
|
annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: true },
|
|
1824
|
-
inputSchema: {}
|
|
2117
|
+
inputSchema: { projectDir: projectDirInput }
|
|
1825
2118
|
},
|
|
1826
|
-
async () => {
|
|
2119
|
+
async (args) => {
|
|
1827
2120
|
try {
|
|
2121
|
+
const ctx = withProjectDir(baseCtx, args.projectDir);
|
|
1828
2122
|
const site = requireSiteFile(ctx);
|
|
1829
2123
|
const res = await ctx.client.getBillingStatus(site.siteId, site.credential);
|
|
1830
2124
|
const lines = [
|
|
@@ -1854,11 +2148,13 @@ Full status:`, res);
|
|
|
1854
2148
|
outputSchema: STRUCTURED_TOOL_OUTPUT_SCHEMA,
|
|
1855
2149
|
annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: true },
|
|
1856
2150
|
inputSchema: {
|
|
1857
|
-
|
|
2151
|
+
projectDir: projectDirInput,
|
|
2152
|
+
scope: z3.enum(["site", "public_recovery"])
|
|
1858
2153
|
}
|
|
1859
2154
|
},
|
|
1860
2155
|
async (args) => {
|
|
1861
2156
|
try {
|
|
2157
|
+
const ctx = withProjectDir(baseCtx, args.projectDir);
|
|
1862
2158
|
if (args.scope === "site") {
|
|
1863
2159
|
const site = requireSiteFile(ctx);
|
|
1864
2160
|
const res2 = await ctx.client.createBillingPortal(site.siteId, site.credential);
|
|
@@ -1909,14 +2205,16 @@ Full status:`, res);
|
|
|
1909
2205
|
outputSchema: STRUCTURED_TOOL_OUTPUT_SCHEMA,
|
|
1910
2206
|
annotations: { readOnlyHint: false, destructiveHint: true, openWorldHint: true },
|
|
1911
2207
|
inputSchema: {
|
|
1912
|
-
|
|
1913
|
-
|
|
1914
|
-
|
|
1915
|
-
|
|
2208
|
+
projectDir: projectDirInput,
|
|
2209
|
+
action: z3.enum(["start", "status", "complete"]),
|
|
2210
|
+
hostname: z3.string().optional().describe("Required for start."),
|
|
2211
|
+
verificationId: z3.string().optional().describe("Required for status or complete."),
|
|
2212
|
+
preserveExistingCredentials: z3.boolean().optional().describe("Explicitly keep old local credentials working (default: revoke them all).")
|
|
1916
2213
|
}
|
|
1917
2214
|
},
|
|
1918
2215
|
async (args) => {
|
|
1919
2216
|
try {
|
|
2217
|
+
const ctx = withProjectDir(baseCtx, args.projectDir);
|
|
1920
2218
|
if (args.action === "start") {
|
|
1921
2219
|
if (!args.hostname) {
|
|
1922
2220
|
throw new SakupaError("invalid_request", "hostname is required for start");
|
|
@@ -2014,14 +2312,16 @@ ${res.archiveUrl}`,
|
|
|
2014
2312
|
outputSchema: STRUCTURED_TOOL_OUTPUT_SCHEMA,
|
|
2015
2313
|
annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: true },
|
|
2016
2314
|
inputSchema: {
|
|
2315
|
+
projectDir: projectDirInput,
|
|
2017
2316
|
category: ticketCategoryEnum,
|
|
2018
|
-
subject:
|
|
2019
|
-
description:
|
|
2020
|
-
contactEmail:
|
|
2317
|
+
subject: z3.string().describe("Short subject line."),
|
|
2318
|
+
description: z3.string().describe("Problem description (no secrets, no card data)."),
|
|
2319
|
+
contactEmail: z3.string().optional().describe("Optional contact email for follow-up.")
|
|
2021
2320
|
}
|
|
2022
2321
|
},
|
|
2023
2322
|
async (args) => {
|
|
2024
2323
|
try {
|
|
2324
|
+
const ctx = withProjectDir(baseCtx, args.projectDir);
|
|
2025
2325
|
const site = requireSiteFile(ctx);
|
|
2026
2326
|
const res = await ctx.client.createTicket(site.credential, {
|
|
2027
2327
|
siteId: site.siteId,
|
|
@@ -2047,18 +2347,20 @@ ${res.archiveUrl}`,
|
|
|
2047
2347
|
outputSchema: STRUCTURED_TOOL_OUTPUT_SCHEMA,
|
|
2048
2348
|
annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: true },
|
|
2049
2349
|
inputSchema: {
|
|
2050
|
-
|
|
2051
|
-
|
|
2052
|
-
|
|
2053
|
-
|
|
2054
|
-
|
|
2350
|
+
projectDir: projectDirInput,
|
|
2351
|
+
toolName: z3.string().describe('The Sakupa tool that failed, e.g. "deploy_site".'),
|
|
2352
|
+
errorCode: z3.string().optional(),
|
|
2353
|
+
errorMessage: z3.string().optional().describe("Sanitized error message (no secrets)."),
|
|
2354
|
+
requestId: z3.string().optional(),
|
|
2355
|
+
deploymentId: z3.string().optional(),
|
|
2055
2356
|
severity: severityEnum.optional(),
|
|
2056
|
-
description:
|
|
2057
|
-
confirmSubmit:
|
|
2357
|
+
description: z3.string().optional().describe("What happened, in the user's words (no secrets)."),
|
|
2358
|
+
confirmSubmit: z3.boolean().optional().describe("User reviewed the report payload and approved submission.")
|
|
2058
2359
|
}
|
|
2059
2360
|
},
|
|
2060
2361
|
async (args) => {
|
|
2061
2362
|
try {
|
|
2363
|
+
const ctx = withProjectDir(baseCtx, args.projectDir);
|
|
2062
2364
|
const siteState = loadSiteFile(ctx.projectDir);
|
|
2063
2365
|
const site = siteState.kind === "ok" ? siteState.file : null;
|
|
2064
2366
|
const diagnostics = {
|
|
@@ -2102,39 +2404,40 @@ Summary: ${res.sanitizedSummary}`,
|
|
|
2102
2404
|
}
|
|
2103
2405
|
|
|
2104
2406
|
// src/tools/lifecycle.ts
|
|
2105
|
-
import { z as
|
|
2106
|
-
var deleteConfirmation =
|
|
2107
|
-
siteId:
|
|
2108
|
-
expectedSiteUpdatedAt:
|
|
2109
|
-
expectedStatus:
|
|
2110
|
-
expectedMode:
|
|
2111
|
-
expectedServingMode:
|
|
2112
|
-
expectedShortId:
|
|
2113
|
-
expectedSubscriptionStatus:
|
|
2114
|
-
expectedPlan:
|
|
2115
|
-
expectedCancelAtPeriodEnd:
|
|
2116
|
-
expectedCurrentPeriodEnd:
|
|
2117
|
-
expectedLastDeploymentId:
|
|
2118
|
-
expectedBoundHostnames:
|
|
2119
|
-
acknowledge:
|
|
2407
|
+
import { z as z4 } from "zod";
|
|
2408
|
+
var deleteConfirmation = z4.object({
|
|
2409
|
+
siteId: z4.string().min(1),
|
|
2410
|
+
expectedSiteUpdatedAt: z4.string().datetime(),
|
|
2411
|
+
expectedStatus: z4.enum(["active", "expired", "deleted"]),
|
|
2412
|
+
expectedMode: z4.enum(["free", "paid"]),
|
|
2413
|
+
expectedServingMode: z4.enum(["normal", "over_limit_notice", "risk_notice", "stopped"]),
|
|
2414
|
+
expectedShortId: z4.string().optional(),
|
|
2415
|
+
expectedSubscriptionStatus: z4.enum(["incomplete", "active", "past_due", "canceled"]).optional(),
|
|
2416
|
+
expectedPlan: z4.enum(["water", "personal", "share", "business"]).optional(),
|
|
2417
|
+
expectedCancelAtPeriodEnd: z4.boolean().optional(),
|
|
2418
|
+
expectedCurrentPeriodEnd: z4.string().datetime().optional(),
|
|
2419
|
+
expectedLastDeploymentId: z4.string().optional(),
|
|
2420
|
+
expectedBoundHostnames: z4.array(z4.string()),
|
|
2421
|
+
acknowledge: z4.literal("delete_site_and_cancel_renewal")
|
|
2120
2422
|
});
|
|
2121
|
-
var unbindConfirmation =
|
|
2122
|
-
siteId:
|
|
2123
|
-
bindingId:
|
|
2124
|
-
expectedBindingUpdatedAt:
|
|
2125
|
-
expectedBindingStatus:
|
|
2126
|
-
apexDomain:
|
|
2127
|
-
expectedBoundHostnames:
|
|
2128
|
-
acknowledge:
|
|
2423
|
+
var unbindConfirmation = z4.object({
|
|
2424
|
+
siteId: z4.string().min(1),
|
|
2425
|
+
bindingId: z4.string().min(1),
|
|
2426
|
+
expectedBindingUpdatedAt: z4.string().datetime(),
|
|
2427
|
+
expectedBindingStatus: z4.enum(["provisioning", "active"]),
|
|
2428
|
+
apexDomain: z4.string().min(1),
|
|
2429
|
+
expectedBoundHostnames: z4.array(z4.string()),
|
|
2430
|
+
acknowledge: z4.literal("unbind_domain_and_remove_custom_hostnames")
|
|
2129
2431
|
});
|
|
2130
|
-
function registerLifecycleTools(server,
|
|
2432
|
+
function registerLifecycleTools(server, baseCtx) {
|
|
2131
2433
|
server.registerTool(
|
|
2132
2434
|
"delete_site",
|
|
2133
2435
|
{
|
|
2134
2436
|
description: "Preview or execute deletion of this Sakupa site. Execution requires an exact server-validated confirmation bound to the current site state.",
|
|
2135
2437
|
inputSchema: {
|
|
2136
|
-
|
|
2137
|
-
|
|
2438
|
+
projectDir: projectDirInput,
|
|
2439
|
+
action: z4.enum(["preview", "confirm"]),
|
|
2440
|
+
operationId: z4.string().min(1).optional(),
|
|
2138
2441
|
confirmation: deleteConfirmation.optional()
|
|
2139
2442
|
},
|
|
2140
2443
|
outputSchema: STRUCTURED_TOOL_OUTPUT_SCHEMA,
|
|
@@ -2142,6 +2445,7 @@ function registerLifecycleTools(server, ctx) {
|
|
|
2142
2445
|
},
|
|
2143
2446
|
async (args) => {
|
|
2144
2447
|
try {
|
|
2448
|
+
const ctx = withProjectDir(baseCtx, args.projectDir);
|
|
2145
2449
|
const site = requireSiteFile(ctx);
|
|
2146
2450
|
if (!args.operationId) {
|
|
2147
2451
|
throw new Error("operationId is required for delete_site");
|
|
@@ -2168,13 +2472,14 @@ function registerLifecycleTools(server, ctx) {
|
|
|
2168
2472
|
confirmation: args.confirmation
|
|
2169
2473
|
});
|
|
2170
2474
|
deleteSiteFile(ctx.projectDir);
|
|
2475
|
+
removeCreation(site.siteId);
|
|
2171
2476
|
return structuredToolResult({
|
|
2172
2477
|
schemaVersion: 1,
|
|
2173
2478
|
outcome: result.servingDeletionPending ? "pending_provider" : "completed",
|
|
2174
2479
|
resultCode: "site_deleted",
|
|
2175
2480
|
operationId: args.operationId,
|
|
2176
|
-
summary:
|
|
2177
|
-
data: { result },
|
|
2481
|
+
summary: `Site deleted; the local management credential file was removed from ${ctx.projectDir}.`,
|
|
2482
|
+
data: { result, projectDir: ctx.projectDir },
|
|
2178
2483
|
nextActions: []
|
|
2179
2484
|
});
|
|
2180
2485
|
} catch (error) {
|
|
@@ -2187,8 +2492,9 @@ function registerLifecycleTools(server, ctx) {
|
|
|
2187
2492
|
{
|
|
2188
2493
|
description: "Preview or execute removal of the custom apex/www serving surface while preserving the subscription and permanent Sakupa URL.",
|
|
2189
2494
|
inputSchema: {
|
|
2190
|
-
|
|
2191
|
-
|
|
2495
|
+
projectDir: projectDirInput,
|
|
2496
|
+
action: z4.enum(["preview", "confirm"]),
|
|
2497
|
+
operationId: z4.string().min(1).optional(),
|
|
2192
2498
|
confirmation: unbindConfirmation.optional()
|
|
2193
2499
|
},
|
|
2194
2500
|
outputSchema: STRUCTURED_TOOL_OUTPUT_SCHEMA,
|
|
@@ -2196,6 +2502,7 @@ function registerLifecycleTools(server, ctx) {
|
|
|
2196
2502
|
},
|
|
2197
2503
|
async (args) => {
|
|
2198
2504
|
try {
|
|
2505
|
+
const ctx = withProjectDir(baseCtx, args.projectDir);
|
|
2199
2506
|
const site = requireSiteFile(ctx);
|
|
2200
2507
|
if (!args.operationId) throw new Error("operationId is required for unbind_domain");
|
|
2201
2508
|
if (args.action === "preview") {
|
|
@@ -2226,7 +2533,7 @@ function registerLifecycleTools(server, ctx) {
|
|
|
2226
2533
|
outcome: result.servingDeletionPending ? "pending_provider" : "completed",
|
|
2227
2534
|
resultCode: "domain_unbound",
|
|
2228
2535
|
operationId: args.operationId,
|
|
2229
|
-
summary:
|
|
2536
|
+
summary: `Custom domain unbound; the subscription, deployed content, and permanent Sakupa URL are unchanged. (project: ${ctx.projectDir})`,
|
|
2230
2537
|
data: { result },
|
|
2231
2538
|
nextActions: [{ tool: "site_status", allowed: true }]
|
|
2232
2539
|
});
|
|
@@ -2241,19 +2548,20 @@ function registerLifecycleTools(server, ctx) {
|
|
|
2241
2548
|
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
2242
2549
|
|
|
2243
2550
|
// src/tools/billing.ts
|
|
2244
|
-
import { z as
|
|
2245
|
-
var plan =
|
|
2246
|
-
function registerBillingTools(server,
|
|
2551
|
+
import { z as z5 } from "zod";
|
|
2552
|
+
var plan = z5.enum(["water", "personal", "share", "business"]);
|
|
2553
|
+
function registerBillingTools(server, baseCtx) {
|
|
2247
2554
|
server.registerTool(
|
|
2248
2555
|
"list_billing_plans",
|
|
2249
2556
|
{
|
|
2250
2557
|
description: "Return the authoritative Sakupa monthly plan catalog, exact limits, prices, catalog version and plan-change billing rules. This is read-only and does not require a site.",
|
|
2251
|
-
inputSchema: {},
|
|
2558
|
+
inputSchema: { projectDir: projectDirInput },
|
|
2252
2559
|
outputSchema: STRUCTURED_TOOL_OUTPUT_SCHEMA,
|
|
2253
2560
|
annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: true }
|
|
2254
2561
|
},
|
|
2255
|
-
async () => {
|
|
2562
|
+
async (args) => {
|
|
2256
2563
|
try {
|
|
2564
|
+
const ctx = withProjectDir(baseCtx, args.projectDir);
|
|
2257
2565
|
const catalog = await ctx.client.getBillingPlanCatalog();
|
|
2258
2566
|
return structuredToolResult({
|
|
2259
2567
|
schemaVersion: 1,
|
|
@@ -2273,14 +2581,16 @@ function registerBillingTools(server, ctx) {
|
|
|
2273
2581
|
{
|
|
2274
2582
|
description: "Create a Stripe-hosted confirmation link for a manually selected subscription plan. Creating the link does not change billing; only the user can confirm on Stripe.",
|
|
2275
2583
|
inputSchema: {
|
|
2584
|
+
projectDir: projectDirInput,
|
|
2276
2585
|
targetPlan: plan,
|
|
2277
|
-
operationId:
|
|
2586
|
+
operationId: z5.string().min(1)
|
|
2278
2587
|
},
|
|
2279
2588
|
outputSchema: STRUCTURED_TOOL_OUTPUT_SCHEMA,
|
|
2280
2589
|
annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: true }
|
|
2281
2590
|
},
|
|
2282
2591
|
async (args) => {
|
|
2283
2592
|
try {
|
|
2593
|
+
const ctx = withProjectDir(baseCtx, args.projectDir);
|
|
2284
2594
|
const site = requireSiteFile(ctx);
|
|
2285
2595
|
const result = await ctx.client.changeSubscriptionPlan(site.credential, {
|
|
2286
2596
|
siteId: site.siteId,
|
|
@@ -2339,6 +2649,15 @@ Workflow:
|
|
|
2339
2649
|
5. create_support_ticket (subscribed sites) opens a support ticket; report_bug sends a
|
|
2340
2650
|
sanitized diagnostic report after the user explicitly confirms it.
|
|
2341
2651
|
|
|
2652
|
+
Project directory contract: ONE directory = ONE site (its .sakupa/site.json holds the
|
|
2653
|
+
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
|
|
2657
|
+
Explicit Environment line: TEST vs PRODUCTION). analyze_site, deploy_site, site_status,
|
|
2658
|
+
refresh_site, delete_site and unbind_domain echo
|
|
2659
|
+
the directory they acted on \u2014 verify it matches the user's active project.
|
|
2660
|
+
|
|
2342
2661
|
Safety boundaries:
|
|
2343
2662
|
- Static output only: no SSR, API routes, middleware, server actions, databases or online builds.
|
|
2344
2663
|
- Never upload source projects, secrets, .env files, private keys, archives, videos or audio.
|