@sakupa/mcp 0.1.0 → 0.3.0
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 +188 -211
- package/dist/index.js +240 -263
- package/package.json +1 -1
package/dist/index.js
CHANGED
|
@@ -1,59 +1,7 @@
|
|
|
1
1
|
// src/version.ts
|
|
2
|
-
var MCP_VERSION = "0.
|
|
2
|
+
var MCP_VERSION = "0.3.0";
|
|
3
3
|
var CLIENT_TYPE = "sakupa-mcp";
|
|
4
4
|
|
|
5
|
-
// src/transport.ts
|
|
6
|
-
var FetchTransport = class {
|
|
7
|
-
baseUrl;
|
|
8
|
-
constructor(baseUrl) {
|
|
9
|
-
this.baseUrl = baseUrl.replace(/\/+$/, "");
|
|
10
|
-
}
|
|
11
|
-
async request(req) {
|
|
12
|
-
let url = `${this.baseUrl}${req.path}`;
|
|
13
|
-
if (req.query && Object.keys(req.query).length > 0) {
|
|
14
|
-
url += `?${new URLSearchParams(req.query).toString()}`;
|
|
15
|
-
}
|
|
16
|
-
const headers = {
|
|
17
|
-
accept: "application/json",
|
|
18
|
-
...req.body !== void 0 ? { "content-type": "application/json" } : {},
|
|
19
|
-
...req.headers
|
|
20
|
-
};
|
|
21
|
-
const res = await fetch(url, {
|
|
22
|
-
method: req.method,
|
|
23
|
-
headers,
|
|
24
|
-
...req.body !== void 0 ? { body: req.body } : {}
|
|
25
|
-
});
|
|
26
|
-
const text2 = await res.text();
|
|
27
|
-
const responseHeaders = {};
|
|
28
|
-
res.headers.forEach((value, key) => {
|
|
29
|
-
responseHeaders[key] = value;
|
|
30
|
-
});
|
|
31
|
-
return {
|
|
32
|
-
status: res.status,
|
|
33
|
-
headers: responseHeaders,
|
|
34
|
-
...text2.length > 0 ? { body: text2 } : {}
|
|
35
|
-
};
|
|
36
|
-
}
|
|
37
|
-
async upload(target, body) {
|
|
38
|
-
if (target.url.startsWith("memory://")) {
|
|
39
|
-
throw new Error(
|
|
40
|
-
`Upload target "${target.url}" is an in-process memory URL. memory:// targets only exist inside the in-process test harness and cannot be uploaded to over HTTP.`
|
|
41
|
-
);
|
|
42
|
-
}
|
|
43
|
-
const res = await fetch(target.url, {
|
|
44
|
-
method: target.method,
|
|
45
|
-
headers: target.headers,
|
|
46
|
-
body
|
|
47
|
-
});
|
|
48
|
-
if (!res.ok) {
|
|
49
|
-
const text2 = await res.text().catch(() => "");
|
|
50
|
-
throw new Error(
|
|
51
|
-
`Upload of "${target.path}" failed with HTTP ${res.status}${text2 ? `: ${text2.slice(0, 200)}` : ""}`
|
|
52
|
-
);
|
|
53
|
-
}
|
|
54
|
-
}
|
|
55
|
-
};
|
|
56
|
-
|
|
57
5
|
// ../core/dist/domain/constants.js
|
|
58
6
|
var SERVICE_DOMAIN = "sakupa.com";
|
|
59
7
|
var FREE_SITE_URL_SUFFIX = `.${SERVICE_DOMAIN}`;
|
|
@@ -186,11 +134,11 @@ var HTTP_STATUS = {
|
|
|
186
134
|
conflict: 409,
|
|
187
135
|
state_conflict: 409,
|
|
188
136
|
rate_limited: 429,
|
|
189
|
-
insufficient_balance: 402,
|
|
190
137
|
payment_required: 402,
|
|
191
138
|
confirmation_required: 428,
|
|
192
139
|
dns_not_verified: 403,
|
|
193
140
|
not_deployable: 422,
|
|
141
|
+
upgrade_required: 426,
|
|
194
142
|
internal: 500
|
|
195
143
|
};
|
|
196
144
|
var SakupaError = class extends Error {
|
|
@@ -490,17 +438,80 @@ function safeDecode(bytes) {
|
|
|
490
438
|
}
|
|
491
439
|
}
|
|
492
440
|
|
|
441
|
+
// ../core/dist/domain/hashing.js
|
|
442
|
+
async function sha256Hex(bytes) {
|
|
443
|
+
const digest = await globalThis.crypto.subtle.digest("SHA-256", bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength));
|
|
444
|
+
let hex = "";
|
|
445
|
+
for (const byte of new Uint8Array(digest))
|
|
446
|
+
hex += byte.toString(16).padStart(2, "0");
|
|
447
|
+
return hex;
|
|
448
|
+
}
|
|
449
|
+
|
|
493
450
|
// ../core/dist/domain/subscription.js
|
|
494
|
-
var SUBSCRIPTION_WARNING_TEXT = "You are subscribing {
|
|
451
|
+
var SUBSCRIPTION_WARNING_TEXT = "You are subscribing this site ({siteUrl}) to Sakupa Hosting: the {plan} monthly plan (\xA5{priceJpy}/month).\n\nPaying makes THIS site permanent on its {siteUrl} address \u2014 it stops expiring. Binding a custom domain afterwards is an optional included extra: it requires proving DNS control of that domain, and whether or not you ever bind one does not change the subscription or qualify for a refund.\n\nIf this site outgrows its plan, Sakupa automatically upgrades the subscription to the next plan (water -> personal -> share -> business) and renewals bill the new plan. There is no metered overage: above the Business plan, growth is limited instead of billed further.\n\nYou can cancel anytime; when the subscription ends, the site immediately becomes a free temporary site again (24h validity) and all paid data \u2014 custom domain bindings included \u2014 is removed.";
|
|
495
452
|
|
|
496
453
|
// ../core/dist/dto.js
|
|
497
454
|
var CREDENTIAL_HEADER = "x-sakupa-credential";
|
|
498
455
|
var IDEMPOTENCY_HEADER = "x-sakupa-idempotency-key";
|
|
456
|
+
var MCP_VERSION_HEADER = "x-sakupa-mcp-version";
|
|
499
457
|
|
|
500
458
|
// ../core/dist/services/sites.js
|
|
501
459
|
var utf8Decoder = new TextDecoder("utf-8", { fatal: false });
|
|
502
460
|
var utf8Encoder = new TextEncoder();
|
|
503
461
|
|
|
462
|
+
// src/transport.ts
|
|
463
|
+
var FetchTransport = class {
|
|
464
|
+
baseUrl;
|
|
465
|
+
constructor(baseUrl) {
|
|
466
|
+
this.baseUrl = baseUrl.replace(/\/+$/, "");
|
|
467
|
+
}
|
|
468
|
+
async request(req) {
|
|
469
|
+
let url = `${this.baseUrl}${req.path}`;
|
|
470
|
+
if (req.query && Object.keys(req.query).length > 0) {
|
|
471
|
+
url += `?${new URLSearchParams(req.query).toString()}`;
|
|
472
|
+
}
|
|
473
|
+
const headers = {
|
|
474
|
+
accept: "application/json",
|
|
475
|
+
[MCP_VERSION_HEADER]: MCP_VERSION,
|
|
476
|
+
...req.body !== void 0 ? { "content-type": "application/json" } : {},
|
|
477
|
+
...req.headers
|
|
478
|
+
};
|
|
479
|
+
const res = await fetch(url, {
|
|
480
|
+
method: req.method,
|
|
481
|
+
headers,
|
|
482
|
+
...req.body !== void 0 ? { body: req.body } : {}
|
|
483
|
+
});
|
|
484
|
+
const text2 = await res.text();
|
|
485
|
+
const responseHeaders = {};
|
|
486
|
+
res.headers.forEach((value, key) => {
|
|
487
|
+
responseHeaders[key] = value;
|
|
488
|
+
});
|
|
489
|
+
return {
|
|
490
|
+
status: res.status,
|
|
491
|
+
headers: responseHeaders,
|
|
492
|
+
...text2.length > 0 ? { body: text2 } : {}
|
|
493
|
+
};
|
|
494
|
+
}
|
|
495
|
+
async upload(target, body) {
|
|
496
|
+
if (target.url.startsWith("memory://")) {
|
|
497
|
+
throw new Error(
|
|
498
|
+
`Upload target "${target.url}" is an in-process memory URL. memory:// targets only exist inside the in-process test harness and cannot be uploaded to over HTTP.`
|
|
499
|
+
);
|
|
500
|
+
}
|
|
501
|
+
const res = await fetch(target.url, {
|
|
502
|
+
method: target.method,
|
|
503
|
+
headers: target.headers,
|
|
504
|
+
body
|
|
505
|
+
});
|
|
506
|
+
if (!res.ok) {
|
|
507
|
+
const text2 = await res.text().catch(() => "");
|
|
508
|
+
throw new Error(
|
|
509
|
+
`Upload of "${target.path}" failed with HTTP ${res.status}${text2 ? `: ${text2.slice(0, 200)}` : ""}`
|
|
510
|
+
);
|
|
511
|
+
}
|
|
512
|
+
}
|
|
513
|
+
};
|
|
514
|
+
|
|
504
515
|
// src/api-client.ts
|
|
505
516
|
var KNOWN_ERROR_CODES = /* @__PURE__ */ new Set([
|
|
506
517
|
"invalid_request",
|
|
@@ -511,11 +522,11 @@ var KNOWN_ERROR_CODES = /* @__PURE__ */ new Set([
|
|
|
511
522
|
"conflict",
|
|
512
523
|
"state_conflict",
|
|
513
524
|
"rate_limited",
|
|
514
|
-
"insufficient_balance",
|
|
515
525
|
"payment_required",
|
|
516
526
|
"confirmation_required",
|
|
517
527
|
"dns_not_verified",
|
|
518
528
|
"not_deployable",
|
|
529
|
+
"upgrade_required",
|
|
519
530
|
"internal"
|
|
520
531
|
]);
|
|
521
532
|
var HttpApiClient = class {
|
|
@@ -616,14 +627,7 @@ var HttpApiClient = class {
|
|
|
616
627
|
{ body: req }
|
|
617
628
|
);
|
|
618
629
|
}
|
|
619
|
-
/**
|
|
620
|
-
async createCheckout(req) {
|
|
621
|
-
return this.call("POST", "/v1/payments/checkout", {
|
|
622
|
-
body: req,
|
|
623
|
-
idempotencyKey: req.idempotencyKey
|
|
624
|
-
});
|
|
625
|
-
}
|
|
626
|
-
/** Subscription checkout: a fixed monthly plan for one apex domain (owner-only). */
|
|
630
|
+
/** Subscription checkout: a fixed monthly plan permanentizing one site (owner-only). */
|
|
627
631
|
async createPlanCheckout(req, credential) {
|
|
628
632
|
return this.call("POST", "/v1/payments/checkout", {
|
|
629
633
|
credential,
|
|
@@ -632,31 +636,24 @@ var HttpApiClient = class {
|
|
|
632
636
|
});
|
|
633
637
|
}
|
|
634
638
|
/** Stripe-hosted billing portal (payment method, invoices, cancellation). */
|
|
635
|
-
async createBillingPortal(
|
|
639
|
+
async createBillingPortal(siteId, credential) {
|
|
636
640
|
return this.call(
|
|
637
641
|
"POST",
|
|
638
|
-
`/v1/
|
|
642
|
+
`/v1/sites/${encodeURIComponent(siteId)}/billing/portal`,
|
|
639
643
|
{ credential, body: {} }
|
|
640
644
|
);
|
|
641
645
|
}
|
|
642
|
-
async getBillingStatus(
|
|
646
|
+
async getBillingStatus(siteId, credential) {
|
|
643
647
|
return this.call(
|
|
644
648
|
"GET",
|
|
645
|
-
`/v1/
|
|
646
|
-
|
|
647
|
-
);
|
|
648
|
-
}
|
|
649
|
-
async setBillingPlan(apexDomain, credential, req) {
|
|
650
|
-
return this.call(
|
|
651
|
-
"POST",
|
|
652
|
-
`/v1/billing/${encodeURIComponent(apexDomain)}/plan`,
|
|
653
|
-
{ credential, body: req }
|
|
649
|
+
`/v1/sites/${encodeURIComponent(siteId)}/billing`,
|
|
650
|
+
{ credential }
|
|
654
651
|
);
|
|
655
652
|
}
|
|
656
|
-
async
|
|
653
|
+
async setBillingPlan(siteId, credential, req) {
|
|
657
654
|
return this.call(
|
|
658
655
|
"POST",
|
|
659
|
-
`/v1/sites/${encodeURIComponent(siteId)}/
|
|
656
|
+
`/v1/sites/${encodeURIComponent(siteId)}/billing/plan`,
|
|
660
657
|
{ credential, body: req }
|
|
661
658
|
);
|
|
662
659
|
}
|
|
@@ -702,8 +699,7 @@ function readSiteFile(projectDir) {
|
|
|
702
699
|
apiBaseUrl: typeof parsed.apiBaseUrl === "string" ? parsed.apiBaseUrl : "",
|
|
703
700
|
...typeof parsed.shortId === "string" ? { shortId: parsed.shortId } : {},
|
|
704
701
|
...typeof parsed.url === "string" ? { url: parsed.url } : {},
|
|
705
|
-
...typeof parsed.
|
|
706
|
-
...typeof parsed.billingDomainId === "string" ? { billingDomainId: parsed.billingDomainId } : {}
|
|
702
|
+
...typeof parsed.boundDomain === "string" ? { boundDomain: parsed.boundDomain } : {}
|
|
707
703
|
};
|
|
708
704
|
} catch {
|
|
709
705
|
return null;
|
|
@@ -1194,8 +1190,8 @@ var severityEnum = z.enum(["low", "medium", "high", "critical"]);
|
|
|
1194
1190
|
function planCatalog() {
|
|
1195
1191
|
return TIER_ORDER.map((p) => `${p} \xA5${tierPriceJpy(p)}/month`).join(", ");
|
|
1196
1192
|
}
|
|
1197
|
-
function subscriptionWarning(
|
|
1198
|
-
return SUBSCRIPTION_WARNING_TEXT.replaceAll("{
|
|
1193
|
+
function subscriptionWarning(siteUrl, plan) {
|
|
1194
|
+
return SUBSCRIPTION_WARNING_TEXT.replaceAll("{siteUrl}", siteUrl).replaceAll("{plan}", plan).replaceAll("{priceJpy}", String(tierPriceJpy(plan)));
|
|
1199
1195
|
}
|
|
1200
1196
|
var ticketCategoryEnum = z.enum([
|
|
1201
1197
|
"billing",
|
|
@@ -1234,6 +1230,36 @@ Please ask the user to choose, then re-run deploy_site with:
|
|
|
1234
1230
|
Output directory: "${analysis.recommendedOutputDir ?? "."}", ${analysis.fileCount} files.`
|
|
1235
1231
|
);
|
|
1236
1232
|
}
|
|
1233
|
+
var MB2 = 1024 * 1024;
|
|
1234
|
+
function ensureUploadSizeWithinLimits(manifest, isFirstFreeDeploy) {
|
|
1235
|
+
const oversized = manifest.find((f) => f.size > MAX_SINGLE_FILE_BYTES);
|
|
1236
|
+
if (oversized) {
|
|
1237
|
+
throw new SakupaError(
|
|
1238
|
+
"validation_failed",
|
|
1239
|
+
`"${oversized.path}" is ${(oversized.size / MB2).toFixed(1)}MB, over the ${Math.floor(MAX_SINGLE_FILE_BYTES / MB2)}MB per-file limit. Remove or shrink it before deploying.`
|
|
1240
|
+
);
|
|
1241
|
+
}
|
|
1242
|
+
if (isFirstFreeDeploy) {
|
|
1243
|
+
const total = manifest.reduce((sum, f) => sum + f.size, 0);
|
|
1244
|
+
if (total > FREE_SITE_MAX_TOTAL_BYTES) {
|
|
1245
|
+
throw new SakupaError(
|
|
1246
|
+
"validation_failed",
|
|
1247
|
+
`The static output is ${(total / MB2).toFixed(1)}MB, over the ${Math.floor(FREE_SITE_MAX_TOTAL_BYTES / MB2)}MB free-site limit. Reduce the output (bundle/compress assets, drop large media) or subscribe the site to a paid plan for larger sites.`
|
|
1248
|
+
);
|
|
1249
|
+
}
|
|
1250
|
+
}
|
|
1251
|
+
}
|
|
1252
|
+
async function buildHashedManifest(files, outputAbs) {
|
|
1253
|
+
const manifest = [];
|
|
1254
|
+
for (const file of files) {
|
|
1255
|
+
const bytes = new Uint8Array(await fs2.readFile(join3(outputAbs, file.path)));
|
|
1256
|
+
manifest.push({ path: file.path, size: file.size, contentHash: await sha256Hex(bytes) });
|
|
1257
|
+
}
|
|
1258
|
+
return manifest;
|
|
1259
|
+
}
|
|
1260
|
+
function isIncrementalReuseFailure(e) {
|
|
1261
|
+
return isSakupaError(e) && e.code === "state_conflict" && typeof e.details === "object" && e.details !== null && e.details.incrementalReuseFailed === true;
|
|
1262
|
+
}
|
|
1237
1263
|
async function uploadAll(ctx, targets, files, outputAbs) {
|
|
1238
1264
|
for (let i = 0; i < targets.length; i++) {
|
|
1239
1265
|
const target = targets[i];
|
|
@@ -1246,6 +1272,12 @@ async function uploadAll(ctx, targets, files, outputAbs) {
|
|
|
1246
1272
|
);
|
|
1247
1273
|
}
|
|
1248
1274
|
const bytes = new Uint8Array(await fs2.readFile(join3(outputAbs, match.path)));
|
|
1275
|
+
if (bytes.byteLength !== match.size) {
|
|
1276
|
+
throw new SakupaError(
|
|
1277
|
+
"validation_failed",
|
|
1278
|
+
`"${match.path}" changed since analysis (${match.size} -> ${bytes.byteLength} bytes). Re-run deploy_site so Sakupa re-checks the current files before uploading.`
|
|
1279
|
+
);
|
|
1280
|
+
}
|
|
1249
1281
|
await ctx.client.uploadFile(target, bytes);
|
|
1250
1282
|
}
|
|
1251
1283
|
return targets.length;
|
|
@@ -1281,7 +1313,7 @@ Next action: ${analysis.suggestedNextAction}`,
|
|
|
1281
1313
|
server.registerTool(
|
|
1282
1314
|
"deploy_site",
|
|
1283
1315
|
{
|
|
1284
|
-
description: `Deploy the local static output to Sakupa. First deploy creates a free temporary site (valid ${FREE_SITE_TTL_HOURS}h, public URL like https://{shortId}.sakupa.com) and stores the management credential in .sakupa/site.json. Later runs update the existing site
|
|
1316
|
+
description: `Deploy the local static output to Sakupa. First deploy creates a free temporary site (valid ${FREE_SITE_TTL_HOURS}h, public URL like https://{shortId}.sakupa.com) and stores the management credential in .sakupa/site.json. Later runs update the existing site (free sites also refresh their validity; subscribed sites are permanent). Runs analyze_site first and refuses to upload source projects, secrets, .env files, archives, media or server code. Never uploads anything when the analysis says the project is not deployable.`,
|
|
1285
1317
|
inputSchema: {
|
|
1286
1318
|
outputDir: z.string().optional().describe("Output directory relative to the project root (overrides detection)."),
|
|
1287
1319
|
spaFallback: z.boolean().optional().describe("Enable SPA fallback (requires spaFallbackConfirmed: true)."),
|
|
@@ -1303,9 +1335,10 @@ Next action: ${analysis.suggestedNextAction}`,
|
|
|
1303
1335
|
return notDeployableResult(analysis);
|
|
1304
1336
|
}
|
|
1305
1337
|
const files = analysis.files;
|
|
1306
|
-
const manifest = files.map((f) => ({ path: f.path, size: f.size }));
|
|
1307
1338
|
const outputAbs = resolve2(ctx.projectDir, analysis.recommendedOutputDir ?? ".");
|
|
1339
|
+
const manifest = await buildHashedManifest(files, outputAbs);
|
|
1308
1340
|
const existing = readSiteFile(ctx.projectDir);
|
|
1341
|
+
ensureUploadSizeWithinLimits(manifest, !existing);
|
|
1309
1342
|
if (!existing) {
|
|
1310
1343
|
const created = await ctx.client.createSite({
|
|
1311
1344
|
manifest,
|
|
@@ -1332,32 +1365,49 @@ Next action: ${analysis.suggestedNextAction}`,
|
|
|
1332
1365
|
Files uploaded: ${uploaded2} (${finalized2.totalBytes} bytes)
|
|
1333
1366
|
` + (finalized2.expiresAt ? `Expires at: ${finalized2.expiresAt}
|
|
1334
1367
|
` : "") + `
|
|
1335
|
-
This is a FREE temporary preview: it stays live for ${FREE_SITE_TTL_HOURS} hours. Deploying again or calling refresh_site extends the validity. The management credential was saved to .sakupa/site.json \u2014 keep that file: it is the only way to manage this
|
|
1368
|
+
This is a FREE temporary preview: it stays live for ${FREE_SITE_TTL_HOURS} hours. Deploying again or calling refresh_site extends the validity; subscribing the site (subscribe_site) makes this URL permanent. The management credential was saved to .sakupa/site.json \u2014 keep that file: it is the only way to manage this site.
|
|
1336
1369
|
` + (finalized2.warnings.length > 0 ? `
|
|
1337
1370
|
Warnings:
|
|
1338
1371
|
${JSON.stringify(finalized2.warnings, null, 2)}` : "")
|
|
1339
1372
|
);
|
|
1340
1373
|
}
|
|
1341
|
-
const
|
|
1342
|
-
|
|
1343
|
-
|
|
1344
|
-
|
|
1345
|
-
|
|
1346
|
-
|
|
1347
|
-
|
|
1348
|
-
|
|
1349
|
-
deployment.
|
|
1350
|
-
|
|
1351
|
-
|
|
1352
|
-
|
|
1374
|
+
const updateOnce = async (forceFullUpload) => {
|
|
1375
|
+
const req = {
|
|
1376
|
+
manifest,
|
|
1377
|
+
...args.lang !== void 0 ? { lang: args.lang } : {},
|
|
1378
|
+
spaFallback: args.spaFallback === true,
|
|
1379
|
+
...args.spaFallbackConfirmed !== void 0 ? { spaFallbackConfirmed: args.spaFallbackConfirmed } : {},
|
|
1380
|
+
...forceFullUpload ? { forceFullUpload: true } : {}
|
|
1381
|
+
};
|
|
1382
|
+
const deployment = await ctx.client.createDeployment(
|
|
1383
|
+
existing.siteId,
|
|
1384
|
+
existing.credential,
|
|
1385
|
+
req
|
|
1386
|
+
);
|
|
1387
|
+
const uploaded2 = await uploadAll(ctx, deployment.uploadTargets, files, outputAbs);
|
|
1388
|
+
const finalized2 = await ctx.client.finalizeDeployment(
|
|
1389
|
+
deployment.deploymentId,
|
|
1390
|
+
existing.siteId,
|
|
1391
|
+
existing.credential
|
|
1392
|
+
);
|
|
1393
|
+
return { uploaded: uploaded2, finalized: finalized2 };
|
|
1394
|
+
};
|
|
1395
|
+
let update;
|
|
1396
|
+
try {
|
|
1397
|
+
update = await updateOnce(false);
|
|
1398
|
+
} catch (e) {
|
|
1399
|
+
if (!isIncrementalReuseFailure(e)) throw e;
|
|
1400
|
+
update = await updateOnce(true);
|
|
1401
|
+
}
|
|
1402
|
+
const { uploaded, finalized } = update;
|
|
1353
1403
|
writeSiteFile(ctx.projectDir, { ...existing, url: finalized.url });
|
|
1354
1404
|
return text(
|
|
1355
1405
|
`Site updated: ${finalized.url}
|
|
1356
1406
|
Files uploaded: ${uploaded} (${finalized.totalBytes} bytes)
|
|
1357
1407
|
` + (finalized.expiresAt ? `Validity refreshed \u2014 expires at: ${finalized.expiresAt}
|
|
1358
1408
|
` : "") + (finalized.mode === "free" ? `
|
|
1359
|
-
Reminder: free sites stay live for ${FREE_SITE_TTL_HOURS} hours after the last deploy or refresh_site call.
|
|
1360
|
-
` : "") + (finalized.warnings.length > 0 ? `
|
|
1409
|
+
Reminder: free sites stay live for ${FREE_SITE_TTL_HOURS} hours after the last deploy or refresh_site call. Subscribing (subscribe_site) makes the site permanent.
|
|
1410
|
+
` : "\nThis site is subscribed and permanent \u2014 no expiry.\n") + (finalized.warnings.length > 0 ? `
|
|
1361
1411
|
Warnings:
|
|
1362
1412
|
${JSON.stringify(finalized.warnings, null, 2)}` : "")
|
|
1363
1413
|
);
|
|
@@ -1369,7 +1419,7 @@ ${JSON.stringify(finalized.warnings, null, 2)}` : "")
|
|
|
1369
1419
|
server.registerTool(
|
|
1370
1420
|
"refresh_site",
|
|
1371
1421
|
{
|
|
1372
|
-
description: "Refresh the validity of the free temporary site WITHOUT uploading content. Uses the local credential in .sakupa/site.json.",
|
|
1422
|
+
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.",
|
|
1373
1423
|
inputSchema: {}
|
|
1374
1424
|
},
|
|
1375
1425
|
async () => {
|
|
@@ -1388,7 +1438,7 @@ Free sites stay live for ${FREE_SITE_TTL_HOURS} hours after each deploy or refre
|
|
|
1388
1438
|
server.registerTool(
|
|
1389
1439
|
"site_status",
|
|
1390
1440
|
{
|
|
1391
|
-
description: "Show the current status of this project's Sakupa site: URL, mode (free/paid), expiry,
|
|
1441
|
+
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.",
|
|
1392
1442
|
inputSchema: {}
|
|
1393
1443
|
},
|
|
1394
1444
|
async () => {
|
|
@@ -1401,10 +1451,53 @@ Free sites stay live for ${FREE_SITE_TTL_HOURS} hours after each deploy or refre
|
|
|
1401
1451
|
}
|
|
1402
1452
|
}
|
|
1403
1453
|
);
|
|
1454
|
+
server.registerTool(
|
|
1455
|
+
"subscribe_site",
|
|
1456
|
+
{
|
|
1457
|
+
description: `Create a Stripe Checkout link that subscribes THIS site to a Sakupa Hosting monthly plan (${planCatalog()}). Paying makes the site PERMANENT on its {shortId}.sakupa.com URL \u2014 no more 24h expiry; that is the core value of paying. Binding a custom domain afterwards (bind_domain) is an optional included extra and requires DNS control of that domain. Owner-only: requires this project's site credential (.sakupa/site.json) \u2014 deploy_site first. If the site outgrows its plan, Sakupa auto-upgrades to the next plan (capped at Business, where growth is limited instead of billed further). Card details are entered only on the Stripe-hosted page \u2014 never through the AI tool. Without confirm: true this tool only shows the mandatory disclosure and makes no API call.`,
|
|
1458
|
+
inputSchema: {
|
|
1459
|
+
plan: planEnum.describe(
|
|
1460
|
+
"Monthly plan: water (very light personal pages), personal (personal brand / small shop), share (small-business site), business (steadier traffic, more headroom)."
|
|
1461
|
+
),
|
|
1462
|
+
confirm: z.boolean().optional().describe("User read the subscription disclosure and confirmed. Required to proceed.")
|
|
1463
|
+
}
|
|
1464
|
+
},
|
|
1465
|
+
async (args) => {
|
|
1466
|
+
try {
|
|
1467
|
+
const site = requireSiteFile(ctx);
|
|
1468
|
+
const siteUrl = site.url ?? (site.shortId ? `https://${site.shortId}.sakupa.com` : site.siteId);
|
|
1469
|
+
if (args.confirm !== true) {
|
|
1470
|
+
return text(
|
|
1471
|
+
`${subscriptionWarning(siteUrl, args.plan)}
|
|
1472
|
+
|
|
1473
|
+
No checkout link was created yet. Please show this disclosure to the user and, after their explicit confirmation, re-run subscribe_site with confirm: true.`
|
|
1474
|
+
);
|
|
1475
|
+
}
|
|
1476
|
+
const res = await ctx.client.createPlanCheckout(
|
|
1477
|
+
{
|
|
1478
|
+
siteId: site.siteId,
|
|
1479
|
+
plan: args.plan,
|
|
1480
|
+
idempotencyKey: randomUUID(),
|
|
1481
|
+
confirmPlan: true
|
|
1482
|
+
},
|
|
1483
|
+
site.credential
|
|
1484
|
+
);
|
|
1485
|
+
return text(
|
|
1486
|
+
`Stripe Checkout link \u2014 Sakupa Hosting for this site: ${res.plan} plan, \xA5${res.monthlyPriceJpy}/month
|
|
1487
|
+
${res.checkoutUrl}
|
|
1488
|
+
|
|
1489
|
+
Open this link in a browser to subscribe. Card data is entered only on the Stripe-hosted page \u2014 never give card numbers, passwords or security codes to the AI tool.
|
|
1490
|
+
Once payment confirms, the site becomes permanent on its current URL. Binding a custom domain (bind_domain) is optional and still requires DNS verification.`
|
|
1491
|
+
);
|
|
1492
|
+
} catch (e) {
|
|
1493
|
+
return toolError(e);
|
|
1494
|
+
}
|
|
1495
|
+
}
|
|
1496
|
+
);
|
|
1404
1497
|
server.registerTool(
|
|
1405
1498
|
"bind_domain",
|
|
1406
1499
|
{
|
|
1407
|
-
description:
|
|
1500
|
+
description: "Bind a custom domain to this subscribed site \u2014 an OPTIONAL extra serving surface; the permanent {shortId}.sakupa.com URL keeps working alongside it. Requires an ACTIVE subscription (subscribe_site). Ownership is proven ONLY by DNS control of the registered apex domain \u2014 payment never grants ownership. A binding request never reserves the hostname: whoever proves DNS control first gets it, and unverified requests expire after 72 hours. Call again with verificationId to check progress.",
|
|
1408
1501
|
inputSchema: {
|
|
1409
1502
|
hostname: z.string().describe('Hostname to bind, e.g. "example.com" or "www.example.com".'),
|
|
1410
1503
|
verificationId: z.string().optional().describe("Check an existing DNS verification instead of starting a new one.")
|
|
@@ -1416,12 +1509,12 @@ Free sites stay live for ${FREE_SITE_TTL_HOURS} hours after each deploy or refre
|
|
|
1416
1509
|
if (args.verificationId !== void 0) {
|
|
1417
1510
|
const res2 = await ctx.client.checkVerification(args.verificationId, site.credential);
|
|
1418
1511
|
if (res2.status === "verified") {
|
|
1419
|
-
writeSiteFile(ctx.projectDir, { ...site,
|
|
1512
|
+
writeSiteFile(ctx.projectDir, { ...site, boundDomain: res2.apexDomain });
|
|
1420
1513
|
}
|
|
1421
1514
|
return text(
|
|
1422
1515
|
`DNS verification ${res2.verificationId}: ${res2.status}
|
|
1423
1516
|
${res2.message}
|
|
1424
|
-
` + (res2.provisioningJobId ? `Provisioning started (job ${res2.provisioningJobId}). HTTPS certificate and
|
|
1517
|
+
` + (res2.provisioningJobId ? `Provisioning started (job ${res2.provisioningJobId}). HTTPS certificate and serving setup are in progress; check again with bind_domain + verificationId later.
|
|
1425
1518
|
` : "") + (res2.pendingDnsRecords.length > 0 ? `
|
|
1426
1519
|
DNS records still required:
|
|
1427
1520
|
${JSON.stringify(res2.pendingDnsRecords, null, 2)}` : "")
|
|
@@ -1432,22 +1525,15 @@ ${JSON.stringify(res2.pendingDnsRecords, null, 2)}` : "")
|
|
|
1432
1525
|
hostname: args.hostname
|
|
1433
1526
|
};
|
|
1434
1527
|
const res = await ctx.client.bindDomain(site.credential, req);
|
|
1435
|
-
writeSiteFile(ctx.projectDir, {
|
|
1436
|
-
...site,
|
|
1437
|
-
billingDomain: res.apexDomain,
|
|
1438
|
-
billingDomainId: res.billingDomainId
|
|
1439
|
-
});
|
|
1440
1528
|
const servingHint = res.isApex ? `"${res.hostname}" is an apex domain: your DNS provider must support ALIAS, ANAME, CNAME flattening, or alias records to point it at the assigned CDN domain.` : `"${res.hostname}" is a subdomain: create a CNAME record pointing it at the assigned CDN domain once provisioning completes.`;
|
|
1441
1529
|
return text(
|
|
1442
1530
|
`Domain binding started for ${res.hostname}.
|
|
1443
1531
|
|
|
1444
|
-
Billing subject: the apex domain "${res.apexDomain}". Subdomains of ${res.apexDomain} cannot be separate billing subjects \u2014 they all share this domain hosting subscription.
|
|
1445
|
-
|
|
1446
1532
|
1. Prove control of ${res.apexDomain} by creating this DNS record:
|
|
1447
1533
|
name: ${res.verificationRecord.name}
|
|
1448
1534
|
type: ${res.verificationRecord.type}
|
|
1449
1535
|
value: ${res.verificationRecord.value}
|
|
1450
|
-
Ownership
|
|
1536
|
+
Ownership comes ONLY from DNS control; paying never grants it. This request does not reserve the hostname \u2014 the first verified request wins, and this challenge expires after 72 hours.
|
|
1451
1537
|
|
|
1452
1538
|
2. Serving DNS (after verification): ${servingHint}
|
|
1453
1539
|
Server guidance: ${res.servingInstructions}
|
|
@@ -1462,33 +1548,24 @@ Then run bind_domain again with verificationId: "${res.verificationId}" to check
|
|
|
1462
1548
|
server.registerTool(
|
|
1463
1549
|
"billing_status",
|
|
1464
1550
|
{
|
|
1465
|
-
description: "Show
|
|
1466
|
-
inputSchema: {
|
|
1467
|
-
domain: z.string().optional().describe("Apex billing domain; defaults to the domain bound in .sakupa/site.json.")
|
|
1468
|
-
}
|
|
1551
|
+
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).",
|
|
1552
|
+
inputSchema: {}
|
|
1469
1553
|
},
|
|
1470
|
-
async (
|
|
1554
|
+
async () => {
|
|
1471
1555
|
try {
|
|
1472
|
-
const site =
|
|
1473
|
-
const
|
|
1474
|
-
if (!domain) {
|
|
1475
|
-
throw new SakupaError(
|
|
1476
|
-
"invalid_request",
|
|
1477
|
-
'No domain given and this project has no bound billing domain. Pass domain, e.g. billing_status { domain: "example.com" }.'
|
|
1478
|
-
);
|
|
1479
|
-
}
|
|
1480
|
-
const res = await ctx.client.getBillingStatus(domain, site?.credential);
|
|
1556
|
+
const site = requireSiteFile(ctx);
|
|
1557
|
+
const res = await ctx.client.getBillingStatus(site.siteId, site.credential);
|
|
1481
1558
|
const lines = [
|
|
1482
|
-
`Billing status for ${res.
|
|
1483
|
-
res.
|
|
1559
|
+
`Billing status for site ${res.siteId} (mode: ${res.mode})`,
|
|
1560
|
+
res.permanentUrl ? `Permanent URL: ${res.permanentUrl}` : void 0,
|
|
1561
|
+
res.plan ? `Plan: ${res.plan} (\xA5${res.monthlyPriceJpy ?? tierPriceJpy(res.plan)}/month)` : "Plan: (no subscription yet)",
|
|
1484
1562
|
res.subscriptionStatus ? `Subscription payment state: ${res.subscriptionStatus}` : void 0,
|
|
1485
|
-
res.
|
|
1486
|
-
res.
|
|
1563
|
+
res.cancelAtPeriodEnd ? "Renewal: CANCELED \u2014 the site reverts to free at the end of the already-paid month" : void 0,
|
|
1564
|
+
res.currentPeriodStart ? `Current paid period: ${res.currentPeriodStart} -> ${res.currentPeriodEnd ?? "?"}` : void 0,
|
|
1487
1565
|
res.estimatedUsageTier ? `Estimated usage tier: ${res.estimatedUsageTier}` : void 0,
|
|
1488
1566
|
res.lastReconciledAt ? `Last usage reconciliation: ${res.lastReconciledAt}` : void 0,
|
|
1489
|
-
res.
|
|
1490
|
-
res.risks.
|
|
1491
|
-
res.ownerView ? void 0 : "Note: public view (no valid owner credential presented)."
|
|
1567
|
+
res.boundHostnames.length > 0 ? `Bound custom domains: ${res.boundHostnames.join(", ")}` : void 0,
|
|
1568
|
+
res.risks.pastDue ? "ATTENTION: renewal payment failing \u2014 update the payment method (manage_billing). Serving continues while Stripe retries; if Stripe gives up, the site reverts to free." : void 0
|
|
1492
1569
|
].filter((l) => l !== void 0);
|
|
1493
1570
|
return textJson(`${lines.join("\n")}
|
|
1494
1571
|
|
|
@@ -1498,71 +1575,18 @@ Full status:`, res);
|
|
|
1498
1575
|
}
|
|
1499
1576
|
}
|
|
1500
1577
|
);
|
|
1501
|
-
server.registerTool(
|
|
1502
|
-
"subscribe_domain",
|
|
1503
|
-
{
|
|
1504
|
-
description: `Create a Stripe Checkout link that subscribes an apex domain to a Sakupa Domain Hosting monthly plan (${planCatalog()}). Owner-only: requires this project's site credential (.sakupa/site.json) \u2014 deploy_site first; a lost credential means payment is not possible. One domain = one subscription; if the site outgrows its plan, Sakupa auto-upgrades to the next plan (capped at Business, where growth is limited instead of billed further). Paying never proves domain ownership and never activates a website \u2014 DNS verification (bind_domain) is still required. Card details are entered only on the Stripe-hosted page \u2014 never through the AI tool. Without confirm: true this tool only shows the mandatory disclosure and makes no API call.`,
|
|
1505
|
-
inputSchema: {
|
|
1506
|
-
domain: z.string().describe('Apex domain to subscribe, e.g. "example.com".'),
|
|
1507
|
-
plan: planEnum.describe(
|
|
1508
|
-
"Monthly plan: water (very light personal pages), personal (personal brand / small shop), share (small-business site), business (steadier traffic, more headroom)."
|
|
1509
|
-
),
|
|
1510
|
-
confirm: z.boolean().optional().describe("User read the subscription disclosure and confirmed. Required to proceed.")
|
|
1511
|
-
}
|
|
1512
|
-
},
|
|
1513
|
-
async (args) => {
|
|
1514
|
-
try {
|
|
1515
|
-
const site = requireSiteFile(ctx);
|
|
1516
|
-
if (args.confirm !== true) {
|
|
1517
|
-
return text(
|
|
1518
|
-
`${subscriptionWarning(args.domain, args.plan)}
|
|
1519
|
-
|
|
1520
|
-
No checkout link was created yet. Please show this disclosure to the user and, after their explicit confirmation, re-run subscribe_domain with confirm: true.`
|
|
1521
|
-
);
|
|
1522
|
-
}
|
|
1523
|
-
const res = await ctx.client.createPlanCheckout(
|
|
1524
|
-
{
|
|
1525
|
-
siteId: site.siteId,
|
|
1526
|
-
domain: args.domain,
|
|
1527
|
-
plan: args.plan,
|
|
1528
|
-
idempotencyKey: randomUUID(),
|
|
1529
|
-
confirmPlan: true
|
|
1530
|
-
},
|
|
1531
|
-
site.credential
|
|
1532
|
-
);
|
|
1533
|
-
return text(
|
|
1534
|
-
`Stripe Checkout link \u2014 Sakupa Domain Hosting for ${res.apexDomain}: ${res.plan} plan, \xA5${res.monthlyPriceJpy}/month
|
|
1535
|
-
${res.checkoutUrl}
|
|
1536
|
-
|
|
1537
|
-
Open this link in a browser to subscribe. Card data is entered only on the Stripe-hosted page \u2014 never give card numbers, passwords or security codes to the AI tool.
|
|
1538
|
-
Reminder: the subscription is payment state only. It grants no domain ownership and no management rights; the domain must still pass DNS verification (bind_domain) before long-term hosting activates.`
|
|
1539
|
-
);
|
|
1540
|
-
} catch (e) {
|
|
1541
|
-
return toolError(e);
|
|
1542
|
-
}
|
|
1543
|
-
}
|
|
1544
|
-
);
|
|
1545
1578
|
server.registerTool(
|
|
1546
1579
|
"manage_billing",
|
|
1547
1580
|
{
|
|
1548
|
-
description: "Open the Stripe-hosted billing portal for
|
|
1549
|
-
inputSchema: {
|
|
1550
|
-
domain: z.string().optional().describe("Apex billing domain; defaults to the domain bound in .sakupa/site.json.")
|
|
1551
|
-
}
|
|
1581
|
+
description: "Open the Stripe-hosted billing portal for this site: update the payment method, view invoices, or cancel the subscription. All billing operations happen on the Stripe-hosted page \u2014 never inside the AI tool. Requires the local site credential (.sakupa/site.json).",
|
|
1582
|
+
inputSchema: {}
|
|
1552
1583
|
},
|
|
1553
|
-
async (
|
|
1584
|
+
async () => {
|
|
1554
1585
|
try {
|
|
1555
1586
|
const site = requireSiteFile(ctx);
|
|
1556
|
-
const
|
|
1557
|
-
if (!domain) {
|
|
1558
|
-
throw new SakupaError(
|
|
1559
|
-
"invalid_request",
|
|
1560
|
-
"No domain given and this project has no bound billing domain. Pass domain explicitly."
|
|
1561
|
-
);
|
|
1562
|
-
}
|
|
1563
|
-
const res = await ctx.client.createBillingPortal(domain, site.credential);
|
|
1587
|
+
const res = await ctx.client.createBillingPortal(site.siteId, site.credential);
|
|
1564
1588
|
return text(
|
|
1565
|
-
`Stripe billing portal for
|
|
1589
|
+
`Stripe billing portal for this site:
|
|
1566
1590
|
${res.portalUrl}
|
|
1567
1591
|
|
|
1568
1592
|
Open this link in a browser to update the payment method, view invoices, or manage the subscription. The link is temporary \u2014 create a fresh one when needed.`
|
|
@@ -1575,7 +1599,7 @@ Open this link in a browser to update the payment method, view invoices, or mana
|
|
|
1575
1599
|
server.registerTool(
|
|
1576
1600
|
"recover_domain_site",
|
|
1577
1601
|
{
|
|
1578
|
-
description: "Recover management control of a
|
|
1602
|
+
description: "Recover management control of a subscribed site WITH A BOUND CUSTOM DOMAIN after losing the local project, by proving DNS control of the apex domain. Sites without a bound domain are identified solely by their local credential and cannot be recovered. By default, completing recovery REVOKES all previous local credentials. Call first with the hostname to get the DNS record, then again with verificationId to complete recovery (writes a new .sakupa/site.json and returns a download link for the current site content).",
|
|
1579
1603
|
inputSchema: {
|
|
1580
1604
|
hostname: z.string().describe('Hostname of the site to recover, e.g. "www.example.com".'),
|
|
1581
1605
|
verificationId: z.string().optional().describe("Complete a recovery previously started for this hostname."),
|
|
@@ -1587,7 +1611,7 @@ Open this link in a browser to update the payment method, view invoices, or mana
|
|
|
1587
1611
|
if (args.verificationId === void 0) {
|
|
1588
1612
|
const res2 = await ctx.client.recoverDomain({ hostname: args.hostname });
|
|
1589
1613
|
return text(
|
|
1590
|
-
`Recovery started for ${args.hostname} (apex
|
|
1614
|
+
`Recovery started for ${args.hostname} (apex domain: ${res2.apexDomain}).
|
|
1591
1615
|
|
|
1592
1616
|
Create this DNS record to prove apex-domain control:
|
|
1593
1617
|
name: ${res2.verificationRecord.name}
|
|
@@ -1606,13 +1630,13 @@ After the DNS record resolves, re-run recover_domain_site with verificationId: "
|
|
|
1606
1630
|
});
|
|
1607
1631
|
writeSiteFile(ctx.projectDir, {
|
|
1608
1632
|
siteId: res.siteId,
|
|
1609
|
-
|
|
1633
|
+
...res.boundHostnames[0] !== void 0 ? { boundDomain: res.boundHostnames[0] } : {},
|
|
1610
1634
|
credential: res.credential,
|
|
1611
1635
|
createdAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
1612
1636
|
apiBaseUrl: ctx.apiBaseUrl
|
|
1613
1637
|
});
|
|
1614
1638
|
return text(
|
|
1615
|
-
`Recovery complete
|
|
1639
|
+
`Recovery complete.
|
|
1616
1640
|
Site: ${res.siteId} (hostnames: ${res.boundHostnames.join(", ") || "(none)"})
|
|
1617
1641
|
Previous credentials revoked: ${res.revokedPreviousCredentials ? "YES" : "no (preserved on request)"}
|
|
1618
1642
|
|
|
@@ -1629,39 +1653,32 @@ ${res.archiveUrl}`
|
|
|
1629
1653
|
server.registerTool(
|
|
1630
1654
|
"set_billing_plan",
|
|
1631
1655
|
{
|
|
1632
|
-
description: `Change
|
|
1656
|
+
description: `Change this site's hosting plan, cancel/re-enable renewal, or cancel immediately. Plans: ${planCatalog()}. Plan changes go through the Stripe subscription; renewals bill the new plan. Auto-upgrade (one plan up when usage exceeds the current plan, capped at Business) is built in and not configurable. cancelRenewal: true ends the subscription at the period end; cancelNow: true ends it IMMEDIATELY \u2014 the site reverts to a free 24h site at once and all paid data (custom domain bindings included) is removed, with no refund of the current period. The API returns the consequences first; explicit owner confirmation (confirm: true) is required before anything is applied.`,
|
|
1633
1657
|
inputSchema: {
|
|
1634
|
-
domain: z.string().optional().describe("Apex billing domain; defaults to the domain bound in .sakupa/site.json."),
|
|
1635
1658
|
plan: planEnum.optional().describe("Target monthly plan."),
|
|
1636
1659
|
cancelRenewal: z.boolean().optional().describe(
|
|
1637
|
-
"true: cancel renewal (
|
|
1660
|
+
"true: cancel renewal (the site stays permanent to the end of the paid month, then reverts to free). false: re-enable renewal."
|
|
1638
1661
|
),
|
|
1662
|
+
cancelNow: z.boolean().optional().describe("End the subscription immediately; the site reverts to free right away."),
|
|
1639
1663
|
confirm: z.boolean().optional().describe("User saw the consequences and explicitly confirmed.")
|
|
1640
1664
|
}
|
|
1641
1665
|
},
|
|
1642
1666
|
async (args) => {
|
|
1643
1667
|
try {
|
|
1644
1668
|
const site = requireSiteFile(ctx);
|
|
1645
|
-
const domain = args.domain ?? site.billingDomain;
|
|
1646
|
-
if (!domain) {
|
|
1647
|
-
throw new SakupaError(
|
|
1648
|
-
"invalid_request",
|
|
1649
|
-
"No domain given and this project has no bound billing domain. Pass domain explicitly."
|
|
1650
|
-
);
|
|
1651
|
-
}
|
|
1652
1669
|
const req = {
|
|
1653
|
-
|
|
1654
|
-
...args.plan !== void 0 ? { committedTier: args.plan } : {},
|
|
1670
|
+
...args.plan !== void 0 ? { plan: args.plan } : {},
|
|
1655
1671
|
...args.cancelRenewal !== void 0 ? { cancelRenewal: args.cancelRenewal } : {},
|
|
1672
|
+
...args.cancelNow !== void 0 ? { cancelNow: args.cancelNow } : {},
|
|
1656
1673
|
confirm: args.confirm === true
|
|
1657
1674
|
};
|
|
1658
1675
|
try {
|
|
1659
|
-
const res = await ctx.client.setBillingPlan(
|
|
1676
|
+
const res = await ctx.client.setBillingPlan(site.siteId, site.credential, req);
|
|
1660
1677
|
return textJson(
|
|
1661
|
-
`Billing
|
|
1678
|
+
`Billing updated for site ${res.siteId} (mode: ${res.mode}).
|
|
1662
1679
|
Consequences:
|
|
1663
1680
|
${res.consequences.map((c) => `- ${c}`).join("\n")}
|
|
1664
|
-
|
|
1681
|
+
Result:`,
|
|
1665
1682
|
res
|
|
1666
1683
|
);
|
|
1667
1684
|
} catch (e) {
|
|
@@ -1681,50 +1698,6 @@ ${e.message}
|
|
|
1681
1698
|
}
|
|
1682
1699
|
}
|
|
1683
1700
|
);
|
|
1684
|
-
server.registerTool(
|
|
1685
|
-
"downgrade_to_free",
|
|
1686
|
-
{
|
|
1687
|
-
description: "Move this paid custom-domain site back to free temporary mode. Takes effect at the next billing period boundary by default; immediateStopServing stops custom-domain serving now but never erases the already-paid month. Remember to also cancel the domain subscription (set_billing_plan cancelRenewal: true or manage_billing) if no other site uses it. The API returns the consequences first; confirm: true is required to apply.",
|
|
1688
|
-
inputSchema: {
|
|
1689
|
-
immediateStopServing: z.boolean().optional().describe(
|
|
1690
|
-
"Also stop custom-domain serving immediately (does not remove current-cycle charges)."
|
|
1691
|
-
),
|
|
1692
|
-
confirm: z.boolean().optional().describe("User saw the consequences and explicitly confirmed.")
|
|
1693
|
-
}
|
|
1694
|
-
},
|
|
1695
|
-
async (args) => {
|
|
1696
|
-
try {
|
|
1697
|
-
const site = requireSiteFile(ctx);
|
|
1698
|
-
try {
|
|
1699
|
-
const res = await ctx.client.downgradeSite(site.siteId, site.credential, {
|
|
1700
|
-
...args.immediateStopServing !== void 0 ? { immediateStopServing: args.immediateStopServing } : {},
|
|
1701
|
-
confirm: args.confirm === true
|
|
1702
|
-
});
|
|
1703
|
-
return text(
|
|
1704
|
-
`Downgrade scheduled for site ${res.siteId}.
|
|
1705
|
-
Effective at: ${res.effectiveAt}
|
|
1706
|
-
Immediate stop-serving applied: ${res.immediateStopApplied ? "yes" : "no"}
|
|
1707
|
-
${res.message}
|
|
1708
|
-
|
|
1709
|
-
After it takes effect the site becomes a free temporary site again (24h validity, free banner, free limits). If no other site uses this domain, also cancel its hosting subscription (set_billing_plan cancelRenewal: true or manage_billing).`
|
|
1710
|
-
);
|
|
1711
|
-
} catch (e) {
|
|
1712
|
-
if (isSakupaError(e) && e.code === "confirmation_required") {
|
|
1713
|
-
return text(
|
|
1714
|
-
`Confirmation required before downgrading \u2014 nothing was applied.
|
|
1715
|
-
|
|
1716
|
-
${e.message}
|
|
1717
|
-
` + (e.details !== void 0 ? `${JSON.stringify(e.details, null, 2)}
|
|
1718
|
-
` : "") + "\nPlease show these consequences to the user and, after their explicit confirmation, re-run downgrade_to_free with confirm: true."
|
|
1719
|
-
);
|
|
1720
|
-
}
|
|
1721
|
-
throw e;
|
|
1722
|
-
}
|
|
1723
|
-
} catch (e) {
|
|
1724
|
-
return toolError(e);
|
|
1725
|
-
}
|
|
1726
|
-
}
|
|
1727
|
-
);
|
|
1728
1701
|
server.registerTool(
|
|
1729
1702
|
"create_support_ticket",
|
|
1730
1703
|
{
|
|
@@ -1755,7 +1728,7 @@ ${e.message}
|
|
|
1755
1728
|
server.registerTool(
|
|
1756
1729
|
"report_bug",
|
|
1757
1730
|
{
|
|
1758
|
-
description: "Prepare and submit a sanitized bug report when a Sakupa tool failed and the issue looks like a product bug. Only whitelisted structured diagnostics are sent (tool name, error code/message, site id,
|
|
1731
|
+
description: "Prepare and submit a sanitized bug report when a Sakupa tool failed and the issue looks like a product bug. Only whitelisted structured diagnostics are sent (tool name, error code/message, site id, bound domain, deployment id, timestamps, client/MCP version, request id) \u2014 NEVER file contents, source code, secrets, .env values or credentials. Without confirmSubmit: true the exact payload is shown for user review and nothing is submitted.",
|
|
1759
1732
|
inputSchema: {
|
|
1760
1733
|
toolName: z.string().describe('The Sakupa tool that failed, e.g. "deploy_site".'),
|
|
1761
1734
|
errorCode: z.string().optional(),
|
|
@@ -1775,7 +1748,7 @@ ${e.message}
|
|
|
1775
1748
|
...args.errorCode !== void 0 ? { errorCode: args.errorCode } : {},
|
|
1776
1749
|
...args.errorMessage !== void 0 ? { sanitizedErrorMessage: args.errorMessage } : {},
|
|
1777
1750
|
...site ? { siteId: site.siteId } : {},
|
|
1778
|
-
...site?.
|
|
1751
|
+
...site?.boundDomain ? { boundDomain: site.boundDomain } : {},
|
|
1779
1752
|
...args.deploymentId !== void 0 ? { deploymentId: args.deploymentId } : {},
|
|
1780
1753
|
occurredAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
1781
1754
|
clientType: CLIENT_TYPE,
|
|
@@ -1818,19 +1791,23 @@ Workflow:
|
|
|
1818
1791
|
site (public URL {shortId}.sakupa.com, valid 24 hours, free banner shown) and stores the
|
|
1819
1792
|
management credential in .sakupa/site.json. Deploying again updates the site and refreshes
|
|
1820
1793
|
its validity; refresh_site extends validity without uploading.
|
|
1821
|
-
3.
|
|
1794
|
+
3. To make the site PERMANENT, subscribe it to a monthly hosting plan (subscribe_site ->
|
|
1822
1795
|
Stripe-hosted checkout; water/personal/share/business, auto-upgrade when the site outgrows
|
|
1823
|
-
its plan)
|
|
1824
|
-
|
|
1825
|
-
|
|
1826
|
-
|
|
1796
|
+
its plan). Paying makes the {shortId}.sakupa.com URL permanent \u2014 that is what payment buys.
|
|
1797
|
+
4. Optionally bind a custom domain to the subscribed site (bind_domain): an included extra
|
|
1798
|
+
serving surface alongside the permanent URL. Ownership is proven only by DNS control; the
|
|
1799
|
+
first verified request wins; unverified requests expire after 72 hours. billing_status,
|
|
1800
|
+
set_billing_plan, manage_billing and recover_domain_site manage the paid lifecycle.
|
|
1801
|
+
Canceling the subscription immediately reverts the site to a free 24h site and removes all
|
|
1802
|
+
paid data.
|
|
1827
1803
|
|
|
1828
1804
|
Safety boundaries:
|
|
1829
1805
|
- Static output only: no SSR, API routes, middleware, server actions, databases or online builds.
|
|
1830
1806
|
- Never upload source projects, secrets, .env files, private keys, archives, videos or audio.
|
|
1831
1807
|
- Payment card data is entered only on Stripe-hosted pages \u2014 never through the AI tool.
|
|
1832
|
-
- A subscription
|
|
1833
|
-
- The management credential lives only in .sakupa/site.json; never share or upload it
|
|
1808
|
+
- A subscription never grants domain ownership; only DNS verification does.
|
|
1809
|
+
- The management credential lives only in .sakupa/site.json; never share or upload it. Without
|
|
1810
|
+
a bound custom domain, a lost credential is unrecoverable by design.`;
|
|
1834
1811
|
function createSakupaMcpServer(opts) {
|
|
1835
1812
|
const client = opts.client ?? new HttpApiClient(new FetchTransport(opts.apiBaseUrl));
|
|
1836
1813
|
const server = new McpServer(
|