@sakupa/mcp 0.7.32 → 0.7.34

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 +535 -275
  2. package/dist/index.js +412 -224
  3. package/package.json +1 -1
package/dist/index.js CHANGED
@@ -124,7 +124,7 @@ var FORBIDDEN_PATH_SEGMENTS = [
124
124
  var ALLOWED_HIDDEN_PATHS = [".well-known/"];
125
125
 
126
126
  // ../core/dist/domain/version.js
127
- var SAKUPA_MCP_VERSION = "0.7.32";
127
+ var SAKUPA_MCP_VERSION = "0.7.34";
128
128
 
129
129
  // ../core/dist/domain/errors.js
130
130
  var HTTP_STATUS = {
@@ -1217,7 +1217,7 @@ function normalizeOutputDir(outputDir) {
1217
1217
  return normalized === "" ? "." : normalized;
1218
1218
  }
1219
1219
  async function analyzeProject(projectDir, opts = {}) {
1220
- const root = resolve(projectDir);
1220
+ const root = await fs.realpath(resolve(projectDir));
1221
1221
  const pkg = await readPackageJson(root);
1222
1222
  const detection = await detectFramework(root, pkg);
1223
1223
  const ssrRisks = [...detection?.ssrRisks ?? []];
@@ -1233,6 +1233,16 @@ async function analyzeProject(projectDir, opts = {}) {
1233
1233
  outputDirExists = false;
1234
1234
  } else {
1235
1235
  outputDirExists = await isDirectory(abs) || outputDirRel === "." && await isDirectory(root);
1236
+ if (outputDirExists) {
1237
+ try {
1238
+ const physical = await fs.realpath(abs);
1239
+ if (physical !== root && !physical.startsWith(root + sep)) {
1240
+ outputDirExists = false;
1241
+ }
1242
+ } catch {
1243
+ outputDirExists = false;
1244
+ }
1245
+ }
1236
1246
  }
1237
1247
  } else if (detection) {
1238
1248
  for (const candidate of detection.outputCandidates) {
@@ -1354,11 +1364,186 @@ async function analyzeProject(projectDir, opts = {}) {
1354
1364
  };
1355
1365
  }
1356
1366
 
1357
- // src/tools/context.ts
1358
- import { z as z2 } from "zod";
1359
- import { statSync } from "node:fs";
1367
+ // src/project-root.ts
1368
+ import { randomUUID } from "node:crypto";
1369
+ import {
1370
+ chmodSync as chmodSync2,
1371
+ existsSync as existsSync2,
1372
+ lstatSync,
1373
+ mkdirSync as mkdirSync2,
1374
+ readFileSync as readFileSync2,
1375
+ realpathSync,
1376
+ renameSync,
1377
+ statSync,
1378
+ unlinkSync,
1379
+ writeFileSync as writeFileSync2
1380
+ } from "node:fs";
1360
1381
  import { homedir } from "node:os";
1361
- import { isAbsolute, parse, resolve as resolve2 } from "node:path";
1382
+ import { isAbsolute, join as join3, parse, relative, resolve as resolve2, sep as sep2 } from "node:path";
1383
+ var SAKUPA_DIR = ".sakupa";
1384
+ var PROJECT_FILE = "project.json";
1385
+ var PROJECT_SCHEMA_VERSION = 1;
1386
+ var ProjectRootError = class extends Error {
1387
+ code;
1388
+ constructor(code, message) {
1389
+ super(message);
1390
+ this.name = "ProjectRootError";
1391
+ this.code = code;
1392
+ }
1393
+ };
1394
+ function projectMarkerPath(projectDir) {
1395
+ return join3(projectDir, SAKUPA_DIR, PROJECT_FILE);
1396
+ }
1397
+ function loadProjectMarker(projectDir) {
1398
+ const path = projectMarkerPath(projectDir);
1399
+ if (!existsSync2(path)) return { kind: "absent" };
1400
+ let parsed;
1401
+ try {
1402
+ parsed = JSON.parse(readFileSync2(path, "utf8"));
1403
+ } catch (error) {
1404
+ return {
1405
+ kind: "corrupted",
1406
+ problem: `the file cannot be read as JSON (${error instanceof Error ? error.message : String(error)})`
1407
+ };
1408
+ }
1409
+ if (typeof parsed !== "object" || parsed === null) {
1410
+ return { kind: "corrupted", problem: "the file does not contain a JSON object" };
1411
+ }
1412
+ const record = parsed;
1413
+ if (record.schemaVersion !== PROJECT_SCHEMA_VERSION) {
1414
+ return {
1415
+ kind: "corrupted",
1416
+ problem: `unsupported schemaVersion ${String(record.schemaVersion)}`
1417
+ };
1418
+ }
1419
+ if (typeof record.projectId !== "string" || !isUuid(record.projectId)) {
1420
+ return { kind: "corrupted", problem: "projectId is missing or is not a UUID" };
1421
+ }
1422
+ if (typeof record.createdAt !== "string" || !Number.isFinite(Date.parse(record.createdAt))) {
1423
+ return { kind: "corrupted", problem: "createdAt is missing or invalid" };
1424
+ }
1425
+ if (record.outputDir !== void 0 && (typeof record.outputDir !== "string" || !isSafeRelativeOutput(record.outputDir))) {
1426
+ return { kind: "corrupted", problem: "outputDir is not a safe project-relative path" };
1427
+ }
1428
+ return {
1429
+ kind: "ok",
1430
+ marker: {
1431
+ schemaVersion: PROJECT_SCHEMA_VERSION,
1432
+ projectId: record.projectId,
1433
+ createdAt: record.createdAt,
1434
+ ...record.outputDir !== void 0 ? { outputDir: normalizeRelative(record.outputDir) } : {}
1435
+ }
1436
+ };
1437
+ }
1438
+ function resolveLockedProjectRoot(projectDir) {
1439
+ const canonical = canonicalProjectDirectory(projectDir);
1440
+ assertSafeProjectRoot(canonical);
1441
+ const markerState = loadProjectMarker(canonical);
1442
+ if (markerState.kind === "corrupted") {
1443
+ throw new ProjectRootError(
1444
+ "corrupted_marker",
1445
+ `Sakupa project marker ${projectMarkerPath(canonical)} is damaged: ${markerState.problem}.`
1446
+ );
1447
+ }
1448
+ if (markerState.kind === "absent") {
1449
+ throw new ProjectRootError(
1450
+ "not_initialized",
1451
+ `The MCP working directory ${canonical} is not initialized. Run \`npx -y @sakupa/mcp@latest init\` in that directory; do not pass a path argument.`
1452
+ );
1453
+ }
1454
+ return {
1455
+ projectDir: canonical,
1456
+ requestedPath: canonical,
1457
+ markerKind: "project",
1458
+ marker: markerState.marker
1459
+ };
1460
+ }
1461
+ function updateProjectOutputDir(projectDir, outputDir) {
1462
+ const canonical = canonicalProjectDirectory(projectDir);
1463
+ const state = loadProjectMarker(canonical);
1464
+ if (state.kind !== "ok") {
1465
+ throw new ProjectRootError(
1466
+ state.kind === "corrupted" ? "corrupted_marker" : "not_initialized",
1467
+ state.kind === "corrupted" ? `Cannot update damaged Sakupa project marker: ${state.problem}.` : `No Sakupa project marker exists in ${canonical}.`
1468
+ );
1469
+ }
1470
+ if (!isSafeRelativeOutput(outputDir)) {
1471
+ throw new ProjectRootError(
1472
+ "unsafe_path",
1473
+ `Output directory "${outputDir}" must stay inside the initialized Sakupa project.`
1474
+ );
1475
+ }
1476
+ const marker = {
1477
+ ...state.marker,
1478
+ outputDir: normalizeRelative(outputDir)
1479
+ };
1480
+ writeMarkerAtomically(canonical, marker);
1481
+ return marker;
1482
+ }
1483
+ function canonicalProjectDirectory(path) {
1484
+ const canonical = canonicalExistingPath(resolve2(path));
1485
+ if (!statSync(canonical).isDirectory()) {
1486
+ throw new ProjectRootError("invalid_path", `Project path ${canonical} is not a directory.`);
1487
+ }
1488
+ return canonical;
1489
+ }
1490
+ function canonicalExistingPath(path) {
1491
+ try {
1492
+ const stat2 = lstatSync(path, { throwIfNoEntry: false });
1493
+ if (!stat2) {
1494
+ throw new ProjectRootError("invalid_path", `Project path ${path} does not exist.`);
1495
+ }
1496
+ return realpathSync(path);
1497
+ } catch (error) {
1498
+ if (error instanceof ProjectRootError) throw error;
1499
+ throw new ProjectRootError(
1500
+ "invalid_path",
1501
+ `Project path ${path} cannot be resolved (${error instanceof Error ? error.message : String(error)}).`
1502
+ );
1503
+ }
1504
+ }
1505
+ function assertSafeProjectRoot(projectDir) {
1506
+ if (parse(projectDir).root === projectDir || projectDir === realpathSync(homedir())) {
1507
+ throw new ProjectRootError(
1508
+ "unsafe_path",
1509
+ `Refusing to use ${projectDir} as a Sakupa project root; choose a specific project directory.`
1510
+ );
1511
+ }
1512
+ }
1513
+ function isUuid(value) {
1514
+ return /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(value);
1515
+ }
1516
+ function normalizeRelative(path) {
1517
+ const normalized = path.split(sep2).join("/").replace(/^\.\//, "").replace(/\/$/, "");
1518
+ return normalized.length === 0 ? "." : normalized;
1519
+ }
1520
+ function isSafeRelativeOutput(path) {
1521
+ if (path.length === 0 || isAbsolute(path)) return false;
1522
+ const normalized = normalizeRelative(path);
1523
+ if (normalized === ".") return true;
1524
+ const rel = relative("/sakupa-root", resolve2("/sakupa-root", normalized));
1525
+ return rel !== ".." && !rel.startsWith(`..${sep2}`) && !isAbsolute(rel);
1526
+ }
1527
+ function writeMarkerAtomically(projectDir, marker) {
1528
+ const dir = join3(projectDir, SAKUPA_DIR);
1529
+ mkdirSync2(dir, { recursive: true, mode: 448 });
1530
+ const path = projectMarkerPath(projectDir);
1531
+ const temporary = `${path}.${process.pid}.${randomUUID()}.tmp`;
1532
+ try {
1533
+ writeFileSync2(temporary, `${JSON.stringify(marker, null, 2)}
1534
+ `, {
1535
+ encoding: "utf8",
1536
+ mode: 384
1537
+ });
1538
+ renameSync(temporary, path);
1539
+ try {
1540
+ chmodSync2(path, 384);
1541
+ } catch {
1542
+ }
1543
+ } finally {
1544
+ if (existsSync2(temporary)) unlinkSync(temporary);
1545
+ }
1546
+ }
1362
1547
 
1363
1548
  // src/tools/result.ts
1364
1549
  import { z } from "zod";
@@ -1407,37 +1592,25 @@ var LocalGuidanceError = class extends SakupaError {
1407
1592
  super(code, message);
1408
1593
  }
1409
1594
  };
1410
- var projectDirInput = z2.string().describe(
1411
- "REQUIRED on every call: absolute path of the user's PROJECT ROOT \u2014 the folder the user opened/works in (for framework projects: where package.json lives, NEVER the build-output subfolder like dist/out; the analyzer locates the output automatically). .sakupa/site.json lives here, so PASS THE SAME DIRECTORY EVERY TIME for the same project. Only YOU know which directory the user is in \u2014 the server never guesses and refuses calls without it."
1412
- );
1413
- function withProjectDir(ctx, projectDirArg) {
1414
- if (projectDirArg === void 0) {
1415
- throw new LocalGuidanceError(
1416
- "invalid_request",
1417
- "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."
1418
- );
1419
- }
1420
- if (!isAbsolute(projectDirArg)) {
1421
- throw new LocalGuidanceError(
1422
- "invalid_request",
1423
- `projectDir must be an ABSOLUTE path (got "${projectDirArg}"). Pass the full path of the directory the user is currently working in.`
1424
- );
1425
- }
1426
- const dir = resolve2(projectDirArg);
1427
- if (parse(dir).root === dir || dir === homedir()) {
1428
- throw new LocalGuidanceError(
1429
- "invalid_request",
1430
- `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.`
1431
- );
1432
- }
1433
- const stat2 = statSync(dir, { throwIfNoEntry: false });
1434
- if (!stat2?.isDirectory()) {
1435
- throw new LocalGuidanceError(
1436
- "invalid_request",
1437
- `projectDir "${dir}" does not exist or is not a directory. Pass the absolute path of the directory the user is currently working in.`
1438
- );
1595
+ function withProjectDir(ctx) {
1596
+ try {
1597
+ const resolved = resolveLockedProjectRoot(ctx.projectDir);
1598
+ return {
1599
+ ...ctx,
1600
+ projectDir: resolved.projectDir,
1601
+ requestedPath: resolved.requestedPath,
1602
+ markerKind: resolved.markerKind,
1603
+ projectMarker: resolved.marker
1604
+ };
1605
+ } catch (error) {
1606
+ if (error instanceof ProjectRootError) {
1607
+ throw new LocalGuidanceError(
1608
+ error.code === "not_initialized" ? "not_found" : "invalid_request",
1609
+ error.message
1610
+ );
1611
+ }
1612
+ throw error;
1439
1613
  }
1440
- return { ...ctx, projectDir: dir };
1441
1614
  }
1442
1615
  function requireSiteFile(ctx) {
1443
1616
  const state = loadSiteFile(ctx.projectDir);
@@ -1450,7 +1623,7 @@ function requireSiteFile(ctx) {
1450
1623
  if (state.kind === "absent") {
1451
1624
  throw new LocalGuidanceError(
1452
1625
  "not_found",
1453
- `No .sakupa/site.json found in ${ctx.projectDir} \u2014 this directory has no Sakupa site binding. If you meant to manage (delete/status/bind) an EXISTING site, re-run this tool with projectDir set to THAT site's own directory (each site's binding lives in its own folder). To publish THIS directory as a new site, run deploy. If this was a paid custom-domain site whose project file was lost, use recover.`
1626
+ `No .sakupa/site.json found in ${ctx.projectDir} \u2014 this directory has no Sakupa site binding. If you meant to manage a different existing site, open that project as the AI tool workspace and start its Sakupa MCP process there. To publish THIS directory as a new site, run deploy. If this was a paid custom-domain site whose project file was lost, use recover.`
1454
1627
  );
1455
1628
  }
1456
1629
  return state.file;
@@ -1494,14 +1667,15 @@ function toolError(e) {
1494
1667
  }
1495
1668
 
1496
1669
  // src/tools/definitions.ts
1497
- import { randomUUID } from "node:crypto";
1498
- import { existsSync as existsSync3, promises as fs2 } from "node:fs";
1499
- import { join as join5, relative as relative2, resolve as resolve4, sep as sep3 } from "node:path";
1500
- import { z as z3 } from "zod";
1670
+ import { randomUUID as randomUUID2 } from "node:crypto";
1671
+ import { promises as fs2 } from "node:fs";
1672
+ import { join as join6, resolve as resolve4 } from "node:path";
1673
+ import { z as z2 } from "zod";
1501
1674
 
1502
1675
  // src/recovery-archive.ts
1676
+ import { existsSync as existsSync3, realpathSync as realpathSync2 } from "node:fs";
1503
1677
  import { mkdtemp, mkdir, readFile, readdir, rename, rm, stat, writeFile } from "node:fs/promises";
1504
- import { dirname as dirname2, isAbsolute as isAbsolute2, join as join3, relative, resolve as resolve3, sep as sep2 } from "node:path";
1678
+ import { dirname as dirname2, isAbsolute as isAbsolute2, join as join4, relative as relative2, resolve as resolve3, sep as sep3 } from "node:path";
1505
1679
 
1506
1680
  // ../../node_modules/fflate/esm/index.mjs
1507
1681
  import { createRequire } from "module";
@@ -1920,15 +2094,15 @@ function strFromU8(dat, latin1) {
1920
2094
  var slzh = function(d, b) {
1921
2095
  return b + 30 + b2(d, b + 26) + b2(d, b + 28);
1922
2096
  };
1923
- var zh = function(d, b, z6) {
2097
+ var zh = function(d, b, z5) {
1924
2098
  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;
1925
- 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];
2099
+ var _a2 = z64hs(d, es, efl, z5, b4(d, b + 20), b4(d, b + 24), b4(d, b + 42)), sc = _a2[0], su = _a2[1], off = _a2[2];
1926
2100
  return [b2(d, b + 10), sc, su, fn, es + efl + b2(d, b + 32), off];
1927
2101
  };
1928
- var z64hs = function(d, b, l, z6, sc, su, off) {
2102
+ var z64hs = function(d, b, l, z5, sc, su, off) {
1929
2103
  var nsc = sc == 4294967295, nsu = su == 4294967295, noff = off == 4294967295, e = b + l;
1930
2104
  var nf = nsc + nsu + noff;
1931
- if (z6 && nf) {
2105
+ if (z5 && nf) {
1932
2106
  for (; b + 4 < e; b += 4 + b2(d, b + 2)) {
1933
2107
  if (b2(d, b) == 1) {
1934
2108
  return [
@@ -1939,7 +2113,7 @@ var z64hs = function(d, b, l, z6, sc, su, off) {
1939
2113
  ];
1940
2114
  }
1941
2115
  }
1942
- if (z6 < 2)
2116
+ if (z5 < 2)
1943
2117
  err(13);
1944
2118
  }
1945
2119
  return [sc, su, off, 0];
@@ -1956,18 +2130,18 @@ function unzipSync(data, opts) {
1956
2130
  if (!c)
1957
2131
  return {};
1958
2132
  var o = b4(data, e + 16);
1959
- var z6 = b4(data, e - 20) == 117853008;
1960
- if (z6) {
2133
+ var z5 = b4(data, e - 20) == 117853008;
2134
+ if (z5) {
1961
2135
  var ze = b4(data, e - 12);
1962
- z6 = b4(data, ze) == 101075792;
1963
- if (z6) {
2136
+ z5 = b4(data, ze) == 101075792;
2137
+ if (z5) {
1964
2138
  c = b4(data, ze + 32);
1965
2139
  o = b4(data, ze + 48);
1966
2140
  }
1967
2141
  }
1968
2142
  var fltr = opts && opts.filter;
1969
2143
  for (var i = 0; i < c; ++i) {
1970
- 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);
2144
+ var _a2 = zh(data, o, z5), c_2 = _a2[0], sc = _a2[1], su = _a2[2], fn = _a2[3], no = _a2[4], off = _a2[5], b = slzh(data, off);
1971
2145
  o = no;
1972
2146
  if (!fltr || fltr({
1973
2147
  name: fn,
@@ -1991,15 +2165,30 @@ function safeOutputPath(projectDir, outputDir) {
1991
2165
  if (outputDir.length === 0 || isAbsolute2(outputDir)) {
1992
2166
  throw new SakupaError("invalid_request", "Recovery outputDir must be a relative directory");
1993
2167
  }
1994
- const root = resolve3(projectDir);
2168
+ const root = realpathSync2(resolve3(projectDir));
1995
2169
  const target = resolve3(root, outputDir);
1996
- const rel = relative(root, target);
1997
- if (rel === "" || rel === ".." || rel.startsWith(`..${sep2}`) || isAbsolute2(rel)) {
2170
+ const rel = relative2(root, target);
2171
+ if (rel === "" || rel === ".." || rel.startsWith(`..${sep3}`) || isAbsolute2(rel)) {
1998
2172
  throw new SakupaError("invalid_request", "Recovery outputDir must stay inside projectDir");
1999
2173
  }
2000
- if (rel === ".sakupa" || rel.startsWith(`.sakupa${sep2}`)) {
2174
+ if (rel === ".sakupa" || rel.startsWith(`.sakupa${sep3}`)) {
2001
2175
  throw new SakupaError("invalid_request", "Recovery content cannot be written inside .sakupa");
2002
2176
  }
2177
+ let existingAncestor = target;
2178
+ while (!existsSync3(existingAncestor)) {
2179
+ const parent = dirname2(existingAncestor);
2180
+ if (parent === existingAncestor) break;
2181
+ existingAncestor = parent;
2182
+ }
2183
+ const physicalAncestor = realpathSync2(existingAncestor);
2184
+ const physicalTarget = resolve3(physicalAncestor, relative2(existingAncestor, target));
2185
+ const physicalRel = relative2(root, physicalTarget);
2186
+ if (physicalRel === ".." || physicalRel.startsWith(`..${sep3}`) || isAbsolute2(physicalRel)) {
2187
+ throw new SakupaError(
2188
+ "invalid_request",
2189
+ "Recovery outputDir resolves through a symlink outside projectDir"
2190
+ );
2191
+ }
2003
2192
  return target;
2004
2193
  }
2005
2194
  function safeEntryName(name) {
@@ -2028,9 +2217,9 @@ async function listExistingFiles(root, current = root) {
2028
2217
  if (entry.isSymbolicLink()) {
2029
2218
  throw new SakupaError("state_conflict", `Recovery output contains a symlink: ${entry.name}`);
2030
2219
  }
2031
- const absolute = join3(current, entry.name);
2220
+ const absolute = join4(current, entry.name);
2032
2221
  if (entry.isDirectory()) files.push(...await listExistingFiles(root, absolute));
2033
- else if (entry.isFile()) files.push(relative(root, absolute).split(sep2).join("/"));
2222
+ else if (entry.isFile()) files.push(relative2(root, absolute).split(sep3).join("/"));
2034
2223
  else
2035
2224
  throw new SakupaError(
2036
2225
  "state_conflict",
@@ -2046,7 +2235,7 @@ async function existingOutputMatches(outputDir, files) {
2046
2235
  return false;
2047
2236
  }
2048
2237
  for (const name of expected) {
2049
- const actual = await readFile(join3(outputDir, ...name.split("/")));
2238
+ const actual = await readFile(join4(outputDir, ...name.split("/")));
2050
2239
  const wanted = files[name];
2051
2240
  if (!wanted || actual.byteLength !== wanted.byteLength || !actual.equals(wanted)) return false;
2052
2241
  }
@@ -2056,7 +2245,7 @@ async function extractRecoveryArchive(input) {
2056
2245
  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) {
2057
2246
  throw new SakupaError("validation_failed", "Recovery archive metadata exceeds product limits");
2058
2247
  }
2059
- const outputDir = safeOutputPath(input.projectDir, input.outputDir ?? "html");
2248
+ const outputDir = safeOutputPath(input.projectDir, input.outputDir);
2060
2249
  const maxArchiveBytes = input.expectedBytes + input.expectedFiles * 4096 + 65536;
2061
2250
  if (input.archive.byteLength > maxArchiveBytes) {
2062
2251
  throw new SakupaError("validation_failed", "Recovery archive is larger than its site metadata");
@@ -2097,13 +2286,13 @@ async function extractRecoveryArchive(input) {
2097
2286
  `Recovery output already exists with different content: ${outputDir}. Move it aside or choose another outputDir.`
2098
2287
  );
2099
2288
  }
2100
- const tempDir = await mkdtemp(join3(resolve3(input.projectDir), ".sakupa-restore-"));
2289
+ const tempDir = await mkdtemp(join4(resolve3(input.projectDir), ".sakupa-restore-"));
2101
2290
  try {
2102
2291
  let writtenBytes = 0;
2103
2292
  const entries = Object.entries(files).sort(([a], [b]) => a.localeCompare(b));
2104
2293
  for (const [rawName, data] of entries) {
2105
2294
  const name = safeEntryName(rawName);
2106
- const destination = join3(tempDir, ...name.split("/"));
2295
+ const destination = join4(tempDir, ...name.split("/"));
2107
2296
  await mkdir(dirname2(destination), { recursive: true });
2108
2297
  await writeFile(destination, data, { flag: "wx" });
2109
2298
  writtenBytes += data.byteLength;
@@ -2129,19 +2318,19 @@ async function extractRecoveryArchive(input) {
2129
2318
  }
2130
2319
 
2131
2320
  // src/creation-registry.ts
2132
- import { existsSync as existsSync2, mkdirSync as mkdirSync2, readFileSync as readFileSync2, writeFileSync as writeFileSync2 } from "node:fs";
2321
+ import { existsSync as existsSync4, mkdirSync as mkdirSync3, readFileSync as readFileSync3, writeFileSync as writeFileSync3 } from "node:fs";
2133
2322
  import { homedir as homedir2 } from "node:os";
2134
- import { dirname as dirname3, join as join4 } from "node:path";
2323
+ import { dirname as dirname3, join as join5 } from "node:path";
2135
2324
  var RECENT_WINDOW_MS = FREE_SITE_TTL_HOURS * 60 * 60 * 1e3;
2136
2325
  function creationRegistryPath() {
2137
2326
  const base = process.env["SAKUPA_STATE_DIR"] ?? homedir2();
2138
- return join4(base, ".sakupa", "created-sites.json");
2327
+ return join5(base, ".sakupa", "created-sites.json");
2139
2328
  }
2140
2329
  function readAll() {
2141
2330
  const path = creationRegistryPath();
2142
- if (!existsSync2(path)) return [];
2331
+ if (!existsSync4(path)) return [];
2143
2332
  try {
2144
- const parsed = JSON.parse(readFileSync2(path, "utf-8"));
2333
+ const parsed = JSON.parse(readFileSync3(path, "utf-8"));
2145
2334
  if (!Array.isArray(parsed)) return [];
2146
2335
  return parsed.filter(
2147
2336
  (e) => typeof e === "object" && e !== null && typeof e.siteId === "string" && typeof e.createdAt === "string"
@@ -2152,8 +2341,8 @@ function readAll() {
2152
2341
  }
2153
2342
  function writeAll(records) {
2154
2343
  const path = creationRegistryPath();
2155
- mkdirSync2(dirname3(path), { recursive: true });
2156
- writeFileSync2(path, `${JSON.stringify(records, null, 2)}
2344
+ mkdirSync3(dirname3(path), { recursive: true });
2345
+ writeFileSync3(path, `${JSON.stringify(records, null, 2)}
2157
2346
  `, "utf-8");
2158
2347
  }
2159
2348
  function listRecentCreations(nowMs, apiBaseUrl) {
@@ -2328,12 +2517,12 @@ ${JSON.stringify(obj, null, 2)}`;
2328
2517
  nextActions: []
2329
2518
  });
2330
2519
  }
2331
- var planEnum = z3.enum(["water", "personal", "share", "business"]);
2332
- var severityEnum = z3.enum(["low", "medium", "high", "critical"]);
2520
+ var planEnum = z2.enum(["water", "personal", "share", "business"]);
2521
+ var severityEnum = z2.enum(["low", "medium", "high", "critical"]);
2333
2522
  function planCatalog() {
2334
2523
  return TIER_ORDER.map((p) => `${p} JPY ${tierPriceJpy(p)}/month`).join(", ");
2335
2524
  }
2336
- var ticketCategoryEnum = z3.enum([
2525
+ var ticketCategoryEnum = z2.enum([
2337
2526
  "billing",
2338
2527
  "payment",
2339
2528
  "refund_review",
@@ -2381,7 +2570,7 @@ function ensureUploadSizeWithinLimits(manifest, isFirstFreeDeploy) {
2381
2570
  async function buildHashedManifest(files, outputAbs) {
2382
2571
  const manifest = [];
2383
2572
  for (const file of files) {
2384
- const bytes = new Uint8Array(await fs2.readFile(join5(outputAbs, file.path)));
2573
+ const bytes = new Uint8Array(await fs2.readFile(join6(outputAbs, file.path)));
2385
2574
  manifest.push({ path: file.path, size: file.size, contentHash: await sha256Hex(bytes) });
2386
2575
  }
2387
2576
  return manifest;
@@ -2400,7 +2589,7 @@ async function uploadAll(ctx, targets, files, outputAbs) {
2400
2589
  `No local file matches upload target "${target.path}"; aborting upload.`
2401
2590
  );
2402
2591
  }
2403
- const bytes = new Uint8Array(await fs2.readFile(join5(outputAbs, match.path)));
2592
+ const bytes = new Uint8Array(await fs2.readFile(join6(outputAbs, match.path)));
2404
2593
  if (bytes.byteLength !== match.size) {
2405
2594
  throw new SakupaError(
2406
2595
  "validation_failed",
@@ -2443,21 +2632,6 @@ ${block}`, checklist: toDnsChecklist(diag.checks) };
2443
2632
  };
2444
2633
  }
2445
2634
  }
2446
- function projectRootAbove(projectDir) {
2447
- if (existsSync3(join5(projectDir, "package.json"))) return null;
2448
- const packageRoot = findAncestor(projectDir, (dir) => existsSync3(join5(dir, "package.json")), 4);
2449
- if (packageRoot) {
2450
- return {
2451
- projectRoot: packageRoot,
2452
- outputDir: relative2(packageRoot, projectDir).split(sep3).join("/")
2453
- };
2454
- }
2455
- return null;
2456
- }
2457
- function findNeighborBinding(projectDir) {
2458
- const bound = (dir) => loadSiteFile(dir).kind !== "absent";
2459
- return findAncestor(projectDir, bound, 3);
2460
- }
2461
2635
  function freeSiteCreationBarrier(apiBaseUrl) {
2462
2636
  const recent = listRecentCreations(Date.now(), apiBaseUrl);
2463
2637
  if (recent.length < FREE_ACTIVE_SITES_PER_IP) return null;
@@ -2468,7 +2642,7 @@ function freeSiteCreationBarrier(apiBaseUrl) {
2468
2642
 
2469
2643
  ` + recent.map((r) => `- ${r.url} (project: ${r.projectDir}, created: ${r.createdAt})`).join("\n") + `
2470
2644
 
2471
- How a slot frees up: (1) delete one of the sites above \u2014 run delete with that site's projectDir; its slot frees immediately; (2) every record expires on its own 24 hours after creation; (3) a site that upgrades to a paid plan stops counting the next time any tool sees it. Deleting a project's .sakupa folder does NOT free a slot: this registry lives in the home directory and the server still counts the live site.
2645
+ How a slot frees up: (1) delete one of the sites above \u2014 run delete with that site opened as the AI tool's current project; its slot frees immediately; (2) every record expires on its own 24 hours after creation; (3) a site that upgrades to a paid plan stops counting the next time any tool sees it. Deleting a project's .sakupa folder does NOT free a slot: this registry lives in the home directory and the server still counts the live site.
2472
2646
 
2473
2647
  If this list is stale (sites deleted or subscribed from another machine), remove the local registry file at ${registryPath} and retry \u2014 that only skips this local precheck; the server still enforces the same per-IP limit and is the final authority.`,
2474
2648
  { recentCreations: recent, limit: FREE_ACTIVE_SITES_PER_IP, registryPath },
@@ -2484,13 +2658,12 @@ function registerTools(server, baseCtx) {
2484
2658
  outputSchema: STRUCTURED_TOOL_OUTPUT_SCHEMA,
2485
2659
  annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: false },
2486
2660
  inputSchema: {
2487
- projectDir: projectDirInput,
2488
- outputDir: z3.string().optional().describe("Output directory relative to the project root (overrides detection).")
2661
+ outputDir: z2.string().optional().describe("Output directory relative to the project root (overrides detection).")
2489
2662
  }
2490
2663
  },
2491
2664
  async (args) => {
2492
2665
  try {
2493
- const ctx = withProjectDir(baseCtx, args.projectDir);
2666
+ const ctx = withProjectDir(baseCtx);
2494
2667
  const analysis = await analyzeProject(ctx.projectDir, {
2495
2668
  ...args.outputDir !== void 0 ? { outputDir: args.outputDir } : {}
2496
2669
  });
@@ -2508,35 +2681,54 @@ Next action: ${analysis.suggestedNextAction}`,
2508
2681
  server.registerTool(
2509
2682
  "deploy",
2510
2683
  {
2511
- description: `Deploy the local static output to Sakupa. First deploy creates a free temporary site (valid ${FREE_SITE_TTL_HOURS}h, public URL like https://${previewHostPattern}) and stores the management credential in .sakupa/site.json. Later runs update the existing site (free sites also refresh their validity; subscribed sites are permanent). Runs analyze first and refuses to upload source projects, secrets, .env files, archives, media or server code. projectDir is ALWAYS the project root where .sakupa belongs; outputDir is a separate REQUIRED relative path supplied from the current project inspection ("." when the root itself is published). NEVER pass the build/output folder as projectDir. Never uploads anything when analysis says the project is not deployable.`,
2684
+ description: `Deploy the local static output to Sakupa. First deploy creates a free temporary site (valid ${FREE_SITE_TTL_HOURS}h, public URL like https://${previewHostPattern}) and stores the management credential in .sakupa/site.json. Later runs update the existing site (free sites also refresh their validity; subscribed sites are permanent). Runs analyze first and refuses to upload source projects, secrets, .env files, archives, media or server code. The MCP process is locked to the current directory initialized by \`npx -y @sakupa/mcp@latest init\`; no tool argument can change that root. outputDir is a separate REQUIRED relative path supplied from the current project inspection. Never uploads anything when analysis says the project is not deployable.`,
2512
2685
  outputSchema: STRUCTURED_TOOL_OUTPUT_SCHEMA,
2513
2686
  annotations: { readOnlyHint: false, destructiveHint: true, openWorldHint: true },
2514
2687
  inputSchema: {
2515
- projectDir: projectDirInput,
2516
- outputDir: z3.string().min(1).describe(
2517
- 'REQUIRED: exact publish directory relative to projectDir, supplied by the AI after inspecting this project (for example ".", "dist", "html", or any custom build directory). NEVER put this path in projectDir; .sakupa belongs at projectDir.'
2688
+ outputDir: z2.string().min(1).describe(
2689
+ 'REQUIRED: exact publish directory relative to the initialized project root, supplied by the AI after inspecting this project (for example ".", "dist", "html", or any custom build directory). Sakupa applies it only inside the cwd-locked project.'
2518
2690
  ),
2519
- spaFallback: z3.boolean().optional().describe(
2691
+ outputDirChangeConfirmed: z2.boolean().optional().describe(
2692
+ "Required only when changing the previously successful publish directory. Confirm only after showing the old and new directories to the user."
2693
+ ),
2694
+ spaFallback: z2.boolean().optional().describe(
2520
2695
  "Override automatic SPA-fallback detection (single index.html + JS auto-enables rewriting unknown paths to index.html; multiple HTML pages auto-disable it). Pass only to force the behavior against the detected structure."
2521
2696
  ),
2522
- publicConfirmed: z3.boolean().optional().describe(
2697
+ publicConfirmed: z2.boolean().optional().describe(
2523
2698
  "Required only for the first deployment: user explicitly confirmed creation of a public 24-hour URL."
2524
2699
  ),
2525
- subprojectConfirmed: z3.boolean().optional().describe(
2526
- "Only when creating a NEW site in a subfolder of a package.json project: the user explicitly confirmed this subfolder is an INDEPENDENT site, not the project's build output."
2700
+ subprojectConfirmed: z2.boolean().optional().describe(
2701
+ "Deprecated compatibility field. Project independence is established only by `sakupa-mcp init`, never inferred from package.json or folder names."
2527
2702
  ),
2528
- lang: z3.string().optional().describe("Site language override (en | ja | zh-CN); defaults to the html lang.")
2703
+ lang: z2.string().optional().describe("Site language override (en | ja | zh-CN); defaults to the html lang.")
2529
2704
  }
2530
2705
  },
2531
2706
  async (args) => {
2532
2707
  try {
2533
- const ctx = withProjectDir(baseCtx, args.projectDir);
2708
+ const ctx = withProjectDir(baseCtx);
2534
2709
  const analysis = await analyzeProject(ctx.projectDir, { outputDir: args.outputDir });
2535
2710
  if (!analysis.deployable || !analysis.files) {
2536
2711
  return notDeployableResult(analysis);
2537
2712
  }
2713
+ const effectiveOutputDir = analysis.recommendedOutputDir ?? ".";
2714
+ const recordedOutputDir = ctx.projectMarker?.outputDir;
2715
+ if (recordedOutputDir !== void 0 && resolve4(ctx.projectDir, recordedOutputDir) !== resolve4(ctx.projectDir, effectiveOutputDir) && args.outputDirChangeConfirmed !== true) {
2716
+ return structuredToolResult({
2717
+ schemaVersion: 1,
2718
+ outcome: "waiting_user",
2719
+ resultCode: "publish_directory_change_confirmation_required",
2720
+ summary: `This initialized project last published from "${recordedOutputDir}", but this request selected "${effectiveOutputDir}". Nothing was uploaded and the site was not changed. Show both paths to the user; only after explicit confirmation call deploy again with outputDirChangeConfirmed: true.`,
2721
+ data: {
2722
+ projectDir: ctx.projectDir,
2723
+ previousOutputDir: recordedOutputDir,
2724
+ requestedOutputDir: effectiveOutputDir,
2725
+ confirmationField: "outputDirChangeConfirmed"
2726
+ },
2727
+ nextActions: [{ tool: "deploy", allowed: true, reasonCode: "explicit_confirmation" }]
2728
+ });
2729
+ }
2538
2730
  const files = analysis.files;
2539
- const outputAbs = resolve4(ctx.projectDir, analysis.recommendedOutputDir ?? ".");
2731
+ const outputAbs = resolve4(ctx.projectDir, effectiveOutputDir);
2540
2732
  const manifest = await buildHashedManifest(files, outputAbs);
2541
2733
  const siteFileState = loadSiteFile(ctx.projectDir);
2542
2734
  if (siteFileState.kind === "corrupted") {
@@ -2551,46 +2743,31 @@ Next action: ${analysis.suggestedNextAction}`,
2551
2743
  }
2552
2744
  let existing = siteFileState.kind === "ok" ? siteFileState.file : null;
2553
2745
  let credentialRelocatedFrom = null;
2554
- if (!existing && analysis.recommendedOutputDir !== ".") {
2746
+ if (!existing && effectiveOutputDir !== ".") {
2747
+ const outputProjectMarker = loadProjectMarker(outputAbs);
2748
+ if (outputProjectMarker.kind !== "absent") {
2749
+ return text(
2750
+ "publish_directory_is_independent_project",
2751
+ outputProjectMarker.kind === "corrupted" ? `The selected publish directory ${outputAbs} contains a damaged Sakupa project marker: ${outputProjectMarker.problem}. Nothing was deployed.` : `The selected publish directory ${outputAbs} is itself an explicitly initialized Sakupa project. Refusing to move or reuse its credential from ${ctx.projectDir}. Run deploy from that independent project instead, or choose a publish directory that is not another Sakupa project.`,
2752
+ { projectRoot: ctx.projectDir, outputDir: effectiveOutputDir },
2753
+ "blocked"
2754
+ );
2755
+ }
2555
2756
  const outputSiteState = loadSiteFile(outputAbs);
2556
2757
  if (outputSiteState.kind === "corrupted") {
2557
2758
  return text(
2558
2759
  "output_site_file_corrupted",
2559
- `A misplaced .sakupa/site.json exists in output directory ${outputAbs}, but it is damaged: ${outputSiteState.problem}. Repair that file before retrying with projectDir: ${ctx.projectDir}. Nothing was deployed and no site was created.`,
2760
+ `A misplaced .sakupa/site.json exists in output directory ${outputAbs}, but it is damaged: ${outputSiteState.problem}. Repair that file before retrying with the MCP still opened at ${ctx.projectDir}. Nothing was deployed and no site was created.`,
2560
2761
  { projectRoot: ctx.projectDir, outputDir: analysis.recommendedOutputDir },
2561
2762
  "blocked"
2562
2763
  );
2563
2764
  }
2564
2765
  if (outputSiteState.kind === "ok") {
2565
- writeSiteFile(ctx.projectDir, outputSiteState.file);
2566
- deleteSiteFile(outputAbs);
2567
2766
  existing = outputSiteState.file;
2568
2767
  credentialRelocatedFrom = outputAbs;
2569
2768
  }
2570
2769
  }
2571
2770
  if (!existing) {
2572
- const rootHint = args.subprojectConfirmed === true ? null : projectRootAbove(ctx.projectDir);
2573
- if (rootHint) {
2574
- return text(
2575
- "not_project_root",
2576
- `${ctx.projectDir} is a SUBFOLDER of a package.json project, and .sakupa must live at the project ROOT. Re-run deploy with projectDir: ${rootHint.projectRoot} and outputDir: ${rootHint.outputDir}. Only if the user explicitly says this subfolder is an INDEPENDENT site (e.g. a docs/ site inside a repo), re-run with subprojectConfirmed: true. Nothing was deployed and no site was created.`,
2577
- {
2578
- projectRoot: rootHint.projectRoot,
2579
- outputDir: rootHint.outputDir,
2580
- confirmationField: "subprojectConfirmed"
2581
- },
2582
- "blocked"
2583
- );
2584
- }
2585
- const neighbor = findNeighborBinding(ctx.projectDir);
2586
- if (neighbor) {
2587
- return text(
2588
- "neighbor_binding_found",
2589
- `No .sakupa binding in ${ctx.projectDir}, but one EXISTS at ${neighbor} \u2014 this looks like the same project addressed at a different directory level. To update that existing site, re-run deploy with projectDir: ${neighbor}. Only if the user explicitly wants a SEPARATE new site, move this deploy to a directory outside that project. Nothing was deployed and no site was created.`,
2590
- { neighborProjectDir: neighbor },
2591
- "blocked"
2592
- );
2593
- }
2594
2771
  const barrier = freeSiteCreationBarrier(ctx.apiBaseUrl);
2595
2772
  if (barrier) return barrier;
2596
2773
  if (args.publicConfirmed !== true) {
@@ -2624,6 +2801,7 @@ Next action: ${analysis.suggestedNextAction}`,
2624
2801
  createdAt,
2625
2802
  apiBaseUrl: ctx.apiBaseUrl
2626
2803
  });
2804
+ updateProjectOutputDir(ctx.projectDir, effectiveOutputDir);
2627
2805
  recordCreation({
2628
2806
  siteId: created.siteId,
2629
2807
  projectDir: ctx.projectDir,
@@ -2698,6 +2876,8 @@ ${JSON.stringify(finalized2.warnings, null, 2)}` : ""),
2698
2876
  }
2699
2877
  const { uploaded, finalized } = update;
2700
2878
  writeSiteFile(ctx.projectDir, { ...existing, url: finalized.url });
2879
+ if (credentialRelocatedFrom !== null) deleteSiteFile(credentialRelocatedFrom);
2880
+ updateProjectOutputDir(ctx.projectDir, effectiveOutputDir);
2701
2881
  noteSiteMode(existing.siteId, finalized.mode);
2702
2882
  return text(
2703
2883
  "site_updated",
@@ -2736,11 +2916,11 @@ ${JSON.stringify(finalized.warnings, null, 2)}` : ""),
2736
2916
  description: "Refresh the validity of the free temporary site WITHOUT uploading content. Uses the local credential in .sakupa/site.json. Subscribed sites are permanent and need no refresh.",
2737
2917
  outputSchema: STRUCTURED_TOOL_OUTPUT_SCHEMA,
2738
2918
  annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: true },
2739
- inputSchema: { projectDir: projectDirInput }
2919
+ inputSchema: {}
2740
2920
  },
2741
- async (args) => {
2921
+ async () => {
2742
2922
  try {
2743
- const ctx = withProjectDir(baseCtx, args.projectDir);
2923
+ const ctx = withProjectDir(baseCtx);
2744
2924
  const site = requireSiteFile(ctx);
2745
2925
  const res = await ctx.client.refreshSite(site.siteId, site.credential);
2746
2926
  return text(
@@ -2760,11 +2940,11 @@ NO content was uploaded or changed by this call \u2014 to publish new or edited
2760
2940
  description: "Show the current status of this project's Sakupa site: URL, mode (free/paid), expiry, custom domains, size, last deployment and warnings. For a paid site this tool also automatically returns the complete authoritative billing snapshot; users never need to know or name a separate billing tool to get accurate subscription information.",
2761
2941
  outputSchema: STRUCTURED_TOOL_OUTPUT_SCHEMA,
2762
2942
  annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: true },
2763
- inputSchema: { projectDir: projectDirInput }
2943
+ inputSchema: {}
2764
2944
  },
2765
- async (args) => {
2945
+ async () => {
2766
2946
  try {
2767
- const ctx = withProjectDir(baseCtx, args.projectDir);
2947
+ const ctx = withProjectDir(baseCtx);
2768
2948
  const site = requireSiteFile(ctx);
2769
2949
  const res = await ctx.client.getSiteStatus(site.siteId, site.credential);
2770
2950
  noteSiteMode(res.siteId, res.mode);
@@ -2792,7 +2972,6 @@ NO content was uploaded or changed by this call \u2014 to publish new or edited
2792
2972
  outputSchema: STRUCTURED_TOOL_OUTPUT_SCHEMA,
2793
2973
  annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: true },
2794
2974
  inputSchema: {
2795
- projectDir: projectDirInput,
2796
2975
  plan: planEnum.describe(
2797
2976
  "Monthly plan: water (very light personal pages), personal (personal brand / small shop), share (small-business site), business (steadier traffic, more headroom)."
2798
2977
  )
@@ -2800,13 +2979,13 @@ NO content was uploaded or changed by this call \u2014 to publish new or edited
2800
2979
  },
2801
2980
  async (args) => {
2802
2981
  try {
2803
- const ctx = withProjectDir(baseCtx, args.projectDir);
2982
+ const ctx = withProjectDir(baseCtx);
2804
2983
  const site = requireSiteFile(ctx);
2805
2984
  const res = await ctx.client.createPlanCheckout(
2806
2985
  {
2807
2986
  siteId: site.siteId,
2808
2987
  plan: args.plan,
2809
- idempotencyKey: randomUUID()
2988
+ idempotencyKey: randomUUID2()
2810
2989
  },
2811
2990
  site.credential
2812
2991
  );
@@ -2839,17 +3018,16 @@ Once payment confirms, the site becomes permanent on its current URL. Binding a
2839
3018
  outputSchema: STRUCTURED_TOOL_OUTPUT_SCHEMA,
2840
3019
  annotations: { readOnlyHint: false, destructiveHint: true, openWorldHint: true },
2841
3020
  inputSchema: {
2842
- projectDir: projectDirInput,
2843
- action: z3.enum(["start", "status"]),
2844
- hostname: z3.string().optional().describe("Required for start."),
2845
- verificationId: z3.string().optional().describe(
3021
+ action: z2.enum(["start", "status"]),
3022
+ hostname: z2.string().optional().describe("Required for start."),
3023
+ verificationId: z2.string().optional().describe(
2846
3024
  "Optional for status: when omitted, the server finds this site's latest binding verification \u2014 a NEW session can resume without it."
2847
3025
  )
2848
3026
  }
2849
3027
  },
2850
3028
  async (args) => {
2851
3029
  try {
2852
- const ctx = withProjectDir(baseCtx, args.projectDir);
3030
+ const ctx = withProjectDir(baseCtx);
2853
3031
  const site = requireSiteFile(ctx);
2854
3032
  if (args.action === "status") {
2855
3033
  const res2 = args.verificationId ? await ctx.client.checkVerification(args.verificationId, site.credential) : await ctx.client.checkVerification("latest", site.credential, site.siteId);
@@ -2954,11 +3132,11 @@ When the user says the TXT is set, run bind "status". It verifies ownership and
2954
3132
  description: "Return the sole authoritative source for this site's hosting subscription: current plan, next renewal plan or cancellation, effective time, payment state, current paid entitlement, reconciled usage, estimated usage tier, bound custom domains and risks. Owner-only (uses the credential in .sakupa/site.json).",
2955
3133
  outputSchema: STRUCTURED_TOOL_OUTPUT_SCHEMA,
2956
3134
  annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: true },
2957
- inputSchema: { projectDir: projectDirInput }
3135
+ inputSchema: {}
2958
3136
  },
2959
- async (args) => {
3137
+ async () => {
2960
3138
  try {
2961
- const ctx = withProjectDir(baseCtx, args.projectDir);
3139
+ const ctx = withProjectDir(baseCtx);
2962
3140
  const site = requireSiteFile(ctx);
2963
3141
  const res = await ctx.client.getBillingStatus(site.siteId, site.credential);
2964
3142
  noteSiteMode(res.siteId, res.mode);
@@ -2996,14 +3174,13 @@ Full status:`, res);
2996
3174
  outputSchema: STRUCTURED_TOOL_OUTPUT_SCHEMA,
2997
3175
  annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: true },
2998
3176
  inputSchema: {
2999
- projectDir: projectDirInput,
3000
- scope: z3.enum(["site", "public_recovery"])
3177
+ scope: z2.enum(["site", "public_recovery"])
3001
3178
  }
3002
3179
  },
3003
3180
  async (args) => {
3004
3181
  try {
3005
- const ctx = withProjectDir(baseCtx, args.projectDir);
3006
3182
  if (args.scope === "site") {
3183
+ const ctx = withProjectDir(baseCtx);
3007
3184
  const site = requireSiteFile(ctx);
3008
3185
  const res2 = await ctx.client.createBillingPortal(site.siteId, site.credential);
3009
3186
  return structuredToolResult({
@@ -3021,7 +3198,7 @@ Full status:`, res);
3021
3198
  nextActions: [{ tool: "billing", allowed: true }]
3022
3199
  });
3023
3200
  }
3024
- const res = await ctx.client.getPublicBillingPortal();
3201
+ const res = await baseCtx.client.getPublicBillingPortal();
3025
3202
  return structuredToolResult({
3026
3203
  schemaVersion: 1,
3027
3204
  outcome: "waiting_user",
@@ -3049,21 +3226,28 @@ Full status:`, res);
3049
3226
  server.registerTool(
3050
3227
  "recover",
3051
3228
  {
3052
- 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.",
3229
+ 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.",
3053
3230
  outputSchema: STRUCTURED_TOOL_OUTPUT_SCHEMA,
3054
3231
  annotations: { readOnlyHint: false, destructiveHint: true, openWorldHint: true },
3055
3232
  inputSchema: {
3056
- projectDir: projectDirInput,
3057
- action: z3.enum(["start", "status", "complete", "download"]),
3058
- hostname: z3.string().optional().describe("Required for start."),
3059
- verificationId: z3.string().optional().describe("For status or complete; inferred from local recovery state when omitted."),
3060
- outputDir: z3.string().optional().describe("Relative extraction directory for complete/download (default: html)."),
3061
- preserveExistingCredentials: z3.boolean().optional().describe("Explicitly keep old local credentials working (default: revoke them all).")
3233
+ action: z2.enum(["start", "status", "complete", "download"]),
3234
+ hostname: z2.string().optional().describe("Required for start."),
3235
+ verificationId: z2.string().optional().describe("For status or complete; inferred from local recovery state when omitted."),
3236
+ outputDir: z2.string().optional().describe(
3237
+ "REQUIRED for complete/download: exact extraction directory relative to the initialized project root. Inspect the current project; Sakupa never guesses a name."
3238
+ ),
3239
+ preserveExistingCredentials: z2.boolean().optional().describe("Explicitly keep old local credentials working (default: revoke them all).")
3062
3240
  }
3063
3241
  },
3064
3242
  async (args) => {
3065
3243
  try {
3066
- const ctx = withProjectDir(baseCtx, args.projectDir);
3244
+ const ctx = withProjectDir(baseCtx);
3245
+ if ((args.action === "complete" || args.action === "download") && args.outputDir === void 0) {
3246
+ throw new LocalGuidanceError(
3247
+ "invalid_request",
3248
+ "recover requires outputDir for complete/download. Inspect the current project and pass the exact extraction directory relative to the initialized Sakupa root; the server never defaults to html, dist, build, or any other name."
3249
+ );
3250
+ }
3067
3251
  const localSite = loadSiteFile(ctx.projectDir);
3068
3252
  const localCredentialIsActive = async () => {
3069
3253
  if (localSite.kind !== "ok") return false;
@@ -3076,12 +3260,18 @@ Full status:`, res);
3076
3260
  }
3077
3261
  };
3078
3262
  const download = async () => {
3263
+ if (args.outputDir === void 0) {
3264
+ throw new LocalGuidanceError(
3265
+ "invalid_request",
3266
+ "recover download requires an explicit outputDir."
3267
+ );
3268
+ }
3079
3269
  const site = requireSiteFile(ctx);
3080
3270
  const archive = await ctx.client.getSiteArchive(site.siteId, site.credential);
3081
3271
  const bytes = await ctx.client.downloadArchive(archive.archiveUrl);
3082
3272
  const extracted = await extractRecoveryArchive({
3083
3273
  projectDir: ctx.projectDir,
3084
- ...args.outputDir !== void 0 ? { outputDir: args.outputDir } : {},
3274
+ outputDir: args.outputDir,
3085
3275
  archive: bytes,
3086
3276
  expectedBytes: archive.totalBytes,
3087
3277
  expectedFiles: archive.fileCount
@@ -3124,9 +3314,8 @@ No DNS verification was started or repeated.`,
3124
3314
  {
3125
3315
  tool: "recover",
3126
3316
  arguments: {
3127
- projectDir: ctx.projectDir,
3128
3317
  action: "download",
3129
- outputDir: args.outputDir ?? "html"
3318
+ ...args.outputDir !== void 0 ? { outputDir: args.outputDir } : {}
3130
3319
  },
3131
3320
  allowed: true
3132
3321
  }
@@ -3151,7 +3340,6 @@ No DNS verification was started or repeated.`,
3151
3340
  {
3152
3341
  tool: "recover",
3153
3342
  arguments: {
3154
- projectDir: ctx.projectDir,
3155
3343
  action: "status",
3156
3344
  verificationId: pending2.verificationId
3157
3345
  },
@@ -3224,9 +3412,8 @@ After the DNS record resolves, re-run recover with verificationId: "${res2.verif
3224
3412
  {
3225
3413
  tool: "recover",
3226
3414
  arguments: {
3227
- projectDir: ctx.projectDir,
3228
3415
  action: "download",
3229
- outputDir: args.outputDir ?? "html"
3416
+ ...args.outputDir !== void 0 ? { outputDir: args.outputDir } : {}
3230
3417
  },
3231
3418
  allowed: true
3232
3419
  }
@@ -3259,10 +3446,9 @@ After the DNS record resolves, re-run recover with verificationId: "${res2.verif
3259
3446
  {
3260
3447
  tool: "recover",
3261
3448
  arguments: {
3262
- projectDir: ctx.projectDir,
3263
3449
  action: "complete",
3264
3450
  verificationId,
3265
- outputDir: args.outputDir ?? "html"
3451
+ ...args.outputDir !== void 0 ? { outputDir: args.outputDir } : {}
3266
3452
  },
3267
3453
  allowed: res2.readyToComplete,
3268
3454
  ...res2.readyToComplete ? {} : { reasonCode: res2.status }
@@ -3336,9 +3522,8 @@ Files: ${extracted.fileCount}; bytes: ${extracted.totalBytes}
3336
3522
  {
3337
3523
  tool: "recover",
3338
3524
  arguments: {
3339
- projectDir: ctx.projectDir,
3340
3525
  action: "download",
3341
- outputDir: args.outputDir ?? "html"
3526
+ ...args.outputDir !== void 0 ? { outputDir: args.outputDir } : {}
3342
3527
  },
3343
3528
  allowed: true
3344
3529
  }
@@ -3357,16 +3542,15 @@ Files: ${extracted.fileCount}; bytes: ${extracted.totalBytes}
3357
3542
  outputSchema: STRUCTURED_TOOL_OUTPUT_SCHEMA,
3358
3543
  annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: true },
3359
3544
  inputSchema: {
3360
- projectDir: projectDirInput,
3361
3545
  category: ticketCategoryEnum,
3362
- subject: z3.string().describe("Short subject line."),
3363
- description: z3.string().describe("Problem description (no secrets, no card data)."),
3364
- contactEmail: z3.string().optional().describe("Optional contact email for follow-up.")
3546
+ subject: z2.string().describe("Short subject line."),
3547
+ description: z2.string().describe("Problem description (no secrets, no card data)."),
3548
+ contactEmail: z2.string().optional().describe("Optional contact email for follow-up.")
3365
3549
  }
3366
3550
  },
3367
3551
  async (args) => {
3368
3552
  try {
3369
- const ctx = withProjectDir(baseCtx, args.projectDir);
3553
+ const ctx = withProjectDir(baseCtx);
3370
3554
  const site = requireSiteFile(ctx);
3371
3555
  const res = await ctx.client.createTicket(site.credential, {
3372
3556
  siteId: site.siteId,
@@ -3392,26 +3576,25 @@ Files: ${extracted.fileCount}; bytes: ${extracted.totalBytes}
3392
3576
  outputSchema: STRUCTURED_TOOL_OUTPUT_SCHEMA,
3393
3577
  annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: true },
3394
3578
  inputSchema: {
3395
- projectDir: projectDirInput,
3396
- toolName: z3.string().describe('The Sakupa tool that failed, e.g. "deploy".'),
3397
- errorCode: z3.string().optional(),
3398
- errorMessage: z3.string().optional().describe("Sanitized error message (no secrets)."),
3399
- requestId: z3.string().optional(),
3400
- deploymentId: z3.string().optional(),
3579
+ toolName: z2.string().describe('The Sakupa tool that failed, e.g. "deploy".'),
3580
+ errorCode: z2.string().optional(),
3581
+ errorMessage: z2.string().optional().describe("Sanitized error message (no secrets)."),
3582
+ requestId: z2.string().optional(),
3583
+ deploymentId: z2.string().optional(),
3401
3584
  severity: severityEnum.optional(),
3402
- description: z3.string().optional().describe("What happened, in the user's words (no secrets)."),
3403
- agentContext: z3.string().optional().describe(
3585
+ description: z2.string().optional().describe("What happened, in the user's words (no secrets)."),
3586
+ agentContext: z2.string().optional().describe(
3404
3587
  "YOUR OWN factual account of the session as the AI: which tools you called, what they returned, expected vs actual. Write it yourself from your observations \u2014 never ask the user to compose it, and do not read it back to them; it travels alongside the user's description as a second witness. No secrets, no file contents."
3405
3588
  ),
3406
- contactEmail: z3.string().optional().describe(
3589
+ contactEmail: z2.string().optional().describe(
3407
3590
  "OPTIONAL. Before submitting, ask the user ONCE whether they want to leave a contact for follow-up. Omit entirely if they decline \u2014 never require it."
3408
3591
  ),
3409
- confirmSubmit: z3.boolean().optional().describe("User reviewed the report payload and approved submission.")
3592
+ confirmSubmit: z2.boolean().optional().describe("User reviewed the report payload and approved submission.")
3410
3593
  }
3411
3594
  },
3412
3595
  async (args) => {
3413
3596
  try {
3414
- const ctx = withProjectDir(baseCtx, args.projectDir);
3597
+ const ctx = withProjectDir(baseCtx);
3415
3598
  const siteState = loadSiteFile(ctx.projectDir);
3416
3599
  const site = siteState.kind === "ok" ? siteState.file : null;
3417
3600
  const diagnostics = {
@@ -3458,22 +3641,22 @@ Summary: ${res.sanitizedSummary}`,
3458
3641
  }
3459
3642
 
3460
3643
  // src/tools/lifecycle.ts
3461
- import { randomUUID as randomUUID2 } from "node:crypto";
3462
- import { z as z4 } from "zod";
3463
- var deleteConfirmation = z4.object({
3464
- siteId: z4.string().min(1),
3465
- expectedSiteUpdatedAt: z4.string().datetime(),
3466
- expectedStatus: z4.enum(["active", "expired", "deleted"]),
3467
- expectedMode: z4.enum(["free", "paid"]),
3468
- expectedServingMode: z4.enum(["normal", "over_limit_notice", "risk_notice", "stopped"]),
3469
- expectedShortId: z4.string().optional(),
3470
- expectedSubscriptionStatus: z4.enum(["incomplete", "active", "past_due", "canceled"]).optional(),
3471
- expectedPlan: z4.enum(["water", "personal", "share", "business"]).optional(),
3472
- expectedCancelAtPeriodEnd: z4.boolean().optional(),
3473
- expectedCurrentPeriodEnd: z4.string().datetime().optional(),
3474
- expectedLastDeploymentId: z4.string().optional(),
3475
- expectedBoundHostnames: z4.array(z4.string()),
3476
- acknowledge: z4.literal("delete_and_cancel_renewal")
3644
+ import { randomUUID as randomUUID3 } from "node:crypto";
3645
+ import { z as z3 } from "zod";
3646
+ var deleteConfirmation = z3.object({
3647
+ siteId: z3.string().min(1),
3648
+ expectedSiteUpdatedAt: z3.string().datetime(),
3649
+ expectedStatus: z3.enum(["active", "expired", "deleted"]),
3650
+ expectedMode: z3.enum(["free", "paid"]),
3651
+ expectedServingMode: z3.enum(["normal", "over_limit_notice", "risk_notice", "stopped"]),
3652
+ expectedShortId: z3.string().optional(),
3653
+ expectedSubscriptionStatus: z3.enum(["incomplete", "active", "past_due", "canceled"]).optional(),
3654
+ expectedPlan: z3.enum(["water", "personal", "share", "business"]).optional(),
3655
+ expectedCancelAtPeriodEnd: z3.boolean().optional(),
3656
+ expectedCurrentPeriodEnd: z3.string().datetime().optional(),
3657
+ expectedLastDeploymentId: z3.string().optional(),
3658
+ expectedBoundHostnames: z3.array(z3.string()),
3659
+ acknowledge: z3.literal("delete_and_cancel_renewal")
3477
3660
  });
3478
3661
  function registerLifecycleTools(server, baseCtx) {
3479
3662
  server.registerTool(
@@ -3481,9 +3664,8 @@ function registerLifecycleTools(server, baseCtx) {
3481
3664
  {
3482
3665
  description: "Preview or execute deletion of this Sakupa site. Execution requires an exact server-validated confirmation bound to the current site state. Paid sites must first cancel renewal through portal and return to free mode. For a temporary pause, publish a pause notice as index.html with deploy instead of deleting the site.",
3483
3666
  inputSchema: {
3484
- projectDir: projectDirInput,
3485
- action: z4.enum(["preview", "confirm"]),
3486
- operationId: z4.string().min(1).optional(),
3667
+ action: z3.enum(["preview", "confirm"]),
3668
+ operationId: z3.string().min(1).optional(),
3487
3669
  confirmation: deleteConfirmation.optional()
3488
3670
  },
3489
3671
  outputSchema: STRUCTURED_TOOL_OUTPUT_SCHEMA,
@@ -3491,9 +3673,9 @@ function registerLifecycleTools(server, baseCtx) {
3491
3673
  },
3492
3674
  async (args) => {
3493
3675
  try {
3494
- const ctx = withProjectDir(baseCtx, args.projectDir);
3676
+ const ctx = withProjectDir(baseCtx);
3495
3677
  const site = requireSiteFile(ctx);
3496
- const operationId = args.operationId ?? randomUUID2();
3678
+ const operationId = args.operationId ?? randomUUID3();
3497
3679
  if (args.action === "preview") {
3498
3680
  const preview = await ctx.client.previewDeleteSite(site.siteId, site.credential, {
3499
3681
  operationId
@@ -3562,20 +3744,19 @@ function registerLifecycleTools(server, baseCtx) {
3562
3744
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
3563
3745
 
3564
3746
  // src/tools/billing.ts
3565
- import { z as z5 } from "zod";
3747
+ import { z as z4 } from "zod";
3566
3748
  function registerBillingTools(server, baseCtx) {
3567
3749
  server.registerTool(
3568
3750
  "plans",
3569
3751
  {
3570
3752
  description: "Return the authoritative Sakupa monthly plan catalog, exact limits, prices, catalog version and plan-change billing rules. This is read-only and does not require a site.",
3571
- inputSchema: { projectDir: projectDirInput },
3753
+ inputSchema: {},
3572
3754
  outputSchema: STRUCTURED_TOOL_OUTPUT_SCHEMA,
3573
3755
  annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: true }
3574
3756
  },
3575
- async (args) => {
3757
+ async () => {
3576
3758
  try {
3577
- const ctx = withProjectDir(baseCtx, args.projectDir);
3578
- const catalog = await ctx.client.getBillingPlanCatalog();
3759
+ const catalog = await baseCtx.client.getBillingPlanCatalog();
3579
3760
  return structuredToolResult({
3580
3761
  schemaVersion: 1,
3581
3762
  outcome: "completed",
@@ -3594,15 +3775,14 @@ function registerBillingTools(server, baseCtx) {
3594
3775
  {
3595
3776
  description: "Create one Stripe-hosted subscription-management link. The user chooses the plan or period-end cancellation on Stripe; Sakupa never infers intent from the conversation. Creating the link does not change billing.",
3596
3777
  inputSchema: {
3597
- projectDir: projectDirInput,
3598
- operationId: z5.string().min(1)
3778
+ operationId: z4.string().min(1)
3599
3779
  },
3600
3780
  outputSchema: STRUCTURED_TOOL_OUTPUT_SCHEMA,
3601
3781
  annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: true }
3602
3782
  },
3603
3783
  async (args) => {
3604
3784
  try {
3605
- const ctx = withProjectDir(baseCtx, args.projectDir);
3785
+ const ctx = withProjectDir(baseCtx);
3606
3786
  const site = requireSiteFile(ctx);
3607
3787
  const result = await ctx.client.changeSubscriptionPlan(site.credential, {
3608
3788
  siteId: site.siteId,
@@ -3663,18 +3843,22 @@ Workflow:
3663
3843
  5. support (subscribed sites) opens a support ticket; report sends a
3664
3844
  sanitized diagnostic report after the user explicitly confirms it.
3665
3845
 
3666
- Project directory contract: ONE directory = ONE site (its .sakupa/site.json holds the
3667
- binding). projectDir is REQUIRED on EVERY tool call \u2014 always pass the absolute path of
3668
- the user's PROJECT ROOT, the SAME directory every time for the same project: the folder
3669
- the user opened (for framework projects, where package.json lives \u2014 never the dist/out
3670
- build folder). For deploy, ALWAYS pass outputDir separately as the exact path RELATIVE to
3671
- projectDir (use "." when publishing the root); outputDir is required and may have ANY name,
3672
- so inspect the current project and never infer it from a conventional folder name. The server
3673
- NEVER guesses either directory: only you can see the user's actual workspace and build output.
3846
+ Project directory contract: before the first deploy or a new recovery, initialize the intended
3847
+ project once by running "npx -y @sakupa/mcp@latest init" with NO path argument from the AI
3848
+ tool's current project directory. This immediately creates the non-secret .sakupa/project.json
3849
+ there. ONE MCP process = ONE cwd-locked project = ONE site. Site tools do not accept projectDir
3850
+ and cannot select another root; plans and public_recovery portal remain project-independent.
3851
+ Sakupa stores .sakupa/site.json and recovery state only in the locked directory; it never uses
3852
+ package.json, .git, framework names or output-directory names to guess. For deploy, ALWAYS pass
3853
+ outputDir separately as the exact path RELATIVE to the locked directory (use "." when publishing
3854
+ the root); outputDir is
3855
+ required and may have ANY name, so inspect the current project. If it differs from the last
3856
+ successful publish directory, show the old and new paths and obtain explicit confirmation
3857
+ before retrying with outputDirChangeConfirmed: true.
3674
3858
  After every deploy, TELL the user which environment it went to (deploy results carry an
3675
3859
  Explicit Environment line: TEST vs PRODUCTION). analyze, deploy, status,
3676
3860
  refresh and delete echo
3677
- the directory they acted on \u2014 verify it matches the user's active project.
3861
+ the cwd-locked directory they acted on.
3678
3862
 
3679
3863
  When the same operation fails twice in a row, or the user is clearly stuck or
3680
3864
  frustrated, proactively offer report: it files the problem into Sakupa's ticket and
@@ -3712,7 +3896,11 @@ function createSakupaMcpServer(opts) {
3712
3896
  { name: "sakupa", version: MCP_VERSION },
3713
3897
  { instructions: instructionsFor(previewHostPatternFor(opts.apiBaseUrl)) }
3714
3898
  );
3715
- const ctx = { client, apiBaseUrl: opts.apiBaseUrl };
3899
+ const ctx = {
3900
+ client,
3901
+ apiBaseUrl: opts.apiBaseUrl,
3902
+ projectDir: canonicalProjectDirectory(opts.projectDir ?? process.cwd())
3903
+ };
3716
3904
  registerTools(server, ctx);
3717
3905
  registerBillingTools(server, ctx);
3718
3906
  registerLifecycleTools(server, ctx);