@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/bin.js
CHANGED
|
@@ -10,6 +10,7 @@ var FREE_SITE_URL_SUFFIX = `.${SERVICE_DOMAIN}`;
|
|
|
10
10
|
var TEST_ACCESS_HEADER = "x-sakupa-test-token";
|
|
11
11
|
var FREE_SITE_TTL_HOURS = 24;
|
|
12
12
|
var FREE_SITE_MAX_TOTAL_BYTES = 10 * 1024 * 1024;
|
|
13
|
+
var FREE_ACTIVE_SITES_PER_IP = 3;
|
|
13
14
|
var PAID_SITE_MAX_TOTAL_BYTES = 2 * 1024 * 1024 * 1024;
|
|
14
15
|
var MAX_FILE_COUNT = 5e3;
|
|
15
16
|
var MAX_SINGLE_FILE_BYTES = 25 * 1024 * 1024;
|
|
@@ -128,7 +129,7 @@ var FORBIDDEN_PATH_SEGMENTS = [
|
|
|
128
129
|
var ALLOWED_HIDDEN_PATHS = [".well-known/"];
|
|
129
130
|
|
|
130
131
|
// ../core/dist/domain/version.js
|
|
131
|
-
var SAKUPA_MCP_VERSION = "0.7.
|
|
132
|
+
var SAKUPA_MCP_VERSION = "0.7.9";
|
|
132
133
|
|
|
133
134
|
// ../core/dist/domain/errors.js
|
|
134
135
|
var HTTP_STATUS = {
|
|
@@ -457,10 +458,10 @@ var WEBHOOK_PROCESSING_LEASE_MS = 5 * 60 * 1e3;
|
|
|
457
458
|
// src/config.ts
|
|
458
459
|
var TEST_API_BASE_URL = "https://api-test.sakupa.com";
|
|
459
460
|
function previewHostPatternFor(apiBaseUrl) {
|
|
460
|
-
return apiBaseUrl ===
|
|
461
|
+
return environmentFor(apiBaseUrl) === "test" ? "{shortId}-test.sakupa.com" : "{shortId}.sakupa.com";
|
|
461
462
|
}
|
|
462
463
|
function loadMcpRuntimeConfig(env = process.env, cwd = process.cwd()) {
|
|
463
|
-
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(/\/+$/, "");
|
|
464
465
|
const projectDir = env["SAKUPA_PROJECT_DIR"] ?? cwd;
|
|
465
466
|
const testAccessToken = env["SAKUPA_TEST_ACCESS_TOKEN"]?.trim() ?? "";
|
|
466
467
|
if (apiBaseUrl === TEST_API_BASE_URL) {
|
|
@@ -478,6 +479,9 @@ function loadMcpRuntimeConfig(env = process.env, cwd = process.cwd()) {
|
|
|
478
479
|
}
|
|
479
480
|
return { apiBaseUrl, projectDir };
|
|
480
481
|
}
|
|
482
|
+
function environmentFor(apiBaseUrl) {
|
|
483
|
+
return apiBaseUrl === TEST_API_BASE_URL ? "test" : "production";
|
|
484
|
+
}
|
|
481
485
|
|
|
482
486
|
// src/server.ts
|
|
483
487
|
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
@@ -590,11 +594,14 @@ var HttpApiClient = class {
|
|
|
590
594
|
async bindDomain(credential, req) {
|
|
591
595
|
return this.call("POST", "/v1/domains/bind", { credential, body: req });
|
|
592
596
|
}
|
|
593
|
-
async checkVerification(verificationId, credential) {
|
|
597
|
+
async checkVerification(verificationId, credential, latestForSiteId) {
|
|
594
598
|
return this.call(
|
|
595
599
|
"POST",
|
|
596
600
|
`/v1/domains/verifications/${encodeURIComponent(verificationId)}/check`,
|
|
597
|
-
{
|
|
601
|
+
{
|
|
602
|
+
credential,
|
|
603
|
+
...latestForSiteId !== void 0 ? { body: { siteId: latestForSiteId } } : {}
|
|
604
|
+
}
|
|
598
605
|
);
|
|
599
606
|
}
|
|
600
607
|
async unbindDomain(siteId, credential, req) {
|
|
@@ -680,8 +687,8 @@ var HttpApiClient = class {
|
|
|
680
687
|
// src/tools/definitions.ts
|
|
681
688
|
import { randomUUID } from "node:crypto";
|
|
682
689
|
import { promises as fs2 } from "node:fs";
|
|
683
|
-
import { join as
|
|
684
|
-
import { z as
|
|
690
|
+
import { join as join4, resolve as resolve3 } from "node:path";
|
|
691
|
+
import { z as z3 } from "zod";
|
|
685
692
|
|
|
686
693
|
// src/analyze/analyzer.ts
|
|
687
694
|
import { promises as fs } from "node:fs";
|
|
@@ -1203,10 +1210,165 @@ function credentialGitReminder(projectDir) {
|
|
|
1203
1210
|
return '\nNOTE: this project is inside a git repository. The management credential in .sakupa/site.json is the key to this site \u2014 do NOT commit it to a PUBLIC repository (add ".sakupa/" to .gitignore yourself if you want to keep it out of version control).';
|
|
1204
1211
|
}
|
|
1205
1212
|
|
|
1213
|
+
// src/creation-registry.ts
|
|
1214
|
+
import { existsSync as existsSync2, mkdirSync as mkdirSync2, readFileSync as readFileSync2, writeFileSync as writeFileSync2 } from "node:fs";
|
|
1215
|
+
import { homedir } from "node:os";
|
|
1216
|
+
import { dirname as dirname2, join as join3 } from "node:path";
|
|
1217
|
+
var RECENT_WINDOW_MS = FREE_SITE_TTL_HOURS * 60 * 60 * 1e3;
|
|
1218
|
+
function creationRegistryPath() {
|
|
1219
|
+
const base = process.env["SAKUPA_STATE_DIR"] ?? homedir();
|
|
1220
|
+
return join3(base, ".sakupa", "created-sites.json");
|
|
1221
|
+
}
|
|
1222
|
+
function readAll() {
|
|
1223
|
+
const path = creationRegistryPath();
|
|
1224
|
+
if (!existsSync2(path)) return [];
|
|
1225
|
+
try {
|
|
1226
|
+
const parsed = JSON.parse(readFileSync2(path, "utf-8"));
|
|
1227
|
+
if (!Array.isArray(parsed)) return [];
|
|
1228
|
+
return parsed.filter(
|
|
1229
|
+
(e) => typeof e === "object" && e !== null && typeof e.siteId === "string" && typeof e.createdAt === "string"
|
|
1230
|
+
);
|
|
1231
|
+
} catch {
|
|
1232
|
+
return [];
|
|
1233
|
+
}
|
|
1234
|
+
}
|
|
1235
|
+
function writeAll(records) {
|
|
1236
|
+
const path = creationRegistryPath();
|
|
1237
|
+
mkdirSync2(dirname2(path), { recursive: true });
|
|
1238
|
+
writeFileSync2(path, `${JSON.stringify(records, null, 2)}
|
|
1239
|
+
`, "utf-8");
|
|
1240
|
+
}
|
|
1241
|
+
function listRecentCreations(nowMs) {
|
|
1242
|
+
return readAll().filter((e) => {
|
|
1243
|
+
const t = Date.parse(e.createdAt);
|
|
1244
|
+
return Number.isFinite(t) && nowMs - t < RECENT_WINDOW_MS;
|
|
1245
|
+
});
|
|
1246
|
+
}
|
|
1247
|
+
function recordCreation(record) {
|
|
1248
|
+
const rest = readAll().filter((e) => e.siteId !== record.siteId);
|
|
1249
|
+
writeAll([...rest, record]);
|
|
1250
|
+
}
|
|
1251
|
+
function removeCreation(siteId) {
|
|
1252
|
+
const all = readAll();
|
|
1253
|
+
const rest = all.filter((e) => e.siteId !== siteId);
|
|
1254
|
+
if (rest.length !== all.length) writeAll(rest);
|
|
1255
|
+
}
|
|
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
|
+
|
|
1206
1362
|
// src/version.ts
|
|
1207
1363
|
var MCP_VERSION = SAKUPA_MCP_VERSION;
|
|
1208
1364
|
var CLIENT_TYPE = "sakupa-mcp";
|
|
1209
1365
|
|
|
1366
|
+
// src/tools/context.ts
|
|
1367
|
+
import { z as z2 } from "zod";
|
|
1368
|
+
import { statSync } from "node:fs";
|
|
1369
|
+
import { homedir as homedir2 } from "node:os";
|
|
1370
|
+
import { isAbsolute, parse, resolve as resolve2 } from "node:path";
|
|
1371
|
+
|
|
1210
1372
|
// src/tools/result.ts
|
|
1211
1373
|
import { z } from "zod";
|
|
1212
1374
|
var STRUCTURED_TOOL_OUTPUT_SCHEMA = {
|
|
@@ -1249,22 +1411,64 @@ function structuredToolResult(envelope) {
|
|
|
1249
1411
|
}
|
|
1250
1412
|
|
|
1251
1413
|
// src/tools/context.ts
|
|
1414
|
+
var LocalGuidanceError = class extends SakupaError {
|
|
1415
|
+
constructor(code, message) {
|
|
1416
|
+
super(code, message);
|
|
1417
|
+
}
|
|
1418
|
+
};
|
|
1419
|
+
var projectDirInput = z2.string().optional().describe(
|
|
1420
|
+
"Absolute path of the project directory the user is CURRENTLY working in (the folder holding the site files and .sakupa). Always pass it explicitly; when omitted the server falls back to its startup directory, which may not be where the user is working now."
|
|
1421
|
+
);
|
|
1422
|
+
function withProjectDir(ctx, projectDirArg) {
|
|
1423
|
+
if (projectDirArg === void 0) return ctx;
|
|
1424
|
+
if (!isAbsolute(projectDirArg)) {
|
|
1425
|
+
throw new LocalGuidanceError(
|
|
1426
|
+
"invalid_request",
|
|
1427
|
+
`projectDir must be an ABSOLUTE path (got "${projectDirArg}"). Pass the full path of the directory the user is currently working in.`
|
|
1428
|
+
);
|
|
1429
|
+
}
|
|
1430
|
+
const dir = resolve2(projectDirArg);
|
|
1431
|
+
if (parse(dir).root === dir || dir === homedir2()) {
|
|
1432
|
+
throw new LocalGuidanceError(
|
|
1433
|
+
"invalid_request",
|
|
1434
|
+
`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.`
|
|
1435
|
+
);
|
|
1436
|
+
}
|
|
1437
|
+
const stat = statSync(dir, { throwIfNoEntry: false });
|
|
1438
|
+
if (!stat?.isDirectory()) {
|
|
1439
|
+
throw new LocalGuidanceError(
|
|
1440
|
+
"invalid_request",
|
|
1441
|
+
`projectDir "${dir}" does not exist or is not a directory. Pass the absolute path of the directory the user is currently working in.`
|
|
1442
|
+
);
|
|
1443
|
+
}
|
|
1444
|
+
return { ...ctx, projectDir: dir };
|
|
1445
|
+
}
|
|
1252
1446
|
function requireSiteFile(ctx) {
|
|
1253
1447
|
const state = loadSiteFile(ctx.projectDir);
|
|
1254
1448
|
if (state.kind === "corrupted") {
|
|
1255
|
-
throw new
|
|
1449
|
+
throw new LocalGuidanceError(
|
|
1256
1450
|
"invalid_request",
|
|
1257
1451
|
`.sakupa/site.json in ${ctx.projectDir} is damaged: ${state.problem}. ` + siteFileRecoveryGuidance(ctx.projectDir)
|
|
1258
1452
|
);
|
|
1259
1453
|
}
|
|
1260
1454
|
if (state.kind === "absent") {
|
|
1261
|
-
throw new
|
|
1455
|
+
throw new LocalGuidanceError(
|
|
1262
1456
|
"not_found",
|
|
1263
1457
|
`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.`
|
|
1264
1458
|
);
|
|
1265
1459
|
}
|
|
1266
1460
|
return state.file;
|
|
1267
1461
|
}
|
|
1462
|
+
var STATIC_SUMMARY = {
|
|
1463
|
+
not_found: "The required local project binding or resource is unavailable; if this project has no .sakupa/site.json yet, run deploy_site first.",
|
|
1464
|
+
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.",
|
|
1465
|
+
invalid_request: "The request arguments or local project checks did not pass.",
|
|
1466
|
+
validation_failed: "The request arguments or local project checks did not pass.",
|
|
1467
|
+
state_conflict: "The resource state has changed; re-query the current status before deciding the next step.",
|
|
1468
|
+
confirmation_required: "The site or billing state changed, so the previous confirmation is stale; run the preview again and confirm against the fresh snapshot.",
|
|
1469
|
+
payment_required: "This operation requires an active subscription; check billing_status first.",
|
|
1470
|
+
rate_limited: "The server rate limit was reached; retry after the returned wait time."
|
|
1471
|
+
};
|
|
1268
1472
|
function toolError(e) {
|
|
1269
1473
|
const errorCode = isSakupaError(e) ? e.code : "internal";
|
|
1270
1474
|
const retryable = errorCode === "rate_limited" || errorCode === "internal";
|
|
@@ -1283,7 +1487,7 @@ function toolError(e) {
|
|
|
1283
1487
|
)
|
|
1284
1488
|
) : void 0;
|
|
1285
1489
|
const minimumVersion = rawDetails && typeof rawDetails["minimumVersion"] === "string" ? rawDetails["minimumVersion"] : void 0;
|
|
1286
|
-
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
|
|
1490
|
+
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.");
|
|
1287
1491
|
const result = structuredToolResult({
|
|
1288
1492
|
schemaVersion: 1,
|
|
1289
1493
|
outcome: "failed",
|
|
@@ -1322,12 +1526,12 @@ ${JSON.stringify(obj, null, 2)}`;
|
|
|
1322
1526
|
nextActions: []
|
|
1323
1527
|
});
|
|
1324
1528
|
}
|
|
1325
|
-
var planEnum =
|
|
1326
|
-
var severityEnum =
|
|
1529
|
+
var planEnum = z3.enum(["water", "personal", "share", "business"]);
|
|
1530
|
+
var severityEnum = z3.enum(["low", "medium", "high", "critical"]);
|
|
1327
1531
|
function planCatalog() {
|
|
1328
1532
|
return TIER_ORDER.map((p) => `${p} JPY ${tierPriceJpy(p)}/month`).join(", ");
|
|
1329
1533
|
}
|
|
1330
|
-
var ticketCategoryEnum =
|
|
1534
|
+
var ticketCategoryEnum = z3.enum([
|
|
1331
1535
|
"billing",
|
|
1332
1536
|
"payment",
|
|
1333
1537
|
"refund_review",
|
|
@@ -1375,7 +1579,7 @@ function ensureUploadSizeWithinLimits(manifest, isFirstFreeDeploy) {
|
|
|
1375
1579
|
async function buildHashedManifest(files, outputAbs) {
|
|
1376
1580
|
const manifest = [];
|
|
1377
1581
|
for (const file of files) {
|
|
1378
|
-
const bytes = new Uint8Array(await fs2.readFile(
|
|
1582
|
+
const bytes = new Uint8Array(await fs2.readFile(join4(outputAbs, file.path)));
|
|
1379
1583
|
manifest.push({ path: file.path, size: file.size, contentHash: await sha256Hex(bytes) });
|
|
1380
1584
|
}
|
|
1381
1585
|
return manifest;
|
|
@@ -1394,7 +1598,7 @@ async function uploadAll(ctx, targets, files, outputAbs) {
|
|
|
1394
1598
|
`No local file matches upload target "${target.path}"; aborting upload.`
|
|
1395
1599
|
);
|
|
1396
1600
|
}
|
|
1397
|
-
const bytes = new Uint8Array(await fs2.readFile(
|
|
1601
|
+
const bytes = new Uint8Array(await fs2.readFile(join4(outputAbs, match.path)));
|
|
1398
1602
|
if (bytes.byteLength !== match.size) {
|
|
1399
1603
|
throw new SakupaError(
|
|
1400
1604
|
"validation_failed",
|
|
@@ -1405,8 +1609,25 @@ async function uploadAll(ctx, targets, files, outputAbs) {
|
|
|
1405
1609
|
}
|
|
1406
1610
|
return targets.length;
|
|
1407
1611
|
}
|
|
1408
|
-
|
|
1409
|
-
|
|
1612
|
+
var DNS_RETRY_AFTER_SECONDS = 300;
|
|
1613
|
+
var DNS_MAX_ATTEMPTS = 10;
|
|
1614
|
+
function freeSiteCreationBarrier() {
|
|
1615
|
+
const recent = listRecentCreations(Date.now());
|
|
1616
|
+
if (recent.length < FREE_ACTIVE_SITES_PER_IP) return null;
|
|
1617
|
+
const registryPath = creationRegistryPath();
|
|
1618
|
+
return text(
|
|
1619
|
+
"local_site_limit_reached",
|
|
1620
|
+
`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.
|
|
1621
|
+
|
|
1622
|
+
` + recent.map((r) => `- ${r.url} (project: ${r.projectDir}, created: ${r.createdAt})`).join("\n") + `
|
|
1623
|
+
|
|
1624
|
+
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.`,
|
|
1625
|
+
{ recentCreations: recent, limit: FREE_ACTIVE_SITES_PER_IP, registryPath },
|
|
1626
|
+
"blocked"
|
|
1627
|
+
);
|
|
1628
|
+
}
|
|
1629
|
+
function registerTools(server, baseCtx) {
|
|
1630
|
+
const previewHostPattern = previewHostPatternFor(baseCtx.apiBaseUrl);
|
|
1410
1631
|
server.registerTool(
|
|
1411
1632
|
"analyze_site",
|
|
1412
1633
|
{
|
|
@@ -1414,11 +1635,13 @@ function registerTools(server, ctx) {
|
|
|
1414
1635
|
outputSchema: STRUCTURED_TOOL_OUTPUT_SCHEMA,
|
|
1415
1636
|
annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: false },
|
|
1416
1637
|
inputSchema: {
|
|
1417
|
-
|
|
1638
|
+
projectDir: projectDirInput,
|
|
1639
|
+
outputDir: z3.string().optional().describe("Output directory relative to the project root (overrides detection).")
|
|
1418
1640
|
}
|
|
1419
1641
|
},
|
|
1420
1642
|
async (args) => {
|
|
1421
1643
|
try {
|
|
1644
|
+
const ctx = withProjectDir(baseCtx, args.projectDir);
|
|
1422
1645
|
const analysis = await analyzeProject(ctx.projectDir, {
|
|
1423
1646
|
...args.outputDir !== void 0 ? { outputDir: args.outputDir } : {}
|
|
1424
1647
|
});
|
|
@@ -1440,18 +1663,20 @@ Next action: ${analysis.suggestedNextAction}`,
|
|
|
1440
1663
|
outputSchema: STRUCTURED_TOOL_OUTPUT_SCHEMA,
|
|
1441
1664
|
annotations: { readOnlyHint: false, destructiveHint: true, openWorldHint: true },
|
|
1442
1665
|
inputSchema: {
|
|
1443
|
-
|
|
1444
|
-
|
|
1666
|
+
projectDir: projectDirInput,
|
|
1667
|
+
outputDir: z3.string().optional().describe("Output directory relative to the project root (overrides detection)."),
|
|
1668
|
+
spaFallback: z3.boolean().optional().describe(
|
|
1445
1669
|
"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."
|
|
1446
1670
|
),
|
|
1447
|
-
publicConfirmed:
|
|
1671
|
+
publicConfirmed: z3.boolean().optional().describe(
|
|
1448
1672
|
"Required only for the first deployment: user explicitly confirmed creation of a public 24-hour URL."
|
|
1449
1673
|
),
|
|
1450
|
-
lang:
|
|
1674
|
+
lang: z3.string().optional().describe("Site language override (en | ja | zh-CN); defaults to the html lang.")
|
|
1451
1675
|
}
|
|
1452
1676
|
},
|
|
1453
1677
|
async (args) => {
|
|
1454
1678
|
try {
|
|
1679
|
+
const ctx = withProjectDir(baseCtx, args.projectDir);
|
|
1455
1680
|
const analysis = await analyzeProject(ctx.projectDir, {
|
|
1456
1681
|
...args.outputDir !== void 0 ? { outputDir: args.outputDir } : {}
|
|
1457
1682
|
});
|
|
@@ -1459,7 +1684,7 @@ Next action: ${analysis.suggestedNextAction}`,
|
|
|
1459
1684
|
return notDeployableResult(analysis);
|
|
1460
1685
|
}
|
|
1461
1686
|
const files = analysis.files;
|
|
1462
|
-
const outputAbs =
|
|
1687
|
+
const outputAbs = resolve3(ctx.projectDir, analysis.recommendedOutputDir ?? ".");
|
|
1463
1688
|
const manifest = await buildHashedManifest(files, outputAbs);
|
|
1464
1689
|
const siteFileState = loadSiteFile(ctx.projectDir);
|
|
1465
1690
|
if (siteFileState.kind === "corrupted") {
|
|
@@ -1473,6 +1698,10 @@ Next action: ${analysis.suggestedNextAction}`,
|
|
|
1473
1698
|
);
|
|
1474
1699
|
}
|
|
1475
1700
|
const existing = siteFileState.kind === "ok" ? siteFileState.file : null;
|
|
1701
|
+
if (!existing) {
|
|
1702
|
+
const barrier = freeSiteCreationBarrier();
|
|
1703
|
+
if (barrier) return barrier;
|
|
1704
|
+
}
|
|
1476
1705
|
if (!existing && args.publicConfirmed !== true) {
|
|
1477
1706
|
return text(
|
|
1478
1707
|
"public_deployment_confirmation_required",
|
|
@@ -1494,17 +1723,26 @@ Next action: ${analysis.suggestedNextAction}`,
|
|
|
1494
1723
|
created.siteId,
|
|
1495
1724
|
created.credential
|
|
1496
1725
|
);
|
|
1726
|
+
const createdAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
1497
1727
|
writeSiteFile(ctx.projectDir, {
|
|
1498
1728
|
siteId: created.siteId,
|
|
1499
1729
|
shortId: created.shortId,
|
|
1500
1730
|
url: finalized2.url,
|
|
1501
1731
|
credential: created.credential,
|
|
1502
|
-
createdAt
|
|
1732
|
+
createdAt,
|
|
1503
1733
|
apiBaseUrl: ctx.apiBaseUrl
|
|
1504
1734
|
});
|
|
1735
|
+
recordCreation({
|
|
1736
|
+
siteId: created.siteId,
|
|
1737
|
+
projectDir: ctx.projectDir,
|
|
1738
|
+
url: finalized2.url,
|
|
1739
|
+
createdAt
|
|
1740
|
+
});
|
|
1505
1741
|
return text(
|
|
1506
1742
|
"site_published",
|
|
1507
1743
|
`Site published: ${finalized2.url}
|
|
1744
|
+
Environment: ${environmentFor(ctx.apiBaseUrl).toUpperCase()} (${ctx.apiBaseUrl})
|
|
1745
|
+
Project directory: ${ctx.projectDir}
|
|
1508
1746
|
Files uploaded: ${uploaded2} (${finalized2.totalBytes} bytes)
|
|
1509
1747
|
` + (finalized2.expiresAt ? `Expires at: ${finalized2.expiresAt}
|
|
1510
1748
|
` : "") + `
|
|
@@ -1522,7 +1760,9 @@ ${JSON.stringify(finalized2.warnings, null, 2)}` : ""),
|
|
|
1522
1760
|
filesUploaded: uploaded2,
|
|
1523
1761
|
totalBytes: finalized2.totalBytes,
|
|
1524
1762
|
warnings: finalized2.warnings,
|
|
1525
|
-
credentialStoredLocally: true
|
|
1763
|
+
credentialStoredLocally: true,
|
|
1764
|
+
projectDir: ctx.projectDir,
|
|
1765
|
+
environment: environmentFor(ctx.apiBaseUrl)
|
|
1526
1766
|
}
|
|
1527
1767
|
);
|
|
1528
1768
|
}
|
|
@@ -1568,6 +1808,8 @@ ${JSON.stringify(finalized2.warnings, null, 2)}` : ""),
|
|
|
1568
1808
|
return text(
|
|
1569
1809
|
"site_updated",
|
|
1570
1810
|
`Site updated: ${finalized.url}
|
|
1811
|
+
Environment: ${environmentFor(ctx.apiBaseUrl).toUpperCase()} (${ctx.apiBaseUrl})
|
|
1812
|
+
Project directory: ${ctx.projectDir}
|
|
1571
1813
|
Files uploaded: ${uploaded} (${finalized.totalBytes} bytes)
|
|
1572
1814
|
` + (finalized.expiresAt ? `Validity refreshed \u2014 expires at: ${finalized.expiresAt}
|
|
1573
1815
|
` : "") + (finalized.mode === "free" ? `
|
|
@@ -1579,6 +1821,8 @@ ${JSON.stringify(finalized.warnings, null, 2)}` : ""),
|
|
|
1579
1821
|
siteId: existing.siteId,
|
|
1580
1822
|
url: finalized.url,
|
|
1581
1823
|
mode: finalized.mode,
|
|
1824
|
+
projectDir: ctx.projectDir,
|
|
1825
|
+
environment: environmentFor(ctx.apiBaseUrl),
|
|
1582
1826
|
expiresAt: finalized.expiresAt,
|
|
1583
1827
|
filesUploaded: uploaded,
|
|
1584
1828
|
totalBytes: finalized.totalBytes,
|
|
@@ -1596,17 +1840,18 @@ ${JSON.stringify(finalized.warnings, null, 2)}` : ""),
|
|
|
1596
1840
|
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.",
|
|
1597
1841
|
outputSchema: STRUCTURED_TOOL_OUTPUT_SCHEMA,
|
|
1598
1842
|
annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: true },
|
|
1599
|
-
inputSchema: {}
|
|
1843
|
+
inputSchema: { projectDir: projectDirInput }
|
|
1600
1844
|
},
|
|
1601
|
-
async () => {
|
|
1845
|
+
async (args) => {
|
|
1602
1846
|
try {
|
|
1847
|
+
const ctx = withProjectDir(baseCtx, args.projectDir);
|
|
1603
1848
|
const site = requireSiteFile(ctx);
|
|
1604
1849
|
const res = await ctx.client.refreshSite(site.siteId, site.credential);
|
|
1605
1850
|
return text(
|
|
1606
1851
|
"site_refreshed",
|
|
1607
|
-
`Site validity refreshed. New expiry: ${res.expiresAt}
|
|
1852
|
+
`Site validity refreshed (project: ${ctx.projectDir}). New expiry: ${res.expiresAt}
|
|
1608
1853
|
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.`,
|
|
1609
|
-
{ siteId: site.siteId, expiresAt: res.expiresAt }
|
|
1854
|
+
{ siteId: site.siteId, expiresAt: res.expiresAt, projectDir: ctx.projectDir }
|
|
1610
1855
|
);
|
|
1611
1856
|
} catch (e) {
|
|
1612
1857
|
return toolError(e);
|
|
@@ -1619,13 +1864,17 @@ NO content was uploaded or changed by this call \u2014 to publish new or edited
|
|
|
1619
1864
|
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.",
|
|
1620
1865
|
outputSchema: STRUCTURED_TOOL_OUTPUT_SCHEMA,
|
|
1621
1866
|
annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: true },
|
|
1622
|
-
inputSchema: {}
|
|
1867
|
+
inputSchema: { projectDir: projectDirInput }
|
|
1623
1868
|
},
|
|
1624
|
-
async () => {
|
|
1869
|
+
async (args) => {
|
|
1625
1870
|
try {
|
|
1871
|
+
const ctx = withProjectDir(baseCtx, args.projectDir);
|
|
1626
1872
|
const site = requireSiteFile(ctx);
|
|
1627
1873
|
const res = await ctx.client.getSiteStatus(site.siteId, site.credential);
|
|
1628
|
-
return textJson("site_status_returned", "Site status:",
|
|
1874
|
+
return textJson("site_status_returned", "Site status:", {
|
|
1875
|
+
...res,
|
|
1876
|
+
projectDir: ctx.projectDir
|
|
1877
|
+
});
|
|
1629
1878
|
} catch (e) {
|
|
1630
1879
|
return toolError(e);
|
|
1631
1880
|
}
|
|
@@ -1638,6 +1887,7 @@ NO content was uploaded or changed by this call \u2014 to publish new or edited
|
|
|
1638
1887
|
outputSchema: STRUCTURED_TOOL_OUTPUT_SCHEMA,
|
|
1639
1888
|
annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: true },
|
|
1640
1889
|
inputSchema: {
|
|
1890
|
+
projectDir: projectDirInput,
|
|
1641
1891
|
plan: planEnum.describe(
|
|
1642
1892
|
"Monthly plan: water (very light personal pages), personal (personal brand / small shop), share (small-business site), business (steadier traffic, more headroom)."
|
|
1643
1893
|
)
|
|
@@ -1645,6 +1895,7 @@ NO content was uploaded or changed by this call \u2014 to publish new or edited
|
|
|
1645
1895
|
},
|
|
1646
1896
|
async (args) => {
|
|
1647
1897
|
try {
|
|
1898
|
+
const ctx = withProjectDir(baseCtx, args.projectDir);
|
|
1648
1899
|
const site = requireSiteFile(ctx);
|
|
1649
1900
|
const res = await ctx.client.createPlanCheckout(
|
|
1650
1901
|
{
|
|
@@ -1683,39 +1934,62 @@ Once payment confirms, the site becomes permanent on its current URL. Binding a
|
|
|
1683
1934
|
outputSchema: STRUCTURED_TOOL_OUTPUT_SCHEMA,
|
|
1684
1935
|
annotations: { readOnlyHint: false, destructiveHint: true, openWorldHint: true },
|
|
1685
1936
|
inputSchema: {
|
|
1686
|
-
|
|
1687
|
-
|
|
1688
|
-
|
|
1937
|
+
projectDir: projectDirInput,
|
|
1938
|
+
action: z3.enum(["start", "status"]),
|
|
1939
|
+
hostname: z3.string().optional().describe("Required for start."),
|
|
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
|
+
)
|
|
1689
1943
|
}
|
|
1690
1944
|
},
|
|
1691
1945
|
async (args) => {
|
|
1692
1946
|
try {
|
|
1947
|
+
const ctx = withProjectDir(baseCtx, args.projectDir);
|
|
1693
1948
|
const site = requireSiteFile(ctx);
|
|
1694
1949
|
if (args.action === "status") {
|
|
1695
|
-
|
|
1696
|
-
throw new SakupaError("invalid_request", "verificationId is required for status");
|
|
1697
|
-
}
|
|
1698
|
-
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);
|
|
1699
1951
|
if (res2.status === "verified") {
|
|
1700
1952
|
writeSiteFile(ctx.projectDir, { ...site, boundDomain: res2.apexDomain });
|
|
1701
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
|
+
});
|
|
1702
1963
|
return text(
|
|
1703
1964
|
res2.status === "verified" ? "domain_verification_succeeded" : "domain_verification_pending",
|
|
1704
|
-
`
|
|
1965
|
+
`Domain binding status for ${apex2}: ${res2.status}
|
|
1705
1966
|
${res2.message}
|
|
1706
|
-
|
|
1707
|
-
|
|
1708
|
-
|
|
1709
|
-
|
|
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."),
|
|
1710
1974
|
{
|
|
1711
1975
|
verificationId: res2.verificationId,
|
|
1712
1976
|
status: res2.status,
|
|
1713
|
-
apexDomain:
|
|
1977
|
+
apexDomain: apex2,
|
|
1714
1978
|
provisioningJobId: res2.provisioningJobId,
|
|
1715
|
-
|
|
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,
|
|
1716
1990
|
message: res2.message
|
|
1717
1991
|
},
|
|
1718
|
-
res2.status === "verified" ? "
|
|
1992
|
+
res2.status === "verified" ? "pending_provider" : "waiting_user"
|
|
1719
1993
|
);
|
|
1720
1994
|
}
|
|
1721
1995
|
if (!args.hostname) {
|
|
@@ -1726,25 +2000,42 @@ ${JSON.stringify(res2.pendingDnsRecords, null, 2)}` : ""),
|
|
|
1726
2000
|
hostname: args.hostname
|
|
1727
2001
|
};
|
|
1728
2002
|
const res = await ctx.client.bindDomain(site.credential, req);
|
|
2003
|
+
const apex = res.apexDomain;
|
|
2004
|
+
const txtShort = shortHostFor(res.verificationRecord.name, apex);
|
|
1729
2005
|
return text(
|
|
1730
2006
|
"domain_verification_started",
|
|
1731
|
-
`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):
|
|
2010
|
+
|
|
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).
|
|
1732
2014
|
|
|
1733
|
-
|
|
1734
|
-
name: ${res.verificationRecord.name}
|
|
1735
|
-
type: ${res.verificationRecord.type}
|
|
1736
|
-
value: ${res.verificationRecord.value}
|
|
1737
|
-
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.
|
|
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.
|
|
1738
2016
|
|
|
1739
|
-
|
|
2017
|
+
Ownership comes ONLY from DNS control; paying never grants it. The first verified request wins and this challenge expires after 72 hours.
|
|
1740
2018
|
|
|
1741
|
-
Then run bind_domain
|
|
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.`,
|
|
1742
2020
|
{
|
|
1743
2021
|
verificationId: res.verificationId,
|
|
1744
|
-
apexDomain:
|
|
2022
|
+
apexDomain: apex,
|
|
1745
2023
|
includedHostnames: res.includedHostnames,
|
|
1746
2024
|
verificationRecord: res.verificationRecord,
|
|
1747
|
-
|
|
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
|
|
1748
2039
|
},
|
|
1749
2040
|
"waiting_user"
|
|
1750
2041
|
);
|
|
@@ -1759,10 +2050,11 @@ Then run bind_domain again with verificationId: "${res.verificationId}" to check
|
|
|
1759
2050
|
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).",
|
|
1760
2051
|
outputSchema: STRUCTURED_TOOL_OUTPUT_SCHEMA,
|
|
1761
2052
|
annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: true },
|
|
1762
|
-
inputSchema: {}
|
|
2053
|
+
inputSchema: { projectDir: projectDirInput }
|
|
1763
2054
|
},
|
|
1764
|
-
async () => {
|
|
2055
|
+
async (args) => {
|
|
1765
2056
|
try {
|
|
2057
|
+
const ctx = withProjectDir(baseCtx, args.projectDir);
|
|
1766
2058
|
const site = requireSiteFile(ctx);
|
|
1767
2059
|
const res = await ctx.client.getBillingStatus(site.siteId, site.credential);
|
|
1768
2060
|
const lines = [
|
|
@@ -1792,11 +2084,13 @@ Full status:`, res);
|
|
|
1792
2084
|
outputSchema: STRUCTURED_TOOL_OUTPUT_SCHEMA,
|
|
1793
2085
|
annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: true },
|
|
1794
2086
|
inputSchema: {
|
|
1795
|
-
|
|
2087
|
+
projectDir: projectDirInput,
|
|
2088
|
+
scope: z3.enum(["site", "public_recovery"])
|
|
1796
2089
|
}
|
|
1797
2090
|
},
|
|
1798
2091
|
async (args) => {
|
|
1799
2092
|
try {
|
|
2093
|
+
const ctx = withProjectDir(baseCtx, args.projectDir);
|
|
1800
2094
|
if (args.scope === "site") {
|
|
1801
2095
|
const site = requireSiteFile(ctx);
|
|
1802
2096
|
const res2 = await ctx.client.createBillingPortal(site.siteId, site.credential);
|
|
@@ -1847,14 +2141,16 @@ Full status:`, res);
|
|
|
1847
2141
|
outputSchema: STRUCTURED_TOOL_OUTPUT_SCHEMA,
|
|
1848
2142
|
annotations: { readOnlyHint: false, destructiveHint: true, openWorldHint: true },
|
|
1849
2143
|
inputSchema: {
|
|
1850
|
-
|
|
1851
|
-
|
|
1852
|
-
|
|
1853
|
-
|
|
2144
|
+
projectDir: projectDirInput,
|
|
2145
|
+
action: z3.enum(["start", "status", "complete"]),
|
|
2146
|
+
hostname: z3.string().optional().describe("Required for start."),
|
|
2147
|
+
verificationId: z3.string().optional().describe("Required for status or complete."),
|
|
2148
|
+
preserveExistingCredentials: z3.boolean().optional().describe("Explicitly keep old local credentials working (default: revoke them all).")
|
|
1854
2149
|
}
|
|
1855
2150
|
},
|
|
1856
2151
|
async (args) => {
|
|
1857
2152
|
try {
|
|
2153
|
+
const ctx = withProjectDir(baseCtx, args.projectDir);
|
|
1858
2154
|
if (args.action === "start") {
|
|
1859
2155
|
if (!args.hostname) {
|
|
1860
2156
|
throw new SakupaError("invalid_request", "hostname is required for start");
|
|
@@ -1952,14 +2248,16 @@ ${res.archiveUrl}`,
|
|
|
1952
2248
|
outputSchema: STRUCTURED_TOOL_OUTPUT_SCHEMA,
|
|
1953
2249
|
annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: true },
|
|
1954
2250
|
inputSchema: {
|
|
2251
|
+
projectDir: projectDirInput,
|
|
1955
2252
|
category: ticketCategoryEnum,
|
|
1956
|
-
subject:
|
|
1957
|
-
description:
|
|
1958
|
-
contactEmail:
|
|
2253
|
+
subject: z3.string().describe("Short subject line."),
|
|
2254
|
+
description: z3.string().describe("Problem description (no secrets, no card data)."),
|
|
2255
|
+
contactEmail: z3.string().optional().describe("Optional contact email for follow-up.")
|
|
1959
2256
|
}
|
|
1960
2257
|
},
|
|
1961
2258
|
async (args) => {
|
|
1962
2259
|
try {
|
|
2260
|
+
const ctx = withProjectDir(baseCtx, args.projectDir);
|
|
1963
2261
|
const site = requireSiteFile(ctx);
|
|
1964
2262
|
const res = await ctx.client.createTicket(site.credential, {
|
|
1965
2263
|
siteId: site.siteId,
|
|
@@ -1985,18 +2283,20 @@ ${res.archiveUrl}`,
|
|
|
1985
2283
|
outputSchema: STRUCTURED_TOOL_OUTPUT_SCHEMA,
|
|
1986
2284
|
annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: true },
|
|
1987
2285
|
inputSchema: {
|
|
1988
|
-
|
|
1989
|
-
|
|
1990
|
-
|
|
1991
|
-
|
|
1992
|
-
|
|
2286
|
+
projectDir: projectDirInput,
|
|
2287
|
+
toolName: z3.string().describe('The Sakupa tool that failed, e.g. "deploy_site".'),
|
|
2288
|
+
errorCode: z3.string().optional(),
|
|
2289
|
+
errorMessage: z3.string().optional().describe("Sanitized error message (no secrets)."),
|
|
2290
|
+
requestId: z3.string().optional(),
|
|
2291
|
+
deploymentId: z3.string().optional(),
|
|
1993
2292
|
severity: severityEnum.optional(),
|
|
1994
|
-
description:
|
|
1995
|
-
confirmSubmit:
|
|
2293
|
+
description: z3.string().optional().describe("What happened, in the user's words (no secrets)."),
|
|
2294
|
+
confirmSubmit: z3.boolean().optional().describe("User reviewed the report payload and approved submission.")
|
|
1996
2295
|
}
|
|
1997
2296
|
},
|
|
1998
2297
|
async (args) => {
|
|
1999
2298
|
try {
|
|
2299
|
+
const ctx = withProjectDir(baseCtx, args.projectDir);
|
|
2000
2300
|
const siteState = loadSiteFile(ctx.projectDir);
|
|
2001
2301
|
const site = siteState.kind === "ok" ? siteState.file : null;
|
|
2002
2302
|
const diagnostics = {
|
|
@@ -2040,19 +2340,20 @@ Summary: ${res.sanitizedSummary}`,
|
|
|
2040
2340
|
}
|
|
2041
2341
|
|
|
2042
2342
|
// src/tools/billing.ts
|
|
2043
|
-
import { z as
|
|
2044
|
-
var plan =
|
|
2045
|
-
function registerBillingTools(server,
|
|
2343
|
+
import { z as z4 } from "zod";
|
|
2344
|
+
var plan = z4.enum(["water", "personal", "share", "business"]);
|
|
2345
|
+
function registerBillingTools(server, baseCtx) {
|
|
2046
2346
|
server.registerTool(
|
|
2047
2347
|
"list_billing_plans",
|
|
2048
2348
|
{
|
|
2049
2349
|
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.",
|
|
2050
|
-
inputSchema: {},
|
|
2350
|
+
inputSchema: { projectDir: projectDirInput },
|
|
2051
2351
|
outputSchema: STRUCTURED_TOOL_OUTPUT_SCHEMA,
|
|
2052
2352
|
annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: true }
|
|
2053
2353
|
},
|
|
2054
|
-
async () => {
|
|
2354
|
+
async (args) => {
|
|
2055
2355
|
try {
|
|
2356
|
+
const ctx = withProjectDir(baseCtx, args.projectDir);
|
|
2056
2357
|
const catalog = await ctx.client.getBillingPlanCatalog();
|
|
2057
2358
|
return structuredToolResult({
|
|
2058
2359
|
schemaVersion: 1,
|
|
@@ -2072,14 +2373,16 @@ function registerBillingTools(server, ctx) {
|
|
|
2072
2373
|
{
|
|
2073
2374
|
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.",
|
|
2074
2375
|
inputSchema: {
|
|
2376
|
+
projectDir: projectDirInput,
|
|
2075
2377
|
targetPlan: plan,
|
|
2076
|
-
operationId:
|
|
2378
|
+
operationId: z4.string().min(1)
|
|
2077
2379
|
},
|
|
2078
2380
|
outputSchema: STRUCTURED_TOOL_OUTPUT_SCHEMA,
|
|
2079
2381
|
annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: true }
|
|
2080
2382
|
},
|
|
2081
2383
|
async (args) => {
|
|
2082
2384
|
try {
|
|
2385
|
+
const ctx = withProjectDir(baseCtx, args.projectDir);
|
|
2083
2386
|
const site = requireSiteFile(ctx);
|
|
2084
2387
|
const result = await ctx.client.changeSubscriptionPlan(site.credential, {
|
|
2085
2388
|
siteId: site.siteId,
|
|
@@ -2109,39 +2412,40 @@ function registerBillingTools(server, ctx) {
|
|
|
2109
2412
|
}
|
|
2110
2413
|
|
|
2111
2414
|
// src/tools/lifecycle.ts
|
|
2112
|
-
import { z as
|
|
2113
|
-
var deleteConfirmation =
|
|
2114
|
-
siteId:
|
|
2115
|
-
expectedSiteUpdatedAt:
|
|
2116
|
-
expectedStatus:
|
|
2117
|
-
expectedMode:
|
|
2118
|
-
expectedServingMode:
|
|
2119
|
-
expectedShortId:
|
|
2120
|
-
expectedSubscriptionStatus:
|
|
2121
|
-
expectedPlan:
|
|
2122
|
-
expectedCancelAtPeriodEnd:
|
|
2123
|
-
expectedCurrentPeriodEnd:
|
|
2124
|
-
expectedLastDeploymentId:
|
|
2125
|
-
expectedBoundHostnames:
|
|
2126
|
-
acknowledge:
|
|
2415
|
+
import { z as z5 } from "zod";
|
|
2416
|
+
var deleteConfirmation = z5.object({
|
|
2417
|
+
siteId: z5.string().min(1),
|
|
2418
|
+
expectedSiteUpdatedAt: z5.string().datetime(),
|
|
2419
|
+
expectedStatus: z5.enum(["active", "expired", "deleted"]),
|
|
2420
|
+
expectedMode: z5.enum(["free", "paid"]),
|
|
2421
|
+
expectedServingMode: z5.enum(["normal", "over_limit_notice", "risk_notice", "stopped"]),
|
|
2422
|
+
expectedShortId: z5.string().optional(),
|
|
2423
|
+
expectedSubscriptionStatus: z5.enum(["incomplete", "active", "past_due", "canceled"]).optional(),
|
|
2424
|
+
expectedPlan: z5.enum(["water", "personal", "share", "business"]).optional(),
|
|
2425
|
+
expectedCancelAtPeriodEnd: z5.boolean().optional(),
|
|
2426
|
+
expectedCurrentPeriodEnd: z5.string().datetime().optional(),
|
|
2427
|
+
expectedLastDeploymentId: z5.string().optional(),
|
|
2428
|
+
expectedBoundHostnames: z5.array(z5.string()),
|
|
2429
|
+
acknowledge: z5.literal("delete_site_and_cancel_renewal")
|
|
2127
2430
|
});
|
|
2128
|
-
var unbindConfirmation =
|
|
2129
|
-
siteId:
|
|
2130
|
-
bindingId:
|
|
2131
|
-
expectedBindingUpdatedAt:
|
|
2132
|
-
expectedBindingStatus:
|
|
2133
|
-
apexDomain:
|
|
2134
|
-
expectedBoundHostnames:
|
|
2135
|
-
acknowledge:
|
|
2431
|
+
var unbindConfirmation = z5.object({
|
|
2432
|
+
siteId: z5.string().min(1),
|
|
2433
|
+
bindingId: z5.string().min(1),
|
|
2434
|
+
expectedBindingUpdatedAt: z5.string().datetime(),
|
|
2435
|
+
expectedBindingStatus: z5.enum(["provisioning", "active"]),
|
|
2436
|
+
apexDomain: z5.string().min(1),
|
|
2437
|
+
expectedBoundHostnames: z5.array(z5.string()),
|
|
2438
|
+
acknowledge: z5.literal("unbind_domain_and_remove_custom_hostnames")
|
|
2136
2439
|
});
|
|
2137
|
-
function registerLifecycleTools(server,
|
|
2440
|
+
function registerLifecycleTools(server, baseCtx) {
|
|
2138
2441
|
server.registerTool(
|
|
2139
2442
|
"delete_site",
|
|
2140
2443
|
{
|
|
2141
2444
|
description: "Preview or execute deletion of this Sakupa site. Execution requires an exact server-validated confirmation bound to the current site state.",
|
|
2142
2445
|
inputSchema: {
|
|
2143
|
-
|
|
2144
|
-
|
|
2446
|
+
projectDir: projectDirInput,
|
|
2447
|
+
action: z5.enum(["preview", "confirm"]),
|
|
2448
|
+
operationId: z5.string().min(1).optional(),
|
|
2145
2449
|
confirmation: deleteConfirmation.optional()
|
|
2146
2450
|
},
|
|
2147
2451
|
outputSchema: STRUCTURED_TOOL_OUTPUT_SCHEMA,
|
|
@@ -2149,6 +2453,7 @@ function registerLifecycleTools(server, ctx) {
|
|
|
2149
2453
|
},
|
|
2150
2454
|
async (args) => {
|
|
2151
2455
|
try {
|
|
2456
|
+
const ctx = withProjectDir(baseCtx, args.projectDir);
|
|
2152
2457
|
const site = requireSiteFile(ctx);
|
|
2153
2458
|
if (!args.operationId) {
|
|
2154
2459
|
throw new Error("operationId is required for delete_site");
|
|
@@ -2175,13 +2480,14 @@ function registerLifecycleTools(server, ctx) {
|
|
|
2175
2480
|
confirmation: args.confirmation
|
|
2176
2481
|
});
|
|
2177
2482
|
deleteSiteFile(ctx.projectDir);
|
|
2483
|
+
removeCreation(site.siteId);
|
|
2178
2484
|
return structuredToolResult({
|
|
2179
2485
|
schemaVersion: 1,
|
|
2180
2486
|
outcome: result.servingDeletionPending ? "pending_provider" : "completed",
|
|
2181
2487
|
resultCode: "site_deleted",
|
|
2182
2488
|
operationId: args.operationId,
|
|
2183
|
-
summary:
|
|
2184
|
-
data: { result },
|
|
2489
|
+
summary: `Site deleted; the local management credential file was removed from ${ctx.projectDir}.`,
|
|
2490
|
+
data: { result, projectDir: ctx.projectDir },
|
|
2185
2491
|
nextActions: []
|
|
2186
2492
|
});
|
|
2187
2493
|
} catch (error) {
|
|
@@ -2194,8 +2500,9 @@ function registerLifecycleTools(server, ctx) {
|
|
|
2194
2500
|
{
|
|
2195
2501
|
description: "Preview or execute removal of the custom apex/www serving surface while preserving the subscription and permanent Sakupa URL.",
|
|
2196
2502
|
inputSchema: {
|
|
2197
|
-
|
|
2198
|
-
|
|
2503
|
+
projectDir: projectDirInput,
|
|
2504
|
+
action: z5.enum(["preview", "confirm"]),
|
|
2505
|
+
operationId: z5.string().min(1).optional(),
|
|
2199
2506
|
confirmation: unbindConfirmation.optional()
|
|
2200
2507
|
},
|
|
2201
2508
|
outputSchema: STRUCTURED_TOOL_OUTPUT_SCHEMA,
|
|
@@ -2203,6 +2510,7 @@ function registerLifecycleTools(server, ctx) {
|
|
|
2203
2510
|
},
|
|
2204
2511
|
async (args) => {
|
|
2205
2512
|
try {
|
|
2513
|
+
const ctx = withProjectDir(baseCtx, args.projectDir);
|
|
2206
2514
|
const site = requireSiteFile(ctx);
|
|
2207
2515
|
if (!args.operationId) throw new Error("operationId is required for unbind_domain");
|
|
2208
2516
|
if (args.action === "preview") {
|
|
@@ -2233,7 +2541,7 @@ function registerLifecycleTools(server, ctx) {
|
|
|
2233
2541
|
outcome: result.servingDeletionPending ? "pending_provider" : "completed",
|
|
2234
2542
|
resultCode: "domain_unbound",
|
|
2235
2543
|
operationId: args.operationId,
|
|
2236
|
-
summary:
|
|
2544
|
+
summary: `Custom domain unbound; the subscription, deployed content, and permanent Sakupa URL are unchanged. (project: ${ctx.projectDir})`,
|
|
2237
2545
|
data: { result },
|
|
2238
2546
|
nextActions: [{ tool: "site_status", allowed: true }]
|
|
2239
2547
|
});
|
|
@@ -2346,6 +2654,15 @@ Workflow:
|
|
|
2346
2654
|
5. create_support_ticket (subscribed sites) opens a support ticket; report_bug sends a
|
|
2347
2655
|
sanitized diagnostic report after the user explicitly confirms it.
|
|
2348
2656
|
|
|
2657
|
+
Project directory contract: ONE directory = ONE site (its .sakupa/site.json holds the
|
|
2658
|
+
binding). Every project-scoped tool accepts projectDir \u2014 ALWAYS pass the absolute path of
|
|
2659
|
+
the directory the user is currently working in, on every call. Without it the server falls
|
|
2660
|
+
back to its startup directory, which may be a different project than the one the user is
|
|
2661
|
+
looking at. After every deploy, TELL the user which environment it went to (deploy results carry an
|
|
2662
|
+
Explicit Environment line: TEST vs PRODUCTION). analyze_site, deploy_site, site_status,
|
|
2663
|
+
refresh_site, delete_site and unbind_domain echo
|
|
2664
|
+
the directory they acted on \u2014 verify it matches the user's active project.
|
|
2665
|
+
|
|
2349
2666
|
Safety boundaries:
|
|
2350
2667
|
- Static output only: no SSR, API routes, middleware, server actions, databases or online builds.
|
|
2351
2668
|
- Never upload source projects, secrets, .env files, private keys, archives, videos or audio.
|
|
@@ -2398,7 +2715,7 @@ async function main() {
|
|
|
2398
2715
|
const transport = new StdioServerTransport();
|
|
2399
2716
|
await server.connect(transport);
|
|
2400
2717
|
console.error(
|
|
2401
|
-
`[sakupa-mcp] v${MCP_VERSION} connected (api: ${config.apiBaseUrl}, project: ${config.projectDir})`
|
|
2718
|
+
`[sakupa-mcp] v${MCP_VERSION} connected (api: ${config.apiBaseUrl}, default project: ${config.projectDir}; tools accept per-call projectDir)`
|
|
2402
2719
|
);
|
|
2403
2720
|
}
|
|
2404
2721
|
main().catch((err) => {
|