@sakupa/mcp 1.5.1 → 1.6.0

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