@sakupa/mcp 0.7.41 → 0.7.43

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 +582 -70
  2. package/dist/index.js +587 -75
  3. package/package.json +1 -1
package/dist/bin.js CHANGED
@@ -258,6 +258,7 @@ var SERVICE_DOMAIN = "sakupa.com";
258
258
  var DEFAULT_API_BASE_URL = "https://api.sakupa.com";
259
259
  var FREE_SITE_URL_SUFFIX = `.${SERVICE_DOMAIN}`;
260
260
  var TEST_ACCESS_HEADER = "x-sakupa-test-token";
261
+ var CREDENTIAL_ROTATION_RECOMMEND_AFTER_SECONDS = 7 * 24 * 60 * 60;
261
262
  var FREE_SITE_TTL_HOURS = 24;
262
263
  var FREE_SITE_MAX_TOTAL_BYTES = 10 * 1024 * 1024;
263
264
  var FREE_ACTIVE_SITES_PER_IP = 3;
@@ -379,7 +380,7 @@ var FORBIDDEN_PATH_SEGMENTS = [
379
380
  var ALLOWED_HIDDEN_PATHS = [".well-known/"];
380
381
 
381
382
  // ../core/dist/domain/version.js
382
- var SAKUPA_MCP_VERSION = "0.7.41";
383
+ var SAKUPA_MCP_VERSION = "0.7.43";
383
384
 
384
385
  // ../core/dist/domain/errors.js
385
386
  var HTTP_STATUS = {
@@ -835,6 +836,20 @@ var HttpApiClient = class {
835
836
  credential
836
837
  });
837
838
  }
