@camstack/server 1.2.476 → 1.2.478

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.
@@ -42,6 +42,7 @@ const path = __importStar(require("node:path"));
42
42
  const system_1 = require("@camstack/system");
43
43
  const moleculer_1 = require("moleculer");
44
44
  const agent_deploy_swap_js_1 = require("./agent-deploy-swap.js");
45
+ const agent_undeploy_js_1 = require("./agent-undeploy.js");
45
46
  const apply_model_distribution_js_1 = require("./apply-model-distribution.js");
46
47
  const fetch_bundle_from_hub_js_1 = require("./fetch-bundle-from-hub.js");
47
48
  /**
@@ -57,6 +58,14 @@ const deploySwapLogger = {
57
58
  child: () => deploySwapLogger,
58
59
  withTags: () => deploySwapLogger,
59
60
  };
61
+ /**
62
+ * Packages an agent bootstraps itself when the bootstrap does not say
63
+ * otherwise. Mirrors the hub reconcile's `AGENT_BOOTSTRAP_PACKAGES`.
64
+ */
65
+ const DEFAULT_AGENT_BOOTSTRAP_PACKAGES = new Set([
66
+ '@camstack/system',
67
+ '@camstack/addon-agent-ui',
68
+ ]);
60
69
  /** Timeout for the agent-local `$process.restart` delegation. */
61
70
  const AGENT_PROCESS_RESTART_TIMEOUT_MS = 30_000;
62
71
  function getLocalIps() {
@@ -455,81 +464,28 @@ function createAgentService(deps) {
455
464
  },
456
465
  },
457
466
  /**
458
- * Genuinely drop a deployed addon from this agent.
459
- *
460
- * A plain `loadedAddons.delete()` only forgets the bookkeeping entry —
461
- * the addon instance keeps running and, crucially, its Moleculer
462
- * service keeps advertising the addon's capabilities into the cluster
463
- * (the hub still sees the provider as a live `<cap>@<agent>` entry).
464
- * To truly undeploy we must, in order:
465
- * 1. `shutdown()` the running instance (releases timers, sockets,
466
- * native handles) — same hook the agent's SIGTERM path uses.
467
- * 2. `broker.destroyService(addonId)` — the deployed-addon Moleculer
468
- * service is named after the addon DECLARATION id (see
469
- * `createAddonService` → `name: declaration.id`). Destroying it
470
- * unregisters every capability action so the cluster stops
471
- * routing to / advertising this agent's provider.
472
- * 3. Drop the `loadedAddons` entry and delete the on-disk folder.
473
- *
474
- * `addonId` here is the addon DECLARATION id — the same key
475
- * `loadedAddons` uses and the same value `$agent.status` reports, so
476
- * the hub reconciler can match it directly.
467
+ * Genuinely drop a deployed addon — or a whole package — from this agent.
468
+ * `{ packageName }` is the operator's uninstall (`addons.uninstallPackage
469
+ * ({ nodeId })`); `{ addonId }` is the stale-addon reconcile. Both stop the
470
+ * runner(s) hosting the addon, which the old body never did: on an agent
471
+ * nearly every addon lives in a group runner, so deleting its folder left
472
+ * the process running and advertising its caps. See `agent-undeploy.ts`.
477
473
  */
