@sakupa/mcp 0.7.34 → 0.7.36

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 +918 -73
  2. package/dist/index.js +954 -76
  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.34";
127
+ var SAKUPA_MCP_VERSION = "0.7.36";
128
128
 
129
129
  // ../core/dist/domain/errors.js
130
130
  var HTTP_STATUS = {
@@ -1364,6 +1364,10 @@ async function analyzeProject(projectDir, opts = {}) {
1364
1364
  };
1365
1365
  }
1366
1366
 
1367
+ // src/project-binding.ts
1368
+ import { fileURLToPath } from "node:url";
1369
+ import { resolve as resolve3 } from "node:path";
1370
+
1367
1371
  // src/project-root.ts
1368
1372
  import { randomUUID } from "node:crypto";
1369
1373
  import {
@@ -1374,6 +1378,7 @@ import {
1374
1378
  readFileSync as readFileSync2,
1375
1379
  realpathSync,
1376
1380
  renameSync,
1381
+ rmdirSync as rmdirSync2,
1377
1382
  statSync,
1378
1383
  unlinkSync,
1379
1384
  writeFileSync as writeFileSync2
@@ -1435,6 +1440,37 @@ function loadProjectMarker(projectDir) {
1435
1440
  }
1436
1441
  };
1437
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
+ }
1438
1474
  function resolveLockedProjectRoot(projectDir) {
1439
1475
  const canonical = canonicalProjectDirectory(projectDir);
1440
1476
  assertSafeProjectRoot(canonical);
@@ -1480,6 +1516,14 @@ function updateProjectOutputDir(projectDir, outputDir) {
1480
1516
  writeMarkerAtomically(canonical, marker);
1481
1517
  return marker;
1482
1518
  }
1519
+ function deleteProjectMarker(projectDir) {
1520
+ const path = projectMarkerPath(projectDir);
1521
+ if (existsSync2(path)) unlinkSync(path);
1522
+ try {
1523
+ rmdirSync2(join3(projectDir, SAKUPA_DIR));
1524
+ } catch {
1525
+ }
1526
+ }
1483
1527
  function canonicalProjectDirectory(path) {
1484
1528
  const canonical = canonicalExistingPath(resolve2(path));
1485
1529
  if (!statSync(canonical).isDirectory()) {
@@ -1545,6 +1589,257 @@ function writeMarkerAtomically(projectDir, marker) {
1545
1589
  }
1546
1590
  }
1547
1591
 
1592
+ // src/project-binding.ts
1593
+ var ProjectBindingError = class extends Error {
1594
+ diagnostics;
1595
+ constructor(diagnostics) {
1596
+ super(diagnostics.guidance);
1597
+ this.name = "ProjectBindingError";
1598
+ this.diagnostics = diagnostics;
1599
+ }
1600
+ };
1601
+ var ProjectBindingResolver = class {
1602
+ constructor(processCwd, rootsProvider) {
1603
+ this.processCwd = processCwd;
1604
+ this.rootsProvider = rootsProvider;
1605
+ }
1606
+ bound;
1607
+ boundState;
1608
+ resolving;
1609
+ async resolve() {
1610
+ if (this.bound) return this.bound;
1611
+ if (this.resolving) return this.resolving;
1612
+ this.resolving = this.inspect().then((inspection) => {
1613
+ if (!inspection.selected) throw new ProjectBindingError(inspection.diagnostics);
1614
+ this.bound = inspection.selected;
1615
+ this.boundState = inspection.diagnostics;
1616
+ return inspection.selected;
1617
+ }).finally(() => {
1618
+ this.resolving = void 0;
1619
+ });
1620
+ return this.resolving;
1621
+ }
1622
+ async diagnose() {
1623
+ if (this.bound) return this.boundState ?? boundDiagnostics(this.processCwd, this.bound);
1624
+ const inspection = await this.inspect();
1625
+ if (inspection.selected) {
1626
+ this.bound = inspection.selected;
1627
+ this.boundState = inspection.diagnostics;
1628
+ }
1629
+ return inspection.diagnostics;
1630
+ }
1631
+ async initialize() {
1632
+ if (this.bound) return this.bound;
1633
+ const inspection = await this.inspect(true);
1634
+ if (inspection.selected) {
1635
+ this.bound = inspection.selected;
1636
+ this.boundState = inspection.diagnostics;
1637
+ return inspection.selected;
1638
+ }
1639
+ if (!inspection.initializableRoot) {
1640
+ throw new ProjectBindingError(inspection.diagnostics);
1641
+ }
1642
+ const initialized = initializeProject(inspection.initializableRoot);
1643
+ this.bound = { ...initialized, bindingSource: "mcp_root" };
1644
+ this.boundState = boundDiagnostics(
1645
+ this.processCwd,
1646
+ this.bound,
1647
+ { supported: true, roots: [] },
1648
+ inspection.diagnostics.rootCandidates
1649
+ );
1650
+ return this.bound;
1651
+ }
1652
+ async inspect(forInitialization = false) {
1653
+ const snapshot = await safeRootsSnapshot(this.rootsProvider);
1654
+ const rootCandidates = snapshot.roots.map(inspectRoot);
1655
+ const initializedRoots = rootCandidates.filter(
1656
+ (candidate) => candidate.initialized && candidate.path !== void 0
1657
+ );
1658
+ if (snapshot.supported && snapshot.error) {
1659
+ return {
1660
+ diagnostics: diagnostic(
1661
+ "roots_request_failed",
1662
+ snapshot,
1663
+ this.processCwd,
1664
+ rootCandidates,
1665
+ "The IDE advertised MCP Roots, but the Roots request failed. Retry help after the IDE finishes loading the workspace. If it persists, restart the MCP connection; do not initialize or deploy from the IDE installation directory."
1666
+ )
1667
+ };
1668
+ }
1669
+ if (initializedRoots.length === 1) {
1670
+ const initializedRoot = initializedRoots[0];
1671
+ if (!initializedRoot) throw new Error("initialized Root disappeared during resolution");
1672
+ const project = resolveLockedProjectRoot(initializedRoot.path);
1673
+ const selected = { ...project, bindingSource: "mcp_root" };
1674
+ return {
1675
+ selected,
1676
+ diagnostics: boundDiagnostics(this.processCwd, selected, snapshot, rootCandidates)
1677
+ };
1678
+ }
1679
+ if (initializedRoots.length > 1) {
1680
+ return {
1681
+ diagnostics: diagnostic(
1682
+ "multiple_initialized_roots",
1683
+ snapshot,
1684
+ this.processCwd,
1685
+ rootCandidates,
1686
+ "More than one IDE workspace Root is already initialized for Sakupa. Close the unrelated workspaces and retry help; Sakupa will not guess which site to manage."
1687
+ )
1688
+ };
1689
+ }
1690
+ const validRoots = rootCandidates.filter(
1691
+ (candidate) => candidate.path !== void 0 && candidate.problem === void 0
1692
+ );
1693
+ if (forInitialization && snapshot.supported) {
1694
+ if (validRoots.length === 1) {
1695
+ const validRoot = validRoots[0];
1696
+ if (!validRoot) throw new Error("workspace Root disappeared during initialization");
1697
+ return {
1698
+ initializableRoot: validRoot.path,
1699
+ diagnostics: diagnostic(
1700
+ "workspace_not_initialized",
1701
+ snapshot,
1702
+ this.processCwd,
1703
+ rootCandidates,
1704
+ `The active MCP workspace ${validRoot.path} is ready to initialize.`
1705
+ )
1706
+ };
1707
+ }
1708
+ if (validRoots.length > 1) {
1709
+ return {
1710
+ diagnostics: diagnostic(
1711
+ "multiple_uninitialized_roots",
1712
+ snapshot,
1713
+ this.processCwd,
1714
+ rootCandidates,
1715
+ "The IDE exposes multiple uninitialized workspace Roots. Open only the intended project before calling init; Sakupa will not choose a directory for the user."
1716
+ )
1717
+ };
1718
+ }
1719
+ }
1720
+ if (snapshot.supported && validRoots.length === 1) {
1721
+ const validRoot = validRoots[0];
1722
+ if (!validRoot) throw new Error("workspace Root disappeared during diagnosis");
1723
+ return {
1724
+ diagnostics: diagnostic(
1725
+ "workspace_not_initialized",
1726
+ snapshot,
1727
+ this.processCwd,
1728
+ rootCandidates,
1729
+ `The IDE workspace ${validRoot.path} is not initialized. Call init with no path arguments; it will create .sakupa directly in that workspace Root.`
1730
+ )
1731
+ };
1732
+ }
1733
+ if (snapshot.supported && validRoots.length > 1) {
1734
+ return {
1735
+ diagnostics: diagnostic(
1736
+ "multiple_uninitialized_roots",
1737
+ snapshot,
1738
+ this.processCwd,
1739
+ rootCandidates,
1740
+ "The IDE exposes multiple uninitialized workspace Roots. Open only the intended project, then call init. Sakupa will not guess a project directory."
1741
+ )
1742
+ };
1743
+ }
1744
+ if (snapshot.supported) {
1745
+ return {
1746
+ diagnostics: diagnostic(
1747
+ "workspace_not_initialized",
1748
+ snapshot,
1749
+ this.processCwd,
1750
+ rootCandidates,
1751
+ "The IDE did not expose one usable file workspace Root. Open exactly one local project workspace, then retry help before calling init or deploy."
1752
+ )
1753
+ };
1754
+ }
1755
+ try {
1756
+ const cwdProject = resolveLockedProjectRoot(this.processCwd);
1757
+ const selected = { ...cwdProject, bindingSource: "process_cwd" };
1758
+ return {
1759
+ selected,
1760
+ diagnostics: boundDiagnostics(this.processCwd, selected, snapshot, rootCandidates)
1761
+ };
1762
+ } catch {
1763
+ }
1764
+ const cwdProblem = inspectDirectory(this.processCwd);
1765
+ return {
1766
+ diagnostics: diagnostic(
1767
+ cwdProblem.problem ? "invalid_process_cwd" : "process_cwd_is_not_workspace",
1768
+ snapshot,
1769
+ this.processCwd,
1770
+ rootCandidates,
1771
+ "This IDE did not provide MCP Roots and the MCP process cwd is not an initialized project. Do not write into the IDE installation directory. Run `npx -y @sakupa/mcp@latest init` with no path arguments from the intended project terminal, then configure/restart the MCP process in that directory."
1772
+ )
1773
+ };
1774
+ }
1775
+ };
1776
+ function fileRootUriToPath(uri, windows = process.platform === "win32") {
1777
+ const parsed = new URL(uri);
1778
+ if (parsed.protocol !== "file:") throw new Error("Root URI is not a file URI");
1779
+ return fileURLToPath(parsed, { windows });
1780
+ }
1781
+ async function safeRootsSnapshot(provider) {
1782
+ if (!provider) return { supported: false, roots: [] };
1783
+ try {
1784
+ return await provider();
1785
+ } catch (error) {
1786
+ return {
1787
+ supported: true,
1788
+ roots: [],
1789
+ error: error instanceof Error ? error.message : String(error)
1790
+ };
1791
+ }
1792
+ }
1793
+ function inspectRoot(root) {
1794
+ try {
1795
+ const path = canonicalProjectDirectory(fileRootUriToPath(root.uri));
1796
+ const marker = loadProjectMarker(path);
1797
+ return {
1798
+ uri: root.uri,
1799
+ ...root.name !== void 0 ? { name: root.name } : {},
1800
+ path,
1801
+ initialized: marker.kind === "ok",
1802
+ ...marker.kind === "corrupted" ? { problem: marker.problem } : {}
1803
+ };
1804
+ } catch (error) {
1805
+ return {
1806
+ uri: root.uri,
1807
+ ...root.name !== void 0 ? { name: root.name } : {},
1808
+ initialized: false,
1809
+ problem: error instanceof Error ? error.message : String(error)
1810
+ };
1811
+ }
1812
+ }
1813
+ function inspectDirectory(path) {
1814
+ try {
1815
+ return { path: canonicalProjectDirectory(resolve3(path)) };
1816
+ } catch (error) {
1817
+ return { problem: error instanceof Error ? error.message : String(error) };
1818
+ }
1819
+ }
1820
+ function diagnostic(diagnosisCode, snapshot, processCwd, rootCandidates, guidance) {
1821
+ return {
1822
+ diagnosisCode,
1823
+ mcpRootsSupported: snapshot.supported,
1824
+ processCwd,
1825
+ rootCandidates,
1826
+ guidance,
1827
+ reportRecommended: false
1828
+ };
1829
+ }
1830
+ function boundDiagnostics(processCwd, selected, snapshot = { supported: false, roots: [] }, rootCandidates = []) {
1831
+ return {
1832
+ diagnosisCode: "project_bound",
1833
+ mcpRootsSupported: snapshot.supported,
1834
+ processCwd,
1835
+ rootCandidates,
1836
+ selectedProjectDir: selected.projectDir,
1837
+ bindingSource: selected.bindingSource,
1838
+ guidance: `Sakupa is locked to ${selected.projectDir} from ${selected.bindingSource}.`,
1839
+ reportRecommended: false
1840
+ };
1841
+ }
1842
+
1548
1843
  // src/tools/result.ts
1549
1844
  import { z } from "zod";
1550
1845
  var STRUCTURED_TOOL_OUTPUT_SCHEMA = {
@@ -1587,22 +1882,39 @@ function structuredToolResult(envelope) {
1587
1882
  }
1588
1883
 
1589
1884
  // src/tools/context.ts
1885
+ import { randomUUID as randomUUID2 } from "node:crypto";
1590
1886
  var LocalGuidanceError = class extends SakupaError {
1591
1887
  constructor(code, message) {
1592
1888
  super(code, message);
1593
1889
  }
1594
1890
  };
1595
- function withProjectDir(ctx) {
1891
+ var fallbackResolvers = /* @__PURE__ */ new WeakMap();
1892
+ var reportAuthorizations = /* @__PURE__ */ new WeakMap();
1893
+ function resolverFor(ctx) {
1894
+ if (ctx.projectBinding) return ctx.projectBinding;
1895
+ let resolver = fallbackResolvers.get(ctx);
1896
+ if (!resolver) {
1897
+ resolver = new ProjectBindingResolver(ctx.projectDir, ctx.rootsProvider);
1898
+ fallbackResolvers.set(ctx, resolver);
1899
+ }
1900
+ return resolver;
1901
+ }
1902
+ async function withProjectDir(ctx) {
1596
1903
  try {
1597
- const resolved = resolveLockedProjectRoot(ctx.projectDir);
1904
+ const binding = await resolverFor(ctx).resolve();
1905
+ const resolved = resolveLockedProjectRoot(binding.projectDir);
1598
1906
  return {
1599
1907
  ...ctx,
1600
1908
  projectDir: resolved.projectDir,
1601
1909
  requestedPath: resolved.requestedPath,
1602
1910
  markerKind: resolved.markerKind,
1603
- projectMarker: resolved.marker
1911
+ projectMarker: resolved.marker,
1912
+ bindingSource: binding.bindingSource
1604
1913
  };
1605
1914
  } catch (error) {
1915
+ if (error instanceof ProjectBindingError) {
1916
+ throw new LocalGuidanceError("not_found", error.message);
1917
+ }
1606
1918
  if (error instanceof ProjectRootError) {
1607
1919
  throw new LocalGuidanceError(
1608
1920
  error.code === "not_initialized" ? "not_found" : "invalid_request",
@@ -1612,6 +1924,60 @@ function withProjectDir(ctx) {
1612
1924
  throw error;
1613
1925
  }
1614
1926
  }
1927
+ async function diagnoseProjectBinding(ctx) {
1928
+ return resolverFor(ctx).diagnose();
1929
+ }
1930
+ async function initializeWorkspaceProject(ctx) {
1931
+ try {
1932
+ const resolved = await resolverFor(ctx).initialize();
1933
+ return {
1934
+ ...ctx,
1935
+ projectDir: resolved.projectDir,
1936
+ requestedPath: resolved.requestedPath,
1937
+ markerKind: resolved.markerKind,
1938
+ projectMarker: resolved.marker,
1939
+ bindingSource: resolved.bindingSource
1940
+ };
1941
+ } catch (error) {
1942
+ if (error instanceof ProjectBindingError) {
1943
+ throw new LocalGuidanceError("invalid_request", error.message);
1944
+ }
1945
+ throw error;
1946
+ }
1947
+ }
1948
+ async function optionalProjectContext(ctx) {
1949
+ try {
1950
+ return await withProjectDir(ctx);
1951
+ } catch {
1952
+ return null;
1953
+ }
1954
+ }
1955
+ function reportAuthorizationStore(ctx) {
1956
+ let store = reportAuthorizations.get(ctx);
1957
+ if (!store) {
1958
+ store = /* @__PURE__ */ new Map();
1959
+ reportAuthorizations.set(ctx, store);
1960
+ }
1961
+ return store;
1962
+ }
1963
+ function issueReportAuthorization(ctx, failedTool) {
1964
+ const token = randomUUID2();
1965
+ reportAuthorizationStore(ctx).set(token, {
1966
+ failedTool,
1967
+ expiresAt: Date.now() + 10 * 60 * 1e3
1968
+ });
1969
+ return token;
1970
+ }
1971
+ function requireReportAuthorization(ctx, token, failedTool) {
1972
+ const authorization = token ? reportAuthorizationStore(ctx).get(token) : void 0;
1973
+ if (!authorization || authorization.expiresAt < Date.now() || authorization.failedTool !== failedTool) {
1974
+ if (token) reportAuthorizationStore(ctx).delete(token);
1975
+ throw new LocalGuidanceError(
1976
+ "invalid_request",
1977
+ 'report is the last resort. Run help with topic:"diagnose", failedTool and the error code first. Only when help returns reportRecommended:true, copy its helpAuthorization into report.'
1978
+ );
1979
+ }
1980
+ }
1615
1981
  function requireSiteFile(ctx) {
1616
1982
  const state = loadSiteFile(ctx.projectDir);
1617
1983
  if (state.kind === "corrupted") {
@@ -1650,7 +2016,7 @@ function toolError(e) {
1650
2016
  ) : void 0;
1651
2017
  const minimumVersion = rawDetails && typeof rawDetails["minimumVersion"] === "string" ? rawDetails["minimumVersion"] : void 0;
1652
2018
  const serverGuidance = isSakupaError(e) && errorCode !== "internal" && errorCode !== "unauthorized" && errorCode !== "upgrade_required" && e.message.trim().length > 0 ? e.message : void 0;
1653
- const safeSummary = e instanceof LocalGuidanceError ? e.message : errorCode === "upgrade_required" ? `This Sakupa MCP client is v${MCP_VERSION}, older than the server's minimum supported version${minimumVersion !== void 0 ? ` (v${minimumVersion})` : ""}, so the server refused the call. To fix it: ask the user to fully restart their MCP client session \u2014 "npx -y @sakupa/mcp@latest" setups fetch the current version on restart (run "npx clear-npx-cache" first if the old version persists); global installs need "npm install -g @sakupa/mcp@latest". After the restart, retry this exact tool call.` : errorCode === "unauthorized" ? UNAUTHORIZED_SUMMARY : serverGuidance ?? (retryable ? "An upstream service is temporarily unavailable or busy; retry shortly." : opaqueUnclassified ? "This failed with an error Sakupa could not classify, and retrying will not help. Tell the user briefly, then offer to file it: run the report tool to send Sakupa a diagnostic report of what was being attempted so we can investigate." : "The operation failed; no server-internal details are exposed.");
2019
+ const safeSummary = e instanceof LocalGuidanceError ? e.message : errorCode === "upgrade_required" ? `This Sakupa MCP client is v${MCP_VERSION}, older than the server's minimum supported version${minimumVersion !== void 0 ? ` (v${minimumVersion})` : ""}, so the server refused the call. To fix it: ask the user to fully restart their MCP client session \u2014 "npx -y @sakupa/mcp@latest" setups fetch the current version on restart (run "npx clear-npx-cache" first if the old version persists); global installs need "npm install -g @sakupa/mcp@latest". After the restart, retry this exact tool call.` : errorCode === "unauthorized" ? UNAUTHORIZED_SUMMARY : serverGuidance ?? (retryable ? "An upstream service is temporarily unavailable or busy; retry shortly." : opaqueUnclassified ? "This failed with an error Sakupa could not classify, and retrying the same call will not help. Run help with the failed tool and error code first; only use report if help explicitly recommends it." : "The operation failed; no server-internal details are exposed.");
1654
2020
  const result = structuredToolResult({
1655
2021
  schemaVersion: 1,
1656
2022
  outcome: "failed",
@@ -1661,21 +2027,28 @@ function toolError(e) {
1661
2027
  retryable,
1662
2028
  ...safeDetails && Object.keys(safeDetails).length > 0 ? { details: safeDetails } : {}
1663
2029
  },
1664
- nextActions: []
2030
+ nextActions: [
2031
+ {
2032
+ tool: "help",
2033
+ arguments: { topic: "diagnose", failedTool: "unknown", errorCode },
2034
+ allowed: true,
2035
+ reasonCode: "diagnose_before_report"
2036
+ }
2037
+ ]
1665
2038
  });
1666
2039
  return { ...result, isError: true };
1667
2040
  }
1668
2041
 
1669
2042
  // src/tools/definitions.ts
1670
- import { randomUUID as randomUUID2 } from "node:crypto";
2043
+ import { randomUUID as randomUUID3 } from "node:crypto";
1671
2044
  import { promises as fs2 } from "node:fs";
1672
- import { join as join6, resolve as resolve4 } from "node:path";
2045
+ import { join as join6, relative as relative3, resolve as resolve5, sep as sep4 } from "node:path";
1673
2046
  import { z as z2 } from "zod";
1674
2047
 
1675
2048
  // src/recovery-archive.ts
1676
2049
  import { existsSync as existsSync3, realpathSync as realpathSync2 } from "node:fs";
1677
2050
  import { mkdtemp, mkdir, readFile, readdir, rename, rm, stat, writeFile } from "node:fs/promises";
1678
- import { dirname as dirname2, isAbsolute as isAbsolute2, join as join4, relative as relative2, resolve as resolve3, sep as sep3 } from "node:path";
2051
+ import { dirname as dirname2, isAbsolute as isAbsolute2, join as join4, relative as relative2, resolve as resolve4, sep as sep3 } from "node:path";
1679
2052
 
1680
2053
  // ../../node_modules/fflate/esm/index.mjs
1681
2054
  import { createRequire } from "module";
@@ -2094,15 +2467,15 @@ function strFromU8(dat, latin1) {
2094
2467
  var slzh = function(d, b) {
2095
2468
  return b + 30 + b2(d, b + 26) + b2(d, b + 28);
2096
2469
  };
2097
- var zh = function(d, b, z5) {
2470
+ var zh = function(d, b, z6) {
2098
2471
  var fnl = b2(d, b + 28), efl = b2(d, b + 30), fn = strFromU8(d.subarray(b + 46, b + 46 + fnl), !(b2(d, b + 8) & 2048)), es = b + 46 + fnl;
2099
- var _a2 = z64hs(d, es, efl, z5, b4(d, b + 20), b4(d, b + 24), b4(d, b + 42)), sc = _a2[0], su = _a2[1], off = _a2[2];
2472
+ var _a2 = z64hs(d, es, efl, z6, b4(d, b + 20), b4(d, b + 24), b4(d, b + 42)), sc = _a2[0], su = _a2[1], off = _a2[2];
2100
2473
  return [b2(d, b + 10), sc, su, fn, es + efl + b2(d, b + 32), off];
2101
2474
  };
2102
- var z64hs = function(d, b, l, z5, sc, su, off) {
2475
+ var z64hs = function(d, b, l, z6, sc, su, off) {
2103
2476
  var nsc = sc == 4294967295, nsu = su == 4294967295, noff = off == 4294967295, e = b + l;
2104
2477
  var nf = nsc + nsu + noff;
2105
- if (z5 && nf) {
2478
+ if (z6 && nf) {
2106
2479
  for (; b + 4 < e; b += 4 + b2(d, b + 2)) {
2107
2480
  if (b2(d, b) == 1) {
2108
2481
  return [
@@ -2113,7 +2486,7 @@ var z64hs = function(d, b, l, z5, sc, su, off) {
2113
2486
  ];
2114
2487
  }
2115
2488
  }
2116
- if (z5 < 2)
2489
+ if (z6 < 2)
2117
2490
  err(13);
2118
2491
  }
2119
2492
  return [sc, su, off, 0];
@@ -2130,18 +2503,18 @@ function unzipSync(data, opts) {
2130
2503
  if (!c)
2131
2504
  return {};
2132
2505
  var o = b4(data, e + 16);
2133
- var z5 = b4(data, e - 20) == 117853008;
2134
- if (z5) {
2506
+ var z6 = b4(data, e - 20) == 117853008;
2507
+ if (z6) {
2135
2508
  var ze = b4(data, e - 12);
2136
- z5 = b4(data, ze) == 101075792;
2137
- if (z5) {
2509
+ z6 = b4(data, ze) == 101075792;
2510
+ if (z6) {
2138
2511
  c = b4(data, ze + 32);
2139
2512
  o = b4(data, ze + 48);
2140
2513
  }
2141
2514
  }
2142
2515
  var fltr = opts && opts.filter;
2143
2516
  for (var i = 0; i < c; ++i) {
2144
- var _a2 = zh(data, o, z5), c_2 = _a2[0], sc = _a2[1], su = _a2[2], fn = _a2[3], no = _a2[4], off = _a2[5], b = slzh(data, off);
2517
+ var _a2 = zh(data, o, z6), c_2 = _a2[0], sc = _a2[1], su = _a2[2], fn = _a2[3], no = _a2[4], off = _a2[5], b = slzh(data, off);
2145
2518
  o = no;
2146
2519
  if (!fltr || fltr({
2147
2520
  name: fn,
@@ -2165,8 +2538,8 @@ function safeOutputPath(projectDir, outputDir) {
2165
2538
  if (outputDir.length === 0 || isAbsolute2(outputDir)) {
2166
2539
  throw new SakupaError("invalid_request", "Recovery outputDir must be a relative directory");
2167
2540
  }
2168
- const root = realpathSync2(resolve3(projectDir));
2169
- const target = resolve3(root, outputDir);
2541
+ const root = realpathSync2(resolve4(projectDir));
2542
+ const target = resolve4(root, outputDir);
2170
2543
  const rel = relative2(root, target);
2171
2544
  if (rel === "" || rel === ".." || rel.startsWith(`..${sep3}`) || isAbsolute2(rel)) {
2172
2545
  throw new SakupaError("invalid_request", "Recovery outputDir must stay inside projectDir");
@@ -2181,7 +2554,7 @@ function safeOutputPath(projectDir, outputDir) {
2181
2554
  existingAncestor = parent;
2182
2555
  }
2183
2556
  const physicalAncestor = realpathSync2(existingAncestor);
2184
- const physicalTarget = resolve3(physicalAncestor, relative2(existingAncestor, target));
2557
+ const physicalTarget = resolve4(physicalAncestor, relative2(existingAncestor, target));
2185
2558
  const physicalRel = relative2(root, physicalTarget);
2186
2559
  if (physicalRel === ".." || physicalRel.startsWith(`..${sep3}`) || isAbsolute2(physicalRel)) {
2187
2560
  throw new SakupaError(
@@ -2286,7 +2659,7 @@ async function extractRecoveryArchive(input) {
2286
2659
  `Recovery output already exists with different content: ${outputDir}. Move it aside or choose another outputDir.`
2287
2660
  );
2288
2661
  }
2289
- const tempDir = await mkdtemp(join4(resolve3(input.projectDir), ".sakupa-restore-"));
2662
+ const tempDir = await mkdtemp(join4(resolve4(input.projectDir), ".sakupa-restore-"));
2290
2663
  try {
2291
2664
  let writtenBytes = 0;
2292
2665
  const entries = Object.entries(files).sort(([a], [b]) => a.localeCompare(b));
@@ -2649,6 +3022,27 @@ If this list is stale (sites deleted or subscribed from another machine), remove
2649
3022
  "blocked"
2650
3023
  );
2651
3024
  }
3025
+ function outputDirectoryChain(projectRoot, outputAbs) {
3026
+ const rel = relative3(projectRoot, outputAbs);
3027
+ if (rel === "" || rel === ".") return [];
3028
+ if (rel === ".." || rel.startsWith(`..${sep4}`)) return [];
3029
+ const chain = [];
3030
+ let cursor = projectRoot;
3031
+ for (const part of rel.split(sep4).filter(Boolean)) {
3032
+ cursor = join6(cursor, part);
3033
+ chain.push(cursor);
3034
+ }
3035
+ return chain;
3036
+ }
3037
+ async function sakupaDirectoryEntries(projectDir) {
3038
+ try {
3039
+ return await fs2.readdir(join6(projectDir, ".sakupa"));
3040
+ } catch (error) {
3041
+ const code = error.code;
3042
+ if (code === "ENOENT") return [];
3043
+ throw error;
3044
+ }
3045
+ }
2652
3046
  function registerTools(server, baseCtx) {
2653
3047
  const previewHostPattern = previewHostPatternFor(baseCtx.apiBaseUrl);
2654
3048
  server.registerTool(
@@ -2663,7 +3057,7 @@ function registerTools(server, baseCtx) {
2663
3057
  },
2664
3058
  async (args) => {
2665
3059
  try {
2666
- const ctx = withProjectDir(baseCtx);
3060
+ const ctx = await withProjectDir(baseCtx);
2667
3061
  const analysis = await analyzeProject(ctx.projectDir, {
2668
3062
  ...args.outputDir !== void 0 ? { outputDir: args.outputDir } : {}
2669
3063
  });
@@ -2691,6 +3085,9 @@ Next action: ${analysis.suggestedNextAction}`,
2691
3085
  outputDirChangeConfirmed: z2.boolean().optional().describe(
2692
3086
  "Required only when changing the previously successful publish directory. Confirm only after showing the old and new directories to the user."
2693
3087
  ),
3088
+ sakupaRelocationConfirmed: z2.boolean().optional().describe(
3089
+ "Required only when a nested directory is itself initialized with .sakupa/project.json. Confirm only after showing the source and authoritative project Root; Sakupa then migrates non-conflicting state without exposing credentials."
3090
+ ),
2694
3091
  spaFallback: z2.boolean().optional().describe(
2695
3092
  "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."
2696
3093
  ),
@@ -2705,14 +3102,14 @@ Next action: ${analysis.suggestedNextAction}`,
2705
3102
  },
2706
3103
  async (args) => {
2707
3104
  try {
2708
- const ctx = withProjectDir(baseCtx);
3105
+ const ctx = await withProjectDir(baseCtx);
2709
3106
  const analysis = await analyzeProject(ctx.projectDir, { outputDir: args.outputDir });
2710
3107
  if (!analysis.deployable || !analysis.files) {
2711
3108
  return notDeployableResult(analysis);
2712
3109
  }
2713
3110
  const effectiveOutputDir = analysis.recommendedOutputDir ?? ".";
2714
3111
  const recordedOutputDir = ctx.projectMarker?.outputDir;
2715
- if (recordedOutputDir !== void 0 && resolve4(ctx.projectDir, recordedOutputDir) !== resolve4(ctx.projectDir, effectiveOutputDir) && args.outputDirChangeConfirmed !== true) {
3112
+ if (recordedOutputDir !== void 0 && resolve5(ctx.projectDir, recordedOutputDir) !== resolve5(ctx.projectDir, effectiveOutputDir) && args.outputDirChangeConfirmed !== true) {
2716
3113
  return structuredToolResult({
2717
3114
  schemaVersion: 1,
2718
3115
  outcome: "waiting_user",
@@ -2728,7 +3125,7 @@ Next action: ${analysis.suggestedNextAction}`,
2728
3125
  });
2729
3126
  }
2730
3127
  const files = analysis.files;
2731
- const outputAbs = resolve4(ctx.projectDir, effectiveOutputDir);
3128
+ const outputAbs = resolve5(ctx.projectDir, effectiveOutputDir);
2732
3129
  const manifest = await buildHashedManifest(files, outputAbs);
2733
3130
  const siteFileState = loadSiteFile(ctx.projectDir);
2734
3131
  if (siteFileState.kind === "corrupted") {
@@ -2742,30 +3139,157 @@ Next action: ${analysis.suggestedNextAction}`,
2742
3139
  );
2743
3140
  }
2744
3141
  let existing = siteFileState.kind === "ok" ? siteFileState.file : null;
2745
- let credentialRelocatedFrom = null;
2746
- if (!existing && effectiveOutputDir !== ".") {
2747
- const outputProjectMarker = loadProjectMarker(outputAbs);
2748
- if (outputProjectMarker.kind !== "absent") {
3142
+ const credentialRelocatedFrom = [];
3143
+ const markerRelocatedFrom = [];
3144
+ const nestedSiteFiles = [];
3145
+ const nestedRecoveryFiles = [];
3146
+ for (const candidateDir of outputDirectoryChain(ctx.projectDir, outputAbs)) {
3147
+ const entries = await sakupaDirectoryEntries(candidateDir);
3148
+ if (entries.length === 0) continue;
3149
+ const unknownEntries = entries.filter(
3150
+ (entry) => !["project.json", "site.json", "recovery.json"].includes(entry)
3151
+ );
3152
+ if (unknownEntries.length > 0) {
2749
3153
  return text(
2750
- "publish_directory_is_independent_project",
2751
- outputProjectMarker.kind === "corrupted" ? `The selected publish directory ${outputAbs} contains a damaged Sakupa project marker: ${outputProjectMarker.problem}. Nothing was deployed.` : `The selected publish directory ${outputAbs} is itself an explicitly initialized Sakupa project. Refusing to move or reuse its credential from ${ctx.projectDir}. Run deploy from that independent project instead, or choose a publish directory that is not another Sakupa project.`,
2752
- { projectRoot: ctx.projectDir, outputDir: effectiveOutputDir },
3154
+ "nested_sakupa_contains_unknown_files",
3155
+ `A nested .sakupa directory at ${candidateDir} contains unknown files ${JSON.stringify(unknownEntries)}. Nothing was moved or deployed. Run help; Sakupa will never delete unrecognized user files.`,
3156
+ { projectRoot: ctx.projectDir, nestedDirectory: candidateDir, unknownEntries },
2753
3157
  "blocked"
2754
3158
  );
2755
3159
  }
2756
- const outputSiteState = loadSiteFile(outputAbs);
2757
- if (outputSiteState.kind === "corrupted") {
3160
+ const nestedMarker = loadProjectMarker(candidateDir);
3161
+ if (nestedMarker.kind === "corrupted") {
2758
3162
  return text(
2759
- "output_site_file_corrupted",
2760
- `A misplaced .sakupa/site.json exists in output directory ${outputAbs}, but it is damaged: ${outputSiteState.problem}. Repair that file before retrying with the MCP still opened at ${ctx.projectDir}. Nothing was deployed and no site was created.`,
2761
- { projectRoot: ctx.projectDir, outputDir: analysis.recommendedOutputDir },
3163
+ "nested_project_marker_corrupted",
3164
+ `The nested .sakupa/project.json at ${candidateDir} is damaged: ${nestedMarker.problem}. Nothing was moved or deployed.`,
3165
+ { projectRoot: ctx.projectDir, nestedDirectory: candidateDir },
2762
3166
  "blocked"
2763
3167
  );
2764
3168
  }
2765
- if (outputSiteState.kind === "ok") {
2766
- existing = outputSiteState.file;
2767
- credentialRelocatedFrom = outputAbs;
3169
+ if (nestedMarker.kind === "ok") {
3170
+ if (args.sakupaRelocationConfirmed !== true) {
3171
+ return structuredToolResult({
3172
+ schemaVersion: 1,
3173
+ outcome: "waiting_user",
3174
+ resultCode: "sakupa_relocation_confirmation_required",
3175
+ summary: `A nested Sakupa project marker exists at ${candidateDir}/.sakupa, but the active MCP Root is ${ctx.projectDir}. Nothing was moved or deployed. Show both paths to the user; after confirmation retry deploy with sakupaRelocationConfirmed:true. Sakupa will preserve credentials and refuse conflicts.`,
3176
+ data: {
3177
+ projectRoot: ctx.projectDir,
3178
+ misplacedSakupaDirectory: join6(candidateDir, ".sakupa"),
3179
+ targetSakupaDirectory: join6(ctx.projectDir, ".sakupa"),
3180
+ confirmationField: "sakupaRelocationConfirmed"
3181
+ },
3182
+ nextActions: [
3183
+ {
3184
+ tool: "deploy",
3185
+ allowed: true,
3186
+ reasonCode: "explicit_sakupa_relocation_confirmation"
3187
+ }
3188
+ ]
3189
+ });
3190
+ }
3191
+ markerRelocatedFrom.push(candidateDir);
2768
3192
  }
3193
+ const nestedSite = loadSiteFile(candidateDir);
3194
+ if (nestedSite.kind === "corrupted") {
3195
+ return text(
3196
+ "output_site_file_corrupted",
3197
+ `A misplaced .sakupa/site.json exists at ${candidateDir}, but it is damaged: ${nestedSite.problem}. Nothing was moved or deployed.`,
3198
+ { projectRoot: ctx.projectDir, nestedDirectory: candidateDir },
3199
+ "blocked"
3200
+ );
3201
+ }
3202
+ if (nestedSite.kind === "ok")
3203
+ nestedSiteFiles.push({ dir: candidateDir, file: nestedSite.file });
3204
+ const nestedRecovery = loadRecoveryFile(candidateDir);
3205
+ if (nestedRecovery) nestedRecoveryFiles.push({ dir: candidateDir, file: nestedRecovery });
3206
+ }
3207
+ const allSiteFiles = [
3208
+ ...existing ? [{ dir: ctx.projectDir, file: existing }] : [],
3209
+ ...nestedSiteFiles
3210
+ ];
3211
+ const uniqueSiteBindings = new Set(
3212
+ allSiteFiles.map(({ file }) => `${file.siteId}\0${file.credential}`)
3213
+ );
3214
+ if (uniqueSiteBindings.size > 1) {
3215
+ return text(
3216
+ "sakupa_relocation_conflict",
3217
+ "The project Root and nested publish path contain different Sakupa site bindings. Nothing was moved or deployed; run help. Sakupa will never overwrite one site credential with another.",
3218
+ {
3219
+ projectRoot: ctx.projectDir,
3220
+ conflictingDirectories: allSiteFiles.map(({ dir }) => dir)
3221
+ },
3222
+ "blocked"
3223
+ );
3224
+ }
3225
+ if (!existing && nestedSiteFiles[0]) existing = nestedSiteFiles[0].file;
3226
+ credentialRelocatedFrom.push(...nestedSiteFiles.map(({ dir }) => dir));
3227
+ const rootRecovery = loadRecoveryFile(ctx.projectDir);
3228
+ const allRecoveries = [
3229
+ ...rootRecovery ? [{ dir: ctx.projectDir, file: rootRecovery }] : [],
3230
+ ...nestedRecoveryFiles
3231
+ ];
3232
+ const uniqueRecoveries = new Set(
3233
+ allRecoveries.map(({ file }) => `${file.verificationId}\0${file.credential}`)
3234
+ );
3235
+ if (uniqueRecoveries.size > 1) {
3236
+ return text(
3237
+ "recovery_relocation_conflict",
3238
+ "The project Root and nested publish path contain different recovery credentials. Nothing was moved or deployed; run help. Sakupa will not restart DNS recovery.",
3239
+ {
3240
+ projectRoot: ctx.projectDir,
3241
+ conflictingDirectories: allRecoveries.map(({ dir }) => dir)
3242
+ },
3243
+ "blocked"
3244
+ );
3245
+ }
3246
+ const localCredentials = /* @__PURE__ */ new Set([
3247
+ ...allSiteFiles.map(({ file }) => file.credential),
3248
+ ...allRecoveries.map(({ file }) => file.credential)
3249
+ ]);
3250
+ if (allSiteFiles.length > 0 && allRecoveries.length > 0 && localCredentials.size > 1) {
3251
+ return text(
3252
+ "site_recovery_relocation_conflict",
3253
+ "The project Root and nested publish path contain site and recovery credentials that do not belong to the same recovered site. Nothing was moved or deployed; run help. Sakupa will never merge unrelated credentials.",
3254
+ {
3255
+ projectRoot: ctx.projectDir,
3256
+ siteDirectories: allSiteFiles.map(({ dir }) => dir),
3257
+ recoveryDirectories: allRecoveries.map(({ dir }) => dir)
3258
+ },
3259
+ "blocked"
3260
+ );
3261
+ }
3262
+ if (nestedRecoveryFiles.length > 0) {
3263
+ const recovery = rootRecovery ?? nestedRecoveryFiles[0]?.file;
3264
+ if (!recovery) throw new Error("recovery relocation lost its source state");
3265
+ if (!rootRecovery) writeRecoveryFile(ctx.projectDir, recovery);
3266
+ for (const { dir } of nestedRecoveryFiles) deleteRecoveryFile(dir);
3267
+ for (const dir of markerRelocatedFrom) deleteProjectMarker(dir);
3268
+ return structuredToolResult({
3269
+ schemaVersion: 1,
3270
+ outcome: "blocked",
3271
+ resultCode: "recovery_relocated_resume_required",
3272
+ summary: `Recovery state was moved safely to ${ctx.projectDir}/.sakupa before any deploy. The credential remains intact. Do not repeat DNS verification; call recover with action:"download" and outputDir:"${effectiveOutputDir}".`,
3273
+ data: {
3274
+ projectRoot: ctx.projectDir,
3275
+ relocatedFrom: nestedRecoveryFiles.map(({ dir }) => dir),
3276
+ credentialStoredLocally: true,
3277
+ dnsVerificationRepeated: false
3278
+ },
3279
+ nextActions: [
3280
+ {
3281
+ tool: "recover",
3282
+ arguments: { action: "download", outputDir: effectiveOutputDir },
3283
+ allowed: true,
3284
+ reasonCode: "credential_first_recovery_resume"
3285
+ }
3286
+ ]
3287
+ });
3288
+ }
3289
+ for (const dir of markerRelocatedFrom.filter(
3290
+ (candidate) => !credentialRelocatedFrom.includes(candidate)
3291
+ )) {
3292
+ deleteProjectMarker(dir);
2769
3293
  }
2770
3294
  if (!existing) {
2771
3295
  const barrier = freeSiteCreationBarrier(ctx.apiBaseUrl);
@@ -2876,7 +3400,10 @@ ${JSON.stringify(finalized2.warnings, null, 2)}` : ""),
2876
3400
  }
2877
3401
  const { uploaded, finalized } = update;
2878
3402
  writeSiteFile(ctx.projectDir, { ...existing, url: finalized.url });
2879
- if (credentialRelocatedFrom !== null) deleteSiteFile(credentialRelocatedFrom);
3403
+ for (const source of credentialRelocatedFrom) {
3404
+ deleteSiteFile(source);
3405
+ if (markerRelocatedFrom.includes(source)) deleteProjectMarker(source);
3406
+ }
2880
3407
  updateProjectOutputDir(ctx.projectDir, effectiveOutputDir);
2881
3408
  noteSiteMode(existing.siteId, finalized.mode);
2882
3409
  return text(
@@ -2886,7 +3413,7 @@ Environment: ${environmentFor(ctx.apiBaseUrl).toUpperCase()} (${ctx.apiBaseUrl})
2886
3413
  Project directory: ${ctx.projectDir}
2887
3414
  Files uploaded: ${uploaded} (${finalized.totalBytes} bytes)
2888
3415
  ` + (finalized.expiresAt ? `Validity refreshed \u2014 expires at: ${finalized.expiresAt}
2889
- ` : "") + (credentialRelocatedFrom ? `Credential binding relocated from ${credentialRelocatedFrom}/.sakupa to ${ctx.projectDir}/.sakupa; the existing site was preserved.
3416
+ ` : "") + (credentialRelocatedFrom.length > 0 ? `Credential binding relocated from ${credentialRelocatedFrom.join(", ")} to ${ctx.projectDir}/.sakupa; the existing site was preserved.
2890
3417
  ` : "") + (finalized.mode === "free" ? `
2891
3418
  Reminder: free sites stay live for ${FREE_SITE_TTL_HOURS} hours after the last deploy or refresh call. Subscribing (subscribe) makes the site permanent.
2892
3419
  ` : "\nThis site is subscribed and permanent \u2014 no expiry.\n") + (finalized.warnings.length > 0 ? `
@@ -2902,7 +3429,7 @@ ${JSON.stringify(finalized.warnings, null, 2)}` : ""),
2902
3429
  filesUploaded: uploaded,
2903
3430
  totalBytes: finalized.totalBytes,
2904
3431
  warnings: finalized.warnings,
2905
- ...credentialRelocatedFrom ? { credentialRelocatedFrom } : {}
3432
+ ...credentialRelocatedFrom.length > 0 ? { credentialRelocatedFrom } : {}
2906
3433
  }
2907
3434
  );
2908
3435
  } catch (e) {
@@ -2920,7 +3447,7 @@ ${JSON.stringify(finalized.warnings, null, 2)}` : ""),
2920
3447
  },
2921
3448
  async () => {
2922
3449
  try {
2923
- const ctx = withProjectDir(baseCtx);
3450
+ const ctx = await withProjectDir(baseCtx);
2924
3451
  const site = requireSiteFile(ctx);
2925
3452
  const res = await ctx.client.refreshSite(site.siteId, site.credential);
2926
3453
  return text(
@@ -2944,7 +3471,7 @@ NO content was uploaded or changed by this call \u2014 to publish new or edited
2944
3471
  },
2945
3472
  async () => {
2946
3473
  try {
2947
- const ctx = withProjectDir(baseCtx);
3474
+ const ctx = await withProjectDir(baseCtx);
2948
3475
  const site = requireSiteFile(ctx);
2949
3476
  const res = await ctx.client.getSiteStatus(site.siteId, site.credential);
2950
3477
  noteSiteMode(res.siteId, res.mode);
@@ -2979,13 +3506,13 @@ NO content was uploaded or changed by this call \u2014 to publish new or edited
2979
3506
  },
2980
3507
  async (args) => {
2981
3508
  try {
2982
- const ctx = withProjectDir(baseCtx);
3509
+ const ctx = await withProjectDir(baseCtx);
2983
3510
  const site = requireSiteFile(ctx);
2984
3511
  const res = await ctx.client.createPlanCheckout(
2985
3512
  {
2986
3513
  siteId: site.siteId,
2987
3514
  plan: args.plan,
2988
- idempotencyKey: randomUUID2()
3515
+ idempotencyKey: randomUUID3()
2989
3516
  },
2990
3517
  site.credential
2991
3518
  );
@@ -3027,7 +3554,7 @@ Once payment confirms, the site becomes permanent on its current URL. Binding a
3027
3554
  },
3028
3555
  async (args) => {
3029
3556
  try {
3030
- const ctx = withProjectDir(baseCtx);
3557
+ const ctx = await withProjectDir(baseCtx);
3031
3558
  const site = requireSiteFile(ctx);
3032
3559
  if (args.action === "status") {
3033
3560
  const res2 = args.verificationId ? await ctx.client.checkVerification(args.verificationId, site.credential) : await ctx.client.checkVerification("latest", site.credential, site.siteId);
@@ -3136,7 +3663,7 @@ When the user says the TXT is set, run bind "status". It verifies ownership and
3136
3663
  },
3137
3664
  async () => {
3138
3665
  try {
3139
- const ctx = withProjectDir(baseCtx);
3666
+ const ctx = await withProjectDir(baseCtx);
3140
3667
  const site = requireSiteFile(ctx);
3141
3668
  const res = await ctx.client.getBillingStatus(site.siteId, site.credential);
3142
3669
  noteSiteMode(res.siteId, res.mode);
@@ -3180,7 +3707,7 @@ Full status:`, res);
3180
3707
  async (args) => {
3181
3708
  try {
3182
3709
  if (args.scope === "site") {
3183
- const ctx = withProjectDir(baseCtx);
3710
+ const ctx = await withProjectDir(baseCtx);
3184
3711
  const site = requireSiteFile(ctx);
3185
3712
  const res2 = await ctx.client.createBillingPortal(site.siteId, site.credential);
3186
3713
  return structuredToolResult({
@@ -3241,7 +3768,7 @@ Full status:`, res);
3241
3768
  },
3242
3769
  async (args) => {
3243
3770
  try {
3244
- const ctx = withProjectDir(baseCtx);
3771
+ const ctx = await withProjectDir(baseCtx);
3245
3772
  if ((args.action === "complete" || args.action === "download") && args.outputDir === void 0) {
3246
3773
  throw new LocalGuidanceError(
3247
3774
  "invalid_request",
@@ -3550,7 +4077,7 @@ Files: ${extracted.fileCount}; bytes: ${extracted.totalBytes}
3550
4077
  },
3551
4078
  async (args) => {
3552
4079
  try {
3553
- const ctx = withProjectDir(baseCtx);
4080
+ const ctx = await withProjectDir(baseCtx);
3554
4081
  const site = requireSiteFile(ctx);
3555
4082
  const res = await ctx.client.createTicket(site.credential, {
3556
4083
  siteId: site.siteId,
@@ -3572,11 +4099,12 @@ Files: ${extracted.fileCount}; bytes: ${extracted.totalBytes}
3572
4099
  server.registerTool(
3573
4100
  "report",
3574
4101
  {
3575
- description: "Prepare and submit a sanitized bug report when a Sakupa tool failed and the issue looks like a product bug. Only whitelisted structured diagnostics are sent (tool name, error code/message, site id, bound domain, deployment id, timestamps, client/MCP version, request id) \u2014 NEVER file contents, source code, secrets, .env values or credentials. Without confirmSubmit: true the exact payload is shown for user review and nothing is submitted.",
4102
+ description: "LAST RESORT after help explicitly returns reportRecommended:true. Prepare and submit a sanitized product bug report using helpAuthorization from that diagnosis. Only whitelisted structured diagnostics are sent (tool name, error code/message, site id, bound domain, deployment id, timestamps, client/MCP version, request id) \u2014 NEVER file contents, source code, secrets, .env values or credentials. Without confirmSubmit: true the exact payload is shown for user review and nothing is submitted.",
3576
4103
  outputSchema: STRUCTURED_TOOL_OUTPUT_SCHEMA,
3577
4104
  annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: true },
3578
4105
  inputSchema: {
3579
4106
  toolName: z2.string().describe('The Sakupa tool that failed, e.g. "deploy".'),
4107
+ helpAuthorization: z2.string().describe("Short-lived authorization returned only by help when report is recommended."),
3580
4108
  errorCode: z2.string().optional(),
3581
4109
  errorMessage: z2.string().optional().describe("Sanitized error message (no secrets)."),
3582
4110
  requestId: z2.string().optional(),
@@ -3594,8 +4122,9 @@ Files: ${extracted.fileCount}; bytes: ${extracted.totalBytes}
3594
4122
  },
3595
4123
  async (args) => {
3596
4124
  try {
3597
- const ctx = withProjectDir(baseCtx);
3598
- const siteState = loadSiteFile(ctx.projectDir);
4125
+ requireReportAuthorization(baseCtx, args.helpAuthorization, args.toolName);
4126
+ const ctx = await optionalProjectContext(baseCtx);
4127
+ const siteState = ctx ? loadSiteFile(ctx.projectDir) : { kind: "absent" };
3599
4128
  const site = siteState.kind === "ok" ? siteState.file : null;
3600
4129
  const diagnostics = {
3601
4130
  toolName: args.toolName,
@@ -3626,7 +4155,7 @@ Files: ${extracted.fileCount}; bytes: ${extracted.totalBytes}
3626
4155
  "preview"
3627
4156
  );
3628
4157
  }
3629
- const res = await ctx.client.reportBug(payload, site?.credential);
4158
+ const res = await baseCtx.client.reportBug(payload, site?.credential);
3630
4159
  return text(
3631
4160
  "bug_report_submitted",
3632
4161
  `Bug report submitted. Ticket: ${res.ticketId}
@@ -3641,7 +4170,7 @@ Summary: ${res.sanitizedSummary}`,
3641
4170
  }
3642
4171
 
3643
4172
  // src/tools/lifecycle.ts
3644
- import { randomUUID as randomUUID3 } from "node:crypto";
4173
+ import { randomUUID as randomUUID4 } from "node:crypto";
3645
4174
  import { z as z3 } from "zod";
3646
4175
  var deleteConfirmation = z3.object({
3647
4176
  siteId: z3.string().min(1),
@@ -3673,9 +4202,9 @@ function registerLifecycleTools(server, baseCtx) {
3673
4202
  },
3674
4203
  async (args) => {
3675
4204
  try {
3676
- const ctx = withProjectDir(baseCtx);
4205
+ const ctx = await withProjectDir(baseCtx);
3677
4206
  const site = requireSiteFile(ctx);
3678
- const operationId = args.operationId ?? randomUUID3();
4207
+ const operationId = args.operationId ?? randomUUID4();
3679
4208
  if (args.action === "preview") {
3680
4209
  const preview = await ctx.client.previewDeleteSite(site.siteId, site.credential, {
3681
4210
  operationId
@@ -3694,15 +4223,31 @@ function registerLifecycleTools(server, baseCtx) {
3694
4223
  ]
3695
4224
  });
3696
4225
  }
4226
+ const confirmArguments = {
4227
+ action: "confirm",
4228
+ operationId,
4229
+ confirmation: preview.confirmation
4230
+ };
4231
+ const confirmationJson = JSON.stringify(preview.confirmation);
3697
4232
  return structuredToolResult({
3698
4233
  schemaVersion: 1,
3699
4234
  outcome: "waiting_user",
3700
4235
  resultCode: "delete_confirmation_required",
3701
4236
  operationId,
3702
- summary: `Deletion consequences returned, bound to the current site and billing state; after confirmation the content and the permanent URL are unrecoverable. To proceed, call delete again with action:"confirm", operationId:"${operationId}", and the confirmation object from data.preview.confirmation.`,
3703
- data: { preview },
4237
+ summary: `Deletion consequences returned, bound to the current site and billing state; after confirmation the content and the permanent URL are unrecoverable. To proceed, call delete exactly once with these arguments (copy them verbatim; do not infer values): ${JSON.stringify(confirmArguments)}. Exact confirmation object: ${confirmationJson}`,
4238
+ data: { preview, confirmation: preview.confirmation, confirmArguments },
4239
+ userAction: {
4240
+ type: "confirm_in_mcp",
4241
+ expectedOutcome: "Permanently delete this site if its cloud state is unchanged.",
4242
+ resumeWith: { tool: "delete", arguments: confirmArguments }
4243
+ },
3704
4244
  nextActions: [
3705
- { tool: "delete", allowed: true, reasonCode: "exact_confirmation_required" }
4245
+ {
4246
+ tool: "delete",
4247
+ arguments: confirmArguments,
4248
+ allowed: true,
4249
+ reasonCode: "exact_confirmation_required"
4250
+ }
3706
4251
  ]
3707
4252
  });
3708
4253
  }
@@ -3782,7 +4327,7 @@ function registerBillingTools(server, baseCtx) {
3782
4327
  },
3783
4328
  async (args) => {
3784
4329
  try {
3785
- const ctx = withProjectDir(baseCtx);
4330
+ const ctx = await withProjectDir(baseCtx);
3786
4331
  const site = requireSiteFile(ctx);
3787
4332
  const result = await ctx.client.changeSubscriptionPlan(site.credential, {
3788
4333
  siteId: site.siteId,
@@ -3810,7 +4355,316 @@ function registerBillingTools(server, baseCtx) {
3810
4355
  );
3811
4356
  }
3812
4357
 
4358
+ // src/tools/help.ts
4359
+ import { join as join7 } from "node:path";
4360
+ import { z as z5 } from "zod";
4361
+ var TOOL_TOPICS = [
4362
+ "init",
4363
+ "analyze",
4364
+ "deploy",
4365
+ "refresh",
4366
+ "status",
4367
+ "plans",
4368
+ "subscribe",
4369
+ "bind",
4370
+ "billing",
4371
+ "portal",
4372
+ "recover",
4373
+ "change",
4374
+ "delete",
4375
+ "support",
4376
+ "report",
4377
+ "help"
4378
+ ];
4379
+ var HELP_TOPICS = ["diagnose", "overview", ...TOOL_TOPICS];
4380
+ var TOOL_MANUALS = {
4381
+ init: {
4382
+ purpose: "Initialize the active IDE workspace as one Sakupa project.",
4383
+ sideEffects: "Creates only .sakupa/project.json locally; no API call, site or charge.",
4384
+ preconditions: "Exactly one usable MCP workspace Root. Clients without Roots use CLI init.",
4385
+ parameters: "No parameters and no path argument.",
4386
+ warnings: [
4387
+ "Never initialize the IDE installation directory.",
4388
+ "Existing site.json and recovery.json are preserved."
4389
+ ],
4390
+ nextStep: "Call analyze, then deploy with the exact relative outputDir."
4391
+ },
4392
+ analyze: {
4393
+ purpose: "Inspect a project or explicit output directory for safe static deployment.",
4394
+ sideEffects: "Read-only local file inspection; no API call.",
4395
+ preconditions: "An initialized, unambiguous project binding.",
4396
+ parameters: "Optional outputDir relative to the bound project Root.",
4397
+ warnings: ["Build locally first.", "Never publish source, secrets, server code or media."],
4398
+ nextStep: "Fix reported blockers, then call deploy with the exact outputDir."
4399
+ },
4400
+ deploy: {
4401
+ purpose: "Create or update the bound Sakupa static site.",
4402
+ sideEffects: "Reads local output, uploads files and may create a public free site.",
4403
+ preconditions: "Initialized project, exact outputDir and first-publication confirmation.",
4404
+ parameters: "outputDir is required and relative to the project Root.",
4405
+ warnings: [
4406
+ ".sakupa must remain at the project Root and is never uploaded.",
4407
+ "A changed outputDir requires explicit confirmation."
4408
+ ],
4409
+ nextStep: "Call status to verify the cloud result."
4410
+ },
4411
+ refresh: {
4412
+ purpose: "Extend a free site lifetime without uploading content.",
4413
+ sideEffects: "Updates the site expiry in Sakupa.",
4414
+ preconditions: "A valid local site credential.",
4415
+ parameters: "No parameters.",
4416
+ warnings: ["Subscribed sites are permanent and do not need refresh."],
4417
+ nextStep: "Call status to verify the new expiry."
4418
+ },
4419
+ status: {
4420
+ purpose: "Read the bound site, deployment, domain and serving state.",
4421
+ sideEffects: "Read-only API request.",
4422
+ preconditions: "A valid local site credential.",
4423
+ parameters: "No parameters.",
4424
+ warnings: ["Billing truth comes from billing, not inferred status text."],
4425
+ nextStep: "Follow only the returned real tool names."
4426
+ },
4427
+ plans: {
4428
+ purpose: "Read the authoritative hosting plan catalog and rules.",
4429
+ sideEffects: "Read-only public API request.",
4430
+ preconditions: "None; project initialization is not required.",
4431
+ parameters: "No parameters.",
4432
+ warnings: ["JPY prices and cloud plan order are authoritative."],
4433
+ nextStep: "Use subscribe for first payment or change for an existing subscription."
4434
+ },
4435
+ subscribe: {
4436
+ purpose: "Create Stripe Checkout for the first subscription.",
4437
+ sideEffects: "Creates a short-lived Stripe Checkout session; payment happens only on Stripe.",
4438
+ preconditions: "A free bound site with a valid credential.",
4439
+ parameters: "The selected plan from plans.",
4440
+ warnings: ["Creating a link does not subscribe or charge the user."],
4441
+ nextStep: "Show the complete URL, then query billing after Stripe confirmation."
4442
+ },
4443
+ bind: {
4444
+ purpose: "Start, check or inspect custom-domain binding.",
4445
+ sideEffects: "May create DNS verification and hostname provisioning state.",
4446
+ preconditions: "A subscribed site and DNS control.",
4447
+ parameters: "Action plus hostname or verificationId as returned by the prior step.",
4448
+ warnings: ["www is mandatory; the apex is optional.", "Copy DNS values verbatim."],
4449
+ nextStep: "Follow the returned DNS checklist and call bind status/check."
4450
+ },
4451
+ billing: {
4452
+ purpose: "Read the single authoritative subscription and usage snapshot.",
4453
+ sideEffects: "Read-only API reconciliation.",
4454
+ preconditions: "A valid bound site.",
4455
+ parameters: "No parameters.",
4456
+ warnings: ["Never infer renewal state from user wording or an old link."],
4457
+ nextStep: "Use change or portal only when the user wants billing management."
4458
+ },
4459
+ portal: {
4460
+ purpose: "Open Stripe billing/customer management or public recovery login.",
4461
+ sideEffects: "Creates or returns a Stripe-hosted management URL.",
4462
+ preconditions: "Site scope needs a credential; public recovery does not.",
4463
+ parameters: "Use the supported scope.",
4464
+ warnings: ["Opening a link does not change subscription state."],
4465
+ nextStep: "Query billing after the user confirms an operation in Stripe."
4466
+ },
4467
+ recover: {
4468
+ purpose: "Recover a paid custom-domain site credential and download its content.",
4469
+ sideEffects: "Creates DNS verification state and writes local credential/archive files.",
4470
+ preconditions: "DNS control of a domain bound to an active paid site.",
4471
+ parameters: "Use the returned action and verificationId; outputDir is relative to Root.",
4472
+ warnings: [
4473
+ "After site.json exists, resume download and never repeat DNS verification.",
4474
+ "Credential is saved before archive creation/download."
4475
+ ],
4476
+ nextStep: "Call recover download when local credentials already exist."
4477
+ },
4478
+ change: {
4479
+ purpose: "Open the unified Stripe subscription-management page.",
4480
+ sideEffects: "Creates a short-lived Portal session and audit record only.",
4481
+ preconditions: "An active subscription.",
4482
+ parameters: "No plan direction or target is accepted from conversational intent.",
4483
+ warnings: ["Only Stripe confirmation changes the subscription."],
4484
+ nextStep: "Call billing after the user finishes on Stripe."
4485
+ },
4486
+ delete: {
4487
+ purpose: "Preview and permanently delete a free site.",
4488
+ sideEffects: "Confirm permanently removes content, URL and local site credential.",
4489
+ preconditions: "Paid subscriptions must fully end and return the site to free mode first.",
4490
+ parameters: "Preview first; confirm with the exact returned confirmArguments.",
4491
+ warnings: [
4492
+ "Never guess timestamps or confirmation fields from status.",
4493
+ "Deletion is irreversible and does not secretly cancel payment."
4494
+ ],
4495
+ nextStep: "Use the preview userAction.resumeWith arguments verbatim."
4496
+ },
4497
+ support: {
4498
+ purpose: "Create a customer-service ticket for billing, refund, payment or domain assistance.",
4499
+ sideEffects: "Submits a support ticket.",
4500
+ preconditions: "A bound subscribed site and user-provided issue description.",
4501
+ parameters: "Category, subject, sanitized description and optional contact email.",
4502
+ warnings: ["Never include credentials, source, card data or secrets."],
4503
+ nextStep: "Wait for support follow-up."
4504
+ },
4505
+ report: {
4506
+ purpose: "Last-resort product bug report after help recommends it.",
4507
+ sideEffects: "Preview is local; confirmSubmit sends a sanitized diagnostic report.",
4508
+ preconditions: "Call help first and show the exact report preview to the user.",
4509
+ parameters: "Failed tool, helpAuthorization, sanitized diagnostics and explicit confirmSubmit.",
4510
+ warnings: [
4511
+ "Never report ordinary setup errors help can solve.",
4512
+ "Submission requires approval."
4513
+ ],
4514
+ nextStep: "Submit only after the user reviews the preview."
4515
+ },
4516
+ help: {
4517
+ purpose: "Diagnose the current MCP/project state or explain any Sakupa tool.",
4518
+ sideEffects: "Read-only local diagnosis; no API call or file write.",
4519
+ preconditions: "None; works even when project binding is broken.",
4520
+ parameters: "topic defaults to diagnose; use overview or a tool name for its manual.",
4521
+ warnings: ["Use help before repeating failed calls or suggesting report."],
4522
+ nextStep: "Follow the returned diagnosis and nextActions."
4523
+ }
4524
+ };
4525
+ function registerHelpTools(server, baseCtx) {
4526
+ server.registerTool(
4527
+ "init",
4528
+ {
4529
+ description: "Initialize the active MCP workspace Root as a Sakupa project. Takes no path argument, creates only .sakupa/project.json at that exact Root, preserves site/recovery state, makes no API call and is idempotent. Clients without MCP Roots must use the no-argument CLI init.",
4530
+ inputSchema: {},
4531
+ outputSchema: STRUCTURED_TOOL_OUTPUT_SCHEMA,
4532
+ annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: false }
4533
+ },
4534
+ async () => {
4535
+ try {
4536
+ const ctx = await initializeWorkspaceProject(baseCtx);
4537
+ const marker = loadProjectMarker(ctx.projectDir);
4538
+ if (marker.kind !== "ok")
4539
+ throw new Error("init postcondition failed: project marker missing");
4540
+ const site = loadSiteFile(ctx.projectDir);
4541
+ const recovery = loadRecoveryFile(ctx.projectDir);
4542
+ const sakupaDirectory = join7(ctx.projectDir, ".sakupa");
4543
+ return structuredToolResult({
4544
+ schemaVersion: 1,
4545
+ outcome: "completed",
4546
+ resultCode: "project_initialized",
4547
+ summary: `Sakupa project initialized and verified at the active workspace Root: ${ctx.projectDir}. .sakupa is at ${sakupaDirectory}; no cloud site was created and no charge occurred.`,
4548
+ data: {
4549
+ projectRoot: ctx.projectDir,
4550
+ sakupaDirectory,
4551
+ projectId: marker.marker.projectId,
4552
+ bindingSource: ctx.bindingSource,
4553
+ sakupaAtProjectRoot: true,
4554
+ postconditionVerified: true,
4555
+ existingSitePreserved: site.kind !== "absent",
4556
+ existingRecoveryPreserved: recovery !== null,
4557
+ cloudApiCalled: false
4558
+ },
4559
+ nextActions: [{ tool: "analyze", allowed: true, reasonCode: "project_ready" }]
4560
+ });
4561
+ } catch (error) {
4562
+ return toolError(error);
4563
+ }
4564
+ }
4565
+ );
4566
+ server.registerTool(
4567
+ "help",
4568
+ {
4569
+ description: "FIRST troubleshooting tool for every Sakupa difficulty. With topic diagnose (default), inspect MCP Roots, cwd, binding and local state without requiring a project or calling the API. Use overview or a tool name for complete usage, side effects, parameters and warnings. Only recommend report when help explicitly returns reportRecommended:true.",
4570
+ inputSchema: {
4571
+ topic: z5.enum(HELP_TOPICS).optional().default("diagnose"),
4572
+ failedTool: z5.string().optional(),
4573
+ errorCode: z5.string().optional(),
4574
+ resultCode: z5.string().optional(),
4575
+ requestId: z5.string().optional()
4576
+ },
4577
+ outputSchema: STRUCTURED_TOOL_OUTPUT_SCHEMA,
4578
+ annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: false }
4579
+ },
4580
+ async (args) => {
4581
+ try {
4582
+ if (args.topic === "overview") {
4583
+ const catalog = Object.fromEntries(
4584
+ TOOL_TOPICS.map((tool) => [tool, { purpose: TOOL_MANUALS[tool].purpose }])
4585
+ );
4586
+ return structuredToolResult({
4587
+ schemaVersion: 1,
4588
+ outcome: "completed",
4589
+ resultCode: "help_overview",
4590
+ summary: 'Sakupa tool overview returned. On any failure call help with topic:"diagnose" before retrying, support or report.',
4591
+ data: { tools: catalog, toolOrder: TOOL_TOPICS },
4592
+ nextActions: []
4593
+ });
4594
+ }
4595
+ if (args.topic !== "diagnose") {
4596
+ const manual = TOOL_MANUALS[args.topic];
4597
+ return structuredToolResult({
4598
+ schemaVersion: 1,
4599
+ outcome: "completed",
4600
+ resultCode: "help_tool_manual",
4601
+ summary: `${args.topic}: ${manual.purpose}
4602
+ Side effects: ${manual.sideEffects}
4603
+ Preconditions: ${manual.preconditions}
4604
+ Parameters: ${manual.parameters}
4605
+ Warnings: ${manual.warnings.join(" ")}
4606
+ Next: ${manual.nextStep}`,
4607
+ data: { tool: args.topic, ...manual },
4608
+ nextActions: []
4609
+ });
4610
+ }
4611
+ const diagnosis = await diagnoseProjectBinding(baseCtx);
4612
+ const selected = diagnosis.selectedProjectDir;
4613
+ const marker = selected ? loadProjectMarker(selected) : { kind: "absent" };
4614
+ const site = selected ? loadSiteFile(selected) : { kind: "absent" };
4615
+ let recoveryState = "absent";
4616
+ if (selected) {
4617
+ try {
4618
+ recoveryState = loadRecoveryFile(selected) === null ? "absent" : "ok";
4619
+ } catch {
4620
+ recoveryState = "corrupted";
4621
+ }
4622
+ }
4623
+ const opaqueFailure = args.errorCode === "internal" || args.resultCode === "error_internal";
4624
+ const reportRecommended = opaqueFailure && (diagnosis.diagnosisCode === "project_bound" || diagnosis.diagnosisCode === "roots_request_failed");
4625
+ const helpAuthorization = reportRecommended ? issueReportAuthorization(baseCtx, args.failedTool ?? "unknown") : void 0;
4626
+ const nextActions = reportRecommended ? [
4627
+ {
4628
+ tool: "report",
4629
+ arguments: {
4630
+ toolName: args.failedTool ?? "unknown",
4631
+ helpAuthorization,
4632
+ ...args.errorCode !== void 0 ? { errorCode: args.errorCode } : {},
4633
+ ...args.requestId !== void 0 ? { requestId: args.requestId } : {}
4634
+ },
4635
+ allowed: true,
4636
+ reasonCode: "help_confirmed_last_resort"
4637
+ }
4638
+ ] : diagnosis.diagnosisCode === "workspace_not_initialized" ? [{ tool: "init", allowed: true, reasonCode: "initialize_active_root" }] : [];
4639
+ const summary = `Help diagnosis: ${diagnosis.diagnosisCode}. ${diagnosis.guidance} MCP version: ${MCP_VERSION}. ` + (reportRecommended ? diagnosis.diagnosisCode === "project_bound" ? "Local project binding is healthy but the failure is an unclassified internal error. report is now available as the last resort; preview it before submission." : "The MCP Roots request itself failed with an unclassified internal error. report is now available as the last resort; preview it before submission." : "Do not submit report for this diagnosis; follow the guidance and retry help.");
4640
+ return structuredToolResult({
4641
+ schemaVersion: 1,
4642
+ outcome: diagnosis.diagnosisCode === "project_bound" ? "completed" : "blocked",
4643
+ resultCode: `help_${diagnosis.diagnosisCode}`,
4644
+ summary,
4645
+ data: {
4646
+ ...diagnosis,
4647
+ mcpVersion: MCP_VERSION,
4648
+ projectMarkerState: marker.kind,
4649
+ siteState: site.kind,
4650
+ recoveryState,
4651
+ reportRecommended,
4652
+ ...helpAuthorization !== void 0 ? { helpAuthorization } : {},
4653
+ ...args.failedTool !== void 0 ? { failedTool: args.failedTool } : {},
4654
+ ...args.errorCode !== void 0 ? { errorCode: args.errorCode } : {},
4655
+ ...args.resultCode !== void 0 ? { failedResultCode: args.resultCode } : {}
4656
+ },
4657
+ nextActions
4658
+ });
4659
+ } catch (error) {
4660
+ return toolError(error);
4661
+ }
4662
+ }
4663
+ );
4664
+ }
4665
+
3813
4666
  // src/server.ts
4667
+ import { resolve as resolve6 } from "node:path";
3814
4668
  var instructionsFor = (hostPattern) => `Sakupa publishes AI-made static websites. AI-made pages, live in seconds.
3815
4669
 
3816
4670
  Workflow:
@@ -3840,14 +4694,17 @@ Workflow:
3840
4694
  24h site and removes paid data after Stripe sends the signed final-cancellation webhook.
3841
4695
  Recovery writes the new local credential before downloading content. If a session stops after
3842
4696
  .sakupa/site.json exists, NEVER repeat DNS recovery: resume with recover action "download".
3843
- 5. support (subscribed sites) opens a support ticket; report sends a
3844
- sanitized diagnostic report after the user explicitly confirms it.
4697
+ 5. If any Sakupa operation is difficult or fails, call help FIRST. support handles billing,
4698
+ payment, refund and other customer-service requests. report is the LAST resort only when
4699
+ help explicitly recommends a product bug report, and submission still requires user review.
3845
4700
 
3846
4701
  Project directory contract: before the first deploy or a new recovery, initialize the intended
3847
- project once by running "npx -y @sakupa/mcp@latest init" with NO path argument from the AI
3848
- tool's current project directory. This immediately creates the non-secret .sakupa/project.json
3849
- there. ONE MCP process = ONE cwd-locked project = ONE site. Site tools do not accept projectDir
3850
- and cannot select another root; plans and public_recovery portal remain project-independent.
4702
+ project by calling init with NO path argument. init uses the IDE's exact MCP Root and creates the
4703
+ non-secret .sakupa/project.json directly there. The CLI command
4704
+ "npx -y @sakupa/mcp@latest init" remains the safe fallback for clients without MCP Roots and
4705
+ also accepts NO path argument. ONE MCP process = ONE Roots-first locked project = ONE site.
4706
+ Site tools do not accept projectDir and cannot select another root; help, plans, report preview
4707
+ and public_recovery portal remain project-independent.
3851
4708
  Sakupa stores .sakupa/site.json and recovery state only in the locked directory; it never uses
3852
4709
  package.json, .git, framework names or output-directory names to guess. For deploy, ALWAYS pass
3853
4710
  outputDir separately as the exact path RELATIVE to the locked directory (use "." when publishing
@@ -3855,14 +4712,17 @@ the root); outputDir is
3855
4712
  required and may have ANY name, so inspect the current project. If it differs from the last
3856
4713
  successful publish directory, show the old and new paths and obtain explicit confirmation
3857
4714
  before retrying with outputDirChangeConfirmed: true.
4715
+ After init, require sakupaAtProjectRoot=true. Before deploy, Sakupa checks every directory segment
4716
+ between the project Root and outputDir for a misplaced .sakupa and safely relocates only validated,
4717
+ non-conflicting state; never copy, delete or overwrite site.json by shell command.
3858
4718
  After every deploy, TELL the user which environment it went to (deploy results carry an
3859
4719
  Explicit Environment line: TEST vs PRODUCTION). analyze, deploy, status,
3860
4720
  refresh and delete echo
3861
- the cwd-locked directory they acted on.
4721
+ the Roots-first locked directory they acted on.
3862
4722
 
3863
- When the same operation fails twice in a row, or the user is clearly stuck or
3864
- frustrated, proactively offer report: it files the problem into Sakupa's ticket and
3865
- alert stream, and you should attach your own factual account via agentContext.
4723
+ On any difficulty, call help before retrying or escalating. Only offer report when help returns
4724
+ reportRecommended:true; attach your own factual account via agentContext and show the exact
4725
+ sanitized preview before asking the user to confirm submission.
3866
4726
 
3867
4727
  Present every step as Sakupa's own: never attribute DNS, certificates or hosting to
3868
4728
  underlying infrastructure vendors in front of the user. Relay DNS record values and full
@@ -3896,14 +4756,32 @@ function createSakupaMcpServer(opts) {
3896
4756
  { name: "sakupa", version: MCP_VERSION },
3897
4757
  { instructions: instructionsFor(previewHostPatternFor(opts.apiBaseUrl)) }
3898
4758
  );
4759
+ const processCwd = resolve6(opts.projectDir ?? process.cwd());
4760
+ const rootsProvider = opts.rootsProvider ?? (async () => {
4761
+ const capabilities = server.server.getClientCapabilities();
4762
+ if (!capabilities?.roots) return { supported: false, roots: [] };
4763
+ try {
4764
+ const response = await server.server.listRoots();
4765
+ return { supported: true, roots: response.roots };
4766
+ } catch (error) {
4767
+ return {
4768
+ supported: true,
4769
+ roots: [],
4770
+ error: error instanceof Error ? error.message : String(error)
4771
+ };
4772
+ }
4773
+ });
3899
4774
  const ctx = {
3900
4775
  client,
3901
4776
  apiBaseUrl: opts.apiBaseUrl,
3902
- projectDir: canonicalProjectDirectory(opts.projectDir ?? process.cwd())
4777
+ projectDir: processCwd,
4778
+ rootsProvider,
4779
+ projectBinding: new ProjectBindingResolver(processCwd, rootsProvider)
3903
4780
  };
3904
4781
  registerTools(server, ctx);
3905
4782
  registerBillingTools(server, ctx);
3906
4783
  registerLifecycleTools(server, ctx);
4784
+ registerHelpTools(server, ctx);
3907
4785
  return server;
3908
4786
  }
3909
4787
  export {