@checkstack/backend 0.8.2 → 0.9.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (42) hide show
  1. package/CHANGELOG.md +333 -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 +18 -13
  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/cache-manager.test.ts +172 -0
  13. package/src/services/cache-manager.ts +67 -14
  14. package/src/services/compatibility-checker.test.ts +146 -0
  15. package/src/services/compatibility-checker.ts +137 -0
  16. package/src/services/dev-auth.test.ts +87 -0
  17. package/src/services/dev-auth.ts +56 -0
  18. package/src/services/event-bus.test.ts +52 -0
  19. package/src/services/event-bus.ts +27 -1
  20. package/src/services/plugin-artifact-store.ts +131 -0
  21. package/src/services/plugin-bundle-resolver.ts +76 -0
  22. package/src/services/plugin-event-recorder.ts +87 -0
  23. package/src/services/plugin-installers/catalog-installer.ts +33 -0
  24. package/src/services/plugin-installers/github-installer.ts +207 -0
  25. package/src/services/plugin-installers/install-from-tarball.ts +69 -0
  26. package/src/services/plugin-installers/installer-registry.ts +51 -0
  27. package/src/services/plugin-installers/npm-installer.ts +156 -0
  28. package/src/services/plugin-installers/plugin-install-error.ts +37 -0
  29. package/src/services/plugin-installers/tarball-installer.ts +80 -0
  30. package/src/services/plugin-installers/tarball-utils.test.ts +200 -0
  31. package/src/services/plugin-installers/tarball-utils.ts +172 -0
  32. package/src/services/plugin-manager-orchestrator.ts +522 -0
  33. package/src/services/plugin-manager-router.ts +219 -0
  34. package/src/services/queue-manager.ts +77 -2
  35. package/src/services/queue-proxy.ts +7 -0
  36. package/src/utils/plugin-discovery.test.ts +6 -0
  37. package/src/utils/plugin-discovery.ts +6 -1
  38. package/tsconfig.json +3 -0
  39. package/src/plugin-lifecycle.test.ts +0 -276
  40. package/src/plugin-manager/plugin-admin-router.ts +0 -89
  41. package/src/services/plugin-installer.test.ts +0 -90
  42. package/src/services/plugin-installer.ts +0 -70
