@sakupa/mcp 0.1.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.
Files changed (3) hide show
  1. package/dist/bin.js +1866 -0
  2. package/dist/index.js +1861 -0
  3. package/package.json +32 -0
package/dist/bin.js ADDED
@@ -0,0 +1,1866 @@
1
+ #!/usr/bin/env node
2
+
3
+ // src/bin.ts
4
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
5
+
6
+ // ../core/dist/domain/constants.js
7
+ var SERVICE_DOMAIN = "sakupa.com";
8
+ var DEFAULT_API_BASE_URL = "https://api.sakupa.com";
9
+ var FREE_SITE_URL_SUFFIX = `.${SERVICE_DOMAIN}`;
10
+ var FREE_SITE_TTL_HOURS = 24;
11
+ var FREE_SITE_MAX_TOTAL_BYTES = 10 * 1024 * 1024;
12
+ var PAID_SITE_MAX_TOTAL_BYTES = 2 * 1024 * 1024 * 1024;
13
+ var MAX_FILE_COUNT = 5e3;
14
+ var MAX_SINGLE_FILE_BYTES = 25 * 1024 * 1024;
15
+ var FREE_REQUESTS_PER_MINUTE = 240;
16
+ var LOAD_BUDGET_ADVISORY = {
17
+ maxFiles: 50,
18
+ maxTotalBytes: 5 * 1024 * 1024,
19
+ minLoadsPerMinute: 8
20
+ };
21
+ var ALLOWED_STATIC_EXTENSIONS = {
22
+ html: "text/html; charset=utf-8",
23
+ htm: "text/html; charset=utf-8",
24
+ css: "text/css; charset=utf-8",
25
+ js: "text/javascript; charset=utf-8",
26
+ mjs: "text/javascript; charset=utf-8",
27
+ json: "application/json",
28
+ map: "application/json",
29
+ txt: "text/plain; charset=utf-8",
30
+ xml: "application/xml",
31
+ webmanifest: "application/manifest+json",
32
+ png: "image/png",
33
+ jpg: "image/jpeg",
34
+ jpeg: "image/jpeg",
35
+ webp: "image/webp",
36
+ gif: "image/gif",
37
+ svg: "image/svg+xml",
38
+ ico: "image/x-icon",
39
+ avif: "image/avif",
40
+ woff: "font/woff",
41
+ woff2: "font/woff2",
42
+ ttf: "font/ttf",
43
+ otf: "font/otf"
44
+ };
45
+ var FORBIDDEN_EXTENSIONS = [
46
+ "zip",
47
+ "tar",
48
+ "gz",
49
+ "tgz",
50
+ "bz2",
51
+ "xz",
52
+ "7z",
53
+ "rar",
54
+ "mp4",
55
+ "mov",
56
+ "avi",
57
+ "webm",
58
+ "mkv",
59
+ "m4v",
60
+ "mp3",
61
+ "wav",
62
+ "ogg",
63
+ "flac",
64
+ "aac",
65
+ "m4a",
66
+ "pdf",
67
+ "exe",
68
+ "dll",
69
+ "so",
70
+ "dylib",
71
+ "msi",
72
+ "dmg",
73
+ "pkg",
74
+ "deb",
75
+ "rpm",
76
+ "bin",
77
+ "sh",
78
+ "bash",
79
+ "zsh",
80
+ "bat",
81
+ "cmd",
82
+ "ps1",
83
+ "php",
84
+ "py",
85
+ "rb",
86
+ "pl",
87
+ "jar",
88
+ "war",
89
+ "sqlite",
90
+ "db",
91
+ "pem",
92
+ "key",
93
+ "p12",
94
+ "pfx",
95
+ "crt",
96
+ "csr",
97
+ "env"
98
+ ];
99
+ var FORBIDDEN_FILE_NAMES = [
100
+ ".env",
101
+ ".envrc",
102
+ ".npmrc",
103
+ ".netrc",
104
+ "id_rsa",
105
+ "id_dsa",
106
+ "id_ecdsa",
107
+ "id_ed25519",
108
+ "credentials",
109
+ "credentials.json",
110
+ "serviceaccount.json",
111
+ "service-account.json",
112
+ ".htpasswd"
113
+ ];
114
+ var FORBIDDEN_PATH_SEGMENTS = [
115
+ "node_modules",
116
+ ".git",
117
+ ".svn",
118
+ ".hg",
119
+ ".npm",
120
+ ".yarn",
121
+ ".pnpm-store",
122
+ "__macosx",
123
+ ".sakupa",
124
+ ".aws",
125
+ ".ssh"
126
+ ];
127
+ var ALLOWED_HIDDEN_PATHS = [".well-known/"];
128
+
129
+ // ../core/dist/domain/errors.js
130
+ var HTTP_STATUS = {
131
+ invalid_request: 400,
132
+ validation_failed: 422,
133
+ unauthorized: 401,
134
+ forbidden: 403,
135
+ not_found: 404,
136
+ conflict: 409,
137
+ state_conflict: 409,
138
+ rate_limited: 429,
139
+ insufficient_balance: 402,
140
+ payment_required: 402,
141
+ confirmation_required: 428,
142
+ dns_not_verified: 403,
143
+ not_deployable: 422,
144
+ internal: 500
145
+ };
146
+ var SakupaError = class extends Error {
147
+ code;
148
+ httpStatus;
149
+ details;
150
+ constructor(code, message, details) {
151
+ super(message);
152
+ this.name = "SakupaError";
153
+ this.code = code;
154
+ this.httpStatus = HTTP_STATUS[code];
155
+ this.details = details;
156
+ }
157
+ };
158
+ function isSakupaError(err) {
159
+ return err instanceof SakupaError;
160
+ }
161
+
162
+ // ../core/dist/domain/tiers.js
163
+ var GB = 1024 * 1024 * 1024;
164
+ var MB = 1024 * 1024;
165
+ var TIER_ORDER = ["water", "personal", "share", "business"];
166
+ var TIERS = {
167
+ water: {
168
+ id: "water",
169
+ displayName: "Water",
170
+ priceJpy: 200,
171
+ storageBytes: 100 * MB,
172
+ transferBytes: 2 * GB,
173
+ requests: 2e4
174
+ },
175
+ personal: {
176
+ id: "personal",
177
+ displayName: "Personal",
178
+ priceJpy: 500,
179
+ storageBytes: 300 * MB,
180
+ transferBytes: 8 * GB,
181
+ requests: 1e5
182
+ },
183
+ share: {
184
+ id: "share",
185
+ displayName: "Share",
186
+ priceJpy: 1e3,
187
+ storageBytes: 1 * GB,
188
+ transferBytes: 20 * GB,
189
+ requests: 3e5
190
+ },
191
+ business: {
192
+ id: "business",
193
+ displayName: "Business",
194
+ priceJpy: 2e3,
195
+ storageBytes: 2 * GB,
196
+ transferBytes: 50 * GB,
197
+ requests: 8e5
198
+ }
199
+ };
200
+ function tierPriceJpy(id) {
201
+ return TIERS[id].priceJpy;
202
+ }
203
+
204
+ // ../core/dist/domain/manifest.js
205
+ var PRIVATE_KEY_MARKER = /-----BEGIN [A-Z0-9 ]*PRIVATE KEY-----/;
206
+ var TEXT_SNIFF_EXTENSIONS = /* @__PURE__ */ new Set(["html", "htm", "js", "mjs", "css", "json", "txt", "xml"]);
207
+ function fileExtension(path) {
208
+ const base = path.split("/").pop() ?? "";
209
+ const idx = base.lastIndexOf(".");
210
+ if (idx <= 0)
211
+ return "";
212
+ return base.slice(idx + 1).toLowerCase();
213
+ }
214
+ function isPathSafe(path) {
215
+ if (path.length === 0 || path.length > 1024)
216
+ return false;
217
+ if (path.startsWith("/") || path.includes("\\") || path.includes("\0"))
218
+ return false;
219
+ const segments = path.split("/");
220
+ for (const seg of segments) {
221
+ if (seg === "" || seg === "." || seg === "..")
222
+ return false;
223
+ }
224
+ return true;
225
+ }
226
+ function isAllowedHidden(path) {
227
+ return ALLOWED_HIDDEN_PATHS.some((prefix) => path === prefix || path.startsWith(prefix));
228
+ }
229
+ function hasHiddenSegment(path) {
230
+ return path.split("/").some((seg) => seg.startsWith(".") && seg.length > 1);
231
+ }
232
+ function extractHtmlLang(html) {
233
+ const m = /<html[^>]*\slang\s*=\s*["']?([A-Za-z-]+)["']?/i.exec(html);
234
+ return m?.[1] ?? null;
235
+ }
236
+ function normalizeSupportedLang(lang) {
237
+ if (!lang)
238
+ return null;
239
+ const lower = lang.toLowerCase();
240
+ if (lower === "en" || lower.startsWith("en-"))
241
+ return "en";
242
+ if (lower === "ja" || lower.startsWith("ja-"))
243
+ return "ja";
244
+ if (lower === "zh-cn" || lower === "zh" || lower === "zh-hans")
245
+ return "zh-CN";
246
+ return null;
247
+ }
248
+ function validateDeployableFiles(files, opts) {
249
+ const issues = [];
250
+ const seen = /* @__PURE__ */ new Set();
251
+ let totalBytes = 0;
252
+ if (files.length === 0) {
253
+ issues.push({ severity: "error", code: "empty_upload", message: "No files to deploy." });
254
+ }
255
+ if (files.length > MAX_FILE_COUNT) {
256
+ issues.push({
257
+ severity: "error",
258
+ code: "too_many_files",
259
+ message: `Too many files (${files.length} > ${MAX_FILE_COUNT}).`
260
+ });
261
+ }
262
+ const htmlPaths = [];
263
+ for (const file of files) {
264
+ const { path, size } = file;
265
+ if (!isPathSafe(path)) {
266
+ issues.push({
267
+ severity: "error",
268
+ code: "unsafe_path",
269
+ path,
270
+ message: `Unsafe file path: ${path}`
271
+ });
272
+ continue;
273
+ }
274
+ if (seen.has(path)) {
275
+ issues.push({
276
+ severity: "error",
277
+ code: "duplicate_path",
278
+ path,
279
+ message: `Duplicate file path: ${path}`
280
+ });
281
+ continue;
282
+ }
283
+ seen.add(path);
284
+ totalBytes += size;
285
+ const segments = path.split("/");
286
+ const base = (segments[segments.length - 1] ?? "").toLowerCase();
287
+ const ext = fileExtension(path);
288
+ const forbiddenSegment = segments.find((seg) => FORBIDDEN_PATH_SEGMENTS.includes(seg.toLowerCase()));
289
+ if (forbiddenSegment) {
290
+ issues.push({
291
+ severity: "error",
292
+ code: "forbidden_directory",
293
+ path,
294
+ message: `Directory "${forbiddenSegment}" cannot be deployed. Deploy only the built static output.`
295
+ });
296
+ continue;
297
+ }
298
+ if (FORBIDDEN_FILE_NAMES.includes(base) || base.startsWith(".env")) {
299
+ issues.push({
300
+ severity: "error",
301
+ code: "forbidden_file_name",
302
+ path,
303
+ message: `File "${base}" looks like a secret or configuration file and is never deployable.`
304
+ });
305
+ continue;
306
+ }
307
+ if (hasHiddenSegment(path) && !isAllowedHidden(path)) {
308
+ issues.push({
309
+ severity: "error",
310
+ code: "hidden_file",
311
+ path,
312
+ message: `Hidden file "${path}" is not deployable (only .well-known/ is allowed).`
313
+ });
314
+ continue;
315
+ }
316
+ if (FORBIDDEN_EXTENSIONS.includes(ext)) {
317
+ issues.push({
318
+ severity: "error",
319
+ code: "forbidden_extension",
320
+ path,
321
+ message: `File type ".${ext}" is not allowed (archives, media, executables, server code and secrets are rejected).`
322
+ });
323
+ continue;
324
+ }
325
+ if (!(ext in ALLOWED_STATIC_EXTENSIONS)) {
326
+ issues.push({
327
+ severity: "error",
328
+ code: "unknown_extension",
329
+ path,
330
+ message: `File type ".${ext || "(none)"}" is not in the static whitelist.`
331
+ });
332
+ continue;
333
+ }
334
+ if (size > MAX_SINGLE_FILE_BYTES) {
335
+ issues.push({
336
+ severity: "error",
337
+ code: "file_too_large",
338
+ path,
339
+ message: `File exceeds the ${Math.floor(MAX_SINGLE_FILE_BYTES / (1024 * 1024))}MB single-file limit.`
340
+ });
341
+ }
342
+ if (file.content && TEXT_SNIFF_EXTENSIONS.has(ext)) {
343
+ const text2 = safeDecode(file.content);
344
+ if (text2 && PRIVATE_KEY_MARKER.test(text2)) {
345
+ issues.push({
346
+ severity: "error",
347
+ code: "private_key_content",
348
+ path,
349
+ message: `File contains a private key block and is never deployable.`
350
+ });
351
+ }
352
+ }
353
+ if (ext === "html" || ext === "htm")
354
+ htmlPaths.push(path);
355
+ }
356
+ const maxTotal = opts.mode === "free" ? FREE_SITE_MAX_TOTAL_BYTES : PAID_SITE_MAX_TOTAL_BYTES;
357
+ if (totalBytes > maxTotal) {
358
+ issues.push({
359
+ severity: "error",
360
+ code: "total_too_large",
361
+ message: `Total size ${(totalBytes / (1024 * 1024)).toFixed(1)}MB exceeds the ${Math.floor(maxTotal / (1024 * 1024))}MB ${opts.mode} limit.`
362
+ });
363
+ }
364
+ if (opts.mode === "free" && files.length > 0) {
365
+ const loadsPerMinute = Math.max(1, Math.floor(FREE_REQUESTS_PER_MINUTE / files.length));
366
+ if (files.length > LOAD_BUDGET_ADVISORY.maxFiles || totalBytes > LOAD_BUDGET_ADVISORY.maxTotalBytes || loadsPerMinute < LOAD_BUDGET_ADVISORY.minLoadsPerMinute) {
367
+ issues.push({
368
+ severity: "warning",
369
+ code: "load_budget",
370
+ message: `Load budget checkup: ${files.length} files, ${(totalBytes / (1024 * 1024)).toFixed(1)}MB total. A full page load fetches up to ${files.length} requests, so the free preview's ${FREE_REQUESTS_PER_MINUTE} requests/minute cap sustains roughly ${loadsPerMinute} full loads per minute before visitors see rate-limit errors. Consider bundling CSS/JS, compressing or lazy-loading images, and inlining small icons. This is advice only \u2014 publishing is not blocked.`
371
+ });
372
+ }
373
+ }
374
+ const entryHtmlPath = seen.has("index.html") ? "index.html" : void 0;
375
+ if (!entryHtmlPath && files.length > 0) {
376
+ issues.push({
377
+ severity: "error",
378
+ code: "missing_index_html",
379
+ message: "No index.html at the root of the output directory. Deploy the built static output, not the source project."
380
+ });
381
+ }
382
+ let htmlLang;
383
+ let supportedLang;
384
+ if (entryHtmlPath) {
385
+ const entry = files.find((f) => f.path === entryHtmlPath);
386
+ if (entry?.content) {
387
+ const text2 = safeDecode(entry.content) ?? "";
388
+ const lang = extractHtmlLang(text2);
389
+ if (lang)
390
+ htmlLang = lang;
391
+ const normalized = normalizeSupportedLang(lang);
392
+ if (normalized) {
393
+ supportedLang = normalized;
394
+ } else if (lang) {
395
+ issues.push({
396
+ severity: "warning",
397
+ code: "unsupported_html_lang",
398
+ path: entryHtmlPath,
399
+ message: `html lang="${lang}" is not a supported UI language (en, ja, zh-CN). English will be used for Sakupa surfaces.`
400
+ });
401
+ } else {
402
+ issues.push({
403
+ severity: "warning",
404
+ code: "missing_html_lang",
405
+ path: entryHtmlPath,
406
+ message: 'The entry HTML has no lang attribute. Add html lang="en" | "ja" | "zh-CN" so Sakupa surfaces match the site language.'
407
+ });
408
+ }
409
+ }
410
+ }
411
+ const jsCount = files.filter((f) => ["js", "mjs"].includes(fileExtension(f.path))).length;
412
+ const looksLikeSpa = htmlPaths.length === 1 && entryHtmlPath === "index.html" && jsCount > 0;
413
+ const wantsSpa = opts.spaFallbackRequested === true || looksLikeSpa;
414
+ const spaFallbackConfirmationRequired = wantsSpa && opts.spaFallbackConfirmed !== true;
415
+ if (opts.spaFallbackRequested === true && opts.spaFallbackConfirmed !== true) {
416
+ issues.push({
417
+ severity: "error",
418
+ code: "spa_fallback_confirmation_required",
419
+ message: "SPA fallback rewrites unknown paths to index.html and changes normal 404 behavior. It must be explicitly confirmed."
420
+ });
421
+ }
422
+ const ok = issues.every((i) => i.severity !== "error");
423
+ return {
424
+ ok,
425
+ issues,
426
+ totalBytes,
427
+ fileCount: files.length,
428
+ entryHtmlPath,
429
+ htmlLang,
430
+ supportedLang,
431
+ looksLikeSpa,
432
+ spaFallbackConfirmationRequired
433
+ };
434
+ }
435
+ function safeDecode(bytes) {
436
+ try {
437
+ return new TextDecoder("utf-8", { fatal: false }).decode(bytes);
438
+ } catch {
439
+ return null;
440
+ }
441
+ }
442
+
443
+ // ../core/dist/domain/subscription.js
444
+ var SUBSCRIPTION_WARNING_TEXT = "You are subscribing {billingDomain} to Sakupa Domain Hosting: the {plan} monthly plan (\xA5{priceJpy}/month).\n\nPaying does not prove that you own this domain, does not grant management rights, and does not by itself publish a website: the domain must still pass DNS ownership verification.\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; hosting then runs to the end of the already-paid month and stops.";
445
+
446
+ // ../core/dist/dto.js
447
+ var CREDENTIAL_HEADER = "x-sakupa-credential";
448
+ var IDEMPOTENCY_HEADER = "x-sakupa-idempotency-key";
449
+
450
+ // ../core/dist/services/sites.js
451
+ var utf8Decoder = new TextDecoder("utf-8", { fatal: false });
452
+ var utf8Encoder = new TextEncoder();
453
+
454
+ // src/server.ts
455
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
456
+
457
+ // src/api-client.ts
458
+ var KNOWN_ERROR_CODES = /* @__PURE__ */ new Set([
459
+ "invalid_request",
460
+ "validation_failed",
461
+ "unauthorized",
462
+ "forbidden",
463
+ "not_found",
464
+ "conflict",
465
+ "state_conflict",
466
+ "rate_limited",
467
+ "insufficient_balance",
468
+ "payment_required",
469
+ "confirmation_required",
470
+ "dns_not_verified",
471
+ "not_deployable",
472
+ "internal"
473
+ ]);
474
+ var HttpApiClient = class {
475
+ constructor(transport) {
476
+ this.transport = transport;
477
+ }
478
+ async call(method, path, opts = {}) {
479
+ const headers = {};
480
+ if (opts.credential !== void 0) headers[CREDENTIAL_HEADER] = opts.credential;
481
+ if (opts.idempotencyKey !== void 0) headers[IDEMPOTENCY_HEADER] = opts.idempotencyKey;
482
+ const req = {
483
+ method,
484
+ path,
485
+ headers,
486
+ ...opts.body !== void 0 ? { body: JSON.stringify(opts.body) } : {}
487
+ };
488
+ const res = await this.transport.request(req);
489
+ if (res.status < 200 || res.status >= 300) {
490
+ throw this.toError(res.status, res.body);
491
+ }
492
+ if (res.body === void 0 || res.body.length === 0) return void 0;
493
+ return JSON.parse(res.body);
494
+ }
495
+ toError(status, body) {
496
+ if (body) {
497
+ try {
498
+ const parsed = JSON.parse(body);
499
+ const code = parsed?.error?.code;
500
+ const message = parsed?.error?.message ?? `API request failed with HTTP ${status}`;
501
+ if (typeof code === "string" && KNOWN_ERROR_CODES.has(code)) {
502
+ return new SakupaError(code, message, parsed.error.details);
503
+ }
504
+ return new SakupaError("internal", message, {
505
+ originalCode: code,
506
+ details: parsed?.error?.details
507
+ });
508
+ } catch (e) {
509
+ if (e instanceof SakupaError) throw e;
510
+ }
511
+ }
512
+ return new SakupaError("internal", `API request failed with HTTP ${status}`, {
513
+ httpStatus: status,
514
+ body: body?.slice(0, 200)
515
+ });
516
+ }
517
+ async createSite(req, _clientIp) {
518
+ return this.call("POST", "/v1/sites", { body: req });
519
+ }
520
+ async createDeployment(siteId, credential, req) {
521
+ return this.call(
522
+ "POST",
523
+ `/v1/sites/${encodeURIComponent(siteId)}/deployments`,
524
+ { credential, body: req }
525
+ );
526
+ }
527
+ async finalizeDeployment(deploymentId, siteId, credential) {
528
+ return this.call(
529
+ "POST",
530
+ `/v1/deployments/${encodeURIComponent(deploymentId)}/finalize`,
531
+ { credential, body: { siteId } }
532
+ );
533
+ }
534
+ async uploadFile(target, body) {
535
+ await this.transport.upload(target, body);
536
+ }
537
+ async refreshSite(siteId, credential) {
538
+ return this.call(
539
+ "POST",
540
+ `/v1/sites/${encodeURIComponent(siteId)}/refresh`,
541
+ { credential }
542
+ );
543
+ }
544
+ async getSiteStatus(siteId, credential) {
545
+ return this.call("GET", `/v1/sites/${encodeURIComponent(siteId)}`, {
546
+ credential
547
+ });
548
+ }
549
+ async deleteSite(siteId, credential) {
550
+ await this.call("DELETE", `/v1/sites/${encodeURIComponent(siteId)}`, { credential });
551
+ }
552
+ async bindDomain(credential, req) {
553
+ return this.call("POST", "/v1/domains/bind", { credential, body: req });
554
+ }
555
+ async checkVerification(verificationId, credential) {
556
+ return this.call(
557
+ "POST",
558
+ `/v1/domains/verifications/${encodeURIComponent(verificationId)}/check`,
559
+ { credential }
560
+ );
561
+ }
562
+ async recoverDomain(req) {
563
+ return this.call("POST", "/v1/domains/recover", { body: req });
564
+ }
565
+ async completeRecovery(verificationId, req) {
566
+ return this.call(
567
+ "POST",
568
+ `/v1/domains/recover/${encodeURIComponent(verificationId)}/complete`,
569
+ { body: req }
570
+ );
571
+ }
572
+ /** Wallet-era recharge checkout; unused (kept for SakupaApiClient compatibility). */
573
+ async createCheckout(req) {
574
+ return this.call("POST", "/v1/payments/checkout", {
575
+ body: req,
576
+ idempotencyKey: req.idempotencyKey
577
+ });
578
+ }
579
+ /** Subscription checkout: a fixed monthly plan for one apex domain (owner-only). */
580
+ async createPlanCheckout(req, credential) {
581
+ return this.call("POST", "/v1/payments/checkout", {
582
+ credential,
583
+ body: req,
584
+ idempotencyKey: req.idempotencyKey
585
+ });
586
+ }
587
+ /** Stripe-hosted billing portal (payment method, invoices, cancellation). */
588
+ async createBillingPortal(apexDomain, credential) {
589
+ return this.call(
590
+ "POST",
591
+ `/v1/billing/${encodeURIComponent(apexDomain)}/portal`,
592
+ { credential, body: {} }
593
+ );
594
+ }
595
+ async getBillingStatus(apexDomain, credential) {
596
+ return this.call(
597
+ "GET",
598
+ `/v1/billing/${encodeURIComponent(apexDomain)}`,
599
+ credential !== void 0 ? { credential } : {}
600
+ );
601
+ }
602
+ async setBillingPlan(apexDomain, credential, req) {
603
+ return this.call(
604
+ "POST",
605
+ `/v1/billing/${encodeURIComponent(apexDomain)}/plan`,
606
+ { credential, body: req }
607
+ );
608
+ }
609
+ async downgradeSite(siteId, credential, req) {
610
+ return this.call(
611
+ "POST",
612
+ `/v1/sites/${encodeURIComponent(siteId)}/downgrade`,
613
+ { credential, body: req }
614
+ );
615
+ }
616
+ async createTicket(credential, req) {
617
+ return this.call("POST", "/v1/support/tickets", {
618
+ credential,
619
+ body: req
620
+ });
621
+ }
622
+ async reportBug(req, credential) {
623
+ return this.call("POST", "/v1/support/bug-reports", {
624
+ ...credential !== void 0 ? { credential } : {},
625
+ body: req
626
+ });
627
+ }
628
+ };
629
+
630
+ // src/tools/definitions.ts
631
+ import { randomUUID } from "node:crypto";
632
+ import { promises as fs2 } from "node:fs";
633
+ import { join as join3, resolve as resolve2 } from "node:path";
634
+ import { z } from "zod";
635
+
636
+ // src/analyze/analyzer.ts
637
+ import { promises as fs } from "node:fs";
638
+ import { join, posix, resolve, sep } from "node:path";
639
+ var SERVER_RUNTIME_DEPS = ["express", "koa", "fastify", "hapi", "@hapi/hapi"];
640
+ var DB_RUNTIME_DEPS = [
641
+ "prisma",
642
+ "@prisma/client",
643
+ "mongoose",
644
+ "pg",
645
+ "mysql2",
646
+ "better-sqlite3",
647
+ "typeorm",
648
+ "sequelize",
649
+ "redis",
650
+ "ioredis"
651
+ ];
652
+ var USE_SERVER_SCAN_MAX_FILES = 200;
653
+ var USE_SERVER_SCAN_MAX_BYTES = 256 * 1024;
654
+ var CONTENT_READ_MAX_BYTES = 1024 * 1024;
655
+ var TEXT_CONTENT_EXTENSIONS = /* @__PURE__ */ new Set(["html", "htm", "js", "mjs", "css", "json", "txt", "xml"]);
656
+ var SOURCE_SCAN_EXTENSIONS = /* @__PURE__ */ new Set(["js", "jsx", "ts", "tsx", "mjs", "cjs"]);
657
+ var FORBIDDEN_SEGMENTS_LOWER = new Set(FORBIDDEN_PATH_SEGMENTS.map((s) => s.toLowerCase()));
658
+ async function isDirectory(path) {
659
+ try {
660
+ return (await fs.stat(path)).isDirectory();
661
+ } catch {
662
+ return false;
663
+ }
664
+ }
665
+ async function isFile(path) {
666
+ try {
667
+ return (await fs.stat(path)).isFile();
668
+ } catch {
669
+ return false;
670
+ }
671
+ }
672
+ async function readTextIfExists(path, maxBytes = CONTENT_READ_MAX_BYTES) {
673
+ try {
674
+ const stat = await fs.stat(path);
675
+ if (!stat.isFile() || stat.size > maxBytes) return null;
676
+ return await fs.readFile(path, "utf8");
677
+ } catch {
678
+ return null;
679
+ }
680
+ }
681
+ async function firstExistingFile(dir, names) {
682
+ for (const name of names) {
683
+ const p = join(dir, name);
684
+ if (await isFile(p)) return p;
685
+ }
686
+ return null;
687
+ }
688
+ function extensionOf(path) {
689
+ const base = path.split("/").pop() ?? "";
690
+ const idx = base.lastIndexOf(".");
691
+ if (idx <= 0) return "";
692
+ return base.slice(idx + 1).toLowerCase();
693
+ }
694
+ async function walkFiles(dir, opts) {
695
+ const out = [];
696
+ async function recurse(current, relPrefix) {
697
+ if (out.length > opts.maxFiles) return;
698
+ let entries;
699
+ try {
700
+ entries = await fs.readdir(current, { withFileTypes: true });
701
+ } catch {
702
+ return;
703
+ }
704
+ entries.sort((a, b) => a.name < b.name ? -1 : a.name > b.name ? 1 : 0);
705
+ for (const entry of entries) {
706
+ if (out.length > opts.maxFiles) return;
707
+ const rel = relPrefix.length > 0 ? `${relPrefix}/${entry.name}` : entry.name;
708
+ if (entry.isSymbolicLink()) continue;
709
+ if (entry.isDirectory()) {
710
+ if (FORBIDDEN_SEGMENTS_LOWER.has(entry.name.toLowerCase())) continue;
711
+ if (opts.skipRelDirs?.has(rel)) continue;
712
+ await recurse(join(current, entry.name), rel);
713
+ } else if (entry.isFile()) {
714
+ try {
715
+ const stat = await fs.stat(join(current, entry.name));
716
+ out.push({ path: rel, size: stat.size });
717
+ } catch {
718
+ }
719
+ }
720
+ }
721
+ }
722
+ await recurse(dir, "");
723
+ return out;
724
+ }
725
+ async function readPackageJson(projectDir) {
726
+ const raw = await readTextIfExists(join(projectDir, "package.json"));
727
+ if (raw === null) return null;
728
+ try {
729
+ const parsed = JSON.parse(raw);
730
+ return typeof parsed === "object" && parsed !== null ? parsed : null;
731
+ } catch {
732
+ return null;
733
+ }
734
+ }
735
+ function allDeps(pkg) {
736
+ return { ...pkg?.dependencies ?? {}, ...pkg?.devDependencies ?? {} };
737
+ }
738
+ async function anyFileMatches(dir, predicate) {
739
+ if (!await isDirectory(dir)) return false;
740
+ const files = await walkFiles(dir, { maxFiles: 2e3 });
741
+ return files.some((f) => predicate(f.path.split("/").pop() ?? ""));
742
+ }
743
+ async function detectFramework(projectDir, pkg) {
744
+ const deps = allDeps(pkg);
745
+ const ssrRisks = [];
746
+ const nextConfigPath = await firstExistingFile(projectDir, [
747
+ "next.config.js",
748
+ "next.config.mjs",
749
+ "next.config.ts",
750
+ "next.config.cjs"
751
+ ]);
752
+ if ("next" in deps || nextConfigPath !== null) {
753
+ const config = nextConfigPath ? await readTextIfExists(nextConfigPath) : null;
754
+ const staticExport = config !== null && /output\s*:\s*['"]export['"]/.test(config);
755
+ if (!staticExport) {
756
+ ssrRisks.push(
757
+ `Next.js project without output: 'export' in next.config.* \u2014 the default Next.js build requires a Node.js server. Sakupa only serves static files; add output: 'export' to next.config.* and build locally to produce a static "out" directory.`
758
+ );
759
+ }
760
+ for (const apiDir of ["pages/api", "src/pages/api"]) {
761
+ if (await isDirectory(join(projectDir, apiDir))) {
762
+ ssrRisks.push(
763
+ `API routes (${apiDir}/) require a server runtime and will not run on Sakupa. Remove them or move their logic to build time before static export.`
764
+ );
765
+ break;
766
+ }
767
+ }
768
+ for (const appDir of ["app", "src/app"]) {
769
+ if (await anyFileMatches(
770
+ join(projectDir, appDir),
771
+ (base) => /^route\.(ts|js|tsx|jsx|mjs)$/.test(base)
772
+ )) {
773
+ ssrRisks.push(
774
+ `App Router route handlers (${appDir}/**/route.ts|js) require a server runtime and will not run on Sakupa.`
775
+ );
776
+ break;
777
+ }
778
+ }
779
+ if (await firstExistingFile(projectDir, [
780
+ "middleware.ts",
781
+ "middleware.js",
782
+ "src/middleware.ts",
783
+ "src/middleware.js"
784
+ ]) !== null) {
785
+ ssrRisks.push("middleware.(ts|js) runs on a server/edge runtime and will not run on Sakupa.");
786
+ }
787
+ return {
788
+ framework: "next",
789
+ outputCandidates: ["out"],
790
+ buildCommandHint: "npm run build",
791
+ ssrRisks
792
+ };
793
+ }
794
+ const nuxtConfigPath = await firstExistingFile(projectDir, [
795
+ "nuxt.config.ts",
796
+ "nuxt.config.js",
797
+ "nuxt.config.mjs"
798
+ ]);
799
+ if ("nuxt" in deps || "nuxt3" in deps || nuxtConfigPath !== null) {
800
+ for (const serverDir of ["server/api", "server/routes"]) {
801
+ if (await isDirectory(join(projectDir, serverDir))) {
802
+ ssrRisks.push(
803
+ `Nuxt server handlers (${serverDir}/) require a server runtime and will not run on Sakupa. Use static generation (npx nuxi generate) and deploy .output/public.`
804
+ );
805
+ }
806
+ }
807
+ return {
808
+ framework: "nuxt",
809
+ outputCandidates: [".output/public", "dist"],
810
+ buildCommandHint: "npm run generate (or npx nuxi generate)",
811
+ ssrRisks
812
+ };
813
+ }
814
+ const astroConfigPath = await firstExistingFile(projectDir, [
815
+ "astro.config.mjs",
816
+ "astro.config.js",
817
+ "astro.config.ts"
818
+ ]);
819
+ if ("astro" in deps || astroConfigPath !== null) {
820
+ const config = astroConfigPath ? await readTextIfExists(astroConfigPath) : null;
821
+ if (config !== null && /output\s*:\s*['"]server['"]/.test(config)) {
822
+ ssrRisks.push(
823
+ "Astro config sets output: 'server' (SSR). Sakupa only serves static files; use the default static output (or output: 'static') and build locally."
824
+ );
825
+ }
826
+ return {
827
+ framework: "astro",
828
+ outputCandidates: ["dist"],
829
+ buildCommandHint: "npm run build",
830
+ ssrRisks
831
+ };
832
+ }
833
+ if ("@sveltejs/kit" in deps) {
834
+ if ("@sveltejs/adapter-node" in deps) {
835
+ ssrRisks.push(
836
+ "SvelteKit is configured with @sveltejs/adapter-node, which produces a Node.js server. Sakupa only serves static files; switch to @sveltejs/adapter-static and rebuild."
837
+ );
838
+ } else if (!("@sveltejs/adapter-static" in deps)) {
839
+ ssrRisks.push(
840
+ "SvelteKit requires @sveltejs/adapter-static to produce a fully static build. Install and configure it, then build locally."
841
+ );
842
+ }
843
+ if (await anyFileMatches(join(projectDir, "src/routes"), (base) => base.startsWith("+server."))) {
844
+ ssrRisks.push(
845
+ "SvelteKit +server.* endpoint files require a server runtime and will not run on Sakupa."
846
+ );
847
+ }
848
+ return {
849
+ framework: "sveltekit",
850
+ outputCandidates: ["build"],
851
+ buildCommandHint: "npm run build",
852
+ ssrRisks
853
+ };
854
+ }
855
+ if ("react-scripts" in deps) {
856
+ return {
857
+ framework: "create-react-app",
858
+ outputCandidates: ["build"],
859
+ buildCommandHint: "npm run build",
860
+ ssrRisks
861
+ };
862
+ }
863
+ const viteConfigPath = await firstExistingFile(projectDir, [
864
+ "vite.config.ts",
865
+ "vite.config.js",
866
+ "vite.config.mjs"
867
+ ]);
868
+ if ("vite" in deps || viteConfigPath !== null) {
869
+ let framework = "vite";
870
+ if ("vue" in deps) framework = "vue (vite)";
871
+ else if ("react" in deps) framework = "react (vite)";
872
+ else if ("svelte" in deps) framework = "svelte (vite)";
873
+ return {
874
+ framework,
875
+ outputCandidates: ["dist"],
876
+ buildCommandHint: "npm run build",
877
+ ssrRisks
878
+ };
879
+ }
880
+ return null;
881
+ }
882
+ function serverAndDbDepRisks(pkg, hasStaticOutput) {
883
+ const deps = allDeps(pkg);
884
+ const risks = [];
885
+ for (const name of SERVER_RUNTIME_DEPS) {
886
+ if (name in deps) {
887
+ risks.push(
888
+ hasStaticOutput ? `Warning: dependency "${name}" is a server framework. Sakupa never runs it online \u2014 only the static output is served. Using it locally at build time is fine.` : `Dependency "${name}" is a server framework. Sakupa does not host server runtimes; only prebuilt static output can be deployed.`
889
+ );
890
+ }
891
+ }
892
+ for (const name of DB_RUNTIME_DEPS) {
893
+ if (name in deps) {
894
+ risks.push(
895
+ hasStaticOutput ? `Warning: dependency "${name}" is a database/runtime client. Sakupa never runs it online \u2014 using a database locally at build time to generate static pages is fine.` : `Dependency "${name}" is a database/runtime client. Sakupa does not host databases or server runtimes; generate static pages locally and deploy only the output.`
896
+ );
897
+ }
898
+ }
899
+ return risks;
900
+ }
901
+ async function scanForUseServer(projectDir, skipRelDirs) {
902
+ const files = await walkFiles(projectDir, { maxFiles: USE_SERVER_SCAN_MAX_FILES, skipRelDirs });
903
+ let scanned = 0;
904
+ for (const file of files) {
905
+ if (scanned >= USE_SERVER_SCAN_MAX_FILES) break;
906
+ if (!SOURCE_SCAN_EXTENSIONS.has(extensionOf(file.path))) continue;
907
+ scanned += 1;
908
+ const text2 = await readTextIfExists(join(projectDir, file.path), USE_SERVER_SCAN_MAX_BYTES);
909
+ if (text2 !== null && /['"]use server['"]/.test(text2)) return true;
910
+ }
911
+ return false;
912
+ }
913
+ function normalizeOutputDir(outputDir) {
914
+ const normalized = posix.normalize(outputDir.replaceAll(sep, "/")).replace(/\/+$/, "");
915
+ return normalized === "" ? "." : normalized;
916
+ }
917
+ async function analyzeProject(projectDir, opts = {}) {
918
+ const root = resolve(projectDir);
919
+ const pkg = await readPackageJson(root);
920
+ const detection = await detectFramework(root, pkg);
921
+ const ssrRisks = [...detection?.ssrRisks ?? []];
922
+ const hasBuildScript = typeof pkg?.scripts?.["build"] === "string";
923
+ const buildRequired = detection !== null || hasBuildScript;
924
+ let outputDirRel;
925
+ let outputDirExists = false;
926
+ if (opts.outputDir !== void 0) {
927
+ outputDirRel = normalizeOutputDir(opts.outputDir);
928
+ const abs = resolve(root, outputDirRel);
929
+ if (!abs.startsWith(root)) {
930
+ outputDirRel = ".";
931
+ outputDirExists = false;
932
+ } else {
933
+ outputDirExists = await isDirectory(abs) || outputDirRel === "." && await isDirectory(root);
934
+ }
935
+ } else if (detection) {
936
+ for (const candidate of detection.outputCandidates) {
937
+ if (await isDirectory(join(root, candidate))) {
938
+ outputDirRel = candidate;
939
+ outputDirExists = true;
940
+ break;
941
+ }
942
+ }
943
+ if (outputDirRel === void 0) {
944
+ outputDirRel = detection.outputCandidates[0] ?? "dist";
945
+ outputDirExists = false;
946
+ }
947
+ } else if (!buildRequired) {
948
+ outputDirRel = ".";
949
+ outputDirExists = true;
950
+ } else if (hasBuildScript) {
951
+ for (const candidate of ["dist", "build", "out", "public"]) {
952
+ if (await isFile(join(root, candidate, "index.html"))) {
953
+ outputDirRel = candidate;
954
+ outputDirExists = true;
955
+ break;
956
+ }
957
+ }
958
+ }
959
+ const projectType = detection ? "framework" : outputDirRel !== void 0 && !buildRequired ? "plain-static" : "unknown";
960
+ const buildCommandHint = detection ? detection.buildCommandHint : hasBuildScript ? "npm run build" : void 0;
961
+ if (pkg !== null && buildRequired) {
962
+ const skip = /* @__PURE__ */ new Set();
963
+ if (outputDirRel !== void 0 && outputDirRel !== ".") skip.add(outputDirRel);
964
+ if (await scanForUseServer(root, skip)) {
965
+ ssrRisks.push(
966
+ "Source files contain 'use server' directives (server actions). Server actions require a server runtime and will not run on Sakupa."
967
+ );
968
+ }
969
+ }
970
+ if (outputDirRel === void 0 || !outputDirExists) {
971
+ ssrRisks.push(...serverAndDbDepRisks(pkg, false));
972
+ const sourceWithoutBuild = pkg !== null && buildRequired && (await isDirectory(join(root, "src")) || await isDirectory(join(root, "pages")));
973
+ let suggestedNextAction2;
974
+ if (opts.outputDir !== void 0) {
975
+ suggestedNextAction2 = `The requested output directory "${opts.outputDir}" does not exist. Build the project locally first (${buildCommandHint ?? "npm run build"}) or pass the correct directory, then re-run analyze_site.`;
976
+ } else if (ssrRisks.length > 0 && detection) {
977
+ suggestedNextAction2 = `This ${detection.framework} project appears to require a server runtime (see ssrRisks) and no static output directory was found. Convert it to static output (e.g. Next.js output: 'export', Nuxt generate, Astro static, SvelteKit adapter-static), run the build locally (${buildCommandHint ?? "npm run build"}), then re-run analyze_site.`;
978
+ } else if (sourceWithoutBuild || detection && !outputDirExists) {
979
+ suggestedNextAction2 = `This looks like a source project, not built static output. Run the build locally (${buildCommandHint ?? "npm run build"}) then re-run analyze_site.`;
980
+ } else {
981
+ suggestedNextAction2 = "No deployable static output was found. Create an index.html (or build the project locally so a static output directory exists), then re-run analyze_site.";
982
+ }
983
+ return {
984
+ projectType,
985
+ ...detection ? { framework: detection.framework } : {},
986
+ ...outputDirRel !== void 0 ? { recommendedOutputDir: outputDirRel } : {},
987
+ outputDirExists: false,
988
+ ...buildCommandHint !== void 0 ? { buildCommandHint } : {},
989
+ entryHtmlFound: false,
990
+ langSupported: false,
991
+ totalBytes: 0,
992
+ fileCount: 0,
993
+ issues: [],
994
+ ssrRisks,
995
+ spa: { looksLikeSpa: false, fallbackRecommended: false, confirmationRequired: false },
996
+ deployable: false,
997
+ suggestedNextAction: suggestedNextAction2
998
+ };
999
+ }
1000
+ const outputAbs = outputDirRel === "." ? root : resolve(root, outputDirRel);
1001
+ const walked = await walkFiles(outputAbs, { maxFiles: MAX_FILE_COUNT + 1 });
1002
+ const candidates = [];
1003
+ for (const file of walked) {
1004
+ const ext = extensionOf(file.path);
1005
+ let content;
1006
+ if (TEXT_CONTENT_EXTENSIONS.has(ext) && file.size <= CONTENT_READ_MAX_BYTES) {
1007
+ try {
1008
+ content = new Uint8Array(await fs.readFile(join(outputAbs, file.path)));
1009
+ } catch {
1010
+ content = void 0;
1011
+ }
1012
+ }
1013
+ candidates.push({ path: file.path, size: file.size, ...content ? { content } : {} });
1014
+ }
1015
+ const validation = validateDeployableFiles(candidates, {
1016
+ mode: "free",
1017
+ ...opts.spaFallbackRequested !== void 0 ? { spaFallbackRequested: opts.spaFallbackRequested } : {},
1018
+ ...opts.spaFallbackConfirmed !== void 0 ? { spaFallbackConfirmed: opts.spaFallbackConfirmed } : {}
1019
+ });
1020
+ ssrRisks.push(...serverAndDbDepRisks(pkg, true));
1021
+ const deployable = validation.ok && walked.length > 0;
1022
+ const spa = {
1023
+ looksLikeSpa: validation.looksLikeSpa,
1024
+ fallbackRecommended: validation.looksLikeSpa,
1025
+ confirmationRequired: validation.spaFallbackConfirmationRequired
1026
+ };
1027
+ let suggestedNextAction;
1028
+ if (!deployable) {
1029
+ const firstError = validation.issues.find((i) => i.severity === "error");
1030
+ if (firstError?.code === "missing_index_html") {
1031
+ suggestedNextAction = `No index.html at the root of "${outputDirRel}". Deploy the built static output (the directory whose root contains index.html), not the source project. Build locally first if needed (${buildCommandHint ?? "npm run build"}), then re-run analyze_site.`;
1032
+ } else if (firstError?.code === "spa_fallback_confirmation_required") {
1033
+ suggestedNextAction = "SPA fallback rewrites unknown paths to index.html and changes normal 404 behavior. Confirm it explicitly: re-run with spaFallbackRequested: true and spaFallbackConfirmed: true.";
1034
+ } else {
1035
+ suggestedNextAction = "Fix the listed issues (remove forbidden/secret files, reduce size, add missing entry HTML), then re-run analyze_site.";
1036
+ }
1037
+ } else if (spa.confirmationRequired) {
1038
+ suggestedNextAction = `The output in "${outputDirRel}" is deployable, but it looks like a single-page app. Decide about SPA fallback first: run deploy_site with spaFallback: true and spaFallbackConfirmed: true to enable it, or with spaFallbackConfirmed: true alone to deploy without fallback.`;
1039
+ } else {
1040
+ suggestedNextAction = `Run deploy_site to publish the static output in "${outputDirRel}".`;
1041
+ }
1042
+ return {
1043
+ projectType,
1044
+ ...detection ? { framework: detection.framework } : {},
1045
+ recommendedOutputDir: outputDirRel,
1046
+ outputDirExists: true,
1047
+ ...buildCommandHint !== void 0 ? { buildCommandHint } : {},
1048
+ entryHtmlFound: validation.entryHtmlPath !== void 0,
1049
+ ...validation.htmlLang !== void 0 ? { htmlLang: validation.htmlLang } : {},
1050
+ langSupported: validation.supportedLang !== void 0,
1051
+ totalBytes: validation.totalBytes,
1052
+ fileCount: validation.fileCount,
1053
+ issues: validation.issues,
1054
+ ssrRisks,
1055
+ spa,
1056
+ deployable,
1057
+ suggestedNextAction,
1058
+ ...deployable ? { files: walked.map((f) => ({ path: f.path, size: f.size })) } : {}
1059
+ };
1060
+ }
1061
+
1062
+ // src/project-file.ts
1063
+ import { chmodSync, existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
1064
+ import { join as join2 } from "node:path";
1065
+ var SITE_DIR = ".sakupa";
1066
+ var SITE_FILE = "site.json";
1067
+ function siteFilePath(projectDir) {
1068
+ return join2(projectDir, SITE_DIR, SITE_FILE);
1069
+ }
1070
+ function readSiteFile(projectDir) {
1071
+ const path = siteFilePath(projectDir);
1072
+ let raw;
1073
+ try {
1074
+ raw = readFileSync(path, "utf8");
1075
+ } catch {
1076
+ return null;
1077
+ }
1078
+ try {
1079
+ const parsed = JSON.parse(raw);
1080
+ if (typeof parsed !== "object" || parsed === null) return null;
1081
+ if (typeof parsed.siteId !== "string" || parsed.siteId.length === 0) return null;
1082
+ if (typeof parsed.credential !== "string" || parsed.credential.length === 0) return null;
1083
+ return {
1084
+ siteId: parsed.siteId,
1085
+ credential: parsed.credential,
1086
+ createdAt: typeof parsed.createdAt === "string" ? parsed.createdAt : "",
1087
+ apiBaseUrl: typeof parsed.apiBaseUrl === "string" ? parsed.apiBaseUrl : "",
1088
+ ...typeof parsed.shortId === "string" ? { shortId: parsed.shortId } : {},
1089
+ ...typeof parsed.url === "string" ? { url: parsed.url } : {},
1090
+ ...typeof parsed.billingDomain === "string" ? { billingDomain: parsed.billingDomain } : {},
1091
+ ...typeof parsed.billingDomainId === "string" ? { billingDomainId: parsed.billingDomainId } : {}
1092
+ };
1093
+ } catch {
1094
+ return null;
1095
+ }
1096
+ }
1097
+ function writeSiteFile(projectDir, file) {
1098
+ const dir = join2(projectDir, SITE_DIR);
1099
+ mkdirSync(dir, { recursive: true });
1100
+ const path = join2(dir, SITE_FILE);
1101
+ writeFileSync(path, `${JSON.stringify(file, null, 2)}
1102
+ `, "utf8");
1103
+ try {
1104
+ chmodSync(path, 384);
1105
+ } catch {
1106
+ }
1107
+ }
1108
+
1109
+ // src/version.ts
1110
+ var MCP_VERSION = "0.1.0";
1111
+ var CLIENT_TYPE = "sakupa-mcp";
1112
+
1113
+ // src/tools/context.ts
1114
+ function requireSiteFile(ctx) {
1115
+ const file = readSiteFile(ctx.projectDir);
1116
+ if (!file) {
1117
+ throw new SakupaError(
1118
+ "not_found",
1119
+ `No .sakupa/site.json found in ${ctx.projectDir}. This project has no Sakupa site binding yet \u2014 run deploy_site first to publish it (the management credential will be stored locally in .sakupa/site.json). If this was a paid custom-domain site whose project file was lost, use recover_domain_site instead.`
1120
+ );
1121
+ }
1122
+ return file;
1123
+ }
1124
+ function toolError(e) {
1125
+ if (isSakupaError(e)) {
1126
+ let text2 = `Error [${e.code}]: ${e.message}`;
1127
+ if (e.details !== void 0) {
1128
+ text2 += `
1129
+ Details: ${JSON.stringify(e.details, null, 2)}`;
1130
+ }
1131
+ return { content: [{ type: "text", text: text2 }], isError: true };
1132
+ }
1133
+ const message = e instanceof Error ? e.message : String(e);
1134
+ return { content: [{ type: "text", text: `Error: ${message}` }], isError: true };
1135
+ }
1136
+
1137
+ // src/tools/definitions.ts
1138
+ function text(t) {
1139
+ return { content: [{ type: "text", text: t }] };
1140
+ }
1141
+ function textJson(header, obj) {
1142
+ return text(`${header}
1143
+ ${JSON.stringify(obj, null, 2)}`);
1144
+ }
1145
+ var planEnum = z.enum(["water", "personal", "share", "business"]);
1146
+ var severityEnum = z.enum(["low", "medium", "high", "critical"]);
1147
+ function planCatalog() {
1148
+ return TIER_ORDER.map((p) => `${p} \xA5${tierPriceJpy(p)}/month`).join(", ");
1149
+ }
1150
+ function subscriptionWarning(domain, plan) {
1151
+ return SUBSCRIPTION_WARNING_TEXT.replaceAll("{billingDomain}", domain).replaceAll("{plan}", plan).replaceAll("{priceJpy}", String(tierPriceJpy(plan)));
1152
+ }
1153
+ var ticketCategoryEnum = z.enum([
1154
+ "billing",
1155
+ "payment",
1156
+ "refund_review",
1157
+ "chargeback_review",
1158
+ "domain_verification",
1159
+ "deployment",
1160
+ "serving",
1161
+ "bug_report",
1162
+ "abuse",
1163
+ "other"
1164
+ ]);
1165
+ function analysisSummary(analysis) {
1166
+ const { files: _files, ...rest } = analysis;
1167
+ return rest;
1168
+ }
1169
+ function notDeployableResult(analysis) {
1170
+ return textJson(
1171
+ `This project is NOT deployable as-is. No files were uploaded and no API call was made.
1172
+ Next action: ${analysis.suggestedNextAction}
1173
+ Analysis:`,
1174
+ analysisSummary(analysis)
1175
+ );
1176
+ }
1177
+ function spaConfirmationResult(analysis) {
1178
+ return text(
1179
+ `SPA fallback confirmation required \u2014 nothing was deployed yet.
1180
+
1181
+ 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.
1182
+
1183
+ Please ask the user to choose, then re-run deploy_site with:
1184
+ - spaFallback: true, spaFallbackConfirmed: true -> enable SPA fallback
1185
+ - spaFallbackConfirmed: true (spaFallback omitted or false) -> deploy WITHOUT fallback (unknown paths return 404)
1186
+
1187
+ Output directory: "${analysis.recommendedOutputDir ?? "."}", ${analysis.fileCount} files.`
1188
+ );
1189
+ }
1190
+ async function uploadAll(ctx, targets, files, outputAbs) {
1191
+ for (let i = 0; i < targets.length; i++) {
1192
+ const target = targets[i];
1193
+ if (!target) continue;
1194
+ const match = files.find((f) => f.path === target.path) ?? files.find((f) => target.path.endsWith(`/${f.path}`)) ?? (targets.length === files.length ? files[i] : void 0);
1195
+ if (!match) {
1196
+ throw new SakupaError(
1197
+ "internal",
1198
+ `No local file matches upload target "${target.path}"; aborting upload.`
1199
+ );
1200
+ }
1201
+ const bytes = new Uint8Array(await fs2.readFile(join3(outputAbs, match.path)));
1202
+ await ctx.client.uploadFile(target, bytes);
1203
+ }
1204
+ return targets.length;
1205
+ }
1206
+ function registerTools(server, ctx) {
1207
+ server.registerTool(
1208
+ "analyze_site",
1209
+ {
1210
+ 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.",
1211
+ inputSchema: {
1212
+ outputDir: z.string().optional().describe("Output directory relative to the project root (overrides detection)."),
1213
+ spaFallbackRequested: z.boolean().optional().describe("User asked for SPA fallback (unknown paths rewritten to index.html)."),
1214
+ spaFallbackConfirmed: z.boolean().optional().describe("User explicitly confirmed the SPA fallback 404-behavior change.")
1215
+ }
1216
+ },
1217
+ async (args) => {
1218
+ try {
1219
+ const analysis = await analyzeProject(ctx.projectDir, {
1220
+ ...args.outputDir !== void 0 ? { outputDir: args.outputDir } : {},
1221
+ ...args.spaFallbackRequested !== void 0 ? { spaFallbackRequested: args.spaFallbackRequested } : {},
1222
+ ...args.spaFallbackConfirmed !== void 0 ? { spaFallbackConfirmed: args.spaFallbackConfirmed } : {}
1223
+ });
1224
+ return textJson(
1225
+ `Analysis of ${ctx.projectDir}
1226
+ Next action: ${analysis.suggestedNextAction}`,
1227
+ analysisSummary(analysis)
1228
+ );
1229
+ } catch (e) {
1230
+ return toolError(e);
1231
+ }
1232
+ }
1233
+ );
1234
+ server.registerTool(
1235
+ "deploy_site",
1236
+ {
1237
+ 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 and refresh its validity. 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.`,
1238
+ inputSchema: {
1239
+ outputDir: z.string().optional().describe("Output directory relative to the project root (overrides detection)."),
1240
+ spaFallback: z.boolean().optional().describe("Enable SPA fallback (requires spaFallbackConfirmed: true)."),
1241
+ spaFallbackConfirmed: z.boolean().optional().describe("User explicitly confirmed the SPA fallback 404-behavior change."),
1242
+ lang: z.string().optional().describe("Site language override (en | ja | zh-CN); defaults to the html lang.")
1243
+ }
1244
+ },
1245
+ async (args) => {
1246
+ try {
1247
+ const analysis = await analyzeProject(ctx.projectDir, {
1248
+ ...args.outputDir !== void 0 ? { outputDir: args.outputDir } : {},
1249
+ ...args.spaFallback !== void 0 ? { spaFallbackRequested: args.spaFallback } : {},
1250
+ ...args.spaFallbackConfirmed !== void 0 ? { spaFallbackConfirmed: args.spaFallbackConfirmed } : {}
1251
+ });
1252
+ if (analysis.spa.confirmationRequired && args.spaFallbackConfirmed !== true) {
1253
+ return spaConfirmationResult(analysis);
1254
+ }
1255
+ if (!analysis.deployable || !analysis.files) {
1256
+ return notDeployableResult(analysis);
1257
+ }
1258
+ const files = analysis.files;
1259
+ const manifest = files.map((f) => ({ path: f.path, size: f.size }));
1260
+ const outputAbs = resolve2(ctx.projectDir, analysis.recommendedOutputDir ?? ".");
1261
+ const existing = readSiteFile(ctx.projectDir);
1262
+ if (!existing) {
1263
+ const created = await ctx.client.createSite({
1264
+ manifest,
1265
+ ...args.lang !== void 0 ? { lang: args.lang } : {},
1266
+ spaFallback: args.spaFallback === true,
1267
+ ...args.spaFallbackConfirmed !== void 0 ? { spaFallbackConfirmed: args.spaFallbackConfirmed } : {}
1268
+ });
1269
+ const uploaded2 = await uploadAll(ctx, created.uploadTargets, files, outputAbs);
1270
+ const finalized2 = await ctx.client.finalizeDeployment(
1271
+ created.deploymentId,
1272
+ created.siteId,
1273
+ created.credential
1274
+ );
1275
+ writeSiteFile(ctx.projectDir, {
1276
+ siteId: created.siteId,
1277
+ shortId: created.shortId,
1278
+ url: finalized2.url,
1279
+ credential: created.credential,
1280
+ createdAt: (/* @__PURE__ */ new Date()).toISOString(),
1281
+ apiBaseUrl: ctx.apiBaseUrl
1282
+ });
1283
+ return text(
1284
+ `Site published: ${finalized2.url}
1285
+ Files uploaded: ${uploaded2} (${finalized2.totalBytes} bytes)
1286
+ ` + (finalized2.expiresAt ? `Expires at: ${finalized2.expiresAt}
1287
+ ` : "") + `
1288
+ 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 free site.
1289
+ ` + (finalized2.warnings.length > 0 ? `
1290
+ Warnings:
1291
+ ${JSON.stringify(finalized2.warnings, null, 2)}` : "")
1292
+ );
1293
+ }
1294
+ const deployment = await ctx.client.createDeployment(existing.siteId, existing.credential, {
1295
+ manifest,
1296
+ ...args.lang !== void 0 ? { lang: args.lang } : {},
1297
+ spaFallback: args.spaFallback === true,
1298
+ ...args.spaFallbackConfirmed !== void 0 ? { spaFallbackConfirmed: args.spaFallbackConfirmed } : {}
1299
+ });
1300
+ const uploaded = await uploadAll(ctx, deployment.uploadTargets, files, outputAbs);
1301
+ const finalized = await ctx.client.finalizeDeployment(
1302
+ deployment.deploymentId,
1303
+ existing.siteId,
1304
+ existing.credential
1305
+ );
1306
+ writeSiteFile(ctx.projectDir, { ...existing, url: finalized.url });
1307
+ return text(
1308
+ `Site updated: ${finalized.url}
1309
+ Files uploaded: ${uploaded} (${finalized.totalBytes} bytes)
1310
+ ` + (finalized.expiresAt ? `Validity refreshed \u2014 expires at: ${finalized.expiresAt}
1311
+ ` : "") + (finalized.mode === "free" ? `
1312
+ Reminder: free sites stay live for ${FREE_SITE_TTL_HOURS} hours after the last deploy or refresh_site call.
1313
+ ` : "") + (finalized.warnings.length > 0 ? `
1314
+ Warnings:
1315
+ ${JSON.stringify(finalized.warnings, null, 2)}` : "")
1316
+ );
1317
+ } catch (e) {
1318
+ return toolError(e);
1319
+ }
1320
+ }
1321
+ );
1322
+ server.registerTool(
1323
+ "refresh_site",
1324
+ {
1325
+ description: "Refresh the validity of the free temporary site WITHOUT uploading content. Uses the local credential in .sakupa/site.json.",
1326
+ inputSchema: {}
1327
+ },
1328
+ async () => {
1329
+ try {
1330
+ const site = requireSiteFile(ctx);
1331
+ const res = await ctx.client.refreshSite(site.siteId, site.credential);
1332
+ return text(
1333
+ `Site validity refreshed. New expiry: ${res.expiresAt}
1334
+ Free sites stay live for ${FREE_SITE_TTL_HOURS} hours after each deploy or refresh.`
1335
+ );
1336
+ } catch (e) {
1337
+ return toolError(e);
1338
+ }
1339
+ }
1340
+ );
1341
+ server.registerTool(
1342
+ "site_status",
1343
+ {
1344
+ description: "Show the current status of this project's Sakupa site: URL, mode (free/paid), expiry, domain binding, size, last deployment and warnings.",
1345
+ inputSchema: {}
1346
+ },
1347
+ async () => {
1348
+ try {
1349
+ const site = requireSiteFile(ctx);
1350
+ const res = await ctx.client.getSiteStatus(site.siteId, site.credential);
1351
+ return textJson("Site status:", res);
1352
+ } catch (e) {
1353
+ return toolError(e);
1354
+ }
1355
+ }
1356
+ );
1357
+ server.registerTool(
1358
+ "bind_domain",
1359
+ {
1360
+ description: `Start or continue binding a custom domain to this site (the paid long-term mode). Ownership is proven ONLY by DNS control of the registered apex domain \u2014 payment never grants ownership. The billing subject is always the apex domain: all its subdomains share one hosting subscription (plans: ${planCatalog()}). The apex domain needs an ACTIVE subscription (see subscribe_domain) before paid configuration can start. Call again with verificationId to check DNS verification progress.`,
1361
+ inputSchema: {
1362
+ hostname: z.string().describe('Hostname to bind, e.g. "example.com" or "www.example.com".'),
1363
+ verificationId: z.string().optional().describe("Check an existing DNS verification instead of starting a new one.")
1364
+ }
1365
+ },
1366
+ async (args) => {
1367
+ try {
1368
+ const site = requireSiteFile(ctx);
1369
+ if (args.verificationId !== void 0) {
1370
+ const res2 = await ctx.client.checkVerification(args.verificationId, site.credential);
1371
+ if (res2.status === "verified") {
1372
+ writeSiteFile(ctx.projectDir, { ...site, billingDomain: res2.apexDomain });
1373
+ }
1374
+ return text(
1375
+ `DNS verification ${res2.verificationId}: ${res2.status}
1376
+ ${res2.message}
1377
+ ` + (res2.provisioningJobId ? `Provisioning started (job ${res2.provisioningJobId}). HTTPS certificate and CDN setup are in progress; check again with bind_domain + verificationId later.
1378
+ ` : "") + (res2.pendingDnsRecords.length > 0 ? `
1379
+ DNS records still required:
1380
+ ${JSON.stringify(res2.pendingDnsRecords, null, 2)}` : "")
1381
+ );
1382
+ }
1383
+ const req = {
1384
+ siteId: site.siteId,
1385
+ hostname: args.hostname
1386
+ };
1387
+ const res = await ctx.client.bindDomain(site.credential, req);
1388
+ writeSiteFile(ctx.projectDir, {
1389
+ ...site,
1390
+ billingDomain: res.apexDomain,
1391
+ billingDomainId: res.billingDomainId
1392
+ });
1393
+ 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.`;
1394
+ return text(
1395
+ `Domain binding started for ${res.hostname}.
1396
+
1397
+ Billing subject: the apex domain "${res.apexDomain}". Subdomains of ${res.apexDomain} cannot be separate billing subjects \u2014 they all share this domain hosting subscription.
1398
+
1399
+ 1. Prove control of ${res.apexDomain} by creating this DNS record:
1400
+ name: ${res.verificationRecord.name}
1401
+ type: ${res.verificationRecord.type}
1402
+ value: ${res.verificationRecord.value}
1403
+ Ownership and management authority come ONLY from DNS control. Paying for the subscription never grants ownership, management rights, or activation.
1404
+
1405
+ 2. Serving DNS (after verification): ${servingHint}
1406
+ Server guidance: ${res.servingInstructions}
1407
+
1408
+ Then run bind_domain again with verificationId: "${res.verificationId}" to check verification and start provisioning.`
1409
+ );
1410
+ } catch (e) {
1411
+ return toolError(e);
1412
+ }
1413
+ }
1414
+ );
1415
+ server.registerTool(
1416
+ "billing_status",
1417
+ {
1418
+ description: "Show the hosting subscription status of an apex billing domain: current plan, payment state, current paid period, reconciled usage, estimated usage tier, next renewal, risks and the per-site usage breakdown. Reads are based on the latest reconciled snapshot.",
1419
+ inputSchema: {
1420
+ domain: z.string().optional().describe("Apex billing domain; defaults to the domain bound in .sakupa/site.json.")
1421
+ }
1422
+ },
1423
+ async (args) => {
1424
+ try {
1425
+ const site = readSiteFile(ctx.projectDir);
1426
+ const domain = args.domain ?? site?.billingDomain;
1427
+ if (!domain) {
1428
+ throw new SakupaError(
1429
+ "invalid_request",
1430
+ 'No domain given and this project has no bound billing domain. Pass domain, e.g. billing_status { domain: "example.com" }.'
1431
+ );
1432
+ }
1433
+ const res = await ctx.client.getBillingStatus(domain, site?.credential);
1434
+ const lines = [
1435
+ `Billing status for ${res.paidSubject} (service status: ${res.serviceStatus})`,
1436
+ res.committedTier ? `Plan: ${res.committedTier} (\xA5${tierPriceJpy(res.committedTier)}/month)` : "Plan: (no subscription yet)",
1437
+ res.subscriptionStatus ? `Subscription payment state: ${res.subscriptionStatus}` : void 0,
1438
+ res.cancelAtCycleEnd ? "Renewal: CANCELED \u2014 hosting stops at the end of the already-paid month" : void 0,
1439
+ res.currentCycleStart ? `Current paid period: ${res.currentCycleStart} -> ${res.currentCycleEnd ?? "?"}` : void 0,
1440
+ res.estimatedUsageTier ? `Estimated usage tier: ${res.estimatedUsageTier}` : void 0,
1441
+ res.lastReconciledAt ? `Last usage reconciliation: ${res.lastReconciledAt}` : void 0,
1442
+ res.nextSettlementAt ? `Next renewal: ${res.nextSettlementAt}` : void 0,
1443
+ res.risks.unpaid ? "ATTENTION: renewal payment failed \u2014 update the payment method (manage_billing) to keep the custom domain hosted." : void 0,
1444
+ res.ownerView ? void 0 : "Note: public view (no valid owner credential presented)."
1445
+ ].filter((l) => l !== void 0);
1446
+ return textJson(`${lines.join("\n")}
1447
+
1448
+ Full status:`, res);
1449
+ } catch (e) {
1450
+ return toolError(e);
1451
+ }
1452
+ }
1453
+ );
1454
+ server.registerTool(
1455
+ "subscribe_domain",
1456
+ {
1457
+ 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.`,
1458
+ inputSchema: {
1459
+ domain: z.string().describe('Apex domain to subscribe, e.g. "example.com".'),
1460
+ plan: planEnum.describe(
1461
+ "Monthly plan: water (very light personal pages), personal (personal brand / small shop), share (small-business site), business (steadier traffic, more headroom)."
1462
+ ),
1463
+ confirm: z.boolean().optional().describe("User read the subscription disclosure and confirmed. Required to proceed.")
1464
+ }
1465
+ },
1466
+ async (args) => {
1467
+ try {
1468
+ const site = requireSiteFile(ctx);
1469
+ if (args.confirm !== true) {
1470
+ return text(
1471
+ `${subscriptionWarning(args.domain, 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_domain with confirm: true.`
1474
+ );
1475
+ }
1476
+ const res = await ctx.client.createPlanCheckout(
1477
+ {
1478
+ siteId: site.siteId,
1479
+ domain: args.domain,
1480
+ plan: args.plan,
1481
+ idempotencyKey: randomUUID(),
1482
+ confirmPlan: true
1483
+ },
1484
+ site.credential
1485
+ );
1486
+ return text(
1487
+ `Stripe Checkout link \u2014 Sakupa Domain Hosting for ${res.apexDomain}: ${res.plan} plan, \xA5${res.monthlyPriceJpy}/month
1488
+ ${res.checkoutUrl}
1489
+
1490
+ 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.
1491
+ 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.`
1492
+ );
1493
+ } catch (e) {
1494
+ return toolError(e);
1495
+ }
1496
+ }
1497
+ );
1498
+ server.registerTool(
1499
+ "manage_billing",
1500
+ {
1501
+ description: "Open the Stripe-hosted billing portal for the bound apex domain: 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) of a site bound to the domain.",
1502
+ inputSchema: {
1503
+ domain: z.string().optional().describe("Apex billing domain; defaults to the domain bound in .sakupa/site.json.")
1504
+ }
1505
+ },
1506
+ async (args) => {
1507
+ try {
1508
+ const site = requireSiteFile(ctx);
1509
+ const domain = args.domain ?? site.billingDomain;
1510
+ if (!domain) {
1511
+ throw new SakupaError(
1512
+ "invalid_request",
1513
+ "No domain given and this project has no bound billing domain. Pass domain explicitly."
1514
+ );
1515
+ }
1516
+ const res = await ctx.client.createBillingPortal(domain, site.credential);
1517
+ return text(
1518
+ `Stripe billing portal for ${res.apexDomain}:
1519
+ ${res.portalUrl}
1520
+
1521
+ 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.`
1522
+ );
1523
+ } catch (e) {
1524
+ return toolError(e);
1525
+ }
1526
+ }
1527
+ );
1528
+ server.registerTool(
1529
+ "recover_domain_site",
1530
+ {
1531
+ description: "Recover management control of a paid custom-domain site after losing the local project, by proving DNS control of the apex domain. By default, completing recovery REVOKES all previous local credentials for the site. 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).",
1532
+ inputSchema: {
1533
+ hostname: z.string().describe('Hostname of the site to recover, e.g. "www.example.com".'),
1534
+ verificationId: z.string().optional().describe("Complete a recovery previously started for this hostname."),
1535
+ preserveExistingCredentials: z.boolean().optional().describe("Explicitly keep old local credentials working (default: revoke them all).")
1536
+ }
1537
+ },
1538
+ async (args) => {
1539
+ try {
1540
+ if (args.verificationId === void 0) {
1541
+ const res2 = await ctx.client.recoverDomain({ hostname: args.hostname });
1542
+ return text(
1543
+ `Recovery started for ${args.hostname} (apex billing domain: ${res2.apexDomain}).
1544
+
1545
+ Create this DNS record to prove apex-domain control:
1546
+ name: ${res2.verificationRecord.name}
1547
+ type: ${res2.verificationRecord.type}
1548
+ value: ${res2.verificationRecord.value}
1549
+
1550
+ ${res2.message}
1551
+
1552
+ 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.
1553
+
1554
+ After the DNS record resolves, re-run recover_domain_site with verificationId: "${res2.verificationId}".`
1555
+ );
1556
+ }
1557
+ const res = await ctx.client.completeRecovery(args.verificationId, {
1558
+ ...args.preserveExistingCredentials !== void 0 ? { preserveExistingCredentials: args.preserveExistingCredentials } : {}
1559
+ });
1560
+ writeSiteFile(ctx.projectDir, {
1561
+ siteId: res.siteId,
1562
+ billingDomain: res.billingDomain,
1563
+ credential: res.credential,
1564
+ createdAt: (/* @__PURE__ */ new Date()).toISOString(),
1565
+ apiBaseUrl: ctx.apiBaseUrl
1566
+ });
1567
+ return text(
1568
+ `Recovery complete for ${res.billingDomain}.
1569
+ Site: ${res.siteId} (hostnames: ${res.boundHostnames.join(", ") || "(none)"})
1570
+ Previous credentials revoked: ${res.revokedPreviousCredentials ? "YES" : "no (preserved on request)"}
1571
+
1572
+ A NEW management credential was written to .sakupa/site.json in this project \u2014 this project now manages the site.
1573
+
1574
+ Download the current site content (signed URL):
1575
+ ${res.archiveUrl}`
1576
+ );
1577
+ } catch (e) {
1578
+ return toolError(e);
1579
+ }
1580
+ }
1581
+ );
1582
+ server.registerTool(
1583
+ "set_billing_plan",
1584
+ {
1585
+ description: `Change the hosting plan of the apex billing domain or cancel/re-enable renewal. 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. The API returns the billing consequences first; explicit owner confirmation (confirm: true) is required before anything is applied.`,
1586
+ inputSchema: {
1587
+ domain: z.string().optional().describe("Apex billing domain; defaults to the domain bound in .sakupa/site.json."),
1588
+ plan: planEnum.optional().describe("Target monthly plan."),
1589
+ cancelRenewal: z.boolean().optional().describe(
1590
+ "true: cancel renewal (hosting runs to the end of the paid month, then stops). false: re-enable renewal."
1591
+ ),
1592
+ confirm: z.boolean().optional().describe("User saw the consequences and explicitly confirmed.")
1593
+ }
1594
+ },
1595
+ async (args) => {
1596
+ try {
1597
+ const site = requireSiteFile(ctx);
1598
+ const domain = args.domain ?? site.billingDomain;
1599
+ if (!domain) {
1600
+ throw new SakupaError(
1601
+ "invalid_request",
1602
+ "No domain given and this project has no bound billing domain. Pass domain explicitly."
1603
+ );
1604
+ }
1605
+ const req = {
1606
+ siteId: site.siteId,
1607
+ ...args.plan !== void 0 ? { committedTier: args.plan } : {},
1608
+ ...args.cancelRenewal !== void 0 ? { cancelRenewal: args.cancelRenewal } : {},
1609
+ confirm: args.confirm === true
1610
+ };
1611
+ try {
1612
+ const res = await ctx.client.setBillingPlan(domain, site.credential, req);
1613
+ return textJson(
1614
+ `Billing plan updated for ${res.apexDomain}.
1615
+ Consequences:
1616
+ ${res.consequences.map((c) => `- ${c}`).join("\n")}
1617
+ Applied plan:`,
1618
+ res
1619
+ );
1620
+ } catch (e) {
1621
+ if (isSakupaError(e) && e.code === "confirmation_required") {
1622
+ return text(
1623
+ `Confirmation required before changing the billing plan \u2014 nothing was applied.
1624
+
1625
+ ${e.message}
1626
+ ` + (e.details !== void 0 ? `${JSON.stringify(e.details, null, 2)}
1627
+ ` : "") + "\nPlease show these consequences to the user and, after their explicit confirmation, re-run set_billing_plan with the same arguments plus confirm: true."
1628
+ );
1629
+ }
1630
+ throw e;
1631
+ }
1632
+ } catch (e) {
1633
+ return toolError(e);
1634
+ }
1635
+ }
1636
+ );
1637
+ server.registerTool(
1638
+ "downgrade_to_free",
1639
+ {
1640
+ 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.",
1641
+ inputSchema: {
1642
+ immediateStopServing: z.boolean().optional().describe(
1643
+ "Also stop custom-domain serving immediately (does not remove current-cycle charges)."
1644
+ ),
1645
+ confirm: z.boolean().optional().describe("User saw the consequences and explicitly confirmed.")
1646
+ }
1647
+ },
1648
+ async (args) => {
1649
+ try {
1650
+ const site = requireSiteFile(ctx);
1651
+ try {
1652
+ const res = await ctx.client.downgradeSite(site.siteId, site.credential, {
1653
+ ...args.immediateStopServing !== void 0 ? { immediateStopServing: args.immediateStopServing } : {},
1654
+ confirm: args.confirm === true
1655
+ });
1656
+ return text(
1657
+ `Downgrade scheduled for site ${res.siteId}.
1658
+ Effective at: ${res.effectiveAt}
1659
+ Immediate stop-serving applied: ${res.immediateStopApplied ? "yes" : "no"}
1660
+ ${res.message}
1661
+
1662
+ 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).`
1663
+ );
1664
+ } catch (e) {
1665
+ if (isSakupaError(e) && e.code === "confirmation_required") {
1666
+ return text(
1667
+ `Confirmation required before downgrading \u2014 nothing was applied.
1668
+
1669
+ ${e.message}
1670
+ ` + (e.details !== void 0 ? `${JSON.stringify(e.details, null, 2)}
1671
+ ` : "") + "\nPlease show these consequences to the user and, after their explicit confirmation, re-run downgrade_to_free with confirm: true."
1672
+ );
1673
+ }
1674
+ throw e;
1675
+ }
1676
+ } catch (e) {
1677
+ return toolError(e);
1678
+ }
1679
+ }
1680
+ );
1681
+ server.registerTool(
1682
+ "create_support_ticket",
1683
+ {
1684
+ 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.",
1685
+ inputSchema: {
1686
+ category: ticketCategoryEnum,
1687
+ subject: z.string().describe("Short subject line."),
1688
+ description: z.string().describe("Problem description (no secrets, no card data)."),
1689
+ contactEmail: z.string().optional().describe("Optional contact email for follow-up.")
1690
+ }
1691
+ },
1692
+ async (args) => {
1693
+ try {
1694
+ const site = requireSiteFile(ctx);
1695
+ const res = await ctx.client.createTicket(site.credential, {
1696
+ siteId: site.siteId,
1697
+ category: args.category,
1698
+ subject: args.subject,
1699
+ description: args.description,
1700
+ ...args.contactEmail !== void 0 ? { contactEmail: args.contactEmail } : {}
1701
+ });
1702
+ return text(`Support ticket created: ${res.ticketId} (status: ${res.status}).`);
1703
+ } catch (e) {
1704
+ return toolError(e);
1705
+ }
1706
+ }
1707
+ );
1708
+ server.registerTool(
1709
+ "report_bug",
1710
+ {
1711
+ 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, billing 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.",
1712
+ inputSchema: {
1713
+ toolName: z.string().describe('The Sakupa tool that failed, e.g. "deploy_site".'),
1714
+ errorCode: z.string().optional(),
1715
+ errorMessage: z.string().optional().describe("Sanitized error message (no secrets)."),
1716
+ requestId: z.string().optional(),
1717
+ deploymentId: z.string().optional(),
1718
+ severity: severityEnum.optional(),
1719
+ description: z.string().optional().describe("What happened, in the user's words (no secrets)."),
1720
+ confirmSubmit: z.boolean().optional().describe("User reviewed the report payload and approved submission.")
1721
+ }
1722
+ },
1723
+ async (args) => {
1724
+ try {
1725
+ const site = readSiteFile(ctx.projectDir);
1726
+ const diagnostics = {
1727
+ toolName: args.toolName,
1728
+ ...args.errorCode !== void 0 ? { errorCode: args.errorCode } : {},
1729
+ ...args.errorMessage !== void 0 ? { sanitizedErrorMessage: args.errorMessage } : {},
1730
+ ...site ? { siteId: site.siteId } : {},
1731
+ ...site?.billingDomain ? { billingDomain: site.billingDomain } : {},
1732
+ ...args.deploymentId !== void 0 ? { deploymentId: args.deploymentId } : {},
1733
+ occurredAt: (/* @__PURE__ */ new Date()).toISOString(),
1734
+ clientType: CLIENT_TYPE,
1735
+ mcpVersion: MCP_VERSION,
1736
+ ...args.requestId !== void 0 ? { requestId: args.requestId } : {}
1737
+ };
1738
+ const payload = {
1739
+ ...site ? { siteId: site.siteId } : {},
1740
+ ...args.severity !== void 0 ? { severity: args.severity } : {},
1741
+ diagnostics,
1742
+ ...args.description !== void 0 ? { description: args.description } : {}
1743
+ };
1744
+ if (args.confirmSubmit !== true) {
1745
+ return textJson(
1746
+ "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.",
1747
+ payload
1748
+ );
1749
+ }
1750
+ const res = await ctx.client.reportBug(payload, site?.credential);
1751
+ return text(
1752
+ `Bug report submitted. Ticket: ${res.ticketId}
1753
+ Summary: ${res.sanitizedSummary}`
1754
+ );
1755
+ } catch (e) {
1756
+ return toolError(e);
1757
+ }
1758
+ }
1759
+ );
1760
+ }
1761
+
1762
+ // src/transport.ts
1763
+ var FetchTransport = class {
1764
+ baseUrl;
1765
+ constructor(baseUrl) {
1766
+ this.baseUrl = baseUrl.replace(/\/+$/, "");
1767
+ }
1768
+ async request(req) {
1769
+ let url = `${this.baseUrl}${req.path}`;
1770
+ if (req.query && Object.keys(req.query).length > 0) {
1771
+ url += `?${new URLSearchParams(req.query).toString()}`;
1772
+ }
1773
+ const headers = {
1774
+ accept: "application/json",
1775
+ ...req.body !== void 0 ? { "content-type": "application/json" } : {},
1776
+ ...req.headers
1777
+ };
1778
+ const res = await fetch(url, {
1779
+ method: req.method,
1780
+ headers,
1781
+ ...req.body !== void 0 ? { body: req.body } : {}
1782
+ });
1783
+ const text2 = await res.text();
1784
+ const responseHeaders = {};
1785
+ res.headers.forEach((value, key) => {
1786
+ responseHeaders[key] = value;
1787
+ });
1788
+ return {
1789
+ status: res.status,
1790
+ headers: responseHeaders,
1791
+ ...text2.length > 0 ? { body: text2 } : {}
1792
+ };
1793
+ }
1794
+ async upload(target, body) {
1795
+ if (target.url.startsWith("memory://")) {
1796
+ throw new Error(
1797
+ `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.`
1798
+ );
1799
+ }
1800
+ const res = await fetch(target.url, {
1801
+ method: target.method,
1802
+ headers: target.headers,
1803
+ body
1804
+ });
1805
+ if (!res.ok) {
1806
+ const text2 = await res.text().catch(() => "");
1807
+ throw new Error(
1808
+ `Upload of "${target.path}" failed with HTTP ${res.status}${text2 ? `: ${text2.slice(0, 200)}` : ""}`
1809
+ );
1810
+ }
1811
+ }
1812
+ };
1813
+
1814
+ // src/server.ts
1815
+ var INSTRUCTIONS = `Sakupa publishes AI-made static websites. AI-made pages, live in seconds.
1816
+
1817
+ Workflow:
1818
+ 1. analyze_site \u2014 check whether this project has deployable static output. If a build is needed
1819
+ (Vite/Vue/React/Svelte/Astro/Next static export/Nuxt generate), run the build LOCALLY first,
1820
+ then re-run analyze_site.
1821
+ 2. deploy_site \u2014 uploads ONLY the built static output. The first deploy creates a free temporary
1822
+ site (public URL {shortId}.sakupa.com, valid 24 hours, free banner shown) and stores the
1823
+ management credential in .sakupa/site.json. Deploying again updates the site and refreshes
1824
+ its validity; refresh_site extends validity without uploading.
1825
+ 3. For long-term use, subscribe the apex domain to a monthly hosting plan (subscribe_domain ->
1826
+ Stripe-hosted checkout; water/personal/share/business, auto-upgrade when the site outgrows
1827
+ its plan), then bind the custom domain (bind_domain): the billing subject is always the
1828
+ registered apex domain and ownership is proven only by DNS control. billing_status,
1829
+ set_billing_plan, manage_billing, downgrade_to_free and recover_domain_site manage the
1830
+ paid lifecycle.
1831
+
1832
+ Safety boundaries:
1833
+ - Static output only: no SSR, API routes, middleware, server actions, databases or online builds.
1834
+ - Never upload source projects, secrets, .env files, private keys, archives, videos or audio.
1835
+ - Payment card data is entered only on Stripe-hosted pages \u2014 never through the AI tool.
1836
+ - A subscription is payment state only; it never grants domain ownership or management rights.
1837
+ - The management credential lives only in .sakupa/site.json; never share or upload it.`;
1838
+ function createSakupaMcpServer(opts) {
1839
+ const client = opts.client ?? new HttpApiClient(new FetchTransport(opts.apiBaseUrl));
1840
+ const server = new McpServer(
1841
+ { name: "sakupa", version: MCP_VERSION },
1842
+ { instructions: INSTRUCTIONS }
1843
+ );
1844
+ registerTools(server, {
1845
+ client,
1846
+ projectDir: opts.projectDir,
1847
+ apiBaseUrl: opts.apiBaseUrl
1848
+ });
1849
+ return server;
1850
+ }
1851
+
1852
+ // src/bin.ts
1853
+ async function main() {
1854
+ const apiBaseUrl = process.env["SAKUPA_API_URL"] ?? DEFAULT_API_BASE_URL;
1855
+ const projectDir = process.env["SAKUPA_PROJECT_DIR"] ?? process.cwd();
1856
+ const server = createSakupaMcpServer({ apiBaseUrl, projectDir });
1857
+ const transport = new StdioServerTransport();
1858
+ await server.connect(transport);
1859
+ console.error(
1860
+ `[sakupa-mcp] v${MCP_VERSION} connected (api: ${apiBaseUrl}, project: ${projectDir})`
1861
+ );
1862
+ }
1863
+ main().catch((err) => {
1864
+ console.error("[sakupa-mcp] fatal:", err instanceof Error ? err.message : err);
1865
+ process.exit(1);
1866
+ });