@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,207 @@
1
+ import type {
2
+ PluginInstaller,
3
+ PluginSource,
4
+ FetchedTarball,
5
+ InstalledArtifact,
6
+ } from "@checkstack/backend-api";
7
+ import {
8
+ extractPackageJson,
9
+ tryExtractBundle,
10
+ MAX_TARBALL_SIZE_BYTES,
11
+ } from "./tarball-utils";
12
+ import { installFromArtifact } from "./install-from-tarball";
13
+ import { rootLogger } from "../../logger";
14
+ import { extractErrorMessage } from "@checkstack/common";
15
+ import { PluginInstallError } from "./plugin-install-error";
16
+
17
+ interface GithubReleaseAsset {
18
+ name: string;
19
+ browser_download_url: string;
20
+ size: number;
21
+ }
22
+
23
+ interface GithubRelease {
24
+ tag_name: string;
25
+ assets: GithubReleaseAsset[];
26
+ }
27
+
28
+ /**
29
+ * Install a plugin from a GitHub release.
30
+ *
31
+ * Convention (enforced by the `@checkstack/scripts plugin-pack` CLI):
32
+ * - The release for tag `vX.Y.Z` includes exactly one `.tgz` asset.
33
+ * - For multi-package plugins (`checkstack.bundle` set on the primary),
34
+ * that asset is a `--bundle`-mode outer tarball with `bundle.json`.
35
+ *
36
+ * Building never happens at install time — the CLI runs at release time.
37
+ */
38
+ export class GithubPluginInstaller implements PluginInstaller {
39
+ constructor(private readonly runtimeDir: string) {}
40
+
41
+ async fetchTarball(source: PluginSource): Promise<FetchedTarball> {
42
+ if (source.type !== "github") {
43
+ throw new Error(
44
+ `GithubPluginInstaller cannot handle source type '${source.type}'`,
45
+ );
46
+ }
47
+
48
+ // Determine API base. Defaults to public github.com; for GitHub
49
+ // Enterprise the source carries an explicit `apiBaseUrl` like
50
+ // `https://github.example.com/api/v3`. We strip a trailing slash so
51
+ // either form (`/api/v3` or `/api/v3/`) is accepted.
52
+ const apiBase = (
53
+ source.apiBaseUrl ?? "https://api.github.com"
54
+ ).replace(/\/$/, "");
55
+ const releaseUrl = `${apiBase}/repos/${source.owner}/${source.repo}/releases/tags/${source.tag}`;
56
+ rootLogger.info(`📦 Resolving GitHub release: ${releaseUrl}`);
57
+
58
+ // Token resolution: configurable env var name (defaults to
59
+ // `GITHUB_TOKEN`). Different deployments may need to talk to several
60
+ // GitHub instances (public + enterprise), so we let the source
61
+ // declare which env var holds the right PAT.
62
+ const tokenEnvVar = source.tokenEnvVar ?? "GITHUB_TOKEN";
63
+ const token = process.env[tokenEnvVar];
64
+
65
+ const headers: Record<string, string> = {
66
+ Accept: "application/vnd.github+json",
67
+ "X-GitHub-Api-Version": "2022-11-28",
68
+ };
69
+ if (token) {
70
+ headers.Authorization = `Bearer ${token}`;
71
+ }
72
+
73
+ const repoLabel = `${source.owner}/${source.repo}@${source.tag}`;
74
+
75
+ let meta: Response;
76
+ try {
77
+ meta = await fetch(releaseUrl, { headers });
78
+ } catch (error) {
79
+ throw new PluginInstallError(
80
+ "BAD_GATEWAY",
81
+ `Could not reach GitHub at ${apiBase}: ${extractErrorMessage(error)}`,
82
+ );
83
+ }
84
+ if (!meta.ok) {
85
+ if (meta.status === 404) {
86
+ throw new PluginInstallError(
87
+ "NOT_FOUND",
88
+ `GitHub release '${repoLabel}' not found. Verify the owner, repo, and tag, ` +
89
+ `and (for private repos) that the platform's PAT in '${tokenEnvVar}' has access.`,
90
+ );
91
+ }
92
+ if (meta.status === 401) {
93
+ throw new PluginInstallError(
94
+ "UNAUTHORIZED",
95
+ token
96
+ ? `GitHub rejected the token in '${tokenEnvVar}' (HTTP 401). The token may be expired.`
97
+ : `GitHub release '${repoLabel}' requires authentication. Set a PAT in '${tokenEnvVar}'.`,
98
+ );
99
+ }
100
+ if (meta.status === 403) {
101
+ throw new PluginInstallError(
102
+ "FORBIDDEN",
103
+ `GitHub returned 403 for '${repoLabel}'. Either rate-limited or the token in '${tokenEnvVar}' lacks access.`,
104
+ );
105
+ }
106
+ throw new PluginInstallError(
107
+ "BAD_GATEWAY",
108
+ `GitHub release lookup returned HTTP ${meta.status} for ${repoLabel}.`,
109
+ );
110
+ }
111
+ const release = (await meta.json()) as GithubRelease;
112
+
113
+ const tgzAssets = release.assets.filter((a) => a.name.endsWith(".tgz"));
114
+ let asset: GithubReleaseAsset | undefined;
115
+ if (source.assetName) {
116
+ asset = release.assets.find((a) => a.name === source.assetName);
117
+ if (!asset) {
118
+ throw new PluginInstallError(
119
+ "NOT_FOUND",
120
+ `Release '${repoLabel}' has no asset named '${source.assetName}'. ` +
121
+ `Available .tgz assets: ${
122
+ tgzAssets.map((a) => a.name).join(", ") || "(none)"
123
+ }.`,
124
+ );
125
+ }
126
+ } else if (tgzAssets.length === 1) {
127
+ asset = tgzAssets[0];
128
+ } else if (tgzAssets.length === 0) {
129
+ throw new PluginInstallError(
130
+ "NOT_FOUND",
131
+ `Release '${repoLabel}' has no .tgz asset. ` +
132
+ `Run 'bunx @checkstack/scripts plugin-pack' in the plugin repo and attach the produced tarball to the release.`,
133
+ );
134
+ } else {
135
+ throw new PluginInstallError(
136
+ "BAD_REQUEST",
137
+ `Release '${repoLabel}' has ${tgzAssets.length} .tgz assets (${tgzAssets
138
+ .map((a) => a.name)
139
+ .join(", ")}) — set 'assetName' on the source to disambiguate.`,
140
+ );
141
+ }
142
+
143
+ if (asset.size > MAX_TARBALL_SIZE_BYTES) {
144
+ throw new PluginInstallError(
145
+ "PAYLOAD_TOO_LARGE",
146
+ `Release asset '${asset.name}' is ${(asset.size / 1024 / 1024).toFixed(
147
+ 1,
148
+ )}MB, which exceeds the ${(MAX_TARBALL_SIZE_BYTES / 1024 / 1024).toFixed(
149
+ 0,
150
+ )}MB platform limit.`,
151
+ );
152
+ }
153
+
154
+ rootLogger.info(`📦 Downloading: ${asset.browser_download_url}`);
155
+ let tarResp: Response;
156
+ try {
157
+ tarResp = await fetch(asset.browser_download_url, {
158
+ headers: token ? { Authorization: `Bearer ${token}` } : undefined,
159
+ });
160
+ } catch (error) {
161
+ throw new PluginInstallError(
162
+ "BAD_GATEWAY",
163
+ `Could not download release asset '${asset.name}': ${extractErrorMessage(error)}`,
164
+ );
165
+ }
166
+ if (!tarResp.ok) {
167
+ throw new PluginInstallError(
168
+ "BAD_GATEWAY",
169
+ `Failed to download release asset '${asset.name}': HTTP ${tarResp.status} ${tarResp.statusText}.`,
170
+ );
171
+ }
172
+ const buf = new Uint8Array(await tarResp.arrayBuffer());
173
+
174
+ const bundle = await tryExtractBundle(buf);
175
+ if (bundle) {
176
+ const primary = bundle.siblings.find(
177
+ (s) => s.packageJson.name === bundle.manifest.primary,
178
+ );
179
+ if (!primary) {
180
+ throw new PluginInstallError(
181
+ "VALIDATION_FAILED",
182
+ `Bundle manifest in '${asset.name}' names primary '${bundle.manifest.primary}' but no matching sibling tarball was found.`,
183
+ );
184
+ }
185
+ return { tarball: buf, packageJson: primary.packageJson, bundle };
186
+ }
187
+
188
+ let packageJson;
189
+ try {
190
+ packageJson = await extractPackageJson(buf);
191
+ } catch (error) {
192
+ throw new PluginInstallError(
193
+ "VALIDATION_FAILED",
194
+ `Release asset '${asset.name}' is not a valid Checkstack plugin tarball: ${extractErrorMessage(error)}`,
195
+ );
196
+ }
197
+ return { tarball: buf, packageJson };
198
+ }
199
+
200
+ async installFromArtifact(input: {
201
+ tarball: Uint8Array;
202
+ pluginName: string;
203
+ allowInstallScripts?: boolean;
204
+ }): Promise<InstalledArtifact> {
205
+ return installFromArtifact({ ...input, runtimeDir: this.runtimeDir });
206
+ }
207
+ }
@@ -0,0 +1,69 @@
1
+ import path from "node:path";
2
+ import fs from "node:fs/promises";
3
+ import os from "node:os";
4
+ import { exec } from "node:child_process";
5
+ import { promisify } from "node:util";
6
+ import type { InstalledArtifact } from "@checkstack/backend-api";
7
+ import { rootLogger } from "../../logger";
8
+ import { extractPackageJson } from "./tarball-utils";
9
+
10
+ const execAsync = promisify(exec);
11
+
12
+ /**
13
+ * Shared post-fetch installer used by every per-source installer.
14
+ *
15
+ * Writes the tarball bytes to a tmpfile and runs `bun install <file>` into
16
+ * the runtime dir. Lifecycle scripts are blocked by default
17
+ * (`--ignore-scripts`); plugins can opt in via
18
+ * `package.json#checkstack.allowInstallScripts: true` — surfaced in the
19
+ * install warning UI.
20
+ */
21
+ export async function installFromArtifact({
22
+ tarball,
23
+ pluginName,
24
+ allowInstallScripts,
25
+ runtimeDir,
26
+ }: {
27
+ tarball: Uint8Array;
28
+ pluginName: string;
29
+ allowInstallScripts?: boolean;
30
+ runtimeDir: string;
31
+ }): Promise<InstalledArtifact> {
32
+ await fs.mkdir(runtimeDir, { recursive: true });
33
+
34
+ const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "checkstack-plugin-"));
35
+ const tarPath = path.join(tmpDir, "plugin.tgz");
36
+ try {
37
+ await fs.writeFile(tarPath, tarball);
38
+
39
+ const ignoreScripts = allowInstallScripts ? "" : "--ignore-scripts";
40
+ const cmd = `bun install "${tarPath}" --cwd "${runtimeDir}" --no-save ${ignoreScripts}`.trim();
41
+
42
+ rootLogger.info(`📦 Running: ${cmd}`);
43
+ const { stderr } = await execAsync(cmd);
44
+ if (stderr) rootLogger.debug(stderr);
45
+
46
+ // After install, validate the installed package.json matches what we
47
+ // expected. The pkg dir under node_modules can be a scoped path
48
+ // (`@scope/name`), so resolve via the tarball's package.json name.
49
+ const expected = await extractPackageJson(tarball);
50
+ if (expected.name !== pluginName) {
51
+ throw new Error(
52
+ `Tarball name mismatch: expected '${pluginName}', got '${expected.name}'`,
53
+ );
54
+ }
55
+
56
+ const pkgDir = path.join(runtimeDir, "node_modules", expected.name);
57
+ const installedPkgJson = JSON.parse(
58
+ await fs.readFile(path.join(pkgDir, "package.json"), "utf8"),
59
+ );
60
+
61
+ return {
62
+ name: installedPkgJson.name,
63
+ path: pkgDir,
64
+ version: installedPkgJson.version,
65
+ };
66
+ } finally {
67
+ await fs.rm(tmpDir, { recursive: true, force: true });
68
+ }
69
+ }
@@ -0,0 +1,51 @@
1
+ import type {
2
+ PluginInstaller,
3
+ PluginInstallerRegistry,
4
+ PluginSource,
5
+ FetchedTarball,
6
+ PluginArtifactStore,
7
+ } from "@checkstack/backend-api";
8
+ import { NpmPluginInstaller } from "./npm-installer";
9
+ import { TarballPluginInstaller } from "./tarball-installer";
10
+ import { GithubPluginInstaller } from "./github-installer";
11
+ import { CatalogPluginInstaller } from "./catalog-installer";
12
+
13
+ /**
14
+ * Composite registry that routes a `PluginSource` to its installer.
15
+ *
16
+ * One instance is registered against `coreServices.pluginInstallerRegistry`
17
+ * at boot. All plugin install/uninstall flows go through this — the
18
+ * legacy single-method `PluginInstaller` is gone.
19
+ */
20
+ export class DefaultPluginInstallerRegistry
21
+ implements PluginInstallerRegistry
22
+ {
23
+ private readonly installers: Record<PluginSource["type"], PluginInstaller>;
24
+
25
+ constructor({
26
+ runtimeDir,
27
+ artifactStore,
28
+ }: {
29
+ runtimeDir: string;
30
+ artifactStore: PluginArtifactStore;
31
+ }) {
32
+ this.installers = {
33
+ npm: new NpmPluginInstaller(runtimeDir),
34
+ tarball: new TarballPluginInstaller(runtimeDir, artifactStore),
35
+ github: new GithubPluginInstaller(runtimeDir),
36
+ catalog: new CatalogPluginInstaller(),
37
+ };
38
+ }
39
+
40
+ forSource(type: PluginSource["type"]): PluginInstaller {
41
+ const installer = this.installers[type];
42
+ if (!installer) {
43
+ throw new Error(`No plugin installer registered for source type '${type}'`);
44
+ }
45
+ return installer;
46
+ }
47
+
48
+ fetchTarball(source: PluginSource): Promise<FetchedTarball> {
49
+ return this.forSource(source.type).fetchTarball(source);
50
+ }
51
+ }
@@ -0,0 +1,156 @@
1
+ import type {
2
+ PluginInstaller,
3
+ PluginSource,
4
+ FetchedTarball,
5
+ InstalledArtifact,
6
+ } from "@checkstack/backend-api";
7
+ import {
8
+ extractPackageJson,
9
+ tryExtractBundle,
10
+ MAX_TARBALL_SIZE_BYTES,
11
+ } from "./tarball-utils";
12
+ import { installFromArtifact } from "./install-from-tarball";
13
+ import { rootLogger } from "../../logger";
14
+ import { extractErrorMessage } from "@checkstack/common";
15
+ import { PluginInstallError } from "./plugin-install-error";
16
+
17
+ const DEFAULT_REGISTRY = "https://registry.npmjs.org";
18
+
19
+ /**
20
+ * Fetch a plugin tarball directly from an npm registry.
21
+ *
22
+ * No on-disk install happens here — we hit the registry's metadata API to
23
+ * resolve `dist.tarball`, then download bytes to memory. The originator
24
+ * uploads those bytes to `plugin_artifacts`; receiving instances install
25
+ * from there.
26
+ */
27
+ export class NpmPluginInstaller implements PluginInstaller {
28
+ constructor(private readonly runtimeDir: string) {}
29
+
30
+ async fetchTarball(source: PluginSource): Promise<FetchedTarball> {
31
+ if (source.type !== "npm") {
32
+ throw new Error(
33
+ `NpmPluginInstaller cannot handle source type '${source.type}'`,
34
+ );
35
+ }
36
+
37
+ const registry = (source.registry || DEFAULT_REGISTRY).replace(/\/$/, "");
38
+ const versionPath = source.version ? `/${source.version}` : "/latest";
39
+ const metaUrl = `${registry}/${encodeNpmName(source.packageName)}${versionPath}`;
40
+ const versionLabel = source.version ?? "latest";
41
+
42
+ rootLogger.info(`📦 Resolving npm metadata: ${metaUrl}`);
43
+ let metaResp: Response;
44
+ try {
45
+ metaResp = await fetch(metaUrl);
46
+ } catch (error) {
47
+ throw new PluginInstallError(
48
+ "BAD_GATEWAY",
49
+ `Could not reach npm registry at ${registry}: ${extractErrorMessage(error)}`,
50
+ );
51
+ }
52
+ if (!metaResp.ok) {
53
+ if (metaResp.status === 404) {
54
+ throw new PluginInstallError(
55
+ "NOT_FOUND",
56
+ `Package '${source.packageName}@${versionLabel}' not found on the npm registry${
57
+ source.registry ? ` at ${source.registry}` : ""
58
+ }.`,
59
+ );
60
+ }
61
+ if (metaResp.status === 401 || metaResp.status === 403) {
62
+ throw new PluginInstallError(
63
+ metaResp.status === 401 ? "UNAUTHORIZED" : "FORBIDDEN",
64
+ `Access to '${source.packageName}' on ${registry} was rejected (HTTP ${metaResp.status}). ` +
65
+ `If this is a private registry, configure auth on the platform.`,
66
+ );
67
+ }
68
+ throw new PluginInstallError(
69
+ "BAD_GATEWAY",
70
+ `npm registry returned HTTP ${metaResp.status} for ${source.packageName}@${versionLabel}.`,
71
+ );
72
+ }
73
+ const meta = (await metaResp.json()) as {
74
+ name: string;
75
+ version: string;
76
+ dist?: { tarball?: string };
77
+ };
78
+
79
+ const tarballUrl = meta.dist?.tarball;
80
+ if (!tarballUrl) {
81
+ throw new PluginInstallError(
82
+ "BAD_GATEWAY",
83
+ `npm metadata for '${source.packageName}@${versionLabel}' did not include a download URL (dist.tarball missing).`,
84
+ );
85
+ }
86
+
87
+ rootLogger.info(`📦 Downloading tarball: ${tarballUrl}`);
88
+ let tarResp: Response;
89
+ try {
90
+ tarResp = await fetch(tarballUrl);
91
+ } catch (error) {
92
+ throw new PluginInstallError(
93
+ "BAD_GATEWAY",
94
+ `Could not download tarball from ${tarballUrl}: ${extractErrorMessage(error)}`,
95
+ );
96
+ }
97
+ if (!tarResp.ok) {
98
+ throw new PluginInstallError(
99
+ "BAD_GATEWAY",
100
+ `Tarball download for '${source.packageName}@${versionLabel}' failed: HTTP ${tarResp.status} ${tarResp.statusText}.`,
101
+ );
102
+ }
103
+ const buf = new Uint8Array(await tarResp.arrayBuffer());
104
+ if (buf.byteLength > MAX_TARBALL_SIZE_BYTES) {
105
+ throw new PluginInstallError(
106
+ "PAYLOAD_TOO_LARGE",
107
+ `Tarball for '${source.packageName}' is ${(
108
+ buf.byteLength /
109
+ 1024 /
110
+ 1024
111
+ ).toFixed(1)}MB, which exceeds the ${(
112
+ MAX_TARBALL_SIZE_BYTES /
113
+ 1024 /
114
+ 1024
115
+ ).toFixed(0)}MB platform limit.`,
116
+ );
117
+ }
118
+
119
+ const bundle = await tryExtractBundle(buf);
120
+ if (bundle) {
121
+ // npm doesn't ship bundle tarballs (those go via GitHub release / direct
122
+ // upload). If we ever encounter one here it means an external author
123
+ // mis-published — reject explicitly rather than silently accept.
124
+ throw new PluginInstallError(
125
+ "BAD_REQUEST",
126
+ `Package '${source.packageName}' from npm contains a bundle manifest. ` +
127
+ `Bundle tarballs must be installed via the GitHub release or tarball-upload sources.`,
128
+ );
129
+ }
130
+
131
+ let packageJson;
132
+ try {
133
+ packageJson = await extractPackageJson(buf);
134
+ } catch (error) {
135
+ throw new PluginInstallError(
136
+ "VALIDATION_FAILED",
137
+ `Package '${source.packageName}@${versionLabel}' is not a valid Checkstack plugin: ${extractErrorMessage(error)}`,
138
+ );
139
+ }
140
+ return { tarball: buf, packageJson };
141
+ }
142
+
143
+ async installFromArtifact(input: {
144
+ tarball: Uint8Array;
145
+ pluginName: string;
146
+ allowInstallScripts?: boolean;
147
+ }): Promise<InstalledArtifact> {
148
+ return installFromArtifact({ ...input, runtimeDir: this.runtimeDir });
149
+ }
150
+ }
151
+
152
+ function encodeNpmName(name: string): string {
153
+ // npm scoped names use `@scope/name`; the registry expects the slash to
154
+ // remain unencoded but `@` is OK either way.
155
+ return name.startsWith("@") ? name : encodeURIComponent(name);
156
+ }
@@ -0,0 +1,37 @@
1
+ /**
2
+ * Typed error thrown by per-source installers, the bundle resolver, and the
3
+ * compatibility checker. The router catches these and maps `code` to the
4
+ * matching oRPC error code so the UI sees a meaningful HTTP status + message
5
+ * instead of a generic "Internal server error".
6
+ *
7
+ * Throw a plain `Error` for genuine unexpected failures — those become 500s.
8
+ * Throw a `PluginInstallError` for any condition the user can fix (bad
9
+ * package name, network 404, validation failure, missing GHE token, etc.).
10
+ */
11
+ export type PluginInstallErrorCode =
12
+ | "NOT_FOUND" // resource doesn't exist (npm 404, github tag not found, …)
13
+ | "BAD_REQUEST" // malformed input the user gave us
14
+ | "UNAUTHORIZED" // auth required (private repo, no token, …)
15
+ | "FORBIDDEN" // auth provided but rejected by the source
16
+ | "PAYLOAD_TOO_LARGE" // tarball over the size cap
17
+ | "BAD_GATEWAY" // upstream registry/github responded with a 5xx
18
+ | "VALIDATION_FAILED" // metadata schema rejected the package.json
19
+ | "COMPATIBILITY_FAILED" // dep ranges don't satisfy the platform
20
+ | "NOT_IMPLEMENTED"; // catalog source (coming soon)
21
+
22
+ export class PluginInstallError extends Error {
23
+ constructor(
24
+ public readonly code: PluginInstallErrorCode,
25
+ message: string,
26
+ public readonly details?: Record<string, unknown>,
27
+ ) {
28
+ super(message);
29
+ this.name = "PluginInstallError";
30
+ }
31
+ }
32
+
33
+ export function isPluginInstallError(
34
+ value: unknown,
35
+ ): value is PluginInstallError {
36
+ return value instanceof PluginInstallError;
37
+ }
@@ -0,0 +1,80 @@
1
+ import type {
2
+ PluginInstaller,
3
+ PluginSource,
4
+ FetchedTarball,
5
+ InstalledArtifact,
6
+ PluginArtifactStore,
7
+ } from "@checkstack/backend-api";
8
+ import { extractPackageJson, tryExtractBundle } from "./tarball-utils";
9
+ import { installFromArtifact } from "./install-from-tarball";
10
+ import { extractErrorMessage } from "@checkstack/common";
11
+ import { PluginInstallError } from "./plugin-install-error";
12
+
13
+ /**
14
+ * "Filesystem" / direct-upload installer.
15
+ *
16
+ * The tarball bytes are uploaded by the user via a multipart REST endpoint,
17
+ * temporarily persisted to `plugin_artifacts` (artifactId surfaces in the
18
+ * source), then this installer just resolves them by id.
19
+ */
20
+ export class TarballPluginInstaller implements PluginInstaller {
21
+ constructor(
22
+ private readonly runtimeDir: string,
23
+ private readonly artifactStore: PluginArtifactStore,
24
+ ) {}
25
+
26
+ async fetchTarball(source: PluginSource): Promise<FetchedTarball> {
27
+ if (source.type !== "tarball") {
28
+ throw new Error(
29
+ `TarballPluginInstaller cannot handle source type '${source.type}'`,
30
+ );
31
+ }
32
+
33
+ const stored = await this.artifactStore.fetchById(source.artifactId);
34
+ if (!stored) {
35
+ throw new PluginInstallError(
36
+ "NOT_FOUND",
37
+ `No uploaded tarball found with id '${source.artifactId}'. ` +
38
+ `It may have been pruned or the upload did not complete — try uploading again.`,
39
+ );
40
+ }
41
+
42
+ const bundle = await tryExtractBundle(stored.tarball);
43
+ if (bundle) {
44
+ // For bundles, the outer tarball's "package.json" is the primary's.
45
+ const primary = bundle.siblings.find(
46
+ (s) => s.packageJson.name === bundle.manifest.primary,
47
+ );
48
+ if (!primary) {
49
+ throw new PluginInstallError(
50
+ "VALIDATION_FAILED",
51
+ `Bundle manifest names primary '${bundle.manifest.primary}' but no matching sibling tarball was found in the uploaded archive.`,
52
+ );
53
+ }
54
+ return {
55
+ tarball: stored.tarball,
56
+ packageJson: primary.packageJson,
57
+ bundle,
58
+ };
59
+ }
60
+
61
+ let packageJson;
62
+ try {
63
+ packageJson = await extractPackageJson(stored.tarball);
64
+ } catch (error) {
65
+ throw new PluginInstallError(
66
+ "VALIDATION_FAILED",
67
+ `Uploaded tarball is not a valid Checkstack plugin: ${extractErrorMessage(error)}`,
68
+ );
69
+ }
70
+ return { tarball: stored.tarball, packageJson };
71
+ }
72
+
73
+ async installFromArtifact(input: {
74
+ tarball: Uint8Array;
75
+ pluginName: string;
76
+ allowInstallScripts?: boolean;
77
+ }): Promise<InstalledArtifact> {
78
+ return installFromArtifact({ ...input, runtimeDir: this.runtimeDir });
79
+ }
80
+ }