@sakupa/mcp 0.7.3 → 0.7.5
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 +109 -42
- package/dist/index.js +110 -43
- package/package.json +1 -1
package/dist/bin.js
CHANGED
|
@@ -128,7 +128,7 @@ var FORBIDDEN_PATH_SEGMENTS = [
|
|
|
128
128
|
var ALLOWED_HIDDEN_PATHS = [".well-known/"];
|
|
129
129
|
|
|
130
130
|
// ../core/dist/domain/version.js
|
|
131
|
-
var SAKUPA_MCP_VERSION = "0.7.
|
|
131
|
+
var SAKUPA_MCP_VERSION = "0.7.5";
|
|
132
132
|
|
|
133
133
|
// ../core/dist/domain/errors.js
|
|
134
134
|
var HTTP_STATUS = {
|
|
@@ -444,6 +444,9 @@ function safeDecode(bytes) {
|
|
|
444
444
|
}
|
|
445
445
|
}
|
|
446
446
|
|
|
447
|
+
// ../core/dist/domain/credentials.js
|
|
448
|
+
var CREDENTIAL_PATTERN = /^sk_[A-Za-z0-9_-]{43}$/;
|
|
449
|
+
|
|
447
450
|
// ../core/dist/domain/hashing.js
|
|
448
451
|
async function sha256Hex(bytes) {
|
|
449
452
|
const digest = await globalThis.crypto.subtle.digest("SHA-256", bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength));
|
|
@@ -458,10 +461,6 @@ var CREDENTIAL_HEADER = "x-sakupa-credential";
|
|
|
458
461
|
var IDEMPOTENCY_HEADER = "x-sakupa-idempotency-key";
|
|
459
462
|
var MCP_VERSION_HEADER = "x-sakupa-mcp-version";
|
|
460
463
|
|
|
461
|
-
// ../core/dist/services/sites.js
|
|
462
|
-
var utf8Decoder = new TextDecoder("utf-8", { fatal: false });
|
|
463
|
-
var utf8Encoder = new TextEncoder();
|
|
464
|
-
|
|
465
464
|
// ../core/dist/services/subscriptions.js
|
|
466
465
|
var WEBHOOK_PROCESSING_LEASE_MS = 5 * 60 * 1e3;
|
|
467
466
|
|
|
@@ -1128,20 +1127,42 @@ var SITE_FILE = "site.json";
|
|
|
1128
1127
|
function siteFilePath(projectDir) {
|
|
1129
1128
|
return join2(projectDir, SITE_DIR, SITE_FILE);
|
|
1130
1129
|
}
|
|
1131
|
-
function
|
|
1130
|
+
function loadSiteFile(projectDir) {
|
|
1132
1131
|
const path = siteFilePath(projectDir);
|
|
1132
|
+
if (!existsSync(path)) return { kind: "absent" };
|
|
1133
1133
|
let raw;
|
|
1134
1134
|
try {
|
|
1135
1135
|
raw = readFileSync(path, "utf8");
|
|
1136
|
-
} catch {
|
|
1137
|
-
return
|
|
1136
|
+
} catch (err) {
|
|
1137
|
+
return {
|
|
1138
|
+
kind: "corrupted",
|
|
1139
|
+
problem: `the file exists but could not be read (${err instanceof Error ? err.message : String(err)})`
|
|
1140
|
+
};
|
|
1138
1141
|
}
|
|
1142
|
+
let parsed;
|
|
1139
1143
|
try {
|
|
1140
|
-
|
|
1141
|
-
|
|
1142
|
-
|
|
1143
|
-
|
|
1144
|
+
parsed = JSON.parse(raw);
|
|
1145
|
+
} catch {
|
|
1146
|
+
return { kind: "corrupted", problem: "the file exists but is not valid JSON" };
|
|
1147
|
+
}
|
|
1148
|
+
if (typeof parsed !== "object" || parsed === null) {
|
|
1149
|
+
return { kind: "corrupted", problem: "the file does not contain a JSON object" };
|
|
1150
|
+
}
|
|
1151
|
+
if (typeof parsed.siteId !== "string" || parsed.siteId.length === 0) {
|
|
1152
|
+
return { kind: "corrupted", problem: "the siteId field is missing or empty" };
|
|
1153
|
+
}
|
|
1154
|
+
if (typeof parsed.credential !== "string" || parsed.credential.length === 0) {
|
|
1155
|
+
return { kind: "corrupted", problem: "the credential field is missing or empty" };
|
|
1156
|
+
}
|
|
1157
|
+
if (!CREDENTIAL_PATTERN.test(parsed.credential)) {
|
|
1144
1158
|
return {
|
|
1159
|
+
kind: "corrupted",
|
|
1160
|
+
problem: "the credential does not match the shape Sakupa issues (sk_ followed by exactly 43 URL-safe base64 characters) \u2014 it looks locally altered or damaged"
|
|
1161
|
+
};
|
|
1162
|
+
}
|
|
1163
|
+
return {
|
|
1164
|
+
kind: "ok",
|
|
1165
|
+
file: {
|
|
1145
1166
|
siteId: parsed.siteId,
|
|
1146
1167
|
credential: parsed.credential,
|
|
1147
1168
|
createdAt: typeof parsed.createdAt === "string" ? parsed.createdAt : "",
|
|
@@ -1149,12 +1170,26 @@ function readSiteFile(projectDir) {
|
|
|
1149
1170
|
...typeof parsed.shortId === "string" ? { shortId: parsed.shortId } : {},
|
|
1150
1171
|
...typeof parsed.url === "string" ? { url: parsed.url } : {},
|
|
1151
1172
|
...typeof parsed.boundDomain === "string" ? { boundDomain: parsed.boundDomain } : {}
|
|
1152
|
-
}
|
|
1153
|
-
}
|
|
1154
|
-
return null;
|
|
1155
|
-
}
|
|
1173
|
+
}
|
|
1174
|
+
};
|
|
1156
1175
|
}
|
|
1157
|
-
function
|
|
1176
|
+
function siteFileRecoveryGuidance(projectDir) {
|
|
1177
|
+
return `The site itself is intact on the server; only the local binding file (${siteFilePath(projectDir)}) is the problem. Restore the file (from a backup or by undoing the local edit). Do NOT delete it to work around the error: the credential is unrecoverable by design, so abandoning it permanently orphans the existing site. Publishing this project as a brand-NEW site requires the user to manually delete the .sakupa directory first \u2014 the tool will never overwrite it.`;
|
|
1178
|
+
}
|
|
1179
|
+
function writeSiteFile(projectDir, file, opts = {}) {
|
|
1180
|
+
if (opts.allowReplace !== true) {
|
|
1181
|
+
const existing = loadSiteFile(projectDir);
|
|
1182
|
+
if (existing.kind === "corrupted") {
|
|
1183
|
+
throw new Error(
|
|
1184
|
+
`Refusing to overwrite ${siteFilePath(projectDir)}: ${existing.problem}. ` + siteFileRecoveryGuidance(projectDir)
|
|
1185
|
+
);
|
|
1186
|
+
}
|
|
1187
|
+
if (existing.kind === "ok" && existing.file.siteId !== file.siteId) {
|
|
1188
|
+
throw new Error(
|
|
1189
|
+
`Refusing to overwrite ${siteFilePath(projectDir)}: it already binds this project to site ${existing.file.siteId}. ` + siteFileRecoveryGuidance(projectDir)
|
|
1190
|
+
);
|
|
1191
|
+
}
|
|
1192
|
+
}
|
|
1158
1193
|
const dir = join2(projectDir, SITE_DIR);
|
|
1159
1194
|
mkdirSync(dir, { recursive: true });
|
|
1160
1195
|
const path = join2(dir, SITE_FILE);
|
|
@@ -1232,14 +1267,20 @@ function structuredToolResult(envelope) {
|
|
|
1232
1267
|
|
|
1233
1268
|
// src/tools/context.ts
|
|
1234
1269
|
function requireSiteFile(ctx) {
|
|
1235
|
-
const
|
|
1236
|
-
if (
|
|
1270
|
+
const state = loadSiteFile(ctx.projectDir);
|
|
1271
|
+
if (state.kind === "corrupted") {
|
|
1272
|
+
throw new SakupaError(
|
|
1273
|
+
"invalid_request",
|
|
1274
|
+
`.sakupa/site.json in ${ctx.projectDir} is damaged: ${state.problem}. ` + siteFileRecoveryGuidance(ctx.projectDir)
|
|
1275
|
+
);
|
|
1276
|
+
}
|
|
1277
|
+
if (state.kind === "absent") {
|
|
1237
1278
|
throw new SakupaError(
|
|
1238
1279
|
"not_found",
|
|
1239
1280
|
`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.`
|
|
1240
1281
|
);
|
|
1241
1282
|
}
|
|
1242
|
-
return file;
|
|
1283
|
+
return state.file;
|
|
1243
1284
|
}
|
|
1244
1285
|
function toolError(e) {
|
|
1245
1286
|
const errorCode = isSakupaError(e) ? e.code : "internal";
|
|
@@ -1258,7 +1299,7 @@ function toolError(e) {
|
|
|
1258
1299
|
([key, value]) => safeDetailKeys.has(key) && (typeof value === "string" || typeof value === "number" || typeof value === "boolean")
|
|
1259
1300
|
)
|
|
1260
1301
|
) : void 0;
|
|
1261
|
-
const safeSummary = errorCode === "not_found" ? "
|
|
1302
|
+
const safeSummary = errorCode === "not_found" ? "The required local project binding or resource is unavailable; if this project has no .sakupa/site.json yet, run deploy_site first." : errorCode === "unauthorized" ? "The server rejected the site credential: the one in .sakupa/site.json no longer matches the server-side verifier. The site itself is intact on the server \u2014 only the local binding file is the problem. Repair the file (restore a backup or undo the local edit). Do NOT delete the .sakupa directory to work around this: the credential is unrecoverable by design, so abandoning it permanently orphans the existing site." : errorCode === "invalid_request" || errorCode === "validation_failed" ? "The request arguments or local project checks did not pass." : errorCode === "state_conflict" ? "The resource state has changed; re-query the current status before deciding the next step." : errorCode === "confirmation_required" ? "The site or billing state changed, so the previous confirmation is stale; run the preview again and confirm against the fresh snapshot." : errorCode === "payment_required" ? "This operation requires an active subscription; check billing_status first." : errorCode === "rate_limited" ? "The server rate limit was reached; retry after the returned wait time." : retryable ? "An upstream service is temporarily unavailable or busy; retry shortly." : "The operation failed; no server-internal details are exposed.";
|
|
1262
1303
|
const result = structuredToolResult({
|
|
1263
1304
|
schemaVersion: 1,
|
|
1264
1305
|
outcome: "failed",
|
|
@@ -1460,7 +1501,18 @@ Next action: ${analysis.suggestedNextAction}`,
|
|
|
1460
1501
|
const files = analysis.files;
|
|
1461
1502
|
const outputAbs = resolve2(ctx.projectDir, analysis.recommendedOutputDir ?? ".");
|
|
1462
1503
|
const manifest = await buildHashedManifest(files, outputAbs);
|
|
1463
|
-
const
|
|
1504
|
+
const siteFileState = loadSiteFile(ctx.projectDir);
|
|
1505
|
+
if (siteFileState.kind === "corrupted") {
|
|
1506
|
+
return text(
|
|
1507
|
+
"site_file_corrupted",
|
|
1508
|
+
`This project's .sakupa/site.json is damaged: ${siteFileState.problem}.
|
|
1509
|
+
|
|
1510
|
+
` + siteFileRecoveryGuidance(ctx.projectDir) + "\n\nNothing was deployed and no new site was created.",
|
|
1511
|
+
{ problem: siteFileState.problem },
|
|
1512
|
+
"blocked"
|
|
1513
|
+
);
|
|
1514
|
+
}
|
|
1515
|
+
const existing = siteFileState.kind === "ok" ? siteFileState.file : null;
|
|
1464
1516
|
if (!existing && args.publicConfirmed !== true) {
|
|
1465
1517
|
return text(
|
|
1466
1518
|
"public_deployment_confirmation_required",
|
|
@@ -1540,6 +1592,16 @@ ${JSON.stringify(finalized2.warnings, null, 2)}` : ""),
|
|
|
1540
1592
|
try {
|
|
1541
1593
|
update = await updateOnce(false);
|
|
1542
1594
|
} catch (e) {
|
|
1595
|
+
if (isSakupaError(e) && e.code === "unauthorized") {
|
|
1596
|
+
return text(
|
|
1597
|
+
"credential_mismatch",
|
|
1598
|
+
`The server rejected the credential in .sakupa/site.json for site ${existing.siteId} \u2014 the local file no longer matches the server-side verifier (most often a local edit or merge damage).
|
|
1599
|
+
|
|
1600
|
+
` + siteFileRecoveryGuidance(ctx.projectDir) + "\n\nNothing was deployed and no new site was created.",
|
|
1601
|
+
{ siteId: existing.siteId },
|
|
1602
|
+
"blocked"
|
|
1603
|
+
);
|
|
1604
|
+
}
|
|
1543
1605
|
if (!isIncrementalReuseFailure(e)) throw e;
|
|
1544
1606
|
update = await updateOnce(true);
|
|
1545
1607
|
}
|
|
@@ -1784,13 +1846,13 @@ Full status:`, res);
|
|
|
1784
1846
|
schemaVersion: 1,
|
|
1785
1847
|
outcome: "waiting_user",
|
|
1786
1848
|
resultCode: "site_billing_portal_ready",
|
|
1787
|
-
summary:
|
|
1849
|
+
summary: `Short-lived Stripe customer portal link created for this site: ${res2.portalUrl}. Any change still happens only on the Stripe-hosted page.`,
|
|
1788
1850
|
data: { scope: args.scope, portalUrl: res2.portalUrl },
|
|
1789
1851
|
userAction: {
|
|
1790
1852
|
type: "open_url",
|
|
1791
1853
|
provider: "stripe",
|
|
1792
1854
|
url: res2.portalUrl,
|
|
1793
|
-
expectedOutcome: "
|
|
1855
|
+
expectedOutcome: "The user manages payment methods, invoices, or cancels renewal on the Stripe-hosted page"
|
|
1794
1856
|
},
|
|
1795
1857
|
nextActions: [{ tool: "billing_status", allowed: true }]
|
|
1796
1858
|
});
|
|
@@ -1800,7 +1862,7 @@ Full status:`, res);
|
|
|
1800
1862
|
schemaVersion: 1,
|
|
1801
1863
|
outcome: "waiting_user",
|
|
1802
1864
|
resultCode: "public_billing_recovery_portal_ready",
|
|
1803
|
-
summary: `Stripe
|
|
1865
|
+
summary: `Stripe public email-OTP login page: ${res.portalUrl}. It uses a one-time passcode, does not recover the Sakupa key, and grants no site authority; when one email has several Customers, Stripe may open only the most recently created usable record.`,
|
|
1804
1866
|
data: {
|
|
1805
1867
|
scope: args.scope,
|
|
1806
1868
|
portalUrl: res.portalUrl,
|
|
@@ -1811,7 +1873,7 @@ Full status:`, res);
|
|
|
1811
1873
|
type: "open_url",
|
|
1812
1874
|
provider: "stripe",
|
|
1813
1875
|
url: res.portalUrl,
|
|
1814
|
-
expectedOutcome: "
|
|
1876
|
+
expectedOutcome: "The user verifies the billing email with Stripe, then reviews and cancels the subscription shown in the portal"
|
|
1815
1877
|
},
|
|
1816
1878
|
nextActions: []
|
|
1817
1879
|
});
|
|
@@ -1875,7 +1937,7 @@ After the DNS record resolves, re-run recover_domain_site with verificationId: "
|
|
|
1875
1937
|
schemaVersion: 1,
|
|
1876
1938
|
outcome: res2.status === "expired" ? "expired" : res2.readyToComplete ? "completed" : "pending_provider",
|
|
1877
1939
|
resultCode: res2.status === "expired" ? "domain_recovery_expired" : res2.readyToComplete ? "domain_recovery_ready" : "domain_recovery_pending_dns",
|
|
1878
|
-
summary: `DNS
|
|
1940
|
+
summary: `DNS recovery verification status: ${res2.status}`,
|
|
1879
1941
|
data: { recovery: res2 },
|
|
1880
1942
|
nextActions: [
|
|
1881
1943
|
{
|
|
@@ -1890,13 +1952,17 @@ After the DNS record resolves, re-run recover_domain_site with verificationId: "
|
|
|
1890
1952
|
const res = await ctx.client.completeRecovery(args.verificationId, {
|
|
1891
1953
|
...args.preserveExistingCredentials !== void 0 ? { preserveExistingCredentials: args.preserveExistingCredentials } : {}
|
|
1892
1954
|
});
|
|
1893
|
-
writeSiteFile(
|
|
1894
|
-
|
|
1895
|
-
|
|
1896
|
-
|
|
1897
|
-
|
|
1898
|
-
|
|
1899
|
-
|
|
1955
|
+
writeSiteFile(
|
|
1956
|
+
ctx.projectDir,
|
|
1957
|
+
{
|
|
1958
|
+
siteId: res.siteId,
|
|
1959
|
+
...res.boundHostnames[0] !== void 0 ? { boundDomain: res.boundHostnames[0] } : {},
|
|
1960
|
+
credential: res.credential,
|
|
1961
|
+
createdAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
1962
|
+
apiBaseUrl: ctx.apiBaseUrl
|
|
1963
|
+
},
|
|
1964
|
+
{ allowReplace: true }
|
|
1965
|
+
);
|
|
1900
1966
|
return text(
|
|
1901
1967
|
"domain_recovery_completed",
|
|
1902
1968
|
`Recovery complete.
|
|
@@ -1973,7 +2039,8 @@ ${res.archiveUrl}`,
|
|
|
1973
2039
|
},
|
|
1974
2040
|
async (args) => {
|
|
1975
2041
|
try {
|
|
1976
|
-
const
|
|
2042
|
+
const siteState = loadSiteFile(ctx.projectDir);
|
|
2043
|
+
const site = siteState.kind === "ok" ? siteState.file : null;
|
|
1977
2044
|
const diagnostics = {
|
|
1978
2045
|
toolName: args.toolName,
|
|
1979
2046
|
...args.errorCode !== void 0 ? { errorCode: args.errorCode } : {},
|
|
@@ -2033,7 +2100,7 @@ function registerBillingTools(server, ctx) {
|
|
|
2033
2100
|
schemaVersion: 1,
|
|
2034
2101
|
outcome: "completed",
|
|
2035
2102
|
resultCode: "billing_catalog_returned",
|
|
2036
|
-
summary:
|
|
2103
|
+
summary: `Returned ${catalog.plans.length} monthly plans; the Stripe-hosted page is the final confirmation surface for payment and plan changes.`,
|
|
2037
2104
|
data: { catalog },
|
|
2038
2105
|
nextActions: [{ tool: "subscribe_site", allowed: true }]
|
|
2039
2106
|
});
|
|
@@ -2066,13 +2133,13 @@ function registerBillingTools(server, ctx) {
|
|
|
2066
2133
|
outcome: "waiting_user",
|
|
2067
2134
|
resultCode: "stripe_plan_change_confirmation_required",
|
|
2068
2135
|
operationId: args.operationId,
|
|
2069
|
-
summary:
|
|
2136
|
+
summary: `Stripe confirmation link created for ${result.currentPlan} -> ${result.targetPlan}; the subscription has NOT changed yet.`,
|
|
2070
2137
|
data: { result },
|
|
2071
2138
|
userAction: {
|
|
2072
2139
|
type: "open_url",
|
|
2073
2140
|
provider: "stripe",
|
|
2074
2141
|
url: result.portalUrl,
|
|
2075
|
-
expectedOutcome: "
|
|
2142
|
+
expectedOutcome: "After the user confirms on the Stripe-hosted page, the webhook updates the Sakupa subscription state"
|
|
2076
2143
|
},
|
|
2077
2144
|
nextActions: [{ tool: "billing_status", allowed: true }]
|
|
2078
2145
|
});
|
|
@@ -2137,7 +2204,7 @@ function registerLifecycleTools(server, ctx) {
|
|
|
2137
2204
|
outcome: "waiting_user",
|
|
2138
2205
|
resultCode: "delete_site_confirmation_required",
|
|
2139
2206
|
operationId: args.operationId,
|
|
2140
|
-
summary: "
|
|
2207
|
+
summary: "Deletion consequences returned, bound to the current site and billing state; after confirmation the content and the permanent URL are unrecoverable.",
|
|
2141
2208
|
data: { preview },
|
|
2142
2209
|
nextActions: [
|
|
2143
2210
|
{ tool: "delete_site", allowed: true, reasonCode: "exact_confirmation_required" }
|
|
@@ -2155,7 +2222,7 @@ function registerLifecycleTools(server, ctx) {
|
|
|
2155
2222
|
outcome: result.servingDeletionPending ? "pending_provider" : "completed",
|
|
2156
2223
|
resultCode: "site_deleted",
|
|
2157
2224
|
operationId: args.operationId,
|
|
2158
|
-
summary: "
|
|
2225
|
+
summary: "Site deleted; the local management credential file was removed.",
|
|
2159
2226
|
data: { result },
|
|
2160
2227
|
nextActions: []
|
|
2161
2228
|
});
|
|
@@ -2189,7 +2256,7 @@ function registerLifecycleTools(server, ctx) {
|
|
|
2189
2256
|
outcome: "waiting_user",
|
|
2190
2257
|
resultCode: "unbind_domain_confirmation_required",
|
|
2191
2258
|
operationId: args.operationId,
|
|
2192
|
-
summary: "\
|
|
2259
|
+
summary: "Exact binding snapshot returned; unbinding removes ONLY the custom domain \u2014 the subscription, content, and permanent URL stay unchanged.",
|
|
2193
2260
|
data: { preview },
|
|
2194
2261
|
nextActions: [
|
|
2195
2262
|
{ tool: "unbind_domain", allowed: true, reasonCode: "exact_confirmation_required" }
|
|
@@ -2208,7 +2275,7 @@ function registerLifecycleTools(server, ctx) {
|
|
|
2208
2275
|
outcome: result.servingDeletionPending ? "pending_provider" : "completed",
|
|
2209
2276
|
resultCode: "domain_unbound",
|
|
2210
2277
|
operationId: args.operationId,
|
|
2211
|
-
summary: "
|
|
2278
|
+
summary: "Custom domain unbound; the subscription, deployed content, and permanent Sakupa URL are unchanged.",
|
|
2212
2279
|
data: { result },
|
|
2213
2280
|
nextActions: [{ tool: "site_status", allowed: true }]
|
|
2214
2281
|
});
|
package/dist/index.js
CHANGED
|
@@ -123,7 +123,7 @@ var FORBIDDEN_PATH_SEGMENTS = [
|
|
|
123
123
|
var ALLOWED_HIDDEN_PATHS = [".well-known/"];
|
|
124
124
|
|
|
125
125
|
// ../core/dist/domain/version.js
|
|
126
|
-
var SAKUPA_MCP_VERSION = "0.7.
|
|
126
|
+
var SAKUPA_MCP_VERSION = "0.7.5";
|
|
127
127
|
|
|
128
128
|
// ../core/dist/domain/errors.js
|
|
129
129
|
var HTTP_STATUS = {
|
|
@@ -439,6 +439,9 @@ function safeDecode(bytes) {
|
|
|
439
439
|
}
|
|
440
440
|
}
|
|
441
441
|
|
|
442
|
+
// ../core/dist/domain/credentials.js
|
|
443
|
+
var CREDENTIAL_PATTERN = /^sk_[A-Za-z0-9_-]{43}$/;
|
|
444
|
+
|
|
442
445
|
// ../core/dist/domain/hashing.js
|
|
443
446
|
async function sha256Hex(bytes) {
|
|
444
447
|
const digest = await globalThis.crypto.subtle.digest("SHA-256", bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength));
|
|
@@ -453,10 +456,6 @@ var CREDENTIAL_HEADER = "x-sakupa-credential";
|
|
|
453
456
|
var IDEMPOTENCY_HEADER = "x-sakupa-idempotency-key";
|
|
454
457
|
var MCP_VERSION_HEADER = "x-sakupa-mcp-version";
|
|
455
458
|
|
|
456
|
-
// ../core/dist/services/sites.js
|
|
457
|
-
var utf8Decoder = new TextDecoder("utf-8", { fatal: false });
|
|
458
|
-
var utf8Encoder = new TextEncoder();
|
|
459
|
-
|
|
460
459
|
// ../core/dist/services/subscriptions.js
|
|
461
460
|
var WEBHOOK_PROCESSING_LEASE_MS = 5 * 60 * 1e3;
|
|
462
461
|
|
|
@@ -764,20 +763,42 @@ var SITE_FILE = "site.json";
|
|
|
764
763
|
function siteFilePath(projectDir) {
|
|
765
764
|
return join(projectDir, SITE_DIR, SITE_FILE);
|
|
766
765
|
}
|
|
767
|
-
function
|
|
766
|
+
function loadSiteFile(projectDir) {
|
|
768
767
|
const path = siteFilePath(projectDir);
|
|
768
|
+
if (!existsSync(path)) return { kind: "absent" };
|
|
769
769
|
let raw;
|
|
770
770
|
try {
|
|
771
771
|
raw = readFileSync(path, "utf8");
|
|
772
|
-
} catch {
|
|
773
|
-
return
|
|
772
|
+
} catch (err) {
|
|
773
|
+
return {
|
|
774
|
+
kind: "corrupted",
|
|
775
|
+
problem: `the file exists but could not be read (${err instanceof Error ? err.message : String(err)})`
|
|
776
|
+
};
|
|
774
777
|
}
|
|
778
|
+
let parsed;
|
|
775
779
|
try {
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
+
parsed = JSON.parse(raw);
|
|
781
|
+
} catch {
|
|
782
|
+
return { kind: "corrupted", problem: "the file exists but is not valid JSON" };
|
|
783
|
+
}
|
|
784
|
+
if (typeof parsed !== "object" || parsed === null) {
|
|
785
|
+
return { kind: "corrupted", problem: "the file does not contain a JSON object" };
|
|
786
|
+
}
|
|
787
|
+
if (typeof parsed.siteId !== "string" || parsed.siteId.length === 0) {
|
|
788
|
+
return { kind: "corrupted", problem: "the siteId field is missing or empty" };
|
|
789
|
+
}
|
|
790
|
+
if (typeof parsed.credential !== "string" || parsed.credential.length === 0) {
|
|
791
|
+
return { kind: "corrupted", problem: "the credential field is missing or empty" };
|
|
792
|
+
}
|
|
793
|
+
if (!CREDENTIAL_PATTERN.test(parsed.credential)) {
|
|
780
794
|
return {
|
|
795
|
+
kind: "corrupted",
|
|
796
|
+
problem: "the credential does not match the shape Sakupa issues (sk_ followed by exactly 43 URL-safe base64 characters) \u2014 it looks locally altered or damaged"
|
|
797
|
+
};
|
|
798
|
+
}
|
|
799
|
+
return {
|
|
800
|
+
kind: "ok",
|
|
801
|
+
file: {
|
|
781
802
|
siteId: parsed.siteId,
|
|
782
803
|
credential: parsed.credential,
|
|
783
804
|
createdAt: typeof parsed.createdAt === "string" ? parsed.createdAt : "",
|
|
@@ -785,12 +806,26 @@ function readSiteFile(projectDir) {
|
|
|
785
806
|
...typeof parsed.shortId === "string" ? { shortId: parsed.shortId } : {},
|
|
786
807
|
...typeof parsed.url === "string" ? { url: parsed.url } : {},
|
|
787
808
|
...typeof parsed.boundDomain === "string" ? { boundDomain: parsed.boundDomain } : {}
|
|
788
|
-
}
|
|
789
|
-
}
|
|
790
|
-
return null;
|
|
791
|
-
}
|
|
809
|
+
}
|
|
810
|
+
};
|
|
792
811
|
}
|
|
793
|
-
function
|
|
812
|
+
function siteFileRecoveryGuidance(projectDir) {
|
|
813
|
+
return `The site itself is intact on the server; only the local binding file (${siteFilePath(projectDir)}) is the problem. Restore the file (from a backup or by undoing the local edit). Do NOT delete it to work around the error: the credential is unrecoverable by design, so abandoning it permanently orphans the existing site. Publishing this project as a brand-NEW site requires the user to manually delete the .sakupa directory first \u2014 the tool will never overwrite it.`;
|
|
814
|
+
}
|
|
815
|
+
function writeSiteFile(projectDir, file, opts = {}) {
|
|
816
|
+
if (opts.allowReplace !== true) {
|
|
817
|
+
const existing = loadSiteFile(projectDir);
|
|
818
|
+
if (existing.kind === "corrupted") {
|
|
819
|
+
throw new Error(
|
|
820
|
+
`Refusing to overwrite ${siteFilePath(projectDir)}: ${existing.problem}. ` + siteFileRecoveryGuidance(projectDir)
|
|
821
|
+
);
|
|
822
|
+
}
|
|
823
|
+
if (existing.kind === "ok" && existing.file.siteId !== file.siteId) {
|
|
824
|
+
throw new Error(
|
|
825
|
+
`Refusing to overwrite ${siteFilePath(projectDir)}: it already binds this project to site ${existing.file.siteId}. ` + siteFileRecoveryGuidance(projectDir)
|
|
826
|
+
);
|
|
827
|
+
}
|
|
828
|
+
}
|
|
794
829
|
const dir = join(projectDir, SITE_DIR);
|
|
795
830
|
mkdirSync(dir, { recursive: true });
|
|
796
831
|
const path = join(dir, SITE_FILE);
|
|
@@ -1290,14 +1325,20 @@ function structuredToolResult(envelope) {
|
|
|
1290
1325
|
|
|
1291
1326
|
// src/tools/context.ts
|
|
1292
1327
|
function requireSiteFile(ctx) {
|
|
1293
|
-
const
|
|
1294
|
-
if (
|
|
1328
|
+
const state = loadSiteFile(ctx.projectDir);
|
|
1329
|
+
if (state.kind === "corrupted") {
|
|
1330
|
+
throw new SakupaError(
|
|
1331
|
+
"invalid_request",
|
|
1332
|
+
`.sakupa/site.json in ${ctx.projectDir} is damaged: ${state.problem}. ` + siteFileRecoveryGuidance(ctx.projectDir)
|
|
1333
|
+
);
|
|
1334
|
+
}
|
|
1335
|
+
if (state.kind === "absent") {
|
|
1295
1336
|
throw new SakupaError(
|
|
1296
1337
|
"not_found",
|
|
1297
1338
|
`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.`
|
|
1298
1339
|
);
|
|
1299
1340
|
}
|
|
1300
|
-
return file;
|
|
1341
|
+
return state.file;
|
|
1301
1342
|
}
|
|
1302
1343
|
function toolError(e) {
|
|
1303
1344
|
const errorCode = isSakupaError(e) ? e.code : "internal";
|
|
@@ -1316,7 +1357,7 @@ function toolError(e) {
|
|
|
1316
1357
|
([key, value]) => safeDetailKeys.has(key) && (typeof value === "string" || typeof value === "number" || typeof value === "boolean")
|
|
1317
1358
|
)
|
|
1318
1359
|
) : void 0;
|
|
1319
|
-
const safeSummary = errorCode === "not_found" ? "
|
|
1360
|
+
const safeSummary = errorCode === "not_found" ? "The required local project binding or resource is unavailable; if this project has no .sakupa/site.json yet, run deploy_site first." : errorCode === "unauthorized" ? "The server rejected the site credential: the one in .sakupa/site.json no longer matches the server-side verifier. The site itself is intact on the server \u2014 only the local binding file is the problem. Repair the file (restore a backup or undo the local edit). Do NOT delete the .sakupa directory to work around this: the credential is unrecoverable by design, so abandoning it permanently orphans the existing site." : errorCode === "invalid_request" || errorCode === "validation_failed" ? "The request arguments or local project checks did not pass." : errorCode === "state_conflict" ? "The resource state has changed; re-query the current status before deciding the next step." : errorCode === "confirmation_required" ? "The site or billing state changed, so the previous confirmation is stale; run the preview again and confirm against the fresh snapshot." : errorCode === "payment_required" ? "This operation requires an active subscription; check billing_status first." : errorCode === "rate_limited" ? "The server rate limit was reached; retry after the returned wait time." : retryable ? "An upstream service is temporarily unavailable or busy; retry shortly." : "The operation failed; no server-internal details are exposed.";
|
|
1320
1361
|
const result = structuredToolResult({
|
|
1321
1362
|
schemaVersion: 1,
|
|
1322
1363
|
outcome: "failed",
|
|
@@ -1522,7 +1563,18 @@ Next action: ${analysis.suggestedNextAction}`,
|
|
|
1522
1563
|
const files = analysis.files;
|
|
1523
1564
|
const outputAbs = resolve2(ctx.projectDir, analysis.recommendedOutputDir ?? ".");
|
|
1524
1565
|
const manifest = await buildHashedManifest(files, outputAbs);
|
|
1525
|
-
const
|
|
1566
|
+
const siteFileState = loadSiteFile(ctx.projectDir);
|
|
1567
|
+
if (siteFileState.kind === "corrupted") {
|
|
1568
|
+
return text(
|
|
1569
|
+
"site_file_corrupted",
|
|
1570
|
+
`This project's .sakupa/site.json is damaged: ${siteFileState.problem}.
|
|
1571
|
+
|
|
1572
|
+
` + siteFileRecoveryGuidance(ctx.projectDir) + "\n\nNothing was deployed and no new site was created.",
|
|
1573
|
+
{ problem: siteFileState.problem },
|
|
1574
|
+
"blocked"
|
|
1575
|
+
);
|
|
1576
|
+
}
|
|
1577
|
+
const existing = siteFileState.kind === "ok" ? siteFileState.file : null;
|
|
1526
1578
|
if (!existing && args.publicConfirmed !== true) {
|
|
1527
1579
|
return text(
|
|
1528
1580
|
"public_deployment_confirmation_required",
|
|
@@ -1602,6 +1654,16 @@ ${JSON.stringify(finalized2.warnings, null, 2)}` : ""),
|
|
|
1602
1654
|
try {
|
|
1603
1655
|
update = await updateOnce(false);
|
|
1604
1656
|
} catch (e) {
|
|
1657
|
+
if (isSakupaError(e) && e.code === "unauthorized") {
|
|
1658
|
+
return text(
|
|
1659
|
+
"credential_mismatch",
|
|
1660
|
+
`The server rejected the credential in .sakupa/site.json for site ${existing.siteId} \u2014 the local file no longer matches the server-side verifier (most often a local edit or merge damage).
|
|
1661
|
+
|
|
1662
|
+
` + siteFileRecoveryGuidance(ctx.projectDir) + "\n\nNothing was deployed and no new site was created.",
|
|
1663
|
+
{ siteId: existing.siteId },
|
|
1664
|
+
"blocked"
|
|
1665
|
+
);
|
|
1666
|
+
}
|
|
1605
1667
|
if (!isIncrementalReuseFailure(e)) throw e;
|
|
1606
1668
|
update = await updateOnce(true);
|
|
1607
1669
|
}
|
|
@@ -1846,13 +1908,13 @@ Full status:`, res);
|
|
|
1846
1908
|
schemaVersion: 1,
|
|
1847
1909
|
outcome: "waiting_user",
|
|
1848
1910
|
resultCode: "site_billing_portal_ready",
|
|
1849
|
-
summary:
|
|
1911
|
+
summary: `Short-lived Stripe customer portal link created for this site: ${res2.portalUrl}. Any change still happens only on the Stripe-hosted page.`,
|
|
1850
1912
|
data: { scope: args.scope, portalUrl: res2.portalUrl },
|
|
1851
1913
|
userAction: {
|
|
1852
1914
|
type: "open_url",
|
|
1853
1915
|
provider: "stripe",
|
|
1854
1916
|
url: res2.portalUrl,
|
|
1855
|
-
expectedOutcome: "
|
|
1917
|
+
expectedOutcome: "The user manages payment methods, invoices, or cancels renewal on the Stripe-hosted page"
|
|
1856
1918
|
},
|
|
1857
1919
|
nextActions: [{ tool: "billing_status", allowed: true }]
|
|
1858
1920
|
});
|
|
@@ -1862,7 +1924,7 @@ Full status:`, res);
|
|
|
1862
1924
|
schemaVersion: 1,
|
|
1863
1925
|
outcome: "waiting_user",
|
|
1864
1926
|
resultCode: "public_billing_recovery_portal_ready",
|
|
1865
|
-
summary: `Stripe
|
|
1927
|
+
summary: `Stripe public email-OTP login page: ${res.portalUrl}. It uses a one-time passcode, does not recover the Sakupa key, and grants no site authority; when one email has several Customers, Stripe may open only the most recently created usable record.`,
|
|
1866
1928
|
data: {
|
|
1867
1929
|
scope: args.scope,
|
|
1868
1930
|
portalUrl: res.portalUrl,
|
|
@@ -1873,7 +1935,7 @@ Full status:`, res);
|
|
|
1873
1935
|
type: "open_url",
|
|
1874
1936
|
provider: "stripe",
|
|
1875
1937
|
url: res.portalUrl,
|
|
1876
|
-
expectedOutcome: "
|
|
1938
|
+
expectedOutcome: "The user verifies the billing email with Stripe, then reviews and cancels the subscription shown in the portal"
|
|
1877
1939
|
},
|
|
1878
1940
|
nextActions: []
|
|
1879
1941
|
});
|
|
@@ -1937,7 +1999,7 @@ After the DNS record resolves, re-run recover_domain_site with verificationId: "
|
|
|
1937
1999
|
schemaVersion: 1,
|
|
1938
2000
|
outcome: res2.status === "expired" ? "expired" : res2.readyToComplete ? "completed" : "pending_provider",
|
|
1939
2001
|
resultCode: res2.status === "expired" ? "domain_recovery_expired" : res2.readyToComplete ? "domain_recovery_ready" : "domain_recovery_pending_dns",
|
|
1940
|
-
summary: `DNS
|
|
2002
|
+
summary: `DNS recovery verification status: ${res2.status}`,
|
|
1941
2003
|
data: { recovery: res2 },
|
|
1942
2004
|
nextActions: [
|
|
1943
2005
|
{
|
|
@@ -1952,13 +2014,17 @@ After the DNS record resolves, re-run recover_domain_site with verificationId: "
|
|
|
1952
2014
|
const res = await ctx.client.completeRecovery(args.verificationId, {
|
|
1953
2015
|
...args.preserveExistingCredentials !== void 0 ? { preserveExistingCredentials: args.preserveExistingCredentials } : {}
|
|
1954
2016
|
});
|
|
1955
|
-
writeSiteFile(
|
|
1956
|
-
|
|
1957
|
-
|
|
1958
|
-
|
|
1959
|
-
|
|
1960
|
-
|
|
1961
|
-
|
|
2017
|
+
writeSiteFile(
|
|
2018
|
+
ctx.projectDir,
|
|
2019
|
+
{
|
|
2020
|
+
siteId: res.siteId,
|
|
2021
|
+
...res.boundHostnames[0] !== void 0 ? { boundDomain: res.boundHostnames[0] } : {},
|
|
2022
|
+
credential: res.credential,
|
|
2023
|
+
createdAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
2024
|
+
apiBaseUrl: ctx.apiBaseUrl
|
|
2025
|
+
},
|
|
2026
|
+
{ allowReplace: true }
|
|
2027
|
+
);
|
|
1962
2028
|
return text(
|
|
1963
2029
|
"domain_recovery_completed",
|
|
1964
2030
|
`Recovery complete.
|
|
@@ -2035,7 +2101,8 @@ ${res.archiveUrl}`,
|
|
|
2035
2101
|
},
|
|
2036
2102
|
async (args) => {
|
|
2037
2103
|
try {
|
|
2038
|
-
const
|
|
2104
|
+
const siteState = loadSiteFile(ctx.projectDir);
|
|
2105
|
+
const site = siteState.kind === "ok" ? siteState.file : null;
|
|
2039
2106
|
const diagnostics = {
|
|
2040
2107
|
toolName: args.toolName,
|
|
2041
2108
|
...args.errorCode !== void 0 ? { errorCode: args.errorCode } : {},
|
|
@@ -2130,7 +2197,7 @@ function registerLifecycleTools(server, ctx) {
|
|
|
2130
2197
|
outcome: "waiting_user",
|
|
2131
2198
|
resultCode: "delete_site_confirmation_required",
|
|
2132
2199
|
operationId: args.operationId,
|
|
2133
|
-
summary: "
|
|
2200
|
+
summary: "Deletion consequences returned, bound to the current site and billing state; after confirmation the content and the permanent URL are unrecoverable.",
|
|
2134
2201
|
data: { preview },
|
|
2135
2202
|
nextActions: [
|
|
2136
2203
|
{ tool: "delete_site", allowed: true, reasonCode: "exact_confirmation_required" }
|
|
@@ -2148,7 +2215,7 @@ function registerLifecycleTools(server, ctx) {
|
|
|
2148
2215
|
outcome: result.servingDeletionPending ? "pending_provider" : "completed",
|
|
2149
2216
|
resultCode: "site_deleted",
|
|
2150
2217
|
operationId: args.operationId,
|
|
2151
|
-
summary: "
|
|
2218
|
+
summary: "Site deleted; the local management credential file was removed.",
|
|
2152
2219
|
data: { result },
|
|
2153
2220
|
nextActions: []
|
|
2154
2221
|
});
|
|
@@ -2182,7 +2249,7 @@ function registerLifecycleTools(server, ctx) {
|
|
|
2182
2249
|
outcome: "waiting_user",
|
|
2183
2250
|
resultCode: "unbind_domain_confirmation_required",
|
|
2184
2251
|
operationId: args.operationId,
|
|
2185
|
-
summary: "\
|
|
2252
|
+
summary: "Exact binding snapshot returned; unbinding removes ONLY the custom domain \u2014 the subscription, content, and permanent URL stay unchanged.",
|
|
2186
2253
|
data: { preview },
|
|
2187
2254
|
nextActions: [
|
|
2188
2255
|
{ tool: "unbind_domain", allowed: true, reasonCode: "exact_confirmation_required" }
|
|
@@ -2201,7 +2268,7 @@ function registerLifecycleTools(server, ctx) {
|
|
|
2201
2268
|
outcome: result.servingDeletionPending ? "pending_provider" : "completed",
|
|
2202
2269
|
resultCode: "domain_unbound",
|
|
2203
2270
|
operationId: args.operationId,
|
|
2204
|
-
summary: "
|
|
2271
|
+
summary: "Custom domain unbound; the subscription, deployed content, and permanent Sakupa URL are unchanged.",
|
|
2205
2272
|
data: { result },
|
|
2206
2273
|
nextActions: [{ tool: "site_status", allowed: true }]
|
|
2207
2274
|
});
|
|
@@ -2234,7 +2301,7 @@ function registerBillingTools(server, ctx) {
|
|
|
2234
2301
|
schemaVersion: 1,
|
|
2235
2302
|
outcome: "completed",
|
|
2236
2303
|
resultCode: "billing_catalog_returned",
|
|
2237
|
-
summary:
|
|
2304
|
+
summary: `Returned ${catalog.plans.length} monthly plans; the Stripe-hosted page is the final confirmation surface for payment and plan changes.`,
|
|
2238
2305
|
data: { catalog },
|
|
2239
2306
|
nextActions: [{ tool: "subscribe_site", allowed: true }]
|
|
2240
2307
|
});
|
|
@@ -2267,13 +2334,13 @@ function registerBillingTools(server, ctx) {
|
|
|
2267
2334
|
outcome: "waiting_user",
|
|
2268
2335
|
resultCode: "stripe_plan_change_confirmation_required",
|
|
2269
2336
|
operationId: args.operationId,
|
|
2270
|
-
summary:
|
|
2337
|
+
summary: `Stripe confirmation link created for ${result.currentPlan} -> ${result.targetPlan}; the subscription has NOT changed yet.`,
|
|
2271
2338
|
data: { result },
|
|
2272
2339
|
userAction: {
|
|
2273
2340
|
type: "open_url",
|
|
2274
2341
|
provider: "stripe",
|
|
2275
2342
|
url: result.portalUrl,
|
|
2276
|
-
expectedOutcome: "
|
|
2343
|
+
expectedOutcome: "After the user confirms on the Stripe-hosted page, the webhook updates the Sakupa subscription state"
|
|
2277
2344
|
},
|
|
2278
2345
|
nextActions: [{ tool: "billing_status", allowed: true }]
|
|
2279
2346
|
});
|
|
@@ -2358,7 +2425,7 @@ export {
|
|
|
2358
2425
|
createSakupaMcpServer,
|
|
2359
2426
|
deleteSiteFile,
|
|
2360
2427
|
loadMcpRuntimeConfig,
|
|
2361
|
-
|
|
2428
|
+
loadSiteFile,
|
|
2362
2429
|
registerLifecycleTools,
|
|
2363
2430
|
registerTools,
|
|
2364
2431
|
requireSiteFile,
|