@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
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import { describe, it, expect } from "bun:test";
|
|
2
|
+
import { createDevAuthService } from "./dev-auth";
|
|
3
|
+
|
|
4
|
+
describe("createDevAuthService", () => {
|
|
5
|
+
it("authenticate() returns a stable RealUser identity", async () => {
|
|
6
|
+
const svc = createDevAuthService({ getAllAccessRules: () => [] });
|
|
7
|
+
const user = await svc.authenticate(new Request("http://x"));
|
|
8
|
+
expect(user).toBeDefined();
|
|
9
|
+
if (!user || user.type !== "user") {
|
|
10
|
+
throw new Error("expected RealUser");
|
|
11
|
+
}
|
|
12
|
+
expect(user.id).toBe("dev-user");
|
|
13
|
+
expect(user.email).toBe("dev@checkstack.local");
|
|
14
|
+
expect(user.name).toBe("Dev User");
|
|
15
|
+
expect(user.roles).toEqual(["admin"]);
|
|
16
|
+
expect(user.teamIds).toEqual([]);
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
it("populates accessRules from the registered set", async () => {
|
|
20
|
+
const svc = createDevAuthService({
|
|
21
|
+
getAllAccessRules: () => [
|
|
22
|
+
{ id: "catalog.system.read" },
|
|
23
|
+
{ id: "catalog.system.manage" },
|
|
24
|
+
],
|
|
25
|
+
});
|
|
26
|
+
const user = await svc.authenticate(new Request("http://x"));
|
|
27
|
+
if (!user || user.type !== "user") throw new Error("expected RealUser");
|
|
28
|
+
expect(user.accessRules).toEqual([
|
|
29
|
+
"catalog.system.read",
|
|
30
|
+
"catalog.system.manage",
|
|
31
|
+
]);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it("re-reads access rules on each authenticate (rules registered later still apply)", async () => {
|
|
35
|
+
const rules = [{ id: "first.rule" }];
|
|
36
|
+
const svc = createDevAuthService({ getAllAccessRules: () => rules });
|
|
37
|
+
|
|
38
|
+
const before = await svc.authenticate(new Request("http://x"));
|
|
39
|
+
if (!before || before.type !== "user") throw new Error("expected RealUser");
|
|
40
|
+
expect(before.accessRules).toEqual(["first.rule"]);
|
|
41
|
+
|
|
42
|
+
rules.push({ id: "second.rule" });
|
|
43
|
+
const after = await svc.authenticate(new Request("http://x"));
|
|
44
|
+
if (!after || after.type !== "user") throw new Error("expected RealUser");
|
|
45
|
+
expect(after.accessRules).toEqual(["first.rule", "second.rule"]);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it("getAnonymousAccessRules returns an empty list (anonymous gets nothing in dev)", async () => {
|
|
49
|
+
const svc = createDevAuthService({
|
|
50
|
+
getAllAccessRules: () => [{ id: "x" }, { id: "y" }],
|
|
51
|
+
});
|
|
52
|
+
expect(await svc.getAnonymousAccessRules()).toEqual([]);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it("getCredentials returns an empty headers object", async () => {
|
|
56
|
+
const svc = createDevAuthService({ getAllAccessRules: () => [] });
|
|
57
|
+
expect(await svc.getCredentials()).toEqual({ headers: {} });
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it("checkResourceTeamAccess always grants", async () => {
|
|
61
|
+
const svc = createDevAuthService({ getAllAccessRules: () => [] });
|
|
62
|
+
expect(
|
|
63
|
+
await svc.checkResourceTeamAccess({
|
|
64
|
+
userId: "x",
|
|
65
|
+
userType: "user",
|
|
66
|
+
resourceType: "system",
|
|
67
|
+
resourceId: "abc",
|
|
68
|
+
action: "manage",
|
|
69
|
+
hasGlobalAccess: false,
|
|
70
|
+
}),
|
|
71
|
+
).toEqual({ hasAccess: true });
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it("getAccessibleResourceIds returns the input list unfiltered", async () => {
|
|
75
|
+
const svc = createDevAuthService({ getAllAccessRules: () => [] });
|
|
76
|
+
expect(
|
|
77
|
+
await svc.getAccessibleResourceIds({
|
|
78
|
+
userId: "x",
|
|
79
|
+
userType: "user",
|
|
80
|
+
resourceType: "system",
|
|
81
|
+
resourceIds: ["one", "two", "three"],
|
|
82
|
+
action: "read",
|
|
83
|
+
hasGlobalAccess: false,
|
|
84
|
+
}),
|
|
85
|
+
).toEqual(["one", "two", "three"]);
|
|
86
|
+
});
|
|
87
|
+
});
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import type { AuthService, RealUser } from "@checkstack/backend-api";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Dev-only auth service.
|
|
5
|
+
*
|
|
6
|
+
* Used by `bunx @checkstack/scripts dev` so plugin authors don't have to
|
|
7
|
+
* deal with login flows / cookie state while iterating. Returns a
|
|
8
|
+
* synthetic user that has every registered access rule, so any procedure
|
|
9
|
+
* (regardless of the access guards on it) authorizes.
|
|
10
|
+
*
|
|
11
|
+
* NEVER register this in production. The runtime gates installation
|
|
12
|
+
* behind a `CHECKSTACK_DEV_AUTH=true` env var that we set explicitly in
|
|
13
|
+
* the dev server entry point.
|
|
14
|
+
*
|
|
15
|
+
* The user identity is stable (`dev-user`) so plugin code that derives
|
|
16
|
+
* UI / data from `user.id` behaves consistently across reloads.
|
|
17
|
+
*/
|
|
18
|
+
export function createDevAuthService({
|
|
19
|
+
getAllAccessRules,
|
|
20
|
+
}: {
|
|
21
|
+
getAllAccessRules: () => Array<{ id: string }>;
|
|
22
|
+
}): AuthService {
|
|
23
|
+
const devUser: RealUser = {
|
|
24
|
+
type: "user",
|
|
25
|
+
id: "dev-user",
|
|
26
|
+
email: "dev@checkstack.local",
|
|
27
|
+
name: "Dev User",
|
|
28
|
+
accessRules: [], // populated lazily below
|
|
29
|
+
roles: ["admin"],
|
|
30
|
+
teamIds: [],
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
return {
|
|
34
|
+
async authenticate(_request) {
|
|
35
|
+
// Always grant every access rule the platform currently knows about.
|
|
36
|
+
// We resolve this lazily so rules registered by plugins after auth
|
|
37
|
+
// construction (the normal flow) still apply.
|
|
38
|
+
devUser.accessRules = getAllAccessRules().map((r) => r.id);
|
|
39
|
+
return devUser;
|
|
40
|
+
},
|
|
41
|
+
async getCredentials() {
|
|
42
|
+
return { headers: {} };
|
|
43
|
+
},
|
|
44
|
+
async getAnonymousAccessRules() {
|
|
45
|
+
// Anonymous users get nothing; the dev user is the only authenticated
|
|
46
|
+
// identity in dev mode.
|
|
47
|
+
return [];
|
|
48
|
+
},
|
|
49
|
+
async checkResourceTeamAccess() {
|
|
50
|
+
return { hasAccess: true };
|
|
51
|
+
},
|
|
52
|
+
async getAccessibleResourceIds({ resourceIds }) {
|
|
53
|
+
return resourceIds;
|
|
54
|
+
},
|
|
55
|
+
};
|
|
56
|
+
}
|
|
@@ -465,5 +465,57 @@ describe("EventBus", () => {
|
|
|
465
465
|
`No local listeners for hook: ${testHook.id}`
|
|
466
466
|
);
|
|
467
467
|
});
|
|
468
|
+
|
|
469
|
+
it("should drop emit (not enqueue) when no listeners are registered", async () => {
|
|
470
|
+
const testHook = createHook<{ value: number }>(
|
|
471
|
+
"test.emit.no.listeners",
|
|
472
|
+
);
|
|
473
|
+
|
|
474
|
+
// Capture queue creation: getQueue is invoked lazily on enqueue.
|
|
475
|
+
const before = (mockQueueManager as any).getQueue?.mock?.calls?.length ?? 0;
|
|
476
|
+
|
|
477
|
+
await eventBus.emit(testHook, { value: 1 });
|
|
478
|
+
await eventBus.emit(testHook, { value: 2 });
|
|
479
|
+
|
|
480
|
+
const after = (mockQueueManager as any).getQueue?.mock?.calls?.length ?? 0;
|
|
481
|
+
// No queue should be created and no enqueue should occur for an
|
|
482
|
+
// entirely unsubscribed hook — otherwise jobs would pile up forever.
|
|
483
|
+
expect(after).toBe(before);
|
|
484
|
+
|
|
485
|
+
expect(mockLogger.debug).toHaveBeenCalledWith(
|
|
486
|
+
`Dropped hook ${testHook.id}: no listeners registered`,
|
|
487
|
+
);
|
|
488
|
+
});
|
|
489
|
+
|
|
490
|
+
it("should enqueue when at least one distributed listener is registered", async () => {
|
|
491
|
+
const testHook = createHook<{ value: number }>(
|
|
492
|
+
"test.emit.with.listener",
|
|
493
|
+
);
|
|
494
|
+
const received: number[] = [];
|
|
495
|
+
await eventBus.subscribe("test-plugin", testHook, async (payload) => {
|
|
496
|
+
received.push(payload.value);
|
|
497
|
+
});
|
|
498
|
+
|
|
499
|
+
await eventBus.emit(testHook, { value: 7 });
|
|
500
|
+
// Allow the mock queue to deliver synchronously.
|
|
501
|
+
await new Promise((resolve) => setTimeout(resolve, 10));
|
|
502
|
+
expect(received).toContain(7);
|
|
503
|
+
});
|
|
504
|
+
|
|
505
|
+
it("should enqueue when at least one instance-local listener is registered", async () => {
|
|
506
|
+
const testHook = createHook<{ value: number }>(
|
|
507
|
+
"test.emit.with.local.listener",
|
|
508
|
+
);
|
|
509
|
+
await eventBus.subscribe("test-plugin", testHook, async () => {}, {
|
|
510
|
+
mode: "instance-local",
|
|
511
|
+
});
|
|
512
|
+
|
|
513
|
+
// emit (distributed) should still enqueue because a local listener
|
|
514
|
+
// exists — drops are based on absence of *any* listener.
|
|
515
|
+
await eventBus.emit(testHook, { value: 1 });
|
|
516
|
+
expect(mockLogger.debug).not.toHaveBeenCalledWith(
|
|
517
|
+
`Dropped hook ${testHook.id}: no listeners registered`,
|
|
518
|
+
);
|
|
519
|
+
});
|
|
468
520
|
});
|
|
469
521
|
});
|
|
@@ -185,9 +185,35 @@ export class EventBus implements IEventBus {
|
|
|
185
185
|
}
|
|
186
186
|
|
|
187
187
|
/**
|
|
188
|
-
* Emit a hook
|
|
188
|
+
* Emit a hook.
|
|
189
|
+
*
|
|
190
|
+
* Skips the underlying queue enqueue when no listener has been
|
|
191
|
+
* registered for the hook in this process. Without this guard, hooks
|
|
192
|
+
* with no subscribers (e.g. `core.plugin.initialized` when no plugin
|
|
193
|
+
* has registered a listener) would accumulate jobs in the in-memory
|
|
194
|
+
* queue forever — they'd be enqueued, no consumer group exists to
|
|
195
|
+
* process them, and `processNext` short-circuits before its cleanup
|
|
196
|
+
* pass when `consumerGroups.size === 0`.
|
|
197
|
+
*
|
|
198
|
+
* Note: this checks listeners in the *local process*. In distributed
|
|
199
|
+
* deployments with a Redis-backed queue, a subscriber on another
|
|
200
|
+
* replica would never see the event under this rule. Callers that
|
|
201
|
+
* need cross-process delivery must therefore ensure at least one
|
|
202
|
+
* listener registers on every replica that should receive the hook.
|
|
189
203
|
*/
|
|
190
204
|
async emit<T>(hook: Hook<T>, payload: T): Promise<void> {
|
|
205
|
+
const hasDistributedListeners =
|
|
206
|
+
(this.listeners.get(hook.id)?.length ?? 0) > 0;
|
|
207
|
+
const hasLocalListeners =
|
|
208
|
+
(this.localListeners.get(hook.id)?.length ?? 0) > 0;
|
|
209
|
+
|
|
210
|
+
if (!hasDistributedListeners && !hasLocalListeners) {
|
|
211
|
+
this.logger.debug(
|
|
212
|
+
`Dropped hook ${hook.id}: no listeners registered`,
|
|
213
|
+
);
|
|
214
|
+
return;
|
|
215
|
+
}
|
|
216
|
+
|
|
191
217
|
let channel = this.queueChannels.get(hook.id);
|
|
192
218
|
|
|
193
219
|
// Create channel lazily if not exists
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { eq, and } from "drizzle-orm";
|
|
3
|
+
import type {
|
|
4
|
+
PluginArtifactStore,
|
|
5
|
+
SafeDatabase,
|
|
6
|
+
} from "@checkstack/backend-api";
|
|
7
|
+
import { pluginArtifacts } from "../schema";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Postgres-backed artifact store.
|
|
11
|
+
*
|
|
12
|
+
* Tarball bytes are stored as base64-encoded `text` (drizzle's `bytea`
|
|
13
|
+
* support is awkward and this keeps the column portable). The 33% size
|
|
14
|
+
* overhead is fine at our hard cap (50 MB per artifact).
|
|
15
|
+
*
|
|
16
|
+
* Sharing the row across instances means a freshly spun replica can
|
|
17
|
+
* recover any runtime-installed plugin from the same Postgres the rest
|
|
18
|
+
* of the platform already depends on — no new infra.
|
|
19
|
+
*/
|
|
20
|
+
const MAX_ARTIFACT_SIZE = 50 * 1024 * 1024;
|
|
21
|
+
|
|
22
|
+
export class PostgresPluginArtifactStore implements PluginArtifactStore {
|
|
23
|
+
readonly maxArtifactSize = MAX_ARTIFACT_SIZE;
|
|
24
|
+
|
|
25
|
+
constructor(
|
|
26
|
+
private readonly db: SafeDatabase<Record<string, unknown>>,
|
|
27
|
+
) {}
|
|
28
|
+
|
|
29
|
+
async store({
|
|
30
|
+
pluginName,
|
|
31
|
+
version,
|
|
32
|
+
bundleId,
|
|
33
|
+
tarball,
|
|
34
|
+
}: {
|
|
35
|
+
pluginName: string;
|
|
36
|
+
version: string;
|
|
37
|
+
bundleId?: string | null;
|
|
38
|
+
tarball: Uint8Array;
|
|
39
|
+
}): Promise<{ artifactId: string; contentHash: string }> {
|
|
40
|
+
if (tarball.byteLength > MAX_ARTIFACT_SIZE) {
|
|
41
|
+
throw new Error(
|
|
42
|
+
`Artifact exceeds maximum size: ${tarball.byteLength} > ${MAX_ARTIFACT_SIZE} bytes`,
|
|
43
|
+
);
|
|
44
|
+
}
|
|
45
|
+
const contentHash = sha256Hex(tarball);
|
|
46
|
+
const encoded = Buffer.from(tarball).toString("base64");
|
|
47
|
+
|
|
48
|
+
// Upsert by (plugin_name, version): reinstalling the same version
|
|
49
|
+
// overwrites — useful for replacing a corrupted artifact, harmless
|
|
50
|
+
// when bytes are identical.
|
|
51
|
+
const inserted = await this.db
|
|
52
|
+
.insert(pluginArtifacts)
|
|
53
|
+
.values({
|
|
54
|
+
pluginName,
|
|
55
|
+
version,
|
|
56
|
+
bundleId: bundleId ?? null,
|
|
57
|
+
tarball: encoded,
|
|
58
|
+
contentHash,
|
|
59
|
+
sizeBytes: tarball.byteLength,
|
|
60
|
+
})
|
|
61
|
+
.onConflictDoUpdate({
|
|
62
|
+
target: [pluginArtifacts.pluginName, pluginArtifacts.version],
|
|
63
|
+
set: {
|
|
64
|
+
tarball: encoded,
|
|
65
|
+
contentHash,
|
|
66
|
+
sizeBytes: tarball.byteLength,
|
|
67
|
+
bundleId: bundleId ?? null,
|
|
68
|
+
},
|
|
69
|
+
})
|
|
70
|
+
.returning({ id: pluginArtifacts.id });
|
|
71
|
+
|
|
72
|
+
return { artifactId: inserted[0].id, contentHash };
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
async fetch({
|
|
76
|
+
pluginName,
|
|
77
|
+
version,
|
|
78
|
+
}: {
|
|
79
|
+
pluginName: string;
|
|
80
|
+
version: string;
|
|
81
|
+
}): Promise<{ tarball: Uint8Array; contentHash: string } | undefined> {
|
|
82
|
+
const rows = await this.db
|
|
83
|
+
.select()
|
|
84
|
+
.from(pluginArtifacts)
|
|
85
|
+
.where(
|
|
86
|
+
and(
|
|
87
|
+
eq(pluginArtifacts.pluginName, pluginName),
|
|
88
|
+
eq(pluginArtifacts.version, version),
|
|
89
|
+
),
|
|
90
|
+
)
|
|
91
|
+
.limit(1);
|
|
92
|
+
if (rows.length === 0) return undefined;
|
|
93
|
+
return {
|
|
94
|
+
tarball: new Uint8Array(Buffer.from(rows[0].tarball, "base64")),
|
|
95
|
+
contentHash: rows[0].contentHash,
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
async fetchById(artifactId: string) {
|
|
100
|
+
const rows = await this.db
|
|
101
|
+
.select()
|
|
102
|
+
.from(pluginArtifacts)
|
|
103
|
+
.where(eq(pluginArtifacts.id, artifactId))
|
|
104
|
+
.limit(1);
|
|
105
|
+
if (rows.length === 0) return;
|
|
106
|
+
return {
|
|
107
|
+
tarball: new Uint8Array(Buffer.from(rows[0].tarball, "base64")),
|
|
108
|
+
contentHash: rows[0].contentHash,
|
|
109
|
+
pluginName: rows[0].pluginName,
|
|
110
|
+
version: rows[0].version,
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
async delete({ pluginName }: { pluginName: string }): Promise<void> {
|
|
115
|
+
await this.db
|
|
116
|
+
.delete(pluginArtifacts)
|
|
117
|
+
.where(eq(pluginArtifacts.pluginName, pluginName));
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
async deleteByBundle({ bundleId }: { bundleId: string }): Promise<void> {
|
|
121
|
+
await this.db
|
|
122
|
+
.delete(pluginArtifacts)
|
|
123
|
+
.where(eq(pluginArtifacts.bundleId, bundleId));
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function sha256Hex(bytes: Uint8Array): string {
|
|
128
|
+
const h = createHash("sha256");
|
|
129
|
+
h.update(bytes);
|
|
130
|
+
return h.digest("hex");
|
|
131
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
PluginInstallerRegistry,
|
|
3
|
+
PluginSource,
|
|
4
|
+
FetchedTarball,
|
|
5
|
+
NpmPluginSource,
|
|
6
|
+
} from "@checkstack/backend-api";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Given a `PluginSource`, fetch the primary tarball and recursively resolve
|
|
10
|
+
* any bundle siblings into a single normalized list.
|
|
11
|
+
*
|
|
12
|
+
* - npm: bundle siblings are *separate* npm packages; for each, we issue a
|
|
13
|
+
* second `fetchTarball` call against the same registry.
|
|
14
|
+
* - tarball / github: bundle siblings are *inline* in the outer tarball
|
|
15
|
+
* (per the `bundle.json` manifest); the per-source installer already
|
|
16
|
+
* extracted them.
|
|
17
|
+
*
|
|
18
|
+
* Returns the primary first, then siblings in declared order.
|
|
19
|
+
*/
|
|
20
|
+
export async function resolveBundle({
|
|
21
|
+
source,
|
|
22
|
+
installerRegistry,
|
|
23
|
+
}: {
|
|
24
|
+
source: PluginSource;
|
|
25
|
+
installerRegistry: PluginInstallerRegistry;
|
|
26
|
+
}): Promise<{
|
|
27
|
+
primary: FetchedTarball;
|
|
28
|
+
packages: FetchedTarball[];
|
|
29
|
+
bundleId?: string;
|
|
30
|
+
}> {
|
|
31
|
+
const primary = await installerRegistry.fetchTarball(source);
|
|
32
|
+
const packages: FetchedTarball[] = [primary];
|
|
33
|
+
|
|
34
|
+
// Inline bundle (tarball / github)
|
|
35
|
+
if (primary.bundle) {
|
|
36
|
+
for (const sib of primary.bundle.siblings) {
|
|
37
|
+
if (sib.packageJson.name === primary.packageJson.name) continue;
|
|
38
|
+
packages.push({ tarball: sib.tarball, packageJson: sib.packageJson });
|
|
39
|
+
}
|
|
40
|
+
return { primary, packages };
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// Cross-package bundle for npm: declared via primary's checkstack.bundle
|
|
44
|
+
const declared = primary.packageJson.checkstack.bundle;
|
|
45
|
+
if (declared && declared.length > 0) {
|
|
46
|
+
if (source.type !== "npm") {
|
|
47
|
+
// Already-inline bundles are handled above; if a non-npm source
|
|
48
|
+
// declared `checkstack.bundle` without inlining, refuse — the
|
|
49
|
+
// expectation is the pack CLI produces a bundle tarball.
|
|
50
|
+
throw new Error(
|
|
51
|
+
`Plugin ${primary.packageJson.name} declares 'checkstack.bundle' but the source ` +
|
|
52
|
+
`(${source.type}) did not ship sibling tarballs. Use the GitHub release / tarball ` +
|
|
53
|
+
`source with a --bundle-mode tarball, or remove 'checkstack.bundle'.`,
|
|
54
|
+
);
|
|
55
|
+
}
|
|
56
|
+
for (const siblingName of declared) {
|
|
57
|
+
const siblingSource: NpmPluginSource = {
|
|
58
|
+
type: "npm",
|
|
59
|
+
packageName: siblingName,
|
|
60
|
+
// Pin sibling version to primary's exact version — bundles are
|
|
61
|
+
// released atomically and identical-versioned by convention.
|
|
62
|
+
version: primary.packageJson.version,
|
|
63
|
+
registry: source.registry,
|
|
64
|
+
};
|
|
65
|
+
const fetched = await installerRegistry.fetchTarball(siblingSource);
|
|
66
|
+
if (fetched.bundle) {
|
|
67
|
+
throw new Error(
|
|
68
|
+
`Sibling '${siblingName}' itself ships a bundle — bundles cannot nest.`,
|
|
69
|
+
);
|
|
70
|
+
}
|
|
71
|
+
packages.push(fetched);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
return { primary, packages };
|
|
76
|
+
}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import { desc, eq } from "drizzle-orm";
|
|
2
|
+
import type { SafeDatabase } from "@checkstack/backend-api";
|
|
3
|
+
import type { PluginSource } from "@checkstack/common";
|
|
4
|
+
import type {
|
|
5
|
+
InstallEvent,
|
|
6
|
+
InstallEventAction,
|
|
7
|
+
InstallEventPhase,
|
|
8
|
+
InstallEventStatus,
|
|
9
|
+
} from "@checkstack/pluginmanager-common";
|
|
10
|
+
import { pluginInstallEvents } from "../schema";
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Persist install/uninstall lifecycle events.
|
|
14
|
+
*
|
|
15
|
+
* Every step of the install/uninstall flow writes a row here:
|
|
16
|
+
* - originator: validate / persist / broadcast / destructive-cleanup / audit
|
|
17
|
+
* - receiving instances: in-process-load / in-process-unload
|
|
18
|
+
*
|
|
19
|
+
* Failures are kept (status="failed") so the admin Events page can surface
|
|
20
|
+
* partial state for manual remediation. The originator dying mid-flight
|
|
21
|
+
* leaves a "started" row that never transitions — the UI flags those.
|
|
22
|
+
*/
|
|
23
|
+
export class PluginEventRecorder {
|
|
24
|
+
constructor(
|
|
25
|
+
private readonly db: SafeDatabase<Record<string, unknown>>,
|
|
26
|
+
private readonly instanceId: string,
|
|
27
|
+
) {}
|
|
28
|
+
|
|
29
|
+
async record(input: {
|
|
30
|
+
pluginName?: string | null;
|
|
31
|
+
bundleId?: string | null;
|
|
32
|
+
action: InstallEventAction;
|
|
33
|
+
phase: InstallEventPhase;
|
|
34
|
+
status: InstallEventStatus;
|
|
35
|
+
source?: PluginSource | null;
|
|
36
|
+
error?: string | null;
|
|
37
|
+
userId?: string | null;
|
|
38
|
+
}): Promise<void> {
|
|
39
|
+
// Drizzle writes `null` as a real NULL column and `undefined` as
|
|
40
|
+
// "skip this column"; both map to NULL on insert here, but normalize
|
|
41
|
+
// to `null` so the row's shape matches the schema-declared
|
|
42
|
+
// nullable columns.
|
|
43
|
+
await this.db.insert(pluginInstallEvents).values({
|
|
44
|
+
pluginName: input.pluginName ?? null,
|
|
45
|
+
bundleId: input.bundleId ?? null,
|
|
46
|
+
action: input.action,
|
|
47
|
+
phase: input.phase,
|
|
48
|
+
status: input.status,
|
|
49
|
+
source: input.source ?? null,
|
|
50
|
+
error: input.error ?? null,
|
|
51
|
+
instanceId: this.instanceId,
|
|
52
|
+
userId: input.userId ?? null,
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
async list(input?: {
|
|
57
|
+
pluginName?: string;
|
|
58
|
+
limit?: number;
|
|
59
|
+
}): Promise<InstallEvent[]> {
|
|
60
|
+
const limit = input?.limit ?? 100;
|
|
61
|
+
const where = input?.pluginName
|
|
62
|
+
? eq(pluginInstallEvents.pluginName, input.pluginName)
|
|
63
|
+
: undefined;
|
|
64
|
+
|
|
65
|
+
const rows = await this.db
|
|
66
|
+
.select()
|
|
67
|
+
.from(pluginInstallEvents)
|
|
68
|
+
.where(where)
|
|
69
|
+
.orderBy(desc(pluginInstallEvents.createdAt))
|
|
70
|
+
.limit(limit);
|
|
71
|
+
|
|
72
|
+
return rows.map((row) => ({
|
|
73
|
+
id: row.id,
|
|
74
|
+
pluginName: row.pluginName,
|
|
75
|
+
bundleId: row.bundleId,
|
|
76
|
+
action: row.action as InstallEventAction,
|
|
77
|
+
phase: row.phase as InstallEventPhase,
|
|
78
|
+
status: row.status as InstallEventStatus,
|
|
79
|
+
source: row.source as PluginSource | null,
|
|
80
|
+
error: row.error,
|
|
81
|
+
instanceId: row.instanceId,
|
|
82
|
+
userId: row.userId,
|
|
83
|
+
createdAt: row.createdAt.toISOString(),
|
|
84
|
+
}));
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
PluginInstaller,
|
|
3
|
+
PluginSource,
|
|
4
|
+
FetchedTarball,
|
|
5
|
+
InstalledArtifact,
|
|
6
|
+
} from "@checkstack/backend-api";
|
|
7
|
+
import { PluginInstallError } from "./plugin-install-error";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Stub for the future Checkstack catalog/marketplace.
|
|
11
|
+
*
|
|
12
|
+
* Surfaces in the UI as a "Coming Soon" tab; calling the install endpoint
|
|
13
|
+
* with a `catalog` source returns an explicit not-implemented error.
|
|
14
|
+
*/
|
|
15
|
+
export class CatalogPluginInstaller implements PluginInstaller {
|
|
16
|
+
async fetchTarball(_source: PluginSource): Promise<FetchedTarball> {
|
|
17
|
+
throw new PluginInstallError(
|
|
18
|
+
"NOT_IMPLEMENTED",
|
|
19
|
+
"The Checkstack plugin catalog isn't available yet. Install via npm, GitHub release, or tarball upload in the meantime.",
|
|
20
|
+
);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
async installFromArtifact(_input: {
|
|
24
|
+
tarball: Uint8Array;
|
|
25
|
+
pluginName: string;
|
|
26
|
+
allowInstallScripts?: boolean;
|
|
27
|
+
}): Promise<InstalledArtifact> {
|
|
28
|
+
throw new PluginInstallError(
|
|
29
|
+
"NOT_IMPLEMENTED",
|
|
30
|
+
"The Checkstack plugin catalog isn't available yet.",
|
|
31
|
+
);
|
|
32
|
+
}
|
|
33
|
+
}
|