@serverkgg/bridge 0.0.12 → 0.0.14

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.
Files changed (82) hide show
  1. package/package.json +9 -1
  2. package/src/catalogs/curseforge.test.ts +580 -0
  3. package/src/catalogs/curseforge.ts +461 -0
  4. package/src/catalogs/index.ts +2 -0
  5. package/src/catalogs/thunderstore.test.ts +94 -0
  6. package/src/catalogs/thunderstore.ts +239 -0
  7. package/src/codecs/index.ts +3 -0
  8. package/src/codecs/ini.test.ts +152 -0
  9. package/src/codecs/ini.ts +158 -0
  10. package/src/codecs/sourceCfg.test.ts +100 -0
  11. package/src/codecs/sourceCfg.ts +111 -0
  12. package/src/codecs/xmlProperties.test.ts +104 -0
  13. package/src/codecs/xmlProperties.ts +133 -0
  14. package/src/context/bridgeContext.ts +27 -0
  15. package/src/core/bridgeRconError.ts +19 -0
  16. package/src/core/index.ts +1 -0
  17. package/src/install/index.ts +1 -0
  18. package/src/install/installStamp.test.ts +125 -0
  19. package/src/install/installStamp.ts +40 -0
  20. package/src/main.ts +1 -0
  21. package/src/manifest/index.ts +1 -0
  22. package/src/manifest/manifest.test.ts +427 -2
  23. package/src/manifest/manifest.ts +142 -14
  24. package/src/manifest/primitives.ts +6 -0
  25. package/src/manifest/runtime.ts +89 -0
  26. package/src/modules/bridgeModule.ts +2 -1
  27. package/src/net/index.ts +2 -0
  28. package/src/net/loopback.test.ts +152 -0
  29. package/src/net/loopback.ts +109 -0
  30. package/src/net/loopbackMethod.ts +7 -0
  31. package/src/presence/index.ts +1 -0
  32. package/src/presence/rosterSync.test.ts +179 -0
  33. package/src/presence/rosterSync.ts +57 -0
  34. package/src/protocol/bridgeEvents.ts +4 -0
  35. package/src/rcon/index.ts +4 -0
  36. package/src/rcon/rconAccess.test.ts +286 -0
  37. package/src/rcon/rconAccess.ts +252 -0
  38. package/src/rcon/rconClient.test.ts +1544 -0
  39. package/src/rcon/rconClient.ts +275 -0
  40. package/src/rcon/rconError.ts +21 -0
  41. package/src/rcon/rconFrame.test.ts +269 -0
  42. package/src/rcon/rconFrame.ts +108 -0
  43. package/src/rcon/rconIds.test.ts +59 -0
  44. package/src/rcon/rconIds.ts +33 -0
  45. package/src/rcon/rconLink.ts +427 -0
  46. package/src/rcon/rconWriter.test.ts +95 -0
  47. package/src/rcon/rconWriter.ts +63 -0
  48. package/src/runtime/bridgeCodec.ts +45 -2
  49. package/src/runtime/bridgeConsoleHandler.test.ts +196 -0
  50. package/src/runtime/bridgeConsoleHandler.ts +26 -0
  51. package/src/runtime/bridgeContext.ts +13 -6
  52. package/src/runtime/bridgeDriver.test.ts +1 -0
  53. package/src/runtime/bridgeLiveConfig.test.ts +149 -0
  54. package/src/runtime/bridgeLiveConfig.ts +63 -0
  55. package/src/runtime/bridgeRcon.ts +19 -0
  56. package/src/runtime/bridgeRegistry.ts +1 -0
  57. package/src/runtime/serve.ts +10 -1
  58. package/src/steam/index.ts +7 -0
  59. package/src/steam/steamApp.test.ts +78 -0
  60. package/src/steam/steamApp.ts +35 -0
  61. package/src/steam/steamcmd.test.ts +157 -0
  62. package/src/steam/steamcmd.ts +273 -0
  63. package/src/steam/steamcmdLogin.ts +34 -0
  64. package/src/steam/steamcmdNarrator.test.ts +90 -0
  65. package/src/steam/steamcmdNarrator.ts +43 -0
  66. package/src/steam/steamcmdPlatform.ts +4 -0
  67. package/src/steam/webApi.ts +70 -0
  68. package/src/steam/workshop.test.ts +45 -0
  69. package/src/steam/workshop.ts +148 -0
  70. package/src/utils/execDetail.ts +16 -0
  71. package/src/utils/index.ts +2 -0
  72. package/src/utils/token.test.ts +50 -0
  73. package/src/utils/token.ts +35 -0
  74. package/src/wine/index.ts +3 -0
  75. package/src/wine/protonCommand.test.ts +67 -0
  76. package/src/wine/protonCommand.ts +49 -0
  77. package/src/wine/wineCommand.test.ts +92 -0
  78. package/src/wine/wineCommand.ts +54 -0
  79. package/src/wine/winePrefix.test.ts +111 -0
  80. package/src/wine/winePrefix.ts +88 -0
  81. package/src/runtime/bridgeSecrets.test.ts +0 -121
  82. package/src/runtime/bridgeSecrets.ts +0 -43
