@checkstack/backend 0.8.2 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (36) hide show
  1. package/CHANGELOG.md +203 -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 +12 -7
  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/compatibility-checker.test.ts +146 -0
  13. package/src/services/compatibility-checker.ts +137 -0
  14. package/src/services/dev-auth.test.ts +87 -0
  15. package/src/services/dev-auth.ts +56 -0
  16. package/src/services/plugin-artifact-store.ts +131 -0
  17. package/src/services/plugin-bundle-resolver.ts +76 -0
  18. package/src/services/plugin-event-recorder.ts +87 -0
  19. package/src/services/plugin-installers/catalog-installer.ts +33 -0
  20. package/src/services/plugin-installers/github-installer.ts +207 -0
  21. package/src/services/plugin-installers/install-from-tarball.ts +69 -0
  22. package/src/services/plugin-installers/installer-registry.ts +51 -0
  23. package/src/services/plugin-installers/npm-installer.ts +156 -0
  24. package/src/services/plugin-installers/plugin-install-error.ts +37 -0
  25. package/src/services/plugin-installers/tarball-installer.ts +80 -0
  26. package/src/services/plugin-installers/tarball-utils.test.ts +200 -0
  27. package/src/services/plugin-installers/tarball-utils.ts +172 -0
  28. package/src/services/plugin-manager-orchestrator.ts +522 -0
  29. package/src/services/plugin-manager-router.ts +219 -0
  30. package/src/utils/plugin-discovery.test.ts +6 -0
  31. package/src/utils/plugin-discovery.ts +6 -1
  32. package/tsconfig.json +3 -0
  33. package/src/plugin-lifecycle.test.ts +0 -276
  34. package/src/plugin-manager/plugin-admin-router.ts +0 -89
  35. package/src/services/plugin-installer.test.ts +0 -90
  36. package/src/services/plugin-installer.ts +0 -70
@@ -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
+ }
@@ -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
+ }