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