@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.
Files changed (36) hide show
  1. package/CHANGELOG.md +203 -0
  2. package/drizzle/0001_slim_mordo.sql +34 -0
  3. package/drizzle/meta/0001_snapshot.json +444 -0
  4. package/drizzle/meta/_journal.json +7 -0
  5. package/package.json +12 -7
  6. package/src/index.ts +276 -17
  7. package/src/plugin-deregistration.test.ts +137 -0
  8. package/src/plugin-manager/api-router.ts +35 -11
  9. package/src/plugin-manager/plugin-loader.ts +73 -0
  10. package/src/plugin-manager.ts +295 -105
  11. package/src/schema.ts +79 -1
  12. package/src/services/compatibility-checker.test.ts +146 -0
  13. package/src/services/compatibility-checker.ts +137 -0
  14. package/src/services/dev-auth.test.ts +87 -0
  15. package/src/services/dev-auth.ts +56 -0
  16. package/src/services/plugin-artifact-store.ts +131 -0
  17. package/src/services/plugin-bundle-resolver.ts +76 -0
  18. package/src/services/plugin-event-recorder.ts +87 -0
  19. package/src/services/plugin-installers/catalog-installer.ts +33 -0
  20. package/src/services/plugin-installers/github-installer.ts +207 -0
  21. package/src/services/plugin-installers/install-from-tarball.ts +69 -0
  22. package/src/services/plugin-installers/installer-registry.ts +51 -0
  23. package/src/services/plugin-installers/npm-installer.ts +156 -0
  24. package/src/services/plugin-installers/plugin-install-error.ts +37 -0
  25. package/src/services/plugin-installers/tarball-installer.ts +80 -0
  26. package/src/services/plugin-installers/tarball-utils.test.ts +200 -0
  27. package/src/services/plugin-installers/tarball-utils.ts +172 -0
  28. package/src/services/plugin-manager-orchestrator.ts +522 -0
  29. package/src/services/plugin-manager-router.ts +219 -0
  30. package/src/utils/plugin-discovery.test.ts +6 -0
  31. package/src/utils/plugin-discovery.ts +6 -1
  32. package/tsconfig.json +3 -0
  33. package/src/plugin-lifecycle.test.ts +0 -276
  34. package/src/plugin-manager/plugin-admin-router.ts +0 -89
  35. package/src/services/plugin-installer.test.ts +0 -90
  36. package/src/services/plugin-installer.ts +0 -70
