@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.
- package/CHANGELOG.md +333 -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 +18 -13
- 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/cache-manager.test.ts +172 -0
- package/src/services/cache-manager.ts +67 -14
- 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/event-bus.test.ts +52 -0
- package/src/services/event-bus.ts +27 -1
- 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/queue-manager.ts +77 -2
- package/src/services/queue-proxy.ts +7 -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
|
@@ -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
|
-
}
|