@lark-apaas/openclaw-scripts-diagnose-cli 0.1.4-alpha.1 → 0.1.4-alpha.2

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 (2) hide show
  1. package/dist/index.cjs +727 -182
  2. package/package.json +1 -1
package/dist/index.cjs CHANGED
@@ -23,12 +23,12 @@ var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__ge
23
23
  //#endregion
24
24
  let node_process = require("node:process");
25
25
  node_process = __toESM(node_process);
26
+ let node_path = require("node:path");
27
+ node_path = __toESM(node_path);
26
28
  let json5 = require("json5");
27
29
  json5 = __toESM(json5);
28
30
  let node_fs = require("node:fs");
29
31
  node_fs = __toESM(node_fs);
30
- let node_path = require("node:path");
31
- node_path = __toESM(node_path);
32
32
  let node_child_process = require("node:child_process");
33
33
  let node_crypto = require("node:crypto");
34
34
  node_crypto = __toESM(node_crypto);
@@ -53,7 +53,7 @@ let json_diff = require("json-diff");
53
53
  * it terse and parseable.
54
54
  */
55
55
  function getVersion() {
56
- return "0.1.4-alpha.1";
56
+ return "0.1.4-alpha.2";
57
57
  }
58
58
  //#endregion
59
59
  //#region src/rule-engine/base.ts
@@ -105,6 +105,9 @@ function topoSort(rules) {
105
105
  }
106
106
  //#endregion
107
107
  //#region src/utils.ts
108
+ function getExtensionsDir(configPath) {
109
+ return node_path.default.join(node_path.default.dirname(configPath), "extensions");
110
+ }
108
111
  /**
109
112
  * Canonical provider-ref for the feishu app secret. Both
110
113
  * `feishu_default_account` (multi-agent path) and `feishu_channel`
@@ -262,6 +265,32 @@ function shell(cmd, timeoutMs = 6e4) {
262
265
  }).trim();
263
266
  }
264
267
  //#endregion
268
+ //#region src/oc-runtime.ts
269
+ /**
270
+ * 探测 openclaw 运行时版本。能成功跑出版本号 = 已安装且可用。
271
+ *
272
+ * 比单纯 fs.existsSync 路径更可靠:能挡住 dangling symlink、依赖丢失、
273
+ * bin 是错误版本等"看似装好但跑不起来"的状态。
274
+ *
275
+ * 失败(命令缺失 / 解析失败 / 超时)返回 null。
276
+ */
277
+ function readOpenclawRuntimeVersion() {
278
+ try {
279
+ const lines = (0, node_child_process.execSync)("openclaw --version", {
280
+ encoding: "utf-8",
281
+ timeout: 5e3,
282
+ stdio: [
283
+ "ignore",
284
+ "pipe",
285
+ "ignore"
286
+ ]
287
+ }).split("\n").map((l) => l.trim()).filter(Boolean);
288
+ return (lines[lines.length - 1]?.match(/(?:OpenClaw\s+)?(\d+\.\d+\.\d+)/))?.[1] ?? null;
289
+ } catch {
290
+ return null;
291
+ }
292
+ }
293
+ //#endregion
265
294
  //#region \0@oxc-project+runtime@0.115.0/helpers/decorate.js
