@sakupa/mcp 0.7.32 → 0.7.33
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/bin.js +512 -173
- package/dist/index.js +403 -120
- 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
|
}
|
|
@@ -1114,30 +1414,30 @@ async function analyzeProject(projectDir, opts = {}) {
|
|
|
1114
1414
|
|
|
1115
1415
|
// src/project-file.ts
|
|
1116
1416
|
import {
|
|
1117
|
-
chmodSync,
|
|
1118
|
-
existsSync,
|
|
1119
|
-
mkdirSync,
|
|
1120
|
-
readFileSync,
|
|
1417
|
+
chmodSync as chmodSync2,
|
|
1418
|
+
existsSync as existsSync2,
|
|
1419
|
+
mkdirSync as mkdirSync2,
|
|
1420
|
+
readFileSync as readFileSync2,
|
|
1121
1421
|
rmdirSync,
|
|
1122
1422
|
rmSync,
|
|
1123
|
-
writeFileSync
|
|
1423
|
+
writeFileSync as writeFileSync2
|
|
1124
1424
|
} from "node:fs";
|
|
1125
|
-
import { dirname, join as
|
|
1425
|
+
import { dirname as dirname2, join as join3 } from "node:path";
|
|
1126
1426
|
var SITE_DIR = ".sakupa";
|
|
1127
|
-
var
|
|
1128
|
-
var
|
|
1427
|
+
var SITE_FILE2 = "site.json";
|
|
1428
|
+
var RECOVERY_FILE2 = "recovery.json";
|
|
1129
1429
|
function siteFilePath(projectDir) {
|
|
1130
|
-
return
|
|
1430
|
+
return join3(projectDir, SITE_DIR, SITE_FILE2);
|
|
1131
1431
|
}
|
|
1132
1432
|
function recoveryFilePath(projectDir) {
|
|
1133
|
-
return
|
|
1433
|
+
return join3(projectDir, SITE_DIR, RECOVERY_FILE2);
|
|
1134
1434
|
}
|
|
1135
1435
|
function loadSiteFile(projectDir) {
|
|
1136
1436
|
const path = siteFilePath(projectDir);
|
|
1137
|
-
if (!
|
|
1437
|
+
if (!existsSync2(path)) return { kind: "absent" };
|
|
1138
1438
|
let raw;
|
|
1139
1439
|
try {
|
|
1140
|
-
raw =
|
|
1440
|
+
raw = readFileSync2(path, "utf8");
|
|
1141
1441
|
} catch (err2) {
|
|
1142
1442
|
return {
|
|
1143
1443
|
kind: "corrupted",
|
|
@@ -1180,9 +1480,9 @@ function loadSiteFile(projectDir) {
|
|
|
1180
1480
|
}
|
|
1181
1481
|
function loadRecoveryFile(projectDir) {
|
|
1182
1482
|
const path = recoveryFilePath(projectDir);
|
|
1183
|
-
if (!
|
|
1483
|
+
if (!existsSync2(path)) return null;
|
|
1184
1484
|
try {
|
|
1185
|
-
const parsed = JSON.parse(
|
|
1485
|
+
const parsed = JSON.parse(readFileSync2(path, "utf8"));
|
|
1186
1486
|
if (typeof parsed.verificationId !== "string" || parsed.verificationId.length === 0 || typeof parsed.credential !== "string" || !CREDENTIAL_PATTERN.test(parsed.credential)) {
|
|
1187
1487
|
throw new Error("required recovery fields are missing or invalid");
|
|
1188
1488
|
}
|
|
@@ -1198,19 +1498,19 @@ function loadRecoveryFile(projectDir) {
|
|
|
1198
1498
|
}
|
|
1199
1499
|
}
|
|
1200
1500
|
function writeRecoveryFile(projectDir, file) {
|
|
1201
|
-
const dir =
|
|
1202
|
-
|
|
1203
|
-
const path =
|
|
1204
|
-
|
|
1501
|
+
const dir = join3(projectDir, SITE_DIR);
|
|
1502
|
+
mkdirSync2(dir, { recursive: true });
|
|
1503
|
+
const path = join3(dir, RECOVERY_FILE2);
|
|
1504
|
+
writeFileSync2(path, `${JSON.stringify(file, null, 2)}
|
|
1205
1505
|
`, "utf8");
|
|
1206
1506
|
try {
|
|
1207
|
-
|
|
1507
|
+
chmodSync2(path, 384);
|
|
1208
1508
|
} catch {
|
|
1209
1509
|
}
|
|
1210
1510
|
}
|
|
1211
1511
|
function deleteRecoveryFile(projectDir) {
|
|
1212
1512
|
const path = recoveryFilePath(projectDir);
|
|
1213
|
-
if (
|
|
1513
|
+
if (existsSync2(path)) rmSync(path, { force: true });
|
|
1214
1514
|
}
|
|
1215
1515
|
function siteFileRecoveryGuidance(projectDir) {
|
|
1216
1516
|
return `The site itself is intact on the server; only the local binding file (${siteFilePath(projectDir)}) is the problem. Restore the file (from a backup or by undoing the local edit). Do NOT delete it to work around the error: the credential is unrecoverable by design, so abandoning it permanently orphans the existing site. Publishing this project as a brand-NEW site requires the user to manually delete the .sakupa directory first \u2014 the tool will never overwrite it.`;
|
|
@@ -1229,30 +1529,30 @@ function writeSiteFile(projectDir, file, opts = {}) {
|
|
|
1229
1529
|
);
|
|
1230
1530
|
}
|
|
1231
1531
|
}
|
|
1232
|
-
const dir =
|
|
1233
|
-
|
|
1234
|
-
const path =
|
|
1235
|
-
|
|
1532
|
+
const dir = join3(projectDir, SITE_DIR);
|
|
1533
|
+
mkdirSync2(dir, { recursive: true });
|
|
1534
|
+
const path = join3(dir, SITE_FILE2);
|
|
1535
|
+
writeFileSync2(path, `${JSON.stringify(file, null, 2)}
|
|
1236
1536
|
`, "utf8");
|
|
1237
1537
|
try {
|
|
1238
|
-
|
|
1538
|
+
chmodSync2(path, 384);
|
|
1239
1539
|
} catch {
|
|
1240
1540
|
}
|
|
1241
1541
|
}
|
|
1242
1542
|
function deleteSiteFile(projectDir) {
|
|
1243
1543
|
const path = siteFilePath(projectDir);
|
|
1244
|
-
if (
|
|
1544
|
+
if (existsSync2(path)) {
|
|
1245
1545
|
rmSync(path, { force: true });
|
|
1246
1546
|
}
|
|
1247
1547
|
try {
|
|
1248
|
-
rmdirSync(
|
|
1548
|
+
rmdirSync(join3(projectDir, SITE_DIR));
|
|
1249
1549
|
} catch {
|
|
1250
1550
|
}
|
|
1251
1551
|
}
|
|
1252
1552
|
function findAncestor(startDir, predicate, maxLevels = Number.POSITIVE_INFINITY) {
|
|
1253
1553
|
let cursor = startDir;
|
|
1254
1554
|
for (let i = 0; i < maxLevels; i += 1) {
|
|
1255
|
-
const parent =
|
|
1555
|
+
const parent = dirname2(cursor);
|
|
1256
1556
|
if (parent === cursor) return null;
|
|
1257
1557
|
if (predicate(parent)) return parent;
|
|
1258
1558
|
cursor = parent;
|
|
@@ -1260,7 +1560,7 @@ function findAncestor(startDir, predicate, maxLevels = Number.POSITIVE_INFINITY)
|
|
|
1260
1560
|
return null;
|
|
1261
1561
|
}
|
|
1262
1562
|
function isInsideGitRepo(projectDir) {
|
|
1263
|
-
return
|
|
1563
|
+
return existsSync2(join3(projectDir, ".git")) || findAncestor(projectDir, (dir) => existsSync2(join3(dir, ".git"))) !== null;
|
|
1264
1564
|
}
|
|
1265
1565
|
function credentialGitReminder(projectDir) {
|
|
1266
1566
|
if (!isInsideGitRepo(projectDir)) return "";
|
|
@@ -1268,8 +1568,9 @@ function credentialGitReminder(projectDir) {
|
|
|
1268
1568
|
}
|
|
1269
1569
|
|
|
1270
1570
|
// src/recovery-archive.ts
|
|
1571
|
+
import { existsSync as existsSync3, realpathSync as realpathSync2 } from "node:fs";
|
|
1271
1572
|
import { mkdtemp, mkdir, readFile, readdir, rename, rm, stat, writeFile } from "node:fs/promises";
|
|
1272
|
-
import { dirname as
|
|
1573
|
+
import { dirname as dirname3, isAbsolute as isAbsolute2, join as join4, relative as relative2, resolve as resolve3, sep as sep3 } from "node:path";
|
|
1273
1574
|
|
|
1274
1575
|
// ../../node_modules/fflate/esm/index.mjs
|
|
1275
1576
|
import { createRequire } from "module";
|
|
@@ -1756,18 +2057,33 @@ function unzipSync(data, opts) {
|
|
|
1756
2057
|
|
|
1757
2058
|
// src/recovery-archive.ts
|
|
1758
2059
|
function safeOutputPath(projectDir, outputDir) {
|
|
1759
|
-
if (outputDir.length === 0 ||
|
|
2060
|
+
if (outputDir.length === 0 || isAbsolute2(outputDir)) {
|
|
1760
2061
|
throw new SakupaError("invalid_request", "Recovery outputDir must be a relative directory");
|
|
1761
2062
|
}
|
|
1762
|
-
const root =
|
|
1763
|
-
const target =
|
|
1764
|
-
const rel =
|
|
1765
|
-
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)) {
|
|
1766
2067
|
throw new SakupaError("invalid_request", "Recovery outputDir must stay inside projectDir");
|
|
1767
2068
|
}
|
|
1768
|
-
if (rel === ".sakupa" || rel.startsWith(`.sakupa${
|
|
2069
|
+
if (rel === ".sakupa" || rel.startsWith(`.sakupa${sep3}`)) {
|
|
1769
2070
|
throw new SakupaError("invalid_request", "Recovery content cannot be written inside .sakupa");
|
|
1770
2071
|
}
|
|
2072
|
+
let existingAncestor = target;
|
|
2073
|
+
while (!existsSync3(existingAncestor)) {
|
|
2074
|
+
const parent = dirname3(existingAncestor);
|
|
2075
|
+
if (parent === existingAncestor) break;
|
|
2076
|
+
existingAncestor = parent;
|
|
2077
|
+
}
|
|
2078
|
+
const physicalAncestor = realpathSync2(existingAncestor);
|
|
2079
|
+
const physicalTarget = resolve3(physicalAncestor, relative2(existingAncestor, target));
|
|
2080
|
+
const physicalRel = relative2(root, physicalTarget);
|
|
2081
|
+
if (physicalRel === ".." || physicalRel.startsWith(`..${sep3}`) || isAbsolute2(physicalRel)) {
|
|
2082
|
+
throw new SakupaError(
|
|
2083
|
+
"invalid_request",
|
|
2084
|
+
"Recovery outputDir resolves through a symlink outside projectDir"
|
|
2085
|
+
);
|
|
2086
|
+
}
|
|
1771
2087
|
return target;
|
|
1772
2088
|
}
|
|
1773
2089
|
function safeEntryName(name) {
|
|
@@ -1796,9 +2112,9 @@ async function listExistingFiles(root, current = root) {
|
|
|
1796
2112
|
if (entry.isSymbolicLink()) {
|
|
1797
2113
|
throw new SakupaError("state_conflict", `Recovery output contains a symlink: ${entry.name}`);
|
|
1798
2114
|
}
|
|
1799
|
-
const absolute =
|
|
2115
|
+
const absolute = join4(current, entry.name);
|
|
1800
2116
|
if (entry.isDirectory()) files.push(...await listExistingFiles(root, absolute));
|
|
1801
|
-
else if (entry.isFile()) files.push(
|
|
2117
|
+
else if (entry.isFile()) files.push(relative2(root, absolute).split(sep3).join("/"));
|
|
1802
2118
|
else
|
|
1803
2119
|
throw new SakupaError(
|
|
1804
2120
|
"state_conflict",
|
|
@@ -1814,7 +2130,7 @@ async function existingOutputMatches(outputDir, files) {
|
|
|
1814
2130
|
return false;
|
|
1815
2131
|
}
|
|
1816
2132
|
for (const name of expected) {
|
|
1817
|
-
const actual = await readFile(
|
|
2133
|
+
const actual = await readFile(join4(outputDir, ...name.split("/")));
|
|
1818
2134
|
const wanted = files[name];
|
|
1819
2135
|
if (!wanted || actual.byteLength !== wanted.byteLength || !actual.equals(wanted)) return false;
|
|
1820
2136
|
}
|
|
@@ -1824,7 +2140,7 @@ async function extractRecoveryArchive(input) {
|
|
|
1824
2140
|
if (!Number.isSafeInteger(input.expectedBytes) || input.expectedBytes < 0 || input.expectedBytes > PAID_SITE_MAX_TOTAL_BYTES || !Number.isSafeInteger(input.expectedFiles) || input.expectedFiles < 0 || input.expectedFiles > MAX_FILE_COUNT) {
|
|
1825
2141
|
throw new SakupaError("validation_failed", "Recovery archive metadata exceeds product limits");
|
|
1826
2142
|
}
|
|
1827
|
-
const outputDir = safeOutputPath(input.projectDir, input.outputDir
|
|
2143
|
+
const outputDir = safeOutputPath(input.projectDir, input.outputDir);
|
|
1828
2144
|
const maxArchiveBytes = input.expectedBytes + input.expectedFiles * 4096 + 65536;
|
|
1829
2145
|
if (input.archive.byteLength > maxArchiveBytes) {
|
|
1830
2146
|
throw new SakupaError("validation_failed", "Recovery archive is larger than its site metadata");
|
|
@@ -1865,14 +2181,14 @@ async function extractRecoveryArchive(input) {
|
|
|
1865
2181
|
`Recovery output already exists with different content: ${outputDir}. Move it aside or choose another outputDir.`
|
|
1866
2182
|
);
|
|
1867
2183
|
}
|
|
1868
|
-
const tempDir = await mkdtemp(
|
|
2184
|
+
const tempDir = await mkdtemp(join4(resolve3(input.projectDir), ".sakupa-restore-"));
|
|
1869
2185
|
try {
|
|
1870
2186
|
let writtenBytes = 0;
|
|
1871
2187
|
const entries = Object.entries(files).sort(([a], [b]) => a.localeCompare(b));
|
|
1872
2188
|
for (const [rawName, data] of entries) {
|
|
1873
2189
|
const name = safeEntryName(rawName);
|
|
1874
|
-
const destination =
|
|
1875
|
-
await mkdir(
|
|
2190
|
+
const destination = join4(tempDir, ...name.split("/"));
|
|
2191
|
+
await mkdir(dirname3(destination), { recursive: true });
|
|
1876
2192
|
await writeFile(destination, data, { flag: "wx" });
|
|
1877
2193
|
writtenBytes += data.byteLength;
|
|
1878
2194
|
}
|
|
@@ -1882,7 +2198,7 @@ async function extractRecoveryArchive(input) {
|
|
|
1882
2198
|
"Extracted recovery data does not match site metadata"
|
|
1883
2199
|
);
|
|
1884
2200
|
}
|
|
1885
|
-
await mkdir(
|
|
2201
|
+
await mkdir(dirname3(outputDir), { recursive: true });
|
|
1886
2202
|
await rename(tempDir, outputDir);
|
|
1887
2203
|
return {
|
|
1888
2204
|
outputDir,
|
|
@@ -1897,19 +2213,19 @@ async function extractRecoveryArchive(input) {
|
|
|
1897
2213
|
}
|
|
1898
2214
|
|
|
1899
2215
|
// src/creation-registry.ts
|
|
1900
|
-
import { existsSync as
|
|
1901
|
-
import { homedir } from "node:os";
|
|
1902
|
-
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";
|
|
1903
2219
|
var RECENT_WINDOW_MS = FREE_SITE_TTL_HOURS * 60 * 60 * 1e3;
|
|
1904
2220
|
function creationRegistryPath() {
|
|
1905
|
-
const base = process.env["SAKUPA_STATE_DIR"] ??
|
|
1906
|
-
return
|
|
2221
|
+
const base = process.env["SAKUPA_STATE_DIR"] ?? homedir2();
|
|
2222
|
+
return join5(base, ".sakupa", "created-sites.json");
|
|
1907
2223
|
}
|
|
1908
2224
|
function readAll() {
|
|
1909
2225
|
const path = creationRegistryPath();
|
|
1910
|
-
if (!
|
|
2226
|
+
if (!existsSync4(path)) return [];
|
|
1911
2227
|
try {
|
|
1912
|
-
const parsed = JSON.parse(
|
|
2228
|
+
const parsed = JSON.parse(readFileSync3(path, "utf-8"));
|
|
1913
2229
|
if (!Array.isArray(parsed)) return [];
|
|
1914
2230
|
return parsed.filter(
|
|
1915
2231
|
(e) => typeof e === "object" && e !== null && typeof e.siteId === "string" && typeof e.createdAt === "string"
|
|
@@ -1920,8 +2236,8 @@ function readAll() {
|
|
|
1920
2236
|
}
|
|
1921
2237
|
function writeAll(records) {
|
|
1922
2238
|
const path = creationRegistryPath();
|
|
1923
|
-
|
|
1924
|
-
|
|
2239
|
+
mkdirSync3(dirname4(path), { recursive: true });
|
|
2240
|
+
writeFileSync3(path, `${JSON.stringify(records, null, 2)}
|
|
1925
2241
|
`, "utf-8");
|
|
1926
2242
|
}
|
|
1927
2243
|
function listRecentCreations(nowMs, apiBaseUrl) {
|
|
@@ -2079,9 +2395,6 @@ var CLIENT_TYPE = "sakupa-mcp";
|
|
|
2079
2395
|
|
|
2080
2396
|
// src/tools/context.ts
|
|
2081
2397
|
import { z as z2 } from "zod";
|
|
2082
|
-
import { statSync } from "node:fs";
|
|
2083
|
-
import { homedir as homedir2 } from "node:os";
|
|
2084
|
-
import { isAbsolute as isAbsolute2, parse, resolve as resolve3 } from "node:path";
|
|
2085
2398
|
|
|
2086
2399
|
// src/tools/result.ts
|
|
2087
2400
|
import { z } from "zod";
|
|
@@ -2131,36 +2444,33 @@ var LocalGuidanceError = class extends SakupaError {
|
|
|
2131
2444
|
}
|
|
2132
2445
|
};
|
|
2133
2446
|
var projectDirInput = z2.string().describe(
|
|
2134
|
-
"REQUIRED
|
|
2447
|
+
"REQUIRED: an absolute existing path anywhere inside the user's initialized Sakupa project (the root itself or a child such as dist/html/src). Sakupa resolves upward to its own .sakupa marker and never trusts this argument as the root. If no marker exists, run `npx -y @sakupa/mcp@latest init` once from the intended project root."
|
|
2135
2448
|
);
|
|
2136
2449
|
function withProjectDir(ctx, projectDirArg) {
|
|
2137
2450
|
if (projectDirArg === void 0) {
|
|
2138
2451
|
throw new LocalGuidanceError(
|
|
2139
2452
|
"invalid_request",
|
|
2140
|
-
"projectDir is REQUIRED
|
|
2141
|
-
);
|
|
2142
|
-
}
|
|
2143
|
-
if (!isAbsolute2(projectDirArg)) {
|
|
2144
|
-
throw new LocalGuidanceError(
|
|
2145
|
-
"invalid_request",
|
|
2146
|
-
`projectDir must be an ABSOLUTE path (got "${projectDirArg}"). Pass the full path of the directory the user is currently working in.`
|
|
2453
|
+
"projectDir is REQUIRED as a path locator: pass an absolute existing path anywhere inside the current Sakupa project. The server resolves its own .sakupa marker and never treats the supplied path as an authoritative root."
|
|
2147
2454
|
);
|
|
2148
2455
|
}
|
|
2149
|
-
|
|
2150
|
-
|
|
2151
|
-
|
|
2152
|
-
|
|
2153
|
-
|
|
2154
|
-
|
|
2155
|
-
|
|
2156
|
-
|
|
2157
|
-
|
|
2158
|
-
|
|
2159
|
-
|
|
2160
|
-
|
|
2161
|
-
|
|
2456
|
+
try {
|
|
2457
|
+
const resolved = resolveProjectRoot(projectDirArg);
|
|
2458
|
+
return {
|
|
2459
|
+
...ctx,
|
|
2460
|
+
projectDir: resolved.projectDir,
|
|
2461
|
+
requestedPath: resolved.requestedPath,
|
|
2462
|
+
markerKind: resolved.markerKind,
|
|
2463
|
+
...resolved.marker !== void 0 ? { projectMarker: resolved.marker } : {}
|
|
2464
|
+
};
|
|
2465
|
+
} catch (error) {
|
|
2466
|
+
if (error instanceof ProjectRootError) {
|
|
2467
|
+
throw new LocalGuidanceError(
|
|
2468
|
+
error.code === "not_initialized" ? "not_found" : "invalid_request",
|
|
2469
|
+
error.message
|
|
2470
|
+
);
|
|
2471
|
+
}
|
|
2472
|
+
throw error;
|
|
2162
2473
|
}
|
|
2163
|
-
return { ...ctx, projectDir: dir };
|
|
2164
2474
|
}
|
|
2165
2475
|
function requireSiteFile(ctx) {
|
|
2166
2476
|
const state = loadSiteFile(ctx.projectDir);
|
|
@@ -2292,7 +2602,7 @@ function ensureUploadSizeWithinLimits(manifest, isFirstFreeDeploy) {
|
|
|
2292
2602
|
async function buildHashedManifest(files, outputAbs) {
|
|
2293
2603
|
const manifest = [];
|
|
2294
2604
|
for (const file of files) {
|
|
2295
|
-
const bytes = new Uint8Array(await fs2.readFile(
|
|
2605
|
+
const bytes = new Uint8Array(await fs2.readFile(join6(outputAbs, file.path)));
|
|
2296
2606
|
manifest.push({ path: file.path, size: file.size, contentHash: await sha256Hex(bytes) });
|
|
2297
2607
|
}
|
|
2298
2608
|
return manifest;
|
|
@@ -2311,7 +2621,7 @@ async function uploadAll(ctx, targets, files, outputAbs) {
|
|
|
2311
2621
|
`No local file matches upload target "${target.path}"; aborting upload.`
|
|
2312
2622
|
);
|
|
2313
2623
|
}
|
|
2314
|
-
const bytes = new Uint8Array(await fs2.readFile(
|
|
2624
|
+
const bytes = new Uint8Array(await fs2.readFile(join6(outputAbs, match.path)));
|
|
2315
2625
|
if (bytes.byteLength !== match.size) {
|
|
2316
2626
|
throw new SakupaError(
|
|
2317
2627
|
"validation_failed",
|
|
@@ -2354,21 +2664,6 @@ ${block}`, checklist: toDnsChecklist(diag.checks) };
|
|
|
2354
2664
|
};
|
|
2355
2665
|
}
|
|
2356
2666
|
}
|
|
2357
|
-
function projectRootAbove(projectDir) {
|
|
2358
|
-
if (existsSync3(join5(projectDir, "package.json"))) return null;
|
|
2359
|
-
const packageRoot = findAncestor(projectDir, (dir) => existsSync3(join5(dir, "package.json")), 4);
|
|
2360
|
-
if (packageRoot) {
|
|
2361
|
-
return {
|
|
2362
|
-
projectRoot: packageRoot,
|
|
2363
|
-
outputDir: relative2(packageRoot, projectDir).split(sep3).join("/")
|
|
2364
|
-
};
|
|
2365
|
-
}
|
|
2366
|
-
return null;
|
|
2367
|
-
}
|
|
2368
|
-
function findNeighborBinding(projectDir) {
|
|
2369
|
-
const bound = (dir) => loadSiteFile(dir).kind !== "absent";
|
|
2370
|
-
return findAncestor(projectDir, bound, 3);
|
|
2371
|
-
}
|
|
2372
2667
|
function freeSiteCreationBarrier(apiBaseUrl) {
|
|
2373
2668
|
const recent = listRecentCreations(Date.now(), apiBaseUrl);
|
|
2374
2669
|
if (recent.length < FREE_ACTIVE_SITES_PER_IP) return null;
|
|
@@ -2419,13 +2714,16 @@ Next action: ${analysis.suggestedNextAction}`,
|
|
|
2419
2714
|
server.registerTool(
|
|
2420
2715
|
"deploy",
|
|
2421
2716
|
{
|
|
2422
|
-
description: `Deploy the local static output to Sakupa. First deploy creates a free temporary site (valid ${FREE_SITE_TTL_HOURS}h, public URL like https://${previewHostPattern}) and stores the management credential in .sakupa/site.json. Later runs update the existing site (free sites also refresh their validity; subscribed sites are permanent). Runs analyze first and refuses to upload source projects, secrets, .env files, archives, media or server code. projectDir
|
|
2717
|
+
description: `Deploy the local static output to Sakupa. First deploy creates a free temporary site (valid ${FREE_SITE_TTL_HOURS}h, public URL like https://${previewHostPattern}) and stores the management credential in .sakupa/site.json. Later runs update the existing site (free sites also refresh their validity; subscribed sites are permanent). Runs analyze first and refuses to upload source projects, secrets, .env files, archives, media or server code. projectDir may point anywhere inside a project already initialized with \`npx -y @sakupa/mcp@latest init\`; Sakupa resolves its own marker upward and stores credentials only at that root. outputDir is a separate REQUIRED relative path supplied from the current project inspection. Never uploads anything when analysis says the project is not deployable.`,
|
|
2423
2718
|
outputSchema: STRUCTURED_TOOL_OUTPUT_SCHEMA,
|
|
2424
2719
|
annotations: { readOnlyHint: false, destructiveHint: true, openWorldHint: true },
|
|
2425
2720
|
inputSchema: {
|
|
2426
2721
|
projectDir: projectDirInput,
|
|
2427
2722
|
outputDir: z3.string().min(1).describe(
|
|
2428
|
-
'REQUIRED: exact publish directory relative to
|
|
2723
|
+
'REQUIRED: exact publish directory relative to the initialized project root, supplied by the AI after inspecting this project (for example ".", "dist", "html", or any custom build directory). Sakupa resolves projectDir upward before applying this path.'
|
|
2724
|
+
),
|
|
2725
|
+
outputDirChangeConfirmed: z3.boolean().optional().describe(
|
|
2726
|
+
"Required only when changing the previously successful publish directory. Confirm only after showing the old and new directories to the user."
|
|
2429
2727
|
),
|
|
2430
2728
|
spaFallback: z3.boolean().optional().describe(
|
|
2431
2729
|
"Override automatic SPA-fallback detection (single index.html + JS auto-enables rewriting unknown paths to index.html; multiple HTML pages auto-disable it). Pass only to force the behavior against the detected structure."
|
|
@@ -2434,7 +2732,7 @@ Next action: ${analysis.suggestedNextAction}`,
|
|
|
2434
2732
|
"Required only for the first deployment: user explicitly confirmed creation of a public 24-hour URL."
|
|
2435
2733
|
),
|
|
2436
2734
|
subprojectConfirmed: z3.boolean().optional().describe(
|
|
2437
|
-
"
|
|
2735
|
+
"Deprecated compatibility field. Project independence is established only by `sakupa-mcp init`, never inferred from package.json or folder names."
|
|
2438
2736
|
),
|
|
2439
2737
|
lang: z3.string().optional().describe("Site language override (en | ja | zh-CN); defaults to the html lang.")
|
|
2440
2738
|
}
|
|
@@ -2446,8 +2744,25 @@ Next action: ${analysis.suggestedNextAction}`,
|
|
|
2446
2744
|
if (!analysis.deployable || !analysis.files) {
|
|
2447
2745
|
return notDeployableResult(analysis);
|
|
2448
2746
|
}
|
|
2747
|
+
const effectiveOutputDir = analysis.recommendedOutputDir ?? ".";
|
|
2748
|
+
const recordedOutputDir = ctx.projectMarker?.outputDir;
|
|
2749
|
+
if (recordedOutputDir !== void 0 && resolve4(ctx.projectDir, recordedOutputDir) !== resolve4(ctx.projectDir, effectiveOutputDir) && args.outputDirChangeConfirmed !== true) {
|
|
2750
|
+
return structuredToolResult({
|
|
2751
|
+
schemaVersion: 1,
|
|
2752
|
+
outcome: "waiting_user",
|
|
2753
|
+
resultCode: "publish_directory_change_confirmation_required",
|
|
2754
|
+
summary: `This initialized project last published from "${recordedOutputDir}", but this request selected "${effectiveOutputDir}". Nothing was uploaded and the site was not changed. Show both paths to the user; only after explicit confirmation call deploy again with outputDirChangeConfirmed: true.`,
|
|
2755
|
+
data: {
|
|
2756
|
+
projectDir: ctx.projectDir,
|
|
2757
|
+
previousOutputDir: recordedOutputDir,
|
|
2758
|
+
requestedOutputDir: effectiveOutputDir,
|
|
2759
|
+
confirmationField: "outputDirChangeConfirmed"
|
|
2760
|
+
},
|
|
2761
|
+
nextActions: [{ tool: "deploy", allowed: true, reasonCode: "explicit_confirmation" }]
|
|
2762
|
+
});
|
|
2763
|
+
}
|
|
2449
2764
|
const files = analysis.files;
|
|
2450
|
-
const outputAbs = resolve4(ctx.projectDir,
|
|
2765
|
+
const outputAbs = resolve4(ctx.projectDir, effectiveOutputDir);
|
|
2451
2766
|
const manifest = await buildHashedManifest(files, outputAbs);
|
|
2452
2767
|
const siteFileState = loadSiteFile(ctx.projectDir);
|
|
2453
2768
|
if (siteFileState.kind === "corrupted") {
|
|
@@ -2462,7 +2777,16 @@ Next action: ${analysis.suggestedNextAction}`,
|
|
|
2462
2777
|
}
|
|
2463
2778
|
let existing = siteFileState.kind === "ok" ? siteFileState.file : null;
|
|
2464
2779
|
let credentialRelocatedFrom = null;
|
|
2465
|
-
if (!existing &&
|
|
2780
|
+
if (!existing && effectiveOutputDir !== ".") {
|
|
2781
|
+
const outputProjectMarker = loadProjectMarker(outputAbs);
|
|
2782
|
+
if (outputProjectMarker.kind !== "absent") {
|
|
2783
|
+
return text(
|
|
2784
|
+
"publish_directory_is_independent_project",
|
|
2785
|
+
outputProjectMarker.kind === "corrupted" ? `The selected publish directory ${outputAbs} contains a damaged Sakupa project marker: ${outputProjectMarker.problem}. Nothing was deployed.` : `The selected publish directory ${outputAbs} is itself an explicitly initialized Sakupa project. Refusing to move or reuse its credential from ${ctx.projectDir}. Run deploy from that independent project instead, or choose a publish directory that is not another Sakupa project.`,
|
|
2786
|
+
{ projectRoot: ctx.projectDir, outputDir: effectiveOutputDir },
|
|
2787
|
+
"blocked"
|
|
2788
|
+
);
|
|
2789
|
+
}
|
|
2466
2790
|
const outputSiteState = loadSiteFile(outputAbs);
|
|
2467
2791
|
if (outputSiteState.kind === "corrupted") {
|
|
2468
2792
|
return text(
|
|
@@ -2473,35 +2797,11 @@ Next action: ${analysis.suggestedNextAction}`,
|
|
|
2473
2797
|
);
|
|
2474
2798
|
}
|
|
2475
2799
|
if (outputSiteState.kind === "ok") {
|
|
2476
|
-
writeSiteFile(ctx.projectDir, outputSiteState.file);
|
|
2477
|
-
deleteSiteFile(outputAbs);
|
|
2478
2800
|
existing = outputSiteState.file;
|
|
2479
2801
|
credentialRelocatedFrom = outputAbs;
|
|
2480
2802
|
}
|
|
2481
2803
|
}
|
|
2482
2804
|
if (!existing) {
|
|
2483
|
-
const rootHint = args.subprojectConfirmed === true ? null : projectRootAbove(ctx.projectDir);
|
|
2484
|
-
if (rootHint) {
|
|
2485
|
-
return text(
|
|
2486
|
-
"not_project_root",
|
|
2487
|
-
`${ctx.projectDir} is a SUBFOLDER of a package.json project, and .sakupa must live at the project ROOT. Re-run deploy with projectDir: ${rootHint.projectRoot} and outputDir: ${rootHint.outputDir}. Only if the user explicitly says this subfolder is an INDEPENDENT site (e.g. a docs/ site inside a repo), re-run with subprojectConfirmed: true. Nothing was deployed and no site was created.`,
|
|
2488
|
-
{
|
|
2489
|
-
projectRoot: rootHint.projectRoot,
|
|
2490
|
-
outputDir: rootHint.outputDir,
|
|
2491
|
-
confirmationField: "subprojectConfirmed"
|
|
2492
|
-
},
|
|
2493
|
-
"blocked"
|
|
2494
|
-
);
|
|
2495
|
-
}
|
|
2496
|
-
const neighbor = findNeighborBinding(ctx.projectDir);
|
|
2497
|
-
if (neighbor) {
|
|
2498
|
-
return text(
|
|
2499
|
-
"neighbor_binding_found",
|
|
2500
|
-
`No .sakupa binding in ${ctx.projectDir}, but one EXISTS at ${neighbor} \u2014 this looks like the same project addressed at a different directory level. To update that existing site, re-run deploy with projectDir: ${neighbor}. Only if the user explicitly wants a SEPARATE new site, move this deploy to a directory outside that project. Nothing was deployed and no site was created.`,
|
|
2501
|
-
{ neighborProjectDir: neighbor },
|
|
2502
|
-
"blocked"
|
|
2503
|
-
);
|
|
2504
|
-
}
|
|
2505
2805
|
const barrier = freeSiteCreationBarrier(ctx.apiBaseUrl);
|
|
2506
2806
|
if (barrier) return barrier;
|
|
2507
2807
|
if (args.publicConfirmed !== true) {
|
|
@@ -2535,6 +2835,7 @@ Next action: ${analysis.suggestedNextAction}`,
|
|
|
2535
2835
|
createdAt,
|
|
2536
2836
|
apiBaseUrl: ctx.apiBaseUrl
|
|
2537
2837
|
});
|
|
2838
|
+
updateProjectOutputDir(ctx.projectDir, effectiveOutputDir);
|
|
2538
2839
|
recordCreation({
|
|
2539
2840
|
siteId: created.siteId,
|
|
2540
2841
|
projectDir: ctx.projectDir,
|
|
@@ -2609,6 +2910,9 @@ ${JSON.stringify(finalized2.warnings, null, 2)}` : ""),
|
|
|
2609
2910
|
}
|
|
2610
2911
|
const { uploaded, finalized } = update;
|
|
2611
2912
|
writeSiteFile(ctx.projectDir, { ...existing, url: finalized.url });
|
|
2913
|
+
if (credentialRelocatedFrom !== null) deleteSiteFile(credentialRelocatedFrom);
|
|
2914
|
+
if (ctx.projectMarker === void 0) initializeProject(ctx.projectDir);
|
|
2915
|
+
updateProjectOutputDir(ctx.projectDir, effectiveOutputDir);
|
|
2612
2916
|
noteSiteMode(existing.siteId, finalized.mode);
|
|
2613
2917
|
return text(
|
|
2614
2918
|
"site_updated",
|
|
@@ -2717,7 +3021,7 @@ NO content was uploaded or changed by this call \u2014 to publish new or edited
|
|
|
2717
3021
|
{
|
|
2718
3022
|
siteId: site.siteId,
|
|
2719
3023
|
plan: args.plan,
|
|
2720
|
-
idempotencyKey:
|
|
3024
|
+
idempotencyKey: randomUUID2()
|
|
2721
3025
|
},
|
|
2722
3026
|
site.credential
|
|
2723
3027
|
);
|
|
@@ -2907,14 +3211,14 @@ Full status:`, res);
|
|
|
2907
3211
|
outputSchema: STRUCTURED_TOOL_OUTPUT_SCHEMA,
|
|
2908
3212
|
annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: true },
|
|
2909
3213
|
inputSchema: {
|
|
2910
|
-
projectDir: projectDirInput,
|
|
3214
|
+
projectDir: projectDirInput.optional(),
|
|
2911
3215
|
scope: z3.enum(["site", "public_recovery"])
|
|
2912
3216
|
}
|
|
2913
3217
|
},
|
|
2914
3218
|
async (args) => {
|
|
2915
3219
|
try {
|
|
2916
|
-
const ctx = withProjectDir(baseCtx, args.projectDir);
|
|
2917
3220
|
if (args.scope === "site") {
|
|
3221
|
+
const ctx = withProjectDir(baseCtx, args.projectDir);
|
|
2918
3222
|
const site = requireSiteFile(ctx);
|
|
2919
3223
|
const res2 = await ctx.client.createBillingPortal(site.siteId, site.credential);
|
|
2920
3224
|
return structuredToolResult({
|
|
@@ -2932,7 +3236,7 @@ Full status:`, res);
|
|
|
2932
3236
|
nextActions: [{ tool: "billing", allowed: true }]
|
|
2933
3237
|
});
|
|
2934
3238
|
}
|
|
2935
|
-
const res = await
|
|
3239
|
+
const res = await baseCtx.client.getPublicBillingPortal();
|
|
2936
3240
|
return structuredToolResult({
|
|
2937
3241
|
schemaVersion: 1,
|
|
2938
3242
|
outcome: "waiting_user",
|
|
@@ -2960,7 +3264,7 @@ Full status:`, res);
|
|
|
2960
3264
|
server.registerTool(
|
|
2961
3265
|
"recover",
|
|
2962
3266
|
{
|
|
2963
|
-
description: "Recover management control of a subscribed site WITH A BOUND CUSTOM DOMAIN after losing the local project, by proving DNS control of the apex domain. Sites without a bound domain are identified solely by their local credential and cannot be recovered. By default, completing recovery REVOKES all previous local credentials. Recovery is resumable: start stores local pending state; complete installs and writes the new .sakupa/site.json credential BEFORE requesting content; download uses that credential to reissue an archive and safely extract it into
|
|
3267
|
+
description: "Recover management control of a subscribed site WITH A BOUND CUSTOM DOMAIN after losing the local project, by proving DNS control of the apex domain. Sites without a bound domain are identified solely by their local credential and cannot be recovered. By default, completing recovery REVOKES all previous local credentials. Recovery is resumable: start stores local pending state; complete installs and writes the new .sakupa/site.json credential BEFORE requesting content; download uses that credential to reissue an archive and safely extract it into the explicitly selected outputDir without repeating DNS.",
|
|
2964
3268
|
outputSchema: STRUCTURED_TOOL_OUTPUT_SCHEMA,
|
|
2965
3269
|
annotations: { readOnlyHint: false, destructiveHint: true, openWorldHint: true },
|
|
2966
3270
|
inputSchema: {
|
|
@@ -2968,13 +3272,21 @@ Full status:`, res);
|
|
|
2968
3272
|
action: z3.enum(["start", "status", "complete", "download"]),
|
|
2969
3273
|
hostname: z3.string().optional().describe("Required for start."),
|
|
2970
3274
|
verificationId: z3.string().optional().describe("For status or complete; inferred from local recovery state when omitted."),
|
|
2971
|
-
outputDir: z3.string().optional().describe(
|
|
3275
|
+
outputDir: z3.string().optional().describe(
|
|
3276
|
+
"REQUIRED for complete/download: exact extraction directory relative to the initialized project root. Inspect the current project; Sakupa never guesses a name."
|
|
3277
|
+
),
|
|
2972
3278
|
preserveExistingCredentials: z3.boolean().optional().describe("Explicitly keep old local credentials working (default: revoke them all).")
|
|
2973
3279
|
}
|
|
2974
3280
|
},
|
|
2975
3281
|
async (args) => {
|
|
2976
3282
|
try {
|
|
2977
3283
|
const ctx = withProjectDir(baseCtx, args.projectDir);
|
|
3284
|
+
if ((args.action === "complete" || args.action === "download") && args.outputDir === void 0) {
|
|
3285
|
+
throw new LocalGuidanceError(
|
|
3286
|
+
"invalid_request",
|
|
3287
|
+
"recover requires outputDir for complete/download. Inspect the current project and pass the exact extraction directory relative to the initialized Sakupa root; the server never defaults to html, dist, build, or any other name."
|
|
3288
|
+
);
|
|
3289
|
+
}
|
|
2978
3290
|
const localSite = loadSiteFile(ctx.projectDir);
|
|
2979
3291
|
const localCredentialIsActive = async () => {
|
|
2980
3292
|
if (localSite.kind !== "ok") return false;
|
|
@@ -2987,12 +3299,18 @@ Full status:`, res);
|
|
|
2987
3299
|
}
|
|
2988
3300
|
};
|
|
2989
3301
|
const download = async () => {
|
|
3302
|
+
if (args.outputDir === void 0) {
|
|
3303
|
+
throw new LocalGuidanceError(
|
|
3304
|
+
"invalid_request",
|
|
3305
|
+
"recover download requires an explicit outputDir."
|
|
3306
|
+
);
|
|
3307
|
+
}
|
|
2990
3308
|
const site = requireSiteFile(ctx);
|
|
2991
3309
|
const archive = await ctx.client.getSiteArchive(site.siteId, site.credential);
|
|
2992
3310
|
const bytes = await ctx.client.downloadArchive(archive.archiveUrl);
|
|
2993
3311
|
const extracted = await extractRecoveryArchive({
|
|
2994
3312
|
projectDir: ctx.projectDir,
|
|
2995
|
-
|
|
3313
|
+
outputDir: args.outputDir,
|
|
2996
3314
|
archive: bytes,
|
|
2997
3315
|
expectedBytes: archive.totalBytes,
|
|
2998
3316
|
expectedFiles: archive.fileCount
|
|
@@ -3037,7 +3355,7 @@ No DNS verification was started or repeated.`,
|
|
|
3037
3355
|
arguments: {
|
|
3038
3356
|
projectDir: ctx.projectDir,
|
|
3039
3357
|
action: "download",
|
|
3040
|
-
outputDir: args.outputDir
|
|
3358
|
+
...args.outputDir !== void 0 ? { outputDir: args.outputDir } : {}
|
|
3041
3359
|
},
|
|
3042
3360
|
allowed: true
|
|
3043
3361
|
}
|
|
@@ -3137,7 +3455,7 @@ After the DNS record resolves, re-run recover with verificationId: "${res2.verif
|
|
|
3137
3455
|
arguments: {
|
|
3138
3456
|
projectDir: ctx.projectDir,
|
|
3139
3457
|
action: "download",
|
|
3140
|
-
outputDir: args.outputDir
|
|
3458
|
+
...args.outputDir !== void 0 ? { outputDir: args.outputDir } : {}
|
|
3141
3459
|
},
|
|
3142
3460
|
allowed: true
|
|
3143
3461
|
}
|
|
@@ -3173,7 +3491,7 @@ After the DNS record resolves, re-run recover with verificationId: "${res2.verif
|
|
|
3173
3491
|
projectDir: ctx.projectDir,
|
|
3174
3492
|
action: "complete",
|
|
3175
3493
|
verificationId,
|
|
3176
|
-
outputDir: args.outputDir
|
|
3494
|
+
...args.outputDir !== void 0 ? { outputDir: args.outputDir } : {}
|
|
3177
3495
|
},
|
|
3178
3496
|
allowed: res2.readyToComplete,
|
|
3179
3497
|
...res2.readyToComplete ? {} : { reasonCode: res2.status }
|
|
@@ -3249,7 +3567,7 @@ Files: ${extracted.fileCount}; bytes: ${extracted.totalBytes}
|
|
|
3249
3567
|
arguments: {
|
|
3250
3568
|
projectDir: ctx.projectDir,
|
|
3251
3569
|
action: "download",
|
|
3252
|
-
outputDir: args.outputDir
|
|
3570
|
+
...args.outputDir !== void 0 ? { outputDir: args.outputDir } : {}
|
|
3253
3571
|
},
|
|
3254
3572
|
allowed: true
|
|
3255
3573
|
}
|
|
@@ -3375,14 +3693,13 @@ function registerBillingTools(server, baseCtx) {
|
|
|
3375
3693
|
"plans",
|
|
3376
3694
|
{
|
|
3377
3695
|
description: "Return the authoritative Sakupa monthly plan catalog, exact limits, prices, catalog version and plan-change billing rules. This is read-only and does not require a site.",
|
|
3378
|
-
inputSchema: {
|
|
3696
|
+
inputSchema: {},
|
|
3379
3697
|
outputSchema: STRUCTURED_TOOL_OUTPUT_SCHEMA,
|
|
3380
3698
|
annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: true }
|
|
3381
3699
|
},
|
|
3382
|
-
async (
|
|
3700
|
+
async () => {
|
|
3383
3701
|
try {
|
|
3384
|
-
const
|
|
3385
|
-
const catalog = await ctx.client.getBillingPlanCatalog();
|
|
3702
|
+
const catalog = await baseCtx.client.getBillingPlanCatalog();
|
|
3386
3703
|
return structuredToolResult({
|
|
3387
3704
|
schemaVersion: 1,
|
|
3388
3705
|
outcome: "completed",
|
|
@@ -3438,7 +3755,7 @@ function registerBillingTools(server, baseCtx) {
|
|
|
3438
3755
|
}
|
|
3439
3756
|
|
|
3440
3757
|
// src/tools/lifecycle.ts
|
|
3441
|
-
import { randomUUID as
|
|
3758
|
+
import { randomUUID as randomUUID3 } from "node:crypto";
|
|
3442
3759
|
import { z as z5 } from "zod";
|
|
3443
3760
|
var deleteConfirmation = z5.object({
|
|
3444
3761
|
siteId: z5.string().min(1),
|
|
@@ -3473,7 +3790,7 @@ function registerLifecycleTools(server, baseCtx) {
|
|
|
3473
3790
|
try {
|
|
3474
3791
|
const ctx = withProjectDir(baseCtx, args.projectDir);
|
|
3475
3792
|
const site = requireSiteFile(ctx);
|
|
3476
|
-
const operationId = args.operationId ??
|
|
3793
|
+
const operationId = args.operationId ?? randomUUID3();
|
|
3477
3794
|
if (args.action === "preview") {
|
|
3478
3795
|
const preview = await ctx.client.previewDeleteSite(site.siteId, site.credential, {
|
|
3479
3796
|
operationId
|
|
@@ -3668,14 +3985,18 @@ Workflow:
|
|
|
3668
3985
|
5. support (subscribed sites) opens a support ticket; report sends a
|
|
3669
3986
|
sanitized diagnostic report after the user explicitly confirms it.
|
|
3670
3987
|
|
|
3671
|
-
Project directory contract:
|
|
3672
|
-
|
|
3673
|
-
|
|
3674
|
-
|
|
3675
|
-
|
|
3676
|
-
|
|
3677
|
-
|
|
3678
|
-
|
|
3988
|
+
Project directory contract: before the first deploy or a new recovery, initialize the intended
|
|
3989
|
+
project root once with "npx -y @sakupa/mcp@latest init"; this creates a non-secret
|
|
3990
|
+
.sakupa/project.json marker after the user confirms the canonical path. ONE initialized root =
|
|
3991
|
+
ONE site. Project-bound tools require projectDir, but it is only a path locator: it may point to
|
|
3992
|
+
the root or any existing child directory. The site-independent plans tool and public_recovery
|
|
3993
|
+
portal do not require a local project. Sakupa resolves upward to its own marker and stores
|
|
3994
|
+
.sakupa/site.json only at that authoritative root; it never uses package.json, .git, framework
|
|
3995
|
+
names or output-directory names to guess. For deploy, ALWAYS pass outputDir separately as the
|
|
3996
|
+
exact path RELATIVE to the resolved root (use "." when publishing the root); outputDir is
|
|
3997
|
+
required and may have ANY name, so inspect the current project. If it differs from the last
|
|
3998
|
+
successful publish directory, show the old and new paths and obtain explicit confirmation
|
|
3999
|
+
before retrying with outputDirChangeConfirmed: true.
|
|
3679
4000
|
After every deploy, TELL the user which environment it went to (deploy results carry an
|
|
3680
4001
|
Explicit Environment line: TEST vs PRODUCTION). analyze, deploy, status,
|
|
3681
4002
|
refresh and delete echo
|
|
@@ -3726,12 +4047,30 @@ function createSakupaMcpServer(opts) {
|
|
|
3726
4047
|
|
|
3727
4048
|
// src/bin.ts
|
|
3728
4049
|
async function main() {
|
|
4050
|
+
const argv = process.argv.slice(2);
|
|
4051
|
+
if (argv[0] === "init") {
|
|
4052
|
+
const readline = createInterface({ input: stdin, output: stdout });
|
|
4053
|
+
try {
|
|
4054
|
+
const result = await runInitCommand(argv.slice(1), {
|
|
4055
|
+
write: (message) => stdout.write(`${message}
|
|
4056
|
+
`),
|
|
4057
|
+
confirm: async (question) => /^(?:y|yes)$/i.test((await readline.question(question)).trim())
|
|
4058
|
+
});
|
|
4059
|
+
process.exitCode = result.exitCode;
|
|
4060
|
+
return;
|
|
4061
|
+
} finally {
|
|
4062
|
+
readline.close();
|
|
4063
|
+
}
|
|
4064
|
+
}
|
|
4065
|
+
if (argv.length > 0) {
|
|
4066
|
+
throw new Error("Usage: sakupa-mcp [init [project-directory]]");
|
|
4067
|
+
}
|
|
3729
4068
|
const config = loadMcpRuntimeConfig();
|
|
3730
4069
|
const server = createSakupaMcpServer(config);
|
|
3731
4070
|
const transport = new StdioServerTransport();
|
|
3732
4071
|
await server.connect(transport);
|
|
3733
4072
|
console.error(
|
|
3734
|
-
`[sakupa-mcp] v${MCP_VERSION} connected (api: ${config.apiBaseUrl};
|
|
4073
|
+
`[sakupa-mcp] v${MCP_VERSION} connected (api: ${config.apiBaseUrl}; projectDir resolves through Sakupa markers)`
|
|
3735
4074
|
);
|
|
3736
4075
|
}
|
|
3737
4076
|
main().catch((err2) => {
|