@sakupa/mcp 0.7.32 → 0.7.33

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