@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 +139 -3
- package/dist/index.js +416 -29
- package/package.json +1 -1
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
|
|
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
|
|
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
|
|
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