@sakupa/mcp 0.7.22 → 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 +936 -52
  2. package/dist/index.js +931 -47
  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.22";
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,25 +3000,126 @@ 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
+ });
2317
3123
  const txtShort = shortHostFor(res2.verificationRecord.name, res2.apexDomain);
2318
3124
  return text(
2319
3125
  "domain_recovery_started",
@@ -2347,19 +3153,48 @@ After the DNS record resolves, re-run recover with verificationId: "${res2.verif
2347
3153
  value: res2.verificationRecord.value
2348
3154
  }
2349
3155
  ],
2350
- revokesPreviousCredentialsByDefault: true
3156
+ revokesPreviousCredentialsByDefault: true,
3157
+ localRecoveryStateStored: true
2351
3158
  },
2352
3159
  "waiting_user"
2353
3160
  );
2354
3161
  }
2355
- 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) {
2356
3185
  throw new SakupaError(
2357
3186
  "invalid_request",
2358
- "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."
2359
3194
  );
2360
3195
  }
2361
3196
  if (args.action === "status") {
2362
- const res2 = await ctx.client.getRecoveryStatus(args.verificationId);
3197
+ const res2 = await ctx.client.getRecoveryStatus(verificationId);
2363
3198
  return structuredToolResult({
2364
3199
  schemaVersion: 1,
2365
3200
  outcome: res2.status === "expired" ? "expired" : res2.readyToComplete ? "completed" : "pending_provider",
@@ -2369,14 +3204,29 @@ After the DNS record resolves, re-run recover with verificationId: "${res2.verif
2369
3204
  nextActions: [
2370
3205
  {
2371
3206
  tool: "recover",
2372
- arguments: { action: "complete", verificationId: args.verificationId },
3207
+ arguments: {
3208
+ projectDir: ctx.projectDir,
3209
+ action: "complete",
3210
+ verificationId,
3211
+ outputDir: args.outputDir ?? "html"
3212
+ },
2373
3213
  allowed: res2.readyToComplete,
2374
3214
  ...res2.readyToComplete ? {} : { reasonCode: res2.status }
2375
3215
  }
2376
3216
  ]
2377
3217
  });
2378
3218
  }
2379
- 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,
2380
3230
  ...args.preserveExistingCredentials !== void 0 ? { preserveExistingCredentials: args.preserveExistingCredentials } : {}
2381
3231
  });
2382
3232
  writeSiteFile(
@@ -2384,31 +3234,63 @@ After the DNS record resolves, re-run recover with verificationId: "${res2.verif
2384
3234
  {
2385
3235
  siteId: res.siteId,
2386
3236
  ...res.boundHostnames[0] !== void 0 ? { boundDomain: res.boundHostnames[0] } : {},
2387
- credential: res.credential,
3237
+ credential: recoveryState.credential,
2388
3238
  createdAt: (/* @__PURE__ */ new Date()).toISOString(),
2389
3239
  apiBaseUrl: ctx.apiBaseUrl
2390
3240
  },
2391
3241
  { allowReplace: true }
2392
3242
  );
2393
- return text(
2394
- "domain_recovery_completed",
2395
- `Recovery complete.
3243
+ deleteRecoveryFile(ctx.projectDir);
3244
+ try {
3245
+ const { archive, extracted } = await download();
3246
+ return text(
3247
+ "domain_recovery_completed",
3248
+ `Recovery complete.
2396
3249
  Site: ${res.siteId} (hostnames: ${res.boundHostnames.join(", ") || "(none)"})
2397
3250
  Previous credentials revoked: ${res.revokedPreviousCredentials ? "YES" : "no (preserved on request)"}
2398
3251
 
2399
- A NEW management credential was written to .sakupa/site.json in this project \u2014 this project now manages the site.
2400
- ` + credentialGitReminder(ctx.projectDir) + `
2401
- Download the current site content (signed URL):
2402
- ${res.archiveUrl}`,
2403
- {
2404
- siteId: res.siteId,
2405
- boundHostnames: res.boundHostnames,
2406
- revokedPreviousCredentials: res.revokedPreviousCredentials,
2407
- archiveUrl: res.archiveUrl,
2408
- archiveExpiresAt: res.archiveExpiresAt,
2409
- credentialStoredLocally: true
2410
- }
2411
- );
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
+ }
2412
3294
  } catch (e) {
2413
3295
  return toolError(e);
2414
3296
  }
@@ -2725,6 +3607,8 @@ Workflow:
2725
3607
  Plan changes are confirmed only on Stripe and synchronized by Stripe webhook.
2726
3608
  A cancellation keeps the site paid through the current period. Sakupa reverts it to a free
2727
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".
2728
3612
  5. support (subscribed sites) opens a support ticket; report sends a
2729
3613
  sanitized diagnostic report after the user explicitly confirms it.
2730
3614