@sakupa/mcp 0.4.0 → 0.6.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 +492 -146
- package/dist/index.js +497 -149
- package/package.json +1 -1
package/dist/index.js
CHANGED
|
@@ -1,7 +1,3 @@
|
|
|
1
|
-
// src/version.ts
|
|
2
|
-
var MCP_VERSION = "0.4.0";
|
|
3
|
-
var CLIENT_TYPE = "sakupa-mcp";
|
|
4
|
-
|
|
5
1
|
// ../core/dist/domain/constants.js
|
|
6
2
|
var SERVICE_DOMAIN = "sakupa.com";
|
|
7
3
|
var FREE_SITE_URL_SUFFIX = `.${SERVICE_DOMAIN}`;
|
|
@@ -124,6 +120,9 @@ var FORBIDDEN_PATH_SEGMENTS = [
|
|
|
124
120
|
];
|
|
125
121
|
var ALLOWED_HIDDEN_PATHS = [".well-known/"];
|
|
126
122
|
|
|
123
|
+
// ../core/dist/domain/version.js
|
|
124
|
+
var SAKUPA_MCP_VERSION = "0.6.0";
|
|
125
|
+
|
|
127
126
|
// ../core/dist/domain/errors.js
|
|
128
127
|
var HTTP_STATUS = {
|
|
129
128
|
invalid_request: 400,
|
|
@@ -447,8 +446,17 @@ async function sha256Hex(bytes) {
|
|
|
447
446
|
return hex;
|
|
448
447
|
}
|
|
449
448
|
|
|
450
|
-
// ../core/dist/domain/
|
|
451
|
-
var
|
|
449
|
+
// ../core/dist/domain/billing-operation.js
|
|
450
|
+
var BILLING_MUTATION_LIMITS = {
|
|
451
|
+
globalPerTenMinutes: 20,
|
|
452
|
+
sitePerTenMinutes: 1,
|
|
453
|
+
perBillingPeriod: 3,
|
|
454
|
+
maxConcurrency: 2,
|
|
455
|
+
leaseSeconds: 30,
|
|
456
|
+
maxConsecutiveFailures: 5,
|
|
457
|
+
circuitBreakSeconds: 15 * 60,
|
|
458
|
+
providerTimeoutMs: 1e4
|
|
459
|
+
};
|
|
452
460
|
|
|
453
461
|
// ../core/dist/dto.js
|
|
454
462
|
var CREDENTIAL_HEADER = "x-sakupa-credential";
|
|
@@ -459,6 +467,16 @@ var MCP_VERSION_HEADER = "x-sakupa-mcp-version";
|
|
|
459
467
|
var utf8Decoder = new TextDecoder("utf-8", { fatal: false });
|
|
460
468
|
var utf8Encoder = new TextEncoder();
|
|
461
469
|
|
|
470
|
+
// ../core/dist/services/authorization.js
|
|
471
|
+
var AUTHORIZATION_TTL_MS = 15 * 60 * 1e3;
|
|
472
|
+
|
|
473
|
+
// ../core/dist/services/subscriptions.js
|
|
474
|
+
var WEBHOOK_PROCESSING_LEASE_MS = 5 * 60 * 1e3;
|
|
475
|
+
|
|
476
|
+
// src/version.ts
|
|
477
|
+
var MCP_VERSION = SAKUPA_MCP_VERSION;
|
|
478
|
+
var CLIENT_TYPE = "sakupa-mcp";
|
|
479
|
+
|
|
462
480
|
// src/transport.ts
|
|
463
481
|
var FetchTransport = class {
|
|
464
482
|
baseUrl;
|
|
@@ -620,6 +638,12 @@ var HttpApiClient = class {
|
|
|
620
638
|
async recoverDomain(req) {
|
|
621
639
|
return this.call("POST", "/v1/domains/recover", { body: req });
|
|
622
640
|
}
|
|
641
|
+
async getRecoveryStatus(verificationId) {
|
|
642
|
+
return this.call(
|
|
643
|
+
"GET",
|
|
644
|
+
`/v1/domains/recover/${encodeURIComponent(verificationId)}`
|
|
645
|
+
);
|
|
646
|
+
}
|
|
623
647
|
async completeRecovery(verificationId, req) {
|
|
624
648
|
return this.call(
|
|
625
649
|
"POST",
|
|
@@ -650,13 +674,19 @@ var HttpApiClient = class {
|
|
|
650
674
|
{ credential }
|
|
651
675
|
);
|
|
652
676
|
}
|
|
653
|
-
async
|
|
677
|
+
async getBillingPlanCatalog() {
|
|
678
|
+
return this.call("GET", "/v1/billing/plans");
|
|
679
|
+
}
|
|
680
|
+
async manageSubscription(siteId, credential, req) {
|
|
654
681
|
return this.call(
|
|
655
682
|
"POST",
|
|
656
|
-
`/v1/sites/${encodeURIComponent(siteId)}/billing/
|
|
683
|
+
`/v1/sites/${encodeURIComponent(siteId)}/billing/manage`,
|
|
657
684
|
{ credential, body: req }
|
|
658
685
|
);
|
|
659
686
|
}
|
|
687
|
+
async getPublicBillingPortal() {
|
|
688
|
+
return this.call("GET", "/v1/billing/portal");
|
|
689
|
+
}
|
|
660
690
|
async createTicket(credential, req) {
|
|
661
691
|
return this.call("POST", "/v1/support/tickets", {
|
|
662
692
|
credential,
|
|
@@ -1162,6 +1192,47 @@ async function analyzeProject(projectDir, opts = {}) {
|
|
|
1162
1192
|
};
|
|
1163
1193
|
}
|
|
1164
1194
|
|
|
1195
|
+
// src/tools/result.ts
|
|
1196
|
+
import { z } from "zod";
|
|
1197
|
+
var STRUCTURED_TOOL_OUTPUT_SCHEMA = {
|
|
1198
|
+
schemaVersion: z.literal(1),
|
|
1199
|
+
outcome: z.enum([
|
|
1200
|
+
"completed",
|
|
1201
|
+
"preview",
|
|
1202
|
+
"waiting_user",
|
|
1203
|
+
"pending_provider",
|
|
1204
|
+
"blocked",
|
|
1205
|
+
"expired",
|
|
1206
|
+
"failed"
|
|
1207
|
+
]),
|
|
1208
|
+
resultCode: z.string(),
|
|
1209
|
+
operationId: z.string().optional(),
|
|
1210
|
+
summary: z.string(),
|
|
1211
|
+
data: z.record(z.string(), z.unknown()),
|
|
1212
|
+
userAction: z.object({
|
|
1213
|
+
type: z.enum(["open_url", "confirm_in_mcp", "configure_dns"]),
|
|
1214
|
+
provider: z.enum(["stripe", "sakupa"]).optional(),
|
|
1215
|
+
url: z.string().optional(),
|
|
1216
|
+
expiresAt: z.string().optional(),
|
|
1217
|
+
expectedOutcome: z.string(),
|
|
1218
|
+
resumeWith: z.object({ tool: z.string(), arguments: z.record(z.string(), z.unknown()) }).optional()
|
|
1219
|
+
}).optional(),
|
|
1220
|
+
nextActions: z.array(
|
|
1221
|
+
z.object({
|
|
1222
|
+
tool: z.string(),
|
|
1223
|
+
arguments: z.record(z.string(), z.unknown()).optional(),
|
|
1224
|
+
allowed: z.boolean(),
|
|
1225
|
+
reasonCode: z.string().optional()
|
|
1226
|
+
})
|
|
1227
|
+
)
|
|
1228
|
+
};
|
|
1229
|
+
function structuredToolResult(envelope) {
|
|
1230
|
+
return {
|
|
1231
|
+
content: [{ type: "text", text: envelope.summary }],
|
|
1232
|
+
structuredContent: envelope
|
|
1233
|
+
};
|
|
1234
|
+
}
|
|
1235
|
+
|
|
1165
1236
|
// src/tools/context.ts
|
|
1166
1237
|
function requireSiteFile(ctx) {
|
|
1167
1238
|
const file = readSiteFile(ctx.projectDir);
|
|
@@ -1174,39 +1245,53 @@ function requireSiteFile(ctx) {
|
|
|
1174
1245
|
return file;
|
|
1175
1246
|
}
|
|
1176
1247
|
function toolError(e) {
|
|
1177
|
-
|
|
1178
|
-
|
|
1179
|
-
|
|
1180
|
-
|
|
1181
|
-
|
|
1182
|
-
|
|
1183
|
-
|
|
1184
|
-
|
|
1185
|
-
|
|
1186
|
-
|
|
1248
|
+
const errorCode = isSakupaError(e) ? e.code : "internal";
|
|
1249
|
+
const retryable = false;
|
|
1250
|
+
const safeSummary = errorCode === "not_found" ? "\u6240\u9700\u7684\u672C\u5730\u9879\u76EE\u7ED1\u5B9A\u6216\u8D44\u6E90\u4E0D\u53EF\u7528\uFF1B\u5982\u679C\u672C\u5730\u6CA1\u6709 .sakupa/site.json\uFF0C\u8BF7\u5148\u8FD0\u884C deploy_site first\u3002" : errorCode === "unauthorized" ? "\u5F53\u524D\u64CD\u4F5C\u672A\u901A\u8FC7\u7AD9\u70B9\u6743\u9650\u6821\u9A8C\u3002" : errorCode === "invalid_request" || errorCode === "validation_failed" ? "\u8BF7\u6C42\u53C2\u6570\u6216\u672C\u5730\u9879\u76EE\u68C0\u67E5\u672A\u901A\u8FC7\u3002" : errorCode === "state_conflict" ? "\u8D44\u6E90\u72B6\u6001\u5DF2\u7ECF\u53D8\u5316\uFF0C\u8BF7\u91CD\u65B0\u67E5\u8BE2\u72B6\u6001\u540E\u518D\u51B3\u5B9A\u4E0B\u4E00\u6B65\u3002" : retryable ? "\u5916\u90E8\u670D\u52A1\u6682\u65F6\u4E0D\u53EF\u7528\u6216\u8BF7\u6C42\u8FC7\u4E8E\u9891\u7E41\uFF0C\u8BF7\u7A0D\u540E\u91CD\u8BD5\u3002" : "\u64CD\u4F5C\u5931\u8D25\uFF1B\u672A\u8FD4\u56DE\u670D\u52A1\u7AEF\u5185\u90E8\u8BE6\u60C5\u3002";
|
|
1251
|
+
const result = structuredToolResult({
|
|
1252
|
+
schemaVersion: 1,
|
|
1253
|
+
outcome: "failed",
|
|
1254
|
+
resultCode: `error_${errorCode}`,
|
|
1255
|
+
summary: safeSummary,
|
|
1256
|
+
data: { errorCode, retryable },
|
|
1257
|
+
nextActions: []
|
|
1258
|
+
});
|
|
1259
|
+
return { ...result, isError: true };
|
|
1187
1260
|
}
|
|
1188
1261
|
|
|
1189
1262
|
// src/tools/definitions.ts
|
|
1190
1263
|
import { randomUUID } from "node:crypto";
|
|
1191
1264
|
import { promises as fs2 } from "node:fs";
|
|
1192
1265
|
import { join as join3, resolve as resolve2 } from "node:path";
|
|
1193
|
-
import { z } from "zod";
|
|
1194
|
-
function text(t) {
|
|
1195
|
-
return {
|
|
1266
|
+
import { z as z2 } from "zod";
|
|
1267
|
+
function text(resultCode, t, data = {}, outcome = "completed") {
|
|
1268
|
+
return structuredToolResult({
|
|
1269
|
+
schemaVersion: 1,
|
|
1270
|
+
outcome,
|
|
1271
|
+
resultCode,
|
|
1272
|
+
summary: t,
|
|
1273
|
+
data,
|
|
1274
|
+
nextActions: []
|
|
1275
|
+
});
|
|
1196
1276
|
}
|
|
1197
|
-
function textJson(header, obj) {
|
|
1198
|
-
|
|
1199
|
-
${JSON.stringify(obj, null, 2)}
|
|
1277
|
+
function textJson(resultCode, header, obj, outcome = "completed") {
|
|
1278
|
+
const summary = `${header}
|
|
1279
|
+
${JSON.stringify(obj, null, 2)}`;
|
|
1280
|
+
return structuredToolResult({
|
|
1281
|
+
schemaVersion: 1,
|
|
1282
|
+
outcome,
|
|
1283
|
+
resultCode,
|
|
1284
|
+
summary,
|
|
1285
|
+
data: typeof obj === "object" && obj !== null ? { result: obj } : { result: obj },
|
|
1286
|
+
nextActions: []
|
|
1287
|
+
});
|
|
1200
1288
|
}
|
|
1201
|
-
var planEnum =
|
|
1202
|
-
var severityEnum =
|
|
1289
|
+
var planEnum = z2.enum(["water", "personal", "share", "business"]);
|
|
1290
|
+
var severityEnum = z2.enum(["low", "medium", "high", "critical"]);
|
|
1203
1291
|
function planCatalog() {
|
|
1204
1292
|
return TIER_ORDER.map((p) => `${p} \xA5${tierPriceJpy(p)}/month`).join(", ");
|
|
1205
1293
|
}
|
|
1206
|
-
|
|
1207
|
-
return SUBSCRIPTION_WARNING_TEXT.replaceAll("{siteUrl}", siteUrl).replaceAll("{plan}", plan).replaceAll("{priceJpy}", String(tierPriceJpy(plan)));
|
|
1208
|
-
}
|
|
1209
|
-
var ticketCategoryEnum = z.enum([
|
|
1294
|
+
var ticketCategoryEnum = z2.enum([
|
|
1210
1295
|
"billing",
|
|
1211
1296
|
"payment",
|
|
1212
1297
|
"refund_review",
|
|
@@ -1224,14 +1309,17 @@ function analysisSummary(analysis) {
|
|
|
1224
1309
|
}
|
|
1225
1310
|
function notDeployableResult(analysis) {
|
|
1226
1311
|
return textJson(
|
|
1312
|
+
"site_analysis_not_deployable",
|
|
1227
1313
|
`This project is NOT deployable as-is. No files were uploaded and no API call was made.
|
|
1228
1314
|
Next action: ${analysis.suggestedNextAction}
|
|
1229
1315
|
Analysis:`,
|
|
1230
|
-
analysisSummary(analysis)
|
|
1316
|
+
analysisSummary(analysis),
|
|
1317
|
+
"blocked"
|
|
1231
1318
|
);
|
|
1232
1319
|
}
|
|
1233
1320
|
function spaConfirmationResult(analysis) {
|
|
1234
1321
|
return text(
|
|
1322
|
+
"spa_fallback_confirmation_required",
|
|
1235
1323
|
`SPA fallback confirmation required \u2014 nothing was deployed yet.
|
|
1236
1324
|
|
|
1237
1325
|
This site looks like a single-page application (one index.html plus JavaScript). SPA fallback rewrites every unknown path to index.html so client-side routes work, but it CHANGES normal 404 behavior: visitors never see a not-found page.
|
|
@@ -1240,7 +1328,9 @@ Please ask the user to choose, then re-run deploy_site with:
|
|
|
1240
1328
|
- spaFallback: true, spaFallbackConfirmed: true -> enable SPA fallback
|
|
1241
1329
|
- spaFallbackConfirmed: true (spaFallback omitted or false) -> deploy WITHOUT fallback (unknown paths return 404)
|
|
1242
1330
|
|
|
1243
|
-
Output directory: "${analysis.recommendedOutputDir ?? "."}", ${analysis.fileCount} files
|
|
1331
|
+
Output directory: "${analysis.recommendedOutputDir ?? "."}", ${analysis.fileCount} files.`,
|
|
1332
|
+
{ analysis: analysisSummary(analysis), requestedConfirmation: "spa_fallback" },
|
|
1333
|
+
"waiting_user"
|
|
1244
1334
|
);
|
|
1245
1335
|
}
|
|
1246
1336
|
var MB2 = 1024 * 1024;
|
|
@@ -1300,10 +1390,12 @@ function registerTools(server, ctx) {
|
|
|
1300
1390
|
"analyze_site",
|
|
1301
1391
|
{
|
|
1302
1392
|
description: "Analyze the local project and decide whether it can be deployed as a static site. Detects the framework, the built static output directory (dist/build/out/...), missing index.html, SSR/API-route/database-runtime risks, SPA fallback needs, forbidden files (secrets, .env, archives, media) and size limits. Sakupa deploys ONLY prebuilt static output \u2014 never source, secrets or server code. Run this before deploy_site.",
|
|
1393
|
+
outputSchema: STRUCTURED_TOOL_OUTPUT_SCHEMA,
|
|
1394
|
+
annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: false },
|
|
1303
1395
|
inputSchema: {
|
|
1304
|
-
outputDir:
|
|
1305
|
-
spaFallbackRequested:
|
|
1306
|
-
spaFallbackConfirmed:
|
|
1396
|
+
outputDir: z2.string().optional().describe("Output directory relative to the project root (overrides detection)."),
|
|
1397
|
+
spaFallbackRequested: z2.boolean().optional().describe("User asked for SPA fallback (unknown paths rewritten to index.html)."),
|
|
1398
|
+
spaFallbackConfirmed: z2.boolean().optional().describe("User explicitly confirmed the SPA fallback 404-behavior change.")
|
|
1307
1399
|
}
|
|
1308
1400
|
},
|
|
1309
1401
|
async (args) => {
|
|
@@ -1314,6 +1406,7 @@ function registerTools(server, ctx) {
|
|
|
1314
1406
|
...args.spaFallbackConfirmed !== void 0 ? { spaFallbackConfirmed: args.spaFallbackConfirmed } : {}
|
|
1315
1407
|
});
|
|
1316
1408
|
return textJson(
|
|
1409
|
+
"site_analysis_completed",
|
|
1317
1410
|
`Analysis of ${ctx.projectDir}
|
|
1318
1411
|
Next action: ${analysis.suggestedNextAction}`,
|
|
1319
1412
|
analysisSummary(analysis)
|
|
@@ -1327,11 +1420,16 @@ Next action: ${analysis.suggestedNextAction}`,
|
|
|
1327
1420
|
"deploy_site",
|
|
1328
1421
|
{
|
|
1329
1422
|
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.`,
|
|
1423
|
+
outputSchema: STRUCTURED_TOOL_OUTPUT_SCHEMA,
|
|
1424
|
+
annotations: { readOnlyHint: false, destructiveHint: true, openWorldHint: true },
|
|
1330
1425
|
inputSchema: {
|
|
1331
|
-
outputDir:
|
|
1332
|
-
spaFallback:
|
|
1333
|
-
spaFallbackConfirmed:
|
|
1334
|
-
|
|
1426
|
+
outputDir: z2.string().optional().describe("Output directory relative to the project root (overrides detection)."),
|
|
1427
|
+
spaFallback: z2.boolean().optional().describe("Enable SPA fallback (requires spaFallbackConfirmed: true)."),
|
|
1428
|
+
spaFallbackConfirmed: z2.boolean().optional().describe("User explicitly confirmed the SPA fallback 404-behavior change."),
|
|
1429
|
+
publicConfirmed: z2.boolean().optional().describe(
|
|
1430
|
+
"Required only for the first deployment: user explicitly confirmed creation of a public 24-hour URL."
|
|
1431
|
+
),
|
|
1432
|
+
lang: z2.string().optional().describe("Site language override (en | ja | zh-CN); defaults to the html lang.")
|
|
1335
1433
|
}
|
|
1336
1434
|
},
|
|
1337
1435
|
async (args) => {
|
|
@@ -1351,6 +1449,14 @@ Next action: ${analysis.suggestedNextAction}`,
|
|
|
1351
1449
|
const outputAbs = resolve2(ctx.projectDir, analysis.recommendedOutputDir ?? ".");
|
|
1352
1450
|
const manifest = await buildHashedManifest(files, outputAbs);
|
|
1353
1451
|
const existing = readSiteFile(ctx.projectDir);
|
|
1452
|
+
if (!existing && args.publicConfirmed !== true) {
|
|
1453
|
+
return text(
|
|
1454
|
+
"public_deployment_confirmation_required",
|
|
1455
|
+
`First deployment creates a public URL that anyone with the link can open. The free preview stays live for ${FREE_SITE_TTL_HOURS} hours. Explain this to the user and obtain explicit confirmation before retrying deploy_site with publicConfirmed: true.`,
|
|
1456
|
+
{ publicUrlLifetimeHours: FREE_SITE_TTL_HOURS, confirmationField: "publicConfirmed" },
|
|
1457
|
+
"waiting_user"
|
|
1458
|
+
);
|
|
1459
|
+
}
|
|
1354
1460
|
ensureUploadSizeWithinLimits(manifest, !existing);
|
|
1355
1461
|
if (!existing) {
|
|
1356
1462
|
const created = await ctx.client.createSite({
|
|
@@ -1374,6 +1480,7 @@ Next action: ${analysis.suggestedNextAction}`,
|
|
|
1374
1480
|
apiBaseUrl: ctx.apiBaseUrl
|
|
1375
1481
|
});
|
|
1376
1482
|
return text(
|
|
1483
|
+
"site_published",
|
|
1377
1484
|
`Site published: ${finalized2.url}
|
|
1378
1485
|
Files uploaded: ${uploaded2} (${finalized2.totalBytes} bytes)
|
|
1379
1486
|
` + (finalized2.expiresAt ? `Expires at: ${finalized2.expiresAt}
|
|
@@ -1381,7 +1488,19 @@ Files uploaded: ${uploaded2} (${finalized2.totalBytes} bytes)
|
|
|
1381
1488
|
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.
|
|
1382
1489
|
` + credentialGitReminder(ctx.projectDir) + (finalized2.warnings.length > 0 ? `
|
|
1383
1490
|
Warnings:
|
|
1384
|
-
${JSON.stringify(finalized2.warnings, null, 2)}` : "")
|
|
1491
|
+
${JSON.stringify(finalized2.warnings, null, 2)}` : ""),
|
|
1492
|
+
{
|
|
1493
|
+
siteId: created.siteId,
|
|
1494
|
+
shortId: created.shortId,
|
|
1495
|
+
url: finalized2.url,
|
|
1496
|
+
deploymentId: created.deploymentId,
|
|
1497
|
+
mode: finalized2.mode,
|
|
1498
|
+
expiresAt: finalized2.expiresAt,
|
|
1499
|
+
filesUploaded: uploaded2,
|
|
1500
|
+
totalBytes: finalized2.totalBytes,
|
|
1501
|
+
warnings: finalized2.warnings,
|
|
1502
|
+
credentialStoredLocally: true
|
|
1503
|
+
}
|
|
1385
1504
|
);
|
|
1386
1505
|
}
|
|
1387
1506
|
const updateOnce = async (forceFullUpload) => {
|
|
@@ -1415,6 +1534,7 @@ ${JSON.stringify(finalized2.warnings, null, 2)}` : "")
|
|
|
1415
1534
|
const { uploaded, finalized } = update;
|
|
1416
1535
|
writeSiteFile(ctx.projectDir, { ...existing, url: finalized.url });
|
|
1417
1536
|
return text(
|
|
1537
|
+
"site_updated",
|
|
1418
1538
|
`Site updated: ${finalized.url}
|
|
1419
1539
|
Files uploaded: ${uploaded} (${finalized.totalBytes} bytes)
|
|
1420
1540
|
` + (finalized.expiresAt ? `Validity refreshed \u2014 expires at: ${finalized.expiresAt}
|
|
@@ -1422,7 +1542,16 @@ Files uploaded: ${uploaded} (${finalized.totalBytes} bytes)
|
|
|
1422
1542
|
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.
|
|
1423
1543
|
` : "\nThis site is subscribed and permanent \u2014 no expiry.\n") + (finalized.warnings.length > 0 ? `
|
|
1424
1544
|
Warnings:
|
|
1425
|
-
${JSON.stringify(finalized.warnings, null, 2)}` : "")
|
|
1545
|
+
${JSON.stringify(finalized.warnings, null, 2)}` : ""),
|
|
1546
|
+
{
|
|
1547
|
+
siteId: existing.siteId,
|
|
1548
|
+
url: finalized.url,
|
|
1549
|
+
mode: finalized.mode,
|
|
1550
|
+
expiresAt: finalized.expiresAt,
|
|
1551
|
+
filesUploaded: uploaded,
|
|
1552
|
+
totalBytes: finalized.totalBytes,
|
|
1553
|
+
warnings: finalized.warnings
|
|
1554
|
+
}
|
|
1426
1555
|
);
|
|
1427
1556
|
} catch (e) {
|
|
1428
1557
|
return toolError(e);
|
|
@@ -1433,6 +1562,8 @@ ${JSON.stringify(finalized.warnings, null, 2)}` : "")
|
|
|
1433
1562
|
"refresh_site",
|
|
1434
1563
|
{
|
|
1435
1564
|
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.",
|
|
1565
|
+
outputSchema: STRUCTURED_TOOL_OUTPUT_SCHEMA,
|
|
1566
|
+
annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: true },
|
|
1436
1567
|
inputSchema: {}
|
|
1437
1568
|
},
|
|
1438
1569
|
async () => {
|
|
@@ -1440,8 +1571,10 @@ ${JSON.stringify(finalized.warnings, null, 2)}` : "")
|
|
|
1440
1571
|
const site = requireSiteFile(ctx);
|
|
1441
1572
|
const res = await ctx.client.refreshSite(site.siteId, site.credential);
|
|
1442
1573
|
return text(
|
|
1574
|
+
"site_refreshed",
|
|
1443
1575
|
`Site validity refreshed. New expiry: ${res.expiresAt}
|
|
1444
|
-
Free sites stay live for ${FREE_SITE_TTL_HOURS} hours after each deploy or refresh
|
|
1576
|
+
Free sites stay live for ${FREE_SITE_TTL_HOURS} hours after each deploy or refresh.`,
|
|
1577
|
+
{ siteId: site.siteId, expiresAt: res.expiresAt }
|
|
1445
1578
|
);
|
|
1446
1579
|
} catch (e) {
|
|
1447
1580
|
return toolError(e);
|
|
@@ -1452,13 +1585,15 @@ Free sites stay live for ${FREE_SITE_TTL_HOURS} hours after each deploy or refre
|
|
|
1452
1585
|
"site_status",
|
|
1453
1586
|
{
|
|
1454
1587
|
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.",
|
|
1588
|
+
outputSchema: STRUCTURED_TOOL_OUTPUT_SCHEMA,
|
|
1589
|
+
annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: true },
|
|
1455
1590
|
inputSchema: {}
|
|
1456
1591
|
},
|
|
1457
1592
|
async () => {
|
|
1458
1593
|
try {
|
|
1459
1594
|
const site = requireSiteFile(ctx);
|
|
1460
1595
|
const res = await ctx.client.getSiteStatus(site.siteId, site.credential);
|
|
1461
|
-
return textJson("Site status:", res);
|
|
1596
|
+
return textJson("site_status_returned", "Site status:", res);
|
|
1462
1597
|
} catch (e) {
|
|
1463
1598
|
return toolError(e);
|
|
1464
1599
|
}
|
|
@@ -1467,40 +1602,42 @@ Free sites stay live for ${FREE_SITE_TTL_HOURS} hours after each deploy or refre
|
|
|
1467
1602
|
server.registerTool(
|
|
1468
1603
|
"subscribe_site",
|
|
1469
1604
|
{
|
|
1470
|
-
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
|
|
1605
|
+
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 shows an over-limit notice and never changes billing automatically. The owner can explicitly choose another plan through Stripe Customer Portal. Card details are entered only on the Stripe-hosted page \u2014 never through the AI tool. Opening and completing Stripe Checkout is the final subscription confirmation.`,
|
|
1606
|
+
outputSchema: STRUCTURED_TOOL_OUTPUT_SCHEMA,
|
|
1607
|
+
annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: true },
|
|
1471
1608
|
inputSchema: {
|
|
1472
1609
|
plan: planEnum.describe(
|
|
1473
1610
|
"Monthly plan: water (very light personal pages), personal (personal brand / small shop), share (small-business site), business (steadier traffic, more headroom)."
|
|
1474
|
-
)
|
|
1475
|
-
confirm: z.boolean().optional().describe("User read the subscription disclosure and confirmed. Required to proceed.")
|
|
1611
|
+
)
|
|
1476
1612
|
}
|
|
1477
1613
|
},
|
|
1478
1614
|
async (args) => {
|
|
1479
1615
|
try {
|
|
1480
1616
|
const site = requireSiteFile(ctx);
|
|
1481
|
-
const siteUrl = site.url ?? (site.shortId ? `https://${site.shortId}.sakupa.com` : site.siteId);
|
|
1482
|
-
if (args.confirm !== true) {
|
|
1483
|
-
return text(
|
|
1484
|
-
`${subscriptionWarning(siteUrl, args.plan)}
|
|
1485
|
-
|
|
1486
|
-
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.`
|
|
1487
|
-
);
|
|
1488
|
-
}
|
|
1489
1617
|
const res = await ctx.client.createPlanCheckout(
|
|
1490
1618
|
{
|
|
1491
1619
|
siteId: site.siteId,
|
|
1492
1620
|
plan: args.plan,
|
|
1493
|
-
idempotencyKey: randomUUID()
|
|
1494
|
-
confirmPlan: true
|
|
1621
|
+
idempotencyKey: randomUUID()
|
|
1495
1622
|
},
|
|
1496
1623
|
site.credential
|
|
1497
1624
|
);
|
|
1498
1625
|
return text(
|
|
1626
|
+
"subscription_checkout_ready",
|
|
1499
1627
|
`Stripe Checkout link \u2014 Sakupa Hosting for this site: ${res.plan} plan, \xA5${res.monthlyPriceJpy}/month
|
|
1500
1628
|
${res.checkoutUrl}
|
|
1501
1629
|
|
|
1502
1630
|
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.
|
|
1503
|
-
Once payment confirms, the site becomes permanent on its current URL. Binding a custom domain (bind_domain) is optional and still requires DNS verification
|
|
1631
|
+
Once payment confirms, the site becomes permanent on its current URL. Binding a custom domain (bind_domain) is optional and still requires DNS verification.`,
|
|
1632
|
+
{
|
|
1633
|
+
siteId: res.siteId,
|
|
1634
|
+
plan: res.plan,
|
|
1635
|
+
monthlyPriceJpy: res.monthlyPriceJpy,
|
|
1636
|
+
checkoutUrl: res.checkoutUrl,
|
|
1637
|
+
sessionId: res.sessionId,
|
|
1638
|
+
finalConfirmationProvider: "stripe"
|
|
1639
|
+
},
|
|
1640
|
+
"waiting_user"
|
|
1504
1641
|
);
|
|
1505
1642
|
} catch (e) {
|
|
1506
1643
|
return toolError(e);
|
|
@@ -1511,36 +1648,54 @@ Once payment confirms, the site becomes permanent on its current URL. Binding a
|
|
|
1511
1648
|
"bind_domain",
|
|
1512
1649
|
{
|
|
1513
1650
|
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. The binding unit is the APEX domain: binding example.com automatically includes www.example.com (both serve the same content, one apex TXT verification covers both), and one site binds at most ONE apex domain \u2014 a second domain needs a second subscribed site. Requires an ACTIVE subscription (subscribe_site). Ownership is proven ONLY by DNS control of the apex \u2014 payment never grants ownership. A binding request never reserves the domain: whoever proves DNS control first gets it, and unverified requests expire after 72 hours. Call again with verificationId to check progress.",
|
|
1651
|
+
outputSchema: STRUCTURED_TOOL_OUTPUT_SCHEMA,
|
|
1652
|
+
annotations: { readOnlyHint: false, destructiveHint: true, openWorldHint: true },
|
|
1514
1653
|
inputSchema: {
|
|
1515
|
-
|
|
1516
|
-
|
|
1517
|
-
)
|
|
1518
|
-
verificationId: z.string().optional().describe("Check an existing DNS verification instead of starting a new one.")
|
|
1654
|
+
action: z2.enum(["start", "status"]),
|
|
1655
|
+
hostname: z2.string().optional().describe("Required for start."),
|
|
1656
|
+
verificationId: z2.string().optional().describe("Required for status.")
|
|
1519
1657
|
}
|
|
1520
1658
|
},
|
|
1521
1659
|
async (args) => {
|
|
1522
1660
|
try {
|
|
1523
1661
|
const site = requireSiteFile(ctx);
|
|
1524
|
-
if (args.
|
|
1662
|
+
if (args.action === "status") {
|
|
1663
|
+
if (!args.verificationId) {
|
|
1664
|
+
throw new SakupaError("invalid_request", "verificationId is required for status");
|
|
1665
|
+
}
|
|
1525
1666
|
const res2 = await ctx.client.checkVerification(args.verificationId, site.credential);
|
|
1526
1667
|
if (res2.status === "verified") {
|
|
1527
1668
|
writeSiteFile(ctx.projectDir, { ...site, boundDomain: res2.apexDomain });
|
|
1528
1669
|
}
|
|
1529
1670
|
return text(
|
|
1671
|
+
res2.status === "verified" ? "domain_verification_succeeded" : "domain_verification_pending",
|
|
1530
1672
|
`DNS verification ${res2.verificationId}: ${res2.status}
|
|
1531
1673
|
${res2.message}
|
|
1532
1674
|
` + (res2.provisioningJobId ? `Provisioning started (job ${res2.provisioningJobId}). HTTPS certificates and serving setup are in progress; check again with bind_domain + verificationId later.
|
|
1533
1675
|
` : "") + (res2.pendingDnsRecords.length > 0 ? `
|
|
1534
1676
|
DNS records still required:
|
|
1535
|
-
${JSON.stringify(res2.pendingDnsRecords, null, 2)}` : "")
|
|
1677
|
+
${JSON.stringify(res2.pendingDnsRecords, null, 2)}` : ""),
|
|
1678
|
+
{
|
|
1679
|
+
verificationId: res2.verificationId,
|
|
1680
|
+
status: res2.status,
|
|
1681
|
+
apexDomain: res2.apexDomain,
|
|
1682
|
+
provisioningJobId: res2.provisioningJobId,
|
|
1683
|
+
pendingDnsRecords: res2.pendingDnsRecords,
|
|
1684
|
+
message: res2.message
|
|
1685
|
+
},
|
|
1686
|
+
res2.status === "verified" ? "completed" : "pending_provider"
|
|
1536
1687
|
);
|
|
1537
1688
|
}
|
|
1689
|
+
if (!args.hostname) {
|
|
1690
|
+
throw new SakupaError("invalid_request", "hostname is required for start");
|
|
1691
|
+
}
|
|
1538
1692
|
const req = {
|
|
1539
1693
|
siteId: site.siteId,
|
|
1540
1694
|
hostname: args.hostname
|
|
1541
1695
|
};
|
|
1542
1696
|
const res = await ctx.client.bindDomain(site.credential, req);
|
|
1543
1697
|
return text(
|
|
1698
|
+
"domain_verification_started",
|
|
1544
1699
|
`Domain binding started for ${res.apexDomain} (includes: ${res.includedHostnames.join(", ")} \u2014 both will serve this site).
|
|
1545
1700
|
|
|
1546
1701
|
1. Prove control of ${res.apexDomain} by creating this DNS record:
|
|
@@ -1551,7 +1706,15 @@ ${JSON.stringify(res2.pendingDnsRecords, null, 2)}` : "")
|
|
|
1551
1706
|
|
|
1552
1707
|
2. Serving DNS (after verification): ${res.servingInstructions}
|
|
1553
1708
|
|
|
1554
|
-
Then run bind_domain again with verificationId: "${res.verificationId}" to check verification and start provisioning
|
|
1709
|
+
Then run bind_domain again with verificationId: "${res.verificationId}" to check verification and start provisioning.`,
|
|
1710
|
+
{
|
|
1711
|
+
verificationId: res.verificationId,
|
|
1712
|
+
apexDomain: res.apexDomain,
|
|
1713
|
+
includedHostnames: res.includedHostnames,
|
|
1714
|
+
verificationRecord: res.verificationRecord,
|
|
1715
|
+
servingInstructions: res.servingInstructions
|
|
1716
|
+
},
|
|
1717
|
+
"waiting_user"
|
|
1555
1718
|
);
|
|
1556
1719
|
} catch (e) {
|
|
1557
1720
|
return toolError(e);
|
|
@@ -1562,6 +1725,8 @@ Then run bind_domain again with verificationId: "${res.verificationId}" to check
|
|
|
1562
1725
|
"billing_status",
|
|
1563
1726
|
{
|
|
1564
1727
|
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).",
|
|
1728
|
+
outputSchema: STRUCTURED_TOOL_OUTPUT_SCHEMA,
|
|
1729
|
+
annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: true },
|
|
1565
1730
|
inputSchema: {}
|
|
1566
1731
|
},
|
|
1567
1732
|
async () => {
|
|
@@ -1580,7 +1745,7 @@ Then run bind_domain again with verificationId: "${res.verificationId}" to check
|
|
|
1580
1745
|
res.boundHostnames.length > 0 ? `Bound custom domains: ${res.boundHostnames.join(", ")}` : void 0,
|
|
1581
1746
|
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
|
|
1582
1747
|
].filter((l) => l !== void 0);
|
|
1583
|
-
return textJson(`${lines.join("\n")}
|
|
1748
|
+
return textJson("billing_status_returned", `${lines.join("\n")}
|
|
1584
1749
|
|
|
1585
1750
|
Full status:`, res);
|
|
1586
1751
|
} catch (e) {
|
|
@@ -1591,19 +1756,53 @@ Full status:`, res);
|
|
|
1591
1756
|
server.registerTool(
|
|
1592
1757
|
"manage_billing",
|
|
1593
1758
|
{
|
|
1594
|
-
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.
|
|
1595
|
-
|
|
1759
|
+
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. With .sakupa/site.json, this opens the site-specific portal. Without the local credential, this returns Stripe's public no-code Customer Portal login page. The customer enters the checkout email and confirms a one-time passcode sent by Stripe. This never restores Sakupa site authority.",
|
|
1760
|
+
outputSchema: STRUCTURED_TOOL_OUTPUT_SCHEMA,
|
|
1761
|
+
annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: true },
|
|
1762
|
+
inputSchema: {
|
|
1763
|
+
scope: z2.enum(["site", "public_recovery"])
|
|
1764
|
+
}
|
|
1596
1765
|
},
|
|
1597
|
-
async () => {
|
|
1766
|
+
async (args) => {
|
|
1598
1767
|
try {
|
|
1599
|
-
|
|
1600
|
-
|
|
1601
|
-
|
|
1602
|
-
|
|
1603
|
-
|
|
1604
|
-
|
|
1605
|
-
|
|
1606
|
-
|
|
1768
|
+
if (args.scope === "site") {
|
|
1769
|
+
const site = requireSiteFile(ctx);
|
|
1770
|
+
const res2 = await ctx.client.createBillingPortal(site.siteId, site.credential);
|
|
1771
|
+
return structuredToolResult({
|
|
1772
|
+
schemaVersion: 1,
|
|
1773
|
+
outcome: "waiting_user",
|
|
1774
|
+
resultCode: "site_billing_portal_ready",
|
|
1775
|
+
summary: `\u5DF2\u521B\u5EFA\u6B64\u7AD9\u70B9\u7684 Stripe \u5BA2\u6237\u95E8\u6237\u77ED\u65F6\u94FE\u63A5\uFF1A${res2.portalUrl}\u3002\u4EFB\u4F55\u53D8\u66F4\u4ECD\u987B\u5728 Stripe \u9875\u9762\u5B8C\u6210\u3002`,
|
|
1776
|
+
data: { scope: args.scope, portalUrl: res2.portalUrl },
|
|
1777
|
+
userAction: {
|
|
1778
|
+
type: "open_url",
|
|
1779
|
+
provider: "stripe",
|
|
1780
|
+
url: res2.portalUrl,
|
|
1781
|
+
expectedOutcome: "\u7528\u6237\u5728 Stripe \u6258\u7BA1\u9875\u9762\u7BA1\u7406\u4ED8\u6B3E\u65B9\u5F0F\u3001\u53D1\u7968\u6216\u53D6\u6D88\u7EED\u8BA2"
|
|
1782
|
+
},
|
|
1783
|
+
nextActions: [{ tool: "billing_status", allowed: true }]
|
|
1784
|
+
});
|
|
1785
|
+
}
|
|
1786
|
+
const res = await ctx.client.getPublicBillingPortal();
|
|
1787
|
+
return structuredToolResult({
|
|
1788
|
+
schemaVersion: 1,
|
|
1789
|
+
outcome: "waiting_user",
|
|
1790
|
+
resultCode: "public_billing_recovery_portal_ready",
|
|
1791
|
+
summary: `Stripe \u516C\u5171\u90AE\u7BB1 OTP \u767B\u5F55\u9875\uFF1A${res.portalUrl}\u3002\u5B83\u4F7F\u7528 one-time passcode\uFF0Cdoes not recover the Sakupa key\uFF0C\u4E5F\u4E0D\u6388\u4E88\u7AD9\u70B9\u6743\u9650\uFF1B\u540C\u90AE\u7BB1\u5B58\u5728\u591A\u4E2A Customer \u65F6\uFF0CStripe \u53EF\u80FD\u53EA\u6253\u5F00 most recently created \u7684\u53EF\u7528\u8BB0\u5F55\u3002`,
|
|
1792
|
+
data: {
|
|
1793
|
+
scope: args.scope,
|
|
1794
|
+
portalUrl: res.portalUrl,
|
|
1795
|
+
grantsSiteAuthority: false,
|
|
1796
|
+
acceptsSiteIdentifier: false
|
|
1797
|
+
},
|
|
1798
|
+
userAction: {
|
|
1799
|
+
type: "open_url",
|
|
1800
|
+
provider: "stripe",
|
|
1801
|
+
url: res.portalUrl,
|
|
1802
|
+
expectedOutcome: "\u7528\u6237\u7531 Stripe \u9A8C\u8BC1\u8D26\u5355\u90AE\u7BB1\u540E\u67E5\u770B\u5E76\u53D6\u6D88\u95E8\u6237\u4E2D\u663E\u793A\u7684\u8BA2\u9605"
|
|
1803
|
+
},
|
|
1804
|
+
nextActions: []
|
|
1805
|
+
});
|
|
1607
1806
|
} catch (e) {
|
|
1608
1807
|
return toolError(e);
|
|
1609
1808
|
}
|
|
@@ -1613,17 +1812,24 @@ Open this link in a browser to update the payment method, view invoices, or mana
|
|
|
1613
1812
|
"recover_domain_site",
|
|
1614
1813
|
{
|
|
1615
1814
|
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).",
|
|
1815
|
+
outputSchema: STRUCTURED_TOOL_OUTPUT_SCHEMA,
|
|
1816
|
+
annotations: { readOnlyHint: false, destructiveHint: true, openWorldHint: true },
|
|
1616
1817
|
inputSchema: {
|
|
1617
|
-
|
|
1618
|
-
|
|
1619
|
-
|
|
1818
|
+
action: z2.enum(["start", "status", "complete"]),
|
|
1819
|
+
hostname: z2.string().optional().describe("Required for start."),
|
|
1820
|
+
verificationId: z2.string().optional().describe("Required for status or complete."),
|
|
1821
|
+
preserveExistingCredentials: z2.boolean().optional().describe("Explicitly keep old local credentials working (default: revoke them all).")
|
|
1620
1822
|
}
|
|
1621
1823
|
},
|
|
1622
1824
|
async (args) => {
|
|
1623
1825
|
try {
|
|
1624
|
-
if (args.
|
|
1826
|
+
if (args.action === "start") {
|
|
1827
|
+
if (!args.hostname) {
|
|
1828
|
+
throw new SakupaError("invalid_request", "hostname is required for start");
|
|
1829
|
+
}
|
|
1625
1830
|
const res2 = await ctx.client.recoverDomain({ hostname: args.hostname });
|
|
1626
1831
|
return text(
|
|
1832
|
+
"domain_recovery_started",
|
|
1627
1833
|
`Recovery started for ${args.hostname} (apex domain: ${res2.apexDomain}).
|
|
1628
1834
|
|
|
1629
1835
|
Create this DNS record to prove apex-domain control:
|
|
@@ -1635,9 +1841,40 @@ ${res2.message}
|
|
|
1635
1841
|
|
|
1636
1842
|
IMPORTANT: completing recovery revokes ALL previous local authorizations for this site by default (this protects you if the old project or its credential leaked). If you want to keep the old credentials working, pass preserveExistingCredentials: true when completing.
|
|
1637
1843
|
|
|
1638
|
-
After the DNS record resolves, re-run recover_domain_site with verificationId: "${res2.verificationId}"
|
|
1844
|
+
After the DNS record resolves, re-run recover_domain_site with verificationId: "${res2.verificationId}".`,
|
|
1845
|
+
{
|
|
1846
|
+
verificationId: res2.verificationId,
|
|
1847
|
+
apexDomain: res2.apexDomain,
|
|
1848
|
+
verificationRecord: res2.verificationRecord,
|
|
1849
|
+
revokesPreviousCredentialsByDefault: true
|
|
1850
|
+
},
|
|
1851
|
+
"waiting_user"
|
|
1639
1852
|
);
|
|
1640
1853
|
}
|
|
1854
|
+
if (!args.verificationId) {
|
|
1855
|
+
throw new SakupaError(
|
|
1856
|
+
"invalid_request",
|
|
1857
|
+
"verificationId is required for status or complete"
|
|
1858
|
+
);
|
|
1859
|
+
}
|
|
1860
|
+
if (args.action === "status") {
|
|
1861
|
+
const res2 = await ctx.client.getRecoveryStatus(args.verificationId);
|
|
1862
|
+
return structuredToolResult({
|
|
1863
|
+
schemaVersion: 1,
|
|
1864
|
+
outcome: res2.status === "expired" ? "expired" : res2.readyToComplete ? "completed" : "pending_provider",
|
|
1865
|
+
resultCode: res2.status === "expired" ? "domain_recovery_expired" : res2.readyToComplete ? "domain_recovery_ready" : "domain_recovery_pending_dns",
|
|
1866
|
+
summary: `DNS \u6062\u590D\u9A8C\u8BC1\u72B6\u6001\uFF1A${res2.status}`,
|
|
1867
|
+
data: { recovery: res2 },
|
|
1868
|
+
nextActions: [
|
|
1869
|
+
{
|
|
1870
|
+
tool: "recover_domain_site",
|
|
1871
|
+
arguments: { action: "complete", verificationId: args.verificationId },
|
|
1872
|
+
allowed: res2.readyToComplete,
|
|
1873
|
+
...res2.readyToComplete ? {} : { reasonCode: res2.status }
|
|
1874
|
+
}
|
|
1875
|
+
]
|
|
1876
|
+
});
|
|
1877
|
+
}
|
|
1641
1878
|
const res = await ctx.client.completeRecovery(args.verificationId, {
|
|
1642
1879
|
...args.preserveExistingCredentials !== void 0 ? { preserveExistingCredentials: args.preserveExistingCredentials } : {}
|
|
1643
1880
|
});
|
|
@@ -1649,6 +1886,7 @@ After the DNS record resolves, re-run recover_domain_site with verificationId: "
|
|
|
1649
1886
|
apiBaseUrl: ctx.apiBaseUrl
|
|
1650
1887
|
});
|
|
1651
1888
|
return text(
|
|
1889
|
+
"domain_recovery_completed",
|
|
1652
1890
|
`Recovery complete.
|
|
1653
1891
|
Site: ${res.siteId} (hostnames: ${res.boundHostnames.join(", ") || "(none)"})
|
|
1654
1892
|
Previous credentials revoked: ${res.revokedPreviousCredentials ? "YES" : "no (preserved on request)"}
|
|
@@ -1656,56 +1894,16 @@ Previous credentials revoked: ${res.revokedPreviousCredentials ? "YES" : "no (pr
|
|
|
1656
1894
|
A NEW management credential was written to .sakupa/site.json in this project \u2014 this project now manages the site.
|
|
1657
1895
|
` + credentialGitReminder(ctx.projectDir) + `
|
|
1658
1896
|
Download the current site content (signed URL):
|
|
1659
|
-
${res.archiveUrl}
|
|
1660
|
-
|
|
1661
|
-
|
|
1662
|
-
|
|
1663
|
-
|
|
1664
|
-
|
|
1665
|
-
|
|
1666
|
-
|
|
1667
|
-
"set_billing_plan",
|
|
1668
|
-
{
|
|
1669
|
-
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.`,
|
|
1670
|
-
inputSchema: {
|
|
1671
|
-
plan: planEnum.optional().describe("Target monthly plan."),
|
|
1672
|
-
cancelRenewal: z.boolean().optional().describe(
|
|
1673
|
-
"true: cancel renewal (the site stays permanent to the end of the paid month, then reverts to free). false: re-enable renewal."
|
|
1674
|
-
),
|
|
1675
|
-
cancelNow: z.boolean().optional().describe("End the subscription immediately; the site reverts to free right away."),
|
|
1676
|
-
confirm: z.boolean().optional().describe("User saw the consequences and explicitly confirmed.")
|
|
1677
|
-
}
|
|
1678
|
-
},
|
|
1679
|
-
async (args) => {
|
|
1680
|
-
try {
|
|
1681
|
-
const site = requireSiteFile(ctx);
|
|
1682
|
-
const req = {
|
|
1683
|
-
...args.plan !== void 0 ? { plan: args.plan } : {},
|
|
1684
|
-
...args.cancelRenewal !== void 0 ? { cancelRenewal: args.cancelRenewal } : {},
|
|
1685
|
-
...args.cancelNow !== void 0 ? { cancelNow: args.cancelNow } : {},
|
|
1686
|
-
confirm: args.confirm === true
|
|
1687
|
-
};
|
|
1688
|
-
try {
|
|
1689
|
-
const res = await ctx.client.setBillingPlan(site.siteId, site.credential, req);
|
|
1690
|
-
return textJson(
|
|
1691
|
-
`Billing updated for site ${res.siteId} (mode: ${res.mode}).
|
|
1692
|
-
Consequences:
|
|
1693
|
-
${res.consequences.map((c) => `- ${c}`).join("\n")}
|
|
1694
|
-
Result:`,
|
|
1695
|
-
res
|
|
1696
|
-
);
|
|
1697
|
-
} catch (e) {
|
|
1698
|
-
if (isSakupaError(e) && e.code === "confirmation_required") {
|
|
1699
|
-
return text(
|
|
1700
|
-
`Confirmation required before changing the billing plan \u2014 nothing was applied.
|
|
1701
|
-
|
|
1702
|
-
${e.message}
|
|
1703
|
-
` + (e.details !== void 0 ? `${JSON.stringify(e.details, null, 2)}
|
|
1704
|
-
` : "") + "\nPlease show these consequences to the user and, after their explicit confirmation, re-run set_billing_plan with the same arguments plus confirm: true."
|
|
1705
|
-
);
|
|
1897
|
+
${res.archiveUrl}`,
|
|
1898
|
+
{
|
|
1899
|
+
siteId: res.siteId,
|
|
1900
|
+
boundHostnames: res.boundHostnames,
|
|
1901
|
+
revokedPreviousCredentials: res.revokedPreviousCredentials,
|
|
1902
|
+
archiveUrl: res.archiveUrl,
|
|
1903
|
+
archiveExpiresAt: res.archiveExpiresAt,
|
|
1904
|
+
credentialStoredLocally: true
|
|
1706
1905
|
}
|
|
1707
|
-
|
|
1708
|
-
}
|
|
1906
|
+
);
|
|
1709
1907
|
} catch (e) {
|
|
1710
1908
|
return toolError(e);
|
|
1711
1909
|
}
|
|
@@ -1715,11 +1913,13 @@ ${e.message}
|
|
|
1715
1913
|
"create_support_ticket",
|
|
1716
1914
|
{
|
|
1717
1915
|
description: "Create a Sakupa support ticket for billing, payment, refund review, domain verification, deployment, serving or other issues the MCP cannot solve automatically. Do not include secrets, credentials or card data in the description.",
|
|
1916
|
+
outputSchema: STRUCTURED_TOOL_OUTPUT_SCHEMA,
|
|
1917
|
+
annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: true },
|
|
1718
1918
|
inputSchema: {
|
|
1719
1919
|
category: ticketCategoryEnum,
|
|
1720
|
-
subject:
|
|
1721
|
-
description:
|
|
1722
|
-
contactEmail:
|
|
1920
|
+
subject: z2.string().describe("Short subject line."),
|
|
1921
|
+
description: z2.string().describe("Problem description (no secrets, no card data)."),
|
|
1922
|
+
contactEmail: z2.string().optional().describe("Optional contact email for follow-up.")
|
|
1723
1923
|
}
|
|
1724
1924
|
},
|
|
1725
1925
|
async (args) => {
|
|
@@ -1732,7 +1932,11 @@ ${e.message}
|
|
|
1732
1932
|
description: args.description,
|
|
1733
1933
|
...args.contactEmail !== void 0 ? { contactEmail: args.contactEmail } : {}
|
|
1734
1934
|
});
|
|
1735
|
-
return text(
|
|
1935
|
+
return text(
|
|
1936
|
+
"support_ticket_created",
|
|
1937
|
+
`Support ticket created: ${res.ticketId} (status: ${res.status}).`,
|
|
1938
|
+
{ ticketId: res.ticketId, status: res.status }
|
|
1939
|
+
);
|
|
1736
1940
|
} catch (e) {
|
|
1737
1941
|
return toolError(e);
|
|
1738
1942
|
}
|
|
@@ -1742,15 +1946,17 @@ ${e.message}
|
|
|
1742
1946
|
"report_bug",
|
|
1743
1947
|
{
|
|
1744
1948
|
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.",
|
|
1949
|
+
outputSchema: STRUCTURED_TOOL_OUTPUT_SCHEMA,
|
|
1950
|
+
annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: true },
|
|
1745
1951
|
inputSchema: {
|
|
1746
|
-
toolName:
|
|
1747
|
-
errorCode:
|
|
1748
|
-
errorMessage:
|
|
1749
|
-
requestId:
|
|
1750
|
-
deploymentId:
|
|
1952
|
+
toolName: z2.string().describe('The Sakupa tool that failed, e.g. "deploy_site".'),
|
|
1953
|
+
errorCode: z2.string().optional(),
|
|
1954
|
+
errorMessage: z2.string().optional().describe("Sanitized error message (no secrets)."),
|
|
1955
|
+
requestId: z2.string().optional(),
|
|
1956
|
+
deploymentId: z2.string().optional(),
|
|
1751
1957
|
severity: severityEnum.optional(),
|
|
1752
|
-
description:
|
|
1753
|
-
confirmSubmit:
|
|
1958
|
+
description: z2.string().optional().describe("What happened, in the user's words (no secrets)."),
|
|
1959
|
+
confirmSubmit: z2.boolean().optional().describe("User reviewed the report payload and approved submission.")
|
|
1754
1960
|
}
|
|
1755
1961
|
},
|
|
1756
1962
|
async (args) => {
|
|
@@ -1776,14 +1982,18 @@ ${e.message}
|
|
|
1776
1982
|
};
|
|
1777
1983
|
if (args.confirmSubmit !== true) {
|
|
1778
1984
|
return textJson(
|
|
1985
|
+
"bug_report_preview_ready",
|
|
1779
1986
|
"Bug report prepared but NOT submitted. This is the exact payload that would be sent (structured diagnostics only \u2014 no file contents, source code or secrets). Please show it to the user; re-run report_bug with confirmSubmit: true to submit.",
|
|
1780
|
-
payload
|
|
1987
|
+
payload,
|
|
1988
|
+
"preview"
|
|
1781
1989
|
);
|
|
1782
1990
|
}
|
|
1783
1991
|
const res = await ctx.client.reportBug(payload, site?.credential);
|
|
1784
1992
|
return text(
|
|
1993
|
+
"bug_report_submitted",
|
|
1785
1994
|
`Bug report submitted. Ticket: ${res.ticketId}
|
|
1786
|
-
Summary: ${res.sanitizedSummary}
|
|
1995
|
+
Summary: ${res.sanitizedSummary}`,
|
|
1996
|
+
{ ticketId: res.ticketId, sanitizedSummary: res.sanitizedSummary }
|
|
1787
1997
|
);
|
|
1788
1998
|
} catch (e) {
|
|
1789
1999
|
return toolError(e);
|
|
@@ -1794,6 +2004,134 @@ Summary: ${res.sanitizedSummary}`
|
|
|
1794
2004
|
|
|
1795
2005
|
// src/server.ts
|
|
1796
2006
|
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
2007
|
+
|
|
2008
|
+
// src/tools/billing.ts
|
|
2009
|
+
import { z as z3 } from "zod";
|
|
2010
|
+
var plan = z3.enum(["water", "personal", "share", "business"]);
|
|
2011
|
+
var trigger = z3.enum(["actual_overage", "credential_automation"]);
|
|
2012
|
+
function registerBillingTools(server, ctx) {
|
|
2013
|
+
server.registerTool(
|
|
2014
|
+
"list_billing_plans",
|
|
2015
|
+
{
|
|
2016
|
+
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.",
|
|
2017
|
+
inputSchema: {},
|
|
2018
|
+
outputSchema: STRUCTURED_TOOL_OUTPUT_SCHEMA,
|
|
2019
|
+
annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: true }
|
|
2020
|
+
},
|
|
2021
|
+
async () => {
|
|
2022
|
+
try {
|
|
2023
|
+
const catalog = await ctx.client.getBillingPlanCatalog();
|
|
2024
|
+
return structuredToolResult({
|
|
2025
|
+
schemaVersion: 1,
|
|
2026
|
+
outcome: "completed",
|
|
2027
|
+
resultCode: "billing_catalog_returned",
|
|
2028
|
+
summary: `\u5DF2\u8FD4\u56DE ${catalog.plans.length} \u4E2A\u6708\u4ED8\u65B9\u6848\uFF1BStripe \u6258\u7BA1\u9875\u9762\u662F\u4ED8\u8D39\u4E0E\u6539\u6863\u7684\u6700\u7EC8\u786E\u8BA4\u5165\u53E3\u3002`,
|
|
2029
|
+
data: { catalog },
|
|
2030
|
+
nextActions: [{ tool: "subscribe_site", allowed: true }]
|
|
2031
|
+
});
|
|
2032
|
+
} catch (error) {
|
|
2033
|
+
return toolError(error);
|
|
2034
|
+
}
|
|
2035
|
+
}
|
|
2036
|
+
);
|
|
2037
|
+
server.registerTool(
|
|
2038
|
+
"manage_subscription",
|
|
2039
|
+
{
|
|
2040
|
+
description: "Manage this site subscription through explicit actions. open_plan_change returns a Stripe-hosted confirmation URL. create_automation_authorization returns a short-lived Sakupa authorization URL binding the exact cap and triggers. disable_automation is immediate and owner-authorized. request_authorized_upgrade executes only under an existing exact grant, fresh usage snapshot, catalog version and server-side safety gates.",
|
|
2041
|
+
inputSchema: {
|
|
2042
|
+
action: z3.enum([
|
|
2043
|
+
"open_plan_change",
|
|
2044
|
+
"create_automation_authorization",
|
|
2045
|
+
"disable_automation",
|
|
2046
|
+
"request_authorized_upgrade"
|
|
2047
|
+
]),
|
|
2048
|
+
operationId: z3.string().min(1).describe("Stable idempotency key chosen by the caller."),
|
|
2049
|
+
targetPlan: plan.optional(),
|
|
2050
|
+
maxPlan: plan.optional(),
|
|
2051
|
+
allowedTriggers: z3.array(trigger).min(1).optional(),
|
|
2052
|
+
observedSnapshotId: z3.string().optional(),
|
|
2053
|
+
observedCatalogVersion: z3.string().optional()
|
|
2054
|
+
},
|
|
2055
|
+
outputSchema: STRUCTURED_TOOL_OUTPUT_SCHEMA,
|
|
2056
|
+
annotations: { readOnlyHint: false, destructiveHint: true, openWorldHint: true }
|
|
2057
|
+
},
|
|
2058
|
+
async (args) => {
|
|
2059
|
+
try {
|
|
2060
|
+
const site = requireSiteFile(ctx);
|
|
2061
|
+
let request;
|
|
2062
|
+
if (args.action === "open_plan_change") {
|
|
2063
|
+
if (!args.targetPlan) {
|
|
2064
|
+
throw new SakupaError("invalid_request", "targetPlan is required for open_plan_change");
|
|
2065
|
+
}
|
|
2066
|
+
request = {
|
|
2067
|
+
action: args.action,
|
|
2068
|
+
targetPlan: args.targetPlan,
|
|
2069
|
+
operationId: args.operationId
|
|
2070
|
+
};
|
|
2071
|
+
} else if (args.action === "create_automation_authorization") {
|
|
2072
|
+
if (!args.maxPlan || !args.allowedTriggers) {
|
|
2073
|
+
throw new SakupaError(
|
|
2074
|
+
"invalid_request",
|
|
2075
|
+
"maxPlan and allowedTriggers are required for authorization"
|
|
2076
|
+
);
|
|
2077
|
+
}
|
|
2078
|
+
request = {
|
|
2079
|
+
action: args.action,
|
|
2080
|
+
maxPlan: args.maxPlan,
|
|
2081
|
+
allowedTriggers: args.allowedTriggers,
|
|
2082
|
+
operationId: args.operationId
|
|
2083
|
+
};
|
|
2084
|
+
} else if (args.action === "disable_automation") {
|
|
2085
|
+
request = { action: args.action, operationId: args.operationId };
|
|
2086
|
+
} else {
|
|
2087
|
+
if (!args.targetPlan) {
|
|
2088
|
+
throw new SakupaError(
|
|
2089
|
+
"invalid_request",
|
|
2090
|
+
"targetPlan is required for request_authorized_upgrade"
|
|
2091
|
+
);
|
|
2092
|
+
}
|
|
2093
|
+
request = {
|
|
2094
|
+
action: args.action,
|
|
2095
|
+
operationId: args.operationId,
|
|
2096
|
+
targetPlan: args.targetPlan,
|
|
2097
|
+
trigger: "credential_automation",
|
|
2098
|
+
...args.observedSnapshotId ? { observedSnapshotId: args.observedSnapshotId } : {},
|
|
2099
|
+
...args.observedCatalogVersion ? { observedCatalogVersion: args.observedCatalogVersion } : {}
|
|
2100
|
+
};
|
|
2101
|
+
}
|
|
2102
|
+
const result = await ctx.client.manageSubscription(site.siteId, site.credential, request);
|
|
2103
|
+
const portalUrl = "portalUrl" in result ? result.portalUrl : void 0;
|
|
2104
|
+
const authorizationUrl = "authorizationUrl" in result ? result.authorizationUrl : void 0;
|
|
2105
|
+
const executionOutcome = "outcome" in result ? result.outcome : void 0;
|
|
2106
|
+
const outcome = portalUrl || authorizationUrl ? "waiting_user" : executionOutcome === "pending_provider" ? "pending_provider" : executionOutcome === "blocked" ? "blocked" : "completed";
|
|
2107
|
+
const resultCode = "resultCode" in result ? result.resultCode : portalUrl ? "stripe_plan_change_confirmation_required" : authorizationUrl ? "automation_authorization_required" : args.action === "disable_automation" ? "automation_disabled" : "subscription_action_completed";
|
|
2108
|
+
const url = portalUrl ?? authorizationUrl;
|
|
2109
|
+
return structuredToolResult({
|
|
2110
|
+
schemaVersion: 1,
|
|
2111
|
+
outcome,
|
|
2112
|
+
resultCode,
|
|
2113
|
+
operationId: args.operationId,
|
|
2114
|
+
summary: url ? "\u5DF2\u521B\u5EFA\u77ED\u65F6\u6548\u6258\u7BA1\u786E\u8BA4\u94FE\u63A5\uFF1B\u5F53\u524D\u8BA2\u9605\u5C1A\u672A\u56E0\u521B\u5EFA\u94FE\u63A5\u800C\u6539\u53D8\u3002" : `\u8BA2\u9605\u52A8\u4F5C\u7ED3\u679C\uFF1A${resultCode}`,
|
|
2115
|
+
data: { action: args.action, result },
|
|
2116
|
+
...url ? {
|
|
2117
|
+
userAction: {
|
|
2118
|
+
type: "open_url",
|
|
2119
|
+
provider: portalUrl ? "stripe" : "sakupa",
|
|
2120
|
+
url,
|
|
2121
|
+
..."expiresAt" in result && typeof result.expiresAt === "string" ? { expiresAt: result.expiresAt } : {},
|
|
2122
|
+
expectedOutcome: portalUrl ? "\u7528\u6237\u5728 Stripe \u6258\u7BA1\u9875\u9762\u786E\u8BA4\u540E\uFF0C\u7531 webhook \u66F4\u65B0 Sakupa \u72B6\u6001" : "\u7528\u6237\u5728 Sakupa \u77ED\u65F6\u6548\u9875\u9762\u786E\u8BA4\u7CBE\u786E\u81EA\u52A8\u5347\u7EA7\u6388\u6743"
|
|
2123
|
+
}
|
|
2124
|
+
} : {},
|
|
2125
|
+
nextActions: [{ tool: "billing_status", allowed: true }]
|
|
2126
|
+
});
|
|
2127
|
+
} catch (error) {
|
|
2128
|
+
return toolError(error);
|
|
2129
|
+
}
|
|
2130
|
+
}
|
|
2131
|
+
);
|
|
2132
|
+
}
|
|
2133
|
+
|
|
2134
|
+
// src/server.ts
|
|
1797
2135
|
var INSTRUCTIONS = `Sakupa publishes AI-made static websites. AI-made pages, live in seconds.
|
|
1798
2136
|
|
|
1799
2137
|
Workflow:
|
|
@@ -1805,14 +2143,17 @@ Workflow:
|
|
|
1805
2143
|
management credential in .sakupa/site.json. Deploying again updates the site and refreshes
|
|
1806
2144
|
its validity; refresh_site extends validity without uploading.
|
|
1807
2145
|
3. To make the site PERMANENT, subscribe it to a monthly hosting plan (subscribe_site ->
|
|
1808
|
-
Stripe-hosted checkout; water/personal/share/business
|
|
1809
|
-
|
|
2146
|
+
Stripe-hosted checkout; water/personal/share/business). Paying makes the
|
|
2147
|
+
{shortId}.sakupa.com URL permanent \u2014 that is what payment buys. Usage over the chosen plan
|
|
2148
|
+
shows an over-limit notice by default. Automatic upgrades require a separate, bounded,
|
|
2149
|
+
one-time Sakupa authorization and can never exceed the user-approved plan cap.
|
|
1810
2150
|
4. Optionally bind a custom domain to the subscribed site (bind_domain): an included extra
|
|
1811
2151
|
serving surface alongside the permanent URL. Ownership is proven only by DNS control; the
|
|
1812
2152
|
first verified request wins; unverified requests expire after 72 hours. billing_status,
|
|
1813
|
-
|
|
1814
|
-
|
|
1815
|
-
paid
|
|
2153
|
+
manage_subscription, manage_billing and recover_domain_site manage the paid lifecycle. Manual plan
|
|
2154
|
+
changes are confirmed only on Stripe Customer Portal and synchronized by Stripe webhook.
|
|
2155
|
+
A cancellation keeps the site paid through the current period. Sakupa reverts it to a free
|
|
2156
|
+
24h site and removes paid data after Stripe sends the signed final-cancellation webhook.
|
|
1816
2157
|
|
|
1817
2158
|
Safety boundaries:
|
|
1818
2159
|
- Static output only: no SSR, API routes, middleware, server actions, databases or online builds.
|
|
@@ -1820,7 +2161,9 @@ Safety boundaries:
|
|
|
1820
2161
|
- Payment card data is entered only on Stripe-hosted pages \u2014 never through the AI tool.
|
|
1821
2162
|
- A subscription never grants domain ownership; only DNS verification does.
|
|
1822
2163
|
- The management credential lives only in .sakupa/site.json; never share or upload it. Without
|
|
1823
|
-
a bound custom domain, a lost credential is unrecoverable by design
|
|
2164
|
+
a bound custom domain, a lost credential is unrecoverable by design. manage_billing then opens
|
|
2165
|
+
Stripe's public no-code portal login, where the customer verifies the checkout email with a
|
|
2166
|
+
Stripe one-time passcode; it never restores site authority.`;
|
|
1824
2167
|
function createSakupaMcpServer(opts) {
|
|
1825
2168
|
const client = opts.client ?? new HttpApiClient(new FetchTransport(opts.apiBaseUrl));
|
|
1826
2169
|
const server = new McpServer(
|
|
@@ -1832,6 +2175,11 @@ function createSakupaMcpServer(opts) {
|
|
|
1832
2175
|
projectDir: opts.projectDir,
|
|
1833
2176
|
apiBaseUrl: opts.apiBaseUrl
|
|
1834
2177
|
});
|
|
2178
|
+
registerBillingTools(server, {
|
|
2179
|
+
client,
|
|
2180
|
+
projectDir: opts.projectDir,
|
|
2181
|
+
apiBaseUrl: opts.apiBaseUrl
|
|
2182
|
+
});
|
|
1835
2183
|
return server;
|
|
1836
2184
|
}
|
|
1837
2185
|
export {
|