478
474
  undeploy: {
479
475
  handler: async (ctx) => {
480
476
  const { params, broker } = ctx;
481
- const { addonId } = params;
482
- const entry = deps.loadedAddons.get(addonId);
483
- // 1. Shut the running instance down (best-effort — a throwing
484
- // disposer must not block the rest of the teardown).
485
- if (entry?.addon?.shutdown) {
486
- try {
487
- await entry.addon.shutdown();
488
- }
489
- catch (err) {
490
- broker.logger.warn(`$agent.undeploy: ${addonId} shutdown() threw`, {
491
- error: String(err),
492
- });
493
- }
494
- }
495
- // 2. Destroy the Moleculer service so the cluster stops seeing
496
- // this agent's capability providers for the addon.
497
- try {
498
- await broker.destroyService(addonId);
499
- }
500
- catch {
501
- // Service may not exist (group-spawned addons have no per-addon
502
- // service on the agent broker, or it was never created).
503
- }
504
- // 3. Forget the entry and remove the deployed folder. The deploy
505
- // handler keys the on-disk dir by the PACKAGE name, but $agent
506
- // redeploys for a single addon use addonId === packageName for
507
- // unscoped packages; remove whichever folder matches.
508
- deps.loadedAddons.delete(addonId);
509
- const addonDir = path.join(deps.addonsDir, addonId);
510
- if (fs.existsSync(addonDir)) {
511
- fs.rmSync(addonDir, { recursive: true, force: true });
512
- }
513
- // Defense-in-depth: one package dir can host MANY addons (e.g.
514
- // `@camstack/addon-pipeline` ships decoder-ffmpeg, recorder,
515
- // motion-wasm, …). Removing the whole bundle because ONE member was
516
- // undeployed is destructive — every sibling runner would crash-loop
517
- // on the missing package.json. Only remove the package dir when no
518
- // OTHER loaded addon still lives in it (this addon's own entry was
519
- // already deleted from `loadedAddons` above, so the remaining
520
- // values are exactly the siblings).
521
- const pkgName = entry?.packageName;
522
- if (pkgName && pkgName !== addonId) {
523
- const pkgShared = [...deps.loadedAddons.values()].some((e) => e.packageName === pkgName);
524
- if (!pkgShared) {
525
- const pkgDir = path.join(deps.addonsDir, pkgName);
526
- if (fs.existsSync(pkgDir)) {
527
- fs.rmSync(pkgDir, { recursive: true, force: true });
528
- }
529
- }
530
- }
531
- broker.logger.info(`$agent.undeploy: ${addonId} disposed (instance + service + folder)`);
532
- return { success: true, addonId };
477
+ return (0, agent_undeploy_js_1.undeployFromAgent)({
478
+ addonsDir: deps.addonsDir,
479
+ loadedAddons: deps.loadedAddons,
480
+ bootstrapPackages: deps.bootstrapPackages ?? DEFAULT_AGENT_BOOTSTRAP_PACKAGES,
481
+ listRunnersByAddon: (addonId) => broker.call('$process.listByAddon', { addonId }, { nodeID: broker.nodeID, timeout: AGENT_PROCESS_RESTART_TIMEOUT_MS }),
482
+ stopRunner: (name) => broker.call('$process.stop', { name }, { nodeID: broker.nodeID, timeout: AGENT_PROCESS_RESTART_TIMEOUT_MS }),
483
+ destroyService: (addonId) => broker.destroyService(addonId),
484
+ unregisterProvider: (capName, addonId) => deps.unregisterProvider?.(capName, addonId),
485
+ respawnSurvivors: deps.reloadDeployedAddons,
486
+ reconcileNodeRegistration: deps.reconcileNodeRegistration,
487
+ logger: deploySwapLogger,
488
+ }, { addonId: params.addonId, packageName: params.packageName });
533
489
  },
534
490
  },
