@alfe.ai/integrations 0.2.6 → 0.2.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.d.ts CHANGED
@@ -951,6 +951,16 @@ declare class OpenClawApplier implements RuntimeApplier {
951
951
  * plugins under 2026.5+, which made force-mode skip its uninstall step.
952
952
  */
953
953
  private isPluginInstalled;
954
+ /**
955
+ * Best-effort read of the installed version of a plugin, or `undefined` when
956
+ * it can't be determined. Reads the version from the installed package's
957
+ * package.json under the npm registry path OpenClaw 2026.5+ installs to
958
+ * (`~/.openclaw/npm/node_modules/<pkg>/package.json`) — the same root
959
+ * `isPluginInstalled` checks. 2026.4 extensions-dir installs have no
960
+ * stable package.json version here and return `undefined` (callers fail
961
+ * open to the safe reinstall path).
962
+ */
963
+ private installedPluginVersion;
954
964
  /**
955
965
  * Remove an extensions/ install that has no matching record in
956
966
  * plugins/installs.json. OpenClaw 2026.5+ tracks installs via the records
package/dist/index.js CHANGED
@@ -856,6 +856,22 @@ function stripPluginVersion(spec) {
856
856
  if (at <= 0) return spec;
857
857
  return spec.slice(0, at);
858
858
  }
859
+ /**
860
+ * Return the pinned version from a plugin spec, or `undefined` when the spec
861
+ * carries no version (bare package name). Mirror of `stripPluginVersion`: the
862
+ * version is everything after the LAST '@' when that '@' sits at index > 0
863
+ * (i.e. it's a real version delimiter, not the scope's leading '@').
864
+ *
865
+ * Used by the force-mode reinstall path to compare the pinned version against
866
+ * the installed one so an already-current plugin can skip a ~45s
867
+ * uninstall+reinstall cycle.
868
+ */
869
+ function pluginSpecVersion(spec) {
870
+ const at = spec.lastIndexOf("@");
871
+ if (at <= 0) return void 0;
872
+ const version = spec.slice(at + 1);
873
+ return version.length > 0 ? version : void 0;
874
+ }
859
875
  //#endregion
860
876
  //#region src/integration-manager.ts
861
877
  /**
@@ -1729,6 +1745,15 @@ const BUNDLED_BASELINE_ALLOW = ["browser"];
1729
1745
  const CONFIG_SET_RETRIES$1 = 3;
1730
1746
  const CONFIG_SET_RETRY_DELAY_MS$1 = 750;
1731
1747
  /**
1748
+ * Max `openclaw config get` spawns in flight at once during the batch
1749
+ * verify-after-write. A naive `Promise.all(leaves.map(...))` fires one CLI
1750
+ * process per leaf simultaneously — a 36-leaf model-provider batch on a 2-vCPU
1751
+ * box means 36 concurrent `config get`s, each needing ~1.9s of CPU, so every
1752
+ * process blows its own 10s timeout and the verify reports false even when the
1753
+ * write actually landed. A small pool keeps the read-backs honest under load.
1754
+ */
1755
+ const VERIFY_GET_CONCURRENCY = 4;
1756
+ /**
1732
1757
  * Sentinel OpenClaw returns from `config get` in place of a sensitive value —
1733
1758
  * the key IS set, OpenClaw is just hiding it. Verify-after-write must treat a
1734
1759
  * read-back of this as "present/matches" rather than a mismatch (see
@@ -1739,6 +1764,29 @@ const delay$1 = (ms) => new Promise((resolve) => {
1739
1764
  setTimeout(resolve, ms);
1740
1765
  });
1741
1766
  /**
1767
+ * Run `fn` over `items` with at most `limit` invocations in flight at once,
1768
+ * preserving result order. A tiny local pool (no new dependency) so the batch
1769
+ * verify-after-write doesn't stampede one `openclaw config get` per leaf — see
1770
+ * VERIFY_GET_CONCURRENCY.
1771
+ *
1772
+ * NOTE: relies on `fn` never rejecting (configValueMatches catches internally).
1773
+ * A throwing `fn` would reject the pool while sibling workers keep draining
1774
+ * items in the background — don't wire a throwing `fn` in here.
1775
+ */
1776
+ async function mapWithConcurrency(items, limit, fn) {
1777
+ const results = new Array(items.length);
1778
+ let cursor = 0;
1779
+ const worker = async () => {
1780
+ while (cursor < items.length) {
1781
+ const index = cursor++;
1782
+ results[index] = await fn(items[index]);
1783
+ }
1784
+ };
1785
+ const workers = Array.from({ length: Math.min(limit, items.length) }, () => worker());
1786
+ await Promise.all(workers);
1787
+ return results;
1788
+ }
1789
+ /**
1742
1790
  * Asymmetric structural containment for verify-after-write: is every field we
1743
1791
  * INTENDED present in the ACTUAL read-back with our value? `actual` may carry
1744
1792
  * EXTRA keys that `intended` does not.
@@ -1781,19 +1829,70 @@ function deepSubset(intended, actual) {
1781
1829
  function redactConfigSetTarget(args) {
1782
1830
  return `openclaw config ${args.slice(0, 2).join(" ")}`.trim();
1783
1831
  }
1832
+ /** Cap a captured stream so a runaway OpenClaw dump can't bloat errorMessage. */
1833
+ const ERROR_STREAM_MAX = 400;
1834
+ function truncateForError(s) {
1835
+ return s.length > ERROR_STREAM_MAX ? `${s.slice(0, ERROR_STREAM_MAX)}… (truncated)` : s;
1836
+ }
1837
+ /**
1838
+ * Collect the value strings a `config set` argv carries so they can be scrubbed
1839
+ * out of OpenClaw's own output. OpenClaw validation errors can echo the
1840
+ * offending VALUE back in their diagnostic text, so stdout/stderr must be
1841
+ * treated as value-bearing, not just the argv. For `{path, value}` batch
1842
+ * entries only the `value` side is collected — redacting the path out of
1843
+ * "invalid path models.providers…" would gut the diagnostic.
1844
+ */
1845
+ function collectArgvValues(args) {
1846
+ const out = [];
1847
+ const collect = (v) => {
1848
+ if (typeof v === "string") {
1849
+ if (v.length >= 4) out.push(v);
1850
+ } else if (Array.isArray(v)) v.forEach(collect);
1851
+ else if (v !== null && typeof v === "object") {
1852
+ const o = v;
1853
+ if (typeof o.path === "string" && "value" in o) collect(o.value);
1854
+ else Object.values(o).forEach(collect);
1855
+ }
1856
+ };
1857
+ for (const token of args.slice(2)) {
1858
+ if (token.startsWith("--")) continue;
1859
+ try {
1860
+ collect(JSON.parse(token));
1861
+ } catch {
1862
+ collect(token);
1863
+ }
1864
+ }
1865
+ return out.sort((a, b) => b.length - a.length);
1866
+ }
1867
+ /** Replace every occurrence of the argv-derived values in a captured stream. */
1868
+ function scrubStream(s, values) {
1869
+ let out = s;
1870
+ for (const v of values) if (out.includes(v)) out = out.split(v).join("[REDACTED]");
1871
+ return out;
1872
+ }
1784
1873
  /**
1785
1874
  * Build a diagnosable BUT scrubbed message from an execFile rejection. Node's
1786
1875
  * execFile error `message` is "Command failed: <full argv>" — which for
1787
1876
  * `config set` embeds the value payload — so we never surface it. We keep the
1788
- * redacted target, the exit code, and `stderr` (openclaw's own error text).
1877
+ * redacted target, the exit code, `stderr`, and `stdout`.
1878
+ *
1879
+ * OpenClaw prints its own error text to STDOUT (not stderr), so on a genuine
1880
+ * failure `stderr` is often empty and the real cause is in stdout — surfacing
1881
+ * only stderr left days of bare "(exit 1)" in the logs. We include a truncated
1882
+ * stdout too — but scrubbed first: OpenClaw's error text can echo the offending
1883
+ * value, so both streams are run through `scrubStream` with the argv values
1884
+ * before anything reaches the dashboard-visible errorMessage.
1789
1885
  */
1790
1886
  function configSetErrorMessage(err, args) {
1791
1887
  const target = redactConfigSetTarget(args);
1792
1888
  if (err instanceof Error) {
1793
1889
  const e = err;
1794
- const stderr = typeof e.stderr === "string" ? e.stderr.trim() : "";
1890
+ const values = collectArgvValues(args);
1891
+ const stderr = typeof e.stderr === "string" ? scrubStream(e.stderr.trim(), values) : "";
1892
+ const stdout = typeof e.stdout === "string" ? scrubStream(e.stdout.trim(), values) : "";
1795
1893
  const code = e.code !== void 0 ? ` (exit ${e.code})` : "";
1796
- return stderr ? `${target} failed${code}: ${stderr}` : `${target} failed${code}`;
1894
+ const details = [stderr, stdout ? `stdout: ${truncateForError(stdout)}` : ""].filter(Boolean).join("; ");
1895
+ return details ? `${target} failed${code}: ${details}` : `${target} failed${code}`;
1797
1896
  }
1798
1897
  return `${target} failed: ${String(err)}`;
1799
1898
  }
@@ -1919,9 +2018,21 @@ var OpenClawApplier = class {
1919
2018
  await this.ensurePluginsAllowUnlocked(pkg);
1920
2019
  this.cleanupUntrackedExtensionInstall(pkg);
1921
2020
  if (opts?.force && this.isPluginInstalled(pkg)) {
2021
+ const pinnedVersion = pluginSpecVersion(spec);
2022
+ const installedVersion = this.installedPluginVersion(pkg);
2023
+ if (pinnedVersion && installedVersion && installedVersion === pinnedVersion) {
2024
+ log$3.info({
2025
+ pkg,
2026
+ spec,
2027
+ version: installedVersion
2028
+ }, "Force mode — installed version already matches pinned spec, skipping uninstall+reinstall");
2029
+ return;
2030
+ }
1922
2031
  log$3.info({
1923
2032
  pkg,
1924
- spec
2033
+ spec,
2034
+ installedVersion,
2035
+ pinnedVersion
1925
2036
  }, "Force mode — uninstalling plugin before reinstall");
1926
2037
  try {
1927
2038
  await this.removePluginUnlocked(pkg);
@@ -2039,6 +2150,25 @@ var OpenClawApplier = class {
2039
2150
  return readdirSync(extensionsDir).some((dir) => dir.startsWith(prefix) && /^[0-9a-f]+$/i.test(dir.slice(prefix.length)));
2040
2151
  }
2041
2152
  /**
2153
+ * Best-effort read of the installed version of a plugin, or `undefined` when
2154
+ * it can't be determined. Reads the version from the installed package's
2155
+ * package.json under the npm registry path OpenClaw 2026.5+ installs to
2156
+ * (`~/.openclaw/npm/node_modules/<pkg>/package.json`) — the same root
2157
+ * `isPluginInstalled` checks. 2026.4 extensions-dir installs have no
2158
+ * stable package.json version here and return `undefined` (callers fail
2159
+ * open to the safe reinstall path).
2160
+ */
2161
+ installedPluginVersion(pkg) {
2162
+ const pkgJsonPath = join(this.home, "npm", "node_modules", ...pkg.split("/"), "package.json");
2163
+ if (!existsSync(pkgJsonPath)) return void 0;
2164
+ try {
2165
+ const data = JSON.parse(readFileSync(pkgJsonPath, "utf8"));
2166
+ return typeof data.version === "string" ? data.version : void 0;
2167
+ } catch {
2168
+ return;
2169
+ }
2170
+ }
2171
+ /**
2042
2172
  * Remove an extensions/ install that has no matching record in
2043
2173
  * plugins/installs.json. OpenClaw 2026.5+ tracks installs via the records
2044
2174
  * file — a leftover dir from 2026.4 is invisible to `openclaw plugins
@@ -2192,10 +2322,10 @@ var OpenClawApplier = class {
2192
2322
  "--batch-json",
2193
2323
  JSON.stringify(leaves),
2194
2324
  "--replace"
2195
- ]);
2325
+ ], { timeout: Math.min(Math.max(3e4, leaves.length * 2e3), 12e4) });
2196
2326
  } catch (err) {
2197
2327
  if (await this.verifyApplied(async () => {
2198
- return (await Promise.all(leaves.map((l) => this.configValueMatches(l.path, l.value)))).every(Boolean);
2328
+ return (await mapWithConcurrency(leaves, VERIFY_GET_CONCURRENCY, (l) => this.configValueMatches(l.path, l.value))).every(Boolean);
2199
2329
  })) log$3.warn({ count: leaves.length }, "openclaw config set --batch-json exited non-zero but config landed — continuing");
2200
2330
  else {
2201
2331
  log$3.error({
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@alfe.ai/integrations",
3
- "version": "0.2.6",
3
+ "version": "0.2.7",
4
4
  "description": "Integration lifecycle management for Alfe — registry, resolution, installation, and state",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",