@sakupa/mcp 0.7.31 → 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 +535 -156
- package/dist/index.js +432 -109
- 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 = {
|
|
@@ -781,7 +781,15 @@ var HttpApiClient = class {
|
|
|
781
781
|
};
|
|
782
782
|
|
|
783
783
|
// src/project-file.ts
|
|
784
|
-
import {
|
|
784
|
+
import {
|
|
785
|
+
chmodSync,
|
|
786
|
+
existsSync,
|
|
787
|
+
mkdirSync,
|
|
788
|
+
readFileSync,
|
|
789
|
+
rmdirSync,
|
|
790
|
+
rmSync,
|
|
791
|
+
writeFileSync
|
|
792
|
+
} from "node:fs";
|
|
785
793
|
import { dirname, join } from "node:path";
|
|
786
794
|
var SITE_DIR = ".sakupa";
|
|
787
795
|
var SITE_FILE = "site.json";
|
|
@@ -904,6 +912,10 @@ function deleteSiteFile(projectDir) {
|
|
|
904
912
|
if (existsSync(path)) {
|
|
905
913
|
rmSync(path, { force: true });
|
|
906
914
|
}
|
|
915
|
+
try {
|
|
916
|
+
rmdirSync(join(projectDir, SITE_DIR));
|
|
917
|
+
} catch {
|
|
918
|
+
}
|
|
907
919
|
}
|
|
908
920
|
function findAncestor(startDir, predicate, maxLevels = Number.POSITIVE_INFINITY) {
|
|
909
921
|
let cursor = startDir;
|
|
@@ -1205,7 +1217,7 @@ function normalizeOutputDir(outputDir) {
|
|
|
1205
1217
|
return normalized === "" ? "." : normalized;
|
|
1206
1218
|
}
|
|
1207
1219
|
async function analyzeProject(projectDir, opts = {}) {
|
|
1208
|
-
const root = resolve(projectDir);
|
|
1220
|
+
const root = await fs.realpath(resolve(projectDir));
|
|
1209
1221
|
const pkg = await readPackageJson(root);
|
|
1210
1222
|
const detection = await detectFramework(root, pkg);
|
|
1211
1223
|
const ssrRisks = [...detection?.ssrRisks ?? []];
|
|
@@ -1221,6 +1233,16 @@ async function analyzeProject(projectDir, opts = {}) {
|
|
|
1221
1233
|
outputDirExists = false;
|
|
1222
1234
|
} else {
|
|
1223
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
|
+
}
|
|
1224
1246
|
}
|
|
1225
1247
|
} else if (detection) {
|
|
1226
1248
|
for (const candidate of detection.outputCandidates) {
|
|
@@ -1344,9 +1366,258 @@ async function analyzeProject(projectDir, opts = {}) {
|
|
|
1344
1366
|
|
|
1345
1367
|
// src/tools/context.ts
|
|
1346
1368
|
import { z as z2 } from "zod";
|
|
1347
|
-
|
|
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";
|
|
1348
1384
|
import { homedir } from "node:os";
|
|
1349
|
-
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
|
+
}
|
|
1350
1621
|
|
|
1351
1622
|
// src/tools/result.ts
|
|
1352
1623
|
import { z } from "zod";
|
|
@@ -1396,36 +1667,33 @@ var LocalGuidanceError = class extends SakupaError {
|
|
|
1396
1667
|
}
|
|
1397
1668
|
};
|
|
1398
1669
|
var projectDirInput = z2.string().describe(
|
|
1399
|
-
"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."
|
|
1400
1671
|
);
|
|
1401
1672
|
function withProjectDir(ctx, projectDirArg) {
|
|
1402
1673
|
if (projectDirArg === void 0) {
|
|
1403
1674
|
throw new LocalGuidanceError(
|
|
1404
1675
|
"invalid_request",
|
|
1405
|
-
"projectDir is REQUIRED
|
|
1406
|
-
);
|
|
1407
|
-
}
|
|
1408
|
-
if (!isAbsolute(projectDirArg)) {
|
|
1409
|
-
throw new LocalGuidanceError(
|
|
1410
|
-
"invalid_request",
|
|
1411
|
-
`projectDir must be an ABSOLUTE path (got "${projectDirArg}"). Pass the full path of the directory the user is currently working in.`
|
|
1412
|
-
);
|
|
1413
|
-
}
|
|
1414
|
-
const dir = resolve2(projectDirArg);
|
|
1415
|
-
if (parse(dir).root === dir || dir === homedir()) {
|
|
1416
|
-
throw new LocalGuidanceError(
|
|
1417
|
-
"invalid_request",
|
|
1418
|
-
`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."
|
|
1419
1677
|
);
|
|
1420
1678
|
}
|
|
1421
|
-
|
|
1422
|
-
|
|
1423
|
-
|
|
1424
|
-
|
|
1425
|
-
|
|
1426
|
-
|
|
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;
|
|
1427
1696
|
}
|
|
1428
|
-
return { ...ctx, projectDir: dir };
|
|
1429
1697
|
}
|
|
1430
1698
|
function requireSiteFile(ctx) {
|
|
1431
1699
|
const state = loadSiteFile(ctx.projectDir);
|
|
@@ -1482,14 +1750,15 @@ function toolError(e) {
|
|
|
1482
1750
|
}
|
|
1483
1751
|
|
|
1484
1752
|
// src/tools/definitions.ts
|
|
1485
|
-
import { randomUUID } from "node:crypto";
|
|
1486
|
-
import {
|
|
1487
|
-
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";
|
|
1488
1756
|
import { z as z3 } from "zod";
|
|
1489
1757
|
|
|
1490
1758
|
// src/recovery-archive.ts
|
|
1759
|
+
import { existsSync as existsSync3, realpathSync as realpathSync2 } from "node:fs";
|
|
1491
1760
|
import { mkdtemp, mkdir, readFile, readdir, rename, rm, stat, writeFile } from "node:fs/promises";
|
|
1492
|
-
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";
|
|
1493
1762
|
|
|
1494
1763
|
// ../../node_modules/fflate/esm/index.mjs
|
|
1495
1764
|
import { createRequire } from "module";
|
|
@@ -1979,15 +2248,30 @@ function safeOutputPath(projectDir, outputDir) {
|
|
|
1979
2248
|
if (outputDir.length === 0 || isAbsolute2(outputDir)) {
|
|
1980
2249
|
throw new SakupaError("invalid_request", "Recovery outputDir must be a relative directory");
|
|
1981
2250
|
}
|
|
1982
|
-
const root = resolve3(projectDir);
|
|
2251
|
+
const root = realpathSync2(resolve3(projectDir));
|
|
1983
2252
|
const target = resolve3(root, outputDir);
|
|
1984
|
-
const rel =
|
|
1985
|
-
if (rel === "" || rel === ".." || rel.startsWith(`..${
|
|
2253
|
+
const rel = relative2(root, target);
|
|
2254
|
+
if (rel === "" || rel === ".." || rel.startsWith(`..${sep3}`) || isAbsolute2(rel)) {
|
|
1986
2255
|
throw new SakupaError("invalid_request", "Recovery outputDir must stay inside projectDir");
|
|
1987
2256
|
}
|
|
1988
|
-
if (rel === ".sakupa" || rel.startsWith(`.sakupa${
|
|
2257
|
+
if (rel === ".sakupa" || rel.startsWith(`.sakupa${sep3}`)) {
|
|
1989
2258
|
throw new SakupaError("invalid_request", "Recovery content cannot be written inside .sakupa");
|
|
1990
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
|
+
}
|
|
1991
2275
|
return target;
|
|
1992
2276
|
}
|
|
1993
2277
|
function safeEntryName(name) {
|
|
@@ -2016,9 +2300,9 @@ async function listExistingFiles(root, current = root) {
|
|
|
2016
2300
|
if (entry.isSymbolicLink()) {
|
|
2017
2301
|
throw new SakupaError("state_conflict", `Recovery output contains a symlink: ${entry.name}`);
|
|
2018
2302
|
}
|
|
2019
|
-
const absolute =
|
|
2303
|
+
const absolute = join4(current, entry.name);
|
|
2020
2304
|
if (entry.isDirectory()) files.push(...await listExistingFiles(root, absolute));
|
|
2021
|
-
else if (entry.isFile()) files.push(
|
|
2305
|
+
else if (entry.isFile()) files.push(relative2(root, absolute).split(sep3).join("/"));
|
|
2022
2306
|
else
|
|
2023
2307
|
throw new SakupaError(
|
|
2024
2308
|
"state_conflict",
|
|
@@ -2034,7 +2318,7 @@ async function existingOutputMatches(outputDir, files) {
|
|
|
2034
2318
|
return false;
|
|
2035
2319
|
}
|
|
2036
2320
|
for (const name of expected) {
|
|
2037
|
-
const actual = await readFile(
|
|
2321
|
+
const actual = await readFile(join4(outputDir, ...name.split("/")));
|
|
2038
2322
|
const wanted = files[name];
|
|
2039
2323
|
if (!wanted || actual.byteLength !== wanted.byteLength || !actual.equals(wanted)) return false;
|
|
2040
2324
|
}
|
|
@@ -2044,7 +2328,7 @@ async function extractRecoveryArchive(input) {
|
|
|
2044
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) {
|
|
2045
2329
|
throw new SakupaError("validation_failed", "Recovery archive metadata exceeds product limits");
|
|
2046
2330
|
}
|
|
2047
|
-
const outputDir = safeOutputPath(input.projectDir, input.outputDir
|
|
2331
|
+
const outputDir = safeOutputPath(input.projectDir, input.outputDir);
|
|
2048
2332
|
const maxArchiveBytes = input.expectedBytes + input.expectedFiles * 4096 + 65536;
|
|
2049
2333
|
if (input.archive.byteLength > maxArchiveBytes) {
|
|
2050
2334
|
throw new SakupaError("validation_failed", "Recovery archive is larger than its site metadata");
|
|
@@ -2085,14 +2369,14 @@ async function extractRecoveryArchive(input) {
|
|
|
2085
2369
|
`Recovery output already exists with different content: ${outputDir}. Move it aside or choose another outputDir.`
|
|
2086
2370
|
);
|
|
2087
2371
|
}
|
|
2088
|
-
const tempDir = await mkdtemp(
|
|
2372
|
+
const tempDir = await mkdtemp(join4(resolve3(input.projectDir), ".sakupa-restore-"));
|
|
2089
2373
|
try {
|
|
2090
2374
|
let writtenBytes = 0;
|
|
2091
2375
|
const entries = Object.entries(files).sort(([a], [b]) => a.localeCompare(b));
|
|
2092
2376
|
for (const [rawName, data] of entries) {
|
|
2093
2377
|
const name = safeEntryName(rawName);
|
|
2094
|
-
const destination =
|
|
2095
|
-
await mkdir(
|
|
2378
|
+
const destination = join4(tempDir, ...name.split("/"));
|
|
2379
|
+
await mkdir(dirname3(destination), { recursive: true });
|
|
2096
2380
|
await writeFile(destination, data, { flag: "wx" });
|
|
2097
2381
|
writtenBytes += data.byteLength;
|
|
2098
2382
|
}
|
|
@@ -2102,7 +2386,7 @@ async function extractRecoveryArchive(input) {
|
|
|
2102
2386
|
"Extracted recovery data does not match site metadata"
|
|
2103
2387
|
);
|
|
2104
2388
|
}
|
|
2105
|
-
await mkdir(
|
|
2389
|
+
await mkdir(dirname3(outputDir), { recursive: true });
|
|
2106
2390
|
await rename(tempDir, outputDir);
|
|
2107
2391
|
return {
|
|
2108
2392
|
outputDir,
|
|
@@ -2117,19 +2401,19 @@ async function extractRecoveryArchive(input) {
|
|
|
2117
2401
|
}
|
|
2118
2402
|
|
|
2119
2403
|
// src/creation-registry.ts
|
|
2120
|
-
import { existsSync as
|
|
2404
|
+
import { existsSync as existsSync4, mkdirSync as mkdirSync3, readFileSync as readFileSync3, writeFileSync as writeFileSync3 } from "node:fs";
|
|
2121
2405
|
import { homedir as homedir2 } from "node:os";
|
|
2122
|
-
import { dirname as
|
|
2406
|
+
import { dirname as dirname4, join as join5 } from "node:path";
|
|
2123
2407
|
var RECENT_WINDOW_MS = FREE_SITE_TTL_HOURS * 60 * 60 * 1e3;
|
|
2124
2408
|
function creationRegistryPath() {
|
|
2125
2409
|
const base = process.env["SAKUPA_STATE_DIR"] ?? homedir2();
|
|
2126
|
-
return
|
|
2410
|
+
return join5(base, ".sakupa", "created-sites.json");
|
|
2127
2411
|
}
|
|
2128
2412
|
function readAll() {
|
|
2129
2413
|
const path = creationRegistryPath();
|
|
2130
|
-
if (!
|
|
2414
|
+
if (!existsSync4(path)) return [];
|
|
2131
2415
|
try {
|
|
2132
|
-
const parsed = JSON.parse(
|
|
2416
|
+
const parsed = JSON.parse(readFileSync3(path, "utf-8"));
|
|
2133
2417
|
if (!Array.isArray(parsed)) return [];
|
|
2134
2418
|
return parsed.filter(
|
|
2135
2419
|
(e) => typeof e === "object" && e !== null && typeof e.siteId === "string" && typeof e.createdAt === "string"
|
|
@@ -2140,8 +2424,8 @@ function readAll() {
|
|
|
2140
2424
|
}
|
|
2141
2425
|
function writeAll(records) {
|
|
2142
2426
|
const path = creationRegistryPath();
|
|
2143
|
-
|
|
2144
|
-
|
|
2427
|
+
mkdirSync3(dirname4(path), { recursive: true });
|
|
2428
|
+
writeFileSync3(path, `${JSON.stringify(records, null, 2)}
|
|
2145
2429
|
`, "utf-8");
|
|
2146
2430
|
}
|
|
2147
2431
|
function listRecentCreations(nowMs, apiBaseUrl) {
|
|
@@ -2369,7 +2653,7 @@ function ensureUploadSizeWithinLimits(manifest, isFirstFreeDeploy) {
|
|
|
2369
2653
|
async function buildHashedManifest(files, outputAbs) {
|
|
2370
2654
|
const manifest = [];
|
|
2371
2655
|
for (const file of files) {
|
|
2372
|
-
const bytes = new Uint8Array(await fs2.readFile(
|
|
2656
|
+
const bytes = new Uint8Array(await fs2.readFile(join6(outputAbs, file.path)));
|
|
2373
2657
|
manifest.push({ path: file.path, size: file.size, contentHash: await sha256Hex(bytes) });
|
|
2374
2658
|
}
|
|
2375
2659
|
return manifest;
|
|
@@ -2388,7 +2672,7 @@ async function uploadAll(ctx, targets, files, outputAbs) {
|
|
|
2388
2672
|
`No local file matches upload target "${target.path}"; aborting upload.`
|
|
2389
2673
|
);
|
|
2390
2674
|
}
|
|
2391
|
-
const bytes = new Uint8Array(await fs2.readFile(
|
|
2675
|
+
const bytes = new Uint8Array(await fs2.readFile(join6(outputAbs, match.path)));
|
|
2392
2676
|
if (bytes.byteLength !== match.size) {
|
|
2393
2677
|
throw new SakupaError(
|
|
2394
2678
|
"validation_failed",
|
|
@@ -2431,20 +2715,6 @@ ${block}`, checklist: toDnsChecklist(diag.checks) };
|
|
|
2431
2715
|
};
|
|
2432
2716
|
}
|
|
2433
2717
|
}
|
|
2434
|
-
function projectRootAbove(projectDir) {
|
|
2435
|
-
if (existsSync3(join5(projectDir, "package.json"))) return null;
|
|
2436
|
-
return findAncestor(projectDir, (dir) => existsSync3(join5(dir, "package.json")), 4);
|
|
2437
|
-
}
|
|
2438
|
-
function findNeighborBinding(projectDir, outputRel) {
|
|
2439
|
-
const bound = (dir) => loadSiteFile(dir).kind !== "absent";
|
|
2440
|
-
const above = findAncestor(projectDir, bound, 3);
|
|
2441
|
-
if (above) return above;
|
|
2442
|
-
if (outputRel && outputRel !== ".") {
|
|
2443
|
-
const outputAbs = resolve4(projectDir, outputRel);
|
|
2444
|
-
if (bound(outputAbs)) return outputAbs;
|
|
2445
|
-
}
|
|
2446
|
-
return null;
|
|
2447
|
-
}
|
|
2448
2718
|
function freeSiteCreationBarrier(apiBaseUrl) {
|
|
2449
2719
|
const recent = listRecentCreations(Date.now(), apiBaseUrl);
|
|
2450
2720
|
if (recent.length < FREE_ACTIVE_SITES_PER_IP) return null;
|
|
@@ -2495,12 +2765,17 @@ Next action: ${analysis.suggestedNextAction}`,
|
|
|
2495
2765
|
server.registerTool(
|
|
2496
2766
|
"deploy",
|
|
2497
2767
|
{
|
|
2498
|
-
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. Never uploads anything when
|
|
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.`,
|
|
2499
2769
|
outputSchema: STRUCTURED_TOOL_OUTPUT_SCHEMA,
|
|
2500
2770
|
annotations: { readOnlyHint: false, destructiveHint: true, openWorldHint: true },
|
|
2501
2771
|
inputSchema: {
|
|
2502
2772
|
projectDir: projectDirInput,
|
|
2503
|
-
outputDir: z3.string().
|
|
2773
|
+
outputDir: z3.string().min(1).describe(
|
|
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."
|
|
2778
|
+
),
|
|
2504
2779
|
spaFallback: z3.boolean().optional().describe(
|
|
2505
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."
|
|
2506
2781
|
),
|
|
@@ -2508,7 +2783,7 @@ Next action: ${analysis.suggestedNextAction}`,
|
|
|
2508
2783
|
"Required only for the first deployment: user explicitly confirmed creation of a public 24-hour URL."
|
|
2509
2784
|
),
|
|
2510
2785
|
subprojectConfirmed: z3.boolean().optional().describe(
|
|
2511
|
-
"
|
|
2786
|
+
"Deprecated compatibility field. Project independence is established only by `sakupa-mcp init`, never inferred from package.json or folder names."
|
|
2512
2787
|
),
|
|
2513
2788
|
lang: z3.string().optional().describe("Site language override (en | ja | zh-CN); defaults to the html lang.")
|
|
2514
2789
|
}
|
|
@@ -2516,14 +2791,29 @@ Next action: ${analysis.suggestedNextAction}`,
|
|
|
2516
2791
|
async (args) => {
|
|
2517
2792
|
try {
|
|
2518
2793
|
const ctx = withProjectDir(baseCtx, args.projectDir);
|
|
2519
|
-
const analysis = await analyzeProject(ctx.projectDir, {
|
|
2520
|
-
...args.outputDir !== void 0 ? { outputDir: args.outputDir } : {}
|
|
2521
|
-
});
|
|
2794
|
+
const analysis = await analyzeProject(ctx.projectDir, { outputDir: args.outputDir });
|
|
2522
2795
|
if (!analysis.deployable || !analysis.files) {
|
|
2523
2796
|
return notDeployableResult(analysis);
|
|
2524
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
|
+
}
|
|
2525
2815
|
const files = analysis.files;
|
|
2526
|
-
const outputAbs = resolve4(ctx.projectDir,
|
|
2816
|
+
const outputAbs = resolve4(ctx.projectDir, effectiveOutputDir);
|
|
2527
2817
|
const manifest = await buildHashedManifest(files, outputAbs);
|
|
2528
2818
|
const siteFileState = loadSiteFile(ctx.projectDir);
|
|
2529
2819
|
if (siteFileState.kind === "corrupted") {
|
|
@@ -2536,26 +2826,33 @@ Next action: ${analysis.suggestedNextAction}`,
|
|
|
2536
2826
|
"blocked"
|
|
2537
2827
|
);
|
|
2538
2828
|
}
|
|
2539
|
-
|
|
2540
|
-
|
|
2541
|
-
|
|
2542
|
-
|
|
2829
|
+
let existing = siteFileState.kind === "ok" ? siteFileState.file : null;
|
|
2830
|
+
let credentialRelocatedFrom = null;
|
|
2831
|
+
if (!existing && effectiveOutputDir !== ".") {
|
|
2832
|
+
const outputProjectMarker = loadProjectMarker(outputAbs);
|
|
2833
|
+
if (outputProjectMarker.kind !== "absent") {
|
|
2543
2834
|
return text(
|
|
2544
|
-
"
|
|
2545
|
-
|
|
2546
|
-
{ projectRoot:
|
|
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 },
|
|
2547
2838
|
"blocked"
|
|
2548
2839
|
);
|
|
2549
2840
|
}
|
|
2550
|
-
const
|
|
2551
|
-
if (
|
|
2841
|
+
const outputSiteState = loadSiteFile(outputAbs);
|
|
2842
|
+
if (outputSiteState.kind === "corrupted") {
|
|
2552
2843
|
return text(
|
|
2553
|
-
"
|
|
2554
|
-
`
|
|
2555
|
-
{
|
|
2844
|
+
"output_site_file_corrupted",
|
|
2845
|
+
`A misplaced .sakupa/site.json exists in output directory ${outputAbs}, but it is damaged: ${outputSiteState.problem}. Repair that file before retrying with projectDir: ${ctx.projectDir}. Nothing was deployed and no site was created.`,
|
|
2846
|
+
{ projectRoot: ctx.projectDir, outputDir: analysis.recommendedOutputDir },
|
|
2556
2847
|
"blocked"
|
|
2557
2848
|
);
|
|
2558
2849
|
}
|
|
2850
|
+
if (outputSiteState.kind === "ok") {
|
|
2851
|
+
existing = outputSiteState.file;
|
|
2852
|
+
credentialRelocatedFrom = outputAbs;
|
|
2853
|
+
}
|
|
2854
|
+
}
|
|
2855
|
+
if (!existing) {
|
|
2559
2856
|
const barrier = freeSiteCreationBarrier(ctx.apiBaseUrl);
|
|
2560
2857
|
if (barrier) return barrier;
|
|
2561
2858
|
if (args.publicConfirmed !== true) {
|
|
@@ -2589,6 +2886,7 @@ Next action: ${analysis.suggestedNextAction}`,
|
|
|
2589
2886
|
createdAt,
|
|
2590
2887
|
apiBaseUrl: ctx.apiBaseUrl
|
|
2591
2888
|
});
|
|
2889
|
+
updateProjectOutputDir(ctx.projectDir, effectiveOutputDir);
|
|
2592
2890
|
recordCreation({
|
|
2593
2891
|
siteId: created.siteId,
|
|
2594
2892
|
projectDir: ctx.projectDir,
|
|
@@ -2663,6 +2961,9 @@ ${JSON.stringify(finalized2.warnings, null, 2)}` : ""),
|
|
|
2663
2961
|
}
|
|
2664
2962
|
const { uploaded, finalized } = update;
|
|
2665
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);
|
|
2666
2967
|
noteSiteMode(existing.siteId, finalized.mode);
|
|
2667
2968
|
return text(
|
|
2668
2969
|
"site_updated",
|
|
@@ -2671,6 +2972,7 @@ Environment: ${environmentFor(ctx.apiBaseUrl).toUpperCase()} (${ctx.apiBaseUrl})
|
|
|
2671
2972
|
Project directory: ${ctx.projectDir}
|
|
2672
2973
|
Files uploaded: ${uploaded} (${finalized.totalBytes} bytes)
|
|
2673
2974
|
` + (finalized.expiresAt ? `Validity refreshed \u2014 expires at: ${finalized.expiresAt}
|
|
2975
|
+
` : "") + (credentialRelocatedFrom ? `Credential binding relocated from ${credentialRelocatedFrom}/.sakupa to ${ctx.projectDir}/.sakupa; the existing site was preserved.
|
|
2674
2976
|
` : "") + (finalized.mode === "free" ? `
|
|
2675
2977
|
Reminder: free sites stay live for ${FREE_SITE_TTL_HOURS} hours after the last deploy or refresh call. Subscribing (subscribe) makes the site permanent.
|
|
2676
2978
|
` : "\nThis site is subscribed and permanent \u2014 no expiry.\n") + (finalized.warnings.length > 0 ? `
|
|
@@ -2685,7 +2987,8 @@ ${JSON.stringify(finalized.warnings, null, 2)}` : ""),
|
|
|
2685
2987
|
expiresAt: finalized.expiresAt,
|
|
2686
2988
|
filesUploaded: uploaded,
|
|
2687
2989
|
totalBytes: finalized.totalBytes,
|
|
2688
|
-
warnings: finalized.warnings
|
|
2990
|
+
warnings: finalized.warnings,
|
|
2991
|
+
...credentialRelocatedFrom ? { credentialRelocatedFrom } : {}
|
|
2689
2992
|
}
|
|
2690
2993
|
);
|
|
2691
2994
|
} catch (e) {
|
|
@@ -2769,7 +3072,7 @@ NO content was uploaded or changed by this call \u2014 to publish new or edited
|
|
|
2769
3072
|
{
|
|
2770
3073
|
siteId: site.siteId,
|
|
2771
3074
|
plan: args.plan,
|
|
2772
|
-
idempotencyKey:
|
|
3075
|
+
idempotencyKey: randomUUID2()
|
|
2773
3076
|
},
|
|
2774
3077
|
site.credential
|
|
2775
3078
|
);
|
|
@@ -2959,14 +3262,14 @@ Full status:`, res);
|
|
|
2959
3262
|
outputSchema: STRUCTURED_TOOL_OUTPUT_SCHEMA,
|
|
2960
3263
|
annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: true },
|
|
2961
3264
|
inputSchema: {
|
|
2962
|
-
projectDir: projectDirInput,
|
|
3265
|
+
projectDir: projectDirInput.optional(),
|
|
2963
3266
|
scope: z3.enum(["site", "public_recovery"])
|
|
2964
3267
|
}
|
|
2965
3268
|
},
|
|
2966
3269
|
async (args) => {
|
|
2967
3270
|
try {
|
|
2968
|
-
const ctx = withProjectDir(baseCtx, args.projectDir);
|
|
2969
3271
|
if (args.scope === "site") {
|
|
3272
|
+
const ctx = withProjectDir(baseCtx, args.projectDir);
|
|
2970
3273
|
const site = requireSiteFile(ctx);
|
|
2971
3274
|
const res2 = await ctx.client.createBillingPortal(site.siteId, site.credential);
|
|
2972
3275
|
return structuredToolResult({
|
|
@@ -2984,7 +3287,7 @@ Full status:`, res);
|
|
|
2984
3287
|
nextActions: [{ tool: "billing", allowed: true }]
|
|
2985
3288
|
});
|
|
2986
3289
|
}
|
|
2987
|
-
const res = await
|
|
3290
|
+
const res = await baseCtx.client.getPublicBillingPortal();
|
|
2988
3291
|
return structuredToolResult({
|
|
2989
3292
|
schemaVersion: 1,
|
|
2990
3293
|
outcome: "waiting_user",
|
|
@@ -3012,7 +3315,7 @@ Full status:`, res);
|
|
|
3012
3315
|
server.registerTool(
|
|
3013
3316
|
"recover",
|
|
3014
3317
|
{
|
|
3015
|
-
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.",
|
|
3016
3319
|
outputSchema: STRUCTURED_TOOL_OUTPUT_SCHEMA,
|
|
3017
3320
|
annotations: { readOnlyHint: false, destructiveHint: true, openWorldHint: true },
|
|
3018
3321
|
inputSchema: {
|
|
@@ -3020,13 +3323,21 @@ Full status:`, res);
|
|
|
3020
3323
|
action: z3.enum(["start", "status", "complete", "download"]),
|
|
3021
3324
|
hostname: z3.string().optional().describe("Required for start."),
|
|
3022
3325
|
verificationId: z3.string().optional().describe("For status or complete; inferred from local recovery state when omitted."),
|
|
3023
|
-
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
|
+
),
|
|
3024
3329
|
preserveExistingCredentials: z3.boolean().optional().describe("Explicitly keep old local credentials working (default: revoke them all).")
|
|
3025
3330
|
}
|
|
3026
3331
|
},
|
|
3027
3332
|
async (args) => {
|
|
3028
3333
|
try {
|
|
3029
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
|
+
}
|
|
3030
3341
|
const localSite = loadSiteFile(ctx.projectDir);
|
|
3031
3342
|
const localCredentialIsActive = async () => {
|
|
3032
3343
|
if (localSite.kind !== "ok") return false;
|
|
@@ -3039,12 +3350,18 @@ Full status:`, res);
|
|
|
3039
3350
|
}
|
|
3040
3351
|
};
|
|
3041
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
|
+
}
|
|
3042
3359
|
const site = requireSiteFile(ctx);
|
|
3043
3360
|
const archive = await ctx.client.getSiteArchive(site.siteId, site.credential);
|
|
3044
3361
|
const bytes = await ctx.client.downloadArchive(archive.archiveUrl);
|
|
3045
3362
|
const extracted = await extractRecoveryArchive({
|
|
3046
3363
|
projectDir: ctx.projectDir,
|
|
3047
|
-
|
|
3364
|
+
outputDir: args.outputDir,
|
|
3048
3365
|
archive: bytes,
|
|
3049
3366
|
expectedBytes: archive.totalBytes,
|
|
3050
3367
|
expectedFiles: archive.fileCount
|
|
@@ -3089,7 +3406,7 @@ No DNS verification was started or repeated.`,
|
|
|
3089
3406
|
arguments: {
|
|
3090
3407
|
projectDir: ctx.projectDir,
|
|
3091
3408
|
action: "download",
|
|
3092
|
-
outputDir: args.outputDir
|
|
3409
|
+
...args.outputDir !== void 0 ? { outputDir: args.outputDir } : {}
|
|
3093
3410
|
},
|
|
3094
3411
|
allowed: true
|
|
3095
3412
|
}
|
|
@@ -3189,7 +3506,7 @@ After the DNS record resolves, re-run recover with verificationId: "${res2.verif
|
|
|
3189
3506
|
arguments: {
|
|
3190
3507
|
projectDir: ctx.projectDir,
|
|
3191
3508
|
action: "download",
|
|
3192
|
-
outputDir: args.outputDir
|
|
3509
|
+
...args.outputDir !== void 0 ? { outputDir: args.outputDir } : {}
|
|
3193
3510
|
},
|
|
3194
3511
|
allowed: true
|
|
3195
3512
|
}
|
|
@@ -3225,7 +3542,7 @@ After the DNS record resolves, re-run recover with verificationId: "${res2.verif
|
|
|
3225
3542
|
projectDir: ctx.projectDir,
|
|
3226
3543
|
action: "complete",
|
|
3227
3544
|
verificationId,
|
|
3228
|
-
outputDir: args.outputDir
|
|
3545
|
+
...args.outputDir !== void 0 ? { outputDir: args.outputDir } : {}
|
|
3229
3546
|
},
|
|
3230
3547
|
allowed: res2.readyToComplete,
|
|
3231
3548
|
...res2.readyToComplete ? {} : { reasonCode: res2.status }
|
|
@@ -3301,7 +3618,7 @@ Files: ${extracted.fileCount}; bytes: ${extracted.totalBytes}
|
|
|
3301
3618
|
arguments: {
|
|
3302
3619
|
projectDir: ctx.projectDir,
|
|
3303
3620
|
action: "download",
|
|
3304
|
-
outputDir: args.outputDir
|
|
3621
|
+
...args.outputDir !== void 0 ? { outputDir: args.outputDir } : {}
|
|
3305
3622
|
},
|
|
3306
3623
|
allowed: true
|
|
3307
3624
|
}
|
|
@@ -3421,7 +3738,7 @@ Summary: ${res.sanitizedSummary}`,
|
|
|
3421
3738
|
}
|
|
3422
3739
|
|
|
3423
3740
|
// src/tools/lifecycle.ts
|
|
3424
|
-
import { randomUUID as
|
|
3741
|
+
import { randomUUID as randomUUID3 } from "node:crypto";
|
|
3425
3742
|
import { z as z4 } from "zod";
|
|
3426
3743
|
var deleteConfirmation = z4.object({
|
|
3427
3744
|
siteId: z4.string().min(1),
|
|
@@ -3456,7 +3773,7 @@ function registerLifecycleTools(server, baseCtx) {
|
|
|
3456
3773
|
try {
|
|
3457
3774
|
const ctx = withProjectDir(baseCtx, args.projectDir);
|
|
3458
3775
|
const site = requireSiteFile(ctx);
|
|
3459
|
-
const operationId = args.operationId ??
|
|
3776
|
+
const operationId = args.operationId ?? randomUUID3();
|
|
3460
3777
|
if (args.action === "preview") {
|
|
3461
3778
|
const preview = await ctx.client.previewDeleteSite(site.siteId, site.credential, {
|
|
3462
3779
|
operationId
|
|
@@ -3531,14 +3848,13 @@ function registerBillingTools(server, baseCtx) {
|
|
|
3531
3848
|
"plans",
|
|
3532
3849
|
{
|
|
3533
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.",
|
|
3534
|
-
inputSchema: {
|
|
3851
|
+
inputSchema: {},
|
|
3535
3852
|
outputSchema: STRUCTURED_TOOL_OUTPUT_SCHEMA,
|
|
3536
3853
|
annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: true }
|
|
3537
3854
|
},
|
|
3538
|
-
async (
|
|
3855
|
+
async () => {
|
|
3539
3856
|
try {
|
|
3540
|
-
const
|
|
3541
|
-
const catalog = await ctx.client.getBillingPlanCatalog();
|
|
3857
|
+
const catalog = await baseCtx.client.getBillingPlanCatalog();
|
|
3542
3858
|
return structuredToolResult({
|
|
3543
3859
|
schemaVersion: 1,
|
|
3544
3860
|
outcome: "completed",
|
|
@@ -3626,12 +3942,19 @@ Workflow:
|
|
|
3626
3942
|
5. support (subscribed sites) opens a support ticket; report sends a
|
|
3627
3943
|
sanitized diagnostic report after the user explicitly confirms it.
|
|
3628
3944
|
|
|
3629
|
-
Project directory contract:
|
|
3630
|
-
|
|
3631
|
-
|
|
3632
|
-
|
|
3633
|
-
|
|
3634
|
-
|
|
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.
|
|
3957
|
+
After every deploy, TELL the user which environment it went to (deploy results carry an
|
|
3635
3958
|
Explicit Environment line: TEST vs PRODUCTION). analyze, deploy, status,
|
|
3636
3959
|
refresh and delete echo
|
|
3637
3960
|
the directory they acted on \u2014 verify it matches the user's active project.
|