535
491
  /**
@@ -0,0 +1,255 @@
1
+ "use strict";
2
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
+ if (k2 === undefined) k2 = k;
4
+ var desc = Object.getOwnPropertyDescriptor(m, k);
5
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
+ desc = { enumerable: true, get: function() { return m[k]; } };
7
+ }
8
+ Object.defineProperty(o, k2, desc);
9
+ }) : (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ o[k2] = m[k];
12
+ }));
13
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
14
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
15
+ }) : function(o, v) {
16
+ o["default"] = v;
17
+ });
18
+ var __importStar = (this && this.__importStar) || (function () {
19
+ var ownKeys = function(o) {
20
+ ownKeys = Object.getOwnPropertyNames || function (o) {
21
+ var ar = [];
22
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
23
+ return ar;
24
+ };
25
+ return ownKeys(o);
26
+ };
27
+ return function (mod) {
28
+ if (mod && mod.__esModule) return mod;
29
+ var result = {};
30
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
31
+ __setModuleDefault(result, mod);
32
+ return result;
33
+ };
34
+ })();
35
+ Object.defineProperty(exports, "__esModule", { value: true });
36
+ exports.undeployFromAgent = undeployFromAgent;
37
+ const fs = __importStar(require("node:fs"));
38
+ const path = __importStar(require("node:path"));
39
+ const agent_group_runner_js_1 = require("./agent-group-runner.js");
40
+ function capName(cap) {
41
+ return typeof cap === 'string' ? cap : cap.name;
42
+ }
43
+ function packageDirFor(addonsDir, packageName) {
44
+ const resolved = path.resolve(addonsDir, packageName);
45
+ const root = path.resolve(addonsDir);
46
+ // A package name is data from the network. It must name a dir INSIDE the
47
+ // addon root and never the root itself.
48
+ if (resolved === root || !resolved.startsWith(root + path.sep))
49
+ return null;
50
+ return resolved;
51
+ }
52
+ function resolveTarget(seam, request) {
53
+ const packageName = request.packageName ??
54
+ (request.addonId !== undefined
55
+ ? seam.loadedAddons.get(request.addonId)?.packageName
56
+ : undefined);
57
+ const packageDir = packageName !== undefined ? packageDirFor(seam.addonsDir, packageName) : null;
58
+ const manifest = packageDir !== null ? (0, agent_group_runner_js_1.readPackageManifestAddons)(packageDir) : null;
59
+ const capsByAddon = new Map();
60
+ let protectedBy = null;
61
+ for (const decl of manifest?.declarations ?? []) {
62
+ capsByAddon.set(decl.id, (decl.capabilities ?? []).map(capName));
63
+ if (decl.protected === true)
64
+ protectedBy = decl.id;
65
+ }
66
+ if (request.packageName === undefined) {
67
+ return {
68
+ packageName,
69
+ addonIds: request.addonId !== undefined ? [request.addonId] : [],
70
+ capsByAddon,
71
+ packageDir,
72
+ protectedBy,
73
+ };
74
+ }
75
+ const ids = new Set(capsByAddon.keys());
76
+ for (const entry of seam.loadedAddons.values()) {
77
+ if (entry.packageName === request.packageName)
78
+ ids.add(entry.id);
79
+ }
80
+ return { packageName, addonIds: [...ids], capsByAddon, packageDir, protectedBy };
81
+ }
82
+ function refusalReason(seam, target) {
83
+ if (target.packageName !== undefined && seam.bootstrapPackages.has(target.packageName)) {
84
+ return `${target.packageName} is this node's bootstrap infrastructure and cannot be uninstalled`;
85
+ }
86
+ if (target.protectedBy !== null) {
87
+ return `${target.packageName ?? 'package'} is protected (addon "${target.protectedBy}" declares protected: true)`;
88
+ }
89
+ if (target.packageName === undefined && target.addonIds.length === 0) {
90
+ return 'neither addonId nor packageName was given';
91
+ }
92
+ return null;
93
+ }
94
+ async function shutdownInProcess(seam, addonId) {
95
+ const entry = seam.loadedAddons.get(addonId);
96
+ if (entry?.addon?.shutdown) {
97
+ try {
98
+ await entry.addon.shutdown();
99
+ }
100
+ catch (err) {
101
+ seam.logger.warn(`undeploy: ${addonId} shutdown() threw`, {
102
+ tags: { addonId },
103
+ meta: { error: err instanceof Error ? err.message : String(err) },
104
+ });
105
+ }
106
+ }
107
+ try {
108
+ await seam.destroyService(addonId);
109
+ }
110
+ catch {
111
+ // Group-runner addons have no per-addon service on the agent broker.
112
+ }
113
+ }
114
+ async function stopHostingRunners(seam, addonIds) {
115
+ const removing = new Set(addonIds);
116
+ const runners = new Map();
117
+ const warnings = [];
118
+ for (const id of addonIds) {
119
+ try {
120
+ for (const row of await seam.listRunnersByAddon(id))
121
+ runners.set(row.name, row);
122
+ }
123
+ catch (err) {
124
+ warnings.push(`could not list runners for ${id}: ${err instanceof Error ? err.message : String(err)}`);
125
+ }
126
+ }
127
+ const stopped = [];
128
+ const survivors = [];
129
+ for (const runner of runners.values()) {
130
+ const result = await seam.stopRunner(runner.name).catch((err) => ({
131
+ success: false,
132
+ reason: err instanceof Error ? err.message : String(err),
133
+ }));
134
+ if (!result.success) {
135
+ warnings.push(`runner "${runner.name}" did not stop: ${result.reason ?? 'unknown'}`);
136
+ continue;
137
+ }
138
+ stopped.push(runner.name);
139
+ for (const id of runner.addonIds) {
140
+ if (!removing.has(id))
141
+ survivors.push(id);
142
+ }
143
+ }
144
+ return { stopped, survivors, warnings };
145
+ }
146
+ function removeDirs(seam, request, target) {
147
+ const removed = [];
148
+ const rm = (dir) => {
149
+ if (dir === null || !fs.existsSync(dir))
150
+ return;
151
+ fs.rmSync(dir, { recursive: true, force: true });
152
+ removed.push(dir);
153
+ };
154
+ if (request.packageName !== undefined) {
155
+ rm(target.packageDir);
156
+ return removed;
157
+ }
158
+ // Addon mode — the reconcile's per-id removal. One package dir can host MANY
159
+ // addons; only remove it when no OTHER loaded addon still lives in it (this
160
+ // addon's own row is already gone from `loadedAddons`).
161
+ const addonId = request.addonId;
162
+ if (addonId !== undefined)
163
+ rm(packageDirFor(seam.addonsDir, addonId));
164
+ const pkg = target.packageName;
165
+ if (pkg !== undefined && pkg !== addonId) {
166
+ const shared = [...seam.loadedAddons.values()].some((e) => e.packageName === pkg);
167
+ if (!shared)
168
+ rm(target.packageDir);
169
+ }
170
+ return removed;
171
+ }
172
+ /** Run one `$agent.undeploy`. Never throws for a refusal — it answers `refused`. */
173
+ async function undeployFromAgent(seam, request) {
174
+ const target = resolveTarget(seam, request);
175
+ const scope = request.packageName !== undefined ? 'package' : 'addon';
176
+ const base = {
177
+ scope,
178
+ addonId: request.addonId,
179
+ packageName: target.packageName,
180
+ addonIds: target.addonIds,
181
+ };
182
+ const refused = refusalReason(seam, target);
183
+ if (refused !== null) {
184
+ seam.logger.warn(`undeploy refused: ${refused}`, { meta: { request } });
185
+ return {
186
+ ...base,
187
+ success: false,
188
+ outcome: 'refused',
189
+ reason: refused,
190
+ runnersStopped: [],
191
+ runnersRespawned: [],
192
+ removedDirs: [],
193
+ warnings: [],
194
+ };
195
+ }
196
+ const present = target.addonIds.some((id) => seam.loadedAddons.has(id)) ||
197
+ (target.packageDir !== null && fs.existsSync(target.packageDir));
198
+ if (scope === 'package' && !present) {
199
+ return {
200
+ ...base,
201
+ success: true,
202
+ outcome: 'not-installed',
203
+ runnersStopped: [],
204
+ runnersRespawned: [],
205
+ removedDirs: [],
206
+ warnings: [],
207
+ };
208
+ }
209
+ for (const id of target.addonIds)
210
+ await shutdownInProcess(seam, id);
211
+ const runners = await stopHostingRunners(seam, target.addonIds);
212
+ for (const id of target.addonIds) {
213
+ for (const cap of target.capsByAddon.get(id) ?? [])
214
+ seam.unregisterProvider(cap, id);
215
+ seam.loadedAddons.delete(id);
216
+ }
217
+ // A survivor's row is dropped too, so the discovery pass below sees it as
218
+ // not-loaded and respawns its runner — without the removed addons, whose
219
+ // code is gone by then.
220
+ for (const id of runners.survivors) {
221
+ for (const cap of target.capsByAddon.get(id) ?? [])
222
+ seam.unregisterProvider(cap, id);
223
+ seam.loadedAddons.delete(id);
224
+ }
225
+ const removedDirs = removeDirs(seam, request, target);
226
+ const warnings = [...runners.warnings];
227
+ let runnersRespawned = [];
228
+ if (runners.survivors.length > 0) {
229
+ if (seam.respawnSurvivors) {
230
+ try {
231
+ await seam.respawnSurvivors();
232
+ runnersRespawned = runners.stopped;
233
+ }
234
+ catch (err) {
235
+ warnings.push(`survivors ${runners.survivors.join(', ')} were not respawned: ${err instanceof Error ? err.message : String(err)}`);
236
+ }
237
+ }
238
+ else {
239
+ warnings.push(`survivors ${runners.survivors.join(', ')} were stopped and not respawned`);
240
+ }
241
+ }
242
+ seam.reconcileNodeRegistration?.();
243
+ seam.logger.info(`undeploy: ${target.packageName ?? request.addonId ?? '?'} removed ` +
244
+ `(addons=${target.addonIds.join(',') || '-'} runnersStopped=${runners.stopped.join(',') || '-'} ` +
245
+ `dirs=${removedDirs.length})`, { meta: { warnings } });
246
+ return {
247
+ ...base,
248
+ success: true,
249
+ outcome: 'uninstalled',
250
+ runnersStopped: runners.stopped,
251
+ runnersRespawned,
252
+ removedDirs,
253
+ warnings,
254
+ };
255
+ }
@@ -470,6 +470,19 @@ async function startAgent(configPath) {
470
470
  // (manifest strip + runtime deps + native deps + atomic swap) instead of
471
471
  // the bare tar swap that shipped addons without their node_modules.
472
472
  installBundleTgz: (tgzPath) => installer.installFromTgz(tgzPath),
473
+ // `$agent.undeploy` refuses what this node bootstraps itself, and drops the
474
+ // group-runner cap proxies it removes (registered by
475
+ // `registerGroupRunnerProviders`, ownerless — so a plain unregister owns them).
476
+ bootstrapPackages: new Set([
477
+ '@camstack/system',
478
+ '@camstack/addon-agent-ui',
479
+ ...system_1.AddonInstaller.AGENT_PACKAGES,
480
+ ]),
481
+ unregisterProvider: (capName, addonId) => {
482
+ if (capabilityRegistry.hasProvider(capName, addonId)) {
483
+ capabilityRegistry.unregisterProvider(capName, addonId);
484
+ }
485
+ },
473
486
  });
