@sakupa/mcp 0.7.32 → 0.7.33
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 +512 -173
- package/dist/index.js +403 -120
- 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.33";
|
|
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) {
|
|
@@ -1356,9 +1366,258 @@ async function analyzeProject(projectDir, opts = {}) {
|
|
|
1356
1366
|
|
|
1357
1367
|
// src/tools/context.ts
|
|
1358
1368
|
import { z as z2 } from "zod";
|
|
1359
|
-
|
|
1369
|
+
|
|
1370
|
+
// src/project-root.ts
|
|
1371
|
+
import { randomUUID } from "node:crypto";
|
|
1372
|
+
import {
|
|
1373
|
+
chmodSync as chmodSync2,
|
|
1374
|
+
existsSync as existsSync2,
|
|
1375
|
+
lstatSync,
|
|
1376
|
+
mkdirSync as mkdirSync2,
|
|
1377
|
+
readFileSync as readFileSync2,
|
|
1378
|
+
realpathSync,
|
|
1379
|
+
renameSync,
|
|
1380
|
+
statSync,
|
|
1381
|
+
unlinkSync,
|
|
1382
|
+
writeFileSync as writeFileSync2
|
|
1383
|
+
} from "node:fs";
|
|
1360
1384
|
import { homedir } from "node:os";
|
|
1361
|
-
import { isAbsolute, parse, resolve as resolve2 } from "node:path";
|
|
1385
|
+
import { dirname as dirname2, isAbsolute, join as join3, parse, relative, resolve as resolve2, sep as sep2 } from "node:path";
|
|
1386
|
+
var SAKUPA_DIR = ".sakupa";
|
|
1387
|
+
var PROJECT_FILE = "project.json";
|
|
1388
|
+
var SITE_FILE2 = "site.json";
|
|
1389
|
+
var RECOVERY_FILE2 = "recovery.json";
|
|
1390
|
+
var PROJECT_SCHEMA_VERSION = 1;
|
|
1391
|
+
var ProjectRootError = class extends Error {
|
|
1392
|
+
code;
|
|
1393
|
+
constructor(code, message) {
|
|
1394
|
+
super(message);
|
|
1395
|
+
this.name = "ProjectRootError";
|
|
1396
|
+
this.code = code;
|
|
1397
|
+
}
|
|
1398
|
+
};
|
|
1399
|
+
function projectMarkerPath(projectDir) {
|
|
1400
|
+
return join3(projectDir, SAKUPA_DIR, PROJECT_FILE);
|
|
1401
|
+
}
|
|
1402
|
+
function loadProjectMarker(projectDir) {
|
|
1403
|
+
const path = projectMarkerPath(projectDir);
|
|
1404
|
+
if (!existsSync2(path)) return { kind: "absent" };
|
|
1405
|
+
let parsed;
|
|
1406
|
+
try {
|
|
1407
|
+
parsed = JSON.parse(readFileSync2(path, "utf8"));
|
|
1408
|
+
} catch (error) {
|
|
1409
|
+
return {
|
|
1410
|
+
kind: "corrupted",
|
|
1411
|
+
problem: `the file cannot be read as JSON (${error instanceof Error ? error.message : String(error)})`
|
|
1412
|
+
};
|
|
1413
|
+
}
|
|
1414
|
+
if (typeof parsed !== "object" || parsed === null) {
|
|
1415
|
+
return { kind: "corrupted", problem: "the file does not contain a JSON object" };
|
|
1416
|
+
}
|
|
1417
|
+
const record = parsed;
|
|
1418
|
+
if (record.schemaVersion !== PROJECT_SCHEMA_VERSION) {
|
|
1419
|
+
return {
|
|
1420
|
+
kind: "corrupted",
|
|
1421
|
+
problem: `unsupported schemaVersion ${String(record.schemaVersion)}`
|
|
1422
|
+
};
|
|
1423
|
+
}
|
|
1424
|
+
if (typeof record.projectId !== "string" || !isUuid(record.projectId)) {
|
|
1425
|
+
return { kind: "corrupted", problem: "projectId is missing or is not a UUID" };
|
|
1426
|
+
}
|
|
1427
|
+
if (typeof record.createdAt !== "string" || !Number.isFinite(Date.parse(record.createdAt))) {
|
|
1428
|
+
return { kind: "corrupted", problem: "createdAt is missing or invalid" };
|
|
1429
|
+
}
|
|
1430
|
+
if (record.outputDir !== void 0 && (typeof record.outputDir !== "string" || !isSafeRelativeOutput(record.outputDir))) {
|
|
1431
|
+
return { kind: "corrupted", problem: "outputDir is not a safe project-relative path" };
|
|
1432
|
+
}
|
|
1433
|
+
return {
|
|
1434
|
+
kind: "ok",
|
|
1435
|
+
marker: {
|
|
1436
|
+
schemaVersion: PROJECT_SCHEMA_VERSION,
|
|
1437
|
+
projectId: record.projectId,
|
|
1438
|
+
createdAt: record.createdAt,
|
|
1439
|
+
...record.outputDir !== void 0 ? { outputDir: normalizeRelative(record.outputDir) } : {}
|
|
1440
|
+
}
|
|
1441
|
+
};
|
|
1442
|
+
}
|
|
1443
|
+
function initializeProject(projectDir) {
|
|
1444
|
+
const canonical = canonicalProjectDirectory(projectDir);
|
|
1445
|
+
assertSafeProjectRoot(canonical);
|
|
1446
|
+
const current = loadProjectMarker(canonical);
|
|
1447
|
+
if (current.kind === "corrupted") {
|
|
1448
|
+
throw new ProjectRootError(
|
|
1449
|
+
"corrupted_marker",
|
|
1450
|
+
`Refusing to overwrite damaged Sakupa project marker ${projectMarkerPath(canonical)}: ${current.problem}.`
|
|
1451
|
+
);
|
|
1452
|
+
}
|
|
1453
|
+
if (current.kind === "ok") {
|
|
1454
|
+
return {
|
|
1455
|
+
projectDir: canonical,
|
|
1456
|
+
requestedPath: canonical,
|
|
1457
|
+
markerKind: "project",
|
|
1458
|
+
marker: current.marker
|
|
1459
|
+
};
|
|
1460
|
+
}
|
|
1461
|
+
const marker = {
|
|
1462
|
+
schemaVersion: PROJECT_SCHEMA_VERSION,
|
|
1463
|
+
projectId: randomUUID(),
|
|
1464
|
+
createdAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
1465
|
+
};
|
|
1466
|
+
writeMarkerAtomically(canonical, marker);
|
|
1467
|
+
return {
|
|
1468
|
+
projectDir: canonical,
|
|
1469
|
+
requestedPath: canonical,
|
|
1470
|
+
markerKind: "project",
|
|
1471
|
+
marker
|
|
1472
|
+
};
|
|
1473
|
+
}
|
|
1474
|
+
function resolveProjectRoot(requestedPath) {
|
|
1475
|
+
if (!isAbsolute(requestedPath)) {
|
|
1476
|
+
throw new ProjectRootError(
|
|
1477
|
+
"invalid_path",
|
|
1478
|
+
`Project path must be absolute (got "${requestedPath}").`
|
|
1479
|
+
);
|
|
1480
|
+
}
|
|
1481
|
+
const canonicalRequested = canonicalExistingPath(requestedPath);
|
|
1482
|
+
const requestedStat = statSync(canonicalRequested);
|
|
1483
|
+
let cursor = requestedStat.isDirectory() ? canonicalRequested : dirname2(canonicalRequested);
|
|
1484
|
+
const startDevice = statSync(cursor).dev;
|
|
1485
|
+
const candidates = [];
|
|
1486
|
+
while (true) {
|
|
1487
|
+
if (statSync(cursor).dev !== startDevice) break;
|
|
1488
|
+
const markerState = loadProjectMarker(cursor);
|
|
1489
|
+
const hasSite = existsSync2(join3(cursor, SAKUPA_DIR, SITE_FILE2));
|
|
1490
|
+
const hasRecovery = existsSync2(join3(cursor, SAKUPA_DIR, RECOVERY_FILE2));
|
|
1491
|
+
if (markerState.kind !== "absent" || hasSite || hasRecovery) {
|
|
1492
|
+
candidates.push({ dir: cursor, markerState, hasSite, hasRecovery });
|
|
1493
|
+
}
|
|
1494
|
+
const parent = dirname2(cursor);
|
|
1495
|
+
if (parent === cursor) break;
|
|
1496
|
+
cursor = parent;
|
|
1497
|
+
}
|
|
1498
|
+
if (candidates.length === 0) {
|
|
1499
|
+
throw new ProjectRootError(
|
|
1500
|
+
"not_initialized",
|
|
1501
|
+
`No Sakupa project marker was found at or above ${canonicalRequested}. Run \`npx -y @sakupa/mcp@latest init\` once from the intended project root; no site was changed.`
|
|
1502
|
+
);
|
|
1503
|
+
}
|
|
1504
|
+
const nearest = candidates[0];
|
|
1505
|
+
if (nearest.markerState.kind === "corrupted") {
|
|
1506
|
+
throw new ProjectRootError(
|
|
1507
|
+
"corrupted_marker",
|
|
1508
|
+
`Sakupa project marker ${projectMarkerPath(nearest.dir)} is damaged: ${nearest.markerState.problem}.`
|
|
1509
|
+
);
|
|
1510
|
+
}
|
|
1511
|
+
if (nearest.markerState.kind === "absent") {
|
|
1512
|
+
const explicitAncestor = candidates.find((candidate) => candidate.markerState.kind === "ok");
|
|
1513
|
+
if (explicitAncestor) {
|
|
1514
|
+
throw new ProjectRootError(
|
|
1515
|
+
"ambiguous_binding",
|
|
1516
|
+
`A legacy .sakupa binding exists at ${nearest.dir}, below the initialized Sakupa project ${explicitAncestor.dir}. Start the operation from ${explicitAncestor.dir}; deploy can then validate and relocate the same credential without creating a site.`
|
|
1517
|
+
);
|
|
1518
|
+
}
|
|
1519
|
+
}
|
|
1520
|
+
assertSafeProjectRoot(nearest.dir);
|
|
1521
|
+
if (nearest.markerState.kind === "ok") {
|
|
1522
|
+
return {
|
|
1523
|
+
projectDir: nearest.dir,
|
|
1524
|
+
requestedPath: canonicalRequested,
|
|
1525
|
+
markerKind: "project",
|
|
1526
|
+
marker: nearest.markerState.marker
|
|
1527
|
+
};
|
|
1528
|
+
}
|
|
1529
|
+
return {
|
|
1530
|
+
projectDir: nearest.dir,
|
|
1531
|
+
requestedPath: canonicalRequested,
|
|
1532
|
+
markerKind: nearest.hasSite ? "legacy_site" : "legacy_recovery"
|
|
1533
|
+
};
|
|
1534
|
+
}
|
|
1535
|
+
function updateProjectOutputDir(projectDir, outputDir) {
|
|
1536
|
+
const canonical = canonicalProjectDirectory(projectDir);
|
|
1537
|
+
const state = loadProjectMarker(canonical);
|
|
1538
|
+
if (state.kind !== "ok") {
|
|
1539
|
+
throw new ProjectRootError(
|
|
1540
|
+
state.kind === "corrupted" ? "corrupted_marker" : "not_initialized",
|
|
1541
|
+
state.kind === "corrupted" ? `Cannot update damaged Sakupa project marker: ${state.problem}.` : `No Sakupa project marker exists in ${canonical}.`
|
|
1542
|
+
);
|
|
1543
|
+
}
|
|
1544
|
+
if (!isSafeRelativeOutput(outputDir)) {
|
|
1545
|
+
throw new ProjectRootError(
|
|
1546
|
+
"unsafe_path",
|
|
1547
|
+
`Output directory "${outputDir}" must stay inside the initialized Sakupa project.`
|
|
1548
|
+
);
|
|
1549
|
+
}
|
|
1550
|
+
const marker = {
|
|
1551
|
+
...state.marker,
|
|
1552
|
+
outputDir: normalizeRelative(outputDir)
|
|
1553
|
+
};
|
|
1554
|
+
writeMarkerAtomically(canonical, marker);
|
|
1555
|
+
return marker;
|
|
1556
|
+
}
|
|
1557
|
+
function canonicalProjectDirectory(path) {
|
|
1558
|
+
const canonical = canonicalExistingPath(resolve2(path));
|
|
1559
|
+
if (!statSync(canonical).isDirectory()) {
|
|
1560
|
+
throw new ProjectRootError("invalid_path", `Project path ${canonical} is not a directory.`);
|
|
1561
|
+
}
|
|
1562
|
+
return canonical;
|
|
1563
|
+
}
|
|
1564
|
+
function canonicalExistingPath(path) {
|
|
1565
|
+
try {
|
|
1566
|
+
const stat2 = lstatSync(path, { throwIfNoEntry: false });
|
|
1567
|
+
if (!stat2) {
|
|
1568
|
+
throw new ProjectRootError("invalid_path", `Project path ${path} does not exist.`);
|
|
1569
|
+
}
|
|
1570
|
+
return realpathSync(path);
|
|
1571
|
+
} catch (error) {
|
|
1572
|
+
if (error instanceof ProjectRootError) throw error;
|
|
1573
|
+
throw new ProjectRootError(
|
|
1574
|
+
"invalid_path",
|
|
1575
|
+
`Project path ${path} cannot be resolved (${error instanceof Error ? error.message : String(error)}).`
|
|
1576
|
+
);
|
|
1577
|
+
}
|
|
1578
|
+
}
|
|
1579
|
+
function assertSafeProjectRoot(projectDir) {
|
|
1580
|
+
if (parse(projectDir).root === projectDir || projectDir === realpathSync(homedir())) {
|
|
1581
|
+
throw new ProjectRootError(
|
|
1582
|
+
"unsafe_path",
|
|
1583
|
+
`Refusing to use ${projectDir} as a Sakupa project root; choose a specific project directory.`
|
|
1584
|
+
);
|
|
1585
|
+
}
|
|
1586
|
+
}
|
|
1587
|
+
function isUuid(value) {
|
|
1588
|
+
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);
|
|
1589
|
+
}
|
|
1590
|
+
function normalizeRelative(path) {
|
|
1591
|
+
const normalized = path.split(sep2).join("/").replace(/^\.\//, "").replace(/\/$/, "");
|
|
1592
|
+
return normalized.length === 0 ? "." : normalized;
|
|
1593
|
+
}
|
|
1594
|
+
function isSafeRelativeOutput(path) {
|
|
1595
|
+
if (path.length === 0 || isAbsolute(path)) return false;
|
|
1596
|
+
const normalized = normalizeRelative(path);
|
|
1597
|
+
if (normalized === ".") return true;
|
|
1598
|
+
const rel = relative("/sakupa-root", resolve2("/sakupa-root", normalized));
|
|
1599
|
+
return rel !== ".." && !rel.startsWith(`..${sep2}`) && !isAbsolute(rel);
|
|
1600
|
+
}
|
|
1601
|
+
function writeMarkerAtomically(projectDir, marker) {
|
|
1602
|
+
const dir = join3(projectDir, SAKUPA_DIR);
|
|
1603
|
+
mkdirSync2(dir, { recursive: true, mode: 448 });
|
|
1604
|
+
const path = projectMarkerPath(projectDir);
|
|
1605
|
+
const temporary = `${path}.${process.pid}.${randomUUID()}.tmp`;
|
|
1606
|
+
try {
|
|
1607
|
+
writeFileSync2(temporary, `${JSON.stringify(marker, null, 2)}
|
|
1608
|
+
`, {
|
|
1609
|
+
encoding: "utf8",
|
|
1610
|
+
mode: 384
|
|
1611
|
+
});
|
|
1612
|
+
renameSync(temporary, path);
|
|
1613
|
+
try {
|
|
1614
|
+
chmodSync2(path, 384);
|
|
1615
|
+
} catch {
|
|
1616
|
+
}
|
|
1617
|
+
} finally {
|
|
1618
|
+
if (existsSync2(temporary)) unlinkSync(temporary);
|
|
1619
|
+
}
|
|
1620
|
+
}
|
|
1362
1621
|
|
|
1363
1622
|
// src/tools/result.ts
|
|
1364
1623
|
import { z } from "zod";
|
|
@@ -1408,36 +1667,33 @@ var LocalGuidanceError = class extends SakupaError {
|
|
|
1408
1667
|
}
|
|
1409
1668
|
};
|
|
1410
1669
|
var projectDirInput = z2.string().describe(
|
|
1411
|
-
"REQUIRED
|
|
1670
|
+
"REQUIRED: an absolute existing path anywhere inside the user's initialized Sakupa project (the root itself or a child such as dist/html/src). Sakupa resolves upward to its own .sakupa marker and never trusts this argument as the root. If no marker exists, run `npx -y @sakupa/mcp@latest init` once from the intended project root."
|
|
1412
1671
|
);
|
|
1413
1672
|
function withProjectDir(ctx, projectDirArg) {
|
|
1414
1673
|
if (projectDirArg === void 0) {
|
|
1415
1674
|
throw new LocalGuidanceError(
|
|
1416
1675
|
"invalid_request",
|
|
1417
|
-
"projectDir is REQUIRED
|
|
1418
|
-
);
|
|
1419
|
-
}
|
|
1420
|
-
if (!isAbsolute(projectDirArg)) {
|
|
1421
|
-
throw new LocalGuidanceError(
|
|
1422
|
-
"invalid_request",
|
|
1423
|
-
`projectDir must be an ABSOLUTE path (got "${projectDirArg}"). Pass the full path of the directory the user is currently working in.`
|
|
1424
|
-
);
|
|
1425
|
-
}
|
|
1426
|
-
const dir = resolve2(projectDirArg);
|
|
1427
|
-
if (parse(dir).root === dir || dir === homedir()) {
|
|
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.`
|
|
1676
|
+
"projectDir is REQUIRED as a path locator: pass an absolute existing path anywhere inside the current Sakupa project. The server resolves its own .sakupa marker and never treats the supplied path as an authoritative root."
|
|
1431
1677
|
);
|
|
1432
1678
|
}
|
|
1433
|
-
|
|
1434
|
-
|
|
1435
|
-
|
|
1436
|
-
|
|
1437
|
-
|
|
1438
|
-
|
|
1679
|
+
try {
|
|
1680
|
+
const resolved = resolveProjectRoot(projectDirArg);
|
|
1681
|
+
return {
|
|
1682
|
+
...ctx,
|
|
1683
|
+
projectDir: resolved.projectDir,
|
|
1684
|
+
requestedPath: resolved.requestedPath,
|
|
1685
|
+
markerKind: resolved.markerKind,
|
|
1686
|
+
...resolved.marker !== void 0 ? { projectMarker: resolved.marker } : {}
|
|
1687
|
+
};
|
|
1688
|
+
} catch (error) {
|
|
1689
|
+
if (error instanceof ProjectRootError) {
|
|
1690
|
+
throw new LocalGuidanceError(
|
|
1691
|
+
error.code === "not_initialized" ? "not_found" : "invalid_request",
|
|
1692
|
+
error.message
|
|
1693
|
+
);
|
|
1694
|
+
}
|
|
1695
|
+
throw error;
|
|
1439
1696
|
}
|
|
1440
|
-
return { ...ctx, projectDir: dir };
|
|
1441
1697
|
}
|
|
1442
1698
|
function requireSiteFile(ctx) {
|
|
1443
1699
|
const state = loadSiteFile(ctx.projectDir);
|
|
@@ -1494,14 +1750,15 @@ function toolError(e) {
|
|
|
1494
1750
|
}
|
|
1495
1751
|
|
|
1496
1752
|
// src/tools/definitions.ts
|
|
1497
|
-
import { randomUUID } from "node:crypto";
|
|
1498
|
-
import {
|
|
1499
|
-
import { join as
|
|
1753
|
+
import { randomUUID as randomUUID2 } from "node:crypto";
|
|
1754
|
+
import { promises as fs2 } from "node:fs";
|
|
1755
|
+
import { join as join6, resolve as resolve4 } from "node:path";
|
|
1500
1756
|
import { z as z3 } from "zod";
|
|
1501
1757
|
|
|
1502
1758
|
// src/recovery-archive.ts
|
|
1759
|
+
import { existsSync as existsSync3, realpathSync as realpathSync2 } from "node:fs";
|
|
1503
1760
|
import { mkdtemp, mkdir, readFile, readdir, rename, rm, stat, writeFile } from "node:fs/promises";
|
|
1504
|
-
import { dirname as
|
|
1761
|
+
import { dirname as dirname3, isAbsolute as isAbsolute2, join as join4, relative as relative2, resolve as resolve3, sep as sep3 } from "node:path";
|
|
1505
1762
|
|
|
1506
1763
|
// ../../node_modules/fflate/esm/index.mjs
|
|
1507
1764
|
import { createRequire } from "module";
|
|
@@ -1991,15 +2248,30 @@ function safeOutputPath(projectDir, outputDir) {
|
|
|
1991
2248
|
if (outputDir.length === 0 || isAbsolute2(outputDir)) {
|
|
1992
2249
|
throw new SakupaError("invalid_request", "Recovery outputDir must be a relative directory");
|
|
1993
2250
|
}
|
|
1994
|
-
const root = resolve3(projectDir);
|
|
2251
|
+
const root = realpathSync2(resolve3(projectDir));
|
|
1995
2252
|
const target = resolve3(root, outputDir);
|
|
1996
|
-
const rel =
|
|
1997
|
-
if (rel === "" || rel === ".." || rel.startsWith(`..${
|
|
2253
|
+
const rel = relative2(root, target);
|
|
2254
|
+
if (rel === "" || rel === ".." || rel.startsWith(`..${sep3}`) || isAbsolute2(rel)) {
|
|
1998
2255
|
throw new SakupaError("invalid_request", "Recovery outputDir must stay inside projectDir");
|
|
1999
2256
|
}
|
|
2000
|
-
if (rel === ".sakupa" || rel.startsWith(`.sakupa${
|
|
2257
|
+
if (rel === ".sakupa" || rel.startsWith(`.sakupa${sep3}`)) {
|
|
2001
2258
|
throw new SakupaError("invalid_request", "Recovery content cannot be written inside .sakupa");
|
|
2002
2259
|
}
|
|
2260
|
+
let existingAncestor = target;
|
|
2261
|
+
while (!existsSync3(existingAncestor)) {
|
|
2262
|
+
const parent = dirname3(existingAncestor);
|
|
2263
|
+
if (parent === existingAncestor) break;
|
|
2264
|
+
existingAncestor = parent;
|
|
2265
|
+
}
|
|
2266
|
+
const physicalAncestor = realpathSync2(existingAncestor);
|
|
2267
|
+
const physicalTarget = resolve3(physicalAncestor, relative2(existingAncestor, target));
|
|
2268
|
+
const physicalRel = relative2(root, physicalTarget);
|
|
2269
|
+
if (physicalRel === ".." || physicalRel.startsWith(`..${sep3}`) || isAbsolute2(physicalRel)) {
|
|
2270
|
+
throw new SakupaError(
|
|
2271
|
+
"invalid_request",
|
|
2272
|
+
"Recovery outputDir resolves through a symlink outside projectDir"
|
|
2273
|
+
);
|
|
2274
|
+
}
|
|
2003
2275
|
return target;
|
|
2004
2276
|
}
|
|
2005
2277
|
function safeEntryName(name) {
|
|
@@ -2028,9 +2300,9 @@ async function listExistingFiles(root, current = root) {
|
|
|
2028
2300
|
if (entry.isSymbolicLink()) {
|
|
2029
2301
|
throw new SakupaError("state_conflict", `Recovery output contains a symlink: ${entry.name}`);
|
|
2030
2302
|
}
|
|
2031
|
-
const absolute =
|
|
2303
|
+
const absolute = join4(current, entry.name);
|
|
2032
2304
|
if (entry.isDirectory()) files.push(...await listExistingFiles(root, absolute));
|
|
2033
|
-
else if (entry.isFile()) files.push(
|
|
2305
|
+
else if (entry.isFile()) files.push(relative2(root, absolute).split(sep3).join("/"));
|
|
2034
2306
|
else
|
|
2035
2307
|
throw new SakupaError(
|
|
2036
2308
|
"state_conflict",
|
|
@@ -2046,7 +2318,7 @@ async function existingOutputMatches(outputDir, files) {
|
|
|
2046
2318
|
return false;
|
|
2047
2319
|
}
|
|
2048
2320
|
for (const name of expected) {
|
|
2049
|
-
const actual = await readFile(
|
|
2321
|
+
const actual = await readFile(join4(outputDir, ...name.split("/")));
|
|
2050
2322
|
const wanted = files[name];
|
|
2051
2323
|
if (!wanted || actual.byteLength !== wanted.byteLength || !actual.equals(wanted)) return false;
|
|
2052
2324
|
}
|
|
@@ -2056,7 +2328,7 @@ async function extractRecoveryArchive(input) {
|
|
|
2056
2328
|
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
2329
|
throw new SakupaError("validation_failed", "Recovery archive metadata exceeds product limits");
|
|
2058
2330
|
}
|
|
2059
|
-
const outputDir = safeOutputPath(input.projectDir, input.outputDir
|
|
2331
|
+
const outputDir = safeOutputPath(input.projectDir, input.outputDir);
|
|
2060
2332
|
const maxArchiveBytes = input.expectedBytes + input.expectedFiles * 4096 + 65536;
|
|
2061
2333
|
if (input.archive.byteLength > maxArchiveBytes) {
|
|
2062
2334
|
throw new SakupaError("validation_failed", "Recovery archive is larger than its site metadata");
|
|
@@ -2097,14 +2369,14 @@ async function extractRecoveryArchive(input) {
|
|
|
2097
2369
|
`Recovery output already exists with different content: ${outputDir}. Move it aside or choose another outputDir.`
|
|
2098
2370
|
);
|
|
2099
2371
|
}
|
|
2100
|
-
const tempDir = await mkdtemp(
|
|
2372
|
+
const tempDir = await mkdtemp(join4(resolve3(input.projectDir), ".sakupa-restore-"));
|
|
2101
2373
|
try {
|
|
2102
2374
|
let writtenBytes = 0;
|
|
2103
2375
|
const entries = Object.entries(files).sort(([a], [b]) => a.localeCompare(b));
|
|
2104
2376
|
for (const [rawName, data] of entries) {
|
|
2105
2377
|
const name = safeEntryName(rawName);
|
|
2106
|
-
const destination =
|
|
2107
|
-
await mkdir(
|
|
2378
|
+
const destination = join4(tempDir, ...name.split("/"));
|
|
2379
|
+
await mkdir(dirname3(destination), { recursive: true });
|
|
2108
2380
|
await writeFile(destination, data, { flag: "wx" });
|
|
2109
2381
|
writtenBytes += data.byteLength;
|
|
2110
2382
|
}
|
|
@@ -2114,7 +2386,7 @@ async function extractRecoveryArchive(input) {
|
|
|
2114
2386
|
"Extracted recovery data does not match site metadata"
|
|
2115
2387
|
);
|
|
2116
2388
|
}
|
|
2117
|
-
await mkdir(
|
|
2389
|
+
await mkdir(dirname3(outputDir), { recursive: true });
|
|
2118
2390
|
await rename(tempDir, outputDir);
|
|
2119
2391
|
return {
|
|
2120
2392
|
outputDir,
|
|
@@ -2129,19 +2401,19 @@ async function extractRecoveryArchive(input) {
|
|
|
2129
2401
|
}
|
|
2130
2402
|
|
|
2131
2403
|
// src/creation-registry.ts
|
|
2132
|
-
import { existsSync as
|
|
2404
|
+
import { existsSync as existsSync4, mkdirSync as mkdirSync3, readFileSync as readFileSync3, writeFileSync as writeFileSync3 } from "node:fs";
|
|
2133
2405
|
import { homedir as homedir2 } from "node:os";
|
|
2134
|
-
import { dirname as
|
|
2406
|
+
import { dirname as dirname4, join as join5 } from "node:path";
|
|
2135
2407
|
var RECENT_WINDOW_MS = FREE_SITE_TTL_HOURS * 60 * 60 * 1e3;
|
|
2136
2408
|
function creationRegistryPath() {
|
|
2137
2409
|
const base = process.env["SAKUPA_STATE_DIR"] ?? homedir2();
|
|
2138
|
-
return
|
|
2410
|
+
return join5(base, ".sakupa", "created-sites.json");
|
|
2139
2411
|
}
|
|
2140
2412
|
function readAll() {
|
|
2141
2413
|
const path = creationRegistryPath();
|
|
2142
|
-
if (!
|
|
2414
|
+
if (!existsSync4(path)) return [];
|
|
2143
2415
|
try {
|
|
2144
|
-
const parsed = JSON.parse(
|
|
2416
|
+
const parsed = JSON.parse(readFileSync3(path, "utf-8"));
|
|
2145
2417
|
if (!Array.isArray(parsed)) return [];
|
|
2146
2418
|
return parsed.filter(
|
|
2147
2419
|
(e) => typeof e === "object" && e !== null && typeof e.siteId === "string" && typeof e.createdAt === "string"
|
|
@@ -2152,8 +2424,8 @@ function readAll() {
|
|
|
2152
2424
|
}
|
|
2153
2425
|
function writeAll(records) {
|
|
2154
2426
|
const path = creationRegistryPath();
|
|
2155
|
-
|
|
2156
|
-
|
|
2427
|
+
mkdirSync3(dirname4(path), { recursive: true });
|
|
2428
|
+
writeFileSync3(path, `${JSON.stringify(records, null, 2)}
|
|
2157
2429
|
`, "utf-8");
|
|
2158
2430
|
}
|
|
2159
2431
|
function listRecentCreations(nowMs, apiBaseUrl) {
|
|
@@ -2381,7 +2653,7 @@ function ensureUploadSizeWithinLimits(manifest, isFirstFreeDeploy) {
|
|
|
2381
2653
|
async function buildHashedManifest(files, outputAbs) {
|
|
2382
2654
|
const manifest = [];
|
|
2383
2655
|
for (const file of files) {
|
|
2384
|
-
const bytes = new Uint8Array(await fs2.readFile(
|
|
2656
|
+
const bytes = new Uint8Array(await fs2.readFile(join6(outputAbs, file.path)));
|
|
2385
2657
|
manifest.push({ path: file.path, size: file.size, contentHash: await sha256Hex(bytes) });
|
|
2386
2658
|
}
|
|
2387
2659
|
return manifest;
|
|
@@ -2400,7 +2672,7 @@ async function uploadAll(ctx, targets, files, outputAbs) {
|
|
|
2400
2672
|
`No local file matches upload target "${target.path}"; aborting upload.`
|
|
2401
2673
|
);
|
|
2402
2674
|
}
|
|
2403
|
-
const bytes = new Uint8Array(await fs2.readFile(
|
|
2675
|
+
const bytes = new Uint8Array(await fs2.readFile(join6(outputAbs, match.path)));
|
|
2404
2676
|
if (bytes.byteLength !== match.size) {
|
|
2405
2677
|
throw new SakupaError(
|
|
2406
2678
|
"validation_failed",
|
|
@@ -2443,21 +2715,6 @@ ${block}`, checklist: toDnsChecklist(diag.checks) };
|
|
|
2443
2715
|
};
|
|
2444
2716
|
}
|
|
2445
2717
|
}
|
|
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
2718
|
function freeSiteCreationBarrier(apiBaseUrl) {
|
|
2462
2719
|
const recent = listRecentCreations(Date.now(), apiBaseUrl);
|
|
2463
2720
|
if (recent.length < FREE_ACTIVE_SITES_PER_IP) return null;
|
|
@@ -2508,13 +2765,16 @@ Next action: ${analysis.suggestedNextAction}`,
|
|
|
2508
2765
|
server.registerTool(
|
|
2509
2766
|
"deploy",
|
|
2510
2767
|
{
|
|
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. projectDir
|
|
2768
|
+
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. projectDir may point anywhere inside a project already initialized with \`npx -y @sakupa/mcp@latest init\`; Sakupa resolves its own marker upward and stores credentials only at 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
2769
|
outputSchema: STRUCTURED_TOOL_OUTPUT_SCHEMA,
|
|
2513
2770
|
annotations: { readOnlyHint: false, destructiveHint: true, openWorldHint: true },
|
|
2514
2771
|
inputSchema: {
|
|
2515
2772
|
projectDir: projectDirInput,
|
|
2516
2773
|
outputDir: z3.string().min(1).describe(
|
|
2517
|
-
'REQUIRED: exact publish directory relative to
|
|
2774
|
+
'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 resolves projectDir upward before applying this path.'
|
|
2775
|
+
),
|
|
2776
|
+
outputDirChangeConfirmed: z3.boolean().optional().describe(
|
|
2777
|
+
"Required only when changing the previously successful publish directory. Confirm only after showing the old and new directories to the user."
|
|
2518
2778
|
),
|
|
2519
2779
|
spaFallback: z3.boolean().optional().describe(
|
|
2520
2780
|
"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."
|
|
@@ -2523,7 +2783,7 @@ Next action: ${analysis.suggestedNextAction}`,
|
|
|
2523
2783
|
"Required only for the first deployment: user explicitly confirmed creation of a public 24-hour URL."
|
|
2524
2784
|
),
|
|
2525
2785
|
subprojectConfirmed: z3.boolean().optional().describe(
|
|
2526
|
-
"
|
|
2786
|
+
"Deprecated compatibility field. Project independence is established only by `sakupa-mcp init`, never inferred from package.json or folder names."
|
|
2527
2787
|
),
|
|
2528
2788
|
lang: z3.string().optional().describe("Site language override (en | ja | zh-CN); defaults to the html lang.")
|
|
2529
2789
|
}
|
|
@@ -2535,8 +2795,25 @@ Next action: ${analysis.suggestedNextAction}`,
|
|
|
2535
2795
|
if (!analysis.deployable || !analysis.files) {
|
|
2536
2796
|
return notDeployableResult(analysis);
|
|
2537
2797
|
}
|
|
2798
|
+
const effectiveOutputDir = analysis.recommendedOutputDir ?? ".";
|
|
2799
|
+
const recordedOutputDir = ctx.projectMarker?.outputDir;
|
|
2800
|
+
if (recordedOutputDir !== void 0 && resolve4(ctx.projectDir, recordedOutputDir) !== resolve4(ctx.projectDir, effectiveOutputDir) && args.outputDirChangeConfirmed !== true) {
|
|
2801
|
+
return structuredToolResult({
|
|
2802
|
+
schemaVersion: 1,
|
|
2803
|
+
outcome: "waiting_user",
|
|
2804
|
+
resultCode: "publish_directory_change_confirmation_required",
|
|
2805
|
+
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.`,
|
|
2806
|
+
data: {
|
|
2807
|
+
projectDir: ctx.projectDir,
|
|
2808
|
+
previousOutputDir: recordedOutputDir,
|
|
2809
|
+
requestedOutputDir: effectiveOutputDir,
|
|
2810
|
+
confirmationField: "outputDirChangeConfirmed"
|
|
2811
|
+
},
|
|
2812
|
+
nextActions: [{ tool: "deploy", allowed: true, reasonCode: "explicit_confirmation" }]
|
|
2813
|
+
});
|
|
2814
|
+
}
|
|
2538
2815
|
const files = analysis.files;
|
|
2539
|
-
const outputAbs = resolve4(ctx.projectDir,
|
|
2816
|
+
const outputAbs = resolve4(ctx.projectDir, effectiveOutputDir);
|
|
2540
2817
|
const manifest = await buildHashedManifest(files, outputAbs);
|
|
2541
2818
|
const siteFileState = loadSiteFile(ctx.projectDir);
|
|
2542
2819
|
if (siteFileState.kind === "corrupted") {
|
|
@@ -2551,7 +2828,16 @@ Next action: ${analysis.suggestedNextAction}`,
|
|
|
2551
2828
|
}
|
|
2552
2829
|
let existing = siteFileState.kind === "ok" ? siteFileState.file : null;
|
|
2553
2830
|
let credentialRelocatedFrom = null;
|
|
2554
|
-
if (!existing &&
|
|
2831
|
+
if (!existing && effectiveOutputDir !== ".") {
|
|
2832
|
+
const outputProjectMarker = loadProjectMarker(outputAbs);
|
|
2833
|
+
if (outputProjectMarker.kind !== "absent") {
|
|
2834
|
+
return text(
|
|
2835
|
+
"publish_directory_is_independent_project",
|
|
2836
|
+
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.`,
|
|
2837
|
+
{ projectRoot: ctx.projectDir, outputDir: effectiveOutputDir },
|
|
2838
|
+
"blocked"
|
|
2839
|
+
);
|
|
2840
|
+
}
|
|
2555
2841
|
const outputSiteState = loadSiteFile(outputAbs);
|
|
2556
2842
|
if (outputSiteState.kind === "corrupted") {
|
|
2557
2843
|
return text(
|
|
@@ -2562,35 +2848,11 @@ Next action: ${analysis.suggestedNextAction}`,
|
|
|
2562
2848
|
);
|
|
2563
2849
|
}
|
|
2564
2850
|
if (outputSiteState.kind === "ok") {
|
|
2565
|
-
writeSiteFile(ctx.projectDir, outputSiteState.file);
|
|
2566
|
-
deleteSiteFile(outputAbs);
|
|
2567
2851
|
existing = outputSiteState.file;
|
|
2568
2852
|
credentialRelocatedFrom = outputAbs;
|
|
2569
2853
|
}
|
|
2570
2854
|
}
|
|
2571
2855
|
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
2856
|
const barrier = freeSiteCreationBarrier(ctx.apiBaseUrl);
|
|
2595
2857
|
if (barrier) return barrier;
|
|
2596
2858
|
if (args.publicConfirmed !== true) {
|
|
@@ -2624,6 +2886,7 @@ Next action: ${analysis.suggestedNextAction}`,
|
|
|
2624
2886
|
createdAt,
|
|
2625
2887
|
apiBaseUrl: ctx.apiBaseUrl
|
|
2626
2888
|
});
|
|
2889
|
+
updateProjectOutputDir(ctx.projectDir, effectiveOutputDir);
|
|
2627
2890
|
recordCreation({
|
|
2628
2891
|
siteId: created.siteId,
|
|
2629
2892
|
projectDir: ctx.projectDir,
|
|
@@ -2698,6 +2961,9 @@ ${JSON.stringify(finalized2.warnings, null, 2)}` : ""),
|
|
|
2698
2961
|
}
|
|
2699
2962
|
const { uploaded, finalized } = update;
|
|
2700
2963
|
writeSiteFile(ctx.projectDir, { ...existing, url: finalized.url });
|
|
2964
|
+
if (credentialRelocatedFrom !== null) deleteSiteFile(credentialRelocatedFrom);
|
|
2965
|
+
if (ctx.projectMarker === void 0) initializeProject(ctx.projectDir);
|
|
2966
|
+
updateProjectOutputDir(ctx.projectDir, effectiveOutputDir);
|
|
2701
2967
|
noteSiteMode(existing.siteId, finalized.mode);
|
|
2702
2968
|
return text(
|
|
2703
2969
|
"site_updated",
|
|
@@ -2806,7 +3072,7 @@ NO content was uploaded or changed by this call \u2014 to publish new or edited
|
|
|
2806
3072
|
{
|
|
2807
3073
|
siteId: site.siteId,
|
|
2808
3074
|
plan: args.plan,
|
|
2809
|
-
idempotencyKey:
|
|
3075
|
+
idempotencyKey: randomUUID2()
|
|
2810
3076
|
},
|
|
2811
3077
|
site.credential
|
|
2812
3078
|
);
|
|
@@ -2996,14 +3262,14 @@ Full status:`, res);
|
|
|
2996
3262
|
outputSchema: STRUCTURED_TOOL_OUTPUT_SCHEMA,
|
|
2997
3263
|
annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: true },
|
|
2998
3264
|
inputSchema: {
|
|
2999
|
-
projectDir: projectDirInput,
|
|
3265
|
+
projectDir: projectDirInput.optional(),
|
|
3000
3266
|
scope: z3.enum(["site", "public_recovery"])
|
|
3001
3267
|
}
|
|
3002
3268
|
},
|
|
3003
3269
|
async (args) => {
|
|
3004
3270
|
try {
|
|
3005
|
-
const ctx = withProjectDir(baseCtx, args.projectDir);
|
|
3006
3271
|
if (args.scope === "site") {
|
|
3272
|
+
const ctx = withProjectDir(baseCtx, args.projectDir);
|
|
3007
3273
|
const site = requireSiteFile(ctx);
|
|
3008
3274
|
const res2 = await ctx.client.createBillingPortal(site.siteId, site.credential);
|
|
3009
3275
|
return structuredToolResult({
|
|
@@ -3021,7 +3287,7 @@ Full status:`, res);
|
|
|
3021
3287
|
nextActions: [{ tool: "billing", allowed: true }]
|
|
3022
3288
|
});
|
|
3023
3289
|
}
|
|
3024
|
-
const res = await
|
|
3290
|
+
const res = await baseCtx.client.getPublicBillingPortal();
|
|
3025
3291
|
return structuredToolResult({
|
|
3026
3292
|
schemaVersion: 1,
|
|
3027
3293
|
outcome: "waiting_user",
|
|
@@ -3049,7 +3315,7 @@ Full status:`, res);
|
|
|
3049
3315
|
server.registerTool(
|
|
3050
3316
|
"recover",
|
|
3051
3317
|
{
|
|
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
|
|
3318
|
+
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
3319
|
outputSchema: STRUCTURED_TOOL_OUTPUT_SCHEMA,
|
|
3054
3320
|
annotations: { readOnlyHint: false, destructiveHint: true, openWorldHint: true },
|
|
3055
3321
|
inputSchema: {
|
|
@@ -3057,13 +3323,21 @@ Full status:`, res);
|
|
|
3057
3323
|
action: z3.enum(["start", "status", "complete", "download"]),
|
|
3058
3324
|
hostname: z3.string().optional().describe("Required for start."),
|
|
3059
3325
|
verificationId: z3.string().optional().describe("For status or complete; inferred from local recovery state when omitted."),
|
|
3060
|
-
outputDir: z3.string().optional().describe(
|
|
3326
|
+
outputDir: z3.string().optional().describe(
|
|
3327
|
+
"REQUIRED for complete/download: exact extraction directory relative to the initialized project root. Inspect the current project; Sakupa never guesses a name."
|
|
3328
|
+
),
|
|
3061
3329
|
preserveExistingCredentials: z3.boolean().optional().describe("Explicitly keep old local credentials working (default: revoke them all).")
|
|
3062
3330
|
}
|
|
3063
3331
|
},
|
|
3064
3332
|
async (args) => {
|
|
3065
3333
|
try {
|
|
3066
3334
|
const ctx = withProjectDir(baseCtx, args.projectDir);
|
|
3335
|
+
if ((args.action === "complete" || args.action === "download") && args.outputDir === void 0) {
|
|
3336
|
+
throw new LocalGuidanceError(
|
|
3337
|
+
"invalid_request",
|
|
3338
|
+
"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."
|
|
3339
|
+
);
|
|
3340
|
+
}
|
|
3067
3341
|
const localSite = loadSiteFile(ctx.projectDir);
|
|
3068
3342
|
const localCredentialIsActive = async () => {
|
|
3069
3343
|
if (localSite.kind !== "ok") return false;
|
|
@@ -3076,12 +3350,18 @@ Full status:`, res);
|
|
|
3076
3350
|
}
|
|
3077
3351
|
};
|
|
3078
3352
|
const download = async () => {
|
|
3353
|
+
if (args.outputDir === void 0) {
|
|
3354
|
+
throw new LocalGuidanceError(
|
|
3355
|
+
"invalid_request",
|
|
3356
|
+
"recover download requires an explicit outputDir."
|
|
3357
|
+
);
|
|
3358
|
+
}
|
|
3079
3359
|
const site = requireSiteFile(ctx);
|
|
3080
3360
|
const archive = await ctx.client.getSiteArchive(site.siteId, site.credential);
|
|
3081
3361
|
const bytes = await ctx.client.downloadArchive(archive.archiveUrl);
|
|
3082
3362
|
const extracted = await extractRecoveryArchive({
|
|
3083
3363
|
projectDir: ctx.projectDir,
|
|
3084
|
-
|
|
3364
|
+
outputDir: args.outputDir,
|
|
3085
3365
|
archive: bytes,
|
|
3086
3366
|
expectedBytes: archive.totalBytes,
|
|
3087
3367
|
expectedFiles: archive.fileCount
|
|
@@ -3126,7 +3406,7 @@ No DNS verification was started or repeated.`,
|
|
|
3126
3406
|
arguments: {
|
|
3127
3407
|
projectDir: ctx.projectDir,
|
|
3128
3408
|
action: "download",
|
|
3129
|
-
outputDir: args.outputDir
|
|
3409
|
+
...args.outputDir !== void 0 ? { outputDir: args.outputDir } : {}
|
|
3130
3410
|
},
|
|
3131
3411
|
allowed: true
|
|
3132
3412
|
}
|
|
@@ -3226,7 +3506,7 @@ After the DNS record resolves, re-run recover with verificationId: "${res2.verif
|
|
|
3226
3506
|
arguments: {
|
|
3227
3507
|
projectDir: ctx.projectDir,
|
|
3228
3508
|
action: "download",
|
|
3229
|
-
outputDir: args.outputDir
|
|
3509
|
+
...args.outputDir !== void 0 ? { outputDir: args.outputDir } : {}
|
|
3230
3510
|
},
|
|
3231
3511
|
allowed: true
|
|
3232
3512
|
}
|
|
@@ -3262,7 +3542,7 @@ After the DNS record resolves, re-run recover with verificationId: "${res2.verif
|
|
|
3262
3542
|
projectDir: ctx.projectDir,
|
|
3263
3543
|
action: "complete",
|
|
3264
3544
|
verificationId,
|
|
3265
|
-
outputDir: args.outputDir
|
|
3545
|
+
...args.outputDir !== void 0 ? { outputDir: args.outputDir } : {}
|
|
3266
3546
|
},
|
|
3267
3547
|
allowed: res2.readyToComplete,
|
|
3268
3548
|
...res2.readyToComplete ? {} : { reasonCode: res2.status }
|
|
@@ -3338,7 +3618,7 @@ Files: ${extracted.fileCount}; bytes: ${extracted.totalBytes}
|
|
|
3338
3618
|
arguments: {
|
|
3339
3619
|
projectDir: ctx.projectDir,
|
|
3340
3620
|
action: "download",
|
|
3341
|
-
outputDir: args.outputDir
|
|
3621
|
+
...args.outputDir !== void 0 ? { outputDir: args.outputDir } : {}
|
|
3342
3622
|
},
|
|
3343
3623
|
allowed: true
|
|
3344
3624
|
}
|
|
@@ -3458,7 +3738,7 @@ Summary: ${res.sanitizedSummary}`,
|
|
|
3458
3738
|
}
|
|
3459
3739
|
|
|
3460
3740
|
// src/tools/lifecycle.ts
|
|
3461
|
-
import { randomUUID as
|
|
3741
|
+
import { randomUUID as randomUUID3 } from "node:crypto";
|
|
3462
3742
|
import { z as z4 } from "zod";
|
|
3463
3743
|
var deleteConfirmation = z4.object({
|
|
3464
3744
|
siteId: z4.string().min(1),
|
|
@@ -3493,7 +3773,7 @@ function registerLifecycleTools(server, baseCtx) {
|
|
|
3493
3773
|
try {
|
|
3494
3774
|
const ctx = withProjectDir(baseCtx, args.projectDir);
|
|
3495
3775
|
const site = requireSiteFile(ctx);
|
|
3496
|
-
const operationId = args.operationId ??
|
|
3776
|
+
const operationId = args.operationId ?? randomUUID3();
|
|
3497
3777
|
if (args.action === "preview") {
|
|
3498
3778
|
const preview = await ctx.client.previewDeleteSite(site.siteId, site.credential, {
|
|
3499
3779
|
operationId
|
|
@@ -3568,14 +3848,13 @@ function registerBillingTools(server, baseCtx) {
|
|
|
3568
3848
|
"plans",
|
|
3569
3849
|
{
|
|
3570
3850
|
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: {
|
|
3851
|
+
inputSchema: {},
|
|
3572
3852
|
outputSchema: STRUCTURED_TOOL_OUTPUT_SCHEMA,
|
|
3573
3853
|
annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: true }
|
|
3574
3854
|
},
|
|
3575
|
-
async (
|
|
3855
|
+
async () => {
|
|
3576
3856
|
try {
|
|
3577
|
-
const
|
|
3578
|
-
const catalog = await ctx.client.getBillingPlanCatalog();
|
|
3857
|
+
const catalog = await baseCtx.client.getBillingPlanCatalog();
|
|
3579
3858
|
return structuredToolResult({
|
|
3580
3859
|
schemaVersion: 1,
|
|
3581
3860
|
outcome: "completed",
|
|
@@ -3663,14 +3942,18 @@ Workflow:
|
|
|
3663
3942
|
5. support (subscribed sites) opens a support ticket; report sends a
|
|
3664
3943
|
sanitized diagnostic report after the user explicitly confirms it.
|
|
3665
3944
|
|
|
3666
|
-
Project directory contract:
|
|
3667
|
-
|
|
3668
|
-
|
|
3669
|
-
|
|
3670
|
-
|
|
3671
|
-
|
|
3672
|
-
|
|
3673
|
-
|
|
3945
|
+
Project directory contract: before the first deploy or a new recovery, initialize the intended
|
|
3946
|
+
project root once with "npx -y @sakupa/mcp@latest init"; this creates a non-secret
|
|
3947
|
+
.sakupa/project.json marker after the user confirms the canonical path. ONE initialized root =
|
|
3948
|
+
ONE site. Project-bound tools require projectDir, but it is only a path locator: it may point to
|
|
3949
|
+
the root or any existing child directory. The site-independent plans tool and public_recovery
|
|
3950
|
+
portal do not require a local project. Sakupa resolves upward to its own marker and stores
|
|
3951
|
+
.sakupa/site.json only at that authoritative root; it never uses package.json, .git, framework
|
|
3952
|
+
names or output-directory names to guess. For deploy, ALWAYS pass outputDir separately as the
|
|
3953
|
+
exact path RELATIVE to the resolved root (use "." when publishing the root); outputDir is
|
|
3954
|
+
required and may have ANY name, so inspect the current project. If it differs from the last
|
|
3955
|
+
successful publish directory, show the old and new paths and obtain explicit confirmation
|
|
3956
|
+
before retrying with outputDirChangeConfirmed: true.
|
|
3674
3957
|
After every deploy, TELL the user which environment it went to (deploy results carry an
|
|
3675
3958
|
Explicit Environment line: TEST vs PRODUCTION). analyze, deploy, status,
|
|
3676
3959
|
refresh and delete echo
|