@sakupa/mcp 0.7.31 → 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 +535 -156
  2. package/dist/index.js +432 -109
  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.31";
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, resolve as resolve4 } 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
  }
@@ -1113,23 +1413,31 @@ async function analyzeProject(projectDir, opts = {}) {
1113
1413
  }
1114
1414
 
1115
1415
  // src/project-file.ts
1116
- import { chmodSync, existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
1117
- import { dirname, join as join2 } from "node:path";
1416
+ import {
1417
+ chmodSync as chmodSync2,
1418
+ existsSync as existsSync2,
1419
+ mkdirSync as mkdirSync2,
1420
+ readFileSync as readFileSync2,
1421
+ rmdirSync,
1422
+ rmSync,
1423
+ writeFileSync as writeFileSync2
1424
+ } from "node:fs";
1425
+ import { dirname as dirname2, join as join3 } from "node:path";
1118
1426
  var SITE_DIR = ".sakupa";
1119
- var SITE_FILE = "site.json";
1120
- var RECOVERY_FILE = "recovery.json";
1427
+ var SITE_FILE2 = "site.json";
1428
+ var RECOVERY_FILE2 = "recovery.json";
1121
1429
  function siteFilePath(projectDir) {
1122
- return join2(projectDir, SITE_DIR, SITE_FILE);
1430
+ return join3(projectDir, SITE_DIR, SITE_FILE2);
1123
1431
  }
1124
1432
  function recoveryFilePath(projectDir) {
1125
- return join2(projectDir, SITE_DIR, RECOVERY_FILE);
1433
+ return join3(projectDir, SITE_DIR, RECOVERY_FILE2);
1126
1434
  }
1127
1435
  function loadSiteFile(projectDir) {
1128
1436
  const path = siteFilePath(projectDir);
1129
- if (!existsSync(path)) return { kind: "absent" };
1437
+ if (!existsSync2(path)) return { kind: "absent" };
1130
1438
  let raw;
1131
1439
  try {
1132
- raw = readFileSync(path, "utf8");
1440
+ raw = readFileSync2(path, "utf8");
1133
1441
  } catch (err2) {
1134
1442
  return {
1135
1443
  kind: "corrupted",
@@ -1172,9 +1480,9 @@ function loadSiteFile(projectDir) {
1172
1480
  }
1173
1481
  function loadRecoveryFile(projectDir) {
1174
1482
  const path = recoveryFilePath(projectDir);
1175
- if (!existsSync(path)) return null;
1483
+ if (!existsSync2(path)) return null;
1176
1484
  try {
1177
- const parsed = JSON.parse(readFileSync(path, "utf8"));
1485
+ const parsed = JSON.parse(readFileSync2(path, "utf8"));
1178
1486
  if (typeof parsed.verificationId !== "string" || parsed.verificationId.length === 0 || typeof parsed.credential !== "string" || !CREDENTIAL_PATTERN.test(parsed.credential)) {
1179
1487
  throw new Error("required recovery fields are missing or invalid");
1180
1488
  }
@@ -1190,19 +1498,19 @@ function loadRecoveryFile(projectDir) {
1190
1498
  }
1191
1499
  }
1192
1500
  function writeRecoveryFile(projectDir, file) {
1193
- const dir = join2(projectDir, SITE_DIR);
1194
- mkdirSync(dir, { recursive: true });
1195
- const path = join2(dir, RECOVERY_FILE);
1196
- writeFileSync(path, `${JSON.stringify(file, null, 2)}
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)}
1197
1505
  `, "utf8");
1198
1506
  try {
1199
- chmodSync(path, 384);
1507
+ chmodSync2(path, 384);
1200
1508
  } catch {
1201
1509
  }
1202
1510
  }
1203
1511
  function deleteRecoveryFile(projectDir) {
1204
1512
  const path = recoveryFilePath(projectDir);
1205
- if (existsSync(path)) rmSync(path, { force: true });
1513
+ if (existsSync2(path)) rmSync(path, { force: true });
1206
1514
  }
1207
1515
  function siteFileRecoveryGuidance(projectDir) {
1208
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.`;
@@ -1221,26 +1529,30 @@ function writeSiteFile(projectDir, file, opts = {}) {
1221
1529
  );
1222
1530
  }
1223
1531
  }
1224
- const dir = join2(projectDir, SITE_DIR);
1225
- mkdirSync(dir, { recursive: true });
1226
- const path = join2(dir, SITE_FILE);
1227
- 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)}
1228
1536
  `, "utf8");
1229
1537
  try {
1230
- chmodSync(path, 384);
1538
+ chmodSync2(path, 384);
1231
1539
  } catch {
1232
1540
  }
1233
1541
  }
1234
1542
  function deleteSiteFile(projectDir) {
1235
1543
  const path = siteFilePath(projectDir);
1236
- if (existsSync(path)) {
1544
+ if (existsSync2(path)) {
1237
1545
  rmSync(path, { force: true });
1238
1546
  }
1547
+ try {
1548
+ rmdirSync(join3(projectDir, SITE_DIR));
1549
+ } catch {
1550
+ }
1239
1551
  }
1240
1552
  function findAncestor(startDir, predicate, maxLevels = Number.POSITIVE_INFINITY) {
1241
1553
  let cursor = startDir;
1242
1554
  for (let i = 0; i < maxLevels; i += 1) {
1243
- const parent = dirname(cursor);
1555
+ const parent = dirname2(cursor);
1244
1556
  if (parent === cursor) return null;
1245
1557
  if (predicate(parent)) return parent;
1246
1558
  cursor = parent;
@@ -1248,7 +1560,7 @@ function findAncestor(startDir, predicate, maxLevels = Number.POSITIVE_INFINITY)
1248
1560
  return null;
1249
1561
  }
1250
1562
  function isInsideGitRepo(projectDir) {
1251
- 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;
1252
1564
  }
1253
1565
  function credentialGitReminder(projectDir) {
1254
1566
  if (!isInsideGitRepo(projectDir)) return "";
@@ -1256,8 +1568,9 @@ function credentialGitReminder(projectDir) {
1256
1568
  }
1257
1569
 
1258
1570
  // src/recovery-archive.ts
1571
+ import { existsSync as existsSync3, realpathSync as realpathSync2 } from "node:fs";
1259
1572
  import { mkdtemp, mkdir, readFile, readdir, rename, rm, stat, writeFile } from "node:fs/promises";
1260
- import { dirname as dirname2, isAbsolute, join as join3, relative, resolve as resolve2, sep as sep2 } from "node:path";
1573
+ import { dirname as dirname3, isAbsolute as isAbsolute2, join as join4, relative as relative2, resolve as resolve3, sep as sep3 } from "node:path";
1261
1574
 
1262
1575
  // ../../node_modules/fflate/esm/index.mjs
1263
1576
  import { createRequire } from "module";
@@ -1744,18 +2057,33 @@ function unzipSync(data, opts) {
1744
2057
 
1745
2058
  // src/recovery-archive.ts
1746
2059
  function safeOutputPath(projectDir, outputDir) {
1747
- if (outputDir.length === 0 || isAbsolute(outputDir)) {
2060
+ if (outputDir.length === 0 || isAbsolute2(outputDir)) {
1748
2061
  throw new SakupaError("invalid_request", "Recovery outputDir must be a relative directory");
1749
2062
  }
1750
- const root = resolve2(projectDir);
1751
- const target = resolve2(root, outputDir);
1752
- const rel = relative(root, target);
1753
- if (rel === "" || rel === ".." || rel.startsWith(`..${sep2}`) || isAbsolute(rel)) {
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)) {
1754
2067
  throw new SakupaError("invalid_request", "Recovery outputDir must stay inside projectDir");
1755
2068
  }
1756
- if (rel === ".sakupa" || rel.startsWith(`.sakupa${sep2}`)) {
2069
+ if (rel === ".sakupa" || rel.startsWith(`.sakupa${sep3}`)) {
1757
2070
  throw new SakupaError("invalid_request", "Recovery content cannot be written inside .sakupa");
1758
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
+ }
1759
2087
  return target;
1760
2088
  }
1761
2089
  function safeEntryName(name) {
@@ -1784,9 +2112,9 @@ async function listExistingFiles(root, current = root) {
1784
2112
  if (entry.isSymbolicLink()) {
1785
2113
  throw new SakupaError("state_conflict", `Recovery output contains a symlink: ${entry.name}`);
1786
2114
  }
1787
- const absolute = join3(current, entry.name);
2115
+ const absolute = join4(current, entry.name);
1788
2116
  if (entry.isDirectory()) files.push(...await listExistingFiles(root, absolute));
1789
- 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("/"));
1790
2118
  else
1791
2119
  throw new SakupaError(
1792
2120
  "state_conflict",
@@ -1802,7 +2130,7 @@ async function existingOutputMatches(outputDir, files) {
1802
2130
  return false;
1803
2131
  }
1804
2132
  for (const name of expected) {
1805
- const actual = await readFile(join3(outputDir, ...name.split("/")));
2133
+ const actual = await readFile(join4(outputDir, ...name.split("/")));
1806
2134
  const wanted = files[name];
1807
2135
  if (!wanted || actual.byteLength !== wanted.byteLength || !actual.equals(wanted)) return false;
1808
2136
  }
@@ -1812,7 +2140,7 @@ async function extractRecoveryArchive(input) {
1812
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) {
1813
2141
  throw new SakupaError("validation_failed", "Recovery archive metadata exceeds product limits");
1814
2142
  }
1815
- const outputDir = safeOutputPath(input.projectDir, input.outputDir ?? "html");
2143
+ const outputDir = safeOutputPath(input.projectDir, input.outputDir);
1816
2144
  const maxArchiveBytes = input.expectedBytes + input.expectedFiles * 4096 + 65536;
1817
2145
  if (input.archive.byteLength > maxArchiveBytes) {
1818
2146
  throw new SakupaError("validation_failed", "Recovery archive is larger than its site metadata");
@@ -1853,14 +2181,14 @@ async function extractRecoveryArchive(input) {
1853
2181
  `Recovery output already exists with different content: ${outputDir}. Move it aside or choose another outputDir.`
1854
2182
  );
1855
2183
  }
1856
- const tempDir = await mkdtemp(join3(resolve2(input.projectDir), ".sakupa-restore-"));
2184
+ const tempDir = await mkdtemp(join4(resolve3(input.projectDir), ".sakupa-restore-"));
1857
2185
  try {
1858
2186
  let writtenBytes = 0;
1859
2187
  const entries = Object.entries(files).sort(([a], [b]) => a.localeCompare(b));
1860
2188
  for (const [rawName, data] of entries) {
1861
2189
  const name = safeEntryName(rawName);
1862
- const destination = join3(tempDir, ...name.split("/"));
1863
- await mkdir(dirname2(destination), { recursive: true });
2190
+ const destination = join4(tempDir, ...name.split("/"));
2191
+ await mkdir(dirname3(destination), { recursive: true });
1864
2192
  await writeFile(destination, data, { flag: "wx" });
1865
2193
  writtenBytes += data.byteLength;
1866
2194
  }
@@ -1870,7 +2198,7 @@ async function extractRecoveryArchive(input) {
1870
2198
  "Extracted recovery data does not match site metadata"
1871
2199
  );
1872
2200
  }
1873
- await mkdir(dirname2(outputDir), { recursive: true });
2201
+ await mkdir(dirname3(outputDir), { recursive: true });
1874
2202
  await rename(tempDir, outputDir);
1875
2203
  return {
1876
2204
  outputDir,
@@ -1885,19 +2213,19 @@ async function extractRecoveryArchive(input) {
1885
2213
  }
1886
2214
 
1887
2215
  // src/creation-registry.ts
1888
- import { existsSync as existsSync2, mkdirSync as mkdirSync2, readFileSync as readFileSync2, writeFileSync as writeFileSync2 } from "node:fs";
1889
- import { homedir } from "node:os";
1890
- 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";
1891
2219
  var RECENT_WINDOW_MS = FREE_SITE_TTL_HOURS * 60 * 60 * 1e3;
1892
2220
  function creationRegistryPath() {
1893
- const base = process.env["SAKUPA_STATE_DIR"] ?? homedir();
1894
- return join4(base, ".sakupa", "created-sites.json");
2221
+ const base = process.env["SAKUPA_STATE_DIR"] ?? homedir2();
2222
+ return join5(base, ".sakupa", "created-sites.json");
1895
2223
  }
1896
2224
  function readAll() {
1897
2225
  const path = creationRegistryPath();
1898
- if (!existsSync2(path)) return [];
2226
+ if (!existsSync4(path)) return [];
1899
2227
  try {
1900
- const parsed = JSON.parse(readFileSync2(path, "utf-8"));
2228
+ const parsed = JSON.parse(readFileSync3(path, "utf-8"));
1901
2229
  if (!Array.isArray(parsed)) return [];
1902
2230
  return parsed.filter(
1903
2231
  (e) => typeof e === "object" && e !== null && typeof e.siteId === "string" && typeof e.createdAt === "string"
@@ -1908,8 +2236,8 @@ function readAll() {
1908
2236
  }
1909
2237
  function writeAll(records) {
1910
2238
  const path = creationRegistryPath();
1911
- mkdirSync2(dirname3(path), { recursive: true });
1912
- writeFileSync2(path, `${JSON.stringify(records, null, 2)}
2239
+ mkdirSync3(dirname4(path), { recursive: true });
2240
+ writeFileSync3(path, `${JSON.stringify(records, null, 2)}
1913
2241
  `, "utf-8");
1914
2242
  }
1915
2243
  function listRecentCreations(nowMs, apiBaseUrl) {
@@ -2067,9 +2395,6 @@ var CLIENT_TYPE = "sakupa-mcp";
2067
2395
 
2068
2396
  // src/tools/context.ts
2069
2397
  import { z as z2 } from "zod";
2070
- import { statSync } from "node:fs";
2071
- import { homedir as homedir2 } from "node:os";
2072
- import { isAbsolute as isAbsolute2, parse, resolve as resolve3 } from "node:path";
2073
2398
 
2074
2399
  // src/tools/result.ts
2075
2400
  import { z } from "zod";
@@ -2119,36 +2444,33 @@ var LocalGuidanceError = class extends SakupaError {
2119
2444
  }
2120
2445
  };
2121
2446
  var projectDirInput = z2.string().describe(
2122
- "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."
2123
2448
  );
2124
2449
  function withProjectDir(ctx, projectDirArg) {
2125
2450
  if (projectDirArg === void 0) {
2126
2451
  throw new LocalGuidanceError(
2127
2452
  "invalid_request",
2128
- "projectDir is REQUIRED on every call: pass the absolute path of the directory the user is CURRENTLY working in. The server never guesses a directory \u2014 a wrong guess once published one project's files over a different project's PAID site."
2129
- );
2130
- }
2131
- if (!isAbsolute2(projectDirArg)) {
2132
- throw new LocalGuidanceError(
2133
- "invalid_request",
2134
- `projectDir must be an ABSOLUTE path (got "${projectDirArg}"). Pass the full path of the directory the user is currently working in.`
2135
- );
2136
- }
2137
- const dir = resolve3(projectDirArg);
2138
- if (parse(dir).root === dir || dir === homedir2()) {
2139
- throw new LocalGuidanceError(
2140
- "invalid_request",
2141
- `projectDir "${dir}" is a filesystem root or the home directory. Pass the specific project folder that holds the site's files, not a top-level directory.`
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."
2142
2454
  );
2143
2455
  }
2144
- const stat2 = statSync(dir, { throwIfNoEntry: false });
2145
- if (!stat2?.isDirectory()) {
2146
- throw new LocalGuidanceError(
2147
- "invalid_request",
2148
- `projectDir "${dir}" does not exist or is not a directory. Pass the absolute path of the directory the user is currently working in.`
2149
- );
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;
2150
2473
  }
2151
- return { ...ctx, projectDir: dir };
2152
2474
  }
2153
2475
  function requireSiteFile(ctx) {
2154
2476
  const state = loadSiteFile(ctx.projectDir);
@@ -2280,7 +2602,7 @@ function ensureUploadSizeWithinLimits(manifest, isFirstFreeDeploy) {
2280
2602
  async function buildHashedManifest(files, outputAbs) {
2281
2603
  const manifest = [];
2282
2604
  for (const file of files) {
2283
- const bytes = new Uint8Array(await fs2.readFile(join5(outputAbs, file.path)));
2605
+ const bytes = new Uint8Array(await fs2.readFile(join6(outputAbs, file.path)));
2284
2606
  manifest.push({ path: file.path, size: file.size, contentHash: await sha256Hex(bytes) });
2285
2607
  }
2286
2608
  return manifest;
@@ -2299,7 +2621,7 @@ async function uploadAll(ctx, targets, files, outputAbs) {
2299
2621
  `No local file matches upload target "${target.path}"; aborting upload.`
2300
2622
  );
2301
2623
  }
2302
- const bytes = new Uint8Array(await fs2.readFile(join5(outputAbs, match.path)));
2624
+ const bytes = new Uint8Array(await fs2.readFile(join6(outputAbs, match.path)));
2303
2625
  if (bytes.byteLength !== match.size) {
2304
2626
  throw new SakupaError(
2305
2627
  "validation_failed",
@@ -2342,20 +2664,6 @@ ${block}`, checklist: toDnsChecklist(diag.checks) };
2342
2664
  };
2343
2665
  }
2344
2666
  }
2345
- function projectRootAbove(projectDir) {
2346
- if (existsSync3(join5(projectDir, "package.json"))) return null;
2347
- return findAncestor(projectDir, (dir) => existsSync3(join5(dir, "package.json")), 4);
2348
- }
2349
- function findNeighborBinding(projectDir, outputRel) {
2350
- const bound = (dir) => loadSiteFile(dir).kind !== "absent";
2351
- const above = findAncestor(projectDir, bound, 3);
2352
- if (above) return above;
2353
- if (outputRel && outputRel !== ".") {
2354
- const outputAbs = resolve4(projectDir, outputRel);
2355
- if (bound(outputAbs)) return outputAbs;
2356
- }
2357
- return null;
2358
- }
2359
2667
  function freeSiteCreationBarrier(apiBaseUrl) {
2360
2668
  const recent = listRecentCreations(Date.now(), apiBaseUrl);
2361
2669
  if (recent.length < FREE_ACTIVE_SITES_PER_IP) return null;
@@ -2406,12 +2714,17 @@ Next action: ${analysis.suggestedNextAction}`,
2406
2714
  server.registerTool(
2407
2715
  "deploy",
2408
2716
  {
2409
- 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. Never uploads anything when the 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.`,
2410
2718
  outputSchema: STRUCTURED_TOOL_OUTPUT_SCHEMA,
2411
2719
  annotations: { readOnlyHint: false, destructiveHint: true, openWorldHint: true },
2412
2720
  inputSchema: {
2413
2721
  projectDir: projectDirInput,
2414
- outputDir: z3.string().optional().describe("Output directory relative to the project root (overrides detection)."),
2722
+ outputDir: z3.string().min(1).describe(
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."
2727
+ ),
2415
2728
  spaFallback: z3.boolean().optional().describe(
2416
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."
2417
2730
  ),
@@ -2419,7 +2732,7 @@ Next action: ${analysis.suggestedNextAction}`,
2419
2732
  "Required only for the first deployment: user explicitly confirmed creation of a public 24-hour URL."
2420
2733
  ),
2421
2734
  subprojectConfirmed: z3.boolean().optional().describe(
2422
- "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."
2423
2736
  ),
2424
2737
  lang: z3.string().optional().describe("Site language override (en | ja | zh-CN); defaults to the html lang.")
2425
2738
  }
@@ -2427,14 +2740,29 @@ Next action: ${analysis.suggestedNextAction}`,
2427
2740
  async (args) => {
2428
2741
  try {
2429
2742
  const ctx = withProjectDir(baseCtx, args.projectDir);
2430
- const analysis = await analyzeProject(ctx.projectDir, {
2431
- ...args.outputDir !== void 0 ? { outputDir: args.outputDir } : {}
2432
- });
2743
+ const analysis = await analyzeProject(ctx.projectDir, { outputDir: args.outputDir });
2433
2744
  if (!analysis.deployable || !analysis.files) {
2434
2745
  return notDeployableResult(analysis);
2435
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
+ }
2436
2764
  const files = analysis.files;
2437
- const outputAbs = resolve4(ctx.projectDir, analysis.recommendedOutputDir ?? ".");
2765
+ const outputAbs = resolve4(ctx.projectDir, effectiveOutputDir);
2438
2766
  const manifest = await buildHashedManifest(files, outputAbs);
2439
2767
  const siteFileState = loadSiteFile(ctx.projectDir);
2440
2768
  if (siteFileState.kind === "corrupted") {
@@ -2447,26 +2775,33 @@ Next action: ${analysis.suggestedNextAction}`,
2447
2775
  "blocked"
2448
2776
  );
2449
2777
  }
2450
- const existing = siteFileState.kind === "ok" ? siteFileState.file : null;
2451
- if (!existing) {
2452
- const rootAbove = args.subprojectConfirmed === true ? null : projectRootAbove(ctx.projectDir);
2453
- if (rootAbove) {
2778
+ let existing = siteFileState.kind === "ok" ? siteFileState.file : null;
2779
+ let credentialRelocatedFrom = null;
2780
+ if (!existing && effectiveOutputDir !== ".") {
2781
+ const outputProjectMarker = loadProjectMarker(outputAbs);
2782
+ if (outputProjectMarker.kind !== "absent") {
2454
2783
  return text(
2455
- "not_project_root",
2456
- `${ctx.projectDir} has no package.json but ${rootAbove} does \u2014 this directory is a SUBFOLDER of that project (typically its build output), and .sakupa must live at the project ROOT. Re-run deploy with projectDir: ${rootAbove}. 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.`,
2457
- { projectRoot: rootAbove, confirmationField: "subprojectConfirmed" },
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 },
2458
2787
  "blocked"
2459
2788
  );
2460
2789
  }
2461
- const neighbor = findNeighborBinding(ctx.projectDir, analysis.recommendedOutputDir);
2462
- if (neighbor) {
2790
+ const outputSiteState = loadSiteFile(outputAbs);
2791
+ if (outputSiteState.kind === "corrupted") {
2463
2792
  return text(
2464
- "neighbor_binding_found",
2465
- `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.`,
2466
- { neighborProjectDir: neighbor },
2793
+ "output_site_file_corrupted",
2794
+ `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.`,
2795
+ { projectRoot: ctx.projectDir, outputDir: analysis.recommendedOutputDir },
2467
2796
  "blocked"
2468
2797
  );
2469
2798
  }
2799
+ if (outputSiteState.kind === "ok") {
2800
+ existing = outputSiteState.file;
2801
+ credentialRelocatedFrom = outputAbs;
2802
+ }
2803
+ }
2804
+ if (!existing) {
2470
2805
  const barrier = freeSiteCreationBarrier(ctx.apiBaseUrl);
2471
2806
  if (barrier) return barrier;
2472
2807
  if (args.publicConfirmed !== true) {
@@ -2500,6 +2835,7 @@ Next action: ${analysis.suggestedNextAction}`,
2500
2835
  createdAt,
2501
2836
  apiBaseUrl: ctx.apiBaseUrl
2502
2837
  });
2838
+ updateProjectOutputDir(ctx.projectDir, effectiveOutputDir);
2503
2839
  recordCreation({
2504
2840
  siteId: created.siteId,
2505
2841
  projectDir: ctx.projectDir,
@@ -2574,6 +2910,9 @@ ${JSON.stringify(finalized2.warnings, null, 2)}` : ""),
2574
2910
  }
2575
2911
  const { uploaded, finalized } = update;
2576
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);
2577
2916
  noteSiteMode(existing.siteId, finalized.mode);
2578
2917
  return text(
2579
2918
  "site_updated",
@@ -2582,6 +2921,7 @@ Environment: ${environmentFor(ctx.apiBaseUrl).toUpperCase()} (${ctx.apiBaseUrl})
2582
2921
  Project directory: ${ctx.projectDir}
2583
2922
  Files uploaded: ${uploaded} (${finalized.totalBytes} bytes)
2584
2923
  ` + (finalized.expiresAt ? `Validity refreshed \u2014 expires at: ${finalized.expiresAt}
2924
+ ` : "") + (credentialRelocatedFrom ? `Credential binding relocated from ${credentialRelocatedFrom}/.sakupa to ${ctx.projectDir}/.sakupa; the existing site was preserved.
2585
2925
  ` : "") + (finalized.mode === "free" ? `
2586
2926
  Reminder: free sites stay live for ${FREE_SITE_TTL_HOURS} hours after the last deploy or refresh call. Subscribing (subscribe) makes the site permanent.
2587
2927
  ` : "\nThis site is subscribed and permanent \u2014 no expiry.\n") + (finalized.warnings.length > 0 ? `
@@ -2596,7 +2936,8 @@ ${JSON.stringify(finalized.warnings, null, 2)}` : ""),
2596
2936
  expiresAt: finalized.expiresAt,
2597
2937
  filesUploaded: uploaded,
2598
2938
  totalBytes: finalized.totalBytes,
2599
- warnings: finalized.warnings
2939
+ warnings: finalized.warnings,
2940
+ ...credentialRelocatedFrom ? { credentialRelocatedFrom } : {}
2600
2941
  }
2601
2942
  );
2602
2943
  } catch (e) {
@@ -2680,7 +3021,7 @@ NO content was uploaded or changed by this call \u2014 to publish new or edited
2680
3021
  {
2681
3022
  siteId: site.siteId,
2682
3023
  plan: args.plan,
2683
- idempotencyKey: randomUUID()
3024
+ idempotencyKey: randomUUID2()
2684
3025
  },
2685
3026
  site.credential
2686
3027
  );
@@ -2870,14 +3211,14 @@ Full status:`, res);
2870
3211
  outputSchema: STRUCTURED_TOOL_OUTPUT_SCHEMA,
2871
3212
  annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: true },
2872
3213
  inputSchema: {
2873
- projectDir: projectDirInput,
3214
+ projectDir: projectDirInput.optional(),
2874
3215
  scope: z3.enum(["site", "public_recovery"])
2875
3216
  }
2876
3217
  },
2877
3218
  async (args) => {
2878
3219
  try {
2879
- const ctx = withProjectDir(baseCtx, args.projectDir);
2880
3220
  if (args.scope === "site") {
3221
+ const ctx = withProjectDir(baseCtx, args.projectDir);
2881
3222
  const site = requireSiteFile(ctx);
2882
3223
  const res2 = await ctx.client.createBillingPortal(site.siteId, site.credential);
2883
3224
  return structuredToolResult({
@@ -2895,7 +3236,7 @@ Full status:`, res);
2895
3236
  nextActions: [{ tool: "billing", allowed: true }]
2896
3237
  });
2897
3238
  }
2898
- const res = await ctx.client.getPublicBillingPortal();
3239
+ const res = await baseCtx.client.getPublicBillingPortal();
2899
3240
  return structuredToolResult({
2900
3241
  schemaVersion: 1,
2901
3242
  outcome: "waiting_user",
@@ -2923,7 +3264,7 @@ Full status:`, res);
2923
3264
  server.registerTool(
2924
3265
  "recover",
2925
3266
  {
2926
- 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.",
2927
3268
  outputSchema: STRUCTURED_TOOL_OUTPUT_SCHEMA,
2928
3269
  annotations: { readOnlyHint: false, destructiveHint: true, openWorldHint: true },
2929
3270
  inputSchema: {
@@ -2931,13 +3272,21 @@ Full status:`, res);
2931
3272
  action: z3.enum(["start", "status", "complete", "download"]),
2932
3273
  hostname: z3.string().optional().describe("Required for start."),
2933
3274
  verificationId: z3.string().optional().describe("For status or complete; inferred from local recovery state when omitted."),
2934
- 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
+ ),
2935
3278
  preserveExistingCredentials: z3.boolean().optional().describe("Explicitly keep old local credentials working (default: revoke them all).")
2936
3279
  }
2937
3280
  },
2938
3281
  async (args) => {
2939
3282
  try {
2940
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
+ }
2941
3290
  const localSite = loadSiteFile(ctx.projectDir);
2942
3291
  const localCredentialIsActive = async () => {
2943
3292
  if (localSite.kind !== "ok") return false;
@@ -2950,12 +3299,18 @@ Full status:`, res);
2950
3299
  }
2951
3300
  };
2952
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
+ }
2953
3308
  const site = requireSiteFile(ctx);
2954
3309
  const archive = await ctx.client.getSiteArchive(site.siteId, site.credential);
2955
3310
  const bytes = await ctx.client.downloadArchive(archive.archiveUrl);
2956
3311
  const extracted = await extractRecoveryArchive({
2957
3312
  projectDir: ctx.projectDir,
2958
- ...args.outputDir !== void 0 ? { outputDir: args.outputDir } : {},
3313
+ outputDir: args.outputDir,
2959
3314
  archive: bytes,
2960
3315
  expectedBytes: archive.totalBytes,
2961
3316
  expectedFiles: archive.fileCount
@@ -3000,7 +3355,7 @@ No DNS verification was started or repeated.`,
3000
3355
  arguments: {
3001
3356
  projectDir: ctx.projectDir,
3002
3357
  action: "download",
3003
- outputDir: args.outputDir ?? "html"
3358
+ ...args.outputDir !== void 0 ? { outputDir: args.outputDir } : {}
3004
3359
  },
3005
3360
  allowed: true
3006
3361
  }
@@ -3100,7 +3455,7 @@ After the DNS record resolves, re-run recover with verificationId: "${res2.verif
3100
3455
  arguments: {
3101
3456
  projectDir: ctx.projectDir,
3102
3457
  action: "download",
3103
- outputDir: args.outputDir ?? "html"
3458
+ ...args.outputDir !== void 0 ? { outputDir: args.outputDir } : {}
3104
3459
  },
3105
3460
  allowed: true
3106
3461
  }
@@ -3136,7 +3491,7 @@ After the DNS record resolves, re-run recover with verificationId: "${res2.verif
3136
3491
  projectDir: ctx.projectDir,
3137
3492
  action: "complete",
3138
3493
  verificationId,
3139
- outputDir: args.outputDir ?? "html"
3494
+ ...args.outputDir !== void 0 ? { outputDir: args.outputDir } : {}
3140
3495
  },
3141
3496
  allowed: res2.readyToComplete,
3142
3497
  ...res2.readyToComplete ? {} : { reasonCode: res2.status }
@@ -3212,7 +3567,7 @@ Files: ${extracted.fileCount}; bytes: ${extracted.totalBytes}
3212
3567
  arguments: {
3213
3568
  projectDir: ctx.projectDir,
3214
3569
  action: "download",
3215
- outputDir: args.outputDir ?? "html"
3570
+ ...args.outputDir !== void 0 ? { outputDir: args.outputDir } : {}
3216
3571
  },
3217
3572
  allowed: true
3218
3573
  }
@@ -3338,14 +3693,13 @@ function registerBillingTools(server, baseCtx) {
3338
3693
  "plans",
3339
3694
  {
3340
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.",
3341
- inputSchema: { projectDir: projectDirInput },
3696
+ inputSchema: {},
3342
3697
  outputSchema: STRUCTURED_TOOL_OUTPUT_SCHEMA,
3343
3698
  annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: true }
3344
3699
  },
3345
- async (args) => {
3700
+ async () => {
3346
3701
  try {
3347
- const ctx = withProjectDir(baseCtx, args.projectDir);
3348
- const catalog = await ctx.client.getBillingPlanCatalog();
3702
+ const catalog = await baseCtx.client.getBillingPlanCatalog();
3349
3703
  return structuredToolResult({
3350
3704
  schemaVersion: 1,
3351
3705
  outcome: "completed",
@@ -3401,7 +3755,7 @@ function registerBillingTools(server, baseCtx) {
3401
3755
  }
3402
3756
 
3403
3757
  // src/tools/lifecycle.ts
3404
- import { randomUUID as randomUUID2 } from "node:crypto";
3758
+ import { randomUUID as randomUUID3 } from "node:crypto";
3405
3759
  import { z as z5 } from "zod";
3406
3760
  var deleteConfirmation = z5.object({
3407
3761
  siteId: z5.string().min(1),
@@ -3436,7 +3790,7 @@ function registerLifecycleTools(server, baseCtx) {
3436
3790
  try {
3437
3791
  const ctx = withProjectDir(baseCtx, args.projectDir);
3438
3792
  const site = requireSiteFile(ctx);
3439
- const operationId = args.operationId ?? randomUUID2();
3793
+ const operationId = args.operationId ?? randomUUID3();
3440
3794
  if (args.action === "preview") {
3441
3795
  const preview = await ctx.client.previewDeleteSite(site.siteId, site.credential, {
3442
3796
  operationId
@@ -3631,12 +3985,19 @@ Workflow:
3631
3985
  5. support (subscribed sites) opens a support ticket; report sends a
3632
3986
  sanitized diagnostic report after the user explicitly confirms it.
3633
3987
 
3634
- Project directory contract: ONE directory = ONE site (its .sakupa/site.json holds the
3635
- binding). projectDir is REQUIRED on EVERY tool call \u2014 always pass the absolute path of
3636
- the user's PROJECT ROOT, the SAME directory every time for the same project: the folder
3637
- the user opened (for framework projects, where package.json lives \u2014 never the dist/out
3638
- build folder; output is auto-detected). The server NEVER guesses a directory and refuses
3639
- calls without one: only you can see which directory the user is actually in. After every deploy, TELL the user which environment it went to (deploy results carry an
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.
4000
+ After every deploy, TELL the user which environment it went to (deploy results carry an
3640
4001
  Explicit Environment line: TEST vs PRODUCTION). analyze, deploy, status,
3641
4002
  refresh and delete echo
3642
4003
  the directory they acted on \u2014 verify it matches the user's active project.
@@ -3686,12 +4047,30 @@ function createSakupaMcpServer(opts) {
3686
4047
 
3687
4048
  // src/bin.ts
3688
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
+ }
3689
4068
  const config = loadMcpRuntimeConfig();
3690
4069
  const server = createSakupaMcpServer(config);
3691
4070
  const transport = new StdioServerTransport();
3692
4071
  await server.connect(transport);
3693
4072
  console.error(
3694
- `[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)`
3695
4074
  );
3696
4075
  }
3697
4076
  main().catch((err2) => {