474
487
  broker.createService(agentServiceSchema);
475
488
  // $process service — manages forked child processes (same as hub).
@@ -77,6 +77,7 @@ const types_1 = require("@camstack/types");
77
77
  const server_1 = require("@trpc/server");
78
78
  const integration_id_backfill_1 = require("../../boot/integration-id-backfill");
79
79
  const addon_package_service_js_1 = require("../../core/addon/addon-package.service.js");
80
+ const addon_uninstall_js_1 = require("../../core/addon/addon-uninstall.js");
80
81
  const lifecycle_runner_singleton_js_1 = require("../../core/lifecycle/lifecycle-runner.singleton.js");
81
82
  const agent_installed_packages_js_1 = require("../../core/updates/agent-installed-packages.js");
82
83
  const http_request_census_singleton_js_1 = require("../trpc/http-request-census.singleton.js");
@@ -1318,8 +1319,82 @@ function isHubNode(nodeId) {
1318
1319
  * filter it out to make the list look clean — the number reaching zero is the
1319
1320
  * point of the number.
1320
1321
  */
1321
- function buildAddonsProvider(ar, ps, ls, moleculer, configService, ctx) {
1322
+ /**
1323
+ * How long the hub waits for an agent's `$agent.undeploy`. It stops runners
1324
+ * (each up to a 5 s SIGTERM grace plus the group reap) and may respawn a shared
1325
+ * runner's survivors, so it is far longer than a status read.
1326
+ */
1327
+ const AGENT_UNDEPLOY_TIMEOUT_MS = 120_000;
1328
+ function parseAgentStatusAddons(reply) {
1329
+ if (typeof reply !== 'object' || reply === null || !('addons' in reply))
1330
+ return [];
1331
+ const addons = reply.addons;
1332
+ if (!Array.isArray(addons))
1333
+ return [];
1334
+ const rows = [];
1335
+ for (const raw of addons) {
1336
+ if (typeof raw !== 'object' || raw === null || !('id' in raw) || typeof raw.id !== 'string') {
1337
+ continue;
1338
+ }
1339
+ const packageName = 'packageName' in raw && typeof raw.packageName === 'string' ? raw.packageName : undefined;
1340
+ const version = 'version' in raw && typeof raw.version === 'string' ? raw.version : undefined;
1341
+ rows.push({ id: raw.id, packageName, version });
1342
+ }
1343
+ return rows;
1344
+ }
1345
+ /**
1346
+ * The real seam behind `addons.uninstallPackage` / `listInstallations`. The
1347
+ * agent leg is the same hub-side `broker.call({ nodeID })` to the agent's
1348
+ * `$agent` service that `updatePackage({ nodeId })` uses.
1349
+ */
1350
+ function buildUninstallSeam(ar, ps, broker, agentRegistry) {
1351
+ return {
1352
+ isProtected: (name) => ps.isProtected(name),
1353
+ listHubPackages: () => {
1354
+ let installed;
1355
+ try {
1356
+ installed = ps.listInstalled();
1357
+ }
1358
+ catch {
1359
+ return [];
1360
+ }
1361
+ const idsByPackage = new Map();
1362
+ for (const row of ar.listAddons()) {
1363
+ const ids = idsByPackage.get(row.manifest.packageName) ?? [];
1364
+ ids.push(row.manifest.id);
1365
+ idsByPackage.set(row.manifest.packageName, ids);
1366
+ }
1367
+ return installed.map((p) => ({
1368
+ name: p.name,
1369
+ version: p.version,
1370
+ addonIds: idsByPackage.get(p.name) ?? [],
1371
+ }));
1372
+ },
1373
+ uninstallOnHub: async (name) => {
1374
+ await ps.uninstallAndReload(name);
1375
+ },
1376
+ listAgents: async () => {
1377
+ if (!agentRegistry)
1378
+ return [];
1379
+ const directory = await agentRegistry.listNodeDirectory();
1380
+ return directory.nodes
1381
+ .filter((n) => !n.isHub && !n.id.includes('/'))
1382
+ .map((n) => ({ id: n.id, name: n.name, isOnline: n.isOnline }));
1383
+ },
1384
+ agentAddons: async (nodeId) => parseAgentStatusAddons(await broker.call('$agent.status', {}, { nodeID: nodeId, timeout: agent_installed_packages_js_1.AGENT_STATUS_TIMEOUT_MS })),
1385
+ agentUndeploy: (nodeId, request) => broker.call('$agent.undeploy', request, {
1386
+ nodeID: nodeId,
1387
+ timeout: AGENT_UNDEPLOY_TIMEOUT_MS,
1388
+ }),
1389
+ recordedPackages: async (nodeId) => agentRegistry ? agentRegistry.getRecordedPackages(nodeId) : [],
1390
+ notePackageUndeployed: async (nodeId, packageName, addonIds) => {
1391
+ await agentRegistry?.notePackageUndeployed(nodeId, packageName, addonIds);
1392
+ },
1393
+ };
1394
+ }
1395
+ function buildAddonsProvider(ar, ps, ls, moleculer, configService, ctx, agentRegistry) {
1322
1396
  const broker = moleculer.broker;
1397
+ const uninstallSeam = buildUninstallSeam(ar, ps, broker, agentRegistry);
1323
1398
  const frameworkAllowSet = new Set([addon_package_service_js_1.SYSTEM_PACKAGE]);
1324
1399
  // Process-wide runner built once at boot (manual-boot wires its deps from the
1325
1400
  // addon-package service + event bus). `createdBy` travels per `startJob` call.
@@ -1349,7 +1424,18 @@ function buildAddonsProvider(ar, ps, ls, moleculer, configService, ctx) {
1349
1424
  installFromWorkspace: async (input) => ps.installFromWorkspaceAndLoad(input.packageName),
1350
1425
  isWorkspaceAvailable: async () => ps.isWorkspaceAvailable(),
1351
1426
  listWorkspacePackages: async () => ps.listWorkspacePackages(),
1352
- uninstallPackage: async (input) => ps.uninstallAndReload(input.packageName),
1427
+ uninstallPackage: async (input) => {
1428
+ try {
1429
+ return await (0, addon_uninstall_js_1.uninstallPackage)(uninstallSeam, input);
1430
+ }
1431
+ catch (err) {
1432
+ if (err instanceof addon_uninstall_js_1.PackageProtectedError) {
1433
+ throw new server_1.TRPCError({ code: 'FORBIDDEN', message: err.message });
1434
+ }
1435
+ throw err;
1436
+ }
1437
+ },
1438
+ listInstallations: async (input) => (0, addon_uninstall_js_1.listInstallations)(uninstallSeam, input.packageName),
1353
1439
  reloadPackages: async () => ps.reloadPackages(),
1354
1440
  searchAvailable: async (input) => {
1355
1441
  const results = await ps.searchNpm(input?.query);
@@ -1486,7 +1572,9 @@ function buildAddonsProvider(ar, ps, ls, moleculer, configService, ctx) {
1486
1572
  if (offNode.length > 0) {
1487
1573
  throw new server_1.TRPCError({
1488
1574
  code: 'BAD_REQUEST',
1489
- message: `The lifecycle job engine is hub-local; it cannot target ${offNode.join(', ')}. Update an agent's packages via updatePackage({ nodeId }).`,
1575
+ message: input.kind === 'uninstall'
1576
+ ? `The lifecycle job engine is hub-local; it cannot target ${offNode.join(', ')}. Uninstall from an agent via uninstallPackage({ packageName, nodeId }), or from every node via uninstallPackage({ packageName, everywhere: true }).`
1577
+ : `The lifecycle job engine is hub-local; it cannot target ${offNode.join(', ')}. Update an agent's packages via updatePackage({ nodeId }).`,
1490
1578
  });
1491
1579
  }
1492
1580
  return lifecycleRunner.startJob({