@serverkgg/bridge 0.0.5 → 0.0.7
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/package.json +2 -2
- package/src/cli/validate.ts +5 -2
- package/src/core/bridgeAction.ts +2 -0
- package/src/core/bridgeKind.ts +1 -0
- package/src/manifest/manifest.test.ts +118 -0
- package/src/manifest/manifest.ts +19 -0
- package/src/modules/bridge.ts +4 -0
- package/src/modules/bridgeModule.ts +7 -0
- package/src/panel/bridgePanel.ts +5 -0
- package/src/protocol/bridgeLimits.ts +2 -0
- package/src/protocol/bridgePreview.ts +7 -0
- package/src/protocol/index.ts +1 -0
- package/src/runtime/bridgeActions.ts +6 -0
- package/src/runtime/bridgeDriver.test.ts +204 -2
- package/src/runtime/bridgeDriver.ts +49 -0
- package/src/runtime/bridgeExec.ts +2 -1
- package/src/runtime/bridgePrivilege.ts +6 -4
- package/src/runtime/bridgeRegistry.test.ts +140 -3
- package/src/runtime/bridgeRegistry.ts +18 -0
- package/src/runtime/bridgeSupervisor.ts +2 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@serverkgg/bridge",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.7",
|
|
4
4
|
"description": "Serverk game bridge — driver runtime, wire protocol, manifest schema, and maintainer CLI",
|
|
5
5
|
"homepage": "https://serverk.gg",
|
|
6
6
|
"license": "SEE LICENSE IN LICENSE.md",
|
|
@@ -25,7 +25,7 @@
|
|
|
25
25
|
},
|
|
26
26
|
"scripts": {
|
|
27
27
|
"tsc": "tsc",
|
|
28
|
-
"test": "bun test"
|
|
28
|
+
"test": "bun test --parallel"
|
|
29
29
|
},
|
|
30
30
|
"dependencies": {
|
|
31
31
|
"zod": "4.4.3"
|
package/src/cli/validate.ts
CHANGED
|
@@ -15,7 +15,7 @@ import {
|
|
|
15
15
|
} from "../manifest";
|
|
16
16
|
import type { BridgeDriver } from "../modules";
|
|
17
17
|
import { isBridgeEventName } from "../protocol";
|
|
18
|
-
import { validateDriver } from "../runtime/bridgeDriver";
|
|
18
|
+
import { validateDriver, validatePortActivation } from "../runtime/bridgeDriver";
|
|
19
19
|
import { directorySource } from "./directorySource";
|
|
20
20
|
|
|
21
21
|
const HELP = `serverk-bridge validate [options]
|
|
@@ -214,10 +214,13 @@ const validateGamePackage = async (source: PackageSource, manifest: Manifest, di
|
|
|
214
214
|
}
|
|
215
215
|
|
|
216
216
|
validateDriver(driver);
|
|
217
|
+
validatePortActivation(manifest.container.ports, driver);
|
|
218
|
+
|
|
219
|
+
const conditionalPorts = manifest.container.ports.filter((port) => port.activeWhen.length > 0).length;
|
|
217
220
|
|
|
218
221
|
console.log(`${manifest.metadata.id} is valid`);
|
|
219
222
|
console.log(
|
|
220
|
-
` manifest: ${manifest.container.ports.length} port(s), ${manifest.secrets.length} secret(s), ${manifest.guides.length} guide(s)`,
|
|
223
|
+
` manifest: ${manifest.container.ports.length} port(s), ${conditionalPorts} conditional, ${manifest.secrets.length} secret(s), ${manifest.guides.length} guide(s)`,
|
|
221
224
|
);
|
|
222
225
|
console.log(
|
|
223
226
|
` assets: logo ${assets.logo.dimensions.width}x${assets.logo.dimensions.height}, banner ${assets.banner.dimensions.width}x${assets.banner.dimensions.height}`,
|
package/src/core/bridgeAction.ts
CHANGED
|
@@ -3,6 +3,7 @@ export enum BridgeAction {
|
|
|
3
3
|
Install = "install",
|
|
4
4
|
Installed = "installed",
|
|
5
5
|
List = "list",
|
|
6
|
+
Preview = "preview",
|
|
6
7
|
Quiesce = "quiesce",
|
|
7
8
|
Read = "read",
|
|
8
9
|
Release = "release",
|
|
@@ -16,6 +17,7 @@ export enum BridgeAction {
|
|
|
16
17
|
export const BRIDGE_READ_ACTIONS: readonly string[] = [
|
|
17
18
|
BridgeAction.Installed,
|
|
18
19
|
BridgeAction.List,
|
|
20
|
+
BridgeAction.Preview,
|
|
19
21
|
BridgeAction.Read,
|
|
20
22
|
BridgeAction.Sample,
|
|
21
23
|
BridgeAction.Search,
|
package/src/core/bridgeKind.ts
CHANGED
|
@@ -84,6 +84,12 @@ const withPortSrv = (srv: unknown) => {
|
|
|
84
84
|
});
|
|
85
85
|
};
|
|
86
86
|
|
|
87
|
+
const withPortActiveWhen = (activeWhen: unknown) => {
|
|
88
|
+
return withPort({
|
|
89
|
+
activeWhen,
|
|
90
|
+
});
|
|
91
|
+
};
|
|
92
|
+
|
|
87
93
|
describe("defaults", () => {
|
|
88
94
|
test("fills in the optional sections so consumers never guard", () => {
|
|
89
95
|
const manifest = manifestSchema.parse(base());
|
|
@@ -105,6 +111,7 @@ describe("defaults", () => {
|
|
|
105
111
|
expect(manifest.container.ports[0]?.name).toBeUndefined();
|
|
106
112
|
expect(manifest.container.ports[0]?.hint).toBeUndefined();
|
|
107
113
|
expect(manifest.container.ports[0]?.join).toBe(PortJoin.Combined);
|
|
114
|
+
expect(manifest.container.ports[0]?.activeWhen).toEqual([]);
|
|
108
115
|
});
|
|
109
116
|
});
|
|
110
117
|
|
|
@@ -179,6 +186,117 @@ describe("port srv", () => {
|
|
|
179
186
|
});
|
|
180
187
|
});
|
|
181
188
|
|
|
189
|
+
describe("port activeWhen", () => {
|
|
190
|
+
test("keeps every condition a port declares", () => {
|
|
191
|
+
const manifest = manifestSchema.parse(
|
|
192
|
+
withPortActiveWhen([
|
|
193
|
+
{
|
|
194
|
+
variable: "SERVER_TYPE",
|
|
195
|
+
values: [
|
|
196
|
+
"paper",
|
|
197
|
+
"fabric",
|
|
198
|
+
],
|
|
199
|
+
},
|
|
200
|
+
{
|
|
201
|
+
variable: "CROSSPLAY",
|
|
202
|
+
values: [
|
|
203
|
+
"true",
|
|
204
|
+
],
|
|
205
|
+
},
|
|
206
|
+
]),
|
|
207
|
+
);
|
|
208
|
+
|
|
209
|
+
expect(manifest.container.ports[0]?.activeWhen).toEqual([
|
|
210
|
+
{
|
|
211
|
+
variable: "SERVER_TYPE",
|
|
212
|
+
values: [
|
|
213
|
+
"paper",
|
|
214
|
+
"fabric",
|
|
215
|
+
],
|
|
216
|
+
},
|
|
217
|
+
{
|
|
218
|
+
variable: "CROSSPLAY",
|
|
219
|
+
values: [
|
|
220
|
+
"true",
|
|
221
|
+
],
|
|
222
|
+
},
|
|
223
|
+
]);
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
test("rejects a condition that declares neither values nor empty", () => {
|
|
227
|
+
expect(
|
|
228
|
+
failure(
|
|
229
|
+
withPortActiveWhen([
|
|
230
|
+
{
|
|
231
|
+
variable: "CROSSPLAY",
|
|
232
|
+
},
|
|
233
|
+
]),
|
|
234
|
+
),
|
|
235
|
+
).toEqual([
|
|
236
|
+
{
|
|
237
|
+
path: "container.ports.0.activeWhen.0",
|
|
238
|
+
message: "declare values, empty, or both",
|
|
239
|
+
},
|
|
240
|
+
]);
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
test("rejects a condition whose values list is empty", () => {
|
|
244
|
+
expect(
|
|
245
|
+
failure(
|
|
246
|
+
withPortActiveWhen([
|
|
247
|
+
{
|
|
248
|
+
variable: "CROSSPLAY",
|
|
249
|
+
values: [],
|
|
250
|
+
},
|
|
251
|
+
]),
|
|
252
|
+
),
|
|
253
|
+
).toEqual([
|
|
254
|
+
{
|
|
255
|
+
path: "container.ports.0.activeWhen.0.values",
|
|
256
|
+
message: "Too small: expected array to have >=1 items",
|
|
257
|
+
},
|
|
258
|
+
]);
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
test("rejects a fifth condition", () => {
|
|
262
|
+
expect(
|
|
263
|
+
failure(
|
|
264
|
+
withPortActiveWhen(
|
|
265
|
+
Array.from({
|
|
266
|
+
length: 5,
|
|
267
|
+
}).map((_entry, index) => ({
|
|
268
|
+
variable: `RULE_${index}`,
|
|
269
|
+
empty: false,
|
|
270
|
+
})),
|
|
271
|
+
),
|
|
272
|
+
),
|
|
273
|
+
).toEqual([
|
|
274
|
+
{
|
|
275
|
+
path: "container.ports.0.activeWhen",
|
|
276
|
+
message: "Too big: expected array to have <=4 items",
|
|
277
|
+
},
|
|
278
|
+
]);
|
|
279
|
+
});
|
|
280
|
+
|
|
281
|
+
test("rejects a variable that is not an environment key", () => {
|
|
282
|
+
expect(
|
|
283
|
+
failure(
|
|
284
|
+
withPortActiveWhen([
|
|
285
|
+
{
|
|
286
|
+
variable: "crossplay",
|
|
287
|
+
empty: false,
|
|
288
|
+
},
|
|
289
|
+
]),
|
|
290
|
+
),
|
|
291
|
+
).toEqual([
|
|
292
|
+
{
|
|
293
|
+
path: "container.ports.0.activeWhen.0.variable",
|
|
294
|
+
message: "must be SCREAMING_SNAKE_CASE, starting with a letter",
|
|
295
|
+
},
|
|
296
|
+
]);
|
|
297
|
+
});
|
|
298
|
+
});
|
|
299
|
+
|
|
182
300
|
describe("cross-entry checks", () => {
|
|
183
301
|
test("rejects an image that does not match metadata.id", () => {
|
|
184
302
|
const manifest = base();
|
package/src/manifest/manifest.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
2
|
import { PortJoin, PortProtocol } from "../core";
|
|
3
3
|
import { BridgeIcon } from "../panel/bridgeIcon";
|
|
4
|
+
import { BRIDGE_OPTION_VALUE_LENGTH_LIMIT, BRIDGE_VISIBLE_VALUES_LIMIT } from "../protocol/bridgeLimits";
|
|
4
5
|
import { GameCategory } from "./category";
|
|
5
6
|
import { type GuideEntry, type GuideTag, guideEntrySchema, guideTagSchema } from "./guide";
|
|
6
7
|
import { IMAGE_NAMESPACE } from "./image";
|
|
@@ -34,6 +35,20 @@ const manifestHostRangeSchema = z
|
|
|
34
35
|
|
|
35
36
|
export type ManifestHostRange = z.infer<typeof manifestHostRangeSchema>;
|
|
36
37
|
|
|
38
|
+
const manifestPortActivationSchema = z
|
|
39
|
+
.object({
|
|
40
|
+
variable: zEnvKey,
|
|
41
|
+
values: z
|
|
42
|
+
.array(z.string().min(1).max(BRIDGE_OPTION_VALUE_LENGTH_LIMIT))
|
|
43
|
+
.min(1)
|
|
44
|
+
.max(BRIDGE_VISIBLE_VALUES_LIMIT)
|
|
45
|
+
.optional(),
|
|
46
|
+
empty: z.boolean().optional(),
|
|
47
|
+
})
|
|
48
|
+
.refine((rule) => rule.values !== undefined || rule.empty !== undefined, "declare values, empty, or both");
|
|
49
|
+
|
|
50
|
+
export type ManifestPortActivation = z.infer<typeof manifestPortActivationSchema>;
|
|
51
|
+
|
|
37
52
|
const manifestPortSchema = z.object({
|
|
38
53
|
key: z
|
|
39
54
|
.string()
|
|
@@ -63,6 +78,10 @@ const manifestPortSchema = z.object({
|
|
|
63
78
|
description:
|
|
64
79
|
"Publishes this port as an SRV record under the server hostname, so players join by name with no port.",
|
|
65
80
|
}),
|
|
81
|
+
activeWhen: z.array(manifestPortActivationSchema).max(4).default([]).meta({
|
|
82
|
+
description:
|
|
83
|
+
"Conditions on the server's variables that must all hold for the game to listen on this port. The port is still allocated; the panel hides the address otherwise.",
|
|
84
|
+
}),
|
|
66
85
|
});
|
|
67
86
|
|
|
68
87
|
export type ManifestPort = z.infer<typeof manifestPortSchema>;
|
package/src/modules/bridge.ts
CHANGED
|
@@ -9,6 +9,7 @@ import type {
|
|
|
9
9
|
BridgeCatalogProvider,
|
|
10
10
|
BridgeCatalogQuery,
|
|
11
11
|
} from "../protocol/bridgeCatalog";
|
|
12
|
+
import type { BridgePreview } from "../protocol/bridgePreview";
|
|
12
13
|
import type { BridgeTerminalArg, BridgeTerminalCommand, BridgeTerminalRule } from "../terminal";
|
|
13
14
|
import type { BridgeDriver } from "./bridgeDriver";
|
|
14
15
|
import type {
|
|
@@ -22,6 +23,7 @@ import type {
|
|
|
22
23
|
BridgeInstallModule,
|
|
23
24
|
BridgeLifecycleModule,
|
|
24
25
|
BridgeOptionsModule,
|
|
26
|
+
BridgePreviewModule,
|
|
25
27
|
BridgeQueryModule,
|
|
26
28
|
BridgeRowAction,
|
|
27
29
|
BridgeSample,
|
|
@@ -54,6 +56,8 @@ export namespace Bridge {
|
|
|
54
56
|
export type Option = BridgeOption;
|
|
55
57
|
export type Options = BridgeOptionsModule;
|
|
56
58
|
export type Panel = BridgePanel;
|
|
59
|
+
export type Preview = BridgePreviewModule;
|
|
60
|
+
export type PreviewResult = BridgePreview;
|
|
57
61
|
export type Query = BridgeQueryModule;
|
|
58
62
|
export type Reply = BridgeReply;
|
|
59
63
|
export type Row = BridgeRow;
|
|
@@ -2,6 +2,7 @@ import type { BridgeContext } from "../context";
|
|
|
2
2
|
import type { BridgeKind, BridgeOption, BridgeRow, BridgeStream, BridgeValues } from "../core";
|
|
3
3
|
import type { BridgeCatalogEntry, BridgeCatalogPage, BridgeCatalogQuery } from "../protocol/bridgeCatalog";
|
|
4
4
|
import type { BridgeDescribeResult } from "../protocol/bridgeFrame";
|
|
5
|
+
import type { BridgePreview } from "../protocol/bridgePreview";
|
|
5
6
|
import type { BridgeTerminalCommand, BridgeTerminalRule } from "../terminal";
|
|
6
7
|
|
|
7
8
|
export type BridgeDescription = Omit<BridgeDescribeResult, "supported">;
|
|
@@ -69,6 +70,11 @@ export interface BridgeOptionsModule {
|
|
|
69
70
|
list(context: BridgeContext): Promise<BridgeOption[]>;
|
|
70
71
|
}
|
|
71
72
|
|
|
73
|
+
export interface BridgePreviewModule {
|
|
74
|
+
kind: BridgeKind.Preview;
|
|
75
|
+
preview(context: BridgeContext, values: BridgeValues): Promise<BridgePreview>;
|
|
76
|
+
}
|
|
77
|
+
|
|
72
78
|
export interface BridgeSettingsModule {
|
|
73
79
|
kind: BridgeKind.Settings;
|
|
74
80
|
read(context: BridgeContext): Promise<BridgeValues>;
|
|
@@ -113,6 +119,7 @@ export type BridgePanelModule =
|
|
|
113
119
|
| BridgeCatalogModule
|
|
114
120
|
| BridgeCollectionModule
|
|
115
121
|
| BridgeOptionsModule
|
|
122
|
+
| BridgePreviewModule
|
|
116
123
|
| BridgeSettingsModule;
|
|
117
124
|
|
|
118
125
|
export type BridgeModule =
|
package/src/panel/bridgePanel.ts
CHANGED
|
@@ -16,6 +16,10 @@ export interface BridgeOptionsRef {
|
|
|
16
16
|
module: string;
|
|
17
17
|
}
|
|
18
18
|
|
|
19
|
+
export interface BridgePreviewRef {
|
|
20
|
+
module: string;
|
|
21
|
+
}
|
|
22
|
+
|
|
19
23
|
export interface BridgeField {
|
|
20
24
|
key: string;
|
|
21
25
|
control: BridgeControl;
|
|
@@ -68,6 +72,7 @@ export interface BridgeFormSection {
|
|
|
68
72
|
title?: LocalizedText;
|
|
69
73
|
target: BridgeFormTarget;
|
|
70
74
|
module?: string;
|
|
75
|
+
preview?: BridgePreviewRef;
|
|
71
76
|
reinstall?: boolean;
|
|
72
77
|
restartHint?: boolean;
|
|
73
78
|
confirm?: BridgeConfirm;
|
package/src/protocol/index.ts
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
import { describe, expect, test } from "bun:test";
|
|
2
|
-
import { BridgeKind } from "../core";
|
|
2
|
+
import { BridgeKind, PortJoin, PortProtocol } from "../core";
|
|
3
|
+
import type { ManifestPort, ManifestPortActivation } from "../manifest";
|
|
3
4
|
import type { BridgeDriver } from "../modules";
|
|
4
5
|
import {
|
|
6
|
+
BridgeConfirm,
|
|
5
7
|
BridgeControl,
|
|
6
8
|
type BridgeField,
|
|
7
9
|
BridgeFormTarget,
|
|
@@ -9,7 +11,7 @@ import {
|
|
|
9
11
|
type BridgeSectionAction,
|
|
10
12
|
type BridgeVisibleWhen,
|
|
11
13
|
} from "../panel";
|
|
12
|
-
import { validateDriver } from "./bridgeDriver";
|
|
14
|
+
import { validateDriver, validatePortActivation } from "./bridgeDriver";
|
|
13
15
|
|
|
14
16
|
const text = (value: string) => ({
|
|
15
17
|
ar: value,
|
|
@@ -110,6 +112,124 @@ const formSection = (id: string, fields: BridgeField[]) => ({
|
|
|
110
112
|
fields,
|
|
111
113
|
});
|
|
112
114
|
|
|
115
|
+
const driverWithPreview = (): BridgeDriver => ({
|
|
116
|
+
install: {
|
|
117
|
+
kind: BridgeKind.Install,
|
|
118
|
+
run: async () => {
|
|
119
|
+
return;
|
|
120
|
+
},
|
|
121
|
+
},
|
|
122
|
+
lifecycle: {
|
|
123
|
+
kind: BridgeKind.Lifecycle,
|
|
124
|
+
command: async () => [
|
|
125
|
+
"run",
|
|
126
|
+
],
|
|
127
|
+
},
|
|
128
|
+
modules: {
|
|
129
|
+
transition: {
|
|
130
|
+
kind: BridgeKind.Preview,
|
|
131
|
+
preview: async () => ({
|
|
132
|
+
confirm: BridgeConfirm.Normal,
|
|
133
|
+
lines: [],
|
|
134
|
+
}),
|
|
135
|
+
},
|
|
136
|
+
},
|
|
137
|
+
panel: {
|
|
138
|
+
tabs: [
|
|
139
|
+
{
|
|
140
|
+
id: "version",
|
|
141
|
+
title: text("Version"),
|
|
142
|
+
sections: [
|
|
143
|
+
{
|
|
144
|
+
...formSection("version", [
|
|
145
|
+
field("MC_VERSION"),
|
|
146
|
+
]),
|
|
147
|
+
preview: {
|
|
148
|
+
module: "transition",
|
|
149
|
+
},
|
|
150
|
+
},
|
|
151
|
+
],
|
|
152
|
+
},
|
|
153
|
+
],
|
|
154
|
+
},
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
const formSectionOf = (driver: BridgeDriver) => {
|
|
158
|
+
const [section] = driver.panel?.tabs[0]?.sections ?? [];
|
|
159
|
+
|
|
160
|
+
if (!section || section.layout !== BridgeLayout.Form) {
|
|
161
|
+
throw new Error("fixture is missing its form section");
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
return section;
|
|
165
|
+
};
|
|
166
|
+
|
|
167
|
+
const driverWithVariables = (): BridgeDriver => ({
|
|
168
|
+
install: {
|
|
169
|
+
kind: BridgeKind.Install,
|
|
170
|
+
run: async () => {
|
|
171
|
+
return;
|
|
172
|
+
},
|
|
173
|
+
},
|
|
174
|
+
lifecycle: {
|
|
175
|
+
kind: BridgeKind.Lifecycle,
|
|
176
|
+
command: async () => [
|
|
177
|
+
"run",
|
|
178
|
+
],
|
|
179
|
+
},
|
|
180
|
+
modules: {
|
|
181
|
+
modpacks: {
|
|
182
|
+
kind: BridgeKind.Catalog,
|
|
183
|
+
search: async () => ({
|
|
184
|
+
hits: [],
|
|
185
|
+
total: 0,
|
|
186
|
+
providers: [],
|
|
187
|
+
categories: [],
|
|
188
|
+
sorts: [],
|
|
189
|
+
}),
|
|
190
|
+
installed: async () => [],
|
|
191
|
+
install: async () => {
|
|
192
|
+
throw new Error("not installable in the fixture");
|
|
193
|
+
},
|
|
194
|
+
remove: async () => {
|
|
195
|
+
return;
|
|
196
|
+
},
|
|
197
|
+
},
|
|
198
|
+
},
|
|
199
|
+
panel: {
|
|
200
|
+
tabs: [
|
|
201
|
+
{
|
|
202
|
+
id: "version",
|
|
203
|
+
title: text("Version"),
|
|
204
|
+
sections: [
|
|
205
|
+
formSection("version", [
|
|
206
|
+
field("MC_VERSION"),
|
|
207
|
+
]),
|
|
208
|
+
{
|
|
209
|
+
layout: BridgeLayout.Catalog,
|
|
210
|
+
id: "modpacks",
|
|
211
|
+
module: "modpacks",
|
|
212
|
+
variable: "MODPACK",
|
|
213
|
+
},
|
|
214
|
+
],
|
|
215
|
+
},
|
|
216
|
+
],
|
|
217
|
+
},
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
const port = (activeWhen: ManifestPortActivation[]): ManifestPort => ({
|
|
221
|
+
key: "bedrock",
|
|
222
|
+
containerPort: 19_132,
|
|
223
|
+
protocol: PortProtocol.Udp,
|
|
224
|
+
hostRange: {
|
|
225
|
+
default: 19_132,
|
|
226
|
+
min: 19_132,
|
|
227
|
+
max: 19_232,
|
|
228
|
+
},
|
|
229
|
+
join: PortJoin.Combined,
|
|
230
|
+
activeWhen,
|
|
231
|
+
});
|
|
232
|
+
|
|
113
233
|
const driverWithTabs = (tabs: number, sections: number): BridgeDriver => ({
|
|
114
234
|
install: {
|
|
115
235
|
kind: BridgeKind.Install,
|
|
@@ -448,3 +568,85 @@ describe("visibleWhen", () => {
|
|
|
448
568
|
expect(() => validateDriver(driver)).toThrow('field "MC_VERSION" is visible when "SERVER_TYPE" matches nothing');
|
|
449
569
|
});
|
|
450
570
|
});
|
|
571
|
+
|
|
572
|
+
describe("form preview", () => {
|
|
573
|
+
test("accepts a variables form pointing at a preview module", () => {
|
|
574
|
+
expect(() => validateDriver(driverWithPreview())).not.toThrow();
|
|
575
|
+
});
|
|
576
|
+
|
|
577
|
+
test("rejects a preview ref to a module that is not a preview module", () => {
|
|
578
|
+
const driver = driverWithPreview();
|
|
579
|
+
|
|
580
|
+
driver.modules = {
|
|
581
|
+
transition: {
|
|
582
|
+
kind: BridgeKind.Settings,
|
|
583
|
+
read: async () => ({}),
|
|
584
|
+
},
|
|
585
|
+
};
|
|
586
|
+
|
|
587
|
+
expect(() => validateDriver(driver)).toThrow('references "transition", which is not a preview module');
|
|
588
|
+
});
|
|
589
|
+
|
|
590
|
+
test("rejects a preview on a form that writes settings", () => {
|
|
591
|
+
const driver = driverWithPreview();
|
|
592
|
+
const section = formSectionOf(driver);
|
|
593
|
+
|
|
594
|
+
section.target = BridgeFormTarget.Settings;
|
|
595
|
+
section.module = "config";
|
|
596
|
+
|
|
597
|
+
driver.modules = {
|
|
598
|
+
...driver.modules,
|
|
599
|
+
config: {
|
|
600
|
+
kind: BridgeKind.Settings,
|
|
601
|
+
read: async () => ({}),
|
|
602
|
+
write: async () => {
|
|
603
|
+
return;
|
|
604
|
+
},
|
|
605
|
+
},
|
|
606
|
+
};
|
|
607
|
+
|
|
608
|
+
expect(() => validateDriver(driver)).toThrow("declares a preview but does not target variables");
|
|
609
|
+
});
|
|
610
|
+
});
|
|
611
|
+
|
|
612
|
+
describe("validatePortActivation", () => {
|
|
613
|
+
test("accepts a variable a variables form or a catalog section declares", () => {
|
|
614
|
+
expect(() =>
|
|
615
|
+
validatePortActivation(
|
|
616
|
+
[
|
|
617
|
+
port([
|
|
618
|
+
{
|
|
619
|
+
variable: "MC_VERSION",
|
|
620
|
+
values: [
|
|
621
|
+
"26.2",
|
|
622
|
+
],
|
|
623
|
+
},
|
|
624
|
+
{
|
|
625
|
+
variable: "MODPACK",
|
|
626
|
+
empty: true,
|
|
627
|
+
},
|
|
628
|
+
]),
|
|
629
|
+
],
|
|
630
|
+
driverWithVariables(),
|
|
631
|
+
),
|
|
632
|
+
).not.toThrow();
|
|
633
|
+
});
|
|
634
|
+
|
|
635
|
+
test("rejects a variable nothing in the panel declares", () => {
|
|
636
|
+
expect(() =>
|
|
637
|
+
validatePortActivation(
|
|
638
|
+
[
|
|
639
|
+
port([
|
|
640
|
+
{
|
|
641
|
+
variable: "CROSSPLAY",
|
|
642
|
+
values: [
|
|
643
|
+
"true",
|
|
644
|
+
],
|
|
645
|
+
},
|
|
646
|
+
]),
|
|
647
|
+
],
|
|
648
|
+
driverWithVariables(),
|
|
649
|
+
),
|
|
650
|
+
).toThrow('port "bedrock" activeWhen[0] names "CROSSPLAY", which no variables form or catalog declares');
|
|
651
|
+
});
|
|
652
|
+
});
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { BRIDGE_ID_PATTERN, BRIDGE_MODULE_ID_PATTERN, BridgeKind, type LocalizedText } from "../core";
|
|
2
|
+
import type { ManifestPort } from "../manifest";
|
|
2
3
|
import type { BridgeDriver, BridgePanelModule } from "../modules";
|
|
3
4
|
import {
|
|
4
5
|
type BridgeField,
|
|
@@ -237,6 +238,18 @@ const validateSection = (modules: Record<string, BridgePanelModule>, section: Br
|
|
|
237
238
|
}
|
|
238
239
|
}
|
|
239
240
|
|
|
241
|
+
if (section.preview) {
|
|
242
|
+
if (section.target !== BridgeFormTarget.Variables) {
|
|
243
|
+
fail(`${where} declares a preview but does not target variables`);
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
const preview = moduleOf(modules, section.preview.module, where);
|
|
247
|
+
|
|
248
|
+
if (preview.kind !== BridgeKind.Preview) {
|
|
249
|
+
fail(`${where} references "${section.preview.module}", which is not a preview module`);
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
|
|
240
253
|
validateText(section.confirmText, "confirm text", where);
|
|
241
254
|
|
|
242
255
|
if (section.fields.length === 0) {
|
|
@@ -417,3 +430,39 @@ export const validateDriver = (driver: BridgeDriver) => {
|
|
|
417
430
|
validateTab(modules, tab);
|
|
418
431
|
}
|
|
419
432
|
};
|
|
433
|
+
|
|
434
|
+
const declaredVariables = (driver: BridgeDriver) => {
|
|
435
|
+
const declared = new Set<string>();
|
|
436
|
+
|
|
437
|
+
for (const tab of driver.panel?.tabs ?? []) {
|
|
438
|
+
for (const section of tab.sections) {
|
|
439
|
+
if (section.layout === BridgeLayout.Form && section.target === BridgeFormTarget.Variables) {
|
|
440
|
+
for (const field of section.fields) {
|
|
441
|
+
declared.add(field.key);
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
if (section.layout === BridgeLayout.Catalog && section.variable) {
|
|
446
|
+
declared.add(section.variable);
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
return declared;
|
|
452
|
+
};
|
|
453
|
+
|
|
454
|
+
export const validatePortActivation = (ports: ManifestPort[], driver: BridgeDriver): void => {
|
|
455
|
+
const declared = declaredVariables(driver);
|
|
456
|
+
|
|
457
|
+
for (const port of ports) {
|
|
458
|
+
for (const [index, rule] of port.activeWhen.entries()) {
|
|
459
|
+
if (declared.has(rule.variable)) {
|
|
460
|
+
continue;
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
fail(
|
|
464
|
+
`port "${port.key}" activeWhen[${index}] names "${rule.variable}", which no variables form or catalog declares`,
|
|
465
|
+
);
|
|
466
|
+
}
|
|
467
|
+
}
|
|
468
|
+
};
|
|
@@ -97,9 +97,10 @@ export const createBridgeExec = (target: BridgeExecTarget) => {
|
|
|
97
97
|
throw new BridgeExecError("the exec command is empty");
|
|
98
98
|
}
|
|
99
99
|
|
|
100
|
-
const { argv } = await unprivilegedArgv(command, target.uid, target.gid);
|
|
100
|
+
const { argv, credentials } = await unprivilegedArgv(command, target.uid, target.gid);
|
|
101
101
|
|
|
102
102
|
const child = Bun.spawn(argv, {
|
|
103
|
+
...credentials,
|
|
103
104
|
cwd: target.cwd,
|
|
104
105
|
env: {
|
|
105
106
|
...baseEnvironment(),
|
|
@@ -39,14 +39,16 @@ export const settleUid = async (pid: number, expected: number) => {
|
|
|
39
39
|
return observed;
|
|
40
40
|
};
|
|
41
41
|
|
|
42
|
-
const
|
|
43
|
-
|
|
42
|
+
export const spawnCredentials = (uid: number, gid: number): object => {
|
|
43
|
+
return {
|
|
44
44
|
uid,
|
|
45
45
|
gid,
|
|
46
46
|
};
|
|
47
|
+
};
|
|
47
48
|
|
|
49
|
+
const spawnWithUid = (command: string[], uid: number, gid: number) => {
|
|
48
50
|
return Bun.spawn(command, {
|
|
49
|
-
...
|
|
51
|
+
...spawnCredentials(uid, gid),
|
|
50
52
|
stderr: "ignore",
|
|
51
53
|
stdin: "ignore",
|
|
52
54
|
stdout: "ignore",
|
|
@@ -94,6 +96,6 @@ export const unprivilegedArgv = async (command: string[], uid: number, gid: numb
|
|
|
94
96
|
|
|
95
97
|
return {
|
|
96
98
|
argv: native ? command : setprivArgv(command, uid, gid),
|
|
97
|
-
native,
|
|
99
|
+
credentials: native ? spawnCredentials(uid, gid) : {},
|
|
98
100
|
};
|
|
99
101
|
};
|
|
@@ -2,7 +2,7 @@ import { describe, expect, test } from "bun:test";
|
|
|
2
2
|
import { tmpdir } from "node:os";
|
|
3
3
|
import { BridgeAction, BridgeKind, type BridgeRow, type BridgeValues } from "../core";
|
|
4
4
|
import type { BridgeDriver } from "../modules";
|
|
5
|
-
import { type BridgeConfig, BridgeEventName } from "../protocol";
|
|
5
|
+
import { type BridgeConfig, BridgeConfirm, BridgeEventName } from "../protocol";
|
|
6
6
|
import { createContext } from "./bridgeContext";
|
|
7
7
|
import { createEventRing } from "./bridgeEvents";
|
|
8
8
|
import { log } from "./bridgeLog";
|
|
@@ -38,8 +38,11 @@ const baseDriver = (): BridgeDriver => ({
|
|
|
38
38
|
},
|
|
39
39
|
});
|
|
40
40
|
|
|
41
|
-
const registryFor = (driver: BridgeDriver) => {
|
|
42
|
-
const settings =
|
|
41
|
+
const registryFor = (driver: BridgeDriver, variables: Record<string, string> = {}) => {
|
|
42
|
+
const settings = {
|
|
43
|
+
...config(),
|
|
44
|
+
variables,
|
|
45
|
+
};
|
|
43
46
|
|
|
44
47
|
const supervisor = createSupervisor({
|
|
45
48
|
cwd: settings.cwd,
|
|
@@ -289,3 +292,137 @@ describe("collection row actions", () => {
|
|
|
289
292
|
]);
|
|
290
293
|
});
|
|
291
294
|
});
|
|
295
|
+
|
|
296
|
+
describe("preview modules", () => {
|
|
297
|
+
test("lists the preview action in the manifest", () => {
|
|
298
|
+
const registry = registryFor({
|
|
299
|
+
...baseDriver(),
|
|
300
|
+
modules: {
|
|
301
|
+
transition: {
|
|
302
|
+
kind: BridgeKind.Preview,
|
|
303
|
+
preview: async () => ({
|
|
304
|
+
confirm: BridgeConfirm.Normal,
|
|
305
|
+
lines: [],
|
|
306
|
+
}),
|
|
307
|
+
},
|
|
308
|
+
},
|
|
309
|
+
});
|
|
310
|
+
|
|
311
|
+
expect(registry.manifest().modules).toEqual([
|
|
312
|
+
{
|
|
313
|
+
id: "transition",
|
|
314
|
+
kind: BridgeKind.Preview,
|
|
315
|
+
requiresRunning: false,
|
|
316
|
+
refreshSeconds: null,
|
|
317
|
+
pageSize: null,
|
|
318
|
+
dependsOn: [],
|
|
319
|
+
actions: [
|
|
320
|
+
BridgeAction.Preview,
|
|
321
|
+
],
|
|
322
|
+
},
|
|
323
|
+
]);
|
|
324
|
+
});
|
|
325
|
+
|
|
326
|
+
test("shows the module the pending values as variables and leaves the rest as they are stored", async () => {
|
|
327
|
+
const seen: {
|
|
328
|
+
version: string | null;
|
|
329
|
+
count: string | null;
|
|
330
|
+
type: string | null;
|
|
331
|
+
args: BridgeValues;
|
|
332
|
+
}[] = [];
|
|
333
|
+
|
|
334
|
+
const registry = registryFor(
|
|
335
|
+
{
|
|
336
|
+
...baseDriver(),
|
|
337
|
+
modules: {
|
|
338
|
+
transition: {
|
|
339
|
+
kind: BridgeKind.Preview,
|
|
340
|
+
preview: async (context, values) => {
|
|
341
|
+
seen.push({
|
|
342
|
+
version: context.variable("MC_VERSION"),
|
|
343
|
+
count: context.variable("n"),
|
|
344
|
+
type: context.variable("SERVER_TYPE"),
|
|
345
|
+
args: values,
|
|
346
|
+
});
|
|
347
|
+
|
|
348
|
+
return {
|
|
349
|
+
confirm: BridgeConfirm.Strong,
|
|
350
|
+
lines: [],
|
|
351
|
+
};
|
|
352
|
+
},
|
|
353
|
+
},
|
|
354
|
+
},
|
|
355
|
+
},
|
|
356
|
+
{
|
|
357
|
+
MC_VERSION: "26.2",
|
|
358
|
+
SERVER_TYPE: "paper",
|
|
359
|
+
},
|
|
360
|
+
);
|
|
361
|
+
|
|
362
|
+
await registry.invoke("transition", BridgeAction.Preview, {
|
|
363
|
+
MC_VERSION: "1.21",
|
|
364
|
+
n: 3,
|
|
365
|
+
});
|
|
366
|
+
|
|
367
|
+
expect(seen).toEqual([
|
|
368
|
+
{
|
|
369
|
+
version: "1.21",
|
|
370
|
+
count: null,
|
|
371
|
+
type: "paper",
|
|
372
|
+
args: {
|
|
373
|
+
MC_VERSION: "1.21",
|
|
374
|
+
n: 3,
|
|
375
|
+
},
|
|
376
|
+
},
|
|
377
|
+
]);
|
|
378
|
+
});
|
|
379
|
+
|
|
380
|
+
test("returns what the module answers", async () => {
|
|
381
|
+
const registry = registryFor({
|
|
382
|
+
...baseDriver(),
|
|
383
|
+
modules: {
|
|
384
|
+
transition: {
|
|
385
|
+
kind: BridgeKind.Preview,
|
|
386
|
+
preview: async () => ({
|
|
387
|
+
confirm: BridgeConfirm.Strong,
|
|
388
|
+
lines: [
|
|
389
|
+
{
|
|
390
|
+
ar: "عالمك يبقى",
|
|
391
|
+
en: "Your world stays",
|
|
392
|
+
},
|
|
393
|
+
],
|
|
394
|
+
}),
|
|
395
|
+
},
|
|
396
|
+
},
|
|
397
|
+
});
|
|
398
|
+
|
|
399
|
+
expect(await registry.invoke("transition", BridgeAction.Preview)).toEqual({
|
|
400
|
+
confirm: BridgeConfirm.Strong,
|
|
401
|
+
lines: [
|
|
402
|
+
{
|
|
403
|
+
ar: "عالمك يبقى",
|
|
404
|
+
en: "Your world stays",
|
|
405
|
+
},
|
|
406
|
+
],
|
|
407
|
+
});
|
|
408
|
+
});
|
|
409
|
+
|
|
410
|
+
test("refuses an action a preview module does not answer", async () => {
|
|
411
|
+
const registry = registryFor({
|
|
412
|
+
...baseDriver(),
|
|
413
|
+
modules: {
|
|
414
|
+
transition: {
|
|
415
|
+
kind: BridgeKind.Preview,
|
|
416
|
+
preview: async () => ({
|
|
417
|
+
confirm: BridgeConfirm.Normal,
|
|
418
|
+
lines: [],
|
|
419
|
+
}),
|
|
420
|
+
},
|
|
421
|
+
},
|
|
422
|
+
});
|
|
423
|
+
|
|
424
|
+
await expect(registry.invoke("transition", BridgeAction.Read)).rejects.toThrow(
|
|
425
|
+
'module "transition" does not support "read"',
|
|
426
|
+
);
|
|
427
|
+
});
|
|
428
|
+
});
|
|
@@ -228,6 +228,24 @@ export const createRegistry = (
|
|
|
228
228
|
break;
|
|
229
229
|
}
|
|
230
230
|
|
|
231
|
+
case BridgeKind.Preview: {
|
|
232
|
+
if (action === BridgeAction.Preview) {
|
|
233
|
+
const overlay: Record<string, string> = {};
|
|
234
|
+
|
|
235
|
+
for (const [key, value] of Object.entries(args)) {
|
|
236
|
+
if (typeof value === "string") {
|
|
237
|
+
overlay[key] = value;
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
const scoped = withVariables(context, overlay);
|
|
242
|
+
|
|
243
|
+
return await module.preview(scoped, args);
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
break;
|
|
247
|
+
}
|
|
248
|
+
|
|
231
249
|
case BridgeKind.Settings: {
|
|
232
250
|
if (action === BridgeAction.Read) {
|
|
233
251
|
return await module.read(context);
|
|
@@ -218,8 +218,9 @@ export const createSupervisor = (options: SupervisorOptions) => {
|
|
|
218
218
|
...rest,
|
|
219
219
|
];
|
|
220
220
|
|
|
221
|
-
const { argv: spawnArgv } = await unprivilegedArgv(argv, options.uid, options.gid);
|
|
221
|
+
const { argv: spawnArgv, credentials } = await unprivilegedArgv(argv, options.uid, options.gid);
|
|
222
222
|
const spawned = Bun.spawn(spawnArgv, {
|
|
223
|
+
...credentials,
|
|
223
224
|
cwd: options.cwd,
|
|
224
225
|
stderr: "pipe",
|
|
225
226
|
stdin: "pipe",
|