266
295
  function __decorate(decorators, target, key, desc) {
267
296
  var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
@@ -270,6 +299,33 @@ function __decorate(decorators, target, key, desc) {
270
299
  return c > 3 && r && Object.defineProperty(target, key, r), r;
271
300
  }
272
301
  //#endregion
302
+ //#region src/rules/openclaw-runtime-missing.ts
303
+ /**
304
+ * doctor 入口最先执行:检查 openclaw 是否已安装且可用(执行 `openclaw --version`)。
305
+ *
306
+ * 触发条件:openclaw 不可用 AND openclaw.json 存在。
307
+ * 配合 `config_file_missing`(reset)形成完整覆盖:
308
+ * - oc 可用 + config 存在 → 都 pass,进业务规则
309
+ * - oc 可用 + config 缺 → 仅 config_file_missing fail(reset)
310
+ * - oc 缺 + config 存在 → 仅本规则 fail(user_confirm: install_openclaw)
311
+ * - oc 缺 + config 缺 → 仅 config_file_missing fail(reset 同时装 oc + 初始化 config)
312
+ */
313
+ let OpenclawRuntimeMissingRule = class OpenclawRuntimeMissingRule extends DiagnoseRule {
314
+ validate(ctx) {
315
+ if (readOpenclawRuntimeVersion() != null) return { pass: true };
316
+ if (!node_fs.default.existsSync(ctx.configPath)) return { pass: true };
317
+ return {
318
+ pass: false,
319
+ action: "install_openclaw",
320
+ message: `openclaw 不可用(无法执行 'openclaw --version'),但 ${ctx.configPath} 已存在;请重新安装 openclaw`
321
+ };
322
+ }
323
+ };
324
+ OpenclawRuntimeMissingRule = __decorate([Rule({
325
+ key: "openclaw_runtime_missing",
326
+ repairMode: "user-confirm"
327
+ })], OpenclawRuntimeMissingRule);
328
+ //#endregion
273
329
  //#region src/rules/multi-process-detect.ts
274
330
  let ProcessStatusRule = class ProcessStatusRule extends DiagnoseRule {
275
331
  validate(_ctx) {
@@ -1312,9 +1368,6 @@ function getPluginMaps(config) {
1312
1368
  allow: Array.isArray(rawAllow) ? rawAllow : void 0
1313
1369
  };
1314
1370
  }
1315
- function getExtensionsDir(configPath) {
1316
- return node_path.default.join(node_path.default.dirname(configPath), "extensions");
1317
- }
1318
1371
  function hasNewMiaoda({ entries, installs, allow }) {
1319
1372
  return asRecord(entries?.[NEW_MIAODA]) != null || asRecord(installs?.[NEW_MIAODA]) != null || (allow?.includes(NEW_MIAODA) ?? false);
1320
1373
  }
@@ -1404,6 +1457,588 @@ function getAllow(config) {
1404
1457
  return allow.filter((e) => typeof e === "string");
1405
1458
  }
1406
1459
  //#endregion
1460
+ //#region src/fs-utils.ts
1461
+ /**
1462
+ * Rename src → dst, falling back to `mv` (which handles cross-device copy)
1463
+ * when the kernel returns EXDEV.
1464
+ *
1465
+ * Sandbox filesystems can put sibling paths on different "devices" from
1466
+ * rename(2)'s point of view: bind mounts, overlayfs copy-up, and
1467
+ * mount-point children inside a single directory all trip EXDEV. Seen in
1468
+ * production when reset's atomic swap did
1469
+ * /home/gem/.npm-global/lib/node_modules/openclaw → openclaw.bak
1470
+ * and the openclaw subdir was a bind-mounted volume.
1471
+ *
1472
+ * Behavior:
1473
+ * - Happy path hits rename(2) — atomic, single syscall, microseconds.
1474
+ * - EXDEV path shells out to `mv`, which does rename() then copy+unlink
1475
+ * on failure. Non-atomic but correct; callers already have rollback
1476
+ * logic (install-openclaw restores from .bak) so loss of atomicity
1477
+ * only matters if the process dies mid-copy, and that's survivable.
1478
+ * - Any other error (ENOENT, EACCES, EBUSY...) rethrows as-is so callers
1479
+ * see the real problem instead of a misleading `mv` fallback failure.
1480
+ */
1481
+ function moveSafe(src, dst) {
1482
+ try {
1483
+ node_fs.default.renameSync(src, dst);
1484
+ } catch (e) {
1485
+ if (e?.code !== "EXDEV") throw e;
1486
+ execCaptureErr(`mv ${shellQuote(src)} ${shellQuote(dst)}`);
1487
+ }
1488
+ }
1489
+ /**
1490
+ * Run a shell command, re-throwing with stderr attached on failure.
1491
+ *
1492
+ * Node's `execSync(..., { stdio: 'ignore' })` swallows stderr entirely —
1493
+ * callers only see "Command failed: <cmd>" with no hint of the real error
1494
+ * (ENOSPC, EROFS, "unrecognized option", etc.). Production debugging on
1495
+ * sandboxed boxes is painful without the underlying message, so we pipe
1496
+ * stderr, capture it, and embed it in the thrown Error. stdout stays
1497
+ * suppressed because the commands we run here (tar/mv) are silent on
1498
+ * success.
1499
+ */
1500
+ function execCaptureErr(cmd) {
1501
+ try {
1502
+ (0, node_child_process.execSync)(cmd, { stdio: [
1503
+ "ignore",
1504
+ "ignore",
1505
+ "pipe"
1506
+ ] });
1507
+ } catch (e) {
1508
+ const stderr = e?.stderr;
1509
+ const stderrStr = (typeof stderr === "string" ? stderr : stderr?.toString("utf8") ?? "").trim();
1510
+ const base = e?.message ?? "command failed";
1511
+ throw new Error(stderrStr ? `${base}\nstderr: ${stderrStr}` : base);
1512
+ }
1513
+ }
1514
+ /** POSIX single-quote shell escape. Paths with embedded quotes are rare but
1515
+ * the token-file path conventions in sandboxes don't guarantee cleanliness. */
1516
+ function shellQuote(s) {
1517
+ return `'${s.replace(/'/g, `'\\''`)}'`;
1518
+ }
1519
+ /**
1520
+ * Recursively remove a path, retrying on transient overlayfs races.
1521
+ *
1522
+ * `fs.rmSync(..., { recursive: true, force: true })` walks the tree
1523
+ * issuing rmdir() per directory. On overlayfs (sandbox containers) a
1524
+ * just-emptied subdir can return EAGAIN/EBUSY/ENOTEMPTY for a short
1525
+ * window before whiteout propagation finishes — even though every
1526
+ * child has been unlinked. Same family of races as the tar/rename
1527
+ * issue handled by extractTarballTolerant + the install-openclaw
1528
+ * tmpfs swap.
1529
+ *
1530
+ * Retries with linear backoff (50 → 250 → 750 ms). Any non-transient
1531
+ * error (EACCES, EROFS, ENOSPC...) bubbles immediately. Returns true
1532
+ * if the path is absent at the end (success or never existed).
1533
+ */
1534
+ function rmrfTolerant(target) {
1535
+ if (!node_fs.default.existsSync(target)) return true;
1536
+ const transient = new Set([
1537
+ "EAGAIN",
1538
+ "EBUSY",
1539
+ "ENOTEMPTY"
1540
+ ]);
1541
+ const delaysMs = [
1542
+ 50,
1543
+ 250,
1544
+ 750
1545
+ ];
1546
+ for (let attempt = 0; attempt <= delaysMs.length; attempt++) try {
1547
+ node_fs.default.rmSync(target, {
1548
+ recursive: true,
1549
+ force: true
1550
+ });
1551
+ return true;
1552
+ } catch (e) {
1553
+ const code = e.code ?? "";
1554
+ if (!transient.has(code) || attempt === delaysMs.length) throw e;
1555
+ (0, node_child_process.execSync)(`sleep ${delaysMs[attempt] / 1e3}`);
1556
+ }
1557
+ return !node_fs.default.existsSync(target);
1558
+ }
1559
+ /**
1560
+ * Snapshot the current `openclaw.json` to `<configPath>.<YYYYMMDD_HHMMSS>.bak`
1561
+ * before a repair / fix flow rewrites it. Local-time stamp matches the
1562
+ * shell-style backup convention (`cp openclaw.json openclaw.json.$(date
1563
+ * +%Y%m%d_%H%M%S).bak`) so operators triaging on the sandbox see the
1564
+ * familiar filename pattern.
1565
+ *
1566
+ * Returns the absolute backup path on success, or `null` when:
1567
+ * - source doesn't exist (first-time setup; nothing to back up)
1568
+ * - copy fails for any reason (disk full, permission denied, etc.)
1569
+ *
1570
+ * Failure is logged to cli.log via console.error and swallowed — the
1571
+ * caller's repair/fix work goes ahead either way. The backup is a safety
1572
+ * net, not a correctness gate; better to do the repair than refuse over
1573
+ * a missing rollback file.
1574
+ */
1575
+ function backupConfigSync(configPath) {
1576
+ if (!node_fs.default.existsSync(configPath)) {
1577
+ console.error(`backupConfigSync: skip — source missing path=${configPath}`);
1578
+ return null;
1579
+ }
1580
+ const d = /* @__PURE__ */ new Date();
1581
+ const pad = (n) => String(n).padStart(2, "0");
1582
+ const backupPath = `${configPath}.${`${d.getFullYear()}${pad(d.getMonth() + 1)}${pad(d.getDate())}_${pad(d.getHours())}${pad(d.getMinutes())}${pad(d.getSeconds())}`}.bak`;
1583
+ try {
1584
+ node_fs.default.copyFileSync(configPath, backupPath);
1585
+ const bytes = node_fs.default.statSync(backupPath).size;
1586
+ console.error(`backupConfigSync: ok src=${configPath} dst=${backupPath} bytes=${bytes}`);
1587
+ return backupPath;
1588
+ } catch (e) {
1589
+ console.error(`backupConfigSync: failed src=${configPath} message=${e.message}`);
1590
+ return null;
1591
+ }
1592
+ }
1593
+ /**
1594
+ * Extract an npm-packed gzipped tarball.
1595
+ *
1596
+ * ## The problem this works around
1597
+ *
1598
+ * Some tarballs (openclaw's among them — they're not packed by vanilla
1599
+ * `npm pack`) include relative symlinks inside nested .bin/ dirs whose
1600
+ * targets contain `..`, e.g.
1601
+ * node_modules/<pkg>/node_modules/.bin/foo -> ../foo/bin/cli.js
1602
+ *
1603
+ * GNU tar classifies any symlink target with `..` or a leading `/` as
1604
+ * "dangerous" and defers its extraction to a post-files pass, while also
1605
+ * needing a post-files pass to restore directory permissions/mtimes. The
1606
+ * two passes race: the deferred-symlink handling mutates parent-dir inodes,
1607
+ * then the directory stat-restore pass does `fstatat()` and the recorded
1608
+ * inode doesn't match, firing
1609
+ *
1610
+ * tar: <path>: Directory renamed before its status could be extracted
1611
+ *
1612
+ * from `apply_nonancestor_delayed_set_stat()` in extract.c. This is an
1613
+ * `ERROR` (hard-fail, exit 2) — the `--warning=no-rename-directory`
1614
+ * keyword controls a different, incremental-archive code path and does
1615
+ * NOT silence this. Reference: Paul Eggert, bug-tar 2004-04:
1616
+ * https://lists.gnu.org/archive/html/bug-tar/2004-04/msg00021.html
1617
+ *
1618
+ * ## The fix
1619
+ *
1620
+ * Pass `--absolute-names` (aka `-P`). Per GNU tar docs, this disables the
1621
+ * "normalize dangerous names" logic — including the deferred-symlink pass
1622
+ * that's racing us. Also stops stripping leading `/`, but our tarballs
1623
+ * only contain relative (`./node_modules/...`) paths so there's nothing
1624
+ * to strip. Safe because:
1625
+ * - The tarball is sha512-verified upstream (downloadWithCache)
1626
+ * - All entry paths are relative, no absolute-path escape risk
1627
+ * - All dangerous symlink targets resolve within the extracted tree
1628
+ *
1629
+ * ## Belt-and-suspenders
1630
+ *
1631
+ * If some tar variant still emits the error despite -P, we fall through
1632
+ * to checking the stderr pattern: if every error line is the benign
1633
+ * "Directory renamed …" text (no real failures like ENOSPC/EACCES/gzip
1634
+ * CRC/etc.), swallow exit 2. Callers MUST still verify extraction
1635
+ * (e.g. `fs.existsSync(path.join(dest, 'package.json'))`) — tar's
1636
+ * `skip_this_one = 1` after the error means some dirs missed their
1637
+ * mtime/mode restore, but content was written.
1638
+ */
1639
+ function extractTarballTolerant(tarball, destDir, opts = {}) {
1640
+ const strip = opts.stripComponents ?? 0;
1641
+ const stripFlag = strip > 0 ? ` --strip-components=${strip}` : "";
1642
+ const cmd = `tar -xzf ${shellQuote(tarball)} -C ${shellQuote(destDir)}${stripFlag} -P`;
1643
+ try {
1644
+ execCaptureErr(cmd);
1645
+ return;
1646
+ } catch (e) {
1647
+ const msg = e?.message ?? "";
1648
+ const hasFalseAlarm = msg.includes("Directory renamed before its status could be extracted");
1649
+ const hasFatal = [
1650
+ /Cannot open/i,
1651
+ /Cannot mkdir/i,
1652
+ /Cannot create/i,
1653
+ /No space left on device/i,
1654
+ /Disk quota exceeded/i,
1655
+ /Permission denied/i,
1656
+ /Read-only file system/i,
1657
+ /unrecognized option/i,
1658
+ /gzip:/i,
1659
+ /Unexpected EOF/i,
1660
+ /Invalid argument/i
1661
+ ].some((r) => r.test(msg));
1662
+ if (!hasFalseAlarm || hasFatal) throw e;
1663
+ console.error(`[tar] -P did not suppress "Directory renamed" on ${tarball}; tolerating (content must be verified by caller)`);
1664
+ }
1665
+ }
1666
+ //#endregion
1667
+ //#region src/rules/feishu-plugin-state-normalize.ts
1668
+ const PLUGIN_NAME$1 = "openclaw-lark";
1669
+ const BUILTIN_FEISHU = "feishu";
1670
+ const LEGACY_PLUGIN_NAME = "feishu-openclaw-plugin";
1671
+ const LEGACY_DIRS_TO_REMOVE = [LEGACY_PLUGIN_NAME, BUILTIN_FEISHU];
1672
+ const FEISHU_TOOLS = Object.freeze([
1673
+ "feishu_bitable_app",
1674
+ "feishu_bitable_app_table",
1675
+ "feishu_bitable_app_table_field",
1676
+ "feishu_bitable_app_table_record",
1677
+ "feishu_bitable_app_table_view",
1678
+ "feishu_calendar_calendar",
1679
+ "feishu_calendar_event",
1680
+ "feishu_calendar_event_attendee",
1681
+ "feishu_calendar_freebusy",
1682
+ "feishu_chat",
1683
+ "feishu_chat_members",
1684
+ "feishu_create_doc",
1685
+ "feishu_doc_comments",
1686
+ "feishu_doc_media",
1687
+ "feishu_drive_file",
1688
+ "feishu_fetch_doc",
1689
+ "feishu_get_user",
1690
+ "feishu_im_bot_image",
1691
+ "feishu_im_user_fetch_resource",
1692
+ "feishu_im_user_get_messages",
1693
+ "feishu_im_user_get_thread_messages",
1694
+ "feishu_im_user_message",
1695
+ "feishu_im_user_search_messages",
1696
+ "feishu_oauth",
1697
+ "feishu_oauth_batch_auth",
1698
+ "feishu_search_doc_wiki",
1699
+ "feishu_search_user",
1700
+ "feishu_sheet",
1701
+ "feishu_task_comment",
1702
+ "feishu_task_subtask",
1703
+ "feishu_task_task",
1704
+ "feishu_task_tasklist",
1705
+ "feishu_update_doc",
1706
+ "feishu_wiki_space",
1707
+ "feishu_wiki_space_node"
1708
+ ]);
1709
+ /**
1710
+ * 飞书插件状态规范化:fs 上 <extDir>/openclaw-lark/ 已落盘后统一收尾环境。
1711
+ * 各 fail 与 repair 一一对应,详见 fails.push(...) 字符串与 repair() 调用。
1712
+ */
1713
+ let FeishuPluginStateNormalizeRule = class FeishuPluginStateNormalizeRule extends DiagnoseRule {
1714
+ validate(ctx) {
1715
+ if (!isPluginInstalled(ctx)) return { pass: true };
1716
+ const fails = [];
1717
+ if (!isNewPluginEnabled(ctx.config)) fails.push(`plugins.entries["${PLUGIN_NAME$1}"].enabled !== true(应启用)`);
1718
+ if (isBuiltinFeishuEnabled(ctx.config)) fails.push("plugins.entries.feishu.enabled === true(应禁用)");
1719
+ if (isTopLevelMissingFeishuTools(ctx.config)) fails.push("tools.alsoAllow 缺 feishu_* tools");
1720
+ const legacyResiduals = findLegacyResiduals(ctx);
1721
+ if (legacyResiduals.length > 0) fails.push(`legacy 飞书插件残留:${legacyResiduals.join(", ")}`);
1722
+ if (fails.length === 0) return { pass: true };
1723
+ return {
1724
+ pass: false,
1725
+ message: fails.join(";")
1726
+ };
1727
+ }
1728
+ repair(ctx) {
1729
+ setEntryEnabled(ctx.config, PLUGIN_NAME$1, true);
1730
+ setEntryEnabled(ctx.config, BUILTIN_FEISHU, false);
1731
+ ensureFeishuTools(ctx.config);
1732
+ cleanupLegacyResiduals(ctx);
1733
+ }
1734
+ };
1735
+ FeishuPluginStateNormalizeRule = __decorate([Rule({
1736
+ key: "feishu_plugin_state_normalize",
1737
+ dependsOn: ["config_syntax_check"],
1738
+ repairMode: "standard"
1739
+ })], FeishuPluginStateNormalizeRule);
1740
+ function isPluginInstalled(ctx) {
1741
+ return node_fs.default.existsSync(node_path.default.join(getExtensionsDir(ctx.configPath), PLUGIN_NAME$1));
1742
+ }
1743
+ function isNewPluginEnabled(config) {
1744
+ return asRecord(getNestedMap(config, "plugins", "entries")?.[PLUGIN_NAME$1])?.enabled === true;
1745
+ }
1746
+ function isBuiltinFeishuEnabled(config) {
1747
+ return asRecord(getNestedMap(config, "plugins", "entries")?.[BUILTIN_FEISHU])?.enabled === true;
1748
+ }
1749
+ /** 仅看顶层 tools.alsoAllow——agent 级 alsoAllow 是用户对单 agent 的精细化授权,doctor 不动。
1750
+ * 含 "*" 或任一 feishu_* 即视为已配。 */
1751
+ function isTopLevelMissingFeishuTools(config) {
1752
+ return !hasFeishuTool(readAlsoAllow(config));
1753
+ }
1754
+ function readAlsoAllow(host) {
1755
+ const tools = asRecord(host)?.tools;
1756
+ const arr = asRecord(tools)?.alsoAllow;
1757
+ if (!Array.isArray(arr)) return [];
1758
+ return arr.filter((e) => typeof e === "string");
1759
+ }
1760
+ function hasFeishuTool(alsoAllow) {
1761
+ if (alsoAllow.includes("*")) return true;
1762
+ return alsoAllow.some((t) => FEISHU_TOOLS.includes(t));
1763
+ }
1764
+ function findLegacyResiduals(ctx) {
1765
+ const found = [];
1766
+ const plugins = asRecord(ctx.config.plugins);
1767
+ if (asRecord(plugins?.entries)?.[LEGACY_PLUGIN_NAME] != null) found.push("entries[legacy]");
1768
+ const allow = plugins?.allow;
1769
+ if (Array.isArray(allow) && allow.includes(LEGACY_PLUGIN_NAME)) found.push("allow[legacy]");
1770
+ if (asRecord(plugins?.installs)?.[LEGACY_PLUGIN_NAME] != null) found.push("installs[legacy]");
1771
+ const extDir = getExtensionsDir(ctx.configPath);
1772
+ for (const name of LEGACY_DIRS_TO_REMOVE) if (node_fs.default.existsSync(node_path.default.join(extDir, name))) found.push(`fs/${name}`);
1773
+ return found;
1774
+ }
1775
+ function setEntryEnabled(config, key, enabled) {
1776
+ const entries = ensureRecord(ensureRecord(config, "plugins"), "entries");
1777
+ entries[key] = {
1778
+ ...asRecord(entries[key]) ?? {},
1779
+ enabled
1780
+ };
1781
+ }
1782
+ function ensureFeishuTools(config) {
1783
+ const alsoAllow = readAlsoAllow(config);
1784
+ if (hasFeishuTool(alsoAllow)) return;
1785
+ ensureRecord(config, "tools").alsoAllow = [...new Set([...alsoAllow, ...FEISHU_TOOLS])];
1786
+ }
1787
+ function cleanupLegacyResiduals(ctx) {
1788
+ const plugins = asRecord(ctx.config.plugins);
1789
+ if (plugins) {
1790
+ const entries = asRecord(plugins.entries);
1791
+ if (entries && LEGACY_PLUGIN_NAME in entries) delete entries[LEGACY_PLUGIN_NAME];
1792
+ const installs = asRecord(plugins.installs);
1793
+ if (installs && LEGACY_PLUGIN_NAME in installs) delete installs[LEGACY_PLUGIN_NAME];
1794
+ const allow = plugins.allow;
1795
+ if (Array.isArray(allow)) {
1796
+ for (let i = allow.length - 1; i >= 0; i--) if (allow[i] === LEGACY_PLUGIN_NAME) allow.splice(i, 1);
1797
+ if (!allow.includes(PLUGIN_NAME$1)) allow.push(PLUGIN_NAME$1);
1798
+ }
1799
+ }
1800
+ const extDir = getExtensionsDir(ctx.configPath);
1801
+ for (const name of LEGACY_DIRS_TO_REMOVE) {
1802
+ const target = node_path.default.join(extDir, name);
1803
+ const rel = node_path.default.relative(extDir, target);
1804
+ if (!rel || rel.startsWith("..") || node_path.default.isAbsolute(rel)) continue;
1805
+ try {
1806
+ rmrfTolerant(target);
1807
+ } catch (e) {
1808
+ console.error(`[feishu_plugin_state_normalize] rmrf ${target} failed: ${e.message}`);
1809
+ }
1810
+ }
1811
+ }
1812
+ function ensureRecord(obj, key) {
1813
+ const cur = obj[key];
1814
+ if (cur != null && typeof cur === "object" && !Array.isArray(cur)) return cur;
1815
+ const fresh = {};
1816
+ obj[key] = fresh;
1817
+ return fresh;
1818
+ }
1819
+ //#endregion
1820
+ //#region src/version-compat.ts
1821
+ const VERSION_COMPAT_MAP = Object.freeze([
1822
+ {
1823
+ openclawLarkVersion: "2026.4.8",
1824
+ minOpenclawVersion: "2026.3.28"
1825
+ },
1826
+ {
1827
+ openclawLarkVersion: "2026.4.7",
1828
+ minOpenclawVersion: "2026.3.28"
1829
+ },
1830
+ {
1831
+ openclawLarkVersion: "2026.4.1",
1832
+ minOpenclawVersion: "2026.3.28"
1833
+ },
1834
+ {
1835
+ openclawLarkVersion: "2026.3.31",
1836
+ minOpenclawVersion: "2026.3.28"
1837
+ },
1838
+ {
1839
+ openclawLarkVersion: "2026.3.30",
1840
+ minOpenclawVersion: "2026.3.28"
1841
+ },
1842
+ {
1843
+ openclawLarkVersion: "2026.3.29",
1844
+ minOpenclawVersion: "2026.3.28"
1845
+ },
1846
+ {
1847
+ openclawLarkVersion: "2026.3.26",
1848
+ minOpenclawVersion: "2026.3.22"
1849
+ },
1850
+ {
1851
+ openclawLarkVersion: "2026.3.25",
1852
+ minOpenclawVersion: "2026.3.22"
1853
+ },
1854
+ {
1855
+ openclawLarkVersion: "2026.3.24",
1856
+ minOpenclawVersion: "2026.3.22"
1857
+ },
1858
+ {
1859
+ openclawLarkVersion: "2026.3.18",
1860
+ minOpenclawVersion: "2026.2.26",
1861
+ maxOpenclawVersion: "2026.3.13"
1862
+ },
1863
+ {
1864
+ openclawLarkVersion: "2026.3.17",
1865
+ minOpenclawVersion: "2026.2.26",
1866
+ maxOpenclawVersion: "2026.3.13"
1867
+ },
1868
+ {
1869
+ openclawLarkVersion: "2026.3.15",
1870
+ minOpenclawVersion: "2026.2.26",
1871
+ maxOpenclawVersion: "2026.3.13"
1872
+ },
1873
+ {
1874
+ openclawLarkVersion: "2026.3.12",
1875
+ minOpenclawVersion: "2026.2.26",
1876
+ maxOpenclawVersion: "2026.3.13"
1877
+ },
1878
+ {
1879
+ openclawLarkVersion: "2026.3.10",
1880
+ minOpenclawVersion: "2026.2.26",
1881
+ maxOpenclawVersion: "2026.3.13"
1882
+ },
1883
+ {
1884
+ openclawLarkVersion: "2026.3.9",
1885
+ minOpenclawVersion: "2026.2.26",
1886
+ maxOpenclawVersion: "2026.3.13"
1887
+ }
1888
+ ]);
1889
+ /**
1890
+ * "YYYY.M.D" 按数值分量比较(宽松解析:缺失/非数字段视为 0,suffix 如 "-rc.1" 被忽略)。
1891
+ * 不能用字符串比较 —— '2026.3.18' < '2026.3.9' 字符串语义为 true。
1892
+ */
1893
+ function compareCalVer(a, b) {
1894
+ const pa = parseCalVer(a);
1895
+ const pb = parseCalVer(b);
1896
+ for (let i = 0; i < 3; i++) {
1897
+ if (pa[i] < pb[i]) return -1;
1898
+ if (pa[i] > pb[i]) return 1;
1899
+ }
1900
+ return 0;
1901
+ }
1902
+ function parseCalVer(v) {
1903
+ const parts = v.split(".");
1904
+ return [
1905
+ toNum(parts[0]),
1906
+ toNum(parts[1]),
1907
+ toNum(parts[2])
1908
+ ];
1909
+ }
1910
+ function toNum(s) {
1911
+ const n = s != null ? parseInt(s, 10) : 0;
1912
+ return Number.isFinite(n) && n >= 0 ? n : 0;
1913
+ }
1914
+ /** Whether the given openclaw version falls inside this plugin entry's compat range. */
1915
+ function compatible(entry, openclawVersion) {
1916
+ if (compareCalVer(openclawVersion, entry.minOpenclawVersion) < 0) return false;
1917
+ if (entry.maxOpenclawVersion != null && compareCalVer(openclawVersion, entry.maxOpenclawVersion) > 0) return false;
1918
+ return true;
1919
+ }
1920
+ /** Look up an entry by exact plugin version; undefined if not in the table. */
1921
+ function findEntry(pluginVersion) {
1922
+ return VERSION_COMPAT_MAP.find((e) => e.openclawLarkVersion === pluginVersion);
1923
+ }
1924
+ //#endregion
1925
+ //#region src/rules/feishu-plugin-version-compat.ts
1926
+ const PLUGIN_NAME = "openclaw-lark";
1927
+ const LEGACY_SHORT_NAMES = ["feishu-openclaw-plugin"];
1928
+ const FORK_SCOPES = ["@lark-apaas"];
1929
+ /**
1930
+ * 飞书插件 ↔ openclaw 版本兼容检测。
1931
+ *
1932
+ * 检测顺序(短路):
1933
+ * 1. fork 魔改版(@lark-apaas scope)→ pass,不查版本
1934
+ * 2. legacy 老插件(feishu-openclaw-plugin 短名)→ 走升级流程
1935
+ * 3. 官方版(@larksuite scope 或其他)→ 按 VERSION_COMPAT_MAP 校验
1936
+ *
1937
+ * 决策方向:oc < 推荐 → upgrade_openclaw;oc ≥ 推荐 → upgrade_lark。
1938
+ *
1939
+ * `repairMode: user-confirm` —— 失败结果进 failedRules.userConfirm;CLI 不
1940
+ * 自动执行升级,由消费方弹用户确认后调对应升级接口。
1941
+ */
1942
+ let FeishuPluginVersionCompatRule = class FeishuPluginVersionCompatRule extends DiagnoseRule {
1943
+ validate(ctx) {
1944
+ const recommendedOc = ctx.vars.recommendedOpenclawTag;
1945
+ if (!recommendedOc) {
1946
+ console.error("[feishu_plugin_version_compat] vars.recommendedOpenclawTag 未注入,跳过本规则");
1947
+ return { pass: true };
1948
+ }
1949
+ const ocCur = readOpenclawRuntimeVersion();
1950
+ if (!ocCur) return { pass: true };
1951
+ const installed = detectInstalledPlugin(ctx);
1952
+ if (installed == null) return { pass: true };
1953
+ if (isForkPlugin(installed)) return { pass: true };
1954
+ const isLegacy = isLegacyPlugin(installed);
1955
+ if (!isLegacy && isVersionCompatible(installed, ocCur)) return { pass: true };
1956
+ return decideUpgrade({
1957
+ ocCur,
1958
+ recommendedOc,
1959
+ installed,
1960
+ isLegacy
1961
+ });
1962
+ }
1963
+ };
1964
+ FeishuPluginVersionCompatRule = __decorate([Rule({
1965
+ key: "feishu_plugin_version_compat",
1966
+ dependsOn: ["config_syntax_check"],
1967
+ repairMode: "user-confirm",
1968
+ usesVars: ["recommendedOpenclawTag"]
1969
+ })], FeishuPluginVersionCompatRule);
1970
+ function isForkPlugin(p) {
1971
+ return p.scope != null && FORK_SCOPES.includes(p.scope);
1972
+ }
1973
+ function isLegacyPlugin(p) {
1974
+ return LEGACY_SHORT_NAMES.includes(p.allowName);
1975
+ }
1976
+ /** 装了但版本读不到、版本不在表里、或不在 entry 区间内,都视为不兼容。 */
1977
+ function isVersionCompatible(p, ocCur) {
1978
+ if (!p.version) return false;
1979
+ const entry = findEntry(p.version);
1980
+ return entry != null && compatible(entry, ocCur);
1981
+ }
1982
+ function decideUpgrade(args) {
1983
+ const { ocCur, recommendedOc, installed, isLegacy } = args;
1984
+ const desc = describePlugin(installed);
1985
+ const prefix = isLegacy ? `检测到老飞书插件 (${desc})` : `飞书插件 ${desc} 与 openclaw@${ocCur} 不兼容`;
1986
+ if (compareCalVer(ocCur, recommendedOc) < 0) return {
1987
+ pass: false,
1988
+ action: "upgrade_openclaw",
1989
+ message: `${prefix};将 openclaw 升级到 ${recommendedOc},飞书插件会随之同步升级`
1990
+ };
1991
+ return {
1992
+ pass: false,
1993
+ action: "upgrade_lark",
1994
+ message: `${prefix};当前 openclaw@${ocCur} 已达推荐版本,可直接升级飞书插件`
1995
+ };
1996
+ }
1997
+ function describePlugin(p) {
1998
+ return (p.fullName ?? p.allowName) + (p.version ? `@${p.version}` : "");
1999
+ }
2000
+ function readPluginPackageJson(filePath) {
2001
+ try {
2002
+ if (!node_fs.default.existsSync(filePath)) return null;
2003
+ const raw = node_fs.default.readFileSync(filePath, "utf-8");
2004
+ const parsed = JSON.parse(raw);
2005
+ return {
2006
+ name: typeof parsed.name === "string" ? parsed.name : void 0,
2007
+ version: typeof parsed.version === "string" ? parsed.version : void 0
2008
+ };
2009
+ } catch {
2010
+ return null;
2011
+ }
2012
+ }
2013
+ /** "已装" = plugins.allow 含名 AND extensions/<name>/package.json 真实存在。 */
2014
+ function detectInstalledPlugin(ctx) {
2015
+ const allowRaw = asRecord(ctx.config.plugins)?.allow;
2016
+ const allow = Array.isArray(allowRaw) ? allowRaw.filter((e) => typeof e === "string") : [];
2017
+ const extDir = getExtensionsDir(ctx.configPath);
2018
+ const installs = getNestedMap(ctx.config, "plugins", "installs");
2019
+ for (const name of [PLUGIN_NAME, ...LEGACY_SHORT_NAMES]) {
2020
+ if (!allow.includes(name)) continue;
2021
+ const pkgPath = node_path.default.join(extDir, name, "package.json");
2022
+ if (!node_fs.default.existsSync(pkgPath)) continue;
2023
+ const pkg = readPluginPackageJson(pkgPath) ?? {};
2024
+ const installEntry = installs && asRecord(installs[name]);
2025
+ const fullName = pkg.name ?? extractScopedNameFromSpec(installEntry?.spec);
2026
+ return {
2027
+ allowName: name,
2028
+ fullName,
2029
+ scope: fullName?.startsWith("@") ? fullName.split("/")[0] : void 0,
2030
+ version: pkg.version ?? (typeof installEntry?.version === "string" ? installEntry.version : void 0)
2031
+ };
2032
+ }
2033
+ return null;
2034
+ }
2035
+ /** "@scope/name@1.2.3" / "name@1.2.3" / "@scope/name" / "name" → 去掉 @version 后缀 */
2036
+ function extractScopedNameFromSpec(spec) {
2037
+ if (typeof spec !== "string") return void 0;
2038
+ const at = spec.indexOf("@", 1);
2039
+ return at === -1 ? spec : spec.slice(0, at);
2040
+ }
2041
+ //#endregion
1407
2042
  //#region src/check.ts
1408
2043
  /** Telemetry-aware entry: returns both the legacy CheckResult (for stdout)
1409
2044
  * AND a DoctorReport-shape payload (for `openclaw.report_cli_run`). The
@@ -1416,7 +2051,8 @@ function runCheckImpl(input) {
1416
2051
  const result = { failedRules: {
1417
2052
  standard: [],
1418
2053
  ai: [],
1419
- reset: []
2054
+ reset: [],
2055
+ userConfirm: []
1420
2056
  } };
1421
2057
  const outcomes = [];
1422
2058
  const disabledSet = new Set(input.disabledRules || []);
@@ -1516,10 +2152,11 @@ function runCheckImpl(input) {
1516
2152
  outcomes.push({
1517
2153
  rule: meta.key,
1518
2154
  status: "failed",
1519
- message: r.message
2155
+ message: r.message,
2156
+ action: r.action
1520
2157
  });
1521
2158
  failedKeys.add(meta.key);
1522
- pushFailedRule(result, meta.repairMode, meta.key, r.message || "");
2159
+ pushFailedRule(result, meta.repairMode, meta.key, r.message || "", r.action);
1523
2160
  }
1524
2161
  }
1525
2162
  console.error(`runCheck: end totalMs=${Date.now() - tStart} failed={standard:${result.failedRules.standard.length},ai:${result.failedRules.ai.length},reset:${result.failedRules.reset.length}}`);
@@ -1528,7 +2165,7 @@ function runCheckImpl(input) {
1528
2165
  report: finalize$2(outcomes, aborted)
1529
2166
  };
1530
2167
  }
1531
- function pushFailedRule(result, repairMode, key, detail) {
2168
+ function pushFailedRule(result, repairMode, key, detail, action) {
1532
2169
  switch (repairMode) {
1533
2170
  case "standard":
1534
2171
  result.failedRules.standard.push(key);
@@ -1545,6 +2182,13 @@ function pushFailedRule(result, repairMode, key, detail) {
1545
2182
  detail
1546
2183
  });
1547
2184
  break;
2185
+ case "user-confirm":
2186
+ result.failedRules.userConfirm.push({
2187
+ rule_name: key,
2188
+ detail,
2189
+ action
2190
+ });
2191
+ break;
1548
2192
  }
1549
2193
  }
1550
2194
  function finalize$2(results, aborted) {
@@ -1587,173 +2231,6 @@ function finalize$2(results, aborted) {
1587
2231
  };
1588
2232
  }
1589
2233
  //#endregion
1590
- //#region src/fs-utils.ts
1591
- /**
1592
- * Rename src → dst, falling back to `mv` (which handles cross-device copy)
1593
- * when the kernel returns EXDEV.
1594
- *
1595
- * Sandbox filesystems can put sibling paths on different "devices" from
1596
- * rename(2)'s point of view: bind mounts, overlayfs copy-up, and
1597
- * mount-point children inside a single directory all trip EXDEV. Seen in
1598
- * production when reset's atomic swap did
1599
- * /home/gem/.npm-global/lib/node_modules/openclaw → openclaw.bak
1600
- * and the openclaw subdir was a bind-mounted volume.
1601
- *
1602
- * Behavior:
1603
- * - Happy path hits rename(2) — atomic, single syscall, microseconds.
1604
- * - EXDEV path shells out to `mv`, which does rename() then copy+unlink
1605
- * on failure. Non-atomic but correct; callers already have rollback
1606
- * logic (install-openclaw restores from .bak) so loss of atomicity
1607
- * only matters if the process dies mid-copy, and that's survivable.
1608
- * - Any other error (ENOENT, EACCES, EBUSY...) rethrows as-is so callers
1609
- * see the real problem instead of a misleading `mv` fallback failure.
1610
- */
1611
- function moveSafe(src, dst) {
1612
- try {
1613
- node_fs.default.renameSync(src, dst);
1614
- } catch (e) {
1615
- if (e?.code !== "EXDEV") throw e;
1616
- execCaptureErr(`mv ${shellQuote(src)} ${shellQuote(dst)}`);
1617
- }
1618
- }
1619
- /**
1620
- * Run a shell command, re-throwing with stderr attached on failure.
1621
- *
1622
- * Node's `execSync(..., { stdio: 'ignore' })` swallows stderr entirely —
1623
- * callers only see "Command failed: <cmd>" with no hint of the real error
1624
- * (ENOSPC, EROFS, "unrecognized option", etc.). Production debugging on
1625
- * sandboxed boxes is painful without the underlying message, so we pipe
1626
- * stderr, capture it, and embed it in the thrown Error. stdout stays
1627
- * suppressed because the commands we run here (tar/mv) are silent on
1628
- * success.
1629
- */
1630
- function execCaptureErr(cmd) {
1631
- try {
1632
- (0, node_child_process.execSync)(cmd, { stdio: [
1633
- "ignore",
1634
- "ignore",
1635
- "pipe"
1636
- ] });
1637
- } catch (e) {
1638
- const stderr = e?.stderr;
1639
- const stderrStr = (typeof stderr === "string" ? stderr : stderr?.toString("utf8") ?? "").trim();
1640
- const base = e?.message ?? "command failed";
1641
- throw new Error(stderrStr ? `${base}\nstderr: ${stderrStr}` : base);
1642
- }
1643
- }
1644
- /** POSIX single-quote shell escape. Paths with embedded quotes are rare but
1645
- * the token-file path conventions in sandboxes don't guarantee cleanliness. */
1646
- function shellQuote(s) {
1647
- return `'${s.replace(/'/g, `'\\''`)}'`;
1648
- }
1649
- /**
1650
- * Snapshot the current `openclaw.json` to `<configPath>.<YYYYMMDD_HHMMSS>.bak`
1651
- * before a repair / fix flow rewrites it. Local-time stamp matches the
1652
- * shell-style backup convention (`cp openclaw.json openclaw.json.$(date
1653
- * +%Y%m%d_%H%M%S).bak`) so operators triaging on the sandbox see the
1654
- * familiar filename pattern.
1655
- *
1656
- * Returns the absolute backup path on success, or `null` when:
1657
- * - source doesn't exist (first-time setup; nothing to back up)
1658
- * - copy fails for any reason (disk full, permission denied, etc.)
1659
- *
1660
- * Failure is logged to cli.log via console.error and swallowed — the
1661
- * caller's repair/fix work goes ahead either way. The backup is a safety
1662
- * net, not a correctness gate; better to do the repair than refuse over
1663
- * a missing rollback file.
1664
- */
1665
- function backupConfigSync(configPath) {
1666
- if (!node_fs.default.existsSync(configPath)) {
1667
- console.error(`backupConfigSync: skip — source missing path=${configPath}`);
1668
- return null;
1669
- }
1670
- const d = /* @__PURE__ */ new Date();
1671
- const pad = (n) => String(n).padStart(2, "0");
1672
- const backupPath = `${configPath}.${`${d.getFullYear()}${pad(d.getMonth() + 1)}${pad(d.getDate())}_${pad(d.getHours())}${pad(d.getMinutes())}${pad(d.getSeconds())}`}.bak`;
1673
- try {
1674
- node_fs.default.copyFileSync(configPath, backupPath);
1675
- const bytes = node_fs.default.statSync(backupPath).size;
1676
- console.error(`backupConfigSync: ok src=${configPath} dst=${backupPath} bytes=${bytes}`);
1677
- return backupPath;
1678
- } catch (e) {
1679
- console.error(`backupConfigSync: failed src=${configPath} message=${e.message}`);
1680
- return null;
1681
- }
1682
- }
1683
- /**
1684
- * Extract an npm-packed gzipped tarball.
1685
- *
1686
- * ## The problem this works around
1687
- *
1688
- * Some tarballs (openclaw's among them — they're not packed by vanilla
1689
- * `npm pack`) include relative symlinks inside nested .bin/ dirs whose
1690
- * targets contain `..`, e.g.
1691
- * node_modules/<pkg>/node_modules/.bin/foo -> ../foo/bin/cli.js
1692
- *
1693
- * GNU tar classifies any symlink target with `..` or a leading `/` as
1694
- * "dangerous" and defers its extraction to a post-files pass, while also
1695
- * needing a post-files pass to restore directory permissions/mtimes. The
1696
- * two passes race: the deferred-symlink handling mutates parent-dir inodes,
1697
- * then the directory stat-restore pass does `fstatat()` and the recorded
1698
- * inode doesn't match, firing
1699
- *
1700
- * tar: <path>: Directory renamed before its status could be extracted
1701
- *
1702
- * from `apply_nonancestor_delayed_set_stat()` in extract.c. This is an
1703
- * `ERROR` (hard-fail, exit 2) — the `--warning=no-rename-directory`
1704
- * keyword controls a different, incremental-archive code path and does
1705
- * NOT silence this. Reference: Paul Eggert, bug-tar 2004-04:
1706
- * https://lists.gnu.org/archive/html/bug-tar/2004-04/msg00021.html
1707
- *
1708
- * ## The fix
1709
- *
1710
- * Pass `--absolute-names` (aka `-P`). Per GNU tar docs, this disables the
1711
- * "normalize dangerous names" logic — including the deferred-symlink pass
1712
- * that's racing us. Also stops stripping leading `/`, but our tarballs
1713
- * only contain relative (`./node_modules/...`) paths so there's nothing
1714
- * to strip. Safe because:
1715
- * - The tarball is sha512-verified upstream (downloadWithCache)
1716
- * - All entry paths are relative, no absolute-path escape risk
1717
- * - All dangerous symlink targets resolve within the extracted tree
1718
- *
1719
- * ## Belt-and-suspenders
1720
- *
1721
- * If some tar variant still emits the error despite -P, we fall through
1722
- * to checking the stderr pattern: if every error line is the benign
1723
- * "Directory renamed …" text (no real failures like ENOSPC/EACCES/gzip
1724
- * CRC/etc.), swallow exit 2. Callers MUST still verify extraction
1725
- * (e.g. `fs.existsSync(path.join(dest, 'package.json'))`) — tar's
1726
- * `skip_this_one = 1` after the error means some dirs missed their
1727
- * mtime/mode restore, but content was written.
1728
- */
1729
- function extractTarballTolerant(tarball, destDir, opts = {}) {
1730
- const strip = opts.stripComponents ?? 0;
1731
- const stripFlag = strip > 0 ? ` --strip-components=${strip}` : "";
1732
- const cmd = `tar -xzf ${shellQuote(tarball)} -C ${shellQuote(destDir)}${stripFlag} -P`;
1733
- try {
1734
- execCaptureErr(cmd);
1735
- return;
1736
- } catch (e) {
1737
- const msg = e?.message ?? "";
1738
- const hasFalseAlarm = msg.includes("Directory renamed before its status could be extracted");
1739
- const hasFatal = [
1740
- /Cannot open/i,
1741
- /Cannot mkdir/i,
1742
- /Cannot create/i,
1743
- /No space left on device/i,
1744
- /Disk quota exceeded/i,
1745
- /Permission denied/i,
1746
- /Read-only file system/i,
1747
- /unrecognized option/i,
1748
- /gzip:/i,
1749
- /Unexpected EOF/i,
1750
- /Invalid argument/i
1751
- ].some((r) => r.test(msg));
1752
- if (!hasFalseAlarm || hasFatal) throw e;
1753
- console.error(`[tar] -P did not suppress "Directory renamed" on ${tarball}; tolerating (content must be verified by caller)`);
1754
- }
1755
- }
1756
- //#endregion
1757
2234
  //#region src/repair.ts