839
+ async getCredentialStatus(siteId, credential) {
840
+ return this.call(
841
+ "GET",
842
+ `/v1/sites/${encodeURIComponent(siteId)}/credential`,
843
+ { credential }
844
+ );
845
+ }
846
+ async rotateCredential(siteId, credential, req) {
847
+ return this.call(
848
+ "POST",
849
+ `/v1/sites/${encodeURIComponent(siteId)}/credential/rotate`,
850
+ { credential, body: req }
851
+ );
852
+ }
838
853
  async getSiteArchive(siteId, credential) {
839
854
  return this.call(
840
855
  "GET",
@@ -938,9 +953,9 @@ var HttpApiClient = class {
938
953
  };
939
954
 
940
955
  // src/tools/definitions.ts
941
- import { randomUUID as randomUUID3 } from "node:crypto";
956
+ import { randomUUID as randomUUID5 } from "node:crypto";
942
957
  import { promises as fs2 } from "node:fs";
943
- import { join as join7, relative as relative3, resolve as resolve5, sep as sep4 } from "node:path";
958
+ import { join as join8, relative as relative3, resolve as resolve5, sep as sep4 } from "node:path";
944
959
  import { z as z2 } from "zod";
945
960
 
946
961
  // src/analyze/analyzer.ts
@@ -1378,10 +1393,12 @@ import {
1378
1393
  existsSync as existsSync2,
1379
1394
  mkdirSync as mkdirSync2,
1380
1395
  readFileSync as readFileSync2,
1396
+ renameSync as renameSync2,
1381
1397
  rmdirSync as rmdirSync2,
1382
1398
  rmSync,
1383
1399
  writeFileSync as writeFileSync2
1384
1400
  } from "node:fs";
1401
+ import { randomUUID as randomUUID2 } from "node:crypto";
1385
1402
  import { dirname, join as join3 } from "node:path";
1386
1403
  var SITE_DIR = ".sakupa";
1387
1404
  var SITE_FILE = "site.json";
@@ -1492,12 +1509,22 @@ function writeSiteFile(projectDir, file, opts = {}) {
1492
1509
  const dir = join3(projectDir, SITE_DIR);
1493
1510
  mkdirSync2(dir, { recursive: true });
1494
1511
  const path = join3(dir, SITE_FILE);
1495
- writeFileSync2(path, `${JSON.stringify(file, null, 2)}
1496
- `, "utf8");
1512
+ const temporary = join3(dir, `.site-${randomUUID2()}.tmp`);
1513
+ writeFileSync2(temporary, `${JSON.stringify(file, null, 2)}
1514
+ `, {
1515
+ encoding: "utf8",
1516
+ mode: 384
1517
+ });
1497
1518
  try {
1498
- chmodSync2(path, 384);
1519
+ chmodSync2(temporary, 384);
1499
1520
  } catch {
1500
1521
  }
1522
+ try {
1523
+ renameSync2(temporary, path);
1524
+ } catch (error) {
1525
+ rmSync(temporary, { force: true });
1526
+ throw error;
1527
+ }
1501
1528
  }
1502
1529
  function deleteSiteFile(projectDir) {
1503
1530
  const path = siteFilePath(projectDir);
@@ -1959,15 +1986,15 @@ function strFromU8(dat, latin1) {
1959
1986
  var slzh = function(d, b) {
1960
1987
  return b + 30 + b2(d, b + 26) + b2(d, b + 28);
1961
1988
  };
1962
- var zh = function(d, b, z5) {
1989
+ var zh = function(d, b, z6) {
1963
1990
  var fnl = b2(d, b + 28), efl = b2(d, b + 30), fn = strFromU8(d.subarray(b + 46, b + 46 + fnl), !(b2(d, b + 8) & 2048)), es = b + 46 + fnl;
1964
- var _a2 = z64hs(d, es, efl, z5, b4(d, b + 20), b4(d, b + 24), b4(d, b + 42)), sc = _a2[0], su = _a2[1], off = _a2[2];
1991
+ var _a2 = z64hs(d, es, efl, z6, b4(d, b + 20), b4(d, b + 24), b4(d, b + 42)), sc = _a2[0], su = _a2[1], off = _a2[2];
1965
1992
  return [b2(d, b + 10), sc, su, fn, es + efl + b2(d, b + 32), off];
1966
1993
  };
1967
- var z64hs = function(d, b, l, z5, sc, su, off) {
1994
+ var z64hs = function(d, b, l, z6, sc, su, off) {
1968
1995
  var nsc = sc == 4294967295, nsu = su == 4294967295, noff = off == 4294967295, e = b + l;
1969
1996
  var nf = nsc + nsu + noff;
1970
- if (z5 && nf) {
1997
+ if (z6 && nf) {
1971
1998
  for (; b + 4 < e; b += 4 + b2(d, b + 2)) {
1972
1999
  if (b2(d, b) == 1) {
1973
2000
  return [
@@ -1978,7 +2005,7 @@ var z64hs = function(d, b, l, z5, sc, su, off) {
1978
2005
  ];
1979
2006
  }
1980
2007
  }
1981
- if (z5 < 2)
2008
+ if (z6 < 2)
1982
2009
  err(13);
1983
2010
  }
1984
2011
  return [sc, su, off, 0];
@@ -1995,18 +2022,18 @@ function unzipSync(data, opts) {
1995
2022
  if (!c)
1996
2023
  return {};
1997
2024
  var o = b4(data, e + 16);
1998
- var z5 = b4(data, e - 20) == 117853008;
1999
- if (z5) {
2025
+ var z6 = b4(data, e - 20) == 117853008;
2026
+ if (z6) {
2000
2027
  var ze = b4(data, e - 12);
2001
- z5 = b4(data, ze) == 101075792;
2002
- if (z5) {
2028
+ z6 = b4(data, ze) == 101075792;
2029
+ if (z6) {
2003
2030
  c = b4(data, ze + 32);
2004
2031
  o = b4(data, ze + 48);
2005
2032
  }
2006
2033
  }
2007
2034
  var fltr = opts && opts.filter;
2008
2035
  for (var i = 0; i < c; ++i) {
2009
- var _a2 = zh(data, o, z5), c_2 = _a2[0], sc = _a2[1], su = _a2[2], fn = _a2[3], no = _a2[4], off = _a2[5], b = slzh(data, off);
2036
+ var _a2 = zh(data, o, z6), c_2 = _a2[0], sc = _a2[1], su = _a2[2], fn = _a2[3], no = _a2[4], off = _a2[5], b = slzh(data, off);
2010
2037
  o = no;
2011
2038
  if (!fltr || fltr({
2012
2039
  name: fn,
@@ -2285,34 +2312,37 @@ function resolveReusableSite(rawUrl, currentProjectDir, nowMs, apiBaseUrl) {
2285
2312
  });
2286
2313
  if (matches2.length === 0) {
2287
2314
  throw new Error(
2288
- `No reusable local free-site slot matches ${siteUrl}. Run deploy again for a current list.`
2315
+ `No existing local free site eligible for handoff matches ${siteUrl}. Run deploy again for a current list.`
2289
2316
  );
2290
2317
  }
2291
- if (matches2.length > 1) throw new Error(`More than one local slot matches ${siteUrl}.`);
2318
+ if (matches2.length > 1)
2319
+ throw new Error(`More than one local free-site record matches ${siteUrl}.`);
2292
2320
  const record = matches2[0];
2293
- if (!record) throw new Error("The reusable slot disappeared during resolution.");
2321
+ if (!record) throw new Error("The selected existing free site disappeared during resolution.");
2294
2322
  if (!isAbsolute3(record.projectDir)) {
2295
- throw new Error("The reusable slot project path is not absolute; refusing cwd lookup.");
2323
+ throw new Error("The selected free-site project path is not absolute; refusing cwd lookup.");
2296
2324
  }
2297
2325
  const sourceProjectDir = canonicalProjectDirectory(record.projectDir);
2298
2326
  if (sourceProjectDir === canonicalProjectDirectory(currentProjectDir)) {
2299
- throw new Error("The selected reusable slot already belongs to the current project.");
2327
+ throw new Error("The selected existing free site already belongs to the current project.");
2300
2328
  }
2301
2329
  const state = loadSiteFile(sourceProjectDir);
2302
2330
  if (state.kind === "absent") {
2303
- throw new Error("The selected slot no longer has its original local management credential.");
2331
+ throw new Error(
2332
+ "The selected existing free site no longer has its original local management credential."
2333
+ );
2304
2334
  }
2305
2335
  if (state.kind === "corrupted") {
2306
- throw new Error(`The selected slot credential is damaged: ${state.problem}`);
2336
+ throw new Error(`The selected existing free-site credential is damaged: ${state.problem}`);
2307
2337
  }
2308
2338
  if (state.file.siteId !== record.siteId) {
2309
- throw new Error("The slot registry and original project refer to different sites.");
2339
+ throw new Error("The local free-site record and original project refer to different sites.");
2310
2340
  }
2311
2341
  if (!state.file.url || normalizeSiteUrl(state.file.url) !== siteUrl) {
2312
- throw new Error("The slot URL does not match the original project binding.");
2342
+ throw new Error("The selected free-site URL does not match the original project binding.");
2313
2343
  }
2314
2344
  if (state.file.apiBaseUrl !== "" && state.file.apiBaseUrl !== apiBaseUrl) {
2315
- throw new Error("The selected slot belongs to another Sakupa environment.");
2345
+ throw new Error("The selected existing free site belongs to another Sakupa environment.");
2316
2346
  }
2317
2347
  return { record, sourceProjectDir, site: state.file, siteUrl };
2318
2348
  }
@@ -2334,7 +2364,7 @@ function acquireSiteHandoffLock(siteId) {
2334
2364
  fd2 = openSync(path, "wx", 384);
2335
2365
  } catch {
2336
2366
  throw new Error(
2337
- "Another Sakupa process is already reassigning this free-site slot. Wait for it to finish and retry deploy."
2367
+ "Another Sakupa process is already performing a site handoff for this free site. Wait for it to finish and retry deploy."
2338
2368
  );
2339
2369
  }
2340
2370
  writeFileSync4(fd2, JSON.stringify({ siteId, createdAt: (/* @__PURE__ */ new Date()).toISOString() }));
@@ -2518,6 +2548,172 @@ ${diag.layers}
2518
2548
  var MCP_VERSION = SAKUPA_MCP_VERSION;
2519
2549
  var CLIENT_TYPE = "sakupa-mcp";
2520
2550
 
2551
+ // src/credential-rotation.ts
2552
+ import {
2553
+ chmodSync as chmodSync3,
2554
+ existsSync as existsSync6,
2555
+ mkdirSync as mkdirSync5,
2556
+ readFileSync as readFileSync4,
2557
+ renameSync as renameSync3,
2558
+ rmSync as rmSync2,
2559
+ writeFileSync as writeFileSync5
2560
+ } from "node:fs";
2561
+ import { randomUUID as randomUUID3 } from "node:crypto";
2562
+ import { join as join7 } from "node:path";
2563
+ var ROTATION_FILE = "rotation.json";
2564
+ function credentialRotationPath(projectDir) {
2565
+ return join7(projectDir, ".sakupa", ROTATION_FILE);
2566
+ }
2567
+ function loadCredentialRotation(projectDir) {
2568
+ const path = credentialRotationPath(projectDir);
2569
+ if (!existsSync6(path)) return { kind: "absent" };
2570
+ let parsed;
2571
+ try {
2572
+ parsed = JSON.parse(readFileSync4(path, "utf8"));
2573
+ } catch (error) {
2574
+ return {
2575
+ kind: "corrupted",
2576
+ problem: `the file cannot be read as JSON (${error instanceof Error ? error.message : String(error)})`
2577
+ };
2578
+ }
2579
+ if (typeof parsed.siteId !== "string" || parsed.siteId.length === 0) {
2580
+ return { kind: "corrupted", problem: "siteId is missing or empty" };
2581
+ }
2582
+ if (typeof parsed.candidateCredential !== "string" || !CREDENTIAL_PATTERN.test(parsed.candidateCredential)) {
2583
+ return { kind: "corrupted", problem: "candidateCredential has an invalid shape" };
2584
+ }
2585
+ if (typeof parsed.createdAt !== "string" || !Number.isFinite(Date.parse(parsed.createdAt))) {
2586
+ return { kind: "corrupted", problem: "createdAt is missing or invalid" };
2587
+ }
2588
+ if (typeof parsed.apiBaseUrl !== "string" || parsed.apiBaseUrl.length === 0) {
2589
+ return { kind: "corrupted", problem: "apiBaseUrl is missing or empty" };
2590
+ }
2591
+ return {
2592
+ kind: "ok",
2593
+ file: {
2594
+ siteId: parsed.siteId,
2595
+ candidateCredential: parsed.candidateCredential,
2596
+ createdAt: parsed.createdAt,
2597
+ apiBaseUrl: parsed.apiBaseUrl
2598
+ }
2599
+ };
2600
+ }
2601
+ function writeCredentialRotation(projectDir, file) {
2602
+ if (!CREDENTIAL_PATTERN.test(file.candidateCredential)) {
2603
+ throw new Error("Refusing to persist an invalid credential rotation candidate.");
2604
+ }
2605
+ const current = loadCredentialRotation(projectDir);
2606
+ if (current.kind === "corrupted") {
2607
+ throw new Error(
2608
+ `Refusing to overwrite damaged credential rotation state: ${current.problem}. Run help.`
2609
+ );
2610
+ }
2611
+ if (current.kind === "ok") {
2612
+ if (current.file.siteId === file.siteId && current.file.candidateCredential === file.candidateCredential && current.file.apiBaseUrl === file.apiBaseUrl) {
2613
+ return;
2614
+ }
2615
+ throw new Error(
2616
+ "A different credential rotation is already pending. Run rotate to resume it; no state was overwritten."
2617
+ );
2618
+ }
2619
+ const directory = join7(projectDir, ".sakupa");
2620
+ mkdirSync5(directory, { recursive: true, mode: 448 });
2621
+ const target = credentialRotationPath(projectDir);
2622
+ const temporary = join7(directory, `.rotation-${randomUUID3()}.tmp`);
2623
+ writeFileSync5(temporary, `${JSON.stringify(file, null, 2)}
2624
+ `, {
2625
+ encoding: "utf8",
2626
+ mode: 384
2627
+ });
2628
+ try {
2629
+ chmodSync3(temporary, 384);
2630
+ } catch {
2631
+ }
2632
+ try {
2633
+ renameSync3(temporary, target);
2634
+ } catch (error) {
2635
+ rmSync2(temporary, { force: true });
2636
+ throw error;
2637
+ }
2638
+ }
2639
+ function deleteCredentialRotation(projectDir) {
2640
+ rmSync2(credentialRotationPath(projectDir), { force: true });
2641
+ }
2642
+ function promoteRotatedCredential(projectDir, site, rotation, credentialCreatedAt) {
2643
+ if (rotation.siteId !== site.siteId || rotation.apiBaseUrl !== site.apiBaseUrl) {
2644
+ throw new Error(
2645
+ "Credential rotation state belongs to a different site or Sakupa environment; no file was changed."
2646
+ );
2647
+ }
2648
+ if (!Number.isFinite(Date.parse(credentialCreatedAt))) {
2649
+ throw new Error(
2650
+ "The server returned an invalid credential creation time; no file was changed."
2651
+ );
2652
+ }
2653
+ const updated = {
2654
+ ...site,
2655
+ credential: rotation.candidateCredential,
2656
+ createdAt: credentialCreatedAt
2657
+ };
2658
+ writeSiteFile(projectDir, updated);
2659
+ deleteCredentialRotation(projectDir);
2660
+ return updated;
2661
+ }
2662
+ async function resumeCredentialRotation(client, projectDir, site, apiBaseUrl) {
2663
+ const state = loadCredentialRotation(projectDir);
2664
+ if (state.kind === "absent") return null;
2665
+ if (state.kind === "corrupted") {
2666
+ throw new Error(
2667
+ `Credential rotation state is damaged (${state.problem}). Nothing was overwritten; run help.`
2668
+ );
2669
+ }
2670
+ const pending = state.file;
2671
+ const siteEnvironment = site.apiBaseUrl || apiBaseUrl;
2672
+ if (pending.siteId !== site.siteId || pending.apiBaseUrl !== apiBaseUrl || siteEnvironment !== apiBaseUrl) {
2673
+ throw new Error(
2674
+ "Credential rotation state belongs to a different site or Sakupa environment. Nothing was changed; run help."
2675
+ );
2676
+ }
2677
+ try {
2678
+ const status = await client.getCredentialStatus(site.siteId, pending.candidateCredential);
2679
+ return {
2680
+ site: promoteRotatedCredential(
2681
+ projectDir,
2682
+ { ...site, apiBaseUrl },
2683
+ pending,
2684
+ status.credentialCreatedAt
2685
+ ),
2686
+ status,
2687
+ rotation: null,
2688
+ resumed: true
2689
+ };
2690
+ } catch (error) {
2691
+ if (!isSakupaError(error) || error.code !== "unauthorized") throw error;
2692
+ }
2693
+ try {
2694
+ await client.getCredentialStatus(site.siteId, site.credential);
2695
+ } catch (error) {
2696
+ if (!isSakupaError(error) || error.code !== "unauthorized") throw error;
2697
+ throw new Error(
2698
+ "Neither the current nor pending credential is accepted. No local file was overwritten; run help before retrying."
2699
+ );
2700
+ }
2701
+ const rotation = await client.rotateCredential(site.siteId, site.credential, {
2702
+ newCredential: pending.candidateCredential
2703
+ });
2704
+ return {
2705
+ site: promoteRotatedCredential(
2706
+ projectDir,
2707
+ { ...site, apiBaseUrl },
2708
+ pending,
2709
+ rotation.credentialCreatedAt
2710
+ ),
2711
+ status: rotation,
2712
+ rotation,
2713
+ resumed: true
2714
+ };
2715
+ }
2716
+
2521
2717
  // src/project-binding.ts
2522
2718
  import { fileURLToPath } from "node:url";
2523
2719
  import { resolve as resolve4 } from "node:path";
@@ -2820,7 +3016,7 @@ function structuredToolResult(envelope) {
2820
3016
  }
2821
3017
 
2822
3018
  // src/tools/context.ts
2823
- import { randomUUID as randomUUID2 } from "node:crypto";
3019
+ import { randomUUID as randomUUID4 } from "node:crypto";
2824
3020
  var LocalGuidanceError = class extends SakupaError {
2825
3021
  constructor(code, message) {
2826
3022
  super(code, message);
@@ -2899,7 +3095,7 @@ function reportAuthorizationStore(ctx) {
2899
3095
  return store;
2900
3096
  }
2901
3097
  function issueReportAuthorization(ctx, failedTool) {
2902
- const token = randomUUID2();
3098
+ const token = randomUUID4();
2903
3099
  reportAuthorizationStore(ctx).set(token, {
2904
3100
  failedTool,
2905
3101
  expiresAt: Date.now() + 10 * 60 * 1e3
@@ -2978,14 +3174,14 @@ function toolError(e) {
2978
3174
  }
2979
3175
 
2980
3176
  // src/tools/definitions.ts
2981
- function text(resultCode, t, data = {}, outcome = "completed") {
3177
+ function text(resultCode, t, data = {}, outcome = "completed", nextActions = []) {
2982
3178
  return structuredToolResult({
2983
3179
  schemaVersion: 1,
2984
3180
  outcome,
2985
3181
  resultCode,
2986
3182
  summary: t,
2987
3183
  data,
2988
- nextActions: []
3184
+ nextActions
2989
3185
  });
2990
3186
  }
2991
3187
  function textJson(resultCode, header, obj, outcome = "completed") {
@@ -3053,7 +3249,7 @@ function ensureUploadSizeWithinLimits(manifest, isFirstFreeDeploy) {
3053
3249
  async function buildHashedManifest(files, outputAbs) {
3054
3250
  const manifest = [];
3055
3251
  for (const file of files) {
3056
- const bytes = new Uint8Array(await fs2.readFile(join7(outputAbs, file.path)));
3252
+ const bytes = new Uint8Array(await fs2.readFile(join8(outputAbs, file.path)));
3057
3253
  manifest.push({ path: file.path, size: file.size, contentHash: await sha256Hex(bytes) });
3058
3254
  }
3059
3255
  return manifest;
@@ -3072,7 +3268,7 @@ async function uploadAll(ctx, targets, files, outputAbs) {
3072
3268
  `No local file matches upload target "${target.path}"; aborting upload.`
3073
3269
  );
3074
3270
  }
3075
- const bytes = new Uint8Array(await fs2.readFile(join7(outputAbs, match.path)));
3271
+ const bytes = new Uint8Array(await fs2.readFile(join8(outputAbs, match.path)));
3076
3272
  if (bytes.byteLength !== match.size) {
3077
3273
  throw new SakupaError(
3078
3274
  "validation_failed",
@@ -3121,11 +3317,11 @@ function freeSiteCreationBarrier(apiBaseUrl, deployArguments) {
3121
3317
  const userSiteOptions = recent.map((record) => ({
3122
3318
  label: `Replace content at ${record.siteUrl}`,
3123
3319
  value: record.siteUrl,
3124
- expectedOutcome: "The current project takes over this reusable free-site slot and replaces its online content."
3320
+ expectedOutcome: "A site handoff keeps this existing free-site URL and cloud credential, replaces its online content, and moves the sole local binding to the current project."
3125
3321
  }));
3126
3322
  const summary = `LOCAL PRECHECK by this MCP client (its own creation registry \u2014 the server was NOT contacted): this machine already created ${recent.length} sites in this environment in the last 24 hours, matching the server's limit of ${FREE_ACTIVE_SITES_PER_IP} active free sites per IP. No new site was created.
3127
3323
 
3128
- ` + recent.map((record) => `- ${record.siteUrl}`).join("\n") + "\n\nAsk the user which existing free URL may be REPLACED by the current project. Selecting one authorizes deploy to overwrite that URL with the current files and transfer its local management binding to this project. The cloud site is NOT deleted. The previous project is unbound and its matching credential file is removed after a successful publish; tell the user not to manage this URL from the previous project. YOU then call deploy with the exact nextAction arguments. Never switch workspaces, never ask the user to run a CLI, and never recommend another hosting provider.";
3324
+ ` + recent.map((record) => `- ${record.siteUrl}`).join("\n") + "\n\nThe free-site allowance is full. Ask the user which existing free URL may have its content REPLACED by the current project. Selecting one authorizes a site handoff: deploy keeps that URL and cloud credential, overwrites its online content with the current files, and moves its sole local management binding to this project. The cloud site is NOT deleted. The previous project is unbound and its matching credential file is removed after a successful publish; tell the user not to manage this URL from the previous project. YOU then call deploy with the exact nextAction arguments. Never switch workspaces, never ask the user to run a CLI, and never recommend another hosting provider.";
3129
3325
  return structuredToolResult({
3130
3326
  schemaVersion: 1,
3131
3327
  outcome: "waiting_user",
@@ -3165,14 +3361,14 @@ function outputDirectoryChain(projectRoot, outputAbs) {
3165
3361
  const chain = [];
3166
3362
  let cursor = projectRoot;
3167
3363
  for (const part of rel.split(sep4).filter(Boolean)) {
3168
- cursor = join7(cursor, part);
3364
+ cursor = join8(cursor, part);
3169
3365
  chain.push(cursor);
3170
3366
  }
3171
3367
  return chain;
3172
3368
  }
3173
3369
  async function sakupaDirectoryEntries(projectDir) {
3174
3370
  try {
3175
- return await fs2.readdir(join7(projectDir, ".sakupa"));
3371
+ return await fs2.readdir(join8(projectDir, ".sakupa"));
3176
3372
  } catch (error) {
3177
3373
  const code = error.code;
3178
3374
  if (code === "ENOENT") return [];
@@ -3231,7 +3427,7 @@ Next action: ${analysis.suggestedNextAction}`,
3231
3427
  "Required only for the first deployment: user explicitly confirmed creation of a public 24-hour URL."
3232
3428
  ),
3233
3429
  reuseSiteUrl: z2.string().url().optional().describe(
3234
- "Exact existing free-site URL selected by the user when all three reusable slots are occupied. Never invent this value; copy it from deploy nextActions."
3430
+ "Exact existing free-site URL selected by the user when the three-site free-site allowance is full. Never invent this value; copy it from deploy nextActions."
3235
3431
  ),
3236
3432
  reuseConfirmed: z2.boolean().optional().describe(
3237
3433
  "True only after the user selected reuseSiteUrl knowing its online content will be replaced and its previous project will be unbound."
@@ -3283,6 +3479,8 @@ Next action: ${analysis.suggestedNextAction}`,
3283
3479
  }
3284
3480
  let existing = siteFileState.kind === "ok" ? siteFileState.file : null;
3285
3481
  let handoff = null;
3482
+ let credentialSecurity = null;
3483
+ let credentialRotationResumed = false;
3286
3484
  const resumedHandoffCleanup = existing ? resumeLocalSiteHandoff(ctx.projectDir, existing, Date.now()) : null;
3287
3485
  const credentialRelocatedFrom = [];
3288
3486
  const markerRelocatedFrom = [];
@@ -3320,8 +3518,8 @@ Next action: ${analysis.suggestedNextAction}`,
3320
3518
  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.`,
3321
3519
  data: {
3322
3520
  projectRoot: ctx.projectDir,
3323
- misplacedSakupaDirectory: join7(candidateDir, ".sakupa"),
3324
- targetSakupaDirectory: join7(ctx.projectDir, ".sakupa"),
3521
+ misplacedSakupaDirectory: join8(candidateDir, ".sakupa"),
3522
+ targetSakupaDirectory: join8(ctx.projectDir, ".sakupa"),
3325
3523
  confirmationField: "sakupaRelocationConfirmed"
3326
3524
  },
3327
3525
  nextActions: [
@@ -3434,7 +3632,7 @@ Next action: ${analysis.suggestedNextAction}`,
3434
3632
  if (existing && args.reuseSiteUrl !== void 0) {
3435
3633
  return text(
3436
3634
  "current_project_already_bound",
3437
- `The current project already manages ${existing.url ?? existing.siteId}. reuseSiteUrl is only valid for an unbound project choosing one of its three existing free slots. Nothing was uploaded or rebound.`,
3635
+ `The current project already manages ${existing.url ?? existing.siteId}. reuseSiteUrl is only valid for an unbound project choosing an existing free site for a site handoff. Nothing was uploaded or rebound.`,
3438
3636
  { currentSiteId: existing.siteId, currentUrl: existing.url },
3439
3637
  "blocked"
3440
3638
  );
@@ -3493,6 +3691,17 @@ Next action: ${analysis.suggestedNextAction}`,
3493
3691
  ctx.apiBaseUrl
3494
3692
  );
3495
3693
  releaseHandoffLock = acquireSiteHandoffLock(handoff.site.siteId);
3694
+ const resumedSourceRotation = await resumeCredentialRotation(
3695
+ ctx.client,
3696
+ handoff.sourceProjectDir,
3697
+ handoff.site,
3698
+ ctx.apiBaseUrl
3699
+ );
3700
+ if (resumedSourceRotation) {
3701
+ handoff = { ...handoff, site: resumedSourceRotation.site };
3702
+ credentialSecurity = resumedSourceRotation.status;
3703
+ credentialRotationResumed = true;
3704
+ }
3496
3705
  const cloud = await ctx.client.getSiteStatus(
3497
3706
  handoff.site.siteId,
3498
3707
  handoff.site.credential
@@ -3501,7 +3710,7 @@ Next action: ${analysis.suggestedNextAction}`,
3501
3710
  noteSiteMode(cloud.siteId, cloud.mode);
3502
3711
  return text(
3503
3712
  "selected_site_no_longer_uses_free_slot",
3504
- `${handoff.siteUrl} is now paid and does not consume a free-site slot. It was not changed or rebound. Call deploy again; Sakupa can now create a new free site.`,
3713
+ `${handoff.siteUrl} is now paid and does not count toward the free-site allowance. It was not changed or rebound. Call deploy again; Sakupa can now create a new free site.`,
3505
3714
  { siteUrl: handoff.siteUrl, mode: cloud.mode },
3506
3715
  "blocked"
3507
3716
  );
@@ -3509,7 +3718,7 @@ Next action: ${analysis.suggestedNextAction}`,
3509
3718
  if (cloud.status !== "active") {
3510
3719
  return text(
3511
3720
  "selected_free_site_not_active",
3512
- `${handoff.siteUrl} is no longer an active reusable free site. Nothing was changed; call deploy again for a current slot list.`,
3721
+ `${handoff.siteUrl} is no longer an active free site eligible for handoff. Nothing was changed; call deploy again for a current existing-site list.`,
3513
3722
  { siteUrl: handoff.siteUrl, status: cloud.status },
3514
3723
  "blocked"
3515
3724
  );
@@ -3529,6 +3738,39 @@ Next action: ${analysis.suggestedNextAction}`,
3529
3738
  );
3530
3739
  }
3531
3740
  }
3741
+ if (existing) {
3742
+ try {
3743
+ if (!handoff) {
3744
+ const resumed = await resumeCredentialRotation(
3745
+ ctx.client,
3746
+ ctx.projectDir,
3747
+ existing,
3748
+ ctx.apiBaseUrl
3749
+ );
3750
+ if (resumed) {
3751
+ existing = resumed.site;
3752
+ credentialSecurity = resumed.status;
3753
+ credentialRotationResumed = true;
3754
+ }
3755
+ }
3756
+ credentialSecurity ??= await ctx.client.getCredentialStatus(
3757
+ existing.siteId,
3758
+ existing.credential
3759
+ );
3760
+ } catch (error) {
3761
+ if (isSakupaError(error) && error.code === "unauthorized") {
3762
+ return text(
3763
+ "credential_mismatch",
3764
+ `The server rejected the credential in .sakupa/site.json for site ${existing.siteId}.
3765
+
3766
+ ` + siteFileRecoveryGuidance(ctx.projectDir) + "\n\nNothing was deployed and no new site was created.",
3767
+ { siteId: existing.siteId },
3768
+ "blocked"
3769
+ );
3770
+ }
3771
+ throw error;
3772
+ }
3773
+ }
3532
3774
  ensureUploadSizeWithinLimits(manifest, !existing);
3533
3775
  if (!existing) {
3534
3776
  const created = await ctx.client.createSite({
@@ -3657,12 +3899,14 @@ Project directory: ${ctx.projectDir}
3657
3899
  Files uploaded: ${uploaded} (${finalized.totalBytes} bytes)
3658
3900
  ` + (finalized.expiresAt ? `Validity refreshed \u2014 expires at: ${finalized.expiresAt}
3659
3901
  ` : "") + (credentialRelocatedFrom.length > 0 ? `Credential binding relocated from ${credentialRelocatedFrom.join(", ")} to ${ctx.projectDir}/.sakupa; the existing site was preserved.
3660
- ` : "") + (handoff ? `Reusable free-site slot transferred to the current project. The cloud site was NOT deleted; its content was replaced. Previous project: ${handoff.sourceProjectDir}. ` + (handoffCleanup?.sourceCredentialRemoved ? "Its matching .sakupa/site.json credential was removed. Do not use that previous project to manage this URL.\n" : handoffCleanup?.sourceRemovalState === "absent" ? "Its .sakupa/site.json credential was already absent. Do not use that previous project to manage this URL.\n" : `Its credential could not be safely removed because the file was ${handoffCleanup?.sourceRemovalState}. Do not use the previous project to manage this URL; run help before touching its .sakupa directory.
3661
- `) : "") + (finalized.mode === "free" ? `
3902
+ ` : "") + (handoff ? `Site handoff completed. The existing free-site URL and cloud credential stayed the same, the cloud site was NOT deleted, and its content was replaced. Previous project: ${handoff.sourceProjectDir}. ` + (handoffCleanup?.sourceCredentialRemoved ? "Its matching .sakupa/site.json credential was removed. Do not use that previous project to manage this URL.\n" : handoffCleanup?.sourceRemovalState === "absent" ? "Its .sakupa/site.json credential was already absent. Do not use that previous project to manage this URL.\n" : `Its credential could not be safely removed because the file was ${handoffCleanup?.sourceRemovalState}. Do not use the previous project to manage this URL; run help before touching its .sakupa directory.
3903
+ `) : "") + (credentialRotationResumed ? "A previously confirmed credential rotation was resumed safely before this deploy; every older credential is revoked.\n" : "") + (finalized.mode === "free" ? `
3662
3904
  Reminder: free sites stay live for ${FREE_SITE_TTL_HOURS} hours after the last deploy or refresh call. Subscribing (subscribe) makes the site permanent.
3663
3905
  ` : "\nThis site is subscribed and permanent \u2014 no expiry.\n") + (finalized.warnings.length > 0 ? `
3664
3906
  Warnings:
3665
- ${JSON.stringify(finalized.warnings, null, 2)}` : ""),
3907
+ ${JSON.stringify(finalized.warnings, null, 2)}` : "") + (credentialSecurity?.rotationRecommended ? `
3908
+
3909
+ Optional security recommendation: this management credential was created at ${credentialSecurity.credentialCreatedAt} and is older than 7 days. The deploy SUCCEEDED and rotation is not required. Ask the user whether they want to rotate; call rotate without confirmed:true to show the exact revocation preview. Never rotate automatically.` : ""),
3666
3910
  {
3667
3911
  siteId: existing.siteId,
3668
3912
  url: finalized.url,
@@ -3673,6 +3917,13 @@ ${JSON.stringify(finalized.warnings, null, 2)}` : ""),
3673
3917
  filesUploaded: uploaded,
3674
3918
  totalBytes: finalized.totalBytes,
3675
3919
  warnings: finalized.warnings,
3920
+ credentialSecurity: credentialSecurity ? {
3921
+ credentialCreatedAt: credentialSecurity.credentialCreatedAt,
3922
+ ageSeconds: credentialSecurity.ageSeconds,
3923
+ rotationRecommended: credentialSecurity.rotationRecommended,
3924
+ optional: true,
3925
+ resumedAfterInterruption: credentialRotationResumed
3926
+ } : null,
3676
3927
  ...credentialRelocatedFrom.length > 0 ? { credentialRelocatedFrom } : {},
3677
3928
  ...handoff ? {
3678
3929
  handoff: {
@@ -3685,7 +3936,15 @@ ${JSON.stringify(finalized.warnings, null, 2)}` : ""),
3685
3936
  sourceRemovalState: handoffCleanup?.sourceRemovalState
3686
3937
  }
3687
3938
  } : {}
3688
- }
3939
+ },
3940
+ "completed",
3941
+ credentialSecurity?.rotationRecommended ? [
3942
+ {
3943
+ tool: "rotate",
3944
+ allowed: true,
3945
+ reasonCode: "credential_older_than_seven_days_optional_rotation"
3946
+ }
3947
+ ] : []
3689
3948
  );
3690
3949
  } catch (e) {
3691
3950
  return toolError(e);
@@ -3778,7 +4037,7 @@ NO content was uploaded or changed by this call \u2014 to publish new or edited
3778
4037
  {
3779
4038
  siteId: site.siteId,
3780
4039
  plan: args.plan,
3781
- idempotencyKey: randomUUID3()
4040
+ idempotencyKey: randomUUID5()
3782
4041
  },
3783
4042
  site.credential
3784
4043
  );
@@ -4503,7 +4762,7 @@ function registerBillingTools(server, baseCtx) {
4503
4762
  }
4504
4763
 
4505
4764
  // src/tools/help.ts
4506
- import { join as join8 } from "node:path";
4765
+ import { join as join9 } from "node:path";
4507
4766
  import { z as z4 } from "zod";
4508
4767
  var TOOL_TOPICS = [
4509
4768
  "init",
@@ -4511,6 +4770,7 @@ var TOOL_TOPICS = [
4511
4770
  "deploy",
4512
4771
  "refresh",
4513
4772
  "status",
4773
+ "rotate",
4514
4774
  "plans",
4515
4775
  "subscribe",
4516
4776
  "bind",
@@ -4522,12 +4782,43 @@ var TOOL_TOPICS = [
4522
4782
  "report",
4523
4783
  "help"
4524
4784
  ];
4525
- var HELP_TOPICS = ["diagnose", "overview", ...TOOL_TOPICS];
4785
+ var HELP_TOPICS = ["diagnose", "overview", "terminology", ...TOOL_TOPICS];
4786
+ var HELP_TERMINOLOGY = {
4787
+ freeSiteAllowance: {
4788
+ preferredTerm: "free-site allowance",
4789
+ meaning: "Up to three concurrently active free sites per IP; this is not a deploy-count limit."
4790
+ },
4791
+ siteHandoff: {
4792
+ preferredTerm: "site handoff",
4793
+ meaning: "An existing free site keeps its URL and cloud credential while current project content replaces its online content and the sole local project binding moves here.",
4794
+ credentialValueChanges: false,
4795
+ previousCredentialsRevoked: false
4796
+ },
4797
+ credentialRotation: {
4798
+ preferredTerm: "credential rotation",
4799
+ meaning: "A newly generated management credential becomes authoritative and every previous credential for the site is revoked.",
4800
+ credentialValueChanges: true,
4801
+ previousCredentialsRevoked: true
4802
+ },
4803
+ credentialRelocation: {
4804
+ preferredTerm: "credential-file relocation",
4805
+ meaning: "The same local credential file moves to the authoritative project Root; cloud authority and the credential value do not change.",
4806
+ credentialValueChanges: false,
4807
+ previousCredentialsRevoked: false
4808
+ },
4809
+ siteRecovery: {
4810
+ preferredTerm: "site recovery",
4811
+ meaning: "DNS control restores management of a paid custom-domain site, issues a fresh credential and downloads content; previous credentials are revoked by default unless explicitly preserved.",
4812
+ credentialValueChanges: true,
4813
+ previousCredentialsRevoked: "by_default"
4814
+ }
4815
+ };
4526
4816
  var TOOL_MANUALS = {
4527
4817
  init: {
4528
4818
  purpose: "Initialize the active IDE workspace as one Sakupa project.",
4529
4819
  sideEffects: "Creates only .sakupa/project.json locally; no API call, site or charge.",
4530
4820
  preconditions: "Exactly one usable MCP workspace Root. Clients without Roots use CLI init.",
4821
+ parameterNames: [],
4531
4822
  parameters: "No parameters and no path argument.",
4532
4823
  warnings: [
4533
4824
  "Never initialize the IDE installation directory.",
@@ -4539,6 +4830,7 @@ var TOOL_MANUALS = {
4539
4830
  purpose: "Inspect a project or explicit output directory for safe static deployment.",
4540
4831
  sideEffects: "Read-only local file inspection; no API call.",
4541
4832
  preconditions: "An initialized, unambiguous project binding.",
4833
+ parameterNames: ["outputDir"],
4542
4834
  parameters: "Optional outputDir relative to the bound project Root.",
4543
4835
  warnings: ["Build locally first.", "Never publish source, secrets, server code or media."],
4544
4836
  nextStep: "Fix reported blockers, then call deploy with the exact outputDir."
@@ -4547,19 +4839,32 @@ var TOOL_MANUALS = {
4547
4839
  purpose: "Create or update the bound Sakupa static site.",
4548
4840
  sideEffects: "Reads local output, uploads files and may create a public free site.",
4549
4841
  preconditions: "Initialized project, exact outputDir and first-publication confirmation.",
4842
+ parameterNames: [
4843
+ "outputDir",
4844
+ "outputDirChangeConfirmed",
4845
+ "sakupaRelocationConfirmed",
4846
+ "spaFallback",
4847
+ "publicConfirmed",
4848
+ "reuseSiteUrl",
4849
+ "reuseConfirmed",
4850
+ "subprojectConfirmed",
4851
+ "lang"
4852
+ ],
4550
4853
  parameters: "outputDir is required and relative to the project Root.",
4551
4854
  warnings: [
4552
4855
  ".sakupa must remain at the project Root and is never uploaded.",
4553
4856
  "A changed outputDir requires explicit confirmation.",
4554
- "Three free sites are reusable slots. When full, let the user select a returned URL; call deploy with its exact nextAction to replace content and transfer the local binding.",
4857
+ "Each IP has a three-site free-site allowance. At the count limit, let the user select one returned existing free-site URL; call deploy with its exact nextAction to perform a site handoff.",
4555
4858
  "After handoff, tell the user the previous project is unbound and must not manage that URL."
4556
4859
  ],
4557
- nextStep: "Call status to verify the cloud result."
4860
+ nextStep: "Call status to verify the cloud result.",
4861
+ terminology: ["freeSiteAllowance", "siteHandoff", "credentialRelocation"]
4558
4862
  },
4559
4863
  refresh: {
4560
4864
  purpose: "Extend a free site lifetime without uploading content.",
4561
4865
  sideEffects: "Updates the site expiry in Sakupa.",
4562
4866
  preconditions: "A valid local site credential.",
4867
+ parameterNames: [],
4563
4868
  parameters: "No parameters.",
4564
4869
  warnings: ["Subscribed sites are permanent and do not need refresh."],
4565
4870
  nextStep: "Call status to verify the new expiry."
@@ -4568,14 +4873,26 @@ var TOOL_MANUALS = {
4568
4873
  purpose: "Read the bound site, deployment, domain and serving state.",
4569
4874
  sideEffects: "Read-only API request.",
4570
4875
  preconditions: "A valid local site credential.",
4876
+ parameterNames: [],
4571
4877
  parameters: "No parameters.",
4572
4878
  warnings: ["Billing truth comes from billing, not inferred status text."],
4573
4879
  nextStep: "Follow only the returned real tool names."
4574
4880
  },
4881
+ rotate: {
4882
+ purpose: "Rotate the current site management credential after explicit confirmation.",
4883
+ sideEffects: "Confirmed rotation revokes every previous credential for this site.",
4884
+ preconditions: "A valid local site credential; preview is required before confirmation.",
4885
+ parameterNames: ["confirmed"],
4886
+ parameters: "confirmed=true only from the exact preview resume arguments.",
4887
+ warnings: ["Rotation is optional and never blocks deploy.", "Never expose credential values."],
4888
+ nextStep: "Use the preview resumeWith arguments only after the user confirms.",
4889
+ terminology: ["credentialRotation"]
4890
+ },
4575
4891
  plans: {
4576
4892
  purpose: "Read the authoritative hosting plan catalog and rules.",
4577
4893
  sideEffects: "Read-only public API request.",
4578
4894
  preconditions: "None; project initialization is not required.",
4895
+ parameterNames: [],
4579
4896
  parameters: "No parameters.",
4580
4897
  warnings: ["JPY prices and cloud plan order are authoritative."],
4581
4898
  nextStep: "Use subscribe for first payment or change for an existing subscription."
@@ -4584,6 +4901,7 @@ var TOOL_MANUALS = {
4584
4901
  purpose: "Create Stripe Checkout for the first subscription.",
4585
4902
  sideEffects: "Creates a short-lived Stripe Checkout session; payment happens only on Stripe.",
4586
4903
  preconditions: "A free bound site with a valid credential.",
4904
+ parameterNames: ["plan"],
4587
4905
  parameters: "The selected plan from plans.",
4588
4906
  warnings: ["Creating a link does not subscribe or charge the user."],
4589
4907
  nextStep: "Show the complete URL, then query billing after Stripe confirmation."
@@ -4592,6 +4910,7 @@ var TOOL_MANUALS = {
4592
4910
  purpose: "Start, check or inspect custom-domain binding.",
4593
4911
  sideEffects: "May create DNS verification and hostname provisioning state.",
4594
4912
  preconditions: "A subscribed site and DNS control.",
4913
+ parameterNames: ["action", "hostname", "verificationId"],
4595
4914
  parameters: "Action plus hostname or verificationId as returned by the prior step.",
4596
4915
  warnings: ["www is mandatory; the apex is optional.", "Copy DNS values verbatim."],
4597
4916
  nextStep: "Follow the returned DNS checklist and call bind status/check."
@@ -4600,6 +4919,7 @@ var TOOL_MANUALS = {
4600
4919
  purpose: "Read the single authoritative subscription and usage snapshot.",
4601
4920
  sideEffects: "Read-only API reconciliation.",
4602
4921
  preconditions: "A valid bound site.",
4922
+ parameterNames: [],
4603
4923
  parameters: "No parameters.",
4604
4924
  warnings: ["Never infer renewal state from user wording or an old link."],
4605
4925
  nextStep: "Use change or portal only when the user wants billing management."
@@ -4608,6 +4928,7 @@ var TOOL_MANUALS = {
4608
4928
  purpose: "Open Stripe billing/customer management or public recovery login.",
4609
4929
  sideEffects: "Creates or returns a Stripe-hosted management URL.",
4610
4930
  preconditions: "Site scope needs a credential; public recovery does not.",
4931
+ parameterNames: ["scope"],
4611
4932
  parameters: "Use the supported scope.",
4612
4933
  warnings: ["Opening a link does not change subscription state."],
4613
4934
  nextStep: "Query billing after the user confirms an operation in Stripe."
@@ -4616,17 +4937,26 @@ var TOOL_MANUALS = {
4616
4937
  purpose: "Recover a paid custom-domain site credential and download its content.",
4617
4938
  sideEffects: "Creates DNS verification state and writes local credential/archive files.",
4618
4939
  preconditions: "DNS control of a domain bound to an active paid site.",
4940
+ parameterNames: [
4941
+ "action",
4942
+ "hostname",
4943
+ "verificationId",
4944
+ "outputDir",
4945
+ "preserveExistingCredentials"
4946
+ ],
4619
4947
  parameters: "Use the returned action and verificationId; outputDir is relative to Root.",
4620
4948
  warnings: [
4621
4949
  "After site.json exists, resume download and never repeat DNS verification.",
4622
4950
  "Credential is saved before archive creation/download."
4623
4951
  ],
4624
- nextStep: "Call recover download when local credentials already exist."
4952
+ nextStep: "Call recover download when local credentials already exist.",
4953
+ terminology: ["siteRecovery"]
4625
4954
  },
4626
4955
  change: {
4627
4956
  purpose: "Open the unified Stripe subscription-management page.",
4628
4957
  sideEffects: "Creates a short-lived Portal session and audit record only.",
4629
4958
  preconditions: "An active subscription.",
4959
+ parameterNames: ["operationId"],
4630
4960
  parameters: "No plan direction or target is accepted from conversational intent.",
4631
4961
  warnings: ["Only Stripe confirmation changes the subscription."],
4632
4962
  nextStep: "Call billing after the user finishes on Stripe."
@@ -4635,6 +4965,7 @@ var TOOL_MANUALS = {
4635
4965
  purpose: "Create a customer-service ticket for billing, refund, payment or domain assistance.",
4636
4966
  sideEffects: "Submits a support ticket.",
4637
4967
  preconditions: "A bound subscribed site and user-provided issue description.",
4968
+ parameterNames: ["category", "subject", "description", "contactEmail"],
4638
4969
  parameters: "Category, subject, sanitized description and optional contact email.",
4639
4970
  warnings: ["Never include credentials, source, card data or secrets."],
4640
4971
  nextStep: "Wait for support follow-up."
@@ -4643,6 +4974,19 @@ var TOOL_MANUALS = {
4643
4974
  purpose: "Last-resort product bug report after help recommends it.",
4644
4975
  sideEffects: "Preview is local; confirmSubmit sends a sanitized diagnostic report.",
4645
4976
  preconditions: "Call help first and show the exact report preview to the user.",
4977
+ parameterNames: [
4978
+ "toolName",
4979
+ "helpAuthorization",
4980
+ "errorCode",
4981
+ "errorMessage",
4982
+ "requestId",
4983
+ "deploymentId",
4984
+ "severity",
4985
+ "description",
4986
+ "agentContext",
4987
+ "contactEmail",
4988
+ "confirmSubmit"
4989
+ ],
4646
4990
  parameters: "Failed tool, helpAuthorization, sanitized diagnostics and explicit confirmSubmit.",
4647
4991
  warnings: [
4648
4992
  "Never report ordinary setup errors help can solve.",
@@ -4654,7 +4998,8 @@ var TOOL_MANUALS = {
4654
4998
  purpose: "Diagnose the current MCP/project state or explain any Sakupa tool.",
4655
4999
  sideEffects: "Read-only local diagnosis; no API call or file write.",
4656
5000
  preconditions: "None; works even when project binding is broken.",
4657
- parameters: "topic defaults to diagnose; use overview or a tool name for its manual.",
5001
+ parameterNames: ["topic", "failedTool", "errorCode", "resultCode", "requestId"],
5002
+ parameters: "topic defaults to diagnose; use overview, terminology, or a tool name for its manual.",
4658
5003
  warnings: ["Use help before repeating failed calls or suggesting report."],
4659
5004
  nextStep: "Follow the returned diagnosis and nextActions."
4660
5005
  }
@@ -4676,7 +5021,7 @@ function registerHelpTools(server, baseCtx) {
4676
5021
  throw new Error("init postcondition failed: project marker missing");
4677
5022
  const site = loadSiteFile(ctx.projectDir);
4678
5023
  const recovery = loadRecoveryFile(ctx.projectDir);
4679
- const sakupaDirectory = join8(ctx.projectDir, ".sakupa");
5024
+ const sakupaDirectory = join9(ctx.projectDir, ".sakupa");
4680
5025
  return structuredToolResult({
4681
5026
  schemaVersion: 1,
4682
5027
  outcome: "completed",
@@ -4703,7 +5048,7 @@ function registerHelpTools(server, baseCtx) {
4703
5048
  server.registerTool(
4704
5049
  "help",
4705
5050
  {
4706
- description: "FIRST troubleshooting tool for every Sakupa difficulty. With topic diagnose (default), inspect MCP Roots, cwd, binding and local state without requiring a project or calling the API. Use overview or a tool name for complete usage, side effects, parameters and warnings. Only recommend report when help explicitly returns reportRecommended:true.",
5051
+ description: "FIRST troubleshooting tool for every Sakupa difficulty. With topic diagnose (default), inspect MCP Roots, cwd, binding and local state without requiring a project or calling the API. Use overview, terminology, or a tool name for complete usage, side effects, parameters and warnings. Only recommend report when help explicitly returns reportRecommended:true.",
4707
5052
  inputSchema: {
4708
5053
  topic: z4.enum(HELP_TOPICS).optional().default("diagnose"),
4709
5054
  failedTool: z4.string().optional(),
@@ -4718,19 +5063,39 @@ function registerHelpTools(server, baseCtx) {
4718
5063
  try {
4719
5064
  if (args.topic === "overview") {
4720
5065
  const catalog = Object.fromEntries(
4721
- TOOL_TOPICS.map((tool) => [tool, { purpose: TOOL_MANUALS[tool].purpose }])
5066
+ TOOL_TOPICS.map((tool) => [
5067
+ tool,
5068
+ {
5069
+ purpose: TOOL_MANUALS[tool].purpose,
5070
+ parameterNames: TOOL_MANUALS[tool].parameterNames
5071
+ }
5072
+ ])
4722
5073
  );
4723
5074
  return structuredToolResult({
4724
5075
  schemaVersion: 1,
4725
5076
  outcome: "completed",
4726
5077
  resultCode: "help_overview",
4727
- summary: 'Sakupa tool overview returned. On any failure call help with topic:"diagnose" before retrying, support or report.',
4728
- data: { tools: catalog, toolOrder: TOOL_TOPICS },
5078
+ summary: 'Sakupa tool overview and parameter names returned. Site handoff keeps the cloud credential value; credential rotation changes it and revokes every prior value. Use help topic:"terminology" for every site/credential distinction. On any failure call help with topic:"diagnose" before retrying, support or report.',
5079
+ data: { tools: catalog, toolOrder: TOOL_TOPICS, terminology: HELP_TERMINOLOGY },
5080
+ nextActions: []
5081
+ });
5082
+ }
5083
+ if (args.topic === "terminology") {
5084
+ return structuredToolResult({
5085
+ schemaVersion: 1,
5086
+ outcome: "completed",
5087
+ resultCode: "help_terminology",
5088
+ summary: "Sakupa terminology returned. Site handoff keeps the credential value; credential rotation changes it and revokes every prior credential; credential-file relocation only moves the same local file; site recovery uses DNS control to issue a fresh credential. The free-site allowance is a concurrent-site count, not a deploy-count limit.",
5089
+ data: { terminology: HELP_TERMINOLOGY },
4729
5090
  nextActions: []
4730
5091
  });
4731
5092
  }
4732
5093
  if (args.topic !== "diagnose") {
4733
5094
  const manual = TOOL_MANUALS[args.topic];
5095
+ const relatedTerminology = Object.fromEntries(
5096
+ (manual.terminology ?? []).map((key) => [key, HELP_TERMINOLOGY[key]])
5097
+ );
5098
+ const terminologyText = Object.values(relatedTerminology).map((term) => `${term.preferredTerm}: ${term.meaning}`).join(" ");
4734
5099
  return structuredToolResult({
4735
5100
  schemaVersion: 1,
4736
5101
  outcome: "completed",
@@ -4740,8 +5105,9 @@ Side effects: ${manual.sideEffects}
4740
5105
  Preconditions: ${manual.preconditions}
4741
5106
  Parameters: ${manual.parameters}
4742
5107
  Warnings: ${manual.warnings.join(" ")}
4743
- Next: ${manual.nextStep}`,
4744
- data: { tool: args.topic, ...manual },
5108
+ Next: ${manual.nextStep}` + (terminologyText.length > 0 ? `
5109
+ Terminology: ${terminologyText}` : ""),
5110
+ data: { tool: args.topic, ...manual, relatedTerminology },
4745
5111
  nextActions: []
4746
5112
  });
4747
5113
  }
@@ -4750,12 +5116,26 @@ Next: ${manual.nextStep}`,
4750
5116
  const marker = selected ? loadProjectMarker(selected) : { kind: "absent" };
4751
5117
  const site = selected ? loadSiteFile(selected) : { kind: "absent" };
4752
5118
  let recoveryState = "absent";
5119
+ let credentialRotationState = "absent";
5120
+ let credentialRotationDetails;
4753
5121
  if (selected) {
4754
5122
  try {
4755
5123
  recoveryState = loadRecoveryFile(selected) === null ? "absent" : "ok";
4756
5124
  } catch {
4757
5125
  recoveryState = "corrupted";
4758
5126
  }
5127
+ const rotation = loadCredentialRotation(selected);
5128
+ if (rotation.kind === "ok") {
5129
+ credentialRotationState = "pending";
5130
+ credentialRotationDetails = {
5131
+ siteId: rotation.file.siteId,
5132
+ createdAt: rotation.file.createdAt,
5133
+ apiBaseUrl: rotation.file.apiBaseUrl
5134
+ };
5135
+ } else if (rotation.kind === "corrupted") {
5136
+ credentialRotationState = "corrupted";
5137
+ credentialRotationDetails = { problem: rotation.problem };
5138
+ }
4759
5139
  }
4760
5140
  const opaqueFailure = args.errorCode === "internal" || args.resultCode === "error_internal";
4761
5141
  const reportRecommended = opaqueFailure && (diagnosis.diagnosisCode === "project_bound" || diagnosis.diagnosisCode === "roots_request_failed");
@@ -4772,8 +5152,9 @@ Next: ${manual.nextStep}`,
4772
5152
  allowed: true,
4773
5153
  reasonCode: "help_confirmed_last_resort"
4774
5154
  }
4775
- ] : diagnosis.diagnosisCode === "workspace_not_initialized" ? [{ tool: "init", allowed: true, reasonCode: "initialize_active_root" }] : [];
4776
- const summary = `Help diagnosis: ${diagnosis.diagnosisCode}. ${diagnosis.guidance} MCP version: ${MCP_VERSION}. ` + (reportRecommended ? diagnosis.diagnosisCode === "project_bound" ? "Local project binding is healthy but the failure is an unclassified internal error. report is now available as the last resort; preview it before submission." : "The MCP Roots request itself failed with an unclassified internal error. report is now available as the last resort; preview it before submission." : "Do not submit report for this diagnosis; follow the guidance and retry help.");
5155
+ ] : credentialRotationState === "pending" ? [{ tool: "rotate", allowed: true, reasonCode: "resume_confirmed_rotation" }] : diagnosis.diagnosisCode === "workspace_not_initialized" ? [{ tool: "init", allowed: true, reasonCode: "initialize_active_root" }] : [];
5156
+ const rotationGuidance = credentialRotationState === "pending" ? "A previously confirmed credential rotation is pending; call rotate with no arguments to resume it. The candidate credential is intentionally hidden." : credentialRotationState === "corrupted" ? "The local credential rotation journal is damaged. Preserve .sakupa/rotation.json, do not print, edit or delete it, and do not retry deploy or rotate until the file is recovered from a trusted backup or Sakupa support confirms the recovery path." : "";
5157
+ const summary = `Help diagnosis: ${diagnosis.diagnosisCode}. ${diagnosis.guidance} MCP version: ${MCP_VERSION}. ${rotationGuidance} ` + (reportRecommended ? diagnosis.diagnosisCode === "project_bound" ? "Local project binding is healthy but the failure is an unclassified internal error. report is now available as the last resort; preview it before submission." : "The MCP Roots request itself failed with an unclassified internal error. report is now available as the last resort; preview it before submission." : "Do not submit report for this diagnosis; follow the guidance and retry help.");
4777
5158
  return structuredToolResult({
4778
5159
  schemaVersion: 1,
4779
5160
  outcome: diagnosis.diagnosisCode === "project_bound" ? "completed" : "blocked",
@@ -4785,6 +5166,8 @@ Next: ${manual.nextStep}`,
4785
5166
  projectMarkerState: marker.kind,
4786
5167
  siteState: site.kind,
4787
5168
  recoveryState,
5169
+ credentialRotationState,
5170
+ ...credentialRotationDetails !== void 0 ? { credentialRotationDetails } : {},
4788
5171
  reportRecommended,
4789
5172
  ...helpAuthorization !== void 0 ? { helpAuthorization } : {},
4790
5173
  ...args.failedTool !== void 0 ? { failedTool: args.failedTool } : {},
@@ -4800,6 +5183,128 @@ Next: ${manual.nextStep}`,
4800
5183
  );
4801
5184
  }
4802
5185
 
5186
+ // src/tools/credential.ts
5187
+ import { z as z5 } from "zod";
5188
+ function registerCredentialTools(server, baseCtx) {
5189
+ server.registerTool(
5190
+ "rotate",
5191
+ {
5192
+ description: "Optionally rotate this site management credential. The first call is a read-only preview. Only confirmed:true after explicit user approval installs a locally generated new credential and revokes every previous credential. Rotation is never required to deploy.",
5193
+ inputSchema: {
5194
+ confirmed: z5.boolean().optional().describe(
5195
+ "True only after showing the rotate preview and the user explicitly approves revoking every old credential."
5196
+ )
5197
+ },
5198
+ outputSchema: STRUCTURED_TOOL_OUTPUT_SCHEMA,
5199
+ annotations: { readOnlyHint: false, destructiveHint: true, openWorldHint: true }
5200
+ },
5201
+ async (args) => {
5202
+ let releaseLock;
5203
+ try {
5204
+ const ctx = await withProjectDir(baseCtx);
5205
+ let site = requireSiteFile(ctx);
5206
+ const pending = loadCredentialRotation(ctx.projectDir);
5207
+ if (pending.kind !== "absent" || args.confirmed === true) {
5208
+ releaseLock = acquireSiteHandoffLock(site.siteId);
5209
+ }
5210
+ if (pending.kind !== "absent") {
5211
+ const resumed = await resumeCredentialRotation(
5212
+ ctx.client,
5213
+ ctx.projectDir,
5214
+ site,
5215
+ ctx.apiBaseUrl
5216
+ );
5217
+ if (!resumed) throw new Error("Credential rotation resume state disappeared.");
5218
+ site = resumed.site;
5219
+ return structuredToolResult({
5220
+ schemaVersion: 1,
5221
+ outcome: "completed",
5222
+ resultCode: "credential_rotation_resumed",
5223
+ summary: `Credential rotation resumed and completed for ${site.url ?? site.siteId}. The new credential is stored only in this project .sakupa/site.json. Every previous credential is revoked; old project folders and backup copies can no longer manage this site. No credential value is shown.`,
5224
+ data: {
5225
+ siteId: site.siteId,
5226
+ credentialCreatedAt: resumed.status.credentialCreatedAt,
5227
+ rotationRecommended: false,
5228
+ previousCredentialsRevoked: true,
5229
+ resumedAfterInterruption: true,
5230
+ credentialStoredLocally: true
5231
+ },
5232
+ nextActions: [{ tool: "status", allowed: true }]
5233
+ });
5234
+ }
5235
+ const status = await ctx.client.getCredentialStatus(site.siteId, site.credential);
5236
+ const confirmation = { confirmed: true };
5237
+ if (args.confirmed !== true) {
5238
+ return structuredToolResult({
5239
+ schemaVersion: 1,
5240
+ outcome: "waiting_user",
5241
+ resultCode: "credential_rotation_confirmation_required",
5242
+ summary: `Nothing was changed. Rotating the management credential for ${site.url ?? site.siteId} will generate a new credential locally, save it as the current credential in this project .sakupa/site.json, and revoke EVERY previous credential for this site\u2014including copies in old folders and backups. Rotation is optional and deploy remains available. Current credential created at: ${status.credentialCreatedAt}. Exact confirm arguments: ${JSON.stringify(confirmation)}. Ask the user for explicit approval; never expose credential values.`,
5243
+ data: {
5244
+ siteId: site.siteId,
5245
+ credentialCreatedAt: status.credentialCreatedAt,
5246
+ credentialAgeSeconds: status.ageSeconds,
5247
+ rotationRecommended: status.rotationRecommended,
5248
+ confirmation,
5249
+ confirmArguments: confirmation,
5250
+ previousCredentialsWillBeRevoked: true,
5251
+ optional: true
5252
+ },
5253
+ userAction: {
5254
+ type: "confirm_in_mcp",
5255
+ provider: "sakupa",
5256
+ expectedOutcome: "Generate one new local credential and revoke every previous credential for this site.",
5257
+ resumeWith: { tool: "rotate", arguments: confirmation }
5258
+ },
5259
+ nextActions: [
5260
+ {
5261
+ tool: "rotate",
5262
+ arguments: confirmation,
5263
+ allowed: true,
5264
+ reasonCode: "explicit_credential_rotation_confirmation"
5265
+ }
5266
+ ]
5267
+ });
5268
+ }
5269
+ writeCredentialRotation(ctx.projectDir, {
5270
+ siteId: site.siteId,
5271
+ candidateCredential: generateCredential(),
5272
+ createdAt: (/* @__PURE__ */ new Date()).toISOString(),
5273
+ apiBaseUrl: ctx.apiBaseUrl
5274
+ });
5275
+ const completed = await resumeCredentialRotation(
5276
+ ctx.client,
5277
+ ctx.projectDir,
5278
+ site,
5279
+ ctx.apiBaseUrl
5280
+ );
5281
+ if (!completed) throw new Error("Credential rotation did not produce resumable state.");
5282
+ site = completed.site;
5283
+ return structuredToolResult({
5284
+ schemaVersion: 1,
5285
+ outcome: "completed",
5286
+ resultCode: "credential_rotated",
5287
+ summary: `Management credential rotated for ${site.url ?? site.siteId}. The new credential is stored only in this project .sakupa/site.json. Every previous credential is revoked; old project folders and backup copies can no longer manage this site. No credential value is shown.`,
5288
+ data: {
5289
+ siteId: site.siteId,
5290
+ credentialCreatedAt: completed.status.credentialCreatedAt,
5291
+ rotationRecommended: false,
5292
+ previousCredentialsRevoked: true,
5293
+ revokedPreviousCredentials: completed.rotation?.revokedPreviousCredentials ?? null,
5294
+ resumedAfterInterruption: false,
5295
+ credentialStoredLocally: true
5296
+ },
5297
+ nextActions: [{ tool: "status", allowed: true }]
5298
+ });
5299
+ } catch (error) {
5300
+ return toolError(error);
5301
+ } finally {
5302
+ releaseLock?.();
5303
+ }
5304
+ }
5305
+ );
5306
+ }
5307
+
4803
5308
  // src/transport.ts
4804
5309
  var FetchTransport = class {
4805
5310
  baseUrl;
@@ -4909,7 +5414,9 @@ Workflow:
4909
5414
  site (public URL ${hostPattern}, valid 24 hours, free banner shown) and stores the
4910
5415
  management credential in .sakupa/site.json. Deploying again updates the site and refreshes
4911
5416
  its validity; refresh extends validity without uploading; status shows the
4912
- current deployment and serving state at any time.
5417
+ current deployment and serving state at any time. Every update checks the credential's
5418
+ server-side issue time. When it is older than 7 days, deploy succeeds and only SUGGESTS the
5419
+ optional rotate tool; never rotate without the user's explicit confirmation.
4913
5420
  3. To make the site PERMANENT, subscribe it to a monthly hosting plan (plans
4914
5421
  shows the catalog; subscribe -> Stripe-hosted checkout;
4915
5422
  water/personal/share/business). Paying makes the
@@ -4957,12 +5464,13 @@ On any difficulty, call help before retrying or escalating. Only offer report wh
4957
5464
  reportRecommended:true; attach your own factual account via agentContext and show the exact
4958
5465
  sanitized preview before asking the user to confirm submission.
4959
5466
 
4960
- The three free sites created by one IP are REUSABLE SLOTS, not disposable quota. When all three are
4961
- occupied, deploy returns existing free URLs. Ask the user which URL may have its online content
4962
- REPLACED, then call deploy with the exact returned arguments. The cloud site is never deleted:
4963
- deploy transfers its sole local binding to the current project after a successful update and removes
4964
- the matching credential from the previous project. Tell the user not to manage that URL from the
4965
- previous project. NEVER switch workspaces, ask the user to run CLI, or recommend another host.
5467
+ Each IP has a FREE-SITE ALLOWANCE of three concurrently active free sites; this is not a deploy-count
5468
+ limit. When the count limit is reached, deploy returns existing free URLs. Ask the user which URL may
5469
+ have its online content REPLACED, then call deploy with the exact returned arguments to perform a SITE
5470
+ HANDOFF. The URL and cloud credential stay the same; the cloud site is never deleted. After a
5471
+ successful update, deploy moves its sole local binding to the current project and removes the matching
5472
+ credential file from the previous project. Tell the user not to manage that URL from the previous
5473
+ project. NEVER switch workspaces, ask the user to run CLI, or recommend another host.
4966
5474
 
4967
5475
  Present every step as Sakupa's own: never attribute DNS, certificates or hosting to
4968
5476
  underlying infrastructure vendors in front of the user. Relay DNS record values and full
@@ -4977,6 +5485,9 @@ Safety boundaries:
4977
5485
  - Never repeat, echo, or memorize the credential value from .sakupa/site.json \u2014 quoting it
4978
5486
  into the conversation copies the site's only key outside the protected local file. Read it
4979
5487
  only through the tools.
5488
+ - rotate always previews first. confirmed:true revokes EVERY prior credential, including old
5489
+ folders and backups. Show that consequence and obtain explicit user approval; rotation is
5490
+ optional and never a condition for deploy.
4980
5491
  - Before deploying, if the entry HTML lacks a lang attribute, add one matching the content
4981
5492
  language (infer it from the content) and then deploy; only skip when the user explicitly
4982
5493
  wants no lang attribute.
@@ -5020,6 +5531,7 @@ function createSakupaMcpServer(opts) {
5020
5531
  };
5021
5532
  registerTools(server, ctx);
5022
5533
  registerBillingTools(server, ctx);
5534
+ registerCredentialTools(server, ctx);
5023
5535
  registerHelpTools(server, ctx);
5024
5536
  return server;
5025
5537
  }