@@ -0,0 +1,219 @@
1
+ import { implement, ORPCError } from "@orpc/server";
2
+ import { desc } from "drizzle-orm";
3
+ import {
4
+ autoAuthMiddleware,
5
+ coreServices,
6
+ type RpcContext,
7
+ type SafeDatabase,
8
+ } from "@checkstack/backend-api";
9
+ import {
10
+ pluginManagerContract,
11
+ type InstalledPlugin,
12
+ } from "@checkstack/pluginmanager-common";
13
+ import {
14
+ pluginPackageTypeSchema,
15
+ pluginSourceSchema,
16
+ } from "@checkstack/common";
17
+ import type { PluginManager } from "../plugin-manager";
18
+ import type { ServiceRegistry } from "./service-registry";
19
+ import type { PluginEventRecorder } from "./plugin-event-recorder";
20
+ import { plugins } from "../schema";
21
+ import {
22
+ installOriginator,
23
+ previewInstallOriginator,
24
+ previewUninstallOriginator,
25
+ uninstallOriginator,
26
+ } from "./plugin-manager-orchestrator";
27
+ import {
28
+ PluginInstallError,
29
+ type PluginInstallErrorCode,
30
+ } from "./plugin-installers/plugin-install-error";
31
+
32
+ /**
33
+ * Map our installer/orchestrator error codes onto the closest oRPC error
34
+ * codes so the UI sees a meaningful HTTP status + message instead of a
35
+ * generic `INTERNAL_SERVER_ERROR`.
36
+ *
37
+ * oRPC's built-in `ORPCError` codes match common HTTP semantics; we route
38
+ * `COMPATIBILITY_FAILED` and `VALIDATION_FAILED` to `BAD_REQUEST` because
39
+ * neither is a server fault — both indicate user-correctable input.
40
+ */
41
+ const ORPC_CODE_MAP: Record<
42
+ PluginInstallErrorCode,
43
+ | "BAD_REQUEST"
44
+ | "UNAUTHORIZED"
45
+ | "FORBIDDEN"
46
+ | "NOT_FOUND"
47
+ | "PAYLOAD_TOO_LARGE"
48
+ | "BAD_GATEWAY"
49
+ | "NOT_IMPLEMENTED"
50
+ > = {
51
+ NOT_FOUND: "NOT_FOUND",
52
+ BAD_REQUEST: "BAD_REQUEST",
53
+ UNAUTHORIZED: "UNAUTHORIZED",
54
+ FORBIDDEN: "FORBIDDEN",
55
+ PAYLOAD_TOO_LARGE: "PAYLOAD_TOO_LARGE",
56
+ BAD_GATEWAY: "BAD_GATEWAY",
57
+ VALIDATION_FAILED: "BAD_REQUEST",
58
+ COMPATIBILITY_FAILED: "BAD_REQUEST",
59
+ NOT_IMPLEMENTED: "NOT_IMPLEMENTED",
60
+ };
61
+
62
+ /**
63
+ * Run an originator function and translate any `PluginInstallError` into
64
+ * an `ORPCError` with the right code + the user-facing message intact.
65
+ * Other errors propagate untouched and surface as 500s — those represent
66
+ * genuine bugs and stay loud.
67
+ */
68
+ async function withTranslatedErrors<T>(fn: () => Promise<T>): Promise<T> {
69
+ try {
70
+ return await fn();
71
+ } catch (error) {
72
+ if (error instanceof PluginInstallError) {
73
+ const data: Record<string, unknown> = { kind: error.code };
74
+ if (error.details) Object.assign(data, error.details);
75
+ throw new ORPCError(ORPC_CODE_MAP[error.code], {
76
+ message: error.message,
77
+ data,
78
+ });
79
+ }
80
+ throw error;
81
+ }
82
+ }
83
+
84
+ /**
85
+ * oRPC router that backs `pluginManagerContract`.
86
+ *
87
+ * Lives in `core/backend` because it directly references the in-process
88
+ * `PluginManager` instance. Wired into the request pipeline via
89
+ * `pluginManager.registerCoreRouter("pluginmanager", ...)`.
90
+ */
91
+ export function createPluginManagerRouter({
92
+ db,
93
+ pluginManager,
94
+ registry,
95
+ eventRecorder,
96
+ workspaceRoot,
97
+ runtimeDir,
98
+ }: {
99
+ db: SafeDatabase<Record<string, unknown>>;
100
+ pluginManager: PluginManager;
101
+ registry: ServiceRegistry;
102
+ eventRecorder: PluginEventRecorder;
103
+ workspaceRoot: string;
104
+ runtimeDir: string;
105
+ }) {
106
+ // touch coreServices to keep the import alive in case future router
107
+ // procedures need to resolve services on demand.
108
+ void coreServices;
109
+
110
+ const impl = implement(pluginManagerContract)
111
+ .$context<RpcContext>()
112
+ .use(autoAuthMiddleware);
113
+
114
+ return impl.router({
115
+ list: impl.list.handler(async () => {
116
+ const rows = await db
117
+ .select()
118
+ .from(plugins)
119
+ .orderBy(desc(plugins.id));
120
+ const list: InstalledPlugin[] = rows.map((row) => {
121
+ // type may legitimately not match the enum for legacy/tooling rows
122
+ // — fall back to a safe value rather than 500-ing on parse.
123
+ const typeParsed = pluginPackageTypeSchema.safeParse(row.type);
124
+ // metadata is JSONB; drizzle returns `unknown | null`. The contract
125
+ // declares it as `optional()` (accepts undefined, NOT null), so
126
+ // coerce. Also accept only objects — string/number/array would fail
127
+ // schema validation.
128
+ const metadata =
129
+ row.metadata && typeof row.metadata === "object" && !Array.isArray(row.metadata)
130
+ ? (row.metadata as InstalledPlugin["metadata"])
131
+ : undefined;
132
+ // source: null for monorepo-local; validated PluginSource for
133
+ // runtime-installed. Wrap parse in safeParse so a malformed
134
+ // legacy row doesn't break the whole list response.
135
+ const sourceParsed = row.source
136
+ ? pluginSourceSchema.safeParse(row.source)
137
+ : undefined;
138
+ return {
139
+ name: row.name,
140
+ version: row.version ?? "",
141
+ type: typeParsed.success ? typeParsed.data : "backend",
142
+ enabled: row.enabled,
143
+ isUninstallable: row.isUninstallable,
144
+ isPrimary: row.isPrimary,
145
+ bundleId: row.bundleId,
146
+ source: sourceParsed?.success ? sourceParsed.data : null,
147
+ metadata,
148
+ };
149
+ });
150
+ return { plugins: list };
151
+ }),
152
+
153
+ previewInstall: impl.previewInstall.handler(({ input }) =>
154
+ withTranslatedErrors(() =>
155
+ previewInstallOriginator({
156
+ source: input.source,
157
+ registry,
158
+ workspaceRoot,
159
+ runtimeDir,
160
+ }),
161
+ ),
162
+ ),
163
+
164
+ install: impl.install.handler(async ({ input, context }) => {
165
+ const userId = (context.user as { id?: string } | undefined)?.id;
166
+ const result = await withTranslatedErrors(() =>
167
+ installOriginator({
168
+ source: input.source,
169
+ pluginManager,
170
+ db,
171
+ registry,
172
+ workspaceRoot,
173
+ runtimeDir,
174
+ eventRecorder,
175
+ userId,
176
+ }),
177
+ );
178
+ return {
179
+ success: true,
180
+ bundleId: result.bundleId,
181
+ installedPackages: result.installedPackages,
182
+ };
183
+ }),
184
+
185
+ previewUninstall: impl.previewUninstall.handler(({ input }) =>
186
+ withTranslatedErrors(() =>
187
+ previewUninstallOriginator({
188
+ pluginName: input.pluginName,
189
+ db,
190
+ }),
191
+ ),
192
+ ),
193
+
194
+ uninstall: impl.uninstall.handler(async ({ input, context }) => {
195
+ const userId = (context.user as { id?: string } | undefined)?.id;
196
+ const result = await withTranslatedErrors(() =>
197
+ uninstallOriginator({
198
+ pluginName: input.pluginName,
199
+ deleteSchema: input.deleteSchema,
200
+ deleteConfigs: input.deleteConfigs,
201
+ cascade: input.cascade,
202
+ pluginManager,
203
+ db,
204
+ eventRecorder,
205
+ userId,
206
+ }),
207
+ );
208
+ return { success: true, uninstalledPackages: result.uninstalledPackages };
209
+ }),
210
+
211
+ events: impl.events.handler(async ({ input }) => {
212
+ const events = await eventRecorder.list({
213
+ pluginName: input.pluginName,
214
+ limit: input.limit,
215
+ });
216
+ return { events };
217
+ }),
218
+ });
219
+ }
@@ -51,6 +51,7 @@ describe("extractPluginMetadata", () => {
51
51
  pluginPath: "/fake/path/test-backend",
52
52
  type: "backend",
53
53
  enabled: true,
54
+ version: "0.0.1",
54
55
  });
