@sakupa/mcp 0.7.21 → 0.7.23

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/dist/bin.js +958 -58
  2. package/dist/index.js +953 -53
  3. package/package.json +3 -2
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.21";
127
+ var SAKUPA_MCP_VERSION = "0.7.23";
128
128
 
129
129
  // ../core/dist/domain/errors.js
130
130
  var HTTP_STATUS = {
@@ -155,8 +155,8 @@ var SakupaError = class extends Error {
155
155
  this.details = details;
156
156
  }
157
157
  };
158
- function isSakupaError(err) {
159
- return err instanceof SakupaError;
158
+ function isSakupaError(err2) {
159
+ return err2 instanceof SakupaError;
160
160
  }
161
161
 
162
162
  // ../core/dist/domain/tiers.js
@@ -431,7 +431,12 @@ function safeDecode(bytes) {
431
431
  }
432
432
 
433
433
  // ../core/dist/domain/credentials.js
434
+ import { randomBytes, scrypt, timingSafeEqual } from "node:crypto";
435
+ var CREDENTIAL_PREFIX = "sk_";
434
436
  var CREDENTIAL_PATTERN = /^sk_[A-Za-z0-9_-]{43}$/;
437
+ function generateCredential(random = randomBytes) {
438
+ return CREDENTIAL_PREFIX + random(32).toString("base64url");
439
+ }
435
440
 
436
441
  // ../core/dist/domain/hashing.js
437
442
  async function sha256Hex(bytes) {
@@ -556,6 +561,29 @@ var FetchTransport = class {
556
561
  );
557
562
  }
558
563
  }
564
+ async download(url) {
565
+ let target;
566
+ try {
567
+ target = new URL(url);
568
+ } catch {
569
+ throw new SakupaError("invalid_request", "Archive download URL is invalid");
570
+ }
571
+ if (target.origin !== new URL(this.baseUrl).origin) {
572
+ throw new SakupaError("forbidden", "Archive download URL is outside the Sakupa API origin");
573
+ }
574
+ const res = await fetch(target, {
575
+ method: "GET",
576
+ headers: this.testAccessHeadersFor(target.toString())
577
+ });
578
+ if (!res.ok) {
579
+ const detail = await res.text().catch(() => "");
580
+ throw new SakupaError(
581
+ res.status === 429 || res.status >= 500 ? "internal" : "validation_failed",
582
+ `Archive download failed with HTTP ${res.status}${detail ? `: ${detail.slice(0, 200)}` : ""}`
583
+ );
584
+ }
585
+ return new Uint8Array(await res.arrayBuffer());
586
+ }
559
587
  };
560
588
 
561
589
  // src/api-client.ts
@@ -650,6 +678,16 @@ var HttpApiClient = class {
650
678
  credential
651
679
  });
652
680
  }
