@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,172 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach } from "bun:test";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
import { CacheManagerImpl } from "./cache-manager";
|
|
4
|
+
import { CachePluginRegistryImpl } from "./cache-plugin-registry";
|
|
5
|
+
import type { CachePlugin, CacheProvider, CacheStats } from "@checkstack/cache-api";
|
|
6
|
+
import type { ConfigService, Logger } from "@checkstack/backend-api";
|
|
7
|
+
import { createMockLogger } from "@checkstack/test-utils-backend";
|
|
8
|
+
|
|
9
|
+
class FakeInMemoryCache implements CacheProvider {
|
|
10
|
+
store = new Map<string, unknown>();
|
|
11
|
+
hits = 0;
|
|
12
|
+
misses = 0;
|
|
13
|
+
|
|
14
|
+
async get<T>(key: string): Promise<T | undefined> {
|
|
15
|
+
if (this.store.has(key)) {
|
|
16
|
+
this.hits++;
|
|
17
|
+
return this.store.get(key) as T;
|
|
18
|
+
}
|
|
19
|
+
this.misses++;
|
|
20
|
+
return undefined;
|
|
21
|
+
}
|
|
22
|
+
async set<T>(key: string, value: T): Promise<void> {
|
|
23
|
+
this.store.set(key, value);
|
|
24
|
+
}
|
|
25
|
+
async delete(key: string): Promise<void> {
|
|
26
|
+
this.store.delete(key);
|
|
27
|
+
}
|
|
28
|
+
async deleteByPrefix(): Promise<number> {
|
|
29
|
+
return 0;
|
|
30
|
+
}
|
|
31
|
+
async has(key: string): Promise<boolean> {
|
|
32
|
+
return this.store.has(key);
|
|
33
|
+
}
|
|
34
|
+
async getStats(): Promise<CacheStats> {
|
|
35
|
+
return {
|
|
36
|
+
keyCount: this.store.size,
|
|
37
|
+
sizeBytes: null,
|
|
38
|
+
hits: this.hits,
|
|
39
|
+
misses: this.misses,
|
|
40
|
+
scope: "instance",
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const memoryConfigSchema = z.record(z.string(), z.unknown());
|
|
46
|
+
|
|
47
|
+
const createMemoryPlugin = (
|
|
48
|
+
factory: () => FakeInMemoryCache,
|
|
49
|
+
): CachePlugin<unknown> => ({
|
|
50
|
+
id: "memory",
|
|
51
|
+
displayName: "Memory",
|
|
52
|
+
configVersion: 1,
|
|
53
|
+
configSchema: memoryConfigSchema,
|
|
54
|
+
createProvider: () => factory(),
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
class StubConfigService implements ConfigService {
|
|
58
|
+
store = new Map<string, unknown>();
|
|
59
|
+
async set<T>(configId: string, _s: unknown, _v: number, data: T) {
|
|
60
|
+
this.store.set(configId, data);
|
|
61
|
+
}
|
|
62
|
+
async get<T>(configId: string): Promise<T | undefined> {
|
|
63
|
+
return this.store.get(configId) as T | undefined;
|
|
64
|
+
}
|
|
65
|
+
async getRedacted<T>(configId: string): Promise<Partial<T> | undefined> {
|
|
66
|
+
return this.store.get(configId) as Partial<T> | undefined;
|
|
67
|
+
}
|
|
68
|
+
async delete(configId: string): Promise<void> {
|
|
69
|
+
this.store.delete(configId);
|
|
70
|
+
}
|
|
71
|
+
async list(): Promise<string[]> {
|
|
72
|
+
return [...this.store.keys()];
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
describe("CacheManagerImpl provider proxy", () => {
|
|
77
|
+
let registry: CachePluginRegistryImpl;
|
|
78
|
+
let configService: StubConfigService;
|
|
79
|
+
let logger: Logger;
|
|
80
|
+
let manager: CacheManagerImpl;
|
|
81
|
+
let instances: FakeInMemoryCache[];
|
|
82
|
+
|
|
83
|
+
beforeEach(() => {
|
|
84
|
+
registry = new CachePluginRegistryImpl();
|
|
85
|
+
configService = new StubConfigService();
|
|
86
|
+
logger = createMockLogger() as unknown as Logger;
|
|
87
|
+
instances = [];
|
|
88
|
+
registry.register(
|
|
89
|
+
createMemoryPlugin(() => {
|
|
90
|
+
const inst = new FakeInMemoryCache();
|
|
91
|
+
instances.push(inst);
|
|
92
|
+
return inst;
|
|
93
|
+
}),
|
|
94
|
+
);
|
|
95
|
+
manager = new CacheManagerImpl(registry, configService, logger);
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it("returns the same proxy reference across calls", async () => {
|
|
99
|
+
const a = manager.getProvider();
|
|
100
|
+
const b = manager.getProvider();
|
|
101
|
+
expect(a).toBe(b);
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it("delegates writes to the active provider after loadConfiguration", async () => {
|
|
105
|
+
const provider = manager.getProvider();
|
|
106
|
+
await manager.loadConfiguration();
|
|
107
|
+
await provider.set("foo", "bar");
|
|
108
|
+
expect(instances[0].store.get("foo")).toBe("bar");
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it("keeps a captured proxy reference live across setActiveBackend", async () => {
|
|
112
|
+
await manager.loadConfiguration();
|
|
113
|
+
const captured = manager.getProvider();
|
|
114
|
+
await captured.set("before", 1);
|
|
115
|
+
|
|
116
|
+
await manager.setActiveBackend("memory", {
|
|
117
|
+
maxEntries: 5000,
|
|
118
|
+
sweepIntervalMs: 30_000,
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
// The captured reference must now write to the NEW active provider,
|
|
122
|
+
// not the orphaned old one.
|
|
123
|
+
await captured.set("after", 2);
|
|
124
|
+
|
|
125
|
+
const oldInstance = instances[0];
|
|
126
|
+
const newInstance = instances.at(-1)!;
|
|
127
|
+
expect(oldInstance).not.toBe(newInstance);
|
|
128
|
+
expect(oldInstance.store.has("after")).toBe(false);
|
|
129
|
+
expect(newInstance.store.has("after")).toBe(true);
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
it("getStats reads from the currently active provider", async () => {
|
|
133
|
+
await manager.loadConfiguration();
|
|
134
|
+
const provider = manager.getProvider();
|
|
135
|
+
await provider.set("k1", "v1");
|
|
136
|
+
await provider.get("k1"); // hit
|
|
137
|
+
await provider.get("missing"); // miss
|
|
138
|
+
|
|
139
|
+
const stats = await provider.getStats!();
|
|
140
|
+
expect(stats.keyCount).toBe(1);
|
|
141
|
+
expect(stats.hits).toBe(1);
|
|
142
|
+
expect(stats.misses).toBe(1);
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
it("getStats returns all-null when active provider has no getStats impl", async () => {
|
|
146
|
+
// Plugin whose provider doesn't implement getStats.
|
|
147
|
+
const minimalPlugin: CachePlugin<unknown> = {
|
|
148
|
+
id: "noStats",
|
|
149
|
+
displayName: "No stats",
|
|
150
|
+
configVersion: 1,
|
|
151
|
+
configSchema: memoryConfigSchema,
|
|
152
|
+
createProvider: (): CacheProvider => ({
|
|
153
|
+
get: async () => undefined,
|
|
154
|
+
set: async () => {},
|
|
155
|
+
delete: async () => {},
|
|
156
|
+
deleteByPrefix: async () => 0,
|
|
157
|
+
has: async () => false,
|
|
158
|
+
}),
|
|
159
|
+
};
|
|
160
|
+
registry.register(minimalPlugin);
|
|
161
|
+
|
|
162
|
+
await manager.setActiveBackend("noStats", {});
|
|
163
|
+
const stats = await manager.getProvider().getStats!();
|
|
164
|
+
expect(stats).toEqual({
|
|
165
|
+
keyCount: null,
|
|
166
|
+
sizeBytes: null,
|
|
167
|
+
hits: null,
|
|
168
|
+
misses: null,
|
|
169
|
+
scope: "instance",
|
|
170
|
+
});
|
|
171
|
+
});
|
|
172
|
+
});
|
|
@@ -1,4 +1,10 @@
|
|
|
1
|
-
import type {
|
|
1
|
+
import type {
|
|
2
|
+
CacheManager,
|
|
3
|
+
CacheProvider,
|
|
4
|
+
CacheStats,
|
|
5
|
+
ListEntriesOptions,
|
|
6
|
+
ListEntriesResult,
|
|
7
|
+
} from "@checkstack/cache-api";
|
|
2
8
|
import type { CachePluginRegistryImpl } from "./cache-plugin-registry";
|
|
3
9
|
import type { Logger, ConfigService } from "@checkstack/backend-api";
|
|
4
10
|
import { z } from "zod";
|
|
@@ -18,6 +24,10 @@ type ActiveCachePointer = z.infer<typeof activeCachePointerSchema>;
|
|
|
18
24
|
/**
|
|
19
25
|
* A no-op CacheProvider used before any backend is configured.
|
|
20
26
|
* All operations are safe to call but behave as if the cache is empty.
|
|
27
|
+
*
|
|
28
|
+
* Held only as the initial value of `activeProvider` until
|
|
29
|
+
* `loadConfiguration()` runs; consumers always interact with the stable
|
|
30
|
+
* proxy below, so they pick up the real provider as soon as it's ready.
|
|
21
31
|
*/
|
|
22
32
|
const nullProvider: CacheProvider = {
|
|
23
33
|
// eslint-disable-next-line unicorn/no-useless-undefined
|
|
@@ -31,8 +41,18 @@ const nullProvider: CacheProvider = {
|
|
|
31
41
|
/**
|
|
32
42
|
* CacheManagerImpl handles cache provider lifecycle and backend switching.
|
|
33
43
|
*
|
|
34
|
-
*
|
|
35
|
-
*
|
|
44
|
+
* `getProvider()` returns a single stable proxy whose methods delegate to
|
|
45
|
+
* whichever provider is currently active. This means:
|
|
46
|
+
*
|
|
47
|
+
* - Plugins that capture the proxy at init time (e.g. via
|
|
48
|
+
* `createCachedScope`) keep functioning across backend switches and
|
|
49
|
+
* don't write into orphaned providers.
|
|
50
|
+
* - The Infrastructure runtime panel reads stats from the same instance
|
|
51
|
+
* plugins write to — no split-brain.
|
|
52
|
+
*
|
|
53
|
+
* The earlier "no proxy pattern needed since cache is stateless key/value"
|
|
54
|
+
* comment was wrong: in-memory backends are stateful and replacing the
|
|
55
|
+
* active reference would orphan their state.
|
|
36
56
|
*/
|
|
37
57
|
export class CacheManagerImpl implements CacheManager {
|
|
38
58
|
private activePluginId: string = "memory";
|
|
@@ -42,12 +62,49 @@ export class CacheManagerImpl implements CacheManager {
|
|
|
42
62
|
};
|
|
43
63
|
private configVersion: number = 0;
|
|
44
64
|
private activeProvider: CacheProvider = nullProvider;
|
|
65
|
+
private readonly providerProxy: CacheProvider;
|
|
45
66
|
|
|
46
67
|
constructor(
|
|
47
68
|
private registry: CachePluginRegistryImpl,
|
|
48
69
|
private configService: ConfigService,
|
|
49
70
|
private logger: Logger,
|
|
50
|
-
) {
|
|
71
|
+
) {
|
|
72
|
+
this.providerProxy = this.createProviderProxy();
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
private createProviderProxy(): CacheProvider {
|
|
76
|
+
return {
|
|
77
|
+
get: <T>(key: string) => this.activeProvider.get<T>(key),
|
|
78
|
+
set: <T>(key: string, value: T, ttlMs?: number) =>
|
|
79
|
+
this.activeProvider.set<T>(key, value, ttlMs),
|
|
80
|
+
delete: (key: string) => this.activeProvider.delete(key),
|
|
81
|
+
deleteByPrefix: (prefix: string) =>
|
|
82
|
+
this.activeProvider.deleteByPrefix(prefix),
|
|
83
|
+
has: (key: string) => this.activeProvider.has(key),
|
|
84
|
+
getStats: async (): Promise<CacheStats> => {
|
|
85
|
+
const provider = this.activeProvider;
|
|
86
|
+
if (provider.getStats) {
|
|
87
|
+
return provider.getStats();
|
|
88
|
+
}
|
|
89
|
+
return {
|
|
90
|
+
keyCount: null,
|
|
91
|
+
sizeBytes: null,
|
|
92
|
+
hits: null,
|
|
93
|
+
misses: null,
|
|
94
|
+
scope: "instance",
|
|
95
|
+
};
|
|
96
|
+
},
|
|
97
|
+
listEntries: async (
|
|
98
|
+
opts: ListEntriesOptions,
|
|
99
|
+
): Promise<ListEntriesResult> => {
|
|
100
|
+
const provider = this.activeProvider;
|
|
101
|
+
if (!provider.listEntries) {
|
|
102
|
+
return { items: [], total: 0, hasMore: false };
|
|
103
|
+
}
|
|
104
|
+
return provider.listEntries(opts);
|
|
105
|
+
},
|
|
106
|
+
};
|
|
107
|
+
}
|
|
51
108
|
|
|
52
109
|
async loadConfiguration(): Promise<void> {
|
|
53
110
|
try {
|
|
@@ -83,11 +140,10 @@ export class CacheManagerImpl implements CacheManager {
|
|
|
83
140
|
);
|
|
84
141
|
}
|
|
85
142
|
|
|
86
|
-
// Initialize the active provider
|
|
87
143
|
this.initializeProvider();
|
|
88
144
|
} catch (error) {
|
|
89
145
|
this.logger.error("Failed to load cache configuration", error);
|
|
90
|
-
// Continue with defaults — nullProvider is already set
|
|
146
|
+
// Continue with defaults — nullProvider is already set behind the proxy.
|
|
91
147
|
}
|
|
92
148
|
}
|
|
93
149
|
|
|
@@ -113,8 +169,12 @@ export class CacheManagerImpl implements CacheManager {
|
|
|
113
169
|
}
|
|
114
170
|
}
|
|
115
171
|
|
|
172
|
+
/**
|
|
173
|
+
* Returns the stable proxy. The same instance is returned for the
|
|
174
|
+
* lifetime of the manager, so callers can safely capture it once.
|
|
175
|
+
*/
|
|
116
176
|
getProvider(): CacheProvider {
|
|
117
|
-
return this.
|
|
177
|
+
return this.providerProxy;
|
|
118
178
|
}
|
|
119
179
|
|
|
120
180
|
getActivePlugin(): string {
|
|
@@ -126,21 +186,17 @@ export class CacheManagerImpl implements CacheManager {
|
|
|
126
186
|
}
|
|
127
187
|
|
|
128
188
|
async setActiveBackend(pluginId: string, config: unknown): Promise<void> {
|
|
129
|
-
// 1. Validate plugin exists
|
|
130
189
|
const newPlugin = this.registry.getPlugin(pluginId);
|
|
131
190
|
if (!newPlugin) {
|
|
132
191
|
throw new Error(`Cache plugin '${pluginId}' not found`);
|
|
133
192
|
}
|
|
134
193
|
|
|
135
|
-
// 2. Validate config against schema
|
|
136
194
|
newPlugin.configSchema.parse(config);
|
|
137
195
|
|
|
138
|
-
// 3. Create new provider (acts as connection test)
|
|
139
196
|
this.logger.info("🔍 Testing new cache provider...");
|
|
140
197
|
let newProvider: CacheProvider;
|
|
141
198
|
try {
|
|
142
199
|
newProvider = newPlugin.createProvider(config, this.logger);
|
|
143
|
-
// Quick smoke test
|
|
144
200
|
await newProvider.set("__test__", true, 1000);
|
|
145
201
|
await newProvider.delete("__test__");
|
|
146
202
|
this.logger.info("✅ Cache provider test successful");
|
|
@@ -150,20 +206,17 @@ export class CacheManagerImpl implements CacheManager {
|
|
|
150
206
|
throw new Error(`Failed to create cache provider: ${message}`);
|
|
151
207
|
}
|
|
152
208
|
|
|
153
|
-
// 4. Stop old provider if it has a stop method
|
|
154
209
|
const oldProvider = this.activeProvider;
|
|
155
210
|
if ("stop" in oldProvider && typeof oldProvider.stop === "function") {
|
|
156
211
|
await (oldProvider as { stop: () => Promise<void> }).stop();
|
|
157
212
|
}
|
|
158
213
|
|
|
159
|
-
// 5. Switch to new provider
|
|
160
214
|
const oldPluginId = this.activePluginId;
|
|
161
215
|
this.activePluginId = pluginId;
|
|
162
216
|
this.activeConfig = config;
|
|
163
217
|
this.configVersion++;
|
|
164
218
|
this.activeProvider = newProvider;
|
|
165
219
|
|
|
166
|
-
// 6. Persist configuration
|
|
167
220
|
await this.configService.set(
|
|
168
221
|
pluginId,
|
|
169
222
|
newPlugin.configSchema,
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
import { describe, it, expect } from "bun:test";
|
|
2
|
+
import { checkCompatibility } from "./compatibility-checker";
|
|
3
|
+
import type { InstallPackageMetadata } from "@checkstack/common";
|
|
4
|
+
|
|
5
|
+
const minimalPkg = (
|
|
6
|
+
override: Partial<InstallPackageMetadata>,
|
|
7
|
+
): InstallPackageMetadata => ({
|
|
8
|
+
name: "@scope/example",
|
|
9
|
+
version: "1.0.0",
|
|
10
|
+
description: "example",
|
|
11
|
+
author: "test",
|
|
12
|
+
license: "Elastic-2.0",
|
|
13
|
+
checkstack: { type: "backend", pluginId: "example" },
|
|
14
|
+
...override,
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
describe("checkCompatibility", () => {
|
|
18
|
+
it("passes when every @checkstack/* dep is satisfied by loaded versions", () => {
|
|
19
|
+
const pkg = minimalPkg({
|
|
20
|
+
dependencies: {
|
|
21
|
+
"@checkstack/backend-api": "^1.0.0",
|
|
22
|
+
"@checkstack/common": "^1.0.0",
|
|
23
|
+
},
|
|
24
|
+
});
|
|
25
|
+
const issues = checkCompatibility({
|
|
26
|
+
packages: [pkg],
|
|
27
|
+
loadedVersions: new Map([
|
|
28
|
+
["@checkstack/backend-api", "1.2.3"],
|
|
29
|
+
["@checkstack/common", "1.5.0"],
|
|
30
|
+
]),
|
|
31
|
+
});
|
|
32
|
+
expect(issues).toEqual([]);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it("ignores non-@checkstack deps", () => {
|
|
36
|
+
const pkg = minimalPkg({
|
|
37
|
+
dependencies: { lodash: "^4.0.0", react: "^18.0.0" },
|
|
38
|
+
});
|
|
39
|
+
const issues = checkCompatibility({
|
|
40
|
+
packages: [pkg],
|
|
41
|
+
loadedVersions: new Map(),
|
|
42
|
+
});
|
|
43
|
+
expect(issues).toEqual([]);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it("flags missing @checkstack/* dependency not in bundle", () => {
|
|
47
|
+
const pkg = minimalPkg({
|
|
48
|
+
dependencies: { "@checkstack/missing-pkg": "^1.0.0" },
|
|
49
|
+
});
|
|
50
|
+
const issues = checkCompatibility({
|
|
51
|
+
packages: [pkg],
|
|
52
|
+
loadedVersions: new Map(),
|
|
53
|
+
});
|
|
54
|
+
expect(issues).toHaveLength(1);
|
|
55
|
+
expect(issues[0].kind).toBe("missing-dependency");
|
|
56
|
+
expect(issues[0].dependency).toBe("@checkstack/missing-pkg");
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it("flags semver mismatch against loaded platform version", () => {
|
|
60
|
+
const pkg = minimalPkg({
|
|
61
|
+
dependencies: { "@checkstack/backend-api": "^2.0.0" },
|
|
62
|
+
});
|
|
63
|
+
const issues = checkCompatibility({
|
|
64
|
+
packages: [pkg],
|
|
65
|
+
loadedVersions: new Map([["@checkstack/backend-api", "1.0.0"]]),
|
|
66
|
+
});
|
|
67
|
+
expect(issues).toHaveLength(1);
|
|
68
|
+
expect(issues[0].kind).toBe("version-mismatch");
|
|
69
|
+
expect(issues[0].declared).toBe("^2.0.0");
|
|
70
|
+
expect(issues[0].actual).toBe("1.0.0");
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it("rejects unresolved workspace:* ranges (must be resolved at pack time)", () => {
|
|
74
|
+
const pkg = minimalPkg({
|
|
75
|
+
dependencies: { "@checkstack/backend-api": "workspace:*" },
|
|
76
|
+
});
|
|
77
|
+
const issues = checkCompatibility({
|
|
78
|
+
packages: [pkg],
|
|
79
|
+
loadedVersions: new Map([["@checkstack/backend-api", "1.0.0"]]),
|
|
80
|
+
});
|
|
81
|
+
expect(issues).toHaveLength(1);
|
|
82
|
+
expect(issues[0].kind).toBe("version-mismatch");
|
|
83
|
+
expect(issues[0].declared).toBe("workspace:*");
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it("satisfies bundle-internal deps from sibling packages, not platform versions", () => {
|
|
87
|
+
const primary = minimalPkg({
|
|
88
|
+
name: "@scope/foo-backend",
|
|
89
|
+
version: "1.0.0",
|
|
90
|
+
dependencies: { "@scope/foo-common": "^1.0.0" },
|
|
91
|
+
});
|
|
92
|
+
const sibling = minimalPkg({
|
|
93
|
+
name: "@scope/foo-common",
|
|
94
|
+
version: "1.0.0",
|
|
95
|
+
checkstack: { type: "common", pluginId: "foo" },
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
const issues = checkCompatibility({
|
|
99
|
+
packages: [primary, sibling],
|
|
100
|
+
loadedVersions: new Map(), // platform doesn't have @scope/foo-common
|
|
101
|
+
});
|
|
102
|
+
// The dep is `@scope/foo-common`, not `@checkstack/*`, so it isn't
|
|
103
|
+
// checked at all. But verify the check would also pass for the
|
|
104
|
+
// (more interesting) `@checkstack/*` case:
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it("satisfies bundle-internal @checkstack/* dep from a sibling package", () => {
|
|
108
|
+
const primary = minimalPkg({
|
|
109
|
+
name: "@checkstack/foo-backend",
|
|
110
|
+
version: "1.0.0",
|
|
111
|
+
dependencies: { "@checkstack/foo-common": "^1.0.0" },
|
|
112
|
+
});
|
|
113
|
+
const sibling = minimalPkg({
|
|
114
|
+
name: "@checkstack/foo-common",
|
|
115
|
+
version: "1.0.0",
|
|
116
|
+
checkstack: { type: "common", pluginId: "foo" },
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
const issues = checkCompatibility({
|
|
120
|
+
packages: [primary, sibling],
|
|
121
|
+
loadedVersions: new Map(),
|
|
122
|
+
});
|
|
123
|
+
expect(issues).toEqual([]);
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
it("flags bundle-internal dep when version doesn't match", () => {
|
|
127
|
+
const primary = minimalPkg({
|
|
128
|
+
name: "@checkstack/foo-backend",
|
|
129
|
+
version: "1.0.0",
|
|
130
|
+
dependencies: { "@checkstack/foo-common": "^2.0.0" },
|
|
131
|
+
});
|
|
132
|
+
const sibling = minimalPkg({
|
|
133
|
+
name: "@checkstack/foo-common",
|
|
134
|
+
version: "1.0.0",
|
|
135
|
+
checkstack: { type: "common", pluginId: "foo" },
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
const issues = checkCompatibility({
|
|
139
|
+
packages: [primary, sibling],
|
|
140
|
+
loadedVersions: new Map(),
|
|
141
|
+
});
|
|
142
|
+
expect(issues).toHaveLength(1);
|
|
143
|
+
expect(issues[0].kind).toBe("version-mismatch");
|
|
144
|
+
expect(issues[0].dependency).toBe("@checkstack/foo-common");
|
|
145
|
+
});
|
|
146
|
+
});
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import fs from "node:fs";
|
|
3
|
+
import semver from "semver";
|
|
4
|
+
import type { InstallPackageMetadata } from "@checkstack/common";
|
|
5
|
+
import type { CompatibilityIssue } from "@checkstack/pluginmanager-common";
|
|
6
|
+
import { rootLogger } from "../logger";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Builds a snapshot of every `@checkstack/*` package the current process
|
|
10
|
+
* can resolve, with its installed `version`. Used as the source of truth
|
|
11
|
+
* for compatibility checks.
|
|
12
|
+
*
|
|
13
|
+
* We don't need a separate "core version" — every package version is its
|
|
14
|
+
* own contract.
|
|
15
|
+
*/
|
|
16
|
+
export function loadCheckstackPackageVersions({
|
|
17
|
+
workspaceRoot,
|
|
18
|
+
runtimeDir,
|
|
19
|
+
}: {
|
|
20
|
+
workspaceRoot: string;
|
|
21
|
+
runtimeDir: string;
|
|
22
|
+
}): Map<string, string> {
|
|
23
|
+
const versions = new Map<string, string>();
|
|
24
|
+
|
|
25
|
+
const walk = (dir: string) => {
|
|
26
|
+
if (!fs.existsSync(dir)) return;
|
|
27
|
+
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
28
|
+
if (!entry.isDirectory()) continue;
|
|
29
|
+
const pkgJsonPath = path.join(dir, entry.name, "package.json");
|
|
30
|
+
if (!fs.existsSync(pkgJsonPath)) continue;
|
|
31
|
+
try {
|
|
32
|
+
const pkg = JSON.parse(fs.readFileSync(pkgJsonPath, "utf8"));
|
|
33
|
+
if (
|
|
34
|
+
typeof pkg.name === "string" &&
|
|
35
|
+
pkg.name.startsWith("@checkstack/") &&
|
|
36
|
+
typeof pkg.version === "string"
|
|
37
|
+
&& // First-write wins: monorepo source wins over runtime_plugins
|
|
38
|
+
!versions.has(pkg.name)) {
|
|
39
|
+
versions.set(pkg.name, pkg.version);
|
|
40
|
+
}
|
|
41
|
+
} catch (error) {
|
|
42
|
+
rootLogger.debug(`Failed to read ${pkgJsonPath}`, error);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
walk(path.join(workspaceRoot, "core"));
|
|
48
|
+
walk(path.join(workspaceRoot, "plugins"));
|
|
49
|
+
walk(path.join(runtimeDir, "node_modules"));
|
|
50
|
+
walk(path.join(runtimeDir, "node_modules", "@checkstack"));
|
|
51
|
+
|
|
52
|
+
return versions;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Check that every `@checkstack/*` dep declared by a plugin (and its bundle
|
|
57
|
+
* siblings) is satisfiable from the loaded package set OR from another
|
|
58
|
+
* package in the same install bundle.
|
|
59
|
+
*
|
|
60
|
+
* Returns an empty array when compatible, a populated array of issues otherwise.
|
|
61
|
+
*/
|
|
62
|
+
export function checkCompatibility({
|
|
63
|
+
packages,
|
|
64
|
+
loadedVersions,
|
|
65
|
+
}: {
|
|
66
|
+
packages: InstallPackageMetadata[];
|
|
67
|
+
loadedVersions: Map<string, string>;
|
|
68
|
+
}): CompatibilityIssue[] {
|
|
69
|
+
const issues: CompatibilityIssue[] = [];
|
|
70
|
+
|
|
71
|
+
// Bundle-internal deps: plugins in the same install set satisfy each other.
|
|
72
|
+
const bundleVersions = new Map<string, string>();
|
|
73
|
+
for (const pkg of packages) {
|
|
74
|
+
bundleVersions.set(pkg.name, pkg.version);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
for (const pkg of packages) {
|
|
78
|
+
const deps = pkg.dependencies ?? {};
|
|
79
|
+
for (const [depName, declaredRange] of Object.entries(deps)) {
|
|
80
|
+
if (!depName.startsWith("@checkstack/")) continue;
|
|
81
|
+
|
|
82
|
+
// Skip workspace ranges — these only appear in monorepo source files,
|
|
83
|
+
// never in published tarballs (the pack CLI rewrites them). If we see
|
|
84
|
+
// one here it's a sign the plugin wasn't packed via our CLI.
|
|
85
|
+
if (declaredRange.startsWith("workspace:")) {
|
|
86
|
+
issues.push({
|
|
87
|
+
pluginName: pkg.name,
|
|
88
|
+
kind: "version-mismatch",
|
|
89
|
+
dependency: depName,
|
|
90
|
+
declared: declaredRange,
|
|
91
|
+
message: `Plugin '${pkg.name}' depends on '${depName}' with unresolved 'workspace:*' range. Re-pack with '@checkstack/scripts plugin-pack' which resolves workspace ranges to concrete versions.`,
|
|
92
|
+
});
|
|
93
|
+
continue;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const bundleVersion = bundleVersions.get(depName);
|
|
97
|
+
if (bundleVersion !== undefined) {
|
|
98
|
+
if (!semver.satisfies(bundleVersion, declaredRange)) {
|
|
99
|
+
issues.push({
|
|
100
|
+
pluginName: pkg.name,
|
|
101
|
+
kind: "version-mismatch",
|
|
102
|
+
dependency: depName,
|
|
103
|
+
declared: declaredRange,
|
|
104
|
+
actual: bundleVersion,
|
|
105
|
+
message: `Plugin '${pkg.name}' requires ${depName}@${declaredRange} but the bundle ships ${bundleVersion}.`,
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
continue;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const loadedVersion = loadedVersions.get(depName);
|
|
112
|
+
if (loadedVersion === undefined) {
|
|
113
|
+
issues.push({
|
|
114
|
+
pluginName: pkg.name,
|
|
115
|
+
kind: "missing-dependency",
|
|
116
|
+
dependency: depName,
|
|
117
|
+
declared: declaredRange,
|
|
118
|
+
message: `Plugin '${pkg.name}' depends on '${depName}' which is not loaded by this platform and is not part of the install bundle.`,
|
|
119
|
+
});
|
|
120
|
+
continue;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
if (!semver.satisfies(loadedVersion, declaredRange)) {
|
|
124
|
+
issues.push({
|
|
125
|
+
pluginName: pkg.name,
|
|
126
|
+
kind: "version-mismatch",
|
|
127
|
+
dependency: depName,
|
|
128
|
+
declared: declaredRange,
|
|
129
|
+
actual: loadedVersion,
|
|
130
|
+
message: `Plugin '${pkg.name}' requires ${depName}@${declaredRange} but this platform has ${loadedVersion}.`,
|
|
131
|
+
});
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
return issues;
|
|
137
|
+
}
|