@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.
- package/CHANGELOG.md +203 -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 +12 -7
- 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/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/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/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,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
|
+
}
|