@leeguoo/yapi-mcp 0.3.22 → 0.3.24

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/yapi-cli.js CHANGED
@@ -5,6 +5,7 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
5
5
  };
6
6
  Object.defineProperty(exports, "__esModule", { value: true });
7
7
  const crypto_1 = __importDefault(require("crypto"));
8
+ const child_process_1 = require("child_process");
8
9
  const fs_1 = __importDefault(require("fs"));
9
10
  const os_1 = __importDefault(require("os"));
10
11
  const path_1 = __importDefault(require("path"));
@@ -12,12 +13,38 @@ const readline_1 = __importDefault(require("readline"));
12
13
  const markdown_1 = require("./docs/markdown");
13
14
  const install_1 = require("./skill/install");
14
15
  const auth_1 = require("./services/yapi/auth");
16
+ const authCache_1 = require("./services/yapi/authCache");
17
+ class HttpStatusError extends Error {
18
+ status;
19
+ statusText;
20
+ body;
21
+ endpoint;
22
+ constructor(endpoint, status, statusText, body) {
23
+ super(`request failed: ${status} ${statusText} ${body}`);
24
+ this.name = "HttpStatusError";
25
+ this.status = status;
26
+ this.statusText = statusText;
27
+ this.body = body;
28
+ this.endpoint = endpoint;
29
+ }
30
+ }
15
31
  function parseKeyValue(raw) {
16
32
  if (!raw || !raw.includes("="))
17
33
  throw new Error("expected key=value");
18
34
  const idx = raw.indexOf("=");
19
35
  return [raw.slice(0, idx), raw.slice(idx + 1)];
20
36
  }
37
+ function parseQueryArg(raw) {
38
+ const trimmed = String(raw || "").trim().replace(/^\?/, "");
39
+ if (!trimmed)
40
+ throw new Error("expected key=value");
41
+ if (!trimmed.includes("&"))
42
+ return [parseKeyValue(trimmed)];
43
+ const items = Array.from(new URLSearchParams(trimmed).entries()).filter(([key]) => Boolean(key));
44
+ if (items.length)
45
+ return items;
46
+ return [parseKeyValue(trimmed)];
47
+ }
21
48
  function parseHeader(raw) {
22
49
  if (!raw || !raw.includes(":"))
23
50
  throw new Error("expected Header:Value");
@@ -32,6 +59,58 @@ function parseJsonMaybe(text) {
32
59
  return null;
33
60
  }
34
61
  }
62
+ function formatBytes(bytes) {
63
+ if (!Number.isFinite(bytes) || bytes <= 0)
64
+ return "0 B";
65
+ const units = ["B", "KiB", "MiB", "GiB"];
66
+ let value = bytes;
67
+ let index = 0;
68
+ while (value >= 1024 && index < units.length - 1) {
69
+ value /= 1024;
70
+ index += 1;
71
+ }
72
+ return `${value >= 10 || index === 0 ? value.toFixed(0) : value.toFixed(2)} ${units[index]}`;
73
+ }
74
+ function parseByteSize(raw) {
75
+ const text = String(raw || "");
76
+ const bytesMatch = text.match(/(\d+)\s*bytes?/i);
77
+ if (bytesMatch)
78
+ return Number(bytesMatch[1]);
79
+ const unitMatch = text.match(/(\d+(?:\.\d+)?)\s*(kib|kb|mib|mb|gib|gb)\b/i);
80
+ if (!unitMatch)
81
+ return null;
82
+ const value = Number(unitMatch[1]);
83
+ if (!Number.isFinite(value))
84
+ return null;
85
+ const unit = unitMatch[2].toLowerCase();
86
+ const factors = {
87
+ kb: 1000,
88
+ kib: 1024,
89
+ mb: 1000 * 1000,
90
+ mib: 1024 * 1024,
91
+ gb: 1000 * 1000 * 1000,
92
+ gib: 1024 * 1024 * 1024,
93
+ };
94
+ return Math.round(value * (factors[unit] || 1));
95
+ }
96
+ function parsePayloadLimit(text) {
97
+ const match = String(text || "").match(/(?:limit|max(?:imum)?(?:\s+body)?(?:\s+size)?)[^0-9]{0,12}(\d+(?:\.\d+)?\s*(?:bytes?|kib|kb|mib|mb|gib|gb))/i);
98
+ if (match)
99
+ return parseByteSize(match[1]);
100
+ return parseByteSize(text);
101
+ }
102
+ function findGitRoot(startDir) {
103
+ let current = path_1.default.resolve(startDir);
104
+ while (true) {
105
+ const candidate = path_1.default.join(current, ".git");
106
+ if (fs_1.default.existsSync(candidate))
107
+ return current;
108
+ const parent = path_1.default.dirname(current);
109
+ if (parent === current)
110
+ return null;
111
+ current = parent;
112
+ }
113
+ }
35
114
  function getPayloadMessage(payload) {
36
115
  if (!payload || typeof payload !== "object")
37
116
  return "";
@@ -122,6 +201,162 @@ function resolveToken(tokenValue, projectId) {
122
201
  }
123
202
  return tokenValue;
124
203
  }
