@alfe.ai/integrations 0.2.10 → 0.3.1

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
@@ -230,6 +230,37 @@ declare class Installer {
230
230
  * Clone monorepo to a temp dir, extract the subdir to the install path, discard the clone.
231
231
  */
232
232
  private cloneAndExtractSubdir;
233
+ /**
234
+ * Stage a new clone into a transient `.staging-<id>-<rand>` directory under
235
+ * the base path, WITHOUT touching the live install dir. Runs the same
236
+ * clone/extract + shared-package + local-dependency install as `install()`,
237
+ * but leaves the result parked so the caller can swap it in near-atomically
238
+ * via `commitStaged()`.
239
+ *
240
+ * This is the "prepare" half of a diff-based in-place upgrade: the clone +
241
+ * `npm install` are the slow, network-bound steps and MUST happen while the
242
+ * runtime is still live (outside the suspension window). Staging under the
243
+ * base path keeps the eventual `renameSync` on the SAME filesystem so the
244
+ * swap is near-atomic.
245
+ *
246
+ * Before staging, sweeps any orphaned `.staging-*` directories left by a
247
+ * daemon that was killed mid-upgrade. On any failure the staging dir is
248
+ * cleaned up (try/finally) so a failed prepare leaves no partial clone.
249
+ */
250
+ stage(resolved: ResolvedIntegration): Promise<string>;
251
+ /**
252
+ * Commit a previously-staged clone: remove the live install dir and rename
253
+ * the staged dir into its place. Same-filesystem `renameSync` makes the swap
254
+ * near-atomic (no window where the install dir is half-populated). Called
255
+ * inside the runtime-suspension window during a diff-based upgrade.
256
+ */
257
+ commitStaged(name: string, stagedPath: string): void;
258
+ /**
259
+ * Remove any orphaned `.staging-*` directories under the base path. Called at
260
+ * the start of `stage()` so a daemon killed mid-upgrade doesn't accumulate
261
+ * partial clones. Best-effort — a failed removal is logged and skipped.
262
+ */
263
+ private sweepStagingDirs;
233
264
  /**
234
265
  * Update an installed integration to a new version.
235
266
  * Removes old install and does a fresh clone.
@@ -283,9 +314,17 @@ declare class Installer {
283
314
  interface RuntimeApplier {
284
315
  /** The runtime identifier (e.g. 'openclaw', 'nanoclaw') */
285
316
  readonly runtime: string;
286
- /** Install a plugin package into this runtime */
317
+ /**
318
+ * Install a plugin package into this runtime.
319
+ *
320
+ * `opts.integrationId` identifies the owning integration so the applier can
321
+ * distinguish a legitimate same-integration pin bump from a cross-integration
322
+ * pin conflict (two integrations pinning the same plugin at different
323
+ * versions). See `OpenClawApplier`'s first-writer-wins guard.
324
+ */
287
325
  applyPlugin(pkg: string, integrationInstallPath: string, opts?: {
288
326
  force?: boolean;
327
+ integrationId?: string;
289
328
  }): Promise<void>;
290
329
  /**
291
330
  * Optional: pre-trust an integration's full plugin set in one write before
@@ -383,6 +422,20 @@ declare class McpApplier {
383
422
  * bundler reconciles via the store watcher.
384
423
  */
385
424
  removeForIntegration(integrationId: string): Promise<string[]>;
425
+ /**
426
+ * Prune servers owned by this integration whose id is NOT in `keepIds`.
427
+ * Used by the diff-based upgrade to drop MCP declarations the NEW manifest
428
+ * no longer includes, while leaving the still-declared servers (and their
429
+ * working env/credentials) untouched — `applyForIntegration` re-runs
430
+ * afterwards and refreshes the kept ones idempotently.
431
+ *
432
+ * `keepIds` are the manifest's DECLARED ids (`${integrationId}-${server.id}`),
433
+ * not the ids that successfully applied: a transient credential failure that
434
+ * skipped a re-registration must not cause prune to delete a server the new
435
+ * manifest still wants. Pure JSON-store writes — no CLI lock needed. Returns
436
+ * the ids actually removed.
437
+ */
438
+ pruneForIntegration(integrationId: string, keepIds: string[]): Promise<string[]>;
386
439
  private resolveEnv;
387
440
  }
388
441
  //#endregion
