@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.
Files changed (3) hide show
  1. package/dist/bin.js +535 -156
  2. package/dist/index.js +432 -109
  3. 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.31";
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 { chmodSync, existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
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
- import { statSync } from "node:fs";
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 on every call: absolute path of the user's PROJECT ROOT \u2014 the folder the user opened/works in (for framework projects: where package.json lives, NEVER the build-output subfolder like dist/out; the analyzer locates the output automatically). .sakupa/site.json lives here, so PASS THE SAME DIRECTORY EVERY TIME for the same project. Only YOU know which directory the user is in \u2014 the server never guesses and refuses calls without it."
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 on every call: pass the absolute path of the directory the user is CURRENTLY working in. The server never guesses a directory \u2014 a wrong guess once published one project's files over a different project's PAID site."
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
- const stat2 = statSync(dir, { throwIfNoEntry: false });
1422
- if (!stat2?.isDirectory()) {
1423
- throw new LocalGuidanceError(
1424
- "invalid_request",
1425
- `projectDir "${dir}" does not exist or is not a directory. Pass the absolute path of the directory the user is currently working in.`
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 { existsSync as existsSync3, promises as fs2 } from "node:fs";
1487
- import { join as join5, resolve as resolve4 } from "node:path";
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 dirname2, isAbsolute as isAbsolute2, join as join3, relative, resolve as resolve3, sep as sep2 } from "node:path";
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 = relative(root, target);
1985
- if (rel === "" || rel === ".." || rel.startsWith(`..${sep2}`) || isAbsolute2(rel)) {
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${sep2}`)) {
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 = join3(current, entry.name);
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(relative(root, absolute).split(sep2).join("/"));
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(join3(outputDir, ...name.split("/")));
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 ?? "html");
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(join3(resolve3(input.projectDir), ".sakupa-restore-"));
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 = join3(tempDir, ...name.split("/"));
2095
- await mkdir(dirname2(destination), { recursive: true });
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(dirname2(outputDir), { recursive: true });
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 existsSync2, mkdirSync as mkdirSync2, readFileSync as readFileSync2, writeFileSync as writeFileSync2 } from "node:fs";
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 dirname3, join as join4 } from "node:path";
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 join4(base, ".sakupa", "created-sites.json");
2410
+ return join5(base, ".sakupa", "created-sites.json");
2127
2411
  }
2128
2412
  function readAll() {
2129
2413
  const path = creationRegistryPath();
2130
- if (!existsSync2(path)) return [];
2414
+ if (!existsSync4(path)) return [];
2131
2415
  try {
2132
- const parsed = JSON.parse(readFileSync2(path, "utf-8"));
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
- mkdirSync2(dirname3(path), { recursive: true });
2144
- writeFileSync2(path, `${JSON.stringify(records, null, 2)}
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(join5(outputAbs, file.path)));
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(join5(outputAbs, match.path)));
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 the analysis says the project is not deployable.`,
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().optional().describe("Output directory relative to the project root (overrides detection)."),
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
- "Only when creating a NEW site in a subfolder of a package.json project: the user explicitly confirmed this subfolder is an INDEPENDENT site, not the project's build output."
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, analysis.recommendedOutputDir ?? ".");
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
- const existing = siteFileState.kind === "ok" ? siteFileState.file : null;
2540
- if (!existing) {
2541
- const rootAbove = args.subprojectConfirmed === true ? null : projectRootAbove(ctx.projectDir);
2542
- if (rootAbove) {
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
- "not_project_root",
2545
- `${ctx.projectDir} has no package.json but ${rootAbove} does \u2014 this directory is a SUBFOLDER of that project (typically its build output), and .sakupa must live at the project ROOT. Re-run deploy with projectDir: ${rootAbove}. 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.`,
2546
- { projectRoot: rootAbove, confirmationField: "subprojectConfirmed" },
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 neighbor = findNeighborBinding(ctx.projectDir, analysis.recommendedOutputDir);
2551
- if (neighbor) {
2841
+ const outputSiteState = loadSiteFile(outputAbs);
2842
+ if (outputSiteState.kind === "corrupted") {
2552
2843
  return text(
2553
- "neighbor_binding_found",
2554
- `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.`,
2555
- { neighborProjectDir: neighbor },
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: randomUUID()
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 ctx.client.getPublicBillingPortal();
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 html without repeating DNS.",
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("Relative extraction directory for complete/download (default: html)."),
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
- ...args.outputDir !== void 0 ? { outputDir: args.outputDir } : {},
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 ?? "html"
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 ?? "html"
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 ?? "html"
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 ?? "html"
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 randomUUID2 } from "node:crypto";
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 ?? randomUUID2();
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: { projectDir: projectDirInput },
3851
+ inputSchema: {},
3535
3852
  outputSchema: STRUCTURED_TOOL_OUTPUT_SCHEMA,
3536
3853
  annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: true }
3537
3854
  },
3538
- async (args) => {
3855
+ async () => {
3539
3856
  try {
3540
- const ctx = withProjectDir(baseCtx, args.projectDir);
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: ONE directory = ONE site (its .sakupa/site.json holds the
3630
- binding). projectDir is REQUIRED on EVERY tool call \u2014 always pass the absolute path of
3631
- the user's PROJECT ROOT, the SAME directory every time for the same project: the folder
3632
- the user opened (for framework projects, where package.json lives \u2014 never the dist/out
3633
- build folder; output is auto-detected). The server NEVER guesses a directory and refuses
3634
- calls without one: only you can see which directory the user is actually in. After every deploy, TELL the user which environment it went to (deploy results carry an
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.