55
56
  });
56
57
 
@@ -288,6 +289,7 @@ describe("syncPluginsToDatabase", () => {
288
289
  pluginPath: "/workspace/plugins/new-backend",
289
290
  type: "backend",
290
291
  enabled: true,
292
+ version: "2.1.0",
291
293
  },
292
294
  ];
293
295
 
@@ -310,6 +312,7 @@ describe("syncPluginsToDatabase", () => {
310
312
  type: "backend",
311
313
  enabled: true,
312
314
  isUninstallable: false,
315
+ version: "2.1.0",
313
316
  });
314
317
  });
315
318
 
@@ -320,6 +323,7 @@ describe("syncPluginsToDatabase", () => {
320
323
  pluginPath: "/workspace/plugins/new-location",
321
324
  type: "backend",
322
325
  enabled: true,
326
+ version: "1.0.0",
323
327
  },
324
328
  ];
325
329
 
@@ -347,6 +351,7 @@ describe("syncPluginsToDatabase", () => {
347
351
  expect(updateCall.set).toHaveBeenCalledWith({
348
352
  path: "/workspace/plugins/new-location",
349
353
  type: "backend",
354
+ version: "1.0.0",
350
355
  });
351
356
  });
352
357
 
@@ -357,6 +362,7 @@ describe("syncPluginsToDatabase", () => {
357
362
  pluginPath: "/workspace/plugins/remote-backend",
358
363
  type: "backend",
359
364
  enabled: true,
365
+ version: "0.0.0",
360
366
  },
361
367
  ];
