@checkstack/backend 0.8.1 → 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 +280 -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 +14 -9
- package/src/index.ts +460 -23
- package/src/plugin-deregistration.test.ts +137 -0
- package/src/plugin-manager/api-router.ts +35 -11
- package/src/plugin-manager/core-services.ts +21 -2
- package/src/plugin-manager/plugin-loader.ts +94 -0
- package/src/plugin-manager.ts +324 -105
- package/src/router-incremental.test.ts +49 -0
- 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/services/readiness-registry.test.ts +124 -0
- package/src/services/readiness-registry.ts +103 -0
- package/src/utils/plugin-discovery.test.ts +6 -0
- package/src/utils/plugin-discovery.ts +6 -1
- package/tsconfig.json +36 -1
- 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
package/src/plugin-manager.ts
CHANGED
|
@@ -1,8 +1,11 @@
|
|
|
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";
|
|
5
7
|
import type { WebSocketRouteStoreImpl } from "./services/ws-route-registry";
|
|
8
|
+
import type { CoreReadinessRegistry } from "./services/readiness-registry";
|
|
6
9
|
import {
|
|
7
10
|
BackendPlugin,
|
|
8
11
|
ServiceRef,
|
|
@@ -13,12 +16,14 @@ import {
|
|
|
13
16
|
} from "@checkstack/backend-api";
|
|
14
17
|
import type { AnyContractRouter } from "@orpc/contract";
|
|
15
18
|
import type { AccessRule, PluginMetadata } from "@checkstack/common";
|
|
19
|
+
import { extractErrorMessage } from "@checkstack/common";
|
|
16
20
|
|
|
17
21
|
// Extracted modules
|
|
18
22
|
import { registerCoreServices } from "./plugin-manager/core-services";
|
|
19
23
|
import { createExtensionPointManager } from "./plugin-manager/extension-points";
|
|
20
24
|
import { loadPlugins as loadPluginsImpl } from "./plugin-manager/plugin-loader";
|
|
21
25
|
import { rootLogger } from "./logger";
|
|
26
|
+
import type { PluginEventRecorder } from "./services/plugin-event-recorder";
|
|
22
27
|
|
|
23
28
|
export interface DeregisterOptions {
|
|
24
29
|
deleteSchema: boolean;
|
|
@@ -54,7 +59,29 @@ export class PluginManager {
|
|
|
54
59
|
// Global WebSocket route store for server-level routing
|
|
55
60
|
private wsStore: WebSocketRouteStoreImpl;
|
|
56
61
|
|
|
62
|
+
// Global readiness registry — plugins contribute probes, /ready aggregates them
|
|
63
|
+
private readinessRegistry: CoreReadinessRegistry;
|
|
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
|
+
|
|
73
|
+
// Resolves once `/api/:pluginId/*` is registered on the root router and
|
|
74
|
+
// Phase 2 (per-plugin init) is starting. The HTTP server awaits this
|
|
75
|
+
// promise to know when it is safe to stop gating incoming requests.
|
|
76
|
+
// Held as a deferred so the listener (server) can be wired up before
|
|
77
|
+
// loadPlugins() runs.
|
|
78
|
+
private resolveRoutesReady!: () => void;
|
|
79
|
+
readonly routesReadyPromise: Promise<void>;
|
|
80
|
+
|
|
57
81
|
constructor() {
|
|
82
|
+
this.routesReadyPromise = new Promise<void>((resolve) => {
|
|
83
|
+
this.resolveRoutesReady = resolve;
|
|
84
|
+
});
|
|
58
85
|
const registries = registerCoreServices({
|
|
59
86
|
registry: this.registry,
|
|
60
87
|
adminPool,
|
|
@@ -64,6 +91,69 @@ export class PluginManager {
|
|
|
64
91
|
});
|
|
65
92
|
this.collectorRegistry = registries.collectorRegistry;
|
|
66
93
|
this.wsStore = registries.wsStore;
|
|
94
|
+
this.readinessRegistry = registries.readinessRegistry;
|
|
95
|
+
}
|
|
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
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Get the global readiness registry so the server-level /ready endpoint
|
|
153
|
+
* can aggregate plugin-contributed probes.
|
|
154
|
+
*/
|
|
155
|
+
getReadinessRegistry(): CoreReadinessRegistry {
|
|
156
|
+
return this.readinessRegistry;
|
|
67
157
|
}
|
|
68
158
|
|
|
69
159
|
/**
|
|
@@ -124,22 +214,27 @@ export class PluginManager {
|
|
|
124
214
|
pluginMetadataRegistry: this.pluginMetadataRegistry,
|
|
125
215
|
cleanupHandlers: this.cleanupHandlers,
|
|
126
216
|
pluginContractRegistry: this.pluginContractRegistry,
|
|
217
|
+
onApiRouteRegistered: () => this.resolveRoutesReady(),
|
|
127
218
|
},
|
|
128
219
|
});
|
|
220
|
+
// Defensive: if loadPlugins returned without ever calling the callback
|
|
221
|
+
// (e.g. zero plugins discovered and no api route registered), unblock
|
|
222
|
+
// the server gate anyway — by this point Hono is fully configured.
|
|
223
|
+
this.resolveRoutesReady();
|
|
129
224
|
}
|
|
130
225
|
|
|
131
226
|
/**
|
|
132
|
-
*
|
|
133
|
-
*
|
|
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`.
|
|
134
234
|
*/
|
|
135
|
-
async
|
|
136
|
-
|
|
137
|
-
options: DeregisterOptions
|
|
138
|
-
): Promise<void> {
|
|
139
|
-
rootLogger.info(`🔄 Deregistering plugin: ${pluginId}...`);
|
|
235
|
+
async deregisterPluginInProcess(pluginId: string): Promise<void> {
|
|
236
|
+
rootLogger.info(`🔄 Deregistering plugin in-process: ${pluginId}...`);
|
|
140
237
|
|
|
141
|
-
// 1. Emit pluginDeregistering hook locally (instance-local, not distributed)
|
|
142
|
-
// This lets other plugins on THIS instance cleanup dependencies
|
|
143
238
|
const eventBus = await this.registry.get(coreServices.eventBus, {
|
|
144
239
|
pluginId: "core",
|
|
145
240
|
});
|
|
@@ -148,7 +243,6 @@ export class PluginManager {
|
|
|
148
243
|
reason: "uninstall" as const,
|
|
149
244
|
});
|
|
150
245
|
|
|
151
|
-
// 2. Run cleanup handlers (LIFO order)
|
|
152
246
|
const handlers = this.cleanupHandlers.get(pluginId) || [];
|
|
153
247
|
for (const handler of handlers.toReversed()) {
|
|
154
248
|
try {
|
|
@@ -159,7 +253,6 @@ export class PluginManager {
|
|
|
159
253
|
}
|
|
160
254
|
this.cleanupHandlers.delete(pluginId);
|
|
161
255
|
|
|
162
|
-
// 3. Unsubscribe from all EventBus hooks
|
|
163
256
|
const subscriptions = this.hookSubscriptions.get(pluginId) || [];
|
|
164
257
|
for (const unsubscribe of subscriptions) {
|
|
165
258
|
try {
|
|
@@ -170,48 +263,94 @@ export class PluginManager {
|
|
|
170
263
|
}
|
|
171
264
|
this.hookSubscriptions.delete(pluginId);
|
|
172
265
|
|
|
173
|
-
// 4. Remove from router maps and contract registry
|
|
174
266
|
this.pluginRpcRouters.delete(pluginId);
|
|
175
267
|
this.pluginHttpHandlers.delete(pluginId);
|
|
176
268
|
this.pluginContractRegistry.delete(pluginId);
|
|
177
|
-
|
|
269
|
+
this.pluginMetadataRegistry.delete(pluginId);
|
|
178
270
|
|
|
179
|
-
// 4b. Cleanup collectors
|
|
180
|
-
// - Remove collectors owned by this plugin
|
|
181
271
|
this.collectorRegistry.unregisterByOwner(pluginId);
|
|
182
|
-
// - Remove collectors that have no loaded strategy plugins
|
|
183
|
-
// (exclude the current plugin being deregistered)
|
|
184
272
|
const loadedPluginIds = new Set(
|
|
185
|
-
[...this.pluginMetadataRegistry.keys()].filter((id) => id !== pluginId)
|
|
273
|
+
[...this.pluginMetadataRegistry.keys()].filter((id) => id !== pluginId),
|
|
186
274
|
);
|
|
187
275
|
this.collectorRegistry.unregisterByMissingStrategies(loadedPluginIds);
|
|
188
276
|
|
|
189
|
-
// 5. Remove access rules from registry
|
|
190
|
-
const beforeCount = this.registeredAccessRules.length;
|
|
191
277
|
this.registeredAccessRules = this.registeredAccessRules.filter(
|
|
192
|
-
(p) => p.pluginId !== pluginId
|
|
278
|
+
(p) => p.pluginId !== pluginId,
|
|
193
279
|
);
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
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(", ")}...`,
|
|
198
308
|
);
|
|
199
309
|
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
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
|
+
}
|
|
208
324
|
}
|
|
209
325
|
}
|
|
210
326
|
|
|
211
|
-
|
|
212
|
-
|
|
327
|
+
if (deleteConfigs) {
|
|
328
|
+
await db
|
|
329
|
+
.delete(pluginConfigs)
|
|
330
|
+
.where(inArray(pluginConfigs.pluginId, pluginIds));
|
|
331
|
+
rootLogger.debug(` -> Deleted plugin_configs rows`);
|
|
332
|
+
}
|
|
213
333
|
|
|
214
|
-
|
|
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`);
|
|
215
354
|
}
|
|
216
355
|
|
|
217
356
|
/**
|
|
@@ -224,49 +363,39 @@ export class PluginManager {
|
|
|
224
363
|
}
|
|
225
364
|
|
|
226
365
|
/**
|
|
227
|
-
*
|
|
228
|
-
*
|
|
229
|
-
*
|
|
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`).
|
|
230
370
|
*/
|
|
231
|
-
async
|
|
232
|
-
|
|
233
|
-
options: DeregisterOptions
|
|
234
|
-
): Promise<void> {
|
|
235
|
-
rootLogger.info(`📢 Broadcasting deregistration request for: ${pluginId}`);
|
|
236
|
-
|
|
237
|
-
// Emit broadcast hook - all instances receive and perform local cleanup
|
|
371
|
+
async broadcastDeregistration(pluginIds: string[]): Promise<void> {
|
|
372
|
+
rootLogger.info(`📢 Broadcasting deregistration: ${pluginIds.join(", ")}`);
|
|
238
373
|
const eventBus = await this.registry.get(coreServices.eventBus, {
|
|
239
374
|
pluginId: "core",
|
|
240
375
|
});
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
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
|
+
}
|
|
247
382
|
}
|
|
248
383
|
|
|
249
384
|
/**
|
|
250
|
-
*
|
|
251
|
-
*
|
|
252
|
-
* 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.
|
|
253
387
|
*/
|
|
254
|
-
async
|
|
255
|
-
|
|
256
|
-
pluginPath: string
|
|
257
|
-
): Promise<void> {
|
|
258
|
-
rootLogger.info(`📢 Broadcasting installation request for: ${pluginId}`);
|
|
259
|
-
|
|
260
|
-
// Emit broadcast hook - all instances receive and load the plugin
|
|
388
|
+
async broadcastInstallation(pluginIds: string[]): Promise<void> {
|
|
389
|
+
rootLogger.info(`📢 Broadcasting installation: ${pluginIds.join(", ")}`);
|
|
261
390
|
const eventBus = await this.registry.get(coreServices.eventBus, {
|
|
262
391
|
pluginId: "core",
|
|
263
392
|
});
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
393
|
+
for (const pluginId of pluginIds) {
|
|
394
|
+
await eventBus.emit(coreHooks.pluginInstallationRequested, {
|
|
395
|
+
pluginId,
|
|
396
|
+
pluginPath: "", // ignored — receivers resolve via plugin_artifacts
|
|
397
|
+
});
|
|
398
|
+
}
|
|
270
399
|
}
|
|
271
400
|
|
|
272
401
|
/**
|
|
@@ -278,26 +407,61 @@ export class PluginManager {
|
|
|
278
407
|
pluginId: "core",
|
|
279
408
|
});
|
|
280
409
|
|
|
281
|
-
// 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.
|
|
282
413
|
await eventBus.subscribe(
|
|
283
414
|
"core",
|
|
284
415
|
coreHooks.pluginDeregistrationRequested,
|
|
285
|
-
async ({ pluginId
|
|
416
|
+
async ({ pluginId }) => {
|
|
286
417
|
rootLogger.info(`📥 Received deregistration request for: ${pluginId}`);
|
|
287
|
-
|
|
288
|
-
|
|
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
|
+
},
|
|
289
437
|
);
|
|
290
438
|
|
|
291
|
-
// 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.
|
|
292
441
|
await eventBus.subscribe(
|
|
293
442
|
"core",
|
|
294
443
|
coreHooks.pluginInstallationRequested,
|
|
295
|
-
async ({ pluginId
|
|
296
|
-
rootLogger.info(
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
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
|
+
},
|
|
301
465
|
);
|
|
302
466
|
|
|
303
467
|
rootLogger.debug("🔗 Lifecycle listeners registered");
|
|
@@ -316,51 +480,106 @@ export class PluginManager {
|
|
|
316
480
|
}
|
|
317
481
|
|
|
318
482
|
/**
|
|
319
|
-
*
|
|
320
|
-
*
|
|
321
|
-
*
|
|
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.
|
|
322
557
|
*
|
|
323
|
-
*
|
|
324
|
-
*
|
|
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`).
|
|
325
561
|
*/
|
|
326
562
|
async loadSinglePlugin(pluginId: string, pluginPath: string): Promise<void> {
|
|
327
563
|
rootLogger.info(`🔌 Loading plugin at runtime: ${pluginId}`);
|
|
328
564
|
|
|
329
|
-
// Emit instance-local installing hook
|
|
330
565
|
const eventBus = await this.registry.get(coreServices.eventBus, {
|
|
331
566
|
pluginId: "core",
|
|
332
567
|
});
|
|
333
568
|
await eventBus.emitLocal(coreHooks.pluginInstalling, { pluginId });
|
|
334
569
|
|
|
335
570
|
try {
|
|
336
|
-
// 1. Try to import the plugin - if it fails, install it first
|
|
337
571
|
let pluginModule;
|
|
338
572
|
|
|
339
573
|
try {
|
|
340
|
-
// Try importing by package name first
|
|
341
574
|
pluginModule = await import(pluginId);
|
|
342
575
|
} catch {
|
|
343
576
|
try {
|
|
344
|
-
// Try importing by path
|
|
345
577
|
pluginModule = await import(pluginPath);
|
|
346
|
-
} catch {
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
);
|
|
351
|
-
|
|
352
|
-
const installer = await this.registry.get(
|
|
353
|
-
coreServices.pluginInstaller,
|
|
354
|
-
{ 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 },
|
|
355
582
|
);
|
|
356
|
-
const result = await installer.install(pluginId);
|
|
357
|
-
|
|
358
|
-
// Now try importing again
|
|
359
|
-
try {
|
|
360
|
-
pluginModule = await import(result.name);
|
|
361
|
-
} catch {
|
|
362
|
-
pluginModule = await import(result.path);
|
|
363
|
-
}
|
|
364
583
|
}
|
|
365
584
|
}
|
|
366
585
|
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { describe, it, expect } from "bun:test";
|
|
2
|
+
import { Hono } from "hono";
|
|
3
|
+
import { TrieRouter } from "hono/router/trie-router";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Regression test for the "Hono router was already initialized" bug.
|
|
7
|
+
*
|
|
8
|
+
* Hono's default SmartRouter freezes its matcher on first request: any later
|
|
9
|
+
* `app.get/all/...` throws "Can not add a route since the matcher is already
|
|
10
|
+
* built". Plugins register routes during init() (and at runtime via
|
|
11
|
+
* loadSinglePlugin), so we use TrieRouter — which is incremental. If anyone
|
|
12
|
+
* ever swaps the router back to default, this test fails fast.
|
|
13
|
+
*
|
|
14
|
+
* See core/backend/src/index.ts where TrieRouter is wired up.
|
|
15
|
+
*/
|
|
16
|
+
describe("Hono router (TrieRouter) supports incremental route registration", () => {
|
|
17
|
+
it("accepts routes added after the first request", async () => {
|
|
18
|
+
const app = new Hono({ router: new TrieRouter() });
|
|
19
|
+
app.get("/early", (c) => c.text("early"));
|
|
20
|
+
|
|
21
|
+
// Trigger matcher build (this is what freezes SmartRouter).
|
|
22
|
+
const r1 = await app.fetch(new Request("http://x/early"));
|
|
23
|
+
expect(await r1.text()).toBe("early");
|
|
24
|
+
|
|
25
|
+
// Add a route AFTER the matcher is "built". On SmartRouter this throws.
|
|
26
|
+
app.get("/late", (c) => c.text("late"));
|
|
27
|
+
|
|
28
|
+
const r2 = await app.fetch(new Request("http://x/late"));
|
|
29
|
+
expect(r2.status).toBe(200);
|
|
30
|
+
expect(await r2.text()).toBe("late");
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it("accepts a parameterized route added after a request", async () => {
|
|
34
|
+
const app = new Hono({ router: new TrieRouter() });
|
|
35
|
+
app.get("/seed", (c) => c.text("seed"));
|
|
36
|
+
await app.fetch(new Request("http://x/seed"));
|
|
37
|
+
|
|
38
|
+
// This is the actual production scenario: /api/:pluginId/* is registered
|
|
39
|
+
// inside loadPlugins() during init, well after the first request may have
|
|
40
|
+
// already been handled.
|
|
41
|
+
app.all("/api/:pluginId/*", (c) =>
|
|
42
|
+
c.json({ pluginId: c.req.param("pluginId") }),
|
|
43
|
+
);
|
|
44
|
+
|
|
45
|
+
const r = await app.fetch(new Request("http://x/api/healthcheck/foo"));
|
|
46
|
+
expect(r.status).toBe(200);
|
|
47
|
+
expect(await r.json()).toEqual({ pluginId: "healthcheck" });
|
|
48
|
+
});
|
|
49
|
+
});
|