@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
|
@@ -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
|
@@ -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
|
-
}
|