@alfe.ai/integrations 0.2.10 → 0.3.0

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.
@@ -383,6 +414,20 @@ declare class McpApplier {
383
414
  * bundler reconciles via the store watcher.
384
415
  */
385
416
  removeForIntegration(integrationId: string): Promise<string[]>;
417
+ /**
418
+ * Prune servers owned by this integration whose id is NOT in `keepIds`.
419
+ * Used by the diff-based upgrade to drop MCP declarations the NEW manifest
420
+ * no longer includes, while leaving the still-declared servers (and their
421
+ * working env/credentials) untouched — `applyForIntegration` re-runs
422
+ * afterwards and refreshes the kept ones idempotently.
423
+ *
424
+ * `keepIds` are the manifest's DECLARED ids (`${integrationId}-${server.id}`),
425
+ * not the ids that successfully applied: a transient credential failure that
426
+ * skipped a re-registration must not cause prune to delete a server the new
427
+ * manifest still wants. Pure JSON-store writes — no CLI lock needed. Returns
428
+ * the ids actually removed.
429
+ */
430
+ pruneForIntegration(integrationId: string, keepIds: string[]): Promise<string[]>;
386
431
  private resolveEnv;
387
432
  }
388
433
  //#endregion
@@ -560,6 +605,40 @@ declare class IntegrationManager {
560
605
  * If not currently installed, does a normal install + activate.
561
606
  */
562
607
  reinstall(params: IntegrationInstallParams): Promise<ManagerResponse>;
608
+ /**
609
+ * Upgrade an integration to a new version IN PLACE, diff-based, so unchanged
610
+ * plugins are skipped and the clone + npm work happens BEFORE the runtime is
611
+ * suspended. This is the fast path for a version bump — `reinstall()` stays
612
+ * the destructive escape hatch (full uninstall → install → activate) for
613
+ * repair.
614
+ *
615
+ * `opts.onBeforeRuntimeMutation` is the deferred-suspend seam: invoked exactly
616
+ * once, immediately before the first step that mutates the runtime (Phase 2's
617
+ * commit). RuntimeGate ownership stays in the gateway; the manager stays
618
+ * runtime-agnostic.
619
+ *
620
+ * Phases:
621
+ * 0. Guards — no state entry → install + activate; a stale `installing`
622
+ * status (daemon killed mid-install) → reinstall recovery; install not
623
+ * intact / old manifest unparseable → delegate to `reinstall()` (repair
624
+ * stays destructive). Every fallback that can shell the openclaw CLI
625
+ * invokes `opts.onBeforeRuntimeMutation` first (at most once — each
626
+ * returns before Phase 2's seam).
627
+ * 1. Prepare (runtime live) — resolve new version, `installer.stage()` the
628
+ * clone + npm into `.staging-*`, canonicalize a custom-source manifest on
629
+ * the STAGED dir. State untouched (stays active@old) on failure here.
630
+ * 2. Commit — suspend seam, `installer.commitStaged()` swap, parse new
631
+ * manifest + depends_on, `state.update()` (NOT full-replace) preserving
632
+ * customConnectionId + refreshing installedAt + NOT touching secrets, run
633
+ * new-manifest pre/post_install hooks (gated on supported_agents).
634
+ * 3. Diff removals — tear down only plugins/skills/config/MCP the NEW
635
+ * manifest dropped (and no sibling integration still claims).
636
+ * 4. Activate with `forcePlugins` — the applier's version-match skip makes
637
+ * unchanged plugins nearly free; only changed pins pay the reinstall cost.
638
+ */
639
+ upgrade(params: IntegrationInstallParams, opts?: {
640
+ onBeforeRuntimeMutation?: () => Promise<void>;
641
+ }): Promise<ManagerResponse>;
563
642
  /**
564
643
  * Run health checks for one or all integrations.
565
644
  */
@@ -610,6 +689,30 @@ declare class IntegrationManager {
610
689
  clear(): void;
611
690
  private checkHealth;
612
691
  private err;
692
+ /**
693
+ * Compute the plugin (bare package) names and skill names still claimed by
694
+ * SOME integration in the CURRENT lock state, keyed by runtime. Both
695
+ * `deactivate` and `upgrade`'s diff-removal call this AFTER
696
+ * `lockManager.removeEntries(id)` so "still claimed" means "claimed by
697
+ * another integration" — the applier must NOT physically remove a plugin/
698
+ * skill another active integration's manifest still lists.
699
+ *
700
+ * Plugin claims match on bare package name (via `stripPluginVersion`) so two
701
+ * integrations pinning the same plugin at different versions still keep the
702
+ * one file-system install alive when only one is removed.
703
+ */
704
+ private claimedEntriesByRuntime;
705
+ /**
706
+ * Phase 3 of `upgrade`: tear down ONLY the plugins/skills/config/MCP servers
707
+ * the NEW manifest dropped, leaving everything the new version still declares
708
+ * (and everything a sibling integration still claims) in place. Phase 4's
709
+ * `activate` re-applies and re-locks the new set immediately after.
710
+ *
711
+ * `removeEntries` clears this integration's lock rows and returns what it had;
712
+ * a candidate removal is skipped when it is EITHER (a) still declared by the
713
+ * new manifest, or (b) still claimed by another integration.
714
+ */
715
+ private applyUpgradeDiffRemovals;
613
716
  /**
614
717
  * True when the manifest's `supported_agents` (if declared) intersects the
615
718
  * registered runtime appliers. Hooks and MCP registration run once per
@@ -1060,6 +1163,18 @@ declare class OpenClawApplier implements RuntimeApplier {
1060
1163
  */
1061
1164
  removeConfig(integrationId: string): Promise<void>;
1062
1165
  private removeConfigLocked;
1166
+ /**
1167
+ * Drop a set of dotted keys from a dot-free parent object via read-drop-write,
1168
+ * UNLOCKED. If the parent becomes empty, `config unset` it; otherwise
1169
+ * `--replace` the shrunk map (siblings survive because they remain in
1170
+ * `remaining`). Warn-tolerant — a failed drop of an already-gone key must not
1171
+ * fail the caller. Shared by `removeConfig` (whole-integration teardown) and
1172
+ * `applyConfig`'s stale-key diff (per-key removal between manifest versions).
1173
+ *
1174
+ * Assumes the shared CLI lock is already held by the calling public method —
1175
+ * the lock is NOT re-entrant, so this stays an `*Unlocked` internal.
1176
+ */
1177
+ private dropSubtreeKeysUnlocked;
1063
1178
  /**
1064
1179
  * Raw single-key config write — `openclaw config set <key> <value>`.
1065
1180
  *
@@ -1402,8 +1517,10 @@ interface IIntegrationManager {
1402
1517
  }[]>;
1403
1518
  /** Install an integration at a specific version with config */
1404
1519
  install(integrationId: string, version: string, config: unknown, customSource?: CustomInstallSource): Promise<void>;
1405
- /** Activate an installed integration */
1406
- activate(integrationId: string): Promise<{
1520
+ /** Activate an installed integration. `opts.forcePlugins` forces version-drift reinstall of already-installed plugins. */
1521
+ activate(integrationId: string, opts?: {
1522
+ forcePlugins?: boolean;
1523
+ }): Promise<{
1407
1524
  configApplied: boolean;
1408
1525
  }>;
1409
1526
  /** Deactivate a running integration */
@@ -1414,6 +1531,18 @@ interface IIntegrationManager {
1414
1531
  reinstall(integrationId: string, version: string, config: unknown, customSource?: CustomInstallSource): Promise<{
1415
1532
  configApplied: boolean;
1416
1533
  }>;
1534
+ /**
1535
+ * Upgrade an integration to a new version IN PLACE (diff-based). Unchanged
1536
+ * plugins are skipped and the clone + npm work happens before the runtime is
1537
+ * suspended. `opts.onBeforeRuntimeMutation` is the deferred-suspend seam:
1538
+ * invoked exactly once, immediately before the first runtime mutation, so the
1539
+ * gateway can defer the RuntimeGate suspend past the slow clone/npm steps.
1540
+ */
1541
+ upgrade(integrationId: string, version: string, config: unknown, customSource?: CustomInstallSource, opts?: {
1542
+ onBeforeRuntimeMutation?: () => Promise<void>;
1543
+ }): Promise<{
1544
+ configApplied: boolean;
1545
+ }>;
1417
1546
  /** Check if an integration's install directory and manifest are intact on disk */
1418
1547
  isInstallIntact(integrationId: string): Promise<boolean>;
1419
1548
  /** Get the number of consecutive auto-reinstall attempts */
@@ -1433,7 +1562,9 @@ declare class IntegrationManagerAdapter implements IIntegrationManager {
1433
1562
  installedAt?: string;
1434
1563
  }[]>;
1435
1564
  install(integrationId: string, version: string, config: unknown, customSource?: CustomInstallSource): Promise<void>;
1436
- activate(integrationId: string): Promise<{
1565
+ activate(integrationId: string, opts?: {
1566
+ forcePlugins?: boolean;
1567
+ }): Promise<{
1437
1568
  configApplied: boolean;
1438
1569
  }>;
1439
1570
  deactivate(integrationId: string): Promise<void>;
@@ -1441,6 +1572,11 @@ declare class IntegrationManagerAdapter implements IIntegrationManager {
1441
1572
  reinstall(integrationId: string, version: string, config: unknown, customSource?: CustomInstallSource): Promise<{
1442
1573
  configApplied: boolean;
1443
1574
  }>;
1575
+ upgrade(integrationId: string, version: string, config: unknown, customSource?: CustomInstallSource, opts?: {
1576
+ onBeforeRuntimeMutation?: () => Promise<void>;
1577
+ }): Promise<{
1578
+ configApplied: boolean;
1579
+ }>;
1444
1580
  isInstallIntact(integrationId: string): Promise<boolean>;
1445
1581
  getReinstallAttempts(integrationId: string): number;
1446
1582
  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;
@@ -1297,13 +1381,7 @@ var IntegrationManager = class {
1297
1381
  this.log.info(`Deactivating integration: ${integrationId}`);
1298
1382
  try {
1299
1383
  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
- }
1384
+ const { plugins: claimedPluginsByRuntime, skills: claimedSkillsByRuntime } = this.claimedEntriesByRuntime();
1307
1385
  for (const [runtimeName, entries] of Object.entries(removed)) {
1308
1386
  const applier = this.runtimeAppliers.get(runtimeName);
1309
1387
  if (!applier) {
@@ -1469,6 +1547,132 @@ var IntegrationManager = class {
1469
1547
  }
1470
1548
  }
1471
1549
  /**
1550
+ * Upgrade an integration to a new version IN PLACE, diff-based, so unchanged
1551
+ * plugins are skipped and the clone + npm work happens BEFORE the runtime is
1552
+ * suspended. This is the fast path for a version bump — `reinstall()` stays
1553
+ * the destructive escape hatch (full uninstall → install → activate) for
1554
+ * repair.
1555
+ *
1556
+ * `opts.onBeforeRuntimeMutation` is the deferred-suspend seam: invoked exactly
1557
+ * once, immediately before the first step that mutates the runtime (Phase 2's
1558
+ * commit). RuntimeGate ownership stays in the gateway; the manager stays
1559
+ * runtime-agnostic.
1560
+ *
1561
+ * Phases:
1562
+ * 0. Guards — no state entry → install + activate; a stale `installing`
1563
+ * status (daemon killed mid-install) → reinstall recovery; install not
1564
+ * intact / old manifest unparseable → delegate to `reinstall()` (repair
1565
+ * stays destructive). Every fallback that can shell the openclaw CLI
1566
+ * invokes `opts.onBeforeRuntimeMutation` first (at most once — each
1567
+ * returns before Phase 2's seam).
1568
+ * 1. Prepare (runtime live) — resolve new version, `installer.stage()` the
1569
+ * clone + npm into `.staging-*`, canonicalize a custom-source manifest on
1570
+ * the STAGED dir. State untouched (stays active@old) on failure here.
1571
+ * 2. Commit — suspend seam, `installer.commitStaged()` swap, parse new
1572
+ * manifest + depends_on, `state.update()` (NOT full-replace) preserving
1573
+ * customConnectionId + refreshing installedAt + NOT touching secrets, run
1574
+ * new-manifest pre/post_install hooks (gated on supported_agents).
1575
+ * 3. Diff removals — tear down only plugins/skills/config/MCP the NEW
1576
+ * manifest dropped (and no sibling integration still claims).
1577
+ * 4. Activate with `forcePlugins` — the applier's version-match skip makes
1578
+ * unchanged plugins nearly free; only changed pins pay the reinstall cost.
1579
+ */
1580
+ async upgrade(params, opts) {
1581
+ const { name, version, config, customSource } = params;
1582
+ if (!name) return this.err("INVALID_PARAMS", "Integration name is required");
1583
+ const existing = this.state.get(name);
1584
+ if (!existing) {
1585
+ await opts?.onBeforeRuntimeMutation?.();
1586
+ const installResult = await this.install(params);
1587
+ if (!installResult.ok) return installResult;
1588
+ return this.activate(name, { forcePlugins: true });
1589
+ }
1590
+ if (existing.status === "installing") {
1591
+ this.log.warn(`Integration "${name}" has a stale "installing" status (daemon likely killed mid-install) — recovering via reinstall`);
1592
+ await opts?.onBeforeRuntimeMutation?.();
1593
+ return this.reinstall(params);
1594
+ }
1595
+ const oldManifestPath = join(this.installer.getInstallPath(name), "alfe-integration.yaml");
1596
+ let oldManifest = null;
1597
+ if (existsSync(oldManifestPath)) try {
1598
+ oldManifest = parseManifestFile(oldManifestPath);
1599
+ } catch {
1600
+ oldManifest = null;
1601
+ }
1602
+ if (!oldManifest) {
1603
+ this.log.warn(`Integration "${name}" install not intact or old manifest unparseable — delegating to reinstall`);
1604
+ await opts?.onBeforeRuntimeMutation?.();
1605
+ return this.reinstall(params);
1606
+ }
1607
+ this.log.info(`Upgrading integration: ${name}${version ? `@${version}` : ""}`);
1608
+ let stagedPath;
1609
+ try {
1610
+ let resolved;
1611
+ if (customSource) {
1612
+ resolved = buildCustomResolved(name, customSource);
1613
+ this.log.info(`Custom Connection upgrade: ${name} from ${resolved.repository}@${resolved.commit}`);
1614
+ } else {
1615
+ resolved = await this.resolver.resolve(name, version, { fresh: true });
1616
+ this.log.info(`Resolved ${name}@${resolved.version} from ${resolved.repository}`);
1617
+ }
1618
+ stagedPath = await this.installer.stage(resolved);
1619
+ if (customSource) ensureCanonicalManifestName(stagedPath, customSource.manifestPath);
1620
+ } catch (err) {
1621
+ const message = err instanceof Error ? err.message : String(err);
1622
+ this.log.error(`Failed to prepare upgrade for "${name}": ${message}`);
1623
+ return this.err("UPGRADE_FAILED", message);
1624
+ }
1625
+ try {
1626
+ await opts?.onBeforeRuntimeMutation?.();
1627
+ this.installer.commitStaged(name, stagedPath);
1628
+ const newManifestPath = join(this.installer.getInstallPath(name), "alfe-integration.yaml");
1629
+ if (!existsSync(newManifestPath)) throw new Error(`No alfe-integration.yaml found after commit for "${name}"`);
1630
+ const newManifest = parseManifestFile(newManifestPath);
1631
+ this.log.info(`Upgrade manifest validated: ${newManifest.id}@${newManifest.version}`);
1632
+ for (const dep of newManifest.depends_on) {
1633
+ const depState = this.state.get(dep);
1634
+ if (!depState || depState.status === "error") throw new Error(`Dependency "${dep}" is not installed. Install it first: alfe integration install ${dep}`);
1635
+ }
1636
+ this.state.update(name, {
1637
+ status: "installed",
1638
+ version: newManifest.version,
1639
+ installedAt: (/* @__PURE__ */ new Date()).toISOString(),
1640
+ config: config ?? existing.config,
1641
+ customConnectionId: customSource?.connectionId ?? existing.customConnectionId
1642
+ });
1643
+ const installHooksSupported = this.manifestSupportsRegisteredRuntime(newManifest);
1644
+ 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(", ")})`);
1645
+ if (installHooksSupported && newManifest.hooks.pre_install) {
1646
+ this.log.info(`Running pre_install hook: ${newManifest.hooks.pre_install}`);
1647
+ const hookResult = await runHook(this.installer.getInstallPath(name), newManifest.hooks.pre_install);
1648
+ if (hookResult.exitCode !== 0) throw new Error(`pre_install hook failed (exit ${String(hookResult.exitCode)}): ${hookResult.stderr || hookResult.stdout}`);
1649
+ }
1650
+ if (installHooksSupported && newManifest.hooks.post_install) {
1651
+ this.log.info(`Running post_install hook: ${newManifest.hooks.post_install}`);
1652
+ const hookResult = await runHookWithContext(this.installer.getInstallPath(name), newManifest.hooks.post_install, {
1653
+ integrationName: name,
1654
+ config: config ?? existing.config,
1655
+ secrets: this.secrets.get(name),
1656
+ runtimes: [...this.runtimeAppliers.keys()]
1657
+ });
1658
+ if (hookResult.exitCode !== 0) throw new Error(`post_install hook failed (exit ${String(hookResult.exitCode)}): ${hookResult.stderr || hookResult.stdout}`);
1659
+ }
1660
+ await this.applyUpgradeDiffRemovals(name, oldManifest, newManifest);
1661
+ return await this.activate(name, { forcePlugins: true });
1662
+ } catch (err) {
1663
+ try {
1664
+ if (existsSync(stagedPath)) rmSync(stagedPath, {
1665
+ recursive: true,
1666
+ force: true
1667
+ });
1668
+ } catch {}
1669
+ const message = err instanceof Error ? err.message : String(err);
1670
+ this.log.error(`Failed to upgrade "${name}": ${message}`);
1671
+ this.state.setStatus(name, "error", message);
1672
+ return this.err("UPGRADE_FAILED", message);
1673
+ }
1674
+ }
1675
+ /**
1472
1676
  * Run health checks for one or all integrations.
1473
1677
  */
1474
1678
  async health(params) {
@@ -1654,6 +1858,101 @@ var IntegrationManager = class {
1654
1858
  };
1655
1859
  }
1656
1860
  /**
1861
+ * Compute the plugin (bare package) names and skill names still claimed by
1862
+ * SOME integration in the CURRENT lock state, keyed by runtime. Both
1863
+ * `deactivate` and `upgrade`'s diff-removal call this AFTER
1864
+ * `lockManager.removeEntries(id)` so "still claimed" means "claimed by
1865
+ * another integration" — the applier must NOT physically remove a plugin/
1866
+ * skill another active integration's manifest still lists.
1867
+ *
1868
+ * Plugin claims match on bare package name (via `stripPluginVersion`) so two
1869
+ * integrations pinning the same plugin at different versions still keep the
1870
+ * one file-system install alive when only one is removed.
1871
+ */
1872
+ claimedEntriesByRuntime() {
1873
+ const remaining = this.lockManager.read();
1874
+ const plugins = /* @__PURE__ */ new Map();
1875
+ const skills = /* @__PURE__ */ new Map();
1876
+ for (const [rtName, state] of Object.entries(remaining.runtimes)) {
1877
+ plugins.set(rtName, new Set(state.plugins.map((p) => stripPluginVersion(p.package))));
1878
+ skills.set(rtName, new Set(state.skills.map((s) => s.name)));
1879
+ }
1880
+ return {
1881
+ plugins,
1882
+ skills
1883
+ };
1884
+ }
1885
+ /**
1886
+ * Phase 3 of `upgrade`: tear down ONLY the plugins/skills/config/MCP servers
1887
+ * the NEW manifest dropped, leaving everything the new version still declares
1888
+ * (and everything a sibling integration still claims) in place. Phase 4's
1889
+ * `activate` re-applies and re-locks the new set immediately after.
1890
+ *
1891
+ * `removeEntries` clears this integration's lock rows and returns what it had;
1892
+ * a candidate removal is skipped when it is EITHER (a) still declared by the
1893
+ * new manifest, or (b) still claimed by another integration.
1894
+ */
1895
+ async applyUpgradeDiffRemovals(integrationId, oldManifest, newManifest) {
1896
+ const removed = this.lockManager.removeEntries(integrationId);
1897
+ const { plugins: claimedPluginsByRuntime, skills: claimedSkillsByRuntime } = this.claimedEntriesByRuntime();
1898
+ for (const [runtimeName, entries] of Object.entries(removed)) {
1899
+ const applier = this.runtimeAppliers.get(runtimeName);
1900
+ if (!applier) {
1901
+ this.log.warn(`No applier for runtime "${runtimeName}" — cannot diff-remove entries`);
1902
+ continue;
1903
+ }
1904
+ if (!await applier.isAvailable()) {
1905
+ this.log.warn(`Runtime "${runtimeName}" is not available — skipping upgrade diff removal`);
1906
+ continue;
1907
+ }
1908
+ const { plugins: newPlugins, skills: newSkills, config: newRuntimeConfig } = resolveInstallsForRuntime(newManifest, runtimeName);
1909
+ const keepPluginsNew = new Set(newPlugins.map((p) => stripPluginVersion(p.package)));
1910
+ const keepSkillsNew = new Set(newSkills.map((s) => s.clawhub ?? s.path?.split("/").pop() ?? "unknown"));
1911
+ const keepPluginsOther = claimedPluginsByRuntime.get(runtimeName) ?? /* @__PURE__ */ new Set();
1912
+ const keepSkillsOther = claimedSkillsByRuntime.get(runtimeName) ?? /* @__PURE__ */ new Set();
1913
+ for (const plugin of entries.plugins) {
1914
+ const bare = stripPluginVersion(plugin.package);
1915
+ if (keepPluginsNew.has(bare) || keepPluginsOther.has(bare)) continue;
1916
+ this.log.info(`Upgrade: removing dropped plugin ${plugin.package} from ${runtimeName}`);
1917
+ try {
1918
+ await applier.removePlugin(plugin.package);
1919
+ } catch (err) {
1920
+ this.log.warn(`Failed to remove dropped plugin ${plugin.package} from ${runtimeName}: ${err instanceof Error ? err.message : String(err)}`);
1921
+ }
1922
+ }
1923
+ for (const skill of entries.skills) {
1924
+ if (keepSkillsNew.has(skill.name) || keepSkillsOther.has(skill.name)) continue;
1925
+ this.log.info(`Upgrade: removing dropped skill ${skill.name} from ${runtimeName}`);
1926
+ try {
1927
+ if (skill.sourcePath.startsWith("clawhub:")) await applier.removeClawHubSkill(skill.name);
1928
+ else await applier.removeSkill(skill.name);
1929
+ } catch (err) {
1930
+ this.log.warn(`Failed to remove dropped skill ${skill.name} from ${runtimeName}: ${err instanceof Error ? err.message : String(err)}`);
1931
+ }
1932
+ }
1933
+ const oldRuntimeConfig = resolveInstallsForRuntime(oldManifest, runtimeName).config;
1934
+ const oldHadConfig = Boolean(oldRuntimeConfig && Object.keys(oldRuntimeConfig).length > 0);
1935
+ const newHasConfig = Boolean(newRuntimeConfig && Object.keys(newRuntimeConfig).length > 0);
1936
+ if (oldHadConfig && !newHasConfig) {
1937
+ this.log.info(`Upgrade: removing gone config for ${integrationId} from ${runtimeName}`);
1938
+ try {
1939
+ await applier.removeConfig(integrationId);
1940
+ } catch (err) {
1941
+ this.log.warn(`Failed to remove gone config for ${integrationId} from ${runtimeName}: ${err instanceof Error ? err.message : String(err)}`);
1942
+ }
1943
+ }
1944
+ }
1945
+ if (this.mcpApplier) {
1946
+ const keepIds = (newManifest.mcp_servers ?? []).map((s) => `${integrationId}-${s.id}`);
1947
+ this.log.info(`Upgrade: pruning MCP servers for ${integrationId} (keeping ${String(keepIds.length)} declared)`);
1948
+ try {
1949
+ await this.mcpApplier.pruneForIntegration(integrationId, keepIds);
1950
+ } catch (err) {
1951
+ this.log.warn(`Failed to prune MCP servers for ${integrationId}: ${err instanceof Error ? err.message : String(err)}`);
1952
+ }
1953
+ }
1954
+ }
1955
+ /**
1657
1956
  * True when the manifest's `supported_agents` (if declared) intersects the
1658
1957
  * registered runtime appliers. Hooks and MCP registration run once per
1659
1958
  * integration (not per runtime), so they must use this aggregate check —
@@ -2460,10 +2759,29 @@ var OpenClawApplier = class {
2460
2759
  async applyConfigLocked(integrationId, config) {
2461
2760
  const tracking = this.readTracking();
2462
2761
  const integrations = tracking._integrations ?? {};
2762
+ const previous = integrations[integrationId];
2763
+ const { leaves, subtreesByParent } = partitionEntries(flattenConfig(config));
2764
+ if (previous && typeof previous === "object" && !Array.isArray(previous)) {
2765
+ const prev = partitionEntries(flattenConfig(previous));
2766
+ const nextLeafPaths = new Set(leaves.map((l) => l.path));
2767
+ const staleLeaves = prev.leaves.filter((l) => !nextLeafPaths.has(l.path));
2768
+ for (const { path } of staleLeaves) try {
2769
+ await this.runConfigCommandUnlocked(["unset", path]);
2770
+ } catch (err) {
2771
+ log$3.warn({
2772
+ err: err instanceof Error ? err.message : String(err),
2773
+ path
2774
+ }, "Failed to unset stale config leaf during applyConfig diff");
2775
+ }
2776
+ for (const [parentPath, prevKvs] of prev.subtreesByParent) {
2777
+ const nextKvs = subtreesByParent.get(parentPath);
2778
+ const goneKeys = [...prevKvs.keys()].filter((k) => !nextKvs?.has(k));
2779
+ if (goneKeys.length > 0) await this.dropSubtreeKeysUnlocked(parentPath, new Set(goneKeys));
2780
+ }
2781
+ }
2463
2782
  integrations[integrationId] = config;
2464
2783
  tracking._integrations = integrations;
2465
2784
  this.writeTracking(tracking);
2466
- const { leaves, subtreesByParent } = partitionEntries(flattenConfig(config));
2467
2785
  for (const [parentPath, dottedKvs] of subtreesByParent) {
2468
2786
  const merged = { ...await readParentObject(parentPath) };
2469
2787
  for (const [k, v] of dottedKvs) merged[k] = v;
@@ -2530,28 +2848,40 @@ var OpenClawApplier = class {
2530
2848
  path
2531
2849
  }, "Failed to unset config via openclaw config unset");
2532
2850
  }
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
- }
2851
+ for (const [parentPath, dottedKvs] of subtreesByParent) await this.dropSubtreeKeysUnlocked(parentPath, new Set(dottedKvs.keys()));
2551
2852
  tracking._integrations = Object.fromEntries(Object.entries(integrations).filter(([key]) => key !== integrationId));
2552
2853
  this.writeTracking(tracking);
2553
2854
  }
2554
2855
  /**
2856
+ * Drop a set of dotted keys from a dot-free parent object via read-drop-write,
2857
+ * UNLOCKED. If the parent becomes empty, `config unset` it; otherwise
2858
+ * `--replace` the shrunk map (siblings survive because they remain in
2859
+ * `remaining`). Warn-tolerant — a failed drop of an already-gone key must not
2860
+ * fail the caller. Shared by `removeConfig` (whole-integration teardown) and
2861
+ * `applyConfig`'s stale-key diff (per-key removal between manifest versions).
2862
+ *
2863
+ * Assumes the shared CLI lock is already held by the calling public method —
2864
+ * the lock is NOT re-entrant, so this stays an `*Unlocked` internal.
2865
+ */
2866
+ async dropSubtreeKeysUnlocked(parentPath, dottedKeys) {
2867
+ const existing = await readParentObject(parentPath);
2868
+ if (Object.keys(existing).length === 0) return;
2869
+ const remaining = Object.fromEntries(Object.entries(existing).filter(([k]) => !dottedKeys.has(k)));
2870
+ try {
2871
+ if (Object.keys(remaining).length === 0) await this.runConfigCommandUnlocked(["unset", parentPath]);
2872
+ else await this.runConfigSetUnlocked([
2873
+ parentPath,
2874
+ JSON.stringify(remaining),
2875
+ "--replace"
2876
+ ]);
2877
+ } catch (err) {
2878
+ log$3.warn({
2879
+ err: err instanceof Error ? err.message : String(err),
2880
+ parentPath
2881
+ }, "Failed to update parent config during subtree key drop");
2882
+ }
2883
+ }
2884
+ /**
2555
2885
  * Raw single-key config write — `openclaw config set <key> <value>`.
2556
2886
  *
2557
2887
  * Deliberately bypasses the `_integrations` tracking that `applyConfig`
@@ -2692,10 +3022,32 @@ var HermesApplier = class {
2692
3022
  async applyConfig(integrationId, config) {
2693
3023
  const tracking = this.readTracking();
2694
3024
  const integrations = tracking._integrations ?? {};
3025
+ const previous = integrations[integrationId];
3026
+ const { leaves, subtreesByParent } = partitionEntries(flattenConfig(config));
3027
+ if (previous && typeof previous === "object" && !Array.isArray(previous)) {
3028
+ const prev = partitionEntries(flattenConfig(previous));
3029
+ const nextLeafPaths = new Set(leaves.map((l) => l.path));
3030
+ const staleLeafPaths = prev.leaves.filter((l) => !nextLeafPaths.has(l.path)).map((l) => l.path);
3031
+ if (staleLeafPaths.length > 0) try {
3032
+ await this.deleteConfigKeys(staleLeafPaths);
3033
+ } catch (err) {
3034
+ log$2.warn({
3035
+ err: err instanceof Error ? err.message : String(err),
3036
+ integrationId
3037
+ }, "Failed to delete stale Hermes config keys during applyConfig diff");
3038
+ }
3039
+ const staleSubtreeParents = [...prev.subtreesByParent].filter(([parent, prevKvs]) => {
3040
+ const nextKvs = subtreesByParent.get(parent);
3041
+ return [...prevKvs.keys()].some((k) => !nextKvs?.has(k));
3042
+ }).map(([parent]) => parent);
3043
+ if (staleSubtreeParents.length > 0) log$2.warn({
3044
+ integrationId,
3045
+ parents: staleSubtreeParents
3046
+ }, "Hermes applyConfig diff: skipping stale dotted-key subtree(s) — OpenClaw-plugin-shaped config does not apply to Hermes");
3047
+ }
2695
3048
  integrations[integrationId] = config;
2696
3049
  tracking._integrations = integrations;
2697
3050
  this.writeTracking(tracking);
2698
- const { leaves, subtreesByParent } = partitionEntries(flattenConfig(config));
2699
3051
  if (subtreesByParent.size > 0) log$2.warn({
2700
3052
  integrationId,
2701
3053
  parents: [...subtreesByParent.keys()]
@@ -3191,6 +3543,31 @@ var McpApplier = class {
3191
3543
  const owner = `integration:${integrationId}`;
3192
3544
  return this.manager.removeServersByOwner(owner);
3193
3545
  }
3546
+ /**
3547
+ * Prune servers owned by this integration whose id is NOT in `keepIds`.
3548
+ * Used by the diff-based upgrade to drop MCP declarations the NEW manifest
3549
+ * no longer includes, while leaving the still-declared servers (and their
3550
+ * working env/credentials) untouched — `applyForIntegration` re-runs
3551
+ * afterwards and refreshes the kept ones idempotently.
3552
+ *
3553
+ * `keepIds` are the manifest's DECLARED ids (`${integrationId}-${server.id}`),
3554
+ * not the ids that successfully applied: a transient credential failure that
3555
+ * skipped a re-registration must not cause prune to delete a server the new
3556
+ * manifest still wants. Pure JSON-store writes — no CLI lock needed. Returns
3557
+ * the ids actually removed.
3558
+ */
3559
+ async pruneForIntegration(integrationId, keepIds) {
3560
+ const owner = `integration:${integrationId}`;
3561
+ const keep = new Set(keepIds);
3562
+ const removed = [];
3563
+ for (const { id, entry } of this.manager.listServers()) {
3564
+ if (entry.owner !== owner) continue;
3565
+ if (keep.has(id)) continue;
3566
+ await this.manager.removeServer(id, { expectedOwner: owner });
3567
+ removed.push(id);
3568
+ }
3569
+ return removed;
3570
+ }
3194
3571
  async resolveEnv(server, mergedConfig, connectionId) {
3195
3572
  if (!server.env || Object.keys(server.env).length === 0) return {};
3196
3573
  const provider = server.requires_credentials;
@@ -3277,8 +3654,8 @@ var IntegrationManagerAdapter = class {
3277
3654
  });
3278
3655
  if (!result.ok) throw new Error(result.error?.message ?? `Failed to install ${integrationId}`);
3279
3656
  }
3280
- async activate(integrationId) {
3281
- const result = await this.manager.activate(integrationId);
3657
+ async activate(integrationId, opts) {
3658
+ const result = await this.manager.activate(integrationId, opts);
3282
3659
  if (!result.ok) throw new Error(result.error?.message ?? `Failed to activate ${integrationId}`);
3283
3660
  return { configApplied: result.payload?.configApplied ?? false };
3284
3661
  }
@@ -3300,6 +3677,16 @@ var IntegrationManagerAdapter = class {
3300
3677
  if (!result.ok) throw new Error(result.error?.message ?? `Failed to reinstall ${integrationId}`);
3301
3678
  return { configApplied: result.payload?.configApplied ?? false };
3302
3679
  }
3680
+ async upgrade(integrationId, version, config, customSource, opts) {
3681
+ const result = await this.manager.upgrade({
3682
+ name: integrationId,
3683
+ version,
3684
+ config,
3685
+ customSource
3686
+ }, opts);
3687
+ if (!result.ok) throw new Error(result.error?.message ?? `Failed to upgrade ${integrationId}`);
3688
+ return { configApplied: result.payload?.configApplied ?? false };
3689
+ }
3303
3690
  isInstallIntact(integrationId) {
3304
3691
  return Promise.resolve(this.manager.isInstallIntact(integrationId));
3305
3692
  }
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.0",
4
4
  "description": "Integration lifecycle management for Alfe — registry, resolution, installation, and state",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",