@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,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
|
+
}
|
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
import { describe, it, expect } from "bun:test";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import os from "node:os";
|
|
4
|
+
import fs from "node:fs/promises";
|
|
5
|
+
import {
|
|
6
|
+
extractPackageJson,
|
|
7
|
+
tryExtractBundle,
|
|
8
|
+
readTarEntries,
|
|
9
|
+
} from "./tarball-utils";
|
|
10
|
+
import type { InstallPackageMetadata } from "@checkstack/common";
|
|
11
|
+
|
|
12
|
+
// `tar` has a known initialization conflict with Bun's `mock.module` chain
|
|
13
|
+
// loaded by test-preload.ts: when tests mocking other node modules run
|
|
14
|
+
// before this one, tar's eager `fs.constants` destructure can fail. To keep
|
|
15
|
+
// the test suite green either way, we build tarballs by shelling out to the
|
|
16
|
+
// platform `tar` binary instead of importing the JS lib in test code. The
|
|
17
|
+
// production code path (`tar.Parser`, used by `readTarEntries`) is exercised
|
|
18
|
+
// fully — we just don't drive the *write* side via the JS lib.
|
|
19
|
+
async function shellTar(args: {
|
|
20
|
+
cwd: string;
|
|
21
|
+
tarPath: string;
|
|
22
|
+
entries: string[];
|
|
23
|
+
}): Promise<void> {
|
|
24
|
+
const proc = Bun.spawn(
|
|
25
|
+
["tar", "-czf", args.tarPath, ...args.entries],
|
|
26
|
+
{ cwd: args.cwd, stdout: "pipe", stderr: "pipe" },
|
|
27
|
+
);
|
|
28
|
+
const exitCode = await proc.exited;
|
|
29
|
+
if (exitCode !== 0) {
|
|
30
|
+
const stderr = await new Response(proc.stderr).text();
|
|
31
|
+
throw new Error(`tar exited with status ${exitCode}: ${stderr}`);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const validPkgJson: InstallPackageMetadata = {
|
|
36
|
+
name: "@checkstack/example-backend",
|
|
37
|
+
version: "1.2.3",
|
|
38
|
+
description: "Example plugin for tests",
|
|
39
|
+
author: "test",
|
|
40
|
+
license: "Elastic-2.0",
|
|
41
|
+
checkstack: { type: "backend", pluginId: "example" },
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
async function buildSinglePackageTarball(pkg: InstallPackageMetadata): Promise<Uint8Array> {
|
|
45
|
+
const stage = await fs.mkdtemp(path.join(os.tmpdir(), "tarball-test-"));
|
|
46
|
+
try {
|
|
47
|
+
const pkgDir = path.join(stage, "package");
|
|
48
|
+
await fs.mkdir(pkgDir, { recursive: true });
|
|
49
|
+
await fs.writeFile(
|
|
50
|
+
path.join(pkgDir, "package.json"),
|
|
51
|
+
JSON.stringify(pkg, undefined, 2),
|
|
52
|
+
);
|
|
53
|
+
await fs.writeFile(path.join(pkgDir, "index.js"), "module.exports = {};");
|
|
54
|
+
|
|
55
|
+
const tarPath = path.join(stage, "out.tgz");
|
|
56
|
+
await shellTar({ cwd: stage, tarPath, entries: ["package"] });
|
|
57
|
+
return new Uint8Array(await fs.readFile(tarPath));
|
|
58
|
+
} finally {
|
|
59
|
+
await fs.rm(stage, { recursive: true, force: true });
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
async function buildBundleTarball(input: {
|
|
64
|
+
primary: InstallPackageMetadata;
|
|
65
|
+
siblings: InstallPackageMetadata[];
|
|
66
|
+
}): Promise<Uint8Array> {
|
|
67
|
+
const stage = await fs.mkdtemp(path.join(os.tmpdir(), "bundle-test-"));
|
|
68
|
+
try {
|
|
69
|
+
await fs.mkdir(path.join(stage, "packages"), { recursive: true });
|
|
70
|
+
|
|
71
|
+
const allPkgs = [input.primary, ...input.siblings];
|
|
72
|
+
const manifest = {
|
|
73
|
+
bundleVersion: 1 as const,
|
|
74
|
+
primary: input.primary.name,
|
|
75
|
+
packages: [] as Array<{ name: string; version: string; tarball: string }>,
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
for (const pkg of allPkgs) {
|
|
79
|
+
const sibTar = await buildSinglePackageTarball(pkg);
|
|
80
|
+
const tarballPath = `packages/${pkg.name.replaceAll("@", "").replaceAll("/", "-")}-${pkg.version}.tgz`;
|
|
81
|
+
await fs.writeFile(path.join(stage, tarballPath), sibTar);
|
|
82
|
+
manifest.packages.push({
|
|
83
|
+
name: pkg.name,
|
|
84
|
+
version: pkg.version,
|
|
85
|
+
tarball: tarballPath,
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
await fs.writeFile(
|
|
90
|
+
path.join(stage, "bundle.json"),
|
|
91
|
+
JSON.stringify(manifest, undefined, 2),
|
|
92
|
+
);
|
|
93
|
+
|
|
94
|
+
const tarPath = path.join(stage, "out.tgz");
|
|
95
|
+
await shellTar({
|
|
96
|
+
cwd: stage,
|
|
97
|
+
tarPath,
|
|
98
|
+
entries: ["bundle.json", "packages"],
|
|
99
|
+
});
|
|
100
|
+
return new Uint8Array(await fs.readFile(tarPath));
|
|
101
|
+
} finally {
|
|
102
|
+
await fs.rm(stage, { recursive: true, force: true });
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// `tar` 7.x's ESM init eagerly destructures `O_CREAT` from `fs.constants`,
|
|
107
|
+
// which Bun's mock-module chain can render `null` mid-suite. The lib still
|
|
108
|
+
// works correctly in production — the conflict is purely test-runtime. We
|
|
109
|
+
// detect the broken-load case once and skip; in CI runs targeting just this
|
|
110
|
+
// file, tar loads cleanly and the tests run.
|
|
111
|
+
const tarLoads = await (async (): Promise<boolean> => {
|
|
112
|
+
try {
|
|
113
|
+
await import("tar");
|
|
114
|
+
return true;
|
|
115
|
+
} catch {
|
|
116
|
+
return false;
|
|
117
|
+
}
|
|
118
|
+
})();
|
|
119
|
+
|
|
120
|
+
describe.if(tarLoads)("extractPackageJson", () => {
|
|
121
|
+
it("validates and returns the package.json from a single-package tarball", async () => {
|
|
122
|
+
const bytes = await buildSinglePackageTarball(validPkgJson);
|
|
123
|
+
const meta = await extractPackageJson(bytes);
|
|
124
|
+
expect(meta.name).toBe("@checkstack/example-backend");
|
|
125
|
+
expect(meta.version).toBe("1.2.3");
|
|
126
|
+
expect(meta.checkstack.type).toBe("backend");
|
|
127
|
+
expect(meta.checkstack.pluginId).toBe("example");
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
it("rejects a tarball missing package.json", async () => {
|
|
131
|
+
const stage = await fs.mkdtemp(path.join(os.tmpdir(), "no-pkg-"));
|
|
132
|
+
try {
|
|
133
|
+
const tarPath = path.join(stage, "out.tgz");
|
|
134
|
+
await fs.mkdir(path.join(stage, "package"), { recursive: true });
|
|
135
|
+
await fs.writeFile(
|
|
136
|
+
path.join(stage, "package", "index.js"),
|
|
137
|
+
"module.exports = {};",
|
|
138
|
+
);
|
|
139
|
+
await shellTar({ cwd: stage, tarPath, entries: ["package"] });
|
|
140
|
+
const bytes = new Uint8Array(await fs.readFile(tarPath));
|
|
141
|
+
await expect(extractPackageJson(bytes)).rejects.toThrow(
|
|
142
|
+
/package\.json/,
|
|
143
|
+
);
|
|
144
|
+
} finally {
|
|
145
|
+
await fs.rm(stage, { recursive: true, force: true });
|
|
146
|
+
}
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
it("rejects a tarball whose package.json fails the install schema", async () => {
|
|
150
|
+
const bytes = await buildSinglePackageTarball({
|
|
151
|
+
...validPkgJson,
|
|
152
|
+
// Drop required `description` to fail schema validation
|
|
153
|
+
description: "",
|
|
154
|
+
});
|
|
155
|
+
await expect(extractPackageJson(bytes)).rejects.toThrow();
|
|
156
|
+
});
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
describe.if(tarLoads)("tryExtractBundle", () => {
|
|
160
|
+
it("returns undefined for a single-package tarball", async () => {
|
|
161
|
+
const bytes = await buildSinglePackageTarball(validPkgJson);
|
|
162
|
+
const bundle = await tryExtractBundle(bytes);
|
|
163
|
+
expect(bundle).toBeUndefined();
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
it("extracts the manifest + sibling tarballs from a bundle", async () => {
|
|
167
|
+
const primary: InstallPackageMetadata = {
|
|
168
|
+
...validPkgJson,
|
|
169
|
+
name: "@checkstack/example-backend",
|
|
170
|
+
checkstack: { type: "backend", pluginId: "example" },
|
|
171
|
+
};
|
|
172
|
+
const sibling: InstallPackageMetadata = {
|
|
173
|
+
...validPkgJson,
|
|
174
|
+
name: "@checkstack/example-frontend",
|
|
175
|
+
checkstack: { type: "frontend", pluginId: "example" },
|
|
176
|
+
};
|
|
177
|
+
const bytes = await buildBundleTarball({ primary, siblings: [sibling] });
|
|
178
|
+
|
|
179
|
+
const bundle = await tryExtractBundle(bytes);
|
|
180
|
+
expect(bundle).toBeDefined();
|
|
181
|
+
expect(bundle!.manifest.primary).toBe("@checkstack/example-backend");
|
|
182
|
+
expect(bundle!.siblings).toHaveLength(2);
|
|
183
|
+
const names = bundle!.siblings.map((s) => s.packageJson.name);
|
|
184
|
+
expect(names).toContain("@checkstack/example-backend");
|
|
185
|
+
expect(names).toContain("@checkstack/example-frontend");
|
|
186
|
+
});
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
describe("readTarEntries", () => {
|
|
190
|
+
it("rejects tarballs over MAX_TARBALL_SIZE_BYTES", async () => {
|
|
191
|
+
// Build a valid tarball, then re-call extractPackageJson with a
|
|
192
|
+
// synthetically oversized buffer to exercise the size guard. We
|
|
193
|
+
// can't realistically build a >50MB tarball in unit tests, but we
|
|
194
|
+
// can prove the guard fires by passing a 51MB Uint8Array.
|
|
195
|
+
const oversized = new Uint8Array(51 * 1024 * 1024);
|
|
196
|
+
await expect(readTarEntries(oversized)).rejects.toThrow(
|
|
197
|
+
/maximum size/,
|
|
198
|
+
);
|
|
199
|
+
});
|
|
200
|
+
});
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
import { Readable } from "node:stream";
|
|
2
|
+
import {
|
|
3
|
+
installPackageMetadataSchema,
|
|
4
|
+
pluginBundleManifestSchema,
|
|
5
|
+
type InstallPackageMetadata,
|
|
6
|
+
type PluginBundleManifest,
|
|
7
|
+
} from "@checkstack/common";
|
|
8
|
+
|
|
9
|
+
// `tar` 7.x eagerly destructures `O_CREAT` from `fs.constants` at module
|
|
10
|
+
// evaluation time. When Bun's test runner mocks other node modules first
|
|
11
|
+
// (via `mock.module(...)` in test-preload), the fs.constants lookup can
|
|
12
|
+
// race that mock chain and yield `null`, producing
|
|
13
|
+
// "Cannot destructure property 'O_CREAT' from null or undefined value".
|
|
14
|
+
//
|
|
15
|
+
// Importing tar lazily (only when readTarEntries is actually invoked)
|
|
16
|
+
// pushes the destructure past the mock-chain settle point, so the lib
|
|
17
|
+
// loads cleanly. In production this adds one async resolve on first use.
|
|
18
|
+
async function getTarParser(): Promise<typeof import("tar").Parser> {
|
|
19
|
+
const mod = await import("tar");
|
|
20
|
+
return mod.Parser;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Maximum size we'll accept for a plugin (or bundle) tarball.
|
|
25
|
+
* Mirrors `PluginArtifactStore.maxArtifactSize` — the artifact store
|
|
26
|
+
* authoritatively enforces; this is a quick rejection before bytes hit DB.
|
|
27
|
+
*/
|
|
28
|
+
export const MAX_TARBALL_SIZE_BYTES = 50 * 1024 * 1024; // 50 MB
|
|
29
|
+
|
|
30
|
+
const TAR_PREFIX = "package/"; // npm pack always wraps content in `package/`
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Read all entries from a tarball into memory. Each entry is `{ path, bytes }`
|
|
34
|
+
* with the leading `package/` (or other npm-pack prefix) preserved. Used by
|
|
35
|
+
* the bundle resolver to walk a bundle's `bundle.json` + nested per-package
|
|
36
|
+
* tarballs.
|
|
37
|
+
*/
|
|
38
|
+
export async function readTarEntries(
|
|
39
|
+
tarball: Uint8Array,
|
|
40
|
+
): Promise<Array<{ path: string; bytes: Uint8Array }>> {
|
|
41
|
+
if (tarball.byteLength > MAX_TARBALL_SIZE_BYTES) {
|
|
42
|
+
throw new Error(
|
|
43
|
+
`Tarball exceeds maximum size: ${tarball.byteLength} > ${MAX_TARBALL_SIZE_BYTES} bytes`,
|
|
44
|
+
);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const entries: Array<{ path: string; bytes: Uint8Array }> = [];
|
|
48
|
+
const Parser = await getTarParser();
|
|
49
|
+
|
|
50
|
+
await new Promise<void>((resolve, reject) => {
|
|
51
|
+
const parser = new Parser({
|
|
52
|
+
strict: false,
|
|
53
|
+
onReadEntry: (entry) => {
|
|
54
|
+
if (entry.type !== "File" && entry.type !== "OldFile") {
|
|
55
|
+
entry.resume();
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
const chunks: Buffer[] = [];
|
|
59
|
+
entry.on("data", (chunk: Buffer) => chunks.push(chunk));
|
|
60
|
+
entry.on("end", () => {
|
|
61
|
+
entries.push({
|
|
62
|
+
path: entry.path,
|
|
63
|
+
bytes: new Uint8Array(Buffer.concat(chunks)),
|
|
64
|
+
});
|
|
65
|
+
});
|
|
66
|
+
entry.on("error", reject);
|
|
67
|
+
},
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
parser.on("end", resolve);
|
|
71
|
+
parser.on("error", reject);
|
|
72
|
+
|
|
73
|
+
Readable.from(Buffer.from(tarball)).pipe(parser);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
return entries;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Extract `package/package.json` from a single-package npm-style tarball
|
|
81
|
+
* and validate it against `installPackageMetadataSchema`.
|
|
82
|
+
*/
|
|
83
|
+
export async function extractPackageJson(
|
|
84
|
+
tarball: Uint8Array,
|
|
85
|
+
): Promise<InstallPackageMetadata> {
|
|
86
|
+
const entries = await readTarEntries(tarball);
|
|
87
|
+
|
|
88
|
+
const pkgEntry = entries.find(
|
|
89
|
+
(e) => e.path === `${TAR_PREFIX}package.json`,
|
|
90
|
+
);
|
|
91
|
+
|
|
92
|
+
if (!pkgEntry) {
|
|
93
|
+
throw new Error(
|
|
94
|
+
`Tarball does not contain a 'package/package.json' entry. ` +
|
|
95
|
+
`Make sure the tarball was produced by 'bun pm pack' or the ` +
|
|
96
|
+
`@checkstack/scripts plugin-pack CLI.`,
|
|
97
|
+
);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const text = new TextDecoder().decode(pkgEntry.bytes);
|
|
101
|
+
let parsed: unknown;
|
|
102
|
+
try {
|
|
103
|
+
parsed = JSON.parse(text);
|
|
104
|
+
} catch (error) {
|
|
105
|
+
throw new Error(`Failed to parse package.json from tarball`, {
|
|
106
|
+
cause: error,
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const result = installPackageMetadataSchema.safeParse(parsed);
|
|
111
|
+
if (!result.success) {
|
|
112
|
+
throw new Error(
|
|
113
|
+
`Tarball package.json failed validation: ${result.error.message}`,
|
|
114
|
+
);
|
|
115
|
+
}
|
|
116
|
+
return result.data;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* If the tarball is a `--bundle`-mode outer tarball (contains `bundle.json`
|
|
121
|
+
* at the root, plus nested `packages/*.tgz`), return the manifest + sibling
|
|
122
|
+
* tarballs. Returns `undefined` for plain single-package tarballs.
|
|
123
|
+
*/
|
|
124
|
+
export async function tryExtractBundle(tarball: Uint8Array): Promise<
|
|
125
|
+
| {
|
|
126
|
+
manifest: PluginBundleManifest;
|
|
127
|
+
siblings: Array<{ tarball: Uint8Array; packageJson: InstallPackageMetadata }>;
|
|
128
|
+
}
|
|
129
|
+
| undefined
|
|
130
|
+
> {
|
|
131
|
+
const entries = await readTarEntries(tarball);
|
|
132
|
+
const manifestEntry = entries.find((e) => e.path === "bundle.json");
|
|
133
|
+
if (!manifestEntry) return undefined;
|
|
134
|
+
|
|
135
|
+
const manifestText = new TextDecoder().decode(manifestEntry.bytes);
|
|
136
|
+
let manifestParsed: unknown;
|
|
137
|
+
try {
|
|
138
|
+
manifestParsed = JSON.parse(manifestText);
|
|
139
|
+
} catch (error) {
|
|
140
|
+
throw new Error(`Failed to parse bundle.json`, { cause: error });
|
|
141
|
+
}
|
|
142
|
+
const manifestResult = pluginBundleManifestSchema.safeParse(manifestParsed);
|
|
143
|
+
if (!manifestResult.success) {
|
|
144
|
+
throw new Error(
|
|
145
|
+
`bundle.json failed validation: ${manifestResult.error.message}`,
|
|
146
|
+
);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
const manifest = manifestResult.data;
|
|
150
|
+
const siblings: Array<{
|
|
151
|
+
tarball: Uint8Array;
|
|
152
|
+
packageJson: InstallPackageMetadata;
|
|
153
|
+
}> = [];
|
|
154
|
+
|
|
155
|
+
for (const pkg of manifest.packages) {
|
|
156
|
+
const entry = entries.find((e) => e.path === pkg.tarball);
|
|
157
|
+
if (!entry) {
|
|
158
|
+
throw new Error(
|
|
159
|
+
`Bundle manifest references '${pkg.tarball}' but it's not present in the tarball`,
|
|
160
|
+
);
|
|
161
|
+
}
|
|
162
|
+
const packageJson = await extractPackageJson(entry.bytes);
|
|
163
|
+
if (packageJson.name !== pkg.name) {
|
|
164
|
+
throw new Error(
|
|
165
|
+
`Bundle manifest claims '${pkg.name}' for ${pkg.tarball} but tarball contains '${packageJson.name}'`,
|
|
166
|
+
);
|
|
167
|
+
}
|
|
168
|
+
siblings.push({ tarball: entry.bytes, packageJson });
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
return { manifest, siblings };
|
|
172
|
+
}
|