@sakupa/mcp 1.5.0 → 1.6.0
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 +475 -142
- package/dist/index.js +1140 -806
- package/package.json +1 -1
package/dist/bin.js
CHANGED
|
@@ -12,6 +12,7 @@ import {
|
|
|
12
12
|
lstatSync,
|
|
13
13
|
mkdirSync,
|
|
14
14
|
readFileSync,
|
|
15
|
+
readdirSync,
|
|
15
16
|
realpathSync,
|
|
16
17
|
renameSync,
|
|
17
18
|
rmdirSync,
|
|
@@ -23,6 +24,8 @@ import { homedir } from "node:os";
|
|
|
23
24
|
import { isAbsolute, join, parse, relative, resolve, sep } from "node:path";
|
|
24
25
|
var SAKUPA_DIR = ".sakupa";
|
|
25
26
|
var PROJECT_FILE = "project.json";
|
|
27
|
+
var GITIGNORE_FILE = ".gitignore";
|
|
28
|
+
var GITIGNORE_CONTENT = "# Sakupa local state (project marker, site binding, recovery/rotation journals).\n# Managed by @sakupa/mcp \u2014 everything in this directory stays out of version control.\n*\n";
|
|
26
29
|
var PROJECT_SCHEMA_VERSION = 1;
|
|
27
30
|
var ProjectRootError = class extends Error {
|
|
28
31
|
code;
|
|
@@ -152,14 +155,33 @@ function updateProjectOutputDir(projectDir, outputDir) {
|
|
|
152
155
|
writeMarkerAtomically(canonical, marker);
|
|
153
156
|
return marker;
|
|
154
157
|
}
|
|
155
|
-
function
|
|
156
|
-
const
|
|
157
|
-
if (existsSync(
|
|
158
|
+
function ensureSakupaGitignore(projectDir) {
|
|
159
|
+
const dir = join(projectDir, SAKUPA_DIR);
|
|
160
|
+
if (!existsSync(dir)) return;
|
|
161
|
+
const path = join(dir, GITIGNORE_FILE);
|
|
162
|
+
if (existsSync(path)) return;
|
|
163
|
+
try {
|
|
164
|
+
writeFileSync(path, GITIGNORE_CONTENT, { encoding: "utf8", flag: "wx" });
|
|
165
|
+
} catch {
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
function pruneSakupaDirectory(projectDir) {
|
|
169
|
+
const dir = join(projectDir, SAKUPA_DIR);
|
|
170
|
+
if (!existsSync(dir)) return;
|
|
158
171
|
try {
|
|
159
|
-
|
|
172
|
+
const entries = readdirSync(dir);
|
|
173
|
+
if (entries.every((entry) => entry === GITIGNORE_FILE)) {
|
|
174
|
+
for (const entry of entries) unlinkSync(join(dir, entry));
|
|
175
|
+
rmdirSync(dir);
|
|
176
|
+
}
|
|
160
177
|
} catch {
|
|
161
178
|
}
|
|
162
179
|
}
|
|
180
|
+
function deleteProjectMarker(projectDir) {
|
|
181
|
+
const path = projectMarkerPath(projectDir);
|
|
182
|
+
if (existsSync(path)) unlinkSync(path);
|
|
183
|
+
pruneSakupaDirectory(projectDir);
|
|
184
|
+
}
|
|
163
185
|
function canonicalProjectDirectory(path) {
|
|
164
186
|
const canonical = canonicalExistingPath(resolve(path));
|
|
165
187
|
if (!statSync(canonical).isDirectory()) {
|
|
@@ -207,6 +229,7 @@ function isSafeRelativeOutput(path) {
|
|
|
207
229
|
function writeMarkerAtomically(projectDir, marker) {
|
|
208
230
|
const dir = join(projectDir, SAKUPA_DIR);
|
|
209
231
|
mkdirSync(dir, { recursive: true, mode: 448 });
|
|
232
|
+
ensureSakupaGitignore(projectDir);
|
|
210
233
|
const path = projectMarkerPath(projectDir);
|
|
211
234
|
const temporary = `${path}.${process.pid}.${randomUUID()}.tmp`;
|
|
212
235
|
try {
|
|
@@ -403,7 +426,7 @@ function isFreeSiteAllowanceNetworkReference(value) {
|
|
|
403
426
|
}
|
|
404
427
|
|
|
405
428
|
// ../core/dist/domain/version.js
|
|
406
|
-
var SAKUPA_MCP_VERSION = "1.
|
|
429
|
+
var SAKUPA_MCP_VERSION = "1.6.0";
|
|
407
430
|
|
|
408
431
|
// ../core/dist/domain/errors.js
|
|
409
432
|
var HTTP_STATUS = {
|
|
@@ -971,6 +994,13 @@ var HttpApiClient = class {
|
|
|
971
994
|
{ device: { deviceId, credential } }
|
|
972
995
|
);
|
|
973
996
|
}
|
|
997
|
+
async reissueDeviceFreeSiteCredential(siteId, deviceId, credential) {
|
|
998
|
+
return this.call(
|
|
999
|
+
"POST",
|
|
1000
|
+
`/v1/devices/sites/${encodeURIComponent(siteId)}/credential`,
|
|
1001
|
+
{ device: { deviceId, credential } }
|
|
1002
|
+
);
|
|
1003
|
+
}
|
|
974
1004
|
async createSite(req, _clientIp, device) {
|
|
975
1005
|
return this.call("POST", "/v1/sites", { body: req, device });
|
|
976
1006
|
}
|
|
@@ -1172,9 +1202,9 @@ var HttpApiClient = class {
|
|
|
1172
1202
|
};
|
|
1173
1203
|
|
|
1174
1204
|
// src/tools/definitions.ts
|
|
1175
|
-
import { randomUUID as
|
|
1205
|
+
import { randomUUID as randomUUID7 } from "node:crypto";
|
|
1176
1206
|
import { promises as fs2 } from "node:fs";
|
|
1177
|
-
import { join as
|
|
1207
|
+
import { join as join10, relative as relative3, resolve as resolve5, sep as sep4 } from "node:path";
|
|
1178
1208
|
import { z as z2 } from "zod";
|
|
1179
1209
|
|
|
1180
1210
|
// src/analyze/analyzer.ts
|
|
@@ -1610,33 +1640,126 @@ async function analyzeProject(projectDir, opts = {}) {
|
|
|
1610
1640
|
}
|
|
1611
1641
|
|
|
1612
1642
|
// src/project-file.ts
|
|
1643
|
+
import {
|
|
1644
|
+
chmodSync as chmodSync3,
|
|
1645
|
+
existsSync as existsSync3,
|
|
1646
|
+
mkdirSync as mkdirSync3,
|
|
1647
|
+
readFileSync as readFileSync3,
|
|
1648
|
+
renameSync as renameSync3,
|
|
1649
|
+
rmSync as rmSync2,
|
|
1650
|
+
writeFileSync as writeFileSync3
|
|
1651
|
+
} from "node:fs";
|
|
1652
|
+
import { randomUUID as randomUUID3 } from "node:crypto";
|
|
1653
|
+
import { dirname, join as join4 } from "node:path";
|
|
1654
|
+
|
|
1655
|
+
// src/credential-store.ts
|
|
1656
|
+
import { randomUUID as randomUUID2 } from "node:crypto";
|
|
1613
1657
|
import {
|
|
1614
1658
|
chmodSync as chmodSync2,
|
|
1615
1659
|
existsSync as existsSync2,
|
|
1616
1660
|
mkdirSync as mkdirSync2,
|
|
1617
1661
|
readFileSync as readFileSync2,
|
|
1618
1662
|
renameSync as renameSync2,
|
|
1619
|
-
rmdirSync as rmdirSync2,
|
|
1620
1663
|
rmSync,
|
|
1621
1664
|
writeFileSync as writeFileSync2
|
|
1622
1665
|
} from "node:fs";
|
|
1623
|
-
import {
|
|
1624
|
-
import {
|
|
1666
|
+
import { homedir as homedir2 } from "node:os";
|
|
1667
|
+
import { join as join3 } from "node:path";
|
|
1668
|
+
var REF_PATTERN = /^[0-9a-f-]{36}$/i;
|
|
1669
|
+
function credentialStoreDirectory() {
|
|
1670
|
+
const base = process.env["SAKUPA_STATE_DIR"] ?? homedir2();
|
|
1671
|
+
return join3(base, ".sakupa", "credentials");
|
|
1672
|
+
}
|
|
1673
|
+
function credentialStorePath(ref) {
|
|
1674
|
+
if (!REF_PATTERN.test(ref)) throw new Error("Invalid credential reference.");
|
|
1675
|
+
return join3(credentialStoreDirectory(), `${ref}.json`);
|
|
1676
|
+
}
|
|
1677
|
+
function newCredentialRef() {
|
|
1678
|
+
return randomUUID2();
|
|
1679
|
+
}
|
|
1680
|
+
function readStoredCredential(ref) {
|
|
1681
|
+
if (!REF_PATTERN.test(ref)) {
|
|
1682
|
+
return {
|
|
1683
|
+
kind: "corrupted",
|
|
1684
|
+
problem: "the credentialRef does not look like a Sakupa reference"
|
|
1685
|
+
};
|
|
1686
|
+
}
|
|
1687
|
+
const path = credentialStorePath(ref);
|
|
1688
|
+
if (!existsSync2(path)) return { kind: "absent" };
|
|
1689
|
+
let parsed;
|
|
1690
|
+
try {
|
|
1691
|
+
parsed = JSON.parse(readFileSync2(path, "utf8"));
|
|
1692
|
+
} catch (error) {
|
|
1693
|
+
return {
|
|
1694
|
+
kind: "corrupted",
|
|
1695
|
+
problem: `${path} exists but could not be parsed (${error instanceof Error ? error.message : String(error)})`
|
|
1696
|
+
};
|
|
1697
|
+
}
|
|
1698
|
+
if (typeof parsed !== "object" || parsed === null || typeof parsed.credential !== "string") {
|
|
1699
|
+
return { kind: "corrupted", problem: `${path} does not contain a credential` };
|
|
1700
|
+
}
|
|
1701
|
+
if (!CREDENTIAL_PATTERN.test(parsed.credential)) {
|
|
1702
|
+
return {
|
|
1703
|
+
kind: "corrupted",
|
|
1704
|
+
problem: `the credential in ${path} does not match the shape Sakupa issues (sk_ followed by exactly 43 URL-safe base64 characters) \u2014 it looks locally altered or damaged`
|
|
1705
|
+
};
|
|
1706
|
+
}
|
|
1707
|
+
return {
|
|
1708
|
+
kind: "ok",
|
|
1709
|
+
entry: {
|
|
1710
|
+
ref,
|
|
1711
|
+
siteId: typeof parsed.siteId === "string" ? parsed.siteId : "",
|
|
1712
|
+
apiBaseUrl: typeof parsed.apiBaseUrl === "string" ? parsed.apiBaseUrl : "",
|
|
1713
|
+
credential: parsed.credential,
|
|
1714
|
+
createdAt: typeof parsed.createdAt === "string" ? parsed.createdAt : ""
|
|
1715
|
+
}
|
|
1716
|
+
};
|
|
1717
|
+
}
|
|
1718
|
+
function storeCredential(entry) {
|
|
1719
|
+
if (!CREDENTIAL_PATTERN.test(entry.credential)) {
|
|
1720
|
+
throw new Error("Refusing to store a credential that does not match the Sakupa shape.");
|
|
1721
|
+
}
|
|
1722
|
+
const dir = credentialStoreDirectory();
|
|
1723
|
+
mkdirSync2(dir, { recursive: true, mode: 448 });
|
|
1724
|
+
const path = credentialStorePath(entry.ref);
|
|
1725
|
+
const temporary = join3(dir, `.${entry.ref}.${process.pid}.tmp`);
|
|
1726
|
+
writeFileSync2(temporary, `${JSON.stringify(entry, null, 2)}
|
|
1727
|
+
`, {
|
|
1728
|
+
encoding: "utf8",
|
|
1729
|
+
mode: 384
|
|
1730
|
+
});
|
|
1731
|
+
try {
|
|
1732
|
+
chmodSync2(temporary, 384);
|
|
1733
|
+
} catch {
|
|
1734
|
+
}
|
|
1735
|
+
try {
|
|
1736
|
+
renameSync2(temporary, path);
|
|
1737
|
+
} catch (error) {
|
|
1738
|
+
rmSync(temporary, { force: true });
|
|
1739
|
+
throw error;
|
|
1740
|
+
}
|
|
1741
|
+
}
|
|
1742
|
+
function deleteStoredCredential(ref) {
|
|
1743
|
+
if (!REF_PATTERN.test(ref)) return;
|
|
1744
|
+
rmSync(credentialStorePath(ref), { force: true });
|
|
1745
|
+
}
|
|
1746
|
+
|
|
1747
|
+
// src/project-file.ts
|
|
1625
1748
|
var SITE_DIR = ".sakupa";
|
|
1626
1749
|
var SITE_FILE = "site.json";
|
|
1627
1750
|
var RECOVERY_FILE = "recovery.json";
|
|
1628
1751
|
function siteFilePath(projectDir) {
|
|
1629
|
-
return
|
|
1752
|
+
return join4(projectDir, SITE_DIR, SITE_FILE);
|
|
1630
1753
|
}
|
|
1631
1754
|
function recoveryFilePath(projectDir) {
|
|
1632
|
-
return
|
|
1755
|
+
return join4(projectDir, SITE_DIR, RECOVERY_FILE);
|
|
1633
1756
|
}
|
|
1634
|
-
function
|
|
1757
|
+
function readSiteFileOnDisk(projectDir) {
|
|
1635
1758
|
const path = siteFilePath(projectDir);
|
|
1636
|
-
if (!
|
|
1759
|
+
if (!existsSync3(path)) return { kind: "absent" };
|
|
1637
1760
|
let raw;
|
|
1638
1761
|
try {
|
|
1639
|
-
raw =
|
|
1762
|
+
raw = readFileSync3(path, "utf8");
|
|
1640
1763
|
} catch (err2) {
|
|
1641
1764
|
return {
|
|
1642
1765
|
kind: "corrupted",
|
|
@@ -1652,9 +1775,37 @@ function loadSiteFile(projectDir) {
|
|
|
1652
1775
|
if (typeof parsed !== "object" || parsed === null) {
|
|
1653
1776
|
return { kind: "corrupted", problem: "the file does not contain a JSON object" };
|
|
1654
1777
|
}
|
|
1778
|
+
return { kind: "ok", raw: parsed };
|
|
1779
|
+
}
|
|
1780
|
+
function siteFileFrom(raw, credential) {
|
|
1781
|
+
return {
|
|
1782
|
+
siteId: raw.siteId,
|
|
1783
|
+
credential,
|
|
1784
|
+
createdAt: typeof raw.createdAt === "string" ? raw.createdAt : "",
|
|
1785
|
+
apiBaseUrl: typeof raw.apiBaseUrl === "string" ? raw.apiBaseUrl : "",
|
|
1786
|
+
...typeof raw.shortId === "string" ? { shortId: raw.shortId } : {},
|
|
1787
|
+
...typeof raw.url === "string" ? { url: raw.url } : {},
|
|
1788
|
+
...typeof raw.boundDomain === "string" ? { boundDomain: raw.boundDomain } : {}
|
|
1789
|
+
};
|
|
1790
|
+
}
|
|
1791
|
+
function loadSiteFile(projectDir) {
|
|
1792
|
+
const disk = readSiteFileOnDisk(projectDir);
|
|
1793
|
+
if (disk.kind !== "ok") return disk;
|
|
1794
|
+
const parsed = disk.raw;
|
|
1655
1795
|
if (typeof parsed.siteId !== "string" || parsed.siteId.length === 0) {
|
|
1656
1796
|
return { kind: "corrupted", problem: "the siteId field is missing or empty" };
|
|
1657
1797
|
}
|
|
1798
|
+
if (typeof parsed.credentialRef === "string" && parsed.credentialRef.length > 0) {
|
|
1799
|
+
const stored = readStoredCredential(parsed.credentialRef);
|
|
1800
|
+
if (stored.kind === "absent") {
|
|
1801
|
+
return {
|
|
1802
|
+
kind: "corrupted",
|
|
1803
|
+
problem: `its credential is kept in the user-level store, but ${credentialStorePathSafe(parsed.credentialRef)} is missing on this machine. Restore that file from a backup of the home directory (or copy it from the machine that published the site); if it is gone for good, recover the site through its custom domain (recover) or, for a free site created on this device, recover with action "device"`
|
|
1804
|
+
};
|
|
1805
|
+
}
|
|
1806
|
+
if (stored.kind === "corrupted") return { kind: "corrupted", problem: stored.problem };
|
|
1807
|
+
return { kind: "ok", file: siteFileFrom(parsed, stored.entry.credential) };
|
|
1808
|
+
}
|
|
1658
1809
|
if (typeof parsed.credential !== "string" || parsed.credential.length === 0) {
|
|
1659
1810
|
return { kind: "corrupted", problem: "the credential field is missing or empty" };
|
|
1660
1811
|
}
|
|
@@ -1664,24 +1815,41 @@ function loadSiteFile(projectDir) {
|
|
|
1664
1815
|
problem: "the credential does not match the shape Sakupa issues (sk_ followed by exactly 43 URL-safe base64 characters) \u2014 it looks locally altered or damaged"
|
|
1665
1816
|
};
|
|
1666
1817
|
}
|
|
1667
|
-
|
|
1668
|
-
|
|
1669
|
-
|
|
1670
|
-
|
|
1671
|
-
|
|
1672
|
-
|
|
1673
|
-
|
|
1674
|
-
|
|
1675
|
-
|
|
1676
|
-
|
|
1818
|
+
const file = siteFileFrom(parsed, parsed.credential);
|
|
1819
|
+
migrateInlineCredential(projectDir, file);
|
|
1820
|
+
return { kind: "ok", file };
|
|
1821
|
+
}
|
|
1822
|
+
function credentialStorePathSafe(ref) {
|
|
1823
|
+
try {
|
|
1824
|
+
return credentialStorePath(ref);
|
|
1825
|
+
} catch {
|
|
1826
|
+
return `the credential store entry "${ref}"`;
|
|
1827
|
+
}
|
|
1828
|
+
}
|
|
1829
|
+
function migrateInlineCredential(projectDir, file) {
|
|
1830
|
+
try {
|
|
1831
|
+
const ref = newCredentialRef();
|
|
1832
|
+
storeCredential({
|
|
1833
|
+
ref,
|
|
1834
|
+
siteId: file.siteId,
|
|
1835
|
+
apiBaseUrl: file.apiBaseUrl,
|
|
1836
|
+
credential: file.credential,
|
|
1837
|
+
createdAt: file.createdAt
|
|
1838
|
+
});
|
|
1839
|
+
try {
|
|
1840
|
+
writeSiteFileOnDisk(projectDir, file, ref);
|
|
1841
|
+
} catch (error) {
|
|
1842
|
+
deleteStoredCredential(ref);
|
|
1843
|
+
throw error;
|
|
1677
1844
|
}
|
|
1678
|
-
}
|
|
1845
|
+
} catch {
|
|
1846
|
+
}
|
|
1679
1847
|
}
|
|
1680
1848
|
function loadRecoveryFile(projectDir) {
|
|
1681
1849
|
const path = recoveryFilePath(projectDir);
|
|
1682
|
-
if (!
|
|
1850
|
+
if (!existsSync3(path)) return null;
|
|
1683
1851
|
try {
|
|
1684
|
-
const parsed = JSON.parse(
|
|
1852
|
+
const parsed = JSON.parse(readFileSync3(path, "utf8"));
|
|
1685
1853
|
if (typeof parsed.verificationId !== "string" || parsed.verificationId.length === 0 || typeof parsed.credential !== "string" || !CREDENTIAL_PATTERN.test(parsed.credential)) {
|
|
1686
1854
|
throw new Error("required recovery fields are missing or invalid");
|
|
1687
1855
|
}
|
|
@@ -1697,19 +1865,20 @@ function loadRecoveryFile(projectDir) {
|
|
|
1697
1865
|
}
|
|
1698
1866
|
}
|
|
1699
1867
|
function writeRecoveryFile(projectDir, file) {
|
|
1700
|
-
const dir =
|
|
1701
|
-
|
|
1702
|
-
|
|
1703
|
-
|
|
1868
|
+
const dir = join4(projectDir, SITE_DIR);
|
|
1869
|
+
mkdirSync3(dir, { recursive: true, mode: 448 });
|
|
1870
|
+
ensureSakupaGitignore(projectDir);
|
|
1871
|
+
const path = join4(dir, RECOVERY_FILE);
|
|
1872
|
+
writeFileSync3(path, `${JSON.stringify(file, null, 2)}
|
|
1704
1873
|
`, "utf8");
|
|
1705
1874
|
try {
|
|
1706
|
-
|
|
1875
|
+
chmodSync3(path, 384);
|
|
1707
1876
|
} catch {
|
|
1708
1877
|
}
|
|
1709
1878
|
}
|
|
1710
1879
|
function deleteRecoveryFile(projectDir) {
|
|
1711
1880
|
const path = recoveryFilePath(projectDir);
|
|
1712
|
-
if (
|
|
1881
|
+
if (existsSync3(path)) rmSync2(path, { force: true });
|
|
1713
1882
|
}
|
|
1714
1883
|
function siteFileRecoveryGuidance(projectDir) {
|
|
1715
1884
|
return `The site itself is intact on the server; only the local binding file (${siteFilePath(projectDir)}) is the problem. Restore the file (from a backup or by undoing the local edit). Do NOT delete it to work around the error: the credential is unrecoverable by design, so abandoning it permanently orphans the existing site. Run help for a safe repair path; the tool will never overwrite a damaged binding or ask the user to manipulate credentials manually.`;
|
|
@@ -1728,35 +1897,57 @@ function writeSiteFile(projectDir, file, opts = {}) {
|
|
|
1728
1897
|
);
|
|
1729
1898
|
}
|
|
1730
1899
|
}
|
|
1731
|
-
const
|
|
1732
|
-
|
|
1733
|
-
const
|
|
1734
|
-
|
|
1735
|
-
|
|
1900
|
+
const disk = readSiteFileOnDisk(projectDir);
|
|
1901
|
+
const existingRef = disk.kind === "ok" && disk.raw.siteId === file.siteId && typeof disk.raw.credentialRef === "string" && disk.raw.credentialRef.length > 0 ? disk.raw.credentialRef : void 0;
|
|
1902
|
+
const ref = existingRef ?? newCredentialRef();
|
|
1903
|
+
storeCredential({
|
|
1904
|
+
ref,
|
|
1905
|
+
siteId: file.siteId,
|
|
1906
|
+
apiBaseUrl: file.apiBaseUrl,
|
|
1907
|
+
credential: file.credential,
|
|
1908
|
+
createdAt: file.createdAt
|
|
1909
|
+
});
|
|
1910
|
+
try {
|
|
1911
|
+
writeSiteFileOnDisk(projectDir, file, ref);
|
|
1912
|
+
} catch (error) {
|
|
1913
|
+
if (existingRef === void 0) deleteStoredCredential(ref);
|
|
1914
|
+
throw error;
|
|
1915
|
+
}
|
|
1916
|
+
}
|
|
1917
|
+
function writeSiteFileOnDisk(projectDir, file, ref) {
|
|
1918
|
+
const dir = join4(projectDir, SITE_DIR);
|
|
1919
|
+
mkdirSync3(dir, { recursive: true, mode: 448 });
|
|
1920
|
+
ensureSakupaGitignore(projectDir);
|
|
1921
|
+
const path = join4(dir, SITE_FILE);
|
|
1922
|
+
const { credential: _omitted, ...rest } = file;
|
|
1923
|
+
const onDisk = { ...rest, credentialRef: ref };
|
|
1924
|
+
const temporary = join4(dir, `.site-${randomUUID3()}.tmp`);
|
|
1925
|
+
writeFileSync3(temporary, `${JSON.stringify(onDisk, null, 2)}
|
|
1736
1926
|
`, {
|
|
1737
1927
|
encoding: "utf8",
|
|
1738
1928
|
mode: 384
|
|
1739
1929
|
});
|
|
1740
1930
|
try {
|
|
1741
|
-
|
|
1931
|
+
chmodSync3(temporary, 384);
|
|
1742
1932
|
} catch {
|
|
1743
1933
|
}
|
|
1744
1934
|
try {
|
|
1745
|
-
|
|
1935
|
+
renameSync3(temporary, path);
|
|
1746
1936
|
} catch (error) {
|
|
1747
|
-
|
|
1937
|
+
rmSync2(temporary, { force: true });
|
|
1748
1938
|
throw error;
|
|
1749
1939
|
}
|
|
1750
1940
|
}
|
|
1751
1941
|
function deleteSiteFile(projectDir) {
|
|
1752
1942
|
const path = siteFilePath(projectDir);
|
|
1753
|
-
|
|
1754
|
-
|
|
1943
|
+
const disk = readSiteFileOnDisk(projectDir);
|
|
1944
|
+
if (existsSync3(path)) {
|
|
1945
|
+
rmSync2(path, { force: true });
|
|
1755
1946
|
}
|
|
1756
|
-
|
|
1757
|
-
|
|
1758
|
-
} catch {
|
|
1947
|
+
if (disk.kind === "ok" && typeof disk.raw.credentialRef === "string") {
|
|
1948
|
+
deleteStoredCredential(disk.raw.credentialRef);
|
|
1759
1949
|
}
|
|
1950
|
+
pruneSakupaDirectory(projectDir);
|
|
1760
1951
|
}
|
|
1761
1952
|
function deleteSiteFileIfMatches(projectDir, expected) {
|
|
1762
1953
|
const state = loadSiteFile(projectDir);
|
|
@@ -1779,17 +1970,17 @@ function findAncestor(startDir, predicate, maxLevels = Number.POSITIVE_INFINITY)
|
|
|
1779
1970
|
return null;
|
|
1780
1971
|
}
|
|
1781
1972
|
function isInsideGitRepo(projectDir) {
|
|
1782
|
-
return
|
|
1973
|
+
return existsSync3(join4(projectDir, ".git")) || findAncestor(projectDir, (dir) => existsSync3(join4(dir, ".git"))) !== null;
|
|
1783
1974
|
}
|
|
1784
1975
|
function credentialGitReminder(projectDir) {
|
|
1785
1976
|
if (!isInsideGitRepo(projectDir)) return "";
|
|
1786
|
-
return '\nNOTE: this project is inside a git repository. The management credential in .sakupa/site.json is the key to this site \u2014 do NOT commit it to a PUBLIC repository
|
|
1977
|
+
return '\nNOTE: this project is inside a git repository. The management credential in .sakupa/site.json is the key to this site \u2014 do NOT commit it to a PUBLIC repository. Sakupa keeps a ".sakupa/.gitignore" that ignores the whole directory; leave it in place.';
|
|
1787
1978
|
}
|
|
1788
1979
|
|
|
1789
1980
|
// src/recovery-archive.ts
|
|
1790
|
-
import { existsSync as
|
|
1981
|
+
import { existsSync as existsSync4, realpathSync as realpathSync2 } from "node:fs";
|
|
1791
1982
|
import { mkdtemp, mkdir, readFile, readdir, rename, rm, stat, writeFile } from "node:fs/promises";
|
|
1792
|
-
import { dirname as dirname2, isAbsolute as isAbsolute2, join as
|
|
1983
|
+
import { dirname as dirname2, isAbsolute as isAbsolute2, join as join5, relative as relative2, resolve as resolve3, sep as sep3 } from "node:path";
|
|
1793
1984
|
|
|
1794
1985
|
// ../../node_modules/fflate/esm/index.mjs
|
|
1795
1986
|
import { createRequire } from "module";
|
|
@@ -2289,7 +2480,7 @@ function safeOutputPath(projectDir, outputDir) {
|
|
|
2289
2480
|
throw new SakupaError("invalid_request", "Recovery content cannot be written inside .sakupa");
|
|
2290
2481
|
}
|
|
2291
2482
|
let existingAncestor = target;
|
|
2292
|
-
while (!
|
|
2483
|
+
while (!existsSync4(existingAncestor)) {
|
|
2293
2484
|
const parent = dirname2(existingAncestor);
|
|
2294
2485
|
if (parent === existingAncestor) break;
|
|
2295
2486
|
existingAncestor = parent;
|
|
@@ -2331,7 +2522,7 @@ async function listExistingFiles(root, current = root) {
|
|
|
2331
2522
|
if (entry.isSymbolicLink()) {
|
|
2332
2523
|
throw new SakupaError("state_conflict", `Recovery output contains a symlink: ${entry.name}`);
|
|
2333
2524
|
}
|
|
2334
|
-
const absolute =
|
|
2525
|
+
const absolute = join5(current, entry.name);
|
|
2335
2526
|
if (entry.isDirectory()) files.push(...await listExistingFiles(root, absolute));
|
|
2336
2527
|
else if (entry.isFile()) files.push(relative2(root, absolute).split(sep3).join("/"));
|
|
2337
2528
|
else
|
|
@@ -2349,7 +2540,7 @@ async function existingOutputMatches(outputDir, files) {
|
|
|
2349
2540
|
return false;
|
|
2350
2541
|
}
|
|
2351
2542
|
for (const name of expected) {
|
|
2352
|
-
const actual = await readFile(
|
|
2543
|
+
const actual = await readFile(join5(outputDir, ...name.split("/")));
|
|
2353
2544
|
const wanted = files[name];
|
|
2354
2545
|
if (!wanted || actual.byteLength !== wanted.byteLength || !actual.equals(wanted)) return false;
|
|
2355
2546
|
}
|
|
@@ -2400,13 +2591,13 @@ async function extractRecoveryArchive(input) {
|
|
|
2400
2591
|
`Recovery output already exists with different content: ${outputDir}. Move it aside or choose another outputDir.`
|
|
2401
2592
|
);
|
|
2402
2593
|
}
|
|
2403
|
-
const tempDir = await mkdtemp(
|
|
2594
|
+
const tempDir = await mkdtemp(join5(resolve3(input.projectDir), ".sakupa-restore-"));
|
|
2404
2595
|
try {
|
|
2405
2596
|
let writtenBytes = 0;
|
|
2406
2597
|
const entries = Object.entries(files).sort(([a], [b]) => a.localeCompare(b));
|
|
2407
2598
|
for (const [rawName, data] of entries) {
|
|
2408
2599
|
const name = safeEntryName(rawName);
|
|
2409
|
-
const destination =
|
|
2600
|
+
const destination = join5(tempDir, ...name.split("/"));
|
|
2410
2601
|
await mkdir(dirname2(destination), { recursive: true });
|
|
2411
2602
|
await writeFile(destination, data, { flag: "wx" });
|
|
2412
2603
|
writtenBytes += data.byteLength;
|
|
@@ -2432,19 +2623,19 @@ async function extractRecoveryArchive(input) {
|
|
|
2432
2623
|
}
|
|
2433
2624
|
|
|
2434
2625
|
// src/creation-registry.ts
|
|
2435
|
-
import { existsSync as
|
|
2436
|
-
import { homedir as
|
|
2437
|
-
import { dirname as dirname3, join as
|
|
2626
|
+
import { existsSync as existsSync5, mkdirSync as mkdirSync4, readFileSync as readFileSync4, writeFileSync as writeFileSync4 } from "node:fs";
|
|
2627
|
+
import { homedir as homedir3 } from "node:os";
|
|
2628
|
+
import { dirname as dirname3, join as join6 } from "node:path";
|
|
2438
2629
|
var RECENT_WINDOW_MS = FREE_SITE_TTL_HOURS * 60 * 60 * 1e3;
|
|
2439
2630
|
function creationRegistryPath() {
|
|
2440
|
-
const base = process.env["SAKUPA_STATE_DIR"] ??
|
|
2441
|
-
return
|
|
2631
|
+
const base = process.env["SAKUPA_STATE_DIR"] ?? homedir3();
|
|
2632
|
+
return join6(base, ".sakupa", "created-sites.json");
|
|
2442
2633
|
}
|
|
2443
2634
|
function readAll() {
|
|
2444
2635
|
const path = creationRegistryPath();
|
|
2445
|
-
if (!
|
|
2636
|
+
if (!existsSync5(path)) return [];
|
|
2446
2637
|
try {
|
|
2447
|
-
const parsed = JSON.parse(
|
|
2638
|
+
const parsed = JSON.parse(readFileSync4(path, "utf-8"));
|
|
2448
2639
|
if (!Array.isArray(parsed)) return [];
|
|
2449
2640
|
return parsed.filter(
|
|
2450
2641
|
(e) => typeof e === "object" && e !== null && typeof e.siteId === "string" && typeof e.projectDir === "string" && typeof e.url === "string" && typeof e.createdAt === "string" && (e.apiBaseUrl === void 0 || typeof e.apiBaseUrl === "string")
|
|
@@ -2455,8 +2646,8 @@ function readAll() {
|
|
|
2455
2646
|
}
|
|
2456
2647
|
function writeAll(records) {
|
|
2457
2648
|
const path = creationRegistryPath();
|
|
2458
|
-
|
|
2459
|
-
|
|
2649
|
+
mkdirSync4(dirname3(path), { recursive: true });
|
|
2650
|
+
writeFileSync4(path, `${JSON.stringify(records, null, 2)}
|
|
2460
2651
|
`, "utf-8");
|
|
2461
2652
|
}
|
|
2462
2653
|
function listRecentCreations(nowMs, apiBaseUrl) {
|
|
@@ -2500,28 +2691,28 @@ function noteSiteMode(siteId, mode) {
|
|
|
2500
2691
|
// src/device-file.ts
|
|
2501
2692
|
import {
|
|
2502
2693
|
closeSync,
|
|
2503
|
-
existsSync as
|
|
2504
|
-
mkdirSync as
|
|
2694
|
+
existsSync as existsSync6,
|
|
2695
|
+
mkdirSync as mkdirSync5,
|
|
2505
2696
|
openSync,
|
|
2506
|
-
readFileSync as
|
|
2507
|
-
renameSync as
|
|
2697
|
+
readFileSync as readFileSync5,
|
|
2698
|
+
renameSync as renameSync4,
|
|
2508
2699
|
statSync as statSync2,
|
|
2509
2700
|
unlinkSync as unlinkSync2,
|
|
2510
|
-
writeFileSync as
|
|
2701
|
+
writeFileSync as writeFileSync5
|
|
2511
2702
|
} from "node:fs";
|
|
2512
|
-
import { randomUUID as
|
|
2513
|
-
import { homedir as
|
|
2514
|
-
import { dirname as dirname4, join as
|
|
2703
|
+
import { randomUUID as randomUUID4 } from "node:crypto";
|
|
2704
|
+
import { homedir as homedir4 } from "node:os";
|
|
2705
|
+
import { dirname as dirname4, join as join7 } from "node:path";
|
|
2515
2706
|
var DEVICE_LOCK_STALE_MS = 3e4;
|
|
2516
2707
|
var DEVICE_LOCK_WAIT_MS = 2e4;
|
|
2517
2708
|
function deviceRegistryPath() {
|
|
2518
|
-
const base = process.env["SAKUPA_STATE_DIR"] ??
|
|
2519
|
-
return
|
|
2709
|
+
const base = process.env["SAKUPA_STATE_DIR"] ?? homedir4();
|
|
2710
|
+
return join7(base, ".sakupa", "devices.json");
|
|
2520
2711
|
}
|
|
2521
|
-
var deviceLockPath = () =>
|
|
2712
|
+
var deviceLockPath = () => join7(dirname4(deviceRegistryPath()), "devices.lock");
|
|
2522
2713
|
function lockTokenAt(path) {
|
|
2523
2714
|
try {
|
|
2524
|
-
const parsed = JSON.parse(
|
|
2715
|
+
const parsed = JSON.parse(readFileSync5(path, "utf8"));
|
|
2525
2716
|
return typeof parsed.token === "string" ? parsed.token : null;
|
|
2526
2717
|
} catch {
|
|
2527
2718
|
return null;
|
|
@@ -2543,15 +2734,15 @@ function releaseDeviceLock(lock) {
|
|
|
2543
2734
|
}
|
|
2544
2735
|
async function acquireDeviceLock(apiBaseUrl) {
|
|
2545
2736
|
const path = deviceLockPath();
|
|
2546
|
-
|
|
2737
|
+
mkdirSync5(dirname4(path), { recursive: true, mode: 448 });
|
|
2547
2738
|
const deadline = Date.now() + DEVICE_LOCK_WAIT_MS;
|
|
2548
2739
|
while (true) {
|
|
2549
2740
|
const existing = loadDeviceBinding(apiBaseUrl);
|
|
2550
2741
|
if (existing) return existing;
|
|
2551
2742
|
try {
|
|
2552
|
-
const token =
|
|
2743
|
+
const token = randomUUID4();
|
|
2553
2744
|
const fd2 = openSync(path, "wx", 384);
|
|
2554
|
-
|
|
2745
|
+
writeFileSync5(
|
|
2555
2746
|
fd2,
|
|
2556
2747
|
JSON.stringify({ token, pid: process.pid, createdAt: (/* @__PURE__ */ new Date()).toISOString() })
|
|
2557
2748
|
);
|
|
@@ -2576,11 +2767,11 @@ async function acquireDeviceLock(apiBaseUrl) {
|
|
|
2576
2767
|
}
|
|
2577
2768
|
function readRegistry() {
|
|
2578
2769
|
const path = deviceRegistryPath();
|
|
2579
|
-
if (!
|
|
2770
|
+
if (!existsSync6(path)) {
|
|
2580
2771
|
return { schemaVersion: 1, environments: {}, pendingRegistrations: {} };
|
|
2581
2772
|
}
|
|
2582
2773
|
try {
|
|
2583
|
-
const parsed = JSON.parse(
|
|
2774
|
+
const parsed = JSON.parse(readFileSync5(path, "utf8"));
|
|
2584
2775
|
if (parsed.schemaVersion !== 1 || !parsed.environments || typeof parsed.environments !== "object") {
|
|
2585
2776
|
throw new Error("unsupported device registry schema");
|
|
2586
2777
|
}
|
|
@@ -2594,14 +2785,14 @@ function readRegistry() {
|
|
|
2594
2785
|
}
|
|
2595
2786
|
function writeRegistry(registry) {
|
|
2596
2787
|
const path = deviceRegistryPath();
|
|
2597
|
-
|
|
2788
|
+
mkdirSync5(dirname4(path), { recursive: true, mode: 448 });
|
|
2598
2789
|
const temporary = `${path}.${process.pid}.tmp`;
|
|
2599
|
-
|
|
2790
|
+
writeFileSync5(temporary, `${JSON.stringify(registry, null, 2)}
|
|
2600
2791
|
`, {
|
|
2601
2792
|
encoding: "utf8",
|
|
2602
2793
|
mode: 384
|
|
2603
2794
|
});
|
|
2604
|
-
|
|
2795
|
+
renameSync4(temporary, path);
|
|
2605
2796
|
}
|
|
2606
2797
|
function loadDeviceBinding(apiBaseUrl) {
|
|
2607
2798
|
const binding = readRegistry().environments[apiBaseUrl];
|
|
@@ -2622,8 +2813,8 @@ async function ensureDeviceBinding(client, apiBaseUrl) {
|
|
|
2622
2813
|
let pending = registry.pendingRegistrations[apiBaseUrl];
|
|
2623
2814
|
if (!pending) {
|
|
2624
2815
|
pending = {
|
|
2625
|
-
operationId:
|
|
2626
|
-
deviceId:
|
|
2816
|
+
operationId: randomUUID4(),
|
|
2817
|
+
deviceId: randomUUID4(),
|
|
2627
2818
|
credential: generateCredential(),
|
|
2628
2819
|
createdAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
2629
2820
|
};
|
|
@@ -2655,15 +2846,15 @@ async function ensureDeviceBinding(client, apiBaseUrl) {
|
|
|
2655
2846
|
// src/site-handoff.ts
|
|
2656
2847
|
import {
|
|
2657
2848
|
closeSync as closeSync2,
|
|
2658
|
-
existsSync as
|
|
2659
|
-
mkdirSync as
|
|
2849
|
+
existsSync as existsSync7,
|
|
2850
|
+
mkdirSync as mkdirSync6,
|
|
2660
2851
|
openSync as openSync2,
|
|
2661
2852
|
statSync as statSync3,
|
|
2662
2853
|
unlinkSync as unlinkSync3,
|
|
2663
|
-
writeFileSync as
|
|
2854
|
+
writeFileSync as writeFileSync6
|
|
2664
2855
|
} from "node:fs";
|
|
2665
2856
|
import { createHash } from "node:crypto";
|
|
2666
|
-
import { dirname as dirname5, isAbsolute as isAbsolute3, join as
|
|
2857
|
+
import { dirname as dirname5, isAbsolute as isAbsolute3, join as join8 } from "node:path";
|
|
2667
2858
|
var HANDOFF_LOCK_TTL_MS = 15 * 60 * 1e3;
|
|
2668
2859
|
function normalizeSiteUrl(raw) {
|
|
2669
2860
|
const url = new URL(raw);
|
|
@@ -2719,12 +2910,12 @@ function resolveReusableSite(rawUrl, currentProjectDir, nowMs, apiBaseUrl) {
|
|
|
2719
2910
|
}
|
|
2720
2911
|
function lockPath(siteId) {
|
|
2721
2912
|
const digest = createHash("sha256").update(siteId).digest("hex");
|
|
2722
|
-
return
|
|
2913
|
+
return join8(dirname5(creationRegistryPath()), "handoff-locks", `${digest}.lock`);
|
|
2723
2914
|
}
|
|
2724
2915
|
function acquireSiteHandoffLock(siteId) {
|
|
2725
2916
|
const path = lockPath(siteId);
|
|
2726
|
-
|
|
2727
|
-
if (
|
|
2917
|
+
mkdirSync6(dirname5(path), { recursive: true, mode: 448 });
|
|
2918
|
+
if (existsSync7(path)) {
|
|
2728
2919
|
try {
|
|
2729
2920
|
if (Date.now() - statSync3(path).mtimeMs >= HANDOFF_LOCK_TTL_MS) unlinkSync3(path);
|
|
2730
2921
|
} catch {
|
|
@@ -2738,7 +2929,7 @@ function acquireSiteHandoffLock(siteId) {
|
|
|
2738
2929
|
"Another Sakupa process is already performing a site handoff for this free site. Wait for it to finish and retry deploy."
|
|
2739
2930
|
);
|
|
2740
2931
|
}
|
|
2741
|
-
|
|
2932
|
+
writeFileSync6(fd2, JSON.stringify({ siteId, createdAt: (/* @__PURE__ */ new Date()).toISOString() }));
|
|
2742
2933
|
return () => {
|
|
2743
2934
|
try {
|
|
2744
2935
|
closeSync2(fd2);
|
|
@@ -2952,26 +3143,26 @@ var CLIENT_TYPE = "sakupa-mcp";
|
|
|
2952
3143
|
|
|
2953
3144
|
// src/credential-rotation.ts
|
|
2954
3145
|
import {
|
|
2955
|
-
chmodSync as
|
|
2956
|
-
existsSync as
|
|
2957
|
-
mkdirSync as
|
|
2958
|
-
readFileSync as
|
|
2959
|
-
renameSync as
|
|
2960
|
-
rmSync as
|
|
2961
|
-
writeFileSync as
|
|
3146
|
+
chmodSync as chmodSync4,
|
|
3147
|
+
existsSync as existsSync8,
|
|
3148
|
+
mkdirSync as mkdirSync7,
|
|
3149
|
+
readFileSync as readFileSync6,
|
|
3150
|
+
renameSync as renameSync5,
|
|
3151
|
+
rmSync as rmSync3,
|
|
3152
|
+
writeFileSync as writeFileSync7
|
|
2962
3153
|
} from "node:fs";
|
|
2963
|
-
import { randomUUID as
|
|
2964
|
-
import { join as
|
|
3154
|
+
import { randomUUID as randomUUID5 } from "node:crypto";
|
|
3155
|
+
import { join as join9 } from "node:path";
|
|
2965
3156
|
var ROTATION_FILE = "rotation.json";
|
|
2966
3157
|
function credentialRotationPath(projectDir) {
|
|
2967
|
-
return
|
|
3158
|
+
return join9(projectDir, ".sakupa", ROTATION_FILE);
|
|
2968
3159
|
}
|
|
2969
3160
|
function loadCredentialRotation(projectDir) {
|
|
2970
3161
|
const path = credentialRotationPath(projectDir);
|
|
2971
|
-
if (!
|
|
3162
|
+
if (!existsSync8(path)) return { kind: "absent" };
|
|
2972
3163
|
let parsed;
|
|
2973
3164
|
try {
|
|
2974
|
-
parsed = JSON.parse(
|
|
3165
|
+
parsed = JSON.parse(readFileSync6(path, "utf8"));
|
|
2975
3166
|
} catch (error) {
|
|
2976
3167
|
return {
|
|
2977
3168
|
kind: "corrupted",
|
|
@@ -3018,28 +3209,28 @@ function writeCredentialRotation(projectDir, file) {
|
|
|
3018
3209
|
"A different credential rotation is already pending. Run rotate to resume it; no state was overwritten."
|
|
3019
3210
|
);
|
|
3020
3211
|
}
|
|
3021
|
-
const directory =
|
|
3022
|
-
|
|
3212
|
+
const directory = join9(projectDir, ".sakupa");
|
|
3213
|
+
mkdirSync7(directory, { recursive: true, mode: 448 });
|
|
3023
3214
|
const target = credentialRotationPath(projectDir);
|
|
3024
|
-
const temporary =
|
|
3025
|
-
|
|
3215
|
+
const temporary = join9(directory, `.rotation-${randomUUID5()}.tmp`);
|
|
3216
|
+
writeFileSync7(temporary, `${JSON.stringify(file, null, 2)}
|
|
3026
3217
|
`, {
|
|
3027
3218
|
encoding: "utf8",
|
|
3028
3219
|
mode: 384
|
|
3029
3220
|
});
|
|
3030
3221
|
try {
|
|
3031
|
-
|
|
3222
|
+
chmodSync4(temporary, 384);
|
|
3032
3223
|
} catch {
|
|
3033
3224
|
}
|
|
3034
3225
|
try {
|
|
3035
|
-
|
|
3226
|
+
renameSync5(temporary, target);
|
|
3036
3227
|
} catch (error) {
|
|
3037
|
-
|
|
3228
|
+
rmSync3(temporary, { force: true });
|
|
3038
3229
|
throw error;
|
|
3039
3230
|
}
|
|
3040
3231
|
}
|
|
3041
3232
|
function deleteCredentialRotation(projectDir) {
|
|
3042
|
-
|
|
3233
|
+
rmSync3(credentialRotationPath(projectDir), { force: true });
|
|
3043
3234
|
}
|
|
3044
3235
|
function promoteRotatedCredential(projectDir, site, rotation, credentialCreatedAt) {
|
|
3045
3236
|
if (rotation.siteId !== site.siteId || rotation.apiBaseUrl !== site.apiBaseUrl) {
|
|
@@ -3659,7 +3850,7 @@ function timestampForAgentInZone(exactTimestamp, clientTimeZone) {
|
|
|
3659
3850
|
}
|
|
3660
3851
|
|
|
3661
3852
|
// src/tools/context.ts
|
|
3662
|
-
import { randomUUID as
|
|
3853
|
+
import { randomUUID as randomUUID6 } from "node:crypto";
|
|
3663
3854
|
var LocalGuidanceError = class extends SakupaError {
|
|
3664
3855
|
constructor(code, message) {
|
|
3665
3856
|
super(code, message);
|
|
@@ -3744,7 +3935,7 @@ function reportAuthorizationStore(ctx) {
|
|
|
3744
3935
|
return store;
|
|
3745
3936
|
}
|
|
3746
3937
|
function issueReportAuthorization(ctx, failedTool) {
|
|
3747
|
-
const token =
|
|
3938
|
+
const token = randomUUID6();
|
|
3748
3939
|
reportAuthorizationStore(ctx).set(token, {
|
|
3749
3940
|
failedTool,
|
|
3750
3941
|
expiresAt: Date.now() + 10 * 60 * 1e3
|
|
@@ -3777,7 +3968,7 @@ function requireSiteFile(ctx) {
|
|
|
3777
3968
|
}
|
|
3778
3969
|
return state.file;
|
|
3779
3970
|
}
|
|
3780
|
-
var UNAUTHORIZED_SUMMARY = "The server rejected the site credential: the one
|
|
3971
|
+
var UNAUTHORIZED_SUMMARY = "The server rejected the site credential: the one referenced by .sakupa/site.json (kept in the user-level credential store) no longer matches the server-side verifier. The site itself is intact on the server \u2014 only the local binding file is the problem. Repair the file (restore a backup or undo the local edit). Do NOT delete the .sakupa directory to work around this: the credential is unrecoverable by design, so abandoning it permanently orphans the existing site.";
|
|
3781
3972
|
function toolError(e) {
|
|
3782
3973
|
if (e instanceof McpRootsPending) {
|
|
3783
3974
|
return inputRequired({ inputRequests: { roots: inputRequired.listRoots() } });
|
|
@@ -4208,7 +4399,7 @@ function ensureUploadSizeWithinLimits(manifest, isFirstFreeDeploy) {
|
|
|
4208
4399
|
async function buildHashedManifest(files, outputAbs) {
|
|
4209
4400
|
const manifest = [];
|
|
4210
4401
|
for (const file of files) {
|
|
4211
|
-
const bytes = new Uint8Array(await fs2.readFile(
|
|
4402
|
+
const bytes = new Uint8Array(await fs2.readFile(join10(outputAbs, file.path)));
|
|
4212
4403
|
manifest.push({ path: file.path, size: file.size, contentHash: await sha256Hex(bytes) });
|
|
4213
4404
|
}
|
|
4214
4405
|
return manifest;
|
|
@@ -4227,7 +4418,7 @@ async function uploadAll(ctx, targets, files, outputAbs) {
|
|
|
4227
4418
|
`No local file matches upload target "${target.path}"; aborting upload.`
|
|
4228
4419
|
);
|
|
4229
4420
|
}
|
|
4230
|
-
const bytes = new Uint8Array(await fs2.readFile(
|
|
4421
|
+
const bytes = new Uint8Array(await fs2.readFile(join10(outputAbs, match.path)));
|
|
4231
4422
|
if (bytes.byteLength !== match.size) {
|
|
4232
4423
|
throw new SakupaError(
|
|
4233
4424
|
"validation_failed",
|
|
@@ -4372,14 +4563,14 @@ function outputDirectoryChain(projectRoot, outputAbs) {
|
|
|
4372
4563
|
const chain = [];
|
|
4373
4564
|
let cursor = projectRoot;
|
|
4374
4565
|
for (const part of rel.split(sep4).filter(Boolean)) {
|
|
4375
|
-
cursor =
|
|
4566
|
+
cursor = join10(cursor, part);
|
|
4376
4567
|
chain.push(cursor);
|
|
4377
4568
|
}
|
|
4378
4569
|
return chain;
|
|
4379
4570
|
}
|
|
4380
4571
|
async function sakupaDirectoryEntries(projectDir) {
|
|
4381
4572
|
try {
|
|
4382
|
-
return await fs2.readdir(
|
|
4573
|
+
return await fs2.readdir(join10(projectDir, ".sakupa"));
|
|
4383
4574
|
} catch (error) {
|
|
4384
4575
|
const code = error.code;
|
|
4385
4576
|
if (code === "ENOENT") return [];
|
|
@@ -4532,7 +4723,7 @@ function registerTools(server, baseCtx) {
|
|
|
4532
4723
|
const entries = await sakupaDirectoryEntries(candidateDir);
|
|
4533
4724
|
if (entries.length === 0) continue;
|
|
4534
4725
|
const unknownEntries = entries.filter(
|
|
4535
|
-
(entry) => !["project.json", "site.json", "recovery.json"].includes(entry)
|
|
4726
|
+
(entry) => !["project.json", "site.json", "recovery.json", ".gitignore"].includes(entry)
|
|
4536
4727
|
);
|
|
4537
4728
|
if (unknownEntries.length > 0) {
|
|
4538
4729
|
return text(
|
|
@@ -4560,8 +4751,8 @@ function registerTools(server, baseCtx) {
|
|
|
4560
4751
|
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.`,
|
|
4561
4752
|
data: {
|
|
4562
4753
|
projectRoot: ctx.projectDir,
|
|
4563
|
-
misplacedSakupaDirectory:
|
|
4564
|
-
targetSakupaDirectory:
|
|
4754
|
+
misplacedSakupaDirectory: join10(candidateDir, ".sakupa"),
|
|
4755
|
+
targetSakupaDirectory: join10(ctx.projectDir, ".sakupa"),
|
|
4565
4756
|
confirmationField: "sakupaRelocationConfirmed",
|
|
4566
4757
|
confirmation,
|
|
4567
4758
|
confirmArguments
|
|
@@ -5226,7 +5417,7 @@ ${JSON.stringify(finalized.warnings, null, 2)}
|
|
|
5226
5417
|
{
|
|
5227
5418
|
siteId: site.siteId,
|
|
5228
5419
|
plan: args.plan,
|
|
5229
|
-
idempotencyKey:
|
|
5420
|
+
idempotencyKey: randomUUID7()
|
|
5230
5421
|
},
|
|
5231
5422
|
site.credential
|
|
5232
5423
|
);
|
|
@@ -5549,11 +5740,13 @@ Full status:`,
|
|
|
5549
5740
|
"recover",
|
|
5550
5741
|
{
|
|
5551
5742
|
title: "Recover site",
|
|
5552
|
-
description:
|
|
5743
|
+
description: 'Recover management control of a site after losing the local .sakupa binding. Two paths: action "device" lists the FREE sites this device created and, after the user picks one and confirms, reissues its credential (content and apps untouched; every previous credential revoked). Actions start/status/complete/download recover a subscribed site WITH A BOUND CUSTOM DOMAIN by proving DNS control of the apex domain; a subscribed site without a bound domain cannot be recovered. By default, completing recovery REVOKES all previous local credentials. Recovery is resumable: start stores local pending state; complete installs and writes the new .sakupa/site.json credential BEFORE requesting content; download uses that credential to reissue an archive and safely extract it into the explicitly selected outputDir without repeating DNS.',
|
|
5553
5744
|
outputSchema: STRUCTURED_TOOL_OUTPUT_SCHEMA,
|
|
5554
5745
|
annotations: { readOnlyHint: false, destructiveHint: true, openWorldHint: true },
|
|
5555
5746
|
inputSchema: z2.object({
|
|
5556
|
-
action: z2.enum(["start", "status", "complete", "download"]),
|
|
5747
|
+
action: z2.enum(["device", "start", "status", "complete", "download"]),
|
|
5748
|
+
siteId: z2.string().optional().describe("device only: the site chosen from the decision (copied verbatim)."),
|
|
5749
|
+
confirmed: z2.boolean().optional().describe("device only: true only from the exact decision arguments."),
|
|
5557
5750
|
hostname: z2.string().optional().describe("Required for start."),
|
|
5558
5751
|
verificationId: z2.string().optional().describe("For status or complete; inferred from local recovery state when omitted."),
|
|
5559
5752
|
outputDir: z2.string().optional().describe(
|
|
@@ -5602,6 +5795,139 @@ Full status:`,
|
|
|
5602
5795
|
deleteRecoveryFile(ctx.projectDir);
|
|
5603
5796
|
return { archive, extracted };
|
|
5604
5797
|
};
|
|
5798
|
+
if (args.action === "device") {
|
|
5799
|
+
if (localSite.kind === "ok" && await localCredentialIsActive()) {
|
|
5800
|
+
return structuredToolResult({
|
|
5801
|
+
schemaVersion: 1,
|
|
5802
|
+
outcome: "blocked",
|
|
5803
|
+
resultCode: "recovery_credential_already_present",
|
|
5804
|
+
summary: summaryMarkdown({
|
|
5805
|
+
title: "This project already holds a working credential",
|
|
5806
|
+
lead: `${localSite.file.url ?? localSite.file.siteId} is bound here and its credential works; nothing was changed. Use status or deploy. To manage a different free site, open its own project directory.`,
|
|
5807
|
+
next: ["`status`"]
|
|
5808
|
+
}),
|
|
5809
|
+
data: { siteId: localSite.file.siteId, credentialStoredLocally: true },
|
|
5810
|
+
nextActions: [{ tool: "status", arguments: {}, allowed: true }]
|
|
5811
|
+
});
|
|
5812
|
+
}
|
|
5813
|
+
const device = await ensureDeviceBinding(ctx.client, ctx.apiBaseUrl);
|
|
5814
|
+
if (args.confirmed === true && args.siteId) {
|
|
5815
|
+
const res2 = await ctx.client.reissueDeviceFreeSiteCredential(
|
|
5816
|
+
args.siteId,
|
|
5817
|
+
device.deviceId,
|
|
5818
|
+
device.credential
|
|
5819
|
+
);
|
|
5820
|
+
const nowIso = (/* @__PURE__ */ new Date()).toISOString();
|
|
5821
|
+
writeSiteFile(
|
|
5822
|
+
ctx.projectDir,
|
|
5823
|
+
{
|
|
5824
|
+
siteId: res2.siteId,
|
|
5825
|
+
shortId: res2.shortId,
|
|
5826
|
+
url: res2.url,
|
|
5827
|
+
credential: res2.credential,
|
|
5828
|
+
createdAt: nowIso,
|
|
5829
|
+
apiBaseUrl: ctx.apiBaseUrl
|
|
5830
|
+
},
|
|
5831
|
+
{ allowReplace: true }
|
|
5832
|
+
);
|
|
5833
|
+
recordCreation({
|
|
5834
|
+
siteId: res2.siteId,
|
|
5835
|
+
projectDir: ctx.projectDir,
|
|
5836
|
+
url: res2.url,
|
|
5837
|
+
createdAt: nowIso,
|
|
5838
|
+
apiBaseUrl: ctx.apiBaseUrl
|
|
5839
|
+
});
|
|
5840
|
+
return structuredToolResult({
|
|
5841
|
+
schemaVersion: 1,
|
|
5842
|
+
outcome: "completed",
|
|
5843
|
+
resultCode: "free_site_credential_recovered",
|
|
5844
|
+
summary: summaryMarkdown({
|
|
5845
|
+
title: `Credential reissued for ${res2.url}`,
|
|
5846
|
+
lead: "A new management credential was issued and saved for this project; the site content, settings and installed apps are untouched.",
|
|
5847
|
+
facts: [
|
|
5848
|
+
["Site ID", res2.siteId],
|
|
5849
|
+
["Public URL", res2.url],
|
|
5850
|
+
["Free expiry", timestampForAgent(res2.expiresAt)],
|
|
5851
|
+
["Previous credentials revoked", res2.revokedPreviousCredentials],
|
|
5852
|
+
[
|
|
5853
|
+
"Credential path",
|
|
5854
|
+
".sakupa/site.json (reference) + the user-level credential store"
|
|
5855
|
+
]
|
|
5856
|
+
],
|
|
5857
|
+
notes: [
|
|
5858
|
+
"Any other project directory that still referred to this site no longer has management authority.",
|
|
5859
|
+
"No DNS verification was needed: ownership was proven by this device."
|
|
5860
|
+
],
|
|
5861
|
+
next: ["`status`", "`deploy` to publish changes"]
|
|
5862
|
+
}),
|
|
5863
|
+
data: {
|
|
5864
|
+
siteId: res2.siteId,
|
|
5865
|
+
shortId: res2.shortId,
|
|
5866
|
+
url: res2.url,
|
|
5867
|
+
expiresAt: res2.expiresAt,
|
|
5868
|
+
revokedPreviousCredentials: res2.revokedPreviousCredentials,
|
|
5869
|
+
credentialPath: ".sakupa/site.json",
|
|
5870
|
+
credentialStoredLocally: true,
|
|
5871
|
+
dnsVerificationRepeated: false,
|
|
5872
|
+
projectDir: ctx.projectDir
|
|
5873
|
+
},
|
|
5874
|
+
nextActions: [{ tool: "status", arguments: {}, allowed: true }]
|
|
5875
|
+
});
|
|
5876
|
+
}
|
|
5877
|
+
const sites = await discoverDeviceFreeSites(ctx.client, ctx.apiBaseUrl, device);
|
|
5878
|
+
if (sites.length === 0) {
|
|
5879
|
+
return structuredToolResult({
|
|
5880
|
+
schemaVersion: 1,
|
|
5881
|
+
outcome: "completed",
|
|
5882
|
+
resultCode: "device_free_sites_none",
|
|
5883
|
+
summary: summaryMarkdown({
|
|
5884
|
+
title: "No recoverable free site on this device",
|
|
5885
|
+
lead: "This device created no free site that is still active, so there is nothing to reissue. A free site published from another device cannot be recovered here; a subscribed site with a custom domain can be recovered with recover start; otherwise publish again with deploy.",
|
|
5886
|
+
next: ["`deploy`", '`recover` with action "start" (custom-domain site)']
|
|
5887
|
+
}),
|
|
5888
|
+
data: { deviceSites: [], environment: environmentFor(ctx.apiBaseUrl) },
|
|
5889
|
+
nextActions: [
|
|
5890
|
+
{
|
|
5891
|
+
tool: "deploy",
|
|
5892
|
+
arguments: {},
|
|
5893
|
+
allowed: false,
|
|
5894
|
+
reasonCode: "requires_outputDir_from_agent"
|
|
5895
|
+
}
|
|
5896
|
+
]
|
|
5897
|
+
});
|
|
5898
|
+
}
|
|
5899
|
+
return presentDecision(baseCtx.decisions, call, "recover", {
|
|
5900
|
+
resultCode: "device_free_site_selection_required",
|
|
5901
|
+
summary: summaryMarkdown({
|
|
5902
|
+
title: "Choose the free site whose credential should be reissued",
|
|
5903
|
+
lead: `${sites.length} active free site(s) were created on this device. Nothing was changed. Reissuing revokes every previous credential of the chosen site; its content and apps stay as they are.`,
|
|
5904
|
+
facts: sites.map((site) => [
|
|
5905
|
+
site.url,
|
|
5906
|
+
`expires ${timestampForAgent(site.expiresAt)}`
|
|
5907
|
+
])
|
|
5908
|
+
}),
|
|
5909
|
+
data: { deviceSites: sites, environment: environmentFor(ctx.apiBaseUrl) },
|
|
5910
|
+
prompt: "Which free site should get a new management credential for this project?",
|
|
5911
|
+
options: [
|
|
5912
|
+
...sites.map(
|
|
5913
|
+
(site) => callToolDecisionOption({
|
|
5914
|
+
id: `recover_free_site_${site.shortId}`,
|
|
5915
|
+
label: `Reissue the credential of ${site.url}`,
|
|
5916
|
+
description: `Bind this project to ${site.url} with a fresh credential.`,
|
|
5917
|
+
consequences: [
|
|
5918
|
+
"Every previous credential of this site is revoked; other project directories bound to it stop working.",
|
|
5919
|
+
"Content, settings and installed apps are untouched."
|
|
5920
|
+
],
|
|
5921
|
+
tool: "recover",
|
|
5922
|
+
arguments: { action: "device", siteId: site.siteId, confirmed: true },
|
|
5923
|
+
reasonCode: "user_selected_device_free_site"
|
|
5924
|
+
})
|
|
5925
|
+
),
|
|
5926
|
+
noActionDecisionOption({ description: "Reissue nothing." })
|
|
5927
|
+
],
|
|
5928
|
+
legacyUserAction: { type: "select_site", provider: "sakupa" }
|
|
5929
|
+
});
|
|
5930
|
+
}
|
|
5605
5931
|
if (args.action === "download") {
|
|
5606
5932
|
const { archive, extracted } = await download();
|
|
5607
5933
|
return text(
|
|
@@ -6152,7 +6478,7 @@ function registerBillingTools(server, baseCtx) {
|
|
|
6152
6478
|
}
|
|
6153
6479
|
|
|
6154
6480
|
// src/tools/help.ts
|
|
6155
|
-
import { join as
|
|
6481
|
+
import { join as join11 } from "node:path";
|
|
6156
6482
|
import { z as z4 } from "zod";
|
|
6157
6483
|
var TOOL_TOPICS = [
|
|
6158
6484
|
"init",
|
|
@@ -6345,11 +6671,13 @@ var TOOL_MANUALS = {
|
|
|
6345
6671
|
nextStep: "Query billing after the user confirms an operation in Stripe."
|
|
6346
6672
|
},
|
|
6347
6673
|
recover: {
|
|
6348
|
-
purpose:
|
|
6349
|
-
sideEffects: "
|
|
6350
|
-
preconditions: "DNS control of a domain bound to an active paid site.",
|
|
6674
|
+
purpose: 'Recover a lost site binding: reissue the credential of a free site this device created (action "device"), or recover a paid custom-domain site by DNS and download its content.',
|
|
6675
|
+
sideEffects: "device: after an explicit choice, revokes the old credentials of that site and writes the local binding. DNS path: creates verification state and writes local credential/archive files.",
|
|
6676
|
+
preconditions: "device: the site was created on this device and is still an active free site. DNS path: DNS control of a domain bound to an active paid site.",
|
|
6351
6677
|
parameterNames: [
|
|
6352
6678
|
"action",
|
|
6679
|
+
"siteId",
|
|
6680
|
+
"confirmed",
|
|
6353
6681
|
"hostname",
|
|
6354
6682
|
"verificationId",
|
|
6355
6683
|
"outputDir",
|
|
@@ -6447,7 +6775,7 @@ function registerHelpTools(server, baseCtx) {
|
|
|
6447
6775
|
throw new Error("init postcondition failed: project marker missing");
|
|
6448
6776
|
const site = loadSiteFile(ctx.projectDir);
|
|
6449
6777
|
const recovery = loadRecoveryFile(ctx.projectDir);
|
|
6450
|
-
const sakupaDirectory =
|
|
6778
|
+
const sakupaDirectory = join11(ctx.projectDir, ".sakupa");
|
|
6451
6779
|
return structuredToolResult({
|
|
6452
6780
|
schemaVersion: 1,
|
|
6453
6781
|
outcome: "completed",
|
|
@@ -7273,7 +7601,7 @@ function registerAppsTools(server, baseCtx) {
|
|
|
7273
7601
|
}
|
|
7274
7602
|
|
|
7275
7603
|
// src/tools/delete.ts
|
|
7276
|
-
import { randomUUID as
|
|
7604
|
+
import { randomUUID as randomUUID8 } from "node:crypto";
|
|
7277
7605
|
import { z as z7 } from "zod";
|
|
7278
7606
|
var confirmationSchema = z7.object({
|
|
7279
7607
|
siteId: z7.string(),
|
|
@@ -7335,7 +7663,7 @@ function registerDeleteTools(server, baseCtx) {
|
|
|
7335
7663
|
resultCode: result.alreadyDeleted ? "site_already_deleted" : "site_deleted",
|
|
7336
7664
|
summary: summaryMarkdown({
|
|
7337
7665
|
title: result.alreadyDeleted ? "Site was already deleted" : "Site deleted",
|
|
7338
|
-
lead: `${site.url ?? site.siteId} no longer serves anything (visitors get HTTP
|
|
7666
|
+
lead: `${site.url ?? site.siteId} no longer serves anything (visitors get a not-found page, HTTP 404). The local credential file .sakupa/site.json was removed; this project is no longer bound to any site.`,
|
|
7339
7667
|
facts: [
|
|
7340
7668
|
["Site ID", result.siteId],
|
|
7341
7669
|
["Public URL released", site.url],
|
|
@@ -7360,7 +7688,7 @@ function registerDeleteTools(server, baseCtx) {
|
|
|
7360
7688
|
nextActions: []
|
|
7361
7689
|
});
|
|
7362
7690
|
}
|
|
7363
|
-
const operationId =
|
|
7691
|
+
const operationId = randomUUID8();
|
|
7364
7692
|
const preview = await ctx.client.previewDeleteSite(site.siteId, site.credential, {
|
|
7365
7693
|
operationId
|
|
7366
7694
|
});
|
|
@@ -7444,7 +7772,7 @@ function registerDeleteTools(server, baseCtx) {
|
|
|
7444
7772
|
label: `Delete ${site.url ?? site.siteId}`,
|
|
7445
7773
|
description: "Remove the site, its content, apps and submissions, and release the URL.",
|
|
7446
7774
|
consequences: [
|
|
7447
|
-
"Visitors get HTTP
|
|
7775
|
+
"Visitors get a not-found page (HTTP 404) immediately.",
|
|
7448
7776
|
"The local credential file is removed; this project is unbound."
|
|
7449
7777
|
],
|
|
7450
7778
|
tool: "delete",
|
|
@@ -7622,8 +7950,9 @@ Workflow:
|
|
|
7622
7950
|
(Vite/Vue/React/Svelte/Astro/Next static export/Nuxt generate), run the build LOCALLY first,
|
|
7623
7951
|
then re-run analyze.
|
|
7624
7952
|
2. deploy \u2014 uploads ONLY the built static output. The first deploy creates a free temporary
|
|
7625
|
-
site (public URL ${hostPattern}, valid 30 days, free banner shown) and
|
|
7626
|
-
|
|
7953
|
+
site (public URL ${hostPattern}, valid 30 days, free banner shown) and binds this project
|
|
7954
|
+
in .sakupa/site.json; the credential itself is kept in the user-level Sakupa credential store
|
|
7955
|
+
(home directory, owner-only), never inside the project. Deploying again updates the site and refreshes
|
|
7627
7956
|
its validity; refresh extends validity without uploading; status shows the
|
|
7628
7957
|
current deployment and serving state at any time. Every update checks the credential's
|
|
7629
7958
|
server-side issue time. When it is older than 7 days, deploy succeeds and only SUGGESTS the
|
|
@@ -7645,6 +7974,8 @@ Workflow:
|
|
|
7645
7974
|
30-day site and removes paid data after Stripe sends the signed final-cancellation webhook.
|
|
7646
7975
|
Recovery writes the new local credential before downloading content. If a session stops after
|
|
7647
7976
|
.sakupa/site.json exists, NEVER repeat DNS recovery: resume with recover action "download".
|
|
7977
|
+
A FREE site created on this device can get its credential reissued with recover action
|
|
7978
|
+
"device" (no DNS): present every listed site, let the user choose, then confirm.
|
|
7648
7979
|
5. If any Sakupa operation is difficult or fails, call help FIRST. support handles billing,
|
|
7649
7980
|
payment, refund and other customer-service requests. report is the LAST resort only when
|
|
7650
7981
|
help explicitly recommends a product bug report, and submission still requires user review.
|
|
@@ -7738,7 +8069,7 @@ Safety boundaries:
|
|
|
7738
8069
|
- Never upload source projects, secrets, .env files, private keys, archives, videos or audio.
|
|
7739
8070
|
- Payment card data is entered only on Stripe-hosted pages \u2014 never through the AI tool.
|
|
7740
8071
|
- A subscription never grants domain ownership; only DNS verification does.
|
|
7741
|
-
- Never repeat, echo, or memorize the credential value from
|
|
8072
|
+
- Never repeat, echo, or memorize the credential value from the credential store or any file \u2014 quoting it
|
|
7742
8073
|
into the conversation copies the site's only key outside the protected local file. Read it
|
|
7743
8074
|
only through the tools.
|
|
7744
8075
|
- rotate always previews first. confirmed:true revokes EVERY prior credential, including old
|
|
@@ -7751,8 +8082,10 @@ Safety boundaries:
|
|
|
7751
8082
|
other than Japanese, look up the approximate exchange rate and show an estimated local
|
|
7752
8083
|
price next to the JPY amount, clearly marked as an estimate \u2014 Stripe always settles the
|
|
7753
8084
|
real charge in JPY. Never show a bare Yen sign.
|
|
7754
|
-
- The management credential lives only in
|
|
7755
|
-
|
|
8085
|
+
- The management credential lives only in the user-level Sakupa credential store (referenced by
|
|
8086
|
+
.sakupa/site.json); never share or upload it. A lost credential can be reissued only for a free
|
|
8087
|
+
site from the device that created it (recover action "device") or for a subscribed site through
|
|
8088
|
+
its bound custom domain; otherwise it is unrecoverable by design. portal then opens
|
|
7756
8089
|
Stripe's public no-code portal login, where the customer verifies the checkout email with a
|
|
7757
8090
|
Stripe one-time passcode; it never restores site authority.`;
|
|
7758
8091
|
var DECISION_ROUND_TIMEOUT_MS = 12e4;
|