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