@@ -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
- }
@@ -1,90 +0,0 @@
1
- import { describe, it, expect, mock, beforeEach } from "bun:test";
2
-
3
- // 1. Mock child_process and fs BEFORE importing the target module
4
- const mockExec = mock((_cmd: string, cb: any) => {
5
- cb(null, { stdout: "mocked" }, { stderr: "" });
6
- });
7
-
8
- const mockExistsSync = mock(() => true);
9
- const mockMkdirSync = mock();
10
- const mockReadFileSync = mock(() => JSON.stringify({ name: "mock-plugin" }));
11
-
12
- mock.module("node:util", () => ({
13
- promisify: (fn: any) => {
14
- return async (...args: any[]) => {
15
- // Return a promise that resolves with what our mock would return
16
- // We can just call mockExec and return its "result"
17
- return new Promise((resolve) => {
18
- fn(...args, (err: any, stdout: any, stderr: any) =>
19
- resolve({ stdout, stderr })
20
- );
21
- });
22
- };
23
- },
24
- }));
25
-
26
- mock.module("node:child_process", () => ({
27
- exec: mockExec,
28
- }));
29
-
30
- mock.module("node:fs", () => {
31
- const exports = {
32
- existsSync: mockExistsSync,
33
- mkdirSync: mockMkdirSync,
34
- readFileSync: mockReadFileSync,
35
- };
36
- return {
37
- ...exports,
38
- default: exports,
39
- };
40
- });
41
-
42
- // 2. Now import the module under test
43
- import { PluginLocalInstaller } from "./plugin-installer";
44
- import fs from "node:fs";
45
- import path from "node:path";
46
-
47
- describe("PluginLocalInstaller", () => {
48
- const runtimeDir = "/tmp/runtime_plugins";
49
- let installer: PluginLocalInstaller;
50
- let customExec: any;
51
-
52
- beforeEach(() => {
53
- customExec = mock(() => Promise.resolve({ stdout: "mocked", stderr: "" }));
54
- installer = new PluginLocalInstaller(runtimeDir, customExec);
55
- mockExistsSync.mockClear();
56
- mockExistsSync.mockReturnValue(true);
57
- });
58
-
59
- it("should install a package using bun", async () => {
60
- const result = await installer.install("my-plugin");
61
-
62
- expect(customExec).toHaveBeenCalled();
63
- const command = customExec.mock.calls[0][0];
64
- expect(command).toContain("bun install my-plugin");
65
- expect(command).toContain(`--cwd ${path.resolve(runtimeDir)}`);
66
- expect(command).toContain("--no-save");
67
-
68
- expect(result.name).toBe("mock-plugin");
69
- });
70
-
71
- it("should handle scoped packages correctly", async () => {
72
- const result = await installer.install("@scope/plugin");
73
-
74
- expect(customExec).toHaveBeenCalled();
75
- const command = customExec.mock.calls[0][0];
76
- expect(command).toContain("bun install @scope/plugin");
77
-
78
- expect(result.name).toBe("mock-plugin");
79
- });
80
-
81
- it("should throw error if package.json is missing after install", async () => {
82
- // The constructor was already called in beforeEach.
83
- // The next call to existsSync will be inside the install method for the pkgJsonPath.
84
- mockExistsSync.mockReturnValueOnce(false);
85
-
86
- await expect(installer.install("failing-plugin")).rejects.toThrow(
87
- "not found"
88
- );
89
- });
90
- });
@@ -1,70 +0,0 @@
1
- import { PluginInstaller } from "@checkstack/backend-api";
2
- import { exec } from "node:child_process";
3
- import { promisify } from "node:util";
4
- import path from "node:path";
5
- import fs from "node:fs";
6
-
7
- export class PluginLocalInstaller implements PluginInstaller {
8
- private runtimeDir: string;
9
- private execAsync: (
10
- command: string
11
- ) => Promise<{ stdout: string; stderr: string }>;
12
-
13
- constructor(
14
- runtimeDir: string,
15
- customExec?: (
16
- command: string
17
- ) => Promise<{ stdout: string; stderr: string }>
18
- ) {
19
- this.runtimeDir = path.resolve(runtimeDir);
20
- this.execAsync = customExec || promisify(exec);
21
- if (!fs.existsSync(this.runtimeDir)) {
22
- fs.mkdirSync(this.runtimeDir, { recursive: true });
23
- }
24
- }
25
-
26
- async install(packageName: string): Promise<{ name: string; path: string }> {
27
- try {
28
- console.log(
29
- `🔌 Installing plugin: ${packageName} into ${this.runtimeDir}`
30
- );
31
-
32
- // Use bun install with --no-save to avoid creating/modifying lockfiles
33
- // in the runtime directory. This keeps plugins isolated from the main app.
34
- await this.execAsync(
35
- `bun install ${packageName} --cwd ${this.runtimeDir} --no-save`
36
- );
37
-
38
- // Extract the actual package name (packageName could be a URL or @org/name@version)
39
- // For now, we assume it's a simple package name for the path lookup,
40
- // or we can parse it better.
41
- // A safer way is to check the recently changed folders in node_modules?
42
- // Or just assume the input packageName (stripped of @version) matches the folder.
43
- let folderName = packageName;
44
- if (packageName.includes("@") && !packageName.startsWith("@")) {
45
- folderName = packageName.split("@")[0];
46
- } else if (packageName.startsWith("@") && packageName.includes("@", 1)) {
47
- folderName = "@" + packageName.split("@")[1];
48
- }
49
-
50
- const pkgPath = path.join(this.runtimeDir, "node_modules", folderName);
51
- const pkgJsonPath = path.join(pkgPath, "package.json");
52
-
53
- if (!fs.existsSync(pkgJsonPath)) {
54
- throw new Error(
55
- `Package folder ${folderName} not found at ${pkgPath} after installation`
56
- );
57
- }
58
-
59
- const pkgJson = JSON.parse(fs.readFileSync(pkgJsonPath, "utf8"));
60
-
61
- return {
62
- name: pkgJson.name,
63
- path: pkgPath,
64
- };
65
- } catch (error) {
66
- console.error(`❌ Failed to install plugin ${packageName}:`, error);
67
- throw error;
68
- }
69
- }
70
- }