@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.
- package/dist/bin.js +535 -156
- package/dist/index.js +432 -109
- 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.
|
|
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 {
|
|
693
|
-
import { join as
|
|
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 =
|
|
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(
|
|
1062
|
+
await recurse(join2(current, entry.name), rel);
|
|
773
1063
|
} else if (entry.isFile()) {
|
|
774
1064
|
try {
|
|
775
|
-
const stat2 = await fs.stat(
|
|
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(
|
|
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(
|
|
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
|
-
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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 =
|
|
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 =
|
|
989
|
-
if (abs !== root && !abs.startsWith(root +
|
|
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(
|
|
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(
|
|
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(
|
|
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 :
|
|
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(
|
|
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 {
|
|
1117
|
-
|
|
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
|
|
1120
|
-
var
|
|
1427
|
+
var SITE_FILE2 = "site.json";
|
|
1428
|
+
var RECOVERY_FILE2 = "recovery.json";
|
|
1121
1429
|
function siteFilePath(projectDir) {
|
|
1122
|
-
return
|
|
1430
|
+
return join3(projectDir, SITE_DIR, SITE_FILE2);
|
|
1123
1431
|
}
|
|
1124
1432
|
function recoveryFilePath(projectDir) {
|
|
1125
|
-
return
|
|
1433
|
+
return join3(projectDir, SITE_DIR, RECOVERY_FILE2);
|
|
1126
1434
|
}
|
|
1127
1435
|
function loadSiteFile(projectDir) {
|
|
1128
1436
|
const path = siteFilePath(projectDir);
|
|
1129
|
-
if (!
|
|
1437
|
+
if (!existsSync2(path)) return { kind: "absent" };
|
|
1130
1438
|
let raw;
|
|
1131
1439
|
try {
|
|
1132
|
-
raw =
|
|
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 (!
|
|
1483
|
+
if (!existsSync2(path)) return null;
|
|
1176
1484
|
try {
|
|
1177
|
-
const parsed = JSON.parse(
|
|
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 =
|
|
1194
|
-
|
|
1195
|
-
const path =
|
|
1196
|
-
|
|
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
|
-
|
|
1507
|
+
chmodSync2(path, 384);
|
|
1200
1508
|
} catch {
|
|
1201
1509
|
}
|
|
1202
1510
|
}
|
|
1203
1511
|
function deleteRecoveryFile(projectDir) {
|
|
1204
1512
|
const path = recoveryFilePath(projectDir);
|
|
1205
|
-
if (
|
|
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 =
|
|
1225
|
-
|
|
1226
|
-
const path =
|
|
1227
|
-
|
|
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
|
-
|
|
1538
|
+
chmodSync2(path, 384);
|
|
1231
1539
|
} catch {
|
|
1232
1540
|
}
|
|
1233
1541
|
}
|
|
1234
1542
|
function deleteSiteFile(projectDir) {
|
|
1235
1543
|
const path = siteFilePath(projectDir);
|
|
1236
|
-
if (
|
|
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 =
|
|
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
|
|
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
|
|
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 ||
|
|
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 =
|
|
1751
|
-
const target =
|
|
1752
|
-
const rel =
|
|
1753
|
-
if (rel === "" || rel === ".." || rel.startsWith(`..${
|
|
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${
|
|
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 =
|
|
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(
|
|
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(
|
|
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
|
|
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(
|
|
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 =
|
|
1863
|
-
await mkdir(
|
|
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(
|
|
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
|
|
1889
|
-
import { homedir } from "node:os";
|
|
1890
|
-
import { dirname as
|
|
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"] ??
|
|
1894
|
-
return
|
|
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 (!
|
|
2226
|
+
if (!existsSync4(path)) return [];
|
|
1899
2227
|
try {
|
|
1900
|
-
const parsed = JSON.parse(
|
|
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
|
-
|
|
1912
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
2145
|
-
|
|
2146
|
-
|
|
2147
|
-
|
|
2148
|
-
|
|
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(
|
|
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(
|
|
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
|
|
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().
|
|
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
|
-
"
|
|
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,
|
|
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
|
-
|
|
2451
|
-
|
|
2452
|
-
|
|
2453
|
-
|
|
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
|
-
"
|
|
2456
|
-
|
|
2457
|
-
{ projectRoot:
|
|
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
|
|
2462
|
-
if (
|
|
2790
|
+
const outputSiteState = loadSiteFile(outputAbs);
|
|
2791
|
+
if (outputSiteState.kind === "corrupted") {
|
|
2463
2792
|
return text(
|
|
2464
|
-
"
|
|
2465
|
-
`
|
|
2466
|
-
{
|
|
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:
|
|
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
|
|
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
|
|
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(
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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: {
|
|
3696
|
+
inputSchema: {},
|
|
3342
3697
|
outputSchema: STRUCTURED_TOOL_OUTPUT_SCHEMA,
|
|
3343
3698
|
annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: true }
|
|
3344
3699
|
},
|
|
3345
|
-
async (
|
|
3700
|
+
async () => {
|
|
3346
3701
|
try {
|
|
3347
|
-
const
|
|
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
|
|
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 ??
|
|
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:
|
|
3635
|
-
|
|
3636
|
-
|
|
3637
|
-
|
|
3638
|
-
|
|
3639
|
-
|
|
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};
|
|
4073
|
+
`[sakupa-mcp] v${MCP_VERSION} connected (api: ${config.apiBaseUrl}; projectDir resolves through Sakupa markers)`
|
|
3695
4074
|
);
|
|
3696
4075
|
}
|
|
3697
4076
|
main().catch((err2) => {
|