@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
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { leadsGroup, readProcessGroup, signalGroup } from "./bridgeSignal";
|
|
3
|
+
|
|
4
|
+
const MISSING_PID = 2 ** 22 - 1;
|
|
5
|
+
|
|
6
|
+
describe("readProcessGroup", () => {
|
|
7
|
+
test("reads the process group of this process", async () => {
|
|
8
|
+
const group = await readProcessGroup(process.pid);
|
|
9
|
+
|
|
10
|
+
expect(group).not.toBeNull();
|
|
11
|
+
expect(group).toBeGreaterThan(0);
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
test("puts a detached child in a group of its own and an attached one in ours", async () => {
|
|
15
|
+
const detached = Bun.spawn(
|
|
16
|
+
[
|
|
17
|
+
"sleep",
|
|
18
|
+
"5",
|
|
19
|
+
],
|
|
20
|
+
{
|
|
21
|
+
detached: true,
|
|
22
|
+
stdout: "ignore",
|
|
23
|
+
stderr: "ignore",
|
|
24
|
+
},
|
|
25
|
+
);
|
|
26
|
+
|
|
27
|
+
const attached = Bun.spawn(
|
|
28
|
+
[
|
|
29
|
+
"sleep",
|
|
30
|
+
"5",
|
|
31
|
+
],
|
|
32
|
+
{
|
|
33
|
+
stdout: "ignore",
|
|
34
|
+
stderr: "ignore",
|
|
35
|
+
},
|
|
36
|
+
);
|
|
37
|
+
|
|
38
|
+
try {
|
|
39
|
+
expect(await readProcessGroup(detached.pid)).toBe(detached.pid);
|
|
40
|
+
expect(await readProcessGroup(attached.pid)).toBe(await readProcessGroup(process.pid));
|
|
41
|
+
} finally {
|
|
42
|
+
detached.kill("SIGKILL");
|
|
43
|
+
attached.kill("SIGKILL");
|
|
44
|
+
|
|
45
|
+
await Promise.all([
|
|
46
|
+
detached.exited,
|
|
47
|
+
attached.exited,
|
|
48
|
+
]);
|
|
49
|
+
}
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
test("answers null for a pid that does not exist", async () => {
|
|
53
|
+
expect(await readProcessGroup(MISSING_PID)).toBeNull();
|
|
54
|
+
});
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
describe("leadsGroup", () => {
|
|
58
|
+
test("only a pid equal to its own group leads it", () => {
|
|
59
|
+
expect(leadsGroup(1234, 1234)).toBe(true);
|
|
60
|
+
expect(leadsGroup(1234, 1200)).toBe(false);
|
|
61
|
+
expect(leadsGroup(1234, null)).toBe(false);
|
|
62
|
+
});
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
describe("signalGroup", () => {
|
|
66
|
+
test("signals every process sharing the leader's group", async () => {
|
|
67
|
+
const leader = Bun.spawn(
|
|
68
|
+
[
|
|
69
|
+
"bash",
|
|
70
|
+
"-c",
|
|
71
|
+
"sleep 30 & echo $!; wait",
|
|
72
|
+
],
|
|
73
|
+
{
|
|
74
|
+
detached: true,
|
|
75
|
+
stdout: "pipe",
|
|
76
|
+
stderr: "ignore",
|
|
77
|
+
},
|
|
78
|
+
);
|
|
79
|
+
|
|
80
|
+
const first = await leader.stdout.getReader().read();
|
|
81
|
+
const child = Number(new TextDecoder().decode(first.value).trim());
|
|
82
|
+
|
|
83
|
+
expect(Number.isSafeInteger(child)).toBe(true);
|
|
84
|
+
|
|
85
|
+
expect(signalGroup(leader.pid, await readProcessGroup(leader.pid), "SIGKILL")).toBe(true);
|
|
86
|
+
|
|
87
|
+
await leader.exited;
|
|
88
|
+
await Bun.sleep(200);
|
|
89
|
+
|
|
90
|
+
expect(() => process.kill(child, 0)).toThrow();
|
|
91
|
+
}, 20_000);
|
|
92
|
+
|
|
93
|
+
test("refuses to signal a group the pid does not lead", async () => {
|
|
94
|
+
const attached = Bun.spawn(
|
|
95
|
+
[
|
|
96
|
+
"sleep",
|
|
97
|
+
"5",
|
|
98
|
+
],
|
|
99
|
+
{
|
|
100
|
+
stdout: "ignore",
|
|
101
|
+
stderr: "ignore",
|
|
102
|
+
},
|
|
103
|
+
);
|
|
104
|
+
|
|
105
|
+
try {
|
|
106
|
+
expect(signalGroup(attached.pid, await readProcessGroup(attached.pid), "SIGKILL")).toBe(false);
|
|
107
|
+
expect(attached.killed).toBe(false);
|
|
108
|
+
} finally {
|
|
109
|
+
attached.kill("SIGKILL");
|
|
110
|
+
|
|
111
|
+
await attached.exited;
|
|
112
|
+
}
|
|
113
|
+
});
|
|
114
|
+
});
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
const STAT_PGID_INDEX = 2;
|
|
2
|
+
|
|
3
|
+
export const readProcessGroup = async (pid: number) => {
|
|
4
|
+
try {
|
|
5
|
+
const stat = await Bun.file(`/proc/${pid}/stat`).text();
|
|
6
|
+
const fields = stat.slice(stat.lastIndexOf(")") + 2).split(" ");
|
|
7
|
+
const group = Number(fields.at(STAT_PGID_INDEX));
|
|
8
|
+
|
|
9
|
+
return Number.isSafeInteger(group) && group > 0 ? group : null;
|
|
10
|
+
} catch {
|
|
11
|
+
return null;
|
|
12
|
+
}
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
export const leadsGroup = (pid: number, group: number | null) => {
|
|
16
|
+
return group !== null && group === pid;
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
export const signalGroup = (pid: number, group: number | null, signal: NodeJS.Signals) => {
|
|
20
|
+
if (!leadsGroup(pid, group)) {
|
|
21
|
+
return false;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
try {
|
|
25
|
+
process.kill(-pid, signal);
|
|
26
|
+
|
|
27
|
+
return true;
|
|
28
|
+
} catch {
|
|
29
|
+
return false;
|
|
30
|
+
}
|
|
31
|
+
};
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { tmpdir } from "node:os";
|
|
3
|
+
import { BridgeRuntime } from "../core";
|
|
4
|
+
import type { BridgeLeveledLogger } from "./bridgeLog";
|
|
5
|
+
import { readProcessGroup } from "./bridgeSignal";
|
|
6
|
+
import { createSupervisor } from "./bridgeSupervisor";
|
|
7
|
+
|
|
8
|
+
const CHILD_PATTERN = /child=(?<pid>\d+)/;
|
|
9
|
+
|
|
10
|
+
const WRAPPER = "sleep 300 & echo child=$!; wait";
|
|
11
|
+
|
|
12
|
+
const STUBBORN_WRAPPER = 'trap "" TERM; sleep 300 & echo child=$!; wait';
|
|
13
|
+
|
|
14
|
+
const silent: BridgeLeveledLogger = Object.assign(() => {}, {
|
|
15
|
+
warn: () => {},
|
|
16
|
+
error: () => {},
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
const alive = (pid: number) => {
|
|
20
|
+
try {
|
|
21
|
+
process.kill(pid, 0);
|
|
22
|
+
|
|
23
|
+
return true;
|
|
24
|
+
} catch {
|
|
25
|
+
return false;
|
|
26
|
+
}
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
const waitGone = async (pid: number, timeoutMs: number) => {
|
|
30
|
+
for (let waited = 0; waited < timeoutMs; waited += 50) {
|
|
31
|
+
if (!alive(pid)) {
|
|
32
|
+
return true;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
await Bun.sleep(50);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
return !alive(pid);
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
const wrapperSupervisor = (script: string, stopTimeoutSeconds: number) => {
|
|
42
|
+
return createSupervisor({
|
|
43
|
+
cwd: tmpdir(),
|
|
44
|
+
uid: process.getuid?.() ?? 0,
|
|
45
|
+
gid: process.getgid?.() ?? 0,
|
|
46
|
+
stopTimeoutSeconds,
|
|
47
|
+
resolveCommand: async () => {
|
|
48
|
+
return [
|
|
49
|
+
"bash",
|
|
50
|
+
"-c",
|
|
51
|
+
script,
|
|
52
|
+
];
|
|
53
|
+
},
|
|
54
|
+
onLog: silent,
|
|
55
|
+
});
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
const startWrapper = async (script: string, stopTimeoutSeconds: number) => {
|
|
59
|
+
const supervisor = wrapperSupervisor(script, stopTimeoutSeconds);
|
|
60
|
+
const started = supervisor.watch(CHILD_PATTERN, 10_000);
|
|
61
|
+
|
|
62
|
+
await supervisor.start();
|
|
63
|
+
|
|
64
|
+
const match = await started;
|
|
65
|
+
const pid = Number(match?.groups?.pid);
|
|
66
|
+
|
|
67
|
+
expect(match, "the wrapper never reported the pid it spawned").not.toBeNull();
|
|
68
|
+
expect(Number.isSafeInteger(pid)).toBe(true);
|
|
69
|
+
|
|
70
|
+
return {
|
|
71
|
+
supervisor,
|
|
72
|
+
pid,
|
|
73
|
+
};
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
describe("createSupervisor", () => {
|
|
77
|
+
test("runs the game outside the bridge's own process group", async () => {
|
|
78
|
+
const { supervisor, pid } = await startWrapper(WRAPPER, 1);
|
|
79
|
+
|
|
80
|
+
try {
|
|
81
|
+
const gameGroup = await readProcessGroup(pid);
|
|
82
|
+
const bridgeGroup = await readProcessGroup(process.pid);
|
|
83
|
+
|
|
84
|
+
expect(gameGroup, "the game must land in a group of its own or a group kill would hit the bridge").not.toBe(
|
|
85
|
+
bridgeGroup,
|
|
86
|
+
);
|
|
87
|
+
} finally {
|
|
88
|
+
await supervisor.stop();
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
expect(await waitGone(pid, 5000)).toBe(true);
|
|
92
|
+
}, 20_000);
|
|
93
|
+
|
|
94
|
+
test("takes the wrapper's child down with it when the stop times out", async () => {
|
|
95
|
+
const { supervisor, pid } = await startWrapper(WRAPPER, 1);
|
|
96
|
+
|
|
97
|
+
await supervisor.stop();
|
|
98
|
+
|
|
99
|
+
expect(supervisor.runtime).toBe(BridgeRuntime.Stopped);
|
|
100
|
+
expect(await waitGone(pid, 5000), "the game was orphaned when the wrapper was signalled").toBe(true);
|
|
101
|
+
}, 20_000);
|
|
102
|
+
|
|
103
|
+
test("kills the wrapper's child through the escalation when the wrapper ignores SIGTERM", async () => {
|
|
104
|
+
const { supervisor, pid } = await startWrapper(STUBBORN_WRAPPER, 1);
|
|
105
|
+
|
|
106
|
+
await supervisor.stop();
|
|
107
|
+
|
|
108
|
+
expect(supervisor.runtime).toBe(BridgeRuntime.Stopped);
|
|
109
|
+
expect(await waitGone(pid, 5000), "the game outlived the SIGKILL escalation").toBe(true);
|
|
110
|
+
}, 30_000);
|
|
111
|
+
});
|
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
import type { BridgeCommandOptions, BridgeReply } from "../context";
|
|
2
2
|
import { BridgeRuntime, BridgeStream } from "../core";
|
|
3
|
+
import { stripAnsi } from "../utils";
|
|
3
4
|
import type { BridgeLeveledLogger } from "./bridgeLog";
|
|
4
5
|
import { errorText } from "./bridgeLog";
|
|
5
6
|
import { settleUid, unprivilegedArgv } from "./bridgePrivilege";
|
|
7
|
+
import { readProcessGroup, signalGroup } from "./bridgeSignal";
|
|
6
8
|
|
|
7
9
|
const TAIL_LIMIT = 500;
|
|
8
10
|
|
|
@@ -39,6 +41,18 @@ export interface SupervisorOptions {
|
|
|
39
41
|
|
|
40
42
|
type LineWatcher = (entry: SupervisorLine) => void;
|
|
41
43
|
|
|
44
|
+
type SpawnedGame = Bun.Subprocess<"pipe", "pipe", "pipe">;
|
|
45
|
+
|
|
46
|
+
const terminate = (spawned: SpawnedGame, group: number | null, signal: NodeJS.Signals) => {
|
|
47
|
+
if (!signalGroup(spawned.pid, group, signal)) {
|
|
48
|
+
spawned.kill(signal);
|
|
49
|
+
}
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
const sweep = (pid: number, group: number | null) => {
|
|
53
|
+
signalGroup(pid, group, "SIGKILL");
|
|
54
|
+
};
|
|
55
|
+
|
|
42
56
|
const preferOomVictim = async (pid: number) => {
|
|
43
57
|
try {
|
|
44
58
|
await Bun.write(`/proc/${pid}/oom_score_adj`, `${GAME_OOM_SCORE_ADJ}\n`);
|
|
@@ -110,7 +124,8 @@ export const createSupervisor = (options: SupervisorOptions) => {
|
|
|
110
124
|
const watchers = new Set<LineWatcher>();
|
|
111
125
|
const restartStamps: number[] = [];
|
|
112
126
|
|
|
113
|
-
let child:
|
|
127
|
+
let child: SpawnedGame | null = null;
|
|
128
|
+
let childGroup: number | null = null;
|
|
114
129
|
let runtime: BridgeRuntime = BridgeRuntime.Stopped;
|
|
115
130
|
let shouldRun = false;
|
|
116
131
|
let breaker = false;
|
|
@@ -183,7 +198,7 @@ export const createSupervisor = (options: SupervisorOptions) => {
|
|
|
183
198
|
}, timeoutMs);
|
|
184
199
|
|
|
185
200
|
const watcher: LineWatcher = (entry) => {
|
|
186
|
-
const match = entry.line.match(pattern);
|
|
201
|
+
const match = stripAnsi(entry.line).match(pattern);
|
|
187
202
|
|
|
188
203
|
if (!match) {
|
|
189
204
|
return;
|
|
@@ -225,6 +240,7 @@ export const createSupervisor = (options: SupervisorOptions) => {
|
|
|
225
240
|
const spawned = Bun.spawn(spawnArgv, {
|
|
226
241
|
...credentials,
|
|
227
242
|
cwd: options.cwd,
|
|
243
|
+
detached: true,
|
|
228
244
|
stderr: "pipe",
|
|
229
245
|
stdin: "pipe",
|
|
230
246
|
stdout: "pipe",
|
|
@@ -233,6 +249,7 @@ export const createSupervisor = (options: SupervisorOptions) => {
|
|
|
233
249
|
},
|
|
234
250
|
});
|
|
235
251
|
|
|
252
|
+
const group = await readProcessGroup(spawned.pid);
|
|
236
253
|
const observed = await settleUid(spawned.pid, options.uid);
|
|
237
254
|
|
|
238
255
|
if (observed !== options.uid) {
|
|
@@ -247,10 +264,12 @@ export const createSupervisor = (options: SupervisorOptions) => {
|
|
|
247
264
|
);
|
|
248
265
|
}
|
|
249
266
|
|
|
250
|
-
spawned
|
|
267
|
+
terminate(spawned, group, "SIGKILL");
|
|
251
268
|
|
|
252
269
|
await spawned.exited;
|
|
253
270
|
|
|
271
|
+
sweep(spawned.pid, group);
|
|
272
|
+
|
|
254
273
|
throw new Error(
|
|
255
274
|
`the game process runs as uid ${String(observed)}, expected ${options.uid} — refusing to run it as the bridge`,
|
|
256
275
|
);
|
|
@@ -259,6 +278,7 @@ export const createSupervisor = (options: SupervisorOptions) => {
|
|
|
259
278
|
await preferOomVictim(spawned.pid);
|
|
260
279
|
|
|
261
280
|
child = spawned;
|
|
281
|
+
childGroup = group;
|
|
262
282
|
exitCode = null;
|
|
263
283
|
|
|
264
284
|
void pump(spawned.stdout, BridgeStream.Stdout);
|
|
@@ -266,6 +286,7 @@ export const createSupervisor = (options: SupervisorOptions) => {
|
|
|
266
286
|
|
|
267
287
|
options.onLog("game started", {
|
|
268
288
|
pid: spawned.pid,
|
|
289
|
+
group,
|
|
269
290
|
uid: options.uid,
|
|
270
291
|
});
|
|
271
292
|
|
|
@@ -306,9 +327,17 @@ export const createSupervisor = (options: SupervisorOptions) => {
|
|
|
306
327
|
};
|
|
307
328
|
|
|
308
329
|
function handleExit(code: number) {
|
|
330
|
+
const finished = child;
|
|
331
|
+
|
|
309
332
|
child = null;
|
|
310
333
|
exitCode = code;
|
|
311
334
|
|
|
335
|
+
if (finished) {
|
|
336
|
+
sweep(finished.pid, childGroup);
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
childGroup = null;
|
|
340
|
+
|
|
312
341
|
if (!shouldRun) {
|
|
313
342
|
outOfMemory = false;
|
|
314
343
|
|
|
@@ -419,6 +448,7 @@ export const createSupervisor = (options: SupervisorOptions) => {
|
|
|
419
448
|
}
|
|
420
449
|
|
|
421
450
|
const active = child;
|
|
451
|
+
const group = childGroup;
|
|
422
452
|
|
|
423
453
|
runtime = BridgeRuntime.Stopping;
|
|
424
454
|
|
|
@@ -441,7 +471,7 @@ export const createSupervisor = (options: SupervisorOptions) => {
|
|
|
441
471
|
]);
|
|
442
472
|
|
|
443
473
|
if (settled === null) {
|
|
444
|
-
active
|
|
474
|
+
terminate(active, group, "SIGTERM");
|
|
445
475
|
|
|
446
476
|
const afterTerm = await Promise.race([
|
|
447
477
|
active.exited,
|
|
@@ -449,12 +479,14 @@ export const createSupervisor = (options: SupervisorOptions) => {
|
|
|
449
479
|
]);
|
|
450
480
|
|
|
451
481
|
if (afterTerm === null) {
|
|
452
|
-
active
|
|
482
|
+
terminate(active, group, "SIGKILL");
|
|
453
483
|
|
|
454
484
|
await active.exited;
|
|
455
485
|
}
|
|
456
486
|
}
|
|
457
487
|
|
|
488
|
+
sweep(active.pid, group);
|
|
489
|
+
|
|
458
490
|
runtime = BridgeRuntime.Stopped;
|
|
459
491
|
},
|
|
460
492
|
|
package/src/runtime/serve.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { chmod, chown, mkdir, rm } from "node:fs/promises";
|
|
2
|
-
import { BridgeDesired, type BridgeInstallHold, BridgeRuntime, BridgeUserError } from "../core";
|
|
2
|
+
import { BridgeDesired, type BridgeInstallHold, BridgeRuntime, BridgeUserError, type LocalizedText } from "../core";
|
|
3
3
|
import type { BridgeDriver } from "../modules";
|
|
4
4
|
import {
|
|
5
5
|
BRIDGE_INSTALL_ERROR_LIMIT,
|
|
@@ -18,6 +18,8 @@ import { errorText, log } from "./bridgeLog";
|
|
|
18
18
|
import { pendingResult, rejectedPending, unsupportedPending } from "./bridgePending";
|
|
19
19
|
import { createRegistry } from "./bridgeRegistry";
|
|
20
20
|
import { createServer } from "./bridgeServer";
|
|
21
|
+
import { setupSummaryOf } from "./bridgeSetup";
|
|
22
|
+
import { createSetupHandlers } from "./bridgeSetupHandlers";
|
|
21
23
|
import { readDesired, writeDesired } from "./bridgeState";
|
|
22
24
|
import { createSupervisor } from "./bridgeSupervisor";
|
|
23
25
|
|
|
@@ -67,8 +69,12 @@ export const serve = async (driver: BridgeDriver) => {
|
|
|
67
69
|
|
|
68
70
|
let installError: string | null = null;
|
|
69
71
|
|
|
72
|
+
let installErrorText: LocalizedText | null = null;
|
|
73
|
+
|
|
70
74
|
let installHold: BridgeInstallHold | null = null;
|
|
71
75
|
|
|
76
|
+
const setup = createSetupHandlers(driver, log);
|
|
77
|
+
|
|
72
78
|
const supervisor = createSupervisor({
|
|
73
79
|
cwd: config.cwd,
|
|
74
80
|
uid: config.gameUid,
|
|
@@ -98,6 +104,9 @@ export const serve = async (driver: BridgeDriver) => {
|
|
|
98
104
|
onEmit(event, payload) {
|
|
99
105
|
ring.emit(event, payload);
|
|
100
106
|
},
|
|
107
|
+
onSetup(runtime) {
|
|
108
|
+
setup.onSetup(runtime);
|
|
109
|
+
},
|
|
101
110
|
});
|
|
102
111
|
|
|
103
112
|
const registry = createRegistry(driver, context, supervisor, ring);
|
|
@@ -113,7 +122,9 @@ export const serve = async (driver: BridgeDriver) => {
|
|
|
113
122
|
outOfMemory: supervisor.outOfMemory,
|
|
114
123
|
restarts: supervisor.restarts,
|
|
115
124
|
installError,
|
|
125
|
+
installErrorText,
|
|
116
126
|
installHold,
|
|
127
|
+
setup: setupSummaryOf(setup.current()),
|
|
117
128
|
};
|
|
118
129
|
};
|
|
119
130
|
|
|
@@ -307,6 +318,12 @@ export const serve = async (driver: BridgeDriver) => {
|
|
|
307
318
|
|
|
308
319
|
return await pending();
|
|
309
320
|
},
|
|
321
|
+
onSetupStatus() {
|
|
322
|
+
return setup.onSetupStatus();
|
|
323
|
+
},
|
|
324
|
+
async onSetupSubmit(step, action, args) {
|
|
325
|
+
return await setup.onSetupSubmit(context, step, action, args);
|
|
326
|
+
},
|
|
310
327
|
});
|
|
311
328
|
|
|
312
329
|
await chmod(socketPath(config), SOCKET_MODE);
|
|
@@ -358,6 +375,7 @@ export const serve = async (driver: BridgeDriver) => {
|
|
|
358
375
|
log("install complete");
|
|
359
376
|
} catch (error) {
|
|
360
377
|
installError = errorText(error).slice(0, BRIDGE_INSTALL_ERROR_LIMIT);
|
|
378
|
+
installErrorText = error instanceof BridgeUserError ? error.text : null;
|
|
361
379
|
|
|
362
380
|
supervisor.setRuntime(BridgeRuntime.Crashed);
|
|
363
381
|
|
package/src/utils/index.ts
CHANGED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
const ANSI_PATTERN = new RegExp(
|
|
2
|
+
[
|
|
3
|
+
"\\u001B\\][^\\u0007\\u001B]*(?:\\u0007|\\u001B\\\\)",
|
|
4
|
+
"\\u001B\\[[0-9;?]*[ -/]*[@-~]",
|
|
5
|
+
"\\u001B[@-Z\\\\-_]",
|
|
6
|
+
].join("|"),
|
|
7
|
+
"g",
|
|
8
|
+
);
|
|
9
|
+
|
|
10
|
+
export const stripAnsi = (line: string): string => {
|
|
11
|
+
return line.replace(ANSI_PATTERN, "");
|
|
12
|
+
};
|
package/src/wire/panel.test.ts
CHANGED
|
@@ -1,13 +1,17 @@
|
|
|
1
1
|
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { GuideOpenTab } from "@serverkgg/bridge/guides";
|
|
2
3
|
import {
|
|
3
4
|
BridgeAccess,
|
|
4
5
|
BridgeAction,
|
|
5
6
|
BridgeConfirm,
|
|
6
7
|
BridgeControl,
|
|
8
|
+
type BridgeField,
|
|
7
9
|
BridgeFormTarget,
|
|
8
10
|
BridgeKind,
|
|
9
11
|
BridgeLayout,
|
|
10
12
|
type BridgeManifest,
|
|
13
|
+
type BridgeSetupManifest,
|
|
14
|
+
BridgeSetupStepKind,
|
|
11
15
|
BridgeUploadMode,
|
|
12
16
|
} from "@serverkgg/bridge/protocol";
|
|
13
17
|
import { bridgeManifestSchema } from "./panel";
|
|
@@ -98,6 +102,7 @@ const manifest = (): BridgeManifest => ({
|
|
|
98
102
|
],
|
|
99
103
|
},
|
|
100
104
|
terminal: null,
|
|
105
|
+
setup: null,
|
|
101
106
|
modules: [
|
|
102
107
|
{
|
|
103
108
|
id: "broadcast",
|
|
@@ -159,6 +164,7 @@ const previewManifest = (): BridgeManifest => ({
|
|
|
159
164
|
],
|
|
160
165
|
},
|
|
161
166
|
terminal: null,
|
|
167
|
+
setup: null,
|
|
162
168
|
modules: [
|
|
163
169
|
{
|
|
164
170
|
id: "transition",
|
|
@@ -337,6 +343,7 @@ const detailManifest = (): BridgeManifest => ({
|
|
|
337
343
|
],
|
|
338
344
|
},
|
|
339
345
|
terminal: null,
|
|
346
|
+
setup: null,
|
|
340
347
|
modules: [
|
|
341
348
|
{
|
|
342
349
|
id: "modpackStatus",
|
|
@@ -412,6 +419,7 @@ const uploadManifest = (mode?: BridgeUploadMode): BridgeManifest => ({
|
|
|
412
419
|
],
|
|
413
420
|
},
|
|
414
421
|
terminal: null,
|
|
422
|
+
setup: null,
|
|
415
423
|
modules: [
|
|
416
424
|
{
|
|
417
425
|
id: "pending",
|
|
@@ -449,3 +457,150 @@ describe("the upload mirror", () => {
|
|
|
449
457
|
expect(bridgeManifestSchema.safeParse(source).success).toBe(false);
|
|
450
458
|
});
|
|
451
459
|
});
|
|
460
|
+
|
|
461
|
+
const fieldManifest = (field: BridgeField): BridgeManifest => ({
|
|
462
|
+
version: "1.0.0",
|
|
463
|
+
panel: {
|
|
464
|
+
tabs: [
|
|
465
|
+
{
|
|
466
|
+
id: "license",
|
|
467
|
+
title: text("Licence"),
|
|
468
|
+
sections: [
|
|
469
|
+
{
|
|
470
|
+
layout: BridgeLayout.Form,
|
|
471
|
+
id: "license",
|
|
472
|
+
target: BridgeFormTarget.Variables,
|
|
473
|
+
fields: [
|
|
474
|
+
field,
|
|
475
|
+
],
|
|
476
|
+
},
|
|
477
|
+
],
|
|
478
|
+
},
|
|
479
|
+
],
|
|
480
|
+
},
|
|
481
|
+
terminal: null,
|
|
482
|
+
setup: null,
|
|
483
|
+
modules: [],
|
|
484
|
+
events: [],
|
|
485
|
+
});
|
|
486
|
+
|
|
487
|
+
describe("secret fields", () => {
|
|
488
|
+
test("carries a secret control with its pattern and hint through unchanged", () => {
|
|
489
|
+
const source = fieldManifest({
|
|
490
|
+
key: "LICENSE_KEY",
|
|
491
|
+
control: BridgeControl.Secret,
|
|
492
|
+
label: text("Licence key"),
|
|
493
|
+
pattern: "^cfxk_\\w{1,60}_\\w{1,20}$",
|
|
494
|
+
patternHint: text("A licence key starts with cfxk_"),
|
|
495
|
+
});
|
|
496
|
+
|
|
497
|
+
expect(bridgeManifestSchema.parse(source)).toEqual(source);
|
|
498
|
+
});
|
|
499
|
+
|
|
500
|
+
test("refuses a control the platform does not know", () => {
|
|
501
|
+
const source = fieldManifest({
|
|
502
|
+
key: "LICENSE_KEY",
|
|
503
|
+
control: "password" as BridgeControl,
|
|
504
|
+
label: text("Licence key"),
|
|
505
|
+
});
|
|
506
|
+
|
|
507
|
+
expect(bridgeManifestSchema.safeParse(source).success).toBe(false);
|
|
508
|
+
});
|
|
509
|
+
|
|
510
|
+
test("refuses a pattern longer than the platform takes", () => {
|
|
511
|
+
const source = fieldManifest({
|
|
512
|
+
key: "LICENSE_KEY",
|
|
513
|
+
control: BridgeControl.Secret,
|
|
514
|
+
label: text("Licence key"),
|
|
515
|
+
pattern: `^${"a".repeat(256)}$`,
|
|
516
|
+
});
|
|
517
|
+
|
|
518
|
+
expect(bridgeManifestSchema.safeParse(source).success).toBe(false);
|
|
519
|
+
});
|
|
520
|
+
});
|
|
521
|
+
|
|
522
|
+
const setupManifest = (setup: BridgeSetupManifest | null): BridgeManifest => ({
|
|
523
|
+
version: "1.0.0",
|
|
524
|
+
panel: null,
|
|
525
|
+
terminal: null,
|
|
526
|
+
setup,
|
|
527
|
+
modules: [],
|
|
528
|
+
events: [],
|
|
529
|
+
});
|
|
530
|
+
|
|
531
|
+
describe("the setup mirror", () => {
|
|
532
|
+
test("carries every step kind through unchanged", () => {
|
|
533
|
+
const source = setupManifest({
|
|
534
|
+
steps: [
|
|
535
|
+
{
|
|
536
|
+
kind: BridgeSetupStepKind.Driver,
|
|
537
|
+
id: "sign-in",
|
|
538
|
+
title: text("Sign in"),
|
|
539
|
+
help: text("Sign in with your account"),
|
|
540
|
+
requiresRunning: true,
|
|
541
|
+
},
|
|
542
|
+
{
|
|
543
|
+
kind: BridgeSetupStepKind.Form,
|
|
544
|
+
id: "name",
|
|
545
|
+
title: text("Name your server"),
|
|
546
|
+
required: false,
|
|
547
|
+
tab: "settings",
|
|
548
|
+
section: "server",
|
|
549
|
+
fields: [
|
|
550
|
+
"SERVER_NAME",
|
|
551
|
+
],
|
|
552
|
+
},
|
|
553
|
+
{
|
|
554
|
+
kind: BridgeSetupStepKind.Open,
|
|
555
|
+
id: "invite",
|
|
556
|
+
title: text("Invite your friends"),
|
|
557
|
+
target: {
|
|
558
|
+
tab: GuideOpenTab.Access,
|
|
559
|
+
},
|
|
560
|
+
},
|
|
561
|
+
],
|
|
562
|
+
});
|
|
563
|
+
|
|
564
|
+
expect(bridgeManifestSchema.parse(source).setup).toEqual(source.setup);
|
|
565
|
+
});
|
|
566
|
+
|
|
567
|
+
test("takes a manifest that declares no setup", () => {
|
|
568
|
+
expect(bridgeManifestSchema.parse(setupManifest(null)).setup).toBeNull();
|
|
569
|
+
});
|
|
570
|
+
|
|
571
|
+
test("defaults a manifest from an older bridge that carries no setup block to null", () => {
|
|
572
|
+
const older = setupManifest(null);
|
|
573
|
+
|
|
574
|
+
delete (older as Partial<BridgeManifest>).setup;
|
|
575
|
+
|
|
576
|
+
expect(bridgeManifestSchema.parse(older).setup).toBeNull();
|
|
577
|
+
});
|
|
578
|
+
|
|
579
|
+
test("refuses a step kind the platform does not know", () => {
|
|
580
|
+
const source = setupManifest({
|
|
581
|
+
steps: [
|
|
582
|
+
{
|
|
583
|
+
kind: "wizard" as BridgeSetupStepKind.Driver,
|
|
584
|
+
id: "sign-in",
|
|
585
|
+
title: text("Sign in"),
|
|
586
|
+
},
|
|
587
|
+
],
|
|
588
|
+
});
|
|
589
|
+
|
|
590
|
+
expect(bridgeManifestSchema.safeParse(source).success).toBe(false);
|
|
591
|
+
});
|
|
592
|
+
|
|
593
|
+
test("refuses more steps than the platform takes", () => {
|
|
594
|
+
const source = setupManifest({
|
|
595
|
+
steps: Array.from({
|
|
596
|
+
length: 9,
|
|
597
|
+
}).map((_entry, index) => ({
|
|
598
|
+
kind: BridgeSetupStepKind.Driver as const,
|
|
599
|
+
id: `step-${index}`,
|
|
600
|
+
title: text(`Step ${index}`),
|
|
601
|
+
})),
|
|
602
|
+
});
|
|
603
|
+
|
|
604
|
+
expect(bridgeManifestSchema.safeParse(source).success).toBe(false);
|
|
605
|
+
});
|
|
606
|
+
});
|