@sakupa/mcp 1.5.0 → 1.6.0
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 +475 -142
- package/dist/index.js +1140 -806
- package/package.json +1 -1
package/dist/index.js
CHANGED
|
@@ -148,7 +148,7 @@ function isFreeSiteAllowanceNetworkReference(value) {
|
|
|
148
148
|
}
|
|
149
149
|
|
|
150
150
|
// ../core/dist/domain/version.js
|
|
151
|
-
var SAKUPA_MCP_VERSION = "1.
|
|
151
|
+
var SAKUPA_MCP_VERSION = "1.6.0";
|
|
152
152
|
|
|
153
153
|
// ../core/dist/domain/errors.js
|
|
154
154
|
var HTTP_STATUS = {
|
|
@@ -891,6 +891,13 @@ var HttpApiClient = class {
|
|
|
891
891
|
{ device: { deviceId, credential } }
|
|
892
892
|
);
|
|
893
893
|
}
|
|
894
|
+
async reissueDeviceFreeSiteCredential(siteId, deviceId, credential) {
|
|
895
|
+
return this.call(
|
|
896
|
+
"POST",
|
|
897
|
+
`/v1/devices/sites/${encodeURIComponent(siteId)}/credential`,
|
|
898
|
+
{ device: { deviceId, credential } }
|
|
899
|
+
);
|
|
900
|
+
}
|
|
894
901
|
async createSite(req, _clientIp, device) {
|
|
895
902
|
return this.call("POST", "/v1/sites", { body: req, device });
|
|
896
903
|
}
|
|
@@ -1092,129 +1099,90 @@ var HttpApiClient = class {
|
|
|
1092
1099
|
};
|
|
1093
1100
|
|
|
1094
1101
|
// src/project-file.ts
|
|
1102
|
+
import {
|
|
1103
|
+
chmodSync as chmodSync3,
|
|
1104
|
+
existsSync as existsSync3,
|
|
1105
|
+
mkdirSync as mkdirSync3,
|
|
1106
|
+
readFileSync as readFileSync3,
|
|
1107
|
+
renameSync as renameSync3,
|
|
1108
|
+
rmSync as rmSync2,
|
|
1109
|
+
writeFileSync as writeFileSync3
|
|
1110
|
+
} from "node:fs";
|
|
1111
|
+
import { randomUUID as randomUUID3 } from "node:crypto";
|
|
1112
|
+
import { dirname, join as join3 } from "node:path";
|
|
1113
|
+
|
|
1114
|
+
// src/credential-store.ts
|
|
1115
|
+
import { randomUUID } from "node:crypto";
|
|
1095
1116
|
import {
|
|
1096
1117
|
chmodSync,
|
|
1097
1118
|
existsSync,
|
|
1098
1119
|
mkdirSync,
|
|
1099
1120
|
readFileSync,
|
|
1100
1121
|
renameSync,
|
|
1101
|
-
rmdirSync,
|
|
1102
1122
|
rmSync,
|
|
1103
1123
|
writeFileSync
|
|
1104
1124
|
} from "node:fs";
|
|
1105
|
-
import {
|
|
1106
|
-
import {
|
|
1107
|
-
var
|
|
1108
|
-
|
|
1109
|
-
|
|
1110
|
-
|
|
1111
|
-
return join(projectDir, SITE_DIR, SITE_FILE);
|
|
1125
|
+
import { homedir } from "node:os";
|
|
1126
|
+
import { join } from "node:path";
|
|
1127
|
+
var REF_PATTERN = /^[0-9a-f-]{36}$/i;
|
|
1128
|
+
function credentialStoreDirectory() {
|
|
1129
|
+
const base = process.env["SAKUPA_STATE_DIR"] ?? homedir();
|
|
1130
|
+
return join(base, ".sakupa", "credentials");
|
|
1112
1131
|
}
|
|
1113
|
-
function
|
|
1114
|
-
|
|
1132
|
+
function credentialStorePath(ref) {
|
|
1133
|
+
if (!REF_PATTERN.test(ref)) throw new Error("Invalid credential reference.");
|
|
1134
|
+
return join(credentialStoreDirectory(), `${ref}.json`);
|
|
1115
1135
|
}
|
|
1116
|
-
function
|
|
1117
|
-
|
|
1118
|
-
|
|
1119
|
-
|
|
1120
|
-
|
|
1121
|
-
raw = readFileSync(path, "utf8");
|
|
1122
|
-
} catch (err2) {
|
|
1136
|
+
function newCredentialRef() {
|
|
1137
|
+
return randomUUID();
|
|
1138
|
+
}
|
|
1139
|
+
function readStoredCredential(ref) {
|
|
1140
|
+
if (!REF_PATTERN.test(ref)) {
|
|
1123
1141
|
return {
|
|
1124
1142
|
kind: "corrupted",
|
|
1125
|
-
problem:
|
|
1143
|
+
problem: "the credentialRef does not look like a Sakupa reference"
|
|
1126
1144
|
};
|
|
1127
1145
|
}
|
|
1146
|
+
const path = credentialStorePath(ref);
|
|
1147
|
+
if (!existsSync(path)) return { kind: "absent" };
|
|
1128
1148
|
let parsed;
|
|
1129
1149
|
try {
|
|
1130
|
-
parsed = JSON.parse(
|
|
1131
|
-
} catch {
|
|
1132
|
-
return {
|
|
1133
|
-
|
|
1134
|
-
|
|
1135
|
-
|
|
1136
|
-
}
|
|
1137
|
-
if (typeof parsed.siteId !== "string" || parsed.siteId.length === 0) {
|
|
1138
|
-
return { kind: "corrupted", problem: "the siteId field is missing or empty" };
|
|
1150
|
+
parsed = JSON.parse(readFileSync(path, "utf8"));
|
|
1151
|
+
} catch (error) {
|
|
1152
|
+
return {
|
|
1153
|
+
kind: "corrupted",
|
|
1154
|
+
problem: `${path} exists but could not be parsed (${error instanceof Error ? error.message : String(error)})`
|
|
1155
|
+
};
|
|
1139
1156
|
}
|
|
1140
|
-
if (typeof parsed
|
|
1141
|
-
return { kind: "corrupted", problem:
|
|
1157
|
+
if (typeof parsed !== "object" || parsed === null || typeof parsed.credential !== "string") {
|
|
1158
|
+
return { kind: "corrupted", problem: `${path} does not contain a credential` };
|
|
1142
1159
|
}
|
|
1143
1160
|
if (!CREDENTIAL_PATTERN.test(parsed.credential)) {
|
|
1144
1161
|
return {
|
|
1145
1162
|
kind: "corrupted",
|
|
1146
|
-
problem:
|
|
1163
|
+
problem: `the credential in ${path} does not match the shape Sakupa issues (sk_ followed by exactly 43 URL-safe base64 characters) \u2014 it looks locally altered or damaged`
|
|
1147
1164
|
};
|
|
1148
1165
|
}
|
|
1149
1166
|
return {
|
|
1150
1167
|
kind: "ok",
|
|
1151
|
-
|
|
1152
|
-
|
|
1153
|
-
|
|
1154
|
-
createdAt: typeof parsed.createdAt === "string" ? parsed.createdAt : "",
|
|
1168
|
+
entry: {
|
|
1169
|
+
ref,
|
|
1170
|
+
siteId: typeof parsed.siteId === "string" ? parsed.siteId : "",
|
|
1155
1171
|
apiBaseUrl: typeof parsed.apiBaseUrl === "string" ? parsed.apiBaseUrl : "",
|
|
1156
|
-
...typeof parsed.shortId === "string" ? { shortId: parsed.shortId } : {},
|
|
1157
|
-
...typeof parsed.url === "string" ? { url: parsed.url } : {},
|
|
1158
|
-
...typeof parsed.boundDomain === "string" ? { boundDomain: parsed.boundDomain } : {}
|
|
1159
|
-
}
|
|
1160
|
-
};
|
|
1161
|
-
}
|
|
1162
|
-
function loadRecoveryFile(projectDir) {
|
|
1163
|
-
const path = recoveryFilePath(projectDir);
|
|
1164
|
-
if (!existsSync(path)) return null;
|
|
1165
|
-
try {
|
|
1166
|
-
const parsed = JSON.parse(readFileSync(path, "utf8"));
|
|
1167
|
-
if (typeof parsed.verificationId !== "string" || parsed.verificationId.length === 0 || typeof parsed.credential !== "string" || !CREDENTIAL_PATTERN.test(parsed.credential)) {
|
|
1168
|
-
throw new Error("required recovery fields are missing or invalid");
|
|
1169
|
-
}
|
|
1170
|
-
return {
|
|
1171
|
-
verificationId: parsed.verificationId,
|
|
1172
1172
|
credential: parsed.credential,
|
|
1173
1173
|
createdAt: typeof parsed.createdAt === "string" ? parsed.createdAt : ""
|
|
1174
|
-
};
|
|
1175
|
-
} catch (error) {
|
|
1176
|
-
throw new Error(
|
|
1177
|
-
`Recovery state ${path} is damaged (${error instanceof Error ? error.message : String(error)}). Do not start another DNS recovery until this file is repaired or deliberately removed.`
|
|
1178
|
-
);
|
|
1179
|
-
}
|
|
1180
|
-
}
|
|
1181
|
-
function writeRecoveryFile(projectDir, file) {
|
|
1182
|
-
const dir = join(projectDir, SITE_DIR);
|
|
1183
|
-
mkdirSync(dir, { recursive: true });
|
|
1184
|
-
const path = join(dir, RECOVERY_FILE);
|
|
1185
|
-
writeFileSync(path, `${JSON.stringify(file, null, 2)}
|
|
1186
|
-
`, "utf8");
|
|
1187
|
-
try {
|
|
1188
|
-
chmodSync(path, 384);
|
|
1189
|
-
} catch {
|
|
1190
|
-
}
|
|
1191
|
-
}
|
|
1192
|
-
function deleteRecoveryFile(projectDir) {
|
|
1193
|
-
const path = recoveryFilePath(projectDir);
|
|
1194
|
-
if (existsSync(path)) rmSync(path, { force: true });
|
|
1195
|
-
}
|
|
1196
|
-
function siteFileRecoveryGuidance(projectDir) {
|
|
1197
|
-
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. Run help for a safe repair path; the tool will never overwrite a damaged binding or ask the user to manipulate credentials manually.`;
|
|
1198
|
-
}
|
|
1199
|
-
function writeSiteFile(projectDir, file, opts = {}) {
|
|
1200
|
-
if (opts.allowReplace !== true) {
|
|
1201
|
-
const existing = loadSiteFile(projectDir);
|
|
1202
|
-
if (existing.kind === "corrupted") {
|
|
1203
|
-
throw new Error(
|
|
1204
|
-
`Refusing to overwrite ${siteFilePath(projectDir)}: ${existing.problem}. ` + siteFileRecoveryGuidance(projectDir)
|
|
1205
|
-
);
|
|
1206
|
-
}
|
|
1207
|
-
if (existing.kind === "ok" && existing.file.siteId !== file.siteId) {
|
|
1208
|
-
throw new Error(
|
|
1209
|
-
`Refusing to overwrite ${siteFilePath(projectDir)}: it already binds this project to site ${existing.file.siteId}. ` + siteFileRecoveryGuidance(projectDir)
|
|
1210
|
-
);
|
|
1211
1174
|
}
|
|
1175
|
+
};
|
|
1176
|
+
}
|
|
1177
|
+
function storeCredential(entry) {
|
|
1178
|
+
if (!CREDENTIAL_PATTERN.test(entry.credential)) {
|
|
1179
|
+
throw new Error("Refusing to store a credential that does not match the Sakupa shape.");
|
|
1212
1180
|
}
|
|
1213
|
-
const dir =
|
|
1214
|
-
mkdirSync(dir, { recursive: true });
|
|
1215
|
-
const path =
|
|
1216
|
-
const temporary = join(dir,
|
|
1217
|
-
writeFileSync(temporary, `${JSON.stringify(
|
|
1181
|
+
const dir = credentialStoreDirectory();
|
|
1182
|
+
mkdirSync(dir, { recursive: true, mode: 448 });
|
|
1183
|
+
const path = credentialStorePath(entry.ref);
|
|
1184
|
+
const temporary = join(dir, `.${entry.ref}.${process.pid}.tmp`);
|
|
1185
|
+
writeFileSync(temporary, `${JSON.stringify(entry, null, 2)}
|
|
1218
1186
|
`, {
|
|
1219
1187
|
encoding: "utf8",
|
|
1220
1188
|
mode: 384
|
|
@@ -1230,707 +1198,928 @@ function writeSiteFile(projectDir, file, opts = {}) {
|
|
|
1230
1198
|
throw error;
|
|
1231
1199
|
}
|
|
1232
1200
|
}
|
|
1233
|
-
function
|
|
1234
|
-
|
|
1235
|
-
|
|
1236
|
-
|
|
1201
|
+
function deleteStoredCredential(ref) {
|
|
1202
|
+
if (!REF_PATTERN.test(ref)) return;
|
|
1203
|
+
rmSync(credentialStorePath(ref), { force: true });
|
|
1204
|
+
}
|
|
1205
|
+
|
|
1206
|
+
// src/project-root.ts
|
|
1207
|
+
import { randomUUID as randomUUID2 } from "node:crypto";
|
|
1208
|
+
import {
|
|
1209
|
+
chmodSync as chmodSync2,
|
|
1210
|
+
existsSync as existsSync2,
|
|
1211
|
+
lstatSync,
|
|
1212
|
+
mkdirSync as mkdirSync2,
|
|
1213
|
+
readFileSync as readFileSync2,
|
|
1214
|
+
readdirSync,
|
|
1215
|
+
realpathSync,
|
|
1216
|
+
renameSync as renameSync2,
|
|
1217
|
+
rmdirSync,
|
|
1218
|
+
statSync,
|
|
1219
|
+
unlinkSync,
|
|
1220
|
+
writeFileSync as writeFileSync2
|
|
1221
|
+
} from "node:fs";
|
|
1222
|
+
import { homedir as homedir2 } from "node:os";
|
|
1223
|
+
import { isAbsolute, join as join2, parse, relative, resolve, sep } from "node:path";
|
|
1224
|
+
var SAKUPA_DIR = ".sakupa";
|
|
1225
|
+
var PROJECT_FILE = "project.json";
|
|
1226
|
+
var GITIGNORE_FILE = ".gitignore";
|
|
1227
|
+
var GITIGNORE_CONTENT = "# Sakupa local state (project marker, site binding, recovery/rotation journals).\n# Managed by @sakupa/mcp \u2014 everything in this directory stays out of version control.\n*\n";
|
|
1228
|
+
var PROJECT_SCHEMA_VERSION = 1;
|
|
1229
|
+
var ProjectRootError = class extends Error {
|
|
1230
|
+
code;
|
|
1231
|
+
constructor(code, message) {
|
|
1232
|
+
super(message);
|
|
1233
|
+
this.name = "ProjectRootError";
|
|
1234
|
+
this.code = code;
|
|
1237
1235
|
}
|
|
1236
|
+
};
|
|
1237
|
+
function projectMarkerPath(projectDir) {
|
|
1238
|
+
return join2(projectDir, SAKUPA_DIR, PROJECT_FILE);
|
|
1239
|
+
}
|
|
1240
|
+
function loadProjectMarker(projectDir) {
|
|
1241
|
+
const path = projectMarkerPath(projectDir);
|
|
1242
|
+
if (!existsSync2(path)) return { kind: "absent" };
|
|
1243
|
+
let parsed;
|
|
1238
1244
|
try {
|
|
1239
|
-
|
|
1240
|
-
} catch {
|
|
1245
|
+
parsed = JSON.parse(readFileSync2(path, "utf8"));
|
|
1246
|
+
} catch (error) {
|
|
1247
|
+
return {
|
|
1248
|
+
kind: "corrupted",
|
|
1249
|
+
problem: `the file cannot be read as JSON (${error instanceof Error ? error.message : String(error)})`
|
|
1250
|
+
};
|
|
1241
1251
|
}
|
|
1242
|
-
|
|
1243
|
-
|
|
1244
|
-
const state = loadSiteFile(projectDir);
|
|
1245
|
-
if (state.kind === "absent") return "absent";
|
|
1246
|
-
if (state.kind === "corrupted") return "corrupted";
|
|
1247
|
-
if (state.file.siteId !== expected.siteId || state.file.credential !== expected.credential) {
|
|
1248
|
-
return "mismatch";
|
|
1252
|
+
if (typeof parsed !== "object" || parsed === null) {
|
|
1253
|
+
return { kind: "corrupted", problem: "the file does not contain a JSON object" };
|
|
1249
1254
|
}
|
|
1250
|
-
|
|
1251
|
-
|
|
1252
|
-
|
|
1253
|
-
|
|
1254
|
-
|
|
1255
|
-
|
|
1256
|
-
const parent = dirname(cursor);
|
|
1257
|
-
if (parent === cursor) return null;
|
|
1258
|
-
if (predicate(parent)) return parent;
|
|
1259
|
-
cursor = parent;
|
|
1255
|
+
const record = parsed;
|
|
1256
|
+
if (record.schemaVersion !== PROJECT_SCHEMA_VERSION) {
|
|
1257
|
+
return {
|
|
1258
|
+
kind: "corrupted",
|
|
1259
|
+
problem: `unsupported schemaVersion ${String(record.schemaVersion)}`
|
|
1260
|
+
};
|
|
1260
1261
|
}
|
|
1261
|
-
|
|
1262
|
-
}
|
|
1263
|
-
function isInsideGitRepo(projectDir) {
|
|
1264
|
-
return existsSync(join(projectDir, ".git")) || findAncestor(projectDir, (dir) => existsSync(join(dir, ".git"))) !== null;
|
|
1265
|
-
}
|
|
1266
|
-
function credentialGitReminder(projectDir) {
|
|
1267
|
-
if (!isInsideGitRepo(projectDir)) return "";
|
|
1268
|
-
return '\nNOTE: this project is inside a git repository. The management credential in .sakupa/site.json is the key to this site \u2014 do NOT commit it to a PUBLIC repository (add ".sakupa/" to .gitignore yourself if you want to keep it out of version control).';
|
|
1269
|
-
}
|
|
1270
|
-
|
|
1271
|
-
// src/analyze/analyzer.ts
|
|
1272
|
-
import { promises as fs } from "node:fs";
|
|
1273
|
-
import { join as join2, posix, resolve, sep } from "node:path";
|
|
1274
|
-
var SERVER_RUNTIME_DEPS = ["express", "koa", "fastify", "hapi", "@hapi/hapi"];
|
|
1275
|
-
var DB_RUNTIME_DEPS = [
|
|
1276
|
-
"prisma",
|
|
1277
|
-
"@prisma/client",
|
|
1278
|
-
"mongoose",
|
|
1279
|
-
"pg",
|
|
1280
|
-
"mysql2",
|
|
1281
|
-
"better-sqlite3",
|
|
1282
|
-
"typeorm",
|
|
1283
|
-
"sequelize",
|
|
1284
|
-
"redis",
|
|
1285
|
-
"ioredis"
|
|
1286
|
-
];
|
|
1287
|
-
var USE_SERVER_SCAN_MAX_FILES = 200;
|
|
1288
|
-
var USE_SERVER_SCAN_MAX_BYTES = 256 * 1024;
|
|
1289
|
-
var CONTENT_READ_MAX_BYTES = 1024 * 1024;
|
|
1290
|
-
var TEXT_CONTENT_EXTENSIONS = /* @__PURE__ */ new Set(["html", "htm", "js", "mjs", "css", "json", "txt", "xml"]);
|
|
1291
|
-
var SOURCE_SCAN_EXTENSIONS = /* @__PURE__ */ new Set(["js", "jsx", "ts", "tsx", "mjs", "cjs"]);
|
|
1292
|
-
var FORBIDDEN_SEGMENTS_LOWER = new Set(FORBIDDEN_PATH_SEGMENTS.map((s) => s.toLowerCase()));
|
|
1293
|
-
async function isDirectory(path) {
|
|
1294
|
-
try {
|
|
1295
|
-
return (await fs.stat(path)).isDirectory();
|
|
1296
|
-
} catch {
|
|
1297
|
-
return false;
|
|
1262
|
+
if (typeof record.projectId !== "string" || !isUuid(record.projectId)) {
|
|
1263
|
+
return { kind: "corrupted", problem: "projectId is missing or is not a UUID" };
|
|
1298
1264
|
}
|
|
1299
|
-
|
|
1300
|
-
|
|
1301
|
-
try {
|
|
1302
|
-
return (await fs.stat(path)).isFile();
|
|
1303
|
-
} catch {
|
|
1304
|
-
return false;
|
|
1265
|
+
if (typeof record.createdAt !== "string" || !Number.isFinite(Date.parse(record.createdAt))) {
|
|
1266
|
+
return { kind: "corrupted", problem: "createdAt is missing or invalid" };
|
|
1305
1267
|
}
|
|
1306
|
-
|
|
1307
|
-
|
|
1308
|
-
try {
|
|
1309
|
-
const stat2 = await fs.stat(path);
|
|
1310
|
-
if (!stat2.isFile() || stat2.size > maxBytes) return null;
|
|
1311
|
-
return await fs.readFile(path, "utf8");
|
|
1312
|
-
} catch {
|
|
1313
|
-
return null;
|
|
1268
|
+
if (record.outputDir !== void 0 && (typeof record.outputDir !== "string" || !isSafeRelativeOutput(record.outputDir))) {
|
|
1269
|
+
return { kind: "corrupted", problem: "outputDir is not a safe project-relative path" };
|
|
1314
1270
|
}
|
|
1271
|
+
return {
|
|
1272
|
+
kind: "ok",
|
|
1273
|
+
marker: {
|
|
1274
|
+
schemaVersion: PROJECT_SCHEMA_VERSION,
|
|
1275
|
+
projectId: record.projectId,
|
|
1276
|
+
createdAt: record.createdAt,
|
|
1277
|
+
...record.outputDir !== void 0 ? { outputDir: normalizeRelative(record.outputDir) } : {}
|
|
1278
|
+
}
|
|
1279
|
+
};
|
|
1315
1280
|
}
|
|
1316
|
-
|
|
1317
|
-
|
|
1318
|
-
|
|
1319
|
-
|
|
1281
|
+
function initializeProject(projectDir) {
|
|
1282
|
+
const canonical = canonicalProjectDirectory(projectDir);
|
|
1283
|
+
assertSafeProjectRoot(canonical);
|
|
1284
|
+
const current = loadProjectMarker(canonical);
|
|
1285
|
+
if (current.kind === "corrupted") {
|
|
1286
|
+
throw new ProjectRootError(
|
|
1287
|
+
"corrupted_marker",
|
|
1288
|
+
`Refusing to overwrite damaged Sakupa project marker ${projectMarkerPath(canonical)}: ${current.problem}.`
|
|
1289
|
+
);
|
|
1320
1290
|
}
|
|
1321
|
-
|
|
1322
|
-
|
|
1323
|
-
|
|
1324
|
-
|
|
1325
|
-
|
|
1326
|
-
|
|
1327
|
-
|
|
1328
|
-
}
|
|
1329
|
-
async function walkFiles(dir, opts) {
|
|
1330
|
-
const out = [];
|
|
1331
|
-
async function recurse(current, relPrefix) {
|
|
1332
|
-
if (out.length > opts.maxFiles) return;
|
|
1333
|
-
let entries;
|
|
1334
|
-
try {
|
|
1335
|
-
entries = await fs.readdir(current, { withFileTypes: true });
|
|
1336
|
-
} catch {
|
|
1337
|
-
return;
|
|
1338
|
-
}
|
|
1339
|
-
entries.sort((a, b) => a.name < b.name ? -1 : a.name > b.name ? 1 : 0);
|
|
1340
|
-
for (const entry of entries) {
|
|
1341
|
-
if (out.length > opts.maxFiles) return;
|
|
1342
|
-
const rel = relPrefix.length > 0 ? `${relPrefix}/${entry.name}` : entry.name;
|
|
1343
|
-
if (entry.isSymbolicLink()) continue;
|
|
1344
|
-
if (entry.isDirectory()) {
|
|
1345
|
-
if (FORBIDDEN_SEGMENTS_LOWER.has(entry.name.toLowerCase())) continue;
|
|
1346
|
-
if (opts.skipRelDirs?.has(rel)) continue;
|
|
1347
|
-
await recurse(join2(current, entry.name), rel);
|
|
1348
|
-
} else if (entry.isFile()) {
|
|
1349
|
-
try {
|
|
1350
|
-
const stat2 = await fs.stat(join2(current, entry.name));
|
|
1351
|
-
out.push({ path: rel, size: stat2.size });
|
|
1352
|
-
} catch {
|
|
1353
|
-
}
|
|
1354
|
-
}
|
|
1355
|
-
}
|
|
1291
|
+
if (current.kind === "ok") {
|
|
1292
|
+
return {
|
|
1293
|
+
projectDir: canonical,
|
|
1294
|
+
requestedPath: canonical,
|
|
1295
|
+
markerKind: "project",
|
|
1296
|
+
marker: current.marker
|
|
1297
|
+
};
|
|
1356
1298
|
}
|
|
1357
|
-
|
|
1358
|
-
|
|
1299
|
+
const marker = {
|
|
1300
|
+
schemaVersion: PROJECT_SCHEMA_VERSION,
|
|
1301
|
+
projectId: randomUUID2(),
|
|
1302
|
+
createdAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
1303
|
+
};
|
|
1304
|
+
writeMarkerAtomically(canonical, marker);
|
|
1305
|
+
return {
|
|
1306
|
+
projectDir: canonical,
|
|
1307
|
+
requestedPath: canonical,
|
|
1308
|
+
markerKind: "project",
|
|
1309
|
+
marker
|
|
1310
|
+
};
|
|
1359
1311
|
}
|
|
1360
|
-
|
|
1361
|
-
const
|
|
1362
|
-
|
|
1312
|
+
function resolveLockedProjectRoot(projectDir) {
|
|
1313
|
+
const canonical = canonicalProjectDirectory(projectDir);
|
|
1314
|
+
assertSafeProjectRoot(canonical);
|
|
1315
|
+
const markerState = loadProjectMarker(canonical);
|
|
1316
|
+
if (markerState.kind === "corrupted") {
|
|
1317
|
+
throw new ProjectRootError(
|
|
1318
|
+
"corrupted_marker",
|
|
1319
|
+
`Sakupa project marker ${projectMarkerPath(canonical)} is damaged: ${markerState.problem}.`
|
|
1320
|
+
);
|
|
1321
|
+
}
|
|
1322
|
+
if (markerState.kind === "absent") {
|
|
1323
|
+
throw new ProjectRootError(
|
|
1324
|
+
"not_initialized",
|
|
1325
|
+
`The MCP working directory ${canonical} is not initialized. Call the init MCP tool with no path argument. If help confirms that this client has no MCP Roots, the AI may run \`npx -y @sakupa/mcp@latest init\` itself as the fallback.`
|
|
1326
|
+
);
|
|
1327
|
+
}
|
|
1328
|
+
return {
|
|
1329
|
+
projectDir: canonical,
|
|
1330
|
+
requestedPath: canonical,
|
|
1331
|
+
markerKind: "project",
|
|
1332
|
+
marker: markerState.marker
|
|
1333
|
+
};
|
|
1334
|
+
}
|
|
1335
|
+
function updateProjectOutputDir(projectDir, outputDir) {
|
|
1336
|
+
const canonical = canonicalProjectDirectory(projectDir);
|
|
1337
|
+
const state = loadProjectMarker(canonical);
|
|
1338
|
+
if (state.kind !== "ok") {
|
|
1339
|
+
throw new ProjectRootError(
|
|
1340
|
+
state.kind === "corrupted" ? "corrupted_marker" : "not_initialized",
|
|
1341
|
+
state.kind === "corrupted" ? `Cannot update damaged Sakupa project marker: ${state.problem}.` : `No Sakupa project marker exists in ${canonical}.`
|
|
1342
|
+
);
|
|
1343
|
+
}
|
|
1344
|
+
if (!isSafeRelativeOutput(outputDir)) {
|
|
1345
|
+
throw new ProjectRootError(
|
|
1346
|
+
"unsafe_path",
|
|
1347
|
+
`Output directory "${outputDir}" must stay inside the initialized Sakupa project.`
|
|
1348
|
+
);
|
|
1349
|
+
}
|
|
1350
|
+
const marker = {
|
|
1351
|
+
...state.marker,
|
|
1352
|
+
outputDir: normalizeRelative(outputDir)
|
|
1353
|
+
};
|
|
1354
|
+
writeMarkerAtomically(canonical, marker);
|
|
1355
|
+
return marker;
|
|
1356
|
+
}
|
|
1357
|
+
function ensureSakupaGitignore(projectDir) {
|
|
1358
|
+
const dir = join2(projectDir, SAKUPA_DIR);
|
|
1359
|
+
if (!existsSync2(dir)) return;
|
|
1360
|
+
const path = join2(dir, GITIGNORE_FILE);
|
|
1361
|
+
if (existsSync2(path)) return;
|
|
1363
1362
|
try {
|
|
1364
|
-
|
|
1365
|
-
return typeof parsed === "object" && parsed !== null ? parsed : null;
|
|
1363
|
+
writeFileSync2(path, GITIGNORE_CONTENT, { encoding: "utf8", flag: "wx" });
|
|
1366
1364
|
} catch {
|
|
1367
|
-
return null;
|
|
1368
1365
|
}
|
|
1369
1366
|
}
|
|
1370
|
-
function
|
|
1371
|
-
|
|
1367
|
+
function pruneSakupaDirectory(projectDir) {
|
|
1368
|
+
const dir = join2(projectDir, SAKUPA_DIR);
|
|
1369
|
+
if (!existsSync2(dir)) return;
|
|
1370
|
+
try {
|
|
1371
|
+
const entries = readdirSync(dir);
|
|
1372
|
+
if (entries.every((entry) => entry === GITIGNORE_FILE)) {
|
|
1373
|
+
for (const entry of entries) unlinkSync(join2(dir, entry));
|
|
1374
|
+
rmdirSync(dir);
|
|
1375
|
+
}
|
|
1376
|
+
} catch {
|
|
1377
|
+
}
|
|
1372
1378
|
}
|
|
1373
|
-
|
|
1374
|
-
|
|
1375
|
-
|
|
1376
|
-
|
|
1379
|
+
function deleteProjectMarker(projectDir) {
|
|
1380
|
+
const path = projectMarkerPath(projectDir);
|
|
1381
|
+
if (existsSync2(path)) unlinkSync(path);
|
|
1382
|
+
pruneSakupaDirectory(projectDir);
|
|
1377
1383
|
}
|
|
1378
|
-
|
|
1379
|
-
const
|
|
1380
|
-
|
|
1381
|
-
|
|
1382
|
-
|
|
1383
|
-
|
|
1384
|
-
|
|
1385
|
-
|
|
1386
|
-
|
|
1387
|
-
|
|
1388
|
-
|
|
1389
|
-
|
|
1390
|
-
if (!staticExport) {
|
|
1391
|
-
ssrRisks.push(
|
|
1392
|
-
`Next.js project without output: 'export' in next.config.* \u2014 the default Next.js build requires a Node.js server. Sakupa only serves static files; add output: 'export' to next.config.* and build locally to produce a static "out" directory.`
|
|
1393
|
-
);
|
|
1394
|
-
}
|
|
1395
|
-
for (const apiDir of ["pages/api", "src/pages/api"]) {
|
|
1396
|
-
if (await isDirectory(join2(projectDir, apiDir))) {
|
|
1397
|
-
ssrRisks.push(
|
|
1398
|
-
`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.`
|
|
1399
|
-
);
|
|
1400
|
-
break;
|
|
1401
|
-
}
|
|
1402
|
-
}
|
|
1403
|
-
for (const appDir of ["app", "src/app"]) {
|
|
1404
|
-
if (await anyFileMatches(
|
|
1405
|
-
join2(projectDir, appDir),
|
|
1406
|
-
(base) => /^route\.(ts|js|tsx|jsx|mjs)$/.test(base)
|
|
1407
|
-
)) {
|
|
1408
|
-
ssrRisks.push(
|
|
1409
|
-
`App Router route handlers (${appDir}/**/route.ts|js) require a server runtime and will not run on Sakupa.`
|
|
1410
|
-
);
|
|
1411
|
-
break;
|
|
1412
|
-
}
|
|
1384
|
+
function canonicalProjectDirectory(path) {
|
|
1385
|
+
const canonical = canonicalExistingPath(resolve(path));
|
|
1386
|
+
if (!statSync(canonical).isDirectory()) {
|
|
1387
|
+
throw new ProjectRootError("invalid_path", `Project path ${canonical} is not a directory.`);
|
|
1388
|
+
}
|
|
1389
|
+
return canonical;
|
|
1390
|
+
}
|
|
1391
|
+
function canonicalExistingPath(path) {
|
|
1392
|
+
try {
|
|
1393
|
+
const stat2 = lstatSync(path, { throwIfNoEntry: false });
|
|
1394
|
+
if (!stat2) {
|
|
1395
|
+
throw new ProjectRootError("invalid_path", `Project path ${path} does not exist.`);
|
|
1413
1396
|
}
|
|
1414
|
-
|
|
1415
|
-
|
|
1416
|
-
|
|
1417
|
-
|
|
1418
|
-
"
|
|
1419
|
-
|
|
1420
|
-
|
|
1397
|
+
return realpathSync(path);
|
|
1398
|
+
} catch (error) {
|
|
1399
|
+
if (error instanceof ProjectRootError) throw error;
|
|
1400
|
+
throw new ProjectRootError(
|
|
1401
|
+
"invalid_path",
|
|
1402
|
+
`Project path ${path} cannot be resolved (${error instanceof Error ? error.message : String(error)}).`
|
|
1403
|
+
);
|
|
1404
|
+
}
|
|
1405
|
+
}
|
|
1406
|
+
function assertSafeProjectRoot(projectDir) {
|
|
1407
|
+
if (parse(projectDir).root === projectDir || projectDir === realpathSync(homedir2())) {
|
|
1408
|
+
throw new ProjectRootError(
|
|
1409
|
+
"unsafe_path",
|
|
1410
|
+
`Refusing to use ${projectDir} as a Sakupa project root; choose a specific project directory.`
|
|
1411
|
+
);
|
|
1412
|
+
}
|
|
1413
|
+
}
|
|
1414
|
+
function isUuid(value) {
|
|
1415
|
+
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);
|
|
1416
|
+
}
|
|
1417
|
+
function normalizeRelative(path) {
|
|
1418
|
+
const normalized = path.split(sep).join("/").replace(/^\.\//, "").replace(/\/$/, "");
|
|
1419
|
+
return normalized.length === 0 ? "." : normalized;
|
|
1420
|
+
}
|
|
1421
|
+
function isSafeRelativeOutput(path) {
|
|
1422
|
+
if (path.length === 0 || isAbsolute(path)) return false;
|
|
1423
|
+
const normalized = normalizeRelative(path);
|
|
1424
|
+
if (normalized === ".") return true;
|
|
1425
|
+
const rel = relative("/sakupa-root", resolve("/sakupa-root", normalized));
|
|
1426
|
+
return rel !== ".." && !rel.startsWith(`..${sep}`) && !isAbsolute(rel);
|
|
1427
|
+
}
|
|
1428
|
+
function writeMarkerAtomically(projectDir, marker) {
|
|
1429
|
+
const dir = join2(projectDir, SAKUPA_DIR);
|
|
1430
|
+
mkdirSync2(dir, { recursive: true, mode: 448 });
|
|
1431
|
+
ensureSakupaGitignore(projectDir);
|
|
1432
|
+
const path = projectMarkerPath(projectDir);
|
|
1433
|
+
const temporary = `${path}.${process.pid}.${randomUUID2()}.tmp`;
|
|
1434
|
+
try {
|
|
1435
|
+
writeFileSync2(temporary, `${JSON.stringify(marker, null, 2)}
|
|
1436
|
+
`, {
|
|
1437
|
+
encoding: "utf8",
|
|
1438
|
+
mode: 384
|
|
1439
|
+
});
|
|
1440
|
+
renameSync2(temporary, path);
|
|
1441
|
+
try {
|
|
1442
|
+
chmodSync2(path, 384);
|
|
1443
|
+
} catch {
|
|
1421
1444
|
}
|
|
1445
|
+
} finally {
|
|
1446
|
+
if (existsSync2(temporary)) unlinkSync(temporary);
|
|
1447
|
+
}
|
|
1448
|
+
}
|
|
1449
|
+
|
|
1450
|
+
// src/project-file.ts
|
|
1451
|
+
var SITE_DIR = ".sakupa";
|
|
1452
|
+
var SITE_FILE = "site.json";
|
|
1453
|
+
var RECOVERY_FILE = "recovery.json";
|
|
1454
|
+
function siteFilePath(projectDir) {
|
|
1455
|
+
return join3(projectDir, SITE_DIR, SITE_FILE);
|
|
1456
|
+
}
|
|
1457
|
+
function recoveryFilePath(projectDir) {
|
|
1458
|
+
return join3(projectDir, SITE_DIR, RECOVERY_FILE);
|
|
1459
|
+
}
|
|
1460
|
+
function readSiteFileOnDisk(projectDir) {
|
|
1461
|
+
const path = siteFilePath(projectDir);
|
|
1462
|
+
if (!existsSync3(path)) return { kind: "absent" };
|
|
1463
|
+
let raw;
|
|
1464
|
+
try {
|
|
1465
|
+
raw = readFileSync3(path, "utf8");
|
|
1466
|
+
} catch (err2) {
|
|
1422
1467
|
return {
|
|
1423
|
-
|
|
1424
|
-
|
|
1425
|
-
buildCommandHint: "npm run build",
|
|
1426
|
-
ssrRisks
|
|
1468
|
+
kind: "corrupted",
|
|
1469
|
+
problem: `the file exists but could not be read (${err2 instanceof Error ? err2.message : String(err2)})`
|
|
1427
1470
|
};
|
|
1428
1471
|
}
|
|
1429
|
-
|
|
1430
|
-
|
|
1431
|
-
|
|
1432
|
-
|
|
1433
|
-
|
|
1434
|
-
|
|
1435
|
-
|
|
1436
|
-
|
|
1437
|
-
|
|
1438
|
-
|
|
1439
|
-
|
|
1440
|
-
|
|
1472
|
+
let parsed;
|
|
1473
|
+
try {
|
|
1474
|
+
parsed = JSON.parse(raw);
|
|
1475
|
+
} catch {
|
|
1476
|
+
return { kind: "corrupted", problem: "the file exists but is not valid JSON" };
|
|
1477
|
+
}
|
|
1478
|
+
if (typeof parsed !== "object" || parsed === null) {
|
|
1479
|
+
return { kind: "corrupted", problem: "the file does not contain a JSON object" };
|
|
1480
|
+
}
|
|
1481
|
+
return { kind: "ok", raw: parsed };
|
|
1482
|
+
}
|
|
1483
|
+
function siteFileFrom(raw, credential) {
|
|
1484
|
+
return {
|
|
1485
|
+
siteId: raw.siteId,
|
|
1486
|
+
credential,
|
|
1487
|
+
createdAt: typeof raw.createdAt === "string" ? raw.createdAt : "",
|
|
1488
|
+
apiBaseUrl: typeof raw.apiBaseUrl === "string" ? raw.apiBaseUrl : "",
|
|
1489
|
+
...typeof raw.shortId === "string" ? { shortId: raw.shortId } : {},
|
|
1490
|
+
...typeof raw.url === "string" ? { url: raw.url } : {},
|
|
1491
|
+
...typeof raw.boundDomain === "string" ? { boundDomain: raw.boundDomain } : {}
|
|
1492
|
+
};
|
|
1493
|
+
}
|
|
1494
|
+
function loadSiteFile(projectDir) {
|
|
1495
|
+
const disk = readSiteFileOnDisk(projectDir);
|
|
1496
|
+
if (disk.kind !== "ok") return disk;
|
|
1497
|
+
const parsed = disk.raw;
|
|
1498
|
+
if (typeof parsed.siteId !== "string" || parsed.siteId.length === 0) {
|
|
1499
|
+
return { kind: "corrupted", problem: "the siteId field is missing or empty" };
|
|
1500
|
+
}
|
|
1501
|
+
if (typeof parsed.credentialRef === "string" && parsed.credentialRef.length > 0) {
|
|
1502
|
+
const stored = readStoredCredential(parsed.credentialRef);
|
|
1503
|
+
if (stored.kind === "absent") {
|
|
1504
|
+
return {
|
|
1505
|
+
kind: "corrupted",
|
|
1506
|
+
problem: `its credential is kept in the user-level store, but ${credentialStorePathSafe(parsed.credentialRef)} is missing on this machine. Restore that file from a backup of the home directory (or copy it from the machine that published the site); if it is gone for good, recover the site through its custom domain (recover) or, for a free site created on this device, recover with action "device"`
|
|
1507
|
+
};
|
|
1441
1508
|
}
|
|
1509
|
+
if (stored.kind === "corrupted") return { kind: "corrupted", problem: stored.problem };
|
|
1510
|
+
return { kind: "ok", file: siteFileFrom(parsed, stored.entry.credential) };
|
|
1511
|
+
}
|
|
1512
|
+
if (typeof parsed.credential !== "string" || parsed.credential.length === 0) {
|
|
1513
|
+
return { kind: "corrupted", problem: "the credential field is missing or empty" };
|
|
1514
|
+
}
|
|
1515
|
+
if (!CREDENTIAL_PATTERN.test(parsed.credential)) {
|
|
1442
1516
|
return {
|
|
1443
|
-
|
|
1444
|
-
|
|
1445
|
-
buildCommandHint: "npm run generate (or npx nuxi generate)",
|
|
1446
|
-
ssrRisks
|
|
1517
|
+
kind: "corrupted",
|
|
1518
|
+
problem: "the credential does not match the shape Sakupa issues (sk_ followed by exactly 43 URL-safe base64 characters) \u2014 it looks locally altered or damaged"
|
|
1447
1519
|
};
|
|
1448
1520
|
}
|
|
1449
|
-
const
|
|
1450
|
-
|
|
1451
|
-
|
|
1452
|
-
|
|
1453
|
-
|
|
1454
|
-
|
|
1455
|
-
|
|
1456
|
-
|
|
1457
|
-
|
|
1458
|
-
|
|
1459
|
-
|
|
1521
|
+
const file = siteFileFrom(parsed, parsed.credential);
|
|
1522
|
+
migrateInlineCredential(projectDir, file);
|
|
1523
|
+
return { kind: "ok", file };
|
|
1524
|
+
}
|
|
1525
|
+
function credentialStorePathSafe(ref) {
|
|
1526
|
+
try {
|
|
1527
|
+
return credentialStorePath(ref);
|
|
1528
|
+
} catch {
|
|
1529
|
+
return `the credential store entry "${ref}"`;
|
|
1530
|
+
}
|
|
1531
|
+
}
|
|
1532
|
+
function migrateInlineCredential(projectDir, file) {
|
|
1533
|
+
try {
|
|
1534
|
+
const ref = newCredentialRef();
|
|
1535
|
+
storeCredential({
|
|
1536
|
+
ref,
|
|
1537
|
+
siteId: file.siteId,
|
|
1538
|
+
apiBaseUrl: file.apiBaseUrl,
|
|
1539
|
+
credential: file.credential,
|
|
1540
|
+
createdAt: file.createdAt
|
|
1541
|
+
});
|
|
1542
|
+
try {
|
|
1543
|
+
writeSiteFileOnDisk(projectDir, file, ref);
|
|
1544
|
+
} catch (error) {
|
|
1545
|
+
deleteStoredCredential(ref);
|
|
1546
|
+
throw error;
|
|
1547
|
+
}
|
|
1548
|
+
} catch {
|
|
1549
|
+
}
|
|
1550
|
+
}
|
|
1551
|
+
function loadRecoveryFile(projectDir) {
|
|
1552
|
+
const path = recoveryFilePath(projectDir);
|
|
1553
|
+
if (!existsSync3(path)) return null;
|
|
1554
|
+
try {
|
|
1555
|
+
const parsed = JSON.parse(readFileSync3(path, "utf8"));
|
|
1556
|
+
if (typeof parsed.verificationId !== "string" || parsed.verificationId.length === 0 || typeof parsed.credential !== "string" || !CREDENTIAL_PATTERN.test(parsed.credential)) {
|
|
1557
|
+
throw new Error("required recovery fields are missing or invalid");
|
|
1460
1558
|
}
|
|
1461
1559
|
return {
|
|
1462
|
-
|
|
1463
|
-
|
|
1464
|
-
|
|
1465
|
-
ssrRisks
|
|
1560
|
+
verificationId: parsed.verificationId,
|
|
1561
|
+
credential: parsed.credential,
|
|
1562
|
+
createdAt: typeof parsed.createdAt === "string" ? parsed.createdAt : ""
|
|
1466
1563
|
};
|
|
1564
|
+
} catch (error) {
|
|
1565
|
+
throw new Error(
|
|
1566
|
+
`Recovery state ${path} is damaged (${error instanceof Error ? error.message : String(error)}). Do not start another DNS recovery until this file is repaired or deliberately removed.`
|
|
1567
|
+
);
|
|
1467
1568
|
}
|
|
1468
|
-
|
|
1469
|
-
|
|
1470
|
-
|
|
1471
|
-
|
|
1472
|
-
|
|
1473
|
-
|
|
1474
|
-
|
|
1475
|
-
|
|
1569
|
+
}
|
|
1570
|
+
function writeRecoveryFile(projectDir, file) {
|
|
1571
|
+
const dir = join3(projectDir, SITE_DIR);
|
|
1572
|
+
mkdirSync3(dir, { recursive: true, mode: 448 });
|
|
1573
|
+
ensureSakupaGitignore(projectDir);
|
|
1574
|
+
const path = join3(dir, RECOVERY_FILE);
|
|
1575
|
+
writeFileSync3(path, `${JSON.stringify(file, null, 2)}
|
|
1576
|
+
`, "utf8");
|
|
1577
|
+
try {
|
|
1578
|
+
chmodSync3(path, 384);
|
|
1579
|
+
} catch {
|
|
1580
|
+
}
|
|
1581
|
+
}
|
|
1582
|
+
function deleteRecoveryFile(projectDir) {
|
|
1583
|
+
const path = recoveryFilePath(projectDir);
|
|
1584
|
+
if (existsSync3(path)) rmSync2(path, { force: true });
|
|
1585
|
+
}
|
|
1586
|
+
function siteFileRecoveryGuidance(projectDir) {
|
|
1587
|
+
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. Run help for a safe repair path; the tool will never overwrite a damaged binding or ask the user to manipulate credentials manually.`;
|
|
1588
|
+
}
|
|
1589
|
+
function writeSiteFile(projectDir, file, opts = {}) {
|
|
1590
|
+
if (opts.allowReplace !== true) {
|
|
1591
|
+
const existing = loadSiteFile(projectDir);
|
|
1592
|
+
if (existing.kind === "corrupted") {
|
|
1593
|
+
throw new Error(
|
|
1594
|
+
`Refusing to overwrite ${siteFilePath(projectDir)}: ${existing.problem}. ` + siteFileRecoveryGuidance(projectDir)
|
|
1476
1595
|
);
|
|
1477
1596
|
}
|
|
1478
|
-
if (
|
|
1479
|
-
|
|
1480
|
-
|
|
1597
|
+
if (existing.kind === "ok" && existing.file.siteId !== file.siteId) {
|
|
1598
|
+
throw new Error(
|
|
1599
|
+
`Refusing to overwrite ${siteFilePath(projectDir)}: it already binds this project to site ${existing.file.siteId}. ` + siteFileRecoveryGuidance(projectDir)
|
|
1481
1600
|
);
|
|
1482
1601
|
}
|
|
1483
|
-
return {
|
|
1484
|
-
framework: "sveltekit",
|
|
1485
|
-
outputCandidates: ["build"],
|
|
1486
|
-
buildCommandHint: "npm run build",
|
|
1487
|
-
ssrRisks
|
|
1488
|
-
};
|
|
1489
1602
|
}
|
|
1490
|
-
|
|
1491
|
-
|
|
1492
|
-
|
|
1493
|
-
|
|
1494
|
-
|
|
1495
|
-
|
|
1496
|
-
|
|
1603
|
+
const disk = readSiteFileOnDisk(projectDir);
|
|
1604
|
+
const existingRef = disk.kind === "ok" && disk.raw.siteId === file.siteId && typeof disk.raw.credentialRef === "string" && disk.raw.credentialRef.length > 0 ? disk.raw.credentialRef : void 0;
|
|
1605
|
+
const ref = existingRef ?? newCredentialRef();
|
|
1606
|
+
storeCredential({
|
|
1607
|
+
ref,
|
|
1608
|
+
siteId: file.siteId,
|
|
1609
|
+
apiBaseUrl: file.apiBaseUrl,
|
|
1610
|
+
credential: file.credential,
|
|
1611
|
+
createdAt: file.createdAt
|
|
1612
|
+
});
|
|
1613
|
+
try {
|
|
1614
|
+
writeSiteFileOnDisk(projectDir, file, ref);
|
|
1615
|
+
} catch (error) {
|
|
1616
|
+
if (existingRef === void 0) deleteStoredCredential(ref);
|
|
1617
|
+
throw error;
|
|
1497
1618
|
}
|
|
1498
|
-
|
|
1499
|
-
|
|
1500
|
-
|
|
1501
|
-
|
|
1502
|
-
|
|
1503
|
-
|
|
1504
|
-
|
|
1505
|
-
|
|
1506
|
-
|
|
1507
|
-
|
|
1508
|
-
|
|
1509
|
-
|
|
1510
|
-
|
|
1511
|
-
|
|
1512
|
-
|
|
1513
|
-
|
|
1619
|
+
}
|
|
1620
|
+
function writeSiteFileOnDisk(projectDir, file, ref) {
|
|
1621
|
+
const dir = join3(projectDir, SITE_DIR);
|
|
1622
|
+
mkdirSync3(dir, { recursive: true, mode: 448 });
|
|
1623
|
+
ensureSakupaGitignore(projectDir);
|
|
1624
|
+
const path = join3(dir, SITE_FILE);
|
|
1625
|
+
const { credential: _omitted, ...rest } = file;
|
|
1626
|
+
const onDisk = { ...rest, credentialRef: ref };
|
|
1627
|
+
const temporary = join3(dir, `.site-${randomUUID3()}.tmp`);
|
|
1628
|
+
writeFileSync3(temporary, `${JSON.stringify(onDisk, null, 2)}
|
|
1629
|
+
`, {
|
|
1630
|
+
encoding: "utf8",
|
|
1631
|
+
mode: 384
|
|
1632
|
+
});
|
|
1633
|
+
try {
|
|
1634
|
+
chmodSync3(temporary, 384);
|
|
1635
|
+
} catch {
|
|
1636
|
+
}
|
|
1637
|
+
try {
|
|
1638
|
+
renameSync3(temporary, path);
|
|
1639
|
+
} catch (error) {
|
|
1640
|
+
rmSync2(temporary, { force: true });
|
|
1641
|
+
throw error;
|
|
1642
|
+
}
|
|
1643
|
+
}
|
|
1644
|
+
function deleteSiteFile(projectDir) {
|
|
1645
|
+
const path = siteFilePath(projectDir);
|
|
1646
|
+
const disk = readSiteFileOnDisk(projectDir);
|
|
1647
|
+
if (existsSync3(path)) {
|
|
1648
|
+
rmSync2(path, { force: true });
|
|
1649
|
+
}
|
|
1650
|
+
if (disk.kind === "ok" && typeof disk.raw.credentialRef === "string") {
|
|
1651
|
+
deleteStoredCredential(disk.raw.credentialRef);
|
|
1652
|
+
}
|
|
1653
|
+
pruneSakupaDirectory(projectDir);
|
|
1654
|
+
}
|
|
1655
|
+
function deleteSiteFileIfMatches(projectDir, expected) {
|
|
1656
|
+
const state = loadSiteFile(projectDir);
|
|
1657
|
+
if (state.kind === "absent") return "absent";
|
|
1658
|
+
if (state.kind === "corrupted") return "corrupted";
|
|
1659
|
+
if (state.file.siteId !== expected.siteId || state.file.credential !== expected.credential) {
|
|
1660
|
+
return "mismatch";
|
|
1661
|
+
}
|
|
1662
|
+
deleteSiteFile(projectDir);
|
|
1663
|
+
return "removed";
|
|
1664
|
+
}
|
|
1665
|
+
function findAncestor(startDir, predicate, maxLevels = Number.POSITIVE_INFINITY) {
|
|
1666
|
+
let cursor = startDir;
|
|
1667
|
+
for (let i = 0; i < maxLevels; i += 1) {
|
|
1668
|
+
const parent = dirname(cursor);
|
|
1669
|
+
if (parent === cursor) return null;
|
|
1670
|
+
if (predicate(parent)) return parent;
|
|
1671
|
+
cursor = parent;
|
|
1514
1672
|
}
|
|
1515
1673
|
return null;
|
|
1516
1674
|
}
|
|
1517
|
-
function
|
|
1518
|
-
|
|
1519
|
-
|
|
1520
|
-
|
|
1521
|
-
|
|
1522
|
-
|
|
1523
|
-
|
|
1524
|
-
|
|
1525
|
-
|
|
1675
|
+
function isInsideGitRepo(projectDir) {
|
|
1676
|
+
return existsSync3(join3(projectDir, ".git")) || findAncestor(projectDir, (dir) => existsSync3(join3(dir, ".git"))) !== null;
|
|
1677
|
+
}
|
|
1678
|
+
function credentialGitReminder(projectDir) {
|
|
1679
|
+
if (!isInsideGitRepo(projectDir)) return "";
|
|
1680
|
+
return '\nNOTE: this project is inside a git repository. The management credential in .sakupa/site.json is the key to this site \u2014 do NOT commit it to a PUBLIC repository. Sakupa keeps a ".sakupa/.gitignore" that ignores the whole directory; leave it in place.';
|
|
1681
|
+
}
|
|
1682
|
+
|
|
1683
|
+
// src/analyze/analyzer.ts
|
|
1684
|
+
import { promises as fs } from "node:fs";
|
|
1685
|
+
import { join as join4, posix, resolve as resolve2, sep as sep2 } from "node:path";
|
|
1686
|
+
var SERVER_RUNTIME_DEPS = ["express", "koa", "fastify", "hapi", "@hapi/hapi"];
|
|
1687
|
+
var DB_RUNTIME_DEPS = [
|
|
1688
|
+
"prisma",
|
|
1689
|
+
"@prisma/client",
|
|
1690
|
+
"mongoose",
|
|
1691
|
+
"pg",
|
|
1692
|
+
"mysql2",
|
|
1693
|
+
"better-sqlite3",
|
|
1694
|
+
"typeorm",
|
|
1695
|
+
"sequelize",
|
|
1696
|
+
"redis",
|
|
1697
|
+
"ioredis"
|
|
1698
|
+
];
|
|
1699
|
+
var USE_SERVER_SCAN_MAX_FILES = 200;
|
|
1700
|
+
var USE_SERVER_SCAN_MAX_BYTES = 256 * 1024;
|
|
1701
|
+
var CONTENT_READ_MAX_BYTES = 1024 * 1024;
|
|
1702
|
+
var TEXT_CONTENT_EXTENSIONS = /* @__PURE__ */ new Set(["html", "htm", "js", "mjs", "css", "json", "txt", "xml"]);
|
|
1703
|
+
var SOURCE_SCAN_EXTENSIONS = /* @__PURE__ */ new Set(["js", "jsx", "ts", "tsx", "mjs", "cjs"]);
|
|
1704
|
+
var FORBIDDEN_SEGMENTS_LOWER = new Set(FORBIDDEN_PATH_SEGMENTS.map((s) => s.toLowerCase()));
|
|
1705
|
+
async function isDirectory(path) {
|
|
1706
|
+
try {
|
|
1707
|
+
return (await fs.stat(path)).isDirectory();
|
|
1708
|
+
} catch {
|
|
1709
|
+
return false;
|
|
1526
1710
|
}
|
|
1527
|
-
|
|
1528
|
-
|
|
1529
|
-
|
|
1530
|
-
|
|
1531
|
-
|
|
1532
|
-
|
|
1711
|
+
}
|
|
1712
|
+
async function isFile(path) {
|
|
1713
|
+
try {
|
|
1714
|
+
return (await fs.stat(path)).isFile();
|
|
1715
|
+
} catch {
|
|
1716
|
+
return false;
|
|
1533
1717
|
}
|
|
1534
|
-
return risks;
|
|
1535
1718
|
}
|
|
1536
|
-
async function
|
|
1537
|
-
|
|
1538
|
-
|
|
1539
|
-
|
|
1540
|
-
|
|
1541
|
-
|
|
1542
|
-
|
|
1543
|
-
const text2 = await readTextIfExists(join2(projectDir, file.path), USE_SERVER_SCAN_MAX_BYTES);
|
|
1544
|
-
if (text2 !== null && /['"]use server['"]/.test(text2)) return true;
|
|
1719
|
+
async function readTextIfExists(path, maxBytes = CONTENT_READ_MAX_BYTES) {
|
|
1720
|
+
try {
|
|
1721
|
+
const stat2 = await fs.stat(path);
|
|
1722
|
+
if (!stat2.isFile() || stat2.size > maxBytes) return null;
|
|
1723
|
+
return await fs.readFile(path, "utf8");
|
|
1724
|
+
} catch {
|
|
1725
|
+
return null;
|
|
1545
1726
|
}
|
|
1546
|
-
return false;
|
|
1547
1727
|
}
|
|
1548
|
-
function
|
|
1549
|
-
const
|
|
1550
|
-
|
|
1728
|
+
async function firstExistingFile(dir, names) {
|
|
1729
|
+
for (const name of names) {
|
|
1730
|
+
const p = join4(dir, name);
|
|
1731
|
+
if (await isFile(p)) return p;
|
|
1732
|
+
}
|
|
1733
|
+
return null;
|
|
1551
1734
|
}
|
|
1552
|
-
|
|
1553
|
-
const
|
|
1554
|
-
const
|
|
1555
|
-
|
|
1556
|
-
|
|
1557
|
-
|
|
1558
|
-
|
|
1559
|
-
|
|
1560
|
-
|
|
1561
|
-
|
|
1562
|
-
|
|
1563
|
-
|
|
1564
|
-
|
|
1565
|
-
|
|
1566
|
-
|
|
1567
|
-
}
|
|
1568
|
-
|
|
1569
|
-
|
|
1735
|
+
function extensionOf(path) {
|
|
1736
|
+
const base = path.split("/").pop() ?? "";
|
|
1737
|
+
const idx = base.lastIndexOf(".");
|
|
1738
|
+
if (idx <= 0) return "";
|
|
1739
|
+
return base.slice(idx + 1).toLowerCase();
|
|
1740
|
+
}
|
|
1741
|
+
async function walkFiles(dir, opts) {
|
|
1742
|
+
const out = [];
|
|
1743
|
+
async function recurse(current, relPrefix) {
|
|
1744
|
+
if (out.length > opts.maxFiles) return;
|
|
1745
|
+
let entries;
|
|
1746
|
+
try {
|
|
1747
|
+
entries = await fs.readdir(current, { withFileTypes: true });
|
|
1748
|
+
} catch {
|
|
1749
|
+
return;
|
|
1750
|
+
}
|
|
1751
|
+
entries.sort((a, b) => a.name < b.name ? -1 : a.name > b.name ? 1 : 0);
|
|
1752
|
+
for (const entry of entries) {
|
|
1753
|
+
if (out.length > opts.maxFiles) return;
|
|
1754
|
+
const rel = relPrefix.length > 0 ? `${relPrefix}/${entry.name}` : entry.name;
|
|
1755
|
+
if (entry.isSymbolicLink()) continue;
|
|
1756
|
+
if (entry.isDirectory()) {
|
|
1757
|
+
if (FORBIDDEN_SEGMENTS_LOWER.has(entry.name.toLowerCase())) continue;
|
|
1758
|
+
if (opts.skipRelDirs?.has(rel)) continue;
|
|
1759
|
+
await recurse(join4(current, entry.name), rel);
|
|
1760
|
+
} else if (entry.isFile()) {
|
|
1570
1761
|
try {
|
|
1571
|
-
const
|
|
1572
|
-
|
|
1573
|
-
outputDirExists = false;
|
|
1574
|
-
}
|
|
1762
|
+
const stat2 = await fs.stat(join4(current, entry.name));
|
|
1763
|
+
out.push({ path: rel, size: stat2.size });
|
|
1575
1764
|
} catch {
|
|
1576
|
-
outputDirExists = false;
|
|
1577
1765
|
}
|
|
1578
1766
|
}
|
|
1579
1767
|
}
|
|
1580
|
-
} else if (detection) {
|
|
1581
|
-
for (const candidate of detection.outputCandidates) {
|
|
1582
|
-
if (await isDirectory(join2(root, candidate))) {
|
|
1583
|
-
outputDirRel = candidate;
|
|
1584
|
-
outputDirExists = true;
|
|
1585
|
-
break;
|
|
1586
|
-
}
|
|
1587
|
-
}
|
|
1588
|
-
if (outputDirRel === void 0) {
|
|
1589
|
-
outputDirRel = detection.outputCandidates[0] ?? "dist";
|
|
1590
|
-
outputDirExists = false;
|
|
1591
|
-
}
|
|
1592
|
-
} else if (!buildRequired) {
|
|
1593
|
-
outputDirRel = ".";
|
|
1594
|
-
outputDirExists = true;
|
|
1595
|
-
} else if (hasBuildScript) {
|
|
1596
|
-
for (const candidate of ["dist", "build", "out", "public"]) {
|
|
1597
|
-
if (await isFile(join2(root, candidate, "index.html"))) {
|
|
1598
|
-
outputDirRel = candidate;
|
|
1599
|
-
outputDirExists = true;
|
|
1600
|
-
break;
|
|
1601
|
-
}
|
|
1602
|
-
}
|
|
1603
|
-
}
|
|
1604
|
-
const projectType = detection ? "framework" : outputDirRel !== void 0 && !buildRequired ? "plain-static" : "unknown";
|
|
1605
|
-
const buildCommandHint = detection ? detection.buildCommandHint : hasBuildScript ? "npm run build" : void 0;
|
|
1606
|
-
if (pkg !== null && buildRequired) {
|
|
1607
|
-
const skip = /* @__PURE__ */ new Set();
|
|
1608
|
-
if (outputDirRel !== void 0 && outputDirRel !== ".") skip.add(outputDirRel);
|
|
1609
|
-
if (await scanForUseServer(root, skip)) {
|
|
1610
|
-
ssrRisks.push(
|
|
1611
|
-
"Source files contain 'use server' directives (server actions). Server actions require a server runtime and will not run on Sakupa."
|
|
1612
|
-
);
|
|
1613
|
-
}
|
|
1614
|
-
}
|
|
1615
|
-
if (outputDirRel === void 0 || !outputDirExists) {
|
|
1616
|
-
ssrRisks.push(...serverAndDbDepRisks(pkg, false));
|
|
1617
|
-
const sourceWithoutBuild = pkg !== null && buildRequired && (await isDirectory(join2(root, "src")) || await isDirectory(join2(root, "pages")));
|
|
1618
|
-
let suggestedNextAction2;
|
|
1619
|
-
if (opts.outputDir !== void 0) {
|
|
1620
|
-
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.`;
|
|
1621
|
-
} else if (ssrRisks.length > 0 && detection) {
|
|
1622
|
-
suggestedNextAction2 = `This ${detection.framework} project appears to require a server runtime (see ssrRisks) and no static output directory was found. Convert it to static output (e.g. Next.js output: 'export', Nuxt generate, Astro static, SvelteKit adapter-static), run the build locally (${buildCommandHint ?? "npm run build"}), then re-run analyze.`;
|
|
1623
|
-
} else if (sourceWithoutBuild || detection && !outputDirExists) {
|
|
1624
|
-
suggestedNextAction2 = `This looks like a source project, not built static output. Run the build locally (${buildCommandHint ?? "npm run build"}) then re-run analyze.`;
|
|
1625
|
-
} else {
|
|
1626
|
-
suggestedNextAction2 = "No deployable static output was found. Create an index.html (or build the project locally so a static output directory exists), then re-run analyze.";
|
|
1627
|
-
}
|
|
1628
|
-
return {
|
|
1629
|
-
projectType,
|
|
1630
|
-
...detection ? { framework: detection.framework } : {},
|
|
1631
|
-
...outputDirRel !== void 0 ? { recommendedOutputDir: outputDirRel } : {},
|
|
1632
|
-
outputDirExists: false,
|
|
1633
|
-
...buildCommandHint !== void 0 ? { buildCommandHint } : {},
|
|
1634
|
-
entryHtmlFound: false,
|
|
1635
|
-
langSupported: false,
|
|
1636
|
-
totalBytes: 0,
|
|
1637
|
-
fileCount: 0,
|
|
1638
|
-
issues: [],
|
|
1639
|
-
ssrRisks,
|
|
1640
|
-
spa: { looksLikeSpa: false, autoFallback: false },
|
|
1641
|
-
deployable: false,
|
|
1642
|
-
suggestedNextAction: suggestedNextAction2
|
|
1643
|
-
};
|
|
1644
|
-
}
|
|
1645
|
-
const outputAbs = outputDirRel === "." ? root : resolve(root, outputDirRel);
|
|
1646
|
-
const walked = await walkFiles(outputAbs, { maxFiles: MAX_FILE_COUNT + 1 });
|
|
1647
|
-
const candidates = [];
|
|
1648
|
-
for (const file of walked) {
|
|
1649
|
-
const ext = extensionOf(file.path);
|
|
1650
|
-
let content;
|
|
1651
|
-
if (TEXT_CONTENT_EXTENSIONS.has(ext) && file.size <= CONTENT_READ_MAX_BYTES) {
|
|
1652
|
-
try {
|
|
1653
|
-
content = new Uint8Array(await fs.readFile(join2(outputAbs, file.path)));
|
|
1654
|
-
} catch {
|
|
1655
|
-
content = void 0;
|
|
1656
|
-
}
|
|
1657
|
-
}
|
|
1658
|
-
candidates.push({ path: file.path, size: file.size, ...content ? { content } : {} });
|
|
1659
|
-
}
|
|
1660
|
-
const validation = validateDeployableFiles(candidates, {
|
|
1661
|
-
mode: "free",
|
|
1662
|
-
...opts.formsScriptUrl !== void 0 ? { formsScriptUrl: opts.formsScriptUrl } : {}
|
|
1663
|
-
});
|
|
1664
|
-
ssrRisks.push(...serverAndDbDepRisks(pkg, true));
|
|
1665
|
-
const deployable = validation.ok && walked.length > 0;
|
|
1666
|
-
const spa = {
|
|
1667
|
-
looksLikeSpa: validation.looksLikeSpa,
|
|
1668
|
-
autoFallback: validation.looksLikeSpa
|
|
1669
|
-
};
|
|
1670
|
-
let suggestedNextAction;
|
|
1671
|
-
if (!deployable) {
|
|
1672
|
-
const firstError = validation.issues.find((i) => i.severity === "error");
|
|
1673
|
-
if (firstError?.code === "missing_index_html") {
|
|
1674
|
-
suggestedNextAction = `No index.html at the root of "${outputDirRel}". Deploy the built static output (the directory whose root contains index.html), not the source project. Build locally first if needed (${buildCommandHint ?? "npm run build"}), then re-run analyze.`;
|
|
1675
|
-
} else {
|
|
1676
|
-
suggestedNextAction = "Fix the listed issues (remove forbidden/secret files, reduce size, add missing entry HTML), then re-run analyze.";
|
|
1677
|
-
}
|
|
1678
|
-
} else if (spa.looksLikeSpa) {
|
|
1679
|
-
suggestedNextAction = `Run deploy to publish the static output in "${outputDirRel}". It looks like a single-page app, so SPA fallback (unknown paths rewrite to index.html) will be enabled automatically; pass spaFallback: false to opt out.`;
|
|
1680
|
-
} else {
|
|
1681
|
-
suggestedNextAction = `Run deploy to publish the static output in "${outputDirRel}".`;
|
|
1682
1768
|
}
|
|
1683
|
-
|
|
1684
|
-
|
|
1685
|
-
...detection ? { framework: detection.framework } : {},
|
|
1686
|
-
recommendedOutputDir: outputDirRel,
|
|
1687
|
-
outputDirExists: true,
|
|
1688
|
-
...buildCommandHint !== void 0 ? { buildCommandHint } : {},
|
|
1689
|
-
entryHtmlFound: validation.entryHtmlPath !== void 0,
|
|
1690
|
-
...validation.htmlLang !== void 0 ? { htmlLang: validation.htmlLang } : {},
|
|
1691
|
-
langSupported: validation.supportedLang !== void 0,
|
|
1692
|
-
totalBytes: validation.totalBytes,
|
|
1693
|
-
fileCount: validation.fileCount,
|
|
1694
|
-
issues: validation.issues,
|
|
1695
|
-
ssrRisks,
|
|
1696
|
-
spa,
|
|
1697
|
-
deployable,
|
|
1698
|
-
suggestedNextAction,
|
|
1699
|
-
...deployable ? { files: walked.map((f) => ({ path: f.path, size: f.size })) } : {}
|
|
1700
|
-
};
|
|
1769
|
+
await recurse(dir, "");
|
|
1770
|
+
return out;
|
|
1701
1771
|
}
|
|
1702
|
-
|
|
1703
|
-
|
|
1704
|
-
|
|
1705
|
-
|
|
1706
|
-
|
|
1707
|
-
|
|
1708
|
-
|
|
1709
|
-
|
|
1710
|
-
import { isAbsolute as isAbsolute2, resolve as resolve3 } from "node:path";
|
|
1711
|
-
|
|
1712
|
-
// src/project-root.ts
|
|
1713
|
-
import { randomUUID as randomUUID2 } from "node:crypto";
|
|
1714
|
-
import {
|
|
1715
|
-
chmodSync as chmodSync2,
|
|
1716
|
-
existsSync as existsSync2,
|
|
1717
|
-
lstatSync,
|
|
1718
|
-
mkdirSync as mkdirSync2,
|
|
1719
|
-
readFileSync as readFileSync2,
|
|
1720
|
-
realpathSync,
|
|
1721
|
-
renameSync as renameSync2,
|
|
1722
|
-
rmdirSync as rmdirSync2,
|
|
1723
|
-
statSync,
|
|
1724
|
-
unlinkSync,
|
|
1725
|
-
writeFileSync as writeFileSync2
|
|
1726
|
-
} from "node:fs";
|
|
1727
|
-
import { homedir } from "node:os";
|
|
1728
|
-
import { isAbsolute, join as join3, parse, relative, resolve as resolve2, sep as sep2 } from "node:path";
|
|
1729
|
-
var SAKUPA_DIR = ".sakupa";
|
|
1730
|
-
var PROJECT_FILE = "project.json";
|
|
1731
|
-
var PROJECT_SCHEMA_VERSION = 1;
|
|
1732
|
-
var ProjectRootError = class extends Error {
|
|
1733
|
-
code;
|
|
1734
|
-
constructor(code, message) {
|
|
1735
|
-
super(message);
|
|
1736
|
-
this.name = "ProjectRootError";
|
|
1737
|
-
this.code = code;
|
|
1772
|
+
async function readPackageJson(projectDir) {
|
|
1773
|
+
const raw = await readTextIfExists(join4(projectDir, "package.json"));
|
|
1774
|
+
if (raw === null) return null;
|
|
1775
|
+
try {
|
|
1776
|
+
const parsed = JSON.parse(raw);
|
|
1777
|
+
return typeof parsed === "object" && parsed !== null ? parsed : null;
|
|
1778
|
+
} catch {
|
|
1779
|
+
return null;
|
|
1738
1780
|
}
|
|
1739
|
-
};
|
|
1740
|
-
function projectMarkerPath(projectDir) {
|
|
1741
|
-
return join3(projectDir, SAKUPA_DIR, PROJECT_FILE);
|
|
1742
1781
|
}
|
|
1743
|
-
function
|
|
1744
|
-
|
|
1745
|
-
|
|
1746
|
-
|
|
1747
|
-
|
|
1748
|
-
|
|
1749
|
-
|
|
1782
|
+
function allDeps(pkg) {
|
|
1783
|
+
return { ...pkg?.dependencies ?? {}, ...pkg?.devDependencies ?? {} };
|
|
1784
|
+
}
|
|
1785
|
+
async function anyFileMatches(dir, predicate) {
|
|
1786
|
+
if (!await isDirectory(dir)) return false;
|
|
1787
|
+
const files = await walkFiles(dir, { maxFiles: 2e3 });
|
|
1788
|
+
return files.some((f) => predicate(f.path.split("/").pop() ?? ""));
|
|
1789
|
+
}
|
|
1790
|
+
async function detectFramework(projectDir, pkg) {
|
|
1791
|
+
const deps = allDeps(pkg);
|
|
1792
|
+
const ssrRisks = [];
|
|
1793
|
+
const nextConfigPath = await firstExistingFile(projectDir, [
|
|
1794
|
+
"next.config.js",
|
|
1795
|
+
"next.config.mjs",
|
|
1796
|
+
"next.config.ts",
|
|
1797
|
+
"next.config.cjs"
|
|
1798
|
+
]);
|
|
1799
|
+
if ("next" in deps || nextConfigPath !== null) {
|
|
1800
|
+
const config = nextConfigPath ? await readTextIfExists(nextConfigPath) : null;
|
|
1801
|
+
const staticExport = config !== null && /output\s*:\s*['"]export['"]/.test(config);
|
|
1802
|
+
if (!staticExport) {
|
|
1803
|
+
ssrRisks.push(
|
|
1804
|
+
`Next.js project without output: 'export' in next.config.* \u2014 the default Next.js build requires a Node.js server. Sakupa only serves static files; add output: 'export' to next.config.* and build locally to produce a static "out" directory.`
|
|
1805
|
+
);
|
|
1806
|
+
}
|
|
1807
|
+
for (const apiDir of ["pages/api", "src/pages/api"]) {
|
|
1808
|
+
if (await isDirectory(join4(projectDir, apiDir))) {
|
|
1809
|
+
ssrRisks.push(
|
|
1810
|
+
`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.`
|
|
1811
|
+
);
|
|
1812
|
+
break;
|
|
1813
|
+
}
|
|
1814
|
+
}
|
|
1815
|
+
for (const appDir of ["app", "src/app"]) {
|
|
1816
|
+
if (await anyFileMatches(
|
|
1817
|
+
join4(projectDir, appDir),
|
|
1818
|
+
(base) => /^route\.(ts|js|tsx|jsx|mjs)$/.test(base)
|
|
1819
|
+
)) {
|
|
1820
|
+
ssrRisks.push(
|
|
1821
|
+
`App Router route handlers (${appDir}/**/route.ts|js) require a server runtime and will not run on Sakupa.`
|
|
1822
|
+
);
|
|
1823
|
+
break;
|
|
1824
|
+
}
|
|
1825
|
+
}
|
|
1826
|
+
if (await firstExistingFile(projectDir, [
|
|
1827
|
+
"middleware.ts",
|
|
1828
|
+
"middleware.js",
|
|
1829
|
+
"src/middleware.ts",
|
|
1830
|
+
"src/middleware.js"
|
|
1831
|
+
]) !== null) {
|
|
1832
|
+
ssrRisks.push("middleware.(ts|js) runs on a server/edge runtime and will not run on Sakupa.");
|
|
1833
|
+
}
|
|
1750
1834
|
return {
|
|
1751
|
-
|
|
1752
|
-
|
|
1835
|
+
framework: "next",
|
|
1836
|
+
outputCandidates: ["out"],
|
|
1837
|
+
buildCommandHint: "npm run build",
|
|
1838
|
+
ssrRisks
|
|
1753
1839
|
};
|
|
1754
1840
|
}
|
|
1755
|
-
|
|
1756
|
-
|
|
1757
|
-
|
|
1758
|
-
|
|
1759
|
-
|
|
1841
|
+
const nuxtConfigPath = await firstExistingFile(projectDir, [
|
|
1842
|
+
"nuxt.config.ts",
|
|
1843
|
+
"nuxt.config.js",
|
|
1844
|
+
"nuxt.config.mjs"
|
|
1845
|
+
]);
|
|
1846
|
+
if ("nuxt" in deps || "nuxt3" in deps || nuxtConfigPath !== null) {
|
|
1847
|
+
for (const serverDir of ["server/api", "server/routes"]) {
|
|
1848
|
+
if (await isDirectory(join4(projectDir, serverDir))) {
|
|
1849
|
+
ssrRisks.push(
|
|
1850
|
+
`Nuxt server handlers (${serverDir}/) require a server runtime and will not run on Sakupa. Use static generation (npx nuxi generate) and deploy .output/public.`
|
|
1851
|
+
);
|
|
1852
|
+
}
|
|
1853
|
+
}
|
|
1760
1854
|
return {
|
|
1761
|
-
|
|
1762
|
-
|
|
1855
|
+
framework: "nuxt",
|
|
1856
|
+
outputCandidates: [".output/public", "dist"],
|
|
1857
|
+
buildCommandHint: "npm run generate (or npx nuxi generate)",
|
|
1858
|
+
ssrRisks
|
|
1763
1859
|
};
|
|
1764
1860
|
}
|
|
1765
|
-
|
|
1766
|
-
|
|
1767
|
-
|
|
1768
|
-
|
|
1769
|
-
|
|
1770
|
-
|
|
1771
|
-
|
|
1772
|
-
|
|
1861
|
+
const astroConfigPath = await firstExistingFile(projectDir, [
|
|
1862
|
+
"astro.config.mjs",
|
|
1863
|
+
"astro.config.js",
|
|
1864
|
+
"astro.config.ts"
|
|
1865
|
+
]);
|
|
1866
|
+
if ("astro" in deps || astroConfigPath !== null) {
|
|
1867
|
+
const config = astroConfigPath ? await readTextIfExists(astroConfigPath) : null;
|
|
1868
|
+
if (config !== null && /output\s*:\s*['"]server['"]/.test(config)) {
|
|
1869
|
+
ssrRisks.push(
|
|
1870
|
+
"Astro config sets output: 'server' (SSR). Sakupa only serves static files; use the default static output (or output: 'static') and build locally."
|
|
1871
|
+
);
|
|
1872
|
+
}
|
|
1873
|
+
return {
|
|
1874
|
+
framework: "astro",
|
|
1875
|
+
outputCandidates: ["dist"],
|
|
1876
|
+
buildCommandHint: "npm run build",
|
|
1877
|
+
ssrRisks
|
|
1878
|
+
};
|
|
1773
1879
|
}
|
|
1774
|
-
|
|
1775
|
-
|
|
1776
|
-
|
|
1777
|
-
|
|
1778
|
-
|
|
1779
|
-
|
|
1780
|
-
|
|
1880
|
+
if ("@sveltejs/kit" in deps) {
|
|
1881
|
+
if ("@sveltejs/adapter-node" in deps) {
|
|
1882
|
+
ssrRisks.push(
|
|
1883
|
+
"SvelteKit is configured with @sveltejs/adapter-node, which produces a Node.js server. Sakupa only serves static files; switch to @sveltejs/adapter-static and rebuild."
|
|
1884
|
+
);
|
|
1885
|
+
} else if (!("@sveltejs/adapter-static" in deps)) {
|
|
1886
|
+
ssrRisks.push(
|
|
1887
|
+
"SvelteKit requires @sveltejs/adapter-static to produce a fully static build. Install and configure it, then build locally."
|
|
1888
|
+
);
|
|
1781
1889
|
}
|
|
1782
|
-
|
|
1783
|
-
|
|
1784
|
-
|
|
1785
|
-
|
|
1786
|
-
|
|
1787
|
-
|
|
1788
|
-
|
|
1789
|
-
|
|
1790
|
-
"
|
|
1791
|
-
|
|
1792
|
-
|
|
1890
|
+
if (await anyFileMatches(join4(projectDir, "src/routes"), (base) => base.startsWith("+server."))) {
|
|
1891
|
+
ssrRisks.push(
|
|
1892
|
+
"SvelteKit +server.* endpoint files require a server runtime and will not run on Sakupa."
|
|
1893
|
+
);
|
|
1894
|
+
}
|
|
1895
|
+
return {
|
|
1896
|
+
framework: "sveltekit",
|
|
1897
|
+
outputCandidates: ["build"],
|
|
1898
|
+
buildCommandHint: "npm run build",
|
|
1899
|
+
ssrRisks
|
|
1900
|
+
};
|
|
1793
1901
|
}
|
|
1794
|
-
if (
|
|
1902
|
+
if ("react-scripts" in deps) {
|
|
1795
1903
|
return {
|
|
1796
|
-
|
|
1797
|
-
|
|
1798
|
-
|
|
1799
|
-
|
|
1904
|
+
framework: "create-react-app",
|
|
1905
|
+
outputCandidates: ["build"],
|
|
1906
|
+
buildCommandHint: "npm run build",
|
|
1907
|
+
ssrRisks
|
|
1800
1908
|
};
|
|
1801
1909
|
}
|
|
1802
|
-
const
|
|
1803
|
-
|
|
1804
|
-
|
|
1805
|
-
|
|
1806
|
-
|
|
1807
|
-
|
|
1808
|
-
|
|
1809
|
-
|
|
1810
|
-
|
|
1811
|
-
|
|
1812
|
-
|
|
1813
|
-
|
|
1910
|
+
const viteConfigPath = await firstExistingFile(projectDir, [
|
|
1911
|
+
"vite.config.ts",
|
|
1912
|
+
"vite.config.js",
|
|
1913
|
+
"vite.config.mjs"
|
|
1914
|
+
]);
|
|
1915
|
+
if ("vite" in deps || viteConfigPath !== null) {
|
|
1916
|
+
let framework = "vite";
|
|
1917
|
+
if ("vue" in deps) framework = "vue (vite)";
|
|
1918
|
+
else if ("react" in deps) framework = "react (vite)";
|
|
1919
|
+
else if ("svelte" in deps) framework = "svelte (vite)";
|
|
1920
|
+
return {
|
|
1921
|
+
framework,
|
|
1922
|
+
outputCandidates: ["dist"],
|
|
1923
|
+
buildCommandHint: "npm run build",
|
|
1924
|
+
ssrRisks
|
|
1925
|
+
};
|
|
1926
|
+
}
|
|
1927
|
+
return null;
|
|
1814
1928
|
}
|
|
1815
|
-
function
|
|
1816
|
-
const
|
|
1817
|
-
|
|
1818
|
-
const
|
|
1819
|
-
|
|
1820
|
-
|
|
1821
|
-
|
|
1822
|
-
|
|
1823
|
-
|
|
1929
|
+
function serverAndDbDepRisks(pkg, hasStaticOutput) {
|
|
1930
|
+
const deps = allDeps(pkg);
|
|
1931
|
+
const risks = [];
|
|
1932
|
+
for (const name of SERVER_RUNTIME_DEPS) {
|
|
1933
|
+
if (name in deps) {
|
|
1934
|
+
risks.push(
|
|
1935
|
+
hasStaticOutput ? `Warning: dependency "${name}" is a server framework. Sakupa never runs it online \u2014 only the static output is served. Using it locally at build time is fine.` : `Dependency "${name}" is a server framework. Sakupa does not host server runtimes; only prebuilt static output can be deployed.`
|
|
1936
|
+
);
|
|
1937
|
+
}
|
|
1824
1938
|
}
|
|
1825
|
-
|
|
1826
|
-
|
|
1827
|
-
|
|
1828
|
-
|
|
1829
|
-
|
|
1939
|
+
for (const name of DB_RUNTIME_DEPS) {
|
|
1940
|
+
if (name in deps) {
|
|
1941
|
+
risks.push(
|
|
1942
|
+
hasStaticOutput ? `Warning: dependency "${name}" is a database/runtime client. Sakupa never runs it online \u2014 using a database locally at build time to generate static pages is fine.` : `Dependency "${name}" is a database/runtime client. Sakupa does not host databases or server runtimes; generate static pages locally and deploy only the output.`
|
|
1943
|
+
);
|
|
1944
|
+
}
|
|
1830
1945
|
}
|
|
1831
|
-
return
|
|
1832
|
-
projectDir: canonical,
|
|
1833
|
-
requestedPath: canonical,
|
|
1834
|
-
markerKind: "project",
|
|
1835
|
-
marker: markerState.marker
|
|
1836
|
-
};
|
|
1946
|
+
return risks;
|
|
1837
1947
|
}
|
|
1838
|
-
function
|
|
1839
|
-
const
|
|
1840
|
-
|
|
1841
|
-
|
|
1842
|
-
|
|
1843
|
-
|
|
1844
|
-
|
|
1845
|
-
);
|
|
1846
|
-
|
|
1847
|
-
if (!isSafeRelativeOutput(outputDir)) {
|
|
1848
|
-
throw new ProjectRootError(
|
|
1849
|
-
"unsafe_path",
|
|
1850
|
-
`Output directory "${outputDir}" must stay inside the initialized Sakupa project.`
|
|
1851
|
-
);
|
|
1948
|
+
async function scanForUseServer(projectDir, skipRelDirs) {
|
|
1949
|
+
const files = await walkFiles(projectDir, { maxFiles: USE_SERVER_SCAN_MAX_FILES, skipRelDirs });
|
|
1950
|
+
let scanned = 0;
|
|
1951
|
+
for (const file of files) {
|
|
1952
|
+
if (scanned >= USE_SERVER_SCAN_MAX_FILES) break;
|
|
1953
|
+
if (!SOURCE_SCAN_EXTENSIONS.has(extensionOf(file.path))) continue;
|
|
1954
|
+
scanned += 1;
|
|
1955
|
+
const text2 = await readTextIfExists(join4(projectDir, file.path), USE_SERVER_SCAN_MAX_BYTES);
|
|
1956
|
+
if (text2 !== null && /['"]use server['"]/.test(text2)) return true;
|
|
1852
1957
|
}
|
|
1853
|
-
|
|
1854
|
-
...state.marker,
|
|
1855
|
-
outputDir: normalizeRelative(outputDir)
|
|
1856
|
-
};
|
|
1857
|
-
writeMarkerAtomically(canonical, marker);
|
|
1858
|
-
return marker;
|
|
1958
|
+
return false;
|
|
1859
1959
|
}
|
|
1860
|
-
function
|
|
1861
|
-
const
|
|
1862
|
-
|
|
1863
|
-
|
|
1864
|
-
|
|
1865
|
-
|
|
1960
|
+
function normalizeOutputDir(outputDir) {
|
|
1961
|
+
const normalized = posix.normalize(outputDir.replaceAll(sep2, "/")).replace(/\/+$/, "");
|
|
1962
|
+
return normalized === "" ? "." : normalized;
|
|
1963
|
+
}
|
|
1964
|
+
async function analyzeProject(projectDir, opts = {}) {
|
|
1965
|
+
const root = await fs.realpath(resolve2(projectDir));
|
|
1966
|
+
const pkg = await readPackageJson(root);
|
|
1967
|
+
const detection = await detectFramework(root, pkg);
|
|
1968
|
+
const ssrRisks = [...detection?.ssrRisks ?? []];
|
|
1969
|
+
const hasBuildScript = typeof pkg?.scripts?.["build"] === "string";
|
|
1970
|
+
const buildRequired = detection !== null || hasBuildScript;
|
|
1971
|
+
let outputDirRel;
|
|
1972
|
+
let outputDirExists = false;
|
|
1973
|
+
if (opts.outputDir !== void 0) {
|
|
1974
|
+
outputDirRel = normalizeOutputDir(opts.outputDir);
|
|
1975
|
+
const abs = resolve2(root, outputDirRel);
|
|
1976
|
+
if (abs !== root && !abs.startsWith(root + sep2)) {
|
|
1977
|
+
outputDirRel = ".";
|
|
1978
|
+
outputDirExists = false;
|
|
1979
|
+
} else {
|
|
1980
|
+
outputDirExists = await isDirectory(abs) || outputDirRel === "." && await isDirectory(root);
|
|
1981
|
+
if (outputDirExists) {
|
|
1982
|
+
try {
|
|
1983
|
+
const physical = await fs.realpath(abs);
|
|
1984
|
+
if (physical !== root && !physical.startsWith(root + sep2)) {
|
|
1985
|
+
outputDirExists = false;
|
|
1986
|
+
}
|
|
1987
|
+
} catch {
|
|
1988
|
+
outputDirExists = false;
|
|
1989
|
+
}
|
|
1990
|
+
}
|
|
1991
|
+
}
|
|
1992
|
+
} else if (detection) {
|
|
1993
|
+
for (const candidate of detection.outputCandidates) {
|
|
1994
|
+
if (await isDirectory(join4(root, candidate))) {
|
|
1995
|
+
outputDirRel = candidate;
|
|
1996
|
+
outputDirExists = true;
|
|
1997
|
+
break;
|
|
1998
|
+
}
|
|
1999
|
+
}
|
|
2000
|
+
if (outputDirRel === void 0) {
|
|
2001
|
+
outputDirRel = detection.outputCandidates[0] ?? "dist";
|
|
2002
|
+
outputDirExists = false;
|
|
2003
|
+
}
|
|
2004
|
+
} else if (!buildRequired) {
|
|
2005
|
+
outputDirRel = ".";
|
|
2006
|
+
outputDirExists = true;
|
|
2007
|
+
} else if (hasBuildScript) {
|
|
2008
|
+
for (const candidate of ["dist", "build", "out", "public"]) {
|
|
2009
|
+
if (await isFile(join4(root, candidate, "index.html"))) {
|
|
2010
|
+
outputDirRel = candidate;
|
|
2011
|
+
outputDirExists = true;
|
|
2012
|
+
break;
|
|
2013
|
+
}
|
|
2014
|
+
}
|
|
1866
2015
|
}
|
|
1867
|
-
|
|
1868
|
-
|
|
1869
|
-
|
|
1870
|
-
|
|
1871
|
-
|
|
2016
|
+
const projectType = detection ? "framework" : outputDirRel !== void 0 && !buildRequired ? "plain-static" : "unknown";
|
|
2017
|
+
const buildCommandHint = detection ? detection.buildCommandHint : hasBuildScript ? "npm run build" : void 0;
|
|
2018
|
+
if (pkg !== null && buildRequired) {
|
|
2019
|
+
const skip = /* @__PURE__ */ new Set();
|
|
2020
|
+
if (outputDirRel !== void 0 && outputDirRel !== ".") skip.add(outputDirRel);
|
|
2021
|
+
if (await scanForUseServer(root, skip)) {
|
|
2022
|
+
ssrRisks.push(
|
|
2023
|
+
"Source files contain 'use server' directives (server actions). Server actions require a server runtime and will not run on Sakupa."
|
|
2024
|
+
);
|
|
2025
|
+
}
|
|
1872
2026
|
}
|
|
1873
|
-
|
|
1874
|
-
|
|
1875
|
-
|
|
1876
|
-
|
|
1877
|
-
|
|
1878
|
-
|
|
1879
|
-
|
|
2027
|
+
if (outputDirRel === void 0 || !outputDirExists) {
|
|
2028
|
+
ssrRisks.push(...serverAndDbDepRisks(pkg, false));
|
|
2029
|
+
const sourceWithoutBuild = pkg !== null && buildRequired && (await isDirectory(join4(root, "src")) || await isDirectory(join4(root, "pages")));
|
|
2030
|
+
let suggestedNextAction2;
|
|
2031
|
+
if (opts.outputDir !== void 0) {
|
|
2032
|
+
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.`;
|
|
2033
|
+
} else if (ssrRisks.length > 0 && detection) {
|
|
2034
|
+
suggestedNextAction2 = `This ${detection.framework} project appears to require a server runtime (see ssrRisks) and no static output directory was found. Convert it to static output (e.g. Next.js output: 'export', Nuxt generate, Astro static, SvelteKit adapter-static), run the build locally (${buildCommandHint ?? "npm run build"}), then re-run analyze.`;
|
|
2035
|
+
} else if (sourceWithoutBuild || detection && !outputDirExists) {
|
|
2036
|
+
suggestedNextAction2 = `This looks like a source project, not built static output. Run the build locally (${buildCommandHint ?? "npm run build"}) then re-run analyze.`;
|
|
2037
|
+
} else {
|
|
2038
|
+
suggestedNextAction2 = "No deployable static output was found. Create an index.html (or build the project locally so a static output directory exists), then re-run analyze.";
|
|
1880
2039
|
}
|
|
1881
|
-
return
|
|
1882
|
-
|
|
1883
|
-
|
|
1884
|
-
|
|
1885
|
-
|
|
1886
|
-
|
|
1887
|
-
|
|
2040
|
+
return {
|
|
2041
|
+
projectType,
|
|
2042
|
+
...detection ? { framework: detection.framework } : {},
|
|
2043
|
+
...outputDirRel !== void 0 ? { recommendedOutputDir: outputDirRel } : {},
|
|
2044
|
+
outputDirExists: false,
|
|
2045
|
+
...buildCommandHint !== void 0 ? { buildCommandHint } : {},
|
|
2046
|
+
entryHtmlFound: false,
|
|
2047
|
+
langSupported: false,
|
|
2048
|
+
totalBytes: 0,
|
|
2049
|
+
fileCount: 0,
|
|
2050
|
+
issues: [],
|
|
2051
|
+
ssrRisks,
|
|
2052
|
+
spa: { looksLikeSpa: false, autoFallback: false },
|
|
2053
|
+
deployable: false,
|
|
2054
|
+
suggestedNextAction: suggestedNextAction2
|
|
2055
|
+
};
|
|
1888
2056
|
}
|
|
1889
|
-
|
|
1890
|
-
|
|
1891
|
-
|
|
1892
|
-
|
|
1893
|
-
|
|
1894
|
-
|
|
1895
|
-
)
|
|
2057
|
+
const outputAbs = outputDirRel === "." ? root : resolve2(root, outputDirRel);
|
|
2058
|
+
const walked = await walkFiles(outputAbs, { maxFiles: MAX_FILE_COUNT + 1 });
|
|
2059
|
+
const candidates = [];
|
|
2060
|
+
for (const file of walked) {
|
|
2061
|
+
const ext = extensionOf(file.path);
|
|
2062
|
+
let content;
|
|
2063
|
+
if (TEXT_CONTENT_EXTENSIONS.has(ext) && file.size <= CONTENT_READ_MAX_BYTES) {
|
|
2064
|
+
try {
|
|
2065
|
+
content = new Uint8Array(await fs.readFile(join4(outputAbs, file.path)));
|
|
2066
|
+
} catch {
|
|
2067
|
+
content = void 0;
|
|
2068
|
+
}
|
|
2069
|
+
}
|
|
2070
|
+
candidates.push({ path: file.path, size: file.size, ...content ? { content } : {} });
|
|
1896
2071
|
}
|
|
1897
|
-
|
|
1898
|
-
|
|
1899
|
-
|
|
1900
|
-
}
|
|
1901
|
-
|
|
1902
|
-
const
|
|
1903
|
-
|
|
1904
|
-
|
|
1905
|
-
|
|
1906
|
-
|
|
1907
|
-
|
|
1908
|
-
if (
|
|
1909
|
-
|
|
1910
|
-
|
|
1911
|
-
}
|
|
1912
|
-
|
|
1913
|
-
|
|
1914
|
-
mkdirSync2(dir, { recursive: true, mode: 448 });
|
|
1915
|
-
const path = projectMarkerPath(projectDir);
|
|
1916
|
-
const temporary = `${path}.${process.pid}.${randomUUID2()}.tmp`;
|
|
1917
|
-
try {
|
|
1918
|
-
writeFileSync2(temporary, `${JSON.stringify(marker, null, 2)}
|
|
1919
|
-
`, {
|
|
1920
|
-
encoding: "utf8",
|
|
1921
|
-
mode: 384
|
|
1922
|
-
});
|
|
1923
|
-
renameSync2(temporary, path);
|
|
1924
|
-
try {
|
|
1925
|
-
chmodSync2(path, 384);
|
|
1926
|
-
} catch {
|
|
2072
|
+
const validation = validateDeployableFiles(candidates, {
|
|
2073
|
+
mode: "free",
|
|
2074
|
+
...opts.formsScriptUrl !== void 0 ? { formsScriptUrl: opts.formsScriptUrl } : {}
|
|
2075
|
+
});
|
|
2076
|
+
ssrRisks.push(...serverAndDbDepRisks(pkg, true));
|
|
2077
|
+
const deployable = validation.ok && walked.length > 0;
|
|
2078
|
+
const spa = {
|
|
2079
|
+
looksLikeSpa: validation.looksLikeSpa,
|
|
2080
|
+
autoFallback: validation.looksLikeSpa
|
|
2081
|
+
};
|
|
2082
|
+
let suggestedNextAction;
|
|
2083
|
+
if (!deployable) {
|
|
2084
|
+
const firstError = validation.issues.find((i) => i.severity === "error");
|
|
2085
|
+
if (firstError?.code === "missing_index_html") {
|
|
2086
|
+
suggestedNextAction = `No index.html at the root of "${outputDirRel}". Deploy the built static output (the directory whose root contains index.html), not the source project. Build locally first if needed (${buildCommandHint ?? "npm run build"}), then re-run analyze.`;
|
|
2087
|
+
} else {
|
|
2088
|
+
suggestedNextAction = "Fix the listed issues (remove forbidden/secret files, reduce size, add missing entry HTML), then re-run analyze.";
|
|
1927
2089
|
}
|
|
1928
|
-
}
|
|
1929
|
-
|
|
2090
|
+
} else if (spa.looksLikeSpa) {
|
|
2091
|
+
suggestedNextAction = `Run deploy to publish the static output in "${outputDirRel}". It looks like a single-page app, so SPA fallback (unknown paths rewrite to index.html) will be enabled automatically; pass spaFallback: false to opt out.`;
|
|
2092
|
+
} else {
|
|
2093
|
+
suggestedNextAction = `Run deploy to publish the static output in "${outputDirRel}".`;
|
|
1930
2094
|
}
|
|
2095
|
+
return {
|
|
2096
|
+
projectType,
|
|
2097
|
+
...detection ? { framework: detection.framework } : {},
|
|
2098
|
+
recommendedOutputDir: outputDirRel,
|
|
2099
|
+
outputDirExists: true,
|
|
2100
|
+
...buildCommandHint !== void 0 ? { buildCommandHint } : {},
|
|
2101
|
+
entryHtmlFound: validation.entryHtmlPath !== void 0,
|
|
2102
|
+
...validation.htmlLang !== void 0 ? { htmlLang: validation.htmlLang } : {},
|
|
2103
|
+
langSupported: validation.supportedLang !== void 0,
|
|
2104
|
+
totalBytes: validation.totalBytes,
|
|
2105
|
+
fileCount: validation.fileCount,
|
|
2106
|
+
issues: validation.issues,
|
|
2107
|
+
ssrRisks,
|
|
2108
|
+
spa,
|
|
2109
|
+
deployable,
|
|
2110
|
+
suggestedNextAction,
|
|
2111
|
+
...deployable ? { files: walked.map((f) => ({ path: f.path, size: f.size })) } : {}
|
|
2112
|
+
};
|
|
1931
2113
|
}
|
|
1932
2114
|
|
|
2115
|
+
// src/tools/context.ts
|
|
2116
|
+
import {
|
|
2117
|
+
inputRequired
|
|
2118
|
+
} from "@modelcontextprotocol/server";
|
|
2119
|
+
|
|
1933
2120
|
// src/project-binding.ts
|
|
2121
|
+
import { fileURLToPath } from "node:url";
|
|
2122
|
+
import { isAbsolute as isAbsolute2, resolve as resolve3 } from "node:path";
|
|
1934
2123
|
var MCP_ROOTS_TIMEOUT_MS = 5e3;
|
|
1935
2124
|
var McpRootsPending = class extends Error {
|
|
1936
2125
|
constructor() {
|
|
@@ -2466,7 +2655,7 @@ function timestampForAgentInZone(exactTimestamp, clientTimeZone) {
|
|
|
2466
2655
|
}
|
|
2467
2656
|
|
|
2468
2657
|
// src/tools/context.ts
|
|
2469
|
-
import { randomUUID as
|
|
2658
|
+
import { randomUUID as randomUUID4 } from "node:crypto";
|
|
2470
2659
|
var LocalGuidanceError = class extends SakupaError {
|
|
2471
2660
|
constructor(code, message) {
|
|
2472
2661
|
super(code, message);
|
|
@@ -2551,7 +2740,7 @@ function reportAuthorizationStore(ctx) {
|
|
|
2551
2740
|
return store;
|
|
2552
2741
|
}
|
|
2553
2742
|
function issueReportAuthorization(ctx, failedTool) {
|
|
2554
|
-
const token =
|
|
2743
|
+
const token = randomUUID4();
|
|
2555
2744
|
reportAuthorizationStore(ctx).set(token, {
|
|
2556
2745
|
failedTool,
|
|
2557
2746
|
expiresAt: Date.now() + 10 * 60 * 1e3
|
|
@@ -2584,7 +2773,7 @@ function requireSiteFile(ctx) {
|
|
|
2584
2773
|
}
|
|
2585
2774
|
return state.file;
|
|
2586
2775
|
}
|
|
2587
|
-
var UNAUTHORIZED_SUMMARY = "The server rejected the site credential: the one
|
|
2776
|
+
var UNAUTHORIZED_SUMMARY = "The server rejected the site credential: the one referenced by .sakupa/site.json (kept in the user-level credential store) no longer matches the server-side verifier. The site itself is intact on the server \u2014 only the local binding file is the problem. Repair the file (restore a backup or undo the local edit). Do NOT delete the .sakupa directory to work around this: the credential is unrecoverable by design, so abandoning it permanently orphans the existing site.";
|
|
2588
2777
|
function toolError(e) {
|
|
2589
2778
|
if (e instanceof McpRootsPending) {
|
|
2590
2779
|
return inputRequired({ inputRequests: { roots: inputRequired.listRoots() } });
|
|
@@ -2658,15 +2847,15 @@ function toolError(e) {
|
|
|
2658
2847
|
}
|
|
2659
2848
|
|
|
2660
2849
|
// src/tools/definitions.ts
|
|
2661
|
-
import { randomUUID as
|
|
2850
|
+
import { randomUUID as randomUUID7 } from "node:crypto";
|
|
2662
2851
|
import { promises as fs2 } from "node:fs";
|
|
2663
|
-
import { join as
|
|
2852
|
+
import { join as join10, relative as relative3, resolve as resolve5, sep as sep4 } from "node:path";
|
|
2664
2853
|
import { z as z2 } from "zod";
|
|
2665
2854
|
|
|
2666
2855
|
// src/recovery-archive.ts
|
|
2667
|
-
import { existsSync as
|
|
2856
|
+
import { existsSync as existsSync4, realpathSync as realpathSync2 } from "node:fs";
|
|
2668
2857
|
import { mkdtemp, mkdir, readFile, readdir, rename, rm, stat, writeFile } from "node:fs/promises";
|
|
2669
|
-
import { dirname as dirname2, isAbsolute as isAbsolute3, join as
|
|
2858
|
+
import { dirname as dirname2, isAbsolute as isAbsolute3, join as join5, relative as relative2, resolve as resolve4, sep as sep3 } from "node:path";
|
|
2670
2859
|
|
|
2671
2860
|
// ../../node_modules/fflate/esm/index.mjs
|
|
2672
2861
|
import { createRequire } from "module";
|
|
@@ -3166,7 +3355,7 @@ function safeOutputPath(projectDir, outputDir) {
|
|
|
3166
3355
|
throw new SakupaError("invalid_request", "Recovery content cannot be written inside .sakupa");
|
|
3167
3356
|
}
|
|
3168
3357
|
let existingAncestor = target;
|
|
3169
|
-
while (!
|
|
3358
|
+
while (!existsSync4(existingAncestor)) {
|
|
3170
3359
|
const parent = dirname2(existingAncestor);
|
|
3171
3360
|
if (parent === existingAncestor) break;
|
|
3172
3361
|
existingAncestor = parent;
|
|
@@ -3208,7 +3397,7 @@ async function listExistingFiles(root, current = root) {
|
|
|
3208
3397
|
if (entry.isSymbolicLink()) {
|
|
3209
3398
|
throw new SakupaError("state_conflict", `Recovery output contains a symlink: ${entry.name}`);
|
|
3210
3399
|
}
|
|
3211
|
-
const absolute =
|
|
3400
|
+
const absolute = join5(current, entry.name);
|
|
3212
3401
|
if (entry.isDirectory()) files.push(...await listExistingFiles(root, absolute));
|
|
3213
3402
|
else if (entry.isFile()) files.push(relative2(root, absolute).split(sep3).join("/"));
|
|
3214
3403
|
else
|
|
@@ -3226,7 +3415,7 @@ async function existingOutputMatches(outputDir, files) {
|
|
|
3226
3415
|
return false;
|
|
3227
3416
|
}
|
|
3228
3417
|
for (const name of expected) {
|
|
3229
|
-
const actual = await readFile(
|
|
3418
|
+
const actual = await readFile(join5(outputDir, ...name.split("/")));
|
|
3230
3419
|
const wanted = files[name];
|
|
3231
3420
|
if (!wanted || actual.byteLength !== wanted.byteLength || !actual.equals(wanted)) return false;
|
|
3232
3421
|
}
|
|
@@ -3277,13 +3466,13 @@ async function extractRecoveryArchive(input) {
|
|
|
3277
3466
|
`Recovery output already exists with different content: ${outputDir}. Move it aside or choose another outputDir.`
|
|
3278
3467
|
);
|
|
3279
3468
|
}
|
|
3280
|
-
const tempDir = await mkdtemp(
|
|
3469
|
+
const tempDir = await mkdtemp(join5(resolve4(input.projectDir), ".sakupa-restore-"));
|
|
3281
3470
|
try {
|
|
3282
3471
|
let writtenBytes = 0;
|
|
3283
3472
|
const entries = Object.entries(files).sort(([a], [b]) => a.localeCompare(b));
|
|
3284
3473
|
for (const [rawName, data] of entries) {
|
|
3285
3474
|
const name = safeEntryName(rawName);
|
|
3286
|
-
const destination =
|
|
3475
|
+
const destination = join5(tempDir, ...name.split("/"));
|
|
3287
3476
|
await mkdir(dirname2(destination), { recursive: true });
|
|
3288
3477
|
await writeFile(destination, data, { flag: "wx" });
|
|
3289
3478
|
writtenBytes += data.byteLength;
|
|
@@ -3309,19 +3498,19 @@ async function extractRecoveryArchive(input) {
|
|
|
3309
3498
|
}
|
|
3310
3499
|
|
|
3311
3500
|
// src/creation-registry.ts
|
|
3312
|
-
import { existsSync as
|
|
3313
|
-
import { homedir as
|
|
3314
|
-
import { dirname as dirname3, join as
|
|
3501
|
+
import { existsSync as existsSync5, mkdirSync as mkdirSync4, readFileSync as readFileSync4, writeFileSync as writeFileSync4 } from "node:fs";
|
|
3502
|
+
import { homedir as homedir3 } from "node:os";
|
|
3503
|
+
import { dirname as dirname3, join as join6 } from "node:path";
|
|
3315
3504
|
var RECENT_WINDOW_MS = FREE_SITE_TTL_HOURS * 60 * 60 * 1e3;
|
|
3316
3505
|
function creationRegistryPath() {
|
|
3317
|
-
const base = process.env["SAKUPA_STATE_DIR"] ??
|
|
3318
|
-
return
|
|
3506
|
+
const base = process.env["SAKUPA_STATE_DIR"] ?? homedir3();
|
|
3507
|
+
return join6(base, ".sakupa", "created-sites.json");
|
|
3319
3508
|
}
|
|
3320
3509
|
function readAll() {
|
|
3321
3510
|
const path = creationRegistryPath();
|
|
3322
|
-
if (!
|
|
3511
|
+
if (!existsSync5(path)) return [];
|
|
3323
3512
|
try {
|
|
3324
|
-
const parsed = JSON.parse(
|
|
3513
|
+
const parsed = JSON.parse(readFileSync4(path, "utf-8"));
|
|
3325
3514
|
if (!Array.isArray(parsed)) return [];
|
|
3326
3515
|
return parsed.filter(
|
|
3327
3516
|
(e) => typeof e === "object" && e !== null && typeof e.siteId === "string" && typeof e.projectDir === "string" && typeof e.url === "string" && typeof e.createdAt === "string" && (e.apiBaseUrl === void 0 || typeof e.apiBaseUrl === "string")
|
|
@@ -3332,8 +3521,8 @@ function readAll() {
|
|
|
3332
3521
|
}
|
|
3333
3522
|
function writeAll(records) {
|
|
3334
3523
|
const path = creationRegistryPath();
|
|
3335
|
-
|
|
3336
|
-
|
|
3524
|
+
mkdirSync4(dirname3(path), { recursive: true });
|
|
3525
|
+
writeFileSync4(path, `${JSON.stringify(records, null, 2)}
|
|
3337
3526
|
`, "utf-8");
|
|
3338
3527
|
}
|
|
3339
3528
|
function listRecentCreations(nowMs, apiBaseUrl) {
|
|
@@ -3377,28 +3566,28 @@ function noteSiteMode(siteId, mode) {
|
|
|
3377
3566
|
// src/device-file.ts
|
|
3378
3567
|
import {
|
|
3379
3568
|
closeSync,
|
|
3380
|
-
existsSync as
|
|
3381
|
-
mkdirSync as
|
|
3569
|
+
existsSync as existsSync6,
|
|
3570
|
+
mkdirSync as mkdirSync5,
|
|
3382
3571
|
openSync,
|
|
3383
|
-
readFileSync as
|
|
3384
|
-
renameSync as
|
|
3572
|
+
readFileSync as readFileSync5,
|
|
3573
|
+
renameSync as renameSync4,
|
|
3385
3574
|
statSync as statSync2,
|
|
3386
3575
|
unlinkSync as unlinkSync2,
|
|
3387
|
-
writeFileSync as
|
|
3576
|
+
writeFileSync as writeFileSync5
|
|
3388
3577
|
} from "node:fs";
|
|
3389
|
-
import { randomUUID as
|
|
3390
|
-
import { homedir as
|
|
3391
|
-
import { dirname as dirname4, join as
|
|
3578
|
+
import { randomUUID as randomUUID5 } from "node:crypto";
|
|
3579
|
+
import { homedir as homedir4 } from "node:os";
|
|
3580
|
+
import { dirname as dirname4, join as join7 } from "node:path";
|
|
3392
3581
|
var DEVICE_LOCK_STALE_MS = 3e4;
|
|
3393
3582
|
var DEVICE_LOCK_WAIT_MS = 2e4;
|
|
3394
3583
|
function deviceRegistryPath() {
|
|
3395
|
-
const base = process.env["SAKUPA_STATE_DIR"] ??
|
|
3396
|
-
return
|
|
3584
|
+
const base = process.env["SAKUPA_STATE_DIR"] ?? homedir4();
|
|
3585
|
+
return join7(base, ".sakupa", "devices.json");
|
|
3397
3586
|
}
|
|
3398
|
-
var deviceLockPath = () =>
|
|
3587
|
+
var deviceLockPath = () => join7(dirname4(deviceRegistryPath()), "devices.lock");
|
|
3399
3588
|
function lockTokenAt(path) {
|
|
3400
3589
|
try {
|
|
3401
|
-
const parsed = JSON.parse(
|
|
3590
|
+
const parsed = JSON.parse(readFileSync5(path, "utf8"));
|
|
3402
3591
|
return typeof parsed.token === "string" ? parsed.token : null;
|
|
3403
3592
|
} catch {
|
|
3404
3593
|
return null;
|
|
@@ -3420,15 +3609,15 @@ function releaseDeviceLock(lock) {
|
|
|
3420
3609
|
}
|
|
3421
3610
|
async function acquireDeviceLock(apiBaseUrl) {
|
|
3422
3611
|
const path = deviceLockPath();
|
|
3423
|
-
|
|
3612
|
+
mkdirSync5(dirname4(path), { recursive: true, mode: 448 });
|
|
3424
3613
|
const deadline = Date.now() + DEVICE_LOCK_WAIT_MS;
|
|
3425
3614
|
while (true) {
|
|
3426
3615
|
const existing = loadDeviceBinding(apiBaseUrl);
|
|
3427
3616
|
if (existing) return existing;
|
|
3428
3617
|
try {
|
|
3429
|
-
const token =
|
|
3618
|
+
const token = randomUUID5();
|
|
3430
3619
|
const fd2 = openSync(path, "wx", 384);
|
|
3431
|
-
|
|
3620
|
+
writeFileSync5(
|
|
3432
3621
|
fd2,
|
|
3433
3622
|
JSON.stringify({ token, pid: process.pid, createdAt: (/* @__PURE__ */ new Date()).toISOString() })
|
|
3434
3623
|
);
|
|
@@ -3453,11 +3642,11 @@ async function acquireDeviceLock(apiBaseUrl) {
|
|
|
3453
3642
|
}
|
|
3454
3643
|
function readRegistry() {
|
|
3455
3644
|
const path = deviceRegistryPath();
|
|
3456
|
-
if (!
|
|
3645
|
+
if (!existsSync6(path)) {
|
|
3457
3646
|
return { schemaVersion: 1, environments: {}, pendingRegistrations: {} };
|
|
3458
3647
|
}
|
|
3459
3648
|
try {
|
|
3460
|
-
const parsed = JSON.parse(
|
|
3649
|
+
const parsed = JSON.parse(readFileSync5(path, "utf8"));
|
|
3461
3650
|
if (parsed.schemaVersion !== 1 || !parsed.environments || typeof parsed.environments !== "object") {
|
|
3462
3651
|
throw new Error("unsupported device registry schema");
|
|
3463
3652
|
}
|
|
@@ -3471,14 +3660,14 @@ function readRegistry() {
|
|
|
3471
3660
|
}
|
|
3472
3661
|
function writeRegistry(registry) {
|
|
3473
3662
|
const path = deviceRegistryPath();
|
|
3474
|
-
|
|
3663
|
+
mkdirSync5(dirname4(path), { recursive: true, mode: 448 });
|
|
3475
3664
|
const temporary = `${path}.${process.pid}.tmp`;
|
|
3476
|
-
|
|
3665
|
+
writeFileSync5(temporary, `${JSON.stringify(registry, null, 2)}
|
|
3477
3666
|
`, {
|
|
3478
3667
|
encoding: "utf8",
|
|
3479
3668
|
mode: 384
|
|
3480
3669
|
});
|
|
3481
|
-
|
|
3670
|
+
renameSync4(temporary, path);
|
|
3482
3671
|
}
|
|
3483
3672
|
function loadDeviceBinding(apiBaseUrl) {
|
|
3484
3673
|
const binding = readRegistry().environments[apiBaseUrl];
|
|
@@ -3499,8 +3688,8 @@ async function ensureDeviceBinding(client, apiBaseUrl) {
|
|
|
3499
3688
|
let pending = registry.pendingRegistrations[apiBaseUrl];
|
|
3500
3689
|
if (!pending) {
|
|
3501
3690
|
pending = {
|
|
3502
|
-
operationId:
|
|
3503
|
-
deviceId:
|
|
3691
|
+
operationId: randomUUID5(),
|
|
3692
|
+
deviceId: randomUUID5(),
|
|
3504
3693
|
credential: generateCredential(),
|
|
3505
3694
|
createdAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
3506
3695
|
};
|
|
@@ -3532,15 +3721,15 @@ async function ensureDeviceBinding(client, apiBaseUrl) {
|
|
|
3532
3721
|
// src/site-handoff.ts
|
|
3533
3722
|
import {
|
|
3534
3723
|
closeSync as closeSync2,
|
|
3535
|
-
existsSync as
|
|
3536
|
-
mkdirSync as
|
|
3724
|
+
existsSync as existsSync7,
|
|
3725
|
+
mkdirSync as mkdirSync6,
|
|
3537
3726
|
openSync as openSync2,
|
|
3538
3727
|
statSync as statSync3,
|
|
3539
3728
|
unlinkSync as unlinkSync3,
|
|
3540
|
-
writeFileSync as
|
|
3729
|
+
writeFileSync as writeFileSync6
|
|
3541
3730
|
} from "node:fs";
|
|
3542
3731
|
import { createHash } from "node:crypto";
|
|
3543
|
-
import { dirname as dirname5, isAbsolute as isAbsolute4, join as
|
|
3732
|
+
import { dirname as dirname5, isAbsolute as isAbsolute4, join as join8 } from "node:path";
|
|
3544
3733
|
var HANDOFF_LOCK_TTL_MS = 15 * 60 * 1e3;
|
|
3545
3734
|
function normalizeSiteUrl(raw) {
|
|
3546
3735
|
const url = new URL(raw);
|
|
@@ -3596,12 +3785,12 @@ function resolveReusableSite(rawUrl, currentProjectDir, nowMs, apiBaseUrl) {
|
|
|
3596
3785
|
}
|
|
3597
3786
|
function lockPath(siteId) {
|
|
3598
3787
|
const digest = createHash("sha256").update(siteId).digest("hex");
|
|
3599
|
-
return
|
|
3788
|
+
return join8(dirname5(creationRegistryPath()), "handoff-locks", `${digest}.lock`);
|
|
3600
3789
|
}
|
|
3601
3790
|
function acquireSiteHandoffLock(siteId) {
|
|
3602
3791
|
const path = lockPath(siteId);
|
|
3603
|
-
|
|
3604
|
-
if (
|
|
3792
|
+
mkdirSync6(dirname5(path), { recursive: true, mode: 448 });
|
|
3793
|
+
if (existsSync7(path)) {
|
|
3605
3794
|
try {
|
|
3606
3795
|
if (Date.now() - statSync3(path).mtimeMs >= HANDOFF_LOCK_TTL_MS) unlinkSync3(path);
|
|
3607
3796
|
} catch {
|
|
@@ -3615,7 +3804,7 @@ function acquireSiteHandoffLock(siteId) {
|
|
|
3615
3804
|
"Another Sakupa process is already performing a site handoff for this free site. Wait for it to finish and retry deploy."
|
|
3616
3805
|
);
|
|
3617
3806
|
}
|
|
3618
|
-
|
|
3807
|
+
writeFileSync6(fd2, JSON.stringify({ siteId, createdAt: (/* @__PURE__ */ new Date()).toISOString() }));
|
|
3619
3808
|
return () => {
|
|
3620
3809
|
try {
|
|
3621
3810
|
closeSync2(fd2);
|
|
@@ -3796,26 +3985,26 @@ ${diag.layers}
|
|
|
3796
3985
|
|
|
3797
3986
|
// src/credential-rotation.ts
|
|
3798
3987
|
import {
|
|
3799
|
-
chmodSync as
|
|
3800
|
-
existsSync as
|
|
3801
|
-
mkdirSync as
|
|
3802
|
-
readFileSync as
|
|
3803
|
-
renameSync as
|
|
3804
|
-
rmSync as
|
|
3805
|
-
writeFileSync as
|
|
3988
|
+
chmodSync as chmodSync4,
|
|
3989
|
+
existsSync as existsSync8,
|
|
3990
|
+
mkdirSync as mkdirSync7,
|
|
3991
|
+
readFileSync as readFileSync6,
|
|
3992
|
+
renameSync as renameSync5,
|
|
3993
|
+
rmSync as rmSync3,
|
|
3994
|
+
writeFileSync as writeFileSync7
|
|
3806
3995
|
} from "node:fs";
|
|
3807
|
-
import { randomUUID as
|
|
3808
|
-
import { join as
|
|
3996
|
+
import { randomUUID as randomUUID6 } from "node:crypto";
|
|
3997
|
+
import { join as join9 } from "node:path";
|
|
3809
3998
|
var ROTATION_FILE = "rotation.json";
|
|
3810
3999
|
function credentialRotationPath(projectDir) {
|
|
3811
|
-
return
|
|
4000
|
+
return join9(projectDir, ".sakupa", ROTATION_FILE);
|
|
3812
4001
|
}
|
|
3813
4002
|
function loadCredentialRotation(projectDir) {
|
|
3814
4003
|
const path = credentialRotationPath(projectDir);
|
|
3815
|
-
if (!
|
|
4004
|
+
if (!existsSync8(path)) return { kind: "absent" };
|
|
3816
4005
|
let parsed;
|
|
3817
4006
|
try {
|
|
3818
|
-
parsed = JSON.parse(
|
|
4007
|
+
parsed = JSON.parse(readFileSync6(path, "utf8"));
|
|
3819
4008
|
} catch (error) {
|
|
3820
4009
|
return {
|
|
3821
4010
|
kind: "corrupted",
|
|
@@ -3862,28 +4051,28 @@ function writeCredentialRotation(projectDir, file) {
|
|
|
3862
4051
|
"A different credential rotation is already pending. Run rotate to resume it; no state was overwritten."
|
|
3863
4052
|
);
|
|
3864
4053
|
}
|
|
3865
|
-
const directory =
|
|
3866
|
-
|
|
4054
|
+
const directory = join9(projectDir, ".sakupa");
|
|
4055
|
+
mkdirSync7(directory, { recursive: true, mode: 448 });
|
|
3867
4056
|
const target = credentialRotationPath(projectDir);
|
|
3868
|
-
const temporary =
|
|
3869
|
-
|
|
4057
|
+
const temporary = join9(directory, `.rotation-${randomUUID6()}.tmp`);
|
|
4058
|
+
writeFileSync7(temporary, `${JSON.stringify(file, null, 2)}
|
|
3870
4059
|
`, {
|
|
3871
4060
|
encoding: "utf8",
|
|
3872
4061
|
mode: 384
|
|
3873
4062
|
});
|
|
3874
4063
|
try {
|
|
3875
|
-
|
|
4064
|
+
chmodSync4(temporary, 384);
|
|
3876
4065
|
} catch {
|
|
3877
4066
|
}
|
|
3878
4067
|
try {
|
|
3879
|
-
|
|
4068
|
+
renameSync5(temporary, target);
|
|
3880
4069
|
} catch (error) {
|
|
3881
|
-
|
|
4070
|
+
rmSync3(temporary, { force: true });
|
|
3882
4071
|
throw error;
|
|
3883
4072
|
}
|
|
3884
4073
|
}
|
|
3885
4074
|
function deleteCredentialRotation(projectDir) {
|
|
3886
|
-
|
|
4075
|
+
rmSync3(credentialRotationPath(projectDir), { force: true });
|
|
3887
4076
|
}
|
|
3888
4077
|
function promoteRotatedCredential(projectDir, site, rotation, credentialCreatedAt) {
|
|
3889
4078
|
if (rotation.siteId !== site.siteId || rotation.apiBaseUrl !== site.apiBaseUrl) {
|
|
@@ -4318,7 +4507,7 @@ function ensureUploadSizeWithinLimits(manifest, isFirstFreeDeploy) {
|
|
|
4318
4507
|
async function buildHashedManifest(files, outputAbs) {
|
|
4319
4508
|
const manifest = [];
|
|
4320
4509
|
for (const file of files) {
|
|
4321
|
-
const bytes = new Uint8Array(await fs2.readFile(
|
|
4510
|
+
const bytes = new Uint8Array(await fs2.readFile(join10(outputAbs, file.path)));
|
|
4322
4511
|
manifest.push({ path: file.path, size: file.size, contentHash: await sha256Hex(bytes) });
|
|
4323
4512
|
}
|
|
4324
4513
|
return manifest;
|
|
@@ -4337,7 +4526,7 @@ async function uploadAll(ctx, targets, files, outputAbs) {
|
|
|
4337
4526
|
`No local file matches upload target "${target.path}"; aborting upload.`
|
|
4338
4527
|
);
|
|
4339
4528
|
}
|
|
4340
|
-
const bytes = new Uint8Array(await fs2.readFile(
|
|
4529
|
+
const bytes = new Uint8Array(await fs2.readFile(join10(outputAbs, match.path)));
|
|
4341
4530
|
if (bytes.byteLength !== match.size) {
|
|
4342
4531
|
throw new SakupaError(
|
|
4343
4532
|
"validation_failed",
|
|
@@ -4482,14 +4671,14 @@ function outputDirectoryChain(projectRoot, outputAbs) {
|
|
|
4482
4671
|
const chain = [];
|
|
4483
4672
|
let cursor = projectRoot;
|
|
4484
4673
|
for (const part of rel.split(sep4).filter(Boolean)) {
|
|
4485
|
-
cursor =
|
|
4674
|
+
cursor = join10(cursor, part);
|
|
4486
4675
|
chain.push(cursor);
|
|
4487
4676
|
}
|
|
4488
4677
|
return chain;
|
|
4489
4678
|
}
|
|
4490
4679
|
async function sakupaDirectoryEntries(projectDir) {
|
|
4491
4680
|
try {
|
|
4492
|
-
return await fs2.readdir(
|
|
4681
|
+
return await fs2.readdir(join10(projectDir, ".sakupa"));
|
|
4493
4682
|
} catch (error) {
|
|
4494
4683
|
const code = error.code;
|
|
4495
4684
|
if (code === "ENOENT") return [];
|
|
@@ -4642,7 +4831,7 @@ function registerTools(server, baseCtx) {
|
|
|
4642
4831
|
const entries = await sakupaDirectoryEntries(candidateDir);
|
|
4643
4832
|
if (entries.length === 0) continue;
|
|
4644
4833
|
const unknownEntries = entries.filter(
|
|
4645
|
-
(entry) => !["project.json", "site.json", "recovery.json"].includes(entry)
|
|
4834
|
+
(entry) => !["project.json", "site.json", "recovery.json", ".gitignore"].includes(entry)
|
|
4646
4835
|
);
|
|
4647
4836
|
if (unknownEntries.length > 0) {
|
|
4648
4837
|
return text(
|
|
@@ -4670,8 +4859,8 @@ function registerTools(server, baseCtx) {
|
|
|
4670
4859
|
summary: `A nested Sakupa project marker exists at ${candidateDir}/.sakupa, but the active MCP Root is ${ctx.projectDir}. Nothing was moved or deployed. Show both paths to the user; after confirmation retry deploy with sakupaRelocationConfirmed:true. Sakupa will preserve credentials and refuse conflicts.`,
|
|
4671
4860
|
data: {
|
|
4672
4861
|
projectRoot: ctx.projectDir,
|
|
4673
|
-
misplacedSakupaDirectory:
|
|
4674
|
-
targetSakupaDirectory:
|
|
4862
|
+
misplacedSakupaDirectory: join10(candidateDir, ".sakupa"),
|
|
4863
|
+
targetSakupaDirectory: join10(ctx.projectDir, ".sakupa"),
|
|
4675
4864
|
confirmationField: "sakupaRelocationConfirmed",
|
|
4676
4865
|
confirmation,
|
|
4677
4866
|
confirmArguments
|
|
@@ -5336,7 +5525,7 @@ ${JSON.stringify(finalized.warnings, null, 2)}
|
|
|
5336
5525
|
{
|
|
5337
5526
|
siteId: site.siteId,
|
|
5338
5527
|
plan: args.plan,
|
|
5339
|
-
idempotencyKey:
|
|
5528
|
+
idempotencyKey: randomUUID7()
|
|
5340
5529
|
},
|
|
5341
5530
|
site.credential
|
|
5342
5531
|
);
|
|
@@ -5659,11 +5848,13 @@ Full status:`,
|
|
|
5659
5848
|
"recover",
|
|
5660
5849
|
{
|
|
5661
5850
|
title: "Recover site",
|
|
5662
|
-
description:
|
|
5851
|
+
description: 'Recover management control of a site after losing the local .sakupa binding. Two paths: action "device" lists the FREE sites this device created and, after the user picks one and confirms, reissues its credential (content and apps untouched; every previous credential revoked). Actions start/status/complete/download recover a subscribed site WITH A BOUND CUSTOM DOMAIN by proving DNS control of the apex domain; a subscribed site without a bound domain 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.',
|
|
5663
5852
|
outputSchema: STRUCTURED_TOOL_OUTPUT_SCHEMA,
|
|
5664
5853
|
annotations: { readOnlyHint: false, destructiveHint: true, openWorldHint: true },
|
|
5665
5854
|
inputSchema: z2.object({
|
|
5666
|
-
action: z2.enum(["start", "status", "complete", "download"]),
|
|
5855
|
+
action: z2.enum(["device", "start", "status", "complete", "download"]),
|
|
5856
|
+
siteId: z2.string().optional().describe("device only: the site chosen from the decision (copied verbatim)."),
|
|
5857
|
+
confirmed: z2.boolean().optional().describe("device only: true only from the exact decision arguments."),
|
|
5667
5858
|
hostname: z2.string().optional().describe("Required for start."),
|
|
5668
5859
|
verificationId: z2.string().optional().describe("For status or complete; inferred from local recovery state when omitted."),
|
|
5669
5860
|
outputDir: z2.string().optional().describe(
|
|
@@ -5712,6 +5903,139 @@ Full status:`,
|
|
|
5712
5903
|
deleteRecoveryFile(ctx.projectDir);
|
|
5713
5904
|
return { archive, extracted };
|
|
5714
5905
|
};
|
|
5906
|
+
if (args.action === "device") {
|
|
5907
|
+
if (localSite.kind === "ok" && await localCredentialIsActive()) {
|
|
5908
|
+
return structuredToolResult({
|
|
5909
|
+
schemaVersion: 1,
|
|
5910
|
+
outcome: "blocked",
|
|
5911
|
+
resultCode: "recovery_credential_already_present",
|
|
5912
|
+
summary: summaryMarkdown({
|
|
5913
|
+
title: "This project already holds a working credential",
|
|
5914
|
+
lead: `${localSite.file.url ?? localSite.file.siteId} is bound here and its credential works; nothing was changed. Use status or deploy. To manage a different free site, open its own project directory.`,
|
|
5915
|
+
next: ["`status`"]
|
|
5916
|
+
}),
|
|
5917
|
+
data: { siteId: localSite.file.siteId, credentialStoredLocally: true },
|
|
5918
|
+
nextActions: [{ tool: "status", arguments: {}, allowed: true }]
|
|
5919
|
+
});
|
|
5920
|
+
}
|
|
5921
|
+
const device = await ensureDeviceBinding(ctx.client, ctx.apiBaseUrl);
|
|
5922
|
+
if (args.confirmed === true && args.siteId) {
|
|
5923
|
+
const res2 = await ctx.client.reissueDeviceFreeSiteCredential(
|
|
5924
|
+
args.siteId,
|
|
5925
|
+
device.deviceId,
|
|
5926
|
+
device.credential
|
|
5927
|
+
);
|
|
5928
|
+
const nowIso = (/* @__PURE__ */ new Date()).toISOString();
|
|
5929
|
+
writeSiteFile(
|
|
5930
|
+
ctx.projectDir,
|
|
5931
|
+
{
|
|
5932
|
+
siteId: res2.siteId,
|
|
5933
|
+
shortId: res2.shortId,
|
|
5934
|
+
url: res2.url,
|
|
5935
|
+
credential: res2.credential,
|
|
5936
|
+
createdAt: nowIso,
|
|
5937
|
+
apiBaseUrl: ctx.apiBaseUrl
|
|
5938
|
+
},
|
|
5939
|
+
{ allowReplace: true }
|
|
5940
|
+
);
|
|
5941
|
+
recordCreation({
|
|
5942
|
+
siteId: res2.siteId,
|
|
5943
|
+
projectDir: ctx.projectDir,
|
|
5944
|
+
url: res2.url,
|
|
5945
|
+
createdAt: nowIso,
|
|
5946
|
+
apiBaseUrl: ctx.apiBaseUrl
|
|
5947
|
+
});
|
|
5948
|
+
return structuredToolResult({
|
|
5949
|
+
schemaVersion: 1,
|
|
5950
|
+
outcome: "completed",
|
|
5951
|
+
resultCode: "free_site_credential_recovered",
|
|
5952
|
+
summary: summaryMarkdown({
|
|
5953
|
+
title: `Credential reissued for ${res2.url}`,
|
|
5954
|
+
lead: "A new management credential was issued and saved for this project; the site content, settings and installed apps are untouched.",
|
|
5955
|
+
facts: [
|
|
5956
|
+
["Site ID", res2.siteId],
|
|
5957
|
+
["Public URL", res2.url],
|
|
5958
|
+
["Free expiry", timestampForAgent(res2.expiresAt)],
|
|
5959
|
+
["Previous credentials revoked", res2.revokedPreviousCredentials],
|
|
5960
|
+
[
|
|
5961
|
+
"Credential path",
|
|
5962
|
+
".sakupa/site.json (reference) + the user-level credential store"
|
|
5963
|
+
]
|
|
5964
|
+
],
|
|
5965
|
+
notes: [
|
|
5966
|
+
"Any other project directory that still referred to this site no longer has management authority.",
|
|
5967
|
+
"No DNS verification was needed: ownership was proven by this device."
|
|
5968
|
+
],
|
|
5969
|
+
next: ["`status`", "`deploy` to publish changes"]
|
|
5970
|
+
}),
|
|
5971
|
+
data: {
|
|
5972
|
+
siteId: res2.siteId,
|
|
5973
|
+
shortId: res2.shortId,
|
|
5974
|
+
url: res2.url,
|
|
5975
|
+
expiresAt: res2.expiresAt,
|
|
5976
|
+
revokedPreviousCredentials: res2.revokedPreviousCredentials,
|
|
5977
|
+
credentialPath: ".sakupa/site.json",
|
|
5978
|
+
credentialStoredLocally: true,
|
|
5979
|
+
dnsVerificationRepeated: false,
|
|
5980
|
+
projectDir: ctx.projectDir
|
|
5981
|
+
},
|
|
5982
|
+
nextActions: [{ tool: "status", arguments: {}, allowed: true }]
|
|
5983
|
+
});
|
|
5984
|
+
}
|
|
5985
|
+
const sites = await discoverDeviceFreeSites(ctx.client, ctx.apiBaseUrl, device);
|
|
5986
|
+
if (sites.length === 0) {
|
|
5987
|
+
return structuredToolResult({
|
|
5988
|
+
schemaVersion: 1,
|
|
5989
|
+
outcome: "completed",
|
|
5990
|
+
resultCode: "device_free_sites_none",
|
|
5991
|
+
summary: summaryMarkdown({
|
|
5992
|
+
title: "No recoverable free site on this device",
|
|
5993
|
+
lead: "This device created no free site that is still active, so there is nothing to reissue. A free site published from another device cannot be recovered here; a subscribed site with a custom domain can be recovered with recover start; otherwise publish again with deploy.",
|
|
5994
|
+
next: ["`deploy`", '`recover` with action "start" (custom-domain site)']
|
|
5995
|
+
}),
|
|
5996
|
+
data: { deviceSites: [], environment: environmentFor(ctx.apiBaseUrl) },
|
|
5997
|
+
nextActions: [
|
|
5998
|
+
{
|
|
5999
|
+
tool: "deploy",
|
|
6000
|
+
arguments: {},
|
|
6001
|
+
allowed: false,
|
|
6002
|
+
reasonCode: "requires_outputDir_from_agent"
|
|
6003
|
+
}
|
|
6004
|
+
]
|
|
6005
|
+
});
|
|
6006
|
+
}
|
|
6007
|
+
return presentDecision(baseCtx.decisions, call, "recover", {
|
|
6008
|
+
resultCode: "device_free_site_selection_required",
|
|
6009
|
+
summary: summaryMarkdown({
|
|
6010
|
+
title: "Choose the free site whose credential should be reissued",
|
|
6011
|
+
lead: `${sites.length} active free site(s) were created on this device. Nothing was changed. Reissuing revokes every previous credential of the chosen site; its content and apps stay as they are.`,
|
|
6012
|
+
facts: sites.map((site) => [
|
|
6013
|
+
site.url,
|
|
6014
|
+
`expires ${timestampForAgent(site.expiresAt)}`
|
|
6015
|
+
])
|
|
6016
|
+
}),
|
|
6017
|
+
data: { deviceSites: sites, environment: environmentFor(ctx.apiBaseUrl) },
|
|
6018
|
+
prompt: "Which free site should get a new management credential for this project?",
|
|
6019
|
+
options: [
|
|
6020
|
+
...sites.map(
|
|
6021
|
+
(site) => callToolDecisionOption({
|
|
6022
|
+
id: `recover_free_site_${site.shortId}`,
|
|
6023
|
+
label: `Reissue the credential of ${site.url}`,
|
|
6024
|
+
description: `Bind this project to ${site.url} with a fresh credential.`,
|
|
6025
|
+
consequences: [
|
|
6026
|
+
"Every previous credential of this site is revoked; other project directories bound to it stop working.",
|
|
6027
|
+
"Content, settings and installed apps are untouched."
|
|
6028
|
+
],
|
|
6029
|
+
tool: "recover",
|
|
6030
|
+
arguments: { action: "device", siteId: site.siteId, confirmed: true },
|
|
6031
|
+
reasonCode: "user_selected_device_free_site"
|
|
6032
|
+
})
|
|
6033
|
+
),
|
|
6034
|
+
noActionDecisionOption({ description: "Reissue nothing." })
|
|
6035
|
+
],
|
|
6036
|
+
legacyUserAction: { type: "select_site", provider: "sakupa" }
|
|
6037
|
+
});
|
|
6038
|
+
}
|
|
5715
6039
|
if (args.action === "download") {
|
|
5716
6040
|
const { archive, extracted } = await download();
|
|
5717
6041
|
return text(
|
|
@@ -6271,7 +6595,7 @@ function registerBillingTools(server, baseCtx) {
|
|
|
6271
6595
|
}
|
|
6272
6596
|
|
|
6273
6597
|
// src/tools/help.ts
|
|
6274
|
-
import { join as
|
|
6598
|
+
import { join as join11 } from "node:path";
|
|
6275
6599
|
import { z as z4 } from "zod";
|
|
6276
6600
|
var TOOL_TOPICS = [
|
|
6277
6601
|
"init",
|
|
@@ -6464,11 +6788,13 @@ var TOOL_MANUALS = {
|
|
|
6464
6788
|
nextStep: "Query billing after the user confirms an operation in Stripe."
|
|
6465
6789
|
},
|
|
6466
6790
|
recover: {
|
|
6467
|
-
purpose:
|
|
6468
|
-
sideEffects: "
|
|
6469
|
-
preconditions: "DNS control of a domain bound to an active paid site.",
|
|
6791
|
+
purpose: 'Recover a lost site binding: reissue the credential of a free site this device created (action "device"), or recover a paid custom-domain site by DNS and download its content.',
|
|
6792
|
+
sideEffects: "device: after an explicit choice, revokes the old credentials of that site and writes the local binding. DNS path: creates verification state and writes local credential/archive files.",
|
|
6793
|
+
preconditions: "device: the site was created on this device and is still an active free site. DNS path: DNS control of a domain bound to an active paid site.",
|
|
6470
6794
|
parameterNames: [
|
|
6471
6795
|
"action",
|
|
6796
|
+
"siteId",
|
|
6797
|
+
"confirmed",
|
|
6472
6798
|
"hostname",
|
|
6473
6799
|
"verificationId",
|
|
6474
6800
|
"outputDir",
|
|
@@ -6566,7 +6892,7 @@ function registerHelpTools(server, baseCtx) {
|
|
|
6566
6892
|
throw new Error("init postcondition failed: project marker missing");
|
|
6567
6893
|
const site = loadSiteFile(ctx.projectDir);
|
|
6568
6894
|
const recovery = loadRecoveryFile(ctx.projectDir);
|
|
6569
|
-
const sakupaDirectory =
|
|
6895
|
+
const sakupaDirectory = join11(ctx.projectDir, ".sakupa");
|
|
6570
6896
|
return structuredToolResult({
|
|
6571
6897
|
schemaVersion: 1,
|
|
6572
6898
|
outcome: "completed",
|
|
@@ -7392,7 +7718,7 @@ function registerAppsTools(server, baseCtx) {
|
|
|
7392
7718
|
}
|
|
7393
7719
|
|
|
7394
7720
|
// src/tools/delete.ts
|
|
7395
|
-
import { randomUUID as
|
|
7721
|
+
import { randomUUID as randomUUID8 } from "node:crypto";
|
|
7396
7722
|
import { z as z7 } from "zod";
|
|
7397
7723
|
var confirmationSchema = z7.object({
|
|
7398
7724
|
siteId: z7.string(),
|
|
@@ -7454,7 +7780,7 @@ function registerDeleteTools(server, baseCtx) {
|
|
|
7454
7780
|
resultCode: result.alreadyDeleted ? "site_already_deleted" : "site_deleted",
|
|
7455
7781
|
summary: summaryMarkdown({
|
|
7456
7782
|
title: result.alreadyDeleted ? "Site was already deleted" : "Site deleted",
|
|
7457
|
-
lead: `${site.url ?? site.siteId} no longer serves anything (visitors get HTTP
|
|
7783
|
+
lead: `${site.url ?? site.siteId} no longer serves anything (visitors get a not-found page, HTTP 404). The local credential file .sakupa/site.json was removed; this project is no longer bound to any site.`,
|
|
7458
7784
|
facts: [
|
|
7459
7785
|
["Site ID", result.siteId],
|
|
7460
7786
|
["Public URL released", site.url],
|
|
@@ -7479,7 +7805,7 @@ function registerDeleteTools(server, baseCtx) {
|
|
|
7479
7805
|
nextActions: []
|
|
7480
7806
|
});
|
|
7481
7807
|
}
|
|
7482
|
-
const operationId =
|
|
7808
|
+
const operationId = randomUUID8();
|
|
7483
7809
|
const preview = await ctx.client.previewDeleteSite(site.siteId, site.credential, {
|
|
7484
7810
|
operationId
|
|
7485
7811
|
});
|
|
@@ -7563,7 +7889,7 @@ function registerDeleteTools(server, baseCtx) {
|
|
|
7563
7889
|
label: `Delete ${site.url ?? site.siteId}`,
|
|
7564
7890
|
description: "Remove the site, its content, apps and submissions, and release the URL.",
|
|
7565
7891
|
consequences: [
|
|
7566
|
-
"Visitors get HTTP
|
|
7892
|
+
"Visitors get a not-found page (HTTP 404) immediately.",
|
|
7567
7893
|
"The local credential file is removed; this project is unbound."
|
|
7568
7894
|
],
|
|
7569
7895
|
tool: "delete",
|
|
@@ -7590,8 +7916,9 @@ Workflow:
|
|
|
7590
7916
|
(Vite/Vue/React/Svelte/Astro/Next static export/Nuxt generate), run the build LOCALLY first,
|
|
7591
7917
|
then re-run analyze.
|
|
7592
7918
|
2. deploy \u2014 uploads ONLY the built static output. The first deploy creates a free temporary
|
|
7593
|
-
site (public URL ${hostPattern}, valid 30 days, free banner shown) and
|
|
7594
|
-
|
|
7919
|
+
site (public URL ${hostPattern}, valid 30 days, free banner shown) and binds this project
|
|
7920
|
+
in .sakupa/site.json; the credential itself is kept in the user-level Sakupa credential store
|
|
7921
|
+
(home directory, owner-only), never inside the project. Deploying again updates the site and refreshes
|
|
7595
7922
|
its validity; refresh extends validity without uploading; status shows the
|
|
7596
7923
|
current deployment and serving state at any time. Every update checks the credential's
|
|
7597
7924
|
server-side issue time. When it is older than 7 days, deploy succeeds and only SUGGESTS the
|
|
@@ -7613,6 +7940,8 @@ Workflow:
|
|
|
7613
7940
|
30-day site and removes paid data after Stripe sends the signed final-cancellation webhook.
|
|
7614
7941
|
Recovery writes the new local credential before downloading content. If a session stops after
|
|
7615
7942
|
.sakupa/site.json exists, NEVER repeat DNS recovery: resume with recover action "download".
|
|
7943
|
+
A FREE site created on this device can get its credential reissued with recover action
|
|
7944
|
+
"device" (no DNS): present every listed site, let the user choose, then confirm.
|
|
7616
7945
|
5. If any Sakupa operation is difficult or fails, call help FIRST. support handles billing,
|
|
7617
7946
|
payment, refund and other customer-service requests. report is the LAST resort only when
|
|
7618
7947
|
help explicitly recommends a product bug report, and submission still requires user review.
|
|
@@ -7706,7 +8035,7 @@ Safety boundaries:
|
|
|
7706
8035
|
- Never upload source projects, secrets, .env files, private keys, archives, videos or audio.
|
|
7707
8036
|
- Payment card data is entered only on Stripe-hosted pages \u2014 never through the AI tool.
|
|
7708
8037
|
- A subscription never grants domain ownership; only DNS verification does.
|
|
7709
|
-
- Never repeat, echo, or memorize the credential value from
|
|
8038
|
+
- Never repeat, echo, or memorize the credential value from the credential store or any file \u2014 quoting it
|
|
7710
8039
|
into the conversation copies the site's only key outside the protected local file. Read it
|
|
7711
8040
|
only through the tools.
|
|
7712
8041
|
- rotate always previews first. confirmed:true revokes EVERY prior credential, including old
|
|
@@ -7719,8 +8048,10 @@ Safety boundaries:
|
|
|
7719
8048
|
other than Japanese, look up the approximate exchange rate and show an estimated local
|
|
7720
8049
|
price next to the JPY amount, clearly marked as an estimate \u2014 Stripe always settles the
|
|
7721
8050
|
real charge in JPY. Never show a bare Yen sign.
|
|
7722
|
-
- The management credential lives only in
|
|
7723
|
-
|
|
8051
|
+
- The management credential lives only in the user-level Sakupa credential store (referenced by
|
|
8052
|
+
.sakupa/site.json); never share or upload it. A lost credential can be reissued only for a free
|
|
8053
|
+
site from the device that created it (recover action "device") or for a subscribed site through
|
|
8054
|
+
its bound custom domain; otherwise it is unrecoverable by design. portal then opens
|
|
7724
8055
|
Stripe's public no-code portal login, where the customer verifies the checkout email with a
|
|
7725
8056
|
Stripe one-time passcode; it never restores site authority.`;
|
|
7726
8057
|
var DECISION_ROUND_TIMEOUT_MS = 12e4;
|
|
@@ -7825,10 +8156,13 @@ export {
|
|
|
7825
8156
|
TEST_API_BASE_URL,
|
|
7826
8157
|
analyzeProject,
|
|
7827
8158
|
createSakupaMcpServer,
|
|
8159
|
+
credentialStoreDirectory,
|
|
8160
|
+
credentialStorePath,
|
|
7828
8161
|
deleteSiteFile,
|
|
7829
8162
|
environmentFor,
|
|
7830
8163
|
loadMcpRuntimeConfig,
|
|
7831
8164
|
loadSiteFile,
|
|
8165
|
+
readStoredCredential,
|
|
7832
8166
|
registerTools,
|
|
7833
8167
|
requireSiteFile,
|
|
7834
8168
|
siteFilePath,
|