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

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