204
+ function resolveLocalBin(binName) {
205
+ const baseName = process.platform === "win32" ? `${binName}.cmd` : binName;
206
+ const localBin = path_1.default.resolve(__dirname, "..", "node_modules", ".bin", baseName);
207
+ if (fs_1.default.existsSync(localBin))
208
+ return localBin;
209
+ return binName;
210
+ }
211
+ function parseJsonLoose(text) {
212
+ const raw = String(text || "").trim();
213
+ if (!raw)
214
+ return null;
215
+ const direct = parseJsonMaybe(raw);
216
+ if (direct !== null)
217
+ return direct;
218
+ const lines = raw
219
+ .split(/\r?\n/)
220
+ .map((line) => line.trim())
221
+ .filter(Boolean);
222
+ for (let i = lines.length - 1; i >= 0; i -= 1) {
223
+ const parsed = parseJsonMaybe(lines[i]);
224
+ if (parsed !== null)
225
+ return parsed;
226
+ }
227
+ const firstBrace = raw.indexOf("{");
228
+ const firstBracket = raw.indexOf("[");
229
+ const starts = [firstBrace, firstBracket].filter((n) => n >= 0);
230
+ if (!starts.length)
231
+ return null;
232
+ const start = Math.min(...starts);
233
+ const lastBrace = raw.lastIndexOf("}");
234
+ const lastBracket = raw.lastIndexOf("]");
235
+ const end = Math.max(lastBrace, lastBracket);
236
+ if (end <= start)
237
+ return null;
238
+ const sliced = raw.slice(start, end + 1);
239
+ return parseJsonMaybe(sliced);
240
+ }
241
+ function extractCookiesFromPayload(payload) {
242
+ const queue = [payload];
243
+ const visited = new Set();
244
+ while (queue.length) {
245
+ const current = queue.shift();
246
+ if (current === null || current === undefined)
247
+ continue;
248
+ if (visited.has(current))
249
+ continue;
250
+ visited.add(current);
251
+ if (Array.isArray(current)) {
252
+ if (current.every((item) => item && typeof item === "object")) {
253
+ return current;
254
+ }
255
+ continue;
256
+ }
257
+ if (typeof current !== "object")
258
+ continue;
259
+ const obj = current;
260
+ if (Array.isArray(obj.cookies))
261
+ return obj.cookies;
262
+ if (Array.isArray(obj.data))
263
+ return obj.data;
264
+ if (obj.data && typeof obj.data === "object")
265
+ queue.push(obj.data);
266
+ if (obj.result && typeof obj.result === "object")
267
+ queue.push(obj.result);
268
+ if (obj.payload && typeof obj.payload === "object")
269
+ queue.push(obj.payload);
270
+ }
271
+ return [];
272
+ }
273
+ function normalizeCookieExpiresMs(raw) {
274
+ const n = Number(raw);
275
+ if (!Number.isFinite(n) || n <= 0)
276
+ return undefined;
277
+ return n > 1_000_000_000_000 ? n : n * 1000;
278
+ }
279
+ function runAgentBrowser(args, options = {}) {
280
+ const bins = [
281
+ resolveLocalBin("agent-browser-stealth"),
282
+ resolveLocalBin("agent-browser"),
283
+ "agent-browser-stealth",
284
+ "agent-browser",
285
+ ];
286
+ const uniq = Array.from(new Set(bins));
287
+ let lastError = null;
288
+ for (const bin of uniq) {
289
+ try {
290
+ if (options.captureOutput) {
291
+ const output = (0, child_process_1.execFileSync)(bin, args, {
292
+ encoding: "utf8",
293
+ stdio: ["pipe", "pipe", "pipe"],
294
+ });
295
+ return String(output || "");
296
+ }
297
+ (0, child_process_1.execFileSync)(bin, args, { stdio: "inherit" });
298
+ return "";
299
+ }
300
+ catch (error) {
301
+ const code = error.code;
302
+ if (code === "ENOENT") {
303
+ lastError = error;
304
+ continue;
305
+ }
306
+ if (options.ignoreError)
307
+ return "";
308
+ throw error;
309
+ }
310
+ }
311
+ if (lastError?.code === "ENOENT") {
312
+ throw new Error("未找到 agent-browser-stealth。请先执行: pnpm -C packages/yapi-mcp add agent-browser-stealth && pnpm -C packages/yapi-mcp exec agent-browser-stealth install");
313
+ }
314
+ throw new Error("启动 agent-browser-stealth 失败");
315
+ }
316
+ async function loginByBrowserAndReadCookie(baseUrl, loginUrlOption) {
317
+ const normalizedBase = String(baseUrl || "").replace(/\/+$/, "");
318
+ const defaultLoginUrl = normalizedBase;
319
+ let loginUrl = String(loginUrlOption || "").trim();
320
+ if (!loginUrl) {
321
+ const answer = await promptText(`YApi page URL [${defaultLoginUrl}]: `);
322
+ loginUrl = answer.trim() || defaultLoginUrl;
323
+ }
324
+ try {
325
+ const parsed = new URL(loginUrl);
326
+ loginUrl = parsed.toString();
327
+ }
328
+ catch {
329
+ throw new Error(`invalid login url: ${loginUrl}`);
330
+ }
331
+ const sessionName = `yapi-login-${Date.now()}-${crypto_1.default.randomBytes(4).toString("hex")}`;
332
+ console.log(`opening browser for login: ${loginUrl}`);
333
+ console.log(`browser session name: ${sessionName}`);
334
+ runAgentBrowser(["open", loginUrl, "--headed", "--session-name", sessionName]);
335
+ await promptText("请在浏览器完成登录,然后按回车继续...");
336
+ let cookies = [];
337
+ try {
338
+ const output = runAgentBrowser(["cookies", "--json", "--session-name", sessionName], {
339
+ captureOutput: true,
340
+ });
341
+ const payload = parseJsonLoose(output);
342
+ cookies = extractCookiesFromPayload(payload);
343
+ }
344
+ finally {
345
+ runAgentBrowser(["close", "--session-name", sessionName], { ignoreError: true });
346
+ }
347
+ if (!cookies.length) {
348
+ throw new Error(`未读取到浏览器 cookie。请确认登录完成后再回车;如果仍失败,请先自检:\nagent-browser-stealth cookies --json --session-name ${sessionName}`);
349
+ }
350
+ const tokenCookie = cookies.find((item) => String(item?.name || "") === "_yapi_token");
351
+ const uidCookie = cookies.find((item) => String(item?.name || "") === "_yapi_uid");
352
+ const yapiToken = String(tokenCookie?.value || "").trim();
353
+ if (!yapiToken) {
354
+ throw new Error("未找到 _yapi_token,请确认登录站点是目标 YApi 域名");
355
+ }
356
+ const yapiUid = String(uidCookie?.value || "").trim() || undefined;
357
+ const expiresAt = normalizeCookieExpiresMs(tokenCookie?.expiresAt) ?? normalizeCookieExpiresMs(tokenCookie?.expires);
358
+ return { yapiToken, yapiUid, expiresAt };
359
+ }
125
360
  async function fetchWithTimeout(url, options, timeoutMs) {
126
361
  const controller = new AbortController();
127
362
  const timer = setTimeout(() => controller.abort(), timeoutMs);
@@ -184,6 +419,14 @@ function parseArgs(argv) {
184
419
  options.baseUrl = arg.slice(11);
185
420
  continue;
186
421
  }
422
+ if (arg === "--login-url") {
423
+ options.loginUrl = argv[++i];
424
+ continue;
425
+ }
426
+ if (arg.startsWith("--login-url=")) {
427
+ options.loginUrl = arg.slice(12);
428
+ continue;
429
+ }
187
430
  if (arg === "--token") {
188
431
  options.token = argv[++i];
189
432
  continue;
@@ -208,6 +451,10 @@ function parseArgs(argv) {
208
451
  options.authMode = arg.slice(12);
209
452
  continue;
210
453
  }
454
+ if (arg === "--browser") {
455
+ options.browser = true;
456
+ continue;
457
+ }
211
458
  if (arg === "--email") {
212
459
  options.email = argv[++i];
213
460
  continue;
@@ -640,6 +887,7 @@ function usage() {
640
887
  " yapi docs-sync [options] [dir...]",
641
888
  " yapi docs-sync bind <action> [options]",
642
889
  " yapi login [options]",
890
+ " yapi logout [options]",
643
891
  " yapi whoami [options]",
644
892
  " yapi search [options]",
645
893
  " yapi install-skill [options]",
@@ -740,12 +988,24 @@ function loginUsage() {
740
988
  "Options:",
741
989
  " --config <path> config file path (default: ~/.yapi/config.toml)",
742
990
  " --base-url <url> YApi base URL",
991
+ " --login-url <url> page URL for browser login (default: <base-url>)",
992
+ " --browser force browser login and cookie sync",
743
993
  " --email <email> login email for global mode",
744
994
  " --password <pwd> login password for global mode",
745
995
  " --timeout <ms> request timeout in ms",
746
996
  " -h, --help show help",
747
997
  ].join("\n");
748
998
  }
999
+ function logoutUsage() {
1000
+ return [
1001
+ "Usage:",
1002
+ " yapi logout [options]",
1003
+ "Options:",
1004
+ " --config <path> config file path (default: ~/.yapi/config.toml)",
1005
+ " --base-url <url> YApi base URL",
1006
+ " -h, --help show help",
1007
+ ].join("\n");
1008
+ }
749
1009
  function whoamiUsage() {
750
1010
  return [
751
1011
  "Usage:",
@@ -1141,6 +1401,8 @@ async function runLogin(rawArgs) {
1141
1401
  let baseUrl = options.baseUrl || config.base_url || "";
1142
1402
  let email = options.email || config.email || "";
1143
1403
  let password = options.password || config.password || "";
1404
+ const projectId = options.projectId || config.project_id || "";
1405
+ const token = options.token || config.token || "";
1144
1406
  let updated = false;
1145
1407
  if (!baseUrl) {
1146
1408
  if (!process.stdin.isTTY || !process.stdout.isTTY) {
@@ -1150,21 +1412,50 @@ async function runLogin(rawArgs) {
1150
1412
  baseUrl = await promptRequired("YApi base URL: ", false);
1151
1413
  updated = true;
1152
1414
  }
1153
- if (!email) {
1415
+ const useBrowserLogin = Boolean(options.browser) || !email || !password;
1416
+ if (useBrowserLogin) {
1154
1417
  if (!process.stdin.isTTY || !process.stdout.isTTY) {
1155
- console.error("missing --email or config email");
1418
+ console.error("browser login requires interactive terminal");
1156
1419
  return 2;
1157
1420
  }
1158
- email = await promptRequired("YApi email: ", false);
1159
- updated = true;
1160
- }
1161
- if (!password) {
1162
- if (!process.stdin.isTTY || !process.stdout.isTTY) {
1163
- console.error("missing --password or config password");
1421
+ try {
1422
+ const session = await loginByBrowserAndReadCookie(baseUrl, options.loginUrl);
1423
+ const cache = new authCache_1.YApiAuthCache(baseUrl, "warn");
1424
+ cache.saveSession({
1425
+ yapiToken: session.yapiToken,
1426
+ yapiUid: session.yapiUid,
1427
+ expiresAt: session.expiresAt,
1428
+ updatedAt: Date.now(),
1429
+ });
1430
+ }
1431
+ catch (error) {
1432
+ console.error(error instanceof Error ? error.message : String(error));
1164
1433
  return 2;
1165
1434
  }
1166
- password = await promptRequired("YApi password: ", true);
1167
- updated = true;
1435
+ const shouldWriteConfig = updated || !fs_1.default.existsSync(configPath) || config.auth_mode !== "global";
1436
+ if (shouldWriteConfig) {
1437
+ const mergedConfig = {
1438
+ base_url: baseUrl,
1439
+ auth_mode: "global",
1440
+ email,
1441
+ password,
1442
+ token,
1443
+ project_id: projectId,
1444
+ };
1445
+ writeConfig(configPath, mergedConfig);
1446
+ }
1447
+ console.log("login success (cookie synced from browser to ~/.yapi-mcp/auth-*.json)");
1448
+ return 0;
1449
+ }
1450
+ try {
1451
+ const authService = new auth_1.YApiAuthService(baseUrl, email, password, "warn", {
1452
+ timeoutMs: options.timeout || 30000,
1453
+ });
1454
+ await authService.getCookieHeaderWithLogin({ forceLogin: true });
1455
+ }
1456
+ catch (error) {
1457
+ console.error(error instanceof Error ? error.message : String(error));
1458
+ return 2;
1168
1459
  }
1169
1460
  const shouldWriteConfig = updated || !fs_1.default.existsSync(configPath) || config.auth_mode !== "global";
1170
1461
  if (shouldWriteConfig) {
@@ -1173,22 +1464,36 @@ async function runLogin(rawArgs) {
1173
1464
  auth_mode: "global",
1174
1465
  email,
1175
1466
  password,
1176
- token: options.token || config.token || "",
1177
- project_id: options.projectId || config.project_id || "",
1467
+ token,
1468
+ project_id: projectId,
1178
1469
  };
1179
1470
  writeConfig(configPath, mergedConfig);
1180
1471
  }
1181
- try {
1182
- const authService = new auth_1.YApiAuthService(baseUrl, email, password, "warn", {
1183
- timeoutMs: options.timeout || 30000,
1184
- });
1185
- await authService.getCookieHeaderWithLogin({ forceLogin: true });
1472
+ console.log("login success (cookie cached in ~/.yapi-mcp/auth-*.json)");
1473
+ return 0;
1474
+ }
1475
+ async function runLogout(rawArgs) {
1476
+ const options = parseArgs(rawArgs);
1477
+ if (options.help) {
1478
+ console.log(logoutUsage());
1479
+ return 0;
1186
1480
  }
1187
- catch (error) {
1188
- console.error(error instanceof Error ? error.message : String(error));
1481
+ if (options.version) {
1482
+ console.log(readVersion());
1483
+ return 0;
1484
+ }
1485
+ const configPath = options.config || globalConfigPath();
1486
+ const config = fs_1.default.existsSync(configPath)
1487
+ ? parseSimpleToml(fs_1.default.readFileSync(configPath, "utf8"))
1488
+ : {};
1489
+ const baseUrl = options.baseUrl || config.base_url || "";
1490
+ if (!baseUrl) {
1491
+ console.error("missing --base-url or config base_url");
1189
1492
  return 2;
1190
1493
  }
1191
- console.log("login success (cookie cached in ~/.yapi-mcp/auth-*.json)");
1494
+ const cache = new authCache_1.YApiAuthCache(baseUrl, "warn");
1495
+ cache.clearSession();
1496
+ console.log("logout success (session cleared from ~/.yapi-mcp/auth-*.json)");
1192
1497
  return 0;
1193
1498
  }
1194
1499
  async function runWhoami(rawArgs) {
@@ -1477,6 +1782,20 @@ function resolveDocsSyncHome(startDir, ensure) {
1477
1782
  ensureDocsSyncReadme(home);
1478
1783
  return home;
1479
1784
  }
1785
+ function globalYapiHomeDir() {
1786
+ return path_1.default.resolve(process.env.YAPI_HOME || path_1.default.join(os_1.default.homedir(), ".yapi"));
1787
+ }
1788
+ function normalizeComparablePath(targetPath) {
1789
+ try {
1790
+ return fs_1.default.realpathSync.native(targetPath);
1791
+ }
1792
+ catch {
1793
+ return path_1.default.resolve(targetPath);
1794
+ }
1795
+ }
1796
+ function isGlobalDocsSyncHome(homeDir) {
1797
+ return normalizeComparablePath(homeDir) === normalizeComparablePath(globalYapiHomeDir());
1798
+ }
1480
1799
  function docsSyncConfigPath(homeDir) {
1481
1800
  return path_1.default.join(homeDir, "docs-sync.json");
1482
1801
  }
@@ -1591,6 +1910,44 @@ function normalizeBindingDir(rootDir, bindingDir) {
1591
1910
  return resolved;
1592
1911
  return relative;
1593
1912
  }
1913
+ function getBindingBaseDir(homeDir, rootDir, cwd) {
1914
+ if (!isGlobalDocsSyncHome(homeDir)) {
1915
+ return { baseDir: rootDir, gitRoot: findGitRoot(cwd), usedGitRoot: false };
1916
+ }
1917
+ const gitRoot = findGitRoot(cwd);
1918
+ if (gitRoot) {
1919
+ return { baseDir: gitRoot, gitRoot, usedGitRoot: true };
1920
+ }
1921
+ return { baseDir: path_1.default.resolve(cwd), gitRoot: null, usedGitRoot: false };
1922
+ }
1923
+ function normalizeBindingDirForContext(homeDir, rootDir, cwd, bindingDir) {
1924
+ const context = getBindingBaseDir(homeDir, rootDir, cwd);
1925
+ const resolved = path_1.default.isAbsolute(bindingDir)
1926
+ ? bindingDir
1927
+ : path_1.default.resolve(context.baseDir, bindingDir);
1928
+ const relative = path_1.default.relative(rootDir, resolved);
1929
+ if (!relative || relative === ".")
1930
+ return ".";
1931
+ if (relative.startsWith("..") || path_1.default.isAbsolute(relative))
1932
+ return resolved;
1933
+ return relative;
1934
+ }
1935
+ function resolveBindingDirForContext(homeDir, rootDir, cwd, bindingDir) {
1936
+ if (!bindingDir)
1937
+ return rootDir;
1938
+ if (path_1.default.isAbsolute(bindingDir))
1939
+ return bindingDir;
1940
+ const direct = path_1.default.resolve(rootDir, bindingDir);
1941
+ if (!isGlobalDocsSyncHome(homeDir))
1942
+ return direct;
1943
+ if (fs_1.default.existsSync(direct))
1944
+ return direct;
1945
+ const { baseDir } = getBindingBaseDir(homeDir, rootDir, cwd);
1946
+ const contextual = path_1.default.resolve(baseDir, bindingDir);
1947
+ if (fs_1.default.existsSync(contextual))
1948
+ return contextual;
1949
+ return direct;
1950
+ }
1594
1951
  function suggestDocsSyncDir(startDir) {
1595
1952
  const candidates = ["docs", "doc", "documentation", "release-notes"];
1596
1953
  for (const candidate of candidates) {
@@ -1868,6 +2225,59 @@ function buildAddPayload(template, title, apiPath, catId, projectId) {
1868
2225
  tag: template.tag || [],
1869
2226
  };
1870
2227
  }
2228
+ function buildUpdatePayload(docId, title, markdown, html) {
2229
+ const payload = { id: docId, markdown, desc: html };
2230
+ if (title) {
2231
+ payload.title = title;
2232
+ }
2233
+ return payload;
2234
+ }
2235
+ function pickLargestMermaid(metrics) {
2236
+ return metrics
2237
+ .filter((item) => item.renderer === "mermaid")
2238
+ .sort((a, b) => b.renderedBytes - a.renderedBytes)[0];
2239
+ }
2240
+ function buildDocsSyncPreviewLine(item) {
2241
+ const parts = [
2242
+ `file=${item.fileName}`,
2243
+ `action=${item.action}`,
2244
+ `markdown=${formatBytes(item.markdownBytes)}`,
2245
+ `html=${formatBytes(item.htmlBytes)}`,
2246
+ `payload=${formatBytes(item.payloadBytes)}`,
2247
+ `path=${item.apiPath}`,
2248
+ ];
2249
+ if (item.docId) {
2250
+ parts.push(`doc_id=${item.docId}`);
2251
+ }
2252
+ if (item.largestMermaid) {
2253
+ parts.push(`largest_mermaid=#${item.largestMermaid.index}`, `largest_mermaid_svg=${formatBytes(item.largestMermaid.renderedBytes)}`);
2254
+ }
2255
+ return `preview ${parts.join(" ")}`;
2256
+ }
2257
+ function buildDocsSyncPayloadTooLargeMessage(fileName, preview, error) {
2258
+ const lines = [
2259
+ `413 Payload Too Large while syncing ${fileName}`,
2260
+ `- request payload: ${formatBytes(preview.payloadBytes)}`,
2261
+ `- markdown size: ${formatBytes(preview.markdownBytes)}`,
2262
+ `- rendered html size: ${formatBytes(preview.htmlBytes)}`,
2263
+ ];
2264
+ const limitBytes = parsePayloadLimit(error.body || error.message);
2265
+ if (limitBytes) {
2266
+ lines.push(`- server limit: ${formatBytes(limitBytes)}`);
2267
+ }
2268
+ else {
2269
+ lines.push("- server limit: unknown (response did not expose an exact value)");
2270
+ }
2271
+ if (preview.largestMermaid) {
2272
+ lines.push(`- largest Mermaid block: #${preview.largestMermaid.index} -> ${formatBytes(preview.largestMermaid.renderedBytes)}`);
2273
+ }
2274
+ else {
2275
+ lines.push("- largest Mermaid block: none");
2276
+ }
2277
+ lines.push("- suggestion: run `yapi docs-sync --dry-run ...` to preview all files before upload");
2278
+ lines.push("- suggestion: split oversized Mermaid diagrams or move them into separate docs");
2279
+ return lines.join("\n");
2280
+ }
1871
2281
  async function addInterface(title, apiPath, mapping, request) {
1872
2282
  const projectId = Number(mapping.project_id || 0);
1873
2283
  const catId = Number(mapping.catid || 0);
@@ -1894,10 +2304,7 @@ async function addInterface(title, apiPath, mapping, request) {
1894
2304
  return Number(newId);
1895
2305
  }
1896
2306
  async function updateInterface(docId, title, markdown, html, request) {
1897
- const payload = { id: docId, markdown, desc: html };
1898
- if (title) {
1899
- payload.title = title;
1900
- }
2307
+ const payload = buildUpdatePayload(docId, title, markdown, html);
1901
2308
  const resp = await request("/api/interface/up", "POST", {}, payload);
1902
2309
  if (resp?.errcode !== 0) {
1903
2310
  throw new Error(`interface up failed: ${resp?.errmsg || "unknown error"}`);
@@ -1919,14 +2326,19 @@ async function syncDocsDir(dirPath, mapping, options, request) {
1919
2326
  mapping.catid = Number(envCatId);
1920
2327
  if (!mapping.template_id && envTemplateId)
1921
2328
  mapping.template_id = Number(envTemplateId);
1922
- if (!mapping.project_id || !mapping.catid) {
2329
+ const hasTarget = Boolean(mapping.project_id && mapping.catid);
2330
+ if (!hasTarget && !options.dryRun) {
1923
2331
  throw new Error("缺少 project_id/catid。请先绑定或配置:yapi docs-sync bind add --name <binding> --dir <path> --project-id <id> --catid <id>,或在目录下添加 .yapi.json,或设置环境变量 YAPI_PROJECT_ID/YAPI_CATID。");
1924
2332
  }
1925
- const { byPath, byTitle, byId } = await listExistingInterfaces(Number(mapping.catid), request);
2333
+ const { byPath, byTitle, byId } = hasTarget
2334
+ ? await listExistingInterfaces(Number(mapping.catid), request)
2335
+ : { byPath: {}, byTitle: {}, byId: {} };
1926
2336
  let updated = 0;
1927
2337
  let created = 0;
1928
2338
  let skipped = 0;
2339
+ let previewOnly = 0;
1929
2340
  const fileInfos = {};
2341
+ const previews = [];
1930
2342
  const files = resolveSourceFiles(dirPath, mapping);
1931
2343
  for (const mdPath of files) {
1932
2344
  const stem = path_1.default.parse(mdPath).name;
@@ -1940,43 +2352,34 @@ async function syncDocsDir(dirPath, mapping, options, request) {
1940
2352
  if (docId)
1941
2353
  mapping.files[relName] = docId;
1942
2354
  }
1943
- if (!docId) {
2355
+ let action = docId ? "update" : hasTarget ? "create" : "preview-only";
2356
+ if (!docId && hasTarget) {
1944
2357
  created += 1;
1945
2358
  if (!options.dryRun) {
1946
2359
  docId = await addInterface(desiredTitle, apiPath, mapping, request);
1947
2360
  mapping.files[relName] = docId;
1948
2361
  }
1949
2362
  }
2363
+ if (!docId && !hasTarget) {
2364
+ previewOnly += 1;
2365
+ }
1950
2366
  if (docId) {
1951
2367
  const resolvedPath = byId[String(docId)]?.path || apiPath;
1952
2368
  fileInfos[relName] = { docId: Number(docId), apiPath: resolvedPath };
1953
2369
  }
1954
- const contentHash = buildDocsSyncHash(markdown, options);
1955
- const previousHash = mapping.file_hashes[relName];
1956
- const currentTitle = docId ? byId[String(docId)]?.title : "";
1957
- const titleToUpdate = !docId
1958
- ? undefined
1959
- : !currentTitle || currentTitle !== desiredTitle
1960
- ? desiredTitle
1961
- : undefined;
1962
- const shouldSyncTitle = Boolean(titleToUpdate);
1963
- if (!options.force &&
1964
- docId &&
1965
- previousHash &&
1966
- previousHash === contentHash &&
1967
- !shouldSyncTitle) {
1968
- skipped += 1;
1969
- continue;
1970
- }
1971
2370
  const logPrefix = `[docs-sync:${relName}]`;
1972
2371
  let mermaidFailed = false;
1973
2372
  let diagramFailed = false;
2373
+ const diagramMetrics = [];
1974
2374
  const html = (0, markdown_1.renderMarkdownToHtml)(markdown, {
1975
2375
  noMermaid: options.noMermaid,
1976
2376
  logMermaid: true,
1977
2377
  mermaidLook: options.mermaidLook,
1978
2378
  mermaidHandDrawnSeed: options.mermaidHandDrawnSeed,
1979
2379
  logger: (message) => console.log(`${logPrefix} ${message}`),
2380
+ onDiagramRendered: (metric) => {
2381
+ diagramMetrics.push(metric);
2382
+ },
1980
2383
  onMermaidError: () => {
1981
2384
  mermaidFailed = true;
1982
2385
  },
@@ -1984,15 +2387,56 @@ async function syncDocsDir(dirPath, mapping, options, request) {
1984
2387
  diagramFailed = true;
1985
2388
  },
1986
2389
  });
1987
- if (!options.dryRun && docId) {
1988
- await updateInterface(docId, titleToUpdate, markdown, html, request);
2390
+ const contentHash = buildDocsSyncHash(markdown, options);
2391
+ const previousHash = mapping.file_hashes[relName];
2392
+ const currentTitle = docId ? byId[String(docId)]?.title : "";
2393
+ const titleToUpdate = !docId
2394
+ ? undefined
2395
+ : !currentTitle || currentTitle !== desiredTitle
2396
+ ? desiredTitle
2397
+ : undefined;
2398
+ const shouldSyncTitle = Boolean(titleToUpdate);
2399
+ if (!options.force &&
2400
+ docId &&
2401
+ previousHash &&
2402
+ previousHash === contentHash &&
2403
+ !shouldSyncTitle) {
2404
+ action = "skip";
2405
+ skipped += 1;
2406
+ }
2407
+ const payloadObject = docId && action !== "create"
2408
+ ? buildUpdatePayload(docId, titleToUpdate, markdown, html)
2409
+ : buildAddPayload({}, desiredTitle, apiPath, Number(mapping.catid || 0), Number(mapping.project_id || 0));
2410
+ const preview = {
2411
+ fileName: relName,
2412
+ action,
2413
+ markdownBytes: Buffer.byteLength(markdown, "utf8"),
2414
+ htmlBytes: Buffer.byteLength(html, "utf8"),
2415
+ payloadBytes: Buffer.byteLength(JSON.stringify(payloadObject), "utf8"),
2416
+ apiPath: docId ? fileInfos[relName]?.apiPath || apiPath : apiPath,
2417
+ docId: docId ? Number(docId) : undefined,
2418
+ largestMermaid: pickLargestMermaid(diagramMetrics),
2419
+ };
2420
+ previews.push(preview);
2421
+ if (!options.dryRun && docId && action !== "skip") {
2422
+ try {
2423
+ await updateInterface(docId, titleToUpdate, markdown, html, request);
2424
+ }
2425
+ catch (error) {
2426
+ if (error instanceof HttpStatusError && error.status === 413) {
2427
+ throw new Error(buildDocsSyncPayloadTooLargeMessage(relName, preview, error));
2428
+ }
2429
+ throw error;
2430
+ }
1989
2431
  }
1990
2432
  if (docId && !mermaidFailed && !diagramFailed) {
1991
2433
  mapping.file_hashes[relName] = contentHash;
1992
2434
  }
1993
- updated += 1;
2435
+ if (action !== "skip") {
2436
+ updated += 1;
2437
+ }
1994
2438
  }
1995
- return { updated, created, skipped, files: fileInfos };
2439
+ return { updated, created, skipped, previewOnly, files: fileInfos, previews };
1996
2440
  }
1997
2441
  function buildEnvUrls(projectInfo, apiPath) {
1998
2442
  const urls = {};
@@ -2167,7 +2611,7 @@ async function runDocsSyncBindings(rawArgs) {
2167
2611
  file_hashes: existing.file_hashes ? { ...existing.file_hashes } : {},
2168
2612
  };
2169
2613
  if (options.dir) {
2170
- next.dir = normalizeBindingDir(rootDir, options.dir);
2614
+ next.dir = normalizeBindingDirForContext(homeDir, rootDir, process.cwd(), options.dir);
2171
2615
  }
2172
2616
  if (options.projectId !== undefined && Number.isFinite(options.projectId)) {
2173
2617
  next.project_id = Number(options.projectId);
@@ -2190,7 +2634,17 @@ async function runDocsSyncBindings(rawArgs) {
2190
2634
  }
2191
2635
  config.bindings[options.name] = next;
2192
2636
  saveDocsSyncConfig(homeDir, config);
2637
+ const resolvedDir = resolveBindingDirForContext(homeDir, rootDir, process.cwd(), next.dir);
2193
2638
  console.log(`${action === "add" ? "binding added" : "binding updated"}: ${options.name}`);
2639
+ console.log(`stored_dir=${next.dir}`);
2640
+ console.log(`resolved_dir=${resolvedDir}`);
2641
+ const gitRoot = findGitRoot(process.cwd());
2642
+ if (gitRoot) {
2643
+ console.log(`git_root=${gitRoot}`);
2644
+ }
2645
+ else if (isGlobalDocsSyncHome(homeDir)) {
2646
+ console.warn("warning: no git root detected, relative --dir was resolved from current working directory");
2647
+ }
2194
2648
  return 0;
2195
2649
  }
2196
2650
  async function runDocsSync(rawArgs) {
@@ -2398,7 +2852,7 @@ async function runDocsSync(rawArgs) {
2398
2852
  result = await sendOnce();
2399
2853
  }
2400
2854
  if (!result.response.ok) {
2401
- throw new Error(`request failed: ${result.response.status} ${result.response.statusText} ${result.text}`);
2855
+ throw new HttpStatusError(endpoint, result.response.status, result.response.statusText, result.text);
2402
2856
  }
2403
2857
  if (!result.json) {
2404
2858
  throw new Error(`invalid JSON response from ${endpoint}`);
@@ -2414,7 +2868,7 @@ async function runDocsSync(rawArgs) {
2414
2868
  if (!binding) {
2415
2869
  throw new Error(`binding not found: ${name}`);
2416
2870
  }
2417
- const dirPath = resolveBindingDir(rootDir, binding.dir);
2871
+ const dirPath = resolveBindingDirForContext(docsSyncHome, rootDir, process.cwd(), binding.dir);
2418
2872
  const existing = dirToBindings.get(dirPath) || [];
2419
2873
  existing.push(name);
2420
2874
  dirToBindings.set(dirPath, existing);
@@ -2439,12 +2893,16 @@ async function runDocsSync(rawArgs) {
2439
2893
  if (!binding) {
2440
2894
  throw new Error(`binding not found: ${name}`);
2441
2895
  }
2442
- const dirPath = resolveBindingDir(rootDir, binding.dir);
2896
+ const dirPath = resolveBindingDirForContext(docsSyncHome, rootDir, process.cwd(), binding.dir);
2443
2897
  if (!fs_1.default.existsSync(dirPath) || !fs_1.default.statSync(dirPath).isDirectory()) {
2444
2898
  throw new Error(`dir not found for binding ${name}: ${dirPath}`);
2445
2899
  }
2446
2900
  const result = await syncDocsDir(dirPath, binding, options, request);
2447
- console.log(`synced=${result.updated} created=${result.created} skipped=${result.skipped} binding=${name} dir=${dirPath}`);
2901
+ if (options.dryRun) {
2902
+ console.log(`dry-run preview binding=${name}`);
2903
+ result.previews.forEach((item) => console.log(buildDocsSyncPreviewLine(item)));
2904
+ }
2905
+ console.log(`synced=${result.updated} created=${result.created} skipped=${result.skipped} preview_only=${result.previewOnly} binding=${name} dir=${dirPath}`);
2448
2906
  bindingResults[name] = { binding, files: result.files };
2449
2907
  }
2450
2908
  if (!options.dryRun) {
@@ -2460,10 +2918,14 @@ async function runDocsSync(rawArgs) {
2460
2918
  }
2461
2919
  const { mapping, mappingPath } = loadMapping(dirPath);
2462
2920
  const result = await syncDocsDir(dirPath, mapping, options, request);
2921
+ if (options.dryRun) {
2922
+ console.log(`dry-run preview dir=${dirPath}`);
2923
+ result.previews.forEach((item) => console.log(buildDocsSyncPreviewLine(item)));
2924
+ }
2463
2925
  if (!options.dryRun) {
2464
2926
  saveMapping(mapping, mappingPath);
2465
2927
  }
2466
- console.log(`synced=${result.updated} created=${result.created} skipped=${result.skipped} dir=${dirPath}`);
2928
+ console.log(`synced=${result.updated} created=${result.created} skipped=${result.skipped} preview_only=${result.previewOnly} dir=${dirPath}`);
2467
2929
  }
2468
2930
  }
2469
2931
  return 0;
@@ -2489,6 +2951,9 @@ async function main() {
2489
2951
  if (rawArgs[0] === "login") {
2490
2952
  return await runLogin(rawArgs.slice(1));
2491
2953
  }
2954
+ if (rawArgs[0] === "logout") {
2955
+ return await runLogout(rawArgs.slice(1));
2956
+ }
2492
2957
  if (rawArgs[0] === "whoami") {
2493
2958
  return await runWhoami(rawArgs.slice(1));
2494
2959
  }
@@ -2631,7 +3096,7 @@ async function main() {
2631
3096
  }
2632
3097
  const queryItems = [];
2633
3098
  for (const query of options.query || []) {
2634
- queryItems.push(parseKeyValue(query));
3099
+ queryItems.push(...parseQueryArg(query));
2635
3100
  }
2636
3101
  const url = buildUrl(baseUrl, endpoint, queryItems, authMode === "token" ? token : "", options.tokenParam || "token");
2637
3102
  let dataRaw = null;