1758
2235
  /** Telemetry-aware entry. Same per-rule pass + side effects, but also
1759
2236
  * builds a `DoctorReport`-shape payload from per-rule revalidate
@@ -2210,22 +2687,88 @@ function startAsyncReset(ctxBase64) {
2210
2687
  }
2211
2688
  //#endregion
2212
2689
  //#region src/oss/fetchWithDiag.ts
2690
+ /** Methods retried automatically on transient transport errors. POST and
2691
+ * PATCH are deliberately excluded: a transient ECONNRESET / ETIMEDOUT
2692
+ * after the request body has been written might mean the server
2693
+ * received and processed it (just couldn't reply) — auto-retrying
2694
+ * would cause duplicate side effects. AWS / GCP / Aliyun SDKs all use
2695
+ * the same allowlist. */
2696
+ const IDEMPOTENT_METHODS = new Set([
2697
+ "GET",
2698
+ "HEAD",
2699
+ "OPTIONS",
2700
+ "DELETE",
2701
+ "PUT"
2702
+ ]);
2703
+ /** Errno codes treated as transient — almost always a stale connection
2704
+ * or momentary network blip that succeeds on retry. The list mirrors
2705
+ * what AWS / GCP / Aliyun SDKs retry by default. Only used for fetch
2706
+ * rejection cause; HTTP 4xx/5xx responses pass through unchanged
2707
+ * (caller decides whether the application-level status is retryable). */
2708
+ const TRANSIENT_CODES = new Set([
2709
+ "ECONNRESET",
2710
+ "ETIMEDOUT",
2711
+ "EPIPE",
2712
+ "ECONNREFUSED",
2713
+ "EHOSTUNREACH",
2714
+ "ENETUNREACH",
2715
+ "EAI_AGAIN"
2716
+ ]);
2213
2717
  async function fetchWithDiag(url, opts = {}) {
2718
+ const maxRetries = opts.maxRetries ?? 2;
2719
+ const method = (opts.init?.method ?? "GET").toUpperCase();
2720
+ const retrySafe = opts.retryNonIdempotent || IDEMPOTENT_METHODS.has(method);
2721
+ let lastErr;
2722
+ for (let attempt = 0; attempt <= maxRetries; attempt++) try {
2723
+ return await fetchOnce(url, opts);
2724
+ } catch (e) {
2725
+ lastErr = e;
2726
+ const code = extractCauseCode(e);
2727
+ if (!(code !== void 0 && TRANSIENT_CODES.has(code)) || attempt === maxRetries) throw e;
2728
+ if (!retrySafe) {
2729
+ console.error(`fetch ${opts.label ?? originOf(url)}: transient ${code} but method=${method} is non-idempotent, NOT retrying`);
2730
+ throw e;
2731
+ }
2732
+ const delay = 200 * Math.pow(2, attempt) + Math.floor(Math.random() * 100);
2733
+ console.error(`fetch ${opts.label ?? originOf(url)}: transient ${code}, retrying in ${delay}ms (attempt ${attempt + 1}/${maxRetries})`);
2734
+ await new Promise((r) => setTimeout(r, delay));
2735
+ }
2736
+ throw lastErr;
2737
+ }
2738
+ /** One fetch attempt with timeout. Throws on transport / abort failures
2739
+ * (caught by the retry loop above) and on its own enriched message form. */
2740
+ async function fetchOnce(url, opts) {
2214
2741
  const label = opts.label ?? originOf(url);
2215
2742
  const timeoutMs = opts.timeoutMs ?? 3e4;
2216
2743
  const start = Date.now();
2217
2744
  const ac = new AbortController();
2218
2745
  const timer = timeoutMs > 0 && Number.isFinite(timeoutMs) ? setTimeout(() => ac.abort(), timeoutMs) : void 0;
2219
2746
  try {
2220
- return await fetch(url, { signal: ac.signal });
2747
+ return await fetch(url, {
2748
+ ...opts.init,
2749
+ signal: ac.signal
2750
+ });
2221
2751
  } catch (e) {
2222
2752
  const durationMs = Date.now() - start;
2223
2753
  const causeStr = e.name === "AbortError" || ac.signal.aborted && timeoutMs > 0 ? `request aborted after ${timeoutMs}ms (timeout)` : describeCause(e.cause) || e.message;
2224
- throw new Error(`fetch ${label} failed: ${causeStr} (url=${redactUrl(url)} durationMs=${durationMs})`);
2754
+ const enriched = /* @__PURE__ */ new Error(`fetch ${label} failed: ${causeStr} (url=${redactUrl(url)} durationMs=${durationMs})`);
2755
+ enriched.cause = e.cause ?? e;
2756
+ throw enriched;
2225
2757
  } finally {
2226
2758
  if (timer) clearTimeout(timer);
2227
2759
  }
2228
2760
  }
