@sakupa/mcp 0.7.32 → 0.7.33

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 +512 -173
  2. package/dist/index.js +403 -120
  3. package/package.json +1 -1
package/dist/bin.js CHANGED
@@ -2,6 +2,296 @@
2
2
 
3
3
  // src/bin.ts
4
4
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
5
+ import { createInterface } from "node:readline/promises";
6
+ import { stdin, stdout } from "node:process";
7
+
8
+ // src/project-root.ts
9
+ import { randomUUID } from "node:crypto";
10
+ import {
11
+ chmodSync,
12
+ existsSync,
13
+ lstatSync,
14
+ mkdirSync,
15
+ readFileSync,
16
+ realpathSync,
17
+ renameSync,
18
+ statSync,
19
+ unlinkSync,
20
+ writeFileSync
21
+ } from "node:fs";
22
+ import { homedir } from "node:os";
23
+ import { dirname, isAbsolute, join, parse, relative, resolve, sep } from "node:path";
24
+ var SAKUPA_DIR = ".sakupa";
25
+ var PROJECT_FILE = "project.json";
26
+ var SITE_FILE = "site.json";
27
+ var RECOVERY_FILE = "recovery.json";
28
+ var PROJECT_SCHEMA_VERSION = 1;
29
+ var ProjectRootError = class extends Error {
30
+ code;
31
+ constructor(code, message) {
32
+ super(message);
33
+ this.name = "ProjectRootError";
34
+ this.code = code;
35
+ }
36
+ };
37
+ function projectMarkerPath(projectDir) {
38
+ return join(projectDir, SAKUPA_DIR, PROJECT_FILE);
39
+ }
40
+ function loadProjectMarker(projectDir) {
41
+ const path = projectMarkerPath(projectDir);
42
+ if (!existsSync(path)) return { kind: "absent" };
43
+ let parsed;
44
+ try {
45
+ parsed = JSON.parse(readFileSync(path, "utf8"));
46
+ } catch (error) {
47
+ return {
48
+ kind: "corrupted",
49
+ problem: `the file cannot be read as JSON (${error instanceof Error ? error.message : String(error)})`
50
+ };
51
+ }
52
+ if (typeof parsed !== "object" || parsed === null) {
53
+ return { kind: "corrupted", problem: "the file does not contain a JSON object" };
54
+ }
55
+ const record = parsed;
56
+ if (record.schemaVersion !== PROJECT_SCHEMA_VERSION) {
57
+ return {
58
+ kind: "corrupted",
59
+ problem: `unsupported schemaVersion ${String(record.schemaVersion)}`
60
+ };
61
+ }
62
+ if (typeof record.projectId !== "string" || !isUuid(record.projectId)) {
63
+ return { kind: "corrupted", problem: "projectId is missing or is not a UUID" };
64
+ }
65
+ if (typeof record.createdAt !== "string" || !Number.isFinite(Date.parse(record.createdAt))) {
66
+ return { kind: "corrupted", problem: "createdAt is missing or invalid" };
67
+ }
68
+ if (record.outputDir !== void 0 && (typeof record.outputDir !== "string" || !isSafeRelativeOutput(record.outputDir))) {
69
+ return { kind: "corrupted", problem: "outputDir is not a safe project-relative path" };
70
+ }
71
+ return {
72
+ kind: "ok",
73
+ marker: {
74
+ schemaVersion: PROJECT_SCHEMA_VERSION,
75
+ projectId: record.projectId,
76
+ createdAt: record.createdAt,
77
+ ...record.outputDir !== void 0 ? { outputDir: normalizeRelative(record.outputDir) } : {}
78
+ }
79
+ };
80
+ }
81
+ function initializeProject(projectDir) {
82
+ const canonical = canonicalProjectDirectory(projectDir);
83
+ assertSafeProjectRoot(canonical);
84
+ const current = loadProjectMarker(canonical);
85
+ if (current.kind === "corrupted") {
86
+ throw new ProjectRootError(
87
+ "corrupted_marker",
88
+ `Refusing to overwrite damaged Sakupa project marker ${projectMarkerPath(canonical)}: ${current.problem}.`
89
+ );
90
+ }
91
+ if (current.kind === "ok") {
92
+ return {
93
+ projectDir: canonical,
94
+ requestedPath: canonical,
95
+ markerKind: "project",
96
+ marker: current.marker
97
+ };
98
+ }
99
+ const marker = {
100
+ schemaVersion: PROJECT_SCHEMA_VERSION,
101
+ projectId: randomUUID(),
102
+ createdAt: (/* @__PURE__ */ new Date()).toISOString()
103
+ };
104
+ writeMarkerAtomically(canonical, marker);
105
+ return {
106
+ projectDir: canonical,
107
+ requestedPath: canonical,
108
+ markerKind: "project",
109
+ marker
110
+ };
111
+ }
112
+ function resolveProjectRoot(requestedPath) {
113
+ if (!isAbsolute(requestedPath)) {
114
+ throw new ProjectRootError(
115
+ "invalid_path",
116
+ `Project path must be absolute (got "${requestedPath}").`
117
+ );
118
+ }
119
+ const canonicalRequested = canonicalExistingPath(requestedPath);
120
+ const requestedStat = statSync(canonicalRequested);
121
+ let cursor = requestedStat.isDirectory() ? canonicalRequested : dirname(canonicalRequested);
122
+ const startDevice = statSync(cursor).dev;
123
+ const candidates = [];
124
+ while (true) {
125
+ if (statSync(cursor).dev !== startDevice) break;
126
+ const markerState = loadProjectMarker(cursor);
127
+ const hasSite = existsSync(join(cursor, SAKUPA_DIR, SITE_FILE));
128
+ const hasRecovery = existsSync(join(cursor, SAKUPA_DIR, RECOVERY_FILE));
129
+ if (markerState.kind !== "absent" || hasSite || hasRecovery) {
130
+ candidates.push({ dir: cursor, markerState, hasSite, hasRecovery });
131
+ }
132
+ const parent = dirname(cursor);
133
+ if (parent === cursor) break;
134
+ cursor = parent;
135
+ }
136
+ if (candidates.length === 0) {
137
+ throw new ProjectRootError(
138
+ "not_initialized",
139
+ `No Sakupa project marker was found at or above ${canonicalRequested}. Run \`npx -y @sakupa/mcp@latest init\` once from the intended project root; no site was changed.`
140
+ );
141
+ }
142
+ const nearest = candidates[0];
143
+ if (nearest.markerState.kind === "corrupted") {
144
+ throw new ProjectRootError(
145
+ "corrupted_marker",
146
+ `Sakupa project marker ${projectMarkerPath(nearest.dir)} is damaged: ${nearest.markerState.problem}.`
147
+ );
148
+ }
149
+ if (nearest.markerState.kind === "absent") {
150
+ const explicitAncestor = candidates.find((candidate) => candidate.markerState.kind === "ok");
151
+ if (explicitAncestor) {
152
+ throw new ProjectRootError(
153
+ "ambiguous_binding",
154
+ `A legacy .sakupa binding exists at ${nearest.dir}, below the initialized Sakupa project ${explicitAncestor.dir}. Start the operation from ${explicitAncestor.dir}; deploy can then validate and relocate the same credential without creating a site.`
155
+ );
156
+ }
157
+ }
158
+ assertSafeProjectRoot(nearest.dir);
159
+ if (nearest.markerState.kind === "ok") {
160
+ return {
161
+ projectDir: nearest.dir,
162
+ requestedPath: canonicalRequested,
163
+ markerKind: "project",
164
+ marker: nearest.markerState.marker
165
+ };
166
+ }
167
+ return {
168
+ projectDir: nearest.dir,
169
+ requestedPath: canonicalRequested,
170
+ markerKind: nearest.hasSite ? "legacy_site" : "legacy_recovery"
171
+ };
172
+ }
173
+ function updateProjectOutputDir(projectDir, outputDir) {
174
+ const canonical = canonicalProjectDirectory(projectDir);
175
+ const state = loadProjectMarker(canonical);
176
+ if (state.kind !== "ok") {
177
+ throw new ProjectRootError(
178
+ state.kind === "corrupted" ? "corrupted_marker" : "not_initialized",
179
+ state.kind === "corrupted" ? `Cannot update damaged Sakupa project marker: ${state.problem}.` : `No Sakupa project marker exists in ${canonical}.`
180
+ );
181
+ }
182
+ if (!isSafeRelativeOutput(outputDir)) {
183
+ throw new ProjectRootError(
184
+ "unsafe_path",
185
+ `Output directory "${outputDir}" must stay inside the initialized Sakupa project.`
186
+ );
187
+ }
188
+ const marker = {
189
+ ...state.marker,
190
+ outputDir: normalizeRelative(outputDir)
191
+ };
192
+ writeMarkerAtomically(canonical, marker);
193
+ return marker;
194
+ }
195
+ function canonicalProjectDirectory(path) {
196
+ const canonical = canonicalExistingPath(resolve(path));
197
+ if (!statSync(canonical).isDirectory()) {
198
+ throw new ProjectRootError("invalid_path", `Project path ${canonical} is not a directory.`);
199
+ }
200
+ return canonical;
201
+ }
202
+ function canonicalExistingPath(path) {
203
+ try {
204
+ const stat2 = lstatSync(path, { throwIfNoEntry: false });
205
+ if (!stat2) {
206
+ throw new ProjectRootError("invalid_path", `Project path ${path} does not exist.`);
207
+ }
208
+ return realpathSync(path);
209
+ } catch (error) {
210
+ if (error instanceof ProjectRootError) throw error;
211
+ throw new ProjectRootError(
212
+ "invalid_path",
213
+ `Project path ${path} cannot be resolved (${error instanceof Error ? error.message : String(error)}).`
214
+ );
215
+ }
216
+ }
217
+ function assertSafeProjectRoot(projectDir) {
218
+ if (parse(projectDir).root === projectDir || projectDir === realpathSync(homedir())) {
219
+ throw new ProjectRootError(
220
+ "unsafe_path",
221
+ `Refusing to use ${projectDir} as a Sakupa project root; choose a specific project directory.`
222
+ );
223
+ }
224
+ }
225
+ function isUuid(value) {
226
+ 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);
227
+ }
228
+ function normalizeRelative(path) {
229
+ const normalized = path.split(sep).join("/").replace(/^\.\//, "").replace(/\/$/, "");
230
+ return normalized.length === 0 ? "." : normalized;
231
+ }
232
+ function isSafeRelativeOutput(path) {
233
+ if (path.length === 0 || isAbsolute(path)) return false;
234
+ const normalized = normalizeRelative(path);
235
+ if (normalized === ".") return true;
236
+ const rel = relative("/sakupa-root", resolve("/sakupa-root", normalized));
237
+ return rel !== ".." && !rel.startsWith(`..${sep}`) && !isAbsolute(rel);
238
+ }
239
+ function writeMarkerAtomically(projectDir, marker) {
240
+ const dir = join(projectDir, SAKUPA_DIR);
241
+ mkdirSync(dir, { recursive: true, mode: 448 });
242
+ const path = projectMarkerPath(projectDir);
243
+ const temporary = `${path}.${process.pid}.${randomUUID()}.tmp`;
244
+ try {
245
+ writeFileSync(temporary, `${JSON.stringify(marker, null, 2)}
246
+ `, {
247
+ encoding: "utf8",
248
+ mode: 384
249
+ });
250
+ renameSync(temporary, path);
251
+ try {
252
+ chmodSync(path, 384);
253
+ } catch {
254
+ }
255
+ } finally {
256
+ if (existsSync(temporary)) unlinkSync(temporary);
257
+ }
258
+ }
259
+
260
+ // src/cli-init.ts
261
+ async function runInitCommand(args, io) {
262
+ if (args.length > 1) {
263
+ io.write("Usage: sakupa-mcp init [project-directory]");
264
+ return { exitCode: 2, initialized: false };
265
+ }
266
+ try {
267
+ const projectDir = canonicalProjectDirectory(args[0] ?? process.cwd());
268
+ const current = loadProjectMarker(projectDir);
269
+ if (current.kind === "corrupted") {
270
+ io.write(`Refusing to replace damaged Sakupa marker in ${projectDir}: ${current.problem}.`);
271
+ return { exitCode: 1, projectDir, initialized: false };
272
+ }
273
+ if (current.kind === "ok") {
274
+ io.write(`Sakupa project is already initialized: ${projectDir}`);
275
+ return { exitCode: 0, projectDir, initialized: false };
276
+ }
277
+ io.write(`Sakupa project directory: ${projectDir}`);
278
+ const confirmed = await io.confirm(
279
+ "Initialize exactly this directory as one Sakupa project? [y/N] "
280
+ );
281
+ if (!confirmed) {
282
+ io.write("Initialization cancelled; no files were changed.");
283
+ return { exitCode: 0, projectDir, initialized: false };
284
+ }
285
+ initializeProject(projectDir);
286
+ io.write(
287
+ `Initialized ${projectDir}. The .sakupa directory stays here; publish output may be any child directory.`
288
+ );
289
+ return { exitCode: 0, projectDir, initialized: true };
290
+ } catch (error) {
291
+ io.write(error instanceof Error ? error.message : String(error));
292
+ return { exitCode: 1, initialized: false };
293
+ }
294
+ }
5
295
 
6
296
  // ../core/dist/domain/constants.js
7
297
  var SERVICE_DOMAIN = "sakupa.com";
@@ -129,7 +419,7 @@ var FORBIDDEN_PATH_SEGMENTS = [
129
419
  var ALLOWED_HIDDEN_PATHS = [".well-known/"];
130
420
 
131
421
  // ../core/dist/domain/version.js
132
- var SAKUPA_MCP_VERSION = "0.7.32";
422
+ var SAKUPA_MCP_VERSION = "0.7.33";
133
423
 
134
424
  // ../core/dist/domain/errors.js
135
425
  var HTTP_STATUS = {
@@ -688,14 +978,14 @@ var HttpApiClient = class {
688
978
  };
689
979
 
690
980
  // 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";
981
+ import { randomUUID as randomUUID2 } from "node:crypto";
982
+ import { promises as fs2 } from "node:fs";
983
+ import { join as join6, resolve as resolve4 } from "node:path";
694
984
  import { z as z3 } from "zod";
695
985
 
696
986
  // src/analyze/analyzer.ts
697
987
  import { promises as fs } from "node:fs";
698
- import { join, posix, resolve, sep } from "node:path";
988
+ import { join as join2, posix, resolve as resolve2, sep as sep2 } from "node:path";
699
989
  var SERVER_RUNTIME_DEPS = ["express", "koa", "fastify", "hapi", "@hapi/hapi"];
700
990
  var DB_RUNTIME_DEPS = [
701
991
  "prisma",
@@ -740,7 +1030,7 @@ async function readTextIfExists(path, maxBytes = CONTENT_READ_MAX_BYTES) {
740
1030
  }
741
1031
  async function firstExistingFile(dir, names) {
742
1032
  for (const name of names) {
743
- const p = join(dir, name);
1033
+ const p = join2(dir, name);
744
1034
  if (await isFile(p)) return p;
745
1035
  }
746
1036
  return null;
@@ -769,10 +1059,10 @@ async function walkFiles(dir, opts) {
769
1059
  if (entry.isDirectory()) {
770
1060
  if (FORBIDDEN_SEGMENTS_LOWER.has(entry.name.toLowerCase())) continue;
771
1061
  if (opts.skipRelDirs?.has(rel)) continue;
772
- await recurse(join(current, entry.name), rel);
1062
+ await recurse(join2(current, entry.name), rel);
773
1063
  } else if (entry.isFile()) {
774
1064
  try {
775
- const stat2 = await fs.stat(join(current, entry.name));
1065
+ const stat2 = await fs.stat(join2(current, entry.name));
776
1066
  out.push({ path: rel, size: stat2.size });
777
1067
  } catch {
778
1068
  }
@@ -783,7 +1073,7 @@ async function walkFiles(dir, opts) {
783
1073
  return out;
784
1074
  }
785
1075
  async function readPackageJson(projectDir) {
786
- const raw = await readTextIfExists(join(projectDir, "package.json"));
1076
+ const raw = await readTextIfExists(join2(projectDir, "package.json"));
787
1077
  if (raw === null) return null;
788
1078
  try {
789
1079
  const parsed = JSON.parse(raw);
@@ -818,7 +1108,7 @@ async function detectFramework(projectDir, pkg) {
818
1108
  );
819
1109
  }
820
1110
  for (const apiDir of ["pages/api", "src/pages/api"]) {
821
- if (await isDirectory(join(projectDir, apiDir))) {
1111
+ if (await isDirectory(join2(projectDir, apiDir))) {
822
1112
  ssrRisks.push(
823
1113
  `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
1114
  );
@@ -827,7 +1117,7 @@ async function detectFramework(projectDir, pkg) {
827
1117
  }
828
1118
  for (const appDir of ["app", "src/app"]) {
829
1119
  if (await anyFileMatches(
830
- join(projectDir, appDir),
1120
+ join2(projectDir, appDir),
831
1121
  (base) => /^route\.(ts|js|tsx|jsx|mjs)$/.test(base)
832
1122
  )) {
833
1123
  ssrRisks.push(
@@ -858,7 +1148,7 @@ async function detectFramework(projectDir, pkg) {
858
1148
  ]);
859
1149
  if ("nuxt" in deps || "nuxt3" in deps || nuxtConfigPath !== null) {
860
1150
  for (const serverDir of ["server/api", "server/routes"]) {
861
- if (await isDirectory(join(projectDir, serverDir))) {
1151
+ if (await isDirectory(join2(projectDir, serverDir))) {
862
1152
  ssrRisks.push(
863
1153
  `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
1154
  );
@@ -900,7 +1190,7 @@ async function detectFramework(projectDir, pkg) {
900
1190
  "SvelteKit requires @sveltejs/adapter-static to produce a fully static build. Install and configure it, then build locally."
901
1191
  );
902
1192
  }
903
- if (await anyFileMatches(join(projectDir, "src/routes"), (base) => base.startsWith("+server."))) {
1193
+ if (await anyFileMatches(join2(projectDir, "src/routes"), (base) => base.startsWith("+server."))) {
904
1194
  ssrRisks.push(
905
1195
  "SvelteKit +server.* endpoint files require a server runtime and will not run on Sakupa."
906
1196
  );
@@ -965,17 +1255,17 @@ async function scanForUseServer(projectDir, skipRelDirs) {
965
1255
  if (scanned >= USE_SERVER_SCAN_MAX_FILES) break;
966
1256
  if (!SOURCE_SCAN_EXTENSIONS.has(extensionOf(file.path))) continue;
967
1257
  scanned += 1;
968
- const text2 = await readTextIfExists(join(projectDir, file.path), USE_SERVER_SCAN_MAX_BYTES);
1258
+ const text2 = await readTextIfExists(join2(projectDir, file.path), USE_SERVER_SCAN_MAX_BYTES);
969
1259
  if (text2 !== null && /['"]use server['"]/.test(text2)) return true;
970
1260
  }
971
1261
  return false;
972
1262
  }
973
1263
  function normalizeOutputDir(outputDir) {
974
- const normalized = posix.normalize(outputDir.replaceAll(sep, "/")).replace(/\/+$/, "");
1264
+ const normalized = posix.normalize(outputDir.replaceAll(sep2, "/")).replace(/\/+$/, "");
975
1265
  return normalized === "" ? "." : normalized;
976
1266
  }
977
1267
  async function analyzeProject(projectDir, opts = {}) {
978
- const root = resolve(projectDir);
1268
+ const root = await fs.realpath(resolve2(projectDir));
979
1269
  const pkg = await readPackageJson(root);
980
1270
  const detection = await detectFramework(root, pkg);
981
1271
  const ssrRisks = [...detection?.ssrRisks ?? []];
@@ -985,16 +1275,26 @@ async function analyzeProject(projectDir, opts = {}) {
985
1275
  let outputDirExists = false;
986
1276
  if (opts.outputDir !== void 0) {
987
1277
  outputDirRel = normalizeOutputDir(opts.outputDir);
988
- const abs = resolve(root, outputDirRel);
989
- if (abs !== root && !abs.startsWith(root + sep)) {
1278
+ const abs = resolve2(root, outputDirRel);
1279
+ if (abs !== root && !abs.startsWith(root + sep2)) {
990
1280
  outputDirRel = ".";
991
1281
  outputDirExists = false;
992
1282
  } else {
993
1283
  outputDirExists = await isDirectory(abs) || outputDirRel === "." && await isDirectory(root);
1284
+ if (outputDirExists) {
1285
+ try {
1286
+ const physical = await fs.realpath(abs);
1287
+ if (physical !== root && !physical.startsWith(root + sep2)) {
1288
+ outputDirExists = false;
1289
+ }
1290
+ } catch {
1291
+ outputDirExists = false;
1292
+ }
1293
+ }
994
1294
  }
995
1295
  } else if (detection) {
996
1296
  for (const candidate of detection.outputCandidates) {
997
- if (await isDirectory(join(root, candidate))) {
1297
+ if (await isDirectory(join2(root, candidate))) {
998
1298
  outputDirRel = candidate;
999
1299
  outputDirExists = true;
1000
1300
  break;
@@ -1009,7 +1309,7 @@ async function analyzeProject(projectDir, opts = {}) {
1009
1309
  outputDirExists = true;
1010
1310
  } else if (hasBuildScript) {
1011
1311
  for (const candidate of ["dist", "build", "out", "public"]) {
1012
- if (await isFile(join(root, candidate, "index.html"))) {
1312
+ if (await isFile(join2(root, candidate, "index.html"))) {
1013
1313
  outputDirRel = candidate;
1014
1314
  outputDirExists = true;
1015
1315
  break;
@@ -1029,7 +1329,7 @@ async function analyzeProject(projectDir, opts = {}) {
1029
1329
  }
1030
1330
  if (outputDirRel === void 0 || !outputDirExists) {
1031
1331
  ssrRisks.push(...serverAndDbDepRisks(pkg, false));
1032
- const sourceWithoutBuild = pkg !== null && buildRequired && (await isDirectory(join(root, "src")) || await isDirectory(join(root, "pages")));
1332
+ const sourceWithoutBuild = pkg !== null && buildRequired && (await isDirectory(join2(root, "src")) || await isDirectory(join2(root, "pages")));
1033
1333
  let suggestedNextAction2;
1034
1334
  if (opts.outputDir !== void 0) {
1035
1335
  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 +1357,7 @@ async function analyzeProject(projectDir, opts = {}) {
1057
1357
  suggestedNextAction: suggestedNextAction2
1058
1358
  };
1059
1359
  }
1060
- const outputAbs = outputDirRel === "." ? root : resolve(root, outputDirRel);
1360
+ const outputAbs = outputDirRel === "." ? root : resolve2(root, outputDirRel);
1061
1361
  const walked = await walkFiles(outputAbs, { maxFiles: MAX_FILE_COUNT + 1 });
1062
1362
  const candidates = [];
1063
1363
  for (const file of walked) {
@@ -1065,7 +1365,7 @@ async function analyzeProject(projectDir, opts = {}) {
1065
1365
  let content;
1066
1366
  if (TEXT_CONTENT_EXTENSIONS.has(ext) && file.size <= CONTENT_READ_MAX_BYTES) {
1067
1367
  try {
1068
- content = new Uint8Array(await fs.readFile(join(outputAbs, file.path)));
1368
+ content = new Uint8Array(await fs.readFile(join2(outputAbs, file.path)));
1069
1369
  } catch {
1070
1370
  content = void 0;
1071
1371
  }
@@ -1114,30 +1414,30 @@ async function analyzeProject(projectDir, opts = {}) {
1114
1414
 
1115
1415
  // src/project-file.ts
1116
1416
  import {
1117
- chmodSync,
1118
- existsSync,
1119
- mkdirSync,
1120
- readFileSync,
1417
+ chmodSync as chmodSync2,
1418
+ existsSync as existsSync2,
1419
+ mkdirSync as mkdirSync2,
1420
+ readFileSync as readFileSync2,
1121
1421
  rmdirSync,
1122
1422
  rmSync,
1123
- writeFileSync
1423
+ writeFileSync as writeFileSync2
1124
1424
  } from "node:fs";
1125
- import { dirname, join as join2 } from "node:path";
1425
+ import { dirname as dirname2, join as join3 } from "node:path";
1126
1426
  var SITE_DIR = ".sakupa";
1127
- var SITE_FILE = "site.json";
1128
- var RECOVERY_FILE = "recovery.json";
1427
+ var SITE_FILE2 = "site.json";
1428
+ var RECOVERY_FILE2 = "recovery.json";
1129
1429
  function siteFilePath(projectDir) {
1130
- return join2(projectDir, SITE_DIR, SITE_FILE);
1430
+ return join3(projectDir, SITE_DIR, SITE_FILE2);
1131
1431
  }
1132
1432
  function recoveryFilePath(projectDir) {
1133
- return join2(projectDir, SITE_DIR, RECOVERY_FILE);
1433
+ return join3(projectDir, SITE_DIR, RECOVERY_FILE2);
1134
1434
  }
1135
1435
  function loadSiteFile(projectDir) {
1136
1436
  const path = siteFilePath(projectDir);
1137
- if (!existsSync(path)) return { kind: "absent" };
1437
+ if (!existsSync2(path)) return { kind: "absent" };
1138
1438
  let raw;
1139
1439
  try {
1140
- raw = readFileSync(path, "utf8");
1440
+ raw = readFileSync2(path, "utf8");
1141
1441
  } catch (err2) {
1142
1442
  return {
1143
1443
  kind: "corrupted",
@@ -1180,9 +1480,9 @@ function loadSiteFile(projectDir) {
1180
1480
  }
1181
1481
  function loadRecoveryFile(projectDir) {
1182
1482
  const path = recoveryFilePath(projectDir);
1183
- if (!existsSync(path)) return null;
1483
+ if (!existsSync2(path)) return null;
1184
1484
  try {
1185
- const parsed = JSON.parse(readFileSync(path, "utf8"));
1485
+ const parsed = JSON.parse(readFileSync2(path, "utf8"));
1186
1486
  if (typeof parsed.verificationId !== "string" || parsed.verificationId.length === 0 || typeof parsed.credential !== "string" || !CREDENTIAL_PATTERN.test(parsed.credential)) {
1187
1487
  throw new Error("required recovery fields are missing or invalid");
1188
1488
  }
@@ -1198,19 +1498,19 @@ function loadRecoveryFile(projectDir) {
1198
1498
  }
1199
1499
  }
1200
1500
  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)}
1501
+ const dir = join3(projectDir, SITE_DIR);
1502
+ mkdirSync2(dir, { recursive: true });
1503
+ const path = join3(dir, RECOVERY_FILE2);
1504
+ writeFileSync2(path, `${JSON.stringify(file, null, 2)}
1205
1505
  `, "utf8");
1206
1506
  try {
1207
- chmodSync(path, 384);
1507
+ chmodSync2(path, 384);
1208
1508
  } catch {
1209
1509
  }
1210
1510
  }
1211
1511
  function deleteRecoveryFile(projectDir) {
1212
1512
  const path = recoveryFilePath(projectDir);
1213
- if (existsSync(path)) rmSync(path, { force: true });
1513
+ if (existsSync2(path)) rmSync(path, { force: true });
1214
1514
  }
1215
1515
  function siteFileRecoveryGuidance(projectDir) {
1216
1516
  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,30 +1529,30 @@ function writeSiteFile(projectDir, file, opts = {}) {
1229
1529
  );
1230
1530
  }
1231
1531
  }
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)}
1532
+ const dir = join3(projectDir, SITE_DIR);
1533
+ mkdirSync2(dir, { recursive: true });
1534
+ const path = join3(dir, SITE_FILE2);
1535
+ writeFileSync2(path, `${JSON.stringify(file, null, 2)}
1236
1536
  `, "utf8");
1237
1537
  try {
1238
- chmodSync(path, 384);
1538
+ chmodSync2(path, 384);
1239
1539
  } catch {
1240
1540
  }
1241
1541
  }
1242
1542
  function deleteSiteFile(projectDir) {
1243
1543
  const path = siteFilePath(projectDir);
1244
- if (existsSync(path)) {
1544
+ if (existsSync2(path)) {
1245
1545
  rmSync(path, { force: true });
1246
1546
  }
1247
1547
  try {
1248
- rmdirSync(join2(projectDir, SITE_DIR));
1548
+ rmdirSync(join3(projectDir, SITE_DIR));
1249
1549
  } catch {
1250
1550
  }
1251
1551
  }
1252
1552
  function findAncestor(startDir, predicate, maxLevels = Number.POSITIVE_INFINITY) {
1253
1553
  let cursor = startDir;
1254
1554
  for (let i = 0; i < maxLevels; i += 1) {
1255
- const parent = dirname(cursor);
1555
+ const parent = dirname2(cursor);
1256
1556
  if (parent === cursor) return null;
1257
1557
  if (predicate(parent)) return parent;
1258
1558
  cursor = parent;
@@ -1260,7 +1560,7 @@ function findAncestor(startDir, predicate, maxLevels = Number.POSITIVE_INFINITY)
1260
1560
  return null;
1261
1561
  }
1262
1562
  function isInsideGitRepo(projectDir) {
1263
- return existsSync(join2(projectDir, ".git")) || findAncestor(projectDir, (dir) => existsSync(join2(dir, ".git"))) !== null;
1563
+ return existsSync2(join3(projectDir, ".git")) || findAncestor(projectDir, (dir) => existsSync2(join3(dir, ".git"))) !== null;
1264
1564
  }
1265
1565
  function credentialGitReminder(projectDir) {
1266
1566
  if (!isInsideGitRepo(projectDir)) return "";
@@ -1268,8 +1568,9 @@ function credentialGitReminder(projectDir) {
1268
1568
  }
1269
1569
 
1270
1570
  // src/recovery-archive.ts
1571
+ import { existsSync as existsSync3, realpathSync as realpathSync2 } from "node:fs";
1271
1572
  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";
1573
+ import { dirname as dirname3, isAbsolute as isAbsolute2, join as join4, relative as relative2, resolve as resolve3, sep as sep3 } from "node:path";
1273
1574
 
1274
1575
  // ../../node_modules/fflate/esm/index.mjs
1275
1576
  import { createRequire } from "module";
@@ -1756,18 +2057,33 @@ function unzipSync(data, opts) {
1756
2057
 
1757
2058
  // src/recovery-archive.ts
1758
2059
  function safeOutputPath(projectDir, outputDir) {
1759
- if (outputDir.length === 0 || isAbsolute(outputDir)) {
2060
+ if (outputDir.length === 0 || isAbsolute2(outputDir)) {
1760
2061
  throw new SakupaError("invalid_request", "Recovery outputDir must be a relative directory");
1761
2062
  }
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)) {
2063
+ const root = realpathSync2(resolve3(projectDir));
2064
+ const target = resolve3(root, outputDir);
2065
+ const rel = relative2(root, target);
2066
+ if (rel === "" || rel === ".." || rel.startsWith(`..${sep3}`) || isAbsolute2(rel)) {
1766
2067
  throw new SakupaError("invalid_request", "Recovery outputDir must stay inside projectDir");
1767
2068
  }
1768
- if (rel === ".sakupa" || rel.startsWith(`.sakupa${sep2}`)) {
2069
+ if (rel === ".sakupa" || rel.startsWith(`.sakupa${sep3}`)) {
1769
2070
  throw new SakupaError("invalid_request", "Recovery content cannot be written inside .sakupa");
1770
2071
  }
2072
+ let existingAncestor = target;
2073
+ while (!existsSync3(existingAncestor)) {
2074
+ const parent = dirname3(existingAncestor);
2075
+ if (parent === existingAncestor) break;
2076
+ existingAncestor = parent;
2077
+ }
2078
+ const physicalAncestor = realpathSync2(existingAncestor);
2079
+ const physicalTarget = resolve3(physicalAncestor, relative2(existingAncestor, target));
2080
+ const physicalRel = relative2(root, physicalTarget);
2081
+ if (physicalRel === ".." || physicalRel.startsWith(`..${sep3}`) || isAbsolute2(physicalRel)) {
2082
+ throw new SakupaError(
2083
+ "invalid_request",
2084
+ "Recovery outputDir resolves through a symlink outside projectDir"
2085
+ );
2086
+ }
1771
2087
  return target;
1772
2088
  }
1773
2089
  function safeEntryName(name) {
@@ -1796,9 +2112,9 @@ async function listExistingFiles(root, current = root) {
1796
2112
  if (entry.isSymbolicLink()) {
1797
2113
  throw new SakupaError("state_conflict", `Recovery output contains a symlink: ${entry.name}`);
1798
2114
  }
1799
- const absolute = join3(current, entry.name);
2115
+ const absolute = join4(current, entry.name);
1800
2116
  if (entry.isDirectory()) files.push(...await listExistingFiles(root, absolute));
1801
- else if (entry.isFile()) files.push(relative(root, absolute).split(sep2).join("/"));
2117
+ else if (entry.isFile()) files.push(relative2(root, absolute).split(sep3).join("/"));
1802
2118
  else
1803
2119
  throw new SakupaError(
1804
2120
  "state_conflict",
@@ -1814,7 +2130,7 @@ async function existingOutputMatches(outputDir, files) {
1814
2130
  return false;
1815
2131
  }
1816
2132
  for (const name of expected) {
1817
- const actual = await readFile(join3(outputDir, ...name.split("/")));
2133
+ const actual = await readFile(join4(outputDir, ...name.split("/")));
1818
2134
  const wanted = files[name];
1819
2135
  if (!wanted || actual.byteLength !== wanted.byteLength || !actual.equals(wanted)) return false;
1820
2136
  }
@@ -1824,7 +2140,7 @@ async function extractRecoveryArchive(input) {
1824
2140
  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
2141
  throw new SakupaError("validation_failed", "Recovery archive metadata exceeds product limits");
1826
2142
  }
1827
- const outputDir = safeOutputPath(input.projectDir, input.outputDir ?? "html");
2143
+ const outputDir = safeOutputPath(input.projectDir, input.outputDir);
1828
2144
  const maxArchiveBytes = input.expectedBytes + input.expectedFiles * 4096 + 65536;
1829
2145
  if (input.archive.byteLength > maxArchiveBytes) {
1830
2146
  throw new SakupaError("validation_failed", "Recovery archive is larger than its site metadata");
@@ -1865,14 +2181,14 @@ async function extractRecoveryArchive(input) {
1865
2181
  `Recovery output already exists with different content: ${outputDir}. Move it aside or choose another outputDir.`
1866
2182
  );
1867
2183
  }
1868
- const tempDir = await mkdtemp(join3(resolve2(input.projectDir), ".sakupa-restore-"));
2184
+ const tempDir = await mkdtemp(join4(resolve3(input.projectDir), ".sakupa-restore-"));
1869
2185
  try {
1870
2186
  let writtenBytes = 0;
1871
2187
  const entries = Object.entries(files).sort(([a], [b]) => a.localeCompare(b));
1872
2188
  for (const [rawName, data] of entries) {
1873
2189
  const name = safeEntryName(rawName);
1874
- const destination = join3(tempDir, ...name.split("/"));
1875
- await mkdir(dirname2(destination), { recursive: true });
2190
+ const destination = join4(tempDir, ...name.split("/"));
2191
+ await mkdir(dirname3(destination), { recursive: true });
1876
2192
  await writeFile(destination, data, { flag: "wx" });
1877
2193
  writtenBytes += data.byteLength;
1878
2194
  }
@@ -1882,7 +2198,7 @@ async function extractRecoveryArchive(input) {
1882
2198
  "Extracted recovery data does not match site metadata"
1883
2199
  );
1884
2200
  }
1885
- await mkdir(dirname2(outputDir), { recursive: true });
2201
+ await mkdir(dirname3(outputDir), { recursive: true });
1886
2202
  await rename(tempDir, outputDir);
1887
2203
  return {
1888
2204
  outputDir,
@@ -1897,19 +2213,19 @@ async function extractRecoveryArchive(input) {
1897
2213
  }
1898
2214
 
1899
2215
  // 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";
2216
+ import { existsSync as existsSync4, mkdirSync as mkdirSync3, readFileSync as readFileSync3, writeFileSync as writeFileSync3 } from "node:fs";
2217
+ import { homedir as homedir2 } from "node:os";
2218
+ import { dirname as dirname4, join as join5 } from "node:path";
1903
2219
  var RECENT_WINDOW_MS = FREE_SITE_TTL_HOURS * 60 * 60 * 1e3;
1904
2220
  function creationRegistryPath() {
1905
- const base = process.env["SAKUPA_STATE_DIR"] ?? homedir();
1906
- return join4(base, ".sakupa", "created-sites.json");
2221
+ const base = process.env["SAKUPA_STATE_DIR"] ?? homedir2();
2222
+ return join5(base, ".sakupa", "created-sites.json");
1907
2223
  }
1908
2224
  function readAll() {
1909
2225
  const path = creationRegistryPath();
1910
- if (!existsSync2(path)) return [];
2226
+ if (!existsSync4(path)) return [];
1911
2227
  try {
1912
- const parsed = JSON.parse(readFileSync2(path, "utf-8"));
2228
+ const parsed = JSON.parse(readFileSync3(path, "utf-8"));
1913
2229
  if (!Array.isArray(parsed)) return [];
1914
2230
  return parsed.filter(
1915
2231
  (e) => typeof e === "object" && e !== null && typeof e.siteId === "string" && typeof e.createdAt === "string"
@@ -1920,8 +2236,8 @@ function readAll() {
1920
2236
  }
1921
2237
  function writeAll(records) {
1922
2238
  const path = creationRegistryPath();
1923
- mkdirSync2(dirname3(path), { recursive: true });
1924
- writeFileSync2(path, `${JSON.stringify(records, null, 2)}
2239
+ mkdirSync3(dirname4(path), { recursive: true });
2240
+ writeFileSync3(path, `${JSON.stringify(records, null, 2)}
1925
2241
  `, "utf-8");
1926
2242
  }
1927
2243
  function listRecentCreations(nowMs, apiBaseUrl) {
@@ -2079,9 +2395,6 @@ var CLIENT_TYPE = "sakupa-mcp";
2079
2395
 
2080
2396
  // src/tools/context.ts
2081
2397
  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
2398
 
2086
2399
  // src/tools/result.ts
2087
2400
  import { z } from "zod";
@@ -2131,36 +2444,33 @@ var LocalGuidanceError = class extends SakupaError {
2131
2444
  }
2132
2445
  };
2133
2446
  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."
2447
+ "REQUIRED: an absolute existing path anywhere inside the user's initialized Sakupa project (the root itself or a child such as dist/html/src). Sakupa resolves upward to its own .sakupa marker and never trusts this argument as the root. If no marker exists, run `npx -y @sakupa/mcp@latest init` once from the intended project root."
2135
2448
  );
2136
2449
  function withProjectDir(ctx, projectDirArg) {
2137
2450
  if (projectDirArg === void 0) {
2138
2451
  throw new LocalGuidanceError(
2139
2452
  "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.`
2453
+ "projectDir is REQUIRED as a path locator: pass an absolute existing path anywhere inside the current Sakupa project. The server resolves its own .sakupa marker and never treats the supplied path as an authoritative root."
2147
2454
  );
2148
2455
  }
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
- );
2456
+ try {
2457
+ const resolved = resolveProjectRoot(projectDirArg);
2458
+ return {
2459
+ ...ctx,
2460
+ projectDir: resolved.projectDir,
2461
+ requestedPath: resolved.requestedPath,
2462
+ markerKind: resolved.markerKind,
2463
+ ...resolved.marker !== void 0 ? { projectMarker: resolved.marker } : {}
2464
+ };
2465
+ } catch (error) {
2466
+ if (error instanceof ProjectRootError) {
2467
+ throw new LocalGuidanceError(
2468
+ error.code === "not_initialized" ? "not_found" : "invalid_request",
2469
+ error.message
2470
+ );
2471
+ }
2472
+ throw error;
2162
2473
  }
2163
- return { ...ctx, projectDir: dir };
2164
2474
  }
2165
2475
  function requireSiteFile(ctx) {
2166
2476
  const state = loadSiteFile(ctx.projectDir);
@@ -2292,7 +2602,7 @@ function ensureUploadSizeWithinLimits(manifest, isFirstFreeDeploy) {
2292
2602
  async function buildHashedManifest(files, outputAbs) {
2293
2603
  const manifest = [];
2294
2604
  for (const file of files) {
2295
- const bytes = new Uint8Array(await fs2.readFile(join5(outputAbs, file.path)));
2605
+ const bytes = new Uint8Array(await fs2.readFile(join6(outputAbs, file.path)));
2296
2606
  manifest.push({ path: file.path, size: file.size, contentHash: await sha256Hex(bytes) });
2297
2607
  }
2298
2608
  return manifest;
@@ -2311,7 +2621,7 @@ async function uploadAll(ctx, targets, files, outputAbs) {
2311
2621
  `No local file matches upload target "${target.path}"; aborting upload.`
2312
2622
  );
2313
2623
  }
2314
- const bytes = new Uint8Array(await fs2.readFile(join5(outputAbs, match.path)));
2624
+ const bytes = new Uint8Array(await fs2.readFile(join6(outputAbs, match.path)));
2315
2625
  if (bytes.byteLength !== match.size) {
2316
2626
  throw new SakupaError(
2317
2627
  "validation_failed",
@@ -2354,21 +2664,6 @@ ${block}`, checklist: toDnsChecklist(diag.checks) };
2354
2664
  };
2355
2665
  }
2356
2666
  }
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
2667
  function freeSiteCreationBarrier(apiBaseUrl) {
2373
2668
  const recent = listRecentCreations(Date.now(), apiBaseUrl);
2374
2669
  if (recent.length < FREE_ACTIVE_SITES_PER_IP) return null;
@@ -2419,13 +2714,16 @@ Next action: ${analysis.suggestedNextAction}`,
2419
2714
  server.registerTool(
2420
2715
  "deploy",
2421
2716
  {
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.`,
2717
+ 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 may point anywhere inside a project already initialized with \`npx -y @sakupa/mcp@latest init\`; Sakupa resolves its own marker upward and stores credentials only at 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
2718
  outputSchema: STRUCTURED_TOOL_OUTPUT_SCHEMA,
2424
2719
  annotations: { readOnlyHint: false, destructiveHint: true, openWorldHint: true },
2425
2720
  inputSchema: {
2426
2721
  projectDir: projectDirInput,
2427
2722
  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.'
2723
+ '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 resolves projectDir upward before applying this path.'
2724
+ ),
2725
+ outputDirChangeConfirmed: z3.boolean().optional().describe(
2726
+ "Required only when changing the previously successful publish directory. Confirm only after showing the old and new directories to the user."
2429
2727
  ),
2430
2728
  spaFallback: z3.boolean().optional().describe(
2431
2729
  "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."
@@ -2434,7 +2732,7 @@ Next action: ${analysis.suggestedNextAction}`,
2434
2732
  "Required only for the first deployment: user explicitly confirmed creation of a public 24-hour URL."
2435
2733
  ),
2436
2734
  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."
2735
+ "Deprecated compatibility field. Project independence is established only by `sakupa-mcp init`, never inferred from package.json or folder names."
2438
2736
  ),
2439
2737
  lang: z3.string().optional().describe("Site language override (en | ja | zh-CN); defaults to the html lang.")
2440
2738
  }
@@ -2446,8 +2744,25 @@ Next action: ${analysis.suggestedNextAction}`,
2446
2744
  if (!analysis.deployable || !analysis.files) {
2447
2745
  return notDeployableResult(analysis);
2448
2746
  }
2747
+ const effectiveOutputDir = analysis.recommendedOutputDir ?? ".";
2748
+ const recordedOutputDir = ctx.projectMarker?.outputDir;
2749
+ if (recordedOutputDir !== void 0 && resolve4(ctx.projectDir, recordedOutputDir) !== resolve4(ctx.projectDir, effectiveOutputDir) && args.outputDirChangeConfirmed !== true) {
2750
+ return structuredToolResult({
2751
+ schemaVersion: 1,
2752
+ outcome: "waiting_user",
2753
+ resultCode: "publish_directory_change_confirmation_required",
2754
+ 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.`,
2755
+ data: {
2756
+ projectDir: ctx.projectDir,
2757
+ previousOutputDir: recordedOutputDir,
2758
+ requestedOutputDir: effectiveOutputDir,
2759
+ confirmationField: "outputDirChangeConfirmed"
2760
+ },
2761
+ nextActions: [{ tool: "deploy", allowed: true, reasonCode: "explicit_confirmation" }]
2762
+ });
2763
+ }
2449
2764
  const files = analysis.files;
2450
- const outputAbs = resolve4(ctx.projectDir, analysis.recommendedOutputDir ?? ".");
2765
+ const outputAbs = resolve4(ctx.projectDir, effectiveOutputDir);
2451
2766
  const manifest = await buildHashedManifest(files, outputAbs);
2452
2767
  const siteFileState = loadSiteFile(ctx.projectDir);
2453
2768
  if (siteFileState.kind === "corrupted") {
@@ -2462,7 +2777,16 @@ Next action: ${analysis.suggestedNextAction}`,
2462
2777
  }
2463
2778
  let existing = siteFileState.kind === "ok" ? siteFileState.file : null;
2464
2779
  let credentialRelocatedFrom = null;
2465
- if (!existing && analysis.recommendedOutputDir !== ".") {
2780
+ if (!existing && effectiveOutputDir !== ".") {
2781
+ const outputProjectMarker = loadProjectMarker(outputAbs);
2782
+ if (outputProjectMarker.kind !== "absent") {
2783
+ return text(
2784
+ "publish_directory_is_independent_project",
2785
+ 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.`,
2786
+ { projectRoot: ctx.projectDir, outputDir: effectiveOutputDir },
2787
+ "blocked"
2788
+ );
2789
+ }
2466
2790
  const outputSiteState = loadSiteFile(outputAbs);
2467
2791
  if (outputSiteState.kind === "corrupted") {
2468
2792
  return text(
@@ -2473,35 +2797,11 @@ Next action: ${analysis.suggestedNextAction}`,
2473
2797
  );
2474
2798
  }
2475
2799
  if (outputSiteState.kind === "ok") {
2476
- writeSiteFile(ctx.projectDir, outputSiteState.file);
2477
- deleteSiteFile(outputAbs);
2478
2800
  existing = outputSiteState.file;
2479
2801
  credentialRelocatedFrom = outputAbs;
2480
2802
  }
2481
2803
  }
2482
2804
  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
2805
  const barrier = freeSiteCreationBarrier(ctx.apiBaseUrl);
2506
2806
  if (barrier) return barrier;
2507
2807
  if (args.publicConfirmed !== true) {
@@ -2535,6 +2835,7 @@ Next action: ${analysis.suggestedNextAction}`,
2535
2835
  createdAt,
2536
2836
  apiBaseUrl: ctx.apiBaseUrl
2537
2837
  });
2838
+ updateProjectOutputDir(ctx.projectDir, effectiveOutputDir);
2538
2839
  recordCreation({
2539
2840
  siteId: created.siteId,
2540
2841
  projectDir: ctx.projectDir,
@@ -2609,6 +2910,9 @@ ${JSON.stringify(finalized2.warnings, null, 2)}` : ""),
2609
2910
  }
2610
2911
  const { uploaded, finalized } = update;
2611
2912
  writeSiteFile(ctx.projectDir, { ...existing, url: finalized.url });
2913
+ if (credentialRelocatedFrom !== null) deleteSiteFile(credentialRelocatedFrom);
2914
+ if (ctx.projectMarker === void 0) initializeProject(ctx.projectDir);
2915
+ updateProjectOutputDir(ctx.projectDir, effectiveOutputDir);
2612
2916
  noteSiteMode(existing.siteId, finalized.mode);
2613
2917
  return text(
2614
2918
  "site_updated",
@@ -2717,7 +3021,7 @@ NO content was uploaded or changed by this call \u2014 to publish new or edited
2717
3021
  {
2718
3022
  siteId: site.siteId,
2719
3023
  plan: args.plan,
2720
- idempotencyKey: randomUUID()
3024
+ idempotencyKey: randomUUID2()
2721
3025
  },
2722
3026
  site.credential
2723
3027
  );
@@ -2907,14 +3211,14 @@ Full status:`, res);
2907
3211
  outputSchema: STRUCTURED_TOOL_OUTPUT_SCHEMA,
2908
3212
  annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: true },
2909
3213
  inputSchema: {
2910
- projectDir: projectDirInput,
3214
+ projectDir: projectDirInput.optional(),
2911
3215
  scope: z3.enum(["site", "public_recovery"])
2912
3216
  }
2913
3217
  },
2914
3218
  async (args) => {
2915
3219
  try {
2916
- const ctx = withProjectDir(baseCtx, args.projectDir);
2917
3220
  if (args.scope === "site") {
3221
+ const ctx = withProjectDir(baseCtx, args.projectDir);
2918
3222
  const site = requireSiteFile(ctx);
2919
3223
  const res2 = await ctx.client.createBillingPortal(site.siteId, site.credential);
2920
3224
  return structuredToolResult({
@@ -2932,7 +3236,7 @@ Full status:`, res);
2932
3236
  nextActions: [{ tool: "billing", allowed: true }]
2933
3237
  });
2934
3238
  }
2935
- const res = await ctx.client.getPublicBillingPortal();
3239
+ const res = await baseCtx.client.getPublicBillingPortal();
2936
3240
  return structuredToolResult({
2937
3241
  schemaVersion: 1,
2938
3242
  outcome: "waiting_user",
@@ -2960,7 +3264,7 @@ Full status:`, res);
2960
3264
  server.registerTool(
2961
3265
  "recover",
2962
3266
  {
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.",
3267
+ 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
3268
  outputSchema: STRUCTURED_TOOL_OUTPUT_SCHEMA,
2965
3269
  annotations: { readOnlyHint: false, destructiveHint: true, openWorldHint: true },
2966
3270
  inputSchema: {
@@ -2968,13 +3272,21 @@ Full status:`, res);
2968
3272
  action: z3.enum(["start", "status", "complete", "download"]),
2969
3273
  hostname: z3.string().optional().describe("Required for start."),
2970
3274
  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)."),
3275
+ outputDir: z3.string().optional().describe(
3276
+ "REQUIRED for complete/download: exact extraction directory relative to the initialized project root. Inspect the current project; Sakupa never guesses a name."
3277
+ ),
2972
3278
  preserveExistingCredentials: z3.boolean().optional().describe("Explicitly keep old local credentials working (default: revoke them all).")
2973
3279
  }
2974
3280
  },
2975
3281
  async (args) => {
2976
3282
  try {
2977
3283
  const ctx = withProjectDir(baseCtx, args.projectDir);
3284
+ if ((args.action === "complete" || args.action === "download") && args.outputDir === void 0) {
3285
+ throw new LocalGuidanceError(
3286
+ "invalid_request",
3287
+ "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."
3288
+ );
3289
+ }
2978
3290
  const localSite = loadSiteFile(ctx.projectDir);
2979
3291
  const localCredentialIsActive = async () => {
2980
3292
  if (localSite.kind !== "ok") return false;
@@ -2987,12 +3299,18 @@ Full status:`, res);
2987
3299
  }
2988
3300
  };
2989
3301
  const download = async () => {
3302
+ if (args.outputDir === void 0) {
3303
+ throw new LocalGuidanceError(
3304
+ "invalid_request",
3305
+ "recover download requires an explicit outputDir."
3306
+ );
3307
+ }
2990
3308
  const site = requireSiteFile(ctx);
2991
3309
  const archive = await ctx.client.getSiteArchive(site.siteId, site.credential);
2992
3310
  const bytes = await ctx.client.downloadArchive(archive.archiveUrl);
2993
3311
  const extracted = await extractRecoveryArchive({
2994
3312
  projectDir: ctx.projectDir,
2995
- ...args.outputDir !== void 0 ? { outputDir: args.outputDir } : {},
3313
+ outputDir: args.outputDir,
2996
3314
  archive: bytes,
2997
3315
  expectedBytes: archive.totalBytes,
2998
3316
  expectedFiles: archive.fileCount
@@ -3037,7 +3355,7 @@ No DNS verification was started or repeated.`,
3037
3355
  arguments: {
3038
3356
  projectDir: ctx.projectDir,
3039
3357
  action: "download",
3040
- outputDir: args.outputDir ?? "html"
3358
+ ...args.outputDir !== void 0 ? { outputDir: args.outputDir } : {}
3041
3359
  },
3042
3360
  allowed: true
3043
3361
  }
@@ -3137,7 +3455,7 @@ After the DNS record resolves, re-run recover with verificationId: "${res2.verif
3137
3455
  arguments: {
3138
3456
  projectDir: ctx.projectDir,
3139
3457
  action: "download",
3140
- outputDir: args.outputDir ?? "html"
3458
+ ...args.outputDir !== void 0 ? { outputDir: args.outputDir } : {}
3141
3459
  },
3142
3460
  allowed: true
3143
3461
  }
@@ -3173,7 +3491,7 @@ After the DNS record resolves, re-run recover with verificationId: "${res2.verif
3173
3491
  projectDir: ctx.projectDir,
3174
3492
  action: "complete",
3175
3493
  verificationId,
3176
- outputDir: args.outputDir ?? "html"
3494
+ ...args.outputDir !== void 0 ? { outputDir: args.outputDir } : {}
3177
3495
  },
3178
3496
  allowed: res2.readyToComplete,
3179
3497
  ...res2.readyToComplete ? {} : { reasonCode: res2.status }
@@ -3249,7 +3567,7 @@ Files: ${extracted.fileCount}; bytes: ${extracted.totalBytes}
3249
3567
  arguments: {
3250
3568
  projectDir: ctx.projectDir,
3251
3569
  action: "download",
3252
- outputDir: args.outputDir ?? "html"
3570
+ ...args.outputDir !== void 0 ? { outputDir: args.outputDir } : {}
3253
3571
  },
3254
3572
  allowed: true
3255
3573
  }
@@ -3375,14 +3693,13 @@ function registerBillingTools(server, baseCtx) {
3375
3693
  "plans",
3376
3694
  {
3377
3695
  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 },
3696
+ inputSchema: {},
3379
3697
  outputSchema: STRUCTURED_TOOL_OUTPUT_SCHEMA,
3380
3698
  annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: true }
3381
3699
  },
3382
- async (args) => {
3700
+ async () => {
3383
3701
  try {
3384
- const ctx = withProjectDir(baseCtx, args.projectDir);
3385
- const catalog = await ctx.client.getBillingPlanCatalog();
3702
+ const catalog = await baseCtx.client.getBillingPlanCatalog();
3386
3703
  return structuredToolResult({
3387
3704
  schemaVersion: 1,
3388
3705
  outcome: "completed",
@@ -3438,7 +3755,7 @@ function registerBillingTools(server, baseCtx) {
3438
3755
  }
3439
3756
 
3440
3757
  // src/tools/lifecycle.ts
3441
- import { randomUUID as randomUUID2 } from "node:crypto";
3758
+ import { randomUUID as randomUUID3 } from "node:crypto";
3442
3759
  import { z as z5 } from "zod";
3443
3760
  var deleteConfirmation = z5.object({
3444
3761
  siteId: z5.string().min(1),
@@ -3473,7 +3790,7 @@ function registerLifecycleTools(server, baseCtx) {
3473
3790
  try {
3474
3791
  const ctx = withProjectDir(baseCtx, args.projectDir);
3475
3792
  const site = requireSiteFile(ctx);
3476
- const operationId = args.operationId ?? randomUUID2();
3793
+ const operationId = args.operationId ?? randomUUID3();
3477
3794
  if (args.action === "preview") {
3478
3795
  const preview = await ctx.client.previewDeleteSite(site.siteId, site.credential, {
3479
3796
  operationId
@@ -3668,14 +3985,18 @@ Workflow:
3668
3985
  5. support (subscribed sites) opens a support ticket; report sends a
3669
3986
  sanitized diagnostic report after the user explicitly confirms it.
3670
3987
 
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.
3988
+ Project directory contract: before the first deploy or a new recovery, initialize the intended
3989
+ project root once with "npx -y @sakupa/mcp@latest init"; this creates a non-secret
3990
+ .sakupa/project.json marker after the user confirms the canonical path. ONE initialized root =
3991
+ ONE site. Project-bound tools require projectDir, but it is only a path locator: it may point to
3992
+ the root or any existing child directory. The site-independent plans tool and public_recovery
3993
+ portal do not require a local project. Sakupa resolves upward to its own marker and stores
3994
+ .sakupa/site.json only at that authoritative root; it never uses package.json, .git, framework
3995
+ names or output-directory names to guess. For deploy, ALWAYS pass outputDir separately as the
3996
+ exact path RELATIVE to the resolved root (use "." when publishing the root); outputDir is
3997
+ required and may have ANY name, so inspect the current project. If it differs from the last
3998
+ successful publish directory, show the old and new paths and obtain explicit confirmation
3999
+ before retrying with outputDirChangeConfirmed: true.
3679
4000
  After every deploy, TELL the user which environment it went to (deploy results carry an
3680
4001
  Explicit Environment line: TEST vs PRODUCTION). analyze, deploy, status,
3681
4002
  refresh and delete echo
@@ -3726,12 +4047,30 @@ function createSakupaMcpServer(opts) {
3726
4047
 
3727
4048
  // src/bin.ts
3728
4049
  async function main() {
4050
+ const argv = process.argv.slice(2);
4051
+ if (argv[0] === "init") {
4052
+ const readline = createInterface({ input: stdin, output: stdout });
4053
+ try {
4054
+ const result = await runInitCommand(argv.slice(1), {
4055
+ write: (message) => stdout.write(`${message}
4056
+ `),
4057
+ confirm: async (question) => /^(?:y|yes)$/i.test((await readline.question(question)).trim())
4058
+ });
4059
+ process.exitCode = result.exitCode;
4060
+ return;
4061
+ } finally {
4062
+ readline.close();
4063
+ }
4064
+ }
4065
+ if (argv.length > 0) {
4066
+ throw new Error("Usage: sakupa-mcp [init [project-directory]]");
4067
+ }
3729
4068
  const config = loadMcpRuntimeConfig();
3730
4069
  const server = createSakupaMcpServer(config);
3731
4070
  const transport = new StdioServerTransport();
3732
4071
  await server.connect(transport);
3733
4072
  console.error(
3734
- `[sakupa-mcp] v${MCP_VERSION} connected (api: ${config.apiBaseUrl}; every tool call requires projectDir)`
4073
+ `[sakupa-mcp] v${MCP_VERSION} connected (api: ${config.apiBaseUrl}; projectDir resolves through Sakupa markers)`
3735
4074
  );
3736
4075
  }
3737
4076
  main().catch((err2) => {