@@ -0,0 +1,196 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import type { BridgeCommandOptions, BridgeContext, BridgeReply } from "../context";
3
+ import { BridgeKind, BridgeUserError } from "../core";
4
+ import type { BridgeDriver, BridgeTerminalModule } from "../modules";
5
+ import { createConsoleHandler } from "./bridgeConsoleHandler";
6
+
7
+ const context = {} as unknown as BridgeContext;
8
+
9
+ const driverWith = (terminal: BridgeTerminalModule | undefined): BridgeDriver => ({
10
+ install: {
11
+ kind: BridgeKind.Install,
12
+ run: async () => {
13
+ return;
14
+ },
15
+ },
16
+ lifecycle: {
17
+ kind: BridgeKind.Lifecycle,
18
+ command: async () => [
19
+ "run",
20
+ ],
21
+ },
22
+ terminal,
23
+ });
24
+
25
+ const terminalWith = (run: BridgeTerminalModule["run"]): BridgeTerminalModule => ({
26
+ kind: BridgeKind.Terminal,
27
+ commands: [
28
+ {
29
+ name: "Save",
30
+ summary: {
31
+ ar: "يحفظ العالم.",
32
+ en: "Save the world.",
33
+ },
34
+ },
35
+ ],
36
+ run,
37
+ });
38
+
39
+ const handlerFor = (driver: BridgeDriver) => {
40
+ const written: string[] = [];
41
+
42
+ const handler = createConsoleHandler({
43
+ driver,
44
+ context,
45
+ async onStdin(input: string, options: BridgeCommandOptions): Promise<BridgeReply> {
46
+ written.push(input);
47
+
48
+ return {
49
+ sent: input,
50
+ line: options.expect ? "matched" : null,
51
+ groups: {},
52
+ };
53
+ },
54
+ });
55
+
56
+ return {
57
+ handler,
58
+ written,
59
+ };
60
+ };
61
+
62
+ describe("routing a console line to the driver before the game's stdin", () => {
63
+ test("answers with the driver's reply when the driver handles the line", async () => {
64
+ const { handler, written } = handlerFor(
65
+ driverWith(
66
+ terminalWith(async (_context, input) => {
67
+ return {
68
+ sent: input,
69
+ line: "Save requested",
70
+ groups: {},
71
+ };
72
+ }),
73
+ ),
74
+ );
75
+
76
+ expect(await handler("Save", {})).toEqual({
77
+ sent: "Save",
78
+ line: "Save requested",
79
+ groups: {},
80
+ });
81
+ expect(written).toEqual([]);
82
+ });
83
+
84
+ test("hands the driver the same line the caller typed", async () => {
85
+ const seen: string[] = [];
86
+
87
+ const { handler } = handlerFor(
88
+ driverWith(
89
+ terminalWith(async (_context, input) => {
90
+ seen.push(input);
91
+
92
+ return {
93
+ sent: input,
94
+ line: null,
95
+ groups: {},
96
+ };
97
+ }),
98
+ ),
99
+ );
100
+
101
+ await handler("Broadcast hello world", {});
102
+
103
+ expect(seen).toEqual([
104
+ "Broadcast hello world",
105
+ ]);
106
+ });
107
+
108
+ test("writes the line to stdin when the driver answers null", async () => {
109
+ const { handler, written } = handlerFor(
110
+ driverWith(
111
+ terminalWith(async () => {
112
+ return null;
113
+ }),
114
+ ),
115
+ );
116
+
117
+ expect(await handler("save-all", {})).toEqual({
118
+ sent: "save-all",
119
+ line: null,
120
+ groups: {},
121
+ });
122
+ expect(written).toEqual([
123
+ "save-all",
124
+ ]);
125
+ });
126
+
127
+ test("keeps the expect and timeout of a line the driver leaves alone", async () => {
128
+ const { handler } = handlerFor(
129
+ driverWith(
130
+ terminalWith(async () => {
131
+ return null;
132
+ }),
133
+ ),
134
+ );
135
+
136
+ expect(
137
+ (
138
+ await handler("list", {
139
+ expect: /players online/,
140
+ timeoutMs: 1000,
141
+ })
142
+ ).line,
143
+ ).toBe("matched");
144
+ });
145
+
146
+ test("writes the line to stdin when the driver declares no handler", async () => {
147
+ const { handler, written } = handlerFor(
148
+ driverWith({
149
+ kind: BridgeKind.Terminal,
150
+ commands: [],
151
+ }),
152
+ );
153
+
154
+ await handler("list", {});
155
+
156
+ expect(written).toEqual([
157
+ "list",
158
+ ]);
159
+ });
160
+
161
+ test("writes the line to stdin when the game declares no terminal at all", async () => {
162
+ const { handler, written } = handlerFor(driverWith(undefined));
163
+
164
+ await handler("list", {});
165
+
166
+ expect(written).toEqual([
167
+ "list",
168
+ ]);
169
+ });
170
+
171
+ test("refuses the line with the driver's own text when the driver rejects it", async () => {
172
+ const refusal = {
173
+ ar: "هذا الأمر يشتغل داخل اللعبة بس.",
174
+ en: "That command only works inside the game.",
175
+ };
176
+
177
+ const { handler, written } = handlerFor(
178
+ driverWith(
179
+ terminalWith(async () => {
180
+ throw new BridgeUserError(refusal);
181
+ }),
182
+ ),
183
+ );
184
+
185
+ try {
186
+ await handler("TeleportToPlayer meslzy", {});
187
+
188
+ expect.unreachable();
189
+ } catch (error) {
190
+ expect(error).toBeInstanceOf(BridgeUserError);
191
+ expect((error as BridgeUserError).text).toEqual(refusal);
192
+ }
193
+
194
+ expect(written).toEqual([]);
195
+ });
196
+ });
@@ -0,0 +1,26 @@
1
+ import type { BridgeCommandOptions, BridgeContext, BridgeReply } from "../context";
2
+ import type { BridgeDriver } from "../modules";
3
+
4
+ export type BridgeConsoleHandler = (input: string, options: BridgeCommandOptions) => Promise<BridgeReply>;
5
+
6
+ export interface BridgeConsoleOptions {
7
+ driver: BridgeDriver;
8
+ context: BridgeContext;
9
+ onStdin: BridgeConsoleHandler;
10
+ }
11
+
12
+ export const createConsoleHandler = (options: BridgeConsoleOptions): BridgeConsoleHandler => {
13
+ return async (input, commandOptions) => {
14
+ const run = options.driver.terminal?.run;
15
+
16
+ if (run) {
17
+ const reply = await run(options.context, input);
18
+
19
+ if (reply) {
20
+ return reply;
21
+ }
22
+ }
23
+
24
+ return await options.onStdin(input, commandOptions);
25
+ };
26
+ };
@@ -7,10 +7,11 @@ import { configPath } from "./bridgeConfigReader";
7
7
  import { createBridgeEgress } from "./bridgeEgress";
