@sakupa/mcp 0.7.35 → 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.
- package/dist/bin.js +899 -70
- package/dist/index.js +935 -73
- package/package.json +1 -1
package/dist/index.js
CHANGED
|
@@ -124,7 +124,7 @@ var FORBIDDEN_PATH_SEGMENTS = [
|
|
|
124
124
|
var ALLOWED_HIDDEN_PATHS = [".well-known/"];
|
|
125
125
|
|
|
126
126
|
// ../core/dist/domain/version.js
|
|
127
|
-
var SAKUPA_MCP_VERSION = "0.7.
|
|
127
|
+
var SAKUPA_MCP_VERSION = "0.7.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
|
-
|
|
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
|
|
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.
|
|
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
|
|
2043
|
+
import { randomUUID as randomUUID3 } from "node:crypto";
|
|
1671
2044
|
import { promises as fs2 } from "node:fs";
|
|
1672
|
-
import { join as join6, resolve as
|
|
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
|
|
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,
|
|
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,
|
|
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,
|
|
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 (
|
|
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 (
|
|
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
|
|
2134
|
-
if (
|
|
2506
|
+
var z6 = b4(data, e - 20) == 117853008;
|
|
2507
|
+
if (z6) {
|
|
2135
2508
|
var ze = b4(data, e - 12);
|
|
2136
|
-
|
|
2137
|
-
if (
|
|
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,
|
|
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(
|
|
2169
|
-
const target =
|
|
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 =
|
|
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(
|
|
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 &&
|
|
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 =
|
|
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
|
-
|
|
2746
|
-
|
|
2747
|
-
|
|
2748
|
-
|
|
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
|
-
"
|
|
2751
|
-
|
|
2752
|
-
{ projectRoot: ctx.projectDir,
|
|
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
|
|
2757
|
-
if (
|
|
3160
|
+
const nestedMarker = loadProjectMarker(candidateDir);
|
|
3161
|
+
if (nestedMarker.kind === "corrupted") {
|
|
2758
3162
|
return text(
|
|
2759
|
-
"
|
|
2760
|
-
`
|
|
2761
|
-
{ projectRoot: ctx.projectDir,
|
|
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 (
|
|
2766
|
-
|
|
2767
|
-
|
|
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);
|
|
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
|
+
);
|
|
2768
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
|
-
|
|
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}
|
|
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:
|
|
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: "
|
|
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
|
-
|
|
3598
|
-
const
|
|
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
|
|
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
|
|
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 ??
|
|
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
|
|
@@ -3798,7 +4327,7 @@ function registerBillingTools(server, baseCtx) {
|
|
|
3798
4327
|
},
|
|
3799
4328
|
async (args) => {
|
|
3800
4329
|
try {
|
|
3801
|
-
const ctx = withProjectDir(baseCtx);
|
|
4330
|
+
const ctx = await withProjectDir(baseCtx);
|
|
3802
4331
|
const site = requireSiteFile(ctx);
|
|
3803
4332
|
const result = await ctx.client.changeSubscriptionPlan(site.credential, {
|
|
3804
4333
|
siteId: site.siteId,
|
|
@@ -3826,7 +4355,316 @@ function registerBillingTools(server, baseCtx) {
|
|
|
3826
4355
|
);
|
|
3827
4356
|
}
|
|
3828
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
|
+
|
|
3829
4666
|
// src/server.ts
|
|
4667
|
+
import { resolve as resolve6 } from "node:path";
|
|
3830
4668
|
var instructionsFor = (hostPattern) => `Sakupa publishes AI-made static websites. AI-made pages, live in seconds.
|
|
3831
4669
|
|
|
3832
4670
|
Workflow:
|
|
@@ -3856,14 +4694,17 @@ Workflow:
|
|
|
3856
4694
|
24h site and removes paid data after Stripe sends the signed final-cancellation webhook.
|
|
3857
4695
|
Recovery writes the new local credential before downloading content. If a session stops after
|
|
3858
4696
|
.sakupa/site.json exists, NEVER repeat DNS recovery: resume with recover action "download".
|
|
3859
|
-
5.
|
|
3860
|
-
|
|
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.
|
|
3861
4700
|
|
|
3862
4701
|
Project directory contract: before the first deploy or a new recovery, initialize the intended
|
|
3863
|
-
project
|
|
3864
|
-
|
|
3865
|
-
|
|
3866
|
-
|
|
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.
|
|
3867
4708
|
Sakupa stores .sakupa/site.json and recovery state only in the locked directory; it never uses
|
|
3868
4709
|
package.json, .git, framework names or output-directory names to guess. For deploy, ALWAYS pass
|
|
3869
4710
|
outputDir separately as the exact path RELATIVE to the locked directory (use "." when publishing
|
|
@@ -3871,14 +4712,17 @@ the root); outputDir is
|
|
|
3871
4712
|
required and may have ANY name, so inspect the current project. If it differs from the last
|
|
3872
4713
|
successful publish directory, show the old and new paths and obtain explicit confirmation
|
|
3873
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.
|
|
3874
4718
|
After every deploy, TELL the user which environment it went to (deploy results carry an
|
|
3875
4719
|
Explicit Environment line: TEST vs PRODUCTION). analyze, deploy, status,
|
|
3876
4720
|
refresh and delete echo
|
|
3877
|
-
the
|
|
4721
|
+
the Roots-first locked directory they acted on.
|
|
3878
4722
|
|
|
3879
|
-
|
|
3880
|
-
|
|
3881
|
-
|
|
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.
|
|
3882
4726
|
|
|
3883
4727
|
Present every step as Sakupa's own: never attribute DNS, certificates or hosting to
|
|
3884
4728
|
underlying infrastructure vendors in front of the user. Relay DNS record values and full
|
|
@@ -3912,14 +4756,32 @@ function createSakupaMcpServer(opts) {
|
|
|
3912
4756
|
{ name: "sakupa", version: MCP_VERSION },
|
|
3913
4757
|
{ instructions: instructionsFor(previewHostPatternFor(opts.apiBaseUrl)) }
|
|
3914
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
|
+
});
|
|
3915
4774
|
const ctx = {
|
|
3916
4775
|
client,
|
|
3917
4776
|
apiBaseUrl: opts.apiBaseUrl,
|
|
3918
|
-
projectDir:
|
|
4777
|
+
projectDir: processCwd,
|
|
4778
|
+
rootsProvider,
|
|
4779
|
+
projectBinding: new ProjectBindingResolver(processCwd, rootsProvider)
|
|
3919
4780
|
};
|
|
3920
4781
|
registerTools(server, ctx);
|
|
3921
4782
|
registerBillingTools(server, ctx);
|
|
3922
4783
|
registerLifecycleTools(server, ctx);
|
|
4784
|
+
registerHelpTools(server, ctx);
|
|
3923
4785
|
return server;
|
|
3924
4786
|
}
|
|
3925
4787
|
export {
|