@serverkgg/bridge 0.0.9 → 0.0.11
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 +1 -1
- package/src/cli/validate.test.ts +116 -1
- package/src/cli/validate.ts +50 -3
- package/src/context/bridgeContext.ts +8 -1
- package/src/core/bridgeKind.ts +1 -0
- package/src/core/bridgeProbeError.ts +16 -0
- package/src/core/bridgeSetup.ts +20 -0
- package/src/core/bridgeValue.test.ts +29 -0
- package/src/core/bridgeValue.ts +12 -0
- package/src/core/index.ts +2 -0
- package/src/guides/blocks.ts +8 -0
- package/src/guides/markdown.test.ts +18 -0
- package/src/guides/markdown.ts +7 -1
- package/src/manifest/guide.ts +1 -0
- package/src/manifest/manifest.test.ts +49 -2
- package/src/manifest/presence.ts +9 -1
- package/src/modules/bridge.ts +20 -0
- package/src/modules/bridgeDriver.ts +2 -0
- package/src/modules/bridgeModule.ts +7 -0
- package/src/panel/bridgeControl.ts +1 -0
- package/src/panel/bridgeDetail.ts +6 -5
- package/src/panel/bridgePanel.ts +2 -0
- package/src/probes/a2s.test.ts +71 -0
- package/src/probes/a2s.ts +38 -11
- package/src/protocol/bridgeFrame.ts +5 -0
- package/src/protocol/bridgeLimits.ts +10 -0
- package/src/protocol/bridgeManifest.ts +2 -0
- package/src/protocol/bridgeSetup.ts +97 -0
- package/src/protocol/index.ts +1 -0
- package/src/runtime/bridgeConfigReader.ts +12 -8
- package/src/runtime/bridgeContext.ts +28 -2
- package/src/runtime/bridgeDriver.test.ts +512 -2
- package/src/runtime/bridgeDriver.ts +219 -1
- package/src/runtime/bridgeEvents.test.ts +96 -0
- package/src/runtime/bridgeEvents.ts +4 -1
- package/src/runtime/bridgeRegistry.test.ts +38 -1
- package/src/runtime/bridgeRegistry.ts +14 -1
- package/src/runtime/bridgeSecrets.test.ts +121 -0
- package/src/runtime/bridgeSecrets.ts +43 -0
- package/src/runtime/bridgeServer.test.ts +262 -2
- package/src/runtime/bridgeServer.ts +17 -0
- package/src/runtime/bridgeSetup.test.ts +575 -0
- package/src/runtime/bridgeSetup.ts +303 -0
- package/src/runtime/bridgeSetupHandlers.test.ts +303 -0
- package/src/runtime/bridgeSetupHandlers.ts +71 -0
- package/src/runtime/bridgeSignal.test.ts +114 -0
- package/src/runtime/bridgeSignal.ts +31 -0
- package/src/runtime/bridgeSupervisor.test.ts +111 -0
- package/src/runtime/bridgeSupervisor.ts +37 -5
- package/src/runtime/serve.ts +19 -1
- package/src/utils/index.ts +1 -0
- package/src/utils/stripAnsi.ts +12 -0
- package/src/wire/panel.test.ts +155 -0
- package/src/wire/panel.ts +43 -0
|
@@ -1,7 +1,15 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import {
|
|
2
|
+
BRIDGE_ID_PATTERN,
|
|
3
|
+
BRIDGE_MODULE_ID_PATTERN,
|
|
4
|
+
BridgeKind,
|
|
5
|
+
BridgeSetupStepKind,
|
|
6
|
+
type LocalizedText,
|
|
7
|
+
} from "../core";
|
|
8
|
+
import { GuideOpenTab } from "../guides";
|
|
2
9
|
import type { ManifestPort } from "../manifest";
|
|
3
10
|
import type { BridgeDriver, BridgePanelModule } from "../modules";
|
|
4
11
|
import {
|
|
12
|
+
BridgeControl,
|
|
5
13
|
type BridgeField,
|
|
6
14
|
BridgeFormTarget,
|
|
7
15
|
BridgeLayout,
|
|
@@ -25,9 +33,11 @@ import {
|
|
|
25
33
|
BRIDGE_MODULES_LIMIT,
|
|
26
34
|
BRIDGE_OPTION_VALUE_LENGTH_LIMIT,
|
|
27
35
|
BRIDGE_PANEL_TABS_LIMIT,
|
|
36
|
+
BRIDGE_PATTERN_LENGTH_LIMIT,
|
|
28
37
|
BRIDGE_PLACEHOLDER_LENGTH_LIMIT,
|
|
29
38
|
BRIDGE_ROW_ACTIONS_LIMIT,
|
|
30
39
|
BRIDGE_SECTION_ACTIONS_LIMIT,
|
|
40
|
+
BRIDGE_SETUP_STEPS_LIMIT,
|
|
31
41
|
BRIDGE_STAGING_LENGTH_LIMIT,
|
|
32
42
|
BRIDGE_TAB_SECTIONS_LIMIT,
|
|
33
43
|
BRIDGE_TABLE_COLUMNS_LIMIT,
|
|
@@ -47,6 +57,7 @@ const RESERVED_MODULE_IDS = new Set([
|
|
|
47
57
|
"lifecycle",
|
|
48
58
|
"pending",
|
|
49
59
|
"query",
|
|
60
|
+
"setup",
|
|
50
61
|
]);
|
|
51
62
|
|
|
52
63
|
export class BridgeDriverError extends Error {
|
|
@@ -112,6 +123,110 @@ const moduleOf = (modules: Record<string, BridgePanelModule>, id: string, where:
|
|
|
112
123
|
return module as BridgePanelModule;
|
|
113
124
|
};
|
|
114
125
|
|
|
126
|
+
const compilePattern = (pattern: string): RegExp | null => {
|
|
127
|
+
try {
|
|
128
|
+
return new RegExp(pattern);
|
|
129
|
+
} catch {
|
|
130
|
+
return null;
|
|
131
|
+
}
|
|
132
|
+
};
|
|
133
|
+
|
|
134
|
+
const OPEN_ENDED_REPEAT = /^\{\d+,\}/;
|
|
135
|
+
|
|
136
|
+
const repeatsWithoutBound = (source: string): boolean => {
|
|
137
|
+
let index = 0;
|
|
138
|
+
let inClass = false;
|
|
139
|
+
|
|
140
|
+
while (index < source.length) {
|
|
141
|
+
const char = source[index];
|
|
142
|
+
|
|
143
|
+
if (char === "\\") {
|
|
144
|
+
index += 2;
|
|
145
|
+
|
|
146
|
+
continue;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
if (inClass) {
|
|
150
|
+
inClass = char !== "]";
|
|
151
|
+
index += 1;
|
|
152
|
+
|
|
153
|
+
continue;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
if (char === "[") {
|
|
157
|
+
inClass = true;
|
|
158
|
+
index += 1;
|
|
159
|
+
|
|
160
|
+
continue;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
if (char === "*" || char === "+" || (char === "{" && OPEN_ENDED_REPEAT.test(source.slice(index)))) {
|
|
164
|
+
return true;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
index += 1;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
return false;
|
|
171
|
+
};
|
|
172
|
+
|
|
173
|
+
const nestsQuantifiers = (pattern: string): boolean => {
|
|
174
|
+
const opened: number[] = [];
|
|
175
|
+
|
|
176
|
+
let index = 0;
|
|
177
|
+
let inClass = false;
|
|
178
|
+
|
|
179
|
+
while (index < pattern.length) {
|
|
180
|
+
const char = pattern[index];
|
|
181
|
+
|
|
182
|
+
if (char === "\\") {
|
|
183
|
+
index += 2;
|
|
184
|
+
|
|
185
|
+
continue;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
if (inClass) {
|
|
189
|
+
inClass = char !== "]";
|
|
190
|
+
index += 1;
|
|
191
|
+
|
|
192
|
+
continue;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
if (char === "[") {
|
|
196
|
+
inClass = true;
|
|
197
|
+
index += 1;
|
|
198
|
+
|
|
199
|
+
continue;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
if (char === "(") {
|
|
203
|
+
opened.push(index);
|
|
204
|
+
index += 1;
|
|
205
|
+
|
|
206
|
+
continue;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
if (char === ")") {
|
|
210
|
+
const start = opened.pop();
|
|
211
|
+
|
|
212
|
+
index += 1;
|
|
213
|
+
|
|
214
|
+
const quantified =
|
|
215
|
+
pattern[index] === "*" || pattern[index] === "+" || OPEN_ENDED_REPEAT.test(pattern.slice(index));
|
|
216
|
+
|
|
217
|
+
if (start !== undefined && quantified && repeatsWithoutBound(pattern.slice(start + 1, index - 1))) {
|
|
218
|
+
return true;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
continue;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
index += 1;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
return false;
|
|
228
|
+
};
|
|
229
|
+
|
|
115
230
|
const validateVisibleWhen = (visibleWhen: BridgeVisibleWhen | undefined, where: string) => {
|
|
116
231
|
if (!visibleWhen) {
|
|
117
232
|
return;
|
|
@@ -140,12 +255,39 @@ const validateFields = (modules: Record<string, BridgePanelModule>, fields: Brid
|
|
|
140
255
|
validateText(field.label, "label", at);
|
|
141
256
|
validateText(field.help, "help", at);
|
|
142
257
|
validateText(field.warning, "warning", at);
|
|
258
|
+
validateText(field.patternHint, "pattern hint", at);
|
|
143
259
|
validateVisibleWhen(field.visibleWhen, at);
|
|
144
260
|
|
|
145
261
|
if (field.placeholder) {
|
|
146
262
|
lengthIn(field.placeholder, BRIDGE_PLACEHOLDER_LENGTH_LIMIT, "placeholder", at);
|
|
147
263
|
}
|
|
148
264
|
|
|
265
|
+
if (field.pattern !== undefined) {
|
|
266
|
+
if (field.control !== BridgeControl.Text && field.control !== BridgeControl.Secret) {
|
|
267
|
+
fail(`${at} declares a pattern on a "${field.control}" control — only text and secret controls match one`);
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
lengthIn(field.pattern, BRIDGE_PATTERN_LENGTH_LIMIT, "pattern", at);
|
|
271
|
+
|
|
272
|
+
if (!compilePattern(field.pattern)) {
|
|
273
|
+
fail(`${at} declares pattern "${field.pattern}", which is not a valid regular expression`);
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
if (nestsQuantifiers(field.pattern)) {
|
|
277
|
+
fail(
|
|
278
|
+
`${at} declares pattern "${field.pattern}", which repeats a group that already repeats — a value can make it run for minutes`,
|
|
279
|
+
);
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
if (field.patternHint && field.pattern === undefined) {
|
|
284
|
+
fail(`${at} declares a pattern hint with no pattern to explain`);
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
if (field.control === BridgeControl.Secret && field.options) {
|
|
288
|
+
fail(`${at} is a secret control with options — a secret is typed, never chosen from a list`);
|
|
289
|
+
}
|
|
290
|
+
|
|
149
291
|
if (Array.isArray(field.options)) {
|
|
150
292
|
countIn(field.options.length, BRIDGE_FIELD_OPTIONS_LIMIT, "options", at);
|
|
151
293
|
|
|
@@ -437,6 +579,81 @@ const validatePending = (driver: BridgeDriver) => {
|
|
|
437
579
|
}
|
|
438
580
|
};
|
|
439
581
|
|
|
582
|
+
const formSectionOf = (driver: BridgeDriver, tabId: string, sectionId: string) => {
|
|
583
|
+
const tab = (driver.panel?.tabs ?? []).find((entry) => entry.id === tabId);
|
|
584
|
+
const section = (tab?.sections ?? []).find((entry) => entry.id === sectionId);
|
|
585
|
+
|
|
586
|
+
if (!section || section.layout !== BridgeLayout.Form) {
|
|
587
|
+
return null;
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
return section;
|
|
591
|
+
};
|
|
592
|
+
|
|
593
|
+
const validateSetup = (driver: BridgeDriver) => {
|
|
594
|
+
const setup = driver.setup;
|
|
595
|
+
|
|
596
|
+
if (!setup) {
|
|
597
|
+
return;
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
const where = "the setup module";
|
|
601
|
+
|
|
602
|
+
if (setup.kind !== BridgeKind.Setup) {
|
|
603
|
+
fail(`${where} declares kind "${setup.kind}", which is not "${BridgeKind.Setup}"`);
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
if (setup.steps.length === 0) {
|
|
607
|
+
fail(`${where} declares no steps`);
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
countIn(setup.steps.length, BRIDGE_SETUP_STEPS_LIMIT, "steps", where);
|
|
611
|
+
|
|
612
|
+
const ids = new Set<string>();
|
|
613
|
+
|
|
614
|
+
for (const step of setup.steps) {
|
|
615
|
+
if (ids.has(step.id)) {
|
|
616
|
+
fail(`${where} declares step "${step.id}" more than once`);
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
ids.add(step.id);
|
|
620
|
+
|
|
621
|
+
const at = `${where} step "${step.id}"`;
|
|
622
|
+
|
|
623
|
+
validateId(step.id, `${where} step`);
|
|
624
|
+
validateText(step.title, "title", at);
|
|
625
|
+
validateText(step.help, "help", at);
|
|
626
|
+
|
|
627
|
+
if (step.kind === BridgeSetupStepKind.Form) {
|
|
628
|
+
const section = formSectionOf(driver, step.tab, step.section);
|
|
629
|
+
|
|
630
|
+
if (!section) {
|
|
631
|
+
fail(`${at} names tab "${step.tab}" section "${step.section}", which is not a form section of this panel`);
|
|
632
|
+
|
|
633
|
+
continue;
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
for (const key of step.fields ?? []) {
|
|
637
|
+
if (!section.fields.some((field) => field.key === key)) {
|
|
638
|
+
fail(`${at} names field "${key}", which section "${step.section}" does not declare`);
|
|
639
|
+
}
|
|
640
|
+
}
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
if (step.kind === BridgeSetupStepKind.Open) {
|
|
644
|
+
const target = step.target;
|
|
645
|
+
|
|
646
|
+
if (target.tab === GuideOpenTab.Panel && !(driver.panel?.tabs ?? []).some((tab) => tab.id === target.tabId)) {
|
|
647
|
+
fail(`${at} opens panel tab "${target.tabId}", which the panel does not declare`);
|
|
648
|
+
}
|
|
649
|
+
}
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
if (!setup.submit && setup.steps.some((step) => step.kind === BridgeSetupStepKind.Driver)) {
|
|
653
|
+
fail(`${where} declares a driver step but exports no submit — a driver step is answered through submit`);
|
|
654
|
+
}
|
|
655
|
+
};
|
|
656
|
+
|
|
440
657
|
const validateTab = (modules: Record<string, BridgePanelModule>, tab: BridgeTab) => {
|
|
441
658
|
const sections = new Set<string>();
|
|
442
659
|
|
|
@@ -474,6 +691,7 @@ export const validateDriver = (driver: BridgeDriver) => {
|
|
|
474
691
|
countIn(emitted.size, BRIDGE_EVENTS_LIMIT, "event names", "the driver");
|
|
475
692
|
|
|
476
693
|
validatePending(driver);
|
|
694
|
+
validateSetup(driver);
|
|
477
695
|
|
|
478
696
|
for (const [id, module] of Object.entries(modules)) {
|
|
479
697
|
validateModuleId(id, "module");
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { mkdtemp, rm } from "node:fs/promises";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { BridgeKind } from "../core";
|
|
6
|
+
import type { BridgeEventsModule } from "../modules";
|
|
7
|
+
import { BridgeEventName } from "../protocol";
|
|
8
|
+
import { createEventRing } from "./bridgeEvents";
|
|
9
|
+
import type { BridgeLeveledLogger } from "./bridgeLog";
|
|
10
|
+
import { createSupervisor } from "./bridgeSupervisor";
|
|
11
|
+
|
|
12
|
+
const RESET = "\u001B[m";
|
|
13
|
+
|
|
14
|
+
const PLAYER_JOINED = /^\[World\|(?<world>[^\]]+)\] Player '(?<player>[^']+)' joined world (?<target>\S+)$/;
|
|
15
|
+
|
|
16
|
+
const silent: BridgeLeveledLogger = Object.assign(() => {}, {
|
|
17
|
+
warn: () => {},
|
|
18
|
+
error: () => {},
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
const events: BridgeEventsModule = {
|
|
22
|
+
kind: BridgeKind.Events,
|
|
23
|
+
patterns: [
|
|
24
|
+
{
|
|
25
|
+
match: PLAYER_JOINED,
|
|
26
|
+
emit: BridgeEventName.PlayerJoined,
|
|
27
|
+
},
|
|
28
|
+
],
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
const emittedFor = async (line: string) => {
|
|
32
|
+
const dir = await mkdtemp(join(tmpdir(), "bridge-events-"));
|
|
33
|
+
const path = join(dir, "line.txt");
|
|
34
|
+
|
|
35
|
+
await Bun.write(path, `${line}\n`);
|
|
36
|
+
|
|
37
|
+
const supervisor = createSupervisor({
|
|
38
|
+
cwd: dir,
|
|
39
|
+
uid: process.getuid?.() ?? 0,
|
|
40
|
+
gid: process.getgid?.() ?? 0,
|
|
41
|
+
stopTimeoutSeconds: 1,
|
|
42
|
+
onLog: silent,
|
|
43
|
+
resolveCommand: async () => [
|
|
44
|
+
"bash",
|
|
45
|
+
"-c",
|
|
46
|
+
'cat "$0"; sleep 300',
|
|
47
|
+
path,
|
|
48
|
+
],
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
const ring = createEventRing(events, supervisor);
|
|
52
|
+
|
|
53
|
+
await supervisor.start();
|
|
54
|
+
|
|
55
|
+
try {
|
|
56
|
+
for (let waited = 0; waited < 5000; waited += 50) {
|
|
57
|
+
if (ring.drain(0).events.length > 0) {
|
|
58
|
+
break;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
await Bun.sleep(50);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
return ring.drain(0).events;
|
|
65
|
+
} finally {
|
|
66
|
+
await supervisor.stop();
|
|
67
|
+
await rm(dir, {
|
|
68
|
+
force: true,
|
|
69
|
+
recursive: true,
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
const joined = {
|
|
75
|
+
seq: 1,
|
|
76
|
+
event: BridgeEventName.PlayerJoined,
|
|
77
|
+
payload: {
|
|
78
|
+
world: "default",
|
|
79
|
+
player: "Meslzy",
|
|
80
|
+
target: "default",
|
|
81
|
+
},
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
describe("matching a game line against the driver patterns", () => {
|
|
85
|
+
test("emits on the plain line", async () => {
|
|
86
|
+
expect(await emittedFor("[World|default] Player 'Meslzy' joined world default")).toEqual([
|
|
87
|
+
joined,
|
|
88
|
+
]);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
test("emits on the line hytale really prints, wrapped in ansi resets at both ends", async () => {
|
|
92
|
+
expect(await emittedFor(`${RESET}[World|default] Player 'Meslzy' joined world default${RESET}`)).toEqual([
|
|
93
|
+
joined,
|
|
94
|
+
]);
|
|
95
|
+
});
|
|
96
|
+
});
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { BridgeValues } from "../core";
|
|
2
2
|
import type { BridgeEventsModule } from "../modules";
|
|
3
3
|
import type { BridgeEvent } from "../protocol";
|
|
4
|
+
import { stripAnsi } from "../utils";
|
|
4
5
|
import type { BridgeSupervisor } from "./bridgeSupervisor";
|
|
5
6
|
|
|
6
7
|
const EVENT_BUFFER = 500;
|
|
@@ -26,12 +27,14 @@ export const createEventRing = (events: BridgeEventsModule | undefined, supervis
|
|
|
26
27
|
|
|
27
28
|
if (events) {
|
|
28
29
|
supervisor.subscribe((entry) => {
|
|
30
|
+
const line = stripAnsi(entry.line);
|
|
31
|
+
|
|
29
32
|
for (const pattern of events.patterns) {
|
|
30
33
|
if (pattern.stream && pattern.stream !== entry.stream) {
|
|
31
34
|
continue;
|
|
32
35
|
}
|
|
33
36
|
|
|
34
|
-
const match =
|
|
37
|
+
const match = line.match(pattern.match);
|
|
35
38
|
|
|
36
39
|
if (!match) {
|
|
37
40
|
continue;
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { describe, expect, test } from "bun:test";
|
|
2
2
|
import { tmpdir } from "node:os";
|
|
3
|
-
import { BridgeAction, BridgeKind, type BridgeRow, type BridgeValues } from "../core";
|
|
3
|
+
import { BridgeAction, BridgeKind, type BridgeRow, BridgeSetupStepKind, type BridgeValues } from "../core";
|
|
4
4
|
import type { BridgeDriver } from "../modules";
|
|
5
5
|
import { type BridgeConfig, BridgeConfirm, BridgeEventName } from "../protocol";
|
|
6
6
|
import { createContext } from "./bridgeContext";
|
|
@@ -62,6 +62,9 @@ const registryFor = (driver: BridgeDriver, variables: Record<string, string> = {
|
|
|
62
62
|
onEmit(event, payload) {
|
|
63
63
|
ring.emit(event, payload);
|
|
64
64
|
},
|
|
65
|
+
onSetup() {
|
|
66
|
+
return;
|
|
67
|
+
},
|
|
65
68
|
});
|
|
66
69
|
|
|
67
70
|
return createRegistry(driver, context, supervisor, ring);
|
|
@@ -560,3 +563,37 @@ describe("detail modules", () => {
|
|
|
560
563
|
);
|
|
561
564
|
});
|
|
562
565
|
});
|
|
566
|
+
|
|
567
|
+
describe("the setup singleton", () => {
|
|
568
|
+
test("rides the manifest as its own block and never joins the panel modules", () => {
|
|
569
|
+
const steps = [
|
|
570
|
+
{
|
|
571
|
+
kind: BridgeSetupStepKind.Driver as const,
|
|
572
|
+
id: "sign-in",
|
|
573
|
+
title: {
|
|
574
|
+
ar: "تسجيل الدخول",
|
|
575
|
+
en: "Sign in",
|
|
576
|
+
},
|
|
577
|
+
},
|
|
578
|
+
];
|
|
579
|
+
|
|
580
|
+
const registry = registryFor({
|
|
581
|
+
...baseDriver(),
|
|
582
|
+
setup: {
|
|
583
|
+
kind: BridgeKind.Setup,
|
|
584
|
+
steps,
|
|
585
|
+
},
|
|
586
|
+
});
|
|
587
|
+
|
|
588
|
+
const manifest = registry.manifest();
|
|
589
|
+
|
|
590
|
+
expect(manifest.setup).toEqual({
|
|
591
|
+
steps,
|
|
592
|
+
});
|
|
593
|
+
expect(manifest.modules.some((module) => module.kind === BridgeKind.Setup)).toBe(false);
|
|
594
|
+
});
|
|
595
|
+
|
|
596
|
+
test("leaves the manifest block empty for a driver that declares no setup", () => {
|
|
597
|
+
expect(registryFor(baseDriver()).manifest().setup).toBeNull();
|
|
598
|
+
});
|
|
599
|
+
});
|
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
import type { BridgeContext } from "../context";
|
|
2
2
|
import { BridgeAction, BridgeKind, type BridgeRow, type BridgeValues, isReadAction } from "../core";
|
|
3
|
-
import type { BridgeDriver, BridgePanelModule, BridgeTerminalModule } from "../modules";
|
|
3
|
+
import type { BridgeDriver, BridgePanelModule, BridgeSetupModule, BridgeTerminalModule } from "../modules";
|
|
4
4
|
import {
|
|
5
5
|
BRIDGE_VERSION,
|
|
6
6
|
type BridgeManifest,
|
|
7
7
|
type BridgeModuleManifest,
|
|
8
|
+
type BridgeSetupManifest,
|
|
8
9
|
type BridgeTerminalManifest,
|
|
9
10
|
} from "../protocol";
|
|
10
11
|
import { actionsOf } from "./bridgeActions";
|
|
@@ -63,6 +64,16 @@ const terminalManifestOf = (module: BridgeTerminalModule | undefined): BridgeTer
|
|
|
63
64
|
};
|
|
64
65
|
};
|
|
65
66
|
|
|
67
|
+
const setupManifestOf = (module: BridgeSetupModule | undefined): BridgeSetupManifest | null => {
|
|
68
|
+
if (!module) {
|
|
69
|
+
return null;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
return {
|
|
73
|
+
steps: module.steps,
|
|
74
|
+
};
|
|
75
|
+
};
|
|
76
|
+
|
|
66
77
|
const withVariables = (context: BridgeContext, overlay: Record<string, string>): BridgeContext => {
|
|
67
78
|
return {
|
|
68
79
|
get server() {
|
|
@@ -88,6 +99,7 @@ const withVariables = (context: BridgeContext, overlay: Record<string, string>):
|
|
|
88
99
|
return context.exec(command, options);
|
|
89
100
|
},
|
|
90
101
|
logs: context.logs,
|
|
102
|
+
setup: context.setup,
|
|
91
103
|
emit(event, payload) {
|
|
92
104
|
context.emit(event, payload);
|
|
93
105
|
},
|
|
@@ -368,6 +380,7 @@ export const createRegistry = (
|
|
|
368
380
|
version: BRIDGE_VERSION,
|
|
369
381
|
panel: driver.panel ?? null,
|
|
370
382
|
terminal: terminalManifestOf(driver.terminal),
|
|
383
|
+
setup: setupManifestOf(driver.setup),
|
|
371
384
|
modules: entries,
|
|
372
385
|
events: ring.names(),
|
|
373
386
|
};
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
|
2
|
+
import { mkdtemp, rm, writeFile } from "node:fs/promises";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import type { BridgeConfig } from "../protocol";
|
|
6
|
+
import { createBridgeSecrets } from "./bridgeSecrets";
|
|
7
|
+
|
|
8
|
+
const configWith = (secrets: Record<string, string>): BridgeConfig => ({
|
|
9
|
+
code: "wsvv8fakxm",
|
|
10
|
+
token: "token",
|
|
11
|
+
cwd: "/data",
|
|
12
|
+
run: "/run/bridge",
|
|
13
|
+
ports: {
|
|
14
|
+
game: 25_565,
|
|
15
|
+
},
|
|
16
|
+
memoryMb: 2048,
|
|
17
|
+
cpuPercent: 100,
|
|
18
|
+
gameUid: 1000,
|
|
19
|
+
gameGid: 1000,
|
|
20
|
+
variables: {
|
|
21
|
+
VERSION: "1.21",
|
|
22
|
+
},
|
|
23
|
+
secrets,
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
let directory: string;
|
|
27
|
+
|
|
28
|
+
let path: string;
|
|
29
|
+
|
|
30
|
+
const write = async (config: BridgeConfig) => {
|
|
31
|
+
await writeFile(path, `${JSON.stringify(config, null, 2)}\n`);
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
beforeEach(async () => {
|
|
35
|
+
directory = await mkdtemp(join(tmpdir(), "bridge-secrets-"));
|
|
36
|
+
path = join(directory, "config.json");
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
afterEach(async () => {
|
|
40
|
+
await rm(directory, {
|
|
41
|
+
force: true,
|
|
42
|
+
recursive: true,
|
|
43
|
+
});
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
describe("the bridge secrets reader", () => {
|
|
47
|
+
test("answers from the boot snapshot", async () => {
|
|
48
|
+
const config = configWith({
|
|
49
|
+
CURSEFORGE_API_KEY: "boot",
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
await write(config);
|
|
53
|
+
|
|
54
|
+
const secrets = createBridgeSecrets(path, config, 0);
|
|
55
|
+
|
|
56
|
+
expect(secrets.read("CURSEFORGE_API_KEY")).toBe("boot");
|
|
57
|
+
expect(secrets.read("MODRINTH_API_KEY")).toBeNull();
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
test("sees a value the agent rewrote into the config", async () => {
|
|
61
|
+
const config = configWith({
|
|
62
|
+
CURSEFORGE_API_KEY: "boot",
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
await write(config);
|
|
66
|
+
|
|
67
|
+
const secrets = createBridgeSecrets(path, config, 0);
|
|
68
|
+
|
|
69
|
+
expect(secrets.read("CURSEFORGE_API_KEY")).toBe("boot");
|
|
70
|
+
|
|
71
|
+
await write(
|
|
72
|
+
configWith({
|
|
73
|
+
CURSEFORGE_API_KEY: "rotated",
|
|
74
|
+
MODRINTH_API_KEY: "added",
|
|
75
|
+
}),
|
|
76
|
+
);
|
|
77
|
+
|
|
78
|
+
expect(secrets.read("CURSEFORGE_API_KEY")).toBe("rotated");
|
|
79
|
+
expect(secrets.read("MODRINTH_API_KEY")).toBe("added");
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
test("keeps the last good values while the config is mid-write", async () => {
|
|
83
|
+
const config = configWith({
|
|
84
|
+
CURSEFORGE_API_KEY: "boot",
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
await write(config);
|
|
88
|
+
|
|
89
|
+
const secrets = createBridgeSecrets(path, config, 0);
|
|
90
|
+
|
|
91
|
+
expect(secrets.read("CURSEFORGE_API_KEY")).toBe("boot");
|
|
92
|
+
|
|
93
|
+
await writeFile(path, '{"code": "wsvv8fak');
|
|
94
|
+
|
|
95
|
+
expect(secrets.read("CURSEFORGE_API_KEY")).toBe("boot");
|
|
96
|
+
|
|
97
|
+
await rm(path);
|
|
98
|
+
|
|
99
|
+
expect(secrets.read("CURSEFORGE_API_KEY")).toBe("boot");
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
test("re-reads the file at most once per refresh window", async () => {
|
|
103
|
+
const config = configWith({
|
|
104
|
+
CURSEFORGE_API_KEY: "boot",
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
await write(config);
|
|
108
|
+
|
|
109
|
+
const secrets = createBridgeSecrets(path, config, 60_000);
|
|
110
|
+
|
|
111
|
+
expect(secrets.read("CURSEFORGE_API_KEY")).toBe("boot");
|
|
112
|
+
|
|
113
|
+
await write(
|
|
114
|
+
configWith({
|
|
115
|
+
CURSEFORGE_API_KEY: "rotated",
|
|
116
|
+
}),
|
|
117
|
+
);
|
|
118
|
+
|
|
119
|
+
expect(secrets.read("CURSEFORGE_API_KEY")).toBe("boot");
|
|
120
|
+
});
|
|
121
|
+
});
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs";
|
|
2
|
+
import type { BridgeConfig } from "../protocol";
|
|
3
|
+
import { parseConfig } from "./bridgeConfigReader";
|
|
4
|
+
|
|
5
|
+
export const BRIDGE_SECRET_REFRESH_MS = 2000;
|
|
6
|
+
|
|
7
|
+
export interface BridgeSecrets {
|
|
8
|
+
read: (key: string) => string | null;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export const createBridgeSecrets = (
|
|
12
|
+
path: string,
|
|
13
|
+
config: BridgeConfig,
|
|
14
|
+
refreshMs: number = BRIDGE_SECRET_REFRESH_MS,
|
|
15
|
+
): BridgeSecrets => {
|
|
16
|
+
let values = config.secrets;
|
|
17
|
+
|
|
18
|
+
let checkedAt: number | null = null;
|
|
19
|
+
|
|
20
|
+
const refresh = () => {
|
|
21
|
+
const stamp = performance.now();
|
|
22
|
+
|
|
23
|
+
if (checkedAt !== null && stamp - checkedAt < refreshMs) {
|
|
24
|
+
return;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
checkedAt = stamp;
|
|
28
|
+
|
|
29
|
+
try {
|
|
30
|
+
values = parseConfig(readFileSync(path, "utf8")).secrets;
|
|
31
|
+
} catch {
|
|
32
|
+
return;
|
|
33
|
+
}
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
return {
|
|
37
|
+
read(key) {
|
|
38
|
+
refresh();
|
|
39
|
+
|
|
40
|
+
return values[key] ?? null;
|
|
41
|
+
},
|
|
42
|
+
};
|
|
43
|
+
};
|