681
+ async getSiteArchive(siteId, credential) {
682
+ return this.call(
683
+ "GET",
684
+ `/v1/sites/${encodeURIComponent(siteId)}/archive`,
685
+ { credential }
686
+ );
687
+ }
688
+ async downloadArchive(url) {
689
+ return this.transport.download(url);
690
+ }
653
691
  async deleteSite(siteId, credential, req) {
654
692
  return this.call("POST", `/v1/sites/${encodeURIComponent(siteId)}/delete`, {
655
693
  credential,
@@ -747,19 +785,23 @@ import { chmodSync, existsSync, mkdirSync, readFileSync, rmSync, writeFileSync }
747
785
  import { dirname, join } from "node:path";
748
786
  var SITE_DIR = ".sakupa";
749
787
  var SITE_FILE = "site.json";
788
+ var RECOVERY_FILE = "recovery.json";
750
789
  function siteFilePath(projectDir) {
751
790
  return join(projectDir, SITE_DIR, SITE_FILE);
752
791
  }
792
+ function recoveryFilePath(projectDir) {
793
+ return join(projectDir, SITE_DIR, RECOVERY_FILE);
794
+ }
753
795
  function loadSiteFile(projectDir) {
754
796
  const path = siteFilePath(projectDir);
755
797
  if (!existsSync(path)) return { kind: "absent" };
756
798
  let raw;
757
799
  try {
758
800
  raw = readFileSync(path, "utf8");
759
- } catch (err) {
801
+ } catch (err2) {
760
802
  return {
761
803
  kind: "corrupted",
762
- problem: `the file exists but could not be read (${err instanceof Error ? err.message : String(err)})`
804
+ problem: `the file exists but could not be read (${err2 instanceof Error ? err2.message : String(err2)})`
763
805
  };
764
806
  }
765
807
  let parsed;
@@ -796,6 +838,40 @@ function loadSiteFile(projectDir) {
796
838
  }
797
839
  };
798
840
  }
841
+ function loadRecoveryFile(projectDir) {
842
+ const path = recoveryFilePath(projectDir);
843
+ if (!existsSync(path)) return null;
844
+ try {
845
+ const parsed = JSON.parse(readFileSync(path, "utf8"));
846
+ if (typeof parsed.verificationId !== "string" || parsed.verificationId.length === 0 || typeof parsed.credential !== "string" || !CREDENTIAL_PATTERN.test(parsed.credential)) {
847
+ throw new Error("required recovery fields are missing or invalid");
848
+ }
849
+ return {
850
+ verificationId: parsed.verificationId,
851
+ credential: parsed.credential,
852
+ createdAt: typeof parsed.createdAt === "string" ? parsed.createdAt : ""
853
+ };
854
+ } catch (error) {
855
+ throw new Error(
856
+ `Recovery state ${path} is damaged (${error instanceof Error ? error.message : String(error)}). Do not start another DNS recovery until this file is repaired or deliberately removed.`
857
+ );
858
+ }
859
+ }
860
+ function writeRecoveryFile(projectDir, file) {
861
+ const dir = join(projectDir, SITE_DIR);
862
+ mkdirSync(dir, { recursive: true });
863
+ const path = join(dir, RECOVERY_FILE);
864
+ writeFileSync(path, `${JSON.stringify(file, null, 2)}
865
+ `, "utf8");
866
+ try {
867
+ chmodSync(path, 384);
868
+ } catch {
869
+ }
870
+ }
871
+ function deleteRecoveryFile(projectDir) {
872
+ const path = recoveryFilePath(projectDir);
873
+ if (existsSync(path)) rmSync(path, { force: true });
874
+ }
799
875
  function siteFileRecoveryGuidance(projectDir) {
800
876
  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. Publishing this project as a brand-NEW site requires the user to manually delete the .sakupa directory first \u2014 the tool will never overwrite it.`;
801
877
  }
@@ -885,8 +961,8 @@ async function isFile(path) {
885
961
  }
886
962
  async function readTextIfExists(path, maxBytes = CONTENT_READ_MAX_BYTES) {
887
963
  try {
888
- const stat = await fs.stat(path);
889
- if (!stat.isFile() || stat.size > maxBytes) return null;
964
+ const stat2 = await fs.stat(path);
965
+ if (!stat2.isFile() || stat2.size > maxBytes) return null;
890
966
  return await fs.readFile(path, "utf8");
891
967
  } catch {
892
968
  return null;
@@ -926,8 +1002,8 @@ async function walkFiles(dir, opts) {
926
1002
  await recurse(join2(current, entry.name), rel);
927
1003
  } else if (entry.isFile()) {
928
1004
  try {
929
- const stat = await fs.stat(join2(current, entry.name));
930
- out.push({ path: rel, size: stat.size });
1005
+ const stat2 = await fs.stat(join2(current, entry.name));
1006
+ out.push({ path: rel, size: stat2.size });
931
1007
  } catch {
932
1008
  }
933
1009
  }
@@ -1342,8 +1418,8 @@ function withProjectDir(ctx, projectDirArg) {
1342
1418
  `projectDir "${dir}" is a filesystem root or the home directory. Pass the specific project folder that holds the site's files, not a top-level directory.`
1343
1419
  );
1344
1420
  }
1345
- const stat = statSync(dir, { throwIfNoEntry: false });
1346
- if (!stat?.isDirectory()) {
1421
+ const stat2 = statSync(dir, { throwIfNoEntry: false });
1422
+ if (!stat2?.isDirectory()) {
1347
1423
  throw new LocalGuidanceError(
1348
1424
  "invalid_request",
1349
1425
  `projectDir "${dir}" does not exist or is not a directory. Pass the absolute path of the directory the user is currently working in.`
@@ -1408,17 +1484,646 @@ function toolError(e) {
1408
1484
  // src/tools/definitions.ts
1409
1485
  import { randomUUID } from "node:crypto";
1410
1486
  import { existsSync as existsSync3, promises as fs2 } from "node:fs";
1411
- import { join as join4, resolve as resolve3 } from "node:path";
1487
+ import { join as join5, resolve as resolve4 } from "node:path";
1412
1488
  import { z as z3 } from "zod";
1413
1489
 
1490
+ // src/recovery-archive.ts
1491
+ import { mkdtemp, mkdir, readFile, readdir, rename, rm, stat, writeFile } from "node:fs/promises";
1492
+ import { dirname as dirname2, isAbsolute as isAbsolute2, join as join3, relative, resolve as resolve3, sep as sep2 } from "node:path";
1493
+
1494
+ // ../../node_modules/fflate/esm/index.mjs
1495
+ import { createRequire } from "module";
1496
+ var require2 = createRequire("/");
1497
+ var _a;
1498
+ var Worker;
1499
+ var isMarkedAsUntransferable;
1500
+ try {
1501
+ _a = require2("worker_threads"), Worker = _a.Worker, isMarkedAsUntransferable = _a.isMarkedAsUntransferable;
1502
+ } catch (e) {
1503
+ }
1504
+ var u8 = Uint8Array;
1505
+ var u16 = Uint16Array;
1506
+ var i32 = Int32Array;
1507
+ var fleb = new u8([
1508
+ 0,
1509
+ 0,
1510
+ 0,
1511
+ 0,
1512
+ 0,
1513
+ 0,
1514
+ 0,
1515
+ 0,
1516
+ 1,
1517
+ 1,
1518
+ 1,
1519
+ 1,
1520
+ 2,
1521
+ 2,
1522
+ 2,
1523
+ 2,
1524
+ 3,
1525
+ 3,
1526
+ 3,
1527
+ 3,
1528
+ 4,
1529
+ 4,
1530
+ 4,
1531
+ 4,
1532
+ 5,
1533
+ 5,
1534
+ 5,
1535
+ 5,
1536
+ 0,
1537
+ /* unused */
1538
+ 0,
1539
+ 0,
1540
+ /* impossible */
1541
+ 0
1542
+ ]);
1543
+ var fdeb = new u8([
1544
+ 0,
1545
+ 0,
1546
+ 0,
1547
+ 0,
1548
+ 1,
1549
+ 1,
1550
+ 2,
1551
+ 2,
1552
+ 3,
1553
+ 3,
1554
+ 4,
1555
+ 4,
1556
+ 5,
1557
+ 5,
1558
+ 6,
1559
+ 6,
1560
+ 7,
1561
+ 7,
1562
+ 8,
1563
+ 8,
1564
+ 9,
1565
+ 9,
1566
+ 10,
1567
+ 10,
1568
+ 11,
1569
+ 11,
1570
+ 12,
1571
+ 12,
1572
+ 13,
1573
+ 13,
1574
+ /* unused */
1575
+ 0,
1576
+ 0
1577
+ ]);
1578
+ var clim = new u8([16, 17, 18, 0, 8, 7, 9, 6, 10, 5, 11, 4, 12, 3, 13, 2, 14, 1, 15]);
1579
+ var freb = function(eb, start) {
1580
+ var b = new u16(31);
1581
+ for (var i = 0; i < 31; ++i) {
1582
+ b[i] = start += 1 << eb[i - 1];
1583
+ }
1584
+ var r = new i32(b[30]);
1585
+ for (var i = 1; i < 30; ++i) {
1586
+ for (var j = b[i]; j < b[i + 1]; ++j) {
1587
+ r[j] = j - b[i] << 5 | i;
1588
+ }
1589
+ }
1590
+ return { b, r };
1591
+ };
1592
+ var _a = freb(fleb, 2);
1593
+ var fl = _a.b;
1594
+ var revfl = _a.r;
1595
+ fl[28] = 258, revfl[258] = 28;
1596
+ var _b = freb(fdeb, 0);
1597
+ var fd = _b.b;
1598
+ var revfd = _b.r;
1599
+ var rev = new u16(32768);
1600
+ for (i = 0; i < 32768; ++i) {
1601
+ x = (i & 43690) >> 1 | (i & 21845) << 1;
1602
+ x = (x & 52428) >> 2 | (x & 13107) << 2;
1603
+ x = (x & 61680) >> 4 | (x & 3855) << 4;
1604
+ rev[i] = ((x & 65280) >> 8 | (x & 255) << 8) >> 1;
1605
+ }
1606
+ var x;
1607
+ var i;
1608
+ var hMap = function(cd, mb, r) {
1609
+ var s = cd.length;
1610
+ var i = 0;
1611
+ var l = new u16(mb);
1612
+ for (; i < s; ++i) {
1613
+ if (cd[i])
1614
+ ++l[cd[i] - 1];
1615
+ }
1616
+ var le = new u16(mb);
1617
+ for (i = 1; i < mb; ++i) {
1618
+ le[i] = le[i - 1] + l[i - 1] << 1;
1619
+ }
1620
+ var co;
1621
+ if (r) {
1622
+ co = new u16(1 << mb);
1623
+ var rvb = 15 - mb;
1624
+ for (i = 0; i < s; ++i) {
1625
+ if (cd[i]) {
1626
+ var sv = i << 4 | cd[i];
1627
+ var r_1 = mb - cd[i];
1628
+ var v = le[cd[i] - 1]++ << r_1;
1629
+ for (var m = v | (1 << r_1) - 1; v <= m; ++v) {
1630
+ co[rev[v] >> rvb] = sv;
1631
+ }
1632
+ }
1633
+ }
1634
+ } else {
1635
+ co = new u16(s);
1636
+ for (i = 0; i < s; ++i) {
1637
+ if (cd[i]) {
1638
+ co[i] = rev[le[cd[i] - 1]++] >> 15 - cd[i];
1639
+ }
1640
+ }
1641
+ }
1642
+ return co;
1643
+ };
1644
+ var flt = new u8(288);
1645
+ for (i = 0; i < 144; ++i)
1646
+ flt[i] = 8;
1647
+ var i;
1648
+ for (i = 144; i < 256; ++i)
1649
+ flt[i] = 9;
1650
+ var i;
1651
+ for (i = 256; i < 280; ++i)
1652
+ flt[i] = 7;
1653
+ var i;
1654
+ for (i = 280; i < 288; ++i)
1655
+ flt[i] = 8;
1656
+ var i;
1657
+ var fdt = new u8(32);
1658
+ for (i = 0; i < 32; ++i)
1659
+ fdt[i] = 5;
1660
+ var i;
1661
+ var flrm = /* @__PURE__ */ hMap(flt, 9, 1);
1662
+ var fdrm = /* @__PURE__ */ hMap(fdt, 5, 1);
1663
+ var max = function(a) {
1664
+ var m = a[0];
1665
+ for (var i = 1; i < a.length; ++i) {
1666
+ if (a[i] > m)
1667
+ m = a[i];
1668
+ }
1669
+ return m;
1670
+ };
1671
+ var bits = function(d, p, m) {
1672
+ var o = p / 8 | 0;
1673
+ return (d[o] | d[o + 1] << 8) >> (p & 7) & m;
1674
+ };
1675
+ var bits16 = function(d, p) {
1676
+ var o = p / 8 | 0;
1677
+ return (d[o] | d[o + 1] << 8 | d[o + 2] << 16) >> (p & 7);
1678
+ };
1679
+ var shft = function(p) {
1680
+ return (p + 7) / 8 | 0;
1681
+ };
1682
+ var slc = function(v, s, e) {
1683
+ if (s == null || s < 0)
1684
+ s = 0;
1685
+ if (e == null || e > v.length)
1686
+ e = v.length;
1687
+ return new u8(v.subarray(s, e));
1688
+ };
1689
+ var ec = [
1690
+ "unexpected EOF",
1691
+ "invalid block type",
1692
+ "invalid length/literal",
1693
+ "invalid distance",
1694
+ "stream finished",
1695
+ "no stream handler",
1696
+ ,
1697
+ // determined by compression function
1698
+ "no callback",
1699
+ "invalid UTF-8 data",
1700
+ "extra field too long",
1701
+ "date not in range 1980-2099",
1702
+ "filename too long",
1703
+ "stream finishing",
1704
+ "invalid zip data"
1705
+ // determined by unknown compression method
1706
+ ];
1707
+ var err = function(ind, msg, nt) {
1708
+ var e = new Error(msg || ec[ind]);
1709
+ e.code = ind;
1710
+ if (Error.captureStackTrace)
1711
+ Error.captureStackTrace(e, err);
1712
+ if (!nt)
1713
+ throw e;
1714
+ return e;
1715
+ };
1716
+ var inflt = function(dat, st, buf, dict) {
1717
+ var sl = dat.length, dl = dict ? dict.length : 0;
1718
+ if (!sl || st.f && !st.l)
1719
+ return buf || new u8(0);
1720
+ var noBuf = !buf;
1721
+ var resize = noBuf || st.i != 2;
1722
+ var noSt = st.i;
1723
+ if (noBuf)
1724
+ buf = new u8(sl * 3);
1725
+ var cbuf = function(l2) {
1726
+ var bl = buf.length;
1727
+ if (l2 > bl) {
1728
+ var nbuf = new u8(Math.max(bl * 2, l2));
1729
+ nbuf.set(buf);
1730
+ buf = nbuf;
1731
+ }
1732
+ };
1733
+ var final = st.f || 0, pos = st.p || 0, bt = st.b || 0, lm = st.l, dm = st.d, lbt = st.m, dbt = st.n;
1734
+ var tbts = sl * 8;
1735
+ do {
1736
+ if (!lm) {
1737
+ final = bits(dat, pos, 1);
1738
+ var type = bits(dat, pos + 1, 3);
1739
+ pos += 3;
1740
+ if (!type) {
1741
+ var s = shft(pos) + 4, l = dat[s - 4] | dat[s - 3] << 8, t = s + l;
1742
+ if (t > sl) {
1743
+ if (noSt)
1744
+ err(0);
1745
+ break;
1746
+ }
1747
+ if (resize)
1748
+ cbuf(bt + l);
1749
+ buf.set(dat.subarray(s, t), bt);
1750
+ st.b = bt += l, st.p = pos = t * 8, st.f = final;
1751
+ continue;
1752
+ } else if (type == 1)
1753
+ lm = flrm, dm = fdrm, lbt = 9, dbt = 5;
1754
+ else if (type == 2) {
1755
+ var hLit = bits(dat, pos, 31) + 257, hcLen = bits(dat, pos + 10, 15) + 4;
1756
+ var tl = hLit + bits(dat, pos + 5, 31) + 1;
1757
+ pos += 14;
1758
+ var ldt = new u8(tl);
1759
+ var clt = new u8(19);
1760
+ for (var i = 0; i < hcLen; ++i) {
1761
+ clt[clim[i]] = bits(dat, pos + i * 3, 7);
1762
+ }
1763
+ pos += hcLen * 3;
1764
+ var clb = max(clt), clbmsk = (1 << clb) - 1;
1765
+ var clm = hMap(clt, clb, 1);
1766
+ for (var i = 0; i < tl; ) {
1767
+ var r = clm[bits(dat, pos, clbmsk)];
1768
+ pos += r & 15;
1769
+ var s = r >> 4;
1770
+ if (s < 16) {
1771
+ ldt[i++] = s;
1772
+ } else {
1773
+ var c = 0, n = 0;
1774
+ if (s == 16)
1775
+ n = 3 + bits(dat, pos, 3), pos += 2, c = ldt[i - 1];
1776
+ else if (s == 17)
1777
+ n = 3 + bits(dat, pos, 7), pos += 3;
1778
+ else if (s == 18)
1779
+ n = 11 + bits(dat, pos, 127), pos += 7;
1780
+ while (n--)
1781
+ ldt[i++] = c;
1782
+ }
1783
+ }
1784
+ var lt = ldt.subarray(0, hLit), dt = ldt.subarray(hLit);
1785
+ lbt = max(lt);
1786
+ dbt = max(dt);
1787
+ lm = hMap(lt, lbt, 1);
1788
+ dm = hMap(dt, dbt, 1);
1789
+ } else
1790
+ err(1);
1791
+ if (pos > tbts) {
1792
+ if (noSt)
1793
+ err(0);
1794
+ break;
1795
+ }
1796
+ }
1797
+ if (resize)
1798
+ cbuf(bt + 131072);
1799
+ var lms = (1 << lbt) - 1, dms = (1 << dbt) - 1;
1800
+ var lpos = pos;
1801
+ for (; ; lpos = pos) {
1802
+ var c = lm[bits16(dat, pos) & lms], sym = c >> 4;
1803
+ pos += c & 15;
1804
+ if (pos > tbts) {
1805
+ if (noSt)
1806
+ err(0);
1807
+ break;
1808
+ }
1809
+ if (!c)
1810
+ err(2);
1811
+ if (sym < 256)
1812
+ buf[bt++] = sym;
1813
+ else if (sym == 256) {
1814
+ lpos = pos, lm = null;
1815
+ break;
1816
+ } else {
1817
+ var add = sym - 254;
1818
+ if (sym > 264) {
1819
+ var i = sym - 257, b = fleb[i];
1820
+ add = bits(dat, pos, (1 << b) - 1) + fl[i];
1821
+ pos += b;
1822
+ }
1823
+ var d = dm[bits16(dat, pos) & dms], dsym = d >> 4;
1824
+ if (!d)
1825
+ err(3);
1826
+ pos += d & 15;
1827
+ var dt = fd[dsym];
1828
+ if (dsym > 3) {
1829
+ var b = fdeb[dsym];
1830
+ dt += bits16(dat, pos) & (1 << b) - 1, pos += b;
1831
+ }
1832
+ if (pos > tbts) {
1833
+ if (noSt)
1834
+ err(0);
1835
+ break;
1836
+ }
1837
+ if (resize)
1838
+ cbuf(bt + 131072);
1839
+ var end = bt + add;
1840
+ if (bt < dt) {
1841
+ var shift = dl - dt, dend = Math.min(dt, end);
1842
+ if (shift + bt < 0)
1843
+ err(3);
1844
+ for (; bt < dend; ++bt)
1845
+ buf[bt] = dict[shift + bt];
1846
+ }
1847
+ for (; bt < end; ++bt)
1848
+ buf[bt] = buf[bt - dt];
1849
+ }
1850
+ }
1851
+ st.l = lm, st.p = lpos, st.b = bt, st.f = final;
1852
+ if (lm)
1853
+ final = 1, st.m = lbt, st.d = dm, st.n = dbt;
1854
+ } while (!final);
1855
+ return bt != buf.length && noBuf ? slc(buf, 0, bt) : buf.subarray(0, bt);
1856
+ };
1857
+ var et = /* @__PURE__ */ new u8(0);
1858
+ var b2 = function(d, b) {
1859
+ return d[b] | d[b + 1] << 8;
1860
+ };
1861
+ var b4 = function(d, b) {
1862
+ return (d[b] | d[b + 1] << 8 | d[b + 2] << 16 | d[b + 3] << 24) >>> 0;
1863
+ };
1864
+ var b8 = function(d, b) {
1865
+ return b4(d, b) + b4(d, b + 4) * 4294967296;
1866
+ };
1867
+ function inflateSync(data, opts) {
1868
+ return inflt(data, { i: 2 }, opts && opts.out, opts && opts.dictionary);
1869
+ }
1870
+ var td = typeof TextDecoder != "undefined" && /* @__PURE__ */ new TextDecoder();
1871
+ var tds = 0;
1872
+ try {
1873
+ td.decode(et, { stream: true });
1874
+ tds = 1;
1875
+ } catch (e) {
1876
+ }
1877
+ var dutf8 = function(d) {
1878
+ for (var r = "", i = 0; ; ) {
1879
+ var c = d[i++];
1880
+ var eb = (c > 127) + (c > 223) + (c > 239);
1881
+ if (i + eb > d.length)
1882
+ return { s: r, r: slc(d, i - 1) };
1883
+ if (!eb)
1884
+ r += String.fromCharCode(c);
1885
+ else if (eb == 3) {
1886
+ c = ((c & 15) << 18 | (d[i++] & 63) << 12 | (d[i++] & 63) << 6 | d[i++] & 63) - 65536, r += String.fromCharCode(55296 | c >> 10, 56320 | c & 1023);
1887
+ } else if (eb & 1)
1888
+ r += String.fromCharCode((c & 31) << 6 | d[i++] & 63);
1889
+ else
1890
+ r += String.fromCharCode((c & 15) << 12 | (d[i++] & 63) << 6 | d[i++] & 63);
1891
+ }
1892
+ };
1893
+ function strFromU8(dat, latin1) {
1894
+ if (latin1) {
1895
+ var r = "";
1896
+ for (var i = 0; i < dat.length; i += 16384)
1897
+ r += String.fromCharCode.apply(null, dat.subarray(i, i + 16384));
1898
+ return r;
1899
+ } else if (td) {
1900
+ return td.decode(dat);
1901
+ } else {
1902
+ var _a2 = dutf8(dat), s = _a2.s, r = _a2.r;
1903
+ if (r.length)
1904
+ err(8);
1905
+ return s;
1906
+ }
1907
+ }
1908
+ var slzh = function(d, b) {
1909
+ return b + 30 + b2(d, b + 26) + b2(d, b + 28);
1910
+ };
1911
+ var zh = function(d, b, z6) {
1912
+ 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;
1913
+ 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];
1914
+ return [b2(d, b + 10), sc, su, fn, es + efl + b2(d, b + 32), off];
1915
+ };
1916
+ var z64hs = function(d, b, l, z6, sc, su, off) {
1917
+ var nsc = sc == 4294967295, nsu = su == 4294967295, noff = off == 4294967295, e = b + l;
1918
+ var nf = nsc + nsu + noff;
1919
+ if (z6 && nf) {
1920
+ for (; b + 4 < e; b += 4 + b2(d, b + 2)) {
1921
+ if (b2(d, b) == 1) {
1922
+ return [
1923
+ nsc ? b8(d, b + 4 + 8 * nsu) : sc,
1924
+ nsu ? b8(d, b + 4) : su,
1925
+ noff ? b8(d, b + 4 + 8 * (nsu + nsc)) : off,
1926
+ 1
1927
+ ];
1928
+ }
1929
+ }
1930
+ if (z6 < 2)
1931
+ err(13);
1932
+ }
1933
+ return [sc, su, off, 0];
1934
+ };
1935
+ function unzipSync(data, opts) {
1936
+ var files = {};
1937
+ var e = data.length - 22;
1938
+ for (; b4(data, e) != 101010256; --e) {
1939
+ if (!e || data.length - e > 65558)
1940
+ err(13);
1941
+ }
1942
+ ;
1943
+ var c = b2(data, e + 8);
1944
+ if (!c)
1945
+ return {};
1946
+ var o = b4(data, e + 16);
1947
+ var z6 = b4(data, e - 20) == 117853008;
1948
+ if (z6) {
1949
+ var ze = b4(data, e - 12);
1950
+ z6 = b4(data, ze) == 101075792;
1951
+ if (z6) {
1952
+ c = b4(data, ze + 32);
1953
+ o = b4(data, ze + 48);
1954
+ }
1955
+ }
1956
+ var fltr = opts && opts.filter;
1957
+ for (var i = 0; i < c; ++i) {
1958
+ 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);
1959
+ o = no;
1960
+ if (!fltr || fltr({
1961
+ name: fn,
1962
+ size: sc,
1963
+ originalSize: su,
1964
+ compression: c_2
1965
+ })) {
1966
+ if (!c_2)
1967
+ files[fn] = slc(data, b, b + sc);
1968
+ else if (c_2 == 8)
1969
+ files[fn] = inflateSync(data.subarray(b, b + sc), { out: new u8(su) });
1970
+ else
1971
+ err(14, "unknown compression type " + c_2);
1972
+ }
1973
+ }
1974
+ return files;
1975
+ }
1976
+
1977
+ // src/recovery-archive.ts
1978
+ function safeOutputPath(projectDir, outputDir) {
1979
+ if (outputDir.length === 0 || isAbsolute2(outputDir)) {
1980
+ throw new SakupaError("invalid_request", "Recovery outputDir must be a relative directory");
1981
+ }
1982
+ const root = resolve3(projectDir);
1983
+ const target = resolve3(root, outputDir);
1984
+ const rel = relative(root, target);
1985
+ if (rel === "" || rel === ".." || rel.startsWith(`..${sep2}`) || isAbsolute2(rel)) {
1986
+ throw new SakupaError("invalid_request", "Recovery outputDir must stay inside projectDir");
1987
+ }
1988
+ if (rel === ".sakupa" || rel.startsWith(`.sakupa${sep2}`)) {
1989
+ throw new SakupaError("invalid_request", "Recovery content cannot be written inside .sakupa");
1990
+ }
1991
+ return target;
1992
+ }
1993
+ function safeEntryName(name) {
1994
+ if (name.length === 0 || name.startsWith("/") || name.includes("\\") || /^[A-Za-z]:/.test(name)) {
1995
+ throw new SakupaError("validation_failed", `Unsafe recovery archive path: ${name}`);
1996
+ }
1997
+ const segments = name.split("/");
1998
+ if (segments.some((segment) => segment === "" || segment === "." || segment === "..")) {
1999
+ throw new SakupaError("validation_failed", `Unsafe recovery archive path: ${name}`);
2000
+ }
2001
+ return segments.join("/");
2002
+ }
2003
+ async function pathExists(path) {
2004
+ try {
2005
+ await stat(path);
2006
+ return true;
2007
+ } catch (error) {
2008
+ if (error.code === "ENOENT") return false;
2009
+ throw error;
2010
+ }
2011
+ }
2012
+ async function listExistingFiles(root, current = root) {
2013
+ const entries = await readdir(current, { withFileTypes: true });
2014
+ const files = [];
2015
+ for (const entry of entries) {
2016
+ if (entry.isSymbolicLink()) {
2017
+ throw new SakupaError("state_conflict", `Recovery output contains a symlink: ${entry.name}`);
2018
+ }
2019
+ const absolute = join3(current, entry.name);
2020
+ if (entry.isDirectory()) files.push(...await listExistingFiles(root, absolute));
2021
+ else if (entry.isFile()) files.push(relative(root, absolute).split(sep2).join("/"));
2022
+ else
2023
+ throw new SakupaError(
2024
+ "state_conflict",
2025
+ `Recovery output contains a special file: ${entry.name}`
2026
+ );
2027
+ }
2028
+ return files.sort();
2029
+ }
2030
+ async function existingOutputMatches(outputDir, files) {
2031
+ const existing = await listExistingFiles(outputDir);
2032
+ const expected = Object.keys(files).sort();
2033
+ if (existing.length !== expected.length || existing.some((name, i) => name !== expected[i])) {
2034
+ return false;
2035
+ }
2036
+ for (const name of expected) {
2037
+ const actual = await readFile(join3(outputDir, ...name.split("/")));
2038
+ const wanted = files[name];
2039
+ if (!wanted || actual.byteLength !== wanted.byteLength || !actual.equals(wanted)) return false;
2040
+ }
2041
+ return true;
2042
+ }
2043
+ async function extractRecoveryArchive(input) {
2044
+ if (!Number.isSafeInteger(input.expectedBytes) || input.expectedBytes < 0 || input.expectedBytes > PAID_SITE_MAX_TOTAL_BYTES || !Number.isSafeInteger(input.expectedFiles) || input.expectedFiles < 0 || input.expectedFiles > MAX_FILE_COUNT) {
2045
+ throw new SakupaError("validation_failed", "Recovery archive metadata exceeds product limits");
2046
+ }
2047
+ const outputDir = safeOutputPath(input.projectDir, input.outputDir ?? "html");
2048
+ const maxArchiveBytes = input.expectedBytes + input.expectedFiles * 4096 + 65536;
2049
+ if (input.archive.byteLength > maxArchiveBytes) {
2050
+ throw new SakupaError("validation_failed", "Recovery archive is larger than its site metadata");
2051
+ }
2052
+ const seen = /* @__PURE__ */ new Set();
2053
+ let declaredBytes = 0;
2054
+ let declaredFiles = 0;
2055
+ const files = unzipSync(input.archive, {
2056
+ filter(file) {
2057
+ const name = safeEntryName(file.name);
2058
+ if (seen.has(name)) {
2059
+ throw new SakupaError("validation_failed", `Duplicate recovery archive path: ${name}`);
2060
+ }
2061
+ seen.add(name);
2062
+ declaredFiles += 1;
2063
+ declaredBytes += file.originalSize;
2064
+ if (declaredFiles > input.expectedFiles || declaredFiles > MAX_FILE_COUNT || declaredBytes > input.expectedBytes || declaredBytes > PAID_SITE_MAX_TOTAL_BYTES) {
2065
+ throw new SakupaError("validation_failed", "Recovery archive exceeds declared site limits");
2066
+ }
2067
+ return true;
2068
+ }
2069
+ });
2070
+ if (declaredFiles !== input.expectedFiles || declaredBytes !== input.expectedBytes) {
2071
+ throw new SakupaError("validation_failed", "Recovery archive does not match site metadata");
2072
+ }
2073
+ if (await pathExists(outputDir)) {
2074
+ const info = await stat(outputDir);
2075
+ if (info.isDirectory() && await existingOutputMatches(outputDir, files)) {
2076
+ return {
2077
+ outputDir,
2078
+ totalBytes: declaredBytes,
2079
+ fileCount: declaredFiles,
2080
+ alreadyPresent: true
2081
+ };
2082
+ }
2083
+ throw new SakupaError(
2084
+ "state_conflict",
2085
+ `Recovery output already exists with different content: ${outputDir}. Move it aside or choose another outputDir.`
2086
+ );
2087
+ }
2088
+ const tempDir = await mkdtemp(join3(resolve3(input.projectDir), ".sakupa-restore-"));
2089
+ try {
2090
+ let writtenBytes = 0;
2091
+ const entries = Object.entries(files).sort(([a], [b]) => a.localeCompare(b));
2092
+ for (const [rawName, data] of entries) {
2093
+ const name = safeEntryName(rawName);
2094
+ const destination = join3(tempDir, ...name.split("/"));
2095
+ await mkdir(dirname2(destination), { recursive: true });
2096
+ await writeFile(destination, data, { flag: "wx" });
2097
+ writtenBytes += data.byteLength;
2098
+ }
2099
+ if (entries.length !== input.expectedFiles || writtenBytes !== input.expectedBytes) {
2100
+ throw new SakupaError(
2101
+ "validation_failed",
2102
+ "Extracted recovery data does not match site metadata"
2103
+ );
2104
+ }
2105
+ await mkdir(dirname2(outputDir), { recursive: true });
2106
+ await rename(tempDir, outputDir);
2107
+ return {
2108
+ outputDir,
2109
+ totalBytes: writtenBytes,
2110
+ fileCount: entries.length,
2111
+ alreadyPresent: false
2112
+ };
2113
+ } catch (error) {
2114
+ await rm(tempDir, { recursive: true, force: true });
2115
+ throw error;
2116
+ }
2117
+ }
2118
+
1414
2119
  // src/creation-registry.ts
1415
2120
  import { existsSync as existsSync2, mkdirSync as mkdirSync2, readFileSync as readFileSync2, writeFileSync as writeFileSync2 } from "node:fs";
1416
2121
  import { homedir as homedir2 } from "node:os";
1417
- import { dirname as dirname2, join as join3 } from "node:path";
2122
+ import { dirname as dirname3, join as join4 } from "node:path";
1418
2123
  var RECENT_WINDOW_MS = FREE_SITE_TTL_HOURS * 60 * 60 * 1e3;
1419
2124
  function creationRegistryPath() {
1420
2125
  const base = process.env["SAKUPA_STATE_DIR"] ?? homedir2();
1421
- return join3(base, ".sakupa", "created-sites.json");
2126
+ return join4(base, ".sakupa", "created-sites.json");
1422
2127
  }
1423
2128
  function readAll() {
1424
2129
  const path = creationRegistryPath();
@@ -1435,7 +2140,7 @@ function readAll() {
1435
2140
  }
1436
2141
  function writeAll(records) {
1437
2142
  const path = creationRegistryPath();
1438
- mkdirSync2(dirname2(path), { recursive: true });
2143
+ mkdirSync2(dirname3(path), { recursive: true });
1439
2144
  writeFileSync2(path, `${JSON.stringify(records, null, 2)}
1440
2145
  `, "utf-8");
1441
2146
  }
@@ -1664,7 +2369,7 @@ function ensureUploadSizeWithinLimits(manifest, isFirstFreeDeploy) {
1664
2369
  async function buildHashedManifest(files, outputAbs) {
1665
2370
  const manifest = [];
1666
2371
  for (const file of files) {
1667
- const bytes = new Uint8Array(await fs2.readFile(join4(outputAbs, file.path)));
2372
+ const bytes = new Uint8Array(await fs2.readFile(join5(outputAbs, file.path)));
1668
2373
  manifest.push({ path: file.path, size: file.size, contentHash: await sha256Hex(bytes) });
1669
2374
  }
1670
2375
  return manifest;
@@ -1683,7 +2388,7 @@ async function uploadAll(ctx, targets, files, outputAbs) {
1683
2388
  `No local file matches upload target "${target.path}"; aborting upload.`
1684
2389
  );
1685
2390
  }
1686
- const bytes = new Uint8Array(await fs2.readFile(join4(outputAbs, match.path)));
2391
+ const bytes = new Uint8Array(await fs2.readFile(join5(outputAbs, match.path)));
1687
2392
  if (bytes.byteLength !== match.size) {
1688
2393
  throw new SakupaError(
1689
2394
  "validation_failed",
@@ -1727,15 +2432,15 @@ ${block}`, checklist: toDnsChecklist(diag.checks) };
1727
2432
  }
1728
2433
  }
1729
2434
  function projectRootAbove(projectDir) {
1730
- if (existsSync3(join4(projectDir, "package.json"))) return null;
1731
- return findAncestor(projectDir, (dir) => existsSync3(join4(dir, "package.json")), 4);
2435
+ if (existsSync3(join5(projectDir, "package.json"))) return null;
2436
+ return findAncestor(projectDir, (dir) => existsSync3(join5(dir, "package.json")), 4);
1732
2437
  }
1733
2438
  function findNeighborBinding(projectDir, outputRel) {
1734
2439
  const bound = (dir) => loadSiteFile(dir).kind !== "absent";
1735
2440
  const above = findAncestor(projectDir, bound, 3);
1736
2441
  if (above) return above;
1737
2442
  if (outputRel && outputRel !== ".") {
1738
- const outputAbs = resolve3(projectDir, outputRel);
2443
+ const outputAbs = resolve4(projectDir, outputRel);
1739
2444
  if (bound(outputAbs)) return outputAbs;
1740
2445
  }
1741
2446
  return null;
@@ -1818,7 +2523,7 @@ Next action: ${analysis.suggestedNextAction}`,
1818
2523
  return notDeployableResult(analysis);
1819
2524
  }
1820
2525
  const files = analysis.files;
1821
- const outputAbs = resolve3(ctx.projectDir, analysis.recommendedOutputDir ?? ".");
2526
+ const outputAbs = resolve4(ctx.projectDir, analysis.recommendedOutputDir ?? ".");
1822
2527
  const manifest = await buildHashedManifest(files, outputAbs);
1823
2528
  const siteFileState = loadSiteFile(ctx.projectDir);
1824
2529
  if (siteFileState.kind === "corrupted") {
@@ -2295,33 +3000,138 @@ Full status:`, res);
2295
3000
  server.registerTool(
2296
3001
  "recover",
2297
3002
  {
2298
- description: "Recover management control of a subscribed site WITH A BOUND CUSTOM DOMAIN after losing the local project, by proving DNS control of the apex domain. Sites without a bound domain are identified solely by their local credential and cannot be recovered. By default, completing recovery REVOKES all previous local credentials. Call first with the hostname to get the DNS record, then again with verificationId to complete recovery (writes a new .sakupa/site.json and returns a download link for the current site content).",
3003
+ description: "Recover management control of a subscribed site WITH A BOUND CUSTOM DOMAIN after losing the local project, by proving DNS control of the apex domain. Sites without a bound domain are identified solely by their local credential and cannot be recovered. By default, completing recovery REVOKES all previous local credentials. Recovery is resumable: start stores local pending state; complete installs and writes the new .sakupa/site.json credential BEFORE requesting content; download uses that credential to reissue an archive and safely extract it into html without repeating DNS.",
2299
3004
  outputSchema: STRUCTURED_TOOL_OUTPUT_SCHEMA,
2300
3005
  annotations: { readOnlyHint: false, destructiveHint: true, openWorldHint: true },
2301
3006
  inputSchema: {
2302
3007
  projectDir: projectDirInput,
2303
- action: z3.enum(["start", "status", "complete"]),
3008
+ action: z3.enum(["start", "status", "complete", "download"]),
2304
3009
  hostname: z3.string().optional().describe("Required for start."),
2305
- verificationId: z3.string().optional().describe("Required for status or complete."),
3010
+ verificationId: z3.string().optional().describe("For status or complete; inferred from local recovery state when omitted."),
3011
+ outputDir: z3.string().optional().describe("Relative extraction directory for complete/download (default: html)."),
2306
3012
  preserveExistingCredentials: z3.boolean().optional().describe("Explicitly keep old local credentials working (default: revoke them all).")
2307
3013
  }
2308
3014
  },
2309
3015
  async (args) => {
2310
3016
  try {
2311
3017
  const ctx = withProjectDir(baseCtx, args.projectDir);
3018
+ const localSite = loadSiteFile(ctx.projectDir);
3019
+ const localCredentialIsActive = async () => {
3020
+ if (localSite.kind !== "ok") return false;
3021
+ try {
3022
+ await ctx.client.getSiteStatus(localSite.file.siteId, localSite.file.credential);
3023
+ return true;
3024
+ } catch (error) {
3025
+ if (isSakupaError(error) && error.code === "unauthorized") return false;
3026
+ throw error;
3027
+ }
3028
+ };
3029
+ const download = async () => {
3030
+ const site = requireSiteFile(ctx);
3031
+ const archive = await ctx.client.getSiteArchive(site.siteId, site.credential);
3032
+ const bytes = await ctx.client.downloadArchive(archive.archiveUrl);
3033
+ const extracted = await extractRecoveryArchive({
3034
+ projectDir: ctx.projectDir,
3035
+ ...args.outputDir !== void 0 ? { outputDir: args.outputDir } : {},
3036
+ archive: bytes,
3037
+ expectedBytes: archive.totalBytes,
3038
+ expectedFiles: archive.fileCount
3039
+ });
3040
+ deleteRecoveryFile(ctx.projectDir);
3041
+ return { archive, extracted };
3042
+ };
3043
+ if (args.action === "download") {
3044
+ const { archive, extracted } = await download();
3045
+ return text(
3046
+ "recovery_content_downloaded",
3047
+ `Recovered site content with the credential in .sakupa/site.json.
3048
+ Output directory: ${extracted.outputDir}
3049
+ Files: ${extracted.fileCount}; bytes: ${extracted.totalBytes}
3050
+ No DNS verification was started or repeated.`,
3051
+ {
3052
+ siteId: archive.siteId,
3053
+ outputDir: extracted.outputDir,
3054
+ fileCount: extracted.fileCount,
3055
+ totalBytes: extracted.totalBytes,
3056
+ alreadyPresent: extracted.alreadyPresent,
3057
+ credentialUsedFromLocalFile: true,
3058
+ dnsVerificationRepeated: false
3059
+ }
3060
+ );
3061
+ }
2312
3062
  if (args.action === "start") {
3063
+ if (localSite.kind === "ok" && await localCredentialIsActive()) {
3064
+ return structuredToolResult({
3065
+ schemaVersion: 1,
3066
+ outcome: "completed",
3067
+ resultCode: "recovery_credential_already_present",
3068
+ summary: "A valid .sakupa/site.json already exists. Management authority is restored; do not start another TXT recovery. Continue with recover download.",
3069
+ data: {
3070
+ siteId: localSite.file.siteId,
3071
+ credentialStoredLocally: true,
3072
+ dnsVerificationRepeated: false
3073
+ },
3074
+ nextActions: [
3075
+ {
3076
+ tool: "recover",
3077
+ arguments: {
3078
+ projectDir: ctx.projectDir,
3079
+ action: "download",
3080
+ outputDir: args.outputDir ?? "html"
3081
+ },
3082
+ allowed: true
3083
+ }
3084
+ ]
3085
+ });
3086
+ }
3087
+ if (localSite.kind === "corrupted") {
3088
+ throw new SakupaError(
3089
+ "state_conflict",
3090
+ `Cannot start recovery while .sakupa/site.json is damaged: ${localSite.problem}`
3091
+ );
3092
+ }
3093
+ const pending2 = loadRecoveryFile(ctx.projectDir);
3094
+ if (pending2) {
3095
+ return structuredToolResult({
3096
+ schemaVersion: 1,
3097
+ outcome: "waiting_user",
3098
+ resultCode: "domain_recovery_local_state_found",
3099
+ summary: "A resumable DNS recovery already exists in this project. Do not create another TXT challenge; continue the stored verification.",
3100
+ data: { verificationId: pending2.verificationId, resumable: true },
3101
+ nextActions: [
3102
+ {
3103
+ tool: "recover",
3104
+ arguments: {
3105
+ projectDir: ctx.projectDir,
3106
+ action: "status",
3107
+ verificationId: pending2.verificationId
3108
+ },
3109
+ allowed: true
3110
+ }
3111
+ ]
3112
+ });
3113
+ }
2313
3114
  if (!args.hostname) {
2314
3115
  throw new SakupaError("invalid_request", "hostname is required for start");
2315
3116
  }
2316
3117
  const res2 = await ctx.client.recoverDomain({ hostname: args.hostname });
3118
+ writeRecoveryFile(ctx.projectDir, {
3119
+ verificationId: res2.verificationId,
3120
+ credential: generateCredential(),
3121
+ createdAt: (/* @__PURE__ */ new Date()).toISOString()
3122
+ });
3123
+ const txtShort = shortHostFor(res2.verificationRecord.name, res2.apexDomain);
2317
3124
  return text(
2318
3125
  "domain_recovery_started",
2319
3126
  `Recovery started for ${args.hostname} (apex domain: ${res2.apexDomain}).
2320
3127
 
2321
3128
  Create this DNS record to prove apex-domain control:
2322
- name: ${res2.verificationRecord.name}
2323
- type: ${res2.verificationRecord.type}
2324
- value: ${res2.verificationRecord.value}
3129
+ type: ${res2.verificationRecord.type}
3130
+ host/label: ${txtShort}
3131
+ value: ${res2.verificationRecord.value}
3132
+ full name: ${res2.verificationRecord.name}
3133
+
3134
+ Enter ONLY "${txtShort}" in DNS panels whose host/name field automatically appends ".${res2.apexDomain}". The saved full record must be exactly "${res2.verificationRecord.name}" and must never contain the apex twice.
2325
3135
 
2326
3136
  ${res2.message}
2327
3137
 
@@ -2331,20 +3141,60 @@ After the DNS record resolves, re-run recover with verificationId: "${res2.verif
2331
3141
  {
2332
3142
  verificationId: res2.verificationId,
2333
3143
  apexDomain: res2.apexDomain,
2334
- verificationRecord: res2.verificationRecord,
2335
- revokesPreviousCredentialsByDefault: true
3144
+ verificationRecord: {
3145
+ ...res2.verificationRecord,
3146
+ shortHost: txtShort
3147
+ },
3148
+ requiredRecords: [
3149
+ {
3150
+ type: res2.verificationRecord.type,
3151
+ shortHost: txtShort,
3152
+ name: res2.verificationRecord.name,
3153
+ value: res2.verificationRecord.value
3154
+ }
3155
+ ],
3156
+ revokesPreviousCredentialsByDefault: true,
3157
+ localRecoveryStateStored: true
2336
3158
  },
2337
3159
  "waiting_user"
2338
3160
  );
2339
3161
  }
2340
- if (!args.verificationId) {
3162
+ if (localSite.kind === "ok" && args.action === "status" && await localCredentialIsActive()) {
3163
+ return structuredToolResult({
3164
+ schemaVersion: 1,
3165
+ outcome: "completed",
3166
+ resultCode: "recovery_credential_already_present",
3167
+ summary: "The recovered credential is already stored in .sakupa/site.json. Resume content download with the local key; DNS recovery must not be repeated.",
3168
+ data: { siteId: localSite.file.siteId, credentialStoredLocally: true },
3169
+ nextActions: [
3170
+ {
3171
+ tool: "recover",
3172
+ arguments: {
3173
+ projectDir: ctx.projectDir,
3174
+ action: "download",
3175
+ outputDir: args.outputDir ?? "html"
3176
+ },
3177
+ allowed: true
3178
+ }
3179
+ ]
3180
+ });
3181
+ }
3182
+ const pending = loadRecoveryFile(ctx.projectDir);
3183
+ const verificationId = args.verificationId ?? pending?.verificationId;
3184
+ if (!verificationId) {
2341
3185
  throw new SakupaError(
2342
3186
  "invalid_request",
2343
- "verificationId is required for status or complete"
3187
+ "verificationId is required because no local resumable recovery state exists"
3188
+ );
3189
+ }
3190
+ if (pending && pending.verificationId !== verificationId) {
3191
+ throw new SakupaError(
3192
+ "state_conflict",
3193
+ "The supplied verificationId differs from this project\u2019s stored recovery state."
2344
3194
  );
2345
3195
  }
2346
3196
  if (args.action === "status") {
2347
- const res2 = await ctx.client.getRecoveryStatus(args.verificationId);
3197
+ const res2 = await ctx.client.getRecoveryStatus(verificationId);
2348
3198
  return structuredToolResult({
2349
3199
  schemaVersion: 1,
2350
3200
  outcome: res2.status === "expired" ? "expired" : res2.readyToComplete ? "completed" : "pending_provider",
@@ -2354,14 +3204,29 @@ After the DNS record resolves, re-run recover with verificationId: "${res2.verif
2354
3204
  nextActions: [
2355
3205
  {
2356
3206
  tool: "recover",
2357
- arguments: { action: "complete", verificationId: args.verificationId },
3207
+ arguments: {
3208
+ projectDir: ctx.projectDir,
3209
+ action: "complete",
3210
+ verificationId,
3211
+ outputDir: args.outputDir ?? "html"
3212
+ },
2358
3213
  allowed: res2.readyToComplete,
2359
3214
  ...res2.readyToComplete ? {} : { reasonCode: res2.status }
2360
3215
  }
2361
3216
  ]
2362
3217
  });
2363
3218
  }
2364
- const res = await ctx.client.completeRecovery(args.verificationId, {
3219
+ const recoveryState = pending ?? (() => {
3220
+ const created = {
3221
+ verificationId,
3222
+ credential: generateCredential(),
3223
+ createdAt: (/* @__PURE__ */ new Date()).toISOString()
3224
+ };
3225
+ writeRecoveryFile(ctx.projectDir, created);
3226
+ return created;
3227
+ })();
3228
+ const res = await ctx.client.completeRecovery(verificationId, {
3229
+ credential: recoveryState.credential,
2365
3230
  ...args.preserveExistingCredentials !== void 0 ? { preserveExistingCredentials: args.preserveExistingCredentials } : {}
2366
3231
  });
2367
3232
  writeSiteFile(
@@ -2369,31 +3234,63 @@ After the DNS record resolves, re-run recover with verificationId: "${res2.verif
2369
3234
  {
2370
3235
  siteId: res.siteId,
2371
3236
  ...res.boundHostnames[0] !== void 0 ? { boundDomain: res.boundHostnames[0] } : {},
2372
- credential: res.credential,
3237
+ credential: recoveryState.credential,
2373
3238
  createdAt: (/* @__PURE__ */ new Date()).toISOString(),
2374
3239
  apiBaseUrl: ctx.apiBaseUrl
2375
3240
  },
2376
3241
  { allowReplace: true }
2377
3242
  );
2378
- return text(
2379
- "domain_recovery_completed",
2380
- `Recovery complete.
3243
+ deleteRecoveryFile(ctx.projectDir);
3244
+ try {
3245
+ const { archive, extracted } = await download();
3246
+ return text(
3247
+ "domain_recovery_completed",
3248
+ `Recovery complete.
2381
3249
  Site: ${res.siteId} (hostnames: ${res.boundHostnames.join(", ") || "(none)"})
2382
3250
  Previous credentials revoked: ${res.revokedPreviousCredentials ? "YES" : "no (preserved on request)"}
2383
3251
 
2384
- A NEW management credential was written to .sakupa/site.json in this project \u2014 this project now manages the site.
2385
- ` + credentialGitReminder(ctx.projectDir) + `
2386
- Download the current site content (signed URL):
2387
- ${res.archiveUrl}`,
2388
- {
2389
- siteId: res.siteId,
2390
- boundHostnames: res.boundHostnames,
2391
- revokedPreviousCredentials: res.revokedPreviousCredentials,
2392
- archiveUrl: res.archiveUrl,
2393
- archiveExpiresAt: res.archiveExpiresAt,
2394
- credentialStoredLocally: true
2395
- }
2396
- );
3252
+ The NEW management credential was written to .sakupa/site.json before content download. The site archive was then requested with that key and extracted.
3253
+ Output directory: ${extracted.outputDir}
3254
+ Files: ${extracted.fileCount}; bytes: ${extracted.totalBytes}
3255
+ ` + credentialGitReminder(ctx.projectDir),
3256
+ {
3257
+ siteId: res.siteId,
3258
+ boundHostnames: res.boundHostnames,
3259
+ revokedPreviousCredentials: res.revokedPreviousCredentials,
3260
+ archiveExpiresAt: archive.archiveExpiresAt,
3261
+ outputDir: extracted.outputDir,
3262
+ fileCount: extracted.fileCount,
3263
+ totalBytes: extracted.totalBytes,
3264
+ alreadyPresent: extracted.alreadyPresent,
3265
+ credentialStoredLocally: true,
3266
+ archiveRequestedWithRecoveredCredential: true
3267
+ }
3268
+ );
3269
+ } catch (downloadError) {
3270
+ return structuredToolResult({
3271
+ schemaVersion: 1,
3272
+ outcome: "waiting_user",
3273
+ resultCode: "recovery_credential_restored_download_pending",
3274
+ summary: `Management authority was restored and .sakupa/site.json is safely written, but content download did not finish (${downloadError instanceof Error ? downloadError.message : String(downloadError)}). Do not repeat DNS recovery; run recover download in this project.`,
3275
+ data: {
3276
+ siteId: res.siteId,
3277
+ credentialStoredLocally: true,
3278
+ contentDownloadCompleted: false,
3279
+ dnsVerificationRepeated: false
3280
+ },
3281
+ nextActions: [
3282
+ {
3283
+ tool: "recover",
3284
+ arguments: {
3285
+ projectDir: ctx.projectDir,
3286
+ action: "download",
3287
+ outputDir: args.outputDir ?? "html"
3288
+ },
3289
+ allowed: true
3290
+ }
3291
+ ]
3292
+ });
3293
+ }
2397
3294
  } catch (e) {
2398
3295
  return toolError(e);
2399
3296
  }
@@ -2710,6 +3607,8 @@ Workflow:
2710
3607
  Plan changes are confirmed only on Stripe and synchronized by Stripe webhook.
2711
3608
  A cancellation keeps the site paid through the current period. Sakupa reverts it to a free
2712
3609
  24h site and removes paid data after Stripe sends the signed final-cancellation webhook.
3610
+ Recovery writes the new local credential before downloading content. If a session stops after
3611
+ .sakupa/site.json exists, NEVER repeat DNS recovery: resume with recover action "download".
2713
3612
  5. support (subscribed sites) opens a support ticket; report sends a
2714
3613
  sanitized diagnostic report after the user explicitly confirms it.
2715
3614
 
@@ -2728,8 +3627,9 @@ frustrated, proactively offer report: it files the problem into Sakupa's ticket
2728
3627
  alert stream, and you should attach your own factual account via agentContext.
2729
3628
 
2730
3629
  Present every step as Sakupa's own: never attribute DNS, certificates or hosting to
2731
- underlying infrastructure vendors in front of the user. Relay DNS record names and values
2732
- VERBATIM (some point into Sakupa's managed certificate network) without naming vendors.
3630
+ underlying infrastructure vendors in front of the user. Relay DNS record values and full
3631
+ names verbatim, but use the tool's shortHost value for a DNS panel host/name field that
3632
+ automatically appends the apex domain. The saved FQDN must never contain the apex twice.
2733
3633
 
2734
3634
  Safety boundaries:
2735
3635
  - Static output only: no SSR, API routes, middleware, server actions, databases or online builds.