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