@checkstack/backend 0.8.2 → 0.9.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/CHANGELOG.md +203 -0
- package/drizzle/0001_slim_mordo.sql +34 -0
- package/drizzle/meta/0001_snapshot.json +444 -0
- package/drizzle/meta/_journal.json +7 -0
- package/package.json +12 -7
- package/src/index.ts +276 -17
- package/src/plugin-deregistration.test.ts +137 -0
- package/src/plugin-manager/api-router.ts +35 -11
- package/src/plugin-manager/plugin-loader.ts +73 -0
- package/src/plugin-manager.ts +295 -105
- package/src/schema.ts +79 -1
- package/src/services/compatibility-checker.test.ts +146 -0
- package/src/services/compatibility-checker.ts +137 -0
- package/src/services/dev-auth.test.ts +87 -0
- package/src/services/dev-auth.ts +56 -0
- package/src/services/plugin-artifact-store.ts +131 -0
- package/src/services/plugin-bundle-resolver.ts +76 -0
- package/src/services/plugin-event-recorder.ts +87 -0
- package/src/services/plugin-installers/catalog-installer.ts +33 -0
- package/src/services/plugin-installers/github-installer.ts +207 -0
- package/src/services/plugin-installers/install-from-tarball.ts +69 -0
- package/src/services/plugin-installers/installer-registry.ts +51 -0
- package/src/services/plugin-installers/npm-installer.ts +156 -0
- package/src/services/plugin-installers/plugin-install-error.ts +37 -0
- package/src/services/plugin-installers/tarball-installer.ts +80 -0
- package/src/services/plugin-installers/tarball-utils.test.ts +200 -0
- package/src/services/plugin-installers/tarball-utils.ts +172 -0
- package/src/services/plugin-manager-orchestrator.ts +522 -0
- package/src/services/plugin-manager-router.ts +219 -0
- package/src/utils/plugin-discovery.test.ts +6 -0
- package/src/utils/plugin-discovery.ts +6 -1
- package/tsconfig.json +3 -0
- package/src/plugin-lifecycle.test.ts +0 -276
- package/src/plugin-manager/plugin-admin-router.ts +0 -89
- package/src/services/plugin-installer.test.ts +0 -90
- package/src/services/plugin-installer.ts +0 -70
|
@@ -224,6 +224,14 @@ export async function loadPlugins({
|
|
|
224
224
|
// 2. Sync local plugins to database
|
|
225
225
|
await syncPluginsToDatabase({ localPlugins, db: deps.db });
|
|
226
226
|
|
|
227
|
+
// 2.5 Bootstrap runtime-installed plugins missing from node_modules.
|
|
228
|
+
// For every `is_uninstallable=true` row in `plugins`, ensure the
|
|
229
|
+
// package is installed in the runtime dir. If not, fetch its tarball
|
|
230
|
+
// from `plugin_artifacts` and run `bun install` so the import below
|
|
231
|
+
// resolves. This is what lets a freshly spun replica recover the
|
|
232
|
+
// full plugin set from Postgres alone.
|
|
233
|
+
await bootstrapRuntimePlugins({ db: deps.db, registry: deps.registry });
|
|
234
|
+
|
|
227
235
|
// 3. Load all enabled BACKEND plugins from database
|
|
228
236
|
allPlugins = await deps.db
|
|
229
237
|
.select()
|
|
@@ -641,3 +649,68 @@ function validateContractAccessRules({
|
|
|
641
649
|
}
|
|
642
650
|
}
|
|
643
651
|
}
|
|
652
|
+
|
|
653
|
+
/**
|
|
654
|
+
* Fresh-instance bootstrap.
|
|
655
|
+
*
|
|
656
|
+
* Walks every `is_uninstallable=true` row in the `plugins` table and ensures
|
|
657
|
+
* the corresponding package is installed under the runtime dir. Missing
|
|
658
|
+
* packages are recovered from `plugin_artifacts` (bytea) and installed with
|
|
659
|
+
* `bun install --no-save --ignore-scripts`. Once this returns, the normal
|
|
660
|
+
* `pluginModule = await import(...)` in Phase 1 below resolves.
|
|
661
|
+
*/
|
|
662
|
+
async function bootstrapRuntimePlugins({
|
|
663
|
+
db,
|
|
664
|
+
registry,
|
|
665
|
+
}: {
|
|
666
|
+
db: SafeDatabase<Record<string, unknown>>;
|
|
667
|
+
registry: ServiceRegistry;
|
|
668
|
+
}): Promise<void> {
|
|
669
|
+
const installerRegistry = await registry
|
|
670
|
+
.get(coreServices.pluginInstallerRegistry, { pluginId: "core" })
|
|
671
|
+
.catch(() => {});
|
|
672
|
+
const artifactStore = await registry
|
|
673
|
+
.get(coreServices.pluginArtifactStore, { pluginId: "core" })
|
|
674
|
+
.catch(() => {});
|
|
675
|
+
if (!installerRegistry || !artifactStore) {
|
|
676
|
+
rootLogger.debug(
|
|
677
|
+
" -> Skipping runtime plugin bootstrap (services not registered)",
|
|
678
|
+
);
|
|
679
|
+
return;
|
|
680
|
+
}
|
|
681
|
+
|
|
682
|
+
const remoteRows = await db
|
|
683
|
+
.select()
|
|
684
|
+
.from(plugins)
|
|
685
|
+
.where(eq(plugins.isUninstallable, true));
|
|
686
|
+
|
|
687
|
+
for (const row of remoteRows) {
|
|
688
|
+
if (!row.path) continue;
|
|
689
|
+
const pkgJsonPath = path.join(row.path, "package.json");
|
|
690
|
+
if (fs.existsSync(pkgJsonPath)) {
|
|
691
|
+
rootLogger.debug(` -> ${row.name} already present, skipping bootstrap`);
|
|
692
|
+
continue;
|
|
693
|
+
}
|
|
694
|
+
rootLogger.info(
|
|
695
|
+
`🔌 Bootstrapping runtime plugin from artifact store: ${row.name}@${row.version}`,
|
|
696
|
+
);
|
|
697
|
+
const artifact = await artifactStore.fetch({
|
|
698
|
+
pluginName: row.name,
|
|
699
|
+
version: row.version,
|
|
700
|
+
});
|
|
701
|
+
if (!artifact) {
|
|
702
|
+
rootLogger.warn(
|
|
703
|
+
` -> No artifact for ${row.name}@${row.version} — skipping. Re-install from the original source.`,
|
|
704
|
+
);
|
|
705
|
+
continue;
|
|
706
|
+
}
|
|
707
|
+
const allowInstallScripts =
|
|
708
|
+
(row.metadata as { checkstack?: { allowInstallScripts?: boolean } })
|
|
709
|
+
?.checkstack?.allowInstallScripts === true;
|
|
710
|
+
await installerRegistry.forSource("npm").installFromArtifact({
|
|
711
|
+
tarball: artifact.tarball,
|
|
712
|
+
pluginName: row.name,
|
|
713
|
+
allowInstallScripts,
|
|
714
|
+
});
|
|
715
|
+
}
|
|
716
|
+
}
|
package/src/plugin-manager.ts
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import type { Hono } from "hono";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import fs from "node:fs";
|
|
2
4
|
import { adminPool, db } from "./db";
|
|
3
5
|
import { ServiceRegistry } from "./services/service-registry";
|
|
4
6
|
import type { CoreCollectorRegistry } from "./services/collector-registry";
|
|
@@ -14,12 +16,14 @@ import {
|
|
|
14
16
|
} from "@checkstack/backend-api";
|
|
15
17
|
import type { AnyContractRouter } from "@orpc/contract";
|
|
16
18
|
import type { AccessRule, PluginMetadata } from "@checkstack/common";
|
|
19
|
+
import { extractErrorMessage } from "@checkstack/common";
|
|
17
20
|
|
|
18
21
|
// Extracted modules
|
|
19
22
|
import { registerCoreServices } from "./plugin-manager/core-services";
|
|
20
23
|
import { createExtensionPointManager } from "./plugin-manager/extension-points";
|
|
21
24
|
import { loadPlugins as loadPluginsImpl } from "./plugin-manager/plugin-loader";
|
|
22
25
|
import { rootLogger } from "./logger";
|
|
26
|
+
import type { PluginEventRecorder } from "./services/plugin-event-recorder";
|
|
23
27
|
|
|
24
28
|
export interface DeregisterOptions {
|
|
25
29
|
deleteSchema: boolean;
|
|
@@ -58,6 +62,14 @@ export class PluginManager {
|
|
|
58
62
|
// Global readiness registry — plugins contribute probes, /ready aggregates them
|
|
59
63
|
private readinessRegistry: CoreReadinessRegistry;
|
|
60
64
|
|
|
65
|
+
// Audit/error event recorder — wired post-construction in index.ts.
|
|
66
|
+
private eventRecorder: PluginEventRecorder | undefined;
|
|
67
|
+
|
|
68
|
+
// Filesystem location plugins are installed into at runtime (set in
|
|
69
|
+
// index.ts). The runtime install pipeline reads from here on bootstrap
|
|
70
|
+
// and writes to here when handling broadcast install hooks.
|
|
71
|
+
private runtimeDir: string | undefined;
|
|
72
|
+
|
|
61
73
|
// Resolves once `/api/:pluginId/*` is registered on the root router and
|
|
62
74
|
// Phase 2 (per-plugin init) is starting. The HTTP server awaits this
|
|
63
75
|
// promise to know when it is safe to stop gating incoming requests.
|
|
@@ -82,6 +94,60 @@ export class PluginManager {
|
|
|
82
94
|
this.readinessRegistry = registries.readinessRegistry;
|
|
83
95
|
}
|
|
84
96
|
|
|
97
|
+
/**
|
|
98
|
+
* Register access rules owned by a core router (not a regular plugin).
|
|
99
|
+
* Used by `pluginmanager` and other built-in admin endpoints whose access
|
|
100
|
+
* rules need to be visible to the autoAuthMiddleware.
|
|
101
|
+
*/
|
|
102
|
+
registerCoreAccessRules(
|
|
103
|
+
pluginId: string,
|
|
104
|
+
accessRules: AccessRule[],
|
|
105
|
+
): void {
|
|
106
|
+
const prefixed = accessRules.map((rule) => ({
|
|
107
|
+
...rule,
|
|
108
|
+
pluginId,
|
|
109
|
+
id: `${pluginId}.${rule.id}`,
|
|
110
|
+
}));
|
|
111
|
+
this.registeredAccessRules.push(...prefixed);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Register plugin metadata owned by a core router. The /api/:pluginId/*
|
|
116
|
+
* dispatcher in api-router.ts looks up `pluginMetadataRegistry` to build
|
|
117
|
+
* the RpcContext and 500s with "Plugin metadata not found in registry"
|
|
118
|
+
* when the lookup misses. Regular plugins populate the registry as part
|
|
119
|
+
* of their register() lifecycle; core routers (which never go through
|
|
120
|
+
* that lifecycle) need to call this method to register theirs.
|
|
121
|
+
*/
|
|
122
|
+
registerCorePluginMetadata(metadata: PluginMetadata): void {
|
|
123
|
+
this.pluginMetadataRegistry.set(metadata.pluginId, metadata);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Expose the underlying ServiceRegistry to internal core code. Plugins
|
|
128
|
+
* should NEVER touch this directly — they go through the registered
|
|
129
|
+
* service refs.
|
|
130
|
+
*/
|
|
131
|
+
getRegistry(): ServiceRegistry {
|
|
132
|
+
return this.registry;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
setEventRecorder(recorder: PluginEventRecorder): void {
|
|
136
|
+
this.eventRecorder = recorder;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
getEventRecorder(): PluginEventRecorder | undefined {
|
|
140
|
+
return this.eventRecorder;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
setRuntimeDir(dir: string): void {
|
|
144
|
+
this.runtimeDir = dir;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
getRuntimeDir(): string | undefined {
|
|
148
|
+
return this.runtimeDir;
|
|
149
|
+
}
|
|
150
|
+
|
|
85
151
|
/**
|
|
86
152
|
* Get the global readiness registry so the server-level /ready endpoint
|
|
87
153
|
* can aggregate plugin-contributed probes.
|
|
@@ -158,17 +224,17 @@ export class PluginManager {
|
|
|
158
224
|
}
|
|
159
225
|
|
|
160
226
|
/**
|
|
161
|
-
*
|
|
162
|
-
*
|
|
227
|
+
* In-process teardown of a plugin. Runs on EVERY instance that receives
|
|
228
|
+
* the deregistration broadcast (the originator AND all replicas).
|
|
229
|
+
*
|
|
230
|
+
* Strictly memory-only: clears registries, runs cleanup handlers, removes
|
|
231
|
+
* collectors and access rules. **Does NOT touch shared persistent state**
|
|
232
|
+
* (Postgres schemas, plugin_configs, plugin_artifacts, plugins rows) —
|
|
233
|
+
* destructive cleanup is the originator's job, see `deletePluginData`.
|
|
163
234
|
*/
|
|
164
|
-
async
|
|
165
|
-
|
|
166
|
-
options: DeregisterOptions
|
|
167
|
-
): Promise<void> {
|
|
168
|
-
rootLogger.info(`🔄 Deregistering plugin: ${pluginId}...`);
|
|
235
|
+
async deregisterPluginInProcess(pluginId: string): Promise<void> {
|
|
236
|
+
rootLogger.info(`🔄 Deregistering plugin in-process: ${pluginId}...`);
|
|
169
237
|
|
|
170
|
-
// 1. Emit pluginDeregistering hook locally (instance-local, not distributed)
|
|
171
|
-
// This lets other plugins on THIS instance cleanup dependencies
|
|
172
238
|
const eventBus = await this.registry.get(coreServices.eventBus, {
|
|
173
239
|
pluginId: "core",
|
|
174
240
|
});
|
|
@@ -177,7 +243,6 @@ export class PluginManager {
|
|
|
177
243
|
reason: "uninstall" as const,
|
|
178
244
|
});
|
|
179
245
|
|
|
180
|
-
// 2. Run cleanup handlers (LIFO order)
|
|
181
246
|
const handlers = this.cleanupHandlers.get(pluginId) || [];
|
|
182
247
|
for (const handler of handlers.toReversed()) {
|
|
183
248
|
try {
|
|
@@ -188,7 +253,6 @@ export class PluginManager {
|
|
|
188
253
|
}
|
|
189
254
|
this.cleanupHandlers.delete(pluginId);
|
|
190
255
|
|
|
191
|
-
// 3. Unsubscribe from all EventBus hooks
|
|
192
256
|
const subscriptions = this.hookSubscriptions.get(pluginId) || [];
|
|
193
257
|
for (const unsubscribe of subscriptions) {
|
|
194
258
|
try {
|
|
@@ -199,48 +263,94 @@ export class PluginManager {
|
|
|
199
263
|
}
|
|
200
264
|
this.hookSubscriptions.delete(pluginId);
|
|
201
265
|
|
|
202
|
-
// 4. Remove from router maps and contract registry
|
|
203
266
|
this.pluginRpcRouters.delete(pluginId);
|
|
204
267
|
this.pluginHttpHandlers.delete(pluginId);
|
|
205
268
|
this.pluginContractRegistry.delete(pluginId);
|
|
206
|
-
|
|
269
|
+
this.pluginMetadataRegistry.delete(pluginId);
|
|
207
270
|
|
|
208
|
-
// 4b. Cleanup collectors
|
|
209
|
-
// - Remove collectors owned by this plugin
|
|
210
271
|
this.collectorRegistry.unregisterByOwner(pluginId);
|
|
211
|
-
// - Remove collectors that have no loaded strategy plugins
|
|
212
|
-
// (exclude the current plugin being deregistered)
|
|
213
272
|
const loadedPluginIds = new Set(
|
|
214
|
-
[...this.pluginMetadataRegistry.keys()].filter((id) => id !== pluginId)
|
|
273
|
+
[...this.pluginMetadataRegistry.keys()].filter((id) => id !== pluginId),
|
|
215
274
|
);
|
|
216
275
|
this.collectorRegistry.unregisterByMissingStrategies(loadedPluginIds);
|
|
217
276
|
|
|
218
|
-
// 5. Remove access rules from registry
|
|
219
|
-
const beforeCount = this.registeredAccessRules.length;
|
|
220
277
|
this.registeredAccessRules = this.registeredAccessRules.filter(
|
|
221
|
-
(p) => p.pluginId !== pluginId
|
|
278
|
+
(p) => p.pluginId !== pluginId,
|
|
222
279
|
);
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
280
|
+
|
|
281
|
+
await eventBus.emit(coreHooks.pluginDeregistered, { pluginId });
|
|
282
|
+
rootLogger.info(`✅ Plugin deregistered in-process: ${pluginId}`);
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
/**
|
|
286
|
+
* Originator-only destructive cleanup.
|
|
287
|
+
*
|
|
288
|
+
* Runs AFTER `deregisterPluginInProcess` has been broadcast and acked
|
|
289
|
+
* across all instances. Drops the plugin's Postgres schema, deletes its
|
|
290
|
+
* plugin_configs rows, deletes the artifact, deletes the `plugins` row.
|
|
291
|
+
*
|
|
292
|
+
* Multiple instances must NOT call this concurrently — coordination via
|
|
293
|
+
* the originator's request handler is the contract.
|
|
294
|
+
*/
|
|
295
|
+
async deletePluginData({
|
|
296
|
+
pluginIds,
|
|
297
|
+
bundleId,
|
|
298
|
+
deleteSchema,
|
|
299
|
+
deleteConfigs,
|
|
300
|
+
}: {
|
|
301
|
+
pluginIds: string[];
|
|
302
|
+
bundleId?: string | null;
|
|
303
|
+
deleteSchema: boolean;
|
|
304
|
+
deleteConfigs: boolean;
|
|
305
|
+
}): Promise<void> {
|
|
306
|
+
rootLogger.info(
|
|
307
|
+
`🗑 Originator: cleaning up data for ${pluginIds.join(", ")}...`,
|
|
227
308
|
);
|
|
228
309
|
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
310
|
+
const { plugins, pluginConfigs } = await import("./schema");
|
|
311
|
+
const { inArray, eq, and } = await import("drizzle-orm");
|
|
312
|
+
|
|
313
|
+
if (deleteSchema) {
|
|
314
|
+
for (const pluginId of pluginIds) {
|
|
315
|
+
try {
|
|
316
|
+
const schemaName = `plugin_${pluginId}`;
|
|
317
|
+
await db.execute(
|
|
318
|
+
`DROP SCHEMA IF EXISTS "${schemaName}" CASCADE`,
|
|
319
|
+
);
|
|
320
|
+
rootLogger.info(` -> Dropped schema: ${schemaName}`);
|
|
321
|
+
} catch (error) {
|
|
322
|
+
rootLogger.error(`Failed to drop schema for ${pluginId}:`, error);
|
|
323
|
+
}
|
|
237
324
|
}
|
|
238
325
|
}
|
|
239
326
|
|
|
240
|
-
|
|
241
|
-
|
|
327
|
+
if (deleteConfigs) {
|
|
328
|
+
await db
|
|
329
|
+
.delete(pluginConfigs)
|
|
330
|
+
.where(inArray(pluginConfigs.pluginId, pluginIds));
|
|
331
|
+
rootLogger.debug(` -> Deleted plugin_configs rows`);
|
|
332
|
+
}
|
|
242
333
|
|
|
243
|
-
|
|
334
|
+
const artifactStore = await this.registry.get(
|
|
335
|
+
coreServices.pluginArtifactStore,
|
|
336
|
+
{ pluginId: "core" },
|
|
337
|
+
);
|
|
338
|
+
if (bundleId) {
|
|
339
|
+
await artifactStore.deleteByBundle({ bundleId });
|
|
340
|
+
} else {
|
|
341
|
+
for (const pluginName of pluginIds) {
|
|
342
|
+
await artifactStore.delete({ pluginName });
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
rootLogger.debug(` -> Deleted plugin_artifacts rows`);
|
|
346
|
+
|
|
347
|
+
await (bundleId ? db.delete(plugins).where(eq(plugins.bundleId, bundleId)) : db.delete(plugins).where(
|
|
348
|
+
and(
|
|
349
|
+
inArray(plugins.name, pluginIds),
|
|
350
|
+
eq(plugins.isUninstallable, true),
|
|
351
|
+
),
|
|
352
|
+
));
|
|
353
|
+
rootLogger.info(`✅ Originator: data cleanup complete`);
|
|
244
354
|
}
|
|
245
355
|
|
|
246
356
|
/**
|
|
@@ -253,49 +363,39 @@ export class PluginManager {
|
|
|
253
363
|
}
|
|
254
364
|
|
|
255
365
|
/**
|
|
256
|
-
*
|
|
257
|
-
*
|
|
258
|
-
*
|
|
366
|
+
* Originator-side: broadcast in-process deregistration to all instances.
|
|
367
|
+
* Carries plugin ids only — no destructive flags. Destructive ops happen
|
|
368
|
+
* locally on the originator after the broadcast settles
|
|
369
|
+
* (see `deletePluginData`).
|
|
259
370
|
*/
|
|
260
|
-
async
|
|
261
|
-
|
|
262
|
-
options: DeregisterOptions
|
|
263
|
-
): Promise<void> {
|
|
264
|
-
rootLogger.info(`📢 Broadcasting deregistration request for: ${pluginId}`);
|
|
265
|
-
|
|
266
|
-
// Emit broadcast hook - all instances receive and perform local cleanup
|
|
371
|
+
async broadcastDeregistration(pluginIds: string[]): Promise<void> {
|
|
372
|
+
rootLogger.info(`📢 Broadcasting deregistration: ${pluginIds.join(", ")}`);
|
|
267
373
|
const eventBus = await this.registry.get(coreServices.eventBus, {
|
|
268
374
|
pluginId: "core",
|
|
269
375
|
});
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
376
|
+
for (const pluginId of pluginIds) {
|
|
377
|
+
await eventBus.emit(coreHooks.pluginDeregistrationRequested, {
|
|
378
|
+
pluginId,
|
|
379
|
+
deleteSchema: false, // unused by listeners now — destructive ops are originator-only
|
|
380
|
+
});
|
|
381
|
+
}
|
|
276
382
|
}
|
|
277
383
|
|
|
278
384
|
/**
|
|
279
|
-
*
|
|
280
|
-
*
|
|
281
|
-
* Each instance then loads the plugin into memory.
|
|
385
|
+
* Originator-side: broadcast in-process installation to all instances.
|
|
386
|
+
* Each receiver pulls the artifact from `plugin_artifacts` and loads.
|
|
282
387
|
*/
|
|
283
|
-
async
|
|
284
|
-
|
|
285
|
-
pluginPath: string
|
|
286
|
-
): Promise<void> {
|
|
287
|
-
rootLogger.info(`📢 Broadcasting installation request for: ${pluginId}`);
|
|
288
|
-
|
|
289
|
-
// Emit broadcast hook - all instances receive and load the plugin
|
|
388
|
+
async broadcastInstallation(pluginIds: string[]): Promise<void> {
|
|
389
|
+
rootLogger.info(`📢 Broadcasting installation: ${pluginIds.join(", ")}`);
|
|
290
390
|
const eventBus = await this.registry.get(coreServices.eventBus, {
|
|
291
391
|
pluginId: "core",
|
|
292
392
|
});
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
393
|
+
for (const pluginId of pluginIds) {
|
|
394
|
+
await eventBus.emit(coreHooks.pluginInstallationRequested, {
|
|
395
|
+
pluginId,
|
|
396
|
+
pluginPath: "", // ignored — receivers resolve via plugin_artifacts
|
|
397
|
+
});
|
|
398
|
+
}
|
|
299
399
|
}
|
|
300
400
|
|
|
301
401
|
/**
|
|
@@ -307,26 +407,61 @@ export class PluginManager {
|
|
|
307
407
|
pluginId: "core",
|
|
308
408
|
});
|
|
309
409
|
|
|
310
|
-
// Listen for deregistration broadcasts (from any instance)
|
|
410
|
+
// Listen for deregistration broadcasts (from any instance) — every
|
|
411
|
+
// instance does in-process teardown only. Originator separately runs
|
|
412
|
+
// `deletePluginData` after the broadcast settles.
|
|
311
413
|
await eventBus.subscribe(
|
|
312
414
|
"core",
|
|
313
415
|
coreHooks.pluginDeregistrationRequested,
|
|
314
|
-
async ({ pluginId
|
|
416
|
+
async ({ pluginId }) => {
|
|
315
417
|
rootLogger.info(`📥 Received deregistration request for: ${pluginId}`);
|
|
316
|
-
|
|
317
|
-
|
|
418
|
+
try {
|
|
419
|
+
await this.deregisterPluginInProcess(pluginId);
|
|
420
|
+
await this.eventRecorder?.record({
|
|
421
|
+
pluginName: pluginId,
|
|
422
|
+
action: "uninstall",
|
|
423
|
+
phase: "in-process-unload",
|
|
424
|
+
status: "succeeded",
|
|
425
|
+
});
|
|
426
|
+
} catch (error) {
|
|
427
|
+
await this.eventRecorder?.record({
|
|
428
|
+
pluginName: pluginId,
|
|
429
|
+
action: "uninstall",
|
|
430
|
+
phase: "in-process-unload",
|
|
431
|
+
status: "failed",
|
|
432
|
+
error: extractErrorMessage(error),
|
|
433
|
+
});
|
|
434
|
+
throw error;
|
|
435
|
+
}
|
|
436
|
+
},
|
|
318
437
|
);
|
|
319
438
|
|
|
320
|
-
// Listen for installation broadcasts (from any instance)
|
|
439
|
+
// Listen for installation broadcasts (from any instance) — every
|
|
440
|
+
// instance hydrates the artifact (if not already on disk) then loads.
|
|
321
441
|
await eventBus.subscribe(
|
|
322
442
|
"core",
|
|
323
443
|
coreHooks.pluginInstallationRequested,
|
|
324
|
-
async ({ pluginId
|
|
325
|
-
rootLogger.info(
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
444
|
+
async ({ pluginId }) => {
|
|
445
|
+
rootLogger.info(`📥 Received installation request for: ${pluginId}`);
|
|
446
|
+
try {
|
|
447
|
+
await this.hydrateAndLoadPlugin(pluginId);
|
|
448
|
+
await this.eventRecorder?.record({
|
|
449
|
+
pluginName: pluginId,
|
|
450
|
+
action: "install",
|
|
451
|
+
phase: "in-process-load",
|
|
452
|
+
status: "succeeded",
|
|
453
|
+
});
|
|
454
|
+
} catch (error) {
|
|
455
|
+
await this.eventRecorder?.record({
|
|
456
|
+
pluginName: pluginId,
|
|
457
|
+
action: "install",
|
|
458
|
+
phase: "in-process-load",
|
|
459
|
+
status: "failed",
|
|
460
|
+
error: extractErrorMessage(error),
|
|
461
|
+
});
|
|
462
|
+
throw error;
|
|
463
|
+
}
|
|
464
|
+
},
|
|
330
465
|
);
|
|
331
466
|
|
|
332
467
|
rootLogger.debug("🔗 Lifecycle listeners registered");
|
|
@@ -345,51 +480,106 @@ export class PluginManager {
|
|
|
345
480
|
}
|
|
346
481
|
|
|
347
482
|
/**
|
|
348
|
-
*
|
|
349
|
-
*
|
|
350
|
-
*
|
|
483
|
+
* Resolve a plugin's `plugins` row, fetch its artifact from the artifact
|
|
484
|
+
* store (or original `PluginSource` as a fallback), install it into the
|
|
485
|
+
* runtime dir if not present, and run `loadSinglePlugin`. Used by both
|
|
486
|
+
* the installation broadcast handler AND the fresh-instance bootstrap
|
|
487
|
+
* step in `loadPlugins`.
|
|
488
|
+
*/
|
|
489
|
+
async hydrateAndLoadPlugin(pluginId: string): Promise<void> {
|
|
490
|
+
const { plugins: pluginsTable } = await import("./schema");
|
|
491
|
+
const { eq } = await import("drizzle-orm");
|
|
492
|
+
|
|
493
|
+
const rows = await db
|
|
494
|
+
.select()
|
|
495
|
+
.from(pluginsTable)
|
|
496
|
+
.where(eq(pluginsTable.name, pluginId))
|
|
497
|
+
.limit(1);
|
|
498
|
+
|
|
499
|
+
if (rows.length === 0) {
|
|
500
|
+
throw new Error(`Plugin '${pluginId}' has no row in 'plugins' table`);
|
|
501
|
+
}
|
|
502
|
+
const row = rows[0];
|
|
503
|
+
|
|
504
|
+
// Fast path: monorepo-local plugin, just load by package name / path.
|
|
505
|
+
if (!row.isUninstallable) {
|
|
506
|
+
await this.loadSinglePlugin(pluginId, row.path);
|
|
507
|
+
return;
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
if (!this.runtimeDir) {
|
|
511
|
+
throw new Error(
|
|
512
|
+
`Runtime plugin dir not configured — call setRuntimeDir before hydrating runtime plugins.`,
|
|
513
|
+
);
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
const pkgDir = path.join(this.runtimeDir, "node_modules", pluginId);
|
|
517
|
+
if (!fs.existsSync(path.join(pkgDir, "package.json"))) {
|
|
518
|
+
// Module not installed yet — pull from artifact store and install.
|
|
519
|
+
const artifactStore = await this.registry.get(
|
|
520
|
+
coreServices.pluginArtifactStore,
|
|
521
|
+
{ pluginId: "core" },
|
|
522
|
+
);
|
|
523
|
+
const artifact = await artifactStore.fetch({
|
|
524
|
+
pluginName: pluginId,
|
|
525
|
+
version: row.version,
|
|
526
|
+
});
|
|
527
|
+
if (!artifact) {
|
|
528
|
+
throw new Error(
|
|
529
|
+
`No tarball found in plugin_artifacts for ${pluginId}@${row.version}. ` +
|
|
530
|
+
`The plugin row exists but its artifact is missing — re-install from the original source.`,
|
|
531
|
+
);
|
|
532
|
+
}
|
|
533
|
+
const allowInstallScripts =
|
|
534
|
+
(row.metadata as { checkstack?: { allowInstallScripts?: boolean } })
|
|
535
|
+
?.checkstack?.allowInstallScripts === true;
|
|
536
|
+
|
|
537
|
+
const installerRegistry = await this.registry.get(
|
|
538
|
+
coreServices.pluginInstallerRegistry,
|
|
539
|
+
{ pluginId: "core" },
|
|
540
|
+
);
|
|
541
|
+
// Any installer's installFromArtifact does the same thing (delegates
|
|
542
|
+
// to the shared install-from-tarball helper) — pick npm.
|
|
543
|
+
await installerRegistry
|
|
544
|
+
.forSource("npm")
|
|
545
|
+
.installFromArtifact({
|
|
546
|
+
tarball: artifact.tarball,
|
|
547
|
+
pluginName: pluginId,
|
|
548
|
+
allowInstallScripts,
|
|
549
|
+
});
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
await this.loadSinglePlugin(pluginId, pkgDir);
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
/**
|
|
556
|
+
* Register + initialize an installed plugin module.
|
|
351
557
|
*
|
|
352
|
-
*
|
|
353
|
-
*
|
|
558
|
+
* Pre-condition: the package must already be importable (either monorepo
|
|
559
|
+
* source via the workspace, or installed under `runtime_plugins/node_modules`
|
|
560
|
+
* by `hydrateAndLoadPlugin`).
|
|
354
561
|
*/
|
|
355
562
|
async loadSinglePlugin(pluginId: string, pluginPath: string): Promise<void> {
|
|
356
563
|
rootLogger.info(`🔌 Loading plugin at runtime: ${pluginId}`);
|
|
357
564
|
|
|
358
|
-
// Emit instance-local installing hook
|
|
359
565
|
const eventBus = await this.registry.get(coreServices.eventBus, {
|
|
360
566
|
pluginId: "core",
|
|
361
567
|
});
|
|
362
568
|
await eventBus.emitLocal(coreHooks.pluginInstalling, { pluginId });
|
|
363
569
|
|
|
364
570
|
try {
|
|
365
|
-
// 1. Try to import the plugin - if it fails, install it first
|
|
366
571
|
let pluginModule;
|
|
367
572
|
|
|
368
573
|
try {
|
|
369
|
-
// Try importing by package name first
|
|
370
574
|
pluginModule = await import(pluginId);
|
|
371
575
|
} catch {
|
|
372
576
|
try {
|
|
373
|
-
// Try importing by path
|
|
374
577
|
pluginModule = await import(pluginPath);
|
|
375
|
-
} catch {
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
);
|
|
380
|
-
|
|
381
|
-
const installer = await this.registry.get(
|
|
382
|
-
coreServices.pluginInstaller,
|
|
383
|
-
{ pluginId: "core" }
|
|
578
|
+
} catch (error) {
|
|
579
|
+
throw new Error(
|
|
580
|
+
`Plugin ${pluginId} module not available locally — call hydrateAndLoadPlugin instead, or check that the plugin is correctly installed.`,
|
|
581
|
+
{ cause: error },
|
|
384
582
|
);
|
|
385
|
-
const result = await installer.install(pluginId);
|
|
386
|
-
|
|
387
|
-
// Now try importing again
|
|
388
|
-
try {
|
|
389
|
-
pluginModule = await import(result.name);
|
|
390
|
-
} catch {
|
|
391
|
-
pluginModule = await import(result.path);
|
|
392
|
-
}
|
|
393
583
|
}
|
|
394
584
|
}
|
|
395
585
|
|