8
8
  import { createBridgeExec } from "./bridgeExec";
9
9
  import { createBridgeFiles } from "./bridgeFiles";
10
+ import { createBridgeLiveConfig } from "./bridgeLiveConfig";
10
11
  import type { BridgeLeveledLogger } from "./bridgeLog";
11
12
  import { createBridgeNet } from "./bridgeNet";
12
13
  import { createBridgeProbes } from "./bridgeProbe";
13
- import { createBridgeSecrets } from "./bridgeSecrets";
14
+ import { createBridgeRcons } from "./bridgeRcon";
14
15
  import type { BridgeSupervisor } from "./bridgeSupervisor";
15
16
 
16
17
  export interface ContextHooks {
@@ -26,7 +27,7 @@ export const createContext = (
26
27
  ): BridgeContext => {
27
28
  const egress = createBridgeEgress(config);
28
29
 
29
- const secrets = createBridgeSecrets(configPath(), config);
30
+ const live = createBridgeLiveConfig(configPath(), config);
30
31
 
31
32
  const files = createBridgeFiles({
32
33
  root: config.cwd,
@@ -39,19 +40,23 @@ export const createContext = (
39
40
  return {
40
41
  server: {
41
42
  code: config.code,
42
- memoryMb: config.memoryMb,
43
- cpuPercent: config.cpuPercent,
43
+ get memoryMb() {
44
+ return live.memoryMb();
45
+ },
46
+ get cpuPercent() {
47
+ return live.cpuPercent();
48
+ },
44
49
  get running() {
45
50
  return supervisor.running;
46
51
  },
47
52
  },
48
53
 
49
54
  variable(key) {
50
- return config.variables[key] ?? null;
55
+ return live.variable(key);
51
56
  },
52
57
 
53
58
  secret(key) {
54
- return secrets.read(key);
59
+ return live.secret(key);
55
60
  },
56
61
 
57
62
  port(key) {
@@ -72,6 +77,8 @@ export const createContext = (
72
77
 
73
78
  probe: createBridgeProbes(),
74
79
 
80
+ rcon: createBridgeRcons(),
81
+
75
82
  async command(input, options) {
76
83
  return await supervisor.command(input, options);
77
84
  },
@@ -232,6 +232,7 @@ const port = (activeWhen: ManifestPortActivation[]): ManifestPort => ({
232
232
  },
233
233
  join: PortJoin.Combined,
234
234
  activeWhen,
235
+ mirror: false,
235
236
  });
236
237
 
237
238
  const driverWithTabs = (tabs: number, sections: number): BridgeDriver => ({
@@ -0,0 +1,149 @@
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 { createBridgeLiveConfig } from "./bridgeLiveConfig";
7
+
8
+ const configWith = (secrets: Record<string, string>, variables: 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
+ ...variables,
23
+ },
24
+ secrets,
25
+ });
26
+
27
+ let directory: string;
28
+
29
+ let path: string;
30
+
31
+ const write = async (config: BridgeConfig) => {
32
+ await writeFile(path, `${JSON.stringify(config, null, 2)}\n`);
33
+ };
34
+
35
+ beforeEach(async () => {
36
+ directory = await mkdtemp(join(tmpdir(), "bridge-live-config-"));
37
+ path = join(directory, "config.json");
38
+ });
39
+
40
+ afterEach(async () => {
41
+ await rm(directory, {
42
+ force: true,
43
+ recursive: true,
44
+ });
45
+ });
46
+
47
+ describe("the bridge live config reader", () => {
48
+ test("answers from the boot snapshot", async () => {
49
+ const config = configWith({
50
+ CURSEFORGE_API_KEY: "boot",
51
+ });
52
+
53
+ await write(config);
54
+
55
+ const secrets = createBridgeLiveConfig(path, config, 0);
56
+
57
+ expect(secrets.secret("CURSEFORGE_API_KEY")).toBe("boot");
58
+ expect(secrets.secret("MODRINTH_API_KEY")).toBeNull();
59
+ });
60
+
61
+ test("sees a value the agent rewrote into the config", async () => {
62
+ const config = configWith({
63
+ CURSEFORGE_API_KEY: "boot",
64
+ });
65
+
66
+ await write(config);
67
+
68
+ const secrets = createBridgeLiveConfig(path, config, 0);
69
+
70
+ expect(secrets.secret("CURSEFORGE_API_KEY")).toBe("boot");
71
+
72
+ await write(
73
+ configWith({
74
+ CURSEFORGE_API_KEY: "rotated",
75
+ MODRINTH_API_KEY: "added",
76
+ }),
77
+ );
78
+
79
+ expect(secrets.secret("CURSEFORGE_API_KEY")).toBe("rotated");
80
+ expect(secrets.secret("MODRINTH_API_KEY")).toBe("added");
81
+ });
82
+
83
+ test("keeps the last good values while the config is mid-write", async () => {
84
+ const config = configWith({
85
+ CURSEFORGE_API_KEY: "boot",
86
+ });
87
+
88
+ await write(config);
89
+
90
+ const secrets = createBridgeLiveConfig(path, config, 0);
91
+
92
+ expect(secrets.secret("CURSEFORGE_API_KEY")).toBe("boot");
93
+
94
+ await writeFile(path, '{"code": "wsvv8fak');
95
+
96
+ expect(secrets.secret("CURSEFORGE_API_KEY")).toBe("boot");
97
+
98
+ await rm(path);
99
+
100
+ expect(secrets.secret("CURSEFORGE_API_KEY")).toBe("boot");
101
+ });
102
+
103
+ test("re-reads the file at most once per refresh window", async () => {
104
+ const config = configWith({
105
+ CURSEFORGE_API_KEY: "boot",
106
+ });
107
+
108
+ await write(config);
109
+
110
+ const secrets = createBridgeLiveConfig(path, config, 60_000);
111
+
112
+ expect(secrets.secret("CURSEFORGE_API_KEY")).toBe("boot");
113
+
114
+ await write(
115
+ configWith({
116
+ CURSEFORGE_API_KEY: "rotated",
117
+ }),
118
+ );
119
+
120
+ expect(secrets.secret("CURSEFORGE_API_KEY")).toBe("boot");
121
+ });
122
+
123
+ test("sees a variable the customer changed after boot, and the new plan resources", async () => {
124
+ const config = configWith({});
125
+
126
+ await write(config);
127
+
128
+ const live = createBridgeLiveConfig(path, config, 0);
129
+
130
+ expect(live.variable("CROSSPLAY")).toBeNull();
131
+ expect(live.memoryMb()).toBe(2048);
132
+
133
+ await write({
134
+ ...configWith(
135
+ {},
136
+ {
137
+ CROSSPLAY: "true",
138
+ },
139
+ ),
140
+ memoryMb: 4096,
141
+ cpuPercent: 200,
142
+ });
143
+
144
+ expect(live.variable("CROSSPLAY")).toBe("true");
145
+ expect(live.variable("VERSION")).toBe("1.21");
146
+ expect(live.memoryMb()).toBe(4096);
147
+ expect(live.cpuPercent()).toBe(200);
148
+ });
149
+ });
@@ -0,0 +1,63 @@
1
+ import { readFileSync } from "node:fs";
2
+ import type { BridgeConfig } from "../protocol";
3
+ import { parseConfig } from "./bridgeConfigReader";
4
+
5
+ export const BRIDGE_CONFIG_REFRESH_MS = 2000;
6
+
7
+ /**
8
+ * The agent rewrites the bridge config in place when the customer changes a
9
+ * variable, a secret or the plan, and then restarts the game rather than the
10
+ * container. The bridge process outlives that rewrite, so anything answered
11
+ * from the boot snapshot would be stale. Every read here re-checks the file,
12
+ * throttled to one read per refresh window, and keeps the last good values
13
+ * while the agent is mid-write.
14
+ */
15
+ export interface BridgeLiveConfig {
16
+ variable: (key: string) => string | null;
17
+ secret: (key: string) => string | null;
18
+ memoryMb: () => number;
19
+ cpuPercent: () => number;
20
+ }
21
+
22
+ export const createBridgeLiveConfig = (
23
+ path: string,
24
+ config: BridgeConfig,
25
+ refreshMs: number = BRIDGE_CONFIG_REFRESH_MS,
26
+ ): BridgeLiveConfig => {
27
+ let current = config;
28
+
29
+ let checkedAt: number | null = null;
30
+
31
+ const refresh = () => {
32
+ const stamp = performance.now();
33
+
34
+ if (checkedAt !== null && stamp - checkedAt < refreshMs) {
35
+ return current;
36
+ }
37
+
38
+ checkedAt = stamp;
39
+
40
+ try {
41
+ current = parseConfig(readFileSync(path, "utf8"));
42
+ } catch {
43
+ return current;
44
+ }
45
+
46
+ return current;
47
+ };
48
+
49
+ return {
50
+ variable(key) {
51
+ return refresh().variables[key] ?? null;
52
+ },
53
+ secret(key) {
54
+ return refresh().secrets[key] ?? null;
55
+ },
56
+ memoryMb() {
57
+ return refresh().memoryMb;
58
+ },
59
+ cpuPercent() {
60
+ return refresh().cpuPercent;
61
+ },
62
+ };
63
+ };
@@ -0,0 +1,19 @@
1
+ import type { BridgeRcons } from "../context";
2
+ import { RconClient } from "../rcon";
3
+
4
+ const RCON_HOST = "127.0.0.1";
5
+
6
+ export const createBridgeRcons = (): BridgeRcons => {
7
+ return {
8
+ async source(options) {
9
+ const client = new RconClient({
10
+ ...options,
11
+ host: RCON_HOST,
12
+ });
13
+
14
+ await client.connect();
15
+
16
+ return client;
17
+ },
18
+ };
19
+ };
@@ -92,6 +92,7 @@ const withVariables = (context: BridgeContext, overlay: Record<string, string>):
92
92
  codec: context.codec,
93
93
  net: context.net,
94
94
  probe: context.probe,
95
+ rcon: context.rcon,
95
96
  command(input, options) {
96
97
  return context.command(input, options);
97
98
  },
@@ -10,6 +10,7 @@ import {
10
10
  type BridgeStatus,
11
11
  } from "../protocol";
12
12
  import { configPath, readConfig, socketPath, statePath } from "./bridgeConfigReader";
13
+ import { createConsoleHandler } from "./bridgeConsoleHandler";
13
14
  import { createContext } from "./bridgeContext";
14
15
  import { validateDriver } from "./bridgeDriver";
15
16
  import { createEventRing } from "./bridgeEvents";
@@ -111,6 +112,14 @@ export const serve = async (driver: BridgeDriver) => {
111
112
 
112
113
  const registry = createRegistry(driver, context, supervisor, ring);
113
114
 
115
+ const runConsole = createConsoleHandler({
116
+ driver,
117
+ context,
118
+ async onStdin(input, commandOptions) {
119
+ return await supervisor.command(input, commandOptions);
120
+ },
121
+ });
122
+
114
123
  let current = desired;
115
124
 
116
125
  const status = (): BridgeStatus => {
@@ -205,7 +214,7 @@ export const serve = async (driver: BridgeDriver) => {
205
214
  await supervisor.stop();
206
215
  },
207
216
  async onCommand(input, expect, timeoutMs) {
208
- return await supervisor.command(input, {
217
+ return await runConsole(input, {
209
218
  expect: expect === undefined ? undefined : new RegExp(expect),
210
219
  timeoutMs,
211
220
  });
@@ -0,0 +1,7 @@
1
+ export * from "./steamApp";
2
+ export * from "./steamcmd";
3
+ export * from "./steamcmdLogin";
4
+ export * from "./steamcmdNarrator";
5
+ export * from "./steamcmdPlatform";
6
+ export * from "./webApi";
7
+ export * from "./workshop";
@@ -0,0 +1,78 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import type { BridgeContext } from "../context";
3
+ import { appManifestPath, buildIdOf, installedBuildId, missingGameRoots } from "./steamApp";
4
+
5
+ const MANIFEST = [
6
+ '"AppState"',
7
+ "{",
8
+ '\t"appid"\t\t"2394010"',
9
+ '\t"buildid"\t\t"19582821"',
10
+ "}",
11
+ ].join("\n");
12
+
13
+ const testContext = (files: Record<string, string>) => {
14
+ return {
15
+ files: {
16
+ async exists(path: string) {
17
+ return path in files;
18
+ },
19
+ async read(path: string) {
20
+ return files[path] ?? "";
21
+ },
22
+ },
23
+ } as unknown as BridgeContext;
24
+ };
25
+
26
+ describe("appManifestPath", () => {
27
+ test("points at the steamapps manifest", () => {
28
+ expect(appManifestPath("2394010")).toBe("steamapps/appmanifest_2394010.acf");
29
+ });
30
+
31
+ test("accepts another steamapps directory", () => {
32
+ expect(appManifestPath("90", ".steam/steamapps")).toBe(".steam/steamapps/appmanifest_90.acf");
33
+ });
34
+ });
35
+
36
+ describe("buildIdOf", () => {
37
+ test("reads the build id out of an acf manifest", () => {
38
+ expect(buildIdOf(MANIFEST)).toBe("19582821");
39
+ });
40
+
41
+ test("returns null when there is no build id", () => {
42
+ expect(buildIdOf('"AppState"\n{\n}\n')).toBeNull();
43
+ expect(buildIdOf("")).toBeNull();
44
+ });
45
+ });
46
+
47
+ describe("installedBuildId", () => {
48
+ test("returns null when the app was never installed", async () => {
49
+ expect(await installedBuildId(testContext({}), "2394010")).toBeNull();
50
+ });
51
+
52
+ test("reads the installed build id", async () => {
53
+ const context = testContext({
54
+ "steamapps/appmanifest_2394010.acf": MANIFEST,
55
+ });
56
+
57
+ expect(await installedBuildId(context, "2394010")).toBe("19582821");
58
+ });
59
+ });
60
+
61
+ describe("missingGameRoots", () => {
62
+ test("names only the paths that are absent", async () => {
63
+ const context = testContext({
64
+ "PalServer.sh": "",
65
+ Engine: "",
66
+ });
67
+
68
+ expect(
69
+ await missingGameRoots(context, [
70
+ "PalServer.sh",
71
+ "Pal/Content",
72
+ "Engine",
73
+ ]),
74
+ ).toEqual([
75
+ "Pal/Content",
76
+ ]);
77
+ });
78
+ });
@@ -0,0 +1,35 @@
1
+ import type { BridgeContext } from "../context";
2
+
3
+ export const STEAMAPPS_DIRECTORY = "steamapps";
4
+
5
+ const BUILD_ID = /"buildid"\s+"(?<buildId>\d+)"/;
6
+
7
+ export const appManifestPath = (appId: string, directory: string = STEAMAPPS_DIRECTORY) => {
8
+ return `${directory}/appmanifest_${appId}.acf`;
9
+ };
10
+
11
+ export const buildIdOf = (manifest: string) => {
12
+ return manifest.match(BUILD_ID)?.groups?.buildId ?? null;
13
+ };
14
+
15
+ export const installedBuildId = async (context: BridgeContext, appId: string, directory?: string) => {
16
+ const path = appManifestPath(appId, directory);
17
+
18
+ if (!(await context.files.exists(path))) {
19
+ return null;
20
+ }
21
+
22
+ return buildIdOf(await context.files.read(path));
23
+ };
24
+
25
+ export const missingGameRoots = async (context: BridgeContext, roots: string[]) => {
26
+ const missing: string[] = [];
27
+
28
+ for (const path of roots) {
29
+ if (!(await context.files.exists(path))) {
30
+ missing.push(path);
31
+ }
32
+ }
33
+
34
+ return missing;
35
+ };