@sakupa/mcp 0.7.32 → 0.7.34
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 +535 -275
- package/dist/index.js +412 -224
- package/package.json +1 -1
package/dist/index.js
CHANGED
|
@@ -124,7 +124,7 @@ var FORBIDDEN_PATH_SEGMENTS = [
|
|
|
124
124
|
var ALLOWED_HIDDEN_PATHS = [".well-known/"];
|
|
125
125
|
|
|
126
126
|
// ../core/dist/domain/version.js
|
|
127
|
-
var SAKUPA_MCP_VERSION = "0.7.
|
|
127
|
+
var SAKUPA_MCP_VERSION = "0.7.34";
|
|
128
128
|
|
|
129
129
|
// ../core/dist/domain/errors.js
|
|
130
130
|
var HTTP_STATUS = {
|
|
@@ -1217,7 +1217,7 @@ function normalizeOutputDir(outputDir) {
|
|
|
1217
1217
|
return normalized === "" ? "." : normalized;
|
|
1218
1218
|
}
|
|
1219
1219
|
async function analyzeProject(projectDir, opts = {}) {
|
|
1220
|
-
const root = resolve(projectDir);
|
|
1220
|
+
const root = await fs.realpath(resolve(projectDir));
|
|
1221
1221
|
const pkg = await readPackageJson(root);
|
|
1222
1222
|
const detection = await detectFramework(root, pkg);
|
|
1223
1223
|
const ssrRisks = [...detection?.ssrRisks ?? []];
|
|
@@ -1233,6 +1233,16 @@ async function analyzeProject(projectDir, opts = {}) {
|
|
|
1233
1233
|
outputDirExists = false;
|
|
1234
1234
|
} else {
|
|
1235
1235
|
outputDirExists = await isDirectory(abs) || outputDirRel === "." && await isDirectory(root);
|
|
1236
|
+
if (outputDirExists) {
|
|
1237
|
+
try {
|
|
1238
|
+
const physical = await fs.realpath(abs);
|
|
1239
|
+
if (physical !== root && !physical.startsWith(root + sep)) {
|
|
1240
|
+
outputDirExists = false;
|
|
1241
|
+
}
|
|
1242
|
+
} catch {
|
|
1243
|
+
outputDirExists = false;
|
|
1244
|
+
}
|
|
1245
|
+
}
|
|
1236
1246
|
}
|
|
1237
1247
|
} else if (detection) {
|
|
1238
1248
|
for (const candidate of detection.outputCandidates) {
|
|
@@ -1354,11 +1364,186 @@ async function analyzeProject(projectDir, opts = {}) {
|
|
|
1354
1364
|
};
|
|
1355
1365
|
}
|
|
1356
1366
|
|
|
1357
|
-
// src/
|
|
1358
|
-
import {
|
|
1359
|
-
import {
|
|
1367
|
+
// src/project-root.ts
|
|
1368
|
+
import { randomUUID } from "node:crypto";
|
|
1369
|
+
import {
|
|
1370
|
+
chmodSync as chmodSync2,
|
|
1371
|
+
existsSync as existsSync2,
|
|
1372
|
+
lstatSync,
|
|
1373
|
+
mkdirSync as mkdirSync2,
|
|
1374
|
+
readFileSync as readFileSync2,
|
|
1375
|
+
realpathSync,
|
|
1376
|
+
renameSync,
|
|
1377
|
+
statSync,
|
|
1378
|
+
unlinkSync,
|
|
1379
|
+
writeFileSync as writeFileSync2
|
|
1380
|
+
} from "node:fs";
|
|
1360
1381
|
import { homedir } from "node:os";
|
|
1361
|
-
import { isAbsolute, parse, resolve as resolve2 } from "node:path";
|
|
1382
|
+
import { isAbsolute, join as join3, parse, relative, resolve as resolve2, sep as sep2 } from "node:path";
|
|
1383
|
+
var SAKUPA_DIR = ".sakupa";
|
|
1384
|
+
var PROJECT_FILE = "project.json";
|
|
1385
|
+
var PROJECT_SCHEMA_VERSION = 1;
|
|
1386
|
+
var ProjectRootError = class extends Error {
|
|
1387
|
+
code;
|
|
1388
|
+
constructor(code, message) {
|
|
1389
|
+
super(message);
|
|
1390
|
+
this.name = "ProjectRootError";
|
|
1391
|
+
this.code = code;
|
|
1392
|
+
}
|
|
1393
|
+
};
|
|
1394
|
+
function projectMarkerPath(projectDir) {
|
|
1395
|
+
return join3(projectDir, SAKUPA_DIR, PROJECT_FILE);
|
|
1396
|
+
}
|
|
1397
|
+
function loadProjectMarker(projectDir) {
|
|
1398
|
+
const path = projectMarkerPath(projectDir);
|
|
1399
|
+
if (!existsSync2(path)) return { kind: "absent" };
|
|
1400
|
+
let parsed;
|
|
1401
|
+
try {
|
|
1402
|
+
parsed = JSON.parse(readFileSync2(path, "utf8"));
|
|
1403
|
+
} catch (error) {
|
|
1404
|
+
return {
|
|
1405
|
+
kind: "corrupted",
|
|
1406
|
+
problem: `the file cannot be read as JSON (${error instanceof Error ? error.message : String(error)})`
|
|
1407
|
+
};
|
|
1408
|
+
}
|
|
1409
|
+
if (typeof parsed !== "object" || parsed === null) {
|
|
1410
|
+
return { kind: "corrupted", problem: "the file does not contain a JSON object" };
|
|
1411
|
+
}
|
|
1412
|
+
const record = parsed;
|
|
1413
|
+
if (record.schemaVersion !== PROJECT_SCHEMA_VERSION) {
|
|
1414
|
+
return {
|
|
1415
|
+
kind: "corrupted",
|
|
1416
|
+
problem: `unsupported schemaVersion ${String(record.schemaVersion)}`
|
|
1417
|
+
};
|
|
1418
|
+
}
|
|
1419
|
+
if (typeof record.projectId !== "string" || !isUuid(record.projectId)) {
|
|
1420
|
+
return { kind: "corrupted", problem: "projectId is missing or is not a UUID" };
|
|
1421
|
+
}
|
|
1422
|
+
if (typeof record.createdAt !== "string" || !Number.isFinite(Date.parse(record.createdAt))) {
|
|
1423
|
+
return { kind: "corrupted", problem: "createdAt is missing or invalid" };
|
|
1424
|
+
}
|
|
1425
|
+
if (record.outputDir !== void 0 && (typeof record.outputDir !== "string" || !isSafeRelativeOutput(record.outputDir))) {
|
|
1426
|
+
return { kind: "corrupted", problem: "outputDir is not a safe project-relative path" };
|
|
1427
|
+
}
|
|
1428
|
+
return {
|
|
1429
|
+
kind: "ok",
|
|
1430
|
+
marker: {
|
|
1431
|
+
schemaVersion: PROJECT_SCHEMA_VERSION,
|
|
1432
|
+
projectId: record.projectId,
|
|
1433
|
+
createdAt: record.createdAt,
|
|
1434
|
+
...record.outputDir !== void 0 ? { outputDir: normalizeRelative(record.outputDir) } : {}
|
|
1435
|
+
}
|
|
1436
|
+
};
|
|
1437
|
+
}
|
|
1438
|
+
function resolveLockedProjectRoot(projectDir) {
|
|
1439
|
+
const canonical = canonicalProjectDirectory(projectDir);
|
|
1440
|
+
assertSafeProjectRoot(canonical);
|
|
1441
|
+
const markerState = loadProjectMarker(canonical);
|
|
1442
|
+
if (markerState.kind === "corrupted") {
|
|
1443
|
+
throw new ProjectRootError(
|
|
1444
|
+
"corrupted_marker",
|
|
1445
|
+
`Sakupa project marker ${projectMarkerPath(canonical)} is damaged: ${markerState.problem}.`
|
|
1446
|
+
);
|
|
1447
|
+
}
|
|
1448
|
+
if (markerState.kind === "absent") {
|
|
1449
|
+
throw new ProjectRootError(
|
|
1450
|
+
"not_initialized",
|
|
1451
|
+
`The MCP working directory ${canonical} is not initialized. Run \`npx -y @sakupa/mcp@latest init\` in that directory; do not pass a path argument.`
|
|
1452
|
+
);
|
|
1453
|
+
}
|
|
1454
|
+
return {
|
|
1455
|
+
projectDir: canonical,
|
|
1456
|
+
requestedPath: canonical,
|
|
1457
|
+
markerKind: "project",
|
|
1458
|
+
marker: markerState.marker
|
|
1459
|
+
};
|
|
1460
|
+
}
|
|
1461
|
+
function updateProjectOutputDir(projectDir, outputDir) {
|
|
1462
|
+
const canonical = canonicalProjectDirectory(projectDir);
|
|
1463
|
+
const state = loadProjectMarker(canonical);
|
|
1464
|
+
if (state.kind !== "ok") {
|
|
1465
|
+
throw new ProjectRootError(
|
|
1466
|
+
state.kind === "corrupted" ? "corrupted_marker" : "not_initialized",
|
|
1467
|
+
state.kind === "corrupted" ? `Cannot update damaged Sakupa project marker: ${state.problem}.` : `No Sakupa project marker exists in ${canonical}.`
|
|
1468
|
+
);
|
|
1469
|
+
}
|
|
1470
|
+
if (!isSafeRelativeOutput(outputDir)) {
|
|
1471
|
+
throw new ProjectRootError(
|
|
1472
|
+
"unsafe_path",
|
|
1473
|
+
`Output directory "${outputDir}" must stay inside the initialized Sakupa project.`
|
|
1474
|
+
);
|
|
1475
|
+
}
|
|
1476
|
+
const marker = {
|
|
1477
|
+
...state.marker,
|
|
1478
|
+
outputDir: normalizeRelative(outputDir)
|
|
1479
|
+
};
|
|
1480
|
+
writeMarkerAtomically(canonical, marker);
|
|
1481
|
+
return marker;
|
|
1482
|
+
}
|
|
1483
|
+
function canonicalProjectDirectory(path) {
|
|
1484
|
+
const canonical = canonicalExistingPath(resolve2(path));
|
|
1485
|
+
if (!statSync(canonical).isDirectory()) {
|
|
1486
|
+
throw new ProjectRootError("invalid_path", `Project path ${canonical} is not a directory.`);
|
|
1487
|
+
}
|
|
1488
|
+
return canonical;
|
|
1489
|
+
}
|
|
1490
|
+
function canonicalExistingPath(path) {
|
|
1491
|
+
try {
|
|
1492
|
+
const stat2 = lstatSync(path, { throwIfNoEntry: false });
|
|
1493
|
+
if (!stat2) {
|
|
1494
|
+
throw new ProjectRootError("invalid_path", `Project path ${path} does not exist.`);
|
|
1495
|
+
}
|
|
1496
|
+
return realpathSync(path);
|
|
1497
|
+
} catch (error) {
|
|
1498
|
+
if (error instanceof ProjectRootError) throw error;
|
|
1499
|
+
throw new ProjectRootError(
|
|
1500
|
+
"invalid_path",
|
|
1501
|
+
`Project path ${path} cannot be resolved (${error instanceof Error ? error.message : String(error)}).`
|
|
1502
|
+
);
|
|
1503
|
+
}
|
|
1504
|
+
}
|
|
1505
|
+
function assertSafeProjectRoot(projectDir) {
|
|
1506
|
+
if (parse(projectDir).root === projectDir || projectDir === realpathSync(homedir())) {
|
|
1507
|
+
throw new ProjectRootError(
|
|
1508
|
+
"unsafe_path",
|
|
1509
|
+
`Refusing to use ${projectDir} as a Sakupa project root; choose a specific project directory.`
|
|
1510
|
+
);
|
|
1511
|
+
}
|
|
1512
|
+
}
|
|
1513
|
+
function isUuid(value) {
|
|
1514
|
+
return /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(value);
|
|
1515
|
+
}
|
|
1516
|
+
function normalizeRelative(path) {
|
|
1517
|
+
const normalized = path.split(sep2).join("/").replace(/^\.\//, "").replace(/\/$/, "");
|
|
1518
|
+
return normalized.length === 0 ? "." : normalized;
|
|
1519
|
+
}
|
|
1520
|
+
function isSafeRelativeOutput(path) {
|
|
1521
|
+
if (path.length === 0 || isAbsolute(path)) return false;
|
|
1522
|
+
const normalized = normalizeRelative(path);
|
|
1523
|
+
if (normalized === ".") return true;
|
|
1524
|
+
const rel = relative("/sakupa-root", resolve2("/sakupa-root", normalized));
|
|
1525
|
+
return rel !== ".." && !rel.startsWith(`..${sep2}`) && !isAbsolute(rel);
|
|
1526
|
+
}
|
|
1527
|
+
function writeMarkerAtomically(projectDir, marker) {
|
|
1528
|
+
const dir = join3(projectDir, SAKUPA_DIR);
|
|
1529
|
+
mkdirSync2(dir, { recursive: true, mode: 448 });
|
|
1530
|
+
const path = projectMarkerPath(projectDir);
|
|
1531
|
+
const temporary = `${path}.${process.pid}.${randomUUID()}.tmp`;
|
|
1532
|
+
try {
|
|
1533
|
+
writeFileSync2(temporary, `${JSON.stringify(marker, null, 2)}
|
|
1534
|
+
`, {
|
|
1535
|
+
encoding: "utf8",
|
|
1536
|
+
mode: 384
|
|
1537
|
+
});
|
|
1538
|
+
renameSync(temporary, path);
|
|
1539
|
+
try {
|
|
1540
|
+
chmodSync2(path, 384);
|
|
1541
|
+
} catch {
|
|
1542
|
+
}
|
|
1543
|
+
} finally {
|
|
1544
|
+
if (existsSync2(temporary)) unlinkSync(temporary);
|
|
1545
|
+
}
|
|
1546
|
+
}
|
|
1362
1547
|
|
|
1363
1548
|
// src/tools/result.ts
|
|
1364
1549
|
import { z } from "zod";
|
|
@@ -1407,37 +1592,25 @@ var LocalGuidanceError = class extends SakupaError {
|
|
|
1407
1592
|
super(code, message);
|
|
1408
1593
|
}
|
|
1409
1594
|
};
|
|
1410
|
-
|
|
1411
|
-
|
|
1412
|
-
);
|
|
1413
|
-
|
|
1414
|
-
|
|
1415
|
-
|
|
1416
|
-
|
|
1417
|
-
|
|
1418
|
-
|
|
1419
|
-
|
|
1420
|
-
|
|
1421
|
-
|
|
1422
|
-
|
|
1423
|
-
|
|
1424
|
-
|
|
1425
|
-
|
|
1426
|
-
|
|
1427
|
-
|
|
1428
|
-
throw new LocalGuidanceError(
|
|
1429
|
-
"invalid_request",
|
|
1430
|
-
`projectDir "${dir}" is a filesystem root or the home directory. Pass the specific project folder that holds the site's files, not a top-level directory.`
|
|
1431
|
-
);
|
|
1432
|
-
}
|
|
1433
|
-
const stat2 = statSync(dir, { throwIfNoEntry: false });
|
|
1434
|
-
if (!stat2?.isDirectory()) {
|
|
1435
|
-
throw new LocalGuidanceError(
|
|
1436
|
-
"invalid_request",
|
|
1437
|
-
`projectDir "${dir}" does not exist or is not a directory. Pass the absolute path of the directory the user is currently working in.`
|
|
1438
|
-
);
|
|
1595
|
+
function withProjectDir(ctx) {
|
|
1596
|
+
try {
|
|
1597
|
+
const resolved = resolveLockedProjectRoot(ctx.projectDir);
|
|
1598
|
+
return {
|
|
1599
|
+
...ctx,
|
|
1600
|
+
projectDir: resolved.projectDir,
|
|
1601
|
+
requestedPath: resolved.requestedPath,
|
|
1602
|
+
markerKind: resolved.markerKind,
|
|
1603
|
+
projectMarker: resolved.marker
|
|
1604
|
+
};
|
|
1605
|
+
} catch (error) {
|
|
1606
|
+
if (error instanceof ProjectRootError) {
|
|
1607
|
+
throw new LocalGuidanceError(
|
|
1608
|
+
error.code === "not_initialized" ? "not_found" : "invalid_request",
|
|
1609
|
+
error.message
|
|
1610
|
+
);
|
|
1611
|
+
}
|
|
1612
|
+
throw error;
|
|
1439
1613
|
}
|
|
1440
|
-
return { ...ctx, projectDir: dir };
|
|
1441
1614
|
}
|
|
1442
1615
|
function requireSiteFile(ctx) {
|
|
1443
1616
|
const state = loadSiteFile(ctx.projectDir);
|
|
@@ -1450,7 +1623,7 @@ function requireSiteFile(ctx) {
|
|
|
1450
1623
|
if (state.kind === "absent") {
|
|
1451
1624
|
throw new LocalGuidanceError(
|
|
1452
1625
|
"not_found",
|
|
1453
|
-
`No .sakupa/site.json found in ${ctx.projectDir} \u2014 this directory has no Sakupa site binding. If you meant to manage
|
|
1626
|
+
`No .sakupa/site.json found in ${ctx.projectDir} \u2014 this directory has no Sakupa site binding. If you meant to manage a different existing site, open that project as the AI tool workspace and start its Sakupa MCP process there. To publish THIS directory as a new site, run deploy. If this was a paid custom-domain site whose project file was lost, use recover.`
|
|
1454
1627
|
);
|
|
1455
1628
|
}
|
|
1456
1629
|
return state.file;
|
|
@@ -1494,14 +1667,15 @@ function toolError(e) {
|
|
|
1494
1667
|
}
|
|
1495
1668
|
|
|
1496
1669
|
// src/tools/definitions.ts
|
|
1497
|
-
import { randomUUID } from "node:crypto";
|
|
1498
|
-
import {
|
|
1499
|
-
import { join as
|
|
1500
|
-
import { z as
|
|
1670
|
+
import { randomUUID as randomUUID2 } from "node:crypto";
|
|
1671
|
+
import { promises as fs2 } from "node:fs";
|
|
1672
|
+
import { join as join6, resolve as resolve4 } from "node:path";
|
|
1673
|
+
import { z as z2 } from "zod";
|
|
1501
1674
|
|
|
1502
1675
|
// src/recovery-archive.ts
|
|
1676
|
+
import { existsSync as existsSync3, realpathSync as realpathSync2 } from "node:fs";
|
|
1503
1677
|
import { mkdtemp, mkdir, readFile, readdir, rename, rm, stat, writeFile } from "node:fs/promises";
|
|
1504
|
-
import { dirname as dirname2, isAbsolute as isAbsolute2, join as
|
|
1678
|
+
import { dirname as dirname2, isAbsolute as isAbsolute2, join as join4, relative as relative2, resolve as resolve3, sep as sep3 } from "node:path";
|
|
1505
1679
|
|
|
1506
1680
|
// ../../node_modules/fflate/esm/index.mjs
|
|
1507
1681
|
import { createRequire } from "module";
|
|
@@ -1920,15 +2094,15 @@ function strFromU8(dat, latin1) {
|
|
|
1920
2094
|
var slzh = function(d, b) {
|
|
1921
2095
|
return b + 30 + b2(d, b + 26) + b2(d, b + 28);
|
|
1922
2096
|
};
|
|
1923
|
-
var zh = function(d, b,
|
|
2097
|
+
var zh = function(d, b, z5) {
|
|
1924
2098
|
var fnl = b2(d, b + 28), efl = b2(d, b + 30), fn = strFromU8(d.subarray(b + 46, b + 46 + fnl), !(b2(d, b + 8) & 2048)), es = b + 46 + fnl;
|
|
1925
|
-
var _a2 = z64hs(d, es, efl,
|
|
2099
|
+
var _a2 = z64hs(d, es, efl, z5, b4(d, b + 20), b4(d, b + 24), b4(d, b + 42)), sc = _a2[0], su = _a2[1], off = _a2[2];
|
|
1926
2100
|
return [b2(d, b + 10), sc, su, fn, es + efl + b2(d, b + 32), off];
|
|
1927
2101
|
};
|
|
1928
|
-
var z64hs = function(d, b, l,
|
|
2102
|
+
var z64hs = function(d, b, l, z5, sc, su, off) {
|
|
1929
2103
|
var nsc = sc == 4294967295, nsu = su == 4294967295, noff = off == 4294967295, e = b + l;
|
|
1930
2104
|
var nf = nsc + nsu + noff;
|
|
1931
|
-
if (
|
|
2105
|
+
if (z5 && nf) {
|
|
1932
2106
|
for (; b + 4 < e; b += 4 + b2(d, b + 2)) {
|
|
1933
2107
|
if (b2(d, b) == 1) {
|
|
1934
2108
|
return [
|
|
@@ -1939,7 +2113,7 @@ var z64hs = function(d, b, l, z6, sc, su, off) {
|
|
|
1939
2113
|
];
|
|
1940
2114
|
}
|
|
1941
2115
|
}
|
|
1942
|
-
if (
|
|
2116
|
+
if (z5 < 2)
|
|
1943
2117
|
err(13);
|
|
1944
2118
|
}
|
|
1945
2119
|
return [sc, su, off, 0];
|
|
@@ -1956,18 +2130,18 @@ function unzipSync(data, opts) {
|
|
|
1956
2130
|
if (!c)
|
|
1957
2131
|
return {};
|
|
1958
2132
|
var o = b4(data, e + 16);
|
|
1959
|
-
var
|
|
1960
|
-
if (
|
|
2133
|
+
var z5 = b4(data, e - 20) == 117853008;
|
|
2134
|
+
if (z5) {
|
|
1961
2135
|
var ze = b4(data, e - 12);
|
|
1962
|
-
|
|
1963
|
-
if (
|
|
2136
|
+
z5 = b4(data, ze) == 101075792;
|
|
2137
|
+
if (z5) {
|
|
1964
2138
|
c = b4(data, ze + 32);
|
|
1965
2139
|
o = b4(data, ze + 48);
|
|
1966
2140
|
}
|
|
1967
2141
|
}
|
|
1968
2142
|
var fltr = opts && opts.filter;
|
|
1969
2143
|
for (var i = 0; i < c; ++i) {
|
|
1970
|
-
var _a2 = zh(data, o,
|
|
2144
|
+
var _a2 = zh(data, o, z5), c_2 = _a2[0], sc = _a2[1], su = _a2[2], fn = _a2[3], no = _a2[4], off = _a2[5], b = slzh(data, off);
|
|
1971
2145
|
o = no;
|
|
1972
2146
|
if (!fltr || fltr({
|
|
1973
2147
|
name: fn,
|
|
@@ -1991,15 +2165,30 @@ function safeOutputPath(projectDir, outputDir) {
|
|
|
1991
2165
|
if (outputDir.length === 0 || isAbsolute2(outputDir)) {
|
|
1992
2166
|
throw new SakupaError("invalid_request", "Recovery outputDir must be a relative directory");
|
|
1993
2167
|
}
|
|
1994
|
-
const root = resolve3(projectDir);
|
|
2168
|
+
const root = realpathSync2(resolve3(projectDir));
|
|
1995
2169
|
const target = resolve3(root, outputDir);
|
|
1996
|
-
const rel =
|
|
1997
|
-
if (rel === "" || rel === ".." || rel.startsWith(`..${
|
|
2170
|
+
const rel = relative2(root, target);
|
|
2171
|
+
if (rel === "" || rel === ".." || rel.startsWith(`..${sep3}`) || isAbsolute2(rel)) {
|
|
1998
2172
|
throw new SakupaError("invalid_request", "Recovery outputDir must stay inside projectDir");
|
|
1999
2173
|
}
|
|
2000
|
-
if (rel === ".sakupa" || rel.startsWith(`.sakupa${
|
|
2174
|
+
if (rel === ".sakupa" || rel.startsWith(`.sakupa${sep3}`)) {
|
|
2001
2175
|
throw new SakupaError("invalid_request", "Recovery content cannot be written inside .sakupa");
|
|
2002
2176
|
}
|
|
2177
|
+
let existingAncestor = target;
|
|
2178
|
+
while (!existsSync3(existingAncestor)) {
|
|
2179
|
+
const parent = dirname2(existingAncestor);
|
|
2180
|
+
if (parent === existingAncestor) break;
|
|
2181
|
+
existingAncestor = parent;
|
|
2182
|
+
}
|
|
2183
|
+
const physicalAncestor = realpathSync2(existingAncestor);
|
|
2184
|
+
const physicalTarget = resolve3(physicalAncestor, relative2(existingAncestor, target));
|
|
2185
|
+
const physicalRel = relative2(root, physicalTarget);
|
|
2186
|
+
if (physicalRel === ".." || physicalRel.startsWith(`..${sep3}`) || isAbsolute2(physicalRel)) {
|
|
2187
|
+
throw new SakupaError(
|
|
2188
|
+
"invalid_request",
|
|
2189
|
+
"Recovery outputDir resolves through a symlink outside projectDir"
|
|
2190
|
+
);
|
|
2191
|
+
}
|
|
2003
2192
|
return target;
|
|
2004
2193
|
}
|
|
2005
2194
|
function safeEntryName(name) {
|
|
@@ -2028,9 +2217,9 @@ async function listExistingFiles(root, current = root) {
|
|
|
2028
2217
|
if (entry.isSymbolicLink()) {
|
|
2029
2218
|
throw new SakupaError("state_conflict", `Recovery output contains a symlink: ${entry.name}`);
|
|
2030
2219
|
}
|
|
2031
|
-
const absolute =
|
|
2220
|
+
const absolute = join4(current, entry.name);
|
|
2032
2221
|
if (entry.isDirectory()) files.push(...await listExistingFiles(root, absolute));
|
|
2033
|
-
else if (entry.isFile()) files.push(
|
|
2222
|
+
else if (entry.isFile()) files.push(relative2(root, absolute).split(sep3).join("/"));
|
|
2034
2223
|
else
|
|
2035
2224
|
throw new SakupaError(
|
|
2036
2225
|
"state_conflict",
|
|
@@ -2046,7 +2235,7 @@ async function existingOutputMatches(outputDir, files) {
|
|
|
2046
2235
|
return false;
|
|
2047
2236
|
}
|
|
2048
2237
|
for (const name of expected) {
|
|
2049
|
-
const actual = await readFile(
|
|
2238
|
+
const actual = await readFile(join4(outputDir, ...name.split("/")));
|
|
2050
2239
|
const wanted = files[name];
|
|
2051
2240
|
if (!wanted || actual.byteLength !== wanted.byteLength || !actual.equals(wanted)) return false;
|
|
2052
2241
|
}
|
|
@@ -2056,7 +2245,7 @@ async function extractRecoveryArchive(input) {
|
|
|
2056
2245
|
if (!Number.isSafeInteger(input.expectedBytes) || input.expectedBytes < 0 || input.expectedBytes > PAID_SITE_MAX_TOTAL_BYTES || !Number.isSafeInteger(input.expectedFiles) || input.expectedFiles < 0 || input.expectedFiles > MAX_FILE_COUNT) {
|
|
2057
2246
|
throw new SakupaError("validation_failed", "Recovery archive metadata exceeds product limits");
|
|
2058
2247
|
}
|
|
2059
|
-
const outputDir = safeOutputPath(input.projectDir, input.outputDir
|
|
2248
|
+
const outputDir = safeOutputPath(input.projectDir, input.outputDir);
|
|
2060
2249
|
const maxArchiveBytes = input.expectedBytes + input.expectedFiles * 4096 + 65536;
|
|
2061
2250
|
if (input.archive.byteLength > maxArchiveBytes) {
|
|
2062
2251
|
throw new SakupaError("validation_failed", "Recovery archive is larger than its site metadata");
|
|
@@ -2097,13 +2286,13 @@ async function extractRecoveryArchive(input) {
|
|
|
2097
2286
|
`Recovery output already exists with different content: ${outputDir}. Move it aside or choose another outputDir.`
|
|
2098
2287
|
);
|
|
2099
2288
|
}
|
|
2100
|
-
const tempDir = await mkdtemp(
|
|
2289
|
+
const tempDir = await mkdtemp(join4(resolve3(input.projectDir), ".sakupa-restore-"));
|
|
2101
2290
|
try {
|
|
2102
2291
|
let writtenBytes = 0;
|
|
2103
2292
|
const entries = Object.entries(files).sort(([a], [b]) => a.localeCompare(b));
|
|
2104
2293
|
for (const [rawName, data] of entries) {
|
|
2105
2294
|
const name = safeEntryName(rawName);
|
|
2106
|
-
const destination =
|
|
2295
|
+
const destination = join4(tempDir, ...name.split("/"));
|
|
2107
2296
|
await mkdir(dirname2(destination), { recursive: true });
|
|
2108
2297
|
await writeFile(destination, data, { flag: "wx" });
|
|
2109
2298
|
writtenBytes += data.byteLength;
|
|
@@ -2129,19 +2318,19 @@ async function extractRecoveryArchive(input) {
|
|
|
2129
2318
|
}
|
|
2130
2319
|
|
|
2131
2320
|
// src/creation-registry.ts
|
|
2132
|
-
import { existsSync as
|
|
2321
|
+
import { existsSync as existsSync4, mkdirSync as mkdirSync3, readFileSync as readFileSync3, writeFileSync as writeFileSync3 } from "node:fs";
|
|
2133
2322
|
import { homedir as homedir2 } from "node:os";
|
|
2134
|
-
import { dirname as dirname3, join as
|
|
2323
|
+
import { dirname as dirname3, join as join5 } from "node:path";
|
|
2135
2324
|
var RECENT_WINDOW_MS = FREE_SITE_TTL_HOURS * 60 * 60 * 1e3;
|
|
2136
2325
|
function creationRegistryPath() {
|
|
2137
2326
|
const base = process.env["SAKUPA_STATE_DIR"] ?? homedir2();
|
|
2138
|
-
return
|
|
2327
|
+
return join5(base, ".sakupa", "created-sites.json");
|
|
2139
2328
|
}
|
|
2140
2329
|
function readAll() {
|
|
2141
2330
|
const path = creationRegistryPath();
|
|
2142
|
-
if (!
|
|
2331
|
+
if (!existsSync4(path)) return [];
|
|
2143
2332
|
try {
|
|
2144
|
-
const parsed = JSON.parse(
|
|
2333
|
+
const parsed = JSON.parse(readFileSync3(path, "utf-8"));
|
|
2145
2334
|
if (!Array.isArray(parsed)) return [];
|
|
2146
2335
|
return parsed.filter(
|
|
2147
2336
|
(e) => typeof e === "object" && e !== null && typeof e.siteId === "string" && typeof e.createdAt === "string"
|
|
@@ -2152,8 +2341,8 @@ function readAll() {
|
|
|
2152
2341
|
}
|
|
2153
2342
|
function writeAll(records) {
|
|
2154
2343
|
const path = creationRegistryPath();
|
|
2155
|
-
|
|
2156
|
-
|
|
2344
|
+
mkdirSync3(dirname3(path), { recursive: true });
|
|
2345
|
+
writeFileSync3(path, `${JSON.stringify(records, null, 2)}
|
|
2157
2346
|
`, "utf-8");
|
|
2158
2347
|
}
|
|
2159
2348
|
function listRecentCreations(nowMs, apiBaseUrl) {
|
|
@@ -2328,12 +2517,12 @@ ${JSON.stringify(obj, null, 2)}`;
|
|
|
2328
2517
|
nextActions: []
|
|
2329
2518
|
});
|
|
2330
2519
|
}
|
|
2331
|
-
var planEnum =
|
|
2332
|
-
var severityEnum =
|
|
2520
|
+
var planEnum = z2.enum(["water", "personal", "share", "business"]);
|
|
2521
|
+
var severityEnum = z2.enum(["low", "medium", "high", "critical"]);
|
|
2333
2522
|
function planCatalog() {
|
|
2334
2523
|
return TIER_ORDER.map((p) => `${p} JPY ${tierPriceJpy(p)}/month`).join(", ");
|
|
2335
2524
|
}
|
|
2336
|
-
var ticketCategoryEnum =
|
|
2525
|
+
var ticketCategoryEnum = z2.enum([
|
|
2337
2526
|
"billing",
|
|
2338
2527
|
"payment",
|
|
2339
2528
|
"refund_review",
|
|
@@ -2381,7 +2570,7 @@ function ensureUploadSizeWithinLimits(manifest, isFirstFreeDeploy) {
|
|
|
2381
2570
|
async function buildHashedManifest(files, outputAbs) {
|
|
2382
2571
|
const manifest = [];
|
|
2383
2572
|
for (const file of files) {
|
|
2384
|
-
const bytes = new Uint8Array(await fs2.readFile(
|
|
2573
|
+
const bytes = new Uint8Array(await fs2.readFile(join6(outputAbs, file.path)));
|
|
2385
2574
|
manifest.push({ path: file.path, size: file.size, contentHash: await sha256Hex(bytes) });
|
|
2386
2575
|
}
|
|
2387
2576
|
return manifest;
|
|
@@ -2400,7 +2589,7 @@ async function uploadAll(ctx, targets, files, outputAbs) {
|
|
|
2400
2589
|
`No local file matches upload target "${target.path}"; aborting upload.`
|
|
2401
2590
|
);
|
|
2402
2591
|
}
|
|
2403
|
-
const bytes = new Uint8Array(await fs2.readFile(
|
|
2592
|
+
const bytes = new Uint8Array(await fs2.readFile(join6(outputAbs, match.path)));
|
|
2404
2593
|
if (bytes.byteLength !== match.size) {
|
|
2405
2594
|
throw new SakupaError(
|
|
2406
2595
|
"validation_failed",
|
|
@@ -2443,21 +2632,6 @@ ${block}`, checklist: toDnsChecklist(diag.checks) };
|
|
|
2443
2632
|
};
|
|
2444
2633
|
}
|
|
2445
2634
|
}
|
|
2446
|
-
function projectRootAbove(projectDir) {
|
|
2447
|
-
if (existsSync3(join5(projectDir, "package.json"))) return null;
|
|
2448
|
-
const packageRoot = findAncestor(projectDir, (dir) => existsSync3(join5(dir, "package.json")), 4);
|
|
2449
|
-
if (packageRoot) {
|
|
2450
|
-
return {
|
|
2451
|
-
projectRoot: packageRoot,
|
|
2452
|
-
outputDir: relative2(packageRoot, projectDir).split(sep3).join("/")
|
|
2453
|
-
};
|
|
2454
|
-
}
|
|
2455
|
-
return null;
|
|
2456
|
-
}
|
|
2457
|
-
function findNeighborBinding(projectDir) {
|
|
2458
|
-
const bound = (dir) => loadSiteFile(dir).kind !== "absent";
|
|
2459
|
-
return findAncestor(projectDir, bound, 3);
|
|
2460
|
-
}
|
|
2461
2635
|
function freeSiteCreationBarrier(apiBaseUrl) {
|
|
2462
2636
|
const recent = listRecentCreations(Date.now(), apiBaseUrl);
|
|
2463
2637
|
if (recent.length < FREE_ACTIVE_SITES_PER_IP) return null;
|
|
@@ -2468,7 +2642,7 @@ function freeSiteCreationBarrier(apiBaseUrl) {
|
|
|
2468
2642
|
|
|
2469
2643
|
` + recent.map((r) => `- ${r.url} (project: ${r.projectDir}, created: ${r.createdAt})`).join("\n") + `
|
|
2470
2644
|
|
|
2471
|
-
How a slot frees up: (1) delete one of the sites above \u2014 run delete with that site's
|
|
2645
|
+
How a slot frees up: (1) delete one of the sites above \u2014 run delete with that site opened as the AI tool's current project; its slot frees immediately; (2) every record expires on its own 24 hours after creation; (3) a site that upgrades to a paid plan stops counting the next time any tool sees it. Deleting a project's .sakupa folder does NOT free a slot: this registry lives in the home directory and the server still counts the live site.
|
|
2472
2646
|
|
|
2473
2647
|
If this list is stale (sites deleted or subscribed from another machine), remove the local registry file at ${registryPath} and retry \u2014 that only skips this local precheck; the server still enforces the same per-IP limit and is the final authority.`,
|
|
2474
2648
|
{ recentCreations: recent, limit: FREE_ACTIVE_SITES_PER_IP, registryPath },
|
|
@@ -2484,13 +2658,12 @@ function registerTools(server, baseCtx) {
|
|
|
2484
2658
|
outputSchema: STRUCTURED_TOOL_OUTPUT_SCHEMA,
|
|
2485
2659
|
annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: false },
|
|
2486
2660
|
inputSchema: {
|
|
2487
|
-
|
|
2488
|
-
outputDir: z3.string().optional().describe("Output directory relative to the project root (overrides detection).")
|
|
2661
|
+
outputDir: z2.string().optional().describe("Output directory relative to the project root (overrides detection).")
|
|
2489
2662
|
}
|
|
2490
2663
|
},
|
|
2491
2664
|
async (args) => {
|
|
2492
2665
|
try {
|
|
2493
|
-
const ctx = withProjectDir(baseCtx
|
|
2666
|
+
const ctx = withProjectDir(baseCtx);
|
|
2494
2667
|
const analysis = await analyzeProject(ctx.projectDir, {
|
|
2495
2668
|
...args.outputDir !== void 0 ? { outputDir: args.outputDir } : {}
|
|
2496
2669
|
});
|
|
@@ -2508,35 +2681,54 @@ Next action: ${analysis.suggestedNextAction}`,
|
|
|
2508
2681
|
server.registerTool(
|
|
2509
2682
|
"deploy",
|
|
2510
2683
|
{
|
|
2511
|
-
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://${previewHostPattern}) and stores the management credential in .sakupa/site.json. Later runs update the existing site (free sites also refresh their validity; subscribed sites are permanent). Runs analyze first and refuses to upload source projects, secrets, .env files, archives, media or server code.
|
|
2684
|
+
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://${previewHostPattern}) and stores the management credential in .sakupa/site.json. Later runs update the existing site (free sites also refresh their validity; subscribed sites are permanent). Runs analyze first and refuses to upload source projects, secrets, .env files, archives, media or server code. The MCP process is locked to the current directory initialized by \`npx -y @sakupa/mcp@latest init\`; no tool argument can change that root. outputDir is a separate REQUIRED relative path supplied from the current project inspection. Never uploads anything when analysis says the project is not deployable.`,
|
|
2512
2685
|
outputSchema: STRUCTURED_TOOL_OUTPUT_SCHEMA,
|
|
2513
2686
|
annotations: { readOnlyHint: false, destructiveHint: true, openWorldHint: true },
|
|
2514
2687
|
inputSchema: {
|
|
2515
|
-
|
|
2516
|
-
|
|
2517
|
-
'REQUIRED: exact publish directory relative to projectDir, supplied by the AI after inspecting this project (for example ".", "dist", "html", or any custom build directory). NEVER put this path in projectDir; .sakupa belongs at projectDir.'
|
|
2688
|
+
outputDir: z2.string().min(1).describe(
|
|
2689
|
+
'REQUIRED: exact publish directory relative to the initialized project root, supplied by the AI after inspecting this project (for example ".", "dist", "html", or any custom build directory). Sakupa applies it only inside the cwd-locked project.'
|
|
2518
2690
|
),
|
|
2519
|
-
|
|
2691
|
+
outputDirChangeConfirmed: z2.boolean().optional().describe(
|
|
2692
|
+
"Required only when changing the previously successful publish directory. Confirm only after showing the old and new directories to the user."
|
|
2693
|
+
),
|
|
2694
|
+
spaFallback: z2.boolean().optional().describe(
|
|
2520
2695
|
"Override automatic SPA-fallback detection (single index.html + JS auto-enables rewriting unknown paths to index.html; multiple HTML pages auto-disable it). Pass only to force the behavior against the detected structure."
|
|
2521
2696
|
),
|
|
2522
|
-
publicConfirmed:
|
|
2697
|
+
publicConfirmed: z2.boolean().optional().describe(
|
|
2523
2698
|
"Required only for the first deployment: user explicitly confirmed creation of a public 24-hour URL."
|
|
2524
2699
|
),
|
|
2525
|
-
subprojectConfirmed:
|
|
2526
|
-
"
|
|
2700
|
+
subprojectConfirmed: z2.boolean().optional().describe(
|
|
2701
|
+
"Deprecated compatibility field. Project independence is established only by `sakupa-mcp init`, never inferred from package.json or folder names."
|
|
2527
2702
|
),
|
|
2528
|
-
lang:
|
|
2703
|
+
lang: z2.string().optional().describe("Site language override (en | ja | zh-CN); defaults to the html lang.")
|
|
2529
2704
|
}
|
|
2530
2705
|
},
|
|
2531
2706
|
async (args) => {
|
|
2532
2707
|
try {
|
|
2533
|
-
const ctx = withProjectDir(baseCtx
|
|
2708
|
+
const ctx = withProjectDir(baseCtx);
|
|
2534
2709
|
const analysis = await analyzeProject(ctx.projectDir, { outputDir: args.outputDir });
|
|
2535
2710
|
if (!analysis.deployable || !analysis.files) {
|
|
2536
2711
|
return notDeployableResult(analysis);
|
|
2537
2712
|
}
|
|
2713
|
+
const effectiveOutputDir = analysis.recommendedOutputDir ?? ".";
|
|
2714
|
+
const recordedOutputDir = ctx.projectMarker?.outputDir;
|
|
2715
|
+
if (recordedOutputDir !== void 0 && resolve4(ctx.projectDir, recordedOutputDir) !== resolve4(ctx.projectDir, effectiveOutputDir) && args.outputDirChangeConfirmed !== true) {
|
|
2716
|
+
return structuredToolResult({
|
|
2717
|
+
schemaVersion: 1,
|
|
2718
|
+
outcome: "waiting_user",
|
|
2719
|
+
resultCode: "publish_directory_change_confirmation_required",
|
|
2720
|
+
summary: `This initialized project last published from "${recordedOutputDir}", but this request selected "${effectiveOutputDir}". Nothing was uploaded and the site was not changed. Show both paths to the user; only after explicit confirmation call deploy again with outputDirChangeConfirmed: true.`,
|
|
2721
|
+
data: {
|
|
2722
|
+
projectDir: ctx.projectDir,
|
|
2723
|
+
previousOutputDir: recordedOutputDir,
|
|
2724
|
+
requestedOutputDir: effectiveOutputDir,
|
|
2725
|
+
confirmationField: "outputDirChangeConfirmed"
|
|
2726
|
+
},
|
|
2727
|
+
nextActions: [{ tool: "deploy", allowed: true, reasonCode: "explicit_confirmation" }]
|
|
2728
|
+
});
|
|
2729
|
+
}
|
|
2538
2730
|
const files = analysis.files;
|
|
2539
|
-
const outputAbs = resolve4(ctx.projectDir,
|
|
2731
|
+
const outputAbs = resolve4(ctx.projectDir, effectiveOutputDir);
|
|
2540
2732
|
const manifest = await buildHashedManifest(files, outputAbs);
|
|
2541
2733
|
const siteFileState = loadSiteFile(ctx.projectDir);
|
|
2542
2734
|
if (siteFileState.kind === "corrupted") {
|
|
@@ -2551,46 +2743,31 @@ Next action: ${analysis.suggestedNextAction}`,
|
|
|
2551
2743
|
}
|
|
2552
2744
|
let existing = siteFileState.kind === "ok" ? siteFileState.file : null;
|
|
2553
2745
|
let credentialRelocatedFrom = null;
|
|
2554
|
-
if (!existing &&
|
|
2746
|
+
if (!existing && effectiveOutputDir !== ".") {
|
|
2747
|
+
const outputProjectMarker = loadProjectMarker(outputAbs);
|
|
2748
|
+
if (outputProjectMarker.kind !== "absent") {
|
|
2749
|
+
return text(
|
|
2750
|
+
"publish_directory_is_independent_project",
|
|
2751
|
+
outputProjectMarker.kind === "corrupted" ? `The selected publish directory ${outputAbs} contains a damaged Sakupa project marker: ${outputProjectMarker.problem}. Nothing was deployed.` : `The selected publish directory ${outputAbs} is itself an explicitly initialized Sakupa project. Refusing to move or reuse its credential from ${ctx.projectDir}. Run deploy from that independent project instead, or choose a publish directory that is not another Sakupa project.`,
|
|
2752
|
+
{ projectRoot: ctx.projectDir, outputDir: effectiveOutputDir },
|
|
2753
|
+
"blocked"
|
|
2754
|
+
);
|
|
2755
|
+
}
|
|
2555
2756
|
const outputSiteState = loadSiteFile(outputAbs);
|
|
2556
2757
|
if (outputSiteState.kind === "corrupted") {
|
|
2557
2758
|
return text(
|
|
2558
2759
|
"output_site_file_corrupted",
|
|
2559
|
-
`A misplaced .sakupa/site.json exists in output directory ${outputAbs}, but it is damaged: ${outputSiteState.problem}. Repair that file before retrying with
|
|
2760
|
+
`A misplaced .sakupa/site.json exists in output directory ${outputAbs}, but it is damaged: ${outputSiteState.problem}. Repair that file before retrying with the MCP still opened at ${ctx.projectDir}. Nothing was deployed and no site was created.`,
|
|
2560
2761
|
{ projectRoot: ctx.projectDir, outputDir: analysis.recommendedOutputDir },
|
|
2561
2762
|
"blocked"
|
|
2562
2763
|
);
|
|
2563
2764
|
}
|
|
2564
2765
|
if (outputSiteState.kind === "ok") {
|
|
2565
|
-
writeSiteFile(ctx.projectDir, outputSiteState.file);
|
|
2566
|
-
deleteSiteFile(outputAbs);
|
|
2567
2766
|
existing = outputSiteState.file;
|
|
2568
2767
|
credentialRelocatedFrom = outputAbs;
|
|
2569
2768
|
}
|
|
2570
2769
|
}
|
|
2571
2770
|
if (!existing) {
|
|
2572
|
-
const rootHint = args.subprojectConfirmed === true ? null : projectRootAbove(ctx.projectDir);
|
|
2573
|
-
if (rootHint) {
|
|
2574
|
-
return text(
|
|
2575
|
-
"not_project_root",
|
|
2576
|
-
`${ctx.projectDir} is a SUBFOLDER of a package.json project, and .sakupa must live at the project ROOT. Re-run deploy with projectDir: ${rootHint.projectRoot} and outputDir: ${rootHint.outputDir}. Only if the user explicitly says this subfolder is an INDEPENDENT site (e.g. a docs/ site inside a repo), re-run with subprojectConfirmed: true. Nothing was deployed and no site was created.`,
|
|
2577
|
-
{
|
|
2578
|
-
projectRoot: rootHint.projectRoot,
|
|
2579
|
-
outputDir: rootHint.outputDir,
|
|
2580
|
-
confirmationField: "subprojectConfirmed"
|
|
2581
|
-
},
|
|
2582
|
-
"blocked"
|
|
2583
|
-
);
|
|
2584
|
-
}
|
|
2585
|
-
const neighbor = findNeighborBinding(ctx.projectDir);
|
|
2586
|
-
if (neighbor) {
|
|
2587
|
-
return text(
|
|
2588
|
-
"neighbor_binding_found",
|
|
2589
|
-
`No .sakupa binding in ${ctx.projectDir}, but one EXISTS at ${neighbor} \u2014 this looks like the same project addressed at a different directory level. To update that existing site, re-run deploy with projectDir: ${neighbor}. Only if the user explicitly wants a SEPARATE new site, move this deploy to a directory outside that project. Nothing was deployed and no site was created.`,
|
|
2590
|
-
{ neighborProjectDir: neighbor },
|
|
2591
|
-
"blocked"
|
|
2592
|
-
);
|
|
2593
|
-
}
|
|
2594
2771
|
const barrier = freeSiteCreationBarrier(ctx.apiBaseUrl);
|
|
2595
2772
|
if (barrier) return barrier;
|
|
2596
2773
|
if (args.publicConfirmed !== true) {
|
|
@@ -2624,6 +2801,7 @@ Next action: ${analysis.suggestedNextAction}`,
|
|
|
2624
2801
|
createdAt,
|
|
2625
2802
|
apiBaseUrl: ctx.apiBaseUrl
|
|
2626
2803
|
});
|
|
2804
|
+
updateProjectOutputDir(ctx.projectDir, effectiveOutputDir);
|
|
2627
2805
|
recordCreation({
|
|
2628
2806
|
siteId: created.siteId,
|
|
2629
2807
|
projectDir: ctx.projectDir,
|
|
@@ -2698,6 +2876,8 @@ ${JSON.stringify(finalized2.warnings, null, 2)}` : ""),
|
|
|
2698
2876
|
}
|
|
2699
2877
|
const { uploaded, finalized } = update;
|
|
2700
2878
|
writeSiteFile(ctx.projectDir, { ...existing, url: finalized.url });
|
|
2879
|
+
if (credentialRelocatedFrom !== null) deleteSiteFile(credentialRelocatedFrom);
|
|
2880
|
+
updateProjectOutputDir(ctx.projectDir, effectiveOutputDir);
|
|
2701
2881
|
noteSiteMode(existing.siteId, finalized.mode);
|
|
2702
2882
|
return text(
|
|
2703
2883
|
"site_updated",
|
|
@@ -2736,11 +2916,11 @@ ${JSON.stringify(finalized.warnings, null, 2)}` : ""),
|
|
|
2736
2916
|
description: "Refresh the validity of the free temporary site WITHOUT uploading content. Uses the local credential in .sakupa/site.json. Subscribed sites are permanent and need no refresh.",
|
|
2737
2917
|
outputSchema: STRUCTURED_TOOL_OUTPUT_SCHEMA,
|
|
2738
2918
|
annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: true },
|
|
2739
|
-
inputSchema: {
|
|
2919
|
+
inputSchema: {}
|
|
2740
2920
|
},
|
|
2741
|
-
async (
|
|
2921
|
+
async () => {
|
|
2742
2922
|
try {
|
|
2743
|
-
const ctx = withProjectDir(baseCtx
|
|
2923
|
+
const ctx = withProjectDir(baseCtx);
|
|
2744
2924
|
const site = requireSiteFile(ctx);
|
|
2745
2925
|
const res = await ctx.client.refreshSite(site.siteId, site.credential);
|
|
2746
2926
|
return text(
|
|
@@ -2760,11 +2940,11 @@ NO content was uploaded or changed by this call \u2014 to publish new or edited
|
|
|
2760
2940
|
description: "Show the current status of this project's Sakupa site: URL, mode (free/paid), expiry, custom domains, size, last deployment and warnings. For a paid site this tool also automatically returns the complete authoritative billing snapshot; users never need to know or name a separate billing tool to get accurate subscription information.",
|
|
2761
2941
|
outputSchema: STRUCTURED_TOOL_OUTPUT_SCHEMA,
|
|
2762
2942
|
annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: true },
|
|
2763
|
-
inputSchema: {
|
|
2943
|
+
inputSchema: {}
|
|
2764
2944
|
},
|
|
2765
|
-
async (
|
|
2945
|
+
async () => {
|
|
2766
2946
|
try {
|
|
2767
|
-
const ctx = withProjectDir(baseCtx
|
|
2947
|
+
const ctx = withProjectDir(baseCtx);
|
|
2768
2948
|
const site = requireSiteFile(ctx);
|
|
2769
2949
|
const res = await ctx.client.getSiteStatus(site.siteId, site.credential);
|
|
2770
2950
|
noteSiteMode(res.siteId, res.mode);
|
|
@@ -2792,7 +2972,6 @@ NO content was uploaded or changed by this call \u2014 to publish new or edited
|
|
|
2792
2972
|
outputSchema: STRUCTURED_TOOL_OUTPUT_SCHEMA,
|
|
2793
2973
|
annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: true },
|
|
2794
2974
|
inputSchema: {
|
|
2795
|
-
projectDir: projectDirInput,
|
|
2796
2975
|
plan: planEnum.describe(
|
|
2797
2976
|
"Monthly plan: water (very light personal pages), personal (personal brand / small shop), share (small-business site), business (steadier traffic, more headroom)."
|
|
2798
2977
|
)
|
|
@@ -2800,13 +2979,13 @@ NO content was uploaded or changed by this call \u2014 to publish new or edited
|
|
|
2800
2979
|
},
|
|
2801
2980
|
async (args) => {
|
|
2802
2981
|
try {
|
|
2803
|
-
const ctx = withProjectDir(baseCtx
|
|
2982
|
+
const ctx = withProjectDir(baseCtx);
|
|
2804
2983
|
const site = requireSiteFile(ctx);
|
|
2805
2984
|
const res = await ctx.client.createPlanCheckout(
|
|
2806
2985
|
{
|
|
2807
2986
|
siteId: site.siteId,
|
|
2808
2987
|
plan: args.plan,
|
|
2809
|
-
idempotencyKey:
|
|
2988
|
+
idempotencyKey: randomUUID2()
|
|
2810
2989
|
},
|
|
2811
2990
|
site.credential
|
|
2812
2991
|
);
|
|
@@ -2839,17 +3018,16 @@ Once payment confirms, the site becomes permanent on its current URL. Binding a
|
|
|
2839
3018
|
outputSchema: STRUCTURED_TOOL_OUTPUT_SCHEMA,
|
|
2840
3019
|
annotations: { readOnlyHint: false, destructiveHint: true, openWorldHint: true },
|
|
2841
3020
|
inputSchema: {
|
|
2842
|
-
|
|
2843
|
-
|
|
2844
|
-
|
|
2845
|
-
verificationId: z3.string().optional().describe(
|
|
3021
|
+
action: z2.enum(["start", "status"]),
|
|
3022
|
+
hostname: z2.string().optional().describe("Required for start."),
|
|
3023
|
+
verificationId: z2.string().optional().describe(
|
|
2846
3024
|
"Optional for status: when omitted, the server finds this site's latest binding verification \u2014 a NEW session can resume without it."
|
|
2847
3025
|
)
|
|
2848
3026
|
}
|
|
2849
3027
|
},
|
|
2850
3028
|
async (args) => {
|
|
2851
3029
|
try {
|
|
2852
|
-
const ctx = withProjectDir(baseCtx
|
|
3030
|
+
const ctx = withProjectDir(baseCtx);
|
|
2853
3031
|
const site = requireSiteFile(ctx);
|
|
2854
3032
|
if (args.action === "status") {
|
|
2855
3033
|
const res2 = args.verificationId ? await ctx.client.checkVerification(args.verificationId, site.credential) : await ctx.client.checkVerification("latest", site.credential, site.siteId);
|
|
@@ -2954,11 +3132,11 @@ When the user says the TXT is set, run bind "status". It verifies ownership and
|
|
|
2954
3132
|
description: "Return the sole authoritative source for this site's hosting subscription: current plan, next renewal plan or cancellation, effective time, payment state, current paid entitlement, reconciled usage, estimated usage tier, bound custom domains and risks. Owner-only (uses the credential in .sakupa/site.json).",
|
|
2955
3133
|
outputSchema: STRUCTURED_TOOL_OUTPUT_SCHEMA,
|
|
2956
3134
|
annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: true },
|
|
2957
|
-
inputSchema: {
|
|
3135
|
+
inputSchema: {}
|
|
2958
3136
|
},
|
|
2959
|
-
async (
|
|
3137
|
+
async () => {
|
|
2960
3138
|
try {
|
|
2961
|
-
const ctx = withProjectDir(baseCtx
|
|
3139
|
+
const ctx = withProjectDir(baseCtx);
|
|
2962
3140
|
const site = requireSiteFile(ctx);
|
|
2963
3141
|
const res = await ctx.client.getBillingStatus(site.siteId, site.credential);
|
|
2964
3142
|
noteSiteMode(res.siteId, res.mode);
|
|
@@ -2996,14 +3174,13 @@ Full status:`, res);
|
|
|
2996
3174
|
outputSchema: STRUCTURED_TOOL_OUTPUT_SCHEMA,
|
|
2997
3175
|
annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: true },
|
|
2998
3176
|
inputSchema: {
|
|
2999
|
-
|
|
3000
|
-
scope: z3.enum(["site", "public_recovery"])
|
|
3177
|
+
scope: z2.enum(["site", "public_recovery"])
|
|
3001
3178
|
}
|
|
3002
3179
|
},
|
|
3003
3180
|
async (args) => {
|
|
3004
3181
|
try {
|
|
3005
|
-
const ctx = withProjectDir(baseCtx, args.projectDir);
|
|
3006
3182
|
if (args.scope === "site") {
|
|
3183
|
+
const ctx = withProjectDir(baseCtx);
|
|
3007
3184
|
const site = requireSiteFile(ctx);
|
|
3008
3185
|
const res2 = await ctx.client.createBillingPortal(site.siteId, site.credential);
|
|
3009
3186
|
return structuredToolResult({
|
|
@@ -3021,7 +3198,7 @@ Full status:`, res);
|
|
|
3021
3198
|
nextActions: [{ tool: "billing", allowed: true }]
|
|
3022
3199
|
});
|
|
3023
3200
|
}
|
|
3024
|
-
const res = await
|
|
3201
|
+
const res = await baseCtx.client.getPublicBillingPortal();
|
|
3025
3202
|
return structuredToolResult({
|
|
3026
3203
|
schemaVersion: 1,
|
|
3027
3204
|
outcome: "waiting_user",
|
|
@@ -3049,21 +3226,28 @@ Full status:`, res);
|
|
|
3049
3226
|
server.registerTool(
|
|
3050
3227
|
"recover",
|
|
3051
3228
|
{
|
|
3052
|
-
description: "Recover management control of a subscribed site WITH A BOUND CUSTOM DOMAIN after losing the local project, by proving DNS control of the apex domain. Sites without a bound domain are identified solely by their local credential and cannot be recovered. By default, completing recovery REVOKES all previous local credentials. Recovery is resumable: start stores local pending state; complete installs and writes the new .sakupa/site.json credential BEFORE requesting content; download uses that credential to reissue an archive and safely extract it into
|
|
3229
|
+
description: "Recover management control of a subscribed site WITH A BOUND CUSTOM DOMAIN after losing the local project, by proving DNS control of the apex domain. Sites without a bound domain are identified solely by their local credential and cannot be recovered. By default, completing recovery REVOKES all previous local credentials. Recovery is resumable: start stores local pending state; complete installs and writes the new .sakupa/site.json credential BEFORE requesting content; download uses that credential to reissue an archive and safely extract it into the explicitly selected outputDir without repeating DNS.",
|
|
3053
3230
|
outputSchema: STRUCTURED_TOOL_OUTPUT_SCHEMA,
|
|
3054
3231
|
annotations: { readOnlyHint: false, destructiveHint: true, openWorldHint: true },
|
|
3055
3232
|
inputSchema: {
|
|
3056
|
-
|
|
3057
|
-
|
|
3058
|
-
|
|
3059
|
-
|
|
3060
|
-
|
|
3061
|
-
|
|
3233
|
+
action: z2.enum(["start", "status", "complete", "download"]),
|
|
3234
|
+
hostname: z2.string().optional().describe("Required for start."),
|
|
3235
|
+
verificationId: z2.string().optional().describe("For status or complete; inferred from local recovery state when omitted."),
|
|
3236
|
+
outputDir: z2.string().optional().describe(
|
|
3237
|
+
"REQUIRED for complete/download: exact extraction directory relative to the initialized project root. Inspect the current project; Sakupa never guesses a name."
|
|
3238
|
+
),
|
|
3239
|
+
preserveExistingCredentials: z2.boolean().optional().describe("Explicitly keep old local credentials working (default: revoke them all).")
|
|
3062
3240
|
}
|
|
3063
3241
|
},
|
|
3064
3242
|
async (args) => {
|
|
3065
3243
|
try {
|
|
3066
|
-
const ctx = withProjectDir(baseCtx
|
|
3244
|
+
const ctx = withProjectDir(baseCtx);
|
|
3245
|
+
if ((args.action === "complete" || args.action === "download") && args.outputDir === void 0) {
|
|
3246
|
+
throw new LocalGuidanceError(
|
|
3247
|
+
"invalid_request",
|
|
3248
|
+
"recover requires outputDir for complete/download. Inspect the current project and pass the exact extraction directory relative to the initialized Sakupa root; the server never defaults to html, dist, build, or any other name."
|
|
3249
|
+
);
|
|
3250
|
+
}
|
|
3067
3251
|
const localSite = loadSiteFile(ctx.projectDir);
|
|
3068
3252
|
const localCredentialIsActive = async () => {
|
|
3069
3253
|
if (localSite.kind !== "ok") return false;
|
|
@@ -3076,12 +3260,18 @@ Full status:`, res);
|
|
|
3076
3260
|
}
|
|
3077
3261
|
};
|
|
3078
3262
|
const download = async () => {
|
|
3263
|
+
if (args.outputDir === void 0) {
|
|
3264
|
+
throw new LocalGuidanceError(
|
|
3265
|
+
"invalid_request",
|
|
3266
|
+
"recover download requires an explicit outputDir."
|
|
3267
|
+
);
|
|
3268
|
+
}
|
|
3079
3269
|
const site = requireSiteFile(ctx);
|
|
3080
3270
|
const archive = await ctx.client.getSiteArchive(site.siteId, site.credential);
|
|
3081
3271
|
const bytes = await ctx.client.downloadArchive(archive.archiveUrl);
|
|
3082
3272
|
const extracted = await extractRecoveryArchive({
|
|
3083
3273
|
projectDir: ctx.projectDir,
|
|
3084
|
-
|
|
3274
|
+
outputDir: args.outputDir,
|
|
3085
3275
|
archive: bytes,
|
|
3086
3276
|
expectedBytes: archive.totalBytes,
|
|
3087
3277
|
expectedFiles: archive.fileCount
|
|
@@ -3124,9 +3314,8 @@ No DNS verification was started or repeated.`,
|
|
|
3124
3314
|
{
|
|
3125
3315
|
tool: "recover",
|
|
3126
3316
|
arguments: {
|
|
3127
|
-
projectDir: ctx.projectDir,
|
|
3128
3317
|
action: "download",
|
|
3129
|
-
outputDir: args.outputDir
|
|
3318
|
+
...args.outputDir !== void 0 ? { outputDir: args.outputDir } : {}
|
|
3130
3319
|
},
|
|
3131
3320
|
allowed: true
|
|
3132
3321
|
}
|
|
@@ -3151,7 +3340,6 @@ No DNS verification was started or repeated.`,
|
|
|
3151
3340
|
{
|
|
3152
3341
|
tool: "recover",
|
|
3153
3342
|
arguments: {
|
|
3154
|
-
projectDir: ctx.projectDir,
|
|
3155
3343
|
action: "status",
|
|
3156
3344
|
verificationId: pending2.verificationId
|
|
3157
3345
|
},
|
|
@@ -3224,9 +3412,8 @@ After the DNS record resolves, re-run recover with verificationId: "${res2.verif
|
|
|
3224
3412
|
{
|
|
3225
3413
|
tool: "recover",
|
|
3226
3414
|
arguments: {
|
|
3227
|
-
projectDir: ctx.projectDir,
|
|
3228
3415
|
action: "download",
|
|
3229
|
-
outputDir: args.outputDir
|
|
3416
|
+
...args.outputDir !== void 0 ? { outputDir: args.outputDir } : {}
|
|
3230
3417
|
},
|
|
3231
3418
|
allowed: true
|
|
3232
3419
|
}
|
|
@@ -3259,10 +3446,9 @@ After the DNS record resolves, re-run recover with verificationId: "${res2.verif
|
|
|
3259
3446
|
{
|
|
3260
3447
|
tool: "recover",
|
|
3261
3448
|
arguments: {
|
|
3262
|
-
projectDir: ctx.projectDir,
|
|
3263
3449
|
action: "complete",
|
|
3264
3450
|
verificationId,
|
|
3265
|
-
outputDir: args.outputDir
|
|
3451
|
+
...args.outputDir !== void 0 ? { outputDir: args.outputDir } : {}
|
|
3266
3452
|
},
|
|
3267
3453
|
allowed: res2.readyToComplete,
|
|
3268
3454
|
...res2.readyToComplete ? {} : { reasonCode: res2.status }
|
|
@@ -3336,9 +3522,8 @@ Files: ${extracted.fileCount}; bytes: ${extracted.totalBytes}
|
|
|
3336
3522
|
{
|
|
3337
3523
|
tool: "recover",
|
|
3338
3524
|
arguments: {
|
|
3339
|
-
projectDir: ctx.projectDir,
|
|
3340
3525
|
action: "download",
|
|
3341
|
-
outputDir: args.outputDir
|
|
3526
|
+
...args.outputDir !== void 0 ? { outputDir: args.outputDir } : {}
|
|
3342
3527
|
},
|
|
3343
3528
|
allowed: true
|
|
3344
3529
|
}
|
|
@@ -3357,16 +3542,15 @@ Files: ${extracted.fileCount}; bytes: ${extracted.totalBytes}
|
|
|
3357
3542
|
outputSchema: STRUCTURED_TOOL_OUTPUT_SCHEMA,
|
|
3358
3543
|
annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: true },
|
|
3359
3544
|
inputSchema: {
|
|
3360
|
-
projectDir: projectDirInput,
|
|
3361
3545
|
category: ticketCategoryEnum,
|
|
3362
|
-
subject:
|
|
3363
|
-
description:
|
|
3364
|
-
contactEmail:
|
|
3546
|
+
subject: z2.string().describe("Short subject line."),
|
|
3547
|
+
description: z2.string().describe("Problem description (no secrets, no card data)."),
|
|
3548
|
+
contactEmail: z2.string().optional().describe("Optional contact email for follow-up.")
|
|
3365
3549
|
}
|
|
3366
3550
|
},
|
|
3367
3551
|
async (args) => {
|
|
3368
3552
|
try {
|
|
3369
|
-
const ctx = withProjectDir(baseCtx
|
|
3553
|
+
const ctx = withProjectDir(baseCtx);
|
|
3370
3554
|
const site = requireSiteFile(ctx);
|
|
3371
3555
|
const res = await ctx.client.createTicket(site.credential, {
|
|
3372
3556
|
siteId: site.siteId,
|
|
@@ -3392,26 +3576,25 @@ Files: ${extracted.fileCount}; bytes: ${extracted.totalBytes}
|
|
|
3392
3576
|
outputSchema: STRUCTURED_TOOL_OUTPUT_SCHEMA,
|
|
3393
3577
|
annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: true },
|
|
3394
3578
|
inputSchema: {
|
|
3395
|
-
|
|
3396
|
-
|
|
3397
|
-
|
|
3398
|
-
|
|
3399
|
-
|
|
3400
|
-
deploymentId: z3.string().optional(),
|
|
3579
|
+
toolName: z2.string().describe('The Sakupa tool that failed, e.g. "deploy".'),
|
|
3580
|
+
errorCode: z2.string().optional(),
|
|
3581
|
+
errorMessage: z2.string().optional().describe("Sanitized error message (no secrets)."),
|
|
3582
|
+
requestId: z2.string().optional(),
|
|
3583
|
+
deploymentId: z2.string().optional(),
|
|
3401
3584
|
severity: severityEnum.optional(),
|
|
3402
|
-
description:
|
|
3403
|
-
agentContext:
|
|
3585
|
+
description: z2.string().optional().describe("What happened, in the user's words (no secrets)."),
|
|
3586
|
+
agentContext: z2.string().optional().describe(
|
|
3404
3587
|
"YOUR OWN factual account of the session as the AI: which tools you called, what they returned, expected vs actual. Write it yourself from your observations \u2014 never ask the user to compose it, and do not read it back to them; it travels alongside the user's description as a second witness. No secrets, no file contents."
|
|
3405
3588
|
),
|
|
3406
|
-
contactEmail:
|
|
3589
|
+
contactEmail: z2.string().optional().describe(
|
|
3407
3590
|
"OPTIONAL. Before submitting, ask the user ONCE whether they want to leave a contact for follow-up. Omit entirely if they decline \u2014 never require it."
|
|
3408
3591
|
),
|
|
3409
|
-
confirmSubmit:
|
|
3592
|
+
confirmSubmit: z2.boolean().optional().describe("User reviewed the report payload and approved submission.")
|
|
3410
3593
|
}
|
|
3411
3594
|
},
|
|
3412
3595
|
async (args) => {
|
|
3413
3596
|
try {
|
|
3414
|
-
const ctx = withProjectDir(baseCtx
|
|
3597
|
+
const ctx = withProjectDir(baseCtx);
|
|
3415
3598
|
const siteState = loadSiteFile(ctx.projectDir);
|
|
3416
3599
|
const site = siteState.kind === "ok" ? siteState.file : null;
|
|
3417
3600
|
const diagnostics = {
|
|
@@ -3458,22 +3641,22 @@ Summary: ${res.sanitizedSummary}`,
|
|
|
3458
3641
|
}
|
|
3459
3642
|
|
|
3460
3643
|
// src/tools/lifecycle.ts
|
|
3461
|
-
import { randomUUID as
|
|
3462
|
-
import { z as
|
|
3463
|
-
var deleteConfirmation =
|
|
3464
|
-
siteId:
|
|
3465
|
-
expectedSiteUpdatedAt:
|
|
3466
|
-
expectedStatus:
|
|
3467
|
-
expectedMode:
|
|
3468
|
-
expectedServingMode:
|
|
3469
|
-
expectedShortId:
|
|
3470
|
-
expectedSubscriptionStatus:
|
|
3471
|
-
expectedPlan:
|
|
3472
|
-
expectedCancelAtPeriodEnd:
|
|
3473
|
-
expectedCurrentPeriodEnd:
|
|
3474
|
-
expectedLastDeploymentId:
|
|
3475
|
-
expectedBoundHostnames:
|
|
3476
|
-
acknowledge:
|
|
3644
|
+
import { randomUUID as randomUUID3 } from "node:crypto";
|
|
3645
|
+
import { z as z3 } from "zod";
|
|
3646
|
+
var deleteConfirmation = z3.object({
|
|
3647
|
+
siteId: z3.string().min(1),
|
|
3648
|
+
expectedSiteUpdatedAt: z3.string().datetime(),
|
|
3649
|
+
expectedStatus: z3.enum(["active", "expired", "deleted"]),
|
|
3650
|
+
expectedMode: z3.enum(["free", "paid"]),
|
|
3651
|
+
expectedServingMode: z3.enum(["normal", "over_limit_notice", "risk_notice", "stopped"]),
|
|
3652
|
+
expectedShortId: z3.string().optional(),
|
|
3653
|
+
expectedSubscriptionStatus: z3.enum(["incomplete", "active", "past_due", "canceled"]).optional(),
|
|
3654
|
+
expectedPlan: z3.enum(["water", "personal", "share", "business"]).optional(),
|
|
3655
|
+
expectedCancelAtPeriodEnd: z3.boolean().optional(),
|
|
3656
|
+
expectedCurrentPeriodEnd: z3.string().datetime().optional(),
|
|
3657
|
+
expectedLastDeploymentId: z3.string().optional(),
|
|
3658
|
+
expectedBoundHostnames: z3.array(z3.string()),
|
|
3659
|
+
acknowledge: z3.literal("delete_and_cancel_renewal")
|
|
3477
3660
|
});
|
|
3478
3661
|
function registerLifecycleTools(server, baseCtx) {
|
|
3479
3662
|
server.registerTool(
|
|
@@ -3481,9 +3664,8 @@ function registerLifecycleTools(server, baseCtx) {
|
|
|
3481
3664
|
{
|
|
3482
3665
|
description: "Preview or execute deletion of this Sakupa site. Execution requires an exact server-validated confirmation bound to the current site state. Paid sites must first cancel renewal through portal and return to free mode. For a temporary pause, publish a pause notice as index.html with deploy instead of deleting the site.",
|
|
3483
3666
|
inputSchema: {
|
|
3484
|
-
|
|
3485
|
-
|
|
3486
|
-
operationId: z4.string().min(1).optional(),
|
|
3667
|
+
action: z3.enum(["preview", "confirm"]),
|
|
3668
|
+
operationId: z3.string().min(1).optional(),
|
|
3487
3669
|
confirmation: deleteConfirmation.optional()
|
|
3488
3670
|
},
|
|
3489
3671
|
outputSchema: STRUCTURED_TOOL_OUTPUT_SCHEMA,
|
|
@@ -3491,9 +3673,9 @@ function registerLifecycleTools(server, baseCtx) {
|
|
|
3491
3673
|
},
|
|
3492
3674
|
async (args) => {
|
|
3493
3675
|
try {
|
|
3494
|
-
const ctx = withProjectDir(baseCtx
|
|
3676
|
+
const ctx = withProjectDir(baseCtx);
|
|
3495
3677
|
const site = requireSiteFile(ctx);
|
|
3496
|
-
const operationId = args.operationId ??
|
|
3678
|
+
const operationId = args.operationId ?? randomUUID3();
|
|
3497
3679
|
if (args.action === "preview") {
|
|
3498
3680
|
const preview = await ctx.client.previewDeleteSite(site.siteId, site.credential, {
|
|
3499
3681
|
operationId
|
|
@@ -3562,20 +3744,19 @@ function registerLifecycleTools(server, baseCtx) {
|
|
|
3562
3744
|
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
3563
3745
|
|
|
3564
3746
|
// src/tools/billing.ts
|
|
3565
|
-
import { z as
|
|
3747
|
+
import { z as z4 } from "zod";
|
|
3566
3748
|
function registerBillingTools(server, baseCtx) {
|
|
3567
3749
|
server.registerTool(
|
|
3568
3750
|
"plans",
|
|
3569
3751
|
{
|
|
3570
3752
|
description: "Return the authoritative Sakupa monthly plan catalog, exact limits, prices, catalog version and plan-change billing rules. This is read-only and does not require a site.",
|
|
3571
|
-
inputSchema: {
|
|
3753
|
+
inputSchema: {},
|
|
3572
3754
|
outputSchema: STRUCTURED_TOOL_OUTPUT_SCHEMA,
|
|
3573
3755
|
annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: true }
|
|
3574
3756
|
},
|
|
3575
|
-
async (
|
|
3757
|
+
async () => {
|
|
3576
3758
|
try {
|
|
3577
|
-
const
|
|
3578
|
-
const catalog = await ctx.client.getBillingPlanCatalog();
|
|
3759
|
+
const catalog = await baseCtx.client.getBillingPlanCatalog();
|
|
3579
3760
|
return structuredToolResult({
|
|
3580
3761
|
schemaVersion: 1,
|
|
3581
3762
|
outcome: "completed",
|
|
@@ -3594,15 +3775,14 @@ function registerBillingTools(server, baseCtx) {
|
|
|
3594
3775
|
{
|
|
3595
3776
|
description: "Create one Stripe-hosted subscription-management link. The user chooses the plan or period-end cancellation on Stripe; Sakupa never infers intent from the conversation. Creating the link does not change billing.",
|
|
3596
3777
|
inputSchema: {
|
|
3597
|
-
|
|
3598
|
-
operationId: z5.string().min(1)
|
|
3778
|
+
operationId: z4.string().min(1)
|
|
3599
3779
|
},
|
|
3600
3780
|
outputSchema: STRUCTURED_TOOL_OUTPUT_SCHEMA,
|
|
3601
3781
|
annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: true }
|
|
3602
3782
|
},
|
|
3603
3783
|
async (args) => {
|
|
3604
3784
|
try {
|
|
3605
|
-
const ctx = withProjectDir(baseCtx
|
|
3785
|
+
const ctx = withProjectDir(baseCtx);
|
|
3606
3786
|
const site = requireSiteFile(ctx);
|
|
3607
3787
|
const result = await ctx.client.changeSubscriptionPlan(site.credential, {
|
|
3608
3788
|
siteId: site.siteId,
|
|
@@ -3663,18 +3843,22 @@ Workflow:
|
|
|
3663
3843
|
5. support (subscribed sites) opens a support ticket; report sends a
|
|
3664
3844
|
sanitized diagnostic report after the user explicitly confirms it.
|
|
3665
3845
|
|
|
3666
|
-
Project directory contract:
|
|
3667
|
-
|
|
3668
|
-
|
|
3669
|
-
|
|
3670
|
-
|
|
3671
|
-
|
|
3672
|
-
|
|
3673
|
-
|
|
3846
|
+
Project directory contract: before the first deploy or a new recovery, initialize the intended
|
|
3847
|
+
project once by running "npx -y @sakupa/mcp@latest init" with NO path argument from the AI
|
|
3848
|
+
tool's current project directory. This immediately creates the non-secret .sakupa/project.json
|
|
3849
|
+
there. ONE MCP process = ONE cwd-locked project = ONE site. Site tools do not accept projectDir
|
|
3850
|
+
and cannot select another root; plans and public_recovery portal remain project-independent.
|
|
3851
|
+
Sakupa stores .sakupa/site.json and recovery state only in the locked directory; it never uses
|
|
3852
|
+
package.json, .git, framework names or output-directory names to guess. For deploy, ALWAYS pass
|
|
3853
|
+
outputDir separately as the exact path RELATIVE to the locked directory (use "." when publishing
|
|
3854
|
+
the root); outputDir is
|
|
3855
|
+
required and may have ANY name, so inspect the current project. If it differs from the last
|
|
3856
|
+
successful publish directory, show the old and new paths and obtain explicit confirmation
|
|
3857
|
+
before retrying with outputDirChangeConfirmed: true.
|
|
3674
3858
|
After every deploy, TELL the user which environment it went to (deploy results carry an
|
|
3675
3859
|
Explicit Environment line: TEST vs PRODUCTION). analyze, deploy, status,
|
|
3676
3860
|
refresh and delete echo
|
|
3677
|
-
the directory they acted on
|
|
3861
|
+
the cwd-locked directory they acted on.
|
|
3678
3862
|
|
|
3679
3863
|
When the same operation fails twice in a row, or the user is clearly stuck or
|
|
3680
3864
|
frustrated, proactively offer report: it files the problem into Sakupa's ticket and
|
|
@@ -3712,7 +3896,11 @@ function createSakupaMcpServer(opts) {
|
|
|
3712
3896
|
{ name: "sakupa", version: MCP_VERSION },
|
|
3713
3897
|
{ instructions: instructionsFor(previewHostPatternFor(opts.apiBaseUrl)) }
|
|
3714
3898
|
);
|
|
3715
|
-
const ctx = {
|
|
3899
|
+
const ctx = {
|
|
3900
|
+
client,
|
|
3901
|
+
apiBaseUrl: opts.apiBaseUrl,
|
|
3902
|
+
projectDir: canonicalProjectDirectory(opts.projectDir ?? process.cwd())
|
|
3903
|
+
};
|
|
3716
3904
|
registerTools(server, ctx);
|
|
3717
3905
|
registerBillingTools(server, ctx);
|
|
3718
3906
|
registerLifecycleTools(server, ctx);
|