@@ -560,6 +613,40 @@ declare class IntegrationManager {
560
613
  * If not currently installed, does a normal install + activate.
561
614
  */
562
615
  reinstall(params: IntegrationInstallParams): Promise<ManagerResponse>;
616
+ /**
617
+ * Upgrade an integration to a new version IN PLACE, diff-based, so unchanged
618
+ * plugins are skipped and the clone + npm work happens BEFORE the runtime is
619
+ * suspended. This is the fast path for a version bump — `reinstall()` stays
620
+ * the destructive escape hatch (full uninstall → install → activate) for
621
+ * repair.
622
+ *
623
+ * `opts.onBeforeRuntimeMutation` is the deferred-suspend seam: invoked exactly
624
+ * once, immediately before the first step that mutates the runtime (Phase 2's
625
+ * commit). RuntimeGate ownership stays in the gateway; the manager stays
626
+ * runtime-agnostic.
627
+ *
628
+ * Phases:
629
+ * 0. Guards — no state entry → install + activate; a stale `installing`
630
+ * status (daemon killed mid-install) → reinstall recovery; install not
631
+ * intact / old manifest unparseable → delegate to `reinstall()` (repair
632
+ * stays destructive). Every fallback that can shell the openclaw CLI
633
+ * invokes `opts.onBeforeRuntimeMutation` first (at most once — each
634
+ * returns before Phase 2's seam).
635
+ * 1. Prepare (runtime live) — resolve new version, `installer.stage()` the
636
+ * clone + npm into `.staging-*`, canonicalize a custom-source manifest on
637
+ * the STAGED dir. State untouched (stays active@old) on failure here.
638
+ * 2. Commit — suspend seam, `installer.commitStaged()` swap, parse new
639
+ * manifest + depends_on, `state.update()` (NOT full-replace) preserving
640
+ * customConnectionId + refreshing installedAt + NOT touching secrets, run
641
+ * new-manifest pre/post_install hooks (gated on supported_agents).
642
+ * 3. Diff removals — tear down only plugins/skills/config/MCP the NEW
643
+ * manifest dropped (and no sibling integration still claims).
644
+ * 4. Activate with `forcePlugins` — the applier's version-match skip makes
645
+ * unchanged plugins nearly free; only changed pins pay the reinstall cost.
646
+ */
647
+ upgrade(params: IntegrationInstallParams, opts?: {
648
+ onBeforeRuntimeMutation?: () => Promise<void>;
649
+ }): Promise<ManagerResponse>;
563
650
  /**
564
651
  * Run health checks for one or all integrations.
565
652
  */
@@ -610,6 +697,30 @@ declare class IntegrationManager {
610
697
  clear(): void;
611
698
  private checkHealth;
612
699
  private err;
700
+ /**
701
+ * Compute the plugin (bare package) names and skill names still claimed by
702
+ * SOME integration in the CURRENT lock state, keyed by runtime. Both
703
+ * `deactivate` and `upgrade`'s diff-removal call this AFTER
704
+ * `lockManager.removeEntries(id)` so "still claimed" means "claimed by
705
+ * another integration" — the applier must NOT physically remove a plugin/
706
+ * skill another active integration's manifest still lists.
707
+ *
708
+ * Plugin claims match on bare package name (via `stripPluginVersion`) so two
709
+ * integrations pinning the same plugin at different versions still keep the
710
+ * one file-system install alive when only one is removed.
711
+ */
712
+ private claimedEntriesByRuntime;
713
+ /**
714
+ * Phase 3 of `upgrade`: tear down ONLY the plugins/skills/config/MCP servers
715
+ * the NEW manifest dropped, leaving everything the new version still declares
716
+ * (and everything a sibling integration still claims) in place. Phase 4's
717
+ * `activate` re-applies and re-locks the new set immediately after.
718
+ *
719
+ * `removeEntries` clears this integration's lock rows and returns what it had;
720
+ * a candidate removal is skipped when it is EITHER (a) still declared by the
721
+ * new manifest, or (b) still claimed by another integration.
722
+ */
723
+ private applyUpgradeDiffRemovals;
613
724
  /**
614
725
  * True when the manifest's `supported_agents` (if declared) intersects the
615
726
  * registered runtime appliers. Hooks and MCP registration run once per
@@ -892,6 +1003,24 @@ declare class OpenClawApplier implements RuntimeApplier {
892
1003
  * this instance performed (0 = never). See HEAL_MIN_INTERVAL_MS.
893
1004
  */
894
1005
  private lastHealAt;
1006
+ /**
1007
+ * Cross-integration plugin-pin claims made during THIS process lifetime,
1008
+ * keyed by bare package name. `applyPlugin` is now version-aware for both
1009
+ * force and non-force callers — it actively reinstalls a plugin whose
1010
+ * installed version differs from the pinned spec (previously the non-force
1011
+ * path keyed only on bare-name presence and silently ignored a pin bump). If
1012
+ * two integrations pin the SAME plugin at DIFFERENT versions, that
1013
+ * version-aware reinstall would make them fight — each reconcile pass
1014
+ * uninstall+reinstalls the other's version forever. This map records the
1015
+ * first-applied pin per package so a later, divergent pin from a DIFFERENT
1016
+ * integration is refused (first-writer-wins) with a WARN instead of thrashing.
1017
+ * A later apply from the SAME integration (a legitimate pin bump across an
1018
+ * upgrade) is NOT a conflict and proceeds. Cleared per-package on
1019
+ * `removePlugin`, and wholesale on daemon restart. Convention is single-owner
1020
+ * per plugin (see the `alfe` manifest note) — this only turns a convention
1021
+ * violation into a stable warning.
1022
+ */
1023
+ private readonly appliedPluginPins;
895
1024
  constructor(options: OpenClawApplierOptions);
896
1025
  /**
897
1026
  * Convenience: `openclaw config set <args>`, UNLOCKED + retried.
@@ -977,8 +1106,16 @@ declare class OpenClawApplier implements RuntimeApplier {
977
1106
  private healMalformedStateDb;
978
1107
  applyPlugin(spec: string, _installPath?: string, opts?: {
979
1108
  force?: boolean;
1109
+ integrationId?: string;
980
1110
  }): Promise<void>;
981
1111
  private applyPluginLocked;
1112
+ /**
1113
+ * Record the pin a caller just applied for a package, keyed by bare name, so
1114
+ * a later divergent pin from a DIFFERENT integration in this process can be
1115
+ * refused (first-writer-wins). Bare (versionless) pins are untrackable and
1116
+ * ignored — they can't conflict on version. See `appliedPluginPins`.
1117
+ */
1118
+ private recordPluginPin;
982
1119
  /**
983
1120
  * Ensure one or more plugins are in plugins.allow in openclaw.json, UNLOCKED.
984
1121
  * Uses `openclaw config set` to avoid clobbering OpenClaw's own file format.
@@ -1060,6 +1197,18 @@ declare class OpenClawApplier implements RuntimeApplier {
1060
1197
  */
1061
1198
  removeConfig(integrationId: string): Promise<void>;
1062
1199
  private removeConfigLocked;
1200
+ /**
1201
+ * Drop a set of dotted keys from a dot-free parent object via read-drop-write,
1202
+ * UNLOCKED. If the parent becomes empty, `config unset` it; otherwise
1203
+ * `--replace` the shrunk map (siblings survive because they remain in
1204
+ * `remaining`). Warn-tolerant — a failed drop of an already-gone key must not
1205
+ * fail the caller. Shared by `removeConfig` (whole-integration teardown) and
1206
+ * `applyConfig`'s stale-key diff (per-key removal between manifest versions).
1207
+ *
1208
+ * Assumes the shared CLI lock is already held by the calling public method —
1209
+ * the lock is NOT re-entrant, so this stays an `*Unlocked` internal.
1210
+ */
1211
+ private dropSubtreeKeysUnlocked;
1063
1212
  /**
1064
1213
  * Raw single-key config write — `openclaw config set <key> <value>`.
1065
1214
  *
@@ -1402,8 +1551,10 @@ interface IIntegrationManager {
1402
1551
  }[]>;
1403
1552
  /** Install an integration at a specific version with config */
1404
1553
  install(integrationId: string, version: string, config: unknown, customSource?: CustomInstallSource): Promise<void>;
1405
- /** Activate an installed integration */
1406
- activate(integrationId: string): Promise<{
1554
+ /** Activate an installed integration. `opts.forcePlugins` forces version-drift reinstall of already-installed plugins. */
1555
+ activate(integrationId: string, opts?: {
1556
+ forcePlugins?: boolean;
1557
+ }): Promise<{
1407
1558
  configApplied: boolean;
1408
1559
  }>;
1409
1560
  /** Deactivate a running integration */
@@ -1414,6 +1565,18 @@ interface IIntegrationManager {
1414
1565
  reinstall(integrationId: string, version: string, config: unknown, customSource?: CustomInstallSource): Promise<{
1415
1566
  configApplied: boolean;
1416
1567
  }>;
1568
+ /**
1569
+ * Upgrade an integration to a new version IN PLACE (diff-based). Unchanged
1570
+ * plugins are skipped and the clone + npm work happens before the runtime is
1571
+ * suspended. `opts.onBeforeRuntimeMutation` is the deferred-suspend seam:
1572
+ * invoked exactly once, immediately before the first runtime mutation, so the
1573
+ * gateway can defer the RuntimeGate suspend past the slow clone/npm steps.
1574
+ */
1575
+ upgrade(integrationId: string, version: string, config: unknown, customSource?: CustomInstallSource, opts?: {
1576
+ onBeforeRuntimeMutation?: () => Promise<void>;
1577
+ }): Promise<{
1578
+ configApplied: boolean;
1579
+ }>;
1417
1580
  /** Check if an integration's install directory and manifest are intact on disk */
1418
1581
  isInstallIntact(integrationId: string): Promise<boolean>;
1419
1582
  /** Get the number of consecutive auto-reinstall attempts */
@@ -1433,7 +1596,9 @@ declare class IntegrationManagerAdapter implements IIntegrationManager {
1433
1596
  installedAt?: string;
1434
1597
  }[]>;
1435
1598
  install(integrationId: string, version: string, config: unknown, customSource?: CustomInstallSource): Promise<void>;
1436
- activate(integrationId: string): Promise<{
1599
+ activate(integrationId: string, opts?: {
1600
+ forcePlugins?: boolean;
1601
+ }): Promise<{
1437
1602
  configApplied: boolean;
1438
1603
  }>;
1439
1604
  deactivate(integrationId: string): Promise<void>;
@@ -1441,6 +1606,11 @@ declare class IntegrationManagerAdapter implements IIntegrationManager {
1441
1606
  reinstall(integrationId: string, version: string, config: unknown, customSource?: CustomInstallSource): Promise<{
1442
1607
  configApplied: boolean;
1443
1608
  }>;
1609
+ upgrade(integrationId: string, version: string, config: unknown, customSource?: CustomInstallSource, opts?: {
1610
+ onBeforeRuntimeMutation?: () => Promise<void>;
1611
+ }): Promise<{
1612
+ configApplied: boolean;
1613
+ }>;
1444
1614
  isInstallIntact(integrationId: string): Promise<boolean>;
1445
1615
  getReinstallAttempts(integrationId: string): number;
1446
1616
  incrementReinstallAttempts(integrationId: string): void;
package/dist/index.js CHANGED
@@ -1,5 +1,6 @@
1
1
  import { execFile, spawn } from "node:child_process";
2
2
  import { promisify } from "node:util";
3
+ import { randomBytes } from "node:crypto";
3
4
  import { basename, dirname, join } from "node:path";
4
5
  import { homedir, platform, tmpdir } from "node:os";
5
6
  import { closeSync, copyFileSync, cpSync, existsSync, mkdirSync, mkdtempSync, openSync, readFileSync, readSync, readdirSync, renameSync, rmSync, writeFileSync } from "node:fs";
@@ -156,6 +157,13 @@ const log$4 = createLogger("Installer");
156
157
  const INTEGRATIONS_DIR = join(homedir(), ".alfe", "integrations");
157
158
  const GIT_TIMEOUT_MS = 6e4;
158
159
  const NPM_TIMEOUT_MS = 6e4;
160
+ /**
161
+ * Dot-prefix for the transient staging directories used by the diff-based
162
+ * in-place upgrade (`stage()` → `commitStaged()`). Dot-prefixed so `list()`
163
+ * (and the daemon reconcile that consumes it) never mistakes a half-cloned
164
+ * staging dir for a real integration install.
165
+ */
166
+ const STAGING_PREFIX = ".staging-";
159
167
  /** Shared @alfe.ai packages available to all integration hooks */
160
168
  const SHARED_PACKAGES = {
161
169
  "@alfe.ai/config": "latest",
@@ -267,6 +275,81 @@ var Installer = class {
267
275
  }
268
276
  }
269
277
  /**
278
+ * Stage a new clone into a transient `.staging-<id>-<rand>` directory under
279
+ * the base path, WITHOUT touching the live install dir. Runs the same
280
+ * clone/extract + shared-package + local-dependency install as `install()`,
281
+ * but leaves the result parked so the caller can swap it in near-atomically
282
+ * via `commitStaged()`.
283
+ *
284
+ * This is the "prepare" half of a diff-based in-place upgrade: the clone +
285
+ * `npm install` are the slow, network-bound steps and MUST happen while the
286
+ * runtime is still live (outside the suspension window). Staging under the
287
+ * base path keeps the eventual `renameSync` on the SAME filesystem so the
288
+ * swap is near-atomic.
289
+ *
290
+ * Before staging, sweeps any orphaned `.staging-*` directories left by a
291
+ * daemon that was killed mid-upgrade. On any failure the staging dir is
292
+ * cleaned up (try/finally) so a failed prepare leaves no partial clone.
293
+ */
294
+ async stage(resolved) {
295
+ mkdirSync(this.basePath, { recursive: true });
296
+ this.sweepStagingDirs();
297
+ const stagingPath = join(this.basePath, `${STAGING_PREFIX}${resolved.id}-${randomBytes(6).toString("hex")}`);
298
+ if (existsSync(stagingPath)) rmSync(stagingPath, {
299
+ recursive: true,
300
+ force: true
301
+ });
302
+ try {
303
+ if (resolved.subdir) await this.cloneAndExtractSubdir(resolved, stagingPath);
304
+ else await this.cloneDirect(resolved, stagingPath);
305
+ await this.ensureSharedPackages();
306
+ await this.installLocalDependencies(stagingPath);
307
+ return stagingPath;
308
+ } catch (err) {
309
+ if (existsSync(stagingPath)) rmSync(stagingPath, {
310
+ recursive: true,
311
+ force: true
312
+ });
313
+ throw err;
314
+ }
315
+ }
316
+ /**
317
+ * Commit a previously-staged clone: remove the live install dir and rename
318
+ * the staged dir into its place. Same-filesystem `renameSync` makes the swap
319
+ * near-atomic (no window where the install dir is half-populated). Called
320
+ * inside the runtime-suspension window during a diff-based upgrade.
321
+ */
322
+ commitStaged(name, stagedPath) {
323
+ const installPath = this.getInstallPath(name);
324
+ if (existsSync(installPath)) rmSync(installPath, {
325
+ recursive: true,
326
+ force: true
327
+ });
328
+ renameSync(stagedPath, installPath);
329
+ }
330
+ /**
331
+ * Remove any orphaned `.staging-*` directories under the base path. Called at
332
+ * the start of `stage()` so a daemon killed mid-upgrade doesn't accumulate
333
+ * partial clones. Best-effort — a failed removal is logged and skipped.
334
+ */
335
+ sweepStagingDirs() {
336
+ if (!existsSync(this.basePath)) return;
337
+ for (const entry of readdirSync(this.basePath, { withFileTypes: true })) {
338
+ if (!entry.isDirectory() || !entry.name.startsWith(STAGING_PREFIX)) continue;
339
+ try {
340
+ rmSync(join(this.basePath, entry.name), {
341
+ recursive: true,
342
+ force: true
343
+ });
344
+ } catch (err) {
345
+ log$4.warn({
346
+ dir: entry.name,
347
+ err: err instanceof Error ? err.message : String(err)
348
+ }, "Failed to sweep orphaned staging dir");
349
+ }
350
+ }
351
+ }
352
+ /**
270
353
  * Update an installed integration to a new version.
271
354
  * Removes old install and does a fresh clone.
272
355
  */
@@ -351,6 +434,7 @@ var Installer = class {
351
434
  const installed = [];
352
435
  for (const entry of entries) {
353
436
  if (!entry.isDirectory()) continue;
437
+ if (entry.name.startsWith(".")) continue;
354
438
  const integrationPath = join(this.basePath, entry.name);
355
439
  const manifestPath = join(integrationPath, "alfe-integration.yaml");
356
440
  if (!existsSync(manifestPath)) continue;
@@ -1187,7 +1271,10 @@ var IntegrationManager = class {
1187
1271
  for (const plugin of plugins) {
1188
1272
  this.log.info(`Applying plugin ${plugin.package} to ${runtimeName}`);
1189
1273
  try {
1190
- await applier.applyPlugin(plugin.package, installPath, { force: opts?.forcePlugins });
1274
+ await applier.applyPlugin(plugin.package, installPath, {
1275
+ force: opts?.forcePlugins,
1276
+ integrationId
1277
+ });
1191
1278
  } catch (err) {
1192
1279
  const msg = err instanceof Error ? err.message : String(err);
1193
1280
  this.log.error(`Failed to apply plugin ${plugin.package}: ${msg}`);
@@ -1297,13 +1384,7 @@ var IntegrationManager = class {
1297
1384
  this.log.info(`Deactivating integration: ${integrationId}`);
1298
1385
  try {
1299
1386
  const removed = this.lockManager.removeEntries(integrationId);
1300
- const remaining = this.lockManager.read();
1301
- const claimedPluginsByRuntime = /* @__PURE__ */ new Map();
1302
- const claimedSkillsByRuntime = /* @__PURE__ */ new Map();
1303
- for (const [rtName, state] of Object.entries(remaining.runtimes)) {
1304
- claimedPluginsByRuntime.set(rtName, new Set(state.plugins.map((p) => stripPluginVersion(p.package))));
1305
- claimedSkillsByRuntime.set(rtName, new Set(state.skills.map((s) => s.name)));
1306
- }
1387
+ const { plugins: claimedPluginsByRuntime, skills: claimedSkillsByRuntime } = this.claimedEntriesByRuntime();
1307
1388
  for (const [runtimeName, entries] of Object.entries(removed)) {
1308
1389
  const applier = this.runtimeAppliers.get(runtimeName);
1309
1390
  if (!applier) {
@@ -1469,6 +1550,132 @@ var IntegrationManager = class {
1469
1550
  }
1470
1551
  }
1471
1552
  /**
1553
+ * Upgrade an integration to a new version IN PLACE, diff-based, so unchanged
1554
+ * plugins are skipped and the clone + npm work happens BEFORE the runtime is
1555
+ * suspended. This is the fast path for a version bump — `reinstall()` stays
1556
+ * the destructive escape hatch (full uninstall → install → activate) for
1557
+ * repair.
1558
+ *
1559
+ * `opts.onBeforeRuntimeMutation` is the deferred-suspend seam: invoked exactly
1560
+ * once, immediately before the first step that mutates the runtime (Phase 2's
1561
+ * commit). RuntimeGate ownership stays in the gateway; the manager stays
1562
+ * runtime-agnostic.
1563
+ *
1564
+ * Phases:
1565
+ * 0. Guards — no state entry → install + activate; a stale `installing`
1566
+ * status (daemon killed mid-install) → reinstall recovery; install not
1567
+ * intact / old manifest unparseable → delegate to `reinstall()` (repair
1568
+ * stays destructive). Every fallback that can shell the openclaw CLI
1569
+ * invokes `opts.onBeforeRuntimeMutation` first (at most once — each
1570
+ * returns before Phase 2's seam).
1571
+ * 1. Prepare (runtime live) — resolve new version, `installer.stage()` the
1572
+ * clone + npm into `.staging-*`, canonicalize a custom-source manifest on
1573
+ * the STAGED dir. State untouched (stays active@old) on failure here.
1574
+ * 2. Commit — suspend seam, `installer.commitStaged()` swap, parse new
1575
+ * manifest + depends_on, `state.update()` (NOT full-replace) preserving
1576
+ * customConnectionId + refreshing installedAt + NOT touching secrets, run
1577
+ * new-manifest pre/post_install hooks (gated on supported_agents).
1578
+ * 3. Diff removals — tear down only plugins/skills/config/MCP the NEW
1579
+ * manifest dropped (and no sibling integration still claims).
1580
+ * 4. Activate with `forcePlugins` — the applier's version-match skip makes
1581
+ * unchanged plugins nearly free; only changed pins pay the reinstall cost.
1582
+ */
1583
+ async upgrade(params, opts) {
1584
+ const { name, version, config, customSource } = params;
1585
+ if (!name) return this.err("INVALID_PARAMS", "Integration name is required");
1586
+ const existing = this.state.get(name);
1587
+ if (!existing) {
1588
+ await opts?.onBeforeRuntimeMutation?.();
1589
+ const installResult = await this.install(params);
1590
+ if (!installResult.ok) return installResult;
1591
+ return this.activate(name, { forcePlugins: true });
1592
+ }
1593
+ if (existing.status === "installing") {
1594
+ this.log.warn(`Integration "${name}" has a stale "installing" status (daemon likely killed mid-install) — recovering via reinstall`);
1595
+ await opts?.onBeforeRuntimeMutation?.();
1596
+ return this.reinstall(params);
1597
+ }
1598
+ const oldManifestPath = join(this.installer.getInstallPath(name), "alfe-integration.yaml");
1599
+ let oldManifest = null;
1600
+ if (existsSync(oldManifestPath)) try {
1601
+ oldManifest = parseManifestFile(oldManifestPath);
1602
+ } catch {
1603
+ oldManifest = null;
1604
+ }
1605
+ if (!oldManifest) {
1606
+ this.log.warn(`Integration "${name}" install not intact or old manifest unparseable — delegating to reinstall`);
1607
+ await opts?.onBeforeRuntimeMutation?.();
1608
+ return this.reinstall(params);
1609
+ }
1610
+ this.log.info(`Upgrading integration: ${name}${version ? `@${version}` : ""}`);
1611
+ let stagedPath;
1612
+ try {
1613
+ let resolved;
1614
+ if (customSource) {
1615
+ resolved = buildCustomResolved(name, customSource);
1616
+ this.log.info(`Custom Connection upgrade: ${name} from ${resolved.repository}@${resolved.commit}`);
1617
+ } else {
1618
+ resolved = await this.resolver.resolve(name, version, { fresh: true });
1619
+ this.log.info(`Resolved ${name}@${resolved.version} from ${resolved.repository}`);
1620
+ }
1621
+ stagedPath = await this.installer.stage(resolved);
1622
+ if (customSource) ensureCanonicalManifestName(stagedPath, customSource.manifestPath);
1623
+ } catch (err) {
1624
+ const message = err instanceof Error ? err.message : String(err);
1625
+ this.log.error(`Failed to prepare upgrade for "${name}": ${message}`);
1626
+ return this.err("UPGRADE_FAILED", message);
1627
+ }
1628
+ try {
1629
+ await opts?.onBeforeRuntimeMutation?.();
1630
+ this.installer.commitStaged(name, stagedPath);
1631
+ const newManifestPath = join(this.installer.getInstallPath(name), "alfe-integration.yaml");
1632
+ if (!existsSync(newManifestPath)) throw new Error(`No alfe-integration.yaml found after commit for "${name}"`);
1633
+ const newManifest = parseManifestFile(newManifestPath);
1634
+ this.log.info(`Upgrade manifest validated: ${newManifest.id}@${newManifest.version}`);
1635
+ for (const dep of newManifest.depends_on) {
1636
+ const depState = this.state.get(dep);
1637
+ if (!depState || depState.status === "error") throw new Error(`Dependency "${dep}" is not installed. Install it first: alfe integration install ${dep}`);
1638
+ }
1639
+ this.state.update(name, {
1640
+ status: "installed",
1641
+ version: newManifest.version,
1642
+ installedAt: (/* @__PURE__ */ new Date()).toISOString(),
1643
+ config: config ?? existing.config,
1644
+ customConnectionId: customSource?.connectionId ?? existing.customConnectionId
1645
+ });
1646
+ const installHooksSupported = this.manifestSupportsRegisteredRuntime(newManifest);
1647
+ if (!installHooksSupported && (newManifest.hooks.pre_install || newManifest.hooks.post_install)) this.log.warn(`Integration "${name}" upgrade install hooks skipped — no registered runtime (${[...this.runtimeAppliers.keys()].join(", ")}) is in supported_agents (${(newManifest.supported_agents ?? []).join(", ")})`);
1648
+ if (installHooksSupported && newManifest.hooks.pre_install) {
1649
+ this.log.info(`Running pre_install hook: ${newManifest.hooks.pre_install}`);
1650
+ const hookResult = await runHook(this.installer.getInstallPath(name), newManifest.hooks.pre_install);
1651
+ if (hookResult.exitCode !== 0) throw new Error(`pre_install hook failed (exit ${String(hookResult.exitCode)}): ${hookResult.stderr || hookResult.stdout}`);
1652
+ }
1653
+ if (installHooksSupported && newManifest.hooks.post_install) {
1654
+ this.log.info(`Running post_install hook: ${newManifest.hooks.post_install}`);
1655
+ const hookResult = await runHookWithContext(this.installer.getInstallPath(name), newManifest.hooks.post_install, {
1656
+ integrationName: name,
1657
+ config: config ?? existing.config,
1658
+ secrets: this.secrets.get(name),
1659
+ runtimes: [...this.runtimeAppliers.keys()]
1660
+ });
1661
+ if (hookResult.exitCode !== 0) throw new Error(`post_install hook failed (exit ${String(hookResult.exitCode)}): ${hookResult.stderr || hookResult.stdout}`);
1662
+ }
1663
+ await this.applyUpgradeDiffRemovals(name, oldManifest, newManifest);
1664
+ return await this.activate(name, { forcePlugins: true });
1665
+ } catch (err) {
1666
+ try {
1667
+ if (existsSync(stagedPath)) rmSync(stagedPath, {
1668
+ recursive: true,
1669
+ force: true
1670
+ });
1671
+ } catch {}
1672
+ const message = err instanceof Error ? err.message : String(err);
1673
+ this.log.error(`Failed to upgrade "${name}": ${message}`);
1674
+ this.state.setStatus(name, "error", message);
1675
+ return this.err("UPGRADE_FAILED", message);
1676
+ }
1677
+ }
1678
+ /**
1472
1679
  * Run health checks for one or all integrations.
1473
1680
  */
1474
1681
  async health(params) {
@@ -1654,6 +1861,101 @@ var IntegrationManager = class {
1654
1861
  };
1655
1862
  }
1656
1863
  /**
1864
+ * Compute the plugin (bare package) names and skill names still claimed by
1865
+ * SOME integration in the CURRENT lock state, keyed by runtime. Both
1866
+ * `deactivate` and `upgrade`'s diff-removal call this AFTER
1867
+ * `lockManager.removeEntries(id)` so "still claimed" means "claimed by
1868
+ * another integration" — the applier must NOT physically remove a plugin/
1869
+ * skill another active integration's manifest still lists.
1870
+ *
1871
+ * Plugin claims match on bare package name (via `stripPluginVersion`) so two
1872
+ * integrations pinning the same plugin at different versions still keep the
1873
+ * one file-system install alive when only one is removed.
1874
+ */
1875
+ claimedEntriesByRuntime() {
1876
+ const remaining = this.lockManager.read();
1877
+ const plugins = /* @__PURE__ */ new Map();
1878
+ const skills = /* @__PURE__ */ new Map();
1879
+ for (const [rtName, state] of Object.entries(remaining.runtimes)) {
1880
+ plugins.set(rtName, new Set(state.plugins.map((p) => stripPluginVersion(p.package))));
1881
+ skills.set(rtName, new Set(state.skills.map((s) => s.name)));
1882
+ }
1883
+ return {
1884
+ plugins,
1885
+ skills
1886
+ };
1887
+ }
1888
+ /**
1889
+ * Phase 3 of `upgrade`: tear down ONLY the plugins/skills/config/MCP servers
1890
+ * the NEW manifest dropped, leaving everything the new version still declares
1891
+ * (and everything a sibling integration still claims) in place. Phase 4's
1892
+ * `activate` re-applies and re-locks the new set immediately after.
1893
+ *
1894
+ * `removeEntries` clears this integration's lock rows and returns what it had;
1895
+ * a candidate removal is skipped when it is EITHER (a) still declared by the
1896
+ * new manifest, or (b) still claimed by another integration.
1897
+ */
1898
+ async applyUpgradeDiffRemovals(integrationId, oldManifest, newManifest) {
1899
+ const removed = this.lockManager.removeEntries(integrationId);
1900
+ const { plugins: claimedPluginsByRuntime, skills: claimedSkillsByRuntime } = this.claimedEntriesByRuntime();
1901
+ for (const [runtimeName, entries] of Object.entries(removed)) {
1902
+ const applier = this.runtimeAppliers.get(runtimeName);
1903
+ if (!applier) {
1904
+ this.log.warn(`No applier for runtime "${runtimeName}" — cannot diff-remove entries`);
1905
+ continue;
1906
+ }
1907
+ if (!await applier.isAvailable()) {
1908
+ this.log.warn(`Runtime "${runtimeName}" is not available — skipping upgrade diff removal`);
1909
+ continue;
1910
+ }
1911
+ const { plugins: newPlugins, skills: newSkills, config: newRuntimeConfig } = resolveInstallsForRuntime(newManifest, runtimeName);
1912
+ const keepPluginsNew = new Set(newPlugins.map((p) => stripPluginVersion(p.package)));
1913
+ const keepSkillsNew = new Set(newSkills.map((s) => s.clawhub ?? s.path?.split("/").pop() ?? "unknown"));
1914
+ const keepPluginsOther = claimedPluginsByRuntime.get(runtimeName) ?? /* @__PURE__ */ new Set();
1915
+ const keepSkillsOther = claimedSkillsByRuntime.get(runtimeName) ?? /* @__PURE__ */ new Set();
1916
+ for (const plugin of entries.plugins) {
1917
+ const bare = stripPluginVersion(plugin.package);
1918
+ if (keepPluginsNew.has(bare) || keepPluginsOther.has(bare)) continue;
1919
+ this.log.info(`Upgrade: removing dropped plugin ${plugin.package} from ${runtimeName}`);
1920
+ try {
1921
+ await applier.removePlugin(plugin.package);
1922
+ } catch (err) {
1923
+ this.log.warn(`Failed to remove dropped plugin ${plugin.package} from ${runtimeName}: ${err instanceof Error ? err.message : String(err)}`);
1924
+ }
1925
+ }
1926
+ for (const skill of entries.skills) {
1927
+ if (keepSkillsNew.has(skill.name) || keepSkillsOther.has(skill.name)) continue;
1928
+ this.log.info(`Upgrade: removing dropped skill ${skill.name} from ${runtimeName}`);
1929
+ try {
1930
+ if (skill.sourcePath.startsWith("clawhub:")) await applier.removeClawHubSkill(skill.name);
1931
+ else await applier.removeSkill(skill.name);
1932
+ } catch (err) {
1933
+ this.log.warn(`Failed to remove dropped skill ${skill.name} from ${runtimeName}: ${err instanceof Error ? err.message : String(err)}`);
1934
+ }
1935
+ }
1936
+ const oldRuntimeConfig = resolveInstallsForRuntime(oldManifest, runtimeName).config;
1937
+ const oldHadConfig = Boolean(oldRuntimeConfig && Object.keys(oldRuntimeConfig).length > 0);
1938
+ const newHasConfig = Boolean(newRuntimeConfig && Object.keys(newRuntimeConfig).length > 0);
1939
+ if (oldHadConfig && !newHasConfig) {
1940
+ this.log.info(`Upgrade: removing gone config for ${integrationId} from ${runtimeName}`);
1941
+ try {
1942
+ await applier.removeConfig(integrationId);
1943
+ } catch (err) {
1944
+ this.log.warn(`Failed to remove gone config for ${integrationId} from ${runtimeName}: ${err instanceof Error ? err.message : String(err)}`);
1945
+ }
1946
+ }
1947
+ }
1948
+ if (this.mcpApplier) {
1949
+ const keepIds = (newManifest.mcp_servers ?? []).map((s) => `${integrationId}-${s.id}`);
1950
+ this.log.info(`Upgrade: pruning MCP servers for ${integrationId} (keeping ${String(keepIds.length)} declared)`);
1951
+ try {
1952
+ await this.mcpApplier.pruneForIntegration(integrationId, keepIds);
1953
+ } catch (err) {
1954
+ this.log.warn(`Failed to prune MCP servers for ${integrationId}: ${err instanceof Error ? err.message : String(err)}`);
1955
+ }
1956
+ }
1957
+ }
1958
+ /**
1657
1959
  * True when the manifest's `supported_agents` (if declared) intersects the
1658
1960
  * registered runtime appliers. Hooks and MCP registration run once per
1659
1961
  * integration (not per runtime), so they must use this aggregate check —
@@ -2002,6 +2304,24 @@ var OpenClawApplier = class {
2002
2304
  * this instance performed (0 = never). See HEAL_MIN_INTERVAL_MS.
2003
2305
  */
2004
2306
  lastHealAt = 0;
2307
+ /**
2308
+ * Cross-integration plugin-pin claims made during THIS process lifetime,
2309
+ * keyed by bare package name. `applyPlugin` is now version-aware for both
2310
+ * force and non-force callers — it actively reinstalls a plugin whose
2311
+ * installed version differs from the pinned spec (previously the non-force
2312
+ * path keyed only on bare-name presence and silently ignored a pin bump). If
2313
+ * two integrations pin the SAME plugin at DIFFERENT versions, that
2314
+ * version-aware reinstall would make them fight — each reconcile pass
2315
+ * uninstall+reinstalls the other's version forever. This map records the
2316
+ * first-applied pin per package so a later, divergent pin from a DIFFERENT
2317
+ * integration is refused (first-writer-wins) with a WARN instead of thrashing.
2318
+ * A later apply from the SAME integration (a legitimate pin bump across an
2319
+ * upgrade) is NOT a conflict and proceeds. Cleared per-package on
2320
+ * `removePlugin`, and wholesale on daemon restart. Convention is single-owner
2321
+ * per plugin (see the `alfe` manifest note) — this only turns a convention
2322
+ * violation into a stable warning.
2323
+ */
2324
+ appliedPluginPins = /* @__PURE__ */ new Map();
2005
2325
  constructor(options) {
2006
2326
  const home = options.home ?? options.workspace;
2007
2327
  if (!home) throw new Error("OpenClawApplier requires `home` (or legacy `workspace`) option");
@@ -2185,25 +2505,43 @@ var OpenClawApplier = class {
2185
2505
  }
2186
2506
  async applyPluginLocked(spec, opts) {
2187
2507
  const pkg = stripPluginVersion(spec);
2508
+ const pinnedVersion = pluginSpecVersion(spec);
2509
+ const claim = this.appliedPluginPins.get(pkg);
2510
+ if (pinnedVersion !== void 0 && claim !== void 0 && claim.version !== pinnedVersion && claim.integrationId !== opts?.integrationId) {
2511
+ log$3.warn({
2512
+ pkg,
2513
+ requestedVersion: pinnedVersion,
2514
+ keptVersion: claim.version,
2515
+ requestedBy: opts?.integrationId,
2516
+ ownedBy: claim.integrationId
2517
+ }, "Conflicting plugin pin across integrations — keeping the first-applied version (first-writer-wins)");
2518
+ return;
2519
+ }
2188
2520
  await this.ensurePluginsAllowUnlocked(pkg);
2189
2521
  this.cleanupUntrackedExtensionInstall(pkg);
2190
- if (opts?.force && this.isPluginInstalled(pkg)) {
2191
- const pinnedVersion = pluginSpecVersion(spec);
2522
+ if (this.isPluginInstalled(pkg)) {
2192
2523
  const installedVersion = this.installedPluginVersion(pkg);
2193
- if (pinnedVersion && installedVersion && installedVersion === pinnedVersion) {
2194
- log$3.info({
2524
+ const versionsComparable = pinnedVersion !== void 0 && installedVersion !== void 0;
2525
+ if (versionsComparable && installedVersion === pinnedVersion) {
2526
+ if (opts?.force) log$3.info({
2195
2527
  pkg,
2196
2528
  spec,
2197
2529
  version: installedVersion
2198
2530
  }, "Force mode — installed version already matches pinned spec, skipping uninstall+reinstall");
2531
+ this.recordPluginPin(pkg, pinnedVersion, opts?.integrationId);
2532
+ return;
2533
+ }
2534
+ if (!(versionsComparable && installedVersion !== pinnedVersion) && !opts?.force) {
2535
+ this.recordPluginPin(pkg, pinnedVersion, opts?.integrationId);
2199
2536
  return;
2200
2537
  }
2201
2538
  log$3.info({
2202
2539
  pkg,
2203
2540
  spec,
2204
2541
  installedVersion,
2205
- pinnedVersion
2206
- }, "Force mode — uninstalling plugin before reinstall");
2542
+ pinnedVersion,
2543
+ force: opts?.force ?? false
2544
+ }, "Reinstalling plugin to converge on the pinned version");
2207
2545
  try {
2208
2546
  await this.removePluginUnlocked(pkg);
2209
2547
  } catch (err) {
@@ -2211,7 +2549,7 @@ var OpenClawApplier = class {
2211
2549
  pkg,
2212
2550
  spec,
2213
2551
  err: err instanceof Error ? err.message : String(err)
2214
- }, "Failed to uninstall plugin during force reinstall — proceeding");
2552
+ }, "Failed to uninstall plugin before reinstall — proceeding");
2215
2553
  }
2216
2554
  }
2217
2555
  if (!this.isPluginInstalled(pkg)) {
@@ -2248,6 +2586,20 @@ var OpenClawApplier = class {
2248
2586
  setTimeout(r, 500);
2249
2587
  });
2250
2588
  }
2589
+ this.recordPluginPin(pkg, pinnedVersion, opts?.integrationId);
2590
+ }
2591
+ /**
2592
+ * Record the pin a caller just applied for a package, keyed by bare name, so
2593
+ * a later divergent pin from a DIFFERENT integration in this process can be
2594
+ * refused (first-writer-wins). Bare (versionless) pins are untrackable and
2595
+ * ignored — they can't conflict on version. See `appliedPluginPins`.
2596
+ */
2597
+ recordPluginPin(pkg, version, integrationId) {
2598
+ if (version === void 0) return;
2599
+ this.appliedPluginPins.set(pkg, {
2600
+ version,
2601
+ integrationId
2602
+ });
2251
2603
  }
2252
2604
  /**
2253
2605
  * Ensure one or more plugins are in plugins.allow in openclaw.json, UNLOCKED.
@@ -2393,6 +2745,7 @@ var OpenClawApplier = class {
2393
2745
  */
2394
2746
  async removePluginUnlocked(spec) {
2395
2747
  const pkg = stripPluginVersion(spec);
2748
+ this.appliedPluginPins.delete(pkg);
2396
2749
  await this.execOpenClawHealing([
2397
2750
  "plugins",
2398
2751
  "uninstall",
@@ -2460,10 +2813,29 @@ var OpenClawApplier = class {
2460
2813
  async applyConfigLocked(integrationId, config) {
2461
2814
  const tracking = this.readTracking();
2462
2815
  const integrations = tracking._integrations ?? {};
2816
+ const previous = integrations[integrationId];
2817
+ const { leaves, subtreesByParent } = partitionEntries(flattenConfig(config));
2818
+ if (previous && typeof previous === "object" && !Array.isArray(previous)) {
2819
+ const prev = partitionEntries(flattenConfig(previous));
2820
+ const nextLeafPaths = new Set(leaves.map((l) => l.path));
2821
+ const staleLeaves = prev.leaves.filter((l) => !nextLeafPaths.has(l.path));
2822
+ for (const { path } of staleLeaves) try {
2823
+ await this.runConfigCommandUnlocked(["unset", path]);
2824
+ } catch (err) {
2825
+ log$3.warn({
2826
+ err: err instanceof Error ? err.message : String(err),
2827
+ path
2828
+ }, "Failed to unset stale config leaf during applyConfig diff");
2829
+ }
2830
+ for (const [parentPath, prevKvs] of prev.subtreesByParent) {
2831
+ const nextKvs = subtreesByParent.get(parentPath);
2832
+ const goneKeys = [...prevKvs.keys()].filter((k) => !nextKvs?.has(k));
2833
+ if (goneKeys.length > 0) await this.dropSubtreeKeysUnlocked(parentPath, new Set(goneKeys));
2834
+ }
2835
+ }
2463
2836
  integrations[integrationId] = config;
2464
2837
  tracking._integrations = integrations;
2465
2838
  this.writeTracking(tracking);
2466
- const { leaves, subtreesByParent } = partitionEntries(flattenConfig(config));
2467
2839
  for (const [parentPath, dottedKvs] of subtreesByParent) {
2468
2840
  const merged = { ...await readParentObject(parentPath) };
2469
2841
  for (const [k, v] of dottedKvs) merged[k] = v;
@@ -2530,28 +2902,40 @@ var OpenClawApplier = class {
2530
2902
  path
2531
2903
  }, "Failed to unset config via openclaw config unset");
2532
2904
  }
2533
- for (const [parentPath, dottedKvs] of subtreesByParent) {
2534
- const existing = await readParentObject(parentPath);
2535
- const remaining = Object.fromEntries(Object.entries(existing).filter(([k]) => !dottedKvs.has(k)));
2536
- if (Object.keys(existing).length === 0) continue;
2537
- try {
2538
- if (Object.keys(remaining).length === 0) await this.runConfigCommandUnlocked(["unset", parentPath]);
2539
- else await this.runConfigSetUnlocked([
2540
- parentPath,
2541
- JSON.stringify(remaining),
2542
- "--replace"
2543
- ]);
2544
- } catch (err) {
2545
- log$3.warn({
2546
- err: err instanceof Error ? err.message : String(err),
2547
- parentPath
2548
- }, "Failed to update parent config during remove");
2549
- }
2550
- }
2905
+ for (const [parentPath, dottedKvs] of subtreesByParent) await this.dropSubtreeKeysUnlocked(parentPath, new Set(dottedKvs.keys()));
2551
2906
  tracking._integrations = Object.fromEntries(Object.entries(integrations).filter(([key]) => key !== integrationId));
2552
2907
  this.writeTracking(tracking);
2553
2908
  }
2554
2909
  /**
2910
+ * Drop a set of dotted keys from a dot-free parent object via read-drop-write,
2911
+ * UNLOCKED. If the parent becomes empty, `config unset` it; otherwise
2912
+ * `--replace` the shrunk map (siblings survive because they remain in
2913
+ * `remaining`). Warn-tolerant — a failed drop of an already-gone key must not
2914
+ * fail the caller. Shared by `removeConfig` (whole-integration teardown) and
2915
+ * `applyConfig`'s stale-key diff (per-key removal between manifest versions).
2916
+ *
2917
+ * Assumes the shared CLI lock is already held by the calling public method —
2918
+ * the lock is NOT re-entrant, so this stays an `*Unlocked` internal.
2919
+ */
2920
+ async dropSubtreeKeysUnlocked(parentPath, dottedKeys) {
2921
+ const existing = await readParentObject(parentPath);
2922
+ if (Object.keys(existing).length === 0) return;
2923
+ const remaining = Object.fromEntries(Object.entries(existing).filter(([k]) => !dottedKeys.has(k)));
2924
+ try {
2925
+ if (Object.keys(remaining).length === 0) await this.runConfigCommandUnlocked(["unset", parentPath]);
2926
+ else await this.runConfigSetUnlocked([
2927
+ parentPath,
2928
+ JSON.stringify(remaining),
2929
+ "--replace"
2930
+ ]);
2931
+ } catch (err) {
2932
+ log$3.warn({
2933
+ err: err instanceof Error ? err.message : String(err),
2934
+ parentPath
2935
+ }, "Failed to update parent config during subtree key drop");
2936
+ }
2937
+ }
2938
+ /**
2555
2939
  * Raw single-key config write — `openclaw config set <key> <value>`.
2556
2940
  *
2557
2941
  * Deliberately bypasses the `_integrations` tracking that `applyConfig`
@@ -2692,10 +3076,32 @@ var HermesApplier = class {
2692
3076
  async applyConfig(integrationId, config) {
2693
3077
  const tracking = this.readTracking();
2694
3078
  const integrations = tracking._integrations ?? {};
3079
+ const previous = integrations[integrationId];
3080
+ const { leaves, subtreesByParent } = partitionEntries(flattenConfig(config));
3081
+ if (previous && typeof previous === "object" && !Array.isArray(previous)) {
3082
+ const prev = partitionEntries(flattenConfig(previous));
3083
+ const nextLeafPaths = new Set(leaves.map((l) => l.path));
3084
+ const staleLeafPaths = prev.leaves.filter((l) => !nextLeafPaths.has(l.path)).map((l) => l.path);
3085
+ if (staleLeafPaths.length > 0) try {
3086
+ await this.deleteConfigKeys(staleLeafPaths);
3087
+ } catch (err) {
3088
+ log$2.warn({
3089
+ err: err instanceof Error ? err.message : String(err),
3090
+ integrationId
3091
+ }, "Failed to delete stale Hermes config keys during applyConfig diff");
3092
+ }
3093
+ const staleSubtreeParents = [...prev.subtreesByParent].filter(([parent, prevKvs]) => {
3094
+ const nextKvs = subtreesByParent.get(parent);
3095
+ return [...prevKvs.keys()].some((k) => !nextKvs?.has(k));
3096
+ }).map(([parent]) => parent);
3097
+ if (staleSubtreeParents.length > 0) log$2.warn({
3098
+ integrationId,
3099
+ parents: staleSubtreeParents
3100
+ }, "Hermes applyConfig diff: skipping stale dotted-key subtree(s) — OpenClaw-plugin-shaped config does not apply to Hermes");
3101
+ }
2695
3102
  integrations[integrationId] = config;
2696
3103
  tracking._integrations = integrations;
2697
3104
  this.writeTracking(tracking);
2698
- const { leaves, subtreesByParent } = partitionEntries(flattenConfig(config));
2699
3105
  if (subtreesByParent.size > 0) log$2.warn({
2700
3106
  integrationId,
2701
3107
  parents: [...subtreesByParent.keys()]
@@ -3191,6 +3597,31 @@ var McpApplier = class {
3191
3597
  const owner = `integration:${integrationId}`;
3192
3598
  return this.manager.removeServersByOwner(owner);
3193
3599
  }
3600
+ /**
3601
+ * Prune servers owned by this integration whose id is NOT in `keepIds`.
3602
+ * Used by the diff-based upgrade to drop MCP declarations the NEW manifest
3603
+ * no longer includes, while leaving the still-declared servers (and their
3604
+ * working env/credentials) untouched — `applyForIntegration` re-runs
3605
+ * afterwards and refreshes the kept ones idempotently.
3606
+ *
3607
+ * `keepIds` are the manifest's DECLARED ids (`${integrationId}-${server.id}`),
3608
+ * not the ids that successfully applied: a transient credential failure that
3609
+ * skipped a re-registration must not cause prune to delete a server the new
3610
+ * manifest still wants. Pure JSON-store writes — no CLI lock needed. Returns
3611
+ * the ids actually removed.
3612
+ */
3613
+ async pruneForIntegration(integrationId, keepIds) {
3614
+ const owner = `integration:${integrationId}`;
3615
+ const keep = new Set(keepIds);
3616
+ const removed = [];
3617
+ for (const { id, entry } of this.manager.listServers()) {
3618
+ if (entry.owner !== owner) continue;
3619
+ if (keep.has(id)) continue;
3620
+ await this.manager.removeServer(id, { expectedOwner: owner });
3621
+ removed.push(id);
3622
+ }
3623
+ return removed;
3624
+ }
3194
3625
  async resolveEnv(server, mergedConfig, connectionId) {
3195
3626
  if (!server.env || Object.keys(server.env).length === 0) return {};
3196
3627
  const provider = server.requires_credentials;
@@ -3277,8 +3708,8 @@ var IntegrationManagerAdapter = class {
3277
3708
  });
3278
3709
  if (!result.ok) throw new Error(result.error?.message ?? `Failed to install ${integrationId}`);
3279
3710
  }
3280
- async activate(integrationId) {
3281
- const result = await this.manager.activate(integrationId);
3711
+ async activate(integrationId, opts) {
3712
+ const result = await this.manager.activate(integrationId, opts);
3282
3713
  if (!result.ok) throw new Error(result.error?.message ?? `Failed to activate ${integrationId}`);
3283
3714
  return { configApplied: result.payload?.configApplied ?? false };
3284
3715
  }
@@ -3300,6 +3731,16 @@ var IntegrationManagerAdapter = class {
3300
3731
  if (!result.ok) throw new Error(result.error?.message ?? `Failed to reinstall ${integrationId}`);
3301
3732
  return { configApplied: result.payload?.configApplied ?? false };
3302
3733
  }
3734
+ async upgrade(integrationId, version, config, customSource, opts) {
3735
+ const result = await this.manager.upgrade({
3736
+ name: integrationId,
3737
+ version,
3738
+ config,
3739
+ customSource
3740
+ }, opts);
3741
+ if (!result.ok) throw new Error(result.error?.message ?? `Failed to upgrade ${integrationId}`);
3742
+ return { configApplied: result.payload?.configApplied ?? false };
3743
+ }
3303
3744
  isInstallIntact(integrationId) {
3304
3745
  return Promise.resolve(this.manager.isInstallIntact(integrationId));
3305
3746
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@alfe.ai/integrations",
3
- "version": "0.2.10",
3
+ "version": "0.3.1",
4
4
  "description": "Integration lifecycle management for Alfe — registry, resolution, installation, and state",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",