@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/bin.js CHANGED
@@ -2,6 +2,247 @@
2
2
 
3
3
  // src/bin.ts
4
4
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
5
+ import { stdout } from "node:process";
6
+
7
+ // src/project-root.ts
8
+ import { randomUUID } from "node:crypto";
9
+ import {
10
+ chmodSync,
11
+ existsSync,
12
+ lstatSync,
13
+ mkdirSync,
14
+ readFileSync,
15
+ realpathSync,
16
+ renameSync,
17
+ statSync,
18
+ unlinkSync,
19
+ writeFileSync
20
+ } from "node:fs";
21
+ import { homedir } from "node:os";
22
+ import { isAbsolute, join, parse, relative, resolve, sep } from "node:path";
23
+ var SAKUPA_DIR = ".sakupa";
24
+ var PROJECT_FILE = "project.json";
25
+ var PROJECT_SCHEMA_VERSION = 1;
26
+ var ProjectRootError = class extends Error {
27
+ code;
28
+ constructor(code, message) {
29
+ super(message);
30
+ this.name = "ProjectRootError";
31
+ this.code = code;
32
+ }
33
+ };
34
+ function projectMarkerPath(projectDir) {
35
+ return join(projectDir, SAKUPA_DIR, PROJECT_FILE);
36
+ }
37
+ function loadProjectMarker(projectDir) {
38
+ const path = projectMarkerPath(projectDir);
39
+ if (!existsSync(path)) return { kind: "absent" };
40
+ let parsed;
41
+ try {
42
+ parsed = JSON.parse(readFileSync(path, "utf8"));
43
+ } catch (error) {
44
+ return {
45
+ kind: "corrupted",
46
+ problem: `the file cannot be read as JSON (${error instanceof Error ? error.message : String(error)})`
47
+ };
48
+ }
49
+ if (typeof parsed !== "object" || parsed === null) {
50
+ return { kind: "corrupted", problem: "the file does not contain a JSON object" };
51
+ }
52
+ const record = parsed;
53
+ if (record.schemaVersion !== PROJECT_SCHEMA_VERSION) {
54
+ return {
55
+ kind: "corrupted",
56
+ problem: `unsupported schemaVersion ${String(record.schemaVersion)}`
57
+ };
58
+ }
59
+ if (typeof record.projectId !== "string" || !isUuid(record.projectId)) {
60
+ return { kind: "corrupted", problem: "projectId is missing or is not a UUID" };
61
+ }
62
+ if (typeof record.createdAt !== "string" || !Number.isFinite(Date.parse(record.createdAt))) {
63
+ return { kind: "corrupted", problem: "createdAt is missing or invalid" };
64
+ }
65
+ if (record.outputDir !== void 0 && (typeof record.outputDir !== "string" || !isSafeRelativeOutput(record.outputDir))) {
66
+ return { kind: "corrupted", problem: "outputDir is not a safe project-relative path" };
67
+ }
68
+ return {
69
+ kind: "ok",
70
+ marker: {
71
+ schemaVersion: PROJECT_SCHEMA_VERSION,
72
+ projectId: record.projectId,
73
+ createdAt: record.createdAt,
74
+ ...record.outputDir !== void 0 ? { outputDir: normalizeRelative(record.outputDir) } : {}
75
+ }
76
+ };
77
+ }
78
+ function initializeProject(projectDir) {
79
+ const canonical = canonicalProjectDirectory(projectDir);
80
+ assertSafeProjectRoot(canonical);
81
+ const current = loadProjectMarker(canonical);
82
+ if (current.kind === "corrupted") {
83
+ throw new ProjectRootError(
84
+ "corrupted_marker",
85
+ `Refusing to overwrite damaged Sakupa project marker ${projectMarkerPath(canonical)}: ${current.problem}.`
86
+ );
87
+ }
88
+ if (current.kind === "ok") {
89
+ return {
90
+ projectDir: canonical,
91
+ requestedPath: canonical,
92
+ markerKind: "project",
93
+ marker: current.marker
94
+ };
95
+ }
96
+ const marker = {
97
+ schemaVersion: PROJECT_SCHEMA_VERSION,
98
+ projectId: randomUUID(),
99
+ createdAt: (/* @__PURE__ */ new Date()).toISOString()
100
+ };
101
+ writeMarkerAtomically(canonical, marker);
102
+ return {
103
+ projectDir: canonical,
104
+ requestedPath: canonical,
105
+ markerKind: "project",
106
+ marker
107
+ };
108
+ }
109
+ function resolveLockedProjectRoot(projectDir) {
110
+ const canonical = canonicalProjectDirectory(projectDir);
111
+ assertSafeProjectRoot(canonical);
112
+ const markerState = loadProjectMarker(canonical);
113
+ if (markerState.kind === "corrupted") {
114
+ throw new ProjectRootError(
115
+ "corrupted_marker",
116
+ `Sakupa project marker ${projectMarkerPath(canonical)} is damaged: ${markerState.problem}.`
117
+ );
118
+ }
119
+ if (markerState.kind === "absent") {
120
+ throw new ProjectRootError(
121
+ "not_initialized",
122
+ `The MCP working directory ${canonical} is not initialized. Run \`npx -y @sakupa/mcp@latest init\` in that directory; do not pass a path argument.`
123
+ );
124
+ }
125
+ return {
126
+ projectDir: canonical,
127
+ requestedPath: canonical,
128
+ markerKind: "project",
129
+ marker: markerState.marker
130
+ };
131
+ }
132
+ function updateProjectOutputDir(projectDir, outputDir) {
133
+ const canonical = canonicalProjectDirectory(projectDir);
134
+ const state = loadProjectMarker(canonical);
135
+ if (state.kind !== "ok") {
136
+ throw new ProjectRootError(
137
+ state.kind === "corrupted" ? "corrupted_marker" : "not_initialized",
138
+ state.kind === "corrupted" ? `Cannot update damaged Sakupa project marker: ${state.problem}.` : `No Sakupa project marker exists in ${canonical}.`
139
+ );
140
+ }
141
+ if (!isSafeRelativeOutput(outputDir)) {
142
+ throw new ProjectRootError(
143
+ "unsafe_path",
144
+ `Output directory "${outputDir}" must stay inside the initialized Sakupa project.`
145
+ );
146
+ }
147
+ const marker = {
148
+ ...state.marker,
149
+ outputDir: normalizeRelative(outputDir)
150
+ };
151
+ writeMarkerAtomically(canonical, marker);
152
+ return marker;
153
+ }
154
+ function canonicalProjectDirectory(path) {
155
+ const canonical = canonicalExistingPath(resolve(path));
156
+ if (!statSync(canonical).isDirectory()) {
157
+ throw new ProjectRootError("invalid_path", `Project path ${canonical} is not a directory.`);
158
+ }
159
+ return canonical;
160
+ }
161
+ function canonicalExistingPath(path) {
162
+ try {
163
+ const stat2 = lstatSync(path, { throwIfNoEntry: false });
164
+ if (!stat2) {
165
+ throw new ProjectRootError("invalid_path", `Project path ${path} does not exist.`);
166
+ }
167
+ return realpathSync(path);
168
+ } catch (error) {
169
+ if (error instanceof ProjectRootError) throw error;
170
+ throw new ProjectRootError(
171
+ "invalid_path",
172
+ `Project path ${path} cannot be resolved (${error instanceof Error ? error.message : String(error)}).`
173
+ );
174
+ }
175
+ }
176
+ function assertSafeProjectRoot(projectDir) {
177
+ if (parse(projectDir).root === projectDir || projectDir === realpathSync(homedir())) {
178
+ throw new ProjectRootError(
179
+ "unsafe_path",
180
+ `Refusing to use ${projectDir} as a Sakupa project root; choose a specific project directory.`
181
+ );
182
+ }
183
+ }
184
+ function isUuid(value) {
185
+ 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);
186
+ }
187
+ function normalizeRelative(path) {
188
+ const normalized = path.split(sep).join("/").replace(/^\.\//, "").replace(/\/$/, "");
189
+ return normalized.length === 0 ? "." : normalized;
190
+ }
191
+ function isSafeRelativeOutput(path) {
192
+ if (path.length === 0 || isAbsolute(path)) return false;
193
+ const normalized = normalizeRelative(path);
194
+ if (normalized === ".") return true;
195
+ const rel = relative("/sakupa-root", resolve("/sakupa-root", normalized));
196
+ return rel !== ".." && !rel.startsWith(`..${sep}`) && !isAbsolute(rel);
197
+ }
198
+ function writeMarkerAtomically(projectDir, marker) {
199
+ const dir = join(projectDir, SAKUPA_DIR);
200
+ mkdirSync(dir, { recursive: true, mode: 448 });
201
+ const path = projectMarkerPath(projectDir);
202
+ const temporary = `${path}.${process.pid}.${randomUUID()}.tmp`;
203
+ try {
204
+ writeFileSync(temporary, `${JSON.stringify(marker, null, 2)}
205
+ `, {
206
+ encoding: "utf8",
207
+ mode: 384
208
+ });
209
+ renameSync(temporary, path);
210
+ try {
211
+ chmodSync(path, 384);
212
+ } catch {
213
+ }
214
+ } finally {
215
+ if (existsSync(temporary)) unlinkSync(temporary);
216
+ }
217
+ }
218
+
219
+ // src/cli-init.ts
220
+ async function runInitCommand(args, io, cwd = process.cwd()) {
221
+ if (args.length > 0) {
222
+ io.write("Usage: sakupa-mcp init");
223
+ return { exitCode: 2, initialized: false };
224
+ }
225
+ try {
226
+ const projectDir = canonicalProjectDirectory(cwd);
227
+ const current = loadProjectMarker(projectDir);
228
+ if (current.kind === "corrupted") {
229
+ io.write(`Refusing to replace damaged Sakupa marker in ${projectDir}: ${current.problem}.`);
230
+ return { exitCode: 1, projectDir, initialized: false };
231
+ }
232
+ if (current.kind === "ok") {
233
+ io.write(`Sakupa project is already initialized: ${projectDir}`);
234
+ return { exitCode: 0, projectDir, initialized: false };
235
+ }
236
+ initializeProject(projectDir);
237
+ io.write(
238
+ `Initialized the current directory ${projectDir}. Created .sakupa/project.json here; site credentials and recovery state will stay in this .sakupa directory.`
239
+ );
240
+ return { exitCode: 0, projectDir, initialized: true };
241
+ } catch (error) {
242
+ io.write(error instanceof Error ? error.message : String(error));
243
+ return { exitCode: 1, initialized: false };
244
+ }
245
+ }
5
246
 
6
247
  // ../core/dist/domain/constants.js
7
248
  var SERVICE_DOMAIN = "sakupa.com";
@@ -129,7 +370,7 @@ var FORBIDDEN_PATH_SEGMENTS = [
129
370
  var ALLOWED_HIDDEN_PATHS = [".well-known/"];
130
371
 
131
372
  // ../core/dist/domain/version.js
132
- var SAKUPA_MCP_VERSION = "0.7.32";
373
+ var SAKUPA_MCP_VERSION = "0.7.34";
133
374
 
134
375
  // ../core/dist/domain/errors.js
135
376
  var HTTP_STATUS = {
@@ -688,14 +929,14 @@ var HttpApiClient = class {
688
929
  };
689
930
 
690
931
  // src/tools/definitions.ts
691
- import { randomUUID } from "node:crypto";
692
- import { existsSync as existsSync3, promises as fs2 } from "node:fs";
693
- import { join as join5, relative as relative2, resolve as resolve4, sep as sep3 } from "node:path";
694
- import { z as z3 } from "zod";
932
+ import { randomUUID as randomUUID2 } from "node:crypto";
933
+ import { promises as fs2 } from "node:fs";
934
+ import { join as join6, resolve as resolve4 } from "node:path";
935
+ import { z as z2 } from "zod";
695
936
 
696
937
  // src/analyze/analyzer.ts
697
938
  import { promises as fs } from "node:fs";
698
- import { join, posix, resolve, sep } from "node:path";
939
+ import { join as join2, posix, resolve as resolve2, sep as sep2 } from "node:path";
699
940
  var SERVER_RUNTIME_DEPS = ["express", "koa", "fastify", "hapi", "@hapi/hapi"];
700
941
  var DB_RUNTIME_DEPS = [
701
942
  "prisma",
@@ -740,7 +981,7 @@ async function readTextIfExists(path, maxBytes = CONTENT_READ_MAX_BYTES) {
740
981
  }
741
982
  async function firstExistingFile(dir, names) {
742
983
  for (const name of names) {
743
- const p = join(dir, name);
984
+ const p = join2(dir, name);
744
985
  if (await isFile(p)) return p;
745
986
  }
746
987
  return null;
@@ -769,10 +1010,10 @@ async function walkFiles(dir, opts) {
769
1010
  if (entry.isDirectory()) {
770
1011
  if (FORBIDDEN_SEGMENTS_LOWER.has(entry.name.toLowerCase())) continue;
771
1012
  if (opts.skipRelDirs?.has(rel)) continue;
772
- await recurse(join(current, entry.name), rel);
1013
+ await recurse(join2(current, entry.name), rel);
773
1014
  } else if (entry.isFile()) {
774
1015
  try {
775
- const stat2 = await fs.stat(join(current, entry.name));
1016
+ const stat2 = await fs.stat(join2(current, entry.name));
776
1017
  out.push({ path: rel, size: stat2.size });
777
1018
  } catch {
778
1019
  }
@@ -783,7 +1024,7 @@ async function walkFiles(dir, opts) {
783
1024
  return out;
784
1025
  }
785
1026
  async function readPackageJson(projectDir) {
786
- const raw = await readTextIfExists(join(projectDir, "package.json"));
1027
+ const raw = await readTextIfExists(join2(projectDir, "package.json"));
787
1028
  if (raw === null) return null;
788
1029
  try {
789
1030
  const parsed = JSON.parse(raw);
@@ -818,7 +1059,7 @@ async function detectFramework(projectDir, pkg) {
818
1059
  );
819
1060
  }
820
1061
  for (const apiDir of ["pages/api", "src/pages/api"]) {
821
- if (await isDirectory(join(projectDir, apiDir))) {
1062
+ if (await isDirectory(join2(projectDir, apiDir))) {
822
1063
  ssrRisks.push(
823
1064
  `API routes (${apiDir}/) require a server runtime and will not run on Sakupa. Remove them or move their logic to build time before static export.`
824
1065
  );
@@ -827,7 +1068,7 @@ async function detectFramework(projectDir, pkg) {
827
1068
  }
828
1069
  for (const appDir of ["app", "src/app"]) {
829
1070
  if (await anyFileMatches(
830
- join(projectDir, appDir),
1071
+ join2(projectDir, appDir),
831
1072
  (base) => /^route\.(ts|js|tsx|jsx|mjs)$/.test(base)
832
1073
  )) {
833
1074
  ssrRisks.push(
@@ -858,7 +1099,7 @@ async function detectFramework(projectDir, pkg) {
858
1099
  ]);
859
1100
  if ("nuxt" in deps || "nuxt3" in deps || nuxtConfigPath !== null) {
860
1101
  for (const serverDir of ["server/api", "server/routes"]) {
861
- if (await isDirectory(join(projectDir, serverDir))) {
1102
+ if (await isDirectory(join2(projectDir, serverDir))) {
862
1103
  ssrRisks.push(
863
1104
  `Nuxt server handlers (${serverDir}/) require a server runtime and will not run on Sakupa. Use static generation (npx nuxi generate) and deploy .output/public.`
864
1105
  );
@@ -900,7 +1141,7 @@ async function detectFramework(projectDir, pkg) {
900
1141
  "SvelteKit requires @sveltejs/adapter-static to produce a fully static build. Install and configure it, then build locally."
901
1142
  );
902
1143
  }
903
- if (await anyFileMatches(join(projectDir, "src/routes"), (base) => base.startsWith("+server."))) {
1144
+ if (await anyFileMatches(join2(projectDir, "src/routes"), (base) => base.startsWith("+server."))) {
904
1145
  ssrRisks.push(
905
1146
  "SvelteKit +server.* endpoint files require a server runtime and will not run on Sakupa."
906
1147
  );
@@ -965,17 +1206,17 @@ async function scanForUseServer(projectDir, skipRelDirs) {
965
1206
  if (scanned >= USE_SERVER_SCAN_MAX_FILES) break;
966
1207
  if (!SOURCE_SCAN_EXTENSIONS.has(extensionOf(file.path))) continue;
967
1208
  scanned += 1;
968
- const text2 = await readTextIfExists(join(projectDir, file.path), USE_SERVER_SCAN_MAX_BYTES);
1209
+ const text2 = await readTextIfExists(join2(projectDir, file.path), USE_SERVER_SCAN_MAX_BYTES);
969
1210
  if (text2 !== null && /['"]use server['"]/.test(text2)) return true;
970
1211
  }
971
1212
  return false;
972
1213
  }
973
1214
  function normalizeOutputDir(outputDir) {
974
- const normalized = posix.normalize(outputDir.replaceAll(sep, "/")).replace(/\/+$/, "");
1215
+ const normalized = posix.normalize(outputDir.replaceAll(sep2, "/")).replace(/\/+$/, "");
975
1216
  return normalized === "" ? "." : normalized;
976
1217
  }
977
1218
  async function analyzeProject(projectDir, opts = {}) {
978
- const root = resolve(projectDir);
1219
+ const root = await fs.realpath(resolve2(projectDir));
979
1220
  const pkg = await readPackageJson(root);
980
1221
  const detection = await detectFramework(root, pkg);
981
1222
  const ssrRisks = [...detection?.ssrRisks ?? []];
@@ -985,16 +1226,26 @@ async function analyzeProject(projectDir, opts = {}) {
985
1226
  let outputDirExists = false;
986
1227
  if (opts.outputDir !== void 0) {
987
1228
  outputDirRel = normalizeOutputDir(opts.outputDir);
988
- const abs = resolve(root, outputDirRel);
989
- if (abs !== root && !abs.startsWith(root + sep)) {
1229
+ const abs = resolve2(root, outputDirRel);
1230
+ if (abs !== root && !abs.startsWith(root + sep2)) {
990
1231
  outputDirRel = ".";
991
1232
  outputDirExists = false;
992
1233
  } else {
993
1234
  outputDirExists = await isDirectory(abs) || outputDirRel === "." && await isDirectory(root);
1235
+ if (outputDirExists) {
1236
+ try {
1237
+ const physical = await fs.realpath(abs);
1238
+ if (physical !== root && !physical.startsWith(root + sep2)) {
1239
+ outputDirExists = false;
1240
+ }
1241
+ } catch {
1242
+ outputDirExists = false;
1243
+ }
1244
+ }
994
1245
  }
995
1246
  } else if (detection) {
996
1247
  for (const candidate of detection.outputCandidates) {
997
- if (await isDirectory(join(root, candidate))) {
1248
+ if (await isDirectory(join2(root, candidate))) {
998
1249
  outputDirRel = candidate;
999
1250
  outputDirExists = true;
1000
1251
  break;
@@ -1009,7 +1260,7 @@ async function analyzeProject(projectDir, opts = {}) {
1009
1260
  outputDirExists = true;
1010
1261
  } else if (hasBuildScript) {
1011
1262
  for (const candidate of ["dist", "build", "out", "public"]) {
1012
- if (await isFile(join(root, candidate, "index.html"))) {
1263
+ if (await isFile(join2(root, candidate, "index.html"))) {
1013
1264
  outputDirRel = candidate;
1014
1265
  outputDirExists = true;
1015
1266
  break;
@@ -1029,7 +1280,7 @@ async function analyzeProject(projectDir, opts = {}) {
1029
1280
  }
1030
1281
  if (outputDirRel === void 0 || !outputDirExists) {
1031
1282
  ssrRisks.push(...serverAndDbDepRisks(pkg, false));
1032
- const sourceWithoutBuild = pkg !== null && buildRequired && (await isDirectory(join(root, "src")) || await isDirectory(join(root, "pages")));
1283
+ const sourceWithoutBuild = pkg !== null && buildRequired && (await isDirectory(join2(root, "src")) || await isDirectory(join2(root, "pages")));
1033
1284
  let suggestedNextAction2;
1034
1285
  if (opts.outputDir !== void 0) {
1035
1286
  suggestedNextAction2 = `The requested output directory "${opts.outputDir}" does not exist. Build the project locally first (${buildCommandHint ?? "npm run build"}) or pass the correct directory, then re-run analyze.`;
@@ -1057,7 +1308,7 @@ async function analyzeProject(projectDir, opts = {}) {
1057
1308
  suggestedNextAction: suggestedNextAction2
1058
1309
  };
1059
1310
  }
1060
- const outputAbs = outputDirRel === "." ? root : resolve(root, outputDirRel);
1311
+ const outputAbs = outputDirRel === "." ? root : resolve2(root, outputDirRel);
1061
1312
  const walked = await walkFiles(outputAbs, { maxFiles: MAX_FILE_COUNT + 1 });
1062
1313
  const candidates = [];
1063
1314
  for (const file of walked) {
@@ -1065,7 +1316,7 @@ async function analyzeProject(projectDir, opts = {}) {
1065
1316
  let content;
1066
1317
  if (TEXT_CONTENT_EXTENSIONS.has(ext) && file.size <= CONTENT_READ_MAX_BYTES) {
1067
1318
  try {
1068
- content = new Uint8Array(await fs.readFile(join(outputAbs, file.path)));
1319
+ content = new Uint8Array(await fs.readFile(join2(outputAbs, file.path)));
1069
1320
  } catch {
1070
1321
  content = void 0;
1071
1322
  }
@@ -1114,30 +1365,30 @@ async function analyzeProject(projectDir, opts = {}) {
1114
1365
 
1115
1366
  // src/project-file.ts
1116
1367
  import {
1117
- chmodSync,
1118
- existsSync,
1119
- mkdirSync,
1120
- readFileSync,
1368
+ chmodSync as chmodSync2,
1369
+ existsSync as existsSync2,
1370
+ mkdirSync as mkdirSync2,
1371
+ readFileSync as readFileSync2,
1121
1372
  rmdirSync,
1122
1373
  rmSync,
1123
- writeFileSync
1374
+ writeFileSync as writeFileSync2
1124
1375
  } from "node:fs";
1125
- import { dirname, join as join2 } from "node:path";
1376
+ import { dirname, join as join3 } from "node:path";
1126
1377
  var SITE_DIR = ".sakupa";
1127
1378
  var SITE_FILE = "site.json";
1128
1379
  var RECOVERY_FILE = "recovery.json";
1129
1380
  function siteFilePath(projectDir) {
1130
- return join2(projectDir, SITE_DIR, SITE_FILE);
1381
+ return join3(projectDir, SITE_DIR, SITE_FILE);
1131
1382
  }
1132
1383
  function recoveryFilePath(projectDir) {
1133
- return join2(projectDir, SITE_DIR, RECOVERY_FILE);
1384
+ return join3(projectDir, SITE_DIR, RECOVERY_FILE);
1134
1385
  }
1135
1386
  function loadSiteFile(projectDir) {
1136
1387
  const path = siteFilePath(projectDir);
1137
- if (!existsSync(path)) return { kind: "absent" };
1388
+ if (!existsSync2(path)) return { kind: "absent" };
1138
1389
  let raw;
1139
1390
  try {
1140
- raw = readFileSync(path, "utf8");
1391
+ raw = readFileSync2(path, "utf8");
1141
1392
  } catch (err2) {
1142
1393
  return {
1143
1394
  kind: "corrupted",
@@ -1180,9 +1431,9 @@ function loadSiteFile(projectDir) {
1180
1431
  }
1181
1432
  function loadRecoveryFile(projectDir) {
1182
1433
  const path = recoveryFilePath(projectDir);
1183
- if (!existsSync(path)) return null;
1434
+ if (!existsSync2(path)) return null;
1184
1435
  try {
1185
- const parsed = JSON.parse(readFileSync(path, "utf8"));
1436
+ const parsed = JSON.parse(readFileSync2(path, "utf8"));
1186
1437
  if (typeof parsed.verificationId !== "string" || parsed.verificationId.length === 0 || typeof parsed.credential !== "string" || !CREDENTIAL_PATTERN.test(parsed.credential)) {
1187
1438
  throw new Error("required recovery fields are missing or invalid");
1188
1439
  }
@@ -1198,19 +1449,19 @@ function loadRecoveryFile(projectDir) {
1198
1449
  }
1199
1450
  }
1200
1451
  function writeRecoveryFile(projectDir, file) {
1201
- const dir = join2(projectDir, SITE_DIR);
1202
- mkdirSync(dir, { recursive: true });
1203
- const path = join2(dir, RECOVERY_FILE);
1204
- writeFileSync(path, `${JSON.stringify(file, null, 2)}
1452
+ const dir = join3(projectDir, SITE_DIR);
1453
+ mkdirSync2(dir, { recursive: true });
1454
+ const path = join3(dir, RECOVERY_FILE);
1455
+ writeFileSync2(path, `${JSON.stringify(file, null, 2)}
1205
1456
  `, "utf8");
1206
1457
  try {
1207
- chmodSync(path, 384);
1458
+ chmodSync2(path, 384);
1208
1459
  } catch {
1209
1460
  }
1210
1461
  }
1211
1462
  function deleteRecoveryFile(projectDir) {
1212
1463
  const path = recoveryFilePath(projectDir);
1213
- if (existsSync(path)) rmSync(path, { force: true });
1464
+ if (existsSync2(path)) rmSync(path, { force: true });
1214
1465
  }
1215
1466
  function siteFileRecoveryGuidance(projectDir) {
1216
1467
  return `The site itself is intact on the server; only the local binding file (${siteFilePath(projectDir)}) is the problem. Restore the file (from a backup or by undoing the local edit). Do NOT delete it to work around the error: the credential is unrecoverable by design, so abandoning it permanently orphans the existing site. Publishing this project as a brand-NEW site requires the user to manually delete the .sakupa directory first \u2014 the tool will never overwrite it.`;
@@ -1229,23 +1480,23 @@ function writeSiteFile(projectDir, file, opts = {}) {
1229
1480
  );
1230
1481
  }
1231
1482
  }
1232
- const dir = join2(projectDir, SITE_DIR);
1233
- mkdirSync(dir, { recursive: true });
1234
- const path = join2(dir, SITE_FILE);
1235
- writeFileSync(path, `${JSON.stringify(file, null, 2)}
1483
+ const dir = join3(projectDir, SITE_DIR);
1484
+ mkdirSync2(dir, { recursive: true });
1485
+ const path = join3(dir, SITE_FILE);
1486
+ writeFileSync2(path, `${JSON.stringify(file, null, 2)}
1236
1487
  `, "utf8");
1237
1488
  try {
1238
- chmodSync(path, 384);
1489
+ chmodSync2(path, 384);
1239
1490
  } catch {
1240
1491
  }
1241
1492
  }
1242
1493
  function deleteSiteFile(projectDir) {
1243
1494
  const path = siteFilePath(projectDir);
1244
- if (existsSync(path)) {
1495
+ if (existsSync2(path)) {
1245
1496
  rmSync(path, { force: true });
1246
1497
  }
1247
1498
  try {
1248
- rmdirSync(join2(projectDir, SITE_DIR));
1499
+ rmdirSync(join3(projectDir, SITE_DIR));
1249
1500
  } catch {
1250
1501
  }
1251
1502
  }
@@ -1260,7 +1511,7 @@ function findAncestor(startDir, predicate, maxLevels = Number.POSITIVE_INFINITY)
1260
1511
  return null;
1261
1512
  }
1262
1513
  function isInsideGitRepo(projectDir) {
1263
- return existsSync(join2(projectDir, ".git")) || findAncestor(projectDir, (dir) => existsSync(join2(dir, ".git"))) !== null;
1514
+ return existsSync2(join3(projectDir, ".git")) || findAncestor(projectDir, (dir) => existsSync2(join3(dir, ".git"))) !== null;
1264
1515
  }
1265
1516
  function credentialGitReminder(projectDir) {
1266
1517
  if (!isInsideGitRepo(projectDir)) return "";
@@ -1268,8 +1519,9 @@ function credentialGitReminder(projectDir) {
1268
1519
  }
1269
1520
 
1270
1521
  // src/recovery-archive.ts
1522
+ import { existsSync as existsSync3, realpathSync as realpathSync2 } from "node:fs";
1271
1523
  import { mkdtemp, mkdir, readFile, readdir, rename, rm, stat, writeFile } from "node:fs/promises";
1272
- import { dirname as dirname2, isAbsolute, join as join3, relative, resolve as resolve2, sep as sep2 } from "node:path";
1524
+ import { dirname as dirname2, isAbsolute as isAbsolute2, join as join4, relative as relative2, resolve as resolve3, sep as sep3 } from "node:path";
1273
1525
 
1274
1526
  // ../../node_modules/fflate/esm/index.mjs
1275
1527
  import { createRequire } from "module";
@@ -1688,15 +1940,15 @@ function strFromU8(dat, latin1) {
1688
1940
  var slzh = function(d, b) {
1689
1941
  return b + 30 + b2(d, b + 26) + b2(d, b + 28);
1690
1942
  };
1691
- var zh = function(d, b, z6) {
1943
+ var zh = function(d, b, z5) {
1692
1944
  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;
1693
- 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];
1945
+ 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];
1694
1946
  return [b2(d, b + 10), sc, su, fn, es + efl + b2(d, b + 32), off];
1695
1947
  };
1696
- var z64hs = function(d, b, l, z6, sc, su, off) {
1948
+ var z64hs = function(d, b, l, z5, sc, su, off) {
1697
1949
  var nsc = sc == 4294967295, nsu = su == 4294967295, noff = off == 4294967295, e = b + l;
1698
1950
  var nf = nsc + nsu + noff;
1699
- if (z6 && nf) {
1951
+ if (z5 && nf) {
1700
1952
  for (; b + 4 < e; b += 4 + b2(d, b + 2)) {
1701
1953
  if (b2(d, b) == 1) {
1702
1954
  return [
@@ -1707,7 +1959,7 @@ var z64hs = function(d, b, l, z6, sc, su, off) {
1707
1959
  ];
1708
1960
  }
1709
1961
  }
1710
- if (z6 < 2)
1962
+ if (z5 < 2)
1711
1963
  err(13);
1712
1964
  }
1713
1965
  return [sc, su, off, 0];
@@ -1724,18 +1976,18 @@ function unzipSync(data, opts) {
1724
1976
  if (!c)
1725
1977
  return {};
1726
1978
  var o = b4(data, e + 16);
1727
- var z6 = b4(data, e - 20) == 117853008;
1728
- if (z6) {
1979
+ var z5 = b4(data, e - 20) == 117853008;
1980
+ if (z5) {
1729
1981
  var ze = b4(data, e - 12);
1730
- z6 = b4(data, ze) == 101075792;
1731
- if (z6) {
1982
+ z5 = b4(data, ze) == 101075792;
1983
+ if (z5) {
1732
1984
  c = b4(data, ze + 32);
1733
1985
  o = b4(data, ze + 48);
1734
1986
  }
1735
1987
  }
1736
1988
  var fltr = opts && opts.filter;
1737
1989
  for (var i = 0; i < c; ++i) {
1738
- 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);
1990
+ 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);
1739
1991
  o = no;
1740
1992
  if (!fltr || fltr({
1741
1993
  name: fn,
@@ -1756,18 +2008,33 @@ function unzipSync(data, opts) {
1756
2008
 
1757
2009
  // src/recovery-archive.ts
1758
2010
  function safeOutputPath(projectDir, outputDir) {
1759
- if (outputDir.length === 0 || isAbsolute(outputDir)) {
2011
+ if (outputDir.length === 0 || isAbsolute2(outputDir)) {
1760
2012
  throw new SakupaError("invalid_request", "Recovery outputDir must be a relative directory");
1761
2013
  }
1762
- const root = resolve2(projectDir);
1763
- const target = resolve2(root, outputDir);
1764
- const rel = relative(root, target);
1765
- if (rel === "" || rel === ".." || rel.startsWith(`..${sep2}`) || isAbsolute(rel)) {
2014
+ const root = realpathSync2(resolve3(projectDir));
2015
+ const target = resolve3(root, outputDir);
2016
+ const rel = relative2(root, target);
2017
+ if (rel === "" || rel === ".." || rel.startsWith(`..${sep3}`) || isAbsolute2(rel)) {
1766
2018
  throw new SakupaError("invalid_request", "Recovery outputDir must stay inside projectDir");
1767
2019
  }
1768
- if (rel === ".sakupa" || rel.startsWith(`.sakupa${sep2}`)) {
2020
+ if (rel === ".sakupa" || rel.startsWith(`.sakupa${sep3}`)) {
1769
2021
  throw new SakupaError("invalid_request", "Recovery content cannot be written inside .sakupa");
1770
2022
  }
2023
+ let existingAncestor = target;
2024
+ while (!existsSync3(existingAncestor)) {
2025
+ const parent = dirname2(existingAncestor);
2026
+ if (parent === existingAncestor) break;
2027
+ existingAncestor = parent;
2028
+ }
2029
+ const physicalAncestor = realpathSync2(existingAncestor);
2030
+ const physicalTarget = resolve3(physicalAncestor, relative2(existingAncestor, target));
2031
+ const physicalRel = relative2(root, physicalTarget);
2032
+ if (physicalRel === ".." || physicalRel.startsWith(`..${sep3}`) || isAbsolute2(physicalRel)) {
2033
+ throw new SakupaError(
2034
+ "invalid_request",
2035
+ "Recovery outputDir resolves through a symlink outside projectDir"
2036
+ );
2037
+ }
1771
2038
  return target;
1772
2039
  }
1773
2040
  function safeEntryName(name) {
@@ -1796,9 +2063,9 @@ async function listExistingFiles(root, current = root) {
1796
2063
  if (entry.isSymbolicLink()) {
1797
2064
  throw new SakupaError("state_conflict", `Recovery output contains a symlink: ${entry.name}`);
1798
2065
  }
1799
- const absolute = join3(current, entry.name);
2066
+ const absolute = join4(current, entry.name);
1800
2067
  if (entry.isDirectory()) files.push(...await listExistingFiles(root, absolute));
1801
- else if (entry.isFile()) files.push(relative(root, absolute).split(sep2).join("/"));
2068
+ else if (entry.isFile()) files.push(relative2(root, absolute).split(sep3).join("/"));
1802
2069
  else
1803
2070
  throw new SakupaError(
1804
2071
  "state_conflict",
@@ -1814,7 +2081,7 @@ async function existingOutputMatches(outputDir, files) {
1814
2081
  return false;
1815
2082
  }
1816
2083
  for (const name of expected) {
1817
- const actual = await readFile(join3(outputDir, ...name.split("/")));
2084
+ const actual = await readFile(join4(outputDir, ...name.split("/")));
1818
2085
  const wanted = files[name];
1819
2086
  if (!wanted || actual.byteLength !== wanted.byteLength || !actual.equals(wanted)) return false;
1820
2087
  }
@@ -1824,7 +2091,7 @@ async function extractRecoveryArchive(input) {
1824
2091
  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) {
1825
2092
  throw new SakupaError("validation_failed", "Recovery archive metadata exceeds product limits");
1826
2093
  }
1827
- const outputDir = safeOutputPath(input.projectDir, input.outputDir ?? "html");
2094
+ const outputDir = safeOutputPath(input.projectDir, input.outputDir);
1828
2095
  const maxArchiveBytes = input.expectedBytes + input.expectedFiles * 4096 + 65536;
1829
2096
  if (input.archive.byteLength > maxArchiveBytes) {
1830
2097
  throw new SakupaError("validation_failed", "Recovery archive is larger than its site metadata");
@@ -1865,13 +2132,13 @@ async function extractRecoveryArchive(input) {
1865
2132
  `Recovery output already exists with different content: ${outputDir}. Move it aside or choose another outputDir.`
1866
2133
  );
1867
2134
  }
1868
- const tempDir = await mkdtemp(join3(resolve2(input.projectDir), ".sakupa-restore-"));
2135
+ const tempDir = await mkdtemp(join4(resolve3(input.projectDir), ".sakupa-restore-"));
1869
2136
  try {
1870
2137
  let writtenBytes = 0;
1871
2138
  const entries = Object.entries(files).sort(([a], [b]) => a.localeCompare(b));
1872
2139
  for (const [rawName, data] of entries) {
1873
2140
  const name = safeEntryName(rawName);
1874
- const destination = join3(tempDir, ...name.split("/"));
2141
+ const destination = join4(tempDir, ...name.split("/"));
1875
2142
  await mkdir(dirname2(destination), { recursive: true });
1876
2143
  await writeFile(destination, data, { flag: "wx" });
1877
2144
  writtenBytes += data.byteLength;
@@ -1897,19 +2164,19 @@ async function extractRecoveryArchive(input) {
1897
2164
  }
1898
2165
 
1899
2166
  // src/creation-registry.ts
1900
- import { existsSync as existsSync2, mkdirSync as mkdirSync2, readFileSync as readFileSync2, writeFileSync as writeFileSync2 } from "node:fs";
1901
- import { homedir } from "node:os";
1902
- import { dirname as dirname3, join as join4 } from "node:path";
2167
+ import { existsSync as existsSync4, mkdirSync as mkdirSync3, readFileSync as readFileSync3, writeFileSync as writeFileSync3 } from "node:fs";
2168
+ import { homedir as homedir2 } from "node:os";
2169
+ import { dirname as dirname3, join as join5 } from "node:path";
1903
2170
  var RECENT_WINDOW_MS = FREE_SITE_TTL_HOURS * 60 * 60 * 1e3;
1904
2171
  function creationRegistryPath() {
1905
- const base = process.env["SAKUPA_STATE_DIR"] ?? homedir();
1906
- return join4(base, ".sakupa", "created-sites.json");
2172
+ const base = process.env["SAKUPA_STATE_DIR"] ?? homedir2();
2173
+ return join5(base, ".sakupa", "created-sites.json");
1907
2174
  }
1908
2175
  function readAll() {
1909
2176
  const path = creationRegistryPath();
1910
- if (!existsSync2(path)) return [];
2177
+ if (!existsSync4(path)) return [];
1911
2178
  try {
1912
- const parsed = JSON.parse(readFileSync2(path, "utf-8"));
2179
+ const parsed = JSON.parse(readFileSync3(path, "utf-8"));
1913
2180
  if (!Array.isArray(parsed)) return [];
1914
2181
  return parsed.filter(
1915
2182
  (e) => typeof e === "object" && e !== null && typeof e.siteId === "string" && typeof e.createdAt === "string"
@@ -1920,8 +2187,8 @@ function readAll() {
1920
2187
  }
1921
2188
  function writeAll(records) {
1922
2189
  const path = creationRegistryPath();
1923
- mkdirSync2(dirname3(path), { recursive: true });
1924
- writeFileSync2(path, `${JSON.stringify(records, null, 2)}
2190
+ mkdirSync3(dirname3(path), { recursive: true });
2191
+ writeFileSync3(path, `${JSON.stringify(records, null, 2)}
1925
2192
  `, "utf-8");
1926
2193
  }
1927
2194
  function listRecentCreations(nowMs, apiBaseUrl) {
@@ -2077,12 +2344,6 @@ ${diag.layers}
2077
2344
  var MCP_VERSION = SAKUPA_MCP_VERSION;
2078
2345
  var CLIENT_TYPE = "sakupa-mcp";
2079
2346
 
2080
- // src/tools/context.ts
2081
- import { z as z2 } from "zod";
2082
- import { statSync } from "node:fs";
2083
- import { homedir as homedir2 } from "node:os";
2084
- import { isAbsolute as isAbsolute2, parse, resolve as resolve3 } from "node:path";
2085
-
2086
2347
  // src/tools/result.ts
2087
2348
  import { z } from "zod";
2088
2349
  var STRUCTURED_TOOL_OUTPUT_SCHEMA = {
@@ -2130,37 +2391,25 @@ var LocalGuidanceError = class extends SakupaError {
2130
2391
  super(code, message);
2131
2392
  }
2132
2393
  };
2133
- var projectDirInput = z2.string().describe(
2134
- "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."
2135
- );
2136
- function withProjectDir(ctx, projectDirArg) {
2137
- if (projectDirArg === void 0) {
2138
- throw new LocalGuidanceError(
2139
- "invalid_request",
2140
- "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."
2141
- );
2142
- }
2143
- if (!isAbsolute2(projectDirArg)) {
2144
- throw new LocalGuidanceError(
2145
- "invalid_request",
2146
- `projectDir must be an ABSOLUTE path (got "${projectDirArg}"). Pass the full path of the directory the user is currently working in.`
2147
- );
2148
- }
2149
- const dir = resolve3(projectDirArg);
2150
- if (parse(dir).root === dir || dir === homedir2()) {
2151
- throw new LocalGuidanceError(
2152
- "invalid_request",
2153
- `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.`
2154
- );
2155
- }
2156
- const stat2 = statSync(dir, { throwIfNoEntry: false });
2157
- if (!stat2?.isDirectory()) {
2158
- throw new LocalGuidanceError(
2159
- "invalid_request",
2160
- `projectDir "${dir}" does not exist or is not a directory. Pass the absolute path of the directory the user is currently working in.`
2161
- );
2394
+ function withProjectDir(ctx) {
2395
+ try {
2396
+ const resolved = resolveLockedProjectRoot(ctx.projectDir);
2397
+ return {
2398
+ ...ctx,
2399
+ projectDir: resolved.projectDir,
2400
+ requestedPath: resolved.requestedPath,
2401
+ markerKind: resolved.markerKind,
2402
+ projectMarker: resolved.marker
2403
+ };
2404
+ } catch (error) {
2405
+ if (error instanceof ProjectRootError) {
2406
+ throw new LocalGuidanceError(
2407
+ error.code === "not_initialized" ? "not_found" : "invalid_request",
2408
+ error.message
2409
+ );
2410
+ }
2411
+ throw error;
2162
2412
  }
2163
- return { ...ctx, projectDir: dir };
2164
2413
  }
2165
2414
  function requireSiteFile(ctx) {
2166
2415
  const state = loadSiteFile(ctx.projectDir);
@@ -2173,7 +2422,7 @@ function requireSiteFile(ctx) {
2173
2422
  if (state.kind === "absent") {
2174
2423
  throw new LocalGuidanceError(
2175
2424
  "not_found",
2176
- `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.`
2425
+ `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.`
2177
2426
  );
2178
2427
  }
2179
2428
  return state.file;
@@ -2239,12 +2488,12 @@ ${JSON.stringify(obj, null, 2)}`;
2239
2488
  nextActions: []
2240
2489
  });
2241
2490
  }
2242
- var planEnum = z3.enum(["water", "personal", "share", "business"]);
2243
- var severityEnum = z3.enum(["low", "medium", "high", "critical"]);
2491
+ var planEnum = z2.enum(["water", "personal", "share", "business"]);
2492
+ var severityEnum = z2.enum(["low", "medium", "high", "critical"]);
2244
2493
  function planCatalog() {
2245
2494
  return TIER_ORDER.map((p) => `${p} JPY ${tierPriceJpy(p)}/month`).join(", ");
2246
2495
  }
2247
- var ticketCategoryEnum = z3.enum([
2496
+ var ticketCategoryEnum = z2.enum([
2248
2497
  "billing",
2249
2498
  "payment",
2250
2499
  "refund_review",
@@ -2292,7 +2541,7 @@ function ensureUploadSizeWithinLimits(manifest, isFirstFreeDeploy) {
2292
2541
  async function buildHashedManifest(files, outputAbs) {
2293
2542
  const manifest = [];
2294
2543
  for (const file of files) {
2295
- const bytes = new Uint8Array(await fs2.readFile(join5(outputAbs, file.path)));
2544
+ const bytes = new Uint8Array(await fs2.readFile(join6(outputAbs, file.path)));
2296
2545
  manifest.push({ path: file.path, size: file.size, contentHash: await sha256Hex(bytes) });
2297
2546
  }
2298
2547
  return manifest;
@@ -2311,7 +2560,7 @@ async function uploadAll(ctx, targets, files, outputAbs) {
2311
2560
  `No local file matches upload target "${target.path}"; aborting upload.`
2312
2561
  );
2313
2562
  }
2314
- const bytes = new Uint8Array(await fs2.readFile(join5(outputAbs, match.path)));
2563
+ const bytes = new Uint8Array(await fs2.readFile(join6(outputAbs, match.path)));
2315
2564
  if (bytes.byteLength !== match.size) {
2316
2565
  throw new SakupaError(
2317
2566
  "validation_failed",
@@ -2354,21 +2603,6 @@ ${block}`, checklist: toDnsChecklist(diag.checks) };
2354
2603
  };
2355
2604
  }
2356
2605
  }
2357
- function projectRootAbove(projectDir) {
2358
- if (existsSync3(join5(projectDir, "package.json"))) return null;
2359
- const packageRoot = findAncestor(projectDir, (dir) => existsSync3(join5(dir, "package.json")), 4);
2360
- if (packageRoot) {
2361
- return {
2362
- projectRoot: packageRoot,
2363
- outputDir: relative2(packageRoot, projectDir).split(sep3).join("/")
2364
- };
2365
- }
2366
- return null;
2367
- }
2368
- function findNeighborBinding(projectDir) {
2369
- const bound = (dir) => loadSiteFile(dir).kind !== "absent";
2370
- return findAncestor(projectDir, bound, 3);
2371
- }
2372
2606
  function freeSiteCreationBarrier(apiBaseUrl) {
2373
2607
  const recent = listRecentCreations(Date.now(), apiBaseUrl);
2374
2608
  if (recent.length < FREE_ACTIVE_SITES_PER_IP) return null;
@@ -2379,7 +2613,7 @@ function freeSiteCreationBarrier(apiBaseUrl) {
2379
2613
 
2380
2614
  ` + recent.map((r) => `- ${r.url} (project: ${r.projectDir}, created: ${r.createdAt})`).join("\n") + `
2381
2615
 
2382
- 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.
2616
+ 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.
2383
2617
 
2384
2618
  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.`,
2385
2619
  { recentCreations: recent, limit: FREE_ACTIVE_SITES_PER_IP, registryPath },
@@ -2395,13 +2629,12 @@ function registerTools(server, baseCtx) {
2395
2629
  outputSchema: STRUCTURED_TOOL_OUTPUT_SCHEMA,
2396
2630
  annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: false },
2397
2631
  inputSchema: {
2398
- projectDir: projectDirInput,
2399
- outputDir: z3.string().optional().describe("Output directory relative to the project root (overrides detection).")
2632
+ outputDir: z2.string().optional().describe("Output directory relative to the project root (overrides detection).")
2400
2633
  }
2401
2634
  },
2402
2635
  async (args) => {
2403
2636
  try {
2404
- const ctx = withProjectDir(baseCtx, args.projectDir);
2637
+ const ctx = withProjectDir(baseCtx);
2405
2638
  const analysis = await analyzeProject(ctx.projectDir, {
2406
2639
  ...args.outputDir !== void 0 ? { outputDir: args.outputDir } : {}
2407
2640
  });
@@ -2419,35 +2652,54 @@ Next action: ${analysis.suggestedNextAction}`,
2419
2652
  server.registerTool(
2420
2653
  "deploy",
2421
2654
  {
2422
- 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.`,
2655
+ 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.`,
2423
2656
  outputSchema: STRUCTURED_TOOL_OUTPUT_SCHEMA,
2424
2657
  annotations: { readOnlyHint: false, destructiveHint: true, openWorldHint: true },
2425
2658
  inputSchema: {
2426
- projectDir: projectDirInput,
2427
- outputDir: z3.string().min(1).describe(
2428
- '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.'
2659
+ outputDir: z2.string().min(1).describe(
2660
+ '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.'
2661
+ ),
2662
+ outputDirChangeConfirmed: z2.boolean().optional().describe(
2663
+ "Required only when changing the previously successful publish directory. Confirm only after showing the old and new directories to the user."
2429
2664
  ),
2430
- spaFallback: z3.boolean().optional().describe(
2665
+ spaFallback: z2.boolean().optional().describe(
2431
2666
  "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."
2432
2667
  ),
2433
- publicConfirmed: z3.boolean().optional().describe(
2668
+ publicConfirmed: z2.boolean().optional().describe(
2434
2669
  "Required only for the first deployment: user explicitly confirmed creation of a public 24-hour URL."
2435
2670
  ),
2436
- subprojectConfirmed: z3.boolean().optional().describe(
2437
- "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."
2671
+ subprojectConfirmed: z2.boolean().optional().describe(
2672
+ "Deprecated compatibility field. Project independence is established only by `sakupa-mcp init`, never inferred from package.json or folder names."
2438
2673
  ),
2439
- lang: z3.string().optional().describe("Site language override (en | ja | zh-CN); defaults to the html lang.")
2674
+ lang: z2.string().optional().describe("Site language override (en | ja | zh-CN); defaults to the html lang.")
2440
2675
  }
2441
2676
  },
2442
2677
  async (args) => {
2443
2678
  try {
2444
- const ctx = withProjectDir(baseCtx, args.projectDir);
2679
+ const ctx = withProjectDir(baseCtx);
2445
2680
  const analysis = await analyzeProject(ctx.projectDir, { outputDir: args.outputDir });
2446
2681
  if (!analysis.deployable || !analysis.files) {
2447
2682
  return notDeployableResult(analysis);
2448
2683
  }
2684
+ const effectiveOutputDir = analysis.recommendedOutputDir ?? ".";
2685
+ const recordedOutputDir = ctx.projectMarker?.outputDir;
2686
+ if (recordedOutputDir !== void 0 && resolve4(ctx.projectDir, recordedOutputDir) !== resolve4(ctx.projectDir, effectiveOutputDir) && args.outputDirChangeConfirmed !== true) {
2687
+ return structuredToolResult({
2688
+ schemaVersion: 1,
2689
+ outcome: "waiting_user",
2690
+ resultCode: "publish_directory_change_confirmation_required",
2691
+ 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.`,
2692
+ data: {
2693
+ projectDir: ctx.projectDir,
2694
+ previousOutputDir: recordedOutputDir,
2695
+ requestedOutputDir: effectiveOutputDir,
2696
+ confirmationField: "outputDirChangeConfirmed"
2697
+ },
2698
+ nextActions: [{ tool: "deploy", allowed: true, reasonCode: "explicit_confirmation" }]
2699
+ });
2700
+ }
2449
2701
  const files = analysis.files;
2450
- const outputAbs = resolve4(ctx.projectDir, analysis.recommendedOutputDir ?? ".");
2702
+ const outputAbs = resolve4(ctx.projectDir, effectiveOutputDir);
2451
2703
  const manifest = await buildHashedManifest(files, outputAbs);
2452
2704
  const siteFileState = loadSiteFile(ctx.projectDir);
2453
2705
  if (siteFileState.kind === "corrupted") {
@@ -2462,46 +2714,31 @@ Next action: ${analysis.suggestedNextAction}`,
2462
2714
  }
2463
2715
  let existing = siteFileState.kind === "ok" ? siteFileState.file : null;
2464
2716
  let credentialRelocatedFrom = null;
2465
- if (!existing && analysis.recommendedOutputDir !== ".") {
2717
+ if (!existing && effectiveOutputDir !== ".") {
2718
+ const outputProjectMarker = loadProjectMarker(outputAbs);
2719
+ if (outputProjectMarker.kind !== "absent") {
2720
+ return text(
2721
+ "publish_directory_is_independent_project",
2722
+ 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.`,
2723
+ { projectRoot: ctx.projectDir, outputDir: effectiveOutputDir },
2724
+ "blocked"
2725
+ );
2726
+ }
2466
2727
  const outputSiteState = loadSiteFile(outputAbs);
2467
2728
  if (outputSiteState.kind === "corrupted") {
2468
2729
  return text(
2469
2730
  "output_site_file_corrupted",
2470
- `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.`,
2731
+ `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.`,
2471
2732
  { projectRoot: ctx.projectDir, outputDir: analysis.recommendedOutputDir },
2472
2733
  "blocked"
2473
2734
  );
2474
2735
  }
2475
2736
  if (outputSiteState.kind === "ok") {
2476
- writeSiteFile(ctx.projectDir, outputSiteState.file);
2477
- deleteSiteFile(outputAbs);
2478
2737
  existing = outputSiteState.file;
2479
2738
  credentialRelocatedFrom = outputAbs;
2480
2739
  }
2481
2740
  }
2482
2741
  if (!existing) {
2483
- const rootHint = args.subprojectConfirmed === true ? null : projectRootAbove(ctx.projectDir);
2484
- if (rootHint) {
2485
- return text(
2486
- "not_project_root",
2487
- `${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.`,
2488
- {
2489
- projectRoot: rootHint.projectRoot,
2490
- outputDir: rootHint.outputDir,
2491
- confirmationField: "subprojectConfirmed"
2492
- },
2493
- "blocked"
2494
- );
2495
- }
2496
- const neighbor = findNeighborBinding(ctx.projectDir);
2497
- if (neighbor) {
2498
- return text(
2499
- "neighbor_binding_found",
2500
- `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.`,
2501
- { neighborProjectDir: neighbor },
2502
- "blocked"
2503
- );
2504
- }
2505
2742
  const barrier = freeSiteCreationBarrier(ctx.apiBaseUrl);
2506
2743
  if (barrier) return barrier;
2507
2744
  if (args.publicConfirmed !== true) {
@@ -2535,6 +2772,7 @@ Next action: ${analysis.suggestedNextAction}`,
2535
2772
  createdAt,
2536
2773
  apiBaseUrl: ctx.apiBaseUrl
2537
2774
  });
2775
+ updateProjectOutputDir(ctx.projectDir, effectiveOutputDir);
2538
2776
  recordCreation({
2539
2777
  siteId: created.siteId,
2540
2778
  projectDir: ctx.projectDir,
@@ -2609,6 +2847,8 @@ ${JSON.stringify(finalized2.warnings, null, 2)}` : ""),
2609
2847
  }
2610
2848
  const { uploaded, finalized } = update;
2611
2849
  writeSiteFile(ctx.projectDir, { ...existing, url: finalized.url });
2850
+ if (credentialRelocatedFrom !== null) deleteSiteFile(credentialRelocatedFrom);
2851
+ updateProjectOutputDir(ctx.projectDir, effectiveOutputDir);
2612
2852
  noteSiteMode(existing.siteId, finalized.mode);
2613
2853
  return text(
2614
2854
  "site_updated",
@@ -2647,11 +2887,11 @@ ${JSON.stringify(finalized.warnings, null, 2)}` : ""),
2647
2887
  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.",
2648
2888
  outputSchema: STRUCTURED_TOOL_OUTPUT_SCHEMA,
2649
2889
  annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: true },
2650
- inputSchema: { projectDir: projectDirInput }
2890
+ inputSchema: {}
2651
2891
  },
2652
- async (args) => {
2892
+ async () => {
2653
2893
  try {
2654
- const ctx = withProjectDir(baseCtx, args.projectDir);
2894
+ const ctx = withProjectDir(baseCtx);
2655
2895
  const site = requireSiteFile(ctx);
2656
2896
  const res = await ctx.client.refreshSite(site.siteId, site.credential);
2657
2897
  return text(
@@ -2671,11 +2911,11 @@ NO content was uploaded or changed by this call \u2014 to publish new or edited
2671
2911
  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.",
2672
2912
  outputSchema: STRUCTURED_TOOL_OUTPUT_SCHEMA,
2673
2913
  annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: true },
2674
- inputSchema: { projectDir: projectDirInput }
2914
+ inputSchema: {}
2675
2915
  },
2676
- async (args) => {
2916
+ async () => {
2677
2917
  try {
2678
- const ctx = withProjectDir(baseCtx, args.projectDir);
2918
+ const ctx = withProjectDir(baseCtx);
2679
2919
  const site = requireSiteFile(ctx);
2680
2920
  const res = await ctx.client.getSiteStatus(site.siteId, site.credential);
2681
2921
  noteSiteMode(res.siteId, res.mode);
@@ -2703,7 +2943,6 @@ NO content was uploaded or changed by this call \u2014 to publish new or edited
2703
2943
  outputSchema: STRUCTURED_TOOL_OUTPUT_SCHEMA,
2704
2944
  annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: true },
2705
2945
  inputSchema: {
2706
- projectDir: projectDirInput,
2707
2946
  plan: planEnum.describe(
2708
2947
  "Monthly plan: water (very light personal pages), personal (personal brand / small shop), share (small-business site), business (steadier traffic, more headroom)."
2709
2948
  )
@@ -2711,13 +2950,13 @@ NO content was uploaded or changed by this call \u2014 to publish new or edited
2711
2950
  },
2712
2951
  async (args) => {
2713
2952
  try {
2714
- const ctx = withProjectDir(baseCtx, args.projectDir);
2953
+ const ctx = withProjectDir(baseCtx);
2715
2954
  const site = requireSiteFile(ctx);
2716
2955
  const res = await ctx.client.createPlanCheckout(
2717
2956
  {
2718
2957
  siteId: site.siteId,
2719
2958
  plan: args.plan,
2720
- idempotencyKey: randomUUID()
2959
+ idempotencyKey: randomUUID2()
2721
2960
  },
2722
2961
  site.credential
2723
2962
  );
@@ -2750,17 +2989,16 @@ Once payment confirms, the site becomes permanent on its current URL. Binding a
2750
2989
  outputSchema: STRUCTURED_TOOL_OUTPUT_SCHEMA,
2751
2990
  annotations: { readOnlyHint: false, destructiveHint: true, openWorldHint: true },
2752
2991
  inputSchema: {
2753
- projectDir: projectDirInput,
2754
- action: z3.enum(["start", "status"]),
2755
- hostname: z3.string().optional().describe("Required for start."),
2756
- verificationId: z3.string().optional().describe(
2992
+ action: z2.enum(["start", "status"]),
2993
+ hostname: z2.string().optional().describe("Required for start."),
2994
+ verificationId: z2.string().optional().describe(
2757
2995
  "Optional for status: when omitted, the server finds this site's latest binding verification \u2014 a NEW session can resume without it."
2758
2996
  )
2759
2997
  }
2760
2998
  },
2761
2999
  async (args) => {
2762
3000
  try {
2763
- const ctx = withProjectDir(baseCtx, args.projectDir);
3001
+ const ctx = withProjectDir(baseCtx);
2764
3002
  const site = requireSiteFile(ctx);
2765
3003
  if (args.action === "status") {
2766
3004
  const res2 = args.verificationId ? await ctx.client.checkVerification(args.verificationId, site.credential) : await ctx.client.checkVerification("latest", site.credential, site.siteId);
@@ -2865,11 +3103,11 @@ When the user says the TXT is set, run bind "status". It verifies ownership and
2865
3103
  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).",
2866
3104
  outputSchema: STRUCTURED_TOOL_OUTPUT_SCHEMA,
2867
3105
  annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: true },
2868
- inputSchema: { projectDir: projectDirInput }
3106
+ inputSchema: {}
2869
3107
  },
2870
- async (args) => {
3108
+ async () => {
2871
3109
  try {
2872
- const ctx = withProjectDir(baseCtx, args.projectDir);
3110
+ const ctx = withProjectDir(baseCtx);
2873
3111
  const site = requireSiteFile(ctx);
2874
3112
  const res = await ctx.client.getBillingStatus(site.siteId, site.credential);
2875
3113
  noteSiteMode(res.siteId, res.mode);
@@ -2907,14 +3145,13 @@ Full status:`, res);
2907
3145
  outputSchema: STRUCTURED_TOOL_OUTPUT_SCHEMA,
2908
3146
  annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: true },
2909
3147
  inputSchema: {
2910
- projectDir: projectDirInput,
2911
- scope: z3.enum(["site", "public_recovery"])
3148
+ scope: z2.enum(["site", "public_recovery"])
2912
3149
  }
2913
3150
  },
2914
3151
  async (args) => {
2915
3152
  try {
2916
- const ctx = withProjectDir(baseCtx, args.projectDir);
2917
3153
  if (args.scope === "site") {
3154
+ const ctx = withProjectDir(baseCtx);
2918
3155
  const site = requireSiteFile(ctx);
2919
3156
  const res2 = await ctx.client.createBillingPortal(site.siteId, site.credential);
2920
3157
  return structuredToolResult({
@@ -2932,7 +3169,7 @@ Full status:`, res);
2932
3169
  nextActions: [{ tool: "billing", allowed: true }]
2933
3170
  });
2934
3171
  }
2935
- const res = await ctx.client.getPublicBillingPortal();
3172
+ const res = await baseCtx.client.getPublicBillingPortal();
2936
3173
  return structuredToolResult({
2937
3174
  schemaVersion: 1,
2938
3175
  outcome: "waiting_user",
@@ -2960,21 +3197,28 @@ Full status:`, res);
2960
3197
  server.registerTool(
2961
3198
  "recover",
2962
3199
  {
2963
- 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.",
3200
+ 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.",
2964
3201
  outputSchema: STRUCTURED_TOOL_OUTPUT_SCHEMA,
2965
3202
  annotations: { readOnlyHint: false, destructiveHint: true, openWorldHint: true },
2966
3203
  inputSchema: {
2967
- projectDir: projectDirInput,
2968
- action: z3.enum(["start", "status", "complete", "download"]),
2969
- hostname: z3.string().optional().describe("Required for start."),
2970
- verificationId: z3.string().optional().describe("For status or complete; inferred from local recovery state when omitted."),
2971
- outputDir: z3.string().optional().describe("Relative extraction directory for complete/download (default: html)."),
2972
- preserveExistingCredentials: z3.boolean().optional().describe("Explicitly keep old local credentials working (default: revoke them all).")
3204
+ action: z2.enum(["start", "status", "complete", "download"]),
3205
+ hostname: z2.string().optional().describe("Required for start."),
3206
+ verificationId: z2.string().optional().describe("For status or complete; inferred from local recovery state when omitted."),
3207
+ outputDir: z2.string().optional().describe(
3208
+ "REQUIRED for complete/download: exact extraction directory relative to the initialized project root. Inspect the current project; Sakupa never guesses a name."
3209
+ ),
3210
+ preserveExistingCredentials: z2.boolean().optional().describe("Explicitly keep old local credentials working (default: revoke them all).")
2973
3211
  }
2974
3212
  },
2975
3213
  async (args) => {
2976
3214
  try {
2977
- const ctx = withProjectDir(baseCtx, args.projectDir);
3215
+ const ctx = withProjectDir(baseCtx);
3216
+ if ((args.action === "complete" || args.action === "download") && args.outputDir === void 0) {
3217
+ throw new LocalGuidanceError(
3218
+ "invalid_request",
3219
+ "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."
3220
+ );
3221
+ }
2978
3222
  const localSite = loadSiteFile(ctx.projectDir);
2979
3223
  const localCredentialIsActive = async () => {
2980
3224
  if (localSite.kind !== "ok") return false;
@@ -2987,12 +3231,18 @@ Full status:`, res);
2987
3231
  }
2988
3232
  };
2989
3233
  const download = async () => {
3234
+ if (args.outputDir === void 0) {
3235
+ throw new LocalGuidanceError(
3236
+ "invalid_request",
3237
+ "recover download requires an explicit outputDir."
3238
+ );
3239
+ }
2990
3240
  const site = requireSiteFile(ctx);
2991
3241
  const archive = await ctx.client.getSiteArchive(site.siteId, site.credential);
2992
3242
  const bytes = await ctx.client.downloadArchive(archive.archiveUrl);
2993
3243
  const extracted = await extractRecoveryArchive({
2994
3244
  projectDir: ctx.projectDir,
2995
- ...args.outputDir !== void 0 ? { outputDir: args.outputDir } : {},
3245
+ outputDir: args.outputDir,
2996
3246
  archive: bytes,
2997
3247
  expectedBytes: archive.totalBytes,
2998
3248
  expectedFiles: archive.fileCount
@@ -3035,9 +3285,8 @@ No DNS verification was started or repeated.`,
3035
3285
  {
3036
3286
  tool: "recover",
3037
3287
  arguments: {
3038
- projectDir: ctx.projectDir,
3039
3288
  action: "download",
3040
- outputDir: args.outputDir ?? "html"
3289
+ ...args.outputDir !== void 0 ? { outputDir: args.outputDir } : {}
3041
3290
  },
3042
3291
  allowed: true
3043
3292
  }
@@ -3062,7 +3311,6 @@ No DNS verification was started or repeated.`,
3062
3311
  {
3063
3312
  tool: "recover",
3064
3313
  arguments: {
3065
- projectDir: ctx.projectDir,
3066
3314
  action: "status",
3067
3315
  verificationId: pending2.verificationId
3068
3316
  },
@@ -3135,9 +3383,8 @@ After the DNS record resolves, re-run recover with verificationId: "${res2.verif
3135
3383
  {
3136
3384
  tool: "recover",
3137
3385
  arguments: {
3138
- projectDir: ctx.projectDir,
3139
3386
  action: "download",
3140
- outputDir: args.outputDir ?? "html"
3387
+ ...args.outputDir !== void 0 ? { outputDir: args.outputDir } : {}
3141
3388
  },
3142
3389
  allowed: true
3143
3390
  }
@@ -3170,10 +3417,9 @@ After the DNS record resolves, re-run recover with verificationId: "${res2.verif
3170
3417
  {
3171
3418
  tool: "recover",
3172
3419
  arguments: {
3173
- projectDir: ctx.projectDir,
3174
3420
  action: "complete",
3175
3421
  verificationId,
3176
- outputDir: args.outputDir ?? "html"
3422
+ ...args.outputDir !== void 0 ? { outputDir: args.outputDir } : {}
3177
3423
  },
3178
3424
  allowed: res2.readyToComplete,
3179
3425
  ...res2.readyToComplete ? {} : { reasonCode: res2.status }
@@ -3247,9 +3493,8 @@ Files: ${extracted.fileCount}; bytes: ${extracted.totalBytes}
3247
3493
  {
3248
3494
  tool: "recover",
3249
3495
  arguments: {
3250
- projectDir: ctx.projectDir,
3251
3496
  action: "download",
3252
- outputDir: args.outputDir ?? "html"
3497
+ ...args.outputDir !== void 0 ? { outputDir: args.outputDir } : {}
3253
3498
  },
3254
3499
  allowed: true
3255
3500
  }
@@ -3268,16 +3513,15 @@ Files: ${extracted.fileCount}; bytes: ${extracted.totalBytes}
3268
3513
  outputSchema: STRUCTURED_TOOL_OUTPUT_SCHEMA,
3269
3514
  annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: true },
3270
3515
  inputSchema: {
3271
- projectDir: projectDirInput,
3272
3516
  category: ticketCategoryEnum,
3273
- subject: z3.string().describe("Short subject line."),
3274
- description: z3.string().describe("Problem description (no secrets, no card data)."),
3275
- contactEmail: z3.string().optional().describe("Optional contact email for follow-up.")
3517
+ subject: z2.string().describe("Short subject line."),
3518
+ description: z2.string().describe("Problem description (no secrets, no card data)."),
3519
+ contactEmail: z2.string().optional().describe("Optional contact email for follow-up.")
3276
3520
  }
3277
3521
  },
3278
3522
  async (args) => {
3279
3523
  try {
3280
- const ctx = withProjectDir(baseCtx, args.projectDir);
3524
+ const ctx = withProjectDir(baseCtx);
3281
3525
  const site = requireSiteFile(ctx);
3282
3526
  const res = await ctx.client.createTicket(site.credential, {
3283
3527
  siteId: site.siteId,
@@ -3303,26 +3547,25 @@ Files: ${extracted.fileCount}; bytes: ${extracted.totalBytes}
3303
3547
  outputSchema: STRUCTURED_TOOL_OUTPUT_SCHEMA,
3304
3548
  annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: true },
3305
3549
  inputSchema: {
3306
- projectDir: projectDirInput,
3307
- toolName: z3.string().describe('The Sakupa tool that failed, e.g. "deploy".'),
3308
- errorCode: z3.string().optional(),
3309
- errorMessage: z3.string().optional().describe("Sanitized error message (no secrets)."),
3310
- requestId: z3.string().optional(),
3311
- deploymentId: z3.string().optional(),
3550
+ toolName: z2.string().describe('The Sakupa tool that failed, e.g. "deploy".'),
3551
+ errorCode: z2.string().optional(),
3552
+ errorMessage: z2.string().optional().describe("Sanitized error message (no secrets)."),
3553
+ requestId: z2.string().optional(),
3554
+ deploymentId: z2.string().optional(),
3312
3555
  severity: severityEnum.optional(),
3313
- description: z3.string().optional().describe("What happened, in the user's words (no secrets)."),
3314
- agentContext: z3.string().optional().describe(
3556
+ description: z2.string().optional().describe("What happened, in the user's words (no secrets)."),
3557
+ agentContext: z2.string().optional().describe(
3315
3558
  "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."
3316
3559
  ),
3317
- contactEmail: z3.string().optional().describe(
3560
+ contactEmail: z2.string().optional().describe(
3318
3561
  "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."
3319
3562
  ),
3320
- confirmSubmit: z3.boolean().optional().describe("User reviewed the report payload and approved submission.")
3563
+ confirmSubmit: z2.boolean().optional().describe("User reviewed the report payload and approved submission.")
3321
3564
  }
3322
3565
  },
3323
3566
  async (args) => {
3324
3567
  try {
3325
- const ctx = withProjectDir(baseCtx, args.projectDir);
3568
+ const ctx = withProjectDir(baseCtx);
3326
3569
  const siteState = loadSiteFile(ctx.projectDir);
3327
3570
  const site = siteState.kind === "ok" ? siteState.file : null;
3328
3571
  const diagnostics = {
@@ -3369,20 +3612,19 @@ Summary: ${res.sanitizedSummary}`,
3369
3612
  }
3370
3613
 
3371
3614
  // src/tools/billing.ts
3372
- import { z as z4 } from "zod";
3615
+ import { z as z3 } from "zod";
3373
3616
  function registerBillingTools(server, baseCtx) {
3374
3617
  server.registerTool(
3375
3618
  "plans",
3376
3619
  {
3377
3620
  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.",
3378
- inputSchema: { projectDir: projectDirInput },
3621
+ inputSchema: {},
3379
3622
  outputSchema: STRUCTURED_TOOL_OUTPUT_SCHEMA,
3380
3623
  annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: true }
3381
3624
  },
3382
- async (args) => {
3625
+ async () => {
3383
3626
  try {
3384
- const ctx = withProjectDir(baseCtx, args.projectDir);
3385
- const catalog = await ctx.client.getBillingPlanCatalog();
3627
+ const catalog = await baseCtx.client.getBillingPlanCatalog();
3386
3628
  return structuredToolResult({
3387
3629
  schemaVersion: 1,
3388
3630
  outcome: "completed",
@@ -3401,15 +3643,14 @@ function registerBillingTools(server, baseCtx) {
3401
3643
  {
3402
3644
  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.",
3403
3645
  inputSchema: {
3404
- projectDir: projectDirInput,
3405
- operationId: z4.string().min(1)
3646
+ operationId: z3.string().min(1)
3406
3647
  },
3407
3648
  outputSchema: STRUCTURED_TOOL_OUTPUT_SCHEMA,
3408
3649
  annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: true }
3409
3650
  },
3410
3651
  async (args) => {
3411
3652
  try {
3412
- const ctx = withProjectDir(baseCtx, args.projectDir);
3653
+ const ctx = withProjectDir(baseCtx);
3413
3654
  const site = requireSiteFile(ctx);
3414
3655
  const result = await ctx.client.changeSubscriptionPlan(site.credential, {
3415
3656
  siteId: site.siteId,
@@ -3438,22 +3679,22 @@ function registerBillingTools(server, baseCtx) {
3438
3679
  }
3439
3680
 
3440
3681
  // src/tools/lifecycle.ts
3441
- import { randomUUID as randomUUID2 } from "node:crypto";
3442
- import { z as z5 } from "zod";
3443
- var deleteConfirmation = z5.object({
3444
- siteId: z5.string().min(1),
3445
- expectedSiteUpdatedAt: z5.string().datetime(),
3446
- expectedStatus: z5.enum(["active", "expired", "deleted"]),
3447
- expectedMode: z5.enum(["free", "paid"]),
3448
- expectedServingMode: z5.enum(["normal", "over_limit_notice", "risk_notice", "stopped"]),
3449
- expectedShortId: z5.string().optional(),
3450
- expectedSubscriptionStatus: z5.enum(["incomplete", "active", "past_due", "canceled"]).optional(),
3451
- expectedPlan: z5.enum(["water", "personal", "share", "business"]).optional(),
3452
- expectedCancelAtPeriodEnd: z5.boolean().optional(),
3453
- expectedCurrentPeriodEnd: z5.string().datetime().optional(),
3454
- expectedLastDeploymentId: z5.string().optional(),
3455
- expectedBoundHostnames: z5.array(z5.string()),
3456
- acknowledge: z5.literal("delete_and_cancel_renewal")
3682
+ import { randomUUID as randomUUID3 } from "node:crypto";
3683
+ import { z as z4 } from "zod";
3684
+ var deleteConfirmation = z4.object({
3685
+ siteId: z4.string().min(1),
3686
+ expectedSiteUpdatedAt: z4.string().datetime(),
3687
+ expectedStatus: z4.enum(["active", "expired", "deleted"]),
3688
+ expectedMode: z4.enum(["free", "paid"]),
3689
+ expectedServingMode: z4.enum(["normal", "over_limit_notice", "risk_notice", "stopped"]),
3690
+ expectedShortId: z4.string().optional(),
3691
+ expectedSubscriptionStatus: z4.enum(["incomplete", "active", "past_due", "canceled"]).optional(),
3692
+ expectedPlan: z4.enum(["water", "personal", "share", "business"]).optional(),
3693
+ expectedCancelAtPeriodEnd: z4.boolean().optional(),
3694
+ expectedCurrentPeriodEnd: z4.string().datetime().optional(),
3695
+ expectedLastDeploymentId: z4.string().optional(),
3696
+ expectedBoundHostnames: z4.array(z4.string()),
3697
+ acknowledge: z4.literal("delete_and_cancel_renewal")
3457
3698
  });
3458
3699
  function registerLifecycleTools(server, baseCtx) {
3459
3700
  server.registerTool(
@@ -3461,9 +3702,8 @@ function registerLifecycleTools(server, baseCtx) {
3461
3702
  {
3462
3703
  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.",
3463
3704
  inputSchema: {
3464
- projectDir: projectDirInput,
3465
- action: z5.enum(["preview", "confirm"]),
3466
- operationId: z5.string().min(1).optional(),
3705
+ action: z4.enum(["preview", "confirm"]),
3706
+ operationId: z4.string().min(1).optional(),
3467
3707
  confirmation: deleteConfirmation.optional()
3468
3708
  },
3469
3709
  outputSchema: STRUCTURED_TOOL_OUTPUT_SCHEMA,
@@ -3471,9 +3711,9 @@ function registerLifecycleTools(server, baseCtx) {
3471
3711
  },
3472
3712
  async (args) => {
3473
3713
  try {
3474
- const ctx = withProjectDir(baseCtx, args.projectDir);
3714
+ const ctx = withProjectDir(baseCtx);
3475
3715
  const site = requireSiteFile(ctx);
3476
- const operationId = args.operationId ?? randomUUID2();
3716
+ const operationId = args.operationId ?? randomUUID3();
3477
3717
  if (args.action === "preview") {
3478
3718
  const preview = await ctx.client.previewDeleteSite(site.siteId, site.credential, {
3479
3719
  operationId
@@ -3668,18 +3908,22 @@ Workflow:
3668
3908
  5. support (subscribed sites) opens a support ticket; report sends a
3669
3909
  sanitized diagnostic report after the user explicitly confirms it.
3670
3910
 
3671
- Project directory contract: ONE directory = ONE site (its .sakupa/site.json holds the
3672
- binding). projectDir is REQUIRED on EVERY tool call \u2014 always pass the absolute path of
3673
- the user's PROJECT ROOT, the SAME directory every time for the same project: the folder
3674
- the user opened (for framework projects, where package.json lives \u2014 never the dist/out
3675
- build folder). For deploy, ALWAYS pass outputDir separately as the exact path RELATIVE to
3676
- projectDir (use "." when publishing the root); outputDir is required and may have ANY name,
3677
- so inspect the current project and never infer it from a conventional folder name. The server
3678
- NEVER guesses either directory: only you can see the user's actual workspace and build output.
3911
+ Project directory contract: before the first deploy or a new recovery, initialize the intended
3912
+ project once by running "npx -y @sakupa/mcp@latest init" with NO path argument from the AI
3913
+ tool's current project directory. This immediately creates the non-secret .sakupa/project.json
3914
+ there. ONE MCP process = ONE cwd-locked project = ONE site. Site tools do not accept projectDir
3915
+ and cannot select another root; plans and public_recovery portal remain project-independent.
3916
+ Sakupa stores .sakupa/site.json and recovery state only in the locked directory; it never uses
3917
+ package.json, .git, framework names or output-directory names to guess. For deploy, ALWAYS pass
3918
+ outputDir separately as the exact path RELATIVE to the locked directory (use "." when publishing
3919
+ the root); outputDir is
3920
+ required and may have ANY name, so inspect the current project. If it differs from the last
3921
+ successful publish directory, show the old and new paths and obtain explicit confirmation
3922
+ before retrying with outputDirChangeConfirmed: true.
3679
3923
  After every deploy, TELL the user which environment it went to (deploy results carry an
3680
3924
  Explicit Environment line: TEST vs PRODUCTION). analyze, deploy, status,
3681
3925
  refresh and delete echo
3682
- the directory they acted on \u2014 verify it matches the user's active project.
3926
+ the cwd-locked directory they acted on.
3683
3927
 
3684
3928
  When the same operation fails twice in a row, or the user is clearly stuck or
3685
3929
  frustrated, proactively offer report: it files the problem into Sakupa's ticket and
@@ -3717,7 +3961,11 @@ function createSakupaMcpServer(opts) {
3717
3961
  { name: "sakupa", version: MCP_VERSION },
3718
3962
  { instructions: instructionsFor(previewHostPatternFor(opts.apiBaseUrl)) }
3719
3963
  );
3720
- const ctx = { client, apiBaseUrl: opts.apiBaseUrl };
3964
+ const ctx = {
3965
+ client,
3966
+ apiBaseUrl: opts.apiBaseUrl,
3967
+ projectDir: canonicalProjectDirectory(opts.projectDir ?? process.cwd())
3968
+ };
3721
3969
  registerTools(server, ctx);
3722
3970
  registerBillingTools(server, ctx);
3723
3971
  registerLifecycleTools(server, ctx);
@@ -3726,12 +3974,24 @@ function createSakupaMcpServer(opts) {
3726
3974
 
3727
3975
  // src/bin.ts
3728
3976
  async function main() {
3977
+ const argv = process.argv.slice(2);
3978
+ if (argv[0] === "init") {
3979
+ const result = await runInitCommand(argv.slice(1), {
3980
+ write: (message) => stdout.write(`${message}
3981
+ `)
3982
+ });
3983
+ process.exitCode = result.exitCode;
3984
+ return;
3985
+ }
3986
+ if (argv.length > 0) {
3987
+ throw new Error("Usage: sakupa-mcp [init]");
3988
+ }
3729
3989
  const config = loadMcpRuntimeConfig();
3730
3990
  const server = createSakupaMcpServer(config);
3731
3991
  const transport = new StdioServerTransport();
3732
3992
  await server.connect(transport);
3733
3993
  console.error(
3734
- `[sakupa-mcp] v${MCP_VERSION} connected (api: ${config.apiBaseUrl}; every tool call requires projectDir)`
3994
+ `[sakupa-mcp] v${MCP_VERSION} connected (api: ${config.apiBaseUrl}; project: ${process.cwd()})`
3735
3995
  );
3736
3996
  }
3737
3997
  main().catch((err2) => {