@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.
Files changed (3) hide show
  1. package/dist/bin.js +475 -142
  2. package/dist/index.js +1140 -806
  3. 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.5.0";
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 { randomUUID } from "node:crypto";
1106
- import { dirname, join } from "node:path";
1107
- var SITE_DIR = ".sakupa";
1108
- var SITE_FILE = "site.json";
1109
- var RECOVERY_FILE = "recovery.json";
1110
- function siteFilePath(projectDir) {
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 recoveryFilePath(projectDir) {
1114
- return join(projectDir, SITE_DIR, RECOVERY_FILE);
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 loadSiteFile(projectDir) {
1117
- const path = siteFilePath(projectDir);
1118
- if (!existsSync(path)) return { kind: "absent" };
1119
- let raw;
1120
- try {
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: `the file exists but could not be read (${err2 instanceof Error ? err2.message : String(err2)})`
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(raw);
1131
- } catch {
1132
- return { kind: "corrupted", problem: "the file exists but is not valid JSON" };
1133
- }
1134
- if (typeof parsed !== "object" || parsed === null) {
1135
- return { kind: "corrupted", problem: "the file does not contain a JSON object" };
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.credential !== "string" || parsed.credential.length === 0) {
1141
- return { kind: "corrupted", problem: "the credential field is missing or empty" };
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: "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"
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
- file: {
1152
- siteId: parsed.siteId,
1153
- credential: parsed.credential,
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 = join(projectDir, SITE_DIR);
1214
- mkdirSync(dir, { recursive: true });
1215
- const path = join(dir, SITE_FILE);
1216
- const temporary = join(dir, `.site-${randomUUID()}.tmp`);
1217
- writeFileSync(temporary, `${JSON.stringify(file, null, 2)}
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 deleteSiteFile(projectDir) {
1234
- const path = siteFilePath(projectDir);
1235
- if (existsSync(path)) {
1236
- rmSync(path, { force: true });
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
- rmdirSync(join(projectDir, SITE_DIR));
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
- function deleteSiteFileIfMatches(projectDir, expected) {
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
- deleteSiteFile(projectDir);
1251
- return "removed";
1252
- }
1253
- function findAncestor(startDir, predicate, maxLevels = Number.POSITIVE_INFINITY) {
1254
- let cursor = startDir;
1255
- for (let i = 0; i < maxLevels; i += 1) {
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
- return null;
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
- async function isFile(path) {
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
- async function readTextIfExists(path, maxBytes = CONTENT_READ_MAX_BYTES) {
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
- async function firstExistingFile(dir, names) {
1317
- for (const name of names) {
1318
- const p = join2(dir, name);
1319
- if (await isFile(p)) return p;
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
- return null;
1322
- }
1323
- function extensionOf(path) {
1324
- const base = path.split("/").pop() ?? "";
1325
- const idx = base.lastIndexOf(".");
1326
- if (idx <= 0) return "";
1327
- return base.slice(idx + 1).toLowerCase();
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
- await recurse(dir, "");
1358
- return out;
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
- async function readPackageJson(projectDir) {
1361
- const raw = await readTextIfExists(join2(projectDir, "package.json"));
1362
- if (raw === null) return null;
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
- const parsed = JSON.parse(raw);
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 allDeps(pkg) {
1371
- return { ...pkg?.dependencies ?? {}, ...pkg?.devDependencies ?? {} };
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
- async function anyFileMatches(dir, predicate) {
1374
- if (!await isDirectory(dir)) return false;
1375
- const files = await walkFiles(dir, { maxFiles: 2e3 });
1376
- return files.some((f) => predicate(f.path.split("/").pop() ?? ""));
1379
+ function deleteProjectMarker(projectDir) {
1380
+ const path = projectMarkerPath(projectDir);
1381
+ if (existsSync2(path)) unlinkSync(path);
1382
+ pruneSakupaDirectory(projectDir);
1377
1383
  }
1378
- async function detectFramework(projectDir, pkg) {
1379
- const deps = allDeps(pkg);
1380
- const ssrRisks = [];
1381
- const nextConfigPath = await firstExistingFile(projectDir, [
1382
- "next.config.js",
1383
- "next.config.mjs",
1384
- "next.config.ts",
1385
- "next.config.cjs"
1386
- ]);
1387
- if ("next" in deps || nextConfigPath !== null) {
1388
- const config = nextConfigPath ? await readTextIfExists(nextConfigPath) : null;
1389
- const staticExport = config !== null && /output\s*:\s*['"]export['"]/.test(config);
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
- if (await firstExistingFile(projectDir, [
1415
- "middleware.ts",
1416
- "middleware.js",
1417
- "src/middleware.ts",
1418
- "src/middleware.js"
1419
- ]) !== null) {
1420
- ssrRisks.push("middleware.(ts|js) runs on a server/edge runtime and will not run on Sakupa.");
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
- framework: "next",
1424
- outputCandidates: ["out"],
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
- const nuxtConfigPath = await firstExistingFile(projectDir, [
1430
- "nuxt.config.ts",
1431
- "nuxt.config.js",
1432
- "nuxt.config.mjs"
1433
- ]);
1434
- if ("nuxt" in deps || "nuxt3" in deps || nuxtConfigPath !== null) {
1435
- for (const serverDir of ["server/api", "server/routes"]) {
1436
- if (await isDirectory(join2(projectDir, serverDir))) {
1437
- ssrRisks.push(
1438
- `Nuxt server handlers (${serverDir}/) require a server runtime and will not run on Sakupa. Use static generation (npx nuxi generate) and deploy .output/public.`
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
- framework: "nuxt",
1444
- outputCandidates: [".output/public", "dist"],
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 astroConfigPath = await firstExistingFile(projectDir, [
1450
- "astro.config.mjs",
1451
- "astro.config.js",
1452
- "astro.config.ts"
1453
- ]);
1454
- if ("astro" in deps || astroConfigPath !== null) {
1455
- const config = astroConfigPath ? await readTextIfExists(astroConfigPath) : null;
1456
- if (config !== null && /output\s*:\s*['"]server['"]/.test(config)) {
1457
- ssrRisks.push(
1458
- "Astro config sets output: 'server' (SSR). Sakupa only serves static files; use the default static output (or output: 'static') and build locally."
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
- framework: "astro",
1463
- outputCandidates: ["dist"],
1464
- buildCommandHint: "npm run build",
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
- if ("@sveltejs/kit" in deps) {
1469
- if ("@sveltejs/adapter-node" in deps) {
1470
- ssrRisks.push(
1471
- "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."
1472
- );
1473
- } else if (!("@sveltejs/adapter-static" in deps)) {
1474
- ssrRisks.push(
1475
- "SvelteKit requires @sveltejs/adapter-static to produce a fully static build. Install and configure it, then build locally."
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 (await anyFileMatches(join2(projectDir, "src/routes"), (base) => base.startsWith("+server."))) {
1479
- ssrRisks.push(
1480
- "SvelteKit +server.* endpoint files require a server runtime and will not run on Sakupa."
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
- if ("react-scripts" in deps) {
1491
- return {
1492
- framework: "create-react-app",
1493
- outputCandidates: ["build"],
1494
- buildCommandHint: "npm run build",
1495
- ssrRisks
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
- const viteConfigPath = await firstExistingFile(projectDir, [
1499
- "vite.config.ts",
1500
- "vite.config.js",
1501
- "vite.config.mjs"
1502
- ]);
1503
- if ("vite" in deps || viteConfigPath !== null) {
1504
- let framework = "vite";
1505
- if ("vue" in deps) framework = "vue (vite)";
1506
- else if ("react" in deps) framework = "react (vite)";
1507
- else if ("svelte" in deps) framework = "svelte (vite)";
1508
- return {
1509
- framework,
1510
- outputCandidates: ["dist"],
1511
- buildCommandHint: "npm run build",
1512
- ssrRisks
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 serverAndDbDepRisks(pkg, hasStaticOutput) {
1518
- const deps = allDeps(pkg);
1519
- const risks = [];
1520
- for (const name of SERVER_RUNTIME_DEPS) {
1521
- if (name in deps) {
1522
- risks.push(
1523
- 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.`
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
- for (const name of DB_RUNTIME_DEPS) {
1528
- if (name in deps) {
1529
- risks.push(
1530
- 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.`
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 scanForUseServer(projectDir, skipRelDirs) {
1537
- const files = await walkFiles(projectDir, { maxFiles: USE_SERVER_SCAN_MAX_FILES, skipRelDirs });
1538
- let scanned = 0;
1539
- for (const file of files) {
1540
- if (scanned >= USE_SERVER_SCAN_MAX_FILES) break;
1541
- if (!SOURCE_SCAN_EXTENSIONS.has(extensionOf(file.path))) continue;
1542
- scanned += 1;
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 normalizeOutputDir(outputDir) {
1549
- const normalized = posix.normalize(outputDir.replaceAll(sep, "/")).replace(/\/+$/, "");
1550
- return normalized === "" ? "." : normalized;
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
- async function analyzeProject(projectDir, opts = {}) {
1553
- const root = await fs.realpath(resolve(projectDir));
1554
- const pkg = await readPackageJson(root);
1555
- const detection = await detectFramework(root, pkg);
1556
- const ssrRisks = [...detection?.ssrRisks ?? []];
1557
- const hasBuildScript = typeof pkg?.scripts?.["build"] === "string";
1558
- const buildRequired = detection !== null || hasBuildScript;
1559
- let outputDirRel;
1560
- let outputDirExists = false;
1561
- if (opts.outputDir !== void 0) {
1562
- outputDirRel = normalizeOutputDir(opts.outputDir);
1563
- const abs = resolve(root, outputDirRel);
1564
- if (abs !== root && !abs.startsWith(root + sep)) {
1565
- outputDirRel = ".";
1566
- outputDirExists = false;
1567
- } else {
1568
- outputDirExists = await isDirectory(abs) || outputDirRel === "." && await isDirectory(root);
1569
- if (outputDirExists) {
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 physical = await fs.realpath(abs);
1572
- if (physical !== root && !physical.startsWith(root + sep)) {
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
- return {
1684
- projectType,
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
- // src/tools/context.ts
1704
- import {
1705
- inputRequired
1706
- } from "@modelcontextprotocol/server";
1707
-
1708
- // src/project-binding.ts
1709
- import { fileURLToPath } from "node:url";
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 loadProjectMarker(projectDir) {
1744
- const path = projectMarkerPath(projectDir);
1745
- if (!existsSync2(path)) return { kind: "absent" };
1746
- let parsed;
1747
- try {
1748
- parsed = JSON.parse(readFileSync2(path, "utf8"));
1749
- } catch (error) {
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
- kind: "corrupted",
1752
- problem: `the file cannot be read as JSON (${error instanceof Error ? error.message : String(error)})`
1835
+ framework: "next",
1836
+ outputCandidates: ["out"],
1837
+ buildCommandHint: "npm run build",
1838
+ ssrRisks
1753
1839
  };
1754
1840
  }
1755
- if (typeof parsed !== "object" || parsed === null) {
1756
- return { kind: "corrupted", problem: "the file does not contain a JSON object" };
1757
- }
1758
- const record = parsed;
1759
- if (record.schemaVersion !== PROJECT_SCHEMA_VERSION) {
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
- kind: "corrupted",
1762
- problem: `unsupported schemaVersion ${String(record.schemaVersion)}`
1855
+ framework: "nuxt",
1856
+ outputCandidates: [".output/public", "dist"],
1857
+ buildCommandHint: "npm run generate (or npx nuxi generate)",
1858
+ ssrRisks
1763
1859
  };
1764
1860
  }
1765
- if (typeof record.projectId !== "string" || !isUuid(record.projectId)) {
1766
- return { kind: "corrupted", problem: "projectId is missing or is not a UUID" };
1767
- }
1768
- if (typeof record.createdAt !== "string" || !Number.isFinite(Date.parse(record.createdAt))) {
1769
- return { kind: "corrupted", problem: "createdAt is missing or invalid" };
1770
- }
1771
- if (record.outputDir !== void 0 && (typeof record.outputDir !== "string" || !isSafeRelativeOutput(record.outputDir))) {
1772
- return { kind: "corrupted", problem: "outputDir is not a safe project-relative path" };
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
- return {
1775
- kind: "ok",
1776
- marker: {
1777
- schemaVersion: PROJECT_SCHEMA_VERSION,
1778
- projectId: record.projectId,
1779
- createdAt: record.createdAt,
1780
- ...record.outputDir !== void 0 ? { outputDir: normalizeRelative(record.outputDir) } : {}
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
- function initializeProject(projectDir) {
1785
- const canonical = canonicalProjectDirectory(projectDir);
1786
- assertSafeProjectRoot(canonical);
1787
- const current = loadProjectMarker(canonical);
1788
- if (current.kind === "corrupted") {
1789
- throw new ProjectRootError(
1790
- "corrupted_marker",
1791
- `Refusing to overwrite damaged Sakupa project marker ${projectMarkerPath(canonical)}: ${current.problem}.`
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 (current.kind === "ok") {
1902
+ if ("react-scripts" in deps) {
1795
1903
  return {
1796
- projectDir: canonical,
1797
- requestedPath: canonical,
1798
- markerKind: "project",
1799
- marker: current.marker
1904
+ framework: "create-react-app",
1905
+ outputCandidates: ["build"],
1906
+ buildCommandHint: "npm run build",
1907
+ ssrRisks
1800
1908
  };
1801
1909
  }
1802
- const marker = {
1803
- schemaVersion: PROJECT_SCHEMA_VERSION,
1804
- projectId: randomUUID2(),
1805
- createdAt: (/* @__PURE__ */ new Date()).toISOString()
1806
- };
1807
- writeMarkerAtomically(canonical, marker);
1808
- return {
1809
- projectDir: canonical,
1810
- requestedPath: canonical,
1811
- markerKind: "project",
1812
- marker
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 resolveLockedProjectRoot(projectDir) {
1816
- const canonical = canonicalProjectDirectory(projectDir);
1817
- assertSafeProjectRoot(canonical);
1818
- const markerState = loadProjectMarker(canonical);
1819
- if (markerState.kind === "corrupted") {
1820
- throw new ProjectRootError(
1821
- "corrupted_marker",
1822
- `Sakupa project marker ${projectMarkerPath(canonical)} is damaged: ${markerState.problem}.`
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
- if (markerState.kind === "absent") {
1826
- throw new ProjectRootError(
1827
- "not_initialized",
1828
- `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.`
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 updateProjectOutputDir(projectDir, outputDir) {
1839
- const canonical = canonicalProjectDirectory(projectDir);
1840
- const state = loadProjectMarker(canonical);
1841
- if (state.kind !== "ok") {
1842
- throw new ProjectRootError(
1843
- state.kind === "corrupted" ? "corrupted_marker" : "not_initialized",
1844
- state.kind === "corrupted" ? `Cannot update damaged Sakupa project marker: ${state.problem}.` : `No Sakupa project marker exists in ${canonical}.`
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
- const marker = {
1854
- ...state.marker,
1855
- outputDir: normalizeRelative(outputDir)
1856
- };
1857
- writeMarkerAtomically(canonical, marker);
1858
- return marker;
1958
+ return false;
1859
1959
  }
1860
- function deleteProjectMarker(projectDir) {
1861
- const path = projectMarkerPath(projectDir);
1862
- if (existsSync2(path)) unlinkSync(path);
1863
- try {
1864
- rmdirSync2(join3(projectDir, SAKUPA_DIR));
1865
- } catch {
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
- function canonicalProjectDirectory(path) {
1869
- const canonical = canonicalExistingPath(resolve2(path));
1870
- if (!statSync(canonical).isDirectory()) {
1871
- throw new ProjectRootError("invalid_path", `Project path ${canonical} is not a directory.`);
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
- return canonical;
1874
- }
1875
- function canonicalExistingPath(path) {
1876
- try {
1877
- const stat2 = lstatSync(path, { throwIfNoEntry: false });
1878
- if (!stat2) {
1879
- throw new ProjectRootError("invalid_path", `Project path ${path} does not exist.`);
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 realpathSync(path);
1882
- } catch (error) {
1883
- if (error instanceof ProjectRootError) throw error;
1884
- throw new ProjectRootError(
1885
- "invalid_path",
1886
- `Project path ${path} cannot be resolved (${error instanceof Error ? error.message : String(error)}).`
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
- function assertSafeProjectRoot(projectDir) {
1891
- if (parse(projectDir).root === projectDir || projectDir === realpathSync(homedir())) {
1892
- throw new ProjectRootError(
1893
- "unsafe_path",
1894
- `Refusing to use ${projectDir} as a Sakupa project root; choose a specific project directory.`
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
- function isUuid(value) {
1899
- 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);
1900
- }
1901
- function normalizeRelative(path) {
1902
- const normalized = path.split(sep2).join("/").replace(/^\.\//, "").replace(/\/$/, "");
1903
- return normalized.length === 0 ? "." : normalized;
1904
- }
1905
- function isSafeRelativeOutput(path) {
1906
- if (path.length === 0 || isAbsolute(path)) return false;
1907
- const normalized = normalizeRelative(path);
1908
- if (normalized === ".") return true;
1909
- const rel = relative("/sakupa-root", resolve2("/sakupa-root", normalized));
1910
- return rel !== ".." && !rel.startsWith(`..${sep2}`) && !isAbsolute(rel);
1911
- }
1912
- function writeMarkerAtomically(projectDir, marker) {
1913
- const dir = join3(projectDir, SAKUPA_DIR);
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
- } finally {
1929
- if (existsSync2(temporary)) unlinkSync(temporary);
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 randomUUID3 } from "node:crypto";
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 = randomUUID3();
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 in .sakupa/site.json 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.";
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 randomUUID6 } from "node:crypto";
2850
+ import { randomUUID as randomUUID7 } from "node:crypto";
2662
2851
  import { promises as fs2 } from "node:fs";
2663
- import { join as join9, relative as relative3, resolve as resolve5, sep as sep4 } from "node:path";
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 existsSync3, realpathSync as realpathSync2 } from "node:fs";
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 join4, relative as relative2, resolve as resolve4, sep as sep3 } from "node:path";
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 (!existsSync3(existingAncestor)) {
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 = join4(current, entry.name);
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(join4(outputDir, ...name.split("/")));
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(join4(resolve4(input.projectDir), ".sakupa-restore-"));
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 = join4(tempDir, ...name.split("/"));
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 existsSync4, mkdirSync as mkdirSync3, readFileSync as readFileSync3, writeFileSync as writeFileSync3 } from "node:fs";
3313
- import { homedir as homedir2 } from "node:os";
3314
- import { dirname as dirname3, join as join5 } from "node:path";
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"] ?? homedir2();
3318
- return join5(base, ".sakupa", "created-sites.json");
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 (!existsSync4(path)) return [];
3511
+ if (!existsSync5(path)) return [];
3323
3512
  try {
3324
- const parsed = JSON.parse(readFileSync3(path, "utf-8"));
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
- mkdirSync3(dirname3(path), { recursive: true });
3336
- writeFileSync3(path, `${JSON.stringify(records, null, 2)}
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 existsSync5,
3381
- mkdirSync as mkdirSync4,
3569
+ existsSync as existsSync6,
3570
+ mkdirSync as mkdirSync5,
3382
3571
  openSync,
3383
- readFileSync as readFileSync4,
3384
- renameSync as renameSync3,
3572
+ readFileSync as readFileSync5,
3573
+ renameSync as renameSync4,
3385
3574
  statSync as statSync2,
3386
3575
  unlinkSync as unlinkSync2,
3387
- writeFileSync as writeFileSync4
3576
+ writeFileSync as writeFileSync5
3388
3577
  } from "node:fs";
3389
- import { randomUUID as randomUUID4 } from "node:crypto";
3390
- import { homedir as homedir3 } from "node:os";
3391
- import { dirname as dirname4, join as join6 } from "node:path";
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"] ?? homedir3();
3396
- return join6(base, ".sakupa", "devices.json");
3584
+ const base = process.env["SAKUPA_STATE_DIR"] ?? homedir4();
3585
+ return join7(base, ".sakupa", "devices.json");
3397
3586
  }
3398
- var deviceLockPath = () => join6(dirname4(deviceRegistryPath()), "devices.lock");
3587
+ var deviceLockPath = () => join7(dirname4(deviceRegistryPath()), "devices.lock");
3399
3588
  function lockTokenAt(path) {
3400
3589
  try {
3401
- const parsed = JSON.parse(readFileSync4(path, "utf8"));
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
- mkdirSync4(dirname4(path), { recursive: true, mode: 448 });
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 = randomUUID4();
3618
+ const token = randomUUID5();
3430
3619
  const fd2 = openSync(path, "wx", 384);
3431
- writeFileSync4(
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 (!existsSync5(path)) {
3645
+ if (!existsSync6(path)) {
3457
3646
  return { schemaVersion: 1, environments: {}, pendingRegistrations: {} };
3458
3647
  }
3459
3648
  try {
3460
- const parsed = JSON.parse(readFileSync4(path, "utf8"));
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
- mkdirSync4(dirname4(path), { recursive: true, mode: 448 });
3663
+ mkdirSync5(dirname4(path), { recursive: true, mode: 448 });
3475
3664
  const temporary = `${path}.${process.pid}.tmp`;
3476
- writeFileSync4(temporary, `${JSON.stringify(registry, null, 2)}
3665
+ writeFileSync5(temporary, `${JSON.stringify(registry, null, 2)}
3477
3666
  `, {
3478
3667
  encoding: "utf8",
3479
3668
  mode: 384
3480
3669
  });
3481
- renameSync3(temporary, path);
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: randomUUID4(),
3503
- deviceId: randomUUID4(),
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 existsSync6,
3536
- mkdirSync as mkdirSync5,
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 writeFileSync5
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 join7 } from "node:path";
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 join7(dirname5(creationRegistryPath()), "handoff-locks", `${digest}.lock`);
3788
+ return join8(dirname5(creationRegistryPath()), "handoff-locks", `${digest}.lock`);
3600
3789
  }
3601
3790
  function acquireSiteHandoffLock(siteId) {
3602
3791
  const path = lockPath(siteId);
3603
- mkdirSync5(dirname5(path), { recursive: true, mode: 448 });
3604
- if (existsSync6(path)) {
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
- writeFileSync5(fd2, JSON.stringify({ siteId, createdAt: (/* @__PURE__ */ new Date()).toISOString() }));
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 chmodSync3,
3800
- existsSync as existsSync7,
3801
- mkdirSync as mkdirSync6,
3802
- readFileSync as readFileSync5,
3803
- renameSync as renameSync4,
3804
- rmSync as rmSync2,
3805
- writeFileSync as writeFileSync6
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 randomUUID5 } from "node:crypto";
3808
- import { join as join8 } from "node:path";
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 join8(projectDir, ".sakupa", ROTATION_FILE);
4000
+ return join9(projectDir, ".sakupa", ROTATION_FILE);
3812
4001
  }
3813
4002
  function loadCredentialRotation(projectDir) {
3814
4003
  const path = credentialRotationPath(projectDir);
3815
- if (!existsSync7(path)) return { kind: "absent" };
4004
+ if (!existsSync8(path)) return { kind: "absent" };
3816
4005
  let parsed;
3817
4006
  try {
3818
- parsed = JSON.parse(readFileSync5(path, "utf8"));
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 = join8(projectDir, ".sakupa");
3866
- mkdirSync6(directory, { recursive: true, mode: 448 });
4054
+ const directory = join9(projectDir, ".sakupa");
4055
+ mkdirSync7(directory, { recursive: true, mode: 448 });
3867
4056
  const target = credentialRotationPath(projectDir);
3868
- const temporary = join8(directory, `.rotation-${randomUUID5()}.tmp`);
3869
- writeFileSync6(temporary, `${JSON.stringify(file, null, 2)}
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
- chmodSync3(temporary, 384);
4064
+ chmodSync4(temporary, 384);
3876
4065
  } catch {
3877
4066
  }
3878
4067
  try {
3879
- renameSync4(temporary, target);
4068
+ renameSync5(temporary, target);
3880
4069
  } catch (error) {
3881
- rmSync2(temporary, { force: true });
4070
+ rmSync3(temporary, { force: true });
3882
4071
  throw error;
3883
4072
  }
3884
4073
  }
3885
4074
  function deleteCredentialRotation(projectDir) {
3886
- rmSync2(credentialRotationPath(projectDir), { force: true });
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(join9(outputAbs, file.path)));
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(join9(outputAbs, match.path)));
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 = join9(cursor, part);
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(join9(projectDir, ".sakupa"));
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: join9(candidateDir, ".sakupa"),
4674
- targetSakupaDirectory: join9(ctx.projectDir, ".sakupa"),
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: randomUUID6()
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: "Recover management control of a subscribed site WITH A BOUND CUSTOM DOMAIN after losing the local project, by proving DNS control of the apex domain. Sites without a bound domain are identified solely by their local credential and cannot be recovered. By default, completing recovery REVOKES all previous local credentials. Recovery is resumable: start stores local pending state; complete installs and writes the new .sakupa/site.json credential BEFORE requesting content; download uses that credential to reissue an archive and safely extract it into the explicitly selected outputDir without repeating DNS.",
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 join10 } from "node:path";
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: "Recover a paid custom-domain site credential and download its content.",
6468
- sideEffects: "Creates DNS verification state and writes local credential/archive files.",
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 = join10(ctx.projectDir, ".sakupa");
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 randomUUID7 } from "node:crypto";
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 410). The local credential file .sakupa/site.json was removed; this project is no longer bound to any site.`,
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 = randomUUID7();
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 410 immediately.",
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 stores the
7594
- management credential in .sakupa/site.json. Deploying again updates the site and refreshes
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 .sakupa/site.json \u2014 quoting it
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 .sakupa/site.json; never share or upload it. Without
7723
- a bound custom domain, a lost credential is unrecoverable by design. portal then opens
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,