362
368
 
@@ -10,6 +10,7 @@ export interface PluginMetadata {
10
10
  pluginPath: string; // Absolute path to plugin directory
11
11
  type: "backend" | "frontend" | "common";
12
12
  enabled: boolean;
13
+ version: string; // From package.json "version"
13
14
  }
14
15
 
15
16
  /**
@@ -64,6 +65,7 @@ export function extractPluginMetadata({
64
65
  pluginPath: pluginDir,
65
66
  type,
66
67
  enabled: true, // Local plugins are always enabled
68
+ version: typeof pkgJson.version === "string" ? pkgJson.version : "",
67
69
  };
68
70
  } catch (error) {
69
71
  rootLogger.debug(`⚠️ Failed to read package.json for ${pluginDir}:`, error);
@@ -148,16 +150,19 @@ export async function syncPluginsToDatabase({
148
150
  type: plugin.type,
149
151
  enabled: plugin.enabled,
150
152
  isUninstallable: false, // Local plugins are part of monorepo
153
+ version: plugin.version,
151
154
  });
152
155
  } else {
153
156
  // Update existing plugin ONLY if it's a local plugin (not remotely installed)
154
- // This handles the case where a plugin directory was renamed
157
+ // This handles the case where a plugin directory was renamed AND keeps
158
+ // `version` in lockstep with each release.
155
159
  if (!existing[0].isUninstallable) {
156
160
  await db
157
161
  .update(plugins)
158
162
  .set({
159
163
  path: plugin.pluginPath,
160
164
  type: plugin.type,
165
+ version: plugin.version,
161
166
  })
162
167
  .where(
163
168
  and(
package/tsconfig.json CHANGED
@@ -22,6 +22,9 @@
22
22
  {
23
23
  "path": "../drizzle-helper"
24
24
  },
25
+ {
26
+ "path": "../pluginmanager-common"
27
+ },
25
28
  {
26
29
  "path": "../queue-api"
27
30
  },
@@ -1,276 +0,0 @@
1
- import { describe, it, expect, mock, beforeEach, spyOn } from "bun:test";
2
- import {
3
- createMockEventBus,
4
- createMockPluginInstaller,
5
- createMockQueueManager,
6
- type MockEventBus,
7
- type MockPluginInstaller,
8
- } from "@checkstack/test-utils-backend";
9
- import { coreServices, coreHooks } from "@checkstack/backend-api";
10
-
11
- // Note: ./db and ./logger are mocked via test-preload.ts (bunfig.toml preload)
12
- // This ensures mocks are in place BEFORE any module imports them
13
-
14
- import { PluginManager } from "./plugin-manager";
15
-
16
- // ─────────────────────────────────────────────────────────────────────────────
17
- // Tests
18
- // ─────────────────────────────────────────────────────────────────────────────
19
-
20
- describe("Plugin Lifecycle", () => {
21
- let pluginManager: PluginManager;
22
- let mockEventBus: MockEventBus;
23
- let mockInstaller: MockPluginInstaller;
24
-
25
- beforeEach(() => {
26
- pluginManager = new PluginManager();
27
- mockEventBus = createMockEventBus();
28
- mockInstaller = createMockPluginInstaller();
29
-
30
- // Access internal registry to override factory (factories take precedence)
31
- const registry = (pluginManager as never)["registry"] as {
32
- registerFactory: <T>(ref: { id: string }, factory: () => T) => void;
33
- register: <T>(ref: { id: string }, impl: T) => void;
34
- };
35
-
36
- // Override EventBus factory with our mock
37
- registry.registerFactory(
38
- coreServices.eventBus,
39
- () => mockEventBus as never
40
- );
41
-
42
- // Register services
43
- registry.register(coreServices.pluginInstaller, mockInstaller as never);
44
- registry.register(
45
- coreServices.queueManager,
46
- createMockQueueManager() as never
47
- );
48
- });
49
-
50
- describe("requestInstallation", () => {
51
- it("should emit pluginInstallationRequested broadcast", async () => {
52
- await pluginManager.requestInstallation("test-plugin", "/path/to/plugin");
53
-
54
- expect(mockEventBus._emittedEvents).toContainEqual({
55
- hook: coreHooks.pluginInstallationRequested.id,
56
- payload: { pluginId: "test-plugin", pluginPath: "/path/to/plugin" },
57
- });
58
- });
59
- });
60
-
61
- describe("requestDeregistration", () => {
62
- it("should emit pluginDeregistrationRequested broadcast", async () => {
63
- await pluginManager.requestDeregistration("test-plugin", {
64
- deleteSchema: false,
65
- });
66
-
67
- expect(mockEventBus._emittedEvents).toContainEqual({
68
- hook: coreHooks.pluginDeregistrationRequested.id,
69
- payload: { pluginId: "test-plugin", deleteSchema: false },
70
- });
71
- });
72
-
73
- it("should include deleteSchema flag in broadcast", async () => {
74
- await pluginManager.requestDeregistration("test-plugin", {
75
- deleteSchema: true,
76
- });
77
-
78
- expect(mockEventBus._emittedEvents).toContainEqual({
79
- hook: coreHooks.pluginDeregistrationRequested.id,
80
- payload: { pluginId: "test-plugin", deleteSchema: true },
81
- });
82
- });
83
- });
84
-
85
- describe("setupLifecycleListeners", () => {
86
- it("should register listeners for installation broadcasts", async () => {
87
- await pluginManager.setupLifecycleListeners();
88
-
89
- // Spy on loadSinglePlugin and make it a no-op to avoid actual import
90
- const loadSpy = spyOn(
91
- pluginManager,
92
- "loadSinglePlugin"
93
- ).mockImplementation(async () => {});
94
- await mockEventBus._triggerBroadcast(
95
- coreHooks.pluginInstallationRequested,
96
- {
97
- pluginId: "broadcast-plugin",
98
- pluginPath: "/broadcast/path",
99
- }
100
- );
101
-
102
- expect(loadSpy).toHaveBeenCalledWith(
103
- "broadcast-plugin",
104
- "/broadcast/path"
105
- );
106
- });
107
-
108
- it("should register listeners for deregistration broadcasts", async () => {
109
- await pluginManager.setupLifecycleListeners();
110
-
111
- // Trigger a broadcast and verify the listener responds
112
- const deregSpy = spyOn(pluginManager, "deregisterPlugin");
113
- await mockEventBus._triggerBroadcast(
114
- coreHooks.pluginDeregistrationRequested,
115
- {
116
- pluginId: "broadcast-plugin",
117
- deleteSchema: true,
118
- }
119
- );
120
-
121
- expect(deregSpy).toHaveBeenCalledWith("broadcast-plugin", {
122
- deleteSchema: true,
123
- });
124
- });
125
- });
126
-
127
- describe("loadSinglePlugin", () => {
128
- it("should emit pluginInstalling local hook", async () => {
129
- // Try loading - will fail import but should still emit installing hook
130
- try {
131
- await pluginManager.loadSinglePlugin(
132
- "nonexistent-plugin",
133
- "/nonexistent/path"
134
- );
135
- } catch {
136
- // Expected to fail since plugin doesn't exist
137
- }
138
-
139
- // Should have emitted pluginInstalling locally
140
- expect(mockEventBus._localEmittedEvents).toContainEqual({
141
- hook: coreHooks.pluginInstalling.id,
142
- payload: { pluginId: "nonexistent-plugin" },
143
- });
144
- });
145
-
146
- it("should call installer if import fails", async () => {
147
- try {
148
- await pluginManager.loadSinglePlugin(
149
- "test-remote-plugin",
150
- "/nonexistent/path"
151
- );
152
- } catch {
153
- // Expected to fail at the final import, but installer should be called
154
- }
155
-
156
- // Installer should have been called when imports failed
157
- expect(mockInstaller._installCalls).toContain("test-remote-plugin");
158
- });
159
- });
160
-
161
- describe("deregisterPlugin", () => {
162
- beforeEach(() => {
163
- // Setup cleanup handlers for a plugin
164
- const cleanupHandlers = (pluginManager as never)[
165
- "cleanupHandlers"
166
- ] as Map<string, (() => Promise<void>)[]>;
167
- cleanupHandlers.set("test-plugin", [async () => {}]);
168
- });
169
-
170
- it("should emit pluginDeregistering local hook", async () => {
171
- await pluginManager.deregisterPlugin("test-plugin", {
172
- deleteSchema: false,
173
- });
174
-
175
- const deregisteringEvent = mockEventBus._localEmittedEvents.find(
176
- (e) => e.hook === coreHooks.pluginDeregistering.id
177
- );
178
- expect(deregisteringEvent).toBeDefined();
179
- expect(deregisteringEvent?.payload).toMatchObject({
180
- pluginId: "test-plugin",
181
- });
182
- });
183
-
184
- it("should emit pluginDeregistered after cleanup", async () => {
185
- await pluginManager.deregisterPlugin("test-plugin", {
186
- deleteSchema: false,
187
- });
188
-
189
- const deregisteredEvent = mockEventBus._emittedEvents.find(
190
- (e) => e.hook === coreHooks.pluginDeregistered.id
191
- );
192
- expect(deregisteredEvent).toBeDefined();
193
- expect(deregisteredEvent?.payload).toMatchObject({
194
- pluginId: "test-plugin",
195
- });
196
- });
197
-
198
- it("should run cleanup handlers in reverse order", async () => {
199
- const callOrder: number[] = [];
200
- const cleanupHandlers = (pluginManager as never)[
201
- "cleanupHandlers"
202
- ] as Map<string, (() => Promise<void>)[]>;
203
-
204
- cleanupHandlers.set("test-plugin", [
205
- async () => {
206
- callOrder.push(1);
207
- },
208
- async () => {
209
- callOrder.push(2);
210
- },
211
- async () => {
212
- callOrder.push(3);
213
- },
214
- ]);
215
-
216
- await pluginManager.deregisterPlugin("test-plugin", {
217
- deleteSchema: false,
218
- });
219
-
220
- expect(callOrder).toEqual([3, 2, 1]);
221
- });
222
-
223
- it("should remove plugin router", async () => {
224
- const pluginRpcRouters = (pluginManager as never)[
225
- "pluginRpcRouters"
226
- ] as Map<string, unknown>;
227
- pluginRpcRouters.set("test-plugin", { mockRouter: true });
228
-
229
- await pluginManager.deregisterPlugin("test-plugin", {
230
- deleteSchema: false,
231
- });
232
-
233
- expect(pluginRpcRouters.has("test-plugin")).toBe(false);
234
- });
235
-
236
- it("should clear access rules for plugin", async () => {
237
- const registeredAccessRules = (pluginManager as never)[
238
- "registeredAccessRules"
239
- ] as { pluginId: string; id: string }[];
240
-
241
- // Clear existing access rules first
242
- while (registeredAccessRules.length > 0) {
243
- registeredAccessRules.pop();
244
- }
245
-
246
- // Add test access rules
247
- registeredAccessRules.push(
248
- { pluginId: "test-plugin", id: "test-plugin.perm1" },
249
- { pluginId: "test-plugin", id: "test-plugin.perm2" },
250
- { pluginId: "other-plugin", id: "other-plugin.perm1" }
251
- );
252
-
253
- await pluginManager.deregisterPlugin("test-plugin", {
254
- deleteSchema: false,
255
- });
256
-
257
- // Use getAllAccessRules() which returns the current array
258
- const remaining = pluginManager.getAllAccessRules();
259
- expect(remaining).toHaveLength(1);
260
- expect(remaining[0].id).toBe("other-plugin.perm1");
261
- });
262
- });
263
-
264
- describe("registerCoreRouter", () => {
265
- it("should add router to pluginRpcRouters", () => {
266
- const mockRouter = { test: true };
267
-
268
- pluginManager.registerCoreRouter("admin", mockRouter);
269
-
270
- const pluginRpcRouters = (pluginManager as never)[
271
- "pluginRpcRouters"
272
- ] as Map<string, unknown>;
273
- expect(pluginRpcRouters.get("admin")).toBe(mockRouter);
274
- });
275
- });
276
- });
@@ -1,89 +0,0 @@
1
- import { implement } from "@orpc/server";
2
- import {
3
- autoAuthMiddleware,
4
- type RpcContext,
5
- } from "@checkstack/backend-api";
6
- import { pluginAdminContract } from "@checkstack/backend-api";
7
- import type { PluginManager } from "../plugin-manager";
8
- import type { PluginInstaller } from "@checkstack/backend-api";
9
- import { db } from "../db";
10
- import { plugins } from "../schema";
11
- import { eq } from "drizzle-orm";
12
- import { rootLogger } from "../logger";
13
-
14
- // ─────────────────────────────────────────────────────────────────────────────
15
- // Router Factory
16
- // ─────────────────────────────────────────────────────────────────────────────
17
-
18
- export function createPluginAdminRouter({
19
- pluginManager,
20
- installer,
21
- }: {
22
- pluginManager: PluginManager;
23
- installer: PluginInstaller;
24
- }) {
25
- const impl = implement(pluginAdminContract)
26
- .$context<RpcContext>()
27
- .use(autoAuthMiddleware);
28
-
29
- return impl.router({
30
- install: impl.install.handler(async ({ input }) => {
31
- const { packageName } = input;
32
- rootLogger.info(`📦 Installing plugin: ${packageName}`);
33
-
34
- // 1. npm install to filesystem
35
- const result = await installer.install(packageName);
36
-
37
- // 2. Insert/update in DB with isUninstallable=true (remote plugin)
38
- await db
39
- .insert(plugins)
40
- .values({
41
- name: result.name,
42
- path: result.path,
43
- enabled: true,
44
- isUninstallable: true, // Remote plugins can be uninstalled
45
- type: "backend",
46
- })
47
- .onConflictDoUpdate({
48
- target: [plugins.name],
49
- set: { path: result.path, enabled: true },
50
- });
51
-
52
- // 3. Broadcast to all instances to load the plugin
53
- await pluginManager.requestInstallation(result.name, result.path);
54
-
55
- return {
56
- success: true,
57
- pluginId: result.name,
58
- path: result.path,
59
- };
60
- }),
61
-
62
- deregister: impl.deregister.handler(async ({ input }) => {
63
- const { pluginId, deleteSchema } = input;
64
- rootLogger.info(`🗑️ Deregistering plugin: ${pluginId}`);
65
-
66
- // Check if plugin exists and is uninstallable
67
- const existing = await db
68
- .select()
69
- .from(plugins)
70
- .where(eq(plugins.name, pluginId))
71
- .limit(1);
72
-
73
- if (existing.length === 0) {
74
- throw new Error(`Plugin ${pluginId} not found`);
75
- }
76
-
77
- if (!existing[0].isUninstallable) {
78
- throw new Error(
79
- `Plugin ${pluginId} is a core plugin and cannot be deregistered`
80
- );
81
- }
82
-
83
- // Broadcast to all instances to deregister
84
- await pluginManager.requestDeregistration(pluginId, { deleteSchema });
85
-
86
- return { success: true };
87
- }),
88
- });
89
- }