@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 +174 -4
- package/dist/index.js +478 -37
- 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.
|
|
@@ -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
|
-
/**
|
|
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
|
|
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
|
|
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, {
|
|
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
|
|
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 (
|
|
2191
|
-
const pinnedVersion = pluginSpecVersion(spec);
|
|
2522
|
+
if (this.isPluginInstalled(pkg)) {
|
|
2192
2523
|
const installedVersion = this.installedPluginVersion(pkg);
|
|
2193
|
-
|
|
2194
|
-
|
|
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
|
-
|
|
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
|
|
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