2761
+ /** Pull the errno-style code from the deepest cause we can reach. Used by
2762
+ * retry-classification only — error messages still get the human-readable
2763
+ * describeCause() rendering. */
2764
+ function extractCauseCode(e) {
2765
+ let cur = e.cause ?? e;
2766
+ for (let depth = 0; depth < 5 && cur; depth++) {
2767
+ const code = cur.code;
2768
+ if (typeof code === "string") return code;
2769
+ cur = cur.cause;
2770
+ }
2771
+ }
2229
2772
  /** Walk the Error.cause chain and produce a single-line summary like
2230
2773
  * `ENOTFOUND getaddrinfo ENOTFOUND oss.example.com`. */
2231
2774
  function describeCause(c, depth = 0) {
@@ -3504,7 +4047,8 @@ async function runDoctor(rawCtx, opts) {
3504
4047
  results.push({
3505
4048
  rule: key,
3506
4049
  status: "failed",
3507
- message: v1.message
4050
+ message: v1.message,
4051
+ action: v1.action
3508
4052
  });
3509
4053
  failedKeys.add(key);
3510
4054
  continue;
@@ -3514,7 +4058,8 @@ async function runDoctor(rawCtx, opts) {
3514
4058
  results.push({
3515
4059
  rule: key,
3516
4060
  status: "failed",
3517
- message: v1.message
4061
+ message: v1.message,
4062
+ action: v1.action
3518
4063
  });
3519
4064
  failedKeys.add(key);
3520
4065
  continue;
@@ -3731,7 +4276,7 @@ async function reportCliRun(opts) {
3731
4276
  //#region src/help.ts
3732
4277
  const BIN = "mclaw-diagnose";
3733
4278
  function versionBanner() {
3734
- return `v0.1.4-alpha.1`;
4279
+ return `v0.1.4-alpha.2`;
3735
4280
  }
3736
4281
  const COMMANDS = [
3737
4282
  {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lark-apaas/openclaw-scripts-diagnose-cli",
3
- "version": "0.1.4-alpha.1",
3
+ "version": "0.1.4-alpha.2",
4
4
  "description": "CLI for OpenClaw config diagnose and repair with JSON5 support",
5
5
  "main": "dist/index.cjs",
6
6
  "bin": {