@sakupa/mcp 0.7.22 → 0.7.24

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 +942 -53
  2. package/dist/index.js +937 -48
  3. package/package.json +3 -2
package/dist/bin.js CHANGED
@@ -129,7 +129,7 @@ var FORBIDDEN_PATH_SEGMENTS = [
129
129
  var ALLOWED_HIDDEN_PATHS = [".well-known/"];
130
130
 
131
131
  // ../core/dist/domain/version.js
132
- var SAKUPA_MCP_VERSION = "0.7.22";
132
+ var SAKUPA_MCP_VERSION = "0.7.24";
133
133
 
134
134
  // ../core/dist/domain/errors.js
135
135
  var HTTP_STATUS = {
@@ -160,8 +160,8 @@ var SakupaError = class extends Error {
160
160
  this.details = details;
161
161
  }
162
162
  };
163
- function isSakupaError(err) {
164
- return err instanceof SakupaError;
163
+ function isSakupaError(err2) {
164
+ return err2 instanceof SakupaError;
165
165
  }
166
166
 
167
167
  // ../core/dist/domain/tiers.js
@@ -436,7 +436,12 @@ function safeDecode(bytes) {
436
436
  }
437
437
 
438
438
  // ../core/dist/domain/credentials.js
439
+ import { randomBytes, scrypt, timingSafeEqual } from "node:crypto";
440
+ var CREDENTIAL_PREFIX = "sk_";
439
441
  var CREDENTIAL_PATTERN = /^sk_[A-Za-z0-9_-]{43}$/;
442
+ function generateCredential(random = randomBytes) {
443
+ return CREDENTIAL_PREFIX + random(32).toString("base64url");
444
+ }
440
445
 
441
446
  // ../core/dist/domain/hashing.js
442
447
  async function sha256Hex(bytes) {
@@ -580,6 +585,16 @@ var HttpApiClient = class {
580
585
  credential
581
586
  });
582
587
  }
588
+ async getSiteArchive(siteId, credential) {
589
+ return this.call(
590
+ "GET",
591
+ `/v1/sites/${encodeURIComponent(siteId)}/archive`,
592
+ { credential }
593
+ );
594
+ }
595
+ async downloadArchive(url) {
596
+ return this.transport.download(url);
597
+ }
583
598
  async deleteSite(siteId, credential, req) {
584
599
  return this.call("POST", `/v1/sites/${encodeURIComponent(siteId)}/delete`, {
585
600
  credential,
@@ -675,7 +690,7 @@ var HttpApiClient = class {
675
690
  // src/tools/definitions.ts
676
691
  import { randomUUID } from "node:crypto";
677
692
  import { existsSync as existsSync3, promises as fs2 } from "node:fs";
678
- import { join as join4, resolve as resolve3 } from "node:path";
693
+ import { join as join5, resolve as resolve4 } from "node:path";
679
694
  import { z as z3 } from "zod";
680
695
 
681
696
  // src/analyze/analyzer.ts
@@ -716,8 +731,8 @@ async function isFile(path) {
716
731
  }
717
732
  async function readTextIfExists(path, maxBytes = CONTENT_READ_MAX_BYTES) {
718
733
  try {
719
- const stat = await fs.stat(path);
720
- if (!stat.isFile() || stat.size > maxBytes) return null;
734
+ const stat2 = await fs.stat(path);
735
+ if (!stat2.isFile() || stat2.size > maxBytes) return null;
721
736
  return await fs.readFile(path, "utf8");
722
737
  } catch {
723
738
  return null;
@@ -757,8 +772,8 @@ async function walkFiles(dir, opts) {
757
772
  await recurse(join(current, entry.name), rel);
758
773
  } else if (entry.isFile()) {
759
774
  try {
760
- const stat = await fs.stat(join(current, entry.name));
761
- out.push({ path: rel, size: stat.size });
775
+ const stat2 = await fs.stat(join(current, entry.name));
776
+ out.push({ path: rel, size: stat2.size });
762
777
  } catch {
763
778
  }
764
779
  }
@@ -1102,19 +1117,23 @@ import { chmodSync, existsSync, mkdirSync, readFileSync, rmSync, writeFileSync }
1102
1117
  import { dirname, join as join2 } from "node:path";
1103
1118
  var SITE_DIR = ".sakupa";
1104
1119
  var SITE_FILE = "site.json";
1120
+ var RECOVERY_FILE = "recovery.json";
1105
1121
  function siteFilePath(projectDir) {
1106
1122
  return join2(projectDir, SITE_DIR, SITE_FILE);
1107
1123
  }
1124
+ function recoveryFilePath(projectDir) {
1125
+ return join2(projectDir, SITE_DIR, RECOVERY_FILE);
1126
+ }
1108
1127
  function loadSiteFile(projectDir) {
1109
1128
  const path = siteFilePath(projectDir);
1110
1129
  if (!existsSync(path)) return { kind: "absent" };
1111
1130
  let raw;
1112
1131
  try {
1113
1132
  raw = readFileSync(path, "utf8");
1114
- } catch (err) {
1133
+ } catch (err2) {
1115
1134
  return {
1116
1135
  kind: "corrupted",
1117
- problem: `the file exists but could not be read (${err instanceof Error ? err.message : String(err)})`
1136
+ problem: `the file exists but could not be read (${err2 instanceof Error ? err2.message : String(err2)})`
1118
1137
  };
1119
1138
  }
1120
1139
  let parsed;
@@ -1151,6 +1170,40 @@ function loadSiteFile(projectDir) {
1151
1170
  }
1152
1171
  };
1153
1172
  }
1173
+ function loadRecoveryFile(projectDir) {
1174
+ const path = recoveryFilePath(projectDir);
1175
+ if (!existsSync(path)) return null;
1176
+ try {
1177
+ const parsed = JSON.parse(readFileSync(path, "utf8"));
1178
+ if (typeof parsed.verificationId !== "string" || parsed.verificationId.length === 0 || typeof parsed.credential !== "string" || !CREDENTIAL_PATTERN.test(parsed.credential)) {
1179
+ throw new Error("required recovery fields are missing or invalid");
1180
+ }
1181
+ return {
1182
+ verificationId: parsed.verificationId,
1183
+ credential: parsed.credential,
1184
+ createdAt: typeof parsed.createdAt === "string" ? parsed.createdAt : ""
1185
+ };
1186
+ } catch (error) {
1187
+ throw new Error(
1188
+ `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.`
1189
+ );
1190
+ }
1191
+ }
1192
+ function writeRecoveryFile(projectDir, file) {
1193
+ const dir = join2(projectDir, SITE_DIR);
1194
+ mkdirSync(dir, { recursive: true });
1195
+ const path = join2(dir, RECOVERY_FILE);
1196
+ writeFileSync(path, `${JSON.stringify(file, null, 2)}
1197
+ `, "utf8");
1198
+ try {
1199
+ chmodSync(path, 384);
1200
+ } catch {
1201
+ }
1202
+ }
1203
+ function deleteRecoveryFile(projectDir) {
1204
+ const path = recoveryFilePath(projectDir);
1205
+ if (existsSync(path)) rmSync(path, { force: true });
1206
+ }
1154
1207
  function siteFileRecoveryGuidance(projectDir) {
1155
1208
  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.`;
1156
1209
  }
@@ -1202,14 +1255,643 @@ function credentialGitReminder(projectDir) {
1202
1255
  return '\nNOTE: this project is inside a git repository. The management credential in .sakupa/site.json is the key to this site \u2014 do NOT commit it to a PUBLIC repository (add ".sakupa/" to .gitignore yourself if you want to keep it out of version control).';
1203
1256
  }
1204
1257
 
1258
+ // src/recovery-archive.ts
1259
+ import { mkdtemp, mkdir, readFile, readdir, rename, rm, stat, writeFile } from "node:fs/promises";
1260
+ import { dirname as dirname2, isAbsolute, join as join3, relative, resolve as resolve2, sep as sep2 } from "node:path";
1261
+
1262
+ // ../../node_modules/fflate/esm/index.mjs
1263
+ import { createRequire } from "module";
1264
+ var require2 = createRequire("/");
1265
+ var _a;
1266
+ var Worker;
1267
+ var isMarkedAsUntransferable;
1268
+ try {
1269
+ _a = require2("worker_threads"), Worker = _a.Worker, isMarkedAsUntransferable = _a.isMarkedAsUntransferable;
1270
+ } catch (e) {
1271
+ }
1272
+ var u8 = Uint8Array;
1273
+ var u16 = Uint16Array;
1274
+ var i32 = Int32Array;
1275
+ var fleb = new u8([
1276
+ 0,
1277
+ 0,
1278
+ 0,
1279
+ 0,
1280
+ 0,
1281
+ 0,
1282
+ 0,
1283
+ 0,
1284
+ 1,
1285
+ 1,
1286
+ 1,
1287
+ 1,
1288
+ 2,
1289
+ 2,
1290
+ 2,
1291
+ 2,
1292
+ 3,
1293
+ 3,
1294
+ 3,
1295
+ 3,
1296
+ 4,
1297
+ 4,
1298
+ 4,
1299
+ 4,
1300
+ 5,
1301
+ 5,
1302
+ 5,
1303
+ 5,
1304
+ 0,
1305
+ /* unused */
1306
+ 0,
1307
+ 0,
1308
+ /* impossible */
1309
+ 0
1310
+ ]);
1311
+ var fdeb = new u8([
1312
+ 0,
1313
+ 0,
1314
+ 0,
1315
+ 0,
1316
+ 1,
1317
+ 1,
1318
+ 2,
1319
+ 2,
1320
+ 3,
1321
+ 3,
1322
+ 4,
1323
+ 4,
1324
+ 5,
1325
+ 5,
1326
+ 6,
1327
+ 6,
1328
+ 7,
1329
+ 7,
1330
+ 8,
1331
+ 8,
1332
+ 9,
1333
+ 9,
1334
+ 10,
1335
+ 10,
1336
+ 11,
1337
+ 11,
1338
+ 12,
1339
+ 12,
1340
+ 13,
1341
+ 13,
1342
+ /* unused */
1343
+ 0,
1344
+ 0
1345
+ ]);
1346
+ var clim = new u8([16, 17, 18, 0, 8, 7, 9, 6, 10, 5, 11, 4, 12, 3, 13, 2, 14, 1, 15]);
1347
+ var freb = function(eb, start) {
1348
+ var b = new u16(31);
1349
+ for (var i = 0; i < 31; ++i) {
1350
+ b[i] = start += 1 << eb[i - 1];
1351
+ }
1352
+ var r = new i32(b[30]);
1353
+ for (var i = 1; i < 30; ++i) {
1354
+ for (var j = b[i]; j < b[i + 1]; ++j) {
1355
+ r[j] = j - b[i] << 5 | i;
1356
+ }
1357
+ }
1358
+ return { b, r };
1359
+ };
1360
+ var _a = freb(fleb, 2);
1361
+ var fl = _a.b;
1362
+ var revfl = _a.r;
1363
+ fl[28] = 258, revfl[258] = 28;
1364
+ var _b = freb(fdeb, 0);
1365
+ var fd = _b.b;
1366
+ var revfd = _b.r;
1367
+ var rev = new u16(32768);
1368
+ for (i = 0; i < 32768; ++i) {
1369
+ x = (i & 43690) >> 1 | (i & 21845) << 1;
1370
+ x = (x & 52428) >> 2 | (x & 13107) << 2;
1371
+ x = (x & 61680) >> 4 | (x & 3855) << 4;
1372
+ rev[i] = ((x & 65280) >> 8 | (x & 255) << 8) >> 1;
1373
+ }
1374
+ var x;
1375
+ var i;
1376
+ var hMap = function(cd, mb, r) {
1377
+ var s = cd.length;
1378
+ var i = 0;
1379
+ var l = new u16(mb);
1380
+ for (; i < s; ++i) {
1381
+ if (cd[i])
1382
+ ++l[cd[i] - 1];
1383
+ }
1384
+ var le = new u16(mb);
1385
+ for (i = 1; i < mb; ++i) {
1386
+ le[i] = le[i - 1] + l[i - 1] << 1;
1387
+ }
1388
+ var co;
1389
+ if (r) {
1390
+ co = new u16(1 << mb);
1391
+ var rvb = 15 - mb;
1392
+ for (i = 0; i < s; ++i) {
1393
+ if (cd[i]) {
1394
+ var sv = i << 4 | cd[i];
1395
+ var r_1 = mb - cd[i];
1396
+ var v = le[cd[i] - 1]++ << r_1;
1397
+ for (var m = v | (1 << r_1) - 1; v <= m; ++v) {
1398
+ co[rev[v] >> rvb] = sv;
1399
+ }
1400
+ }
1401
+ }
1402
+ } else {
1403
+ co = new u16(s);
1404
+ for (i = 0; i < s; ++i) {
1405
+ if (cd[i]) {
1406
+ co[i] = rev[le[cd[i] - 1]++] >> 15 - cd[i];
1407
+ }
1408
+ }
1409
+ }
1410
+ return co;
1411
+ };
1412
+ var flt = new u8(288);
1413
+ for (i = 0; i < 144; ++i)
1414
+ flt[i] = 8;
1415
+ var i;
1416
+ for (i = 144; i < 256; ++i)
1417
+ flt[i] = 9;
1418
+ var i;
1419
+ for (i = 256; i < 280; ++i)
1420
+ flt[i] = 7;
1421
+ var i;
1422
+ for (i = 280; i < 288; ++i)
1423
+ flt[i] = 8;
1424
+ var i;
1425
+ var fdt = new u8(32);
1426
+ for (i = 0; i < 32; ++i)
1427
+ fdt[i] = 5;
1428
+ var i;
1429
+ var flrm = /* @__PURE__ */ hMap(flt, 9, 1);
1430
+ var fdrm = /* @__PURE__ */ hMap(fdt, 5, 1);
1431
+ var max = function(a) {
1432
+ var m = a[0];
1433
+ for (var i = 1; i < a.length; ++i) {
1434
+ if (a[i] > m)
1435
+ m = a[i];
1436
+ }
1437
+ return m;
1438
+ };
1439
+ var bits = function(d, p, m) {
1440
+ var o = p / 8 | 0;
1441
+ return (d[o] | d[o + 1] << 8) >> (p & 7) & m;
1442
+ };
1443
+ var bits16 = function(d, p) {
1444
+ var o = p / 8 | 0;
1445
+ return (d[o] | d[o + 1] << 8 | d[o + 2] << 16) >> (p & 7);
1446
+ };
1447
+ var shft = function(p) {
1448
+ return (p + 7) / 8 | 0;
1449
+ };
1450
+ var slc = function(v, s, e) {
1451
+ if (s == null || s < 0)
1452
+ s = 0;
1453
+ if (e == null || e > v.length)
1454
+ e = v.length;
1455
+ return new u8(v.subarray(s, e));
1456
+ };
1457
+ var ec = [
1458
+ "unexpected EOF",
1459
+ "invalid block type",
1460
+ "invalid length/literal",
1461
+ "invalid distance",
1462
+ "stream finished",
1463
+ "no stream handler",
1464
+ ,
1465
+ // determined by compression function
1466
+ "no callback",
1467
+ "invalid UTF-8 data",
1468
+ "extra field too long",
1469
+ "date not in range 1980-2099",
1470
+ "filename too long",
1471
+ "stream finishing",
1472
+ "invalid zip data"
1473
+ // determined by unknown compression method
1474
+ ];
1475
+ var err = function(ind, msg, nt) {
1476
+ var e = new Error(msg || ec[ind]);
1477
+ e.code = ind;
1478
+ if (Error.captureStackTrace)
1479
+ Error.captureStackTrace(e, err);
1480
+ if (!nt)
1481
+ throw e;
1482
+ return e;
1483
+ };
1484
+ var inflt = function(dat, st, buf, dict) {
1485
+ var sl = dat.length, dl = dict ? dict.length : 0;
1486
+ if (!sl || st.f && !st.l)
1487
+ return buf || new u8(0);
1488
+ var noBuf = !buf;
1489
+ var resize = noBuf || st.i != 2;
1490
+ var noSt = st.i;
1491
+ if (noBuf)
1492
+ buf = new u8(sl * 3);
1493
+ var cbuf = function(l2) {
1494
+ var bl = buf.length;
1495
+ if (l2 > bl) {
1496
+ var nbuf = new u8(Math.max(bl * 2, l2));
1497
+ nbuf.set(buf);
1498
+ buf = nbuf;
1499
+ }
1500
+ };
1501
+ 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;
1502
+ var tbts = sl * 8;
1503
+ do {
1504
+ if (!lm) {
1505
+ final = bits(dat, pos, 1);
1506
+ var type = bits(dat, pos + 1, 3);
1507
+ pos += 3;
1508
+ if (!type) {
1509
+ var s = shft(pos) + 4, l = dat[s - 4] | dat[s - 3] << 8, t = s + l;
1510
+ if (t > sl) {
1511
+ if (noSt)
1512
+ err(0);
1513
+ break;
1514
+ }
1515
+ if (resize)
1516
+ cbuf(bt + l);
1517
+ buf.set(dat.subarray(s, t), bt);
1518
+ st.b = bt += l, st.p = pos = t * 8, st.f = final;
1519
+ continue;
1520
+ } else if (type == 1)
1521
+ lm = flrm, dm = fdrm, lbt = 9, dbt = 5;
1522
+ else if (type == 2) {
1523
+ var hLit = bits(dat, pos, 31) + 257, hcLen = bits(dat, pos + 10, 15) + 4;
1524
+ var tl = hLit + bits(dat, pos + 5, 31) + 1;
1525
+ pos += 14;
1526
+ var ldt = new u8(tl);
1527
+ var clt = new u8(19);
1528
+ for (var i = 0; i < hcLen; ++i) {
1529
+ clt[clim[i]] = bits(dat, pos + i * 3, 7);
1530
+ }
1531
+ pos += hcLen * 3;
1532
+ var clb = max(clt), clbmsk = (1 << clb) - 1;
1533
+ var clm = hMap(clt, clb, 1);
1534
+ for (var i = 0; i < tl; ) {
1535
+ var r = clm[bits(dat, pos, clbmsk)];
1536
+ pos += r & 15;
1537
+ var s = r >> 4;
1538
+ if (s < 16) {
1539
+ ldt[i++] = s;
1540
+ } else {
1541
+ var c = 0, n = 0;
1542
+ if (s == 16)
1543
+ n = 3 + bits(dat, pos, 3), pos += 2, c = ldt[i - 1];
1544
+ else if (s == 17)
1545
+ n = 3 + bits(dat, pos, 7), pos += 3;
1546
+ else if (s == 18)
1547
+ n = 11 + bits(dat, pos, 127), pos += 7;
1548
+ while (n--)
1549
+ ldt[i++] = c;
1550
+ }
1551
+ }
1552
+ var lt = ldt.subarray(0, hLit), dt = ldt.subarray(hLit);
1553
+ lbt = max(lt);
1554
+ dbt = max(dt);
1555
+ lm = hMap(lt, lbt, 1);
1556
+ dm = hMap(dt, dbt, 1);
1557
+ } else
1558
+ err(1);
1559
+ if (pos > tbts) {
1560
+ if (noSt)
1561
+ err(0);
1562
+ break;
1563
+ }
1564
+ }
1565
+ if (resize)
1566
+ cbuf(bt + 131072);
1567
+ var lms = (1 << lbt) - 1, dms = (1 << dbt) - 1;
1568
+ var lpos = pos;
1569
+ for (; ; lpos = pos) {
1570
+ var c = lm[bits16(dat, pos) & lms], sym = c >> 4;
1571
+ pos += c & 15;
1572
+ if (pos > tbts) {
1573
+ if (noSt)
1574
+ err(0);
1575
+ break;
1576
+ }
1577
+ if (!c)
1578
+ err(2);
1579
+ if (sym < 256)
1580
+ buf[bt++] = sym;
1581
+ else if (sym == 256) {
1582
+ lpos = pos, lm = null;
1583
+ break;
1584
+ } else {
1585
+ var add = sym - 254;
1586
+ if (sym > 264) {
1587
+ var i = sym - 257, b = fleb[i];
1588
+ add = bits(dat, pos, (1 << b) - 1) + fl[i];
1589
+ pos += b;
1590
+ }
1591
+ var d = dm[bits16(dat, pos) & dms], dsym = d >> 4;
1592
+ if (!d)
1593
+ err(3);
1594
+ pos += d & 15;
1595
+ var dt = fd[dsym];
1596
+ if (dsym > 3) {
1597
+ var b = fdeb[dsym];
1598
+ dt += bits16(dat, pos) & (1 << b) - 1, pos += b;
1599
+ }
1600
+ if (pos > tbts) {
1601
+ if (noSt)
1602
+ err(0);
1603
+ break;
1604
+ }
1605
+ if (resize)
1606
+ cbuf(bt + 131072);
1607
+ var end = bt + add;
1608
+ if (bt < dt) {
1609
+ var shift = dl - dt, dend = Math.min(dt, end);
1610
+ if (shift + bt < 0)
1611
+ err(3);
1612
+ for (; bt < dend; ++bt)
1613
+ buf[bt] = dict[shift + bt];
1614
+ }
1615
+ for (; bt < end; ++bt)
1616
+ buf[bt] = buf[bt - dt];
1617
+ }
1618
+ }
1619
+ st.l = lm, st.p = lpos, st.b = bt, st.f = final;
1620
+ if (lm)
1621
+ final = 1, st.m = lbt, st.d = dm, st.n = dbt;
1622
+ } while (!final);
1623
+ return bt != buf.length && noBuf ? slc(buf, 0, bt) : buf.subarray(0, bt);
1624
+ };
1625
+ var et = /* @__PURE__ */ new u8(0);
1626
+ var b2 = function(d, b) {
1627
+ return d[b] | d[b + 1] << 8;
1628
+ };
1629
+ var b4 = function(d, b) {
1630
+ return (d[b] | d[b + 1] << 8 | d[b + 2] << 16 | d[b + 3] << 24) >>> 0;
1631
+ };
1632
+ var b8 = function(d, b) {
1633
+ return b4(d, b) + b4(d, b + 4) * 4294967296;
1634
+ };
1635
+ function inflateSync(data, opts) {
1636
+ return inflt(data, { i: 2 }, opts && opts.out, opts && opts.dictionary);
1637
+ }
1638
+ var td = typeof TextDecoder != "undefined" && /* @__PURE__ */ new TextDecoder();
1639
+ var tds = 0;
1640
+ try {
1641
+ td.decode(et, { stream: true });
1642
+ tds = 1;
1643
+ } catch (e) {
1644
+ }
1645
+ var dutf8 = function(d) {
1646
+ for (var r = "", i = 0; ; ) {
1647
+ var c = d[i++];
1648
+ var eb = (c > 127) + (c > 223) + (c > 239);
1649
+ if (i + eb > d.length)
1650
+ return { s: r, r: slc(d, i - 1) };
1651
+ if (!eb)
1652
+ r += String.fromCharCode(c);
1653
+ else if (eb == 3) {
1654
+ 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);
1655
+ } else if (eb & 1)
1656
+ r += String.fromCharCode((c & 31) << 6 | d[i++] & 63);
1657
+ else
1658
+ r += String.fromCharCode((c & 15) << 12 | (d[i++] & 63) << 6 | d[i++] & 63);
1659
+ }
1660
+ };
1661
+ function strFromU8(dat, latin1) {
1662
+ if (latin1) {
1663
+ var r = "";
1664
+ for (var i = 0; i < dat.length; i += 16384)
1665
+ r += String.fromCharCode.apply(null, dat.subarray(i, i + 16384));
1666
+ return r;
1667
+ } else if (td) {
1668
+ return td.decode(dat);
1669
+ } else {
1670
+ var _a2 = dutf8(dat), s = _a2.s, r = _a2.r;
1671
+ if (r.length)
1672
+ err(8);
1673
+ return s;
1674
+ }
1675
+ }
1676
+ var slzh = function(d, b) {
1677
+ return b + 30 + b2(d, b + 26) + b2(d, b + 28);
1678
+ };
1679
+ var zh = function(d, b, z6) {
1680
+ 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;
1681
+ 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];
1682
+ return [b2(d, b + 10), sc, su, fn, es + efl + b2(d, b + 32), off];
1683
+ };
1684
+ var z64hs = function(d, b, l, z6, sc, su, off) {
1685
+ var nsc = sc == 4294967295, nsu = su == 4294967295, noff = off == 4294967295, e = b + l;
1686
+ var nf = nsc + nsu + noff;
1687
+ if (z6 && nf) {
1688
+ for (; b + 4 < e; b += 4 + b2(d, b + 2)) {
1689
+ if (b2(d, b) == 1) {
1690
+ return [
1691
+ nsc ? b8(d, b + 4 + 8 * nsu) : sc,
1692
+ nsu ? b8(d, b + 4) : su,
1693
+ noff ? b8(d, b + 4 + 8 * (nsu + nsc)) : off,
1694
+ 1
1695
+ ];
1696
+ }
1697
+ }
1698
+ if (z6 < 2)
1699
+ err(13);
1700
+ }
1701
+ return [sc, su, off, 0];
1702
+ };
1703
+ function unzipSync(data, opts) {
1704
+ var files = {};
1705
+ var e = data.length - 22;
1706
+ for (; b4(data, e) != 101010256; --e) {
1707
+ if (!e || data.length - e > 65558)
1708
+ err(13);
1709
+ }
1710
+ ;
1711
+ var c = b2(data, e + 8);
1712
+ if (!c)
1713
+ return {};
1714
+ var o = b4(data, e + 16);
1715
+ var z6 = b4(data, e - 20) == 117853008;
1716
+ if (z6) {
1717
+ var ze = b4(data, e - 12);
1718
+ z6 = b4(data, ze) == 101075792;
1719
+ if (z6) {
1720
+ c = b4(data, ze + 32);
1721
+ o = b4(data, ze + 48);
1722
+ }
1723
+ }
1724
+ var fltr = opts && opts.filter;
1725
+ for (var i = 0; i < c; ++i) {
1726
+ 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);
1727
+ o = no;
1728
+ if (!fltr || fltr({
1729
+ name: fn,
1730
+ size: sc,
1731
+ originalSize: su,
1732
+ compression: c_2
1733
+ })) {
1734
+ if (!c_2)
1735
+ files[fn] = slc(data, b, b + sc);
1736
+ else if (c_2 == 8)
1737
+ files[fn] = inflateSync(data.subarray(b, b + sc), { out: new u8(su) });
1738
+ else
1739
+ err(14, "unknown compression type " + c_2);
1740
+ }
1741
+ }
1742
+ return files;
1743
+ }
1744
+
1745
+ // src/recovery-archive.ts
1746
+ function safeOutputPath(projectDir, outputDir) {
1747
+ if (outputDir.length === 0 || isAbsolute(outputDir)) {
1748
+ throw new SakupaError("invalid_request", "Recovery outputDir must be a relative directory");
1749
+ }
1750
+ const root = resolve2(projectDir);
1751
+ const target = resolve2(root, outputDir);
1752
+ const rel = relative(root, target);
1753
+ if (rel === "" || rel === ".." || rel.startsWith(`..${sep2}`) || isAbsolute(rel)) {
1754
+ throw new SakupaError("invalid_request", "Recovery outputDir must stay inside projectDir");
1755
+ }
1756
+ if (rel === ".sakupa" || rel.startsWith(`.sakupa${sep2}`)) {
1757
+ throw new SakupaError("invalid_request", "Recovery content cannot be written inside .sakupa");
1758
+ }
1759
+ return target;
1760
+ }
1761
+ function safeEntryName(name) {
1762
+ if (name.length === 0 || name.startsWith("/") || name.includes("\\") || /^[A-Za-z]:/.test(name)) {
1763
+ throw new SakupaError("validation_failed", `Unsafe recovery archive path: ${name}`);
1764
+ }
1765
+ const segments = name.split("/");
1766
+ if (segments.some((segment) => segment === "" || segment === "." || segment === "..")) {
1767
+ throw new SakupaError("validation_failed", `Unsafe recovery archive path: ${name}`);
1768
+ }
1769
+ return segments.join("/");
1770
+ }
1771
+ async function pathExists(path) {
1772
+ try {
1773
+ await stat(path);
1774
+ return true;
1775
+ } catch (error) {
1776
+ if (error.code === "ENOENT") return false;
1777
+ throw error;
1778
+ }
1779
+ }
1780
+ async function listExistingFiles(root, current = root) {
1781
+ const entries = await readdir(current, { withFileTypes: true });
1782
+ const files = [];
1783
+ for (const entry of entries) {
1784
+ if (entry.isSymbolicLink()) {
1785
+ throw new SakupaError("state_conflict", `Recovery output contains a symlink: ${entry.name}`);
1786
+ }
1787
+ const absolute = join3(current, entry.name);
1788
+ if (entry.isDirectory()) files.push(...await listExistingFiles(root, absolute));
1789
+ else if (entry.isFile()) files.push(relative(root, absolute).split(sep2).join("/"));
1790
+ else
1791
+ throw new SakupaError(
1792
+ "state_conflict",
1793
+ `Recovery output contains a special file: ${entry.name}`
1794
+ );
1795
+ }
1796
+ return files.sort();
1797
+ }
1798
+ async function existingOutputMatches(outputDir, files) {
1799
+ const existing = await listExistingFiles(outputDir);
1800
+ const expected = Object.keys(files).sort();
1801
+ if (existing.length !== expected.length || existing.some((name, i) => name !== expected[i])) {
1802
+ return false;
1803
+ }
1804
+ for (const name of expected) {
1805
+ const actual = await readFile(join3(outputDir, ...name.split("/")));
1806
+ const wanted = files[name];
1807
+ if (!wanted || actual.byteLength !== wanted.byteLength || !actual.equals(wanted)) return false;
1808
+ }
1809
+ return true;
1810
+ }
1811
+ async function extractRecoveryArchive(input) {
1812
+ 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) {
1813
+ throw new SakupaError("validation_failed", "Recovery archive metadata exceeds product limits");
1814
+ }
1815
+ const outputDir = safeOutputPath(input.projectDir, input.outputDir ?? "html");
1816
+ const maxArchiveBytes = input.expectedBytes + input.expectedFiles * 4096 + 65536;
1817
+ if (input.archive.byteLength > maxArchiveBytes) {
1818
+ throw new SakupaError("validation_failed", "Recovery archive is larger than its site metadata");
1819
+ }
1820
+ const seen = /* @__PURE__ */ new Set();
1821
+ let declaredBytes = 0;
1822
+ let declaredFiles = 0;
1823
+ const files = unzipSync(input.archive, {
1824
+ filter(file) {
1825
+ const name = safeEntryName(file.name);
1826
+ if (seen.has(name)) {
1827
+ throw new SakupaError("validation_failed", `Duplicate recovery archive path: ${name}`);
1828
+ }
1829
+ seen.add(name);
1830
+ declaredFiles += 1;
1831
+ declaredBytes += file.originalSize;
1832
+ if (declaredFiles > input.expectedFiles || declaredFiles > MAX_FILE_COUNT || declaredBytes > input.expectedBytes || declaredBytes > PAID_SITE_MAX_TOTAL_BYTES) {
1833
+ throw new SakupaError("validation_failed", "Recovery archive exceeds declared site limits");
1834
+ }
1835
+ return true;
1836
+ }
1837
+ });
1838
+ if (declaredFiles !== input.expectedFiles || declaredBytes !== input.expectedBytes) {
1839
+ throw new SakupaError("validation_failed", "Recovery archive does not match site metadata");
1840
+ }
1841
+ if (await pathExists(outputDir)) {
1842
+ const info = await stat(outputDir);
1843
+ if (info.isDirectory() && await existingOutputMatches(outputDir, files)) {
1844
+ return {
1845
+ outputDir,
1846
+ totalBytes: declaredBytes,
1847
+ fileCount: declaredFiles,
1848
+ alreadyPresent: true
1849
+ };
1850
+ }
1851
+ throw new SakupaError(
1852
+ "state_conflict",
1853
+ `Recovery output already exists with different content: ${outputDir}. Move it aside or choose another outputDir.`
1854
+ );
1855
+ }
1856
+ const tempDir = await mkdtemp(join3(resolve2(input.projectDir), ".sakupa-restore-"));
1857
+ try {
1858
+ let writtenBytes = 0;
1859
+ const entries = Object.entries(files).sort(([a], [b]) => a.localeCompare(b));
1860
+ for (const [rawName, data] of entries) {
1861
+ const name = safeEntryName(rawName);
1862
+ const destination = join3(tempDir, ...name.split("/"));
1863
+ await mkdir(dirname2(destination), { recursive: true });
1864
+ await writeFile(destination, data, { flag: "wx" });
1865
+ writtenBytes += data.byteLength;
1866
+ }
1867
+ if (entries.length !== input.expectedFiles || writtenBytes !== input.expectedBytes) {
1868
+ throw new SakupaError(
1869
+ "validation_failed",
1870
+ "Extracted recovery data does not match site metadata"
1871
+ );
1872
+ }
1873
+ await mkdir(dirname2(outputDir), { recursive: true });
1874
+ await rename(tempDir, outputDir);
1875
+ return {
1876
+ outputDir,
1877
+ totalBytes: writtenBytes,
1878
+ fileCount: entries.length,
1879
+ alreadyPresent: false
1880
+ };
1881
+ } catch (error) {
1882
+ await rm(tempDir, { recursive: true, force: true });
1883
+ throw error;
1884
+ }
1885
+ }
1886
+
1205
1887
  // src/creation-registry.ts
1206
1888
  import { existsSync as existsSync2, mkdirSync as mkdirSync2, readFileSync as readFileSync2, writeFileSync as writeFileSync2 } from "node:fs";
1207
1889
  import { homedir } from "node:os";
1208
- import { dirname as dirname2, join as join3 } from "node:path";
1890
+ import { dirname as dirname3, join as join4 } from "node:path";
1209
1891
  var RECENT_WINDOW_MS = FREE_SITE_TTL_HOURS * 60 * 60 * 1e3;
1210
1892
  function creationRegistryPath() {
1211
1893
  const base = process.env["SAKUPA_STATE_DIR"] ?? homedir();
1212
- return join3(base, ".sakupa", "created-sites.json");
1894
+ return join4(base, ".sakupa", "created-sites.json");
1213
1895
  }
1214
1896
  function readAll() {
1215
1897
  const path = creationRegistryPath();
@@ -1226,7 +1908,7 @@ function readAll() {
1226
1908
  }
1227
1909
  function writeAll(records) {
1228
1910
  const path = creationRegistryPath();
1229
- mkdirSync2(dirname2(path), { recursive: true });
1911
+ mkdirSync2(dirname3(path), { recursive: true });
1230
1912
  writeFileSync2(path, `${JSON.stringify(records, null, 2)}
1231
1913
  `, "utf-8");
1232
1914
  }
@@ -1387,7 +2069,7 @@ var CLIENT_TYPE = "sakupa-mcp";
1387
2069
  import { z as z2 } from "zod";
1388
2070
  import { statSync } from "node:fs";
1389
2071
  import { homedir as homedir2 } from "node:os";
1390
- import { isAbsolute, parse, resolve as resolve2 } from "node:path";
2072
+ import { isAbsolute as isAbsolute2, parse, resolve as resolve3 } from "node:path";
1391
2073
 
1392
2074
  // src/tools/result.ts
1393
2075
  import { z } from "zod";
@@ -1446,21 +2128,21 @@ function withProjectDir(ctx, projectDirArg) {
1446
2128
  "projectDir is REQUIRED on every call: pass the absolute path of the directory the user is CURRENTLY working in. The server never guesses a directory \u2014 a wrong guess once published one project's files over a different project's PAID site."
1447
2129
  );
1448
2130
  }
1449
- if (!isAbsolute(projectDirArg)) {
2131
+ if (!isAbsolute2(projectDirArg)) {
1450
2132
  throw new LocalGuidanceError(
1451
2133
  "invalid_request",
1452
2134
  `projectDir must be an ABSOLUTE path (got "${projectDirArg}"). Pass the full path of the directory the user is currently working in.`
1453
2135
  );
1454
2136
  }
1455
- const dir = resolve2(projectDirArg);
2137
+ const dir = resolve3(projectDirArg);
1456
2138
  if (parse(dir).root === dir || dir === homedir2()) {
1457
2139
  throw new LocalGuidanceError(
1458
2140
  "invalid_request",
1459
2141
  `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.`
1460
2142
  );
1461
2143
  }
1462
- const stat = statSync(dir, { throwIfNoEntry: false });
1463
- if (!stat?.isDirectory()) {
2144
+ const stat2 = statSync(dir, { throwIfNoEntry: false });
2145
+ if (!stat2?.isDirectory()) {
1464
2146
  throw new LocalGuidanceError(
1465
2147
  "invalid_request",
1466
2148
  `projectDir "${dir}" does not exist or is not a directory. Pass the absolute path of the directory the user is currently working in.`
@@ -1598,7 +2280,7 @@ function ensureUploadSizeWithinLimits(manifest, isFirstFreeDeploy) {
1598
2280
  async function buildHashedManifest(files, outputAbs) {
1599
2281
  const manifest = [];
1600
2282
  for (const file of files) {
1601
- const bytes = new Uint8Array(await fs2.readFile(join4(outputAbs, file.path)));
2283
+ const bytes = new Uint8Array(await fs2.readFile(join5(outputAbs, file.path)));
1602
2284
  manifest.push({ path: file.path, size: file.size, contentHash: await sha256Hex(bytes) });
1603
2285
  }
1604
2286
  return manifest;
@@ -1617,7 +2299,7 @@ async function uploadAll(ctx, targets, files, outputAbs) {
1617
2299
  `No local file matches upload target "${target.path}"; aborting upload.`
1618
2300
  );
1619
2301
  }
1620
- const bytes = new Uint8Array(await fs2.readFile(join4(outputAbs, match.path)));
2302
+ const bytes = new Uint8Array(await fs2.readFile(join5(outputAbs, match.path)));
1621
2303
  if (bytes.byteLength !== match.size) {
1622
2304
  throw new SakupaError(
1623
2305
  "validation_failed",
@@ -1661,15 +2343,15 @@ ${block}`, checklist: toDnsChecklist(diag.checks) };
1661
2343
  }
1662
2344
  }
1663
2345
  function projectRootAbove(projectDir) {
1664
- if (existsSync3(join4(projectDir, "package.json"))) return null;
1665
- return findAncestor(projectDir, (dir) => existsSync3(join4(dir, "package.json")), 4);
2346
+ if (existsSync3(join5(projectDir, "package.json"))) return null;
2347
+ return findAncestor(projectDir, (dir) => existsSync3(join5(dir, "package.json")), 4);
1666
2348
  }
1667
2349
  function findNeighborBinding(projectDir, outputRel) {
1668
2350
  const bound = (dir) => loadSiteFile(dir).kind !== "absent";
1669
2351
  const above = findAncestor(projectDir, bound, 3);
1670
2352
  if (above) return above;
1671
2353
  if (outputRel && outputRel !== ".") {
1672
- const outputAbs = resolve3(projectDir, outputRel);
2354
+ const outputAbs = resolve4(projectDir, outputRel);
1673
2355
  if (bound(outputAbs)) return outputAbs;
1674
2356
  }
1675
2357
  return null;
@@ -1752,7 +2434,7 @@ Next action: ${analysis.suggestedNextAction}`,
1752
2434
  return notDeployableResult(analysis);
1753
2435
  }
1754
2436
  const files = analysis.files;
1755
- const outputAbs = resolve3(ctx.projectDir, analysis.recommendedOutputDir ?? ".");
2437
+ const outputAbs = resolve4(ctx.projectDir, analysis.recommendedOutputDir ?? ".");
1756
2438
  const manifest = await buildHashedManifest(files, outputAbs);
1757
2439
  const siteFileState = loadSiteFile(ctx.projectDir);
1758
2440
  if (siteFileState.kind === "corrupted") {
@@ -2229,25 +2911,131 @@ Full status:`, res);
2229
2911
  server.registerTool(
2230
2912
  "recover",
2231
2913
  {
2232
- 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).",
2914
+ 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.",
2233
2915
  outputSchema: STRUCTURED_TOOL_OUTPUT_SCHEMA,
2234
2916
  annotations: { readOnlyHint: false, destructiveHint: true, openWorldHint: true },
2235
2917
  inputSchema: {
2236
2918
  projectDir: projectDirInput,
2237
- action: z3.enum(["start", "status", "complete"]),
2919
+ action: z3.enum(["start", "status", "complete", "download"]),
2238
2920
  hostname: z3.string().optional().describe("Required for start."),
2239
- verificationId: z3.string().optional().describe("Required for status or complete."),
2921
+ verificationId: z3.string().optional().describe("For status or complete; inferred from local recovery state when omitted."),
2922
+ outputDir: z3.string().optional().describe("Relative extraction directory for complete/download (default: html)."),
2240
2923
  preserveExistingCredentials: z3.boolean().optional().describe("Explicitly keep old local credentials working (default: revoke them all).")
2241
2924
  }
2242
2925
  },
2243
2926
  async (args) => {
2244
2927
  try {
2245
2928
  const ctx = withProjectDir(baseCtx, args.projectDir);
2929
+ const localSite = loadSiteFile(ctx.projectDir);
2930
+ const localCredentialIsActive = async () => {
2931
+ if (localSite.kind !== "ok") return false;
2932
+ try {
2933
+ await ctx.client.getSiteStatus(localSite.file.siteId, localSite.file.credential);
2934
+ return true;
2935
+ } catch (error) {
2936
+ if (isSakupaError(error) && error.code === "unauthorized") return false;
2937
+ throw error;
2938
+ }
2939
+ };
2940
+ const download = async () => {
2941
+ const site = requireSiteFile(ctx);
2942
+ const archive = await ctx.client.getSiteArchive(site.siteId, site.credential);
2943
+ const bytes = await ctx.client.downloadArchive(archive.archiveUrl);
2944
+ const extracted = await extractRecoveryArchive({
2945
+ projectDir: ctx.projectDir,
2946
+ ...args.outputDir !== void 0 ? { outputDir: args.outputDir } : {},
2947
+ archive: bytes,
2948
+ expectedBytes: archive.totalBytes,
2949
+ expectedFiles: archive.fileCount
2950
+ });
2951
+ deleteRecoveryFile(ctx.projectDir);
2952
+ return { archive, extracted };
2953
+ };
2954
+ if (args.action === "download") {
2955
+ const { archive, extracted } = await download();
2956
+ return text(
2957
+ "recovery_content_downloaded",
2958
+ `Recovered site content with the credential in .sakupa/site.json.
2959
+ Output directory: ${extracted.outputDir}
2960
+ Files: ${extracted.fileCount}; bytes: ${extracted.totalBytes}
2961
+ No DNS verification was started or repeated.`,
2962
+ {
2963
+ siteId: archive.siteId,
2964
+ outputDir: extracted.outputDir,
2965
+ fileCount: extracted.fileCount,
2966
+ totalBytes: extracted.totalBytes,
2967
+ alreadyPresent: extracted.alreadyPresent,
2968
+ credentialUsedFromLocalFile: true,
2969
+ dnsVerificationRepeated: false
2970
+ }
2971
+ );
2972
+ }
2246
2973
  if (args.action === "start") {
2974
+ if (localSite.kind === "ok" && await localCredentialIsActive()) {
2975
+ return structuredToolResult({
2976
+ schemaVersion: 1,
2977
+ outcome: "completed",
2978
+ resultCode: "recovery_credential_already_present",
2979
+ summary: "A valid .sakupa/site.json already exists. Management authority is restored; do not start another TXT recovery. Continue with recover download.",
2980
+ data: {
2981
+ siteId: localSite.file.siteId,
2982
+ credentialStoredLocally: true,
2983
+ dnsVerificationRepeated: false
2984
+ },
2985
+ nextActions: [
2986
+ {
2987
+ tool: "recover",
2988
+ arguments: {
2989
+ projectDir: ctx.projectDir,
2990
+ action: "download",
2991
+ outputDir: args.outputDir ?? "html"
2992
+ },
2993
+ allowed: true
2994
+ }
2995
+ ]
2996
+ });
2997
+ }
2998
+ if (localSite.kind === "corrupted") {
2999
+ throw new SakupaError(
3000
+ "state_conflict",
3001
+ `Cannot start recovery while .sakupa/site.json is damaged: ${localSite.problem}`
3002
+ );
3003
+ }
3004
+ const pending2 = loadRecoveryFile(ctx.projectDir);
3005
+ if (pending2) {
3006
+ return structuredToolResult({
3007
+ schemaVersion: 1,
3008
+ outcome: "waiting_user",
3009
+ resultCode: "domain_recovery_local_state_found",
3010
+ summary: "A resumable DNS recovery already exists in this project. Do not create another TXT challenge; continue the stored verification.",
3011
+ data: { verificationId: pending2.verificationId, resumable: true },
3012
+ nextActions: [
3013
+ {
3014
+ tool: "recover",
3015
+ arguments: {
3016
+ projectDir: ctx.projectDir,
3017
+ action: "status",
3018
+ verificationId: pending2.verificationId
3019
+ },
3020
+ allowed: true
3021
+ }
3022
+ ]
3023
+ });
3024
+ }
2247
3025
  if (!args.hostname) {
2248
3026
  throw new SakupaError("invalid_request", "hostname is required for start");
2249
3027
  }
2250
- const res2 = await ctx.client.recoverDomain({ hostname: args.hostname });
3028
+ const credential = generateCredential();
3029
+ const credentialFingerprint = await sha256Hex(new TextEncoder().encode(credential));
3030
+ const res2 = await ctx.client.recoverDomain({
3031
+ hostname: args.hostname,
3032
+ credentialFingerprint
3033
+ });
3034
+ writeRecoveryFile(ctx.projectDir, {
3035
+ verificationId: res2.verificationId,
3036
+ credential,
3037
+ createdAt: (/* @__PURE__ */ new Date()).toISOString()
3038
+ });
2251
3039
  const txtShort = shortHostFor(res2.verificationRecord.name, res2.apexDomain);
2252
3040
  return text(
2253
3041
  "domain_recovery_started",
@@ -2281,19 +3069,48 @@ After the DNS record resolves, re-run recover with verificationId: "${res2.verif
2281
3069
  value: res2.verificationRecord.value
2282
3070
  }
2283
3071
  ],
2284
- revokesPreviousCredentialsByDefault: true
3072
+ revokesPreviousCredentialsByDefault: true,
3073
+ localRecoveryStateStored: true
2285
3074
  },
2286
3075
  "waiting_user"
2287
3076
  );
2288
3077
  }
2289
- if (!args.verificationId) {
3078
+ if (localSite.kind === "ok" && args.action === "status" && await localCredentialIsActive()) {
3079
+ return structuredToolResult({
3080
+ schemaVersion: 1,
3081
+ outcome: "completed",
3082
+ resultCode: "recovery_credential_already_present",
3083
+ summary: "The recovered credential is already stored in .sakupa/site.json. Resume content download with the local key; DNS recovery must not be repeated.",
3084
+ data: { siteId: localSite.file.siteId, credentialStoredLocally: true },
3085
+ nextActions: [
3086
+ {
3087
+ tool: "recover",
3088
+ arguments: {
3089
+ projectDir: ctx.projectDir,
3090
+ action: "download",
3091
+ outputDir: args.outputDir ?? "html"
3092
+ },
3093
+ allowed: true
3094
+ }
3095
+ ]
3096
+ });
3097
+ }
3098
+ const pending = loadRecoveryFile(ctx.projectDir);
3099
+ const verificationId = args.verificationId ?? pending?.verificationId;
3100
+ if (!verificationId) {
2290
3101
  throw new SakupaError(
2291
3102
  "invalid_request",
2292
- "verificationId is required for status or complete"
3103
+ "verificationId is required because no local resumable recovery state exists"
3104
+ );
3105
+ }
3106
+ if (pending && pending.verificationId !== verificationId) {
3107
+ throw new SakupaError(
3108
+ "state_conflict",
3109
+ "The supplied verificationId differs from this project\u2019s stored recovery state."
2293
3110
  );
2294
3111
  }
2295
3112
  if (args.action === "status") {
2296
- const res2 = await ctx.client.getRecoveryStatus(args.verificationId);
3113
+ const res2 = await ctx.client.getRecoveryStatus(verificationId);
2297
3114
  return structuredToolResult({
2298
3115
  schemaVersion: 1,
2299
3116
  outcome: res2.status === "expired" ? "expired" : res2.readyToComplete ? "completed" : "pending_provider",
@@ -2303,14 +3120,29 @@ After the DNS record resolves, re-run recover with verificationId: "${res2.verif
2303
3120
  nextActions: [
2304
3121
  {
2305
3122
  tool: "recover",
2306
- arguments: { action: "complete", verificationId: args.verificationId },
3123
+ arguments: {
3124
+ projectDir: ctx.projectDir,
3125
+ action: "complete",
3126
+ verificationId,
3127
+ outputDir: args.outputDir ?? "html"
3128
+ },
2307
3129
  allowed: res2.readyToComplete,
2308
3130
  ...res2.readyToComplete ? {} : { reasonCode: res2.status }
2309
3131
  }
2310
3132
  ]
2311
3133
  });
2312
3134
  }
2313
- const res = await ctx.client.completeRecovery(args.verificationId, {
3135
+ const recoveryState = pending ?? (() => {
3136
+ const created = {
3137
+ verificationId,
3138
+ credential: generateCredential(),
3139
+ createdAt: (/* @__PURE__ */ new Date()).toISOString()
3140
+ };
3141
+ writeRecoveryFile(ctx.projectDir, created);
3142
+ return created;
3143
+ })();
3144
+ const res = await ctx.client.completeRecovery(verificationId, {
3145
+ credential: recoveryState.credential,
2314
3146
  ...args.preserveExistingCredentials !== void 0 ? { preserveExistingCredentials: args.preserveExistingCredentials } : {}
2315
3147
  });
2316
3148
  writeSiteFile(
@@ -2318,31 +3150,63 @@ After the DNS record resolves, re-run recover with verificationId: "${res2.verif
2318
3150
  {
2319
3151
  siteId: res.siteId,
2320
3152
  ...res.boundHostnames[0] !== void 0 ? { boundDomain: res.boundHostnames[0] } : {},
2321
- credential: res.credential,
3153
+ credential: recoveryState.credential,
2322
3154
  createdAt: (/* @__PURE__ */ new Date()).toISOString(),
2323
3155
  apiBaseUrl: ctx.apiBaseUrl
2324
3156
  },
2325
3157
  { allowReplace: true }
2326
3158
  );
2327
- return text(
2328
- "domain_recovery_completed",
2329
- `Recovery complete.
3159
+ deleteRecoveryFile(ctx.projectDir);
3160
+ try {
3161
+ const { archive, extracted } = await download();
3162
+ return text(
3163
+ "domain_recovery_completed",
3164
+ `Recovery complete.
2330
3165
  Site: ${res.siteId} (hostnames: ${res.boundHostnames.join(", ") || "(none)"})
2331
3166
  Previous credentials revoked: ${res.revokedPreviousCredentials ? "YES" : "no (preserved on request)"}
2332
3167
 
2333
- A NEW management credential was written to .sakupa/site.json in this project \u2014 this project now manages the site.
2334
- ` + credentialGitReminder(ctx.projectDir) + `
2335
- Download the current site content (signed URL):
2336
- ${res.archiveUrl}`,
2337
- {
2338
- siteId: res.siteId,
2339
- boundHostnames: res.boundHostnames,
2340
- revokedPreviousCredentials: res.revokedPreviousCredentials,
2341
- archiveUrl: res.archiveUrl,
2342
- archiveExpiresAt: res.archiveExpiresAt,
2343
- credentialStoredLocally: true
2344
- }
2345
- );
3168
+ The NEW management credential was written to .sakupa/site.json before content download. The site archive was then requested with that key and extracted.
3169
+ Output directory: ${extracted.outputDir}
3170
+ Files: ${extracted.fileCount}; bytes: ${extracted.totalBytes}
3171
+ ` + credentialGitReminder(ctx.projectDir),
3172
+ {
3173
+ siteId: res.siteId,
3174
+ boundHostnames: res.boundHostnames,
3175
+ revokedPreviousCredentials: res.revokedPreviousCredentials,
3176
+ archiveExpiresAt: archive.archiveExpiresAt,
3177
+ outputDir: extracted.outputDir,
3178
+ fileCount: extracted.fileCount,
3179
+ totalBytes: extracted.totalBytes,
3180
+ alreadyPresent: extracted.alreadyPresent,
3181
+ credentialStoredLocally: true,
3182
+ archiveRequestedWithRecoveredCredential: true
3183
+ }
3184
+ );
3185
+ } catch (downloadError) {
3186
+ return structuredToolResult({
3187
+ schemaVersion: 1,
3188
+ outcome: "waiting_user",
3189
+ resultCode: "recovery_credential_restored_download_pending",
3190
+ 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.`,
3191
+ data: {
3192
+ siteId: res.siteId,
3193
+ credentialStoredLocally: true,
3194
+ contentDownloadCompleted: false,
3195
+ dnsVerificationRepeated: false
3196
+ },
3197
+ nextActions: [
3198
+ {
3199
+ tool: "recover",
3200
+ arguments: {
3201
+ projectDir: ctx.projectDir,
3202
+ action: "download",
3203
+ outputDir: args.outputDir ?? "html"
3204
+ },
3205
+ allowed: true
3206
+ }
3207
+ ]
3208
+ });
3209
+ }
2346
3210
  } catch (e) {
2347
3211
  return toolError(e);
2348
3212
  }
@@ -2700,6 +3564,29 @@ var FetchTransport = class {
2700
3564
  );
2701
3565
  }
2702
3566
  }
3567
+ async download(url) {
3568
+ let target;
3569
+ try {
3570
+ target = new URL(url);
3571
+ } catch {
3572
+ throw new SakupaError("invalid_request", "Archive download URL is invalid");
3573
+ }
3574
+ if (target.origin !== new URL(this.baseUrl).origin) {
3575
+ throw new SakupaError("forbidden", "Archive download URL is outside the Sakupa API origin");
3576
+ }
3577
+ const res = await fetch(target, {
3578
+ method: "GET",
3579
+ headers: this.testAccessHeadersFor(target.toString())
3580
+ });
3581
+ if (!res.ok) {
3582
+ const detail = await res.text().catch(() => "");
3583
+ throw new SakupaError(
3584
+ res.status === 429 || res.status >= 500 ? "internal" : "validation_failed",
3585
+ `Archive download failed with HTTP ${res.status}${detail ? `: ${detail.slice(0, 200)}` : ""}`
3586
+ );
3587
+ }
3588
+ return new Uint8Array(await res.arrayBuffer());
3589
+ }
2703
3590
  };
2704
3591
 
2705
3592
  // src/server.ts
@@ -2730,6 +3617,8 @@ Workflow:
2730
3617
  Plan changes are confirmed only on Stripe and synchronized by Stripe webhook.
2731
3618
  A cancellation keeps the site paid through the current period. Sakupa reverts it to a free
2732
3619
  24h site and removes paid data after Stripe sends the signed final-cancellation webhook.
3620
+ Recovery writes the new local credential before downloading content. If a session stops after
3621
+ .sakupa/site.json exists, NEVER repeat DNS recovery: resume with recover action "download".
2733
3622
  5. support (subscribed sites) opens a support ticket; report sends a
2734
3623
  sanitized diagnostic report after the user explicitly confirms it.
2735
3624
 
@@ -2796,7 +3685,7 @@ async function main() {
2796
3685
  `[sakupa-mcp] v${MCP_VERSION} connected (api: ${config.apiBaseUrl}; every tool call requires projectDir)`
2797
3686
  );
2798
3687
  }
2799
- main().catch((err) => {
2800
- console.error("[sakupa-mcp] fatal:", err instanceof Error ? err.message : err);
3688
+ main().catch((err2) => {
3689
+ console.error("[sakupa-mcp] fatal:", err2 instanceof Error ? err2.message : err2);
2801
3690
  process.exit(1);
2802
3691
  });