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